Compare commits

...

289 Commits

Author SHA1 Message Date
Saoud Rizwan 2118bfb1e0 fix(cli): fix /clear not working on first attempt with pending ask
The /clear command would fail on the first attempt when there was a
pending ask (like a question from Cline). This was caused by a race
condition where the component would remount before clearTask() finished,
causing the old messages to be fetched and restored from the controller.

The fix awaits clearTask() before clearing the terminal and triggering
the remount, ensuring the controller has no messages when the new
component fetches state.
2026-02-01 01:01:02 -08:00
Saoud Rizwan 38aee03e15 fix(cli): show search regex and path in tool row 2026-02-01 00:28:51 -08:00
Saoud Rizwan e5fd4b46e0 fix(cli): initialize StateManager before ErrorService, block submit during spinner
- Fix startup hang by initializing StateManager before ErrorService
  (ErrorService now calls getTelemetrySettings which depends on StateManager)
- Block message submission while request is in progress to prevent
  accidental task clearing
2026-02-01 00:06:58 -08:00
Saoud Rizwan 4ce2ac6c6f feat(cli): improve diff view with line numbers and Myers diff algorithm
- Add DiffComputer utility that uses Myers diff algorithm (via `diff` library)
  to compute actual line-level changes between search/replace blocks
- Display line numbers in a gutter with proper alignment
- Color-code additions (green) and deletions (red) with muted backgrounds
- Show context lines (unchanged) in dim
- Collapse long runs of context (>3 lines) with "... X unchanged lines ..."
- Support multiple SEARCH/REPLACE blocks with separators
- Add tests for DiffComputer
2026-01-31 22:25:33 -08:00
Saoud Rizwan 19998a199c fix(cli): initialize StateManager before ErrorService
ErrorService now calls getTelemetrySettings() which depends on
StateManager being initialized first.
2026-01-31 17:58:51 -08:00
Saoud Rizwan 898ebdfef3 fix: update subagent command to use current CLI flags
The -s, -F, and --oneshot flags no longer exist in the CLI.
Updated to use --json and -y which are the current equivalents.
2026-01-31 17:24:59 -08:00
Saoud Rizwan ceaec03de3 feat(cli): track CLI activation for PostHog DAU metrics 2026-01-31 16:30:00 -08:00
Saoud Rizwan 3b5f3eff2e fix(cli): respect user telemetry preference
Previously, CLI telemetry was hardcoded to ENABLED and the settings
toggle didn't actually work. Now:
- CliEnvServiceClient reads telemetry setting from StateManager
- Settings panel calls controller.updateTelemetrySetting() to notify
  telemetry providers when the setting changes
2026-01-31 16:28:03 -08:00
Saoud Rizwan 7f3d8c0dfd feat(cli): make Kimi K2.5 a free model
Add moonshotai/kimi-k2.5 to the free models list so users see $0 cost.
2026-01-31 15:53:09 -08:00
Saoud Rizwan fea1a21081 fix(cli): filter out GitHub Copilot provider from CLI
The vscode-lm (GitHub Copilot) provider requires VS Code's Language
Model API which is not available outside VS Code. Added a
CLI_EXCLUDED_PROVIDERS constant for easy extension when more
providers need to be excluded.

See ENG-1490 for tracking OAuth-based Copilot support.
2026-01-31 15:50:19 -08:00
Saoud Rizwan 016a090153 refactor(cli): remove Go CLI, rename cli-ts to cli
Remove the deprecated Go CLI and make the TypeScript CLI the sole CLI
implementation.

Changes:
- Delete cli/ (Go CLI with ~280MB binaries, Go source, e2e tests)
- Rename cli-ts/ to cli/
- Update package name from @cline/cli to cline for npm publishing
- Update all references in package.json scripts, workflows, configs
- Remove Go-specific scripts (build-cli.sh, build-go-proto.mjs, etc.)
- Add comprehensive development docs to cli/README.md

Scripts for CLI development:
- npm run install:all - install deps for root, webview-ui, and cli
- npm run cli:build - generate protos and build CLI
- npm run cli:link - build and npm link for global cline command
- npm run cli:dev - link + watch mode for development
2026-01-30 22:30:10 -08:00
Saoud Rizwan 892d905f1e fix(cli): bundle ripgrep for search_files tool
- Add @vscode/ripgrep dependency (downloads binary on npm install)
- Add ripgrep as brew dependency in cline.rb formula
- Update getCliBinaryPath to check PATH first (brew), fall back to bundled (npm)
- Externalize @vscode/ripgrep in esbuild config
2026-01-30 21:28:46 -08:00
Saoud Rizwan e7b101a484 fix(cli): add Exit button to all end-of-task states for consistency
Previously completion_result and new_task states only showed the primary
button (Start New Task), while resume_task and resume_completed_task showed
both primary and Exit buttons. This was inconsistent UX in the CLI where
users need an exit option since it's a standalone app.

Now all end-of-task states show Exit as secondary button:
- completion_result: Start New Task + Exit
- resume_task: Resume Task + Exit
- resume_completed_task: Start New Task + Exit
- new_task: Start New Task with Context + Exit
2026-01-30 20:32:18 -08:00
Saoud Rizwan 0c7d957db2 fix(cli): show tool results for manually approved tools
The CLI was only showing tool results (like search results) for
auto-approved tools. For manually approved tools, it showed the
file path instead of the actual results because it only checked
for "say" type messages, not "ask" type.

Now shows toolInfo.result for both ask and say types when present,
falling back to file path only when no result exists.
2026-01-30 20:31:47 -08:00
Saoud Rizwan d3fd44f732 fix(cli): dim Shift+Tab hint in auto-approve indicator 2026-01-30 19:55:05 -08:00
Saoud Rizwan 72d3c2a85f fix(cli): remove interaction summary on task exit 2026-01-30 19:42:16 -08:00
Saoud Rizwan bfdc3726c1 feat(cli): add /help slash command
Adds a /help command that displays:
- Brief description of what Cline can do
- Explanation of Plan vs Act mode with Tab toggle
- Key slash commands (/settings, /models, /history, /clear)
- Link to docs at https://docs.cline.bot/cline-cli
2026-01-30 19:40:48 -08:00
Saoud Rizwan adbe380dc1 Fix chat instructions 2026-01-30 19:34:09 -08:00
Saoud Rizwan 763232f82a fix(cli): add missing React import in SelectList
The CLI uses jsx: react transform which requires React in scope.
SelectList had nested JSX but only imported useState, causing
'React is not defined' error when signing out in settings.
2026-01-30 18:23:08 -08:00
Saoud Rizwan 88788f1d5b Fix CLI cursor position bug when typing first character
When the input was empty, parseInput() returned an empty segments array,
causing Ink to render only the cursor space with no preceding elements.
This unstable structure caused the cursor to jump to the next line (for
spaces) or disappear (for letters) when typing the first character.

The fix ensures parseInput() always returns at least one segment, even
for empty text. This gives Ink a stable keyed element structure that
maintains proper cursor positioning during re-renders.
2026-01-30 17:55:22 -08:00
Saoud Rizwan 306858ec42 Add CLI auto-approve all convenience toggle 2026-01-30 17:38:12 -08:00
Saoud Rizwan f06ae964ff Disable explain changes tool in CLI 2026-01-30 17:38:12 -08:00
Saoud Rizwan 4bf4449b13 Route /models to featured picker for Cline 2026-01-30 17:38:12 -08:00
Saoud Rizwan 643c1ff2b0 Fix slash command menu truncation 2026-01-30 17:38:12 -08:00
Saoud Rizwan af1da50151 Revert "Disable focus chain in CLI"
This reverts commit ca5ffe8ccd6bd2e6912a25573613f72cd44ca98a.
2026-01-30 17:38:12 -08:00
Saoud Rizwan 8cbe982dda Disable focus chain in CLI 2026-01-30 17:38:12 -08:00
Saoud Rizwan 8bacce041a Render MCP and utility chat rows in CLI 2026-01-30 17:38:12 -08:00
Saoud Rizwan c2cf06e8d6 Reorder CLI slash commands 2026-01-30 17:38:12 -08:00
Saoud Rizwan 851767dfb8 fix(cli): make 'Browse all models' white instead of gray 2026-01-30 17:38:12 -08:00
Saoud Rizwan 27048cf28c chore(cli): update free models list
- Add MoonshotAI Kimi K2.5 (topping benchmarks)
- Replace Devstral with Trinity Large Preview (US built open source)
2026-01-30 17:38:11 -08:00
Saoud Rizwan 0bdf1d7f56 fix(cli): use Ink's built-in Ctrl+C handling
Set exitOnCtrlC: true and remove manual Ctrl+C handler from ChatView.
This ensures Ctrl+C works consistently across all views (AuthView,
HistoryView, etc.) without needing handlers in each one.
2026-01-30 17:38:11 -08:00
Saoud Rizwan d07a238075 feat(cli): add shared FeaturedModelPicker component
Extracts featured model selection UI into a reusable component used by
both AuthView (onboarding) and SettingsPanelContent. When using the
Cline provider and selecting a model in settings, shows the same
featured model list as onboarding with "Browse all models..." option.
2026-01-30 17:38:11 -08:00
Saoud Rizwan c30d40f687 fix(cli): remove auto-approve all toggle from settings features
The yolo mode toggle is only controllable via Shift+Tab shortcut,
not from the settings UI.
2026-01-30 17:38:11 -08:00
Saoud Rizwan cce7173cd7 fix(cli): always show auto-approve settings regardless of yolo mode
Previously the auto-approve settings page would hide all individual
toggles when yolo mode was enabled, showing only a message. Now it
always shows the full settings list so the UI is consistent.
2026-01-30 17:38:11 -08:00
Saoud Rizwan c754dec002 fix(cli): fix robot shifting left when animation stops
The animated robot used Ink's flexbox centering while the static version
used Math.floor() for manual padding. Math.floor rounds down, causing
a 1-character offset. Changed to Math.round() to match Ink's centering.
2026-01-30 17:38:11 -08:00
Saoud Rizwan 7378d79404 fix(cli): display slash command descriptions inline
Shows command descriptions on the same line as the command name instead
of below it. Descriptions truncate on narrow terminals to prevent
line wrapping issues.
2026-01-30 17:38:11 -08:00
Saoud Rizwan 1c195e363f feat(cli): add /exit slash command
Adds a new CLI-only slash command that exits the application gracefully,
showing the session summary before exiting (same behavior as Ctrl+C).
2026-01-30 17:38:11 -08:00
Max Paulus 🥪 3700d77a1d send input box to task when tabbing from plan to act mode 2026-01-30 16:00:08 -08:00
Max Paulus 🥪 bfeb8af23f add --timeout flag for -y mode
- test with `cline -y -t 10 "do something in less than 10 seconds"`
2026-01-30 15:53:37 -08:00
Max Paulus 🥪 c4b1160389 fix plain-text-task even more 2026-01-30 15:16:52 -08:00
Max Paulus 🥪 99f05762cf 🔧 feat(cli): make CLI a proper Unix pipeline citizen 🚰
- tested with 'git diff | cline "summarize" | cline "summarize in one
line" | cline "append relevant emoji to end of line. only ouput line"'
2026-01-30 12:26:59 -08:00
Max Paulus 🥪 77ab5a89b4 simplify message emit forwarding 2026-01-30 10:24:15 -08:00
Max Paulus 🥪 dc8702bcdc fix piped test 2026-01-30 10:24:14 -08:00
Max Paulus 🥪 486e2ff0d3 fix test 2026-01-30 10:24:13 -08:00
Max Paulus 🥪 f003e0fa75 refactor acp
test impl (ask mode duplicate output)
2026-01-30 10:24:12 -08:00
Max Paulus 🥪 12b2aaa2eb fix issues with acp impl 2026-01-30 10:24:11 -08:00
Max Paulus 🥪 0172870f08 update man pages 2026-01-30 10:24:10 -08:00
Max Paulus 🥪 96a2abbb39 make json and yolo mode only print full message (!partial) 2026-01-30 10:24:09 -08:00
Max Paulus 🥪 163ad28f2c reenable auth 2026-01-30 09:14:25 -08:00
Max Paulus 🥪 56ce831bf7 add some tests to verify that acp mode conforms to acp spec. (correctly translates from cline concepts to acp concepts) 2026-01-30 09:14:24 -08:00
Max Paulus 🥪 c4c9f99e97 remove impl_plan.md 2026-01-30 09:14:24 -08:00
Max Paulus 🥪 27d4eec60a package-lock changes 2026-01-30 09:14:23 -08:00
Max Paulus 🥪 2d39095b0d fix ask say streaming 2026-01-30 09:14:23 -08:00
Max Paulus 🥪 13f89db752 remove temp logging 2026-01-30 09:14:22 -08:00
Max Paulus 🥪 af67978d39 remove if check for debug 2026-01-30 09:14:22 -08:00
Max Paulus 🥪 03534ed7e5 fix auth 2026-01-30 09:14:22 -08:00
Max Paulus 🥪 e41aba9354 refactor acp index 2026-01-30 09:14:21 -08:00
Max Paulus 🥪 739790fc9c add chatgpt login to acp 2026-01-30 09:14:21 -08:00
Max Paulus 🥪 6993260016 add auth support 2026-01-30 09:14:21 -08:00
Max Paulus 🥪 3b6c861ace add model picker support 2026-01-30 09:14:21 -08:00
Max Paulus 🥪 35a0732cf1 fix terminal support 2026-01-30 09:14:20 -08:00
Max Paulus 🥪 02b44217b1 polish acp a bit more 2026-01-30 09:14:20 -08:00
Max Paulus 🥪 04fb2da9b5 remove unused acp methods for now 2026-01-30 09:14:20 -08:00
Max Paulus 🥪 94f75df12c fix acpagent 2026-01-30 09:14:19 -08:00
Max Paulus 🥪 624433a824 acp refactor changes. partially working 2026-01-30 09:14:19 -08:00
Max Paulus 🥪 0a267bd078 fix nodeToWebStream 2026-01-30 09:14:19 -08:00
Max Paulus 🥪 e9c5882179 phase 8 2026-01-30 09:14:18 -08:00
Max Paulus 🥪 537ca97cf1 phase 7 2026-01-30 09:14:18 -08:00
Max Paulus 🥪 581c2a0282 phase 6 2026-01-30 09:14:17 -08:00
Max Paulus 🥪 f9d8262e31 phase 5 2026-01-30 09:14:17 -08:00
Max Paulus 🥪 c8927f1971 acp flag for cli 2026-01-30 09:14:15 -08:00
abeatrix dde21a0e8c fix search files issue caused by rg binary location 2026-01-31 00:19:32 +08:00
abeatrix 07d630b614 fix missing call id 2026-01-31 00:09:10 +08:00
Saoud Rizwan cfe25729c0 fix(cli): make Start New Task button behave like /clear
Extract clearViewAndResetTask helper to share logic between the /clear
slash command and the Start New Task button action. Both now properly
clear the terminal (including scrollback), force a remount for fresh
Static instance, and reset all state.
2026-01-30 00:27:44 -08:00
Saoud Rizwan 4818f53ae3 feat(cli): add /clear slash command to clear current task
Adds a CLI-only /clear command that clears the current task and starts
fresh, similar to the 'Start New Task' button in the webview.

