mirror of
https://github.com/cline/cline.git
synced 2026-09-01 23:19:18 +08:00
Compare commits
289 Commits
cli-v3.0.26
...
bee/cli
| Author | SHA1 | Date | |
|---|---|---|---|
| 2118bfb1e0 | |||
| 38aee03e15 | |||
| e5fd4b46e0 | |||
| 4ce2ac6c6f | |||
| 19998a199c | |||
| 898ebdfef3 | |||
| ceaec03de3 | |||
| 3b5f3eff2e | |||
| 7f3d8c0dfd | |||
| fea1a21081 | |||
| 016a090153 | |||
| 892d905f1e | |||
| e7b101a484 | |||
| 0c7d957db2 | |||
| d3fd44f732 | |||
| 72d3c2a85f | |||
| bfdc3726c1 | |||
| adbe380dc1 | |||
| 763232f82a | |||
| 88788f1d5b | |||
| 306858ec42 | |||
| f06ae964ff | |||
| 4bf4449b13 | |||
| 643c1ff2b0 | |||
| af1da50151 | |||
| 8cbe982dda | |||
| 8bacce041a | |||
| c2cf06e8d6 | |||
| 851767dfb8 | |||
| 27048cf28c | |||
| 0bdf1d7f56 | |||
| d07a238075 | |||
| c30d40f687 | |||
| cce7173cd7 | |||
| c754dec002 | |||
| 7378d79404 | |||
| 1c195e363f | |||
| 3700d77a1d | |||
| bfeb8af23f | |||
| c4b1160389 | |||
| 99f05762cf | |||
| 77ab5a89b4 | |||
| dc8702bcdc | |||
| 486e2ff0d3 | |||
| f003e0fa75 | |||
| 12b2aaa2eb | |||
| 0172870f08 | |||
| 96a2abbb39 | |||
| 163ad28f2c | |||
| 56ce831bf7 | |||
| c4c9f99e97 | |||
| 27d4eec60a | |||
| 2d39095b0d | |||
| 13f89db752 | |||
| af67978d39 | |||
| 03534ed7e5 | |||
| e41aba9354 | |||
| 739790fc9c | |||
| 6993260016 | |||
| 3b6c861ace | |||
| 35a0732cf1 | |||
| 02b44217b1 | |||
| 04fb2da9b5 | |||
| 94f75df12c | |||
| 624433a824 | |||
| 0a267bd078 | |||
| e9c5882179 | |||
| 537ca97cf1 | |||
| 581c2a0282 | |||
| f9d8262e31 | |||
| c8927f1971 | |||
| dde21a0e8c | |||
| 07d630b614 | |||
| cfe25729c0 | |||
| 4818f53ae3 | |||
| f4680b9eac | |||
| c9f928e485 | |||
| 49b8569281 | |||
| c19d76cd80 | |||
| e0e9f8a862 | |||
| 79a5052869 | |||
| a5c048f738 | |||
| f0514758a8 | |||
| 499f7bafbb | |||
| 415653d0c8 | |||
| 5fa12f389c | |||
| b1295471a0 | |||
| 303d99ff81 | |||
| 543a228fd9 | |||
| 5cdacca80c | |||
| 0b5024dc42 | |||
| 23a2c24366 | |||
| 58cfed5613 | |||
| 20bdc3cead | |||
| 525d1b6e21 | |||
| d5169f32f7 | |||
| e23b953a02 | |||
| cd8690b939 | |||
| fd961c2cbc | |||
| 1691177bee | |||
| 488adb42a2 | |||
| b59aa52c9e | |||
| 06f40a75c6 | |||
| 49eb4f95da | |||
| ec0fde8602 | |||
| bee130950e | |||
| 1fb423bc8e | |||
| 7905376e20 | |||
| 01740fbda9 | |||
| d4622a75df | |||
| dd145671af | |||
| 1bb2962b26 | |||
| 7afd207b4d | |||
| 28c7ffd216 | |||
| dbc0e8aa1d | |||
| 714236a233 | |||
| 6c152bc307 | |||
| a9f7e014f9 | |||
| f9e758a29e | |||
| 74591cb254 | |||
| 9c273551e2 | |||
| 9d40ee4529 | |||
| 2140db1aae | |||
| 6714587e6e | |||
| 30dcfe05c3 | |||
| ce1d3bc5b9 | |||
| f5fb30e98a | |||
| 67ec7bb8b2 | |||
| 2c42b46e71 | |||
| af19e0d560 | |||
| 27c7720f71 | |||
| 9b27ac8cb9 | |||
| be9fbb01bc | |||
| 82331aff2d | |||
| 7d9c69687f | |||
| 0916c78d52 | |||
| b31c3c5c1d | |||
| 484807c472 | |||
| 23490f7e6f | |||
| 8ea0543b89 | |||
| c7c9813056 | |||
| ff2bdde179 | |||
| a27e0bddbf | |||
| 32199574a9 | |||
| fccb633d08 | |||
| f5679b5c91 | |||
| ee88ddd678 | |||
| ee50091c9a | |||
| f6cc991b48 | |||
| 02b6c1fc91 | |||
| 5df13d8f32 | |||
| 918e32edee | |||
| 06d8e7ae9f | |||
| 2b35590de8 | |||
| 2788d85556 | |||
| 4ffb28377a | |||
| fa570fa500 | |||
| 23f64d24c6 | |||
| f4c79e2d21 | |||
| 10449e458e | |||
| 2ac5a0ea1f | |||
| cb34c643d7 | |||
| d9f4946665 | |||
| cb2355d881 | |||
| 04ce2878aa | |||
| c52d47decd | |||
| 26ca343dae | |||
| 81781a6143 | |||
| e2c92051f4 | |||
| f09a5d79ef | |||
| 8822846800 | |||
| c2a7836d0c | |||
| e84ff527c4 | |||
| 349d7cf24b | |||
| d5af7a2bad | |||
| 77a4bfa3f8 | |||
| d0d9ae5e9a | |||
| 0d188ade2f | |||
| d043ba9ec9 | |||
| dc35c62d5d | |||
| 2dc54861ba | |||
| 5cff5f3f24 | |||
| 39bb06bb93 | |||
| 599e236546 | |||
| 06f015c9d0 | |||
| 49bdafe10e | |||
| 955b06d4fa | |||
| 34ac2176e6 | |||
| c5e87a800b | |||
| 92c9cc5ddc | |||
| dd0b7697f2 | |||
| 9d68aa8b8e | |||
| c59fe3e62c | |||
| 7602f7b7c6 | |||
| a1debe1328 | |||
| 990a00d70f | |||
| a125a61cbd | |||
| e3e6e1ef77 | |||
| 09e897c8f2 | |||
| 6caeeb5da6 | |||
| 40550af42c | |||
| e85e4d6f0c | |||
| d26f76c6e4 | |||
| 5c6f725079 | |||
| 4a456e2145 | |||
| c029e0ffe4 | |||
| cff2217283 | |||
| 904617b573 | |||
| 20fae7e14b | |||
| 699ed190b7 | |||
| 28ae6d6ca1 | |||
| beca76fb32 | |||
| 8de9935f9a | |||
| 6bc700e3db | |||
| 2f17a1a341 | |||
| 639121b644 | |||
| 1187852bed | |||
| 7acc036d91 | |||
| 1fea5aaa63 | |||
| 8de7294de8 | |||
| 2e4ded7f14 | |||
| ac94eb4cf4 | |||
| 73635cf68f | |||
| 9eb0da6c2b | |||
| 59c10df046 | |||
| db4d9a267a | |||
| 734dc6e57f | |||
| 27befb16f5 | |||
| 1c32b08644 | |||
| 5d81bf0fe2 | |||
| f44771acd5 | |||
| 786728e0d4 | |||
| 9714cf8861 | |||
| c5db44964e | |||
| ed38739d65 | |||
| 50bc90835c | |||
| 4d41766b9f | |||
| e958e29439 | |||
| b337bb706e | |||
| 314e06731a | |||
| 3a6f8b98dd | |||
| 65f9322bd9 | |||
| 33983e80f8 | |||
| 8648154a74 | |||
| 5d91ac037e | |||
| 6179dfe0a4 | |||
| cd9e28e6d0 | |||
| 8d56a697eb | |||
| fd506b14b5 | |||
| 55d268773d | |||
| bf978c2c42 | |||
| 7e06dbbd33 | |||
| 8d4822d781 | |||
| dbcaae65d1 | |||
| 66db36ff5b | |||
| a53d17a857 | |||
| de56305315 | |||
| 5cd22e3f86 | |||
| bd9150837d | |||
| fbb494b88a | |||
| 16b4c48457 | |||
| 058c86872b | |||
| d3e8f562bc | |||
| 1594d44190 | |||
| c8419ffa99 | |||
| cf6c80504f | |||
| 230f615927 | |||
| 08e00dc3bf | |||
| c01bf73d3f | |||
| df28359114 | |||
| d3631f0485 | |||
| eb18c556f4 | |||
| b18284ea78 | |||
| e4e99ec711 | |||
| b7b4d0bfe8 | |||
| c12b00e7a9 | |||
| 73bb8c2f64 | |||
| 0e1d513462 | |||
| d6a1b338bc | |||
| 0091fb6ffb | |||
| de3e3fe34a | |||
| e136d75621 | |||
| 91127ffdb9 | |||
| 11f76f0218 | |||
| 74f1b9c75d | |||
| 942c78119e | |||
| 3130c3c96e | |||
| f0225bc20d | |||
| 1bf158245c |
@@ -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.
|
||||
@@ -33,13 +33,7 @@ jobs:
|
||||
node-version: '20.x'
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: '1.24'
|
||||
cache-dependency-path: cli/go.sum
|
||||
|
||||
# Cache root dependencies - only reuse if package-lock.json exactly matches
|
||||
# Cache root dependencies
|
||||
- name: Cache root dependencies
|
||||
uses: actions/cache@v4
|
||||
id: root-cache
|
||||
@@ -47,46 +41,37 @@ jobs:
|
||||
path: node_modules
|
||||
key: ${{ runner.os }}-npm-${{ hashFiles('package-lock.json') }}
|
||||
|
||||
# Cache webview-ui dependencies - only reuse if package-lock.json exactly matches
|
||||
- name: Cache webview-ui dependencies
|
||||
# Cache cli dependencies
|
||||
- name: Cache cli dependencies
|
||||
uses: actions/cache@v4
|
||||
id: webview-cache
|
||||
id: cli-cache
|
||||
with:
|
||||
path: webview-ui/node_modules
|
||||
key: ${{ runner.os }}-npm-webview-${{ hashFiles('webview-ui/package-lock.json') }}
|
||||
path: cli/node_modules
|
||||
key: ${{ runner.os }}-npm-cli-${{ hashFiles('cli/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 cli dependencies
|
||||
if: steps.cli-cache.outputs.cache-hit != 'true'
|
||||
run: cd cli && npm ci
|
||||
|
||||
- name: Generate Protos
|
||||
run: npm run protos
|
||||
|
||||
- name: Read release version
|
||||
id: version
|
||||
run: |
|
||||
# Read version from cli/package.json (stable version)
|
||||
# Read version from cli/package.json
|
||||
VERSION=$(node -p "require('./cli/package.json').version")
|
||||
echo "Release version: $VERSION"
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Download ripgrep binaries
|
||||
run: npm run download-ripgrep
|
||||
|
||||
- name: Clean previous builds
|
||||
run: rm -rf dist-standalone
|
||||
|
||||
- name: Generate Protos (First Pass)
|
||||
run: npm run protos && npm run protos-go
|
||||
|
||||
- name: Compile CLI
|
||||
run: npm run compile-cli
|
||||
|
||||
- name: Compile CLI for all platforms
|
||||
run: npm run compile-cli-all-platforms
|
||||
|
||||
- name: Build standalone NPM package
|
||||
- name: Build and package CLI
|
||||
env:
|
||||
TELEMETRY_SERVICE_API_KEY: ${{ secrets.TELEMETRY_SERVICE_API_KEY }}
|
||||
ERROR_SERVICE_API_KEY: ${{ secrets.ERROR_SERVICE_API_KEY }}
|
||||
@@ -98,21 +83,18 @@ jobs:
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT: ${{ secrets.OTEL_EXPORTER_OTLP_ENDPOINT }}
|
||||
OTEL_EXPORTER_OTLP_HEADERS: ${{ secrets.OTEL_EXPORTER_OTLP_HEADERS }}
|
||||
POSTHOG_TELEMETRY_ENABLED: "true"
|
||||
run: npm run compile-standalone-npm
|
||||
|
||||
- name: Generate Protos (Second Pass - Bug Workaround)
|
||||
run: npm run protos && npm run protos-go
|
||||
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 dist-standalone/dist..."
|
||||
ls -la dist-standalone/dist/
|
||||
|
||||
echo "Checking package.json in dist-standalone..."
|
||||
cat dist-standalone/package.json | grep version
|
||||
cat dist-standalone/package.json
|
||||
|
||||
- name: Publish to NPM with latest tag
|
||||
env:
|
||||
|
||||
@@ -42,14 +42,7 @@ jobs:
|
||||
node-version: '20.x'
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
|
||||
- name: Setup Go
|
||||
if: steps.check_commits.outputs.skip != 'true'
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: '1.24'
|
||||
cache-dependency-path: cli/go.sum
|
||||
|
||||
# Cache root dependencies - only reuse if package-lock.json exactly matches
|
||||
# Cache root dependencies
|
||||
- name: Cache root dependencies
|
||||
if: steps.check_commits.outputs.skip != 'true'
|
||||
uses: actions/cache@v4
|
||||
@@ -58,36 +51,40 @@ jobs:
|
||||
path: node_modules
|
||||
key: ${{ runner.os }}-npm-${{ hashFiles('package-lock.json') }}
|
||||
|
||||
# Cache webview-ui dependencies - only reuse if package-lock.json exactly matches
|
||||
- name: Cache webview-ui dependencies
|
||||
# Cache cli dependencies
|
||||
- name: Cache cli dependencies
|
||||
if: steps.check_commits.outputs.skip != 'true'
|
||||
uses: actions/cache@v4
|
||||
id: webview-cache
|
||||
id: cli-cache
|
||||
with:
|
||||
path: webview-ui/node_modules
|
||||
key: ${{ runner.os }}-npm-webview-${{ hashFiles('webview-ui/package-lock.json') }}
|
||||
path: cli/node_modules
|
||||
key: ${{ runner.os }}-npm-cli-${{ hashFiles('cli/package-lock.json') }}
|
||||
|
||||
- name: Install root dependencies
|
||||
if: steps.check_commits.outputs.skip != 'true' && steps.root-cache.outputs.cache-hit != 'true'
|
||||
run: npm ci --include=optional
|
||||
|
||||
- name: Install webview-ui dependencies
|
||||
if: steps.check_commits.outputs.skip != 'true' && steps.webview-cache.outputs.cache-hit != 'true'
|
||||
run: cd webview-ui && npm ci --include=optional
|
||||
- name: Install cli dependencies
|
||||
if: steps.check_commits.outputs.skip != 'true' && steps.cli-cache.outputs.cache-hit != 'true'
|
||||
run: cd cli && npm ci
|
||||
|
||||
- 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., "1.0.9")
|
||||
# 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
|
||||
|
||||
# Create unique nightly version: 2.0.0-nightly.1736365200
|
||||
VERSION="${BASE_VERSION}-nightly.${TIMESTAMP}"
|
||||
|
||||
|
||||
echo "Base version: $BASE_VERSION"
|
||||
echo "Generated nightly version: $VERSION"
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
@@ -102,31 +99,15 @@ jobs:
|
||||
pkg.version = '${{ steps.version.outputs.version }}';
|
||||
fs.writeFileSync('cli/package.json', JSON.stringify(pkg, null, '\t'));
|
||||
"
|
||||
|
||||
|
||||
echo "Using version ${{ steps.version.outputs.version }} for build"
|
||||
cat cli/package.json | grep '"version"'
|
||||
|
||||
- name: Download ripgrep binaries
|
||||
if: steps.check_commits.outputs.skip != 'true'
|
||||
run: npm run download-ripgrep
|
||||
|
||||
- name: Clean previous builds
|
||||
if: steps.check_commits.outputs.skip != 'true'
|
||||
run: rm -rf dist-standalone
|
||||
|
||||
- name: Generate Protos (First Pass)
|
||||
if: steps.check_commits.outputs.skip != 'true'
|
||||
run: npm run protos && npm run protos-go
|
||||
|
||||
- name: Compile CLI
|
||||
if: steps.check_commits.outputs.skip != 'true'
|
||||
run: npm run compile-cli
|
||||
|
||||
- name: Compile CLI for all platforms
|
||||
if: steps.check_commits.outputs.skip != 'true'
|
||||
run: npm run compile-cli-all-platforms
|
||||
|
||||
- name: Build standalone NPM package
|
||||
- name: Build and package CLI
|
||||
if: steps.check_commits.outputs.skip != 'true'
|
||||
env:
|
||||
TELEMETRY_SERVICE_API_KEY: ${{ secrets.TELEMETRY_SERVICE_API_KEY }}
|
||||
@@ -139,23 +120,19 @@ jobs:
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT: ${{ secrets.OTEL_EXPORTER_OTLP_ENDPOINT }}
|
||||
OTEL_EXPORTER_OTLP_HEADERS: ${{ secrets.OTEL_EXPORTER_OTLP_HEADERS }}
|
||||
POSTHOG_TELEMETRY_ENABLED: "true"
|
||||
run: npm run compile-standalone-npm
|
||||
|
||||
- name: Generate Protos (Second Pass - Bug Workaround)
|
||||
if: steps.check_commits.outputs.skip != 'true'
|
||||
run: npm run protos && npm run protos-go
|
||||
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 dist-standalone/dist..."
|
||||
ls -la dist-standalone/dist/
|
||||
|
||||
echo "Checking package.json in dist-standalone..."
|
||||
cat dist-standalone/package.json | grep version
|
||||
cat dist-standalone/package.json
|
||||
|
||||
- name: Publish to NPM with nightly tag
|
||||
if: steps.check_commits.outputs.skip != 'true'
|
||||
|
||||
@@ -46,3 +46,4 @@ test-results
|
||||
/pkg
|
||||
.secrets
|
||||
|
||||
*.tsbuildinfo
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
@.clinerules/general.md
|
||||
@.clinerules/network.md
|
||||
@.clinerules/cli.md
|
||||
|
||||
+6
-2
@@ -44,7 +44,7 @@
|
||||
"style": {
|
||||
"useNodejsImportProtocol": "off",
|
||||
"useImportType": "off",
|
||||
"useBlockStatements": "warn",
|
||||
"useBlockStatements": "off",
|
||||
"useNamingConvention": "off",
|
||||
"useThrowOnlyError": "info",
|
||||
"useConsistentArrayType": "off",
|
||||
@@ -154,6 +154,8 @@
|
||||
],
|
||||
"includes": [
|
||||
"**",
|
||||
"!**/esbuild.*",
|
||||
"!**/*.mts",
|
||||
"!**/webview-ui/**",
|
||||
"!**/evals/**",
|
||||
"!**/standalone/**",
|
||||
@@ -167,7 +169,9 @@
|
||||
"!**/*.js",
|
||||
"!**/scripts/**",
|
||||
"!**/*.tsx",
|
||||
"!**/testing-platform/**"
|
||||
"!**/testing-platform/**",
|
||||
// ACP mode must redirect console to stderr - this is intentional
|
||||
"!cli/src/acp/index.ts"
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
cline-core-debug.log
|
||||
bin/*
|
||||
+334
-42
@@ -1,73 +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.
|
||||
|
||||
Autonomous coding agent CLI - capable of creating/editing files, running commands, using the browser, and more.
|
||||
## 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
|
||||
- npm or yarn
|
||||
- The parent Cline project dependencies installed
|
||||
|
||||
## Installation
|
||||
|
||||
Install Cline globally using npm:
|
||||
From the repository root:
|
||||
|
||||
```bash
|
||||
npm install -g cline
|
||||
# Install all dependencies first
|
||||
npm run install:all
|
||||
|
||||
# Ensure protos are generated
|
||||
npm run protos
|
||||
|
||||
# Build and link the CLI globally
|
||||
npm 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"
|
||||
```
|
||||
|
||||
This will start the Cline CLI interface where you can interact with the autonomous coding agent.
|
||||
### Commands
|
||||
|
||||
## Features
|
||||
#### `task` (alias: `t`)
|
||||
|
||||
- **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
|
||||
Run a new task with a prompt.
|
||||
|
||||
## Requirements
|
||||
```bash
|
||||
cline task "Create a hello world function in Python"
|
||||
cline t "Create a hello world function"
|
||||
```
|
||||
|
||||
- Node.js 18.0.0 or higher
|
||||
- Supported platforms: macOS, Linux. Windows soon
|
||||
- Supported architectures: x64, arm64
|
||||
**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)
|
||||
npm run install:all
|
||||
|
||||
# 2. Build and link globally so you can run `cline` from anywhere
|
||||
npm run cli:link
|
||||
|
||||
# 3. Test it
|
||||
cline --help
|
||||
```
|
||||
|
||||
### Scripts
|
||||
|
||||
Run these from the repository root:
|
||||
|
||||
| Script | Description |
|
||||
|--------|-------------|
|
||||
| `npm run install:all` | Install deps for root, webview-ui, and cli |
|
||||
| `npm run cli:build` | Generate protos and build CLI |
|
||||
| `npm run cli:build:production` | Production build (minified) |
|
||||
| `npm run cli:link` | Build and `npm link` so you can run `cline` from anywhere |
|
||||
| `npm run cli:unlink` | Remove the global `cline` symlink |
|
||||
| `npm run cli:dev` | Link + watch mode for development |
|
||||
| `npm run cli:watch` | Watch mode only (no initial build) |
|
||||
| `npm run cli:test` | Run CLI tests |
|
||||
|
||||
### Development Workflow
|
||||
|
||||
1. Run `npm 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 `npm 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
|
||||
npm run protos
|
||||
```
|
||||
|
||||
This generates TypeScript types in `src/generated/` that both the CLI and extension use.
|
||||
|
||||
## Publish
|
||||
|
||||
#### 1. Publish to npm
|
||||
```bash
|
||||
npm publish
|
||||
```
|
||||
|
||||
#### 2. Update the Homebrew formula
|
||||
```bash
|
||||
npm 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
|
||||
|
||||
Cline can be configured through:
|
||||
The CLI stores its data in `~/.cline/data/` by default:
|
||||
|
||||
- Environment variables
|
||||
- Configuration files
|
||||
- Command-line arguments
|
||||
- `globalState.json`: Global settings and state
|
||||
- `secrets.json`: API keys and secrets
|
||||
- `workspace/`: Workspace-specific state
|
||||
- `tasks/`: Task history and conversation data
|
||||
|
||||
See the [main documentation](https://cline.bot) for detailed configuration options.
|
||||
Override with the `--config` option or `CLINE_DIR` environment variable.
|
||||
|
||||
## Links
|
||||
## Troubleshooting
|
||||
|
||||
- **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
|
||||
### Build Errors
|
||||
|
||||
## License
|
||||
If you encounter build errors:
|
||||
|
||||
Apache-2.0 - see [LICENSE](https://github.com/cline/cline/blob/main/LICENSE) for details.
|
||||
```bash
|
||||
# Make sure all deps are installed
|
||||
npm run install:all
|
||||
|
||||
## Support
|
||||
# Regenerate proto types
|
||||
npm run protos
|
||||
|
||||
- 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)
|
||||
- Cline CLI Architecture: [architecture.md](./architecture.md)
|
||||
# Then rebuild
|
||||
npm run cli:build
|
||||
```
|
||||
|
||||
### "command not found: cline"
|
||||
|
||||
The CLI isn't linked globally. Run:
|
||||
|
||||
```bash
|
||||
npm run cli:link
|
||||
```
|
||||
|
||||
### Changes Not Reflected
|
||||
|
||||
If your code changes aren't showing up:
|
||||
|
||||
1. Make sure watch mode is running (`npm run cli:dev`)
|
||||
2. Check for TypeScript errors in the watch output
|
||||
3. Try unlinking and relinking: `npm run cli:unlink && npm 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/`.
|
||||
@@ -1,292 +0,0 @@
|
||||
# Cline CLI Architecture
|
||||
|
||||
The CLI is a **standalone terminal interface** for the Cline AI coding assistant, written in Go. It provides the same autonomous coding capabilities as the VS Code extension but runs entirely in the terminal.
|
||||
|
||||
## High-Level Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────────┐
|
||||
│ User Terminal │
|
||||
└─────────────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────────────┐
|
||||
│ cline (Go binary) │
|
||||
│ cmd/cline/main.go │
|
||||
│ • Cobra CLI commands (task, auth, config, instance, etc.) │
|
||||
│ • Interactive input via Bubble Tea │
|
||||
│ • Streaming output with markdown rendering │
|
||||
└─────────────────────────────────────────────────────────────────────────┘
|
||||
│ gRPC (50052) │ starts subprocess
|
||||
▼ ▼
|
||||
┌─────────────────────────┐ ┌─────────────────────────┐
|
||||
│ cline-core │◄────────────────►│ cline-host │
|
||||
│ (Node.js) │ gRPC (51052) │ (Go binary) │
|
||||
│ │ │ cmd/cline-host/main.go│
|
||||
│ • AI/LLM orchestration │ │ │
|
||||
│ • Tool execution │ │ • Workspace paths │
|
||||
│ • Task state mgmt │ │ • File diff editing │
|
||||
│ • Message handling │ │ • Clipboard access │
|
||||
└─────────────────────────┘ │ • Environment info │
|
||||
│ └─────────────────────────┘
|
||||
│ SQLite (self-registration)
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────────────┐
|
||||
│ ~/.cline/data/locks/locks.db │
|
||||
│ (Instance registry - core self-registers on startup) │
|
||||
└─────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Entry Points (`cmd/`)
|
||||
|
||||
### `cmd/cline/main.go` - Main CLI
|
||||
|
||||
Cobra-based CLI with commands:
|
||||
|
||||
- **Root**: `cline [prompt]` - Start a task directly
|
||||
- **task**: Create, send, view, list, pause, restore tasks
|
||||
- **auth**: Authentication setup and provider configuration
|
||||
- **config**: Read/write settings
|
||||
- **instance**: Manage running Cline instances
|
||||
- **logs**: View and clean log files
|
||||
- **doctor**: System health check
|
||||
|
||||
### `cmd/cline-host/main.go` - Host Bridge Service
|
||||
|
||||
Separate gRPC server providing host environment operations to cline-core:
|
||||
|
||||
- Workspace paths
|
||||
- File diff editing
|
||||
- Clipboard access
|
||||
- Shutdown coordination
|
||||
|
||||
---
|
||||
|
||||
## `pkg/cli/` Subsystems
|
||||
|
||||
### 1. `auth/` - Authentication System
|
||||
|
||||
Handles authentication with Cline service and BYO (Bring Your Own) API providers.
|
||||
|
||||
| File | Purpose |
|
||||
| ------------------------- | ------------------------------------------------------------------------ |
|
||||
| `auth_cline_provider.go` | OAuth login flow - opens browser, subscribes to auth callback stream |
|
||||
| `auth_menu.go` | Interactive menu showing auth options based on current state |
|
||||
| `auth_subscription.go` | gRPC stream subscription for auth status updates |
|
||||
| `wizard_byo.go` | Interactive wizard for configuring BYO providers |
|
||||
| `wizard_byo_bedrock.go` | AWS Bedrock-specific credential setup |
|
||||
| `wizard_byo_oca.go` | Oracle Code Assist setup |
|
||||
| `providers_list.go` | Retrieves configured providers from core state |
|
||||
| `providers_byo.go` | Provider selection UI and field configuration |
|
||||
| `models_*.go` | Model listing (static lists + dynamic fetch from OpenRouter/OpenAI/Ollama) |
|
||||
|
||||
**Flow**: User runs `cline auth` → Menu shows options → For BYO: wizard guides through provider/key/model selection → Config saved via gRPC to core.
|
||||
|
||||
---
|
||||
|
||||
### 2. `clerror/` - Error Handling
|
||||
|
||||
Parses and classifies API errors from the Cline service.
|
||||
|
||||
**Error Types:**
|
||||
|
||||
- `ErrorTypeAuth` - 401, bad API key
|
||||
- `ErrorTypeBalance` - Insufficient credits
|
||||
- `ErrorTypeRateLimit` - 429, quota exceeded
|
||||
- `ErrorTypeNetwork` - Connection issues
|
||||
- `ErrorTypeUnknown` - Catch-all
|
||||
|
||||
Extracts billing details (balance, spent, buy credits URL) from error responses.
|
||||
|
||||
---
|
||||
|
||||
### 3. `config/` - Configuration Management
|
||||
|
||||
| File | Purpose |
|
||||
| --------------------- | -------------------------------------------------------------------------- |
|
||||
| `manager.go` | gRPC interface for reading/writing settings via `UpdateSettingsCli` RPC |
|
||||
| `settings_renderer.go`| Pretty-prints config values, censors sensitive fields (keys, secrets) |
|
||||
|
||||
Supports dot-notation paths: `cline config get auto-approval-settings.actions.read-files`
|
||||
|
||||
---
|
||||
|
||||
### 4. `display/` - Terminal Display System
|
||||
|
||||
The most complex subsystem - handles all visual output.
|
||||
|
||||
| File | Purpose |
|
||||
| ----------------------- | -------------------------------------------------------------------- |
|
||||
| `renderer.go` | Central coordinator with lipgloss styles, color methods, markdown delegation |
|
||||
| `streaming.go` | Real-time streaming display with deduplication |
|
||||
| `segment_streamer.go` | Streaming segments (header + body) with context-aware headers |
|
||||
| `typewriter.go` | Character-by-character animation with variable delays |
|
||||
| `markdown_renderer.go` | Glamour wrapper for terminal markdown rendering |
|
||||
| `tool_renderer.go` | Tool operation formatting ("Cline is editing `file.ts`") |
|
||||
| `tool_result_parser.go` | Parses structured tool results (file lists, search results) |
|
||||
| `banner.go` | Session startup banner with version/model/workspace |
|
||||
| `deduplicator.go` | MD5-based deduplication with 2-second window |
|
||||
| `system_renderer.go` | Rich error/warning boxes for balance errors, auth failures |
|
||||
| `ansi.go` | TTY detection, line clearing with escape codes |
|
||||
|
||||
---
|
||||
|
||||
### 5. `global/` - Global State Management
|
||||
|
||||
| File | Purpose |
|
||||
| ------------------ | -------------------------------------------------------------------------- |
|
||||
| `global.go` | Global config (paths, verbosity, output format), initialization |
|
||||
| `registry.go` | Instance discovery via SQLite, health checking, default instance management|
|
||||
| `cline-clients.go` | Starts cline-core + cline-host processes, port allocation, cleanup |
|
||||
|
||||
**Instance lifecycle:**
|
||||
|
||||
1. Find available port pair
|
||||
2. Start `cline-host` on port+1000
|
||||
3. Start `cline-core` on port
|
||||
4. Wait for core to self-register in SQLite
|
||||
5. Set as default if first instance
|
||||
|
||||
---
|
||||
|
||||
### 6. `handlers/` - Message Handlers
|
||||
|
||||
Routes incoming messages from cline-core to appropriate renderers.
|
||||
|
||||
| File | Purpose |
|
||||
| ------------------ | --------------------------------------------------------------------- |
|
||||
| `handler.go` | Handler registry with priority-based routing |
|
||||
| `ask_handlers.go` | Approval requests: tool, command, followup, api_req_failed, etc. |
|
||||
| `say_handlers.go` | Status messages: text, reasoning, command_output, tool, checkpoint, etc. |
|
||||
|
||||
Uses `DisplayContext` providing renderer access, state, and context flags (isLast, isPartial, isStreamingMode).
|
||||
|
||||
---
|
||||
|
||||
### 7. `output/` - Output Coordination
|
||||
|
||||
| File | Purpose |
|
||||
| --------------------- | ----------------------------------------------------------------------- |
|
||||
| `coordinator.go` | Coordinates streaming output with interactive input (saves/restores input state) |
|
||||
| `input_model.go` | Bubble Tea model for rich input (message, approval, feedback types) |
|
||||
| `slash_completion.go` | Autocomplete dropdown for slash commands |
|
||||
|
||||
**Key pattern:** When output needs to print while input is visible, the coordinator saves input state, clears the form, prints, then restores input.
|
||||
|
||||
---
|
||||
|
||||
### 8. `slash/` - Slash Command Registry
|
||||
|
||||
Central registry for commands like `/plan`, `/act`, `/cancel`:
|
||||
|
||||
- **CLI-local commands**: Handled directly by CLI
|
||||
- **Backend commands**: Fetched from core via gRPC, filtered by `CliCompatible` flag
|
||||
|
||||
---
|
||||
|
||||
### 9. `sqlite/` - Instance Locking
|
||||
|
||||
Manages the distributed locking system:
|
||||
|
||||
- **Instance locks**: Track running Cline instances by address
|
||||
- **File locks**: Coordinate file access across instances
|
||||
- SQLite database created by cline-core, CLI reads/writes for discovery
|
||||
|
||||
---
|
||||
|
||||
### 10. `task/` - Task Management
|
||||
|
||||
| File | Purpose |
|
||||
| ----------------------- | -------------------------------------------------------------------- |
|
||||
| `manager.go` | Core orchestrator: create, cancel, resume, restore tasks; stream handling |
|
||||
| `stream_coordinator.go` | Deduplication and turn management for dual streams |
|
||||
| `input_handler.go` | Interactive input during follow mode (polling, approval detection) |
|
||||
| `history_handler.go` | Direct disk access to `taskHistory.json` |
|
||||
| `settings_parser.go` | Parse settings from CLI flags |
|
||||
| `follow_options.go` | Configuration for follow behavior |
|
||||
|
||||
**Streaming:** Task manager subscribes to two gRPC streams:
|
||||
|
||||
1. `SubscribeToState` - Full state updates
|
||||
2. `SubscribeToPartialMessage` - Streaming AI responses
|
||||
|
||||
---
|
||||
|
||||
### 11. `terminal/` - Terminal Handling
|
||||
|
||||
Enhanced keyboard protocol support and terminal configuration:
|
||||
|
||||
- Enables modifyOtherKeys and Kitty keyboard protocol
|
||||
- Detects terminal type (VS Code, iTerm, Ghostty, Kitty, etc.)
|
||||
- Auto-configures shift+enter keybindings for various terminals
|
||||
|
||||
---
|
||||
|
||||
### 12. `types/` - Type Definitions
|
||||
|
||||
| File | Purpose |
|
||||
| -------------- | ----------------------------------------------------------------- |
|
||||
| `messages.go` | `ClineMessage`, `AskType`, `SayType`, `ToolType` enums, proto conversion |
|
||||
| `state.go` | `ConversationState` with thread-safe message access |
|
||||
| `history.go` | `HistoryItem` matching taskHistory.json format |
|
||||
|
||||
---
|
||||
|
||||
### 13. `updater/` - Auto-Update
|
||||
|
||||
Background auto-update checking:
|
||||
|
||||
- 24-hour check interval (cached)
|
||||
- Queries npm registry for newer versions
|
||||
- Supports `latest` and `nightly` channels
|
||||
- Runs `npm install -g cline` to update
|
||||
|
||||
---
|
||||
|
||||
## `pkg/common/` - Shared Types
|
||||
|
||||
| File | Purpose |
|
||||
| --------------- | ------------------------------------------------------------ |
|
||||
| `constants.go` | `SETTINGS_SUBFOLDER`, `DEFAULT_CLINE_CORE_PORT` |
|
||||
| `schema.go` | SQL queries for instance/file locks |
|
||||
| `types.go` | `CoreInstanceInfo`, `LockRow`, `DefaultCoreInstance` |
|
||||
| `utils.go` | Port checking, health checks, address normalization, retry logic |
|
||||
|
||||
---
|
||||
|
||||
## `pkg/generated/` - Auto-Generated
|
||||
|
||||
| File | Purpose |
|
||||
| --------------------- | -------------------------------------------------------------------------------------------------------------------- |
|
||||
| `providers.go` | Provider definitions (Anthropic, OpenAI, Bedrock, etc.) with field metadata and model specs - generated from TypeScript sources |
|
||||
| `field_overrides.go` | Manual overrides for field filtering |
|
||||
|
||||
---
|
||||
|
||||
## `pkg/hostbridge/` - CLI-to-Core Bridge
|
||||
|
||||
This is the **reverse bridge** allowing cline-core to request host environment operations:
|
||||
|
||||
| File | Purpose |
|
||||
| ----------------------- | ---------------------------------------------------- |
|
||||
| `grpc_server.go` | Main server registering all services |
|
||||
| `simple_workspace.go` | Workspace service: returns CWD as workspace path |
|
||||
| `diff.go` | In-memory file diff editing with line-based operations |
|
||||
| `env.go` | Clipboard access, version info, shutdown coordination |
|
||||
| `window.go` | UI stubs (no-ops or console output) |
|
||||
|
||||
**Why this exists:** The same cline-core logic runs in VS Code and CLI. In VS Code, the "host" is the extension with editor APIs. In CLI, hostbridge emulates these capabilities with terminal-appropriate implementations.
|
||||
|
||||
---
|
||||
|
||||
## Key Design Decisions
|
||||
|
||||
1. **Two-process model:** `cline` CLI manages instances; `cline-core` is the actual AI engine (Node.js). This allows reusing the same core as the VS Code extension.
|
||||
|
||||
2. **Self-registration via SQLite:** `cline-core` registers itself in a SQLite database on startup. The CLI discovers instances by reading this database, enabling multi-instance support.
|
||||
|
||||
3. **Host bridge abstraction:** The `cline-host` process provides platform-specific operations (clipboard, workspace paths) via gRPC, allowing `cline-core` to remain host-agnostic.
|
||||
|
||||
4. **Streaming-first UI:** The CLI uses gRPC streaming to display AI responses in real-time with typewriter-style rendering.
|
||||
|
||||
5. **Dual stream handling:** Task manager subscribes to both state updates and partial messages, using deduplication to prevent duplicate rendering.
|
||||
@@ -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,73 +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
|
||||
workspaces []string
|
||||
)
|
||||
|
||||
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")
|
||||
rootCmd.Flags().StringSliceVar(&workspaces, "workspace", nil, "workspace paths")
|
||||
|
||||
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, workspaces)
|
||||
|
||||
// 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,385 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/huh"
|
||||
"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/cli/output"
|
||||
"github.com/cline/cli/pkg/cli/slash"
|
||||
"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
|
||||
workspaces []string
|
||||
)
|
||||
|
||||
func main() {
|
||||
rootCmd := &cobra.Command{
|
||||
Use: "cline [prompt]",
|
||||
Short: "Cline CLI - AI-powered coding assistant",
|
||||
Version: global.CliVersion,
|
||||
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()
|
||||
|
||||
// Validate workspace paths exist
|
||||
if err := common.ValidateDirsExist(workspaces); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Build the full workspace list: cwd first, then additional workspaces
|
||||
allWorkspaces, err := buildWorkspaceList(workspaces)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to build workspace list: %w", err)
|
||||
}
|
||||
|
||||
// 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.Instances.StartNewInstance(ctx, allWorkspaces...)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to start new instance: %w", err)
|
||||
}
|
||||
global.Config.CoreAddress = instance.CoreAddress
|
||||
if global.Config.Verbose {
|
||||
fmt.Printf("Started instance at %s\n\n", global.Config.CoreAddress)
|
||||
}
|
||||
|
||||
// Set up cleanup on exit
|
||||
defer func() {
|
||||
if global.Config.Verbose {
|
||||
fmt.Println("\nCleaning up instance...")
|
||||
}
|
||||
registry := global.Instances.GetRegistry()
|
||||
if err := global.KillInstanceByAddress(context.Background(), registry, global.Config.CoreAddress); 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) {
|
||||
// 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) {
|
||||
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"))
|
||||
}
|
||||
|
||||
// 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 (or just a mode switch with no message), show interactive input
|
||||
// Loop to allow mode switches without a message
|
||||
bannerShown := false
|
||||
for prompt == "" {
|
||||
// Pass the mode flag and workspaces to banner so it shows correct info
|
||||
prompt, err = promptForInitialTask(ctx, mode, allWorkspaces, !bannerShown)
|
||||
bannerShown = true
|
||||
if err != nil {
|
||||
// Check if user cancelled - exit cleanly without error
|
||||
if err == huh.ErrUserAborted {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// Check if user entered a mode switch command
|
||||
if newMode, remaining, isModeSwitch := slash.ParseModeSwitch(prompt); isModeSwitch {
|
||||
mode = newMode
|
||||
prompt = remaining
|
||||
// If just a mode switch with no message, continue loop to re-prompt
|
||||
if prompt == "" {
|
||||
renderer := display.NewRenderer(global.Config.OutputFormat)
|
||||
if mode == "act" {
|
||||
fmt.Printf("\n%s\n\n", renderer.Success("Switched to act mode"))
|
||||
} else {
|
||||
fmt.Printf("\n%s\n\n", renderer.Success("Switched to plan mode"))
|
||||
}
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
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: global.Config.CoreAddress,
|
||||
Verbose: verbose,
|
||||
Workspaces: allWorkspaces,
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
rootCmd.SetVersionTemplate(cli.VersionString())
|
||||
|
||||
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.Flags().StringSliceVarP(&workspaces, "workspace", "w", nil, "additional workspace paths (can be specified multiple times)")
|
||||
|
||||
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, modeFlag string, workspaces []string, showBanner bool) (string, error) {
|
||||
// Show session banner before the initial input (only on first prompt)
|
||||
if showBanner {
|
||||
showSessionBanner(ctx, modeFlag, workspaces)
|
||||
}
|
||||
|
||||
prompt, err := output.PromptForInitialTask(
|
||||
"Start a new Cline task",
|
||||
"/plan or /act to switch modes\ntab to autocomplete commands\nctrl+e to open editor\nctrl+c to exit",
|
||||
modeFlag,
|
||||
slash.NewRegistry(ctx),
|
||||
)
|
||||
if err != nil {
|
||||
if err == output.ErrUserAborted {
|
||||
return "", huh.ErrUserAborted
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
|
||||
return prompt, nil
|
||||
}
|
||||
|
||||
// showSessionBanner displays session info before initial prompt
|
||||
func showSessionBanner(ctx context.Context, modeFlag string, workspaces []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"
|
||||
}
|
||||
|
||||
bannerInfo.Workdirs = workspaces
|
||||
|
||||
// Get provider/model using auth functions (same logic as auth menu)
|
||||
if providerList, err := auth.GetProviderConfigurations(ctx); 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) bool {
|
||||
grpcClient, err := global.GetClientForAddress(ctx, global.Config.CoreAddress)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
state, err := grpcClient.State.GetLatestState(ctx, &cline.EmptyRequest{})
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
stateMap := make(map[string]interface{})
|
||||
if err := json.Unmarshal([]byte(state.StateJson), &stateMap); err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// buildWorkspaceList builds the full workspace list with cwd as the first entry
|
||||
func buildWorkspaceList(additionalWorkspaces []string) ([]string, error) {
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get current working directory: %w", err)
|
||||
}
|
||||
|
||||
// Start with cwd
|
||||
workspaces := []string{cwd}
|
||||
|
||||
// Add additional workspaces, avoiding duplicates
|
||||
for _, ws := range additionalWorkspaces {
|
||||
// Normalize the path
|
||||
absPath, err := common.AbsPath(ws)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to resolve workspace path %s: %w", ws, err)
|
||||
}
|
||||
|
||||
// Skip if it's the same as cwd
|
||||
if absPath == cwd {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check for duplicates
|
||||
isDuplicate := slices.Contains(workspaces, absPath)
|
||||
if !isDuplicate {
|
||||
workspaces = append(workspaces, absPath)
|
||||
}
|
||||
}
|
||||
|
||||
return workspaces, 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].CoreAddress
|
||||
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.CoreAddress, defaultTimeout)
|
||||
|
||||
// Set as default
|
||||
_ = mustRunCLI(ctx, t, "instance", "use", target.CoreAddress)
|
||||
|
||||
// Verify default switched
|
||||
out = listInstancesJSON(ctx, t)
|
||||
if out.DefaultInstance != target.CoreAddress {
|
||||
t.Fatalf("default_instance not updated to %s (got %s)", target.CoreAddress, out.DefaultInstance)
|
||||
}
|
||||
|
||||
// Kill the default instance using runtime PID discovery
|
||||
corePID := getCorePID(t, target.CoreAddress)
|
||||
if corePID <= 0 {
|
||||
t.Fatalf("could not find PID for core process at %s", target.CoreAddress)
|
||||
}
|
||||
t.Logf("Killing cline-core process PID %d for instance %s", corePID, target.CoreAddress)
|
||||
if err := syscall.Kill(corePID, syscall.SIGKILL); err != nil {
|
||||
t.Fatalf("kill pid %d: %v", corePID, err)
|
||||
}
|
||||
|
||||
// Wait for removal
|
||||
waitForAddressRemoved(t, target.CoreAddress, 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.CoreAddress {
|
||||
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.CoreAddress == addr {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func getByAddress(in common.InstancesOutput, addr string) (common.CoreInstanceInfo, bool) {
|
||||
for _, it := range in.CoreInstances {
|
||||
if it.CoreAddress == 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.Instances.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.CoreAddress, 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.CoreAddress, defaultTimeout)
|
||||
waitForAddressHealthy(t, addr127, defaultTimeout)
|
||||
|
||||
out = listInstancesJSON(ctx, t)
|
||||
if !hasAddress(out, inst.CoreAddress) || !hasAddress(out, addr127) {
|
||||
t.Fatalf("expected both %s and %s present", inst.CoreAddress, 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.CoreAddress] = 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.CoreAddress]; !ok {
|
||||
newAddr = it.CoreAddress
|
||||
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.CoreAddress)
|
||||
if corePID <= 0 {
|
||||
t.Fatalf("could not find PID for new instance at %s", info.CoreAddress)
|
||||
}
|
||||
|
||||
t.Logf("Killing new instance %s (PID %d) for iteration %d", info.CoreAddress, 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{
|
||||
CoreAddress: 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].CoreAddress
|
||||
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].CoreAddress {
|
||||
t.Fatalf("expected default_instance=%s, got %s", out.CoreInstances[0].CoreAddress, 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].CoreAddress
|
||||
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.CoreAddress, defaultTimeout)
|
||||
|
||||
// Get PID using runtime discovery
|
||||
gracefulPID := getCorePID(t, gracefulTarget.CoreAddress)
|
||||
if gracefulPID <= 0 {
|
||||
t.Fatalf("could not find PID for graceful target at %s", gracefulTarget.CoreAddress)
|
||||
}
|
||||
|
||||
t.Logf("Testing graceful shutdown (SIGTERM) for instance %s (PID %d)", gracefulTarget.CoreAddress, 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.CoreAddress, 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.CoreAddress, defaultTimeout)
|
||||
|
||||
// Get PID using runtime discovery
|
||||
crashPID := getCorePID(t, crashTarget.CoreAddress)
|
||||
if crashPID <= 0 {
|
||||
t.Fatalf("could not find PID for crash target at %s", crashTarget.CoreAddress)
|
||||
}
|
||||
|
||||
t.Logf("Testing crash cleanup (SIGKILL) for instance %s (PID %d)", crashTarget.CoreAddress, 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.CoreAddress, 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.24.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=
|
||||
+274
-279
@@ -1,315 +1,307 @@
|
||||
.\" 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]# Show more tasks with pagination\f[R]
|
||||
cline history \-n 20 \-p 2
|
||||
.EE
|
||||
.SS Authentication
|
||||
.IP
|
||||
.EX
|
||||
\f[I]# Interactive authentication wizard\f[R]
|
||||
cline auth
|
||||
|
||||
\f[I]# View conversation history\f[R]
|
||||
cline task view
|
||||
\f[I]# Quick setup for Anthropic\f[R]
|
||||
cline auth \-p anthropic \-k sk\-ant\-api\-xxxxx
|
||||
|
||||
\f[I]# Start interactive chat with this task\f[R]
|
||||
cline task chat
|
||||
\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
|
||||
.TP
|
||||
\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 patterns before
|
||||
\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 (backward compatibility).
|
||||
.RS
|
||||
When not set, all commands are allowed.
|
||||
.PP
|
||||
Format:
|
||||
\f[CR]{"allow": ["pattern1", "pattern2"], "deny": ["pattern3"], "allowRedirects": true}\f[R]
|
||||
\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
|
||||
@@ -317,6 +309,7 @@ Format:
|
||||
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.
|
||||
@@ -346,31 +339,29 @@ All segments must pass for the command to be allowed
|
||||
\f[B]Examples:\f[R]
|
||||
.IP
|
||||
.EX
|
||||
\f[I]# Allow only npm and git commands\f[R]
|
||||
export CLINE_COMMAND_PERMISSIONS=\(aq{"allow": ["npm *", "git *"]}\(aq
|
||||
\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\f[R]
|
||||
export CLINE_COMMAND_PERMISSIONS=\(aq{"allow": ["npm *", "git *", "node *"], "deny": ["rm \-rf *", "sudo *"]}\(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{"allow": ["cat *", "echo *"], "allowRedirects": true}\(aq
|
||||
export CLINE_COMMAND_PERMISSIONS=\(aq{\(dqallow\(dq: [\(dqcat *\(dq, \(dqecho *\(dq], \(dqallowRedirects\(dq: true}\(aq
|
||||
.EE
|
||||
.RE
|
||||
.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
|
||||
.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
|
||||
@@ -383,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.
|
||||
|
||||
+191
-245
@@ -2,378 +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**
|
||||
|
||||
**-w**, **\--workspace** *path*
|
||||
|
||||
: Additional workspace paths. Can be specified multiple times to include multiple directories. The current working directory is always included as the first workspace. Example: cline -w /path/to/other/project "refactor shared code"
|
||||
|
||||
# GLOBAL OPTIONS
|
||||
|
||||
These options apply to all subcommands:
|
||||
|
||||
**-F**, **\--output-format** *format*
|
||||
|
||||
: Output format. Options: **rich** (default), **json**, **plain**
|
||||
|
||||
When you use **-F json**, the CLI prints each client message as JSON.
|
||||
|
||||
Each message is a **ClineMessage** object.
|
||||
|
||||
Required fields:
|
||||
|
||||
- **type**: "ask" or "say"
|
||||
- **text**: message text
|
||||
- **ts**: Unix epoch timestamp in milliseconds
|
||||
|
||||
Optional fields (omitted when empty):
|
||||
|
||||
- **reasoning**: reasoning text
|
||||
- **say**: say subtype (present when type is "say")
|
||||
- **ask**: ask subtype (present when type is "ask")
|
||||
- **partial**: streaming flag
|
||||
- **images**: list of image URIs
|
||||
- **files**: list of file paths
|
||||
- **lastCheckpointHash**: git checkpoint hash
|
||||
- **isCheckpointCheckedOut**: checkpoint checkout flag
|
||||
- **isOperationOutsideWorkspace**: workspace safety flag
|
||||
|
||||
**-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 npm 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 recent tasks
|
||||
cline history
|
||||
|
||||
# Show more tasks with pagination
|
||||
cline history -n 20 -p 2
|
||||
```
|
||||
|
||||
## Authentication
|
||||
|
||||
```bash
|
||||
# List previous tasks
|
||||
cline task list
|
||||
# Interactive authentication wizard
|
||||
cline auth
|
||||
|
||||
# Resume a previous task
|
||||
cline task open 1760501486669
|
||||
# Quick setup for Anthropic
|
||||
cline auth -p anthropic -k sk-ant-api-xxxxx
|
||||
|
||||
# View conversation history
|
||||
cline task view
|
||||
# Quick setup for OpenAI
|
||||
cline auth -p openai-native -k sk-xxxxx -m gpt-4o
|
||||
|
||||
# Start interactive chat with this task
|
||||
cline task chat
|
||||
# OpenAI-compatible provider with custom base URL
|
||||
cline auth -p openai -k your-api-key -b https://api.example.com/v1
|
||||
```
|
||||
|
||||
# ENVIRONMENT
|
||||
|
||||
**CLINE_COMMAND_PERMISSIONS**
|
||||
**CLINE_DIR** : Override the default configuration directory. When set, Cline stores all data in this directory instead of `~/.cline/data/`.
|
||||
|
||||
: JSON configuration for restricting which shell commands Cline can execute. When set, commands are validated against allow/deny patterns before execution. When not set, all commands are allowed.
|
||||
**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}`
|
||||
Format: `{"allow": ["pattern1", "pattern2"], "deny": ["pattern3"], "allowRedirects": true}`
|
||||
|
||||
**Fields:**
|
||||
**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.
|
||||
- **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:**
|
||||
**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
|
||||
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:**
|
||||
**Examples:**
|
||||
|
||||
```bash
|
||||
# Allow only npm and git commands.
|
||||
export CLINE_COMMAND_PERMISSIONS='{"allow": ["npm *", "git *"]}'
|
||||
```bash
|
||||
# Allow only npm and git commands.
|
||||
export CLINE_COMMAND_PERMISSIONS='{"allow": ["npm *", "git *"]}'
|
||||
|
||||
# Allow development commands but deny dangerous ones. Deny not strictly required here since allow is set.
|
||||
export CLINE_COMMAND_PERMISSIONS='{"allow": ["npm *", "git *", "node *"], "deny": ["rm -rf *", "sudo *"]}'
|
||||
# Allow development commands but deny dangerous ones. Deny not strictly required here since allow is set.
|
||||
export CLINE_COMMAND_PERMISSIONS='{"allow": ["npm *", "git *", "node *"], "deny": ["rm -rf *", "sudo *"]}'
|
||||
|
||||
# Allow file operations with redirects
|
||||
export CLINE_COMMAND_PERMISSIONS='{"allow": ["cat *", "echo *"], "allowRedirects": true}'
|
||||
```
|
||||
# Allow file operations with redirects
|
||||
export CLINE_COMMAND_PERMISSIONS='{"allow": ["cat *", "echo *"], "allowRedirects": true}'
|
||||
```
|
||||
|
||||
# ARCHITECTURE
|
||||
|
||||
Cline operates on a three-layer architecture:
|
||||
# CONFIGURATION FILES
|
||||
|
||||
**Presentation Layer**
|
||||
```
|
||||
~/.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
|
||||
```
|
||||
|
||||
: User interfaces (CLI, VSCode, JetBrains) that connect to Cline Core via gRPC
|
||||
View logs with `cline dev log`.
|
||||
|
||||
**Cline Core**
|
||||
|
||||
: The autonomous agent service handling task management, AI model integration, state management, tool orchestration, and real-time streaming updates
|
||||
|
||||
**Host Provider Layer**
|
||||
|
||||
: Environment-specific integrations (VSCode APIs, JetBrains APIs, shell APIs) that Cline Core uses to interact with the host system
|
||||
|
||||
# BUGS
|
||||
|
||||
@@ -385,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
|
||||
|
||||
|
||||
Generated
+2950
File diff suppressed because it is too large
Load Diff
+45
-31
@@ -1,27 +1,30 @@
|
||||
{
|
||||
"name": "cline",
|
||||
"version": "1.0.10",
|
||||
"version": "2.0.0",
|
||||
"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"
|
||||
],
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
"scripts": {
|
||||
"prepublishOnly": "npm run build:production",
|
||||
"package:brew": "node ./scripts/update-brew-formula.mts",
|
||||
"package": "npm pack --pack-destination ./dist",
|
||||
"build": "node esbuild.mts",
|
||||
"build:production": "node esbuild.mts --production",
|
||||
"watch": "node esbuild.mts --watch",
|
||||
"dev": "IS_DEV=true && npm run link && npm run watch ; npm run unlink",
|
||||
"clean": "rimraf dist",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"link": "npm run build && npm link",
|
||||
"unlink": "npm unlink -g cline",
|
||||
"test": "vitest",
|
||||
"test:run": "vitest run"
|
||||
},
|
||||
"keywords": [
|
||||
"cline",
|
||||
"claude",
|
||||
@@ -49,20 +52,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,321 +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.Instances.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.CoreAddress)
|
||||
if err := global.KillInstanceByAddress(context.Background(), global.Instances.GetRegistry(), instanceInfo.CoreAddress); 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.CoreAddress)
|
||||
|
||||
// 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 providerList, err := GetProviderConfigurations(ctx); 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,527 +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) (*ProviderListResult, error) {
|
||||
if global.Config.Verbose {
|
||||
fmt.Println("[DEBUG] Retrieving provider configurations from Cline Core")
|
||||
}
|
||||
|
||||
// Get latest state from Cline Core
|
||||
grpcClient, err := global.GetDefaultClient(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get provider configs due to unable to get gRPC client: %w", err)
|
||||
}
|
||||
state, err := grpcClient.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]any
|
||||
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]any)
|
||||
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 := checkCredentialsExists(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: checkCredentialsExists(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
|
||||
hasCredentials := checkCredentialsExists(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: hasCredentials,
|
||||
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
|
||||
}
|
||||
|
||||
// checkCredentialsExists checks if API key field exists in state (never retrieve actual key)
|
||||
func checkCredentialsExists(stateData map[string]interface{}, provider cline.ApiProvider) bool {
|
||||
// Get field mapping from centralized function
|
||||
fields, err := GetProviderFields(provider)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check if the key exists and is not empty
|
||||
if value, ok := stateData[fields.APIKeyField]; ok {
|
||||
if str, ok := value.(string); ok && str != "" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
if value, ok := stateData[fields.UseProfileField]; ok {
|
||||
if hasProfileField, ok := value.(bool); ok && hasProfileField {
|
||||
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]any
|
||||
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]any)
|
||||
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
|
||||
keyFields []string
|
||||
}{
|
||||
{cline.ApiProvider_ANTHROPIC, []string{"apiKey"}},
|
||||
{cline.ApiProvider_OPENAI, []string{"openAiApiKey"}},
|
||||
{cline.ApiProvider_OPENAI_NATIVE, []string{"openAiNativeApiKey"}},
|
||||
{cline.ApiProvider_OPENROUTER, []string{"openRouterApiKey"}},
|
||||
{cline.ApiProvider_XAI, []string{"xaiApiKey"}},
|
||||
{cline.ApiProvider_BEDROCK, []string{"awsAccessKey", "awsUseProfile"}},
|
||||
{cline.ApiProvider_GEMINI, []string{"geminiApiKey"}},
|
||||
{cline.ApiProvider_OLLAMA, []string{"ollamaBaseUrl"}}, // Ollama uses baseUrl instead of API key
|
||||
{cline.ApiProvider_CEREBRAS, []string{"cerebrasApiKey"}},
|
||||
{cline.ApiProvider_HICAP, []string{"hicapApiKey"}},
|
||||
{cline.ApiProvider_NOUSRESEARCH, []string{"nousResearchApiKey"}},
|
||||
}
|
||||
|
||||
for _, providerCheck := range providersToCheck {
|
||||
verboseLog("[DEBUG] Checking for %s key: %s", GetProviderDisplayName(providerCheck.provider), providerCheck.keyFields)
|
||||
for _, keyField := range providerCheck.keyFields {
|
||||
if value, ok := apiConfig[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))
|
||||
break
|
||||
}
|
||||
} else {
|
||||
verboseLog("[DEBUG] Key %s not found", 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,655 +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"
|
||||
UseProfileField string // e.g., "awsUseProfile" (for bedrock) (optional, empty if not applicable)
|
||||
}
|
||||
|
||||
// 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{
|
||||
UseProfileField: "awsUseProfile",
|
||||
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
|
||||
} else if ocaInfo, ok := modelInfo.(*cline.OcaModelInfo); ok {
|
||||
apiConfig.PlanModeOcaModelInfo = ocaInfo
|
||||
apiConfig.ActModeOcaModelInfo = ocaInfo
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
} else if ocaInfo, ok := updates.ModelInfo.(*cline.OcaModelInfo); ok {
|
||||
apiConfig.PlanModeOcaModelInfo = ocaInfo
|
||||
apiConfig.ActModeOcaModelInfo = ocaInfo
|
||||
}
|
||||
}
|
||||
|
||||
// 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,763 +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, modelInfo, 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: modelInfo,
|
||||
}
|
||||
|
||||
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)
|
||||
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)
|
||||
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)
|
||||
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,200 +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 provider for both Plan and Act modes
|
||||
bedrockProvider := cline.ApiProvider_BEDROCK
|
||||
apiConfig.PlanModeApiProvider = &bedrockProvider
|
||||
apiConfig.ActModeApiProvider = &bedrockProvider
|
||||
|
||||
// Set model ID field - this is the primary model ID used by Cline Core
|
||||
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{
|
||||
"planModeApiProvider",
|
||||
"actModeApiProvider",
|
||||
"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.Instances.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,207 +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.Instances != nil {
|
||||
clientAddress = global.Instances.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",
|
||||
"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,197 +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",
|
||||
"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,99 +0,0 @@
|
||||
package display
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/cline/cli/pkg/common"
|
||||
)
|
||||
|
||||
// BannerInfo contains information to display in the session banner
|
||||
type BannerInfo struct {
|
||||
Version string
|
||||
Provider string
|
||||
ModelID string
|
||||
Workdirs []string // workspace directories
|
||||
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+"/"+common.ShortenPath(info.ModelID, 30)))
|
||||
}
|
||||
|
||||
for _, wd := range info.Workdirs {
|
||||
lines = append(lines, dimStyle.Render(common.ShortenPath(wd, 45)))
|
||||
}
|
||||
|
||||
// Checkpoint warning for multi-root workspaces
|
||||
if len(info.Workdirs) > 1 {
|
||||
warningStyle := lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("3")). // Yellow warning color
|
||||
Italic(true)
|
||||
lines = append(lines, "")
|
||||
lines = append(lines, warningStyle.Render("⚠ Checkpoints disabled for multi-root workspaces"))
|
||||
}
|
||||
|
||||
content := lipgloss.JoinVertical(lipgloss.Left, lines...)
|
||||
return boxStyle.Render(content)
|
||||
}
|
||||
@@ -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,144 +0,0 @@
|
||||
package display
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/cline/cli/pkg/cli/types"
|
||||
)
|
||||
|
||||
// HookRenderer renders hook status messages in a CLI-native style.
|
||||
//
|
||||
// Goals:
|
||||
// - Match ToolRenderer’s markdown look
|
||||
// - Keep executions ungrouped
|
||||
// - Render status + high-signal metadata (script paths, error summary)
|
||||
//
|
||||
// Note: hook stdout/stderr currently arrives as separate `hook_output_stream` messages.
|
||||
// The CLI suppresses those by default and prints them only in --verbose mode.
|
||||
// Future work could group streamed output under the corresponding hook block.
|
||||
//
|
||||
// It returns markdown (or rendered markdown when enabled); callers should print the
|
||||
// returned string.
|
||||
|
||||
type HookRenderer struct {
|
||||
mdRenderer *MarkdownRenderer
|
||||
outputFormat string
|
||||
}
|
||||
|
||||
func NewHookRenderer(mdRenderer *MarkdownRenderer, outputFormat string) *HookRenderer {
|
||||
return &HookRenderer{mdRenderer: mdRenderer, outputFormat: outputFormat}
|
||||
}
|
||||
|
||||
func (hr *HookRenderer) RenderHookStatus(h types.HookMessage) string {
|
||||
statusText := strings.TrimSpace(h.Status)
|
||||
if statusText == "" {
|
||||
statusText = "unknown"
|
||||
}
|
||||
|
||||
// Header: aligned with ToolRenderer’s phrasing so transcripts scan consistently.
|
||||
// Example: "### Cline hook completed: PreToolUse (tool: read_file) (exit 0)"
|
||||
var headerBuilder strings.Builder
|
||||
headerBuilder.WriteString(fmt.Sprintf("### Cline hook %s: %s", statusText, h.HookName))
|
||||
if h.ToolName != "" {
|
||||
headerBuilder.WriteString(" ")
|
||||
headerBuilder.WriteString(fmt.Sprintf("(tool: %s)", h.ToolName))
|
||||
}
|
||||
if statusText == "failed" && h.ExitCode != 0 {
|
||||
headerBuilder.WriteString(" ")
|
||||
headerBuilder.WriteString(fmt.Sprintf("(exit %d)", h.ExitCode))
|
||||
}
|
||||
header := headerBuilder.String()
|
||||
|
||||
var lines []string
|
||||
lines = append(lines, header)
|
||||
|
||||
// Pending tool info (PreToolUse): show one high-signal line directly under the header.
|
||||
if h.PendingToolInfo != nil {
|
||||
if pending := hr.formatPendingToolInfo(h.PendingToolInfo); pending != "" {
|
||||
lines = append(lines, fmt.Sprintf("- Pending: %s", pending))
|
||||
}
|
||||
}
|
||||
|
||||
// Script paths: one per line.
|
||||
paths := make([]string, 0, len(h.ScriptPaths))
|
||||
for _, p := range h.ScriptPaths {
|
||||
p = strings.TrimSpace(p)
|
||||
if p != "" {
|
||||
paths = append(paths, p)
|
||||
}
|
||||
}
|
||||
|
||||
if len(paths) == 0 {
|
||||
// Fallback when no script paths are provided.
|
||||
lines = append(lines, "- *(no hook scripts found)*")
|
||||
} else {
|
||||
for _, p := range paths {
|
||||
lines = append(lines, fmt.Sprintf("- Running hook: `%s`", p))
|
||||
}
|
||||
}
|
||||
|
||||
// On failure, show a minimal summary (full stderr reserved for verbose).
|
||||
if statusText == "failed" && h.Error != nil {
|
||||
if msg := strings.TrimSpace(h.Error.Message); msg != "" {
|
||||
lines = append(lines, fmt.Sprintf("- Error: %s", msg))
|
||||
}
|
||||
// If we have a specific script path, include it as a hint.
|
||||
if sp := strings.TrimSpace(h.Error.ScriptPath); sp != "" {
|
||||
lines = append(lines, fmt.Sprintf("- Script: `%s`", sp))
|
||||
}
|
||||
}
|
||||
|
||||
markdown := strings.Join(lines, "\n")
|
||||
return hr.renderMarkdown(markdown)
|
||||
}
|
||||
|
||||
func (hr *HookRenderer) formatPendingToolInfo(info *types.ToolInfo) string {
|
||||
if info == nil {
|
||||
return ""
|
||||
}
|
||||
tool := strings.TrimSpace(info.Tool)
|
||||
if tool == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Keep this intentionally compact and readable.
|
||||
// Format: "<tool> <identifier>" where identifier is the most relevant param.
|
||||
var ident string
|
||||
switch {
|
||||
case strings.TrimSpace(info.Path) != "":
|
||||
ident = strings.TrimSpace(info.Path)
|
||||
case strings.TrimSpace(info.Command) != "":
|
||||
ident = strings.TrimSpace(info.Command)
|
||||
case strings.TrimSpace(info.Url) != "":
|
||||
ident = strings.TrimSpace(info.Url)
|
||||
case strings.TrimSpace(info.McpTool) != "" && strings.TrimSpace(info.McpServer) != "":
|
||||
ident = fmt.Sprintf("%s %s", strings.TrimSpace(info.McpServer), strings.TrimSpace(info.McpTool))
|
||||
case strings.TrimSpace(info.ResourceUri) != "":
|
||||
ident = strings.TrimSpace(info.ResourceUri)
|
||||
case strings.TrimSpace(info.Regex) != "":
|
||||
ident = strings.TrimSpace(info.Regex)
|
||||
default:
|
||||
ident = ""
|
||||
}
|
||||
|
||||
if ident != "" {
|
||||
return fmt.Sprintf("%s %s", tool, ident)
|
||||
}
|
||||
return tool
|
||||
}
|
||||
|
||||
func (hr *HookRenderer) renderMarkdown(markdown string) string {
|
||||
// Align with ToolRenderer: in plain mode or non-TTY, return markdown as-is.
|
||||
if hr.outputFormat == "plain" || !isTTY() {
|
||||
return markdown
|
||||
}
|
||||
if hr.mdRenderer == nil {
|
||||
return markdown
|
||||
}
|
||||
rendered, err := hr.mdRenderer.Render(markdown)
|
||||
if err != nil {
|
||||
return markdown
|
||||
}
|
||||
return rendered
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
package display
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/cline/cli/pkg/cli/types"
|
||||
)
|
||||
|
||||
func TestHookRenderer_RenderHookStatus_FailedShowsErrorAndScript(t *testing.T) {
|
||||
hr := NewHookRenderer(nil, "plain")
|
||||
|
||||
msg := hr.RenderHookStatus(types.HookMessage{
|
||||
HookName: "PreToolUse",
|
||||
ToolName: "execute_command",
|
||||
Status: "failed",
|
||||
ExitCode: 2,
|
||||
ScriptPaths: []string{"repo/.clinerules/hooks/PreToolUse"},
|
||||
Error: &types.HookError{
|
||||
Message: "boom",
|
||||
ScriptPath: "repo/.clinerules/hooks/PreToolUse",
|
||||
},
|
||||
})
|
||||
|
||||
if !strings.Contains(msg, "### Cline hook failed: PreToolUse") {
|
||||
t.Fatalf("expected header in rendered output, got: %q", msg)
|
||||
}
|
||||
if !strings.Contains(msg, "- Error: boom") {
|
||||
t.Fatalf("expected error line in rendered output, got: %q", msg)
|
||||
}
|
||||
if !strings.Contains(msg, "- Script: `repo/.clinerules/hooks/PreToolUse`") {
|
||||
t.Fatalf("expected script line in rendered output, got: %q", msg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHookRenderer_RenderHookStatus_PendingToolInfoAppearsDirectlyUnderHeader(t *testing.T) {
|
||||
hr := NewHookRenderer(nil, "plain")
|
||||
|
||||
msg := hr.RenderHookStatus(types.HookMessage{
|
||||
HookName: "PreToolUse",
|
||||
ToolName: "write_to_file",
|
||||
Status: "running",
|
||||
PendingToolInfo: &types.ToolInfo{
|
||||
Tool: "write_to_file",
|
||||
Path: "src/foo.ts",
|
||||
},
|
||||
ScriptPaths: []string{"repo/.clinerules/hooks/PreToolUse"},
|
||||
})
|
||||
|
||||
header := "### Cline hook running: PreToolUse"
|
||||
pending := "- Pending: write_to_file src/foo.ts"
|
||||
runningHook := "- Running hook: `repo/.clinerules/hooks/PreToolUse`"
|
||||
|
||||
headerIdx := strings.Index(msg, header)
|
||||
if headerIdx == -1 {
|
||||
t.Fatalf("expected header %q in output, got: %q", header, msg)
|
||||
}
|
||||
pendingIdx := strings.Index(msg, pending)
|
||||
if pendingIdx == -1 {
|
||||
t.Fatalf("expected pending line %q in output, got: %q", pending, msg)
|
||||
}
|
||||
runningIdx := strings.Index(msg, runningHook)
|
||||
if runningIdx == -1 {
|
||||
t.Fatalf("expected running hook line %q in output, got: %q", runningHook, msg)
|
||||
}
|
||||
if !(headerIdx < pendingIdx && pendingIdx < runningIdx) {
|
||||
t.Fatalf("expected header < pending < runningHook ordering, got indexes header=%d pending=%d running=%d\nfull=%q", headerIdx, pendingIdx, runningIdx, msg)
|
||||
}
|
||||
}
|
||||
@@ -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,222 +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()
|
||||
// Skip empty headers.
|
||||
if strings.TrimSpace(header) != "" {
|
||||
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.SayTypeHookStatus) {
|
||||
// Hooks are rendered via the state stream; nothing to render here.
|
||||
bodyContent = ""
|
||||
} 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 string(types.SayTypeHookStatus):
|
||||
// Hooks are rendered from the state stream; don’t emit a partial-stream header.
|
||||
return ""
|
||||
|
||||
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,20 +0,0 @@
|
||||
package display
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/cline/cli/pkg/cli/types"
|
||||
)
|
||||
|
||||
func TestStreamingSegment_generateRichHeader_HookIsEmpty(t *testing.T) {
|
||||
ss := &StreamingSegment{
|
||||
sayType: string(types.SayTypeHookStatus),
|
||||
prefix: "HOOK",
|
||||
msg: &types.ClineMessage{},
|
||||
}
|
||||
|
||||
header := ss.generateRichHeader()
|
||||
if header != "" {
|
||||
t.Fatalf("expected empty header for hook segments to avoid double-render, got: %q", header)
|
||||
}
|
||||
}
|
||||
@@ -1,152 +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()
|
||||
|
||||
// Render hooks from the state stream only (not partial stream) to avoid duplicates.
|
||||
//
|
||||
// Rationale: hook status messages are often updated/reordered by the backend (e.g. PreToolUse
|
||||
// hooks are moved above the corresponding tool message). The state stream represents the
|
||||
// authoritative, “final” message ordering, while the partial stream is best-effort for
|
||||
// incremental display.
|
||||
//
|
||||
// Only suppress *partial* hook messages; complete ones still flow through dedupe.
|
||||
if msg.Partial && msg.Say == string(types.SayTypeHookStatus) {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 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"
|
||||
case string(types.SayTypeHookStatus):
|
||||
return "HOOK"
|
||||
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.Print(rendered)
|
||||
return nil
|
||||
}
|
||||
@@ -1,471 +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.ToolTypeWebSearch):
|
||||
if verbTense == "wants to" {
|
||||
action = "wants to search for"
|
||||
} else {
|
||||
action = "is searching for"
|
||||
}
|
||||
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.ToolTypeWebSearch), string(types.ToolTypeFileDeleted):
|
||||
// No preview for read/fetch/search 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),
|
||||
string(types.ToolTypeWebSearch):
|
||||
// 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()
|
||||
}
|
||||
|
||||
func (tr *ToolRenderer) RenderCommandPermissionDenied(command string) string {
|
||||
command = strings.TrimSpace(command)
|
||||
rendered := tr.renderMarkdown("### Command was denied")
|
||||
message := fmt.Sprintf("Cline does not have permission to execute this command: `%s`", command)
|
||||
return fmt.Sprintf("\n%s\n\n%s\n", rendered, message)
|
||||
}
|
||||
|
||||
// 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,302 +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 ""
|
||||
}
|
||||
|
||||
// ParseWebSearch formats webSearch tool results
|
||||
func (p *ToolResultParser) ParseWebSearch(content, query 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)
|
||||
case "webSearch":
|
||||
return p.ParseWebSearch(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
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/cline/cli/pkg/cli/display"
|
||||
"github.com/cline/cli/pkg/cli/global"
|
||||
"github.com/cline/cli/pkg/cli/terminal"
|
||||
"github.com/cline/cli/pkg/cli/updater"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// NewDoctorCommand creates the doctor command
|
||||
func NewDoctorCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "doctor",
|
||||
Aliases: []string{"d"},
|
||||
Short: "Check system health and diagnose problems",
|
||||
Long: `Check the health of your Cline CLI installation and diagnose problems.
|
||||
|
||||
Currently this command performs the following checks and fixes:
|
||||
|
||||
Terminal Configuration:
|
||||
- Detects your terminal emulator (VS Code, Cursor, Ghostty, Kitty, WezTerm, Alacritty)
|
||||
- Configures shift+enter to insert newlines in multiline input
|
||||
- Creates backups before modifying configuration files
|
||||
- Supported terminals: VS Code, Cursor, Ghostty, Kitty, WezTerm, Alacritty
|
||||
- iTerm2 works by default, Terminal.app requires manual setup
|
||||
|
||||
CLI Updates:
|
||||
- Checks npm registry for the latest version
|
||||
- Automatically installs updates via npm if available
|
||||
- Respects NO_AUTO_UPDATE environment variable
|
||||
- Skipped in CI environments
|
||||
|
||||
Note: Future versions will include additional health checks for Node.js version,
|
||||
npm availability, Cline Core connectivity, database integrity, and more.`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return runDoctorChecks()
|
||||
},
|
||||
}
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
// runDoctorChecks performs all doctor diagnostics and configuration
|
||||
func runDoctorChecks() error {
|
||||
renderer := display.NewRenderer(global.Config.OutputFormat)
|
||||
|
||||
fmt.Printf("\n%s\n\n", renderer.Bold("Cline Doctor - System Health Check"))
|
||||
|
||||
// Configure terminal keybindings (terminal.go prints its own status)
|
||||
fmt.Printf("%s\n\n", renderer.Dim("━━━ Terminal Configuration ━━━"))
|
||||
terminal.SetupKeyboardSync()
|
||||
|
||||
// Check for updates (updater.go prints its own status)
|
||||
fmt.Printf("\n%s\n\n", renderer.Dim("━━━ CLI Updates ━━━"))
|
||||
updater.CheckAndUpdateSync(global.Config.Verbose, true)
|
||||
|
||||
// Summary
|
||||
fmt.Printf("\n%s\n", renderer.Dim("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"))
|
||||
fmt.Printf("\n%s\n\n", renderer.SuccessWithCheckmark("Health check complete"))
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -1,510 +0,0 @@
|
||||
package global
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/cline/cli/pkg/common"
|
||||
"github.com/cline/grpc-go/cline"
|
||||
)
|
||||
|
||||
type ClineInstances struct {
|
||||
registry *InstanceRegistry
|
||||
}
|
||||
|
||||
// NewClineInstances creates a new ClineInstances instance
|
||||
func NewClineInstances(configPath string) *ClineInstances {
|
||||
registry := NewInstanceRegistry(configPath)
|
||||
return &ClineInstances{
|
||||
registry: registry,
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize performs cleanup of stale instances
|
||||
func (c *ClineInstances) Initialize(ctx context.Context) error {
|
||||
// Clean up stale entries (direct SQLite operations)
|
||||
_ = c.registry.CleanupStaleInstances(ctx)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// StartNewInstance starts a new Cline instance and waits for cline-core to self-register
|
||||
// An "instance" is a pair of cline-core and cline-host processes
|
||||
func (c *ClineInstances) StartNewInstance(ctx context.Context, workspaces ...string) (*common.CoreInstanceInfo, error) {
|
||||
// Find available ports
|
||||
corePort, hostPort, err := common.FindAvailablePortPair()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to find available ports: %w", err)
|
||||
}
|
||||
|
||||
if Config.Verbose {
|
||||
fmt.Printf("Starting new Cline instance on ports %d (core) and %d (host bridge)\n", corePort, hostPort)
|
||||
}
|
||||
|
||||
// Start cline-host first
|
||||
hostCmd, err := startClineHost(hostPort, workspaces)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to start cline-host: %w", err)
|
||||
}
|
||||
|
||||
// Start cline-core (it will register itself in SQLite locks database)
|
||||
coreCmd, err := startClineCore(corePort, hostPort)
|
||||
if err != nil {
|
||||
// Clean up host process if core fails to start
|
||||
if hostCmd != nil && hostCmd.Process != nil {
|
||||
hostCmd.Process.Kill()
|
||||
}
|
||||
return nil, fmt.Errorf("failed to start cline-core: %w", err)
|
||||
}
|
||||
|
||||
fullAddress := fmt.Sprintf("localhost:%d", corePort)
|
||||
if Config.Verbose {
|
||||
fmt.Println("Waiting for services to start and self-register in SQLite...")
|
||||
}
|
||||
|
||||
// Use RetryOperation to wait for instance to be ready
|
||||
var instance *common.CoreInstanceInfo
|
||||
err = common.RetryOperation(12, 5*time.Second, func() error {
|
||||
// Check if instance registered itself in SQLite
|
||||
foundInstance, err := c.registry.GetInstance(fullAddress)
|
||||
if err != nil || foundInstance == nil {
|
||||
return fmt.Errorf("instance not found in registry: %v", err)
|
||||
}
|
||||
|
||||
// Verify instance is healthy
|
||||
if !common.IsInstanceHealthy(ctx, fullAddress) {
|
||||
return fmt.Errorf("instance is registered but not healthy")
|
||||
}
|
||||
|
||||
// Success - store the instance for return
|
||||
instance = foundInstance
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
// Clean up both processes on failure
|
||||
if coreCmd != nil && coreCmd.Process != nil {
|
||||
fmt.Printf("Cleaning up core process (PID: %d)\n", coreCmd.Process.Pid)
|
||||
coreCmd.Process.Kill()
|
||||
}
|
||||
if hostCmd != nil && hostCmd.Process != nil {
|
||||
fmt.Printf("Cleaning up host process (PID: %d)\n", hostCmd.Process.Pid)
|
||||
hostCmd.Process.Kill()
|
||||
}
|
||||
return nil, fmt.Errorf("failed to start instance: %w", err)
|
||||
}
|
||||
|
||||
if Config.Verbose {
|
||||
fmt.Println("Services started and registered successfully!")
|
||||
fmt.Printf(" Address: %s\n", instance.CoreAddress)
|
||||
fmt.Printf(" Core Port: %d\n", instance.CorePort())
|
||||
fmt.Printf(" Host Bridge Port: %d\n", instance.HostPort())
|
||||
fmt.Printf(" Process PID: %d\n", coreCmd.Process.Pid)
|
||||
}
|
||||
|
||||
// If this is the first instance, set it as default
|
||||
instances := c.registry.ListInstances()
|
||||
if err := c.registry.EnsureDefaultInstance(instances); err != nil {
|
||||
if Config.Verbose {
|
||||
fmt.Printf("Warning: Failed to set default instance: %v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
return instance, nil
|
||||
}
|
||||
|
||||
// StartNewInstanceAtPort starts a new Cline instance at the specified port and waits for self-registration
|
||||
func (c *ClineInstances) StartNewInstanceAtPort(ctx context.Context, corePort int, workspaces ...string) (*common.CoreInstanceInfo, error) {
|
||||
// Find available host port (core port + 1000)
|
||||
hostPort := corePort + 1000
|
||||
coreAddress := fmt.Sprintf("localhost:%d", corePort)
|
||||
|
||||
// Check if the specified core port is available
|
||||
if common.IsInstanceHealthy(ctx, coreAddress) {
|
||||
return nil, fmt.Errorf("port %d is already in use by another Cline instance", corePort)
|
||||
}
|
||||
|
||||
if Config.Verbose {
|
||||
fmt.Printf("Starting new Cline instance on ports %d (core) and %d (host bridge)\n", corePort, hostPort)
|
||||
}
|
||||
|
||||
// Start cline-host first
|
||||
hostCmd, err := startClineHost(hostPort, workspaces)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to start cline-host: %w", err)
|
||||
}
|
||||
|
||||
// Start cline-core (it will register itself in SQLite locks database)
|
||||
coreCmd, err := startClineCore(corePort, hostPort)
|
||||
if err != nil {
|
||||
// Clean up host process if core fails to start
|
||||
if hostCmd != nil && hostCmd.Process != nil {
|
||||
hostCmd.Process.Kill()
|
||||
}
|
||||
return nil, fmt.Errorf("failed to start cline-core: %w", err)
|
||||
}
|
||||
|
||||
fullAddress := fmt.Sprintf("localhost:%d", corePort)
|
||||
if Config.Verbose {
|
||||
fmt.Println("Waiting for services to start and self-register in SQLite...")
|
||||
}
|
||||
|
||||
// Use RetryOperation to wait for instance to be ready
|
||||
var instance *common.CoreInstanceInfo
|
||||
err = common.RetryOperation(12, 5*time.Second, func() error {
|
||||
// Check if instance registered itself in SQLite
|
||||
foundInstance, err := c.registry.GetInstance(fullAddress)
|
||||
if err != nil || foundInstance == nil {
|
||||
return fmt.Errorf("instance not found in registry: %v", err)
|
||||
}
|
||||
|
||||
// Verify instance is healthy
|
||||
if !common.IsInstanceHealthy(ctx, fullAddress) {
|
||||
return fmt.Errorf("instance is registered but not healthy")
|
||||
}
|
||||
|
||||
// Success - store the instance for return
|
||||
instance = foundInstance
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
// Clean up both processes on failure
|
||||
if coreCmd != nil && coreCmd.Process != nil {
|
||||
fmt.Printf("Cleaning up core process (PID: %d)\n", coreCmd.Process.Pid)
|
||||
coreCmd.Process.Kill()
|
||||
}
|
||||
if hostCmd != nil && hostCmd.Process != nil {
|
||||
fmt.Printf("Cleaning up host process (PID: %d)\n", hostCmd.Process.Pid)
|
||||
hostCmd.Process.Kill()
|
||||
}
|
||||
return nil, fmt.Errorf("failed to start instance at port %d: %w", corePort, err)
|
||||
}
|
||||
|
||||
if Config.Verbose {
|
||||
fmt.Println("Services started and registered successfully!")
|
||||
fmt.Printf(" Address: %s\n", instance.CoreAddress)
|
||||
fmt.Printf(" Core Port: %d\n", instance.CorePort())
|
||||
fmt.Printf(" Host Bridge Port: %d\n", instance.HostPort())
|
||||
fmt.Printf(" Process PID: %d\n", coreCmd.Process.Pid)
|
||||
}
|
||||
|
||||
// If this is the first instance, set it as default
|
||||
instances := c.registry.ListInstances()
|
||||
if err := c.registry.EnsureDefaultInstance(instances); err != nil {
|
||||
if Config.Verbose {
|
||||
fmt.Printf("Warning: Failed to set default instance: %v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
return instance, nil
|
||||
}
|
||||
|
||||
// GetRegistry returns the client registry
|
||||
func (c *ClineInstances) GetRegistry() *InstanceRegistry {
|
||||
return c.registry
|
||||
}
|
||||
|
||||
// EnsureInstanceAtAddress ensures an instance exists at the given address, starting one if needed
|
||||
func (c *ClineInstances) EnsureInstanceAtAddress(ctx context.Context, address string) error {
|
||||
// Expect host:port everywhere
|
||||
normalized := address
|
||||
if normalized == "" {
|
||||
normalized = fmt.Sprintf("localhost:%d", common.DEFAULT_CLINE_CORE_PORT)
|
||||
}
|
||||
|
||||
// Check if instance already exists at this address
|
||||
if c.registry.HasInstanceAtAddress(normalized) {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Parse host:port
|
||||
host, port, err := common.ParseHostPort(normalized)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid address format %s", address)
|
||||
}
|
||||
|
||||
// Use IPv6-compatible localhost detection
|
||||
if common.IsLocalAddress(host) {
|
||||
_, err := c.StartNewInstanceAtPort(ctx, port)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to start new instance at %s: %w", normalized, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
return fmt.Errorf("cannot start remote instance at %s", normalized)
|
||||
}
|
||||
|
||||
func startClineHost(hostPort int, workspaces []string) (*exec.Cmd, error) {
|
||||
if Config.Verbose {
|
||||
fmt.Printf("Starting cline-host on port %d\n", hostPort)
|
||||
}
|
||||
|
||||
// Get the directory where the cline binary is located
|
||||
execPath, err := os.Executable()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get executable path: %w", err)
|
||||
}
|
||||
binDir := path.Dir(execPath)
|
||||
clineHostPath := path.Join(binDir, "cline-host")
|
||||
|
||||
// Build command arguments
|
||||
args := []string{
|
||||
"--verbose",
|
||||
"--port", fmt.Sprintf("%d", hostPort),
|
||||
}
|
||||
|
||||
for _, ws := range workspaces {
|
||||
args = append(args, "--workspace", ws)
|
||||
}
|
||||
|
||||
// Start the cline-host process
|
||||
cmd := exec.Command(clineHostPath, args...)
|
||||
|
||||
// Create logs directory in ~/.cline/logs
|
||||
logsDir := path.Join(Config.ConfigPath, "logs")
|
||||
if err := os.MkdirAll(logsDir, 0755); err != nil {
|
||||
return nil, fmt.Errorf("failed to create logs directory: %w", err)
|
||||
}
|
||||
|
||||
// Create timestamped log file
|
||||
timestamp := time.Now().Format("2006-01-02-15-04-05")
|
||||
logFileName := fmt.Sprintf("cline-host-%s-localhost-%d.log", timestamp, hostPort)
|
||||
logFilePath := path.Join(logsDir, logFileName)
|
||||
logFile, err := os.Create(logFilePath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create log file: %w", err)
|
||||
}
|
||||
|
||||
// Redirect stdout and stderr to log file
|
||||
cmd.Stdout = logFile
|
||||
cmd.Stderr = logFile
|
||||
|
||||
// Put the child process in a new process group so Ctrl+C doesn't kill it
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{
|
||||
Setpgid: true,
|
||||
}
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
logFile.Close()
|
||||
return nil, fmt.Errorf("failed to start cline-host: %w", err)
|
||||
}
|
||||
|
||||
if Config.Verbose {
|
||||
fmt.Printf("Started cline-host (PID: %d)\n", cmd.Process.Pid)
|
||||
fmt.Printf("Logging cline-host output to: %s\n", logFilePath)
|
||||
}
|
||||
return cmd, nil
|
||||
}
|
||||
|
||||
// KillInstanceByAddress kills a Cline instance by its address
|
||||
func KillInstanceByAddress(ctx context.Context, registry *InstanceRegistry, address string) error {
|
||||
// Check if the instance exists in the registry
|
||||
_, err := registry.GetInstance(address)
|
||||
if err != nil {
|
||||
return fmt.Errorf("instance %s not found in registry", address)
|
||||
}
|
||||
|
||||
if Config.Verbose {
|
||||
fmt.Printf("Killing instance: %s\n", address)
|
||||
}
|
||||
|
||||
// Get gRPC client and process info
|
||||
client, err := registry.GetClient(ctx, address)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to connect to instance %s: %w", address, err)
|
||||
}
|
||||
|
||||
processInfo, err := client.State.GetProcessInfo(ctx, &cline.EmptyRequest{})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get process info for instance %s: %w", address, err)
|
||||
}
|
||||
|
||||
pid := int(processInfo.ProcessId)
|
||||
if Config.Verbose {
|
||||
fmt.Printf("Terminating process PID %d...\n", pid)
|
||||
}
|
||||
|
||||
// Kill the process
|
||||
if err := syscall.Kill(pid, syscall.SIGTERM); err != nil {
|
||||
return fmt.Errorf("failed to kill process %d: %w", pid, err)
|
||||
}
|
||||
|
||||
// Wait for the instance to remove itself from registry
|
||||
if Config.Verbose {
|
||||
fmt.Printf("Waiting for instance to clean up registry entry...\n")
|
||||
}
|
||||
for range 5 {
|
||||
time.Sleep(1 * time.Second)
|
||||
if !registry.HasInstanceAtAddress(address) {
|
||||
if Config.Verbose {
|
||||
fmt.Printf("Instance %s successfully killed and removed from registry.\n", address)
|
||||
}
|
||||
|
||||
// Update default instance if needed
|
||||
instances, err := registry.ListInstancesCleaned(ctx)
|
||||
if err == nil && len(instances) > 0 {
|
||||
// ensureDefaultInstance logic will handle setting a new default
|
||||
defaultInstance := registry.GetDefaultInstance()
|
||||
if defaultInstance == address || defaultInstance == "" {
|
||||
if len(instances) > 0 {
|
||||
if err := registry.SetDefaultInstance(instances[0].CoreAddress); err == nil {
|
||||
if Config.Verbose {
|
||||
fmt.Printf("Updated default instance to: %s\n", instances[0].CoreAddress)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
return fmt.Errorf("instance killed but failed to remove itself from registry within 5 seconds")
|
||||
}
|
||||
|
||||
func startClineCore(corePort, hostPort int) (*exec.Cmd, error) {
|
||||
if Config.Verbose {
|
||||
fmt.Printf("Starting cline-core on port %d (with hostbridge on %d)\n", corePort, hostPort)
|
||||
}
|
||||
|
||||
// Get the executable path and resolve symlinks (for npm global installs)
|
||||
execPath, err := os.Executable()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get executable path: %w", err)
|
||||
}
|
||||
|
||||
// Resolve symlinks to get the real path
|
||||
// For npm global installs, execPath might be a symlink like:
|
||||
// /opt/homebrew/bin/cline -> /opt/homebrew/lib/node_modules/cline/bin/cline
|
||||
realPath, err := filepath.EvalSymlinks(execPath)
|
||||
if err != nil {
|
||||
// If we can't resolve symlinks, fall back to the original path
|
||||
realPath = execPath
|
||||
if Config.Verbose {
|
||||
fmt.Printf("Warning: Could not resolve symlinks for %s: %v\n", execPath, err)
|
||||
}
|
||||
}
|
||||
|
||||
binDir := path.Dir(realPath)
|
||||
installDir := path.Dir(binDir)
|
||||
clineCorePath := path.Join(installDir, "cline-core.js")
|
||||
|
||||
if Config.Verbose {
|
||||
fmt.Printf("Executable path: %s\n", execPath)
|
||||
if realPath != execPath {
|
||||
fmt.Printf("Real path (after resolving symlinks): %s\n", realPath)
|
||||
}
|
||||
fmt.Printf("Bin directory: %s\n", binDir)
|
||||
fmt.Printf("Install directory: %s\n", installDir)
|
||||
fmt.Printf("Looking for cline-core.js at: %s\n", clineCorePath)
|
||||
}
|
||||
|
||||
// Check if cline-core.js exists at the primary location
|
||||
var finalClineCorePath string
|
||||
var finalInstallDir string
|
||||
if _, err := os.Stat(clineCorePath); os.IsNotExist(err) {
|
||||
// Development mode: Try ../../dist-standalone/cline-core.js
|
||||
// This handles the case where we're running from cli/bin/cline
|
||||
devClineCorePath := path.Join(binDir, "..", "..", "dist-standalone", "cline-core.js")
|
||||
devInstallDir := path.Join(binDir, "..", "..", "dist-standalone")
|
||||
|
||||
if Config.Verbose {
|
||||
fmt.Printf("Primary location not found, trying development path: %s\n", devClineCorePath)
|
||||
}
|
||||
|
||||
if _, err := os.Stat(devClineCorePath); os.IsNotExist(err) {
|
||||
return nil, fmt.Errorf("cline-core.js not found at '%s' or '%s'. Please ensure you're running from the correct location or reinstall with 'npm install -g cline'", clineCorePath, devClineCorePath)
|
||||
}
|
||||
|
||||
finalClineCorePath = devClineCorePath
|
||||
finalInstallDir = devInstallDir
|
||||
if Config.Verbose {
|
||||
fmt.Printf("Using development mode: cline-core.js found at %s\n", finalClineCorePath)
|
||||
}
|
||||
} else {
|
||||
finalClineCorePath = clineCorePath
|
||||
finalInstallDir = installDir
|
||||
if Config.Verbose {
|
||||
fmt.Printf("Using production mode: cline-core.js found at %s\n", finalClineCorePath)
|
||||
}
|
||||
}
|
||||
|
||||
// Create logs directory in ~/.cline/logs
|
||||
logsDir := path.Join(Config.ConfigPath, "logs")
|
||||
if err := os.MkdirAll(logsDir, 0755); err != nil {
|
||||
return nil, fmt.Errorf("failed to create logs directory: %w", err)
|
||||
}
|
||||
|
||||
// Create timestamped log file
|
||||
timestamp := time.Now().Format("2006-01-02-15-04-05")
|
||||
logFileName := fmt.Sprintf("cline-core-%s-localhost-%d.log", timestamp, corePort)
|
||||
logFilePath := path.Join(logsDir, logFileName)
|
||||
logFile, err := os.Create(logFilePath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create log file: %w", err)
|
||||
}
|
||||
|
||||
// Start the cline-core process with --config flag using system node
|
||||
args := []string{finalClineCorePath,
|
||||
"--port", fmt.Sprintf("%d", corePort),
|
||||
"--host-bridge-port", fmt.Sprintf("%d", hostPort),
|
||||
"--config", Config.ConfigPath}
|
||||
|
||||
if Config.Verbose {
|
||||
fmt.Printf("Using system node\n")
|
||||
}
|
||||
|
||||
cmd := exec.Command("node", args...)
|
||||
|
||||
// Set working directory to installation root
|
||||
cmd.Dir = finalInstallDir
|
||||
|
||||
// Redirect stdout and stderr to log file
|
||||
cmd.Stdout = logFile
|
||||
cmd.Stderr = logFile
|
||||
|
||||
// Put the child process in a new process group so Ctrl+C doesn't kill it
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{
|
||||
Setpgid: true,
|
||||
}
|
||||
|
||||
// Set environment variables with NODE_PATH for both real and fake node_modules
|
||||
// The fake node_modules contains the vscode stub that can't be in the real node_modules
|
||||
env := os.Environ()
|
||||
realNodeModules := path.Join(finalInstallDir, "node_modules")
|
||||
fakeNodeModules := path.Join(finalInstallDir, "fake_node_modules")
|
||||
nodePath := fmt.Sprintf("%s%c%s", realNodeModules, os.PathListSeparator, fakeNodeModules)
|
||||
|
||||
env = append(env,
|
||||
fmt.Sprintf("NODE_PATH=%s", nodePath),
|
||||
// These control gRPC debug logging
|
||||
//"GRPC_TRACE=all",
|
||||
//"GRPC_VERBOSITY=DEBUG",
|
||||
"NODE_ENV=development",
|
||||
)
|
||||
cmd.Env = env
|
||||
|
||||
if Config.Verbose {
|
||||
fmt.Printf("NODE_PATH set to: %s\n", nodePath)
|
||||
}
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
logFile.Close()
|
||||
return nil, fmt.Errorf("failed to start cline-core: %w", err)
|
||||
}
|
||||
|
||||
if Config.Verbose {
|
||||
fmt.Printf("Started cline-core (PID: %d)\n", cmd.Process.Pid)
|
||||
fmt.Printf("Logging cline-core output to: %s\n", logFilePath)
|
||||
}
|
||||
return cmd, nil
|
||||
}
|
||||
@@ -1,119 +0,0 @@
|
||||
package global
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/cline/cli/pkg/common"
|
||||
"github.com/cline/grpc-go/client"
|
||||
"github.com/muesli/termenv"
|
||||
)
|
||||
|
||||
type Port uint16
|
||||
|
||||
type GlobalConfig struct {
|
||||
ConfigPath string
|
||||
Verbose bool
|
||||
OutputFormat string
|
||||
CoreAddress string
|
||||
}
|
||||
|
||||
var (
|
||||
Config *GlobalConfig
|
||||
Instances *ClineInstances
|
||||
|
||||
// Version info - set at build time via ldflags
|
||||
// Version is the Cline Core version (from root package.json)
|
||||
Version = "dev"
|
||||
// CliVersion is the CLI package version (from cli/package.json)
|
||||
CliVersion = "dev"
|
||||
Commit = "unknown"
|
||||
Date = "unknown"
|
||||
BuiltBy = "unknown"
|
||||
)
|
||||
|
||||
func InitializeGlobalConfig(cfg *GlobalConfig) error {
|
||||
if cfg.ConfigPath == "" {
|
||||
// Check CLINE_DIR environment variable first
|
||||
if clineDir := os.Getenv("CLINE_DIR"); clineDir != "" {
|
||||
cfg.ConfigPath = clineDir
|
||||
} else {
|
||||
homeDir, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get home directory: %w", err)
|
||||
}
|
||||
cfg.ConfigPath = filepath.Join(homeDir, ".cline")
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure .cline directory exists
|
||||
if err := os.MkdirAll(cfg.ConfigPath, 0755); err != nil {
|
||||
return fmt.Errorf("failed to create config directory: %w", err)
|
||||
}
|
||||
|
||||
// Configure lipgloss color profile based on output format
|
||||
if cfg.OutputFormat == "plain" {
|
||||
lipgloss.SetColorProfile(termenv.Ascii) // NO COLOR mode
|
||||
}
|
||||
// Otherwise lipgloss auto-detects terminal capabilities (default behavior)
|
||||
|
||||
Config = cfg
|
||||
Instances = NewClineInstances(cfg.ConfigPath)
|
||||
|
||||
// Initialize the clients registry
|
||||
ctx := context.Background()
|
||||
if err := Instances.Initialize(ctx); err != nil {
|
||||
return fmt.Errorf("failed to initialize clients: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetDefaultClient returns a client for the default instance or the address override
|
||||
func GetDefaultClient(ctx context.Context) (*client.ClineClient, error) {
|
||||
if Config.CoreAddress != "" && Config.CoreAddress != fmt.Sprintf("localhost:%d", common.DEFAULT_CLINE_CORE_PORT) {
|
||||
// User specified a specific address, use that
|
||||
return Instances.GetRegistry().GetClient(ctx, Config.CoreAddress)
|
||||
}
|
||||
|
||||
// Use the default instance from registry
|
||||
return Instances.GetRegistry().GetDefaultClient(ctx)
|
||||
}
|
||||
|
||||
// GetClientForAddress returns a client for a specific address
|
||||
func GetClientForAddress(ctx context.Context, address string) (*client.ClineClient, error) {
|
||||
return Instances.GetRegistry().GetClient(ctx, address)
|
||||
}
|
||||
|
||||
// EnsureDefaultInstance ensures a default instance exists
|
||||
func EnsureDefaultInstance(ctx context.Context) error {
|
||||
if Instances == nil {
|
||||
return fmt.Errorf("global clients not initialized")
|
||||
}
|
||||
|
||||
registry := Instances.GetRegistry()
|
||||
|
||||
// First, check if there are any instances already registered in SQLite
|
||||
instances := registry.ListInstances()
|
||||
|
||||
// Use the registry's EnsureDefaultInstance to auto-set first instance as default if needed
|
||||
if err := registry.EnsureDefaultInstance(instances); err != nil {
|
||||
return fmt.Errorf("failed to ensure default from existing instances: %w", err)
|
||||
}
|
||||
|
||||
// Now check if we have a default set
|
||||
if registry.GetDefaultInstance() == "" {
|
||||
// No instances exist, start a new one
|
||||
// Note: StartNewInstance will automatically set it as default since it's the first instance
|
||||
_, err := Instances.StartNewInstance(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to start new default instance: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1,303 +0,0 @@
|
||||
package global
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/cline/cli/pkg/cli/sqlite"
|
||||
"github.com/cline/cli/pkg/common"
|
||||
"github.com/cline/grpc-go/client"
|
||||
"github.com/cline/grpc-go/cline"
|
||||
"github.com/cline/grpc-go/host"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/credentials/insecure"
|
||||
"google.golang.org/grpc/health/grpc_health_v1"
|
||||
)
|
||||
|
||||
// InstanceRegistry manages Cline client connections using direct SQLite operations
|
||||
type InstanceRegistry struct {
|
||||
lockManager *sqlite.LockManager
|
||||
configPath string
|
||||
}
|
||||
|
||||
// NewInstanceRegistry creates a new instance registry
|
||||
func NewInstanceRegistry(configPath string) *InstanceRegistry {
|
||||
lockManager, err := sqlite.NewLockManager(configPath)
|
||||
if err != nil {
|
||||
// Log error but continue - we can still function without SQLite
|
||||
log.Fatalf("Warning: Failed to initialize SQLite lock manager: %v\n", err)
|
||||
}
|
||||
|
||||
return &InstanceRegistry{
|
||||
lockManager: lockManager,
|
||||
configPath: configPath,
|
||||
}
|
||||
}
|
||||
|
||||
// GetDefaultInstance returns the default instance address from settings file
|
||||
func (r *InstanceRegistry) GetDefaultInstance() string {
|
||||
defaultAddr, err := sqlite.GetDefaultInstance(r.configPath)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return defaultAddr
|
||||
}
|
||||
|
||||
// SetDefaultInstance sets the default instance (writes default.json)
|
||||
func (r *InstanceRegistry) SetDefaultInstance(address string) error {
|
||||
// Verify the instance exists in SQLite
|
||||
if r.lockManager != nil {
|
||||
exists, err := r.lockManager.HasInstanceAtAddress(address)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to check instance existence: %w", err)
|
||||
}
|
||||
if !exists {
|
||||
return fmt.Errorf("instance %s not found in registry", address)
|
||||
}
|
||||
}
|
||||
|
||||
return sqlite.SetDefaultInstance(r.configPath, address)
|
||||
}
|
||||
|
||||
// GetInstance returns instance information directly from SQLite
|
||||
func (r *InstanceRegistry) GetInstance(address string) (*common.CoreInstanceInfo, error) {
|
||||
if r.lockManager == nil {
|
||||
return nil, fmt.Errorf("lock manager not available")
|
||||
}
|
||||
|
||||
return r.lockManager.GetInstanceInfo(address)
|
||||
}
|
||||
|
||||
// GetClient returns a connected client for the given address (created on-demand)
|
||||
func (r *InstanceRegistry) GetClient(ctx context.Context, address string) (*client.ClineClient, error) {
|
||||
// Verify instance exists in SQLite
|
||||
if r.lockManager != nil {
|
||||
exists, err := r.lockManager.HasInstanceAtAddress(address)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to check instance existence: %w", err)
|
||||
}
|
||||
if !exists {
|
||||
return nil, fmt.Errorf("instance %s not found", address)
|
||||
}
|
||||
}
|
||||
|
||||
// Create client on-demand (no caching)
|
||||
target, err := common.NormalizeAddressForGRPC(address)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid address %s: %w", address, err)
|
||||
}
|
||||
|
||||
cl, err := client.NewClineClient(target)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create client for %s: %w", target, err)
|
||||
}
|
||||
|
||||
if err := cl.Connect(ctx); err != nil {
|
||||
return nil, fmt.Errorf("failed to connect to %s: %w", target, err)
|
||||
}
|
||||
|
||||
return cl, nil
|
||||
}
|
||||
|
||||
// GetDefaultClient returns a client for the default instance
|
||||
func (r *InstanceRegistry) GetDefaultClient(ctx context.Context) (*client.ClineClient, error) {
|
||||
defaultAddr := r.GetDefaultInstance()
|
||||
if defaultAddr == "" {
|
||||
return nil, fmt.Errorf("no default instance configured")
|
||||
}
|
||||
|
||||
// Check if the default instance actually exists in the database
|
||||
if r.lockManager != nil {
|
||||
exists, err := r.lockManager.HasInstanceAtAddress(defaultAddr)
|
||||
if err != nil {
|
||||
// Database is unavailable - Return error instead of attempting cleanup
|
||||
return nil, fmt.Errorf("cannot verify default instance: database unavailable: %w", err)
|
||||
}
|
||||
|
||||
if !exists {
|
||||
// Instance doesn't exist in database but config file references it
|
||||
// This is a stale config - remove it and try to find another instance
|
||||
settingsPath := filepath.Join(r.configPath, common.SETTINGS_SUBFOLDER, "settings", "cli-default-instance.json")
|
||||
if removeErr := os.Remove(settingsPath); removeErr != nil && !os.IsNotExist(removeErr) {
|
||||
fmt.Printf("Warning: Failed to remove stale default instance config: %v\n", removeErr)
|
||||
} else {
|
||||
fmt.Printf("Removed stale default instance config (instance %s not found in database)\n", defaultAddr)
|
||||
}
|
||||
|
||||
// Try to find and set a new default instance
|
||||
instances := r.ListInstances()
|
||||
if len(instances) > 0 {
|
||||
if err := r.EnsureDefaultInstance(instances); err != nil {
|
||||
return nil, fmt.Errorf("failed to set new default instance: %w", err)
|
||||
}
|
||||
|
||||
// Retry with the new default
|
||||
newDefaultAddr := r.GetDefaultInstance()
|
||||
if newDefaultAddr != "" {
|
||||
fmt.Printf("Set new default instance: %s\n", newDefaultAddr)
|
||||
return r.GetClient(ctx, newDefaultAddr)
|
||||
}
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("no default instance configured")
|
||||
}
|
||||
}
|
||||
|
||||
return r.GetClient(ctx, defaultAddr)
|
||||
}
|
||||
|
||||
// ListInstances returns all registered instances directly from SQLite
|
||||
func (r *InstanceRegistry) ListInstances() []*common.CoreInstanceInfo {
|
||||
if r.lockManager == nil {
|
||||
return []*common.CoreInstanceInfo{}
|
||||
}
|
||||
|
||||
// Use context with timeout for health checks
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
instances, err := r.lockManager.ListInstancesWithHealthCheck(ctx)
|
||||
if err != nil {
|
||||
fmt.Printf("Warning: Failed to list instances: %v\n", err)
|
||||
return []*common.CoreInstanceInfo{}
|
||||
}
|
||||
|
||||
return instances
|
||||
}
|
||||
|
||||
// HasInstanceAtAddress checks if an instance exists at the given address (delegates to SQLite)
|
||||
func (r *InstanceRegistry) HasInstanceAtAddress(address string) bool {
|
||||
if r.lockManager == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
exists, err := r.lockManager.HasInstanceAtAddress(address)
|
||||
if err != nil {
|
||||
fmt.Printf("Warning: Failed to check instance existence: %v\n", err)
|
||||
return false
|
||||
}
|
||||
|
||||
return exists
|
||||
}
|
||||
|
||||
// CleanupStaleInstances removes stale instances using direct SQLite operations
|
||||
func (r *InstanceRegistry) CleanupStaleInstances(ctx context.Context) error {
|
||||
if r.lockManager == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Get all instances with health checks
|
||||
instances, err := r.lockManager.ListInstancesWithHealthCheck(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to list instances for cleanup: %w", err)
|
||||
}
|
||||
|
||||
// Clean up all stale instances
|
||||
for _, instance := range instances {
|
||||
if instance.Status != grpc_health_v1.HealthCheckResponse_SERVING {
|
||||
// Try to gracefully shutdown the paired host process before cleanup
|
||||
|
||||
fmt.Printf("Attempting to shutdown dangling host service %s for stale cline core instance %s\n",
|
||||
instance.HostServiceAddress, instance.CoreAddress)
|
||||
r.tryShutdownHostProcess(instance.HostServiceAddress)
|
||||
|
||||
// Remove from SQLite database
|
||||
if err := r.lockManager.RemoveInstanceLock(instance.CoreAddress); err != nil {
|
||||
return fmt.Errorf("failed to remove stale instance %s: %w", instance.CoreAddress, err)
|
||||
}
|
||||
|
||||
fmt.Printf("Removed stale instance: %s\n", instance.CoreAddress)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// tryShutdownHostProcess attempts to gracefully shutdown a host process via RPC
|
||||
func (r *InstanceRegistry) tryShutdownHostProcess(hostServiceAddress string) {
|
||||
err := common.RetryOperation(3, 2*time.Second, func() error {
|
||||
// Create context with timeout
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// Create gRPC connection to host bridge
|
||||
conn, err := grpc.DialContext(ctx, hostServiceAddress,
|
||||
grpc.WithTransportCredentials(insecure.NewCredentials()),
|
||||
grpc.WithBlock())
|
||||
if err != nil {
|
||||
return fmt.Errorf("connection failed: %w", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
// Create env service client and call shutdown
|
||||
envClient := host.NewEnvServiceClient(conn)
|
||||
_, err = envClient.Shutdown(ctx, &cline.EmptyRequest{})
|
||||
if err != nil {
|
||||
return fmt.Errorf("RPC failed: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
fmt.Printf("Warning: Failed to request host bridge shutdown on port %s: %v\n", hostServiceAddress, err)
|
||||
} else {
|
||||
fmt.Printf("Host bridge shutdown requested successfully on port %s\n", hostServiceAddress)
|
||||
}
|
||||
}
|
||||
|
||||
// ListInstancesCleaned performs cleanup and returns instances with health checks
|
||||
func (r *InstanceRegistry) ListInstancesCleaned(ctx context.Context) ([]*common.CoreInstanceInfo, error) {
|
||||
// 1. Clean up stale entries (best-effort)
|
||||
_ = r.CleanupStaleInstances(ctx)
|
||||
|
||||
// 2. Get all instances with real-time health checks
|
||||
instances := r.ListInstances()
|
||||
|
||||
// 3. Ensure default is set if instances exist
|
||||
if err := r.EnsureDefaultInstance(instances); err != nil {
|
||||
fmt.Printf("Warning: Failed to ensure default instance: %v\n", err)
|
||||
}
|
||||
|
||||
return instances, nil
|
||||
}
|
||||
|
||||
// EnsureDefaultInstance ensures a default instance is set if instances exist but no default is configured
|
||||
func (r *InstanceRegistry) EnsureDefaultInstance(instances []*common.CoreInstanceInfo) error {
|
||||
currentDefault := r.GetDefaultInstance()
|
||||
|
||||
// If we have no instances, clear any stale default and remove settings file
|
||||
if len(instances) == 0 {
|
||||
if currentDefault != "" {
|
||||
// Remove the settings file since no instances exist
|
||||
settingsPath := filepath.Join(r.configPath, common.SETTINGS_SUBFOLDER, "settings", "cli-default-instance.json")
|
||||
_ = os.Remove(settingsPath)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// If we have instances but no default, pick the first one
|
||||
if currentDefault == "" {
|
||||
return sqlite.SetDefaultInstance(r.configPath, instances[0].CoreAddress)
|
||||
}
|
||||
|
||||
// Validate current default still exists in the instances
|
||||
defaultExists := false
|
||||
for _, instance := range instances {
|
||||
if instance.CoreAddress == currentDefault {
|
||||
defaultExists = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !defaultExists {
|
||||
// Current default doesn't exist, pick a new one from available instances
|
||||
return sqlite.SetDefaultInstance(r.configPath, instances[0].CoreAddress)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -1,339 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/cline/cli/pkg/cli/clerror"
|
||||
"github.com/cline/cli/pkg/cli/output"
|
||||
"github.com/cline/cli/pkg/cli/types"
|
||||
)
|
||||
|
||||
// AskHandler handles ASK type messages
|
||||
type AskHandler struct {
|
||||
*BaseHandler
|
||||
}
|
||||
|
||||
// NewAskHandler creates a new ASK handler
|
||||
func NewAskHandler() *AskHandler {
|
||||
return &AskHandler{
|
||||
BaseHandler: NewBaseHandler("ask", PriorityHigh),
|
||||
}
|
||||
}
|
||||
|
||||
// CanHandle returns true if this is an ASK message
|
||||
func (h *AskHandler) CanHandle(msg *types.ClineMessage) bool {
|
||||
return msg.IsAsk()
|
||||
}
|
||||
|
||||
func (h *AskHandler) Handle(msg *types.ClineMessage, dc *DisplayContext) error {
|
||||
// Always display approval messages so user can see what they're approving
|
||||
// The input handler will show the approval prompt form after the content is displayed
|
||||
|
||||
switch msg.Ask {
|
||||
case string(types.AskTypeFollowup):
|
||||
return h.handleFollowup(msg, dc)
|
||||
case string(types.AskTypePlanModeRespond):
|
||||
return h.handlePlanModeRespond(msg, dc)
|
||||
case string(types.AskTypeCommand):
|
||||
return h.handleCommand(msg, dc)
|
||||
case string(types.AskTypeCommandOutput):
|
||||
return h.handleCommandOutput(msg, dc)
|
||||
case string(types.AskTypeCompletionResult):
|
||||
return h.handleCompletionResult(msg, dc)
|
||||
case string(types.AskTypeTool):
|
||||
return h.handleTool(msg, dc)
|
||||
case string(types.AskTypeAPIReqFailed):
|
||||
return h.handleAPIReqFailed(msg, dc)
|
||||
case string(types.AskTypeResumeTask):
|
||||
return h.handleResumeTask(msg, dc)
|
||||
case string(types.AskTypeResumeCompletedTask):
|
||||
return h.handleResumeCompletedTask(msg, dc)
|
||||
case string(types.AskTypeMistakeLimitReached):
|
||||
return h.handleMistakeLimitReached(msg, dc)
|
||||
case string(types.AskTypeBrowserActionLaunch):
|
||||
return h.handleBrowserActionLaunch(msg, dc)
|
||||
case string(types.AskTypeUseMcpServer):
|
||||
return h.handleUseMcpServer(msg, dc)
|
||||
case string(types.AskTypeNewTask):
|
||||
return h.handleNewTask(msg, dc)
|
||||
case string(types.AskTypeCondense):
|
||||
return h.handleCondense(msg, dc)
|
||||
case string(types.AskTypeReportBug):
|
||||
return h.handleReportBug(msg, dc)
|
||||
default:
|
||||
return h.handleDefault(msg, dc)
|
||||
}
|
||||
}
|
||||
|
||||
// handleFollowup handles followup questions
|
||||
func (h *AskHandler) handleFollowup(msg *types.ClineMessage, dc *DisplayContext) error {
|
||||
body := dc.ToolRenderer.GenerateAskFollowupBody(msg.Text)
|
||||
|
||||
if body == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
if dc.IsStreamingMode {
|
||||
// In streaming mode, header was already shown by partial stream
|
||||
// Just render the body content
|
||||
output.Print(body)
|
||||
} else {
|
||||
// Non-streaming mode: render header + body together
|
||||
header := dc.ToolRenderer.GenerateAskFollowupHeader()
|
||||
rendered := dc.Renderer.RenderMarkdown(header)
|
||||
output.Print("\n")
|
||||
output.Print(rendered)
|
||||
output.Print("\n")
|
||||
output.Print(body)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// handlePlanModeRespond handles plan mode responses
|
||||
func (h *AskHandler) handlePlanModeRespond(msg *types.ClineMessage, dc *DisplayContext) error {
|
||||
if dc.IsStreamingMode {
|
||||
// In streaming mode, header was already shown by partial stream
|
||||
// Just render the body content
|
||||
body := dc.ToolRenderer.GeneratePlanModeRespondBody(msg.Text)
|
||||
if body != "" {
|
||||
output.Print(body)
|
||||
}
|
||||
} else {
|
||||
// In non-streaming mode, render header + body together
|
||||
header := dc.ToolRenderer.GeneratePlanModeRespondHeader()
|
||||
body := dc.ToolRenderer.GeneratePlanModeRespondBody(msg.Text)
|
||||
|
||||
if body == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Render header
|
||||
rendered := dc.Renderer.RenderMarkdown(header)
|
||||
output.Print("\n")
|
||||
output.Print(rendered)
|
||||
output.Print("\n")
|
||||
|
||||
// Render body
|
||||
output.Print(body)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// showApprovalHint displays a hint in non-interactive mode about how to approve/deny
|
||||
func (h *AskHandler) showApprovalHint(dc *DisplayContext) {
|
||||
if !dc.IsInteractive {
|
||||
output.Printf("\n%s\n", dc.Renderer.Dim("Cline is requesting approval to use this tool"))
|
||||
output.Printf("%s\n", dc.Renderer.Dim("Use cline task send --approve or --deny to respond"))
|
||||
}
|
||||
}
|
||||
|
||||
// handleCommand handles command execution requests
|
||||
func (h *AskHandler) handleCommand(msg *types.ClineMessage, dc *DisplayContext) error {
|
||||
if msg.Text == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check if this command was flagged despite auto-approval settings
|
||||
autoApprovalConflict := strings.HasSuffix(msg.Text, "REQ_APP")
|
||||
|
||||
// Use unified ToolRenderer
|
||||
rendered := dc.ToolRenderer.RenderCommandApprovalRequest(msg.Text, autoApprovalConflict)
|
||||
output.Print(rendered)
|
||||
|
||||
h.showApprovalHint(dc)
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleCommandOutput handles command output requests
|
||||
func (h *AskHandler) handleCommandOutput(msg *types.ClineMessage, dc *DisplayContext) error {
|
||||
if msg.Text == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
commandOutput := msg.Text
|
||||
|
||||
markdown := fmt.Sprintf("```\n%s\n```", commandOutput)
|
||||
rendered := dc.Renderer.RenderMarkdown(markdown)
|
||||
|
||||
fmt.Printf("%s", rendered)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleCompletionResult handles completion result requests
|
||||
func (h *AskHandler) handleCompletionResult(msg *types.ClineMessage, dc *DisplayContext) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleTool handles tool execution requests
|
||||
func (h *AskHandler) handleTool(msg *types.ClineMessage, dc *DisplayContext) error {
|
||||
// Parse tool message
|
||||
var tool types.ToolMessage
|
||||
if err := json.Unmarshal([]byte(msg.Text), &tool); err != nil {
|
||||
// Fallback to simple display
|
||||
return dc.Renderer.RenderMessage("TOOL", msg.Text, true)
|
||||
}
|
||||
|
||||
if dc.IsStreamingMode {
|
||||
// In streaming mode, header was already shown by partial stream
|
||||
// Just render the content preview
|
||||
contentPreview := dc.ToolRenderer.GenerateToolContentPreview(&tool)
|
||||
if contentPreview != "" {
|
||||
output.Print("\n")
|
||||
output.Print(contentPreview)
|
||||
}
|
||||
} else {
|
||||
// Non-streaming mode: render full approval (header + preview)
|
||||
rendered := dc.ToolRenderer.RenderToolApprovalRequest(&tool)
|
||||
output.Print(rendered)
|
||||
}
|
||||
|
||||
h.showApprovalHint(dc)
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleAPIReqFailed handles API request failures
|
||||
func (h *AskHandler) handleAPIReqFailed(msg *types.ClineMessage, dc *DisplayContext) error {
|
||||
// Try to parse as ClineError for better error display
|
||||
clineErr, _ := clerror.ParseClineError(msg.Text)
|
||||
if clineErr != nil {
|
||||
if dc.SystemRenderer != nil {
|
||||
// Render the error with system renderer
|
||||
switch clineErr.GetErrorType() {
|
||||
case clerror.ErrorTypeBalance:
|
||||
dc.SystemRenderer.RenderBalanceError(clineErr)
|
||||
case clerror.ErrorTypeAuth:
|
||||
dc.SystemRenderer.RenderAuthError(clineErr)
|
||||
case clerror.ErrorTypeRateLimit:
|
||||
dc.SystemRenderer.RenderRateLimitError(clineErr)
|
||||
default:
|
||||
dc.SystemRenderer.RenderAPIError(clineErr)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
// Fallback: render with basic renderer using parsed message
|
||||
return dc.Renderer.RenderMessage("ERROR", fmt.Sprintf("API Request Failed: %s. Approve to retry request.", clineErr.Message), true)
|
||||
}
|
||||
// Last resort: display raw text if parsing completely failed
|
||||
return dc.Renderer.RenderMessage("ERROR", fmt.Sprintf("API Request Failed: %s. Approve to retry request.", msg.Text), true)
|
||||
}
|
||||
|
||||
// handleResumeTask handles resume task requests
|
||||
func (h *AskHandler) handleResumeTask(msg *types.ClineMessage, dc *DisplayContext) error {
|
||||
// Don't render - this is metadata only, user already knows they're resuming
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleResumeCompletedTask handles resume completed task requests
|
||||
func (h *AskHandler) handleResumeCompletedTask(msg *types.ClineMessage, dc *DisplayContext) error {
|
||||
// Don't render - this is metadata only, user already knows they're resuming
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleMistakeLimitReached handles mistake limit reached
|
||||
func (h *AskHandler) handleMistakeLimitReached(msg *types.ClineMessage, dc *DisplayContext) error {
|
||||
if dc.SystemRenderer != nil {
|
||||
details := make(map[string]string)
|
||||
if msg.Text != "" {
|
||||
details["details"] = msg.Text
|
||||
}
|
||||
dc.SystemRenderer.RenderError(
|
||||
"critical",
|
||||
"Mistake Limit Reached",
|
||||
"Cline has made too many consecutive mistakes and needs your guidance to proceed.",
|
||||
details,
|
||||
)
|
||||
fmt.Printf("\n**Approval required to continue.**\n")
|
||||
return nil
|
||||
}
|
||||
return dc.Renderer.RenderMessage("ERROR", fmt.Sprintf("Mistake Limit Reached: %s. Approval required.", msg.Text), true)
|
||||
}
|
||||
|
||||
// handleBrowserActionLaunch handles browser action launch requests
|
||||
func (h *AskHandler) handleBrowserActionLaunch(msg *types.ClineMessage, dc *DisplayContext) error {
|
||||
url := strings.TrimSpace(msg.Text)
|
||||
err := dc.Renderer.RenderMessage("BROWSER", fmt.Sprintf("Cline wants to launch browser and navigate to: %s. Approval required.", url), true)
|
||||
h.showApprovalHint(dc)
|
||||
return err
|
||||
}
|
||||
|
||||
// handleUseMcpServer handles MCP server usage requests
|
||||
func (h *AskHandler) handleUseMcpServer(msg *types.ClineMessage, dc *DisplayContext) error {
|
||||
// Parse MCP server usage request
|
||||
type McpServerRequest struct {
|
||||
ServerName string `json:"serverName"`
|
||||
Type string `json:"type"`
|
||||
ToolName string `json:"toolName,omitempty"`
|
||||
Arguments string `json:"arguments,omitempty"`
|
||||
URI string `json:"uri,omitempty"`
|
||||
}
|
||||
|
||||
var mcpReq McpServerRequest
|
||||
if err := json.Unmarshal([]byte(msg.Text), &mcpReq); err != nil {
|
||||
return dc.Renderer.RenderMessage("MCP", msg.Text, true)
|
||||
}
|
||||
|
||||
var operation string
|
||||
if mcpReq.Type == "access_mcp_resource" {
|
||||
operation = "access a resource"
|
||||
} else {
|
||||
operation = fmt.Sprintf("use a tool (%s)", mcpReq.ToolName)
|
||||
if mcpReq.Arguments != "" {
|
||||
operation = fmt.Sprintf("%s with args (%s)", operation, mcpReq.Arguments)
|
||||
}
|
||||
}
|
||||
|
||||
err := dc.Renderer.RenderMessage("MCP",
|
||||
fmt.Sprintf("Cline wants to %s on the %s MCP server", operation, mcpReq.ServerName), true)
|
||||
|
||||
h.showApprovalHint(dc)
|
||||
return err
|
||||
}
|
||||
|
||||
// handleNewTask handles new task creation requests
|
||||
func (h *AskHandler) handleNewTask(msg *types.ClineMessage, dc *DisplayContext) error {
|
||||
return dc.Renderer.RenderMessage("NEW TASK", fmt.Sprintf("Cline wants to start a new task: %s. Approval required.", msg.Text), true)
|
||||
}
|
||||
|
||||
// handleCondense handles conversation condensing requests
|
||||
func (h *AskHandler) handleCondense(msg *types.ClineMessage, dc *DisplayContext) error {
|
||||
return dc.Renderer.RenderMessage("CONDENSE", fmt.Sprintf("Cline wants to condense the conversation: %s. Approval required.", msg.Text), true)
|
||||
}
|
||||
|
||||
// handleReportBug handles bug report requests
|
||||
func (h *AskHandler) handleReportBug(msg *types.ClineMessage, dc *DisplayContext) error {
|
||||
var bugData struct {
|
||||
Title string `json:"title"`
|
||||
WhatHappened string `json:"what_happened"`
|
||||
StepsToReproduce string `json:"steps_to_reproduce"`
|
||||
APIRequestOutput string `json:"api_request_output"`
|
||||
AdditionalContext string `json:"additional_context"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal([]byte(msg.Text), &bugData); err != nil {
|
||||
return dc.Renderer.RenderMessage("BUG REPORT", fmt.Sprintf("Cline wants to create a GitHub issue: %s. Approval required.", msg.Text), true)
|
||||
}
|
||||
|
||||
err := dc.Renderer.RenderMessage("BUG REPORT", "Cline wants to create a GitHub issue:", true)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to render handleReportBug: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("\n**Title**: %s\n", bugData.Title)
|
||||
fmt.Printf("**What Happened**: %s\n", bugData.WhatHappened)
|
||||
fmt.Printf("**Steps to Reproduce**: %s\n", bugData.StepsToReproduce)
|
||||
fmt.Printf("**API Request Output**: %s\n", bugData.APIRequestOutput)
|
||||
fmt.Printf("**Additional Context**: %s\n", bugData.AdditionalContext)
|
||||
fmt.Printf("\nApprove to create a GitHub issue.\n")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleDefault handles unknown ASK message types
|
||||
func (h *AskHandler) handleDefault(msg *types.ClineMessage, dc *DisplayContext) error {
|
||||
return dc.Renderer.RenderMessage("ASK", msg.Text, true)
|
||||
}
|
||||
@@ -1,131 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"github.com/cline/cli/pkg/cli/display"
|
||||
"github.com/cline/cli/pkg/cli/types"
|
||||
)
|
||||
|
||||
// MessageHandler defines the interface for handling different message types
|
||||
type MessageHandler interface {
|
||||
// CanHandle returns true if this handler can process the given message
|
||||
CanHandle(msg *types.ClineMessage) bool
|
||||
|
||||
// Handle processes the message and renders it using the display context
|
||||
Handle(msg *types.ClineMessage, dc *DisplayContext) error
|
||||
|
||||
// GetPriority returns the priority of this handler (higher = more priority)
|
||||
GetPriority() int
|
||||
|
||||
// GetName returns a human-readable name for this handler
|
||||
GetName() string
|
||||
}
|
||||
|
||||
// DisplayContext provides context and utilities for message handlers
|
||||
type DisplayContext struct {
|
||||
State *types.ConversationState
|
||||
Renderer *display.Renderer
|
||||
ToolRenderer *display.ToolRenderer
|
||||
HookRenderer *display.HookRenderer
|
||||
SystemRenderer *display.SystemMessageRenderer
|
||||
IsLast bool
|
||||
IsPartial bool
|
||||
Verbose bool
|
||||
MessageIndex int
|
||||
IsStreamingMode bool
|
||||
IsInteractive bool
|
||||
}
|
||||
|
||||
// BaseHandler provides common functionality for message handlers
|
||||
type BaseHandler struct {
|
||||
name string
|
||||
priority int
|
||||
}
|
||||
|
||||
// NewBaseHandler creates a new base handler
|
||||
func NewBaseHandler(name string, priority int) *BaseHandler {
|
||||
return &BaseHandler{
|
||||
name: name,
|
||||
priority: priority,
|
||||
}
|
||||
}
|
||||
|
||||
// GetName returns the handler name
|
||||
func (h *BaseHandler) GetName() string {
|
||||
return h.name
|
||||
}
|
||||
|
||||
// GetPriority returns the handler priority
|
||||
func (h *BaseHandler) GetPriority() int {
|
||||
return h.priority
|
||||
}
|
||||
|
||||
// HandlerRegistry manages a collection of message handlers
|
||||
type HandlerRegistry struct {
|
||||
handlers []MessageHandler
|
||||
}
|
||||
|
||||
// NewHandlerRegistry creates a new handler registry
|
||||
func NewHandlerRegistry() *HandlerRegistry {
|
||||
return &HandlerRegistry{
|
||||
handlers: make([]MessageHandler, 0),
|
||||
}
|
||||
}
|
||||
|
||||
// Register adds a handler to the registry
|
||||
func (r *HandlerRegistry) Register(handler MessageHandler) {
|
||||
r.handlers = append(r.handlers, handler)
|
||||
|
||||
// Sort handlers by priority (highest first)
|
||||
for i := len(r.handlers) - 1; i > 0; i-- {
|
||||
if r.handlers[i].GetPriority() > r.handlers[i-1].GetPriority() {
|
||||
r.handlers[i], r.handlers[i-1] = r.handlers[i-1], r.handlers[i]
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle finds the appropriate handler and processes the message
|
||||
func (r *HandlerRegistry) Handle(msg *types.ClineMessage, dc *DisplayContext) error {
|
||||
for _, handler := range r.handlers {
|
||||
if handler.CanHandle(msg) {
|
||||
return handler.Handle(msg, dc)
|
||||
}
|
||||
}
|
||||
|
||||
// If no specific handler found, use default text handler
|
||||
return r.handleDefault(msg, dc)
|
||||
}
|
||||
|
||||
// handleDefault provides default handling for unrecognized messages
|
||||
func (r *HandlerRegistry) handleDefault(msg *types.ClineMessage, dc *DisplayContext) error {
|
||||
if msg.Text == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
prefix := "RESPONSE:"
|
||||
|
||||
return dc.Renderer.RenderMessage(prefix, msg.Text, true)
|
||||
}
|
||||
|
||||
// GetHandlers returns all registered handlers
|
||||
func (r *HandlerRegistry) GetHandlers() []MessageHandler {
|
||||
return r.handlers
|
||||
}
|
||||
|
||||
// GetHandlerByName finds a handler by name
|
||||
func (r *HandlerRegistry) GetHandlerByName(name string) MessageHandler {
|
||||
for _, handler := range r.handlers {
|
||||
if handler.GetName() == name {
|
||||
return handler
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// HandlerPriorities defines standard priority levels for handlers
|
||||
const (
|
||||
PriorityHigh = 100
|
||||
PriorityNormal = 50
|
||||
PriorityLow = 10
|
||||
)
|
||||
@@ -1,549 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/cline/cli/pkg/cli/clerror"
|
||||
"github.com/cline/cli/pkg/cli/output"
|
||||
"github.com/cline/cli/pkg/cli/types"
|
||||
)
|
||||
|
||||
// SayHandler handles SAY type messages
|
||||
type SayHandler struct {
|
||||
*BaseHandler
|
||||
}
|
||||
|
||||
// NewSayHandler creates a new SAY handler
|
||||
func NewSayHandler() *SayHandler {
|
||||
return &SayHandler{
|
||||
BaseHandler: NewBaseHandler("say", PriorityNormal),
|
||||
}
|
||||
}
|
||||
|
||||
// CanHandle returns true if this is a SAY message
|
||||
func (h *SayHandler) CanHandle(msg *types.ClineMessage) bool {
|
||||
return msg.IsSay()
|
||||
}
|
||||
|
||||
// Handle processes SAY messages
|
||||
func (h *SayHandler) Handle(msg *types.ClineMessage, dc *DisplayContext) error {
|
||||
timestamp := msg.GetTimestamp()
|
||||
|
||||
switch msg.Say {
|
||||
case string(types.SayTypeTask):
|
||||
return h.handleTask(msg, dc)
|
||||
case string(types.SayTypeError):
|
||||
return h.handleError(msg, dc)
|
||||
case string(types.SayTypeAPIReqStarted):
|
||||
return h.handleAPIReqStarted(msg, dc)
|
||||
case string(types.SayTypeAPIReqFinished):
|
||||
return h.handleAPIReqFinished(msg, dc)
|
||||
case string(types.SayTypeText):
|
||||
return h.handleText(msg, dc)
|
||||
case string(types.SayTypeReasoning):
|
||||
return h.handleReasoning(msg, dc)
|
||||
case string(types.SayTypeCompletionResult):
|
||||
return h.handleCompletionResult(msg, dc)
|
||||
case string(types.SayTypeUserFeedback):
|
||||
return h.handleUserFeedback(msg, dc)
|
||||
case string(types.SayTypeUserFeedbackDiff):
|
||||
return h.handleUserFeedbackDiff(msg, dc)
|
||||
case string(types.SayTypeAPIReqRetried):
|
||||
return h.handleAPIReqRetried(msg, dc)
|
||||
case string(types.SayTypeErrorRetry):
|
||||
return h.handleErrorRetry(msg, dc)
|
||||
case string(types.SayTypeCommand):
|
||||
return h.handleCommand(msg, dc)
|
||||
case string(types.SayTypeCommandOutput):
|
||||
return h.handleCommandOutput(msg, dc)
|
||||
case string(types.SayTypeTool):
|
||||
return h.handleTool(msg, dc)
|
||||
case string(types.SayTypeShellIntegrationWarning):
|
||||
return h.handleShellIntegrationWarning(msg, dc)
|
||||
case string(types.SayTypeBrowserActionLaunch):
|
||||
return h.handleBrowserActionLaunch(msg, dc)
|
||||
case string(types.SayTypeBrowserAction):
|
||||
return h.handleBrowserAction(msg, dc)
|
||||
case string(types.SayTypeBrowserActionResult):
|
||||
return h.handleBrowserActionResult(msg, dc)
|
||||
case string(types.SayTypeMcpServerRequestStarted):
|
||||
return h.handleMcpServerRequestStarted(msg, dc)
|
||||
case string(types.SayTypeMcpServerResponse):
|
||||
return h.handleMcpServerResponse(msg, dc)
|
||||
case string(types.SayTypeMcpNotification):
|
||||
return h.handleMcpNotification(msg, dc)
|
||||
case string(types.SayTypeUseMcpServer):
|
||||
return h.handleUseMcpServer(msg, dc)
|
||||
case string(types.SayTypeDiffError):
|
||||
return h.handleDiffError(msg, dc)
|
||||
case string(types.SayTypeDeletedAPIReqs):
|
||||
return h.handleDeletedAPIReqs(msg, dc)
|
||||
case string(types.SayTypeClineignoreError):
|
||||
return h.handleClineignoreError(msg, dc)
|
||||
case string(types.SayTypeCheckpointCreated):
|
||||
return h.handleCheckpointCreated(msg, dc, timestamp)
|
||||
case string(types.SayTypeLoadMcpDocumentation):
|
||||
return h.handleLoadMcpDocumentation(msg, dc)
|
||||
case string(types.SayTypeInfo):
|
||||
return h.handleInfo(msg, dc)
|
||||
case string(types.SayTypeTaskProgress):
|
||||
return h.handleTaskProgress(msg, dc)
|
||||
case string(types.SayTypeHookStatus):
|
||||
return h.handleHookStatus(msg, dc)
|
||||
case string(types.SayTypeHookOutputStream):
|
||||
return h.handleHookOutputStream(msg, dc)
|
||||
case string(types.SayTypeCommandPermissionDenied):
|
||||
return h.handleCommandPermissionDenied(msg, dc)
|
||||
default:
|
||||
return h.handleDefault(msg, dc)
|
||||
}
|
||||
}
|
||||
|
||||
// handleTask handles task messages
|
||||
func (h *SayHandler) handleTask(msg *types.ClineMessage, dc *DisplayContext) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleError handles error messages
|
||||
func (h *SayHandler) handleError(msg *types.ClineMessage, dc *DisplayContext) error {
|
||||
return dc.Renderer.RenderMessage("ERROR", msg.Text, true)
|
||||
}
|
||||
|
||||
// handleAPIReqStarted handles API request started messages
|
||||
func (h *SayHandler) handleAPIReqStarted(msg *types.ClineMessage, dc *DisplayContext) error {
|
||||
// Parse API request info
|
||||
apiInfo := types.APIRequestInfo{Cost: -1}
|
||||
if err := json.Unmarshal([]byte(msg.Text), &apiInfo); err != nil {
|
||||
return dc.Renderer.RenderMessage("API INFO", msg.Text, true)
|
||||
}
|
||||
|
||||
// Check for streaming failed message with error details
|
||||
if apiInfo.StreamingFailedMessage != "" {
|
||||
clineErr, _ := clerror.ParseClineError(apiInfo.StreamingFailedMessage)
|
||||
if clineErr != nil {
|
||||
return h.renderClineError(clineErr, dc)
|
||||
}
|
||||
}
|
||||
|
||||
// Handle different API request states
|
||||
if apiInfo.CancelReason != "" {
|
||||
if apiInfo.CancelReason == "user_cancelled" {
|
||||
return dc.Renderer.RenderMessage("API INFO", "Request Cancelled", true)
|
||||
} else if apiInfo.CancelReason == "retries_exhausted" {
|
||||
return dc.Renderer.RenderMessage("API INFO", "Request Failed (Retries Exhausted)", true)
|
||||
}
|
||||
return dc.Renderer.RenderMessage("API INFO", "Streaming Failed", true)
|
||||
}
|
||||
|
||||
if apiInfo.Cost >= 0 {
|
||||
return dc.Renderer.RenderAPI("request completed", &apiInfo)
|
||||
}
|
||||
|
||||
// Check for retry status
|
||||
if apiInfo.RetryStatus != nil {
|
||||
return dc.Renderer.RenderRetry(
|
||||
apiInfo.RetryStatus.Attempt,
|
||||
apiInfo.RetryStatus.MaxAttempts,
|
||||
apiInfo.RetryStatus.DelaySec)
|
||||
}
|
||||
|
||||
return dc.Renderer.RenderAPI("processing request", &apiInfo)
|
||||
}
|
||||
|
||||
// renderClineError renders a ClineError with appropriate formatting based on type
|
||||
func (h *SayHandler) renderClineError(err *clerror.ClineError, dc *DisplayContext) error {
|
||||
if dc.SystemRenderer == nil {
|
||||
return dc.Renderer.RenderMessage("ERROR", err.Message, true)
|
||||
}
|
||||
|
||||
switch err.GetErrorType() {
|
||||
case clerror.ErrorTypeBalance:
|
||||
return dc.SystemRenderer.RenderBalanceError(err)
|
||||
case clerror.ErrorTypeAuth:
|
||||
return dc.SystemRenderer.RenderAuthError(err)
|
||||
case clerror.ErrorTypeRateLimit:
|
||||
return dc.SystemRenderer.RenderRateLimitError(err)
|
||||
default:
|
||||
return dc.SystemRenderer.RenderAPIError(err)
|
||||
}
|
||||
}
|
||||
|
||||
// handleAPIReqFinished handles API request finished messages
|
||||
func (h *SayHandler) handleAPIReqFinished(msg *types.ClineMessage, dc *DisplayContext) error {
|
||||
// This message type is typically not displayed as it's handled by the started message
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleText handles regular text messages
|
||||
func (h *SayHandler) handleText(msg *types.ClineMessage, dc *DisplayContext) error {
|
||||
if msg.Text == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Special case for the user's task input
|
||||
if dc.MessageIndex == 0 {
|
||||
markdown := formatUserMessage(msg.Text)
|
||||
rendered := dc.Renderer.RenderMarkdown(markdown)
|
||||
output.Printf("%s", rendered)
|
||||
output.Printf("\n")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Regular Cline text response
|
||||
var rendered string
|
||||
if dc.IsStreamingMode {
|
||||
// In streaming mode, header already shown by partial stream
|
||||
rendered = dc.Renderer.RenderMarkdown(msg.Text)
|
||||
output.Printf("%s\n", rendered)
|
||||
} else {
|
||||
// In non-streaming mode, render header + body together
|
||||
markdown := fmt.Sprintf("### Cline responds\n\n%s", msg.Text)
|
||||
rendered = dc.Renderer.RenderMarkdown(markdown)
|
||||
output.Printf("\n%s\n", rendered)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleReasoning handles reasoning messages
|
||||
func (h *SayHandler) handleReasoning(msg *types.ClineMessage, dc *DisplayContext) error {
|
||||
if msg.Text == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
var rendered string
|
||||
if dc.IsStreamingMode {
|
||||
// In streaming mode, header already shown by partial stream
|
||||
rendered = dc.Renderer.RenderMarkdown(msg.Text)
|
||||
output.Printf("%s\n", rendered)
|
||||
} else {
|
||||
// In non-streaming mode, render header + body together
|
||||
markdown := fmt.Sprintf("### Cline is thinking\n\n%s", msg.Text)
|
||||
rendered = dc.Renderer.RenderMarkdown(markdown)
|
||||
output.Printf("\n%s\n", rendered)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *SayHandler) handleCompletionResult(msg *types.ClineMessage, dc *DisplayContext) error {
|
||||
text := msg.Text
|
||||
|
||||
if strings.HasSuffix(text, "HAS_CHANGES") {
|
||||
text = strings.TrimSuffix(text, "HAS_CHANGES")
|
||||
}
|
||||
|
||||
var rendered string
|
||||
if dc.IsStreamingMode {
|
||||
// In streaming mode, header already shown by partial stream
|
||||
rendered = dc.Renderer.RenderMarkdown(text)
|
||||
output.Printf("%s\n", rendered)
|
||||
} else {
|
||||
// In non-streaming mode, render header + body together
|
||||
markdown := fmt.Sprintf("### Task completed\n\n%s", text)
|
||||
rendered = dc.Renderer.RenderMarkdown(markdown)
|
||||
output.Printf("\n%s\n", rendered)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func formatUserMessage(text string) string {
|
||||
lines := strings.Split(text, "\n")
|
||||
|
||||
// Wrap each line in backticks
|
||||
for i, line := range lines {
|
||||
if line != "" {
|
||||
lines[i] = fmt.Sprintf("`%s`", line)
|
||||
}
|
||||
}
|
||||
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
// handleUserFeedback handles user feedback messages
|
||||
func (h *SayHandler) handleUserFeedback(msg *types.ClineMessage, dc *DisplayContext) error {
|
||||
if msg.Text != "" {
|
||||
markdown := formatUserMessage(msg.Text)
|
||||
rendered := dc.Renderer.RenderMarkdown(markdown)
|
||||
output.Printf("%s", rendered)
|
||||
return nil
|
||||
} else {
|
||||
return dc.Renderer.RenderMessage("USER", "[Provided feedback without text]", true)
|
||||
}
|
||||
}
|
||||
|
||||
// handleUserFeedbackDiff handles user feedback diff messages
|
||||
func (h *SayHandler) handleUserFeedbackDiff(msg *types.ClineMessage, dc *DisplayContext) error {
|
||||
var toolMsg types.ToolMessage
|
||||
if err := json.Unmarshal([]byte(msg.Text), &toolMsg); err != nil {
|
||||
return dc.Renderer.RenderMessage("USER DIFF", msg.Text, true)
|
||||
}
|
||||
|
||||
message := fmt.Sprintf("User manually edited: %s\n\nDiff:\n%s",
|
||||
toolMsg.Path,
|
||||
toolMsg.Diff)
|
||||
|
||||
return dc.Renderer.RenderMessage("USER DIFF", message, true)
|
||||
}
|
||||
|
||||
// handleAPIReqRetried handles API request retry messages
|
||||
func (h *SayHandler) handleAPIReqRetried(msg *types.ClineMessage, dc *DisplayContext) error {
|
||||
return dc.Renderer.RenderMessage("API INFO", "Retrying request", true)
|
||||
}
|
||||
|
||||
// handleErrorRetry handles error retry status messages
|
||||
func (h *SayHandler) handleErrorRetry(msg *types.ClineMessage, dc *DisplayContext) error {
|
||||
// Parse retry info from message text
|
||||
type ErrorRetryInfo struct {
|
||||
Attempt int `json:"attempt"`
|
||||
MaxAttempts int `json:"maxAttempts"`
|
||||
DelaySeconds int `json:"delaySeconds"`
|
||||
Failed bool `json:"failed"`
|
||||
}
|
||||
|
||||
var retryInfo ErrorRetryInfo
|
||||
if err := json.Unmarshal([]byte(msg.Text), &retryInfo); err != nil {
|
||||
// Fallback to simple message if parsing fails
|
||||
return dc.Renderer.RenderMessage("API INFO", "Auto-retry in progress", true)
|
||||
}
|
||||
|
||||
if retryInfo.Failed {
|
||||
// Retry failed after max attempts
|
||||
message := fmt.Sprintf("Auto-retry failed after %d attempts. Manual intervention required.", retryInfo.MaxAttempts)
|
||||
if dc.SystemRenderer != nil {
|
||||
return dc.SystemRenderer.RenderWarning("Auto-Retry Failed", message)
|
||||
}
|
||||
return dc.Renderer.RenderMessage("WARNING", message, true)
|
||||
}
|
||||
|
||||
// Retry in progress
|
||||
message := fmt.Sprintf("Attempt %d/%d - Retrying in %d seconds...",
|
||||
retryInfo.Attempt, retryInfo.MaxAttempts, retryInfo.DelaySeconds)
|
||||
return dc.Renderer.RenderMessage("API INFO", message, true)
|
||||
}
|
||||
|
||||
// handleCommand handles command execution announcements
|
||||
func (h *SayHandler) handleCommand(msg *types.ClineMessage, dc *DisplayContext) error {
|
||||
if msg.Text == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Use unified ToolRenderer
|
||||
rendered := dc.ToolRenderer.RenderCommandExecution(msg.Text)
|
||||
output.Print(rendered)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleCommandOutput handles command output messages
|
||||
func (h *SayHandler) handleCommandOutput(msg *types.ClineMessage, dc *DisplayContext) error {
|
||||
if msg.Text == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Use unified ToolRenderer
|
||||
rendered := dc.ToolRenderer.RenderCommandOutput(msg.Text)
|
||||
output.Print(rendered)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *SayHandler) handleCommandPermissionDenied(msg *types.ClineMessage, dc *DisplayContext) error {
|
||||
if msg.Text == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Use unified ToolRenderer
|
||||
rendered := dc.ToolRenderer.RenderCommandPermissionDenied(msg.Text)
|
||||
output.Print(rendered)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *SayHandler) handleTool(msg *types.ClineMessage, dc *DisplayContext) error {
|
||||
var tool types.ToolMessage
|
||||
if err := json.Unmarshal([]byte(msg.Text), &tool); err != nil {
|
||||
return dc.Renderer.RenderMessage("TOOL", msg.Text, true)
|
||||
}
|
||||
|
||||
// Use unified ToolRenderer
|
||||
rendered := dc.ToolRenderer.RenderToolExecution(&tool)
|
||||
output.Print(rendered)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleShellIntegrationWarning handles shell integration warning messages
|
||||
func (h *SayHandler) handleShellIntegrationWarning(msg *types.ClineMessage, dc *DisplayContext) error {
|
||||
return dc.Renderer.RenderMessage("WARNING", "Shell Integration Unavailable - Cline won't be able to view the command's output.", true)
|
||||
}
|
||||
|
||||
// handleBrowserActionLaunch handles browser action launch messages
|
||||
func (h *SayHandler) handleBrowserActionLaunch(msg *types.ClineMessage, dc *DisplayContext) error {
|
||||
url := msg.Text
|
||||
if url == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
return dc.Renderer.RenderMessage("BROWSER", fmt.Sprintf("Launching browser at: %s", url), true)
|
||||
}
|
||||
|
||||
// handleBrowserAction handles browser action messages
|
||||
func (h *SayHandler) handleBrowserAction(msg *types.ClineMessage, dc *DisplayContext) error {
|
||||
if msg.Text == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
type BrowserActionData struct {
|
||||
Action string `json:"action"`
|
||||
Coordinate string `json:"coordinate,omitempty"`
|
||||
Text string `json:"text,omitempty"`
|
||||
}
|
||||
|
||||
var actionData BrowserActionData
|
||||
if err := json.Unmarshal([]byte(msg.Text), &actionData); err != nil {
|
||||
return dc.Renderer.RenderMessage("BROWSER", msg.Text, true)
|
||||
}
|
||||
|
||||
// Special handling for type action
|
||||
if actionData.Action == "type" && actionData.Text != "" {
|
||||
actionText := fmt.Sprintf("type '%s'", actionData.Text)
|
||||
return dc.Renderer.RenderMessage("BROWSER", fmt.Sprintf("Next action: %s", actionText), true)
|
||||
}
|
||||
|
||||
// Special handling for click action
|
||||
if actionData.Action == "click" && actionData.Coordinate != "" {
|
||||
actionText := fmt.Sprintf("click (%s)", actionData.Coordinate)
|
||||
return dc.Renderer.RenderMessage("BROWSER", fmt.Sprintf("Next action: %s", actionText), true)
|
||||
}
|
||||
|
||||
// Generic handling for all other actions
|
||||
return dc.Renderer.RenderMessage("BROWSER", fmt.Sprintf("Next action: %s", actionData.Action), true)
|
||||
}
|
||||
|
||||
// handleBrowserActionResult handles browser action result messages
|
||||
func (h *SayHandler) handleBrowserActionResult(msg *types.ClineMessage, dc *DisplayContext) error {
|
||||
if msg.Text == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
type BrowserActionResult struct {
|
||||
Screenshot string `json:"screenshot,omitempty"`
|
||||
Logs string `json:"logs,omitempty"`
|
||||
CurrentUrl string `json:"currentUrl,omitempty"`
|
||||
CurrentMousePosition string `json:"currentMousePosition,omitempty"`
|
||||
}
|
||||
|
||||
var result BrowserActionResult
|
||||
if err := json.Unmarshal([]byte(msg.Text), &result); err != nil {
|
||||
return dc.Renderer.RenderMessage("BROWSER", "Action completed", true)
|
||||
}
|
||||
|
||||
// If we have logs, include them in the message
|
||||
if result.Logs != "" {
|
||||
return dc.Renderer.RenderMessage("BROWSER", fmt.Sprintf("Action completed with logs: '%s'", result.Logs), true)
|
||||
}
|
||||
|
||||
// Default case
|
||||
return dc.Renderer.RenderMessage("BROWSER", "Action completed", true)
|
||||
}
|
||||
|
||||
// handleMcpServerRequestStarted handles MCP server request started messages
|
||||
func (h *SayHandler) handleMcpServerRequestStarted(msg *types.ClineMessage, dc *DisplayContext) error {
|
||||
return dc.Renderer.RenderMessage("MCP", "Sending request to server", true)
|
||||
}
|
||||
|
||||
// handleMcpServerResponse handles MCP server response messages
|
||||
func (h *SayHandler) handleMcpServerResponse(msg *types.ClineMessage, dc *DisplayContext) error {
|
||||
return dc.Renderer.RenderMessage("MCP", fmt.Sprintf("Server response: %s", msg.Text), true)
|
||||
}
|
||||
|
||||
// handleMcpNotification handles MCP notification messages
|
||||
func (h *SayHandler) handleMcpNotification(msg *types.ClineMessage, dc *DisplayContext) error {
|
||||
return dc.Renderer.RenderMessage("MCP", fmt.Sprintf("Server notification: %s", msg.Text), true)
|
||||
}
|
||||
|
||||
// handleUseMcpServer handles MCP server usage messages
|
||||
func (h *SayHandler) handleUseMcpServer(msg *types.ClineMessage, dc *DisplayContext) error {
|
||||
return dc.Renderer.RenderMessage("MCP", "Server operation approved", true)
|
||||
}
|
||||
|
||||
// handleDiffError handles diff error messages
|
||||
func (h *SayHandler) handleDiffError(msg *types.ClineMessage, dc *DisplayContext) error {
|
||||
if dc.SystemRenderer != nil {
|
||||
return dc.SystemRenderer.RenderWarning(
|
||||
"Diff Edit Failure",
|
||||
"The model used search patterns that don't match anything in the file. Retrying...",
|
||||
)
|
||||
}
|
||||
return dc.Renderer.RenderMessage("WARNING", "Diff Edit Failure - The model used an invalid diff edit format or used search patterns that don't match anything in the file.", true)
|
||||
}
|
||||
|
||||
// handleDeletedAPIReqs handles deleted API requests messages
|
||||
func (h *SayHandler) handleDeletedAPIReqs(msg *types.ClineMessage, dc *DisplayContext) error {
|
||||
// Don't render - this is internal metadata (aggregated API metrics from deleted checkpoint messages)
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleClineignoreError handles .clineignore error messages
|
||||
func (h *SayHandler) handleClineignoreError(msg *types.ClineMessage, dc *DisplayContext) error {
|
||||
if dc.SystemRenderer != nil {
|
||||
return dc.SystemRenderer.RenderInfo(
|
||||
"Access Denied",
|
||||
fmt.Sprintf("Cline tried to access `%s` which is blocked by the .clineignore file.", msg.Text),
|
||||
)
|
||||
}
|
||||
return dc.Renderer.RenderMessage("WARNING", fmt.Sprintf("Access Denied - Cline tried to access %s which is blocked by the .clineignore file", msg.Text), true)
|
||||
}
|
||||
|
||||
func (h *SayHandler) handleCheckpointCreated(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error {
|
||||
if dc.SystemRenderer != nil {
|
||||
return dc.SystemRenderer.RenderCheckpoint(timestamp, msg.Timestamp)
|
||||
}
|
||||
// Fallback to basic renderer if SystemRenderer not available
|
||||
markdown := fmt.Sprintf("## [%s] Checkpoint created `%d`", timestamp, msg.Timestamp)
|
||||
rendered := dc.Renderer.RenderMarkdown(markdown)
|
||||
output.Print(rendered)
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleLoadMcpDocumentation handles load MCP documentation messages
|
||||
func (h *SayHandler) handleLoadMcpDocumentation(msg *types.ClineMessage, dc *DisplayContext) error {
|
||||
if dc.SystemRenderer != nil {
|
||||
return dc.SystemRenderer.RenderInfo("MCP", "Loading MCP documentation")
|
||||
}
|
||||
return dc.Renderer.RenderMessage("INFO", "Loading MCP documentation", true)
|
||||
}
|
||||
|
||||
// handleInfo handles info messages
|
||||
func (h *SayHandler) handleInfo(msg *types.ClineMessage, dc *DisplayContext) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleTaskProgress handles task progress messages
|
||||
func (h *SayHandler) handleTaskProgress(msg *types.ClineMessage, dc *DisplayContext) error {
|
||||
if msg.Text == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
markdown := fmt.Sprintf("### Progress\n\n%s", msg.Text)
|
||||
rendered := dc.Renderer.RenderMarkdown(markdown)
|
||||
output.Printf("\n%s\n", rendered)
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleDefault handles unknown SAY message types
|
||||
func (h *SayHandler) handleDefault(msg *types.ClineMessage, dc *DisplayContext) error {
|
||||
// Debug: log unhandled say types to help identify missing cases using output.Printf for CLI consistency
|
||||
if dc.Verbose {
|
||||
output.Printf("[DEBUG] Unhandled SAY type: '%s' (text preview: %s)\n", msg.Say, truncateForDisplay(msg.Text, 50))
|
||||
}
|
||||
return dc.Renderer.RenderMessage("SAY", msg.Text, true)
|
||||
}
|
||||
|
||||
func truncateForDisplay(text string, maxLen int) string {
|
||||
if len(text) <= maxLen {
|
||||
return text
|
||||
}
|
||||
return text[:maxLen] + "..."
|
||||
}
|
||||
@@ -1,198 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/cline/cli/pkg/cli/output"
|
||||
"github.com/cline/cli/pkg/cli/types"
|
||||
)
|
||||
|
||||
// Hook-specific SAY handlers and helpers.
|
||||
// Kept in a separate file to keep say_handlers.go focused on routing.
|
||||
|
||||
// handleHookStatus handles hook execution status messages.
|
||||
func (h *SayHandler) handleHookStatus(msg *types.ClineMessage, dc *DisplayContext) error {
|
||||
hook, err := parseHookMessage(msg.Text)
|
||||
if err != nil {
|
||||
// Fallback to basic output if JSON parsing fails
|
||||
return dc.Renderer.RenderMessage("HOOK", msg.Text, true)
|
||||
}
|
||||
|
||||
logHookDebug(hook, dc)
|
||||
hook.ScriptPaths = formatHookPaths(hook.ScriptPaths)
|
||||
|
||||
return renderHookStatus(hook, dc)
|
||||
}
|
||||
|
||||
// handleHookOutputStream handles streaming output from hooks.
|
||||
//
|
||||
// Hook stdout/stderr currently arrives line-by-line from the backend as
|
||||
// `hook_output_stream` messages. The CLI intentionally suppresses these by default
|
||||
// to keep the transcript high-signal.
|
||||
//
|
||||
// In --verbose mode, we print each non-empty line prefixed with "HOOK>" for easy grepping.
|
||||
// Future work could associate these lines with a specific hook execution and render them
|
||||
// as a grouped section under the hook status header.
|
||||
func (h *SayHandler) handleHookOutputStream(msg *types.ClineMessage, dc *DisplayContext) error {
|
||||
if !dc.Verbose {
|
||||
return nil
|
||||
}
|
||||
|
||||
line := strings.TrimRight(msg.Text, "\n")
|
||||
if strings.TrimSpace(line) == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
output.Printf("HOOK> %s\n", line)
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseHookMessage(jsonText string) (types.HookMessage, error) {
|
||||
var hook types.HookMessage
|
||||
if err := json.Unmarshal([]byte(jsonText), &hook); err != nil {
|
||||
return types.HookMessage{}, err
|
||||
}
|
||||
return hook, nil
|
||||
}
|
||||
|
||||
func logHookDebug(hook types.HookMessage, dc *DisplayContext) {
|
||||
if dc.Verbose {
|
||||
output.Printf("[DEBUG] Hook parsed: name=%s, status=%s, toolName=%s, scriptPaths=%v\n",
|
||||
hook.HookName, hook.Status, hook.ToolName, hook.ScriptPaths)
|
||||
}
|
||||
}
|
||||
|
||||
func formatHookPaths(paths []string) []string {
|
||||
if len(paths) == 0 {
|
||||
return paths
|
||||
}
|
||||
formatted := make([]string, 0, len(paths))
|
||||
for _, p := range paths {
|
||||
if strings.TrimSpace(p) == "" {
|
||||
continue
|
||||
}
|
||||
formatted = append(formatted, formatHookPath(p))
|
||||
}
|
||||
return formatted
|
||||
}
|
||||
|
||||
func renderHookStatus(hook types.HookMessage, dc *DisplayContext) error {
|
||||
if dc.HookRenderer != nil {
|
||||
rendered := dc.HookRenderer.RenderHookStatus(hook)
|
||||
// Match ToolRenderer’s spacing: one leading newline, one trailing newline.
|
||||
output.Print("\n")
|
||||
output.Print(rendered)
|
||||
output.Print("\n")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Fallback: if HookRenderer not available
|
||||
return dc.Renderer.RenderMessage("HOOK", fmt.Sprintf("%s %s", hook.HookName, hook.Status), true)
|
||||
}
|
||||
|
||||
func formatHookPath(fullPath string) string {
|
||||
// Normalize for display and prefix checks. This is display-only; do not use for IO.
|
||||
normalized := normalizeSlashes(fullPath)
|
||||
|
||||
// If this is a repo-scoped hook script (i.e. lives under <repo>/.clinerules/hooks/),
|
||||
// always include the repo name for disambiguation even in single-repo workspaces.
|
||||
//
|
||||
// This intentionally runs before workspace-relative formatting, which would otherwise
|
||||
// collapse to ".clinerules/hooks/..." and lose the repo context.
|
||||
if p, ok := tryRepoScopedHooksPath(normalized); ok {
|
||||
return p
|
||||
}
|
||||
|
||||
// Prefer workspace-relative paths first for readability, since most hook scripts
|
||||
// live inside the current project.
|
||||
if p, ok := tryWorkspaceRelativeHookPath(normalized); ok {
|
||||
return p
|
||||
}
|
||||
|
||||
// Follow existing CLI pattern: resolve home via os.UserHomeDir.
|
||||
if p, ok := tryHomeTildePath(normalized); ok {
|
||||
return p
|
||||
}
|
||||
|
||||
// Secondary heuristic: if hook lives under <repo>/.clinerules, collapse to repo-relative.
|
||||
if p, ok := tryRepoRelativeHookPath(normalized); ok {
|
||||
return p
|
||||
}
|
||||
|
||||
return fallbackLastComponents(normalized, 3)
|
||||
}
|
||||
|
||||
func normalizeSlashes(p string) string {
|
||||
return filepath.ToSlash(p)
|
||||
}
|
||||
|
||||
func tryWorkspaceRelativeHookPath(normalizedPath string) (string, bool) {
|
||||
root, err := os.Getwd()
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
|
||||
// filepath.Rel expects OS-native paths, so we need to convert the normalized path
|
||||
// back to OS-native format before calling Rel, then normalize the result for display.
|
||||
targetOS := filepath.FromSlash(normalizedPath)
|
||||
rel, err := filepath.Rel(root, targetOS)
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
// If it's not within the workspace, Rel will start with "..".
|
||||
if rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
|
||||
return "", false
|
||||
}
|
||||
return normalizeSlashes(rel), true
|
||||
}
|
||||
|
||||
func tryHomeTildePath(normalizedPath string) (string, bool) {
|
||||
homeDir, err := os.UserHomeDir()
|
||||
if err != nil || strings.TrimSpace(homeDir) == "" {
|
||||
return "", false
|
||||
}
|
||||
homeDir = normalizeSlashes(homeDir)
|
||||
if !strings.HasPrefix(normalizedPath, homeDir) {
|
||||
return "", false
|
||||
}
|
||||
rel := strings.TrimPrefix(normalizedPath, homeDir)
|
||||
rel = strings.TrimPrefix(rel, "/")
|
||||
return "~/" + rel, true
|
||||
}
|
||||
|
||||
func tryRepoRelativeHookPath(normalizedPath string) (string, bool) {
|
||||
parts := strings.Split(normalizedPath, "/")
|
||||
for i, part := range parts {
|
||||
if part == ".clinerules" && i > 0 {
|
||||
repoName := parts[i-1]
|
||||
return repoName + "/" + strings.Join(parts[i:], "/"), true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// tryRepoScopedHooksPath returns a repo-prefixed path like
|
||||
// "myrepo/.clinerules/hooks/PreToolUse" when the given path points to a hook script
|
||||
// under a repo's .clinerules/hooks directory.
|
||||
//
|
||||
// This is more specific than tryRepoRelativeHookPath and is used to ensure hook script
|
||||
// paths always include repo context.
|
||||
func tryRepoScopedHooksPath(normalizedPath string) (string, bool) {
|
||||
// Fast path check to avoid split work.
|
||||
if !strings.Contains(normalizedPath, "/.clinerules/hooks/") {
|
||||
return "", false
|
||||
}
|
||||
return tryRepoRelativeHookPath(normalizedPath)
|
||||
}
|
||||
|
||||
func fallbackLastComponents(normalizedPath string, n int) string {
|
||||
parts := strings.Split(normalizedPath, "/")
|
||||
if len(parts) >= n {
|
||||
return strings.Join(parts[len(parts)-n:], "/")
|
||||
}
|
||||
return normalizedPath
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestFormatHookPath_PrefersWorkspaceRelative(t *testing.T) {
|
||||
// Create a stable workspace root (avoid TempDir's nested ".../001" patterns)
|
||||
// so that workspace-relative formatting is deterministic.
|
||||
root := filepath.Join(t.TempDir(), "workspace")
|
||||
if err := os.MkdirAll(root, 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll: %v", err)
|
||||
}
|
||||
oldWd, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatalf("Getwd: %v", err)
|
||||
}
|
||||
defer func() { _ = os.Chdir(oldWd) }()
|
||||
if err := os.Chdir(root); err != nil {
|
||||
t.Fatalf("Chdir: %v", err)
|
||||
}
|
||||
|
||||
inside := filepath.Join(root, ".clinerules", "hooks", "pre.sh")
|
||||
got := formatHookPath(inside)
|
||||
// Repo-scoped hook scripts should always include the repo name (the directory
|
||||
// immediately containing .clinerules) even when running inside that repo.
|
||||
expected := "workspace/" + filepath.ToSlash(filepath.Join(".clinerules", "hooks", "pre.sh"))
|
||||
if got != expected {
|
||||
t.Fatalf("expected formatted path to be %q. got=%q", expected, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatHookPath_FallsBackToLastComponents(t *testing.T) {
|
||||
// Use an obviously non-workspace path (relative, but not prefixed with cwd).
|
||||
got := formatHookPath("/var/tmp/foo/bar/baz.sh")
|
||||
if got != "foo/bar/baz.sh" {
|
||||
t.Fatalf("expected last 3 components fallback, got=%q", got)
|
||||
}
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestFormatHookPath_HomeDirToTilde(t *testing.T) {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil || home == "" {
|
||||
t.Skip("home dir not available; skipping")
|
||||
}
|
||||
|
||||
got := formatHookPath(home + "/Documents/Cline/Hooks/TaskStart")
|
||||
want := "~/Documents/Cline/Hooks/TaskStart"
|
||||
if got != want {
|
||||
t.Fatalf("expected %q, got %q", want, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatHookPath_WorkspaceRepoRelative(t *testing.T) {
|
||||
got := formatHookPath("/Users/alice/dev/repo-name/.clinerules/hooks/TaskStart")
|
||||
want := "repo-name/.clinerules/hooks/TaskStart"
|
||||
if got != want {
|
||||
t.Fatalf("expected %q, got %q", want, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatHookPath_FallbackLast3Components(t *testing.T) {
|
||||
got := formatHookPath("/a/b/c/d/e")
|
||||
want := "c/d/e"
|
||||
if got != want {
|
||||
t.Fatalf("expected %q, got %q", want, got)
|
||||
}
|
||||
}
|
||||
@@ -1,506 +0,0 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"syscall"
|
||||
"text/tabwriter"
|
||||
"time"
|
||||
|
||||
"github.com/cline/cli/pkg/cli/display"
|
||||
"github.com/cline/cli/pkg/cli/global"
|
||||
"github.com/cline/cli/pkg/common"
|
||||
client2 "github.com/cline/grpc-go/client"
|
||||
"github.com/cline/grpc-go/cline"
|
||||
"github.com/spf13/cobra"
|
||||
"google.golang.org/grpc/health/grpc_health_v1"
|
||||
)
|
||||
|
||||
const (
|
||||
platformCLI = "CLI"
|
||||
platformJetBrains = "JetBrains"
|
||||
platformNA = "N/A"
|
||||
hostPlatformCLI = "Cline CLI" // Value returned by host bridge for CLI instances
|
||||
)
|
||||
|
||||
// detectInstancePlatform connects to an instance's host bridge and determines its platform
|
||||
func detectInstancePlatform(ctx context.Context, instance *common.CoreInstanceInfo) (string, error) {
|
||||
hostTarget, err := common.NormalizeAddressForGRPC(instance.HostServiceAddress)
|
||||
if err != nil {
|
||||
return platformNA, err
|
||||
}
|
||||
|
||||
hostClient, err := client2.NewClineClient(hostTarget)
|
||||
if err != nil {
|
||||
return platformNA, err
|
||||
}
|
||||
defer hostClient.Disconnect()
|
||||
|
||||
if err := hostClient.Connect(ctx); err != nil {
|
||||
return platformNA, err
|
||||
}
|
||||
|
||||
hostVersion, err := hostClient.Env.GetHostVersion(ctx, &cline.EmptyRequest{})
|
||||
if err != nil {
|
||||
return platformNA, err
|
||||
}
|
||||
|
||||
if hostVersion.Platform == nil {
|
||||
return platformNA, fmt.Errorf("host returned nil platform")
|
||||
}
|
||||
|
||||
platformStr := *hostVersion.Platform
|
||||
if platformStr == hostPlatformCLI {
|
||||
return platformCLI, nil
|
||||
}
|
||||
return platformJetBrains, nil
|
||||
}
|
||||
|
||||
func NewInstanceCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "instance",
|
||||
Aliases: []string{"i"},
|
||||
Short: "Manage Cline instances",
|
||||
Long: `List and manage multiple Cline instances similar to kubectl contexts.`,
|
||||
}
|
||||
|
||||
cmd.AddCommand(newInstanceListCommand())
|
||||
cmd.AddCommand(newInstanceDefaultCommand())
|
||||
cmd.AddCommand(newInstanceNewCommand())
|
||||
cmd.AddCommand(newInstanceKillCommand())
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newInstanceKillCommand() *cobra.Command {
|
||||
var killAllCLI bool
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "kill <address>",
|
||||
Aliases: []string{"k"},
|
||||
Short: "Kill a Cline instance by address",
|
||||
Long: `Kill a running Cline instance and clean up its registry entry.`,
|
||||
Args: func(cmd *cobra.Command, args []string) error {
|
||||
if killAllCLI && len(args) > 0 {
|
||||
return fmt.Errorf("cannot specify both --all-cli flag and address argument")
|
||||
}
|
||||
if !killAllCLI && len(args) != 1 {
|
||||
return fmt.Errorf("requires exactly one address argument when --all-cli is not specified")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if global.Instances == nil {
|
||||
return fmt.Errorf("clients not initialized")
|
||||
}
|
||||
|
||||
ctx := cmd.Context()
|
||||
registry := global.Instances.GetRegistry()
|
||||
|
||||
if killAllCLI {
|
||||
return killAllCLIInstances(ctx, registry)
|
||||
} else {
|
||||
return global.KillInstanceByAddress(ctx, registry, args[0])
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().BoolVarP(&killAllCLI, "all-cli", "a", false, "kill all running CLI instances (excludes JetBrains)")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func killAllCLIInstances(ctx context.Context, registry *global.InstanceRegistry) error {
|
||||
// Get all instances from registry
|
||||
instances, err := registry.ListInstancesCleaned(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to list instances: %w", err)
|
||||
}
|
||||
|
||||
if len(instances) == 0 {
|
||||
fmt.Println("No Cline instances found to kill.")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Filter to only CLI instances
|
||||
var cliInstances []*common.CoreInstanceInfo
|
||||
var skippedNonCLI int
|
||||
for _, instance := range instances {
|
||||
if instance.Status == grpc_health_v1.HealthCheckResponse_SERVING {
|
||||
platform, err := detectInstancePlatform(ctx, instance)
|
||||
if err == nil {
|
||||
if platform == platformCLI {
|
||||
cliInstances = append(cliInstances, instance)
|
||||
} else {
|
||||
skippedNonCLI++
|
||||
fmt.Printf("⊘ Skipping %s instance: %s\n", platform, instance.CoreAddress)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(cliInstances) == 0 {
|
||||
if skippedNonCLI > 0 {
|
||||
fmt.Printf("No CLI instances to kill. Skipped %d JetBrains instance(s).\n", skippedNonCLI)
|
||||
} else {
|
||||
fmt.Println("No CLI instances found to kill.")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
fmt.Printf("Killing %d CLI instance(s)...\n", len(cliInstances))
|
||||
if skippedNonCLI > 0 {
|
||||
fmt.Printf("Skipping %d JetBrains instance(s).\n", skippedNonCLI)
|
||||
}
|
||||
|
||||
var killResults []killResult
|
||||
killedAddresses := make(map[string]bool)
|
||||
|
||||
// Kill all CLI instances
|
||||
for _, instance := range cliInstances {
|
||||
result := killInstanceProcess(ctx, registry, instance.CoreAddress)
|
||||
killResults = append(killResults, result)
|
||||
|
||||
if result.err != nil {
|
||||
fmt.Printf("✗ Failed to kill %s: %v\n", instance.CoreAddress, result.err)
|
||||
} else if result.alreadyDead {
|
||||
fmt.Printf("⚠ Instance %s appears to be already dead\n", instance.CoreAddress)
|
||||
} else {
|
||||
fmt.Printf("✓ Killed %s (PID %d)\n", instance.CoreAddress, result.pid)
|
||||
killedAddresses[instance.CoreAddress] = true
|
||||
}
|
||||
}
|
||||
|
||||
// Wait for killed instances to clean up their registry entries
|
||||
if len(killedAddresses) > 0 {
|
||||
fmt.Printf("Waiting for instances to clean up registry entries...\n")
|
||||
|
||||
maxWaitTime := 10 // seconds
|
||||
for i := 0; i < maxWaitTime; i++ {
|
||||
time.Sleep(1 * time.Second)
|
||||
|
||||
remainingInstances, err := registry.ListInstancesCleaned(ctx)
|
||||
if err != nil {
|
||||
fmt.Printf("Warning: failed to check registry status: %v\n", err)
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if any of the killed instances are still in the registry
|
||||
stillPresent := []string{}
|
||||
for _, remaining := range remainingInstances {
|
||||
if killedAddresses[remaining.CoreAddress] {
|
||||
stillPresent = append(stillPresent, remaining.CoreAddress)
|
||||
}
|
||||
}
|
||||
|
||||
if len(stillPresent) == 0 {
|
||||
fmt.Printf("✓ All killed instances successfully removed from registry.\n")
|
||||
break
|
||||
}
|
||||
|
||||
if i == maxWaitTime-1 {
|
||||
fmt.Printf("⚠ %d killed instance(s) still in registry after %d seconds\n", len(stillPresent), maxWaitTime)
|
||||
for _, addr := range stillPresent {
|
||||
fmt.Printf(" - %s\n", addr)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Print summary
|
||||
successful := 0
|
||||
failed := 0
|
||||
alreadyDead := 0
|
||||
|
||||
for _, result := range killResults {
|
||||
if result.err != nil {
|
||||
failed++
|
||||
} else if result.alreadyDead {
|
||||
alreadyDead++
|
||||
} else {
|
||||
successful++
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Printf("\nSummary: ")
|
||||
if successful > 0 {
|
||||
fmt.Printf("Successfully killed %d instances. ", successful)
|
||||
}
|
||||
if alreadyDead > 0 {
|
||||
fmt.Printf("%d were already dead. ", alreadyDead)
|
||||
}
|
||||
if failed > 0 {
|
||||
fmt.Printf("%d failures.", failed)
|
||||
return fmt.Errorf("failed to kill %d out of %d instances", failed, len(instances))
|
||||
}
|
||||
fmt.Println()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type killResult struct {
|
||||
address string
|
||||
pid int
|
||||
alreadyDead bool
|
||||
err error
|
||||
}
|
||||
|
||||
func killInstanceProcess(ctx context.Context, registry *global.InstanceRegistry, address string) killResult {
|
||||
// Get gRPC client and process info
|
||||
client, err := registry.GetClient(ctx, address)
|
||||
if err != nil {
|
||||
return killResult{address: address, alreadyDead: true, err: nil}
|
||||
}
|
||||
|
||||
processInfo, err := client.State.GetProcessInfo(ctx, &cline.EmptyRequest{})
|
||||
if err != nil {
|
||||
return killResult{address: address, alreadyDead: true, err: nil}
|
||||
}
|
||||
|
||||
pid := int(processInfo.ProcessId)
|
||||
|
||||
// Kill the process
|
||||
if err := syscall.Kill(pid, syscall.SIGTERM); err != nil {
|
||||
return killResult{address: address, pid: pid, err: err}
|
||||
}
|
||||
|
||||
return killResult{address: address, pid: pid, err: nil}
|
||||
}
|
||||
|
||||
func newInstanceListCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "list",
|
||||
Aliases: []string{"l"},
|
||||
Short: "List all registered Cline instances",
|
||||
Long: `List all registered Cline instances with their status and connection details.`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if global.Instances == nil {
|
||||
return fmt.Errorf("clients not initialized")
|
||||
}
|
||||
|
||||
ctx := cmd.Context()
|
||||
registry := global.Instances.GetRegistry()
|
||||
|
||||
// Load, cleanup stale local entries, and update health
|
||||
instances, err := registry.ListInstancesCleaned(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to list instances: %w", err)
|
||||
}
|
||||
defaultInstance := registry.GetDefaultInstance()
|
||||
|
||||
if len(instances) == 0 {
|
||||
fmt.Println("No Cline instances found.")
|
||||
fmt.Println("Run 'cline instance new' to start a new instance, or 'cline task new \"...\"' to auto-start one.")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Build instance data
|
||||
type instanceRow struct {
|
||||
address string
|
||||
status string
|
||||
version string
|
||||
lastSeen string
|
||||
pid string
|
||||
platform string
|
||||
isDefault string
|
||||
}
|
||||
|
||||
var rows []instanceRow
|
||||
for _, instance := range instances {
|
||||
isDefault := ""
|
||||
if instance.CoreAddress == defaultInstance {
|
||||
isDefault = "✓"
|
||||
}
|
||||
|
||||
lastSeen := instance.LastSeen.Format("15:04:05")
|
||||
if time.Since(instance.LastSeen) > 24*time.Hour {
|
||||
lastSeen = instance.LastSeen.Format("2006-01-02")
|
||||
}
|
||||
|
||||
// Get PID and platform via RPC if instance is healthy
|
||||
pid := platformNA
|
||||
platform := platformNA
|
||||
if instance.Status == grpc_health_v1.HealthCheckResponse_SERVING {
|
||||
// Get PID from core
|
||||
if client, err := registry.GetClient(ctx, instance.CoreAddress); err == nil {
|
||||
if processInfo, err := client.State.GetProcessInfo(ctx, &cline.EmptyRequest{}); err == nil {
|
||||
pid = fmt.Sprintf("%d", processInfo.ProcessId)
|
||||
// Update version from RPC if available
|
||||
if processInfo.Version != nil && *processInfo.Version != "" && *processInfo.Version != "unknown" {
|
||||
instance.Version = *processInfo.Version
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get platform from host bridge
|
||||
if detectedPlatform, err := detectInstancePlatform(ctx, instance); err == nil {
|
||||
platform = detectedPlatform
|
||||
}
|
||||
}
|
||||
|
||||
rows = append(rows, instanceRow{
|
||||
address: instance.CoreAddress,
|
||||
status: instance.Status.String(),
|
||||
version: instance.Version,
|
||||
lastSeen: lastSeen,
|
||||
pid: pid,
|
||||
platform: platform,
|
||||
isDefault: isDefault,
|
||||
})
|
||||
}
|
||||
|
||||
// Check output format
|
||||
if global.Config.OutputFormat == "plain" {
|
||||
// Use tabwriter for plain output
|
||||
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
|
||||
fmt.Fprintln(w, "ADDRESS\tSTATUS\tVERSION\tLAST SEEN\tPID\tPLATFORM\tDEFAULT")
|
||||
|
||||
for _, row := range rows {
|
||||
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\t%s\n",
|
||||
row.address,
|
||||
row.status,
|
||||
row.version,
|
||||
row.lastSeen,
|
||||
row.pid,
|
||||
row.platform,
|
||||
row.isDefault,
|
||||
)
|
||||
}
|
||||
|
||||
w.Flush()
|
||||
} else {
|
||||
// Use markdown table for rich output
|
||||
var markdown strings.Builder
|
||||
markdown.WriteString("| **ADDRESS (ID)** | **STATUS** | **VERSION** | **LAST SEEN** | **PID** | **PLATFORM** | **DEFAULT** |\n")
|
||||
markdown.WriteString("|---------|--------|---------|-----------|-----|----------|---------|")
|
||||
|
||||
for _, row := range rows {
|
||||
markdown.WriteString(fmt.Sprintf("\n| %s | %s | %s | %s | %s | %s | %s |",
|
||||
row.address,
|
||||
row.status,
|
||||
row.version,
|
||||
row.lastSeen,
|
||||
row.pid,
|
||||
row.platform,
|
||||
row.isDefault,
|
||||
))
|
||||
}
|
||||
|
||||
// Render the markdown table with terminal width for nice table layout
|
||||
mdRenderer, err := display.NewMarkdownRendererForTerminal()
|
||||
if err != nil {
|
||||
// Fallback to plain table if markdown renderer fails
|
||||
fmt.Println(markdown.String())
|
||||
} else {
|
||||
rendered, err := mdRenderer.Render(markdown.String())
|
||||
if err != nil {
|
||||
fmt.Println(markdown.String())
|
||||
} else {
|
||||
// Post-process to colorize status values
|
||||
colorRenderer := display.NewRenderer(global.Config.OutputFormat)
|
||||
rendered = strings.ReplaceAll(rendered, "SERVING", colorRenderer.Green("SERVING"))
|
||||
rendered = strings.ReplaceAll(rendered, "✓", colorRenderer.Green("✓"))
|
||||
rendered = strings.ReplaceAll(rendered, "NOT_SERVING", colorRenderer.Red("NOT_SERVING"))
|
||||
rendered = strings.ReplaceAll(rendered, "UNKNOWN", colorRenderer.Yellow("UNKNOWN"))
|
||||
|
||||
fmt.Print(strings.TrimLeft(rendered, "\n"))
|
||||
}
|
||||
fmt.Println()
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newInstanceDefaultCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "default <address>",
|
||||
Aliases: []string{"d"},
|
||||
Short: "Set the default Cline instance",
|
||||
Long: `Set the default Cline instance to use for subsequent commands.`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
address := args[0]
|
||||
|
||||
if global.Instances == nil {
|
||||
return fmt.Errorf("clients not initialized")
|
||||
}
|
||||
|
||||
registry := global.Instances.GetRegistry()
|
||||
|
||||
// Verify the instance exists
|
||||
_, err := registry.GetInstance(address)
|
||||
if err != nil {
|
||||
return fmt.Errorf("instance %s not found. Run 'cline instance list' to see available instances", address)
|
||||
}
|
||||
|
||||
// Set as default
|
||||
if err := registry.SetDefaultInstance(address); err != nil {
|
||||
return fmt.Errorf("failed to set default instance: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("Switched to instance: %s\n", address)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newInstanceNewCommand() *cobra.Command {
|
||||
var setDefault bool
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "new",
|
||||
Aliases: []string{"n"},
|
||||
Short: "Create a new Cline instance",
|
||||
Long: `Create a new Cline instance with automatically assigned ports.`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := cmd.Context()
|
||||
|
||||
if global.Instances == nil {
|
||||
return fmt.Errorf("clients not initialized")
|
||||
}
|
||||
|
||||
fmt.Println("Starting new Cline instance...")
|
||||
|
||||
instance, err := global.Instances.StartNewInstance(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to start instance: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("Successfully started new instance:\n")
|
||||
fmt.Printf(" Address: %s\n", instance.CoreAddress)
|
||||
fmt.Printf(" Core Port: %d\n", instance.CorePort())
|
||||
fmt.Printf(" Host Bridge Port: %d\n", instance.HostPort())
|
||||
|
||||
registry := global.Instances.GetRegistry()
|
||||
|
||||
// If --default flag provided, set this instance as the default
|
||||
if setDefault {
|
||||
if err := registry.SetDefaultInstance(instance.CoreAddress); err != nil {
|
||||
fmt.Printf("Warning: Failed to set as default: %v\n", err)
|
||||
} else {
|
||||
fmt.Printf(" Status: Set as default instance\n")
|
||||
}
|
||||
} else {
|
||||
// Otherwise, check if EnsureDefaultInstance already set it as default
|
||||
if registry.GetDefaultInstance() == instance.CoreAddress {
|
||||
fmt.Printf(" Status: Default instance\n")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().BoolVarP(&setDefault, "default", "d", false, "set as default instance")
|
||||
|
||||
return cmd
|
||||
}
|
||||
@@ -1,382 +0,0 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"text/tabwriter"
|
||||
"time"
|
||||
|
||||
"github.com/cline/cli/pkg/cli/display"
|
||||
"github.com/cline/cli/pkg/cli/global"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
type logFileInfo struct {
|
||||
name string
|
||||
path string
|
||||
size int64
|
||||
created time.Time
|
||||
}
|
||||
|
||||
func NewLogsCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "logs",
|
||||
Aliases: []string{"log", "l"},
|
||||
Short: "Manage Cline log files",
|
||||
Long: `List and manage log files created by Cline instances.`,
|
||||
}
|
||||
|
||||
cmd.AddCommand(newLogsListCommand())
|
||||
cmd.AddCommand(newLogsCleanCommand())
|
||||
cmd.AddCommand(newLogsPathCommand())
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newLogsListCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "list",
|
||||
Aliases: []string{"l", "ls"},
|
||||
Short: "List all log files",
|
||||
Long: `List all log files in the Cline logs directory with their sizes and ages.`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if global.Config == nil {
|
||||
return fmt.Errorf("config not initialized")
|
||||
}
|
||||
|
||||
logsDir := filepath.Join(global.Config.ConfigPath, "logs")
|
||||
logs, err := listLogFiles(logsDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to list log files: %w", err)
|
||||
}
|
||||
|
||||
if len(logs) == 0 {
|
||||
fmt.Println("No log files found.")
|
||||
fmt.Printf("Log files will be created in: %s\n", logsDir)
|
||||
return nil
|
||||
}
|
||||
|
||||
return renderLogsTable(logs, false)
|
||||
},
|
||||
}
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newLogsCleanCommand() *cobra.Command {
|
||||
var olderThan int
|
||||
var all bool
|
||||
var dryRun bool
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "clean",
|
||||
Aliases: []string{"c"},
|
||||
Short: "Delete old log files",
|
||||
Long: `Delete log files older than a specified number of days.`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if global.Config == nil {
|
||||
return fmt.Errorf("config not initialized")
|
||||
}
|
||||
|
||||
logsDir := filepath.Join(global.Config.ConfigPath, "logs")
|
||||
logs, err := listLogFiles(logsDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to list log files: %w", err)
|
||||
}
|
||||
|
||||
var toDelete []logFileInfo
|
||||
if all {
|
||||
toDelete = logs
|
||||
} else {
|
||||
toDelete = filterOldLogs(logs, olderThan)
|
||||
}
|
||||
|
||||
if len(toDelete) == 0 {
|
||||
if all {
|
||||
fmt.Println("No log files to delete.")
|
||||
} else {
|
||||
fmt.Printf("No log files older than %d days found.\n", olderThan)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Calculate total size
|
||||
var totalSize int64
|
||||
for _, log := range toDelete {
|
||||
totalSize += log.size
|
||||
}
|
||||
|
||||
if dryRun {
|
||||
fmt.Println("The following log files will be deleted:\n")
|
||||
if err := renderLogsTable(toDelete, true); err != nil {
|
||||
return err
|
||||
}
|
||||
fileWord := "files"
|
||||
if len(toDelete) == 1 {
|
||||
fileWord = "file"
|
||||
}
|
||||
fmt.Printf("\nSummary: %d %s will be deleted (%s freed)\n", len(toDelete), fileWord, formatFileSize(totalSize))
|
||||
fmt.Println("\nRun without --dry-run to actually delete these files.")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Actually delete the files
|
||||
count, bytesFreed, err := deleteLogFiles(toDelete)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to delete log files: %w", err)
|
||||
}
|
||||
|
||||
fileWord := "files"
|
||||
if count == 1 {
|
||||
fileWord = "file"
|
||||
}
|
||||
fmt.Printf("Deleted %d log %s (%s freed)\n", count, fileWord, formatFileSize(bytesFreed))
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().IntVar(&olderThan, "older-than", 7, "delete logs older than N days")
|
||||
cmd.Flags().BoolVar(&all, "all", false, "delete all log files")
|
||||
cmd.Flags().BoolVar(&dryRun, "dry-run", false, "show what would be deleted without deleting")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newLogsPathCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "path",
|
||||
Short: "Print the logs directory path",
|
||||
Long: `Print the absolute path to the Cline logs directory.`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if global.Config == nil {
|
||||
return fmt.Errorf("config not initialized")
|
||||
}
|
||||
|
||||
logsDir := filepath.Join(global.Config.ConfigPath, "logs")
|
||||
fmt.Println(logsDir)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
// Helper functions
|
||||
|
||||
func listLogFiles(logsDir string) ([]logFileInfo, error) {
|
||||
// Check if logs directory exists
|
||||
if _, err := os.Stat(logsDir); os.IsNotExist(err) {
|
||||
return []logFileInfo{}, nil
|
||||
}
|
||||
|
||||
entries, err := os.ReadDir(logsDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var logs []logFileInfo
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
|
||||
// Only process .log files
|
||||
if !strings.HasSuffix(entry.Name(), ".log") {
|
||||
continue
|
||||
}
|
||||
|
||||
// Parse timestamp from filename
|
||||
created, err := parseTimestampFromFilename(entry.Name())
|
||||
if err != nil {
|
||||
// Skip files we can't parse
|
||||
continue
|
||||
}
|
||||
|
||||
info, err := entry.Info()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
logs = append(logs, logFileInfo{
|
||||
name: entry.Name(),
|
||||
path: filepath.Join(logsDir, entry.Name()),
|
||||
size: info.Size(),
|
||||
created: created,
|
||||
})
|
||||
}
|
||||
|
||||
// Sort by created time (oldest first)
|
||||
sort.Slice(logs, func(i, j int) bool {
|
||||
return logs[i].created.Before(logs[j].created)
|
||||
})
|
||||
|
||||
return logs, nil
|
||||
}
|
||||
|
||||
func parseTimestampFromFilename(filename string) (time.Time, error) {
|
||||
// Expected format: cline-core-2025-10-12-21-30-45-localhost-51051.log
|
||||
// or: cline-host-2025-10-12-21-30-45-localhost-52051.log
|
||||
|
||||
parts := strings.Split(filename, "-")
|
||||
if len(parts) < 8 {
|
||||
return time.Time{}, fmt.Errorf("invalid filename format")
|
||||
}
|
||||
|
||||
// Extract timestamp parts: YYYY-MM-DD-HH-mm-ss
|
||||
// They should be at indices 2-7
|
||||
timestampStr := strings.Join(parts[2:8], "-")
|
||||
|
||||
// Parse as local time since the filename timestamp is created in local time
|
||||
parsedTime, err := time.ParseInLocation("2006-01-02-15-04-05", timestampStr, time.Local)
|
||||
if err != nil {
|
||||
return time.Time{}, err
|
||||
}
|
||||
|
||||
return parsedTime, nil
|
||||
}
|
||||
|
||||
func filterOldLogs(logs []logFileInfo, olderThanDays int) []logFileInfo {
|
||||
cutoff := time.Now().AddDate(0, 0, -olderThanDays)
|
||||
var filtered []logFileInfo
|
||||
|
||||
for _, log := range logs {
|
||||
if log.created.Before(cutoff) {
|
||||
filtered = append(filtered, log)
|
||||
}
|
||||
}
|
||||
|
||||
return filtered
|
||||
}
|
||||
|
||||
func deleteLogFiles(files []logFileInfo) (int, int64, error) {
|
||||
var count int
|
||||
var bytesFreed int64
|
||||
|
||||
for _, file := range files {
|
||||
if err := os.Remove(file.path); err != nil {
|
||||
return count, bytesFreed, err
|
||||
}
|
||||
count++
|
||||
bytesFreed += file.size
|
||||
}
|
||||
|
||||
return count, bytesFreed, nil
|
||||
}
|
||||
|
||||
func formatFileSize(bytes int64) string {
|
||||
const unit = 1024
|
||||
if bytes < unit {
|
||||
return fmt.Sprintf("%d B", bytes)
|
||||
}
|
||||
div, exp := int64(unit), 0
|
||||
for n := bytes / unit; n >= unit; n /= unit {
|
||||
div *= unit
|
||||
exp++
|
||||
}
|
||||
return fmt.Sprintf("%.1f %cB", float64(bytes)/float64(div), "KMGTPE"[exp])
|
||||
}
|
||||
|
||||
func formatAge(t time.Time) string {
|
||||
duration := time.Since(t)
|
||||
|
||||
if duration < time.Hour {
|
||||
minutes := int(duration.Minutes())
|
||||
return fmt.Sprintf("%dm ago", minutes)
|
||||
}
|
||||
|
||||
if duration < 24*time.Hour {
|
||||
hours := int(duration.Hours())
|
||||
return fmt.Sprintf("%dh ago", hours)
|
||||
}
|
||||
|
||||
if duration < 7*24*time.Hour {
|
||||
days := int(duration.Hours() / 24)
|
||||
return fmt.Sprintf("%dd ago", days)
|
||||
}
|
||||
|
||||
weeks := int(duration.Hours() / 24 / 7)
|
||||
return fmt.Sprintf("%dw ago", weeks)
|
||||
}
|
||||
|
||||
func renderLogsTable(logs []logFileInfo, markForDeletion bool) error {
|
||||
// Build table data
|
||||
type tableRow struct {
|
||||
filename string
|
||||
size string
|
||||
created string
|
||||
age string
|
||||
}
|
||||
|
||||
var rows []tableRow
|
||||
for _, log := range logs {
|
||||
rows = append(rows, tableRow{
|
||||
filename: log.name,
|
||||
size: formatFileSize(log.size),
|
||||
created: log.created.Format("2006-01-02 15:04:05"),
|
||||
age: formatAge(log.created),
|
||||
})
|
||||
}
|
||||
|
||||
// Check output format
|
||||
if global.Config.OutputFormat == "plain" {
|
||||
// Use tabwriter for plain output
|
||||
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
|
||||
fmt.Fprintln(w, "FILENAME\tSIZE\tCREATED\tAGE")
|
||||
|
||||
for _, row := range rows {
|
||||
fmt.Fprintf(w, "%s\t%s\t%s\t%s\n",
|
||||
row.filename,
|
||||
row.size,
|
||||
row.created,
|
||||
row.age,
|
||||
)
|
||||
}
|
||||
|
||||
w.Flush()
|
||||
return nil
|
||||
}
|
||||
|
||||
// Use markdown table for rich output
|
||||
colorRenderer := display.NewRenderer(global.Config.OutputFormat)
|
||||
var markdown strings.Builder
|
||||
markdown.WriteString("| **FILENAME** | **SIZE** | **CREATED** | **AGE** |\n")
|
||||
markdown.WriteString("|--------------|----------|-------------|---------|")
|
||||
|
||||
for _, row := range rows {
|
||||
line := fmt.Sprintf("\n| %s | %s | %s | %s |",
|
||||
row.filename,
|
||||
row.size,
|
||||
row.created,
|
||||
row.age,
|
||||
)
|
||||
|
||||
// If marking for deletion, wrap in red
|
||||
if markForDeletion {
|
||||
line = colorRenderer.Red(line)
|
||||
}
|
||||
|
||||
markdown.WriteString(line)
|
||||
}
|
||||
|
||||
// Render the markdown table
|
||||
renderer, err := display.NewMarkdownRendererForTerminal()
|
||||
if err != nil {
|
||||
// Fallback to plain markdown if renderer fails
|
||||
fmt.Println(markdown.String())
|
||||
return nil
|
||||
}
|
||||
|
||||
rendered, err := renderer.Render(markdown.String())
|
||||
if err != nil {
|
||||
fmt.Println(markdown.String())
|
||||
return nil
|
||||
}
|
||||
|
||||
fmt.Print(strings.TrimLeft(rendered, "\n"))
|
||||
fmt.Println()
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -1,167 +0,0 @@
|
||||
package output
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
)
|
||||
|
||||
// SuspendInputMsg tells the input model to suspend and hide
|
||||
type SuspendInputMsg struct{}
|
||||
|
||||
// ResumeInputMsg tells the input model to resume and show
|
||||
type ResumeInputMsg struct{}
|
||||
|
||||
// OutputCoordinator manages terminal output and coordinates with interactive input
|
||||
type OutputCoordinator struct {
|
||||
mu sync.Mutex
|
||||
program *tea.Program
|
||||
inputVisible atomic.Bool
|
||||
inputModel *InputModel // Reference to current input model for state restoration
|
||||
restartCallback func(*InputModel) // Callback to restart the program with preserved state
|
||||
}
|
||||
|
||||
var (
|
||||
globalCoordinator *OutputCoordinator
|
||||
coordinatorMu sync.Mutex
|
||||
)
|
||||
|
||||
// GetCoordinator returns the global output coordinator instance
|
||||
func GetCoordinator() *OutputCoordinator {
|
||||
coordinatorMu.Lock()
|
||||
defer coordinatorMu.Unlock()
|
||||
|
||||
if globalCoordinator == nil {
|
||||
globalCoordinator = &OutputCoordinator{}
|
||||
}
|
||||
return globalCoordinator
|
||||
}
|
||||
|
||||
// SetProgram sets the bubbletea program for input coordination
|
||||
func (oc *OutputCoordinator) SetProgram(program *tea.Program) {
|
||||
oc.mu.Lock()
|
||||
defer oc.mu.Unlock()
|
||||
oc.program = program
|
||||
}
|
||||
|
||||
// SetInputModel sets the current input model reference for state preservation
|
||||
func (oc *OutputCoordinator) SetInputModel(model *InputModel) {
|
||||
oc.mu.Lock()
|
||||
defer oc.mu.Unlock()
|
||||
oc.inputModel = model
|
||||
}
|
||||
|
||||
// SetRestartCallback sets the callback for restarting the program
|
||||
func (oc *OutputCoordinator) SetRestartCallback(callback func(*InputModel)) {
|
||||
oc.mu.Lock()
|
||||
defer oc.mu.Unlock()
|
||||
oc.restartCallback = callback
|
||||
}
|
||||
|
||||
// SetInputVisible sets whether input is currently visible
|
||||
func (oc *OutputCoordinator) SetInputVisible(visible bool) {
|
||||
oc.inputVisible.Store(visible)
|
||||
}
|
||||
|
||||
// IsInputVisible returns whether input is currently visible
|
||||
func (oc *OutputCoordinator) IsInputVisible() bool {
|
||||
return oc.inputVisible.Load()
|
||||
}
|
||||
|
||||
// Printf prints formatted output, suspending input if necessary
|
||||
func (oc *OutputCoordinator) Printf(format string, args ...interface{}) {
|
||||
oc.mu.Lock()
|
||||
prog := oc.program
|
||||
model := oc.inputModel
|
||||
restart := oc.restartCallback
|
||||
visible := oc.inputVisible.Load()
|
||||
oc.mu.Unlock()
|
||||
|
||||
if visible && prog != nil && restart != nil && model != nil {
|
||||
// Kill/restart approach: completely stop the program, print, restart with state
|
||||
|
||||
// 1. Save the current input state (text, cursor position, etc.)
|
||||
savedModel := model.Clone()
|
||||
|
||||
// 2. Manually clear the form from terminal BEFORE quitting
|
||||
clearCodes := model.ClearScreen()
|
||||
if clearCodes != "" {
|
||||
fmt.Print(clearCodes)
|
||||
}
|
||||
|
||||
// 3. Quit the program
|
||||
prog.Send(Quit())
|
||||
|
||||
// Small delay to let program actually quit
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
|
||||
// 4. Print the output
|
||||
fmt.Printf(format, args...)
|
||||
|
||||
// 5. Restart with preserved state
|
||||
restart(savedModel)
|
||||
} else {
|
||||
// No input showing, just print normally
|
||||
fmt.Printf(format, args...)
|
||||
}
|
||||
}
|
||||
|
||||
// Println prints a line with newline, suspending input if necessary
|
||||
func (oc *OutputCoordinator) Println(args ...interface{}) {
|
||||
oc.Printf("%s\n", fmt.Sprint(args...))
|
||||
}
|
||||
|
||||
// Print prints output, suspending input if necessary
|
||||
func (oc *OutputCoordinator) Print(args ...interface{}) {
|
||||
oc.Printf("%s", fmt.Sprint(args...))
|
||||
}
|
||||
|
||||
// Package-level convenience functions
|
||||
|
||||
// Printf prints formatted output via the global coordinator
|
||||
func Printf(format string, args ...interface{}) {
|
||||
GetCoordinator().Printf(format, args...)
|
||||
}
|
||||
|
||||
// Println prints a line with newline via the global coordinator
|
||||
func Println(args ...interface{}) {
|
||||
GetCoordinator().Println(args...)
|
||||
}
|
||||
|
||||
// Print prints output via the global coordinator
|
||||
func Print(args ...interface{}) {
|
||||
GetCoordinator().Print(args...)
|
||||
}
|
||||
|
||||
// SetProgram sets the bubbletea program on the global coordinator
|
||||
func SetProgram(program *tea.Program) {
|
||||
GetCoordinator().SetProgram(program)
|
||||
}
|
||||
|
||||
// SetInputVisible sets input visibility on the global coordinator
|
||||
func SetInputVisible(visible bool) {
|
||||
GetCoordinator().SetInputVisible(visible)
|
||||
}
|
||||
|
||||
// IsInputVisible checks input visibility on the global coordinator
|
||||
func IsInputVisible() bool {
|
||||
return GetCoordinator().IsInputVisible()
|
||||
}
|
||||
|
||||
// SetInputModel sets the input model on the global coordinator
|
||||
func SetInputModel(model *InputModel) {
|
||||
GetCoordinator().SetInputModel(model)
|
||||
}
|
||||
|
||||
// SetRestartCallback sets the restart callback on the global coordinator
|
||||
func SetRestartCallback(callback func(*InputModel)) {
|
||||
GetCoordinator().SetRestartCallback(callback)
|
||||
}
|
||||
|
||||
// Quit returns a Bubble Tea quit message
|
||||
func Quit() tea.Msg {
|
||||
return tea.Quit()
|
||||
}
|
||||
@@ -1,620 +0,0 @@
|
||||
package output
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/bubbles/textarea"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/cline/cli/pkg/cli/slash"
|
||||
)
|
||||
|
||||
// InputType represents the type of input being collected
|
||||
type InputType int
|
||||
|
||||
const INPUT_WIDTH = 46
|
||||
|
||||
const (
|
||||
InputTypeMessage InputType = iota
|
||||
InputTypeApproval
|
||||
InputTypeFeedback
|
||||
)
|
||||
|
||||
// InputSubmitMsg is sent when the user submits input
|
||||
type InputSubmitMsg struct {
|
||||
Value string
|
||||
InputType InputType
|
||||
Approved bool // For approval type
|
||||
NeedsFeedback bool // For approval type
|
||||
NoAskAgain bool // For approval type - indicates "don't ask again" was selected
|
||||
}
|
||||
|
||||
// InputCancelMsg is sent when the user cancels input (Ctrl+C)
|
||||
type InputCancelMsg struct{}
|
||||
|
||||
// ChangeInputTypeMsg changes the current input type
|
||||
type ChangeInputTypeMsg struct {
|
||||
InputType InputType
|
||||
Title string
|
||||
Placeholder string
|
||||
}
|
||||
|
||||
// editorFinishedMsg is sent when the external editor finishes
|
||||
type editorFinishedMsg struct {
|
||||
content []byte
|
||||
err error
|
||||
}
|
||||
|
||||
// InputModel is the bubbletea model for interactive input
|
||||
type InputModel struct {
|
||||
textarea textarea.Model
|
||||
suspended bool
|
||||
savedValue string
|
||||
inputType InputType
|
||||
title string
|
||||
placeholder string
|
||||
currentMode string // "plan" or "act"
|
||||
width int
|
||||
lastHeight int // Track height for cleanup on submit
|
||||
|
||||
// For approval type
|
||||
approvalOptions []string
|
||||
selectedOption int
|
||||
pendingApproval bool // Stores approval decision when transitioning to feedback input
|
||||
|
||||
// Styles (huh-inspired theme)
|
||||
styles fieldStyles
|
||||
|
||||
// Slash command autocomplete dropdown
|
||||
completion CompletionModel
|
||||
}
|
||||
|
||||
// fieldStyles holds the styling for the input field
|
||||
type fieldStyles struct {
|
||||
base lipgloss.Style
|
||||
title lipgloss.Style
|
||||
textArea lipgloss.Style
|
||||
cursor lipgloss.Style
|
||||
placeholder lipgloss.Style
|
||||
selector lipgloss.Style
|
||||
selectedOption lipgloss.Style
|
||||
option lipgloss.Style
|
||||
}
|
||||
|
||||
// newFieldStyles creates huh-inspired styles (Charm theme)
|
||||
func newFieldStyles() fieldStyles {
|
||||
// Charm theme colors
|
||||
indigo := lipgloss.AdaptiveColor{Light: "#5A56E0", Dark: "#7571F9"}
|
||||
fuchsia := lipgloss.Color("#F780E2")
|
||||
normalFg := lipgloss.AdaptiveColor{Light: "235", Dark: "252"}
|
||||
green := lipgloss.AdaptiveColor{Light: "#02BA84", Dark: "#02BF87"}
|
||||
|
||||
return fieldStyles{
|
||||
base: lipgloss.NewStyle().
|
||||
PaddingLeft(1).
|
||||
BorderStyle(lipgloss.ThickBorder()).
|
||||
BorderLeft(true).
|
||||
BorderForeground(lipgloss.Color("238")),
|
||||
title: lipgloss.NewStyle().
|
||||
Foreground(indigo).
|
||||
Bold(true),
|
||||
textArea: lipgloss.NewStyle().
|
||||
Foreground(normalFg),
|
||||
cursor: lipgloss.NewStyle().
|
||||
Foreground(green),
|
||||
placeholder: lipgloss.NewStyle().
|
||||
Foreground(lipgloss.AdaptiveColor{Light: "248", Dark: "238"}),
|
||||
selector: lipgloss.NewStyle().
|
||||
Foreground(fuchsia).
|
||||
SetString("> "),
|
||||
selectedOption: lipgloss.NewStyle().
|
||||
Foreground(normalFg),
|
||||
option: lipgloss.NewStyle().
|
||||
Foreground(normalFg),
|
||||
}
|
||||
}
|
||||
|
||||
// NewInputModel creates a new input model
|
||||
func NewInputModel(inputType InputType, title, placeholder, currentMode string) InputModel {
|
||||
return NewInputModelWithRegistry(inputType, title, placeholder, currentMode, nil)
|
||||
}
|
||||
|
||||
// NewInputModelWithRegistry creates a new input model with slash command autocomplete support
|
||||
func NewInputModelWithRegistry(inputType InputType, title, placeholder, currentMode string, registry *slash.Registry) InputModel {
|
||||
ta := textarea.New()
|
||||
ta.Placeholder = placeholder
|
||||
ta.Focus()
|
||||
ta.CharLimit = 0
|
||||
ta.ShowLineNumbers = false
|
||||
ta.Prompt = "" // Remove prompt prefix (this is what adds the inner border!)
|
||||
ta.SetHeight(5)
|
||||
// Don't set width here - let WindowSizeMsg handle it
|
||||
ta.SetWidth(INPUT_WIDTH)
|
||||
|
||||
// Configure keybindings like huh does:
|
||||
// alt+enter and ctrl+j for newlines (textarea will handle these)
|
||||
ta.KeyMap.InsertNewline.SetKeys("alt+enter", "ctrl+j")
|
||||
|
||||
// Apply huh-like styling
|
||||
styles := newFieldStyles()
|
||||
|
||||
// Set cursor color based on mode
|
||||
cursorColor := lipgloss.Color("3") // Yellow for plan
|
||||
if currentMode == "act" {
|
||||
cursorColor = lipgloss.Color("39") // Blue for act
|
||||
}
|
||||
|
||||
ta.FocusedStyle.CursorLine = lipgloss.NewStyle() // No cursor line highlighting
|
||||
ta.FocusedStyle.EndOfBuffer = lipgloss.NewStyle() // No end-of-buffer styling
|
||||
ta.FocusedStyle.Placeholder = styles.placeholder
|
||||
ta.FocusedStyle.Text = styles.textArea
|
||||
ta.FocusedStyle.Prompt = lipgloss.NewStyle() // No prompt styling
|
||||
ta.Cursor.Style = lipgloss.NewStyle().Foreground(cursorColor)
|
||||
ta.Cursor.TextStyle = styles.textArea
|
||||
|
||||
m := InputModel{
|
||||
textarea: ta,
|
||||
inputType: inputType,
|
||||
title: title,
|
||||
placeholder: placeholder,
|
||||
currentMode: currentMode,
|
||||
width: 0, // Will be set by first WindowSizeMsg
|
||||
styles: styles,
|
||||
completion: NewCompletionModel(registry),
|
||||
}
|
||||
|
||||
// For approval type, set up options
|
||||
if inputType == InputTypeApproval {
|
||||
m.approvalOptions = []string{
|
||||
"Yes",
|
||||
"Yes, and don't ask again for this task",
|
||||
"No, with feedback",
|
||||
}
|
||||
m.selectedOption = 0
|
||||
}
|
||||
|
||||
return m
|
||||
}
|
||||
|
||||
// Init initializes the model
|
||||
func (m *InputModel) Init() tea.Cmd {
|
||||
return textarea.Blink
|
||||
}
|
||||
|
||||
// Update handles messages
|
||||
func (m *InputModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
var cmd tea.Cmd
|
||||
|
||||
switch msg := msg.(type) {
|
||||
case editorFinishedMsg:
|
||||
// External editor finished
|
||||
if msg.err == nil && len(msg.content) > 0 {
|
||||
m.textarea.SetValue(string(msg.content))
|
||||
}
|
||||
return m, nil
|
||||
|
||||
case SuspendInputMsg:
|
||||
// Save current value and suspend
|
||||
m.savedValue = m.textarea.Value()
|
||||
m.suspended = true
|
||||
return m, tea.ClearScreen
|
||||
|
||||
case ResumeInputMsg:
|
||||
// Restore value and resume
|
||||
m.textarea.SetValue(m.savedValue)
|
||||
m.suspended = false
|
||||
return m, nil
|
||||
|
||||
case ChangeInputTypeMsg:
|
||||
// Change input type (e.g., from approval to feedback)
|
||||
m.inputType = msg.InputType
|
||||
m.title = msg.Title
|
||||
m.placeholder = msg.Placeholder
|
||||
m.textarea.Placeholder = msg.Placeholder
|
||||
m.textarea.SetValue("")
|
||||
m.textarea.Focus()
|
||||
|
||||
if msg.InputType == InputTypeApproval {
|
||||
m.approvalOptions = []string{
|
||||
"Yes",
|
||||
"Yes, and don't ask again for this task",
|
||||
"No, with feedback",
|
||||
}
|
||||
m.selectedOption = 0
|
||||
}
|
||||
return m, nil
|
||||
|
||||
case tea.KeyMsg:
|
||||
if m.suspended {
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// Handle keys for text input types (Message/Feedback)
|
||||
if m.inputType == InputTypeMessage || m.inputType == InputTypeFeedback {
|
||||
// When completion menu is visible, let it handle navigation keys first
|
||||
if m.completion.Visible() {
|
||||
// ctrl+c always cancels, even with dropdown open
|
||||
if msg.String() == "ctrl+c" {
|
||||
return m, func() tea.Msg { return InputCancelMsg{} }
|
||||
}
|
||||
|
||||
var handled bool
|
||||
m.completion, cmd, handled = m.completion.Update(msg)
|
||||
if handled {
|
||||
// Check if a completion was selected
|
||||
if applied := m.completion.Apply(); applied != "" {
|
||||
m.textarea.SetValue(applied)
|
||||
m.textarea.CursorEnd()
|
||||
}
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
// Key not handled by completion - pass to textarea and update completion
|
||||
m.textarea, cmd = m.textarea.Update(msg)
|
||||
m.completion.CheckInput(m.textarea.Value())
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
// Normal key handling when completion menu is NOT visible
|
||||
switch msg.String() {
|
||||
case "ctrl+c":
|
||||
return m, func() tea.Msg { return InputCancelMsg{} }
|
||||
|
||||
case "ctrl+e":
|
||||
// Open external editor (like huh does)
|
||||
return m, m.openEditor()
|
||||
|
||||
case "tab":
|
||||
// Tab without dropdown visible - do nothing special
|
||||
m.textarea, cmd = m.textarea.Update(msg)
|
||||
return m, cmd
|
||||
|
||||
case "enter":
|
||||
// Intercept enter for submit (textarea handles alt+enter and ctrl+j for newlines)
|
||||
return m.handleSubmit()
|
||||
|
||||
case "up", "down", "left", "right":
|
||||
// Let textarea handle navigation
|
||||
m.textarea, cmd = m.textarea.Update(msg)
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
// Pass all other keys to textarea, then check for slash completion
|
||||
m.textarea, cmd = m.textarea.Update(msg)
|
||||
m.completion.CheckInput(m.textarea.Value())
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
// Handle keys for approval type
|
||||
if m.inputType == InputTypeApproval {
|
||||
switch msg.String() {
|
||||
case "ctrl+c":
|
||||
return m, func() tea.Msg { return InputCancelMsg{} }
|
||||
|
||||
case "enter":
|
||||
return m.handleSubmit()
|
||||
|
||||
case "up":
|
||||
if m.selectedOption > 0 {
|
||||
m.selectedOption--
|
||||
}
|
||||
return m, nil
|
||||
|
||||
case "down":
|
||||
if m.selectedOption < len(m.approvalOptions)-1 {
|
||||
m.selectedOption++
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
}
|
||||
|
||||
default:
|
||||
// Forward all other messages to textarea (including blink ticks)
|
||||
if !m.suspended && (m.inputType == InputTypeMessage || m.inputType == InputTypeFeedback) {
|
||||
m.textarea, cmd = m.textarea.Update(msg)
|
||||
return m, cmd
|
||||
}
|
||||
}
|
||||
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// handleSubmit handles submission based on input type
|
||||
func (m *InputModel) handleSubmit() (tea.Model, tea.Cmd) {
|
||||
switch m.inputType {
|
||||
case InputTypeMessage:
|
||||
value := strings.TrimSpace(m.textarea.Value())
|
||||
return m, func() tea.Msg {
|
||||
return InputSubmitMsg{
|
||||
Value: value,
|
||||
InputType: InputTypeMessage,
|
||||
}
|
||||
}
|
||||
|
||||
case InputTypeApproval:
|
||||
selected := m.approvalOptions[m.selectedOption]
|
||||
approved := strings.HasPrefix(selected, "Yes")
|
||||
needsFeedback := strings.Contains(selected, "feedback")
|
||||
noAskAgain := strings.Contains(selected, "don't ask again")
|
||||
|
||||
if needsFeedback {
|
||||
// Store the approval decision before switching to feedback input
|
||||
m.pendingApproval = approved
|
||||
// Switch to feedback input
|
||||
return m, func() tea.Msg {
|
||||
return ChangeInputTypeMsg{
|
||||
InputType: InputTypeFeedback,
|
||||
Title: "Your feedback",
|
||||
Placeholder: "/plan or /act to switch modes\nctrl+e to open editor\nctrl+c to exit",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return m, func() tea.Msg {
|
||||
return InputSubmitMsg{
|
||||
Value: "",
|
||||
InputType: InputTypeApproval,
|
||||
Approved: approved,
|
||||
NeedsFeedback: false,
|
||||
NoAskAgain: noAskAgain,
|
||||
}
|
||||
}
|
||||
|
||||
case InputTypeFeedback:
|
||||
value := strings.TrimSpace(m.textarea.Value())
|
||||
return m, func() tea.Msg {
|
||||
return InputSubmitMsg{
|
||||
Value: value,
|
||||
InputType: InputTypeFeedback,
|
||||
Approved: m.pendingApproval, // Pass the stored approval decision
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// View renders the model
|
||||
func (m *InputModel) View() string {
|
||||
if m.suspended {
|
||||
return ""
|
||||
}
|
||||
|
||||
var parts []string
|
||||
|
||||
// Render title with mode indicator
|
||||
yellow := lipgloss.Color("3")
|
||||
blue := lipgloss.Color("39")
|
||||
|
||||
modeStyle := lipgloss.NewStyle().Bold(true)
|
||||
if m.currentMode == "plan" {
|
||||
modeStyle = modeStyle.Foreground(yellow)
|
||||
} else {
|
||||
modeStyle = modeStyle.Foreground(blue)
|
||||
}
|
||||
|
||||
modeIndicator := modeStyle.Render(fmt.Sprintf("[%s mode]", m.currentMode))
|
||||
titleText := m.styles.title.Render(m.title)
|
||||
fullTitle := fmt.Sprintf("%s %s", modeIndicator, titleText)
|
||||
parts = append(parts, fullTitle)
|
||||
|
||||
// Render based on input type
|
||||
switch m.inputType {
|
||||
case InputTypeMessage, InputTypeFeedback:
|
||||
parts = append(parts, m.textarea.View())
|
||||
|
||||
// Render completion dropdown if visible
|
||||
if m.completion.Visible() {
|
||||
parts = append(parts, m.completion.View())
|
||||
}
|
||||
|
||||
case InputTypeApproval:
|
||||
var options []string
|
||||
for i, option := range m.approvalOptions {
|
||||
if i == m.selectedOption {
|
||||
options = append(options, m.styles.selector.Render("")+m.styles.selectedOption.Render(option))
|
||||
} else {
|
||||
options = append(options, " "+m.styles.option.Render(option))
|
||||
}
|
||||
}
|
||||
parts = append(parts, strings.Join(options, "\n"))
|
||||
}
|
||||
|
||||
// Wrap everything in the base style with border
|
||||
content := strings.Join(parts, "\n")
|
||||
rendered := m.styles.base.Render(content)
|
||||
|
||||
// Add newline before the form (outside the border)
|
||||
rendered = "\n" + rendered
|
||||
|
||||
// Track height for cleanup
|
||||
m.lastHeight = lipgloss.Height(rendered)
|
||||
|
||||
return rendered
|
||||
}
|
||||
|
||||
// ClearScreen returns the ANSI codes to clear the input from the terminal
|
||||
// This is used when submitting to remove the form cleanly
|
||||
func (m *InputModel) ClearScreen() string {
|
||||
if m.lastHeight == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Move cursor up by lastHeight lines and clear from cursor to end of screen
|
||||
return fmt.Sprintf("\033[%dA\033[J", m.lastHeight)
|
||||
}
|
||||
|
||||
// Clone creates a deep copy of the InputModel with all state preserved
|
||||
func (m *InputModel) Clone() *InputModel {
|
||||
// Create new textarea with same configuration
|
||||
ta := textarea.New()
|
||||
ta.SetValue(m.textarea.Value())
|
||||
ta.Placeholder = m.placeholder
|
||||
ta.CharLimit = 0
|
||||
ta.ShowLineNumbers = false
|
||||
ta.Prompt = ""
|
||||
ta.SetHeight(5)
|
||||
ta.SetWidth(INPUT_WIDTH)
|
||||
ta.Focus()
|
||||
|
||||
// Configure keybindings
|
||||
ta.KeyMap.InsertNewline.SetKeys("alt+enter", "ctrl+j")
|
||||
|
||||
// Apply styles (including mode-based cursor color)
|
||||
cursorColor := lipgloss.Color("3") // Yellow for plan
|
||||
if m.currentMode == "act" {
|
||||
cursorColor = lipgloss.Color("39") // Blue for act
|
||||
}
|
||||
|
||||
ta.FocusedStyle.CursorLine = lipgloss.NewStyle()
|
||||
ta.FocusedStyle.EndOfBuffer = lipgloss.NewStyle()
|
||||
ta.FocusedStyle.Placeholder = m.styles.placeholder
|
||||
ta.FocusedStyle.Text = m.styles.textArea
|
||||
ta.FocusedStyle.Prompt = lipgloss.NewStyle()
|
||||
ta.Cursor.Style = lipgloss.NewStyle().Foreground(cursorColor)
|
||||
ta.Cursor.TextStyle = m.styles.textArea
|
||||
|
||||
// Create cloned model
|
||||
clone := &InputModel{
|
||||
textarea: ta,
|
||||
suspended: false, // New program starts unsuspended
|
||||
savedValue: m.savedValue,
|
||||
inputType: m.inputType,
|
||||
title: m.title,
|
||||
placeholder: m.placeholder,
|
||||
currentMode: m.currentMode,
|
||||
width: m.width,
|
||||
lastHeight: m.lastHeight,
|
||||
approvalOptions: m.approvalOptions,
|
||||
selectedOption: m.selectedOption,
|
||||
pendingApproval: m.pendingApproval, // Preserve approval decision
|
||||
styles: m.styles,
|
||||
completion: NewCompletionModel(m.completion.registry), // Preserve registry, start fresh state
|
||||
}
|
||||
|
||||
return clone
|
||||
}
|
||||
|
||||
// openEditor opens an external editor for composing the message
|
||||
func (m *InputModel) openEditor() tea.Cmd {
|
||||
// Get editor from environment or use nano as default
|
||||
editorCmd := "nano"
|
||||
editorArgs := []string{}
|
||||
|
||||
if editor := os.Getenv("EDITOR"); editor != "" {
|
||||
editorFields := strings.Fields(editor)
|
||||
if len(editorFields) > 0 {
|
||||
editorCmd = editorFields[0]
|
||||
if len(editorFields) > 1 {
|
||||
editorArgs = editorFields[1:]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create temp file with current content
|
||||
tmpFile, err := os.CreateTemp(os.TempDir(), "*.md")
|
||||
if err != nil {
|
||||
return func() tea.Msg {
|
||||
return editorFinishedMsg{err: err}
|
||||
}
|
||||
}
|
||||
|
||||
// Write current textarea value to temp file
|
||||
if err := os.WriteFile(tmpFile.Name(), []byte(m.textarea.Value()), 0o644); err != nil {
|
||||
return func() tea.Msg {
|
||||
return editorFinishedMsg{err: err}
|
||||
}
|
||||
}
|
||||
|
||||
// Open the editor
|
||||
cmd := exec.Command(editorCmd, append(editorArgs, tmpFile.Name())...)
|
||||
return tea.ExecProcess(cmd, func(err error) tea.Msg {
|
||||
content, readErr := os.ReadFile(tmpFile.Name())
|
||||
_ = os.Remove(tmpFile.Name())
|
||||
|
||||
if readErr != nil {
|
||||
return editorFinishedMsg{err: readErr}
|
||||
}
|
||||
|
||||
return editorFinishedMsg{content: content, err: err}
|
||||
})
|
||||
}
|
||||
|
||||
// SetSlashRegistry sets the slash command registry for autocomplete
|
||||
func (m *InputModel) SetSlashRegistry(registry *slash.Registry) {
|
||||
m.completion.SetRegistry(registry)
|
||||
}
|
||||
|
||||
// initialPromptWrapper wraps InputModel to capture the submit result for initial task prompts
|
||||
type initialPromptWrapper struct {
|
||||
model *InputModel
|
||||
result string
|
||||
cancelled bool
|
||||
}
|
||||
|
||||
func (w *initialPromptWrapper) Init() tea.Cmd {
|
||||
return w.model.Init()
|
||||
}
|
||||
|
||||
func (w *initialPromptWrapper) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case InputSubmitMsg:
|
||||
w.result = msg.Value
|
||||
clearCodes := w.model.ClearScreen()
|
||||
if clearCodes != "" {
|
||||
fmt.Print(clearCodes)
|
||||
}
|
||||
return w, tea.Quit
|
||||
|
||||
case InputCancelMsg:
|
||||
w.cancelled = true
|
||||
clearCodes := w.model.ClearScreen()
|
||||
if clearCodes != "" {
|
||||
fmt.Print(clearCodes)
|
||||
}
|
||||
return w, tea.Quit
|
||||
}
|
||||
|
||||
// Forward to wrapped model
|
||||
_, cmd := w.model.Update(msg)
|
||||
return w, cmd
|
||||
}
|
||||
|
||||
func (w *initialPromptWrapper) View() string {
|
||||
return w.model.View()
|
||||
}
|
||||
|
||||
// ErrUserAborted is returned when the user cancels the input prompt
|
||||
var ErrUserAborted = fmt.Errorf("user aborted")
|
||||
|
||||
// PromptForInitialTask displays an interactive prompt for the initial task with slash command autocomplete.
|
||||
// Returns the entered text, or ErrUserAborted if cancelled.
|
||||
func PromptForInitialTask(title, placeholder, mode string, registry *slash.Registry) (string, error) {
|
||||
model := NewInputModelWithRegistry(
|
||||
InputTypeMessage,
|
||||
title,
|
||||
placeholder,
|
||||
mode,
|
||||
registry,
|
||||
)
|
||||
|
||||
wrapper := &initialPromptWrapper{
|
||||
model: &model,
|
||||
}
|
||||
|
||||
p := tea.NewProgram(wrapper)
|
||||
|
||||
_, err := p.Run()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("input prompt failed: %w", err)
|
||||
}
|
||||
|
||||
if wrapper.cancelled {
|
||||
return "", ErrUserAborted
|
||||
}
|
||||
|
||||
return strings.TrimSpace(wrapper.result), nil
|
||||
}
|
||||
@@ -1,265 +0,0 @@
|
||||
package output
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/cline/cli/pkg/cli/slash"
|
||||
)
|
||||
|
||||
const maxVisibleCompletions = 7
|
||||
|
||||
// completionStyles holds the styling for the completion dropdown
|
||||
type completionStyles struct {
|
||||
menu lipgloss.Style
|
||||
selected lipgloss.Style
|
||||
normalName lipgloss.Style
|
||||
description lipgloss.Style
|
||||
scrollIndicator lipgloss.Style
|
||||
}
|
||||
|
||||
// newCompletionStyles creates the default styles for the completion dropdown
|
||||
func newCompletionStyles() completionStyles {
|
||||
return completionStyles{
|
||||
menu: lipgloss.NewStyle().
|
||||
Border(lipgloss.RoundedBorder()).
|
||||
BorderForeground(lipgloss.Color("238")).
|
||||
Padding(0, 1),
|
||||
selected: lipgloss.NewStyle().
|
||||
Background(lipgloss.Color("62")).
|
||||
Foreground(lipgloss.Color("230")),
|
||||
normalName: lipgloss.NewStyle().
|
||||
Foreground(lipgloss.AdaptiveColor{Light: "235", Dark: "252"}),
|
||||
description: lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("243")),
|
||||
scrollIndicator: lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("243")),
|
||||
}
|
||||
}
|
||||
|
||||
// CompletionModel is a Bubbletea model for slash command autocomplete dropdown
|
||||
type CompletionModel struct {
|
||||
registry *slash.Registry
|
||||
visible bool
|
||||
matches []slash.Command
|
||||
index int // selected item (0-based)
|
||||
scroll int // scroll offset for long lists
|
||||
styles completionStyles
|
||||
|
||||
// pendingApply holds the command to apply after selection
|
||||
pendingApply string
|
||||
}
|
||||
|
||||
// NewCompletionModel creates a new completion model with the given registry
|
||||
func NewCompletionModel(registry *slash.Registry) CompletionModel {
|
||||
return CompletionModel{
|
||||
registry: registry,
|
||||
styles: newCompletionStyles(),
|
||||
}
|
||||
}
|
||||
|
||||
// SetRegistry sets the slash command registry
|
||||
func (m *CompletionModel) SetRegistry(registry *slash.Registry) {
|
||||
m.registry = registry
|
||||
}
|
||||
|
||||
// Visible returns whether the completion dropdown is currently visible
|
||||
func (m CompletionModel) Visible() bool {
|
||||
return m.visible
|
||||
}
|
||||
|
||||
// Update handles key messages for the completion dropdown.
|
||||
// Returns the updated model, any commands, and whether the key was handled.
|
||||
// If handled is true, the parent should NOT pass the key to the textarea.
|
||||
func (m CompletionModel) Update(msg tea.Msg) (CompletionModel, tea.Cmd, bool) {
|
||||
if !m.visible {
|
||||
return m, nil, false
|
||||
}
|
||||
|
||||
keyMsg, ok := msg.(tea.KeyMsg)
|
||||
if !ok {
|
||||
return m, nil, false
|
||||
}
|
||||
|
||||
switch keyMsg.String() {
|
||||
case "up":
|
||||
m.navigateUp()
|
||||
return m, nil, true
|
||||
|
||||
case "down":
|
||||
m.navigateDown()
|
||||
return m, nil, true
|
||||
|
||||
case "tab", "enter":
|
||||
// Select the current completion
|
||||
if len(m.matches) > 0 {
|
||||
selected := m.matches[m.index]
|
||||
m.pendingApply = "/" + selected.Name + " "
|
||||
}
|
||||
m.Hide()
|
||||
return m, nil, true
|
||||
|
||||
case "esc":
|
||||
m.Hide()
|
||||
return m, nil, true
|
||||
}
|
||||
|
||||
// Key not handled by completion - let parent process it
|
||||
return m, nil, false
|
||||
}
|
||||
|
||||
// CheckInput updates the completion state based on the current input value.
|
||||
// Call this after each input change to show/hide/update the dropdown.
|
||||
func (m *CompletionModel) CheckInput(value string) {
|
||||
if m.registry == nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Only activate if input starts with "/" (first character requirement)
|
||||
if !strings.HasPrefix(value, "/") {
|
||||
m.Hide()
|
||||
return
|
||||
}
|
||||
|
||||
// Extract the command being typed (everything after "/" until space/newline)
|
||||
rest := value[1:] // Everything after the "/"
|
||||
|
||||
// If there's whitespace, the command is complete - hide dropdown
|
||||
if idx := strings.IndexAny(rest, " \n\t"); idx != -1 {
|
||||
m.Hide()
|
||||
return
|
||||
}
|
||||
|
||||
// Update matches based on prefix
|
||||
m.updateMatches(rest)
|
||||
m.visible = len(m.matches) > 0
|
||||
}
|
||||
|
||||
// Apply returns the command string to insert (if any) and clears the pending state.
|
||||
// The parent should call this after Update returns handled=true for tab/enter.
|
||||
func (m *CompletionModel) Apply() string {
|
||||
result := m.pendingApply
|
||||
m.pendingApply = ""
|
||||
return result
|
||||
}
|
||||
|
||||
// Hide hides the completion dropdown and resets state
|
||||
func (m *CompletionModel) Hide() {
|
||||
m.visible = false
|
||||
m.matches = nil
|
||||
m.index = 0
|
||||
m.scroll = 0
|
||||
}
|
||||
|
||||
// View renders the completion dropdown
|
||||
func (m CompletionModel) View() string {
|
||||
if !m.visible || len(m.matches) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
var lines []string
|
||||
|
||||
// Calculate visible range
|
||||
endIdx := min(m.scroll+maxVisibleCompletions, len(m.matches))
|
||||
|
||||
// Show scroll indicator if there are items above
|
||||
if m.scroll > 0 {
|
||||
lines = append(lines, m.styles.scrollIndicator.Render(" ↑ more"))
|
||||
}
|
||||
|
||||
// Find the longest command name for alignment
|
||||
maxNameLen := 0
|
||||
for _, cmd := range m.matches {
|
||||
nameLen := len(cmd.Name) + 1 // +1 for the "/"
|
||||
if nameLen > maxNameLen {
|
||||
maxNameLen = nameLen
|
||||
}
|
||||
}
|
||||
// Cap at reasonable width
|
||||
if maxNameLen > 15 {
|
||||
maxNameLen = 15
|
||||
}
|
||||
|
||||
// Render visible items
|
||||
for i := m.scroll; i < endIdx; i++ {
|
||||
cmd := m.matches[i]
|
||||
name := "/" + cmd.Name
|
||||
desc := cmd.Description
|
||||
|
||||
// Truncate description if too long
|
||||
maxDescLen := 35
|
||||
if len(desc) > maxDescLen {
|
||||
desc = desc[:maxDescLen-3] + "..."
|
||||
}
|
||||
|
||||
// Pad name for alignment
|
||||
paddedName := fmt.Sprintf("%-*s", maxNameLen, name)
|
||||
|
||||
if i == m.index {
|
||||
// Selected item - highlight the entire line
|
||||
line := fmt.Sprintf("> %s %s", paddedName, desc)
|
||||
lines = append(lines, m.styles.selected.Render(line))
|
||||
} else {
|
||||
// Normal item
|
||||
line := fmt.Sprintf(" %s %s", m.styles.normalName.Render(paddedName), m.styles.description.Render(desc))
|
||||
lines = append(lines, line)
|
||||
}
|
||||
}
|
||||
|
||||
// Show scroll indicator if there are items below
|
||||
if endIdx < len(m.matches) {
|
||||
lines = append(lines, m.styles.scrollIndicator.Render(" ↓ more"))
|
||||
}
|
||||
|
||||
return m.styles.menu.Render(strings.Join(lines, "\n"))
|
||||
}
|
||||
|
||||
// updateMatches filters commands by prefix and updates the matches list
|
||||
func (m *CompletionModel) updateMatches(prefix string) {
|
||||
if m.registry == nil {
|
||||
m.matches = nil
|
||||
return
|
||||
}
|
||||
m.matches = m.registry.GetMatching(prefix)
|
||||
// Reset selection if out of bounds
|
||||
if m.index >= len(m.matches) {
|
||||
m.index = 0
|
||||
m.scroll = 0
|
||||
}
|
||||
m.adjustScroll()
|
||||
}
|
||||
|
||||
// navigateUp moves selection up in the dropdown
|
||||
func (m *CompletionModel) navigateUp() {
|
||||
if len(m.matches) == 0 {
|
||||
return
|
||||
}
|
||||
m.index--
|
||||
if m.index < 0 {
|
||||
m.index = len(m.matches) - 1
|
||||
}
|
||||
m.adjustScroll()
|
||||
}
|
||||
|
||||
// navigateDown moves selection down in the dropdown
|
||||
func (m *CompletionModel) navigateDown() {
|
||||
if len(m.matches) == 0 {
|
||||
return
|
||||
}
|
||||
m.index++
|
||||
if m.index >= len(m.matches) {
|
||||
m.index = 0
|
||||
}
|
||||
m.adjustScroll()
|
||||
}
|
||||
|
||||
// adjustScroll ensures the selected item is visible in the dropdown
|
||||
func (m *CompletionModel) adjustScroll() {
|
||||
if m.index < m.scroll {
|
||||
m.scroll = m.index
|
||||
} else if m.index >= m.scroll+maxVisibleCompletions {
|
||||
m.scroll = m.index - maxVisibleCompletions + 1
|
||||
}
|
||||
}
|
||||
@@ -1,142 +0,0 @@
|
||||
package slash
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/cline/cli/pkg/cli/global"
|
||||
"github.com/cline/grpc-go/cline"
|
||||
)
|
||||
|
||||
// Command represents a slash command available for autocomplete
|
||||
type Command struct {
|
||||
Name string
|
||||
Description string
|
||||
Section string // "default", "custom", or "cli"
|
||||
CLICompatible bool
|
||||
}
|
||||
|
||||
// Registry holds available slash commands for autocomplete
|
||||
type Registry struct {
|
||||
mu sync.RWMutex
|
||||
commands []Command
|
||||
}
|
||||
|
||||
// CLI-local commands (handled by CLI, not sent to backend)
|
||||
var cliLocalCommands = []Command{
|
||||
{Name: "plan", Description: "Switch to plan mode", Section: "cli", CLICompatible: true},
|
||||
{Name: "act", Description: "Switch to act mode", Section: "cli", CLICompatible: true},
|
||||
{Name: "cancel", Description: "Cancel the current task", Section: "cli", CLICompatible: true},
|
||||
{Name: "exit", Description: "Exit follow mode", Section: "cli", CLICompatible: true},
|
||||
}
|
||||
|
||||
// NewRegistry creates a new slash command registry
|
||||
func NewRegistry(ctx context.Context) *Registry {
|
||||
defaultCommands := append([]Command{}, cliLocalCommands...)
|
||||
r := &Registry{
|
||||
commands: defaultCommands,
|
||||
}
|
||||
r.FetchFromBackend(ctx)
|
||||
return r
|
||||
}
|
||||
|
||||
// FetchFromBackend fetches available commands from cline-core backend
|
||||
func (r *Registry) FetchFromBackend(ctx context.Context) error {
|
||||
grpcClient, err := global.GetDefaultClient(ctx)
|
||||
if err != nil && global.Config.Verbose {
|
||||
fmt.Printf("Warning: could not get gRPC client: %v\n", err)
|
||||
return nil
|
||||
}
|
||||
resp, err := grpcClient.Slash.GetAvailableSlashCommands(ctx, &cline.EmptyRequest{})
|
||||
if err != nil && global.Config.Verbose {
|
||||
fmt.Printf("Warning: could not get gRPC client: %v\n", err)
|
||||
return nil
|
||||
}
|
||||
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
// Add backend commands (only CLI-compatible ones)
|
||||
for _, cmd := range resp.Commands {
|
||||
if cmd.CliCompatible {
|
||||
r.commands = append(r.commands, Command{
|
||||
Name: cmd.Name,
|
||||
Description: cmd.Description,
|
||||
Section: cmd.Section,
|
||||
CLICompatible: cmd.CliCompatible,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetMatching returns commands that start with the given prefix (case-insensitive)
|
||||
func (r *Registry) GetMatching(prefix string) []Command {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
|
||||
prefix = strings.ToLower(prefix)
|
||||
var matches []Command
|
||||
for _, cmd := range r.commands {
|
||||
if strings.HasPrefix(strings.ToLower(cmd.Name), prefix) {
|
||||
matches = append(matches, cmd)
|
||||
}
|
||||
}
|
||||
return matches
|
||||
}
|
||||
|
||||
// IsValid checks if a command name is valid
|
||||
func (r *Registry) IsValid(name string) bool {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
|
||||
name = strings.ToLower(name)
|
||||
for _, cmd := range r.commands {
|
||||
if strings.ToLower(cmd.Name) == name {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// IsCLILocal checks if a command is handled locally by CLI (not sent to backend)
|
||||
func (r *Registry) IsCLILocal(name string) bool {
|
||||
name = strings.ToLower(name)
|
||||
for _, cmd := range cliLocalCommands {
|
||||
if strings.ToLower(cmd.Name) == name {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// HasCommands returns true if the registry has any commands loaded
|
||||
func (r *Registry) HasCommands() bool {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
return len(r.commands) > 0
|
||||
}
|
||||
|
||||
// ParseModeSwitch checks if message starts with /act or /plan and extracts the mode and remaining message.
|
||||
// Returns (mode, remainingMessage, isModeSwitch).
|
||||
// This is a package-level function so it can be used both during initial task creation
|
||||
// and during interactive input handling.
|
||||
func ParseModeSwitch(message string) (string, string, bool) {
|
||||
trimmed := strings.TrimSpace(message)
|
||||
lower := strings.ToLower(trimmed)
|
||||
|
||||
if strings.HasPrefix(lower, "/plan") {
|
||||
remaining := strings.TrimSpace(trimmed[5:])
|
||||
return "plan", remaining, true
|
||||
}
|
||||
|
||||
if strings.HasPrefix(lower, "/act") {
|
||||
remaining := strings.TrimSpace(trimmed[4:])
|
||||
return "act", remaining, true
|
||||
}
|
||||
|
||||
return "", message, false
|
||||
}
|
||||
@@ -1,366 +0,0 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/cline/cli/pkg/common"
|
||||
_ "github.com/glebarez/go-sqlite"
|
||||
"google.golang.org/grpc/health/grpc_health_v1"
|
||||
)
|
||||
|
||||
// normalizeAddressVariants returns address variants to try when querying SQLite.
|
||||
// Handles localhost/127.0.0.1 equivalence by returning both forms.
|
||||
func normalizeAddressVariants(address string) []string {
|
||||
variants := []string{address}
|
||||
|
||||
// Extract host and port
|
||||
host, port, err := net.SplitHostPort(address)
|
||||
if err != nil {
|
||||
return variants
|
||||
}
|
||||
|
||||
// Add the alternate form for localhost/127.0.0.1
|
||||
if host == "localhost" {
|
||||
variants = append(variants, net.JoinHostPort("127.0.0.1", port))
|
||||
} else if host == "127.0.0.1" {
|
||||
variants = append(variants, net.JoinHostPort("localhost", port))
|
||||
}
|
||||
|
||||
return variants
|
||||
}
|
||||
|
||||
// LockManager provides access to the SQLite locks database
|
||||
type LockManager struct {
|
||||
dbPath string
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
// NewLockManager creates a new lock manager
|
||||
func NewLockManager(clineDir string) (*LockManager, error) {
|
||||
dbPath := filepath.Join(clineDir, common.SETTINGS_SUBFOLDER, "locks.db")
|
||||
|
||||
// Ensure the directory exists (for future DB creation by cline-core)
|
||||
dbDir := filepath.Dir(dbPath)
|
||||
if err := os.MkdirAll(dbDir, 0755); err != nil {
|
||||
return nil, fmt.Errorf("failed to create database directory: %w", err)
|
||||
}
|
||||
|
||||
// Check if database exists
|
||||
if _, err := os.Stat(dbPath); os.IsNotExist(err) {
|
||||
// Database doesn't exist - return manager with nil db
|
||||
// All methods already handle this gracefully!
|
||||
return &LockManager{dbPath: dbPath, db: nil}, nil
|
||||
}
|
||||
|
||||
// Database exists - open it normally (no schema creation)
|
||||
db, err := sql.Open("sqlite", dbPath)
|
||||
if err != nil {
|
||||
// If we can't open existing database, return nil db manager
|
||||
return &LockManager{dbPath: dbPath, db: nil}, nil
|
||||
}
|
||||
|
||||
// Test the connection
|
||||
if err := db.Ping(); err != nil {
|
||||
db.Close()
|
||||
// If connection fails, return nil db manager
|
||||
return &LockManager{dbPath: dbPath, db: nil}, nil
|
||||
}
|
||||
|
||||
return &LockManager{
|
||||
dbPath: dbPath,
|
||||
db: db,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ensureConnection attempts to establish a database connection if one doesn't exist
|
||||
func (lm *LockManager) ensureConnection() error {
|
||||
// If we already have a connection, we're done
|
||||
if lm.db != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check if database exists now (created by cline-core)
|
||||
if _, err := os.Stat(lm.dbPath); os.IsNotExist(err) {
|
||||
return fmt.Errorf("database not available")
|
||||
}
|
||||
|
||||
// Database exists, try to connect
|
||||
db, err := sql.Open("sqlite", lm.dbPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to connect to database: %w", err)
|
||||
}
|
||||
|
||||
if err := db.Ping(); err != nil {
|
||||
db.Close()
|
||||
return fmt.Errorf("database connection failed: %w", err)
|
||||
}
|
||||
|
||||
// Success! Update our connection permanently
|
||||
lm.db = db
|
||||
return nil
|
||||
}
|
||||
|
||||
// Close closes the database connection
|
||||
func (lm *LockManager) Close() error {
|
||||
if lm.db != nil {
|
||||
return lm.db.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetInstanceLocks returns all instance locks
|
||||
func (lm *LockManager) GetInstanceLocks() ([]common.LockRow, error) {
|
||||
if err := lm.ensureConnection(); err != nil {
|
||||
return []common.LockRow{}, nil
|
||||
}
|
||||
|
||||
query := common.SelectInstanceLocksSQL
|
||||
|
||||
rows, err := lm.db.Query(query)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to query instance locks: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var locks []common.LockRow
|
||||
for rows.Next() {
|
||||
var lock common.LockRow
|
||||
err := rows.Scan(&lock.ID, &lock.HeldBy, &lock.LockType, &lock.LockTarget, &lock.LockedAt)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to scan lock row: %w", err)
|
||||
}
|
||||
locks = append(locks, lock)
|
||||
}
|
||||
|
||||
return locks, nil
|
||||
}
|
||||
|
||||
// RemoveInstanceLock removes an instance lock by address
|
||||
func (lm *LockManager) RemoveInstanceLock(address string) error {
|
||||
if err := lm.ensureConnection(); err != nil {
|
||||
return nil // Gracefully handle missing database for cleanup operations
|
||||
}
|
||||
|
||||
query := common.DeleteInstanceLockSQL
|
||||
_, err := lm.db.Exec(query, address)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to remove instance lock: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// HasInstanceAtAddress checks if an instance exists at the given address
|
||||
func (lm *LockManager) HasInstanceAtAddress(address string) (bool, error) {
|
||||
if err := lm.ensureConnection(); err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
query := common.CountInstanceLockSQL
|
||||
var count int
|
||||
err := lm.db.QueryRow(query, address).Scan(&count)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("failed to check instance existence: %w", err)
|
||||
}
|
||||
|
||||
return count > 0, nil
|
||||
}
|
||||
|
||||
// GetInstanceInfo returns instance information directly from SQLite.
|
||||
// Handles localhost/127.0.0.1 equivalence by trying both variants.
|
||||
func (lm *LockManager) GetInstanceInfo(address string) (*common.CoreInstanceInfo, error) {
|
||||
if err := lm.ensureConnection(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
query := common.SelectInstanceLockByHolderSQL
|
||||
variants := normalizeAddressVariants(address)
|
||||
|
||||
var heldBy, lockTarget string
|
||||
var lockedAt int64
|
||||
var lastErr error
|
||||
|
||||
// Try each address variant (e.g., localhost:50607 and 127.0.0.1:50607)
|
||||
for _, variant := range variants {
|
||||
err := lm.db.QueryRow(query, variant).Scan(&heldBy, &lockTarget, &lockedAt)
|
||||
if err == nil {
|
||||
// Found it!
|
||||
return &common.CoreInstanceInfo{
|
||||
CoreAddress: heldBy,
|
||||
HostServiceAddress: lockTarget,
|
||||
Status: grpc_health_v1.HealthCheckResponse_UNKNOWN,
|
||||
LastSeen: time.Unix(lockedAt/1000, 0),
|
||||
}, nil
|
||||
}
|
||||
if err != sql.ErrNoRows {
|
||||
// Real error (not just "not found"), save it
|
||||
lastErr = err
|
||||
}
|
||||
}
|
||||
|
||||
// None of the variants were found
|
||||
if lastErr != nil {
|
||||
return nil, fmt.Errorf("failed to query instance: %w", lastErr)
|
||||
}
|
||||
return nil, fmt.Errorf("instance %s not found", address)
|
||||
}
|
||||
|
||||
// ListInstancesWithHealthCheck returns all instances with real-time health checks
|
||||
func (lm *LockManager) ListInstancesWithHealthCheck(ctx context.Context) ([]*common.CoreInstanceInfo, error) {
|
||||
if err := lm.ensureConnection(); err != nil {
|
||||
return []*common.CoreInstanceInfo{}, nil
|
||||
}
|
||||
|
||||
// Get all instance locks
|
||||
locks, err := lm.GetInstanceLocks()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get instance locks: %w", err)
|
||||
}
|
||||
|
||||
var instances []*common.CoreInstanceInfo
|
||||
|
||||
for _, lock := range locks {
|
||||
// Create instance info using actual SQLite data
|
||||
status, err := common.PerformHealthCheck(ctx, lock.HeldBy)
|
||||
if status != grpc_health_v1.HealthCheckResponse_SERVING || err != nil {
|
||||
time.Sleep(1 * time.Second)
|
||||
status, err = common.PerformHealthCheck(ctx, lock.HeldBy)
|
||||
}
|
||||
|
||||
info := &common.CoreInstanceInfo{
|
||||
CoreAddress: lock.HeldBy,
|
||||
HostServiceAddress: lock.LockTarget,
|
||||
Status: status,
|
||||
LastSeen: time.Unix(lock.LockedAt/1000, 0),
|
||||
}
|
||||
|
||||
instances = append(instances, info)
|
||||
}
|
||||
|
||||
return instances, nil
|
||||
}
|
||||
|
||||
// GetDefaultInstance reads the default instance from the settings file
|
||||
func GetDefaultInstance(clineDir string) (string, error) {
|
||||
settingsPath := filepath.Join(clineDir, common.SETTINGS_SUBFOLDER, "settings", "cli-default-instance.json")
|
||||
|
||||
data, err := os.ReadFile(settingsPath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return "", nil
|
||||
}
|
||||
return "", fmt.Errorf("failed to read default instance file: %w", err)
|
||||
}
|
||||
|
||||
var defaultInstance common.DefaultCoreInstance
|
||||
if err := json.Unmarshal(data, &defaultInstance); err != nil {
|
||||
return "", fmt.Errorf("failed to parse default instance JSON: %w", err)
|
||||
}
|
||||
|
||||
if defaultInstance.Address == "" {
|
||||
return "", fmt.Errorf("default instance not set in settings file")
|
||||
}
|
||||
|
||||
return defaultInstance.Address, nil
|
||||
}
|
||||
|
||||
// SetDefaultInstance writes the default instance to the settings file with proper locking
|
||||
func SetDefaultInstance(clineDir, address string) error {
|
||||
// Create lock manager for this operation
|
||||
lockManager, err := NewLockManager(clineDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("Warning: SQLite unavailable, writing without lock: %v\n", err)
|
||||
}
|
||||
defer lockManager.Close()
|
||||
|
||||
settingsPath := filepath.Join(clineDir, common.SETTINGS_SUBFOLDER, "settings", "cli-default-instance.json")
|
||||
|
||||
// Generate a unique identifier for this CLI process
|
||||
heldBy := fmt.Sprintf("cli-process-%d", os.Getpid())
|
||||
|
||||
// Use file lock for the write operation
|
||||
return lockManager.WithFileLock(settingsPath, heldBy, func() error {
|
||||
return writeDefaultInstanceJSONToDisk(clineDir, address)
|
||||
})
|
||||
}
|
||||
|
||||
func writeDefaultInstanceJSONToDisk(clineDir, address string) error {
|
||||
settingsDir := filepath.Join(clineDir, common.SETTINGS_SUBFOLDER, "settings")
|
||||
if err := os.MkdirAll(settingsDir, 0755); err != nil {
|
||||
return fmt.Errorf("failed to create settings directory: %w", err)
|
||||
}
|
||||
|
||||
settingsPath := filepath.Join(settingsDir, "cli-default-instance.json")
|
||||
|
||||
payload := common.DefaultCoreInstance{
|
||||
Address: address,
|
||||
LastUpdated: time.Now().Format(time.RFC3339),
|
||||
}
|
||||
|
||||
data, err := json.MarshalIndent(payload, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal default instance JSON: %w", err)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(settingsPath, data, 0644); err != nil {
|
||||
return fmt.Errorf("failed to write default instance file: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// AcquireFileLock attempts to acquire a file lock
|
||||
func (lm *LockManager) AcquireFileLock(filePath, heldBy string) error {
|
||||
if err := lm.ensureConnection(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
now := time.Now().Unix() * 1000 // Convert to milliseconds
|
||||
|
||||
query := common.InsertFileLockSQL
|
||||
|
||||
_, err := lm.db.Exec(query, heldBy, filePath, now)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to acquire file lock for %s: %w", filePath, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ReleaseFileLock releases a file lock
|
||||
func (lm *LockManager) ReleaseFileLock(filePath, heldBy string) error {
|
||||
if lm.db == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
query := common.DeleteFileLockSQL
|
||||
|
||||
_, err := lm.db.Exec(query, heldBy, filePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to release file lock for %s: %w", filePath, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// WithFileLock executes a function while holding a file lock
|
||||
func (lm *LockManager) WithFileLock(filePath, heldBy string, fn func() error) error {
|
||||
if err := lm.AcquireFileLock(filePath, heldBy); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
defer func() {
|
||||
if releaseErr := lm.ReleaseFileLock(filePath, heldBy); releaseErr != nil {
|
||||
fmt.Printf("Warning: Failed to release file lock for %s: %v\n", filePath, releaseErr)
|
||||
}
|
||||
}()
|
||||
|
||||
return fn()
|
||||
}
|
||||
@@ -1,674 +0,0 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/cline/cli/pkg/cli/config"
|
||||
"github.com/cline/cli/pkg/cli/global"
|
||||
"github.com/cline/cli/pkg/cli/task"
|
||||
"github.com/cline/cli/pkg/cli/updater"
|
||||
"github.com/cline/grpc-go/cline"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// TaskOptions contains options for creating a task
|
||||
type TaskOptions struct {
|
||||
Images []string
|
||||
Files []string
|
||||
Mode string
|
||||
Settings []string
|
||||
Yolo bool
|
||||
Address string
|
||||
Verbose bool
|
||||
Workspaces []string
|
||||
}
|
||||
|
||||
func NewTaskCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "task",
|
||||
Aliases: []string{"t"},
|
||||
Short: "Manage Cline tasks",
|
||||
Long: `Create, monitor, and manage Cline AI tasks.`,
|
||||
}
|
||||
|
||||
cmd.AddCommand(newTaskNewCommand())
|
||||
cmd.AddCommand(newTaskPauseCommand())
|
||||
cmd.AddCommand(newTaskChatCommand())
|
||||
cmd.AddCommand(newTaskSendCommand())
|
||||
cmd.AddCommand(newTaskViewCommand())
|
||||
cmd.AddCommand(newTaskListCommand())
|
||||
cmd.AddCommand(newTaskOpenCommand())
|
||||
cmd.AddCommand(newTaskRestoreCommand())
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
var taskManager *task.Manager
|
||||
|
||||
func ensureTaskManager(ctx context.Context, address string) error {
|
||||
if taskManager == nil || (address != "" && taskManager.GetCurrentInstance() != address) {
|
||||
var err error
|
||||
var instanceAddress string
|
||||
|
||||
if address != "" {
|
||||
// Ensure instance exists at the specified address
|
||||
if err := ensureInstanceAtAddress(ctx, address); err != nil {
|
||||
return fmt.Errorf("failed to ensure instance at address %s: %w", address, err)
|
||||
}
|
||||
taskManager, err = task.NewManagerForAddress(ctx, address)
|
||||
instanceAddress = address
|
||||
} else {
|
||||
// Ensure default instance exists
|
||||
if err := global.EnsureDefaultInstance(ctx); err != nil {
|
||||
return fmt.Errorf("failed to ensure default instance: %w", err)
|
||||
}
|
||||
taskManager, err = task.NewManagerForDefault(ctx)
|
||||
if err == nil {
|
||||
instanceAddress = taskManager.GetCurrentInstance()
|
||||
}
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create task manager: %w", err)
|
||||
}
|
||||
|
||||
// Always set the instance we're using as the default
|
||||
registry := global.Instances.GetRegistry()
|
||||
if err := registry.SetDefaultInstance(instanceAddress); err != nil {
|
||||
// Log warning but don't fail - this is not critical
|
||||
fmt.Printf("Warning: failed to set default instance: %v\n", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ensureInstanceAtAddress ensures an instance exists at the given address
|
||||
func ensureInstanceAtAddress(ctx context.Context, address string) error {
|
||||
if global.Instances == nil {
|
||||
return fmt.Errorf("global clients not initialized")
|
||||
}
|
||||
return global.Instances.EnsureInstanceAtAddress(ctx, address)
|
||||
}
|
||||
|
||||
func newTaskNewCommand() *cobra.Command {
|
||||
var (
|
||||
images []string
|
||||
files []string
|
||||
address string
|
||||
mode string
|
||||
settings []string
|
||||
yolo bool
|
||||
)
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "new <prompt>",
|
||||
Aliases: []string{"n"},
|
||||
Short: "Create a new task",
|
||||
Long: `Create a new Cline task with the specified prompt. If no Cline instance exists at the specified address, a new one will be started automatically.`,
|
||||
Args: cobra.MinimumNArgs(0),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := cmd.Context()
|
||||
|
||||
// Check if an instance exists when no address specified
|
||||
if address == "" && global.Instances.GetRegistry().GetDefaultInstance() == "" {
|
||||
fmt.Println("No instances available for creating tasks")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Get content from both args and stdin
|
||||
prompt, err := getContentFromStdinAndArgs(args)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read prompt: %w", err)
|
||||
}
|
||||
|
||||
// Validate that prompt is passed in call
|
||||
if prompt == "" {
|
||||
return fmt.Errorf("prompt required: provide as argument or pipe via stdin")
|
||||
}
|
||||
|
||||
// Ensure task manager is initialized
|
||||
if err := ensureTaskManager(ctx, address); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Set mode if provided
|
||||
if mode != "" {
|
||||
if err := taskManager.SetMode(ctx, mode, nil, nil, nil); err != nil {
|
||||
return fmt.Errorf("failed to set mode: %w", err)
|
||||
}
|
||||
if global.Config.Verbose {
|
||||
fmt.Printf("Mode set to: %s\n", mode)
|
||||
}
|
||||
}
|
||||
|
||||
// Inject yolo_mode_toggled setting if --yolo flag is set
|
||||
|
||||
// Will append to the -s settings to be parsed by the settings parser logic.
|
||||
// If the yoloMode is also set in the settings, this will override that, since it will be set last.
|
||||
if yolo {
|
||||
settings = append(settings, "yolo_mode_toggled=true")
|
||||
}
|
||||
|
||||
// Create the task
|
||||
taskID, err := taskManager.CreateTask(ctx, prompt, images, files, settings)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create task: %w", err)
|
||||
}
|
||||
|
||||
if global.Config.Verbose {
|
||||
fmt.Printf("Task created successfully with ID: %s\n", taskID)
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringSliceVarP(&images, "image", "i", nil, "attach image files")
|
||||
cmd.Flags().StringSliceVarP(&files, "file", "f", nil, "attach files")
|
||||
cmd.Flags().StringVar(&address, "address", "", "specific Cline instance address to use")
|
||||
cmd.Flags().StringVarP(&mode, "mode", "m", "", "mode (act|plan)")
|
||||
cmd.Flags().StringSliceVarP(&settings, "setting", "s", nil, "task settings (key=value format, e.g., -s aws-region=us-west-2 -s mode=act)")
|
||||
cmd.Flags().BoolVarP(&yolo, "yolo", "y", false, "enable yolo mode (non-interactive)")
|
||||
cmd.Flags().BoolVar(&yolo, "no-interactive", false, "enable yolo mode (non-interactive)")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newTaskPauseCommand() *cobra.Command {
|
||||
var address string
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "pause",
|
||||
Aliases: []string{"p"},
|
||||
Short: "Pause the current task",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := cmd.Context()
|
||||
|
||||
if err := ensureTaskManager(ctx, address); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := taskManager.CancelTask(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Println("Task paused successfully")
|
||||
fmt.Printf("Instance: %s\n", taskManager.GetCurrentInstance())
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVar(&address, "address", "", "specific Cline instance address to use")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newTaskSendCommand() *cobra.Command {
|
||||
var (
|
||||
images []string
|
||||
files []string
|
||||
address string
|
||||
mode string
|
||||
approve bool
|
||||
deny bool
|
||||
yolo bool
|
||||
)
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "send [message]",
|
||||
Aliases: []string{"s"},
|
||||
Short: "Send a followup message to the current task and/or update mode/approve",
|
||||
Long: `Send a followup message to continue the conversation with the current task and/or update mode/approve.`,
|
||||
Args: cobra.MinimumNArgs(0),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := cmd.Context()
|
||||
|
||||
// Check if an instance exists when no address specified
|
||||
if address == "" && global.Instances.GetRegistry().GetDefaultInstance() == "" {
|
||||
fmt.Println("No instances available for sending messages")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Get content from both args and stdin
|
||||
message, err := getContentFromStdinAndArgs(args)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read message: %w", err)
|
||||
}
|
||||
|
||||
if message == "" && len(images) == 0 && len(files) == 0 && mode == "" && !approve && !deny {
|
||||
return fmt.Errorf("content (message, files, images) required unless using --mode, --approve, or --deny flags")
|
||||
}
|
||||
|
||||
if approve && deny {
|
||||
return fmt.Errorf("cannot use both --approve and --deny flags")
|
||||
}
|
||||
|
||||
if (approve || deny) && mode != "" {
|
||||
return fmt.Errorf("cannot use --approve/--deny and --mode together")
|
||||
}
|
||||
|
||||
// Ensure task manager is initialized
|
||||
if err := ensureTaskManager(ctx, address); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Check if we can send a message
|
||||
err = taskManager.CheckSendEnabled(ctx)
|
||||
if err != nil {
|
||||
// Handle specific error cases
|
||||
if errors.Is(err, task.ErrNoActiveTask) {
|
||||
fmt.Println("Cannot send message: no active task")
|
||||
return nil
|
||||
}
|
||||
if errors.Is(err, task.ErrTaskBusy) {
|
||||
fmt.Println("Cannot send message: task is currently busy")
|
||||
return nil
|
||||
}
|
||||
// All other errors are unexpected
|
||||
return fmt.Errorf("failed to check if message can be sent: %w", err)
|
||||
}
|
||||
|
||||
// Process yolo flag and apply settings
|
||||
if yolo {
|
||||
settings := []string{"yolo_mode_toggled=true"}
|
||||
parsedSettings, secrets, err := task.ParseTaskSettings(settings)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to parse settings: %w", err)
|
||||
}
|
||||
|
||||
configManager, err := config.NewManager(ctx, taskManager.GetCurrentInstance())
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create config manager: %w", err)
|
||||
}
|
||||
|
||||
if err := configManager.UpdateSettings(ctx, parsedSettings, secrets); err != nil {
|
||||
return fmt.Errorf("failed to apply settings: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if mode != "" {
|
||||
if err := taskManager.SetModeAndSendMessage(ctx, mode, message, images, files); err != nil {
|
||||
return fmt.Errorf("failed to set mode and send message: %w", err)
|
||||
}
|
||||
fmt.Printf("Mode set to %s and message sent successfully.\n", mode)
|
||||
|
||||
} else {
|
||||
// Convert approve/deny booleans to string
|
||||
approveStr := ""
|
||||
if approve {
|
||||
approveStr = "true"
|
||||
}
|
||||
if deny {
|
||||
approveStr = "false"
|
||||
}
|
||||
|
||||
if err := taskManager.SendMessage(ctx, message, images, files, approveStr); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("Message sent successfully.\n")
|
||||
}
|
||||
|
||||
fmt.Printf("Instance: %s\n", taskManager.GetCurrentInstance())
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringSliceVarP(&images, "image", "i", nil, "attach image files")
|
||||
cmd.Flags().StringSliceVarP(&files, "file", "f", nil, "attach files")
|
||||
cmd.Flags().StringVar(&address, "address", "", "specific Cline instance address to use")
|
||||
cmd.Flags().StringVarP(&mode, "mode", "m", "", "mode (act|plan)")
|
||||
cmd.Flags().BoolVarP(&approve, "approve", "a", false, "approve pending request")
|
||||
cmd.Flags().BoolVarP(&deny, "deny", "d", false, "deny pending request")
|
||||
cmd.Flags().BoolVarP(&yolo, "yolo", "y", false, "enable yolo mode (non-interactive)")
|
||||
cmd.Flags().BoolVar(&yolo, "no-interactive", false, "enable yolo mode (non-interactive)")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newTaskChatCommand() *cobra.Command {
|
||||
var address string
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "chat",
|
||||
Aliases: []string{"c"},
|
||||
Short: "Chat with the current task in interactive mode",
|
||||
Long: `Chat with the current task, displaying messages in real-time with interactive input enabled.`,
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := cmd.Context()
|
||||
|
||||
if err := ensureTaskManager(ctx, address); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Check if there's an active task before entering follow mode
|
||||
err := taskManager.CheckSendEnabled(ctx)
|
||||
if err != nil {
|
||||
// Handle specific error cases
|
||||
if errors.Is(err, task.ErrNoActiveTask) {
|
||||
fmt.Println("No active task found. Use 'cline task new' to create a task first.")
|
||||
return nil
|
||||
}
|
||||
// For other errors (like task busy), we can still enter follow mode
|
||||
// as the user may want to observe the task
|
||||
}
|
||||
|
||||
return taskManager.FollowConversation(ctx, taskManager.GetCurrentInstance(), true)
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVar(&address, "address", "", "specific Cline instance address to use")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newTaskViewCommand() *cobra.Command {
|
||||
var (
|
||||
follow bool
|
||||
followComplete bool
|
||||
address string
|
||||
)
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "view",
|
||||
Aliases: []string{"v"},
|
||||
Short: "View task conversation",
|
||||
Long: `Output conversation snapshot by default, or follow with flags.`,
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := cmd.Context()
|
||||
|
||||
if err := ensureTaskManager(ctx, address); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Printf("Using instance: %s\n", taskManager.GetCurrentInstance())
|
||||
|
||||
if follow {
|
||||
// Follow conversation forever (non-interactive)
|
||||
return taskManager.FollowConversation(ctx, taskManager.GetCurrentInstance(), false)
|
||||
} else if followComplete {
|
||||
// Follow until completion
|
||||
return taskManager.FollowConversationUntilCompletion(ctx, task.DefaultFollowOptions())
|
||||
} else {
|
||||
// Default: show snapshot
|
||||
return taskManager.ShowConversation(ctx)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().BoolVarP(&follow, "follow", "f", false, "follow conversation forever")
|
||||
cmd.Flags().BoolVarP(&followComplete, "follow-complete", "c", false, "follow until completion")
|
||||
cmd.Flags().StringVar(&address, "address", "", "specific Cline instance address to use")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newTaskListCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "list",
|
||||
Aliases: []string{"l"},
|
||||
Short: "List recent task history",
|
||||
Long: `Display recent tasks from task history.`,
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
// Read directly from disk
|
||||
return task.ListTasksFromDisk()
|
||||
},
|
||||
}
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newTaskOpenCommand() *cobra.Command {
|
||||
var (
|
||||
address string
|
||||
mode string
|
||||
settings []string
|
||||
yolo bool
|
||||
)
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "open <task-id>",
|
||||
Aliases: []string{"o"},
|
||||
Short: "Open a task by ID",
|
||||
Long: `Open an existing task by ID and optionally update settings or mode.`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := cmd.Context()
|
||||
taskID := args[0]
|
||||
|
||||
// Ensure task manager is initialized
|
||||
if err := ensureTaskManager(ctx, address); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Printf("Using instance: %s\n", taskManager.GetCurrentInstance())
|
||||
|
||||
// Resume the task
|
||||
if err := taskManager.ResumeTask(ctx, taskID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Apply mode if provided
|
||||
if mode != "" {
|
||||
if err := taskManager.SetMode(ctx, mode, nil, nil, nil); err != nil {
|
||||
return fmt.Errorf("failed to set mode: %w", err)
|
||||
}
|
||||
if global.Config.Verbose {
|
||||
fmt.Printf("Mode set to: %s\n", mode)
|
||||
}
|
||||
}
|
||||
|
||||
// Process yolo flag and apply settings
|
||||
if yolo {
|
||||
settings = append(settings, "yolo_mode_toggled=true")
|
||||
}
|
||||
|
||||
if len(settings) > 0 {
|
||||
// Parse settings using existing parser
|
||||
parsedSettings, secrets, err := task.ParseTaskSettings(settings)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to parse settings: %w", err)
|
||||
}
|
||||
|
||||
// Apply task-specific settings using UpdateTaskSettings RPC
|
||||
if parsedSettings != nil {
|
||||
_, err = taskManager.GetClient().State.UpdateTaskSettings(ctx, &cline.UpdateTaskSettingsRequest{
|
||||
Settings: parsedSettings,
|
||||
TaskId: &taskID,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to apply task settings: %w", err)
|
||||
}
|
||||
if global.Config.Verbose {
|
||||
fmt.Println("Task-specific settings applied successfully")
|
||||
}
|
||||
}
|
||||
|
||||
// Handle secrets separately if provided (they must go to global config)
|
||||
if secrets != nil {
|
||||
// Secrets are always global, not task-specific
|
||||
configManager, err := config.NewManager(ctx, taskManager.GetCurrentInstance())
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create config manager: %w", err)
|
||||
}
|
||||
|
||||
if err := configManager.UpdateSettings(ctx, nil, secrets); err != nil {
|
||||
return fmt.Errorf("failed to apply secrets: %w", err)
|
||||
}
|
||||
if global.Config.Verbose {
|
||||
fmt.Println("Global secrets applied successfully")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVar(&address, "address", "", "specific Cline instance address to use")
|
||||
cmd.Flags().StringVarP(&mode, "mode", "m", "", "mode (act|plan)")
|
||||
cmd.Flags().StringSliceVarP(&settings, "setting", "s", nil, "task settings (key=value format, e.g., -s model=claude)")
|
||||
cmd.Flags().BoolVarP(&yolo, "yolo", "y", false, "enable yolo mode (non-interactive)")
|
||||
cmd.Flags().BoolVar(&yolo, "no-interactive", false, "enable yolo mode (non-interactive)")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newTaskRestoreCommand() *cobra.Command {
|
||||
var (
|
||||
restoreType string
|
||||
address string
|
||||
)
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "restore <checkpoint-id>",
|
||||
Short: "Restore task to a specific checkpoint",
|
||||
Long: `Restore the current task to a specific checkpoint by checkpoint ID (timestamp) and by type.`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := cmd.Context()
|
||||
checkpointID := args[0]
|
||||
|
||||
// Convert checkpoint ID string to int64
|
||||
id, err := strconv.ParseInt(checkpointID, 10, 64)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid checkpoint ID '%s': must be a valid number", checkpointID)
|
||||
}
|
||||
|
||||
validTypes := []string{"task", "workspace", "taskAndWorkspace"}
|
||||
if !slices.Contains(validTypes, restoreType) {
|
||||
return fmt.Errorf("invalid restore type '%s': must be one of [task, workspace, taskAndWorkspace]", restoreType)
|
||||
}
|
||||
|
||||
// Ensure task manager is initialized
|
||||
if err := ensureTaskManager(ctx, address); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Validate checkpoint exists before attempting restore
|
||||
if err := taskManager.ValidateCheckpointExists(ctx, id); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Printf("Using instance: %s\n", taskManager.GetCurrentInstance())
|
||||
fmt.Printf("Restoring to checkpoint %d (type: %s)\n", id, restoreType)
|
||||
|
||||
if err := taskManager.RestoreCheckpoint(ctx, id, restoreType); err != nil {
|
||||
return fmt.Errorf("failed to restore checkpoint: %w", err)
|
||||
}
|
||||
|
||||
fmt.Println("Checkpoint restored successfully")
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVarP(&restoreType, "type", "t", "task", "Restore type (task, workspace, taskAndWorkspace)")
|
||||
cmd.Flags().StringVar(&address, "address", "", "specific Cline instance address to use")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
// getContentFromStdinAndArgs reads content from both command line args and stdin, and combines them
|
||||
func getContentFromStdinAndArgs(args []string) (string, error) {
|
||||
var content strings.Builder
|
||||
|
||||
// Add command line args first (if any)
|
||||
if len(args) > 0 {
|
||||
content.WriteString(strings.Join(args, " "))
|
||||
}
|
||||
|
||||
// Check if stdin has data
|
||||
stat, err := os.Stdin.Stat()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to stat stdin: %w", err)
|
||||
}
|
||||
|
||||
// Check if data is being piped to stdin
|
||||
if (stat.Mode() & os.ModeCharDevice) == 0 {
|
||||
// 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
|
||||
}
|
||||
|
||||
// CleanupTaskManager cleans up the task manager resources
|
||||
func CleanupTaskManager() {
|
||||
if taskManager != nil {
|
||||
taskManager.Cleanup()
|
||||
}
|
||||
}
|
||||
|
||||
// CreateAndFollowTask creates a new task and immediately follows it in interactive mode
|
||||
// This is used by the root command to provide a streamlined UX
|
||||
func CreateAndFollowTask(ctx context.Context, prompt string, opts TaskOptions) error {
|
||||
// Initialize task manager with the provided instance address
|
||||
if err := ensureTaskManager(ctx, opts.Address); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Set mode to plan by default if not specified
|
||||
if opts.Mode == "" {
|
||||
opts.Mode = "plan"
|
||||
}
|
||||
|
||||
// Set mode if provided
|
||||
if opts.Mode != "" {
|
||||
if err := taskManager.SetMode(ctx, opts.Mode, nil, nil, nil); err != nil {
|
||||
return fmt.Errorf("failed to set mode: %w", err)
|
||||
}
|
||||
if global.Config.Verbose {
|
||||
fmt.Printf("Mode set to: %s\n", opts.Mode)
|
||||
}
|
||||
}
|
||||
|
||||
// Inject yolo_mode_toggled setting if --yolo flag is set
|
||||
if opts.Yolo {
|
||||
opts.Settings = append(opts.Settings, "yolo_mode_toggled=true")
|
||||
}
|
||||
|
||||
// Create the task
|
||||
taskID, err := taskManager.CreateTask(ctx, prompt, opts.Images, opts.Files, opts.Settings)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create task: %w", err)
|
||||
}
|
||||
|
||||
if global.Config.Verbose {
|
||||
fmt.Printf("Task created successfully with ID: %s\n\n", taskID)
|
||||
}
|
||||
|
||||
// Check for updates in background after task is created
|
||||
updater.CheckAndUpdate(opts.Verbose)
|
||||
|
||||
// If yolo mode is enabled, follow until completion (non-interactive)
|
||||
// Otherwise, follow in interactive mode
|
||||
if opts.Yolo {
|
||||
// Skip active task check since we just created the task
|
||||
return taskManager.FollowConversationUntilCompletion(ctx, task.FollowOptions{
|
||||
SkipActiveTaskCheck: true,
|
||||
})
|
||||
} else {
|
||||
return taskManager.FollowConversation(ctx, taskManager.GetCurrentInstance(), true)
|
||||
}
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
package task
|
||||
|
||||
// FollowOptions contains options for following a conversation
|
||||
type FollowOptions struct {
|
||||
// SkipActiveTaskCheck skips the check for an active task
|
||||
// This is useful when following a task that was just created to avoid race conditions
|
||||
SkipActiveTaskCheck bool
|
||||
}
|
||||
|
||||
// DefaultFollowOptions returns the default options for following a conversation
|
||||
func DefaultFollowOptions() FollowOptions {
|
||||
return FollowOptions{
|
||||
SkipActiveTaskCheck: false,
|
||||
}
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
package task
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
|
||||
"github.com/cline/cli/pkg/cli/display"
|
||||
"github.com/cline/cli/pkg/cli/global"
|
||||
"github.com/cline/cli/pkg/cli/types"
|
||||
"github.com/cline/grpc-go/cline"
|
||||
)
|
||||
|
||||
// ListTasksFromDisk reads task history directly from disk
|
||||
func ListTasksFromDisk() error {
|
||||
// Get the task history file path
|
||||
homeDir, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get home directory: %w", err)
|
||||
}
|
||||
|
||||
filePath := filepath.Join(homeDir, ".cline", "data", "state", "taskHistory.json")
|
||||
|
||||
// Read the file
|
||||
data, err := os.ReadFile(filePath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
fmt.Println("No task history found.")
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("failed to read task history: %w", err)
|
||||
}
|
||||
|
||||
// Parse JSON into intermediate struct
|
||||
var historyItems []types.HistoryItem
|
||||
if err := json.Unmarshal(data, &historyItems); err != nil {
|
||||
return fmt.Errorf("failed to parse task history: %w", err)
|
||||
}
|
||||
|
||||
if len(historyItems) == 0 {
|
||||
fmt.Println("No task history found.")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Sort by timestamp ascending (oldest first, newest last)
|
||||
sort.Slice(historyItems, func(i, j int) bool {
|
||||
return historyItems[i].Ts < historyItems[j].Ts
|
||||
})
|
||||
|
||||
// Convert to protobuf TaskItem format for rendering
|
||||
tasks := make([]*cline.TaskItem, len(historyItems))
|
||||
for i, item := range historyItems {
|
||||
tasks[i] = &cline.TaskItem{
|
||||
Id: item.Id,
|
||||
Task: item.Task,
|
||||
Ts: item.Ts,
|
||||
IsFavorited: item.IsFavorited,
|
||||
Size: item.Size,
|
||||
TotalCost: item.TotalCost,
|
||||
TokensIn: item.TokensIn,
|
||||
TokensOut: item.TokensOut,
|
||||
CacheWrites: item.CacheWrites,
|
||||
CacheReads: item.CacheReads,
|
||||
}
|
||||
}
|
||||
|
||||
// Use existing renderer
|
||||
renderer := display.NewRenderer(global.Config.OutputFormat)
|
||||
return renderer.RenderTaskList(tasks)
|
||||
}
|
||||
@@ -1,566 +0,0 @@
|
||||
package task
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/cline/cli/pkg/cli/global"
|
||||
"github.com/cline/cli/pkg/cli/output"
|
||||
"github.com/cline/cli/pkg/cli/slash"
|
||||
"github.com/cline/cli/pkg/cli/types"
|
||||
)
|
||||
|
||||
// InputHandler manages interactive user input during follow mode
|
||||
type InputHandler struct {
|
||||
manager *Manager
|
||||
coordinator *StreamCoordinator
|
||||
cancelFunc context.CancelFunc
|
||||
mu sync.RWMutex
|
||||
isRunning bool
|
||||
pollTicker *time.Ticker
|
||||
program *tea.Program
|
||||
programRunning bool
|
||||
programDoneChan chan struct{} // Signals when program actually exits
|
||||
resultChan chan output.InputSubmitMsg
|
||||
cancelChan chan struct{}
|
||||
feedbackApproval bool // Track if we're in feedback after approval
|
||||
feedbackApproved bool // Track the approval decision
|
||||
approvalMessage *types.ClineMessage // Store the approval message for determining action
|
||||
slashCommandRegistry *slash.Registry // Slash command registry for autocomplete
|
||||
ctx context.Context // Context for restart callback
|
||||
}
|
||||
|
||||
// NewInputHandler creates a new input handler
|
||||
func NewInputHandler(manager *Manager, coordinator *StreamCoordinator, cancelFunc context.CancelFunc) *InputHandler {
|
||||
return &InputHandler{
|
||||
manager: manager,
|
||||
coordinator: coordinator,
|
||||
cancelFunc: cancelFunc,
|
||||
isRunning: false,
|
||||
pollTicker: time.NewTicker(500 * time.Millisecond),
|
||||
resultChan: make(chan output.InputSubmitMsg, 1),
|
||||
slashCommandRegistry: slash.NewRegistry(context.Background()),
|
||||
cancelChan: make(chan struct{}, 1),
|
||||
}
|
||||
}
|
||||
|
||||
// Start begins monitoring for input opportunities
|
||||
func (ih *InputHandler) Start(ctx context.Context, errChan chan error) {
|
||||
ih.mu.Lock()
|
||||
ih.isRunning = true
|
||||
ih.mu.Unlock()
|
||||
|
||||
defer func() {
|
||||
ih.mu.Lock()
|
||||
ih.isRunning = false
|
||||
ih.mu.Unlock()
|
||||
ih.pollTicker.Stop()
|
||||
if ih.program != nil {
|
||||
ih.program.Quit()
|
||||
}
|
||||
}()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ih.pollTicker.C:
|
||||
// First check if approval is needed
|
||||
needsApproval, approvalMsg, err := ih.manager.CheckNeedsApproval(ctx)
|
||||
if err != nil {
|
||||
if global.Config.Verbose {
|
||||
output.Printf("\nDebug: CheckNeedsApproval error: %v\n", err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if needsApproval {
|
||||
ih.coordinator.SetInputAllowed(true)
|
||||
|
||||
// Show approval prompt
|
||||
approved, feedback, err := ih.promptForApproval(ctx, approvalMsg)
|
||||
|
||||
if err != nil {
|
||||
// Check if the error is due to interrupt (Ctrl+C) or context cancellation
|
||||
if errors.Is(err, context.Canceled) || ctx.Err() != nil {
|
||||
// User pressed Ctrl+C - cancel context to exit FollowConversation
|
||||
ih.cancelFunc()
|
||||
return
|
||||
}
|
||||
if global.Config.Verbose {
|
||||
output.Printf("\nDebug: Approval prompt error: %v\n", err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
ih.coordinator.SetInputAllowed(false)
|
||||
|
||||
// Send approval response
|
||||
approveStr := "false"
|
||||
if approved {
|
||||
approveStr = "true"
|
||||
}
|
||||
|
||||
if err := ih.manager.SendMessage(ctx, feedback, nil, nil, approveStr); err != nil {
|
||||
output.Printf("\nError sending approval: %v\n", err)
|
||||
continue
|
||||
}
|
||||
|
||||
if global.Config.Verbose {
|
||||
output.Printf("\nDebug: Approval sent (approved=%s, feedback=%q)\n", approveStr, feedback)
|
||||
}
|
||||
|
||||
// Give the system a moment to process before re-polling
|
||||
time.Sleep(1 * time.Second)
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if we can send a regular message
|
||||
err = ih.manager.CheckSendEnabled(ctx)
|
||||
if err != nil {
|
||||
// Handle specific error cases
|
||||
if errors.Is(err, ErrNoActiveTask) {
|
||||
// No active task - don't show input prompt
|
||||
ih.coordinator.SetInputAllowed(false)
|
||||
continue
|
||||
}
|
||||
if errors.Is(err, ErrTaskBusy) {
|
||||
// Task is busy - don't show input prompt
|
||||
ih.coordinator.SetInputAllowed(false)
|
||||
continue
|
||||
}
|
||||
// Unexpected error
|
||||
if global.Config.Verbose {
|
||||
output.Printf("\nDebug: CheckSendEnabled error: %v\n", err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// If we reach here, we can send a message
|
||||
ih.coordinator.SetInputAllowed(true)
|
||||
|
||||
// Show prompt and get input
|
||||
message, shouldSend, err := ih.promptForInput(ctx)
|
||||
|
||||
if err != nil {
|
||||
// Check if the error is due to interrupt (Ctrl+C) or context cancellation
|
||||
if errors.Is(err, context.Canceled) || ctx.Err() != nil {
|
||||
// User pressed Ctrl+C - cancel context to exit FollowConversation
|
||||
ih.cancelFunc()
|
||||
return
|
||||
}
|
||||
if global.Config.Verbose {
|
||||
output.Printf("\nDebug: Input prompt error: %v\n", err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
ih.coordinator.SetInputAllowed(false)
|
||||
|
||||
if shouldSend {
|
||||
// Check for mode switch commands first
|
||||
newMode, remainingMessage, isModeSwitch := slash.ParseModeSwitch(message)
|
||||
if isModeSwitch {
|
||||
// Create styles for mode switch messages (respect global color profile)
|
||||
actStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("39")).Bold(true)
|
||||
planStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("3")).Bold(true)
|
||||
|
||||
if remainingMessage != "" {
|
||||
// Switching with a message - behavior differs by mode
|
||||
if newMode == "act" {
|
||||
// Act mode: can send mode + message in one call
|
||||
if err := ih.manager.SetMode(ctx, newMode, &remainingMessage, nil, nil); err != nil {
|
||||
output.Printf("\nError switching to act mode with message: %v\n", err)
|
||||
continue
|
||||
}
|
||||
output.Printf("\n%s\n", actStyle.Render("Switched to act mode"))
|
||||
} else {
|
||||
// Plan mode: must switch first, then send message separately
|
||||
if err := ih.manager.SetMode(ctx, newMode, nil, nil, nil); err != nil {
|
||||
output.Printf("\nError switching to plan mode: %v\n", err)
|
||||
continue
|
||||
}
|
||||
output.Printf("\n%s\n", planStyle.Render("Switched to plan mode"))
|
||||
|
||||
// Now send the message separately
|
||||
time.Sleep(500 * time.Millisecond) // Give mode switch time to process
|
||||
if err := ih.manager.SendMessage(ctx, remainingMessage, nil, nil, ""); err != nil {
|
||||
output.Printf("\nError sending message after mode switch: %v\n", err)
|
||||
continue
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Just switch mode, no message
|
||||
if err := ih.manager.SetMode(ctx, newMode, nil, nil, nil); err != nil {
|
||||
output.Printf("\nError switching to %s mode: %v\n", newMode, err)
|
||||
continue
|
||||
}
|
||||
// Color based on mode
|
||||
if newMode == "act" {
|
||||
output.Printf("\n%s\n", actStyle.Render("Switched to act mode"))
|
||||
} else {
|
||||
output.Printf("\n%s\n", planStyle.Render("Switched to plan mode"))
|
||||
}
|
||||
}
|
||||
|
||||
// Mode switch handled, continue to next poll
|
||||
time.Sleep(1 * time.Second)
|
||||
continue
|
||||
}
|
||||
|
||||
// Handle special commands
|
||||
if handled := ih.handleSpecialCommand(ctx, message); handled {
|
||||
continue
|
||||
}
|
||||
|
||||
// Send the message
|
||||
if err := ih.manager.SendMessage(ctx, message, nil, nil, ""); err != nil {
|
||||
output.Printf("\nError sending message: %v\n", err)
|
||||
continue
|
||||
}
|
||||
|
||||
if global.Config.Verbose {
|
||||
output.Printf("\nDebug: Message sent successfully\n")
|
||||
}
|
||||
|
||||
// Give the system a moment to process before re-polling
|
||||
time.Sleep(1 * time.Second)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// determineAutoApprovalAction determines which auto-approval action to enable based on the ask type
|
||||
func determineAutoApprovalAction(msg *types.ClineMessage) (string, error) {
|
||||
switch types.AskType(msg.Ask) {
|
||||
case types.AskTypeTool:
|
||||
// Parse tool message to determine if it's a read or edit operation
|
||||
var toolMsg types.ToolMessage
|
||||
if err := json.Unmarshal([]byte(msg.Text), &toolMsg); err != nil {
|
||||
return "", fmt.Errorf("failed to parse tool message: %w", err)
|
||||
}
|
||||
|
||||
// Determine action based on tool type
|
||||
switch types.ToolType(toolMsg.Tool) {
|
||||
case types.ToolTypeReadFile,
|
||||
types.ToolTypeListFilesTopLevel,
|
||||
types.ToolTypeListFilesRecursive,
|
||||
types.ToolTypeListCodeDefinitionNames,
|
||||
types.ToolTypeSearchFiles,
|
||||
types.ToolTypeWebFetch,
|
||||
types.ToolTypeWebSearch:
|
||||
return "read_files", nil
|
||||
case types.ToolTypeEditedExistingFile,
|
||||
types.ToolTypeNewFileCreated:
|
||||
return "edit_files", nil
|
||||
case types.ToolTypeFileDeleted:
|
||||
return "apply_patch", nil
|
||||
default:
|
||||
return "", fmt.Errorf("unsupported tool type: %s", toolMsg.Tool)
|
||||
}
|
||||
|
||||
case types.AskTypeCommand:
|
||||
return "execute_all_commands", nil
|
||||
|
||||
case types.AskTypeBrowserActionLaunch:
|
||||
return "use_browser", nil
|
||||
|
||||
case types.AskTypeUseMcpServer:
|
||||
return "use_mcp", nil
|
||||
|
||||
default:
|
||||
return "", fmt.Errorf("unsupported ask type: %s", msg.Ask)
|
||||
}
|
||||
}
|
||||
|
||||
// promptForInput displays an interactive prompt and waits for user input
|
||||
func (ih *InputHandler) promptForInput(ctx context.Context) (string, bool, error) {
|
||||
currentMode := ih.manager.GetCurrentMode()
|
||||
|
||||
model := output.NewInputModelWithRegistry(
|
||||
output.InputTypeMessage,
|
||||
"Cline is ready for your message...",
|
||||
"/plan or /act to switch modes\ntab to autocomplete commands\nctrl+e to open editor\nctrl+c to exit",
|
||||
currentMode,
|
||||
ih.slashCommandRegistry,
|
||||
)
|
||||
|
||||
return ih.runInputProgram(ctx, model)
|
||||
}
|
||||
|
||||
// promptForApproval displays an approval prompt for tool/command requests
|
||||
func (ih *InputHandler) promptForApproval(ctx context.Context, msg *types.ClineMessage) (bool, string, error) {
|
||||
// Store the approval message for later use in determining auto-approval action
|
||||
ih.approvalMessage = msg
|
||||
|
||||
model := output.NewInputModelWithRegistry(
|
||||
output.InputTypeApproval,
|
||||
"Let Cline use this tool?",
|
||||
"",
|
||||
ih.manager.GetCurrentMode(),
|
||||
ih.slashCommandRegistry,
|
||||
)
|
||||
|
||||
message, shouldSend, err := ih.runInputProgram(ctx, model)
|
||||
if err != nil {
|
||||
return false, "", err
|
||||
}
|
||||
|
||||
if !shouldSend {
|
||||
return false, "", nil
|
||||
}
|
||||
|
||||
// The approval and feedback are handled via the model state
|
||||
return ih.feedbackApproved, message, nil
|
||||
}
|
||||
|
||||
// runInputProgram runs the bubbletea program and waits for result
|
||||
func (ih *InputHandler) runInputProgram(ctx context.Context, model output.InputModel) (string, bool, error) {
|
||||
ih.mu.Lock()
|
||||
|
||||
// Create the program with custom update wrapper
|
||||
wrappedModel := &inputProgramWrapper{
|
||||
model: &model,
|
||||
resultChan: ih.resultChan,
|
||||
cancelChan: ih.cancelChan,
|
||||
handler: ih,
|
||||
}
|
||||
|
||||
ih.program = tea.NewProgram(wrappedModel)
|
||||
ih.programDoneChan = make(chan struct{})
|
||||
ih.ctx = ctx
|
||||
|
||||
// Set up coordinator references
|
||||
output.SetProgram(ih.program)
|
||||
output.SetInputModel(wrappedModel.model)
|
||||
output.SetRestartCallback(ih.restartProgram)
|
||||
output.SetInputVisible(true)
|
||||
ih.programRunning = true
|
||||
ih.mu.Unlock()
|
||||
|
||||
// Run program in goroutine
|
||||
programErrChan := make(chan error, 1)
|
||||
go func() {
|
||||
if _, err := ih.program.Run(); err != nil {
|
||||
programErrChan <- err
|
||||
}
|
||||
// Signal that program is done
|
||||
close(ih.programDoneChan)
|
||||
}()
|
||||
|
||||
// Wait for result, cancellation, or context done
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
ih.mu.Lock()
|
||||
output.SetInputVisible(false)
|
||||
if ih.program != nil {
|
||||
ih.program.Quit()
|
||||
}
|
||||
ih.programRunning = false
|
||||
ih.mu.Unlock()
|
||||
return "", false, ctx.Err()
|
||||
|
||||
case <-ih.cancelChan:
|
||||
ih.mu.Lock()
|
||||
output.SetInputVisible(false)
|
||||
ih.programRunning = false
|
||||
ih.mu.Unlock()
|
||||
return "", false, context.Canceled
|
||||
|
||||
case err := <-programErrChan:
|
||||
ih.mu.Lock()
|
||||
output.SetInputVisible(false)
|
||||
ih.programRunning = false
|
||||
ih.mu.Unlock()
|
||||
return "", false, err
|
||||
|
||||
case result := <-ih.resultChan:
|
||||
ih.mu.Lock()
|
||||
output.SetInputVisible(false)
|
||||
ih.programRunning = false
|
||||
ih.mu.Unlock()
|
||||
|
||||
// Handle different input types
|
||||
switch result.InputType {
|
||||
case output.InputTypeMessage:
|
||||
if result.Value == "" {
|
||||
return "", false, nil
|
||||
}
|
||||
return result.Value, true, nil
|
||||
|
||||
case output.InputTypeApproval:
|
||||
if result.NeedsFeedback {
|
||||
// Need to collect feedback - will be handled by model state change
|
||||
return "", false, nil
|
||||
}
|
||||
|
||||
// Check if NoAskAgain was selected
|
||||
if result.NoAskAgain && result.Approved && ih.approvalMessage != nil {
|
||||
// Determine which auto-approval action to enable
|
||||
action, err := determineAutoApprovalAction(ih.approvalMessage)
|
||||
if err != nil {
|
||||
output.Printf("\nWarning: Could not determine auto-approval action: %v\n", err)
|
||||
} else {
|
||||
// Enable the auto-approval action
|
||||
if err := ih.manager.UpdateTaskAutoApprovalAction(ctx, action); err != nil {
|
||||
output.Printf("\nWarning: Could not update auto-approval: %v\n", err)
|
||||
} else {
|
||||
output.Printf("\nAuto-approval enabled for %s\n", action)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Store approval state for when feedback comes back
|
||||
ih.feedbackApproval = false
|
||||
ih.feedbackApproved = result.Approved
|
||||
return "", true, nil
|
||||
|
||||
case output.InputTypeFeedback:
|
||||
// This came from approval flow
|
||||
ih.feedbackApproval = true
|
||||
ih.feedbackApproved = result.Approved // Use the approval decision from the feedback
|
||||
return result.Value, true, nil
|
||||
}
|
||||
|
||||
return "", false, nil
|
||||
}
|
||||
}
|
||||
|
||||
// inputProgramWrapper wraps the InputModel to handle message routing
|
||||
type inputProgramWrapper struct {
|
||||
model *output.InputModel
|
||||
resultChan chan output.InputSubmitMsg
|
||||
cancelChan chan struct{}
|
||||
handler *InputHandler
|
||||
}
|
||||
|
||||
func (w *inputProgramWrapper) Init() tea.Cmd {
|
||||
return w.model.Init()
|
||||
}
|
||||
|
||||
func (w *inputProgramWrapper) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
|
||||
switch msg := msg.(type) {
|
||||
case output.InputSubmitMsg:
|
||||
// Handle input submission - clear the screen before quitting
|
||||
w.resultChan <- msg
|
||||
clearCodes := w.model.ClearScreen()
|
||||
if clearCodes != "" {
|
||||
fmt.Print(clearCodes)
|
||||
}
|
||||
return w, tea.Quit
|
||||
|
||||
case output.InputCancelMsg:
|
||||
// Handle cancellation - clear the screen before quitting
|
||||
w.cancelChan <- struct{}{}
|
||||
clearCodes := w.model.ClearScreen()
|
||||
if clearCodes != "" {
|
||||
fmt.Print(clearCodes)
|
||||
}
|
||||
return w, tea.Quit
|
||||
|
||||
case output.ChangeInputTypeMsg:
|
||||
// Change input type (approval -> feedback)
|
||||
_, cmd := w.model.Update(msg)
|
||||
return w, cmd
|
||||
}
|
||||
|
||||
// Forward to wrapped model
|
||||
_, cmd := w.model.Update(msg)
|
||||
return w, cmd
|
||||
}
|
||||
|
||||
func (w *inputProgramWrapper) View() string {
|
||||
return w.model.View()
|
||||
}
|
||||
|
||||
// handleSpecialCommand processes special commands like /cancel, /exit
|
||||
func (ih *InputHandler) handleSpecialCommand(ctx context.Context, message string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(message)) {
|
||||
case "/cancel":
|
||||
ih.manager.GetRenderer().RenderTaskCancelled()
|
||||
if err := ih.manager.CancelTask(ctx); err != nil {
|
||||
output.Printf("Error cancelling task: %v\n", err)
|
||||
} else {
|
||||
output.Println("Task cancelled successfully")
|
||||
}
|
||||
return true
|
||||
case "/exit", "/quit":
|
||||
output.Println("\nExiting follow mode...")
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Stop stops the input handler
|
||||
func (ih *InputHandler) Stop() {
|
||||
ih.mu.Lock()
|
||||
defer ih.mu.Unlock()
|
||||
if ih.pollTicker != nil {
|
||||
ih.pollTicker.Stop()
|
||||
}
|
||||
if ih.program != nil && ih.programRunning {
|
||||
ih.program.Quit()
|
||||
}
|
||||
ih.isRunning = false
|
||||
}
|
||||
|
||||
// IsRunning returns whether the input handler is currently running
|
||||
func (ih *InputHandler) IsRunning() bool {
|
||||
ih.mu.RLock()
|
||||
defer ih.mu.RUnlock()
|
||||
return ih.isRunning
|
||||
}
|
||||
|
||||
// restartProgram restarts the Bubble Tea program with preserved state
|
||||
func (ih *InputHandler) restartProgram(savedModel *output.InputModel) {
|
||||
ih.mu.Lock()
|
||||
|
||||
// Wait for old program to actually quit
|
||||
if ih.programDoneChan != nil {
|
||||
select {
|
||||
case <-ih.programDoneChan:
|
||||
// Program quit successfully
|
||||
case <-time.After(100 * time.Millisecond):
|
||||
// Timeout - continue anyway
|
||||
}
|
||||
}
|
||||
|
||||
// Create new wrapper with the saved model
|
||||
wrappedModel := &inputProgramWrapper{
|
||||
model: savedModel,
|
||||
resultChan: ih.resultChan,
|
||||
cancelChan: ih.cancelChan,
|
||||
handler: ih,
|
||||
}
|
||||
|
||||
// Start new program
|
||||
ih.program = tea.NewProgram(wrappedModel)
|
||||
ih.programDoneChan = make(chan struct{})
|
||||
|
||||
// Update coordinator references
|
||||
output.SetProgram(ih.program)
|
||||
output.SetInputModel(savedModel)
|
||||
output.SetInputVisible(true)
|
||||
ih.programRunning = true
|
||||
ih.mu.Unlock()
|
||||
|
||||
// Run in goroutine
|
||||
go func() {
|
||||
if _, err := ih.program.Run(); err != nil {
|
||||
// Log error if needed
|
||||
if global.Config.Verbose {
|
||||
output.Printf("\nDebug: Program restart error: %v\n", err)
|
||||
}
|
||||
}
|
||||
close(ih.programDoneChan)
|
||||
}()
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,760 +0,0 @@
|
||||
package task
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/cline/grpc-go/cline"
|
||||
)
|
||||
|
||||
|
||||
func ParseTaskSettings(settingsFlags []string) (*cline.Settings, *cline.Secrets, error) {
|
||||
if len(settingsFlags) == 0 {
|
||||
return nil, nil, nil
|
||||
}
|
||||
|
||||
settings := &cline.Settings{}
|
||||
secrets := &cline.Secrets{}
|
||||
nestedSettings := make(map[string]map[string]string)
|
||||
|
||||
for _, flag := range settingsFlags {
|
||||
// Parse key=value
|
||||
parts := strings.SplitN(flag, "=", 2)
|
||||
if len(parts) != 2 {
|
||||
return nil, nil, fmt.Errorf("invalid setting format '%s': expected key=value", flag)
|
||||
}
|
||||
|
||||
key := strings.TrimSpace(parts[0])
|
||||
value := strings.TrimSpace(parts[1])
|
||||
|
||||
// Convert kebab-case to snake_case
|
||||
key = kebabToSnake(key)
|
||||
|
||||
// Check if this is a nested setting (contains a dot)
|
||||
if strings.Contains(key, ".") {
|
||||
dotParts := strings.SplitN(key, ".", 2)
|
||||
parentField := dotParts[0]
|
||||
childField := dotParts[1]
|
||||
|
||||
if nestedSettings[parentField] == nil {
|
||||
nestedSettings[parentField] = make(map[string]string)
|
||||
}
|
||||
nestedSettings[parentField][childField] = value
|
||||
} else {
|
||||
// Check if it's a secret field first, then settings field
|
||||
if err := setSecretField(secrets, key, value); err == nil {
|
||||
// Successfully set as secret, continue
|
||||
continue
|
||||
}
|
||||
// Not a secret, try as a settings field
|
||||
if err := setSimpleField(settings, key, value); err != nil {
|
||||
return nil, nil, fmt.Errorf("error setting field '%s': %w", key, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Process nested settings
|
||||
for parentField, childFields := range nestedSettings {
|
||||
if err := setNestedField(settings, parentField, childFields); err != nil {
|
||||
return nil, nil, fmt.Errorf("error setting nested field '%s': %w", parentField, err)
|
||||
}
|
||||
}
|
||||
|
||||
return settings, secrets, nil
|
||||
}
|
||||
|
||||
// kebabToSnake converts kebab-case to snake_case
|
||||
func kebabToSnake(s string) string {
|
||||
return strings.ReplaceAll(s, "-", "_")
|
||||
}
|
||||
|
||||
// Pointer helper functions for optional protobuf fields
|
||||
func strPtr(s string) *string { return &s }
|
||||
func boolPtr(b bool) *bool { return &b }
|
||||
func int32Ptr(i int32) *int32 { return &i }
|
||||
func int64Ptr(i int64) *int64 { return &i }
|
||||
func float64Ptr(f float64) *float64 { return &f }
|
||||
|
||||
// setSimpleField sets a simple (non-nested) field on Settings
|
||||
func setSimpleField(settings *cline.Settings, key, value string) error {
|
||||
switch key {
|
||||
// String fields
|
||||
case "aws_region":
|
||||
settings.AwsRegion = strPtr(value)
|
||||
case "aws_bedrock_endpoint":
|
||||
settings.AwsBedrockEndpoint = strPtr(value)
|
||||
case "aws_profile":
|
||||
settings.AwsProfile = strPtr(value)
|
||||
case "aws_authentication":
|
||||
settings.AwsAuthentication = strPtr(value)
|
||||
case "vertex_project_id":
|
||||
settings.VertexProjectId = strPtr(value)
|
||||
case "vertex_region":
|
||||
settings.VertexRegion = strPtr(value)
|
||||
case "requesty_base_url":
|
||||
settings.RequestyBaseUrl = strPtr(value)
|
||||
case "open_ai_base_url":
|
||||
settings.OpenAiBaseUrl = strPtr(value)
|
||||
case "ollama_base_url":
|
||||
settings.OllamaBaseUrl = strPtr(value)
|
||||
case "ollama_api_options_ctx_num":
|
||||
settings.OllamaApiOptionsCtxNum = strPtr(value)
|
||||
case "lm_studio_base_url":
|
||||
settings.LmStudioBaseUrl = strPtr(value)
|
||||
case "lm_studio_max_tokens":
|
||||
settings.LmStudioMaxTokens = strPtr(value)
|
||||
case "anthropic_base_url":
|
||||
settings.AnthropicBaseUrl = strPtr(value)
|
||||
case "gemini_base_url":
|
||||
settings.GeminiBaseUrl = strPtr(value)
|
||||
case "azure_api_version":
|
||||
settings.AzureApiVersion = strPtr(value)
|
||||
case "open_router_provider_sorting":
|
||||
settings.OpenRouterProviderSorting = strPtr(value)
|
||||
case "lite_llm_base_url":
|
||||
settings.LiteLlmBaseUrl = strPtr(value)
|
||||
case "qwen_api_line":
|
||||
settings.QwenApiLine = strPtr(value)
|
||||
case "moonshot_api_line":
|
||||
settings.MoonshotApiLine = strPtr(value)
|
||||
case "zai_api_line":
|
||||
settings.ZaiApiLine = strPtr(value)
|
||||
case "telemetry_setting":
|
||||
settings.TelemetrySetting = strPtr(value)
|
||||
case "asksage_api_url":
|
||||
settings.AsksageApiUrl = strPtr(value)
|
||||
case "default_terminal_profile":
|
||||
settings.DefaultTerminalProfile = strPtr(value)
|
||||
case "sap_ai_core_token_url":
|
||||
settings.SapAiCoreTokenUrl = strPtr(value)
|
||||
case "sap_ai_core_base_url":
|
||||
settings.SapAiCoreBaseUrl = strPtr(value)
|
||||
case "sap_ai_resource_group":
|
||||
settings.SapAiResourceGroup = strPtr(value)
|
||||
case "claude_code_path":
|
||||
settings.ClaudeCodePath = strPtr(value)
|
||||
case "qwen_code_oauth_path":
|
||||
settings.QwenCodeOauthPath = strPtr(value)
|
||||
case "preferred_language":
|
||||
settings.PreferredLanguage = strPtr(value)
|
||||
case "custom_prompt":
|
||||
settings.CustomPrompt = strPtr(value)
|
||||
case "dify_base_url":
|
||||
settings.DifyBaseUrl = strPtr(value)
|
||||
case "oca_base_url":
|
||||
settings.OcaBaseUrl = strPtr(value)
|
||||
case "plan_mode_api_model_id":
|
||||
settings.PlanModeApiModelId = strPtr(value)
|
||||
case "plan_mode_reasoning_effort":
|
||||
settings.PlanModeReasoningEffort = strPtr(value)
|
||||
case "plan_mode_aws_bedrock_custom_model_base_id":
|
||||
settings.PlanModeAwsBedrockCustomModelBaseId = strPtr(value)
|
||||
case "plan_mode_open_router_model_id":
|
||||
settings.PlanModeOpenRouterModelId = strPtr(value)
|
||||
case "plan_mode_open_ai_model_id":
|
||||
settings.PlanModeOpenAiModelId = strPtr(value)
|
||||
case "plan_mode_ollama_model_id":
|
||||
settings.PlanModeOllamaModelId = strPtr(value)
|
||||
case "plan_mode_lm_studio_model_id":
|
||||
settings.PlanModeLmStudioModelId = strPtr(value)
|
||||
case "plan_mode_lite_llm_model_id":
|
||||
settings.PlanModeLiteLlmModelId = strPtr(value)
|
||||
case "plan_mode_requesty_model_id":
|
||||
settings.PlanModeRequestyModelId = strPtr(value)
|
||||
case "plan_mode_together_model_id":
|
||||
settings.PlanModeTogetherModelId = strPtr(value)
|
||||
case "plan_mode_fireworks_model_id":
|
||||
settings.PlanModeFireworksModelId = strPtr(value)
|
||||
case "plan_mode_sap_ai_core_model_id":
|
||||
settings.PlanModeSapAiCoreModelId = strPtr(value)
|
||||
case "plan_mode_sap_ai_core_deployment_id":
|
||||
settings.PlanModeSapAiCoreDeploymentId = strPtr(value)
|
||||
case "plan_mode_groq_model_id":
|
||||
settings.PlanModeGroqModelId = strPtr(value)
|
||||
case "plan_mode_baseten_model_id":
|
||||
settings.PlanModeBasetenModelId = strPtr(value)
|
||||
case "plan_mode_hugging_face_model_id":
|
||||
settings.PlanModeHuggingFaceModelId = strPtr(value)
|
||||
case "plan_mode_huawei_cloud_maas_model_id":
|
||||
settings.PlanModeHuaweiCloudMaasModelId = strPtr(value)
|
||||
case "plan_mode_oca_model_id":
|
||||
settings.PlanModeOcaModelId = strPtr(value)
|
||||
case "act_mode_api_model_id":
|
||||
settings.ActModeApiModelId = strPtr(value)
|
||||
case "act_mode_reasoning_effort":
|
||||
settings.ActModeReasoningEffort = strPtr(value)
|
||||
case "act_mode_aws_bedrock_custom_model_base_id":
|
||||
settings.ActModeAwsBedrockCustomModelBaseId = strPtr(value)
|
||||
case "act_mode_open_router_model_id":
|
||||
settings.ActModeOpenRouterModelId = strPtr(value)
|
||||
case "act_mode_open_ai_model_id":
|
||||
settings.ActModeOpenAiModelId = strPtr(value)
|
||||
case "act_mode_ollama_model_id":
|
||||
settings.ActModeOllamaModelId = strPtr(value)
|
||||
case "act_mode_lm_studio_model_id":
|
||||
settings.ActModeLmStudioModelId = strPtr(value)
|
||||
case "act_mode_lite_llm_model_id":
|
||||
settings.ActModeLiteLlmModelId = strPtr(value)
|
||||
case "act_mode_requesty_model_id":
|
||||
settings.ActModeRequestyModelId = strPtr(value)
|
||||
case "act_mode_together_model_id":
|
||||
settings.ActModeTogetherModelId = strPtr(value)
|
||||
case "act_mode_fireworks_model_id":
|
||||
settings.ActModeFireworksModelId = strPtr(value)
|
||||
case "act_mode_sap_ai_core_model_id":
|
||||
settings.ActModeSapAiCoreModelId = strPtr(value)
|
||||
case "act_mode_sap_ai_core_deployment_id":
|
||||
settings.ActModeSapAiCoreDeploymentId = strPtr(value)
|
||||
case "act_mode_groq_model_id":
|
||||
settings.ActModeGroqModelId = strPtr(value)
|
||||
case "act_mode_baseten_model_id":
|
||||
settings.ActModeBasetenModelId = strPtr(value)
|
||||
case "act_mode_hugging_face_model_id":
|
||||
settings.ActModeHuggingFaceModelId = strPtr(value)
|
||||
case "act_mode_huawei_cloud_maas_model_id":
|
||||
settings.ActModeHuaweiCloudMaasModelId = strPtr(value)
|
||||
case "act_mode_oca_model_id":
|
||||
settings.ActModeOcaModelId = strPtr(value)
|
||||
|
||||
// Boolean fields
|
||||
case "aws_use_cross_region_inference":
|
||||
val, err := parseBool(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
settings.AwsUseCrossRegionInference = boolPtr(val)
|
||||
case "aws_bedrock_use_prompt_cache":
|
||||
val, err := parseBool(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
settings.AwsBedrockUsePromptCache = boolPtr(val)
|
||||
case "aws_use_profile":
|
||||
val, err := parseBool(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
settings.AwsUseProfile = boolPtr(val)
|
||||
case "lite_llm_use_prompt_cache":
|
||||
val, err := parseBool(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
settings.LiteLlmUsePromptCache = boolPtr(val)
|
||||
case "plan_act_separate_models_setting":
|
||||
val, err := parseBool(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
settings.PlanActSeparateModelsSetting = boolPtr(val)
|
||||
case "enable_checkpoints_setting":
|
||||
val, err := parseBool(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
settings.EnableCheckpointsSetting = boolPtr(val)
|
||||
case "sap_ai_core_use_orchestration_mode":
|
||||
val, err := parseBool(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
settings.SapAiCoreUseOrchestrationMode = boolPtr(val)
|
||||
case "strict_plan_mode_enabled":
|
||||
val, err := parseBool(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
settings.StrictPlanModeEnabled = boolPtr(val)
|
||||
case "yolo_mode_toggled":
|
||||
val, err := parseBool(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
settings.YoloModeToggled = boolPtr(val)
|
||||
case "use_auto_condense":
|
||||
val, err := parseBool(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
settings.UseAutoCondense = boolPtr(val)
|
||||
case "plan_mode_aws_bedrock_custom_selected":
|
||||
val, err := parseBool(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
settings.PlanModeAwsBedrockCustomSelected = boolPtr(val)
|
||||
case "act_mode_aws_bedrock_custom_selected":
|
||||
val, err := parseBool(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
settings.ActModeAwsBedrockCustomSelected = boolPtr(val)
|
||||
case "azure_identity":
|
||||
val, err := parseBool(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
settings.AzureIdentity = boolPtr(val)
|
||||
|
||||
// Integer fields
|
||||
case "request_timeout_ms":
|
||||
val, err := parseInt32(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
settings.RequestTimeoutMs = int32Ptr(val)
|
||||
case "shell_integration_timeout":
|
||||
val, err := parseInt32(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
settings.ShellIntegrationTimeout = int32Ptr(val)
|
||||
case "terminal_output_line_limit":
|
||||
val, err := parseInt32(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
settings.TerminalOutputLineLimit = int32Ptr(val)
|
||||
case "max_consecutive_mistakes":
|
||||
val, err := parseInt32(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
settings.MaxConsecutiveMistakes = int32Ptr(val)
|
||||
case "fireworks_model_max_completion_tokens":
|
||||
val, err := parseInt32(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
settings.FireworksModelMaxCompletionTokens = int32Ptr(val)
|
||||
case "fireworks_model_max_tokens":
|
||||
val, err := parseInt32(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
settings.FireworksModelMaxTokens = int32Ptr(val)
|
||||
|
||||
// Int64 fields
|
||||
case "plan_mode_thinking_budget_tokens":
|
||||
val, err := parseInt64(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
settings.PlanModeThinkingBudgetTokens = int64Ptr(val)
|
||||
case "act_mode_thinking_budget_tokens":
|
||||
val, err := parseInt64(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
settings.ActModeThinkingBudgetTokens = int64Ptr(val)
|
||||
|
||||
// Double fields
|
||||
case "auto_condense_threshold":
|
||||
val, err := parseFloat64(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
settings.AutoCondenseThreshold = float64Ptr(val)
|
||||
|
||||
// Enum fields
|
||||
// Note: We can use &val directly for enums because the parser functions return a new local variable.
|
||||
// This is different from using &value (the loop variable), which would cause all fields to share
|
||||
// the same memory address.
|
||||
case "openai_reasoning_effort":
|
||||
val, err := parseOpenaiReasoningEffort(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
settings.OpenaiReasoningEffort = &val
|
||||
case "mode":
|
||||
val, err := parsePlanActMode(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
settings.Mode = &val
|
||||
case "plan_mode_api_provider":
|
||||
val, err := parseApiProvider(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
settings.PlanModeApiProvider = &val
|
||||
case "act_mode_api_provider":
|
||||
val, err := parseApiProvider(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
settings.ActModeApiProvider = &val
|
||||
|
||||
default:
|
||||
return fmt.Errorf("unsupported field '%s'", key)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// setNestedField sets a nested field on Settings
|
||||
// Currently supports: auto_approval_settings, browser_settings
|
||||
func setNestedField(settings *cline.Settings, parentField string, childFields map[string]string) error {
|
||||
switch parentField {
|
||||
case "auto_approval_settings":
|
||||
if settings.AutoApprovalSettings == nil {
|
||||
settings.AutoApprovalSettings = &cline.AutoApprovalSettings{}
|
||||
}
|
||||
return setAutoApprovalSettings(settings.AutoApprovalSettings, childFields)
|
||||
|
||||
case "browser_settings":
|
||||
if settings.BrowserSettings == nil {
|
||||
settings.BrowserSettings = &cline.BrowserSettings{}
|
||||
}
|
||||
return setBrowserSettings(settings.BrowserSettings, childFields)
|
||||
|
||||
default:
|
||||
return fmt.Errorf("unsupported nested field '%s' (complex nested types are not supported via -s flags)", parentField)
|
||||
}
|
||||
}
|
||||
|
||||
// setAutoApprovalSettings sets fields on AutoApprovalSettings
|
||||
func setAutoApprovalSettings(settings *cline.AutoApprovalSettings, fields map[string]string) error {
|
||||
for key, value := range fields {
|
||||
switch key {
|
||||
case "enable_notifications":
|
||||
val, err := parseBool(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
settings.EnableNotifications = boolPtr(val)
|
||||
case "actions":
|
||||
return fmt.Errorf("auto_approval_settings.actions requires nested dot notation (e.g., auto-approval-settings.actions.read-files=true)")
|
||||
default:
|
||||
// Check if this is an action field (actions.*)
|
||||
if strings.HasPrefix(key, "actions.") {
|
||||
actionField := strings.TrimPrefix(key, "actions.")
|
||||
if settings.Actions == nil {
|
||||
settings.Actions = &cline.AutoApprovalActions{}
|
||||
}
|
||||
if err := setAutoApprovalAction(settings.Actions, actionField, value); err != nil {
|
||||
return err
|
||||
}
|
||||
// Continue processing other fields
|
||||
} else {
|
||||
return fmt.Errorf("unsupported auto_approval_settings field '%s'", key)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// setAutoApprovalAction sets fields on AutoApprovalActions
|
||||
func setAutoApprovalAction(actions *cline.AutoApprovalActions, key, value string) error {
|
||||
val, err := parseBool(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
switch key {
|
||||
case "read_files":
|
||||
actions.ReadFiles = boolPtr(val)
|
||||
case "read_files_externally":
|
||||
actions.ReadFilesExternally = boolPtr(val)
|
||||
case "edit_files":
|
||||
actions.EditFiles = boolPtr(val)
|
||||
case "edit_files_externally":
|
||||
actions.EditFilesExternally = boolPtr(val)
|
||||
case "execute_safe_commands":
|
||||
actions.ExecuteSafeCommands = boolPtr(val)
|
||||
case "execute_all_commands":
|
||||
actions.ExecuteAllCommands = boolPtr(val)
|
||||
case "use_browser":
|
||||
actions.UseBrowser = boolPtr(val)
|
||||
case "use_mcp":
|
||||
actions.UseMcp = boolPtr(val)
|
||||
default:
|
||||
return fmt.Errorf("unsupported auto_approval_actions field '%s'", key)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// setBrowserSettings sets fields on BrowserSettings
|
||||
func setBrowserSettings(settings *cline.BrowserSettings, fields map[string]string) error {
|
||||
for key, value := range fields {
|
||||
switch key {
|
||||
case "viewport_width":
|
||||
val, err := parseInt32(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if settings.Viewport == nil {
|
||||
settings.Viewport = &cline.Viewport{}
|
||||
}
|
||||
settings.Viewport.Width = val
|
||||
case "viewport_height":
|
||||
val, err := parseInt32(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if settings.Viewport == nil {
|
||||
settings.Viewport = &cline.Viewport{}
|
||||
}
|
||||
settings.Viewport.Height = val
|
||||
case "remote_browser_host":
|
||||
settings.RemoteBrowserHost = strPtr(value)
|
||||
case "remote_browser_enabled":
|
||||
val, err := parseBool(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
settings.RemoteBrowserEnabled = boolPtr(val)
|
||||
case "chrome_executable_path":
|
||||
settings.ChromeExecutablePath = strPtr(value)
|
||||
case "disable_tool_use":
|
||||
val, err := parseBool(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
settings.DisableToolUse = boolPtr(val)
|
||||
case "custom_args":
|
||||
settings.CustomArgs = strPtr(value)
|
||||
default:
|
||||
return fmt.Errorf("unsupported browser_settings field '%s'", key)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Type parsing helpers
|
||||
func parseBool(value string) (bool, error) {
|
||||
lower := strings.ToLower(value)
|
||||
switch lower {
|
||||
case "true", "t", "yes", "y", "1":
|
||||
return true, nil
|
||||
case "false", "f", "no", "n", "0":
|
||||
return false, nil
|
||||
default:
|
||||
return false, fmt.Errorf("invalid boolean value '%s': expected true/false", value)
|
||||
}
|
||||
}
|
||||
|
||||
func parseInt32(value string) (int32, error) {
|
||||
val, err := strconv.ParseInt(value, 10, 32)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("invalid integer value '%s': %w", value, err)
|
||||
}
|
||||
return int32(val), nil
|
||||
}
|
||||
|
||||
func parseInt64(value string) (int64, error) {
|
||||
val, err := strconv.ParseInt(value, 10, 64)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("invalid integer value '%s': %w", value, err)
|
||||
}
|
||||
return val, nil
|
||||
}
|
||||
|
||||
func parseFloat64(value string) (float64, error) {
|
||||
val, err := strconv.ParseFloat(value, 64)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("invalid float value '%s': %w", value, err)
|
||||
}
|
||||
return val, nil
|
||||
}
|
||||
|
||||
// Enum parsing helpers
|
||||
func parseOpenaiReasoningEffort(value string) (cline.OpenaiReasoningEffort, error) {
|
||||
lower := strings.ToLower(value)
|
||||
switch lower {
|
||||
case "low":
|
||||
return cline.OpenaiReasoningEffort_LOW, nil
|
||||
case "medium":
|
||||
return cline.OpenaiReasoningEffort_MEDIUM, nil
|
||||
case "high":
|
||||
return cline.OpenaiReasoningEffort_HIGH, nil
|
||||
default:
|
||||
return cline.OpenaiReasoningEffort_LOW, fmt.Errorf("invalid openai_reasoning_effort '%s': expected low/medium/high", value)
|
||||
}
|
||||
}
|
||||
|
||||
func parsePlanActMode(value string) (cline.PlanActMode, error) {
|
||||
lower := strings.ToLower(value)
|
||||
switch lower {
|
||||
case "plan":
|
||||
return cline.PlanActMode_PLAN, nil
|
||||
case "act":
|
||||
return cline.PlanActMode_ACT, nil
|
||||
default:
|
||||
return cline.PlanActMode_ACT, fmt.Errorf("invalid mode '%s': expected plan/act", value)
|
||||
}
|
||||
}
|
||||
|
||||
func parseApiProvider(value string) (cline.ApiProvider, error) {
|
||||
lower := strings.ToLower(value)
|
||||
switch lower {
|
||||
case "anthropic":
|
||||
return cline.ApiProvider_ANTHROPIC, nil
|
||||
case "openrouter":
|
||||
return cline.ApiProvider_OPENROUTER, nil
|
||||
case "bedrock":
|
||||
return cline.ApiProvider_BEDROCK, nil
|
||||
case "vertex":
|
||||
return cline.ApiProvider_VERTEX, nil
|
||||
case "openai":
|
||||
return cline.ApiProvider_OPENAI, nil
|
||||
case "ollama":
|
||||
return cline.ApiProvider_OLLAMA, nil
|
||||
case "lmstudio":
|
||||
return cline.ApiProvider_LMSTUDIO, nil
|
||||
case "gemini":
|
||||
return cline.ApiProvider_GEMINI, nil
|
||||
case "openai_native":
|
||||
return cline.ApiProvider_OPENAI_NATIVE, nil
|
||||
case "requesty":
|
||||
return cline.ApiProvider_REQUESTY, nil
|
||||
case "together":
|
||||
return cline.ApiProvider_TOGETHER, nil
|
||||
case "deepseek":
|
||||
return cline.ApiProvider_DEEPSEEK, nil
|
||||
case "qwen":
|
||||
return cline.ApiProvider_QWEN, nil
|
||||
case "doubao":
|
||||
return cline.ApiProvider_DOUBAO, nil
|
||||
case "mistral":
|
||||
return cline.ApiProvider_MISTRAL, nil
|
||||
case "vscode_lm":
|
||||
return cline.ApiProvider_VSCODE_LM, nil
|
||||
case "cline":
|
||||
return cline.ApiProvider_CLINE, nil
|
||||
case "litellm":
|
||||
return cline.ApiProvider_LITELLM, nil
|
||||
case "nebius":
|
||||
return cline.ApiProvider_NEBIUS, nil
|
||||
case "fireworks":
|
||||
return cline.ApiProvider_FIREWORKS, nil
|
||||
case "asksage":
|
||||
return cline.ApiProvider_ASKSAGE, nil
|
||||
case "xai", "grok":
|
||||
return cline.ApiProvider_XAI, nil
|
||||
case "sambanova":
|
||||
return cline.ApiProvider_SAMBANOVA, nil
|
||||
case "cerebras":
|
||||
return cline.ApiProvider_CEREBRAS, nil
|
||||
case "groq":
|
||||
return cline.ApiProvider_GROQ, nil
|
||||
case "sapaicore", "sap_ai_core":
|
||||
return cline.ApiProvider_SAPAICORE, nil
|
||||
case "claude_code":
|
||||
return cline.ApiProvider_CLAUDE_CODE, nil
|
||||
case "moonshot":
|
||||
return cline.ApiProvider_MOONSHOT, nil
|
||||
case "huggingface":
|
||||
return cline.ApiProvider_HUGGINGFACE, nil
|
||||
case "huawei_cloud_maas":
|
||||
return cline.ApiProvider_HUAWEI_CLOUD_MAAS, nil
|
||||
case "baseten":
|
||||
return cline.ApiProvider_BASETEN, nil
|
||||
case "zai":
|
||||
return cline.ApiProvider_ZAI, nil
|
||||
case "vercel_ai_gateway":
|
||||
return cline.ApiProvider_VERCEL_AI_GATEWAY, nil
|
||||
case "qwen_code":
|
||||
return cline.ApiProvider_QWEN_CODE, nil
|
||||
case "dify":
|
||||
return cline.ApiProvider_DIFY, nil
|
||||
case "oca":
|
||||
return cline.ApiProvider_OCA, nil
|
||||
case "minimax":
|
||||
return cline.ApiProvider_MINIMAX, nil
|
||||
default:
|
||||
return cline.ApiProvider_ANTHROPIC, fmt.Errorf("invalid api_provider '%s'", value)
|
||||
}
|
||||
}
|
||||
|
||||
// setSecretField sets a secret field on Secrets
|
||||
// All secret fields are optional strings
|
||||
// Returns nil if field was successfully set, error otherwise
|
||||
func setSecretField(secrets *cline.Secrets, key, value string) error {
|
||||
switch key {
|
||||
case "api_key":
|
||||
secrets.ApiKey = strPtr(value)
|
||||
case "open_router_api_key":
|
||||
secrets.OpenRouterApiKey = strPtr(value)
|
||||
case "aws_access_key":
|
||||
secrets.AwsAccessKey = strPtr(value)
|
||||
case "aws_secret_key":
|
||||
secrets.AwsSecretKey = strPtr(value)
|
||||
case "aws_session_token":
|
||||
secrets.AwsSessionToken = strPtr(value)
|
||||
case "aws_bedrock_api_key":
|
||||
secrets.AwsBedrockApiKey = strPtr(value)
|
||||
case "open_ai_api_key":
|
||||
secrets.OpenAiApiKey = strPtr(value)
|
||||
case "gemini_api_key":
|
||||
secrets.GeminiApiKey = strPtr(value)
|
||||
case "open_ai_native_api_key":
|
||||
secrets.OpenAiNativeApiKey = strPtr(value)
|
||||
case "ollama_api_key":
|
||||
secrets.OllamaApiKey = strPtr(value)
|
||||
case "deep_seek_api_key":
|
||||
secrets.DeepSeekApiKey = strPtr(value)
|
||||
case "requesty_api_key":
|
||||
secrets.RequestyApiKey = strPtr(value)
|
||||
case "together_api_key":
|
||||
secrets.TogetherApiKey = strPtr(value)
|
||||
case "fireworks_api_key":
|
||||
secrets.FireworksApiKey = strPtr(value)
|
||||
case "qwen_api_key":
|
||||
secrets.QwenApiKey = strPtr(value)
|
||||
case "doubao_api_key":
|
||||
secrets.DoubaoApiKey = strPtr(value)
|
||||
case "mistral_api_key":
|
||||
secrets.MistralApiKey = strPtr(value)
|
||||
case "lite_llm_api_key":
|
||||
secrets.LiteLlmApiKey = strPtr(value)
|
||||
case "auth_nonce":
|
||||
secrets.AuthNonce = strPtr(value)
|
||||
case "asksage_api_key":
|
||||
secrets.AsksageApiKey = strPtr(value)
|
||||
case "xai_api_key":
|
||||
secrets.XaiApiKey = strPtr(value)
|
||||
case "moonshot_api_key":
|
||||
secrets.MoonshotApiKey = strPtr(value)
|
||||
case "zai_api_key":
|
||||
secrets.ZaiApiKey = strPtr(value)
|
||||
case "hugging_face_api_key":
|
||||
secrets.HuggingFaceApiKey = strPtr(value)
|
||||
case "nebius_api_key":
|
||||
secrets.NebiusApiKey = strPtr(value)
|
||||
case "sambanova_api_key":
|
||||
secrets.SambanovaApiKey = strPtr(value)
|
||||
case "cerebras_api_key":
|
||||
secrets.CerebrasApiKey = strPtr(value)
|
||||
case "sap_ai_core_client_id":
|
||||
secrets.SapAiCoreClientId = strPtr(value)
|
||||
case "sap_ai_core_client_secret":
|
||||
secrets.SapAiCoreClientSecret = strPtr(value)
|
||||
case "groq_api_key":
|
||||
secrets.GroqApiKey = strPtr(value)
|
||||
case "huawei_cloud_maas_api_key":
|
||||
secrets.HuaweiCloudMaasApiKey = strPtr(value)
|
||||
case "baseten_api_key":
|
||||
secrets.BasetenApiKey = strPtr(value)
|
||||
case "dify_api_key":
|
||||
secrets.DifyApiKey = strPtr(value)
|
||||
case "oca_api_key":
|
||||
secrets.OcaApiKey = strPtr(value)
|
||||
case "oca_refresh_token":
|
||||
secrets.OcaRefreshToken = strPtr(value)
|
||||
case "hicap_api_key":
|
||||
secrets.HicapApiKey = strPtr(value)
|
||||
default:
|
||||
return fmt.Errorf("unsupported secret field '%s'", key)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Note: message types not supported via -s flags:
|
||||
// - OpenRouterModelInfo, OpenAiCompatibleModelInfo, LiteLLMModelInfo, OcaModelInfo
|
||||
// - LanguageModelChatSelector
|
||||
// - DictationSettings
|
||||
// - FocusChainSettings
|
||||
@@ -1,60 +0,0 @@
|
||||
package task
|
||||
|
||||
import "sync"
|
||||
|
||||
// StreamCoordinator manages coordination between SubscribeToState and SubscribeToPartialMessage streams
|
||||
type StreamCoordinator struct {
|
||||
conversationTurnStartIndex int // First message index of current turn
|
||||
processedInCurrentTurn map[string]bool // What we've handled in THIS turn
|
||||
inputAllowed bool // Whether user input is currently allowed
|
||||
mu sync.RWMutex // Protects inputAllowed
|
||||
}
|
||||
|
||||
// NewStreamCoordinator creates a new stream coordinator
|
||||
func NewStreamCoordinator() *StreamCoordinator {
|
||||
return &StreamCoordinator{
|
||||
conversationTurnStartIndex: 0,
|
||||
processedInCurrentTurn: make(map[string]bool),
|
||||
}
|
||||
}
|
||||
|
||||
// SetConversationTurnStartIndex sets the starting index for the current conversation turn
|
||||
func (sc *StreamCoordinator) SetConversationTurnStartIndex(index int) {
|
||||
sc.conversationTurnStartIndex = index
|
||||
}
|
||||
|
||||
// GetConversationTurnStartIndex returns the starting index for the current conversation turn
|
||||
func (sc *StreamCoordinator) GetConversationTurnStartIndex() int {
|
||||
return sc.conversationTurnStartIndex
|
||||
}
|
||||
|
||||
// MarkProcessedInCurrentTurn marks an item as processed in the current turn
|
||||
func (sc *StreamCoordinator) MarkProcessedInCurrentTurn(key string) {
|
||||
sc.processedInCurrentTurn[key] = true
|
||||
}
|
||||
|
||||
// IsProcessedInCurrentTurn checks if an item has been processed in the current turn
|
||||
func (sc *StreamCoordinator) IsProcessedInCurrentTurn(key string) bool {
|
||||
return sc.processedInCurrentTurn[key]
|
||||
}
|
||||
|
||||
// CompleteTurn updates the start index for the next batch of messages
|
||||
// Note: Does NOT reset the processed map - that persists across state updates
|
||||
func (sc *StreamCoordinator) CompleteTurn(totalMessages int) {
|
||||
sc.conversationTurnStartIndex = totalMessages
|
||||
// Don't reset processedInCurrentTurn - it should persist across state updates
|
||||
}
|
||||
|
||||
// SetInputAllowed sets whether user input is currently allowed
|
||||
func (sc *StreamCoordinator) SetInputAllowed(allowed bool) {
|
||||
sc.mu.Lock()
|
||||
defer sc.mu.Unlock()
|
||||
sc.inputAllowed = allowed
|
||||
}
|
||||
|
||||
// IsInputAllowed returns whether user input is currently allowed
|
||||
func (sc *StreamCoordinator) IsInputAllowed() bool {
|
||||
sc.mu.RLock()
|
||||
defer sc.mu.RUnlock()
|
||||
return sc.inputAllowed
|
||||
}
|
||||
@@ -1,695 +0,0 @@
|
||||
package terminal
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/cline/cli/pkg/cli/display"
|
||||
"github.com/cline/cli/pkg/cli/global"
|
||||
)
|
||||
|
||||
// KeyboardProtocol manages enhanced keyboard protocol support for detecting
|
||||
// modified keys like shift+enter across all major terminals.
|
||||
type KeyboardProtocol struct {
|
||||
enabled bool
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
var globalProtocol = &KeyboardProtocol{}
|
||||
|
||||
// EnableEnhancedKeyboard enables enhanced keyboard protocols to support
|
||||
// shift+enter and other modified keys across all major terminals:
|
||||
// - VS Code integrated terminal
|
||||
// - iTerm2
|
||||
// - Terminal.app
|
||||
// - Ghostty
|
||||
// - Kitty
|
||||
// - WezTerm
|
||||
// - Alacritty
|
||||
// - foot
|
||||
// - xterm
|
||||
//
|
||||
// This function is safe to call multiple times and handles cleanup automatically.
|
||||
// It enables both modifyOtherKeys (xterm protocol) and Kitty keyboard protocol
|
||||
// for maximum compatibility.
|
||||
func EnableEnhancedKeyboard() {
|
||||
globalProtocol.mu.Lock()
|
||||
defer globalProtocol.mu.Unlock()
|
||||
|
||||
if globalProtocol.enabled {
|
||||
return // Already enabled
|
||||
}
|
||||
|
||||
// Check if we're in a TTY (not piped/redirected)
|
||||
if !isatty(os.Stdin.Fd()) {
|
||||
return
|
||||
}
|
||||
|
||||
// Enable modifyOtherKeys mode 2
|
||||
// This tells xterm-compatible terminals (VS Code, iTerm2, Terminal.app, etc.)
|
||||
// to send escape sequences for modified keys including shift+enter
|
||||
// Format: CSI > 4 ; 2 m
|
||||
// - Mode 2 enables for ALL keys including well-known ones
|
||||
fmt.Print("\x1b[>4;2m")
|
||||
|
||||
// Also enable Kitty keyboard protocol for terminals that support it
|
||||
// This is a more modern protocol supported by Kitty, Ghostty, WezTerm, foot, etc.
|
||||
// Format: CSI = <flags> u where flags=1 means "disambiguate escape codes"
|
||||
// This makes shift+enter distinguishable from plain enter
|
||||
fmt.Print("\x1b[=1u")
|
||||
|
||||
globalProtocol.enabled = true
|
||||
}
|
||||
|
||||
// DisableEnhancedKeyboard restores the terminal to its default keyboard mode.
|
||||
// This should be called on program exit to be a good citizen.
|
||||
func DisableEnhancedKeyboard() {
|
||||
globalProtocol.mu.Lock()
|
||||
defer globalProtocol.mu.Unlock()
|
||||
|
||||
if !globalProtocol.enabled {
|
||||
return
|
||||
}
|
||||
|
||||
// Disable modifyOtherKeys (restore to mode 0)
|
||||
fmt.Print("\x1b[>4;0m")
|
||||
|
||||
// Disable Kitty keyboard protocol
|
||||
fmt.Print("\x1b[<u")
|
||||
|
||||
globalProtocol.enabled = false
|
||||
}
|
||||
|
||||
// isatty checks if a file descriptor is a terminal
|
||||
func isatty(fd uintptr) bool {
|
||||
// Use the standard library's terminal package
|
||||
// This works across all platforms (Unix, Windows, etc.)
|
||||
fileInfo, err := os.Stdin.Stat()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return (fileInfo.Mode() & os.ModeCharDevice) != 0
|
||||
}
|
||||
|
||||
// SetupKeyboard detects the current terminal and configures keybindings if needed.
|
||||
// Runs in background and doesn't block. Prints status when configs are modified.
|
||||
func SetupKeyboard() {
|
||||
go func() {
|
||||
renderer := display.NewRenderer(global.Config.OutputFormat)
|
||||
setupKeyboardInternal(renderer)
|
||||
}()
|
||||
}
|
||||
|
||||
// SetupKeyboardSync is the synchronous version used by doctor command.
|
||||
// Blocks until complete and prints status for all terminals.
|
||||
func SetupKeyboardSync() {
|
||||
renderer := display.NewRenderer(global.Config.OutputFormat)
|
||||
setupKeyboardInternal(renderer)
|
||||
}
|
||||
|
||||
func setupKeyboardInternal(renderer *display.Renderer) {
|
||||
terminalName := DetectTerminal()
|
||||
|
||||
switch terminalName {
|
||||
case "vscode":
|
||||
// VS Code and Cursor use the same TERM_PROGRAM value
|
||||
modified, path := SetupVSCodeKeybindings()
|
||||
if modified {
|
||||
fmt.Printf("%s VS Code %s\n", renderer.Dim("Configured shift+enter for"), renderer.Dim("terminal"))
|
||||
fmt.Printf("%s %s\n", renderer.Dim(" →"), path)
|
||||
} else if path != "" {
|
||||
fmt.Printf("%s\n", renderer.Dim("✓ VS Code shift+enter already configured"))
|
||||
fmt.Printf("%s %s\n", renderer.Dim(" →"), path)
|
||||
}
|
||||
|
||||
modified, path = SetupCursorKeybindings()
|
||||
if modified {
|
||||
fmt.Printf("%s Cursor %s\n", renderer.Dim("Configured shift+enter for"), renderer.Dim("terminal"))
|
||||
fmt.Printf("%s %s\n", renderer.Dim(" →"), path)
|
||||
} else if path != "" {
|
||||
fmt.Printf("%s\n", renderer.Dim("✓ Cursor shift+enter already configured"))
|
||||
fmt.Printf("%s %s\n", renderer.Dim(" →"), path)
|
||||
}
|
||||
|
||||
case "ghostty":
|
||||
modified, path := SetupGhosttyKeybindings()
|
||||
if modified {
|
||||
fmt.Printf("%s Ghostty %s\n", renderer.Dim("Configured shift+enter for"), renderer.Dim("terminal"))
|
||||
fmt.Printf("%s %s\n", renderer.Dim(" →"), path)
|
||||
fmt.Printf("%s\n", renderer.Dim(" Fully restart Ghostty (quit all windows) for changes to take effect"))
|
||||
} else if path != "" {
|
||||
fmt.Printf("%s\n", renderer.Dim("✓ Ghostty shift+enter already configured"))
|
||||
fmt.Printf("%s %s\n", renderer.Dim(" →"), path)
|
||||
}
|
||||
|
||||
case "wezterm":
|
||||
modified, path := SetupWezTermKeybindings()
|
||||
if modified {
|
||||
fmt.Printf("%s WezTerm %s\n", renderer.Dim("Configured shift+enter for"), renderer.Dim("terminal"))
|
||||
fmt.Printf("%s %s\n", renderer.Dim(" →"), path)
|
||||
} else if path != "" {
|
||||
fmt.Printf("%s\n", renderer.Dim("✓ WezTerm shift+enter already configured"))
|
||||
fmt.Printf("%s %s\n", renderer.Dim(" →"), path)
|
||||
}
|
||||
|
||||
case "alacritty":
|
||||
modified, path := SetupAlacrittyKeybindings()
|
||||
if modified {
|
||||
fmt.Printf("%s Alacritty %s\n", renderer.Dim("Configured shift+enter for"), renderer.Dim("terminal"))
|
||||
fmt.Printf("%s %s\n", renderer.Dim(" →"), path)
|
||||
} else if path != "" {
|
||||
fmt.Printf("%s\n", renderer.Dim("✓ Alacritty shift+enter already configured"))
|
||||
fmt.Printf("%s %s\n", renderer.Dim(" →"), path)
|
||||
}
|
||||
|
||||
case "kitty":
|
||||
modified, path := SetupKittyKeybindings()
|
||||
if modified {
|
||||
fmt.Printf("%s Kitty %s\n", renderer.Dim("Configured shift+enter for"), renderer.Dim("terminal"))
|
||||
fmt.Printf("%s %s\n", renderer.Dim(" →"), path)
|
||||
} else if path != "" {
|
||||
fmt.Printf("%s\n", renderer.Dim("✓ Kitty shift+enter already configured"))
|
||||
fmt.Printf("%s %s\n", renderer.Dim(" →"), path)
|
||||
}
|
||||
|
||||
case "iterm2":
|
||||
fmt.Printf("%s\n", renderer.Dim("✓ iTerm2 shift+enter works by default (maps to alt+enter)"))
|
||||
|
||||
case "terminal.app":
|
||||
fmt.Printf("%s\n", renderer.Dim("⚠ Terminal.app requires manual configuration"))
|
||||
fmt.Printf("%s\n", renderer.Dim(" See: Terminal → Preferences → Profiles → Keyboard"))
|
||||
|
||||
case "unknown":
|
||||
fmt.Printf("%s\n", renderer.Dim("ℹ Terminal not detected - use alt+enter or ctrl+j for newlines"))
|
||||
}
|
||||
}
|
||||
|
||||
// getVSCodeConfigPath returns the platform-specific path to VS Code's User directory
|
||||
func getVSCodeConfigPath() (string, error) {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
switch runtime.GOOS {
|
||||
case "darwin":
|
||||
return filepath.Join(home, "Library", "Application Support", "Code", "User"), nil
|
||||
case "windows":
|
||||
appData := os.Getenv("APPDATA")
|
||||
if appData == "" {
|
||||
appData = filepath.Join(home, "AppData", "Roaming")
|
||||
}
|
||||
return filepath.Join(appData, "Code", "User"), nil
|
||||
default: // linux, freebsd, etc.
|
||||
return filepath.Join(home, ".config", "Code", "User"), nil
|
||||
}
|
||||
}
|
||||
|
||||
// getCursorConfigPath returns the platform-specific path to Cursor's User directory
|
||||
func getCursorConfigPath() (string, error) {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
switch runtime.GOOS {
|
||||
case "darwin":
|
||||
return filepath.Join(home, "Library", "Application Support", "Cursor", "User"), nil
|
||||
case "windows":
|
||||
appData := os.Getenv("APPDATA")
|
||||
if appData == "" {
|
||||
appData = filepath.Join(home, "AppData", "Roaming")
|
||||
}
|
||||
return filepath.Join(appData, "Cursor", "User"), nil
|
||||
default: // linux, freebsd, etc.
|
||||
return filepath.Join(home, ".config", "Cursor", "User"), nil
|
||||
}
|
||||
}
|
||||
|
||||
// DetectTerminal identifies which terminal emulator is currently running
|
||||
func DetectTerminal() string {
|
||||
// Check TERM_PROGRAM (works for most terminals)
|
||||
termProgram := os.Getenv("TERM_PROGRAM")
|
||||
switch termProgram {
|
||||
case "vscode":
|
||||
return "vscode" // Also covers Cursor (uses same value)
|
||||
case "WezTerm":
|
||||
return "wezterm"
|
||||
case "ghostty":
|
||||
return "ghostty"
|
||||
case "iTerm.app":
|
||||
return "iterm2"
|
||||
case "Apple_Terminal":
|
||||
return "terminal.app"
|
||||
}
|
||||
|
||||
// Kitty doesn't set TERM_PROGRAM, check KITTY_WINDOW_ID
|
||||
if os.Getenv("KITTY_WINDOW_ID") != "" {
|
||||
return "kitty"
|
||||
}
|
||||
|
||||
// Alacritty doesn't set TERM_PROGRAM, check ALACRITTY_SOCKET
|
||||
if os.Getenv("ALACRITTY_SOCKET") != "" {
|
||||
return "alacritty"
|
||||
}
|
||||
|
||||
// Ghostty fallback (cross-platform - more reliable than TERM_PROGRAM)
|
||||
if os.Getenv("GHOSTTY_RESOURCES_DIR") != "" {
|
||||
return "ghostty"
|
||||
}
|
||||
|
||||
// Alacritty fallback
|
||||
if os.Getenv("ALACRITTY_LOG") != "" {
|
||||
return "alacritty"
|
||||
}
|
||||
|
||||
// Check TERM variable as last resort
|
||||
term := os.Getenv("TERM")
|
||||
if strings.Contains(term, "kitty") {
|
||||
return "kitty"
|
||||
}
|
||||
if term == "alacritty" {
|
||||
return "alacritty"
|
||||
}
|
||||
if term == "xterm-ghostty" {
|
||||
return "ghostty"
|
||||
}
|
||||
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
// VSCodeKeybinding represents a VS Code keyboard shortcut
|
||||
type VSCodeKeybinding struct {
|
||||
Key string `json:"key"`
|
||||
Command string `json:"command"`
|
||||
Args map[string]interface{} `json:"args,omitempty"`
|
||||
When string `json:"when,omitempty"`
|
||||
}
|
||||
|
||||
// SetupVSCodeKeybindings adds shift+enter support to VS Code's integrated terminal
|
||||
// by modifying the user's keybindings.json file.
|
||||
// Returns (wasModified, configPath) to allow caller to log the change.
|
||||
func SetupVSCodeKeybindings() (bool, string) {
|
||||
// Get platform-specific VS Code config path
|
||||
configDir, err := getVSCodeConfigPath()
|
||||
if err != nil {
|
||||
return false, ""
|
||||
}
|
||||
|
||||
keybindingsPath := filepath.Join(configDir, "keybindings.json")
|
||||
|
||||
// Check if VS Code is installed (keybindings file or parent dir exists)
|
||||
if _, err := os.Stat(filepath.Dir(keybindingsPath)); os.IsNotExist(err) {
|
||||
// VS Code not installed, skip silently
|
||||
return false, ""
|
||||
}
|
||||
|
||||
// Read existing keybindings
|
||||
var keybindings []VSCodeKeybinding
|
||||
|
||||
data, err := os.ReadFile(keybindingsPath)
|
||||
if err != nil {
|
||||
if !os.IsNotExist(err) {
|
||||
return false, ""
|
||||
}
|
||||
// File doesn't exist, start with empty array
|
||||
keybindings = []VSCodeKeybinding{}
|
||||
} else {
|
||||
// Parse existing keybindings
|
||||
if err := json.Unmarshal(data, &keybindings); err != nil {
|
||||
// If parse fails, don't modify the file
|
||||
return false, ""
|
||||
}
|
||||
}
|
||||
|
||||
// Check if shift+enter binding already exists
|
||||
for _, kb := range keybindings {
|
||||
if kb.Key == "shift+enter" && kb.Command == "workbench.action.terminal.sendSequence" {
|
||||
// Already configured
|
||||
return false, keybindingsPath
|
||||
}
|
||||
}
|
||||
|
||||
// Add shift+enter keybinding
|
||||
newBinding := VSCodeKeybinding{
|
||||
Key: "shift+enter",
|
||||
Command: "workbench.action.terminal.sendSequence",
|
||||
Args: map[string]interface{}{
|
||||
"text": "\u001b\n", // ESC + newline (alt+enter sequence)
|
||||
},
|
||||
When: "terminalFocus",
|
||||
}
|
||||
|
||||
keybindings = append(keybindings, newBinding)
|
||||
|
||||
// Create backup
|
||||
if data != nil {
|
||||
backupPath := keybindingsPath + ".backup"
|
||||
_ = os.WriteFile(backupPath, data, 0644)
|
||||
}
|
||||
|
||||
// Write updated keybindings
|
||||
updatedData, err := json.MarshalIndent(keybindings, "", " ")
|
||||
if err != nil {
|
||||
return false, ""
|
||||
}
|
||||
|
||||
// Ensure parent directory exists
|
||||
if err := os.MkdirAll(filepath.Dir(keybindingsPath), 0755); err != nil {
|
||||
return false, ""
|
||||
}
|
||||
|
||||
if err := os.WriteFile(keybindingsPath, updatedData, 0644); err != nil {
|
||||
return false, ""
|
||||
}
|
||||
|
||||
return true, keybindingsPath
|
||||
}
|
||||
|
||||
// SetupCursorKeybindings adds shift+enter support to Cursor's integrated terminal
|
||||
// by modifying the user's keybindings.json file.
|
||||
// Cursor is a fork of VS Code, so it uses the same keybinding format.
|
||||
// Returns (wasModified, configPath) to allow caller to log the change.
|
||||
func SetupCursorKeybindings() (bool, string) {
|
||||
// Get platform-specific Cursor config path
|
||||
configDir, err := getCursorConfigPath()
|
||||
if err != nil {
|
||||
return false, ""
|
||||
}
|
||||
|
||||
keybindingsPath := filepath.Join(configDir, "keybindings.json")
|
||||
|
||||
// Check if Cursor is installed (keybindings file or parent dir exists)
|
||||
if _, err := os.Stat(filepath.Dir(keybindingsPath)); os.IsNotExist(err) {
|
||||
// Cursor not installed, skip silently
|
||||
return false, ""
|
||||
}
|
||||
|
||||
// Read existing keybindings
|
||||
var keybindings []VSCodeKeybinding
|
||||
|
||||
data, err := os.ReadFile(keybindingsPath)
|
||||
if err != nil {
|
||||
if !os.IsNotExist(err) {
|
||||
return false, ""
|
||||
}
|
||||
// File doesn't exist, start with empty array
|
||||
keybindings = []VSCodeKeybinding{}
|
||||
} else {
|
||||
// Parse existing keybindings
|
||||
if err := json.Unmarshal(data, &keybindings); err != nil {
|
||||
// If parse fails, don't modify the file
|
||||
return false, ""
|
||||
}
|
||||
}
|
||||
|
||||
// Check if shift+enter binding already exists
|
||||
for _, kb := range keybindings {
|
||||
if kb.Key == "shift+enter" && kb.Command == "workbench.action.terminal.sendSequence" {
|
||||
// Already configured
|
||||
return false, keybindingsPath
|
||||
}
|
||||
}
|
||||
|
||||
// Add shift+enter keybinding
|
||||
newBinding := VSCodeKeybinding{
|
||||
Key: "shift+enter",
|
||||
Command: "workbench.action.terminal.sendSequence",
|
||||
Args: map[string]interface{}{
|
||||
"text": "\u001b\n", // ESC + newline (alt+enter sequence)
|
||||
},
|
||||
When: "terminalFocus",
|
||||
}
|
||||
|
||||
keybindings = append(keybindings, newBinding)
|
||||
|
||||
// Create backup
|
||||
if data != nil {
|
||||
backupPath := keybindingsPath + ".backup"
|
||||
_ = os.WriteFile(backupPath, data, 0644)
|
||||
}
|
||||
|
||||
// Write updated keybindings
|
||||
updatedData, err := json.MarshalIndent(keybindings, "", " ")
|
||||
if err != nil {
|
||||
return false, ""
|
||||
}
|
||||
|
||||
// Ensure parent directory exists
|
||||
if err := os.MkdirAll(filepath.Dir(keybindingsPath), 0755); err != nil {
|
||||
return false, ""
|
||||
}
|
||||
|
||||
if err := os.WriteFile(keybindingsPath, updatedData, 0644); err != nil {
|
||||
return false, ""
|
||||
}
|
||||
|
||||
return true, keybindingsPath
|
||||
}
|
||||
|
||||
// SetupGhosttyKeybindings adds shift+enter support to Ghostty terminal
|
||||
// by appending to the user's config file.
|
||||
// Returns (wasModified, configPath) to allow caller to log the change.
|
||||
func SetupGhosttyKeybindings() (bool, string) {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return false, ""
|
||||
}
|
||||
|
||||
// Ghostty config location: ~/.config/ghostty/config
|
||||
configPath := filepath.Join(home, ".config", "ghostty", "config")
|
||||
|
||||
// Check if config directory exists
|
||||
configDir := filepath.Dir(configPath)
|
||||
if _, err := os.Stat(configDir); os.IsNotExist(err) {
|
||||
// Ghostty not installed, skip silently
|
||||
return false, ""
|
||||
}
|
||||
|
||||
// Read existing config if it exists
|
||||
var existingContent []byte
|
||||
if data, err := os.ReadFile(configPath); err == nil {
|
||||
existingContent = data
|
||||
// Check if shift+enter already configured
|
||||
if strings.Contains(string(data), "keybind = shift+enter") {
|
||||
return false, configPath
|
||||
}
|
||||
}
|
||||
|
||||
// Keybinding to add - send newline character (0x0a)
|
||||
// Ghostty requires \x0a hex escape syntax, verified working
|
||||
keybinding := "keybind = shift+enter=text:\\x0a\n"
|
||||
|
||||
// Append to config
|
||||
newContent := append(existingContent, []byte(keybinding)...)
|
||||
|
||||
// Ensure directory exists
|
||||
if err := os.MkdirAll(configDir, 0755); err != nil {
|
||||
return false, ""
|
||||
}
|
||||
|
||||
// Create backup if file exists
|
||||
if existingContent != nil {
|
||||
backupPath := configPath + ".backup"
|
||||
_ = os.WriteFile(backupPath, existingContent, 0644)
|
||||
}
|
||||
|
||||
// Write updated config
|
||||
if err := os.WriteFile(configPath, newContent, 0644); err != nil {
|
||||
return false, ""
|
||||
}
|
||||
|
||||
return true, configPath
|
||||
}
|
||||
|
||||
// SetupWezTermKeybindings adds shift+enter support to WezTerm
|
||||
// by appending to the user's .wezterm.lua file.
|
||||
// Returns (wasModified, configPath)
|
||||
func SetupWezTermKeybindings() (bool, string) {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return false, ""
|
||||
}
|
||||
|
||||
configPath := filepath.Join(home, ".wezterm.lua")
|
||||
|
||||
// Check if WezTerm config exists
|
||||
if _, err := os.Stat(configPath); os.IsNotExist(err) {
|
||||
// WezTerm not configured, skip silently
|
||||
return false, ""
|
||||
}
|
||||
|
||||
// Read existing config
|
||||
data, err := os.ReadFile(configPath)
|
||||
if err != nil {
|
||||
return false, ""
|
||||
}
|
||||
|
||||
// Check if shift+enter already configured
|
||||
if strings.Contains(string(data), "key = 'Enter'") && strings.Contains(string(data), "mods = 'SHIFT'") {
|
||||
return false, configPath
|
||||
}
|
||||
|
||||
// Create backup
|
||||
backupPath := configPath + ".backup"
|
||||
_ = os.WriteFile(backupPath, data, 0644)
|
||||
|
||||
// Keybinding to add (insert before final return statement)
|
||||
keybinding := `
|
||||
-- Shift+Enter for newlines (added by Cline CLI)
|
||||
config.keys = config.keys or {}
|
||||
table.insert(config.keys, {
|
||||
key = 'Enter',
|
||||
mods = 'SHIFT',
|
||||
action = wezterm.action.SendString '\x1b\n',
|
||||
})
|
||||
`
|
||||
|
||||
content := string(data)
|
||||
// Try to insert before the final return statement
|
||||
if strings.Contains(content, "return config") {
|
||||
content = strings.Replace(content, "return config", keybinding+"\nreturn config", 1)
|
||||
} else {
|
||||
// No return statement, append at end
|
||||
content += keybinding
|
||||
}
|
||||
|
||||
// Write updated config
|
||||
if err := os.WriteFile(configPath, []byte(content), 0644); err != nil {
|
||||
return false, ""
|
||||
}
|
||||
|
||||
return true, configPath
|
||||
}
|
||||
|
||||
// SetupAlacrittyKeybindings adds shift+enter support to Alacritty
|
||||
// by appending to the user's alacritty.yml file.
|
||||
// Returns (wasModified, configPath)
|
||||
func SetupAlacrittyKeybindings() (bool, string) {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return false, ""
|
||||
}
|
||||
|
||||
// Try both possible locations
|
||||
configPaths := []string{
|
||||
filepath.Join(home, ".config", "alacritty", "alacritty.yml"),
|
||||
filepath.Join(home, ".config", "alacritty", "alacritty.toml"),
|
||||
filepath.Join(home, ".alacritty.yml"),
|
||||
}
|
||||
|
||||
var configPath string
|
||||
for _, path := range configPaths {
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
configPath = path
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if configPath == "" {
|
||||
// Alacritty not configured, skip silently
|
||||
return false, ""
|
||||
}
|
||||
|
||||
// Read existing config
|
||||
data, err := os.ReadFile(configPath)
|
||||
if err != nil {
|
||||
return false, ""
|
||||
}
|
||||
|
||||
// Check if shift+enter already configured
|
||||
if strings.Contains(string(data), "key: Return") && strings.Contains(string(data), "mods: Shift") {
|
||||
return false, configPath
|
||||
}
|
||||
|
||||
// Create backup
|
||||
backupPath := configPath + ".backup"
|
||||
_ = os.WriteFile(backupPath, data, 0644)
|
||||
|
||||
// Keybinding to add
|
||||
var keybinding string
|
||||
if strings.HasSuffix(configPath, ".yml") || strings.HasSuffix(configPath, ".yaml") {
|
||||
keybinding = `
|
||||
# Shift+Enter for newlines (added by Cline CLI)
|
||||
key_bindings:
|
||||
- { key: Return, mods: Shift, chars: "\x1b\n" }
|
||||
`
|
||||
} else {
|
||||
// TOML format
|
||||
keybinding = `
|
||||
# Shift+Enter for newlines (added by Cline CLI)
|
||||
[[keyboard.bindings]]
|
||||
key = "Return"
|
||||
mods = "Shift"
|
||||
chars = "\x1b\n"
|
||||
`
|
||||
}
|
||||
|
||||
// Append to config
|
||||
newContent := append(data, []byte(keybinding)...)
|
||||
|
||||
// Write updated config
|
||||
if err := os.WriteFile(configPath, newContent, 0644); err != nil {
|
||||
return false, ""
|
||||
}
|
||||
|
||||
return true, configPath
|
||||
}
|
||||
|
||||
// SetupKittyKeybindings adds shift+enter support to Kitty terminal
|
||||
// by appending to the user's kitty.conf file.
|
||||
// Returns (wasModified, configPath)
|
||||
func SetupKittyKeybindings() (bool, string) {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return false, ""
|
||||
}
|
||||
|
||||
configPath := filepath.Join(home, ".config", "kitty", "kitty.conf")
|
||||
|
||||
// Check if config directory exists
|
||||
configDir := filepath.Dir(configPath)
|
||||
if _, err := os.Stat(configDir); os.IsNotExist(err) {
|
||||
// Kitty not installed, skip silently
|
||||
return false, ""
|
||||
}
|
||||
|
||||
// Read existing config if it exists
|
||||
var existingContent []byte
|
||||
if data, err := os.ReadFile(configPath); err == nil {
|
||||
existingContent = data
|
||||
// Check if shift+enter already configured
|
||||
if strings.Contains(string(data), "map shift+enter") {
|
||||
return false, configPath
|
||||
}
|
||||
}
|
||||
|
||||
// Keybinding to add
|
||||
keybinding := "# Shift+Enter for newlines (added by Cline CLI)\nmap shift+enter send_text all \\x1b\\n\n"
|
||||
|
||||
// Append to config
|
||||
newContent := append(existingContent, []byte(keybinding)...)
|
||||
|
||||
// Ensure directory exists
|
||||
if err := os.MkdirAll(configDir, 0755); err != nil {
|
||||
return false, ""
|
||||
}
|
||||
|
||||
// Create backup if file exists
|
||||
if existingContent != nil {
|
||||
backupPath := configPath + ".backup"
|
||||
_ = os.WriteFile(backupPath, existingContent, 0644)
|
||||
}
|
||||
|
||||
// Write updated config
|
||||
if err := os.WriteFile(configPath, newContent, 0644); err != nil {
|
||||
return false, ""
|
||||
}
|
||||
|
||||
return true, configPath
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
if you can make a beautiful tui in go, please help!
|
||||
@@ -1,18 +0,0 @@
|
||||
package types
|
||||
|
||||
// HistoryItem represents a task history item from taskHistory.json
|
||||
// This struct matches the JSON format stored on disk
|
||||
type HistoryItem struct {
|
||||
Id string `json:"id"`
|
||||
Ulid string `json:"ulid,omitempty"`
|
||||
Ts int64 `json:"ts"`
|
||||
Task string `json:"task"`
|
||||
TokensIn int32 `json:"tokensIn"`
|
||||
TokensOut int32 `json:"tokensOut"`
|
||||
CacheWrites int32 `json:"cacheWrites,omitempty"`
|
||||
CacheReads int32 `json:"cacheReads,omitempty"`
|
||||
TotalCost float64 `json:"totalCost"`
|
||||
Size int64 `json:"size,omitempty"`
|
||||
IsFavorited bool `json:"isFavorited,omitempty"`
|
||||
WorkspacePaths []string `json:"workspacePaths,omitempty"`
|
||||
}
|
||||
@@ -1,377 +0,0 @@
|
||||
package types
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/cline/grpc-go/cline"
|
||||
)
|
||||
|
||||
// ClineMessage represents a conversation message in the CLI
|
||||
type ClineMessage struct {
|
||||
Type MessageType `json:"type"`
|
||||
Text string `json:"text"`
|
||||
Timestamp int64 `json:"ts"`
|
||||
Reasoning string `json:"reasoning,omitempty"`
|
||||
Say string `json:"say,omitempty"`
|
||||
Ask string `json:"ask,omitempty"`
|
||||
Partial bool `json:"partial,omitempty"`
|
||||
Images []string `json:"images,omitempty"`
|
||||
Files []string `json:"files,omitempty"`
|
||||
LastCheckpointHash string `json:"lastCheckpointHash,omitempty"`
|
||||
IsCheckpointCheckedOut bool `json:"isCheckpointCheckedOut,omitempty"`
|
||||
IsOperationOutsideWorkspace bool `json:"isOperationOutsideWorkspace,omitempty"`
|
||||
}
|
||||
|
||||
// MessageType represents the type of message
|
||||
type MessageType string
|
||||
|
||||
const (
|
||||
MessageTypeAsk MessageType = "ask"
|
||||
MessageTypeSay MessageType = "say"
|
||||
)
|
||||
|
||||
// AskType represents different types of ASK messages
|
||||
type AskType string
|
||||
|
||||
const (
|
||||
AskTypeFollowup AskType = "followup"
|
||||
AskTypePlanModeRespond AskType = "plan_mode_respond"
|
||||
AskTypeCommand AskType = "command"
|
||||
AskTypeCommandOutput AskType = "command_output"
|
||||
AskTypeCompletionResult AskType = "completion_result"
|
||||
AskTypeTool AskType = "tool"
|
||||
AskTypeAPIReqFailed AskType = "api_req_failed"
|
||||
AskTypeResumeTask AskType = "resume_task"
|
||||
AskTypeResumeCompletedTask AskType = "resume_completed_task"
|
||||
AskTypeMistakeLimitReached AskType = "mistake_limit_reached"
|
||||
AskTypeBrowserActionLaunch AskType = "browser_action_launch"
|
||||
AskTypeUseMcpServer AskType = "use_mcp_server"
|
||||
AskTypeNewTask AskType = "new_task"
|
||||
AskTypeCondense AskType = "condense"
|
||||
AskTypeReportBug AskType = "report_bug"
|
||||
)
|
||||
|
||||
// SayType represents different types of SAY messages
|
||||
type SayType string
|
||||
|
||||
const (
|
||||
SayTypeTask SayType = "task"
|
||||
SayTypeError SayType = "error"
|
||||
SayTypeAPIReqStarted SayType = "api_req_started"
|
||||
SayTypeAPIReqFinished SayType = "api_req_finished"
|
||||
SayTypeText SayType = "text"
|
||||
SayTypeReasoning SayType = "reasoning"
|
||||
SayTypeCompletionResult SayType = "completion_result"
|
||||
SayTypeUserFeedback SayType = "user_feedback"
|
||||
SayTypeUserFeedbackDiff SayType = "user_feedback_diff"
|
||||
SayTypeAPIReqRetried SayType = "api_req_retried"
|
||||
SayTypeErrorRetry SayType = "error_retry"
|
||||
SayTypeCommand SayType = "command"
|
||||
SayTypeCommandOutput SayType = "command_output"
|
||||
SayTypeTool SayType = "tool"
|
||||
SayTypeShellIntegrationWarning SayType = "shell_integration_warning"
|
||||
SayTypeBrowserActionLaunch SayType = "browser_action_launch"
|
||||
SayTypeBrowserAction SayType = "browser_action"
|
||||
SayTypeBrowserActionResult SayType = "browser_action_result"
|
||||
SayTypeMcpServerRequestStarted SayType = "mcp_server_request_started"
|
||||
SayTypeMcpServerResponse SayType = "mcp_server_response"
|
||||
SayTypeMcpNotification SayType = "mcp_notification"
|
||||
SayTypeUseMcpServer SayType = "use_mcp_server"
|
||||
SayTypeDiffError SayType = "diff_error"
|
||||
SayTypeDeletedAPIReqs SayType = "deleted_api_reqs"
|
||||
SayTypeClineignoreError SayType = "clineignore_error"
|
||||
SayTypeCheckpointCreated SayType = "checkpoint_created"
|
||||
SayTypeLoadMcpDocumentation SayType = "load_mcp_documentation"
|
||||
SayTypeInfo SayType = "info"
|
||||
SayTypeTaskProgress SayType = "task_progress"
|
||||
// Hook status streaming from the backend.
|
||||
// These values must match the backend "say" strings emitted by the extension.
|
||||
SayTypeHookStatus SayType = "hook_status"
|
||||
SayTypeHookOutputStream SayType = "hook_output_stream"
|
||||
SayTypeCommandPermissionDenied SayType = "command_permission_denied"
|
||||
)
|
||||
|
||||
// ToolMessage represents a tool-related message
|
||||
type ToolMessage struct {
|
||||
Tool string `json:"tool"`
|
||||
Path string `json:"path,omitempty"`
|
||||
Content string `json:"content,omitempty"`
|
||||
Diff string `json:"diff,omitempty"`
|
||||
Regex string `json:"regex,omitempty"`
|
||||
FilePattern string `json:"filePattern,omitempty"`
|
||||
OperationIsLocatedInWorkspace *bool `json:"operationIsLocatedInWorkspace,omitempty"`
|
||||
}
|
||||
|
||||
// ToolType represents different types of tools
|
||||
type ToolType string
|
||||
|
||||
const (
|
||||
ToolTypeEditedExistingFile ToolType = "editedExistingFile"
|
||||
ToolTypeNewFileCreated ToolType = "newFileCreated"
|
||||
ToolTypeReadFile ToolType = "readFile"
|
||||
ToolTypeFileDeleted ToolType = "fileDeleted"
|
||||
ToolTypeListFilesTopLevel ToolType = "listFilesTopLevel"
|
||||
ToolTypeListFilesRecursive ToolType = "listFilesRecursive"
|
||||
ToolTypeListCodeDefinitionNames ToolType = "listCodeDefinitionNames"
|
||||
ToolTypeSearchFiles ToolType = "searchFiles"
|
||||
ToolTypeWebFetch ToolType = "webFetch"
|
||||
ToolTypeWebSearch ToolType = "webSearch"
|
||||
ToolTypeSummarizeTask ToolType = "summarizeTask"
|
||||
)
|
||||
|
||||
// AskData represents the parsed structure of an ASK message
|
||||
type AskData struct {
|
||||
Question string `json:"question"`
|
||||
Response string `json:"response"`
|
||||
Options []string `json:"options,omitempty"`
|
||||
}
|
||||
|
||||
// APIRequestInfo represents API request information
|
||||
type APIRequestInfo struct {
|
||||
Request string `json:"request,omitempty"`
|
||||
TokensIn int `json:"tokensIn,omitempty"`
|
||||
TokensOut int `json:"tokensOut,omitempty"`
|
||||
CacheWrites int `json:"cacheWrites,omitempty"`
|
||||
CacheReads int `json:"cacheReads,omitempty"`
|
||||
Cost float64 `json:"cost,omitempty"`
|
||||
CancelReason string `json:"cancelReason,omitempty"`
|
||||
StreamingFailedMessage string `json:"streamingFailedMessage,omitempty"`
|
||||
RetryStatus *APIRequestRetryStatus `json:"retryStatus,omitempty"`
|
||||
}
|
||||
|
||||
// APIRequestRetryStatus represents retry status information
|
||||
type APIRequestRetryStatus struct {
|
||||
Attempt int `json:"attempt"`
|
||||
MaxAttempts int `json:"maxAttempts"`
|
||||
DelaySec int `json:"delaySec"`
|
||||
ErrorSnippet string `json:"errorSnippet,omitempty"`
|
||||
}
|
||||
|
||||
// HookMessage represents hook execution metadata sent from the backend
|
||||
type HookMessage struct {
|
||||
HookName string `json:"hookName"` // Type of hook (TaskStart, PreToolUse, etc.)
|
||||
ToolName string `json:"toolName,omitempty"` // Optional tool name for tool-specific hooks
|
||||
Status string `json:"status"` // "running", "completed", "cancelled", or "failed"
|
||||
ScriptPaths []string `json:"scriptPaths,omitempty"` // Full paths to hook script(s)
|
||||
PendingToolInfo *ToolInfo `json:"pendingToolInfo,omitempty"` // Metadata about the pending tool execution (PreToolUse)
|
||||
ExitCode int `json:"exitCode,omitempty"` // Exit code for completed/failed hooks
|
||||
HasJsonResponse bool `json:"hasJsonResponse,omitempty"` // Whether hook returned JSON
|
||||
Error *HookError `json:"error,omitempty"` // Error details if hook failed
|
||||
}
|
||||
|
||||
// ToolInfo represents a compact subset of tool parameters for UI display.
|
||||
// This mirrors the extension's pendingToolInfo shape and is used by the CLI to
|
||||
// show what tool the PreToolUse hook is gating.
|
||||
type ToolInfo struct {
|
||||
Tool string `json:"tool"`
|
||||
Path string `json:"path,omitempty"`
|
||||
Command string `json:"command,omitempty"`
|
||||
Content string `json:"content,omitempty"`
|
||||
Diff string `json:"diff,omitempty"`
|
||||
Regex string `json:"regex,omitempty"`
|
||||
Url string `json:"url,omitempty"`
|
||||
McpTool string `json:"mcpTool,omitempty"`
|
||||
McpServer string `json:"mcpServer,omitempty"`
|
||||
ResourceUri string `json:"resourceUri,omitempty"`
|
||||
}
|
||||
|
||||
// HookError represents structured error information from a failed hook
|
||||
type HookError struct {
|
||||
Type string `json:"type"` // Error type: "execution", "timeout", "validation", etc.
|
||||
Message string `json:"message"` // Human-readable error message
|
||||
Details string `json:"details,omitempty"` // Additional error details
|
||||
ScriptPath string `json:"scriptPath,omitempty"` // Path to script that failed
|
||||
}
|
||||
|
||||
// GetTimestamp returns a formatted timestamp string
|
||||
func (m *ClineMessage) GetTimestamp() string {
|
||||
return time.Unix(m.Timestamp/1000, 0).Format("15:04:05")
|
||||
}
|
||||
|
||||
// IsAsk returns true if this is an ASK message
|
||||
func (m *ClineMessage) IsAsk() bool {
|
||||
return m.Type == MessageTypeAsk
|
||||
}
|
||||
|
||||
// IsSay returns true if this is a SAY message
|
||||
func (m *ClineMessage) IsSay() bool {
|
||||
return m.Type == MessageTypeSay
|
||||
}
|
||||
|
||||
// GetMessageKey returns a unique key for this message based on timestamp
|
||||
func (m *ClineMessage) GetMessageKey() string {
|
||||
return strconv.FormatInt(m.Timestamp, 10)
|
||||
}
|
||||
|
||||
// ExtractMessagesFromStateJSON parses the state JSON and extracts messages
|
||||
func ExtractMessagesFromStateJSON(stateJson string) ([]*ClineMessage, error) {
|
||||
// Parse the state JSON to extract clineMessages
|
||||
var rawState map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(stateJson), &rawState); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse state JSON: %w", err)
|
||||
}
|
||||
|
||||
// Try to extract clineMessages
|
||||
clineMessagesRaw, exists := rawState["clineMessages"]
|
||||
if !exists {
|
||||
return []*ClineMessage{}, nil
|
||||
}
|
||||
|
||||
// Convert to JSON and back to get proper Message structs
|
||||
clineMessagesJson, err := json.Marshal(clineMessagesRaw)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal clineMessages: %w", err)
|
||||
}
|
||||
|
||||
var messages []*ClineMessage
|
||||
if err := json.Unmarshal(clineMessagesJson, &messages); err != nil {
|
||||
return nil, fmt.Errorf("failed to unmarshal clineMessages: %w", err)
|
||||
}
|
||||
|
||||
return messages, nil
|
||||
}
|
||||
|
||||
// ConvertProtoToMessage converts a protobuf ClineMessage to our local Message struct
|
||||
func ConvertProtoToMessage(protoMsg *cline.ClineMessage) *ClineMessage {
|
||||
var msgType MessageType
|
||||
var say, ask string
|
||||
|
||||
// Convert message type
|
||||
switch protoMsg.Type {
|
||||
case cline.ClineMessageType_ASK:
|
||||
msgType = MessageTypeAsk
|
||||
ask = convertProtoAskType(protoMsg.Ask)
|
||||
case cline.ClineMessageType_SAY:
|
||||
msgType = MessageTypeSay
|
||||
say = convertProtoSayType(protoMsg.Say)
|
||||
default:
|
||||
msgType = MessageTypeSay
|
||||
say = "unknown"
|
||||
}
|
||||
|
||||
return &ClineMessage{
|
||||
Type: msgType,
|
||||
Text: protoMsg.Text,
|
||||
Timestamp: protoMsg.Ts,
|
||||
Reasoning: protoMsg.Reasoning,
|
||||
Say: say,
|
||||
Ask: ask,
|
||||
Partial: protoMsg.Partial,
|
||||
LastCheckpointHash: protoMsg.LastCheckpointHash,
|
||||
IsCheckpointCheckedOut: protoMsg.IsCheckpointCheckedOut,
|
||||
IsOperationOutsideWorkspace: protoMsg.IsOperationOutsideWorkspace,
|
||||
}
|
||||
}
|
||||
|
||||
// convertProtoAskType converts protobuf ask type to string
|
||||
func convertProtoAskType(askType cline.ClineAsk) string {
|
||||
switch askType {
|
||||
case cline.ClineAsk_FOLLOWUP:
|
||||
return string(AskTypeFollowup)
|
||||
case cline.ClineAsk_PLAN_MODE_RESPOND:
|
||||
return string(AskTypePlanModeRespond)
|
||||
case cline.ClineAsk_COMMAND:
|
||||
return string(AskTypeCommand)
|
||||
case cline.ClineAsk_COMMAND_OUTPUT:
|
||||
return string(AskTypeCommandOutput)
|
||||
case cline.ClineAsk_COMPLETION_RESULT:
|
||||
return string(AskTypeCompletionResult)
|
||||
case cline.ClineAsk_TOOL:
|
||||
return string(AskTypeTool)
|
||||
case cline.ClineAsk_API_REQ_FAILED:
|
||||
return string(AskTypeAPIReqFailed)
|
||||
case cline.ClineAsk_RESUME_TASK:
|
||||
return string(AskTypeResumeTask)
|
||||
case cline.ClineAsk_RESUME_COMPLETED_TASK:
|
||||
return string(AskTypeResumeCompletedTask)
|
||||
case cline.ClineAsk_MISTAKE_LIMIT_REACHED:
|
||||
return string(AskTypeMistakeLimitReached)
|
||||
case cline.ClineAsk_BROWSER_ACTION_LAUNCH:
|
||||
return string(AskTypeBrowserActionLaunch)
|
||||
case cline.ClineAsk_USE_MCP_SERVER:
|
||||
return string(AskTypeUseMcpServer)
|
||||
case cline.ClineAsk_NEW_TASK:
|
||||
return string(AskTypeNewTask)
|
||||
case cline.ClineAsk_CONDENSE:
|
||||
return string(AskTypeCondense)
|
||||
case cline.ClineAsk_REPORT_BUG:
|
||||
return string(AskTypeReportBug)
|
||||
default:
|
||||
return "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
// convertProtoSayType converts protobuf say type to string
|
||||
func convertProtoSayType(sayType cline.ClineSay) string {
|
||||
switch sayType {
|
||||
case cline.ClineSay_TASK:
|
||||
return string(SayTypeTask)
|
||||
case cline.ClineSay_ERROR:
|
||||
return string(SayTypeError)
|
||||
case cline.ClineSay_API_REQ_STARTED:
|
||||
return string(SayTypeAPIReqStarted)
|
||||
case cline.ClineSay_API_REQ_FINISHED:
|
||||
return string(SayTypeAPIReqFinished)
|
||||
case cline.ClineSay_TEXT:
|
||||
return string(SayTypeText)
|
||||
case cline.ClineSay_REASONING:
|
||||
return string(SayTypeReasoning)
|
||||
case cline.ClineSay_COMPLETION_RESULT_SAY:
|
||||
return string(SayTypeCompletionResult)
|
||||
case cline.ClineSay_USER_FEEDBACK:
|
||||
return string(SayTypeUserFeedback)
|
||||
case cline.ClineSay_USER_FEEDBACK_DIFF:
|
||||
return string(SayTypeUserFeedbackDiff)
|
||||
case cline.ClineSay_API_REQ_RETRIED:
|
||||
return string(SayTypeAPIReqRetried)
|
||||
case cline.ClineSay_ERROR_RETRY:
|
||||
return string(SayTypeErrorRetry)
|
||||
case cline.ClineSay_COMMAND_SAY:
|
||||
return string(SayTypeCommand)
|
||||
case cline.ClineSay_COMMAND_OUTPUT_SAY:
|
||||
return string(SayTypeCommandOutput)
|
||||
case cline.ClineSay_TOOL_SAY:
|
||||
return string(SayTypeTool)
|
||||
case cline.ClineSay_SHELL_INTEGRATION_WARNING:
|
||||
return string(SayTypeShellIntegrationWarning)
|
||||
case cline.ClineSay_BROWSER_ACTION_LAUNCH_SAY:
|
||||
return string(SayTypeBrowserActionLaunch)
|
||||
case cline.ClineSay_BROWSER_ACTION:
|
||||
return string(SayTypeBrowserAction)
|
||||
case cline.ClineSay_BROWSER_ACTION_RESULT:
|
||||
return string(SayTypeBrowserActionResult)
|
||||
case cline.ClineSay_MCP_SERVER_REQUEST_STARTED:
|
||||
return string(SayTypeMcpServerRequestStarted)
|
||||
case cline.ClineSay_MCP_SERVER_RESPONSE:
|
||||
return string(SayTypeMcpServerResponse)
|
||||
case cline.ClineSay_MCP_NOTIFICATION:
|
||||
return string(SayTypeMcpNotification)
|
||||
case cline.ClineSay_USE_MCP_SERVER_SAY:
|
||||
return string(SayTypeUseMcpServer)
|
||||
case cline.ClineSay_DIFF_ERROR:
|
||||
return string(SayTypeDiffError)
|
||||
case cline.ClineSay_DELETED_API_REQS:
|
||||
return string(SayTypeDeletedAPIReqs)
|
||||
case cline.ClineSay_CLINEIGNORE_ERROR:
|
||||
return string(SayTypeClineignoreError)
|
||||
case cline.ClineSay_CHECKPOINT_CREATED:
|
||||
return string(SayTypeCheckpointCreated)
|
||||
case cline.ClineSay_LOAD_MCP_DOCUMENTATION:
|
||||
return string(SayTypeLoadMcpDocumentation)
|
||||
case cline.ClineSay_INFO:
|
||||
return string(SayTypeInfo)
|
||||
case cline.ClineSay_TASK_PROGRESS:
|
||||
return string(SayTypeTaskProgress)
|
||||
case cline.ClineSay_HOOK_STATUS:
|
||||
return string(SayTypeHookStatus)
|
||||
case cline.ClineSay_HOOK_OUTPUT_STREAM:
|
||||
return string(SayTypeHookOutputStream)
|
||||
case cline.ClineSay_COMMAND_PERMISSION_DENIED:
|
||||
return string(SayTypeCommandPermissionDenied)
|
||||
default:
|
||||
return "unknown"
|
||||
}
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
package types
|
||||
|
||||
import (
|
||||
"sync"
|
||||
)
|
||||
|
||||
// ConversationState manages the state of the conversation
|
||||
type ConversationState struct {
|
||||
mu sync.RWMutex
|
||||
StreamingMessage *StreamingMessage `json:"streamingMessage,omitempty"`
|
||||
}
|
||||
|
||||
// StreamingMessage manages state for streaming message display
|
||||
type StreamingMessage struct {
|
||||
CurrentKey string `json:"currentKey"`
|
||||
LastText string `json:"lastText"`
|
||||
}
|
||||
|
||||
// NewConversationState creates a new conversation state
|
||||
func NewConversationState() *ConversationState {
|
||||
return &ConversationState{
|
||||
StreamingMessage: &StreamingMessage{},
|
||||
}
|
||||
}
|
||||
|
||||
// SetStreamingMessage updates the streaming message state
|
||||
func (cs *ConversationState) SetStreamingMessage(key, text string) {
|
||||
cs.mu.Lock()
|
||||
defer cs.mu.Unlock()
|
||||
cs.StreamingMessage.CurrentKey = key
|
||||
cs.StreamingMessage.LastText = text
|
||||
}
|
||||
|
||||
// GetStreamingMessage returns the current streaming message state
|
||||
func (cs *ConversationState) GetStreamingMessage() *StreamingMessage {
|
||||
cs.mu.RLock()
|
||||
defer cs.mu.RUnlock()
|
||||
return &StreamingMessage{
|
||||
CurrentKey: cs.StreamingMessage.CurrentKey,
|
||||
LastText: cs.StreamingMessage.LastText,
|
||||
}
|
||||
}
|
||||
|
||||
// Clear resets state
|
||||
func (cs *ConversationState) Clear() {
|
||||
cs.mu.Lock()
|
||||
defer cs.mu.Unlock()
|
||||
cs.StreamingMessage = &StreamingMessage{}
|
||||
}
|
||||
|
||||
// ExtensionState represents the server-side extension state structure
|
||||
type ExtensionState struct {
|
||||
CurrentTaskItem *CurrentTaskItem `json:"currentTaskItem,omitempty"`
|
||||
}
|
||||
|
||||
// CurrentTaskItem - minimal struct with just what we need
|
||||
type CurrentTaskItem struct {
|
||||
Id string `json:"id"`
|
||||
}
|
||||
@@ -1,409 +0,0 @@
|
||||
package updater
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/cline/cli/pkg/cli/global"
|
||||
"github.com/cline/cli/pkg/cli/output"
|
||||
)
|
||||
|
||||
type cacheData struct {
|
||||
LastCheck time.Time `json:"last_check"`
|
||||
LatestVersion string `json:"latest_version"`
|
||||
}
|
||||
|
||||
type npmRegistryResponse struct {
|
||||
DistTags struct {
|
||||
Latest string `json:"latest"`
|
||||
Nightly string `json:"nightly"`
|
||||
} `json:"dist-tags"`
|
||||
}
|
||||
|
||||
const (
|
||||
checkInterval = 24 * time.Hour
|
||||
requestTimeout = 3 * time.Second
|
||||
)
|
||||
|
||||
var (
|
||||
successStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("2")).Bold(true)
|
||||
errorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("1")).Bold(true)
|
||||
dimStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("8"))
|
||||
)
|
||||
|
||||
var verbose bool
|
||||
|
||||
// CheckAndUpdate performs a background update check and attempts to auto-update if needed.
|
||||
// This is non-blocking and safe to call on CLI startup.
|
||||
func CheckAndUpdate(isVerbose bool) {
|
||||
verbose = isVerbose
|
||||
|
||||
// Skip in CI environments
|
||||
if os.Getenv("CI") != "" {
|
||||
if verbose {
|
||||
output.Printf("[updater] Skipping update check (CI environment)\n")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Skip if user disabled auto-updates
|
||||
if os.Getenv("NO_AUTO_UPDATE") != "" {
|
||||
if verbose {
|
||||
output.Printf("[updater] Skipping update check (NO_AUTO_UPDATE set)\n")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if verbose {
|
||||
output.Printf("[updater] Starting background update check...\n")
|
||||
}
|
||||
|
||||
// Run in background so we don't block CLI startup
|
||||
go func() {
|
||||
if err := checkAndUpdateInternal(false); err != nil {
|
||||
if verbose {
|
||||
output.Printf("[updater] Update check failed: %v\n", err)
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// CheckAndUpdateSync performs a synchronous update check (blocks until complete).
|
||||
// If bypassCache is true, ignores the 24-hour cache and always checks npm registry.
|
||||
// This is used by the doctor command.
|
||||
func CheckAndUpdateSync(isVerbose bool, bypassCache bool) {
|
||||
verbose = isVerbose
|
||||
|
||||
// Skip in CI environments
|
||||
if os.Getenv("CI") != "" {
|
||||
if verbose {
|
||||
output.Printf("[updater] Skipping update check (CI environment)\n")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Skip if user disabled auto-updates
|
||||
if os.Getenv("NO_AUTO_UPDATE") != "" {
|
||||
if verbose {
|
||||
output.Printf("[updater] Skipping update check (NO_AUTO_UPDATE set)\n")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if verbose {
|
||||
output.Printf("[updater] Starting update check...\n")
|
||||
}
|
||||
|
||||
// Run synchronously
|
||||
if err := checkAndUpdateInternal(bypassCache); err != nil {
|
||||
if verbose {
|
||||
output.Printf("[updater] Update check failed: %v\n", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func checkAndUpdateInternal(bypassCache bool) error {
|
||||
if verbose {
|
||||
output.Printf("[updater] Loading update cache...\n")
|
||||
}
|
||||
|
||||
// Load cache
|
||||
cache, err := loadCache()
|
||||
if !bypassCache && err == nil && time.Since(cache.LastCheck) < checkInterval {
|
||||
// Checked recently, skip (unless cache is bypassed)
|
||||
if verbose {
|
||||
output.Printf("[updater] Cache is fresh (last checked %v ago), skipping\n", time.Since(cache.LastCheck))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
if err != nil && verbose {
|
||||
output.Printf("[updater] Cache load failed or doesn't exist: %v\n", err)
|
||||
}
|
||||
|
||||
// Determine channel
|
||||
distTag := "latest"
|
||||
if strings.Contains(global.CliVersion, "nightly") {
|
||||
distTag = "nightly"
|
||||
}
|
||||
|
||||
if verbose {
|
||||
output.Printf("[updater] Current version: %s (channel: %s)\n", global.CliVersion, distTag)
|
||||
output.Printf("[updater] Fetching latest version from npm registry...\n")
|
||||
}
|
||||
|
||||
// Fetch latest version from npm
|
||||
latestVersion, err := fetchLatestVersion()
|
||||
if err != nil {
|
||||
if verbose {
|
||||
output.Printf("[updater] Failed to fetch latest version: %v\n", err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
if verbose {
|
||||
output.Printf("[updater] Latest version on npm: %s\n", latestVersion)
|
||||
}
|
||||
|
||||
// Update cache
|
||||
cache = cacheData{
|
||||
LastCheck: time.Now(),
|
||||
LatestVersion: latestVersion,
|
||||
}
|
||||
saveCache(cache)
|
||||
|
||||
if verbose {
|
||||
output.Printf("[updater] Updated cache\n")
|
||||
}
|
||||
|
||||
// Compare versions
|
||||
currentVersion := strings.TrimPrefix(global.CliVersion, "v")
|
||||
latestVersion = strings.TrimPrefix(latestVersion, "v")
|
||||
|
||||
if verbose {
|
||||
output.Printf("[updater] Comparing versions: current=%s latest=%s\n", currentVersion, latestVersion)
|
||||
}
|
||||
|
||||
if !isNewer(latestVersion, currentVersion) {
|
||||
// Already up to date
|
||||
if verbose {
|
||||
output.Printf("[updater] Already on latest version, no update needed\n")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
if verbose {
|
||||
output.Printf("[updater] Update available! Attempting to install...\n")
|
||||
}
|
||||
|
||||
// Determine channel for update command
|
||||
channel := "latest"
|
||||
if strings.Contains(global.CliVersion, "nightly") {
|
||||
channel = "nightly"
|
||||
}
|
||||
|
||||
// Attempt update
|
||||
if verbose {
|
||||
output.Printf("[updater] Running: npm install -g cline%s\n",
|
||||
map[bool]string{true: "@"+channel, false: ""}[channel == "nightly"])
|
||||
}
|
||||
|
||||
if err := attemptUpdate(channel); err != nil {
|
||||
if verbose {
|
||||
output.Printf("[updater] Update failed: %v\n", err)
|
||||
}
|
||||
showFailureMessage(channel)
|
||||
return err
|
||||
}
|
||||
|
||||
if verbose {
|
||||
output.Printf("[updater] Update completed successfully!\n")
|
||||
}
|
||||
|
||||
showSuccessMessage(latestVersion)
|
||||
return nil
|
||||
}
|
||||
|
||||
func fetchLatestVersion() (string, error) {
|
||||
// Determine dist-tag from current version
|
||||
distTag := "latest"
|
||||
if strings.Contains(global.CliVersion, "nightly") {
|
||||
distTag = "nightly"
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), requestTimeout)
|
||||
defer cancel()
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", "https://registry.npmjs.org/cline", nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("npm registry returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var data npmRegistryResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&data); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if distTag == "nightly" {
|
||||
return data.DistTags.Nightly, nil
|
||||
}
|
||||
return data.DistTags.Latest, nil
|
||||
}
|
||||
|
||||
func attemptUpdate(channel string) error {
|
||||
packageName := "cline"
|
||||
if channel == "nightly" {
|
||||
packageName = "cline@nightly"
|
||||
}
|
||||
|
||||
cmd := exec.Command("npm", "install", "-g", packageName)
|
||||
cmd.Stdout = nil
|
||||
cmd.Stderr = nil
|
||||
|
||||
return cmd.Run()
|
||||
}
|
||||
|
||||
func isNewer(latest, current string) bool {
|
||||
// Parse version strings (e.g., "1.0.0-nightly.19")
|
||||
latestBase, latestSuffix := parseVersion(latest)
|
||||
currentBase, currentSuffix := parseVersion(current)
|
||||
|
||||
// Compare base versions (1.0.0)
|
||||
comparison := compareVersionParts(latestBase, currentBase)
|
||||
if comparison != 0 {
|
||||
return comparison > 0
|
||||
}
|
||||
|
||||
// Base versions are equal, compare suffixes (nightly.19)
|
||||
return compareSuffix(latestSuffix, currentSuffix) > 0
|
||||
}
|
||||
|
||||
func parseVersion(version string) (string, string) {
|
||||
parts := strings.SplitN(version, "-", 2)
|
||||
if len(parts) == 2 {
|
||||
return parts[0], parts[1]
|
||||
}
|
||||
return parts[0], ""
|
||||
}
|
||||
|
||||
func compareVersionParts(v1, v2 string) int {
|
||||
parts1 := strings.Split(v1, ".")
|
||||
parts2 := strings.Split(v2, ".")
|
||||
|
||||
for i := 0; i < len(parts1) && i < len(parts2); i++ {
|
||||
// Convert to int for proper numeric comparison
|
||||
n1 := parseInt(parts1[i])
|
||||
n2 := parseInt(parts2[i])
|
||||
|
||||
if n1 > n2 {
|
||||
return 1
|
||||
}
|
||||
if n1 < n2 {
|
||||
return -1
|
||||
}
|
||||
}
|
||||
|
||||
// If all parts are equal, longer version is newer
|
||||
if len(parts1) > len(parts2) {
|
||||
return 1
|
||||
}
|
||||
if len(parts1) < len(parts2) {
|
||||
return -1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func compareSuffix(s1, s2 string) int {
|
||||
// If one has no suffix, stable > prerelease
|
||||
if s1 == "" && s2 == "" {
|
||||
return 0
|
||||
}
|
||||
if s1 == "" {
|
||||
return 1 // Stable is newer than prerelease
|
||||
}
|
||||
if s2 == "" {
|
||||
return -1 // Prerelease is older than stable
|
||||
}
|
||||
|
||||
// Both have suffixes (e.g., "nightly.19" vs "nightly.18")
|
||||
// Extract the numeric part after the last dot
|
||||
n1 := extractBuildNumber(s1)
|
||||
n2 := extractBuildNumber(s2)
|
||||
|
||||
if n1 > n2 {
|
||||
return 1
|
||||
}
|
||||
if n1 < n2 {
|
||||
return -1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func extractBuildNumber(suffix string) int {
|
||||
// Extract number from "nightly.19" -> 19
|
||||
parts := strings.Split(suffix, ".")
|
||||
if len(parts) > 1 {
|
||||
return parseInt(parts[len(parts)-1])
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func parseInt(s string) int {
|
||||
var result int
|
||||
fmt.Sscanf(s, "%d", &result)
|
||||
return result
|
||||
}
|
||||
|
||||
func showSuccessMessage(version string) {
|
||||
output.Printf("\n%s Updated to %s %s Changes will take effect next session\n\n",
|
||||
successStyle.Render("✓"),
|
||||
successStyle.Render("v"+version),
|
||||
dimStyle.Render("→"),
|
||||
)
|
||||
}
|
||||
|
||||
func showFailureMessage(channel string) {
|
||||
packageName := "cline"
|
||||
if channel == "nightly" {
|
||||
packageName = "cline@nightly"
|
||||
}
|
||||
|
||||
output.Printf("\n%s Auto-update failed %s Try: %s\n\n",
|
||||
errorStyle.Render("✗"),
|
||||
dimStyle.Render("·"),
|
||||
"npm install -g "+packageName,
|
||||
)
|
||||
}
|
||||
|
||||
func getCacheFilePath() string {
|
||||
configDir := filepath.Join(os.Getenv("HOME"), ".cline", "data")
|
||||
return filepath.Join(configDir, "cli-update-cache")
|
||||
}
|
||||
|
||||
func loadCache() (cacheData, error) {
|
||||
var cache cacheData
|
||||
cacheFile := getCacheFilePath()
|
||||
|
||||
data, err := os.ReadFile(cacheFile)
|
||||
if err != nil {
|
||||
return cache, err
|
||||
}
|
||||
|
||||
err = json.Unmarshal(data, &cache)
|
||||
return cache, err
|
||||
}
|
||||
|
||||
func saveCache(cache cacheData) error {
|
||||
cacheFile := getCacheFilePath()
|
||||
|
||||
// Ensure config directory exists
|
||||
configDir := filepath.Dir(cacheFile)
|
||||
if err := os.MkdirAll(configDir, 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
data, err := json.Marshal(cache)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return os.WriteFile(cacheFile, data, 0644)
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"runtime"
|
||||
|
||||
"github.com/cline/cli/pkg/cli/global"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// VersionString returns the full version information string
|
||||
func VersionString() string {
|
||||
return fmt.Sprintf(`Cline CLI
|
||||
Cline CLI Version: %s
|
||||
Cline Core Version: %s
|
||||
Commit: %s
|
||||
Built: %s
|
||||
Built by: %s
|
||||
Go version: %s
|
||||
OS/Arch: %s/%s
|
||||
`, global.CliVersion, global.Version, global.Commit, global.Date, global.BuiltBy, runtime.Version(), runtime.GOOS, runtime.GOARCH)
|
||||
}
|
||||
|
||||
// NewVersionCommand creates the version command
|
||||
func NewVersionCommand() *cobra.Command {
|
||||
var short bool
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "version",
|
||||
Aliases: []string{"v"},
|
||||
Short: "Show version information",
|
||||
Long: `Display version information for the Cline CLI.`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if short {
|
||||
fmt.Println(global.CliVersion)
|
||||
return nil
|
||||
}
|
||||
fmt.Print(VersionString())
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().BoolVar(&short, "short", false, "show only version number")
|
||||
|
||||
return cmd
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
package common
|
||||
|
||||
// WE WILL HAVE TO MIGRATE THIS FROM DATA TO v1 LATER
|
||||
const SETTINGS_SUBFOLDER = "data"
|
||||
|
||||
const DEFAULT_CLINE_CORE_PORT = 50052
|
||||
@@ -1,54 +0,0 @@
|
||||
package common
|
||||
|
||||
// Database query constants for the SQLite locks database
|
||||
const (
|
||||
|
||||
// SelectInstanceLocksSQL selects all instance locks ordered by creation time
|
||||
SelectInstanceLocksSQL = `
|
||||
SELECT id, held_by, lock_type, lock_target, locked_at
|
||||
FROM locks
|
||||
WHERE lock_type = 'instance'
|
||||
ORDER BY locked_at ASC
|
||||
`
|
||||
|
||||
SelectInstanceLockByHolderSQL = `
|
||||
SELECT held_by, lock_target, locked_at
|
||||
FROM locks
|
||||
WHERE held_by = ? AND lock_type = 'instance'
|
||||
`
|
||||
SelectInstanceLockHoldersAscSQL = `
|
||||
SELECT held_by, lock_target, locked_at
|
||||
FROM locks
|
||||
WHERE lock_type = 'instance'
|
||||
ORDER BY locked_at ASC
|
||||
`
|
||||
|
||||
// DeleteInstanceLockSQL deletes an instance lock by address
|
||||
DeleteInstanceLockSQL = `
|
||||
DELETE FROM locks
|
||||
WHERE held_by = ? AND lock_type = 'instance'
|
||||
`
|
||||
|
||||
InsertFileLockSQL = `
|
||||
INSERT INTO locks (held_by, lock_type, lock_target, locked_at)
|
||||
VALUES (?, 'file', ?, ?)
|
||||
`
|
||||
|
||||
// DeleteFileLockSQL deletes a file lock by holder and target
|
||||
DeleteFileLockSQL = `
|
||||
DELETE FROM locks
|
||||
WHERE held_by = ? AND lock_type = 'file' AND lock_target = ?
|
||||
`
|
||||
|
||||
// CountInstanceLockSQL counts instance locks for a given address
|
||||
CountInstanceLockSQL = `
|
||||
SELECT COUNT(*) FROM locks
|
||||
WHERE held_by = ? AND lock_type = 'instance'
|
||||
`
|
||||
|
||||
// InsertInstanceLockSQL inserts or replaces an instance lock
|
||||
InsertInstanceLockSQL = `
|
||||
INSERT OR REPLACE INTO locks (held_by, lock_type, lock_target, locked_at)
|
||||
VALUES (?, 'instance', ?, ?)
|
||||
`
|
||||
)
|
||||
@@ -1,54 +0,0 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"google.golang.org/grpc/health/grpc_health_v1"
|
||||
)
|
||||
|
||||
// CoreInstanceInfo represents a discovered Cline instance
|
||||
// This is the canonical definition used across all CLI packages
|
||||
type CoreInstanceInfo struct {
|
||||
// Full core address including port
|
||||
CoreAddress string `json:"address"`
|
||||
// Host bridge service address that core holds (host is ALWAYS running on localhost FYI)
|
||||
HostServiceAddress string `json:"host_port"`
|
||||
Status grpc_health_v1.HealthCheckResponse_ServingStatus `json:"status"`
|
||||
LastSeen time.Time `json:"last_seen"`
|
||||
ProcessPID int `json:"process_pid,omitempty"`
|
||||
Version string `json:"version,omitempty"`
|
||||
}
|
||||
|
||||
func (c *CoreInstanceInfo) CorePort() int {
|
||||
_, port, _ := ParseHostPort(c.CoreAddress)
|
||||
return port
|
||||
}
|
||||
|
||||
func (c *CoreInstanceInfo) HostPort() int {
|
||||
_, port, _ := ParseHostPort(c.HostServiceAddress)
|
||||
return port
|
||||
}
|
||||
|
||||
func (c *CoreInstanceInfo) StatusString() string {
|
||||
return c.Status.String()
|
||||
}
|
||||
|
||||
// LockRow represents a row in the locks table
|
||||
type LockRow struct {
|
||||
ID int64 `json:"id"`
|
||||
HeldBy string `json:"held_by"`
|
||||
LockType string `json:"lock_type"`
|
||||
LockTarget string `json:"lock_target"`
|
||||
LockedAt int64 `json:"locked_at"`
|
||||
}
|
||||
|
||||
// InstancesOutput represents the JSON output format for instance listing
|
||||
type InstancesOutput struct {
|
||||
DefaultInstance string `json:"default_instance"`
|
||||
CoreInstances []CoreInstanceInfo `json:"instances"`
|
||||
}
|
||||
|
||||
type DefaultCoreInstance struct {
|
||||
Address string `json:"default_instance"`
|
||||
LastUpdated string `json:"last_updated"`
|
||||
}
|
||||
@@ -1,256 +0,0 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/credentials/insecure"
|
||||
"google.golang.org/grpc/health/grpc_health_v1"
|
||||
)
|
||||
|
||||
// ParseHostPort parses a host:port address and returns the host and port separately
|
||||
func ParseHostPort(address string) (string, int, error) {
|
||||
host, portStr, err := net.SplitHostPort(address)
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
port, err := strconv.Atoi(portStr)
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
return host, port, nil
|
||||
}
|
||||
|
||||
// IsLocalAddress checks if the given host is a local/loopback address
|
||||
// Supports both IPv4 (localhost, 127.0.0.1) and IPv6 (::1) addresses
|
||||
func IsLocalAddress(host string) bool {
|
||||
// Handle common localhost names
|
||||
if host == "localhost" {
|
||||
return true
|
||||
}
|
||||
|
||||
// Parse as IP and check if it's a loopback
|
||||
if ip := net.ParseIP(host); ip != nil {
|
||||
return ip.IsLoopback()
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// PerformHealthCheck performs a gRPC health check on the given address
|
||||
// Will return UNKNOWN if the service is unreachable (error)
|
||||
func PerformHealthCheck(ctx context.Context, address string) (grpc_health_v1.HealthCheckResponse_ServingStatus, error) {
|
||||
conn, err := grpc.DialContext(ctx, address, grpc.WithTransportCredentials(insecure.NewCredentials()))
|
||||
if err != nil {
|
||||
return grpc_health_v1.HealthCheckResponse_UNKNOWN, err
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
healthClient := grpc_health_v1.NewHealthClient(conn)
|
||||
resp, err := healthClient.Check(ctx, &grpc_health_v1.HealthCheckRequest{})
|
||||
if err != nil {
|
||||
return grpc_health_v1.HealthCheckResponse_UNKNOWN, err
|
||||
}
|
||||
|
||||
return resp.Status, nil
|
||||
}
|
||||
|
||||
// It's healthy if we can reach it and it responds with SERVING
|
||||
func IsInstanceHealthy(ctx context.Context, address string) bool {
|
||||
status, err := PerformHealthCheck(ctx, address)
|
||||
return err == nil && status == grpc_health_v1.HealthCheckResponse_SERVING
|
||||
}
|
||||
|
||||
// It's (likely) our instance if we can reach it and it responds to health checks
|
||||
func IsInstanceOurs(ctx context.Context, address string) bool {
|
||||
_, err := PerformHealthCheck(ctx, address)
|
||||
return err != nil
|
||||
}
|
||||
|
||||
// (unreachable or not serving)
|
||||
func IsInstanceStale(ctx context.Context, address string) (grpc_health_v1.HealthCheckResponse_ServingStatus, bool, error) {
|
||||
status, err := PerformHealthCheck(ctx, address)
|
||||
isStale := err != nil || status != grpc_health_v1.HealthCheckResponse_SERVING
|
||||
return status, isStale, err
|
||||
}
|
||||
|
||||
// IsPortAvailable checks if a port is available for binding
|
||||
func IsPortAvailable(port int) bool {
|
||||
address := fmt.Sprintf("localhost:%d", port)
|
||||
listener, err := net.Listen("tcp", address)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
listener.Close()
|
||||
return true
|
||||
}
|
||||
|
||||
// FindAvailablePortPair finds two available ports by letting the OS allocate them
|
||||
func FindAvailablePortPair() (corePort, hostPort int, err error) {
|
||||
coreListener, err := net.Listen("tcp", ":0")
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
defer coreListener.Close()
|
||||
|
||||
hostListener, err := net.Listen("tcp", ":0")
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
defer hostListener.Close()
|
||||
|
||||
corePort = coreListener.Addr().(*net.TCPAddr).Port
|
||||
hostPort = hostListener.Addr().(*net.TCPAddr).Port
|
||||
|
||||
return corePort, hostPort, nil
|
||||
}
|
||||
|
||||
// NormalizeAddressForGRPC converts address to host:port for grpc client with proper normalization
|
||||
func NormalizeAddressForGRPC(address string) (string, error) {
|
||||
host, port, err := ParseHostPort(address)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Normalize local addresses to localhost for gRPC compatibility
|
||||
if IsLocalAddress(host) {
|
||||
return fmt.Sprintf("localhost:%d", port), nil
|
||||
}
|
||||
|
||||
return address, nil
|
||||
}
|
||||
|
||||
// GetNodeVersion returns the current Node.js version, or "unknown" if unable to detect
|
||||
func GetNodeVersion() string {
|
||||
cmd := exec.Command("node", "--version")
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
return "unknown"
|
||||
}
|
||||
return strings.TrimSpace(string(output))
|
||||
}
|
||||
|
||||
// RetryOperation performs an operation with retry logic
|
||||
func RetryOperation(maxRetries int, timeoutPerAttempt time.Duration, operation func() error) error {
|
||||
var lastErr error
|
||||
|
||||
for attempt := 1; attempt <= maxRetries; attempt++ {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeoutPerAttempt)
|
||||
|
||||
// Create a channel to capture the operation result
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
done <- operation()
|
||||
}()
|
||||
|
||||
select {
|
||||
case err := <-done:
|
||||
cancel()
|
||||
if err == nil {
|
||||
return nil // Success
|
||||
}
|
||||
lastErr = err
|
||||
case <-ctx.Done():
|
||||
cancel()
|
||||
lastErr = ctx.Err()
|
||||
}
|
||||
|
||||
// Add delay between attempts (except for the last one)
|
||||
if attempt < maxRetries {
|
||||
time.Sleep(1 * time.Second)
|
||||
}
|
||||
}
|
||||
|
||||
return fmt.Errorf(`operation failed to after %d attempts: %w
|
||||
|
||||
This is usually caused by an incompatible Node.js version
|
||||
|
||||
REQUIREMENTS:
|
||||
• Node.js version 20+ is required
|
||||
• Current Node.js version: %s
|
||||
|
||||
DEBUGGING STEPS:
|
||||
1. View recent logs: cline log list
|
||||
2. Logs are available in: ~/.cline/logs/
|
||||
3. The most recent cline-core log file is usually valuable
|
||||
|
||||
For additional help, visit: https://github.com/cline/cline/issues
|
||||
`, maxRetries, lastErr, GetNodeVersion())
|
||||
}
|
||||
|
||||
// validateDirsExist validates that all workspace paths exist on the filesystem
|
||||
func ValidateDirsExist(paths []string) error {
|
||||
for _, p := range paths {
|
||||
info, err := os.Stat(p)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return fmt.Errorf("path does not exist: %s", p)
|
||||
}
|
||||
return fmt.Errorf("failed to access path %s: %w", p, err)
|
||||
}
|
||||
if !info.IsDir() {
|
||||
return fmt.Errorf("path is not a directory: %s", p)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// absPath returns the absolute path, resolving symlinks
|
||||
func AbsPath(path string) (string, error) {
|
||||
// First get absolute path
|
||||
abs, err := filepath.Abs(path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
// Then resolve any symlinks
|
||||
resolved, err := filepath.EvalSymlinks(abs)
|
||||
if err != nil {
|
||||
// If symlink resolution fails, return the absolute path
|
||||
return abs, nil
|
||||
}
|
||||
return resolved, nil
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
package generated
|
||||
|
||||
// FieldOverrides allows manual control over field relevance per provider
|
||||
// This file is NOT auto-generated and can be edited manually to override
|
||||
// the automatic field filtering logic.
|
||||
//
|
||||
// Usage:
|
||||
// - Add provider-specific overrides to force include/exclude fields
|
||||
// - true = force include this field for this provider
|
||||
// - false = force exclude this field for this provider
|
||||
// - If no override exists, automatic filtering logic applies
|
||||
var FieldOverrides = map[string]map[string]bool{
|
||||
// Format: "provider_id": {"field_name": shouldInclude}
|
||||
|
||||
// Example overrides (uncomment and modify as needed):
|
||||
|
||||
// "anthropic": {
|
||||
// "requestTimeoutMs": true, // explicitly include
|
||||
// "ollamaBaseUrl": false, // explicitly exclude
|
||||
// },
|
||||
|
||||
// "bedrock": {
|
||||
// "awsSessionToken": true, // include even if marked optional
|
||||
// "azureApiVersion": false, // exclude even if general
|
||||
// },
|
||||
|
||||
// Add more provider-specific overrides as needed
|
||||
}
|
||||
|
||||
// GetFieldOverride returns the override setting for a field, if one exists
|
||||
// Returns (shouldInclude, hasOverride)
|
||||
func GetFieldOverride(providerID, fieldName string) (bool, bool) {
|
||||
if providerOverrides, exists := FieldOverrides[providerID]; exists {
|
||||
if override, hasOverride := providerOverrides[fieldName]; hasOverride {
|
||||
return override, true
|
||||
}
|
||||
}
|
||||
return false, false
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,363 +0,0 @@
|
||||
package hostbridge
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
|
||||
proto "github.com/cline/grpc-go/host"
|
||||
)
|
||||
|
||||
// diffSession represents an in-memory diff editing session
|
||||
type diffSession struct {
|
||||
originalPath string // File path from OpenDiff request
|
||||
originalContent []byte // Original file content (for comparison)
|
||||
currentContent []byte // Current modified content
|
||||
lines []string // Current content split into lines
|
||||
encoding string // File encoding (default: utf8)
|
||||
}
|
||||
|
||||
// DiffService implements the proto.DiffServiceServer interface
|
||||
type DiffService struct {
|
||||
proto.UnimplementedDiffServiceServer
|
||||
verbose bool
|
||||
sessions *sync.Map // thread-safe: diffId -> *diffSession
|
||||
counter *int64 // atomic counter for unique IDs
|
||||
}
|
||||
|
||||
// NewDiffService creates a new DiffService
|
||||
func NewDiffService(verbose bool) *DiffService {
|
||||
counter := int64(0)
|
||||
return &DiffService{
|
||||
verbose: verbose,
|
||||
sessions: &sync.Map{},
|
||||
counter: &counter,
|
||||
}
|
||||
}
|
||||
|
||||
// generateDiffID creates a unique diff ID
|
||||
func (s *DiffService) generateDiffID() string {
|
||||
id := atomic.AddInt64(s.counter, 1)
|
||||
return fmt.Sprintf("diff_%d_%d", os.Getpid(), id)
|
||||
}
|
||||
|
||||
// splitLines splits content into lines, preserving trailing newlines.
|
||||
// This matches the behavior of JavaScript's String.split("\n"):
|
||||
// - "hello\nworld\n" -> ["hello", "world", ""]
|
||||
// - "hello\nworld" -> ["hello", "world"]
|
||||
func splitLines(content string) []string {
|
||||
if content == "" {
|
||||
return []string{}
|
||||
}
|
||||
|
||||
lines := []string{}
|
||||
current := ""
|
||||
|
||||
for _, char := range content {
|
||||
if char == '\n' {
|
||||
lines = append(lines, current)
|
||||
current = ""
|
||||
} else if char != '\r' { // Skip \r characters, handle \r\n as \n
|
||||
current += string(char)
|
||||
}
|
||||
}
|
||||
|
||||
// Always add the last segment - if content ends with newline, this will be
|
||||
// an empty string which preserves the trailing newline when joined back
|
||||
lines = append(lines, current)
|
||||
|
||||
return lines
|
||||
}
|
||||
|
||||
// joinLines joins lines back into content with newlines
|
||||
func joinLines(lines []string) string {
|
||||
if len(lines) == 0 {
|
||||
return ""
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
// OpenDiff opens a diff view for the specified file
|
||||
func (s *DiffService) OpenDiff(ctx context.Context, req *proto.OpenDiffRequest) (*proto.OpenDiffResponse, error) {
|
||||
if s.verbose {
|
||||
log.Printf("OpenDiff called for path: %s", req.GetPath())
|
||||
}
|
||||
|
||||
diffID := s.generateDiffID()
|
||||
|
||||
var originalContent []byte
|
||||
|
||||
// Check if file exists and read original content
|
||||
if req.GetPath() != "" {
|
||||
if _, err := os.Stat(req.GetPath()); err == nil {
|
||||
// File exists, read its content
|
||||
var readErr error
|
||||
originalContent, readErr = ioutil.ReadFile(req.GetPath())
|
||||
if readErr != nil {
|
||||
return nil, fmt.Errorf("failed to read original file: %w", readErr)
|
||||
}
|
||||
} else {
|
||||
// File doesn't exist, use empty content
|
||||
originalContent = []byte{}
|
||||
}
|
||||
}
|
||||
|
||||
// Use provided content as the initial current content
|
||||
currentContent := []byte(req.GetContent())
|
||||
|
||||
// Create the diff session
|
||||
session := &diffSession{
|
||||
originalPath: req.GetPath(),
|
||||
originalContent: originalContent,
|
||||
currentContent: currentContent,
|
||||
lines: splitLines(req.GetContent()),
|
||||
encoding: "utf8", // Default encoding
|
||||
}
|
||||
|
||||
// Store the session
|
||||
s.sessions.Store(diffID, session)
|
||||
|
||||
if s.verbose {
|
||||
log.Printf("Created diff session: %s (original: %d bytes, current: %d bytes)",
|
||||
diffID, len(originalContent), len(currentContent))
|
||||
}
|
||||
|
||||
return &proto.OpenDiffResponse{
|
||||
DiffId: &diffID,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetDocumentText returns the current content of the diff document
|
||||
func (s *DiffService) GetDocumentText(ctx context.Context, req *proto.GetDocumentTextRequest) (*proto.GetDocumentTextResponse, error) {
|
||||
if s.verbose {
|
||||
log.Printf("GetDocumentText called for diff ID: %s", req.GetDiffId())
|
||||
}
|
||||
|
||||
sessionInterface, exists := s.sessions.Load(req.GetDiffId())
|
||||
if !exists {
|
||||
return nil, fmt.Errorf("diff session not found: %s", req.GetDiffId())
|
||||
}
|
||||
|
||||
session := sessionInterface.(*diffSession)
|
||||
content := string(session.currentContent)
|
||||
|
||||
return &proto.GetDocumentTextResponse{
|
||||
Content: &content,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ReplaceText replaces text in the diff document using line-based operations
|
||||
func (s *DiffService) ReplaceText(ctx context.Context, req *proto.ReplaceTextRequest) (*proto.ReplaceTextResponse, error) {
|
||||
if s.verbose {
|
||||
log.Printf("ReplaceText called for diff ID: %s, lines %d-%d",
|
||||
req.GetDiffId(), req.GetStartLine(), req.GetEndLine())
|
||||
}
|
||||
|
||||
sessionInterface, exists := s.sessions.Load(req.GetDiffId())
|
||||
if !exists {
|
||||
return nil, fmt.Errorf("diff session not found: %s", req.GetDiffId())
|
||||
}
|
||||
|
||||
session := sessionInterface.(*diffSession)
|
||||
|
||||
startLine := int(req.GetStartLine())
|
||||
endLine := int(req.GetEndLine())
|
||||
newContent := req.GetContent()
|
||||
|
||||
// Validate line ranges
|
||||
if startLine < 0 {
|
||||
startLine = 0
|
||||
}
|
||||
if endLine < startLine {
|
||||
endLine = startLine
|
||||
}
|
||||
|
||||
// Check if we're replacing to the end of the document
|
||||
replacingToEnd := endLine >= len(session.lines)
|
||||
|
||||
// Split new content into lines
|
||||
newLines := splitLines(newContent)
|
||||
|
||||
// Remove trailing empty line for proper splicing, BUT only when NOT replacing
|
||||
// to the end of the document. When replacing to the end, keep the trailing
|
||||
// empty string to preserve trailing newlines from the content.
|
||||
if !replacingToEnd && len(newLines) > 0 && newLines[len(newLines)-1] == "" {
|
||||
newLines = newLines[:len(newLines)-1]
|
||||
}
|
||||
|
||||
// Ensure we have enough lines in the current content
|
||||
for len(session.lines) < endLine {
|
||||
session.lines = append(session.lines, "")
|
||||
}
|
||||
|
||||
// Replace the specified line range
|
||||
if endLine > len(session.lines) {
|
||||
// Extending beyond current content - append new lines
|
||||
session.lines = append(session.lines[:startLine], newLines...)
|
||||
} else {
|
||||
// Replace within existing content
|
||||
result := make([]string, 0, len(session.lines)-endLine+startLine+len(newLines))
|
||||
result = append(result, session.lines[:startLine]...)
|
||||
result = append(result, newLines...)
|
||||
result = append(result, session.lines[endLine:]...)
|
||||
session.lines = result
|
||||
}
|
||||
|
||||
// Update current content
|
||||
session.currentContent = []byte(joinLines(session.lines))
|
||||
|
||||
// Store the updated session
|
||||
s.sessions.Store(req.GetDiffId(), session)
|
||||
|
||||
if s.verbose {
|
||||
log.Printf("Updated diff session %s: %d lines, %d bytes",
|
||||
req.GetDiffId(), len(session.lines), len(session.currentContent))
|
||||
}
|
||||
|
||||
return &proto.ReplaceTextResponse{}, nil
|
||||
}
|
||||
|
||||
// ScrollDiff scrolls the diff view to a specific line (no-op for CLI)
|
||||
func (s *DiffService) ScrollDiff(ctx context.Context, req *proto.ScrollDiffRequest) (*proto.ScrollDiffResponse, error) {
|
||||
if s.verbose {
|
||||
log.Printf("ScrollDiff called for diff ID: %s, line: %d", req.GetDiffId(), req.GetLine())
|
||||
}
|
||||
|
||||
// Verify session exists
|
||||
if _, exists := s.sessions.Load(req.GetDiffId()); !exists {
|
||||
return nil, fmt.Errorf("diff session not found: %s", req.GetDiffId())
|
||||
}
|
||||
|
||||
// In a CLI implementation, scrolling is a no-op
|
||||
// In a GUI implementation, this would scroll the view to the specified line
|
||||
return &proto.ScrollDiffResponse{}, nil
|
||||
}
|
||||
|
||||
// TruncateDocument truncates the diff document at the specified line
|
||||
func (s *DiffService) TruncateDocument(ctx context.Context, req *proto.TruncateDocumentRequest) (*proto.TruncateDocumentResponse, error) {
|
||||
if s.verbose {
|
||||
log.Printf("TruncateDocument called for diff ID: %s, end line: %d", req.GetDiffId(), req.GetEndLine())
|
||||
}
|
||||
|
||||
sessionInterface, exists := s.sessions.Load(req.GetDiffId())
|
||||
if !exists {
|
||||
return nil, fmt.Errorf("diff session not found: %s", req.GetDiffId())
|
||||
}
|
||||
|
||||
session := sessionInterface.(*diffSession)
|
||||
endLine := int(req.GetEndLine())
|
||||
|
||||
// Truncate lines at the specified position
|
||||
if endLine >= 0 && endLine < len(session.lines) {
|
||||
session.lines = session.lines[:endLine]
|
||||
session.currentContent = []byte(joinLines(session.lines))
|
||||
|
||||
// Store the updated session
|
||||
s.sessions.Store(req.GetDiffId(), session)
|
||||
|
||||
if s.verbose {
|
||||
log.Printf("Truncated diff session %s to %d lines", req.GetDiffId(), len(session.lines))
|
||||
}
|
||||
}
|
||||
|
||||
return &proto.TruncateDocumentResponse{}, nil
|
||||
}
|
||||
|
||||
// SaveDocument saves the diff document to the original file
|
||||
func (s *DiffService) SaveDocument(ctx context.Context, req *proto.SaveDocumentRequest) (*proto.SaveDocumentResponse, error) {
|
||||
if s.verbose {
|
||||
log.Printf("SaveDocument called for diff ID: %s", req.GetDiffId())
|
||||
}
|
||||
|
||||
sessionInterface, exists := s.sessions.Load(req.GetDiffId())
|
||||
if !exists {
|
||||
return nil, fmt.Errorf("diff session not found: %s", req.GetDiffId())
|
||||
}
|
||||
|
||||
session := sessionInterface.(*diffSession)
|
||||
|
||||
if session.originalPath == "" {
|
||||
return nil, fmt.Errorf("no file path specified for diff session: %s", req.GetDiffId())
|
||||
}
|
||||
|
||||
// Create parent directories if they don't exist
|
||||
dir := filepath.Dir(session.originalPath)
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return nil, fmt.Errorf("failed to create directories: %w", err)
|
||||
}
|
||||
|
||||
// Write the current content to the original file
|
||||
if err := ioutil.WriteFile(session.originalPath, session.currentContent, 0644); err != nil {
|
||||
return nil, fmt.Errorf("failed to save file: %w", err)
|
||||
}
|
||||
|
||||
if s.verbose {
|
||||
log.Printf("Saved diff session %s to file: %s (%d bytes)",
|
||||
req.GetDiffId(), session.originalPath, len(session.currentContent))
|
||||
}
|
||||
|
||||
return &proto.SaveDocumentResponse{}, nil
|
||||
}
|
||||
|
||||
// CloseAllDiffs closes all diff views and cleans up all sessions
|
||||
func (s *DiffService) CloseAllDiffs(ctx context.Context, req *proto.CloseAllDiffsRequest) (*proto.CloseAllDiffsResponse, error) {
|
||||
if s.verbose {
|
||||
log.Printf("CloseAllDiffs called")
|
||||
}
|
||||
|
||||
var count int64
|
||||
|
||||
s.sessions.Range(func(key, value any) bool {
|
||||
// Optional: attempt to close if the value supports it
|
||||
if c, ok := value.(interface{ Close() error }); ok {
|
||||
_ = c.Close() // best-effort; ignore error
|
||||
}
|
||||
|
||||
s.sessions.Delete(key)
|
||||
atomic.AddInt64(&count, 1)
|
||||
return true
|
||||
})
|
||||
|
||||
if s.verbose {
|
||||
log.Printf("Closed %d diff sessions", count)
|
||||
}
|
||||
|
||||
return &proto.CloseAllDiffsResponse{}, nil
|
||||
}
|
||||
|
||||
// OpenMultiFileDiff displays a diff view comparing before/after states for multiple files
|
||||
func (s *DiffService) OpenMultiFileDiff(ctx context.Context, req *proto.OpenMultiFileDiffRequest) (*proto.OpenMultiFileDiffResponse, error) {
|
||||
if s.verbose {
|
||||
log.Printf("OpenMultiFileDiff called with title: %s, %d files", req.GetTitle(), len(req.GetDiffs()))
|
||||
}
|
||||
|
||||
// In a CLI implementation, we could display the diffs to console
|
||||
// For now, we'll just log the information
|
||||
title := req.GetTitle()
|
||||
if title == "" {
|
||||
title = "Multi-file diff"
|
||||
}
|
||||
|
||||
if s.verbose {
|
||||
log.Printf("=== %s ===", title)
|
||||
for i, diff := range req.GetDiffs() {
|
||||
log.Printf("File %d: %s", i+1, diff.GetFilePath())
|
||||
log.Printf(" Left content: %d bytes", len(diff.GetLeftContent()))
|
||||
log.Printf(" Right content: %d bytes", len(diff.GetRightContent()))
|
||||
}
|
||||
}
|
||||
|
||||
// In a more sophisticated CLI implementation, we could:
|
||||
// 1. Use a diff library to generate unified diffs
|
||||
// 2. Display them with colors
|
||||
// 3. Allow navigation between files
|
||||
// For now, this is a no-op that just acknowledges the request
|
||||
|
||||
return &proto.OpenMultiFileDiffResponse{}, nil
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
package hostbridge
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
proto "github.com/cline/grpc-go/host"
|
||||
)
|
||||
|
||||
// WindowService implements the proto.WindowServiceServer interface
|
||||
type WindowService struct {
|
||||
proto.UnimplementedWindowServiceServer
|
||||
coreAddress string
|
||||
verbose bool
|
||||
}
|
||||
|
||||
// NewWindowService creates a new WindowService
|
||||
func NewWindowService(coreAddress string, verbose bool) *WindowService {
|
||||
return &WindowService{
|
||||
coreAddress: coreAddress,
|
||||
verbose: verbose,
|
||||
}
|
||||
}
|
||||
|
||||
// ShowTextDocument opens a text document for viewing/editing
|
||||
func (s *WindowService) ShowTextDocument(ctx context.Context, req *proto.ShowTextDocumentRequest) (*proto.TextEditorInfo, error) {
|
||||
if s.verbose {
|
||||
log.Printf("ShowTextDocument called for path: %s", req.GetPath())
|
||||
}
|
||||
|
||||
// For console implementation, we'll just log that we would open the document
|
||||
fmt.Printf("[Cline] Would open document: %s\n", req.GetPath())
|
||||
|
||||
return &proto.TextEditorInfo{
|
||||
DocumentPath: req.GetPath(),
|
||||
IsActive: true,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ShowOpenDialogue shows a file open dialog
|
||||
func (s *WindowService) ShowOpenDialogue(ctx context.Context, req *proto.ShowOpenDialogueRequest) (*proto.SelectedResources, error) {
|
||||
if s.verbose {
|
||||
log.Printf("ShowOpenDialogue called")
|
||||
}
|
||||
|
||||
// For console implementation, return empty list (user cancelled)
|
||||
return &proto.SelectedResources{
|
||||
Paths: []string{},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ShowMessage displays a message to the user
|
||||
func (s *WindowService) ShowMessage(ctx context.Context, req *proto.ShowMessageRequest) (*proto.SelectedResponse, error) {
|
||||
if s.verbose {
|
||||
log.Printf("ShowMessage called: %s", req.GetMessage())
|
||||
}
|
||||
|
||||
// Display message to console
|
||||
fmt.Printf("[Cline] %s\n", req.GetMessage())
|
||||
|
||||
return &proto.SelectedResponse{}, nil
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
package hostbridge
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"os"
|
||||
|
||||
"github.com/cline/grpc-go/cline"
|
||||
"github.com/cline/grpc-go/host"
|
||||
)
|
||||
|
||||
// WorkspaceService implements the host.WorkspaceServiceServer interface
|
||||
type WorkspaceService struct {
|
||||
host.UnimplementedWorkspaceServiceServer
|
||||
coreAddress string
|
||||
verbose bool
|
||||
}
|
||||
|
||||
// NewWorkspaceService creates a new WorkspaceService
|
||||
func NewWorkspaceService(coreAddress string, verbose bool) *WorkspaceService {
|
||||
return &WorkspaceService{
|
||||
coreAddress: coreAddress,
|
||||
verbose: verbose,
|
||||
}
|
||||
}
|
||||
|
||||
// GetWorkspacePaths returns the workspace directory paths
|
||||
func (s *WorkspaceService) GetWorkspacePaths(ctx context.Context, req *host.GetWorkspacePathsRequest) (*host.GetWorkspacePathsResponse, error) {
|
||||
if s.verbose {
|
||||
log.Printf("GetWorkspacePaths called")
|
||||
}
|
||||
|
||||
// Get current working directory as the workspace
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &host.GetWorkspacePathsResponse{
|
||||
Paths: []string{cwd},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// SaveOpenDocumentIfDirty saves an open document if it has unsaved changes
|
||||
func (s *WorkspaceService) SaveOpenDocumentIfDirty(ctx context.Context, req *host.SaveOpenDocumentIfDirtyRequest) (*host.SaveOpenDocumentIfDirtyResponse, error) {
|
||||
if s.verbose {
|
||||
log.Printf("SaveOpenDocumentIfDirty called for path: %v", req.FilePath)
|
||||
}
|
||||
|
||||
// For console implementation, we'll assume the document is already saved
|
||||
// In a real implementation, we'd check if the file has unsaved changes
|
||||
f := false
|
||||
return &host.SaveOpenDocumentIfDirtyResponse{
|
||||
WasSaved: &f, // Assume no changes to save
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetDiagnostics returns diagnostic information for a file
|
||||
func (s *WorkspaceService) GetDiagnostics(ctx context.Context, req *host.GetDiagnosticsRequest) (*host.GetDiagnosticsResponse, error) {
|
||||
if s.verbose {
|
||||
log.Printf("GetDiagnostics called")
|
||||
}
|
||||
|
||||
// For console implementation, return empty diagnostics
|
||||
return &host.GetDiagnosticsResponse{
|
||||
FileDiagnostics: []*cline.FileDiagnostics{},
|
||||
}, nil
|
||||
}
|
||||
@@ -1,166 +0,0 @@
|
||||
package hostbridge
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"os"
|
||||
|
||||
"github.com/atotto/clipboard"
|
||||
"github.com/cline/cli/pkg/cli/global"
|
||||
"github.com/cline/grpc-go/cline"
|
||||
"github.com/cline/grpc-go/host"
|
||||
"google.golang.org/protobuf/proto"
|
||||
)
|
||||
|
||||
// Global shutdown channel - simple approach
|
||||
var globalShutdownCh chan struct{}
|
||||
|
||||
func init() {
|
||||
globalShutdownCh = make(chan struct{})
|
||||
}
|
||||
|
||||
// EnvService implements the host.EnvServiceServer interface
|
||||
type EnvService struct {
|
||||
host.UnimplementedEnvServiceServer
|
||||
verbose bool
|
||||
}
|
||||
|
||||
// NewEnvService creates a new EnvService
|
||||
func NewEnvService(verbose bool) *EnvService {
|
||||
return &EnvService{
|
||||
verbose: verbose,
|
||||
}
|
||||
}
|
||||
|
||||
// ClipboardWriteText writes text to the system clipboard
|
||||
func (s *EnvService) ClipboardWriteText(ctx context.Context, req *cline.StringRequest) (*cline.Empty, error) {
|
||||
if s.verbose {
|
||||
log.Printf("ClipboardWriteText called with text length: %d", len(req.GetValue()))
|
||||
}
|
||||
|
||||
err := clipboard.WriteAll(req.GetValue())
|
||||
if err != nil {
|
||||
if s.verbose {
|
||||
log.Printf("Failed to write to clipboard: %v", err)
|
||||
}
|
||||
// Don't fail if clipboard is not available (e.g., headless environment)
|
||||
}
|
||||
|
||||
return &cline.Empty{}, nil
|
||||
}
|
||||
|
||||
// ClipboardReadText reads text from the system clipboard
|
||||
func (s *EnvService) ClipboardReadText(ctx context.Context, req *cline.EmptyRequest) (*cline.String, error) {
|
||||
if s.verbose {
|
||||
log.Printf("ClipboardReadText called")
|
||||
}
|
||||
|
||||
text, err := clipboard.ReadAll()
|
||||
if err != nil {
|
||||
if s.verbose {
|
||||
log.Printf("Failed to read from clipboard: %v", err)
|
||||
}
|
||||
// Return empty string if clipboard is not available
|
||||
text = ""
|
||||
}
|
||||
|
||||
return &cline.String{
|
||||
Value: text,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetHostVersion returns the host platform name and version
|
||||
func (s *EnvService) GetHostVersion(ctx context.Context, req *cline.EmptyRequest) (*host.GetHostVersionResponse, error) {
|
||||
if s.verbose {
|
||||
log.Printf("GetHostVersion called")
|
||||
}
|
||||
|
||||
return &host.GetHostVersionResponse{
|
||||
Platform: proto.String("Cline CLI"),
|
||||
Version: proto.String(global.CliVersion),
|
||||
ClineType: proto.String("CLI"),
|
||||
ClineVersion: proto.String(global.CliVersion),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Shutdown initiates a graceful shutdown of the host bridge service
|
||||
func (s *EnvService) Shutdown(ctx context.Context, req *cline.EmptyRequest) (*cline.Empty, error) {
|
||||
if s.verbose {
|
||||
log.Printf("Shutdown requested via RPC")
|
||||
}
|
||||
|
||||
// Trigger global shutdown signal
|
||||
select {
|
||||
case globalShutdownCh <- struct{}{}:
|
||||
if s.verbose {
|
||||
log.Printf("Shutdown signal sent successfully")
|
||||
}
|
||||
default:
|
||||
if s.verbose {
|
||||
log.Printf("Shutdown signal already pending")
|
||||
}
|
||||
}
|
||||
|
||||
return &cline.Empty{}, nil
|
||||
}
|
||||
|
||||
// GetTelemetrySettings returns the telemetry settings for CLI mode
|
||||
func (s *EnvService) GetTelemetrySettings(ctx context.Context, req *cline.EmptyRequest) (*host.GetTelemetrySettingsResponse, error) {
|
||||
if s.verbose {
|
||||
log.Printf("GetTelemetrySettings called")
|
||||
}
|
||||
|
||||
// In CLI mode, check the POSTHOG_TELEMETRY_ENABLED environment variable
|
||||
telemetryEnabled := os.Getenv("POSTHOG_TELEMETRY_ENABLED") == "true"
|
||||
|
||||
var setting host.Setting
|
||||
if telemetryEnabled {
|
||||
setting = host.Setting_ENABLED
|
||||
} else {
|
||||
setting = host.Setting_DISABLED
|
||||
}
|
||||
|
||||
return &host.GetTelemetrySettingsResponse{
|
||||
IsEnabled: setting,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// SubscribeToTelemetrySettings returns a stream of telemetry setting changes
|
||||
// In CLI mode, telemetry settings don't change at runtime, so we just send
|
||||
// the current state and keep the stream open
|
||||
func (s *EnvService) SubscribeToTelemetrySettings(req *cline.EmptyRequest, stream host.EnvService_SubscribeToTelemetrySettingsServer) error {
|
||||
if s.verbose {
|
||||
log.Printf("SubscribeToTelemetrySettings called")
|
||||
}
|
||||
|
||||
// Send initial telemetry state
|
||||
telemetryEnabled := os.Getenv("POSTHOG_TELEMETRY_ENABLED") == "true"
|
||||
|
||||
var setting host.Setting
|
||||
if telemetryEnabled {
|
||||
setting = host.Setting_ENABLED
|
||||
} else {
|
||||
setting = host.Setting_DISABLED
|
||||
}
|
||||
|
||||
event := &host.TelemetrySettingsEvent{
|
||||
IsEnabled: setting,
|
||||
}
|
||||
|
||||
if err := stream.Send(event); err != nil {
|
||||
if s.verbose {
|
||||
log.Printf("Failed to send telemetry settings event: %v", err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// Keep stream open until context is cancelled
|
||||
// (In CLI mode, settings don't change dynamically)
|
||||
<-stream.Context().Done()
|
||||
|
||||
if s.verbose {
|
||||
log.Printf("SubscribeToTelemetrySettings stream closed")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -1,115 +0,0 @@
|
||||
package hostbridge
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
|
||||
"github.com/cline/grpc-go/host"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/health"
|
||||
"google.golang.org/grpc/health/grpc_health_v1"
|
||||
)
|
||||
|
||||
// GrpcServer provides gRPC hostbridge functionality
|
||||
type GrpcServer struct {
|
||||
port int
|
||||
verbose bool
|
||||
workspaces []string
|
||||
server *grpc.Server
|
||||
shutdownCh chan struct{}
|
||||
}
|
||||
|
||||
// NewGrpcServer creates a new GrpcServer
|
||||
func NewGrpcServer(port int, verbose bool, workspaces []string) *GrpcServer {
|
||||
return &GrpcServer{
|
||||
port: port,
|
||||
verbose: verbose,
|
||||
workspaces: workspaces,
|
||||
shutdownCh: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
// Start starts the gRPC hostbridge server
|
||||
func (s *GrpcServer) Start(ctx context.Context) error {
|
||||
if s.verbose {
|
||||
log.Printf("Starting gRPC hostbridge server on port %d", s.port)
|
||||
}
|
||||
|
||||
// Create listener
|
||||
lis, err := net.Listen("tcp", fmt.Sprintf(":%d", s.port))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to listen on port %d: %w", s.port, err)
|
||||
}
|
||||
|
||||
// Create gRPC server
|
||||
s.server = grpc.NewServer()
|
||||
|
||||
// Register health service
|
||||
healthServer := health.NewServer()
|
||||
healthServer.SetServingStatus("", grpc_health_v1.HealthCheckResponse_SERVING)
|
||||
grpc_health_v1.RegisterHealthServer(s.server, healthServer)
|
||||
|
||||
// Register services
|
||||
workspaceService := NewSimpleWorkspaceService(s.verbose, s.workspaces)
|
||||
host.RegisterWorkspaceServiceServer(s.server, workspaceService)
|
||||
|
||||
windowService := NewWindowService(s.verbose)
|
||||
host.RegisterWindowServiceServer(s.server, windowService)
|
||||
|
||||
diffService := NewDiffService(s.verbose)
|
||||
host.RegisterDiffServiceServer(s.server, diffService)
|
||||
|
||||
envService := NewEnvService(s.verbose)
|
||||
host.RegisterEnvServiceServer(s.server, envService)
|
||||
|
||||
if s.verbose {
|
||||
log.Printf("Registered HealthService")
|
||||
log.Printf("Registered WorkspaceService")
|
||||
log.Printf("Registered WindowService")
|
||||
log.Printf("Registered DiffService")
|
||||
log.Printf("Registered EnvService")
|
||||
}
|
||||
|
||||
// Start server in goroutine
|
||||
go func() {
|
||||
if s.verbose {
|
||||
log.Printf("gRPC server listening on :%d", s.port)
|
||||
}
|
||||
if err := s.server.Serve(lis); err != nil {
|
||||
log.Printf("gRPC server error: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
// Wait for context cancellation or global shutdown signal
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
if s.verbose {
|
||||
log.Println("Context cancelled, shutting down gRPC hostbridge server...")
|
||||
}
|
||||
case <-globalShutdownCh:
|
||||
if s.verbose {
|
||||
log.Println("Shutdown requested via RPC, shutting down gRPC hostbridge server...")
|
||||
}
|
||||
}
|
||||
|
||||
// Graceful shutdown
|
||||
s.server.GracefulStop()
|
||||
|
||||
if s.verbose {
|
||||
log.Println("gRPC hostbridge server stopped")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// TriggerShutdown triggers a graceful shutdown of the server
|
||||
func (s *GrpcServer) TriggerShutdown() {
|
||||
select {
|
||||
case s.shutdownCh <- struct{}{}:
|
||||
// Shutdown signal sent
|
||||
default:
|
||||
// Channel already has a signal or is closed
|
||||
}
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
package hostbridge
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
)
|
||||
|
||||
// Simple implementations that don't rely on proto files for now
|
||||
// This allows us to test the basic hostbridge structure
|
||||
|
||||
// SimpleService provides basic hostbridge functionality
|
||||
type SimpleService struct {
|
||||
coreAddress string
|
||||
verbose bool
|
||||
}
|
||||
|
||||
// NewSimpleService creates a new SimpleService
|
||||
func NewSimpleService(coreAddress string, verbose bool) *SimpleService {
|
||||
return &SimpleService{
|
||||
coreAddress: coreAddress,
|
||||
verbose: verbose,
|
||||
}
|
||||
}
|
||||
|
||||
// Start starts the simple hostbridge service
|
||||
func (s *SimpleService) Start(ctx context.Context) error {
|
||||
if s.verbose {
|
||||
log.Printf("Starting simple hostbridge service (connecting to core at %s)", s.coreAddress)
|
||||
}
|
||||
|
||||
// For now, just log that we're running
|
||||
fmt.Printf("[Cline Host Bridge] Service started on core address: %s\n", s.coreAddress)
|
||||
|
||||
// Keep running until context is cancelled
|
||||
<-ctx.Done()
|
||||
|
||||
if s.verbose {
|
||||
log.Println("Simple hostbridge service stopped")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user