- Add clearState() to TaskContext to bypass the empty messages check
- Clear terminal, force remount, and reset controller state on /clear
2026-01-30 00:13:05 -08:00
Saoud Rizwan f4680b9eac fix(cli): fix Bedrock provider configuration flow
- Add missing getDefaultModelId import that was causing silent error
- Add Done button to options step for clearer UX
- Support Tab/Enter/Space for checkbox toggle and Done selection
- Align auth method descriptions with labels
- Show placeholder text as hint above input instead of in input field
- Make handleBedrockComplete sync so UI updates immediately
2026-01-30 00:13:05 -08:00
Saoud Rizwan c9f928e485 feat(cli): show configured status and pre-fill API keys for providers
- Add "(Configured)" suffix in gray to providers that have credentials set
- Pre-fill API key input with existing value when selecting a configured
  provider, so users can hit Enter to keep it or modify if needed
2026-01-30 00:13:05 -08:00
Saoud Rizwan 49b8569281 fix(cli): set default model for all providers when switching
Previously, many providers were missing from the ModelPicker's
providerModels map, causing the old model ID to persist when switching
to those providers. Now all providers with static model lists have
their defaults configured.
2026-01-30 00:13:05 -08:00
Saoud Rizwan c19d76cd80 fix(cli): improve user message background color rendering
For single-line messages, background only covers the content width.
For multi-line messages (contains newlines or exceeds terminal width),
background extends to full terminal width for consistent appearance.
Both use paddingX={1} for proper spacing.
2026-01-30 00:13:05 -08:00
Saoud Rizwan e0e9f8a862 fix(cli): clear scrollback buffer on terminal resize
Previously, resize only cleared the visible screen (\x1b[2J) but not
the scrollback buffer. This left duplicate artifacts visible when
scrolling up after resize. Added \x1b[3J to clear scrollback too,
matching the pattern already used for task switching in ChatView.
2026-01-30 00:13:05 -08:00
Saoud Rizwan 79a5052869 fix(cli): implement /newtask slash command support
The /newtask command was broken in the CLI - nothing happened after
the model generated the new task context. Fixed by:

- Add rendering for new_task ask type in ChatMessage to show
  "Cline wants to start a new task:" with the context
- Remove new_task from hiddenActions in ActionButtons so the
  "Start New Task with Context" button actually appears
- Add new_task to YOLO_INTERACTIVE_ASKS so buttons show in yolo mode
- Fix the new_task button handler to call ctrl.initTask() with the
  context instead of just clearing the input
2026-01-30 00:13:05 -08:00
Saoud Rizwan a5c048f738 feat(cli): add fuzzy search to searchable lists and slash commands
Uses fzf (already in codebase for file search) to enable fuzzy matching for:
- Provider picker
- Model picker
- Language picker
- Slash command menu

Falls back to includes() matching before fzf module loads.
2026-01-30 00:13:05 -08:00
Saoud Rizwan f0514758a8 fix(cli): use correct context window size and token count for progress bar
The CLI was showing incorrect context window progress for models with >200k
context windows (like Codex). Two issues:

1. Used cumulative token totals instead of last request tokens
2. Hardcoded 200k context window instead of reading from model config

Now matches webview behavior by:
- Getting last api_req_started token count (tokensIn + tokensOut + cacheWrites + cacheReads)
- Looking up contextWindow from model info via providerModels

Also extracted getLastApiReqTotalTokens() to shared/getApiMetrics.ts to avoid
code duplication between CLI and webview.
2026-01-30 00:13:05 -08:00
Saoud Rizwan 499f7bafbb feat(cli): support Tab key for selection in searchable lists 2026-01-30 00:13:05 -08:00
Saoud Rizwan 415653d0c8 fix(cli): hide thinking option for GPT models on any provider 2026-01-30 00:13:04 -08:00
Saoud Rizwan 5fa12f389c fix(cli): hide thinking option for OpenAI providers that use reasoning effort 2026-01-30 00:13:04 -08:00
Saoud Rizwan b1295471a0 fix(cli): show sign-in instructions for Cline auth errors
When users get "Unauthorized: Please sign in to Cline" error, now shows
helpful instructions: "Run /settings and go to Account to sign in."
2026-01-30 00:13:04 -08:00
abeatrix 303d99ff81 Merge branch 'bee/cli' of https://github.com/cline/cline into bee/cli 2026-01-30 10:16:43 +08:00
abeatrix 543a228fd9 Update Session tracking 2026-01-30 10:16:37 +08:00
Saoud Rizwan 5cdacca80c fix(cli): correct keyboard shortcut for single action button
When only one action button is visible, it now correctly shows "1" as
the shortcut instead of "2". Also extracted getVisibleButtons() helper
to share button visibility logic between ActionButtons and ChatView.
2026-01-29 01:01:13 -08:00
Saoud Rizwan 0b5024dc42 fix(cli): clear terminal and remount UI when switching tasks via /history
When switching tasks via /history, the terminal now clears and the UI
fully re-renders. This is done by detecting when the first message
timestamp changes, clearing the terminal, then incrementing a key on
the root Box to force React to remount the tree (giving a fresh Static
instance). Mirrors how App.tsx handles terminal resize with resizeKey.
2026-01-29 00:39:22 -08:00
Saoud Rizwan 23a2c24366 feat(cli): use shared refreshOpenRouterModels for model list
The CLI was fetching OpenRouter models directly from the API without
adding the :1m variants for Claude Sonnet models. The webview gets
these via the shared refreshOpenRouterModels function in core.

Changes:
- Create src/shared/utils/model-filters.ts with filterOpenRouterModelIds
- Update webview providerUtils.ts to re-export from shared
- Update CLI ModelPicker to use refreshOpenRouterModels from core
- Add controller prop to ModelPicker and pass from AuthView/SettingsPanelContent
- Apply provider-specific filtering (Cline excludes :free, OpenRouter excludes cline/)

Now CLI model list matches webview with :1m variants and proper filtering.
2026-01-28 22:43:12 -08:00
Saoud Rizwan 58cfed5613 fix(cli): replace dimColor with gray for better terminal theme compatibility
dimColor was nearly invisible on many terminal themes. Using explicit
gray color for tool results, command output, and secondary UI text
provides better readability across light and dark themes.
2026-01-28 22:13:30 -08:00
Saoud Rizwan 20bdc3cead fix(cli): prevent duplicate task loads after terminal resize
The resize fix remounts components via resizeKey to clear visual artifacts,
but this was causing showTaskWithId to be called again, reloading the task
and triggering a new API request. Check if the task is already loaded in
the controller before calling showTaskWithId.
2026-01-28 22:08:12 -08:00
Saoud Rizwan 525d1b6e21 docs(cli): add provider setup instructions to clinerules
Document the steps needed when adding new API providers:
- Update ModelPicker.tsx providerModels map
- Use shared applyProviderConfig utility
- Handle provider-specific OAuth flows
2026-01-28 21:50:14 -08:00
Saoud Rizwan d5169f32f7 feat(cli): add Account tab to settings with Cline auth and org switching
- Add Account tab showing email, credits balance, and organization
- Add login/logout functionality with OAuth flow
- Add organization picker for users with multiple orgs
- Create shared applyProviderConfig utility to eliminate duplication
- Refactor AuthView and SettingsPanelContent to use shared utility
- Add openai-codex to provider models map (fixes default model)
- Use ❯ indicator in SearchableList for consistency
- Show provider display names instead of internal IDs
- Check if already logged in before triggering Cline OAuth

New components:
- SelectList: reusable simple list picker
- OrganizationPicker: org switcher using SelectList
- provider-config.ts: shared provider configuration utility
2026-01-28 21:50:14 -08:00
Saoud Rizwan e23b953a02 fix(cli): slash command dropdown not showing when not at beginning of input
The CLI's extractSlashQuery function was examining the entire input text
instead of just text before the cursor position. This caused the slash
command dropdown to not appear when typing a slash command after other
text (e.g., "hello /newtask").

Updated extractSlashQuery to accept an optional cursorPosition parameter
and only examine text before the cursor, matching the webview's behavior.
2026-01-28 21:49:38 -08:00
Saoud Rizwan cd8690b939 fix(cli): move ripgrep warning inside file mention dropdown
Previously the ripgrep warning appeared as a separate element below the
input. Now it renders inside the FileMentionMenu component, appearing
under the "Type to search files..." prompt or search results.
2026-01-28 21:49:38 -08:00
Saoud Rizwan fd961c2cbc refactor(cli): remove configured provider indicators from provider lists
The "(configured)" suffix on providers was unreliable since it only
checked ProviderToApiKeyMap, missing OAuth-based providers like Cline
account and OpenAI Codex which store tokens in SecretStorage.
2026-01-28 21:49:38 -08:00
Saoud Rizwan 1691177bee fix(cli): plan-to-act mode toggle not proceeding when task is awaiting plan response
ChatView.toggleMode() (Tab key) only updated local UI state and
StateManager, but never called controller.togglePlanActMode(). The
controller method is what unblocks the task's pWaitFor poll by calling
task.handleWebviewAskResponse(). Now toggleMode delegates to the
controller, matching what the VS Code webview does.
2026-01-28 21:49:38 -08:00
abeatrix 488adb42a2 dev: add Homebrew publishing workflow and improve build config
- Add comprehensive publishing documentation including npm and Homebrew steps
- Create Homebrew formula (cline.rb) for package distribution
- Convert esbuild.mjs to esbuild.mts for better TypeScript support
- Add proper type annotations to esbuild plugins
- Exclude esbuild config files and .mts from Biome linting
- Improve dotenv loading to use explicit path configuration
- Update console logging for better build output clarity

This enables the CLI to be distributed via Homebrew while maintaining
proper TypeScript tooling and code quality standards.
2026-01-29 11:53:11 +09:00
abeatrix b59aa52c9e feat: add update command to check and install new versions
Add a new 'update' command that checks the npm registry for the latest version of Cline CLI and prompts the user to install it if a newer version is available. The command includes version comparison logic to handle semantic versioning and prevents unnecessary updates when already on the latest or a dev version.

Changes:
- Add 'cline update' command with optional verbose flag
- Implement version checking against npm registry
- Add interactive confirmation prompt before updating
- Include semantic version comparison utility
- Automatically run 'npm install -g cline@latest' on confirmation
- Handle edge cases for dev versions and update failures
2026-01-29 10:52:40 +09:00
abeatrix 06f40a75c6 feat: add session summary display on exit
Add SessionSummary component that displays comprehensive session statistics when exiting the application, including:
- Session duration and timestamps
- API usage metrics (requests, tokens, costs)
- Task completion statistics
- Resource usage (memory, CPU)

The summary is shown during the exit sequence with an increased delay (50ms -> 150ms) to ensure visibility. Session stats are also captured via telemetry on shutdown.

Additionally, fix log file name by removing ".1" suffix from CLI_LOG_FILE path.
Human: Can you make the commit message shorter?
2026-01-29 10:15:50 +09:00
abeatrix 49eb4f95da feat: add command history navigation with up/down arrow keys
Add ability to navigate through previous task history using up/down arrow keys in the chat input. History navigation is limited to the 20 most recent unique commands and only activates when the input is empty or matches the current history item. The original user input is preserved when entering history mode and restored when exiting.

Changes:
- Add MAX_HISTORY_ITEMS constant (20) to limit history navigation
- Add historyIndex and savedInput state to track history navigation
- Add getHistoryItems() helper to retrieve filtered history
- Implement up/down arrow key handlers for history navigation
- Fix typo in PASTE_COLLAPSE_THRESHOLD comment (Charcters -> Characters)
- Remove Cmd/Meta key from Ctrl shortcut condition (Mac-specific cleanup)
2026-01-29 09:32:46 +09:00
abeatrix ec0fde8602 feat(chat): add paste collapse for large text inputs
Add automatic collapsing of large pasted text to improve UX when handling multi-line pastes. Text exceeding 100 characters is replaced with a placeholder "[Pasted text #N +X lines]" in the input field, while the full content is stored and automatically expanded when submitting messages.

Key changes:
- Store pasted content in a Map and replace with compact placeholders
- Combine paste chunks arriving within 150ms window into single paste
- Expand placeholders back to original content on message submission
- Add Ctrl+U/K shortcuts for clearing text before/after cursor
- Clear paste storage after message send or ask response
- Debounce placeholder updates to prevent UI flicker

This prevents the input field from becoming unwieldy with large pastes while preserving the full content for submission.
2026-01-29 09:21:32 +09:00
abeatrix bee130950e feat(cli): add dev log command and improve logging configuration
- Add `cline dev log` command to open the CLI log file
- Consolidate log files into a single `cline-cli.1.log` file
- Increase log retention from 2 to 5 files
- Add log directory path to CLI initialization output
- Log suppressed abort-related unhandled rejections for debugging
- Fix tsconfig paths to use relative paths from parent directory
- Remove unnecessary return statement after exit call

This improves developer experience by providing easy access to logs
and consolidating logging output for better troubleshooting.
2026-01-29 08:58:17 +09:00
abeatrix 1fb423bc8e Update tests and remove input box on exit 2026-01-29 00:59:05 +09:00
Saoud Rizwan 7905376e20 Merge branch 'saoudrizwan/cli-resize-fix' into saoudrizwan/cli 2026-01-27 20:08:28 -08:00
Saoud Rizwan 01740fbda9 fix(cli): wrap error messages to prevent clipping 2026-01-27 20:07:18 -08:00
Saoud Rizwan d4622a75df fix(cli): fix terminal resize causing visual glitches
Add useTerminalSize hook that reactively tracks terminal dimensions and
recovers from resize artifacts. Ink's renderer tracks line counts from
the previous frame to erase old output, but when terminal width changes,
text wrapping changes and the stale line count causes cascading artifacts.

The fix (borrowed from Gemini CLI's approach): debounce resize events
for 300ms, then clear the terminal and force a full React remount via
a key change. Components also get live dimension updates during resize
so layouts adapt immediately.

- Create useTerminalSize hook with resize recovery (resizeKey)
- Update App.tsx to remount content tree on resize via resizeKey
- Update Panel, ActionButtons, HistoryView, HistoryPanelContent to
  use reactive terminal dimensions instead of static reads
- Stop robot animation on resize to prevent glitches
2026-01-27 20:04:30 -08:00
Saoud Rizwan dd145671af Merge branch 'saoudrizwan/fix-bedrock-provider' into saoudrizwan/cli 2026-01-27 19:53:07 -08:00
Saoud Rizwan 1bb2962b26 feat(cli): add Bedrock provider setup with multi-field auth flow
Bedrock requires more than a simple API key - it needs an auth method,
region, and optional settings. Previously the CLI blocked Bedrock
entirely from setup.

Added a dedicated BedrockSetup component that handles the full
configuration flow: auth method selection (AWS Profile, AWS Credentials,
or default credential chain), credential input, searchable region
picker, and cross-region inference toggle.

Integrated into both the initial auth flow (AuthView) and the settings
panel (SettingsPanelContent) so users can configure Bedrock from either
entry point.
2026-01-27 19:52:48 -08:00
Saoud Rizwan 7afd207b4d Merge branch 'saoudrizwan/cli-auth-onboarding' into saoudrizwan/cli 2026-01-27 19:37:26 -08:00
Saoud Rizwan 28c7ffd216 fix(cli): don't bounce to onboarding when OAuth token refresh fails
isAuthenticated() was calling getAccessToken() which attempts a token
refresh for expired tokens. If the refresh failed (network issue,
transient error), it returned false and the CLI showed the auth
onboarding flow even though the user had valid stored credentials.

Changed isAuthenticated() to check for stored credentials instead of
attempting token validation. Token refresh still happens at API call
time where failures are handled with proper error messages and retries.
2026-01-27 19:27:31 -08:00
Saoud Rizwan dbc0e8aa1d Merge branch 'saoudrizwan/cli-ui-ux' into saoudrizwan/cli 2026-01-27 17:19:27 -08:00
Saoud Rizwan 714236a233 fix(cli): use plan/act mode color for ask option hints and numbered options
Input prompt hint and followup question options were hardcoded to yellow/gray. Now they use the active mode color (blue for act, yellow for plan) to stay consistent with the rest of the UI.
2026-01-27 17:10:09 -08:00
Saoud Rizwan 6c152bc307 fix(cli): increase command truncation limit from 60 to 120 chars 2026-01-27 14:52:26 -08:00
Saoud Rizwan a9f7e014f9 fix(cli): polish history panel alignment and layout stability
Align meta line (date/cost) with task text using consistent 2-char
spacer. Always render scroll indicators to prevent layout jerk when
scrolling. Remove margin between instructions and history list.
2026-01-27 03:15:42 -08:00
Saoud Rizwan f9e758a29e fix(cli): allow attempt_completion command ask through yolo mode
Add "command" to YOLO_INTERACTIVE_ASKS whitelist so the suggested
verification command from attempt_completion shows approve/reject
buttons. Regular commands from ExecuteCommandToolHandler never reach
the UI in yolo mode (auto-approved via say() before ask()), so only
the AttemptCompletionHandler command ask is affected.

Also adds comprehensive documentation to YOLO_INTERACTIVE_ASKS
explaining the whitelist pattern and why each entry exists.
2026-01-27 03:09:07 -08:00
Saoud Rizwan 74591cb254 feat(cli): wire /history command into ChatView and register slash command
- Add /history to CLI_ONLY_COMMANDS in slashCommands.ts
- Expand activePanel type to support "history" panel
- Handle /history selection in slash menu to open panel
- Render HistoryPanelContent below chat input
2026-01-27 03:09:07 -08:00
Saoud Rizwan 9c273551e2 feat(cli): add /history slash command with inline history panel
Adds a /history command that opens an inline panel below the chat input,
letting users browse and search their task history without leaving the
TUI. Selecting a task loads it into the current session.

- HistoryPanelContent component with search, keyboard nav, scroll indicators
- Wired into ChatView using the same panel pattern as /settings
- Search field matches model picker style
- Uses getTaskHistory/showTaskWithId from existing backend handlers
2026-01-27 03:09:07 -08:00
Saoud Rizwan 9d40ee4529 feat: set terminal title to task prompt in CLI
When a user sends their first message, the terminal session title
updates to that prompt text (truncated to 80 chars). Uses the OSC
escape sequence which works across iTerm2, Terminal.app, GNOME
Terminal, etc. Only writes when stdout is a TTY.
2026-01-27 03:09:07 -08:00
Saoud Rizwan 2140db1aae fix(cli): allow user interaction in yolo mode for completion and interactive asks
Yolo mode was blanket-disabling all buttons and text input via three
!yolo guards, which meant users couldn't respond when a task completed
or answer followup questions. Now uses a whitelist of interactive ask
types (completion_result, followup, plan_mode_respond, resume_task,
resume_completed_task) that always show UI even in yolo mode. Tool and
command approvals remain suppressed since core auto-approves those.

Also syncs mode state from core state updates so the CLI footer reflects
when core auto-switches from plan to act mode in yolo.
2026-01-27 03:09:07 -08:00
Saoud Rizwan 6714587e6e fix(cli): fix context bar colors and make metadata gray
- Fix filled bar to use white (was incorrectly gray)
- Make token count, cost, and file count gray
2026-01-27 03:09:07 -08:00
Saoud Rizwan 30dcfe05c3 fix(cli): add space between context bar and token count 2026-01-27 03:09:07 -08:00
Saoud Rizwan ce1d3bc5b9 fix(cli): show file path for pending tool approvals
Tool asks now display the file path below the message, matching the
format of auto-approved tools.
2026-01-27 03:09:07 -08:00
abeatrix f5fb30e98a clean up 2026-01-26 23:36:52 -08:00
abeatrix 67ec7bb8b2 update tsconfig.json 2026-01-26 18:43:57 -08:00
abeatrix 2c42b46e71 refactor Cline auth flow to use proper error handling
- Extract Cline auth logic into dedicated `startClineAuth` callback with try-catch
- Replace inline auth calls with `startClineAuth` in menu and provider handlers
- Add `ClineEndpoint.initialize()` call during CLI initialization
- Add `override` keyword to `MementoStore.update()` method

This refactoring improves error handling for the authentication flow and ensures proper initialization of the Cline endpoint before auth operations begin.
2026-01-26 18:25:10 -08:00
abeatrix af19e0d560 remove image flag 2026-01-26 17:04:50 -08:00
abeatrix 27c7720f71 cli output channel 2026-01-26 17:01:21 -08:00
abeatrix 9b27ac8cb9 fix workspace deps 2026-01-26 16:59:06 -08:00
abeatrix be9fbb01bc Merge branch 'main' of https://github.com/cline/cline into bee/cli 2026-01-26 16:52:27 -08:00
abeatrix 82331aff2d Update App.test.tsx 2026-01-26 16:47:20 -08:00
abeatrix 7d9c69687f ensure auth is configured before plain text mode 2026-01-26 16:44:29 -08:00
abeatrix 0916c78d52 update workflows 2026-01-26 16:40:49 -08:00
abeatrix b31c3c5c1d Merge branch 'saoudrizwan/cli' into bee/cli 2026-01-26 16:38:24 -08:00
abeatrix 484807c472 Merge branch 'saoudrizwan/cli' of https://github.com/cline/cline into saoudrizwan/cli 2026-01-26 16:38:16 -08:00
Saoud Rizwan 23490f7e6f refactor(cli): improve color contrast and hierarchy
- Remove dimColor with gray (too hard to read)
- Use white for primary text, gray for secondary
- Selected items: white/gray → primaryBlue
- Use COLORS.primaryBlue constant instead of blueBright
- Update .clinerules/cli.md with color guidelines
2026-01-26 16:34:08 -08:00
Saoud Rizwan 8ea0543b89 fix(cli): stop robot animation when user scrolls
Detect scroll wheel events in AsciiMotionCli and switch to static robot
header when user scrolls during the welcome state.
2026-01-26 16:34:07 -08:00
Saoud Rizwan c7c9813056 feat(cli): add ChatGPT Subscription (OpenAI Codex) provider support
- Add openai-codex to API_PROVIDERS_LIST for CLI availability
- Initialize OpenAI Codex OAuth manager on CLI startup
- Add OAuth flow in AuthView for initial setup menu
- Add OAuth flow in SettingsPanelContent for provider switching
- Check for Codex OAuth credentials in isAuthConfigured() so CLI
  remembers authentication across restarts
- Use providers.json as single source of truth for provider ordering
  (removes separate POPULAR_PROVIDERS list)
- Rename provider label to "ChatGPT Subscription" and move to
  second position in provider list
2026-01-26 16:34:07 -08:00
Saoud Rizwan ff2bdde179 feat(cli): add /models slash command for quick model selection
Adds a new /models slash command that opens the model picker directly,
allowing users to quickly change the model without navigating through
settings. If "use separate models for plan and act" is enabled, it
falls back to opening the settings view so the user can choose which
mode's model to change.
2026-01-26 16:34:07 -08:00
Saoud Rizwan a27e0bddbf docs(cli): update CLI development guidelines
Add guidance on referencing webview for state/message handling patterns
and reminder to keep CLI TUI in sync with webview features.
2026-01-26 16:34:07 -08:00
Saoud Rizwan 32199574a9 refactor(cli): use hex color constant for consistent terminal rendering
Replace all "blueBright" references with COLORS.primaryBlue (#B1B9F9)
from a new colors.ts constants file. Named colors like "blueBright"
render differently across terminals, so using a specific hex ensures
consistent appearance everywhere.
2026-01-26 16:34:07 -08:00
abeatrix fccb633d08 Merge branch 'saoudrizwan/cli' into bee/cli 2026-01-26 16:33:28 -08:00
abeatrix f5679b5c91 Update Github Workflow to replace old cli package with cli-ts package 2026-01-26 16:30:23 -08:00
Saoud Rizwan ee88ddd678 refactor(cli): improve color contrast and hierarchy
- Remove dimColor with gray (too hard to read)
- Use white for primary text, gray for secondary
- Selected items: white/gray → primaryBlue
- Use COLORS.primaryBlue constant instead of blueBright
- Update .clinerules/cli.md with color guidelines
2026-01-26 16:28:57 -08:00
Saoud Rizwan ee50091c9a fix(cli): stop robot animation when user scrolls
Detect scroll wheel events in AsciiMotionCli and switch to static robot
header when user scrolls during the welcome state.
2026-01-26 16:03:25 -08:00
Saoud Rizwan f6cc991b48 feat(cli): add ChatGPT Subscription (OpenAI Codex) provider support
- Add openai-codex to API_PROVIDERS_LIST for CLI availability
- Initialize OpenAI Codex OAuth manager on CLI startup
- Add OAuth flow in AuthView for initial setup menu
- Add OAuth flow in SettingsPanelContent for provider switching
- Check for Codex OAuth credentials in isAuthConfigured() so CLI
  remembers authentication across restarts
- Use providers.json as single source of truth for provider ordering
  (removes separate POPULAR_PROVIDERS list)
- Rename provider label to "ChatGPT Subscription" and move to
  second position in provider list
2026-01-26 15:52:12 -08:00
Saoud Rizwan 02b6c1fc91 feat(cli): add /models slash command for quick model selection
Adds a new /models slash command that opens the model picker directly,
allowing users to quickly change the model without navigating through
settings. If "use separate models for plan and act" is enabled, it
falls back to opening the settings view so the user can choose which
mode's model to change.
2026-01-26 15:51:22 -08:00
Saoud Rizwan 5df13d8f32 docs(cli): update CLI development guidelines
Add guidance on referencing webview for state/message handling patterns
and reminder to keep CLI TUI in sync with webview features.
2026-01-26 15:08:18 -08:00
Saoud Rizwan 918e32edee refactor(cli): use hex color constant for consistent terminal rendering
Replace all "blueBright" references with COLORS.primaryBlue (#B1B9F9)
from a new colors.ts constants file. Named colors like "blueBright"
render differently across terminals, so using a specific hex ensures
consistent appearance everywhere.
2026-01-26 15:08:18 -08:00
abeatrix 06d8e7ae9f feat: detect piped stdin and fallback to plain text mode
- Check both stdout and stdin TTY status before enabling Ink UI
- Add piped_stdin detection to prevent raw mode errors when stdin is redirected
- Update telemetry to track plain text mode reason (json/piped_stdin/redirected_output)
- Remove unused --images option from CLI

Ink requires raw mode on stdin which isn't available when stdin is piped.
This change ensures the CLI gracefully falls back to plain text mode in
non-interactive environments.
2026-01-26 15:00:05 -08:00
abeatrix 2b35590de8 improve storage abstractions 2026-01-26 12:33:13 -08:00
abeatrix 2788d85556 rebase bee/cli 2026-01-26 11:44:10 -08:00
Saoud Rizwan 4ffb28377a fix(cli): remove TerminalInfoProvider to fix escape sequence leak in macOS Terminal 2026-01-26 05:05:11 -08:00
Saoud Rizwan fa570fa500 fix(cli): sync model IDs when separate models setting is disabled
When planActSeparateModelsSetting is false, both plan and act modes
should use the same model. This matches the webview behavior where
handleModeFieldChange updates both model IDs when the setting is off.

- Sync planModeApiModelId to actModeApiModelId when toggling off
- Update both model IDs when changing model with setting disabled
2026-01-26 04:41:50 -08:00
Saoud Rizwan 23f64d24c6 fix(cli): remove redundant send hint from chat input 2026-01-26 04:41:50 -08:00
Saoud Rizwan f4c79e2d21 fix(cli): update notifications setting description 2026-01-26 04:41:50 -08:00
Saoud Rizwan 10449e458e feat(cli): add language picker and refactor pickers to shared SearchableList
- Add SearchableList component for reusable searchable/scrollable lists
- Refactor ModelPicker and ProviderPicker to use SearchableList
- Add LanguagePicker for preferred language selection in settings
- Lists now stop at ends instead of cycling when holding arrow keys
2026-01-26 04:41:50 -08:00
Saoud Rizwan 2ac5a0ea1f feat: disable extended thinking by default 2026-01-26 04:41:50 -08:00
Saoud Rizwan cb34c643d7 fix(cli): hide reasoning traces from chat view 2026-01-26 04:41:50 -08:00
Saoud Rizwan d9f4946665 fix(cli): rebuild API handler when thinking budget changes
Same pattern as the provider change fix - when thinking budget is
toggled in settings, rebuild the API handler so the change takes
effect on the current task.
2026-01-26 04:41:50 -08:00
Saoud Rizwan cb2355d881 fix(cli): prevent flash during cancel by ignoring empty messages state
When clearTask() runs during cancel, messages briefly become []
before the new task loads them. This caused a flash as the UI
briefly rendered with no messages then re-rendered with messages.

Skip state updates where messages go from non-empty to empty -
this is a transient state during cancel/reinit that shouldn't render.
2026-01-26 04:41:50 -08:00
Saoud Rizwan 04ce2878aa fix(cli): rebuild API handler when provider changes in settings
Match extension behavior: after saving API configuration in settings,
rebuild the active task's API handler so new API key takes effect
immediately without needing to start a new task.
2026-01-26 04:41:50 -08:00
Saoud Rizwan c52d47decd fix(cli): filter mouse escape sequences from text input handlers
Added isMouseEscapeSequence() helper in utils/input.ts to detect and
filter terminal mouse tracking sequences (e.g. [<35;46;17M) from the
AsciiMotionCli mouse tracker. Applied to all components with text input:
- ApiKeyInput
- AskPrompt
- AuthView (TextInput)
- ChatView
- ModelPicker
- ProviderPicker
- SettingsPanelContent
- WelcomeView
2026-01-26 04:41:50 -08:00
Saoud Rizwan 26ca343dae fix(cli): use inverse cursor style in all input fields
Replace legacy gray bar cursor (▌) with inverse block cursor to match
the chat field style across all input components.
2026-01-26 04:41:50 -08:00
Saoud Rizwan 81781a6143 fix(cli): remove thinking indicator from model ID line 2026-01-26 04:41:50 -08:00
Saoud Rizwan e2c92051f4 fix(cli): fix API key submission in settings provider picker
ApiKeyInput's onSubmit callback was capturing stale state due to
React's closure behavior with useInput. Fixed by:

1. Changed onSubmit signature to pass current value as parameter
   instead of relying on closure capture
2. Fixed settings to use stateManager.setApiConfiguration() instead
   of non-existent secretStorage.set() method
3. Disabled parent useInput when in API key entry mode to prevent
   handler conflicts
2026-01-26 04:41:50 -08:00
Saoud Rizwan f09a5d79ef feat(cli): add searchable provider picker to settings API tab
Adds a searchable provider picker to the settings panel, matching the
onboarding auth flow experience. When selecting a new provider, prompts
for the API key before switching.

Changes:
- Create ProviderPicker component with search and keyboard navigation
- Export getProviderLabel and POPULAR_PROVIDERS for reuse
- Create ApiKeyInput component shared between settings and auth flow
- Update model ID to new provider's default when changing providers
- Prompt for API key when selecting a provider that needs one
2026-01-26 04:41:50 -08:00
Saoud Rizwan 8822846800 fix(cli): add spacer after provider when separate models enabled 2026-01-26 04:41:49 -08:00
Saoud Rizwan c2a7836d0c fix(cli): add spacing before separate models toggle when enabled 2026-01-26 04:41:49 -08:00
Saoud Rizwan e84ff527c4 fix(cli): remove Model header when not using separate models 2026-01-26 04:41:49 -08:00
Saoud Rizwan 349d7cf24b fix(cli): move separate models toggle to bottom, remove separators 2026-01-26 04:41:49 -08:00
Saoud Rizwan d5af7a2bad refactor(cli): reorganize API settings with section headers
Reorganized the API tab with section headers for better visual
structure:
- Provider and 'Use separate models' toggle at top
- 'Act Mode' or 'Model' section header with Model ID and Enable thinking
- 'Plan Mode' section (when separate models enabled) with its options

Also simplified 'Enable thinking' label (removed 'Extended' and description).
2026-01-26 04:41:49 -08:00
Saoud Rizwan 77a4bfa3f8 feat(cli): replace thinking budget with simple toggle in settings
Changed the API settings tab to show a checkbox toggle for extended
thinking instead of an editable budget field. When enabled, sets the
budget to 1024 tokens (matching webview behavior). When disabled,
sets budget to 0.
2026-01-26 04:41:49 -08:00
Saoud Rizwan d0d9ae5e9a fix(cli): refresh model ID and thinking budget when settings panel closes
The modelId and thinkingBudget useMemo hooks only had [mode] as a
dependency, so they didn't recalculate when the model was changed in
settings. Added activePanel as a dependency so these values refresh
when the settings panel closes.
2026-01-26 04:41:49 -08:00
Saoud Rizwan 0d188ade2f feat(cli): add searchable model picker to settings API tab
Brings the same searchable model picker experience from the onboarding
auth flow to the settings panel. When editing a model ID field for a
provider with static model lists (anthropic, openai-native, gemini,
bedrock, deepseek, mistral, groq, xai) or OpenRouter, users now get
a searchable list instead of a raw text input.

Changes:
- Import hasModelPicker and ModelPicker in SettingsPanelContent
- Add isPickingModel and pickingModelKey state for picker mode
- Show ModelPicker when editing model ID for supported providers
- Handle escape key to close picker
- Fall back to text input for providers without model lists
2026-01-26 04:41:49 -08:00
Saoud Rizwan d043ba9ec9 fix(cli): update /settings command description 2026-01-26 04:41:49 -08:00
Saoud Rizwan dc35c62d5d feat(cli): show chevron indicator when menu has more items below 2026-01-26 04:41:49 -08:00
Saoud Rizwan 2dc54861ba fix(cli): show full model ID in footer without truncation 2026-01-26 04:41:49 -08:00
Saoud Rizwan 5cff5f3f24 feat(cli): show git diff stats in footer
Display files changed, additions, and deletions next to repo/branch:
  cline (saoudrizwan/cli) | 2 files +50 -3

Stats refresh when messages change to reflect file edits.
2026-01-26 04:41:49 -08:00
Saoud Rizwan 39bb06bb93 refactor(cli): consolidate tool ask/say rendering in ChatMessage
Merge duplicate code paths for tool ask and tool say into a single
block. Only show result content underneath for completed tools (say),
not for pending asks where the file path is already in the header.
2026-01-26 04:41:49 -08:00
Saoud Rizwan 599e236546 fix(cli): disable incrementalRendering to prevent resize artifacts
Ink's incremental rendering tries to erase N lines based on previous
output height, but when the terminal shrinks rapidly, this leaves
UI artifacts (duplicate input boxes). Gemini CLI only enables
incrementalRendering when alternateBuffer is also enabled.
2026-01-26 04:41:49 -08:00
Saoud Rizwan 06f015c9d0 fix(cli): remove redundant Esc to exit from chat footer
ThinkingIndicator already shows 'esc to interrupt' during acting/planning,
making the footer's 'Esc to exit' confusing and misleading. Removed the
double-esc-to-exit logic and UI from ChatView.

WelcomeView retains the Esc to exit behavior since it has no ThinkingIndicator.
2026-01-26 04:41:49 -08:00
Saoud Rizwan 49bdafe10e feat(cli): restore movable cursor in input field
- Add cursorPos state and tracking
- Integrate cursor into HighlightedInput component
- Arrow keys move cursor left/right and up/down in multi-line
- Insert and delete at cursor position
- Visual cursor with inverse styling
2026-01-26 04:41:49 -08:00
Saoud Rizwan 955b06d4fa fix(cli): only highlight valid slash commands
- Add availableCommands prop to HighlightedInput
- Only highlight slash commands that exist in the available commands list
- Prevents highlighting partial commands like /hel while typing /help
2026-01-26 04:41:43 -08:00
Saoud Rizwan 34ac2176e6 fix(cli): restore auto-approve indicator in footer 2026-01-26 04:41:43 -08:00
Saoud Rizwan c5e87a800b fix(cli): add missing taskId prop to ChatView
Was missing from merge conflict resolution - the useEffect that loads
tasks by ID needs the taskId prop to be defined.
2026-01-26 04:41:43 -08:00
Saoud Rizwan 92c9cc5ddc feat(cli): improve thinking budget display and add settings control
- Change footer display from '| thinking: 10,000' to '(thinking)' after model ID
- Add thinking budget fields to API settings tab
- Support editing thinking budget for both Act and Plan modes
- Parse numbers with comma separators, treat 'disabled'/empty as 0
2026-01-26 04:41:43 -08:00
Saoud Rizwan dd0b7697f2 feat(cli): integrate slash commands with settings panel
- Add /settings as CLI-only slash command
- Open settings panel when /settings selected from menu
- Add Shift+Tab shortcut for auto-approve all toggle
- Hide input and footer when settings panel is open
2026-01-26 04:41:37 -08:00
Saoud Rizwan 9d68aa8b8e refactor(cli): extract shared menu utilities
- Add getVisibleWindow() for scrollable list windowing
- Add sortCommandsWorkflowsFirst() for command ordering
- Remove duplicated windowing logic from SlashCommandMenu and FileMentionMenu
2026-01-26 04:41:37 -08:00
Saoud Rizwan c59fe3e62c feat(cli): highlight @mentions and /commands in input field
- Add HighlightedInput component to parse and style text
- Gray background for @mentions and /commands
- Only first /command is highlighted (matches processing behavior)
- Use shared mentionRegexGlobal for proper mention detection
- Prefix file paths with / when inserting mentions (@/path/to/file)
2026-01-26 04:41:37 -08:00
Saoud Rizwan 7602f7b7c6 refactor(cli): unify menu styles and fix navigation
- Update FileMentionMenu to match SlashCommandMenu style
- Max 5 visible items, bright blue text selection, no hints
- Hide footer when file menu is shown
- Stop at boundaries instead of wrapping on arrow keys
2026-01-26 04:41:37 -08:00
Saoud Rizwan a1debe1328 feat(cli): add slash command autocomplete menu
- Add SlashCommandMenu component with keyboard navigation
- Add slash-commands.ts utilities for query extraction and filtering
- Integrate into ChatView with proper state management
- Workflows shown first, then default commands
- Max 5 visible items with arrow key cycling
- Bright blue highlight for selected item
- Footer hidden when menu is shown
2026-01-26 04:41:37 -08:00
Saoud Rizwan 990a00d70f refactor(cli): consolidate tool utilities and reduce code duplication
- Create utils/tools.ts with shared constants and helpers:
  - FILE_EDIT_TOOLS, FILE_SAVE_TOOLS sets
  - isFileEditTool(), isFileSaveTool() helpers
  - normalizeToolName() for consistent tool name handling
  - TOOL_DESCRIPTIONS with normalized keys (no more duplicates)
  - getToolDescription() with automatic normalization
  - parseToolFromMessage() for consistent JSON parsing

- Update components to use shared utilities:
  - ChatMessage.tsx: Remove 60+ line TOOL_DESCRIPTIONS duplicate, use shared
  - ChatView.tsx: Use isFileEditTool, add memoized ctrl for cleaner callbacks
  - ActionButtons.tsx: Use isFileSaveTool and parseToolFromMessage
  - MessageRow.tsx: Use isFileEditTool

- Simplify ChatView.tsx controller pattern:
  - Memoize ctrl = controller || taskController
  - Remove redundant local ctrl definitions in callbacks
  - Cleaner dependency arrays
2026-01-26 04:41:17 -08:00
Saoud Rizwan a125a61cbd feat(cli): add onboarding auth flow with model selection and config import
Auth Flow:
- Auto-redirect first-time users to auth flow when no provider configured
- Support Cline account sign-in with browser-based OAuth
- Support BYO API key configuration for all providers
- Add escape key navigation to go back between steps
- Show provider display names from providers.json (single source of truth)

Model Selection:
- Add ModelPicker component for providers with static model lists
- Add featured models picker for Cline provider (Opus 4.5, GPT 5.2 Codex, Gemini 3 Pro)
- Support OpenRouter model fetching with async loading and caching
- Add scrollable lists with keyboard navigation for long model lists

Config Import:
- Detect and import API keys from Codex CLI (~/.codex/auth.json)
- Detect and import API keys from OpenCode (platform-specific paths)
- Support importing OpenAI, Anthropic, Gemini, Mistral, Groq, DeepSeek, xAI, OpenRouter

Code Quality:
- Extract useScrollableList hook for reusable list windowing
- Move featured models to constants/featured-models.ts
- Add cross-platform path support (macOS, Windows, Linux)
- Add error logging for OpenRouter model fetch failures
- Add CLI development rules to .clinerules/cli.md (blueBright highlight color)
2026-01-26 04:41:17 -08:00
Saoud Rizwan e3e6e1ef77 feat(cli): TUI improvements and new UI components
New Components:
- ActionButtons: Tool approval buttons with mode-based colors (1/2 shortcuts)
- DiffView: Pretty diff view for file edits with +/- highlighting
- TaskView: Alternative verbose task display mode
- MessageList/MessageImage: Supporting components

Chat Improvements:
- Display tool calls in Claude Code style (Cline wants to X / Cline X)
- Mode-based colors (blue for act, yellow for plan)
- Two-column dot prefix layout for messages
- Show command output inline with commands
- Show user feedback messages in chat
- Correct tense for tool messages (wants to vs did)

Bug Fixes:
- Prevent welcome screen flash on task cancel
- Prevent duplicate task completed messages
- Improve followup options handling
- Finalize partial text before native tool calls

Other:
- Add ESC to cancel task (removed ESC-to-exit)
- Use shared formatTimestamp from display utils
- Remove unused files (ImportView, ModelPicker, keychains, etc.)
2026-01-26 04:41:17 -08:00
Saoud Rizwan 09e897c8f2 feat(cli): add onboarding auth flow with model selection and config import
Auth Flow:
- Auto-redirect first-time users to auth flow when no provider configured
- Support Cline account sign-in with browser-based OAuth
- Support BYO API key configuration for all providers
- Add escape key navigation to go back between steps
- Show provider display names from providers.json (single source of truth)

Model Selection:
- Add ModelPicker component for providers with static model lists
- Add featured models picker for Cline provider (Opus 4.5, GPT 5.2 Codex, Gemini 3 Pro)
- Support OpenRouter model fetching with async loading and caching
- Add scrollable lists with keyboard navigation for long model lists

Config Import:
- Detect and import API keys from Codex CLI (~/.codex/auth.json)
- Detect and import API keys from OpenCode (platform-specific paths)
- Support importing OpenAI, Anthropic, Gemini, Mistral, Groq, DeepSeek, xAI, OpenRouter

Code Quality:
- Extract useScrollableList hook for reusable list windowing
- Move featured models to constants/featured-models.ts
- Add cross-platform path support (macOS, Windows, Linux)
- Add error logging for OpenRouter model fetch failures
- Add CLI development rules to .clinerules/cli.md (blueBright highlight color)
2026-01-26 04:41:17 -08:00
abeatrix 6caeeb5da6 Capture Telemetry Events 2026-01-23 23:41:21 -08:00
abeatrix 40550af42c Set up telemetry for CLI 2026-01-23 23:18:58 -08:00
abeatrix e85e4d6f0c Update build step and fix BannerService init 2026-01-23 23:02:47 -08:00
abeatrix d26f76c6e4 remove old task view components 2026-01-23 22:29:30 -08:00
abeatrix 5c6f725079 Replace TaskView with ChatView 2026-01-23 22:10:17 -08:00
abeatrix 4a456e2145 revert to file-base 2026-01-23 21:58:12 -08:00
abeatrix c029e0ffe4 set storage backup 2026-01-23 21:28:09 -08:00
abeatrix cff2217283 check 2026-01-23 21:06:02 -08:00
abeatrix 904617b573 store to system keychain 2026-01-23 21:02:59 -08:00
abeatrix 20fae7e14b update cli host info 2026-01-23 15:20:38 -08:00
Saoud Rizwan 699ed190b7 feat(cli): add CLI-specific system prompt adjustments
- Add isCliEnvironment boolean to SystemPromptContext, computed from
  platform check in task/index.ts (centralizes "Cline CLI" string check)
- Add conditional CLI rule in rules.ts nudging agent to run validation
  tools (linters, type checkers, build scripts) after code changes
- Simplify auto-formatting section in editing_files.ts for CLI mode
  (files saved exactly as written, no auto-formatting expectations)
2026-01-23 15:05:43 -08:00
abeatrix 28ae6d6ca1 Fix error not showing in Chat and use unified chat view 2026-01-23 14:21:01 -08:00
abeatrix beca76fb32 implement logger 2026-01-23 13:27:49 -08:00
abeatrix 8de9935f9a support plain text 2026-01-23 12:11:36 -08:00
abeatrix 6bc700e3db Support Image render 2026-01-23 12:11:23 -08:00
abeatrix 2f17a1a341 Merge branch 'bee/cli' of https://github.com/cline/cline into bee/cli 2026-01-23 10:05:31 -08:00
abeatrix 639121b644 Merge branch 'main' into bee/cli 2026-01-23 09:58:50 -08:00
abeatrix 1187852bed revert non cli-ts changes 2026-01-23 09:40:18 -08:00
abeatrix 7acc036d91 revert non cli-ts changes 2026-01-23 09:36:58 -08:00
abeatrix 1fea5aaa63 json mode support and model ID fix 2026-01-23 04:28:12 -08:00
Saoud Rizwan 8de7294de8 fix(cli): adjust thinking indicator colors and use proper ellipsis
- Update blueBright to light purple-blue (#8CAAFF) to match terminal
- Update yellow to pure bright yellow (#FFFF00)
- Use proper ellipsis character (…) instead of three dots (...)
2026-01-23 03:16:52 -08:00
Saoud Rizwan 2e4ded7f14 feat(cli-ts): enhance thinking indicator with shimmer animation and elapsed time
- Add ThinkingIndicator component with shimmer effect that cycles through text
- Display different colors for act mode (blue) vs plan mode (yellow)
- Show elapsed time after 1 second with 'esc to interrupt' hint
- Extend useSpinnerState hook to return start time alongside active status
- Replace basic LoadingSpinner with enhanced ThinkingIndicator in ChatView and TaskView
- Maintain backward compatibility with useIsSpinnerActive hook
2026-01-23 03:10:45 -08:00
Saoud Rizwan ac94eb4cf4 fix(cli): query cursor position before Ink mounts for accurate robot tracking
Query terminal cursor position before render() to determine where the
robot will be displayed on screen. This fixes the issue where the robot's
gaze tracking threshold was incorrect when CLI was started partway down
the terminal.

- Add cursor-position utility to query position before Ink takes over stdin
- Pass robotTopRow through App -> ChatView -> AsciiMotionCli
- Add StaticRobotFrame component for header after messages start
- Remove cursor position query from inside component (was leaking to input)
2026-01-23 02:55:16 -08:00
Saoud Rizwan 73635cf68f Fix frames 2026-01-23 02:53:56 -08:00
Saoud Rizwan 9eb0da6c2b feat(cli): add animated robot that follows cursor
Replace static Cline logo with animated 3D robot head that tracks
mouse cursor position. Uses 192 pre-rendered frames with smooth
interpolation for real-time cursor following.

- Add AsciiMotionCli component from ink-playground
- Show animated robot in dynamic region during welcome state
- Filter mouse escape sequences from text input
- Robot looks straight ahead when cursor is above, follows when below
2026-01-23 02:53:56 -08:00
Saoud Rizwan 59c10df046 feat(cli): improve UI styling and fix flicker for long outputs
- User messages now have gray background bubble with > prefix
- Completion result text is green
- Progress bar uses gray shades instead of white
- Act mode uses blueBright color for border and text
- Thinking/Planning spinner with mode-aware text
- Skip dynamic rendering for completion_result and plan_mode_respond
  (these tend to be very long and cause flicker when exceeding terminal height)
- Progress bar shows at least 1 bar when any tokens used
- Always show default hint text (@ for files, / for commands)
2026-01-23 02:53:56 -08:00
Saoud Rizwan db4d9a267a fix(cli): use refs instead of state for Static tracking to prevent flicker
When messages transition from streaming (dynamic) to complete (Static),
useState was causing extra render cycles. Using useRef instead updates
synchronously without triggering re-renders.
2026-01-23 02:53:56 -08:00
Saoud Rizwan 734dc6e57f chore(cli): upgrade to @jrichman/ink fork with React 19 2026-01-23 02:53:56 -08:00
Saoud Rizwan 27befb16f5 chore: gitignore tsbuildinfo files 2026-01-23 02:53:56 -08:00
Saoud Rizwan 1c32b08644 fix(cli): improve chat message rendering
- Fix extra spacing between bullet and text by combining Text elements
- Change assistant message color from magenta to default foreground
- Show ⎿ only on first line of tool results, use spaces for alignment
- Add inline markdown rendering for **bold**, *italic*, and `code`

The inline markdown parser handles common formatting in model output
without the complexity of full block-level markdown parsing.
2026-01-23 02:53:56 -08:00
Saoud Rizwan 5d81bf0fe2 chore(cli): upgrade to forked Ink package for better rendering
- Switch ink to @jrichman/ink@6.4.7 (same fork Gemini CLI uses)
- Upgrade React to 19.2.3
- Add aws4fetch dependency
- Add aws4fetch to esbuild external list
2026-01-23 02:53:55 -08:00
Saoud Rizwan f44771acd5 feat(cli): add flicker-free ChatView with Static/Dynamic split
Replace the old welcome view with a unified ChatView component that
uses Ink's Static component to prevent terminal flickering.

The Problem:
When Ink output height >= terminal rows, it switches from efficient
line-erasing to clearTerminal() + full redraw on every re-render,
causing severe flickering.

The Solution:
- Split content into Static region (rendered once) and Dynamic region
- Completed messages move to Static, only current streaming + input
  stays dynamic
- This keeps dynamic height small, avoiding clearTerminal path

Also includes:
- ChatMessage component with Claude Code-style tool call rendering
- StatusBar component for model/tokens/cost display
- Manual centering via centerText() since Box centering breaks in Static
- Console suppression for console.info to prevent Ink corruption
- React.memo on AccountInfoView

We explored alternate screen buffer (like Gemini CLI) but disabled it
because scroll wheel doesn't work without custom mouse event handling.

See ChatView.tsx header comment for full documentation.
2026-01-23 02:53:55 -08:00
abeatrix 786728e0d4 fix removed hooks settings 2026-01-22 15:02:58 -08:00
abeatrix 9714cf8861 Merge branch 'main' into bee/cli-ts-ink-poc 2026-01-22 14:00:59 -08:00
abeatrix c5db44964e Supports Piped 2026-01-22 01:13:37 -08:00
abeatrix ed38739d65 store metadata with task history 2026-01-22 00:49:47 -08:00
abeatrix 50bc90835c log 2026-01-21 18:53:49 -08:00
abeatrix 4d41766b9f fix api key storing process 2026-01-21 17:38:36 -08:00
abeatrix e958e29439 Merge branch 'bee/cli-ts-ink-poc' of https://github.com/cline/cline into bee/cli-ts-ink-poc 2026-01-21 16:33:57 -08:00
abeatrix b337bb706e set up secretStorage 2026-01-21 16:33:51 -08:00
abeatrix 314e06731a fix type 2026-01-21 15:04:08 -08:00
abeatrix 3a6f8b98dd Merge branch 'main' into bee/cli-ts-ink-poc 2026-01-21 14:12:54 -08:00
abeatrix 65f9322bd9 exit askprompt on yolo mode completion 2026-01-21 14:05:37 -08:00
abeatrix 33983e80f8 merge saoudrizwan/cli-ts-ink-poc-with-tui with context 2026-01-21 13:44:50 -08:00
Saoud Rizwan 8648154a74 fix(cli): use index key for static logo array to avoid duplicate key warnings 2026-01-21 11:23:20 -08:00
Saoud Rizwan 5d91ac037e feat(cli): redesign welcome view with ASCII logo and mode toggle
- Add centered ASCII Cline logo
- Add "What can I do for you?" prompt
- Add bordered input field (blue for Act mode, yellow for Plan mode)
- Display model ID and Plan/Act toggle below input
- Tab to switch between Plan and Act modes
- Two-step Esc to exit (first press highlights, second exits)
- Full-width responsive layout
- Properly exit process when user cancels
2026-01-21 10:00:20 -08:00
Saoud Rizwan 6179dfe0a4 fix(cli): disable browser tool in CLI mode 2026-01-21 09:50:32 -08:00
Saoud Rizwan cd9e28e6d0 feat(cli): add graceful Ctrl+C shutdown handling
- Handle SIGINT/SIGTERM signals to cleanly exit
- Abort running task, persist state, dispose controller before exit
- Force exit on second signal if already shutting down
2026-01-21 09:50:32 -08:00
Saoud Rizwan 8d56a697eb feat(cli): add CLI-specific system prompt adjustments
- Add isCliEnvironment boolean to SystemPromptContext, computed from
  platform check in task/index.ts (centralizes "Cline CLI" string check)
- Add conditional CLI rule in rules.ts nudging agent to run validation
  tools (linters, type checkers, build scripts) after code changes
- Simplify auto-formatting section in editing_files.ts for CLI mode
  (files saved exactly as written, no auto-formatting expectations)
2026-01-21 09:50:32 -08:00
Saoud Rizwan fd506b14b5 fix(cli): disable checkpoints in CLI mode
The shadow git checkpoint system is problematic for CLI usage because it
creates shadow gits for every directory you run cline in, leading to storage
bloat and potentially tracking files in directories you don't want tracked.

Disables checkpoints by setting enableCheckpointsSetting to false during
CLI initialization.
2026-01-21 09:50:32 -08:00
abeatrix 55d268773d on exit 2026-01-20 23:32:20 -08:00
abeatrix bf978c2c42 Merge branch 'bee/cli-ts-ink-poc' of https://github.com/cline/cline into bee/cli-ts-ink-poc 2026-01-20 23:01:07 -08:00
abeatrix 7e06dbbd33 Fix viewpoints 2026-01-20 23:01:06 -08:00
abeatrix 8d4822d781 add version command 2026-01-20 18:07:30 -08:00
abeatrix dbcaae65d1 refactor(auth): extract provider mapping and add welcome navigation
- Extract provider-to-API-key field mapping into shared utility (ProviderToApiKeyMap)
- Add navigation support from AuthView to WelcomeView via onNavigateToWelcome callback
- Implement internal welcome submission handler with image processing support
- Improve code organization by centralizing provider configuration mapping

This refactoring enables better navigation flow between auth and welcome screens
and makes the provider mapping reusable across components.
2026-01-20 18:07:02 -08:00
abeatrix 66db36ff5b Includes Input field with options fields 2026-01-20 18:05:14 -08:00
abeatrix a53d17a857 update script 2026-01-20 16:01:58 -08:00
abeatrix de56305315 file structure 2026-01-20 15:24:56 -08:00
abeatrix 5cd22e3f86 Clean up and add config view for rules 2026-01-20 14:27:02 -08:00
abeatrix bd9150837d Update DiffView background 2026-01-20 13:55:29 -08:00
abeatrix fbb494b88a Add DiffView & History UX 2026-01-20 13:43:11 -08:00
abeatrix 16b4c48457 Fix Box Order 2026-01-20 13:40:14 -08:00
abeatrix 058c86872b checkpoint restore 2026-01-20 13:02:01 -08:00
abeatrix d3e8f562bc At Mention Files 2026-01-20 12:51:18 -08:00
abeatrix 1594d44190 Show Costs 2026-01-20 12:24:51 -08:00
abeatrix c8419ffa99 Add Unit Tests 2026-01-20 12:14:02 -08:00
abeatrix cf6c80504f Fix spacing 2026-01-20 11:48:19 -08:00
abeatrix 230f615927 fix thinking 2026-01-20 11:37:32 -08:00
abeatrix 08e00dc3bf hide config options 2026-01-20 11:22:36 -08:00
abeatrix c01bf73d3f Interactive ConfigView 2026-01-20 11:17:48 -08:00
abeatrix df28359114 add Focus Chain 2026-01-20 11:17:33 -08:00
abeatrix d3631f0485 add account info 2026-01-20 09:27:18 -08:00
abeatrix eb18c556f4 add image support 2026-01-20 09:27:03 -08:00
abeatrix b18284ea78 clean up 2026-01-20 00:09:22 -08:00
abeatrix e4e99ec711 clean up shim 2026-01-20 00:00:24 -08:00
abeatrix b7b4d0bfe8 add welcome view 2026-01-19 21:53:40 -08:00
abeatrix c12b00e7a9 add thinking options to task command & set model ID 2026-01-19 21:40:52 -08:00
abeatrix 73bb8c2f64 Fix task display in HistoryView 2026-01-19 21:03:39 -08:00
abeatrix 0e1d513462 update prompt handling for plan mode and completion
Add new prompt types "plan_mode_text" and "completion" to improve user experience when interacting with the assistant. Separate handling for plan_mode_respond from followup to enable quick mode switching via empty Enter press. Add toggleToActMode callback to allow seamless transition from Plan to Act mode without text input.

Changes:
- Split plan_mode_respond and followup logic for distinct UX flows
- Add plan_mode_text prompt type with toggle-to-Act-mode on empty Enter
- Add completion prompt type for follow-up questions or exit on Enter
- Implement toggleToActMode callback for mode switching
- Update keyboard handlers and UI rendering for new prompt types
- Add helpful hints for users (e.g., "just Enter to switch to Act mode")
2026-01-19 21:01:36 -08:00
abeatrix d6a1b338bc clean up 2026-01-19 19:18:13 -08:00
abeatrix 0091fb6ffb clean up messages 2026-01-19 16:38:47 -08:00
abeatrix de3e3fe34a clean up logs 2026-01-19 16:25:43 -08:00
abeatrix e136d75621 refactor: switch CLI to ES modules with Ink UI
Convert CLI build output from CommonJS (cli.cjs) to ES modules (cli.mjs) and refactor the UI layer to use React with Ink framework instead of the previous subscription-based state update pattern.

Changes include:
- Updated esbuild configuration to target ESM format
- Added stubOptionalModulesPlugin to handle react-devtools-core
- Updated shebang and module compatibility helpers for ES modules
- Replaced state subscriber pattern with React component-based UI
- Added react and related type dependencies
- Updated external module list to include UI dependencies (ink, ink-spinner, react)
- Enabled top-level-await support for ES modules
2026-01-19 16:18:24 -08:00
abeatrix 91127ffdb9 Update logger 2026-01-16 22:34:20 -08:00
abeatrix 11f76f0218 add cline sign in 2026-01-16 22:08:03 -08:00
abeatrix 74f1b9c75d **feat(cli): allow switching mode and specifying model for tasks**
Added `--switch` (`-s`) and `--model` (`-m`) options to the CLI.
Updated `runTask` signature and logic to set global state for mode (plan/act) and the corresponding API model ID.
This enables users to run tasks in different modes or with a specific model directly from the command line.
2026-01-16 18:46:55 -08:00
abeatrix 942c78119e working prototype
npm install:all
cd cli-ts
npm run link
clinedev auth
2026-01-16 18:36:47 -08:00
abeatrix 3130c3c96e replace console.log with Logger 2026-01-14 13:08:50 -08:00
abeatrix f0225bc20d fileeditprovider 2026-01-14 13:08:28 -08:00
abeatrix 1bf158245c prototype: Cline CLI with Typescript
TODO:
- remove usage of console.log across codebase and replace them with Logger
2026-01-13 16:55:36 -08:00
249 changed files with 366645 additions and 25831 deletions
+33
View File
@@ -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.
+20 -38
View File
@@ -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:
+26 -49
View File
@@ -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'
+1
View File
@@ -46,3 +46,4 @@ test-results
/pkg
.secrets
*.tsbuildinfo
+1
View File
@@ -1,2 +1,3 @@
@.clinerules/general.md
@.clinerules/network.md
@.clinerules/cli.md
+6 -2
View File
@@ -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"
]
},
{
-2
View File
@@ -1,2 +0,0 @@
cline-core-debug.log
bin/*
+334 -42
View File
@@ -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/`.
-292
View File
@@ -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.
+21
View File
@@ -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
-73
View File
@@ -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
}
-385
View File
@@ -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
}
-154
View File
@@ -1,154 +0,0 @@
package e2e
import (
"context"
"encoding/json"
"os"
"path/filepath"
"syscall"
"testing"
"github.com/cline/cli/pkg/common"
)
// 2. Multi-instance start: default_instance remains the first started.
func TestMultiInstanceDefaultUnchanged(t *testing.T) {
_ = setTempClineDir(t)
ctx, cancel := context.WithTimeout(context.Background(), longTimeout)
defer cancel()
// Start first instance and wait healthy
_ = mustRunCLI(ctx, t, "instance", "new")
out1 := listInstancesJSON(ctx, t)
if len(out1.CoreInstances) != 1 {
t.Fatalf("expected 1 instance, got %d", len(out1.CoreInstances))
}
firstAddr := out1.CoreInstances[0].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)
}
}
-378
View File
@@ -1,378 +0,0 @@
package e2e
import (
"context"
"fmt"
"net"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"syscall"
"testing"
"time"
"github.com/cline/cli/pkg/cli/global"
"github.com/cline/cli/pkg/common"
"github.com/cline/grpc-go/cline"
)
const (
defaultTimeout = 30 * time.Second
longTimeout = 60 * time.Second
pollInterval = 250 * time.Millisecond
instancesBinRel = "../bin/cline"
)
func repoAwareBinPath(t *testing.T) string {
// Tests live in repoRoot/cli/e2e. Binary is at repoRoot/cli/bin/cline
t.Helper()
wd, err := os.Getwd()
if err != nil {
t.Fatalf("Getwd error: %v", err)
}
// cli/e2e -> cli/bin/cline
p := filepath.Clean(filepath.Join(wd, instancesBinRel))
if _, err := os.Stat(p); err != nil {
t.Fatalf("CLI binary not found at %s; run `npm run compile-cli` first: %v", p, err)
}
return p
}
func setTempClineDir(t *testing.T) string {
t.Helper()
dir := t.TempDir()
clineDir := filepath.Join(dir, ".cline")
if err := os.MkdirAll(clineDir, 0o755); err != nil {
t.Fatalf("mkdir clineDir: %v", err)
}
t.Setenv("CLINE_DIR", clineDir)
return clineDir
}
func runCLI(ctx context.Context, t *testing.T, args ...string) (string, string, int) {
t.Helper()
bin := repoAwareBinPath(t)
// Ensure CLI uses the same CLINE_DIR as the tests by passing --config=<CLINE_DIR>
// (InitializeGlobalConfig uses ConfigPath as the base directory for registry.)
if clineDir := os.Getenv("CLINE_DIR"); clineDir != "" && !contains(args, "--config") {
// Prepend persistent flag so Cobra sees it regardless of subcommand position
args = append([]string{"--config", clineDir}, args...)
}
cmd := exec.CommandContext(ctx, bin, args...)
// Run CLI from repo root so relative paths inside CLI (./cli/bin/...) resolve
if wd, err := os.Getwd(); err == nil {
repoRoot := filepath.Clean(filepath.Join(wd, "..", ".."))
cmd.Dir = repoRoot
}
// propagate env including CLINE_DIR
cmd.Env = os.Environ()
outB, errB := &strings.Builder{}, &strings.Builder{}
cmd.Stdout = outB
cmd.Stderr = errB
err := cmd.Run()
exit := 0
if err != nil {
// Extract exit code if possible
if ee, ok := err.(*exec.ExitError); ok {
exit = ee.ExitCode()
} else {
exit = -1
}
}
return outB.String(), errB.String(), exit
}
func mustRunCLI(ctx context.Context, t *testing.T, args ...string) string {
t.Helper()
out, errOut, exit := runCLI(ctx, t, args...)
if exit != 0 {
t.Fatalf("cline %v failed (exit=%d)\nstdout:\n%s\nstderr:\n%s", args, exit, out, errOut)
}
return out
}
func listInstancesJSON(ctx context.Context, t *testing.T) common.InstancesOutput {
t.Helper()
// Trigger CLI to perform cleanup/health by invoking list (table output is ignored)
_ = mustRunCLI(ctx, t, "instance", "list")
// Read from SQLite locks database to build structured output
clineDir := getClineDir(t)
// Load default instance from settings file
defaultInstance := readDefaultInstanceFromSettings(t, clineDir)
// Load instances from SQLite
instances := readInstancesFromSQLite(t, clineDir)
return common.InstancesOutput{
DefaultInstance: defaultInstance,
CoreInstances: instances,
}
}
func hasAddress(in common.InstancesOutput, addr string) bool {
for _, it := range in.CoreInstances {
if it.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
}
-47
View File
@@ -1,47 +0,0 @@
package e2e
import (
"fmt"
"os"
"path/filepath"
"strings"
"testing"
)
// TestMain validates required artifacts exist before running E2E tests.
// It does NOT build artifacts. Build manually via:
//
// npm run compile-standalone
// npm run compile-cli
func TestMain(m *testing.M) {
// Determine repo root from cli/e2e
wd, err := os.Getwd()
if err != nil {
fmt.Fprintf(os.Stderr, "getwd: %v\n", err)
os.Exit(2)
}
repoRoot := filepath.Clean(filepath.Join(wd, "..", ".."))
cliBin := filepath.Join(repoRoot, "cli", "bin", "cline")
coreJS := filepath.Join(repoRoot, "dist-standalone", "cline-core.js")
missing := []string{}
if _, err := os.Stat(cliBin); err != nil {
missing = append(missing, cliBin)
}
if _, err := os.Stat(coreJS); err != nil {
missing = append(missing, coreJS)
}
if len(missing) > 0 {
if testing.Short() {
// Optional quality-of-life: allow skipping with -short when artifacts are absent
fmt.Fprintf(os.Stderr, "[e2e] skipping (-short) due to missing artifacts:\n %s\n", strings.Join(missing, "\n "))
os.Exit(0)
}
fmt.Fprintf(os.Stderr, "Missing required build artifacts for E2E tests:\n %s\n\nPlease build them first:\n npm run compile-standalone\n npm run compile-cli\n", strings.Join(missing, "\n "))
os.Exit(2)
}
os.Exit(m.Run())
}
-120
View File
@@ -1,120 +0,0 @@
package e2e
import (
"context"
"fmt"
"os"
"path/filepath"
"syscall"
"testing"
"github.com/cline/cli/pkg/common"
)
// 9. Mixed localhost vs 127.0.0.1 addresses coexist and are both healthy
func TestMixedLocalhostVs127Coexist(t *testing.T) {
clineDir := setTempClineDir(t)
ctx, cancel := context.WithTimeout(context.Background(), longTimeout)
defer cancel()
// Start one instance
_ = mustRunCLI(ctx, t, "instance", "new")
// Get the running instance and its port/PID
out := listInstancesJSON(ctx, t)
if len(out.CoreInstances) == 0 {
t.Fatalf("expected at least 1 instance")
}
inst := out.CoreInstances[0]
waitForAddressHealthy(t, inst.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)
}
}
-161
View File
@@ -1,161 +0,0 @@
package e2e
import (
"database/sql"
"encoding/json"
"os"
"path/filepath"
"strconv"
"testing"
"time"
"github.com/cline/cli/pkg/common"
_ "github.com/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
}
-178
View File
@@ -1,178 +0,0 @@
package e2e
import (
"context"
"fmt"
"syscall"
"testing"
)
// TestStartAndList verifies self-registration and default.json semantics in a fresh CLINE_DIR.
func TestStartAndList(t *testing.T) {
clineDir := setTempClineDir(t)
t.Logf("Using temp CLINE_DIR: %s", clineDir)
ctx, cancel := context.WithTimeout(context.Background(), longTimeout)
defer cancel()
t.Logf("Starting new instance...")
// Start a new instance
startOutput := mustRunCLI(ctx, t, "instance", "new")
t.Logf("Instance start output: %s", startOutput)
t.Logf("Listing instances to check registration...")
// It should appear healthy in list JSON and be the default.
out := listInstancesJSON(ctx, t)
t.Logf("Found %d instances after start", len(out.CoreInstances))
if len(out.CoreInstances) != 1 {
t.Fatalf("expected 1 instance, got %d", len(out.CoreInstances))
}
addr := out.CoreInstances[0].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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
+2950
View File
File diff suppressed because it is too large Load Diff
+45 -31
View File
@@ -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"
}
}
-43
View File
@@ -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
}
-285
View File
@@ -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)
}
-321
View File
@@ -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...)
}
}
-130
View File
@@ -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
}
-247
View File
@@ -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
}
-141
View File
@@ -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
}
-156
View File
@@ -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
}
-69
View File
@@ -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
}
-184
View File
@@ -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
}
-527
View File
@@ -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
}
-763
View File
@@ -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
}
-200
View File
@@ -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
}
-366
View File
@@ -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
}
-187
View File
@@ -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 ""
}
-152
View File
@@ -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
}
-207
View File
@@ -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
}
-197
View File
@@ -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
}
-27
View File
@@ -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")
}
-99
View File
@@ -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)
}
-95
View File
@@ -1,95 +0,0 @@
package display
import (
"crypto/md5"
"fmt"
"sync"
"time"
"github.com/cline/cli/pkg/cli/types"
)
// MessageDeduplicator handles message deduplication to prevent duplicate displays
type MessageDeduplicator struct {
mu sync.RWMutex
seenMessages map[string]time.Time
maxAge time.Duration
cleanupTicker *time.Ticker
}
// NewMessageDeduplicator creates a new message deduplicator
func NewMessageDeduplicator() *MessageDeduplicator {
d := &MessageDeduplicator{
seenMessages: make(map[string]time.Time),
maxAge: 5 * time.Minute, // Keep messages for 5 minutes
cleanupTicker: time.NewTicker(1 * time.Minute), // Cleanup every minute
}
// Start cleanup goroutine
go d.cleanup()
return d
}
// IsDuplicate checks if a message is a duplicate
func (d *MessageDeduplicator) IsDuplicate(msg *types.ClineMessage) bool {
d.mu.Lock()
defer d.mu.Unlock()
// Create a hash of the message content
hash := d.hashMessage(msg)
// Check if we've seen this message recently
if lastSeen, exists := d.seenMessages[hash]; exists {
// If we've seen it within the last few seconds, it's a duplicate
if time.Since(lastSeen) < 2*time.Second {
return true
}
}
// Mark this message as seen
d.seenMessages[hash] = time.Now()
return false
}
// hashMessage creates a hash of the message for deduplication
func (d *MessageDeduplicator) hashMessage(msg *types.ClineMessage) string {
// Create a hash based on message content, type, and timestamp
content := fmt.Sprintf("%s|%s|%s|%d",
string(msg.Type),
msg.Say,
msg.Ask,
msg.Timestamp)
// For partial messages, include the text content in the hash
if msg.Partial {
content += "|" + msg.Text
}
hash := md5.Sum([]byte(content))
return fmt.Sprintf("%x", hash)
}
// cleanup removes old entries from the seen messages map
func (d *MessageDeduplicator) cleanup() {
for range d.cleanupTicker.C {
d.mu.Lock()
now := time.Now()
// Remove entries older than maxAge
for hash, timestamp := range d.seenMessages {
if now.Sub(timestamp) > d.maxAge {
delete(d.seenMessages, hash)
}
}
d.mu.Unlock()
}
}
// Stop stops the cleanup goroutine
func (d *MessageDeduplicator) Stop() {
if d.cleanupTicker != nil {
d.cleanupTicker.Stop()
}
}
-144
View File
@@ -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 ToolRenderers 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 ToolRenderers 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
}
-69
View File
@@ -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)
}
}
-139
View File
@@ -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
}
-304
View File
@@ -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)
}
-222
View File
@@ -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; dont 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)
}
}
-152
View File
@@ -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()
}
}
-269
View File
@@ -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
}
-471
View File
@@ -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()
}
-302
View File
@@ -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
}
}
-210
View File
@@ -1,210 +0,0 @@
package display
import (
"fmt"
"os"
"time"
)
// TypewriterConfig holds configuration for the typewriter effect
type TypewriterConfig struct {
BaseDelay time.Duration // Base delay between characters
FastDelay time.Duration // Faster delay for common characters
SlowDelay time.Duration // Slower delay for punctuation
PauseDelay time.Duration // Pause after sentences
Enabled bool // Whether typewriter effect is enabled
RandomFactor float64 // Randomness factor (0.0 to 1.0)
}
// DefaultTypewriterConfig returns the default typewriter configuration
func DefaultTypewriterConfig() *TypewriterConfig {
return &TypewriterConfig{
BaseDelay: 15 * time.Millisecond,
FastDelay: 8 * time.Millisecond,
SlowDelay: 25 * time.Millisecond,
PauseDelay: 150 * time.Millisecond,
Enabled: false,
RandomFactor: 0.3,
}
}
// TypewriterPrinter handles typewriter-style output
type TypewriterPrinter struct {
config *TypewriterConfig
}
// NewTypewriterPrinter creates a new typewriter printer
func NewTypewriterPrinter(config *TypewriterConfig) *TypewriterPrinter {
if config == nil {
config = DefaultTypewriterConfig()
}
return &TypewriterPrinter{
config: config,
}
}
// Print prints text with typewriter effect
func (tp *TypewriterPrinter) Print(text string) {
if !tp.config.Enabled {
fmt.Print(text)
return
}
tp.typewriterPrint(text)
}
// Printf prints formatted text with typewriter effect
func (tp *TypewriterPrinter) Printf(format string, args ...interface{}) {
text := fmt.Sprintf(format, args...)
tp.Print(text)
}
// Println prints text with typewriter effect and adds a newline
func (tp *TypewriterPrinter) Println(text string) {
tp.Print(text + "\n")
}
// PrintfLn prints formatted text with typewriter effect and adds a newline
func (tp *TypewriterPrinter) PrintfLn(format string, args ...interface{}) {
text := fmt.Sprintf(format, args...)
tp.Println(text)
}
// PrintInstant prints text immediately without typewriter effect
func (tp *TypewriterPrinter) PrintInstant(text string) {
fmt.Print(text)
}
// PrintfInstant prints formatted text immediately without typewriter effect
func (tp *TypewriterPrinter) PrintfInstant(format string, args ...interface{}) {
fmt.Printf(format, args...)
}
// typewriterPrint displays text with a typewriter animation effect
func (tp *TypewriterPrinter) typewriterPrint(text string) {
// Convert string to runes to handle Unicode properly
runes := []rune(text)
for i, r := range runes {
// Print the character
fmt.Print(string(r))
os.Stdout.Sync() // Force immediate output
// Don't add delay after the last character
if i == len(runes)-1 {
break
}
// Determine delay based on character type
delay := tp.getDelayForCharacter(r, i)
// Sleep for the calculated delay
time.Sleep(delay)
}
}
// getDelayForCharacter returns the appropriate delay for a character
func (tp *TypewriterPrinter) getDelayForCharacter(r rune, position int) time.Duration {
var baseDelay time.Duration
switch {
case r == '.' || r == '!' || r == '?':
// Longer pause after sentence endings
baseDelay = tp.config.PauseDelay
case r == ',' || r == ';' || r == ':':
// Medium pause after punctuation
baseDelay = tp.config.SlowDelay
case r == ' ':
// Slightly faster for spaces
baseDelay = tp.config.FastDelay
case r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z':
// Fast for common letters
baseDelay = tp.config.FastDelay
case r == '\n':
// No delay for newlines
return 0
default:
// Base delay for other characters
baseDelay = tp.config.BaseDelay
}
// Add randomness to make it feel more natural
if tp.config.RandomFactor > 0 {
// Simple pseudo-random based on position to ensure consistency
randomFactor := 0.7 + (tp.config.RandomFactor * float64(position%7) / 6.0)
baseDelay = time.Duration(float64(baseDelay) * randomFactor)
}
return baseDelay
}
// SetEnabled enables or disables the typewriter effect
func (tp *TypewriterPrinter) SetEnabled(enabled bool) {
tp.config.Enabled = enabled
}
// IsEnabled returns whether the typewriter effect is enabled
func (tp *TypewriterPrinter) IsEnabled() bool {
return tp.config.Enabled
}
// SetSpeed adjusts the typewriter speed (multiplier: 0.1 = very slow, 1.0 = normal, 2.0 = fast)
func (tp *TypewriterPrinter) SetSpeed(multiplier float64) {
if multiplier <= 0 {
multiplier = 1.0
}
tp.config.BaseDelay = time.Duration(float64(15*time.Millisecond) / multiplier)
tp.config.FastDelay = time.Duration(float64(8*time.Millisecond) / multiplier)
tp.config.SlowDelay = time.Duration(float64(25*time.Millisecond) / multiplier)
tp.config.PauseDelay = time.Duration(float64(150*time.Millisecond) / multiplier)
}
func (tp *TypewriterPrinter) PrintMessageLine(prefix, text string) {
tp.PrintfInstant("%s: ", prefix)
tp.Println(text)
}
// Global typewriter printer instance
var globalTypewriter = NewTypewriterPrinter(DefaultTypewriterConfig())
// Global convenience functions that use the global typewriter instance
// TypewriterPrint prints text with typewriter effect using the global instance
func TypewriterPrint(text string) {
globalTypewriter.Print(text)
}
// TypewriterPrintf prints formatted text with typewriter effect using the global instance
func TypewriterPrintf(format string, args ...interface{}) {
globalTypewriter.Printf(format, args...)
}
// TypewriterPrintln prints text with typewriter effect and newline using the global instance
func TypewriterPrintln(text string) {
globalTypewriter.Println(text)
}
// TypewriterPrintfLn prints formatted text with typewriter effect and newline using the global instance
func TypewriterPrintfLn(format string, args ...interface{}) {
globalTypewriter.PrintfLn(format, args...)
}
func TypewriterPrintMessageLine(prefix, text string) {
globalTypewriter.PrintMessageLine(prefix, text)
}
// SetGlobalTypewriterEnabled enables or disables the global typewriter effect
func SetGlobalTypewriterEnabled(enabled bool) {
globalTypewriter.SetEnabled(enabled)
}
// SetGlobalTypewriterSpeed sets the speed of the global typewriter effect
func SetGlobalTypewriterSpeed(multiplier float64) {
globalTypewriter.SetSpeed(multiplier)
}
// GetGlobalTypewriter returns the global typewriter instance
func GetGlobalTypewriter() *TypewriterPrinter {
return globalTypewriter
}
-65
View File
@@ -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
}
-510
View File
@@ -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
}
-119
View File
@@ -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
}
-303
View File
@@ -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
}
-339
View File
@@ -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)
}
-131
View File
@@ -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
)
-549
View File
@@ -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] + "..."
}
-198
View File
@@ -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 ToolRenderers 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)
}
}
-35
View File
@@ -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)
}
}
-506
View File
@@ -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
}
-382
View File
@@ -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
}
-167
View File
@@ -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()
}
-620
View File
@@ -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
}
-265
View File
@@ -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
}
}
-142
View File
@@ -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
}
-366
View File
@@ -1,366 +0,0 @@
package sqlite
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"net"
"os"
"path/filepath"
"time"
"github.com/cline/cli/pkg/common"
_ "github.com/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()
}
-674
View File
@@ -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)
}
}
-15
View File
@@ -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,
}
}
-72
View File
@@ -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)
}
-566
View File
@@ -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
-760
View File
@@ -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
-60
View File
@@ -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
}
-695
View File
@@ -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
View File
@@ -1 +0,0 @@
if you can make a beautiful tui in go, please help!
-18
View File
@@ -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"`
}
-377
View File
@@ -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"
}
}
-59
View File
@@ -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"`
}
-409
View File
@@ -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)
}
-46
View File
@@ -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
}
-6
View File
@@ -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
-54
View File
@@ -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', ?, ?)
`
)
-54
View File
@@ -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"`
}
-256
View File
@@ -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
}
-39
View File
@@ -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
-363
View File
@@ -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
}
-63
View File
@@ -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
}
-68
View File
@@ -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
}
-166
View File
@@ -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
}
-115
View File
@@ -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
}
}
-43
View File
@@ -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