mirror of
https://github.com/cline/cline.git
synced 2026-09-09 15:02:23 +08:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d644dbdd22 | ||
|
|
172d630d72 | ||
|
|
860354ec9d |
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "cline",
|
||||
"version": "2.13.0",
|
||||
"version": "2.12.0",
|
||||
"description": "Autonomous coding agent CLI - capable of creating/editing files, running commands, using the browser, and more",
|
||||
"main": "dist/lib.mjs",
|
||||
"types": "dist/lib.d.ts",
|
||||
|
||||
@@ -373,6 +373,7 @@ export const ChatView: React.FC<ChatViewProps> = ({
|
||||
handleKeyboardSequence,
|
||||
handleCtrlShortcut,
|
||||
deleteCharBefore,
|
||||
deleteWordBefore,
|
||||
insertText: insertTextAtCursor,
|
||||
} = useTextInput()
|
||||
|
||||
@@ -423,10 +424,12 @@ export const ChatView: React.FC<ChatViewProps> = ({
|
||||
| null
|
||||
>(null)
|
||||
|
||||
// Handle Home/End keys from raw stdin (Ink doesn't expose these in useInput)
|
||||
// Handle Home/End keys and Option+Backspace from raw stdin
|
||||
// (Ink doesn't properly expose these in useInput)
|
||||
useHomeEndKeys({
|
||||
onHome: useCallback(() => setCursorPos(0), [setCursorPos]),
|
||||
onEnd: useCallback(() => setCursorPos(textInputRef.current.length), [setCursorPos]),
|
||||
onOptionBackspace: useCallback(() => deleteWordBefore(), [deleteWordBefore]),
|
||||
isActive: !activePanel, // Only active when no panel is open
|
||||
})
|
||||
|
||||
@@ -1159,13 +1162,20 @@ export const ChatView: React.FC<ChatViewProps> = ({
|
||||
return
|
||||
}
|
||||
|
||||
// 3. Handle Option+arrow via key.meta (backup - Ink sometimes parses these instead of passing raw sequence)
|
||||
// 3. Handle Option+arrow via key.meta
|
||||
// Terminals send these as either:
|
||||
// - CSI format: \x1b[1;3D → Ink sets key.meta + key.leftArrow
|
||||
// - Meta prefix: \x1bb → Ink sets key.meta + input='b' (emacs: Meta-b = word left)
|
||||
// We handle both formats here.
|
||||
// Note: Option+Backspace is NOT handled here — it's handled exclusively by
|
||||
// useHomeEndKeys to avoid double word-delete (both this handler and
|
||||
// useHomeEndKeys fire for the same \x1b\x7f input).
|
||||
if (key.meta) {
|
||||
if (key.leftArrow) {
|
||||
if (key.leftArrow || input === "b") {
|
||||
setCursorPos(findWordStart(textInputRef.current, cursorPosRef.current))
|
||||
return
|
||||
}
|
||||
if (key.rightArrow) {
|
||||
if (key.rightArrow || input === "f") {
|
||||
setCursorPos(findWordEnd(textInputRef.current, cursorPosRef.current))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -8,11 +8,11 @@ describe("KanbanMigrationView", () => {
|
||||
const onSelect = vi.fn()
|
||||
const { lastFrame } = render(createElement(KanbanMigrationView, { isRawModeSupported: true, onSelect }))
|
||||
|
||||
expect(lastFrame()).toContain("Introducing Cline Kanban!")
|
||||
expect(lastFrame()).toContain("Cline is moving out of the terminal. Introducing Cline Kanban.")
|
||||
expect(lastFrame()).toContain("Open the new experience")
|
||||
expect(lastFrame()).toContain("Launch Cline Kanban and start there by default.")
|
||||
expect(lastFrame()).toContain("cline --tui")
|
||||
expect(lastFrame()).toContain("You can always run cline --tui for the terminal experience.")
|
||||
expect(lastFrame()).toContain("Close and rerun with cline --tui if you want the old CLI.")
|
||||
expect(lastFrame()).toContain("Exit")
|
||||
})
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@ const InternalKanbanMigrationView: React.FC<Pick<KanbanMigrationViewProps, "onSe
|
||||
},
|
||||
{
|
||||
label: "Exit",
|
||||
description: "You can always run cline --tui for the terminal experience.",
|
||||
description: "Close and rerun with cline --tui if you want the old CLI.",
|
||||
value: "exit",
|
||||
},
|
||||
],
|
||||
@@ -60,7 +60,7 @@ const InternalKanbanMigrationView: React.FC<Pick<KanbanMigrationViewProps, "onSe
|
||||
<StaticRobotFrame />
|
||||
<Text> </Text>
|
||||
<Text bold color="white">
|
||||
Introducing Cline Kanban!
|
||||
Cline is moving out of the terminal. Introducing Cline Kanban.
|
||||
</Text>
|
||||
<Text color="gray">A board for orchestrating coding agents across worktrees, right from your browser.</Text>
|
||||
<Text> </Text>
|
||||
|
||||
@@ -38,3 +38,58 @@ export const OPTION_RIGHT_SEQUENCES = new Set([
|
||||
"\x1bf", // Meta+f - emacs style
|
||||
"\x1b[1;3C", // CSI 1;3 C - xterm with modifiers
|
||||
])
|
||||
|
||||
// Option+Backspace / Ctrl+Backspace (delete word backwards) sequences
|
||||
// Multiple paths cover different terminals:
|
||||
// - \x1b\x7f: terminals with "Option as Meta/Esc+" configured
|
||||
// - \x08: Ctrl+Backspace on macOS/Linux (detected in enhanced-stdin proxy)
|
||||
// - \x1b[27;3;127~: modifyOtherKeys protocol (detected in enhanced-stdin proxy)
|
||||
// - \x1b[127;3u: Kitty keyboard protocol
|
||||
export const OPTION_BACKSPACE_SEQUENCES = new Set([
|
||||
"\x1b\x7f", // Meta+DEL - terminals with "Option as Meta/Esc+" configured
|
||||
"\x1b\x08", // Meta+BS - some terminals send BS (0x08) instead of DEL (0x7f)
|
||||
"\x1b[27;3;127~", // xterm modifyOtherKeys format: Alt+DEL (modifier=3, key=127)
|
||||
"\x1b[127;3u", // Kitty keyboard protocol format: DEL with Alt modifier
|
||||
])
|
||||
|
||||
/**
|
||||
* Enable xterm modifyOtherKeys mode for detecting modifier keys.
|
||||
*
|
||||
* By default, many terminals (especially iTerm2 without "Esc+" configured)
|
||||
* send identical bytes for Option+Backspace and regular Backspace (\x7f).
|
||||
* Enabling modifyOtherKeys level 2 makes the terminal encode modified keys
|
||||
* in a distinct format. For example:
|
||||
* Alt+Backspace → \x1b[27;3;127~ (instead of just \x7f)
|
||||
*
|
||||
* Level 2 is required because level 1 doesn't modify Alt+Backspace (it only
|
||||
* modifies keys without existing encodings, and Backspace has one).
|
||||
* However, level 2 also modifies Ctrl+C, Ctrl+D, Tab, etc. The stdin proxy
|
||||
* in enhanced-stdin.ts translates these back to their original bytes so
|
||||
* Ink's input handling and signal handlers continue to work.
|
||||
*
|
||||
* Terminals that don't support modifyOtherKeys silently ignore the sequence.
|
||||
*
|
||||
* Note: We also keep Kitty protocol sequences (\x1b[127;3u) in
|
||||
* OPTION_BACKSPACE_SEQUENCES for terminals that natively use that protocol.
|
||||
*/
|
||||
export function enableEnhancedKeyboardMode(): void {
|
||||
// xterm modifyOtherKeys level 2 — all modified keys get CSI encoding.
|
||||
// The stdin proxy translates control keys (Ctrl+C, etc.) back to their
|
||||
// original bytes so Ink and signal handlers aren't broken.
|
||||
process.stdout.write("\x1b[>4;2m")
|
||||
|
||||
// Kitty keyboard protocol (progressive enhancement, flags=1).
|
||||
// VSCode terminal (xterm.js) supports this but NOT modifyOtherKeys.
|
||||
// With Kitty, modified keys use the format: \x1b[{keycode};{modifier}u
|
||||
// e.g. Option+Backspace → \x1b[127;3u, Ctrl+Backspace → \x1b[127;5u
|
||||
// Terminals that don't support it silently ignore the sequence.
|
||||
process.stdout.write("\x1b[>1u")
|
||||
}
|
||||
|
||||
/**
|
||||
* Disable enhanced keyboard mode, restoring default terminal behavior.
|
||||
*/
|
||||
export function disableEnhancedKeyboardMode(): void {
|
||||
process.stdout.write("\x1b[>4;0m") // disable modifyOtherKeys
|
||||
process.stdout.write("\x1b[<u") // disable Kitty keyboard protocol
|
||||
}
|
||||
|
||||
@@ -1,36 +1,50 @@
|
||||
/**
|
||||
* Hook to detect Home/End keys from raw stdin.
|
||||
* Hook to detect Home/End keys and Option+Backspace from raw stdin.
|
||||
*
|
||||
* Ink's useInput hook parses Home/End keys but doesn't expose them in the key object,
|
||||
* and sets input to '' for these keys (because they're in nonAlphanumericKeys).
|
||||
* This hook subscribes to raw stdin events to detect Home/End before Ink processes them.
|
||||
*
|
||||
* For Option+Backspace, many terminals (like iTerm2 without "Esc+" configured) send
|
||||
* the same byte (\x7f) for both regular Backspace and Option+Backspace. To distinguish
|
||||
* them, the CLI enables xterm's modifyOtherKeys mode at startup (in index.ts), which
|
||||
* makes the terminal send \x1b[27;3;127~ for Alt+Backspace. A stdin proxy (enhanced-stdin)
|
||||
* intercepts this sequence before Ink sees it and emits an "option-backspace" event.
|
||||
*
|
||||
* This hook listens for:
|
||||
* - Raw stdin events (via internal_eventEmitter) for Home/End keys and \x1b\x7f
|
||||
* - Enhanced key events (via enhancedKeyEvents) for protocol-detected Option+Backspace
|
||||
*/
|
||||
|
||||
import { useStdin } from "ink"
|
||||
import { useCallback, useEffect, useRef } from "react"
|
||||
|
||||
import { END_SEQUENCES, HOME_SEQUENCES } from "../constants/keyboard"
|
||||
import { END_SEQUENCES, HOME_SEQUENCES, OPTION_BACKSPACE_SEQUENCES } from "../constants/keyboard"
|
||||
import { enhancedKeyEvents } from "../utils/enhanced-stdin"
|
||||
|
||||
interface UseHomeEndKeysOptions {
|
||||
onHome: () => void
|
||||
onEnd: () => void
|
||||
onOptionBackspace?: () => void
|
||||
isActive?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to raw stdin to detect Home/End keys.
|
||||
* These keys are parsed by Ink but not exposed in useInput's key object.
|
||||
* Subscribe to raw stdin to detect Home/End keys and Option+Backspace.
|
||||
* These keys are parsed by Ink but not properly exposed in useInput's key object.
|
||||
*/
|
||||
export function useHomeEndKeys({ onHome, onEnd, isActive = true }: UseHomeEndKeysOptions): void {
|
||||
export function useHomeEndKeys({ onHome, onEnd, onOptionBackspace, isActive = true }: UseHomeEndKeysOptions): void {
|
||||
// Use refs to avoid stale closure issues
|
||||
const onHomeRef = useRef(onHome)
|
||||
const onEndRef = useRef(onEnd)
|
||||
const onOptionBackspaceRef = useRef(onOptionBackspace)
|
||||
onHomeRef.current = onHome
|
||||
onEndRef.current = onEnd
|
||||
onOptionBackspaceRef.current = onOptionBackspace
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const { internal_eventEmitter } = useStdin() as any
|
||||
|
||||
// Handle raw stdin data for Home/End keys and traditional \x1b\x7f
|
||||
const handleInput = useCallback((data: Buffer | string) => {
|
||||
const s = typeof data === "string" ? data : data.toString()
|
||||
|
||||
@@ -38,17 +52,35 @@ export function useHomeEndKeys({ onHome, onEnd, isActive = true }: UseHomeEndKey
|
||||
onHomeRef.current()
|
||||
} else if (END_SEQUENCES.has(s)) {
|
||||
onEndRef.current()
|
||||
} else if (OPTION_BACKSPACE_SEQUENCES.has(s)) {
|
||||
// Handles \x1b\x7f from terminals with "Esc+" configured
|
||||
onOptionBackspaceRef.current?.()
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Handle enhanced key events from stdin proxy (modifyOtherKeys protocol)
|
||||
const handleOptionBackspace = useCallback(() => {
|
||||
onOptionBackspaceRef.current?.()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isActive || !internal_eventEmitter) {
|
||||
if (!isActive) {
|
||||
return
|
||||
}
|
||||
|
||||
internal_eventEmitter.on("input", handleInput)
|
||||
return () => {
|
||||
internal_eventEmitter.removeListener("input", handleInput)
|
||||
// Listen for protocol-detected Option+Backspace from stdin proxy
|
||||
enhancedKeyEvents.on("option-backspace", handleOptionBackspace)
|
||||
|
||||
// Listen for raw stdin events (Home/End keys, traditional \x1b\x7f)
|
||||
if (internal_eventEmitter) {
|
||||
internal_eventEmitter.on("input", handleInput)
|
||||
}
|
||||
}, [isActive, internal_eventEmitter, handleInput])
|
||||
|
||||
return () => {
|
||||
enhancedKeyEvents.removeListener("option-backspace", handleOptionBackspace)
|
||||
if (internal_eventEmitter) {
|
||||
internal_eventEmitter.removeListener("input", handleInput)
|
||||
}
|
||||
}
|
||||
}, [isActive, internal_eventEmitter, handleInput, handleOptionBackspace])
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
*
|
||||
* Supports essential terminal shortcuts:
|
||||
* - Option+Left/Right: move by word (via escape sequences)
|
||||
* - Option+Backspace: delete word backwards (handled by useHomeEndKeys)
|
||||
* - Ctrl+A/E: start/end of line
|
||||
* - Ctrl+W: delete word backwards
|
||||
* - Ctrl+U: delete to start of line
|
||||
@@ -27,7 +28,7 @@ type KeyboardSequence =
|
||||
|
||||
/**
|
||||
* Parse keyboard escape sequences for special key combinations.
|
||||
* Only handles Option+arrow - Home/End are handled by useHomeEndKeys.
|
||||
* Handles Option+arrow. Home/End and Option+Backspace are handled by useHomeEndKeys.
|
||||
*/
|
||||
function parseKeyboardSequence(input: string): KeyboardSequence {
|
||||
if (OPTION_LEFT_SEQUENCES.has(input)) {
|
||||
@@ -83,6 +84,7 @@ export interface UseTextInputReturn {
|
||||
|
||||
// Deletion
|
||||
deleteCharBefore: () => void
|
||||
deleteWordBefore: () => void
|
||||
|
||||
// Keyboard shortcut handlers
|
||||
handleKeyboardSequence: (input: string) => boolean
|
||||
@@ -219,6 +221,7 @@ export function useTextInput(): UseTextInputReturn {
|
||||
insertText,
|
||||
setCursorPos,
|
||||
deleteCharBefore,
|
||||
deleteWordBefore,
|
||||
handleKeyboardSequence,
|
||||
handleCtrlShortcut,
|
||||
}
|
||||
|
||||
@@ -99,12 +99,6 @@ describe("CLI Commands", () => {
|
||||
.description("Run kanban")
|
||||
.action(() => {})
|
||||
|
||||
program
|
||||
.command("update")
|
||||
.description("Check for updates and install if available")
|
||||
.option("-v, --verbose", "Show verbose output")
|
||||
.action(() => {})
|
||||
|
||||
// Default command for interactive mode
|
||||
program
|
||||
.argument("[prompt]", "Task prompt")
|
||||
@@ -119,7 +113,6 @@ describe("CLI Commands", () => {
|
||||
.option("--auto-condense", "Enable AI-powered context compaction instead of mechanical truncation")
|
||||
.option("--hooks-dir <path>", "Additional hooks directory")
|
||||
.option("--auto-approve-all", "Enable auto-approve all")
|
||||
.option("--update", "Check for updates and install if available")
|
||||
.option("--kanban", "Run kanban")
|
||||
.option("--tui", "Open the legacy terminal UI instead of the kanban experience")
|
||||
.action(() => {})
|
||||
@@ -322,20 +315,6 @@ describe("CLI Commands", () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("update command", () => {
|
||||
it("should parse update command", () => {
|
||||
const args = ["node", "cli", "update"]
|
||||
program.parse(args)
|
||||
})
|
||||
|
||||
it("should parse --verbose on update command", () => {
|
||||
const updateCmd = getCommand("update")
|
||||
const args = ["--verbose"]
|
||||
updateCmd.parse(args, { from: "user" })
|
||||
expect(updateCmd.opts().verbose).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("auth command", () => {
|
||||
it("should parse auth command", () => {
|
||||
const args = ["node", "cli", "auth"]
|
||||
@@ -460,11 +439,6 @@ describe("CLI Commands", () => {
|
||||
expect(program.opts().kanban).toBe(true)
|
||||
})
|
||||
|
||||
it("should parse --update flag", () => {
|
||||
program.parse(["node", "cli", "--update"])
|
||||
expect(program.opts().update).toBe(true)
|
||||
})
|
||||
|
||||
it("should parse --tui flag", () => {
|
||||
program.parse(["node", "cli", "--tui"])
|
||||
expect(program.opts().tui).toBe(true)
|
||||
@@ -480,7 +454,6 @@ describe("CLI Commands", () => {
|
||||
expect(commandNames).toContain("auth")
|
||||
expect(commandNames).toContain("mcp")
|
||||
expect(commandNames).toContain("kanban")
|
||||
expect(commandNames).toContain("update")
|
||||
})
|
||||
|
||||
it("should have correct aliases", () => {
|
||||
|
||||
+32
-13
@@ -29,6 +29,7 @@ import { version as CLI_VERSION } from "../package.json"
|
||||
import { runAcpMode } from "./acp/index.js"
|
||||
import { App } from "./components/App"
|
||||
import { KanbanMigrationView } from "./components/KanbanMigrationView"
|
||||
import { disableEnhancedKeyboardMode, enableEnhancedKeyboardMode } from "./constants/keyboard"
|
||||
import { checkRawModeSupport } from "./context/StdinContext"
|
||||
import { createCliHostBridgeProvider } from "./controllers"
|
||||
import { CliCommentReviewController } from "./controllers/CliCommentReviewController"
|
||||
@@ -36,6 +37,7 @@ import { CliWebviewProvider } from "./controllers/CliWebviewProvider"
|
||||
import { isAuthConfigured } from "./utils/auth"
|
||||
import { restoreConsole, suppressConsoleUnlessVerbose } from "./utils/console"
|
||||
import { printInfo, printWarning } from "./utils/display"
|
||||
import { createEnhancedStdin, destroyEnhancedStdin } from "./utils/enhanced-stdin"
|
||||
import {
|
||||
forwardSignalToKanbanProcess,
|
||||
isKanbanCommandAvailable,
|
||||
@@ -538,6 +540,12 @@ function setupSignalHandlers() {
|
||||
}
|
||||
isShuttingDown = true
|
||||
|
||||
// Restore terminal keyboard mode before any output or exit.
|
||||
// Without this, if the process crashes or is killed, the terminal stays
|
||||
// in modifyOtherKeys/Kitty mode, corrupting subsequent input in the shell.
|
||||
disableEnhancedKeyboardMode()
|
||||
destroyEnhancedStdin()
|
||||
|
||||
// Notify components to hide UI before shutdown
|
||||
shutdownEvent.fire()
|
||||
|
||||
@@ -602,6 +610,14 @@ function setupSignalHandlers() {
|
||||
process.on("uncaughtException", (reason: unknown) => {
|
||||
onUnhandledException(reason, "uncaughtException")
|
||||
})
|
||||
|
||||
// Safety net: restore terminal keyboard mode on any exit path.
|
||||
// This is synchronous-only (Node.js constraint for 'exit' event) but
|
||||
// disableEnhancedKeyboardMode() is just process.stdout.write() which is sync.
|
||||
// Prevents terminal corruption if process.exit() is called from unexpected code paths.
|
||||
process.on("exit", () => {
|
||||
disableEnhancedKeyboardMode()
|
||||
})
|
||||
}
|
||||
|
||||
setupSignalHandlers()
|
||||
@@ -690,11 +706,23 @@ async function runInkApp(element: React.ReactElement, cleanup: () => Promise<voi
|
||||
// Clear terminal for clean UI - robot will render at row 1
|
||||
process.stdout.write("\x1b[2J\x1b[3J\x1b[H")
|
||||
|
||||
// Enable xterm modifyOtherKeys mode so terminals send distinct sequences
|
||||
// for modifier+key combos (e.g., Alt+Backspace → \x1b[27;3;127~).
|
||||
// This must be enabled before creating the stdin proxy.
|
||||
enableEnhancedKeyboardMode()
|
||||
|
||||
// Create a stdin proxy that intercepts modifier key sequences before Ink
|
||||
// sees them. Without this, Ink would insert the raw escape sequences as text.
|
||||
const enhancedStdin = createEnhancedStdin()
|
||||
|
||||
// Note: incrementalRendering is disabled because it causes UI glitches on terminal resize.
|
||||
// Ink's incremental rendering tries to erase N lines based on previous output height,
|
||||
// but when the terminal shrinks, this leaves artifacts. Gemini CLI only enables
|
||||
// incrementalRendering when alternateBuffer is also enabled (which we don't use).
|
||||
const { waitUntilExit, unmount } = render(element, { exitOnCtrlC: true })
|
||||
const { waitUntilExit, unmount } = render(element, {
|
||||
exitOnCtrlC: true,
|
||||
stdin: enhancedStdin as any,
|
||||
})
|
||||
|
||||
try {
|
||||
await waitUntilExit()
|
||||
@@ -704,6 +732,8 @@ async function runInkApp(element: React.ReactElement, cleanup: () => Promise<voi
|
||||
} catch {
|
||||
// Already unmounted
|
||||
}
|
||||
destroyEnhancedStdin()
|
||||
disableEnhancedKeyboardMode()
|
||||
restoreConsole()
|
||||
await cleanup()
|
||||
}
|
||||
@@ -1027,7 +1057,7 @@ program
|
||||
.command("update")
|
||||
.description("Check for updates and install if available")
|
||||
.option("-v, --verbose", "Show verbose output")
|
||||
.action((options) => checkForUpdates(CLI_VERSION, { verbose: options.verbose, includeKanban: true }))
|
||||
.action(() => checkForUpdates(CLI_VERSION))
|
||||
|
||||
program
|
||||
.command("kanban")
|
||||
@@ -1183,7 +1213,6 @@ program
|
||||
.option("--auto-condense", "Enable AI-powered context compaction instead of mechanical truncation")
|
||||
.option("--hooks-dir <path>", "Path to additional hooks directory for runtime hook injection")
|
||||
.option("--acp", "Run in ACP (Agent Client Protocol) mode for editor integration")
|
||||
.option("--update", "Check for updates and install if available")
|
||||
.option("--kanban", `Run ${KANBAN_LAUNCH_COMMAND}`)
|
||||
.option("--tui", "Open the legacy terminal UI instead of the kanban experience")
|
||||
.option("-T, --taskId <id>", "Resume an existing task by ID")
|
||||
@@ -1194,16 +1223,6 @@ program
|
||||
exit(1)
|
||||
}
|
||||
|
||||
if (options.update) {
|
||||
if (prompt || options.taskId || options.continue || options.kanban || options.tui || options.acp) {
|
||||
printWarning("Use --update without a prompt or task flags.")
|
||||
exit(1)
|
||||
}
|
||||
|
||||
await checkForUpdates(CLI_VERSION, { verbose: options.verbose, includeKanban: true })
|
||||
return
|
||||
}
|
||||
|
||||
if (options.kanban) {
|
||||
if (prompt) {
|
||||
printWarning("Use --kanban without a prompt.")
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import { describe, expect, it } from "vitest"
|
||||
|
||||
import { translateModifyOtherKeys } from "./enhanced-stdin"
|
||||
|
||||
describe("translateModifyOtherKeys", () => {
|
||||
// Modifier encoding: value = bits + 1
|
||||
// modifier 2 = Shift, 3 = Alt, 4 = Shift+Alt, 5 = Ctrl, 6 = Shift+Ctrl, 7 = Alt+Ctrl
|
||||
|
||||
describe("Alt+Backspace (word delete)", () => {
|
||||
it("returns null for Alt+Backspace (modifyOtherKeys: mod=3, key=127)", () => {
|
||||
// \x1b[27;3;127~ — iTerm2 with modifyOtherKeys level 2
|
||||
expect(translateModifyOtherKeys(3, 127)).toBeNull()
|
||||
})
|
||||
|
||||
it("returns null for Alt+Backspace (Kitty: key=127, mod=3)", () => {
|
||||
// \x1b[127;3u — Kitty keyboard protocol
|
||||
expect(translateModifyOtherKeys(3, 127)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe("Ctrl+Backspace (word delete)", () => {
|
||||
it("returns null for Ctrl+Backspace (Kitty: key=127, mod=5)", () => {
|
||||
// \x1b[127;5u — VSCode terminal via Kitty protocol
|
||||
expect(translateModifyOtherKeys(5, 127)).toBeNull()
|
||||
})
|
||||
|
||||
it("returns null for Ctrl+Alt+Backspace (mod=7, key=127)", () => {
|
||||
expect(translateModifyOtherKeys(7, 127)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe("Ctrl+letter translation", () => {
|
||||
it("translates Ctrl+C (mod=5, key=99) to \\x03", () => {
|
||||
expect(translateModifyOtherKeys(5, 99)).toBe("\x03")
|
||||
})
|
||||
|
||||
it("translates Ctrl+D (mod=5, key=100) to \\x04", () => {
|
||||
expect(translateModifyOtherKeys(5, 100)).toBe("\x04")
|
||||
})
|
||||
|
||||
it("translates Ctrl+Z (mod=5, key=122) to \\x1a", () => {
|
||||
expect(translateModifyOtherKeys(5, 122)).toBe("\x1a")
|
||||
})
|
||||
|
||||
it("translates Ctrl+A (mod=5, key=65) to \\x01", () => {
|
||||
expect(translateModifyOtherKeys(5, 65)).toBe("\x01")
|
||||
})
|
||||
|
||||
it("translates Ctrl+W (mod=5, key=119) to \\x17", () => {
|
||||
// Ctrl+W = word delete (traditional)
|
||||
expect(translateModifyOtherKeys(5, 119)).toBe("\x17")
|
||||
})
|
||||
})
|
||||
|
||||
describe("Alt+letter translation (Meta prefix)", () => {
|
||||
it("translates Alt+b (mod=3, key=98) to ESC+b", () => {
|
||||
// Option+Left sends \x1bb in many terminals
|
||||
expect(translateModifyOtherKeys(3, 98)).toBe("\x1bb")
|
||||
})
|
||||
|
||||
it("translates Alt+f (mod=3, key=102) to ESC+f", () => {
|
||||
// Option+Right sends \x1bf in many terminals
|
||||
expect(translateModifyOtherKeys(3, 102)).toBe("\x1bf")
|
||||
})
|
||||
|
||||
it("translates Alt+d (mod=3, key=100) to ESC+d", () => {
|
||||
expect(translateModifyOtherKeys(3, 100)).toBe("\x1bd")
|
||||
})
|
||||
})
|
||||
|
||||
describe("Alt+Ctrl+letter translation", () => {
|
||||
it("translates Alt+Ctrl+C (mod=7, key=99) to ESC+\\x03", () => {
|
||||
expect(translateModifyOtherKeys(7, 99)).toBe("\x1b\x03")
|
||||
})
|
||||
})
|
||||
|
||||
describe("Shift-only (passthrough)", () => {
|
||||
it("translates Shift+A (mod=2, key=65) to 'A'", () => {
|
||||
// Shift alone: no alt, no ctrl — just pass through keycode
|
||||
expect(translateModifyOtherKeys(2, 65)).toBe("A")
|
||||
})
|
||||
})
|
||||
|
||||
describe("edge cases", () => {
|
||||
it("handles Tab keycode (mod=5, key=9) — Ctrl+Tab", () => {
|
||||
// keycode 9 < 64, so doesn't hit the Ctrl+letter branch
|
||||
expect(translateModifyOtherKeys(5, 9)).toBe("\t")
|
||||
})
|
||||
|
||||
it("handles Enter keycode (mod=3, key=13) — Alt+Enter", () => {
|
||||
// keycode 13 < 64, alt+key where key isn't in letter range
|
||||
expect(translateModifyOtherKeys(3, 13)).toBe("\x1b\r")
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,239 @@
|
||||
/**
|
||||
* Enhanced stdin proxy for intercepting modified key sequences.
|
||||
*
|
||||
* When xterm modifyOtherKeys mode (level 2) is enabled, terminals send
|
||||
* distinct escape sequences for ALL modifier+key combos. For example:
|
||||
* Alt+Backspace → \x1b[27;3;127~
|
||||
* Ctrl+C → \x1b[27;5;99~
|
||||
*
|
||||
* This is great for distinguishing Alt+Backspace from Backspace, but it
|
||||
* breaks Ink's handling of Ctrl+C, Ctrl+D, Tab, etc. since Ink expects
|
||||
* the traditional byte encodings (\x03, \x04, \x09).
|
||||
*
|
||||
* This module provides a PassThrough stream that sits between real stdin
|
||||
* and Ink, translating modifyOtherKeys sequences back to their traditional
|
||||
* encodings — except for Alt+Backspace which gets emitted as an event.
|
||||
*/
|
||||
|
||||
import { EventEmitter } from "events"
|
||||
import { PassThrough } from "stream"
|
||||
|
||||
/** Event emitter for enhanced key events that were intercepted from stdin */
|
||||
export const enhancedKeyEvents = new EventEmitter()
|
||||
|
||||
/**
|
||||
* Regex matching the xterm modifyOtherKeys CSI format: \x1b[27;{modifier};{keycode}~
|
||||
* Groups: modifier (number), keycode (number)
|
||||
*/
|
||||
const MODIFY_OTHER_KEYS_RE = /\x1b\[27;(\d+);(\d+)~/g
|
||||
|
||||
/**
|
||||
* Also match Kitty keyboard protocol format: \x1b[{keycode};{modifier}u
|
||||
*/
|
||||
const KITTY_KEY_RE = /\x1b\[(\d+);(\d+)u/g
|
||||
|
||||
const ESC = "\x1b"
|
||||
|
||||
let activeProxy: (PassThrough & { setRawMode?: (mode: boolean) => void; isTTY?: boolean }) | null = null
|
||||
let teardownProxyListeners: (() => void) | null = null
|
||||
|
||||
/**
|
||||
* Translate a modifyOtherKeys sequence back to its traditional encoding.
|
||||
*
|
||||
* Modifier values (from xterm):
|
||||
* 2 = Shift, 3 = Alt, 4 = Shift+Alt, 5 = Ctrl, 6 = Shift+Ctrl,
|
||||
* 7 = Alt+Ctrl, 8 = Shift+Alt+Ctrl
|
||||
*
|
||||
* @returns the translated bytes string, or null if this should be emitted as an event
|
||||
*/
|
||||
export function translateModifyOtherKeys(modifier: number, keycode: number): string | null {
|
||||
// xterm modifier encoding is (bits + 1), so:
|
||||
// modifier 2 = Shift (bit 0)
|
||||
// modifier 3 = Alt (bit 1)
|
||||
// modifier 5 = Ctrl (bit 2)
|
||||
// modifier 7 = Alt+Ctrl (bit 1 + bit 2)
|
||||
const bits = modifier - 1
|
||||
const hasAlt = (bits & 0x02) !== 0
|
||||
const hasCtrl = (bits & 0x04) !== 0
|
||||
|
||||
// Alt+Backspace (DEL=127) or Ctrl+Backspace (DEL=127) → emit as word-delete event
|
||||
// Alt+Backspace: modifier=3 (hasAlt), Ctrl+Backspace: modifier=5 (hasCtrl)
|
||||
// Both should trigger word deletion. Ctrl+Backspace is sent by VSCode terminal
|
||||
// via Kitty protocol as \x1b[127;5u.
|
||||
if (keycode === 127 && (hasAlt || hasCtrl)) {
|
||||
return null // signal to emit event
|
||||
}
|
||||
|
||||
// Ctrl+letter: translate back to control character
|
||||
// Ctrl+C (99) → \x03, Ctrl+D (100) → \x04, etc.
|
||||
if (hasCtrl && keycode >= 64 && keycode <= 127) {
|
||||
const ctrlChar = String.fromCharCode(keycode & 0x1f)
|
||||
if (hasAlt) {
|
||||
return "\x1b" + ctrlChar // Alt+Ctrl+key → ESC + control char
|
||||
}
|
||||
return ctrlChar
|
||||
}
|
||||
|
||||
// Alt+letter: translate back to ESC + character (Meta prefix)
|
||||
if (hasAlt && !hasCtrl) {
|
||||
return "\x1b" + String.fromCharCode(keycode)
|
||||
}
|
||||
|
||||
// For other combinations, pass through the original keycode
|
||||
return String.fromCharCode(keycode)
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an enhanced stdin proxy that intercepts modifier key sequences.
|
||||
*
|
||||
* Returns a PassThrough stream that proxies stdin but translates modifyOtherKeys
|
||||
* sequences back to traditional encodings. Alt+Backspace is emitted as an event
|
||||
* on enhancedKeyEvents instead of being passed to Ink.
|
||||
*/
|
||||
export function createEnhancedStdin(): PassThrough & { setRawMode?: (mode: boolean) => void; isTTY?: boolean } {
|
||||
if (activeProxy) {
|
||||
return activeProxy
|
||||
}
|
||||
|
||||
const proxy = new PassThrough() as PassThrough & {
|
||||
setRawMode?: (mode: boolean) => void
|
||||
isTTY?: boolean
|
||||
fd?: number
|
||||
}
|
||||
|
||||
// Proxy terminal properties and methods that Ink needs.
|
||||
// Ink's useInput hook calls stdin.ref()/unref() and setRawMode(),
|
||||
// which exist on process.stdin (a TTY Socket) but not on PassThrough.
|
||||
proxy.isTTY = process.stdin.isTTY
|
||||
;(proxy as any).fd = (process.stdin as any).fd
|
||||
if (typeof process.stdin.setRawMode === "function") {
|
||||
;(proxy as any).setRawMode = (mode: boolean) => {
|
||||
process.stdin.setRawMode(mode)
|
||||
}
|
||||
}
|
||||
;(proxy as any).ref = () => {
|
||||
if (typeof (process.stdin as any).ref === "function") {
|
||||
;(process.stdin as any).ref()
|
||||
}
|
||||
}
|
||||
;(proxy as any).unref = () => {
|
||||
if (typeof (process.stdin as any).unref === "function") {
|
||||
;(process.stdin as any).unref()
|
||||
}
|
||||
}
|
||||
|
||||
const onData = (data: Buffer) => {
|
||||
const str = data.toString()
|
||||
|
||||
// Ctrl+Backspace sends \x08 (BS) on macOS/Linux — distinct from regular
|
||||
// Backspace (\x7f). Intercept it as word-delete since Option+Backspace
|
||||
// is indistinguishable from Backspace in terminals without modifyOtherKeys.
|
||||
// This matches Gemini CLI and Codex CLI behavior.
|
||||
if (str === "\x08") {
|
||||
enhancedKeyEvents.emit("option-backspace")
|
||||
return
|
||||
}
|
||||
|
||||
// Fast path: if no ESC character, pass through as-is
|
||||
if (!str.includes(ESC)) {
|
||||
proxy.write(data)
|
||||
return
|
||||
}
|
||||
|
||||
// Check for modifyOtherKeys or Kitty sequences
|
||||
// Use a combined regex approach: find and replace all protocol sequences
|
||||
let result = ""
|
||||
let lastIndex = 0
|
||||
|
||||
// Process modifyOtherKeys format: \x1b[27;{mod};{key}~
|
||||
const combined = str
|
||||
const matches: Array<{ index: number; length: number; modifier: number; keycode: number; format: string }> = []
|
||||
|
||||
// Find all modifyOtherKeys matches
|
||||
MODIFY_OTHER_KEYS_RE.lastIndex = 0
|
||||
let match: RegExpExecArray | null
|
||||
while ((match = MODIFY_OTHER_KEYS_RE.exec(combined)) !== null) {
|
||||
matches.push({
|
||||
index: match.index,
|
||||
length: match[0].length,
|
||||
modifier: Number.parseInt(match[1], 10),
|
||||
keycode: Number.parseInt(match[2], 10),
|
||||
format: "modifyOtherKeys",
|
||||
})
|
||||
}
|
||||
|
||||
// Find all Kitty protocol matches
|
||||
KITTY_KEY_RE.lastIndex = 0
|
||||
while ((match = KITTY_KEY_RE.exec(combined)) !== null) {
|
||||
matches.push({
|
||||
index: match.index,
|
||||
length: match[0].length,
|
||||
modifier: Number.parseInt(match[2], 10), // Note: Kitty format is [keycode;modifier]
|
||||
keycode: Number.parseInt(match[1], 10),
|
||||
format: "kitty",
|
||||
})
|
||||
}
|
||||
|
||||
// If no protocol sequences found, pass through as-is
|
||||
if (matches.length === 0) {
|
||||
proxy.write(data)
|
||||
return
|
||||
}
|
||||
|
||||
// Sort by index and process
|
||||
matches.sort((a, b) => a.index - b.index)
|
||||
|
||||
for (const m of matches) {
|
||||
// Add any text before this match
|
||||
if (m.index > lastIndex) {
|
||||
result += combined.slice(lastIndex, m.index)
|
||||
}
|
||||
|
||||
const translated = translateModifyOtherKeys(m.modifier, m.keycode)
|
||||
if (translated === null) {
|
||||
// Emit as event (e.g., Alt+Backspace)
|
||||
// Flush what we have so far
|
||||
if (result.length > 0) {
|
||||
proxy.write(Buffer.from(result))
|
||||
result = ""
|
||||
}
|
||||
enhancedKeyEvents.emit("option-backspace")
|
||||
} else {
|
||||
result += translated
|
||||
}
|
||||
|
||||
lastIndex = m.index + m.length
|
||||
}
|
||||
|
||||
// Add remaining text after last match
|
||||
if (lastIndex < combined.length) {
|
||||
result += combined.slice(lastIndex)
|
||||
}
|
||||
|
||||
if (result.length > 0) {
|
||||
proxy.write(Buffer.from(result))
|
||||
}
|
||||
}
|
||||
|
||||
const onEnd = () => proxy.end()
|
||||
const onError = (err: Error) => proxy.emit("error", err)
|
||||
|
||||
process.stdin.on("data", onData)
|
||||
process.stdin.on("end", onEnd)
|
||||
process.stdin.on("error", onError)
|
||||
|
||||
teardownProxyListeners = () => {
|
||||
process.stdin.off("data", onData)
|
||||
process.stdin.off("end", onEnd)
|
||||
process.stdin.off("error", onError)
|
||||
}
|
||||
activeProxy = proxy
|
||||
|
||||
return proxy
|
||||
}
|
||||
|
||||
export function destroyEnhancedStdin(): void {
|
||||
teardownProxyListeners?.()
|
||||
teardownProxyListeners = null
|
||||
activeProxy = null
|
||||
}
|
||||
+60
-188
@@ -1,10 +1,9 @@
|
||||
import { type ChildProcess, spawn, spawnSync } from "node:child_process"
|
||||
import { spawn } from "node:child_process"
|
||||
import { realpathSync } from "node:fs"
|
||||
import { exit } from "node:process"
|
||||
import { ClineEndpoint } from "@/config"
|
||||
import { fetch } from "@/shared/net"
|
||||
import { printInfo, printSuccess, printWarning } from "./display"
|
||||
import { resolveKanbanInstallCommand, spawnKanbanInstallProcess } from "./kanban"
|
||||
import { printInfo, printWarning } from "./display"
|
||||
|
||||
export enum PackageManager {
|
||||
NPM = "npm",
|
||||
@@ -20,11 +19,6 @@ interface InstallationInfo {
|
||||
updateCommand?: string
|
||||
}
|
||||
|
||||
interface CheckForUpdatesOptions {
|
||||
verbose?: boolean
|
||||
includeKanban?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a version string is a nightly build.
|
||||
*/
|
||||
@@ -97,12 +91,9 @@ function getInstallationInfo(currentVersion: string): InstallationInfo {
|
||||
* Uses the appropriate tag based on whether the current version is nightly.
|
||||
*/
|
||||
async function getLatestVersion(currentVersion: string): Promise<string | null> {
|
||||
return getLatestPackageVersion("cline", getNpmTag(currentVersion))
|
||||
}
|
||||
|
||||
async function getLatestPackageVersion(packageName: string, tag = "latest"): Promise<string | null> {
|
||||
try {
|
||||
const response = await fetch(`https://registry.npmjs.org/${encodeURIComponent(packageName)}/${tag}`)
|
||||
const tag = getNpmTag(currentVersion)
|
||||
const response = await fetch(`https://registry.npmjs.org/cline/${tag}`)
|
||||
if (!response.ok) return null
|
||||
const data = (await response.json()) as { version: string }
|
||||
return data.version || null
|
||||
@@ -111,29 +102,6 @@ async function getLatestPackageVersion(packageName: string, tag = "latest"): Pro
|
||||
}
|
||||
}
|
||||
|
||||
async function getLatestKanbanVersion(): Promise<string | null> {
|
||||
return getLatestPackageVersion("kanban")
|
||||
}
|
||||
|
||||
function getInstalledKanbanVersion(): string | null {
|
||||
try {
|
||||
const command = process.platform === "win32" ? "kanban.cmd" : "kanban"
|
||||
const result = spawnSync(command, ["--version"], {
|
||||
encoding: "utf8",
|
||||
shell: process.platform === "win32",
|
||||
})
|
||||
if (result.status !== 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
const output = `${result.stdout ?? ""}\n${result.stderr ?? ""}`.trim()
|
||||
const versionMatch = output.match(/\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?/)
|
||||
return versionMatch?.[0] ?? null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-update check that runs on CLI startup.
|
||||
* Checks for updates asynchronously (non-blocking), then spawns a detached
|
||||
@@ -189,181 +157,85 @@ async function checkAndUpdate(currentVersion: string, updateCommand: string): Pr
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForProcessExit(updateProcess: ChildProcess): Promise<number> {
|
||||
return new Promise<number>((resolve, reject) => {
|
||||
updateProcess.once("close", (code) => {
|
||||
resolve(code ?? 1)
|
||||
})
|
||||
|
||||
updateProcess.once("error", (error) => {
|
||||
reject(error)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async function runClineUpdate(updateCommand: string): Promise<number> {
|
||||
const updateProcess = spawn(updateCommand, {
|
||||
stdio: "inherit",
|
||||
shell: true,
|
||||
env: process.env,
|
||||
windowsHide: true,
|
||||
})
|
||||
|
||||
return waitForProcessExit(updateProcess)
|
||||
}
|
||||
|
||||
type KanbanInstallCommand = NonNullable<ReturnType<typeof resolveKanbanInstallCommand>>
|
||||
|
||||
async function runKanbanUpdate(installCommand: KanbanInstallCommand): Promise<number> {
|
||||
const updateProcess = spawnKanbanInstallProcess(installCommand, {
|
||||
env: process.env,
|
||||
windowsHide: true,
|
||||
})
|
||||
return waitForProcessExit(updateProcess)
|
||||
}
|
||||
|
||||
function formatUpdateSummaryTargets(targets: string[]): string {
|
||||
if (targets.length === 0) {
|
||||
return ""
|
||||
}
|
||||
if (targets.length === 1) {
|
||||
return targets[0]
|
||||
}
|
||||
if (targets.length === 2) {
|
||||
return `${targets[0]} and ${targets[1]}`
|
||||
}
|
||||
return `${targets.slice(0, -1).join(", ")}, and ${targets.at(-1)}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Check for updates and install if available (manual command)
|
||||
*/
|
||||
export async function checkForUpdates(currentVersion: string, options: CheckForUpdatesOptions = {}) {
|
||||
const includeKanban = options.includeKanban ?? true
|
||||
|
||||
printInfo("Checking for updates to cline and kanban packages...")
|
||||
export async function checkForUpdates(currentVersion: string, options?: { verbose?: boolean }) {
|
||||
printInfo("Checking for updates...")
|
||||
|
||||
const { updateCommand, packageManager } = getInstallationInfo(currentVersion)
|
||||
|
||||
try {
|
||||
const latestClineVersion = await getLatestVersion(currentVersion)
|
||||
const canCheckClineVersion = latestClineVersion !== null
|
||||
const latestVersion = await getLatestVersion(currentVersion)
|
||||
if (!latestVersion) {
|
||||
printWarning("Failed to check for updates: could not fetch latest version")
|
||||
exit(1)
|
||||
}
|
||||
|
||||
if (options?.verbose) {
|
||||
printInfo(`Current version: ${currentVersion}`)
|
||||
printInfo(`Latest version: ${latestVersion}`)
|
||||
printInfo(`Package manager: ${packageManager}`)
|
||||
if (canCheckClineVersion) {
|
||||
printInfo(`Latest version: ${latestClineVersion}`)
|
||||
}
|
||||
}
|
||||
|
||||
if (!canCheckClineVersion) {
|
||||
printWarning("Failed to check for Cline updates: could not fetch latest version")
|
||||
}
|
||||
|
||||
const clineComparison = latestClineVersion ? compareVersions(currentVersion, latestClineVersion) : null
|
||||
const clineUpdateAvailable = clineComparison !== null && clineComparison < 0
|
||||
const clineIsUpToDate = clineComparison !== null && clineComparison === 0
|
||||
const canUpdateCline = clineUpdateAvailable && Boolean(updateCommand)
|
||||
|
||||
if (clineUpdateAvailable && latestClineVersion) {
|
||||
printInfo(`New version available: ${latestClineVersion} (current: ${currentVersion})`)
|
||||
}
|
||||
|
||||
if (clineUpdateAvailable && !updateCommand) {
|
||||
printInfo("Unable to determine Cline update command for your installation.")
|
||||
printInfo("Please update Cline manually using your package manager.")
|
||||
}
|
||||
|
||||
const kanbanInstallCommand = includeKanban ? resolveKanbanInstallCommand() : null
|
||||
const kanbanInstallerAvailable = kanbanInstallCommand !== null
|
||||
if (includeKanban && !kanbanInstallerAvailable && options.verbose) {
|
||||
printWarning("Unable to determine Kanban update command (npm, pnpm, or bun not found in PATH).")
|
||||
}
|
||||
const latestKanbanVersion = kanbanInstallerAvailable ? await getLatestKanbanVersion() : null
|
||||
const installedKanbanVersion = includeKanban ? getInstalledKanbanVersion() : null
|
||||
const kanbanIsUpToDate =
|
||||
latestKanbanVersion !== null &&
|
||||
installedKanbanVersion !== null &&
|
||||
compareVersions(installedKanbanVersion, latestKanbanVersion) >= 0
|
||||
const shouldInstallKanban =
|
||||
kanbanInstallerAvailable &&
|
||||
latestKanbanVersion !== null &&
|
||||
(installedKanbanVersion === null || compareVersions(installedKanbanVersion, latestKanbanVersion) < 0)
|
||||
|
||||
if (!canCheckClineVersion && !shouldInstallKanban) {
|
||||
exit(1)
|
||||
}
|
||||
|
||||
if (!canUpdateCline && !shouldInstallKanban) {
|
||||
if (clineIsUpToDate && kanbanIsUpToDate && installedKanbanVersion) {
|
||||
printInfo(`You are already on the latest version cline@${currentVersion} and kanban@${installedKanbanVersion}`)
|
||||
} else if (clineIsUpToDate) {
|
||||
printInfo(`You are already on the latest version cline@${currentVersion}`)
|
||||
}
|
||||
// Compare versions
|
||||
if (latestVersion === currentVersion) {
|
||||
printInfo(`You are already on the latest version (${currentVersion})`)
|
||||
exit(0)
|
||||
}
|
||||
|
||||
let hadFailure = false
|
||||
const installedUpdates: string[] = []
|
||||
|
||||
if (canUpdateCline && updateCommand && latestClineVersion) {
|
||||
printInfo(`Installing cline@${latestClineVersion}...`)
|
||||
try {
|
||||
const clineUpdateCode = await runClineUpdate(updateCommand)
|
||||
if (clineUpdateCode === 0) {
|
||||
installedUpdates.push(`cline@${latestClineVersion}`)
|
||||
} else {
|
||||
printWarning(`Cline update failed. Please try running: ${updateCommand}`)
|
||||
hadFailure = true
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
printWarning(`Failed to run Cline update: ${message}`)
|
||||
printInfo(`Please try running manually: ${updateCommand}`)
|
||||
hadFailure = true
|
||||
}
|
||||
// Check if current is newer (dev version)
|
||||
if (compareVersions(currentVersion, latestVersion) > 0) {
|
||||
printInfo(`You are already on a newer version ${currentVersion} (latest: ${latestVersion})`)
|
||||
exit(0)
|
||||
}
|
||||
|
||||
if (shouldInstallKanban && kanbanInstallCommand && latestKanbanVersion) {
|
||||
const kanbanTargetVersion = latestKanbanVersion ?? "latest"
|
||||
printInfo(`Installing kanban@${kanbanTargetVersion}...`)
|
||||
try {
|
||||
const kanbanUpdateCode = await runKanbanUpdate(kanbanInstallCommand)
|
||||
if (kanbanUpdateCode === 0) {
|
||||
installedUpdates.push(`kanban@${kanbanTargetVersion}`)
|
||||
} else {
|
||||
printWarning(`Kanban update failed. Please try running: ${kanbanInstallCommand.displayCommand}`)
|
||||
hadFailure = true
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
printWarning(`Failed to run Kanban update: ${message}`)
|
||||
if (kanbanInstallCommand) {
|
||||
printInfo(`Please try running manually: ${kanbanInstallCommand.displayCommand}`)
|
||||
}
|
||||
hadFailure = true
|
||||
}
|
||||
printInfo(`New version available: ${latestVersion} (current: ${currentVersion})`)
|
||||
|
||||
if (!updateCommand) {
|
||||
printInfo("Unable to determine update command for your installation.")
|
||||
printInfo("Please update manually using your package manager.")
|
||||
exit(0)
|
||||
}
|
||||
|
||||
if (!hadFailure) {
|
||||
if (installedUpdates.length > 1) {
|
||||
printSuccess(`Installed updates for ${formatUpdateSummaryTargets(installedUpdates)}`)
|
||||
} else if (installedUpdates.length === 1) {
|
||||
printSuccess(`Installed update for ${installedUpdates[0]}`)
|
||||
// Ask user to confirm update
|
||||
const userConfirmed = new Promise<boolean>((resolve) => {
|
||||
process.stdout.write("Do you want to update now? (y/N): ")
|
||||
process.stdin.setEncoding("utf-8")
|
||||
process.stdin.once("data", (dataBuff) => {
|
||||
const input = dataBuff.toString().trim().toLowerCase()
|
||||
resolve(input === "y" || input === "yes")
|
||||
})
|
||||
})
|
||||
|
||||
if (!(await userConfirmed)) {
|
||||
exit(0)
|
||||
}
|
||||
|
||||
printInfo(`Installing update via ${packageManager}...`)
|
||||
|
||||
const updateProcess = spawn(updateCommand, {
|
||||
stdio: "inherit",
|
||||
shell: true,
|
||||
env: process.env,
|
||||
windowsHide: true,
|
||||
})
|
||||
|
||||
updateProcess.on("close", (code) => {
|
||||
if (code === 0) {
|
||||
printInfo(`Successfully updated to version ${latestVersion}`)
|
||||
exit(0)
|
||||
} else {
|
||||
printInfo("No updates were installed.")
|
||||
printWarning(`Update failed. Please try running: ${updateCommand}`)
|
||||
exit(1)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
if (hadFailure) {
|
||||
updateProcess.on("error", (err) => {
|
||||
printWarning(`Failed to run update: ${err.message}`)
|
||||
printInfo(`Please try running manually: ${updateCommand}`)
|
||||
exit(1)
|
||||
}
|
||||
|
||||
if (canUpdateCline || shouldInstallKanban) {
|
||||
exit(0)
|
||||
}
|
||||
exit(1)
|
||||
})
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
printWarning(`Error checking for updates: ${message}`)
|
||||
@@ -387,7 +259,7 @@ function parseVersion(version: string): ParsedVersion {
|
||||
return {
|
||||
base: nightlyMatch[1].split(".").map(Number),
|
||||
isNightly: true,
|
||||
timestamp: Number.parseInt(nightlyMatch[2], 10),
|
||||
timestamp: parseInt(nightlyMatch[2], 10),
|
||||
}
|
||||
}
|
||||
return {
|
||||
|
||||
+8
-22
@@ -258,7 +258,7 @@
|
||||
"enterprise-solutions/sso-setup",
|
||||
"enterprise-solutions/team-management/managing-members",
|
||||
{
|
||||
"group": "Remote Provider Configuration",
|
||||
"group": "SaaS Provider Configuration",
|
||||
"pages": [
|
||||
"enterprise-solutions/configuration/remote-configuration/overview",
|
||||
{
|
||||
@@ -268,33 +268,19 @@
|
||||
"enterprise-solutions/configuration/remote-configuration/aws-bedrock/member-configuration"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Google Vertex AI",
|
||||
"pages": [
|
||||
"enterprise-solutions/configuration/remote-configuration/google-vertex/admin-configuration",
|
||||
"enterprise-solutions/configuration/remote-configuration/google-vertex/member-configuration"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "OpenAI Compatible",
|
||||
"pages": [
|
||||
"enterprise-solutions/configuration/remote-configuration/openai-compatible/admin-configuration",
|
||||
"enterprise-solutions/configuration/remote-configuration/openai-compatible/member-configuration"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Anthropic",
|
||||
"pages": [
|
||||
"enterprise-solutions/configuration/remote-configuration/anthropic/admin-configuration",
|
||||
"enterprise-solutions/configuration/remote-configuration/anthropic/member-configuration"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "LiteLLM",
|
||||
"pages": [
|
||||
"enterprise-solutions/configuration/remote-configuration/litellm/admin-configuration",
|
||||
"enterprise-solutions/configuration/remote-configuration/litellm/member-configuration"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Google Vertex AI",
|
||||
"pages": [
|
||||
"enterprise-solutions/configuration/remote-configuration/google-vertex/admin-configuration",
|
||||
"enterprise-solutions/configuration/remote-configuration/google-vertex/member-configuration"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
-98
@@ -1,98 +0,0 @@
|
||||
---
|
||||
title: "Configure Anthropic Provider (Admin)"
|
||||
sidebarTitle: "Configure Anthropic (Admin)"
|
||||
description: "This guide explains how administrators configure Anthropic as the organization-wide LLM provider for Cline."
|
||||
---
|
||||
|
||||
|
||||
As an administrator, you can add Anthropic as the organization-wide LLM provider for all Cline users through the hosted admin console. This centralized approach provides direct access to Anthropic's Claude models, with an optional custom base URL for organizations that route traffic through a proxy.
|
||||
|
||||
## Before You Begin
|
||||
|
||||
To get started with setting up Anthropic as your organization's LLM provider, you'll need a few items in place.
|
||||
|
||||
**Administrator access to the Cline Admin console**
|
||||
You need admin privileges to enforce provider settings across your organization. If you can navigate to **Settings → Cline Settings** in the admin console at [app.cline.bot](https://app.cline.bot), you have the right access level.
|
||||
|
||||
**Anthropic API access**
|
||||
Your organization needs an Anthropic account with API access to Claude models. Members will need individual API keys to authenticate.
|
||||
|
||||
<Note>
|
||||
If your organization requires routing API traffic through a proxy or custom endpoint, have the proxy URL ready before configuring.
|
||||
</Note>
|
||||
|
||||
## Configuration Steps
|
||||
|
||||
<Steps>
|
||||
<Step title="Access Cline Settings">
|
||||
Navigate to [app.cline.bot](https://app.cline.bot) and sign in with your administrator account. Go to **Settings → Cline Settings**.
|
||||
|
||||
<Info>
|
||||
You should see the provider configuration options if you have the correct admin access level.
|
||||
</Info>
|
||||
</Step>
|
||||
|
||||
<Step title="Enable Remote Provider Configuration">
|
||||
Toggle on **Enable settings** to reveal the remote provider configuration options. This allows you to enforce provider settings across your organization.
|
||||
</Step>
|
||||
|
||||
<Step title="Select Anthropic as the API Provider">
|
||||
Open the **API Provider** dropdown menu and select **Anthropic**. This will open the Anthropic configuration panel where you'll configure all your organization-wide settings.
|
||||
</Step>
|
||||
|
||||
<Step title="Configure Anthropic Settings">
|
||||
The configuration panel includes settings that control how Anthropic works for your organization:
|
||||
|
||||
<AccordionGroup>
|
||||
<Accordion title="Base URL (optional)">
|
||||
By default, Cline connects directly to the Anthropic API (`https://api.anthropic.com`). If your organization routes API traffic through a proxy or custom endpoint, enter the base URL here.
|
||||
|
||||
Use cases for a custom base URL:
|
||||
- Corporate proxy that logs or filters API traffic
|
||||
- Self-hosted API gateway for rate limiting or access control
|
||||
- Regional routing requirements
|
||||
|
||||
Leave this empty to use the default Anthropic API endpoint.
|
||||
|
||||
<Tip>
|
||||
If using a proxy, ensure it correctly forwards requests to the Anthropic API and preserves all required headers.
|
||||
</Tip>
|
||||
</Accordion>
|
||||
</AccordionGroup>
|
||||
</Step>
|
||||
|
||||
<Step title="Save Configuration">
|
||||
After configuring your settings, close the provider configuration panel and click **Save** on the settings page to persist your changes.
|
||||
|
||||
Once saved, all organization members signed into the Cline extension will automatically use Anthropic with your configured settings. They won't be able to select other providers or switch to their personal Cline accounts.
|
||||
|
||||
<Warning>
|
||||
Members can't switch to personal Cline accounts or join other organizations once remote configuration is enabled. This ensures consistent provider usage across your team.
|
||||
</Warning>
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
## Verification
|
||||
|
||||
To verify the configuration:
|
||||
|
||||
1. Check that the provider shows as "Anthropic" in the Enabled provider field
|
||||
2. Confirm the settings persist after refreshing the page
|
||||
3. Test with a member account to ensure they see only Anthropic as a provider
|
||||
4. Verify that Claude models are available in the model dropdown
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**Members don't see the configured provider**
|
||||
Ensure you clicked Save after closing the configuration panel. Verify the member account belongs to the correct organization.
|
||||
|
||||
**Connection errors when using a custom base URL**
|
||||
Verify the proxy URL is correct and accessible from your team's development environments. Ensure the proxy correctly forwards requests to the Anthropic API.
|
||||
|
||||
**Configuration changes don't persist**
|
||||
Make sure to click the Save button on the main settings page, not just close the configuration panel.
|
||||
|
||||
**Need to change settings later**
|
||||
You can update the base URL or other settings at any time. Changes take effect immediately for all organization members.
|
||||
|
||||
For further details, consult the [Anthropic API documentation](https://docs.anthropic.com/) and coordinate with your infrastructure team.
|
||||
-95
@@ -1,95 +0,0 @@
|
||||
---
|
||||
title: "Configure Anthropic in VS Code (Members)"
|
||||
sidebarTitle: "Configure Anthropic (Member)"
|
||||
description: "Guide for engineers connecting to their organization's Anthropic provider through VS Code after admin setup"
|
||||
---
|
||||
|
||||
As a team member, you can connect your local development environment to your organization's Anthropic provider setup. This guide walks you through configuring your API key in VS Code so you can start using Claude models through your organization's configuration. Your administrator has already configured the provider settings — you just need to add your API key to get started.
|
||||
|
||||
## Before You Begin
|
||||
|
||||
To successfully connect to your organization's Anthropic provider, you'll need a few things ready.
|
||||
|
||||
**Cline extension installed and configured**
|
||||
The Cline extension must be installed in VS Code and you need to be signed into your organization account. If you haven't installed Cline yet, follow our [installation guide](/getting-started/installing-cline).
|
||||
|
||||
<Info>
|
||||
**Quick Check**: Open the Cline panel in VS Code. If you see your organization name in the bottom left, you're signed in correctly.
|
||||
</Info>
|
||||
|
||||
**Anthropic API key**
|
||||
You need an API key from Anthropic to authenticate requests. Your organization may provide keys centrally or require you to create one through the [Anthropic Console](https://console.anthropic.com/).
|
||||
|
||||
<Note>
|
||||
If you're unsure how to obtain an API key, check with your administrator about your organization's key provisioning process.
|
||||
</Note>
|
||||
|
||||
## Configuration Steps
|
||||
|
||||
<Steps>
|
||||
<Step title="Open Cline Settings">
|
||||
Open VS Code and access the Cline settings panel using either of these methods:
|
||||
|
||||
- Click the settings icon (⚙️) in the Cline panel
|
||||
- Click on the API Provider dropdown located directly below the chat area
|
||||
|
||||
</Step>
|
||||
|
||||
<Step title="Enter Your API Key">
|
||||
|
||||
1. Select or confirm the **Anthropic** provider is selected
|
||||
2. Enter your Anthropic API key in the **API Key** field
|
||||
3. If your administrator configured a custom base URL, it will already be set and locked
|
||||
4. Click **Save** to store your credentials
|
||||
|
||||
<Tip>
|
||||
API keys are stored locally and are only used by the Cline extension.
|
||||
</Tip>
|
||||
|
||||
<Note>
|
||||
The base URL setting is controlled by your administrator. If a custom proxy URL is configured, your API requests will be routed through it automatically.
|
||||
</Note>
|
||||
</Step>
|
||||
|
||||
<Step title="Verify Configuration">
|
||||
After entering your API key, administrator-controlled settings (such as base URL) will be locked (shown with a lock icon 🔒) as they're managed by your organization.
|
||||
</Step>
|
||||
|
||||
<Step title="Test the Connection">
|
||||
Send a test message in Cline to verify your API key works correctly with the configured Anthropic endpoint.
|
||||
|
||||
<Tip>
|
||||
**Testing Recommendation**
|
||||
|
||||
Try a simple test like "Hello" first to verify basic connectivity before starting development tasks.
|
||||
</Tip>
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**Anthropic not available as provider option**
|
||||
Confirm you're signed into the correct Cline organization. Verify your administrator has saved the Anthropic configuration and that you have the latest version of the Cline extension.
|
||||
|
||||
**Authentication errors ("Invalid API Key" or "Unauthorized")**
|
||||
Verify your API key is correct and active. Check the [Anthropic Console](https://console.anthropic.com/) to confirm your key status and that it has sufficient permissions.
|
||||
|
||||
**Connection errors or timeouts**
|
||||
If your administrator configured a custom base URL (proxy), check with your IT team about network requirements. If using the default Anthropic endpoint, ensure you have internet access to `api.anthropic.com`.
|
||||
|
||||
**Models not available**
|
||||
The available models depend on your Anthropic API plan and your organization's configuration. Contact your administrator if expected models are not available.
|
||||
|
||||
**Rate limit errors**
|
||||
Your API key may have rate limits configured by Anthropic. If you encounter rate limit errors during normal use, contact your administrator about adjusting limits or managing key usage across the team.
|
||||
|
||||
## Security Best Practices
|
||||
|
||||
When working with your Anthropic API key:
|
||||
|
||||
- Keep your API key secure and do not share it
|
||||
- Never store your API key in code or version control
|
||||
- Report any suspected key compromise to your administrator immediately
|
||||
- Regularly check the [Anthropic Console](https://console.anthropic.com/) for unusual usage patterns
|
||||
|
||||
For further details, consult the [Anthropic API documentation](https://docs.anthropic.com/) and coordinate with your organization's administrator.
|
||||
-138
@@ -1,138 +0,0 @@
|
||||
---
|
||||
title: "Configure OpenAI Compatible Provider (Admin)"
|
||||
sidebarTitle: "Configure OpenAI Compatible (Admin)"
|
||||
description: "This guide explains how administrators configure an OpenAI-compatible endpoint as the organization-wide LLM provider for Cline."
|
||||
---
|
||||
|
||||
|
||||
As an administrator, you can add an OpenAI-compatible endpoint as the organization-wide LLM provider for all Cline users through the hosted admin console. This covers any provider that exposes an OpenAI-compatible API, including Azure Foundry (Azure OpenAI), self-hosted inference engines (vLLM, TGI), and other compatible services.
|
||||
|
||||
## Before You Begin
|
||||
|
||||
To get started with setting up an OpenAI-compatible provider for your organization, you'll need a few items in place.
|
||||
|
||||
**Administrator access to the Cline Admin console**
|
||||
You need admin privileges to enforce provider settings across your organization. If you can navigate to **Settings → Cline Settings** in the admin console at [app.cline.bot](https://app.cline.bot), you have the right access level.
|
||||
|
||||
**An OpenAI-compatible API endpoint**
|
||||
You need a running endpoint that implements the OpenAI chat completions API. This could be:
|
||||
- Azure Foundry (Azure OpenAI Service)
|
||||
- A self-hosted inference engine (vLLM, text-generation-inference, etc.)
|
||||
- Any third-party service with an OpenAI-compatible API
|
||||
|
||||
<Note>
|
||||
If you're using Azure Foundry, you'll need your Azure OpenAI endpoint URL and optionally the API version. Work with your Azure administrator to ensure the endpoint is provisioned and accessible.
|
||||
</Note>
|
||||
|
||||
**Endpoint URL and authentication details**
|
||||
You'll need the base URL of your endpoint and any required authentication headers.
|
||||
|
||||
## Configuration Steps
|
||||
|
||||
<Steps>
|
||||
<Step title="Access Cline Settings">
|
||||
Navigate to [app.cline.bot](https://app.cline.bot) and sign in with your administrator account. Go to **Settings → Cline Settings**.
|
||||
|
||||
<Info>
|
||||
You should see the provider configuration options if you have the correct admin access level.
|
||||
</Info>
|
||||
</Step>
|
||||
|
||||
<Step title="Enable Remote Provider Configuration">
|
||||
Toggle on **Enable settings** to reveal the remote provider configuration options. This allows you to enforce provider settings across your organization.
|
||||
</Step>
|
||||
|
||||
<Step title="Select OpenAI Compatible as the API Provider">
|
||||
Open the **API Provider** dropdown menu and select **OpenAI Compatible**. This will open the configuration panel where you'll configure all your organization-wide settings.
|
||||
</Step>
|
||||
|
||||
<Step title="Configure OpenAI Compatible Settings">
|
||||
The configuration panel includes settings that control how the provider works for your organization:
|
||||
|
||||
<AccordionGroup>
|
||||
<Accordion title="Base URL (required)">
|
||||
Enter the base URL of your OpenAI-compatible endpoint. Examples:
|
||||
|
||||
- **Azure Foundry**: `https://your-resource.openai.azure.com`
|
||||
- **Self-hosted vLLM**: `https://inference.yourcompany.com/v1`
|
||||
- **Other compatible services**: The provider's API base URL
|
||||
|
||||
<Tip>
|
||||
Use HTTPS endpoints in production for security. Ensure the URL is accessible from your team's development environments.
|
||||
</Tip>
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Custom Headers (optional)">
|
||||
Add custom HTTP headers that will be included with every API request. This is useful for:
|
||||
|
||||
- Custom authentication schemes beyond API keys
|
||||
- Routing headers for internal load balancers
|
||||
- Organization or tenant identifiers required by your endpoint
|
||||
|
||||
Headers are configured as key-value pairs.
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Azure API Version (optional — Azure Foundry only)">
|
||||
If you're using Azure Foundry (Azure OpenAI), specify the API version string. For example: `2024-02-15-preview` or `2024-06-01`.
|
||||
|
||||
This field is only needed for Azure OpenAI deployments. Leave it empty for non-Azure endpoints.
|
||||
|
||||
<Note>
|
||||
Check the [Azure OpenAI API version documentation](https://learn.microsoft.com/en-us/azure/ai-services/openai/reference) for available versions.
|
||||
</Note>
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Azure Identity Authentication (optional — Azure Foundry only)">
|
||||
Enable this to use Azure Active Directory (Entra ID) token-based authentication instead of API keys. When enabled, members authenticate using their Azure AD credentials rather than a static API key.
|
||||
|
||||
This field is only relevant for Azure Foundry deployments.
|
||||
</Accordion>
|
||||
</AccordionGroup>
|
||||
</Step>
|
||||
|
||||
<Step title="Save Configuration">
|
||||
After configuring your settings, close the provider configuration panel and click **Save** on the settings page to persist your changes.
|
||||
|
||||
Once saved, all organization members signed into the Cline extension will automatically use the OpenAI Compatible provider with your configured settings. They won't be able to select other providers or switch to their personal Cline accounts.
|
||||
|
||||
<Warning>
|
||||
Members can't switch to personal Cline accounts or join other organizations once remote configuration is enabled. This ensures consistent provider usage across your team.
|
||||
</Warning>
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
## Azure Foundry Configuration
|
||||
|
||||
For organizations using Azure Foundry (Azure OpenAI Service), use the following configuration:
|
||||
|
||||
1. **Base URL**: Your Azure OpenAI endpoint (e.g., `https://your-resource.openai.azure.com`)
|
||||
2. **Azure API Version**: The API version to use (e.g., `2024-06-01`)
|
||||
3. **Azure Identity Authentication**: Enable if your organization uses Azure AD for authentication instead of API keys
|
||||
|
||||
## Verification
|
||||
|
||||
To verify the configuration:
|
||||
|
||||
1. Check that the provider shows as "OpenAI Compatible" in the Enabled provider field
|
||||
2. Confirm the settings persist after refreshing the page
|
||||
3. Test with a member account to ensure they see only the OpenAI Compatible provider
|
||||
4. Verify that configured models are available in the model dropdown
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**Members don't see the configured provider**
|
||||
Ensure you clicked Save after closing the configuration panel. Verify the member account belongs to the correct organization.
|
||||
|
||||
**Connection errors to the endpoint**
|
||||
Verify the Base URL is correct and accessible from your team's development environments. Check that any firewalls or security groups allow access from developer IP addresses.
|
||||
|
||||
**Azure authentication failures**
|
||||
If using Azure Identity Authentication, verify that members' Azure AD accounts have the appropriate role assignments on the Azure OpenAI resource. If using API keys, verify the key is correctly entered by the member.
|
||||
|
||||
**Configuration changes don't persist**
|
||||
Make sure to click the Save button on the main settings page, not just close the configuration panel.
|
||||
|
||||
**Need to change endpoint or settings later**
|
||||
You can update these settings at any time. Changes take effect immediately for all organization members.
|
||||
|
||||
For Azure Foundry, consult the [Azure OpenAI Service documentation](https://learn.microsoft.com/en-us/azure/ai-services/openai/). For other OpenAI-compatible endpoints, refer to your provider's documentation.
|
||||
-117
@@ -1,117 +0,0 @@
|
||||
---
|
||||
title: "Configure OpenAI Compatible in VS Code (Members)"
|
||||
sidebarTitle: "Configure OpenAI Compatible (Member)"
|
||||
description: "Guide for engineers connecting to their organization's OpenAI-compatible endpoint through VS Code after admin setup"
|
||||
---
|
||||
|
||||
As a team member, you can connect your local development environment to your organization's OpenAI-compatible endpoint. This guide walks you through configuring your credentials in VS Code so you can start using models through your organization's configured endpoint. Your administrator has already configured the provider settings — you just need to add your API key to get started.
|
||||
|
||||
## Before You Begin
|
||||
|
||||
To successfully connect to your organization's OpenAI-compatible endpoint, you'll need a few things ready.
|
||||
|
||||
**Cline extension installed and configured**
|
||||
The Cline extension must be installed in VS Code and you need to be signed into your organization account. If you haven't installed Cline yet, follow our [installation guide](/getting-started/installing-cline).
|
||||
|
||||
<Info>
|
||||
**Quick Check**: Open the Cline panel in VS Code. If you see your organization name in the bottom left, you're signed in correctly.
|
||||
</Info>
|
||||
|
||||
**API key or credentials for your endpoint**
|
||||
You need an API key or credentials to authenticate with your organization's configured endpoint. For Azure Foundry deployments using Azure Identity Authentication, your Azure AD credentials may be used instead.
|
||||
|
||||
<Note>
|
||||
If you're unsure what credentials to use, check with your administrator or IT team about how your organization has configured access.
|
||||
</Note>
|
||||
|
||||
## Configuration Steps
|
||||
|
||||
<Steps>
|
||||
<Step title="Open Cline Settings">
|
||||
Open VS Code and access the Cline settings panel using either of these methods:
|
||||
|
||||
- Click the settings icon (⚙️) in the Cline panel
|
||||
- Click on the API Provider dropdown located directly below the chat area
|
||||
|
||||
</Step>
|
||||
|
||||
<Step title="Configure Your Credentials">
|
||||
The authentication method depends on how your administrator configured the endpoint:
|
||||
|
||||
<AccordionGroup>
|
||||
<Accordion title="API Key Authentication">
|
||||
For most OpenAI-compatible endpoints:
|
||||
|
||||
1. Select or confirm the **OpenAI Compatible** provider is selected
|
||||
2. Enter your API key in the **API Key** field
|
||||
3. The base URL, custom headers, and other settings are preconfigured by your administrator
|
||||
4. Click **Save** to store your credentials
|
||||
|
||||
<Tip>
|
||||
API keys are stored locally and are only used by the Cline extension.
|
||||
</Tip>
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Azure Identity Authentication (Azure Foundry)">
|
||||
If your organization uses Azure AD authentication:
|
||||
|
||||
1. Select or confirm the **OpenAI Compatible** provider is selected
|
||||
2. Ensure you are signed into Azure in your development environment
|
||||
3. The extension will use your Azure AD credentials automatically
|
||||
4. No API key is needed when Azure Identity Authentication is enabled
|
||||
|
||||
<Note>
|
||||
You may need the Azure Account extension or Azure CLI installed for credential resolution.
|
||||
</Note>
|
||||
</Accordion>
|
||||
</AccordionGroup>
|
||||
|
||||
<Note>
|
||||
The Base URL, custom headers, Azure API version, and Azure Identity settings are preconfigured by your administrator and do not need to be set in the extension.
|
||||
</Note>
|
||||
</Step>
|
||||
|
||||
<Step title="Verify Configuration">
|
||||
After configuring your credentials, administrator-controlled settings will be locked (shown with a lock icon 🔒) as they're managed by your organization.
|
||||
</Step>
|
||||
|
||||
<Step title="Test the Connection">
|
||||
Send a test message in Cline to verify your credentials work correctly with the configured endpoint.
|
||||
|
||||
<Tip>
|
||||
**Testing Recommendation**
|
||||
|
||||
Try a simple test like "Hello" first to verify basic connectivity before starting development tasks.
|
||||
</Tip>
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**OpenAI Compatible not available as provider option**
|
||||
Confirm you're signed into the correct Cline organization. Verify your administrator has saved the configuration and that you have the latest version of the Cline extension.
|
||||
|
||||
**Authentication errors ("Access Denied" or "Invalid API Key")**
|
||||
Verify your API key is correct and active. For Azure Foundry with Azure Identity Authentication, ensure you are signed into Azure in your development environment and that your account has the appropriate role assignments on the Azure OpenAI resource.
|
||||
|
||||
**Connection errors or timeouts**
|
||||
The endpoint URL is configured by your administrator. If you experience connection issues, check with your IT team about network requirements (VPN, firewall rules, etc.).
|
||||
|
||||
**Models not available**
|
||||
The available models depend on your organization's endpoint configuration. Contact your administrator if expected models are not available in the model dropdown.
|
||||
|
||||
**Configuration changes don't persist**
|
||||
Make sure to save your credentials. The base URL and other admin-controlled settings cannot be changed locally.
|
||||
|
||||
## Security Best Practices
|
||||
|
||||
When working with your API credentials:
|
||||
|
||||
- Keep your API key secure and do not share it
|
||||
- Never store credentials in code or version control
|
||||
- Report any suspected key compromise to your administrator immediately
|
||||
- Follow your organization's usage guidelines for the configured endpoint
|
||||
|
||||
Your organization administrator controls which endpoint, models, and settings are available. The extension will automatically apply the configured settings based on your organization's remote configuration.
|
||||
|
||||
For Azure Foundry, refer to the [Azure OpenAI Service documentation](https://learn.microsoft.com/en-us/azure/ai-services/openai/). For other endpoints, consult your organization's internal documentation or contact your administrator.
|
||||
@@ -1,11 +1,11 @@
|
||||
---
|
||||
title: "Enterprise Provider Configuration"
|
||||
title: "SaaS Provider Configuration"
|
||||
sidebarTitle: "Overview"
|
||||
description: "Configure inference providers through the Cline hosted admin console for centralized organization management"
|
||||
---
|
||||
|
||||
|
||||
Remote Provider Configuration allows administrators to centrally configure inference providers for their entire organization through the Cline hosted admin console. This approach ensures consistent provider access, security policies, and cost management across all team members without requiring individual developer setup or infrastructure deployment.
|
||||
SaaS Provider Configuration allows administrators to centrally configure inference providers for their entire organization through the Cline hosted admin console. This approach ensures consistent provider access, security policies, and cost management across all team members without requiring individual developer setup or infrastructure deployment.
|
||||
|
||||
## How Remote Configuration Works
|
||||
|
||||
@@ -35,17 +35,11 @@ Cline supports remote configuration for the following inference providers:
|
||||
|
||||
| Provider | Use Case | Configuration | Member Setup |
|
||||
|----------|----------|---------------|--------------|
|
||||
| **Cline** | Organizations using Cline's native provider with centralized API key management | API provider selection, model access | No individual API keys needed — fully managed by organization |
|
||||
| **Amazon Bedrock** | Organizations using AWS infrastructure | Region selection, VPC endpoints, cross-region inference, global inference, prompt caching | AWS credential configuration (API key, CLI profile, or credential chain) |
|
||||
| **Google Vertex AI** | Organizations using Google Cloud Platform | Project ID, region selection, model access | Google Cloud credential configuration (service account, SDK, or ADC) |
|
||||
| **Azure Foundry** | Organizations using Azure OpenAI or Azure AI services | Base URL, Azure API version, Azure identity authentication, custom headers | API key configuration in the extension |
|
||||
| **Anthropic** | Organizations using the Anthropic API directly | Optional custom base URL for proxy deployments, model access | API key configuration in the extension |
|
||||
| **OpenAI Compatible** | Organizations using any OpenAI-compatible endpoint (self-hosted, vLLM, custom proxies) | Base URL, custom headers, model access | API key configuration in the extension |
|
||||
| **LiteLLM** | Organizations requiring multi-model access through a unified proxy | Proxy endpoint, authentication, model routing | API key or endpoint configuration (or centralized with Master Key) |
|
||||
| **Cline** | Organizations using Cline's native provider with centralized API key management | API provider selection, model access | No individual API keys needed - fully managed by organization |
|
||||
| **Amazon Bedrock** | Organizations using AWS infrastructure | Region selection, VPC endpoints, cross-region inference, prompt caching | AWS credential configuration in VS Code |
|
||||
| **LiteLLM** | Organizations requiring multi-model access through a unified proxy | Proxy endpoint, authentication, model routing | API key or endpoint configuration in VS Code (or centralized with Master Key) |
|
||||
| **Google Vertex AI** | Organizations using Google Cloud Platform | Project ID, region selection, model access | Service account or credential configuration in VS Code |
|
||||
|
||||
<Note>
|
||||
**Azure Foundry** uses the OpenAI Compatible provider configuration with Azure-specific settings (API version, Azure identity authentication). See the [OpenAI Compatible admin configuration](/enterprise-solutions/configuration/remote-configuration/openai-compatible/admin-configuration) for setup instructions.
|
||||
</Note>
|
||||
|
||||
## Configuration Process
|
||||
|
||||
@@ -61,7 +55,7 @@ Provider configuration is automatically distributed to all organization members
|
||||
</Step>
|
||||
|
||||
<Step title="Member Credential Setup">
|
||||
Team members add their individual credentials (API keys, AWS profiles, etc.) to connect to the configured provider. For some providers like Cline and LiteLLM (with Master Key), no individual credentials are needed.
|
||||
Team members add their individual credentials (API keys, AWS profiles, etc.) to connect to the configured provider.
|
||||
</Step>
|
||||
|
||||
<Step title="Immediate Access">
|
||||
@@ -98,17 +92,11 @@ Select your provider below to begin the configuration process:
|
||||
AWS-based AI models with enterprise security and compliance features.
|
||||
</Card>
|
||||
|
||||
<Card title="Google Vertex AI" icon="google" href="/enterprise-solutions/configuration/remote-configuration/google-vertex/admin-configuration">
|
||||
Google Cloud's AI platform with Gemini models and regional control.
|
||||
</Card>
|
||||
|
||||
<Card title="OpenAI Compatible" icon="plug" href="/enterprise-solutions/configuration/remote-configuration/openai-compatible/admin-configuration">
|
||||
Any OpenAI-compatible endpoint, including Azure Foundry.
|
||||
</Card>
|
||||
|
||||
<Card title="Anthropic" icon="robot" href="/enterprise-solutions/configuration/remote-configuration/anthropic/admin-configuration">
|
||||
|
||||
<Card title="LiteLLM" icon="layer-group" href="/enterprise-solutions/configuration/remote-configuration/litellm/admin-configuration">
|
||||
Unified proxy for accessing 100+ AI models through a single interface.
|
||||
</Card>
|
||||
|
||||
<Card title="Google Vertex AI" icon="google" href="/enterprise-solutions/configuration/remote-configuration/google-vertex/admin-configuration">
|
||||
Google Cloud's AI platform with advanced ML capabilities and global infrastructure.
|
||||
</Card>
|
||||
</CardGroup>
|
||||
|
||||
@@ -1,155 +0,0 @@
|
||||
---
|
||||
title: "OpenTelemetry Environment Variables"
|
||||
sidebarTitle: "OpenTelemetry Override"
|
||||
description: "Configure OpenTelemetry using environment variables for advanced scenarios"
|
||||
---
|
||||
|
||||
<Note>
|
||||
This is an **advanced configuration method**. Most users should use [Remote Configuration](/enterprise-solutions/monitoring/opentelemetry) via the dashboard instead.
|
||||
</Note>
|
||||
|
||||
Environment variables provide an alternative way to configure OpenTelemetry, useful for self-hosted deployments, local development, CI/CD pipelines, or when you need to override organization settings.
|
||||
|
||||
## When to Use
|
||||
|
||||
- **Self-hosted deployments** without dashboard access
|
||||
- **Local development and testing** with your own collectors
|
||||
- **CI/CD pipelines** that need observability
|
||||
- **Override organization settings** with user-specific configuration
|
||||
|
||||
<Warning>
|
||||
Environment variable configuration bypasses user telemetry settings and will export data regardless of individual preferences.
|
||||
</Warning>
|
||||
|
||||
## Environment Variables
|
||||
|
||||
### Core Configuration
|
||||
|
||||
| Variable | Description | Values |
|
||||
|----------|-------------|--------|
|
||||
| `CLINE_OTEL_TELEMETRY_ENABLED` | Enable OpenTelemetry export | `"true"` or `"false"` |
|
||||
| `CLINE_OTEL_METRICS_EXPORTER` | Metrics exporters (comma-separated) | `"console"`, `"otlp"` |
|
||||
| `CLINE_OTEL_LOGS_EXPORTER` | Logs exporters (comma-separated) | `"console"`, `"otlp"` |
|
||||
|
||||
### OTLP Configuration
|
||||
|
||||
| Variable | Description | Values |
|
||||
|----------|-------------|--------|
|
||||
| `CLINE_OTEL_EXPORTER_OTLP_PROTOCOL` | OTLP protocol | `"grpc"`, `"http/json"`, or `"http/protobuf"` |
|
||||
| `CLINE_OTEL_EXPORTER_OTLP_ENDPOINT` | OTLP collector endpoint (applies to both metrics and logs) | URL with optional port |
|
||||
| `CLINE_OTEL_EXPORTER_OTLP_HEADERS` | Authentication headers (comma-separated `key=value` pairs) | `"key=value,key2=value2"` |
|
||||
| `CLINE_OTEL_EXPORTER_OTLP_INSECURE` | Disable TLS for gRPC (local development only) | `"true"` |
|
||||
|
||||
### Advanced OTLP Configuration
|
||||
|
||||
For separate metrics and logs endpoints:
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `CLINE_OTEL_EXPORTER_OTLP_METRICS_PROTOCOL` | Metrics-specific protocol override |
|
||||
| `CLINE_OTEL_EXPORTER_OTLP_METRICS_ENDPOINT` | Metrics-specific endpoint |
|
||||
| `CLINE_OTEL_EXPORTER_OTLP_LOGS_PROTOCOL` | Logs-specific protocol override |
|
||||
| `CLINE_OTEL_EXPORTER_OTLP_LOGS_ENDPOINT` | Logs-specific endpoint |
|
||||
|
||||
### Export Tuning
|
||||
|
||||
| Variable | Description | Default |
|
||||
|----------|-------------|---------|
|
||||
| `CLINE_OTEL_METRIC_EXPORT_INTERVAL` | Milliseconds between metric exports | 60000 |
|
||||
| `CLINE_OTEL_LOG_BATCH_SIZE` | Maximum batch size for log records | 512 |
|
||||
| `CLINE_OTEL_LOG_BATCH_TIMEOUT` | Maximum time before exporting logs (ms) | 5000 |
|
||||
| `CLINE_OTEL_LOG_MAX_QUEUE_SIZE` | Maximum queue size for log records | 2048 |
|
||||
|
||||
## Quick Start Examples
|
||||
|
||||
### Datadog with gRPC
|
||||
|
||||
```bash
|
||||
export CLINE_OTEL_TELEMETRY_ENABLED=true
|
||||
export CLINE_OTEL_METRICS_EXPORTER=otlp
|
||||
export CLINE_OTEL_LOGS_EXPORTER=otlp
|
||||
export CLINE_OTEL_EXPORTER_OTLP_PROTOCOL=grpc
|
||||
export CLINE_OTEL_EXPORTER_OTLP_ENDPOINT=https://api.datadoghq.com:4317
|
||||
export CLINE_OTEL_EXPORTER_OTLP_HEADERS="dd-api-key=YOUR_API_KEY"
|
||||
|
||||
code .
|
||||
```
|
||||
|
||||
<Note>
|
||||
The endpoint shown above is for Datadog's **US1 region**. If you're in a different region (EU, US3, US5, AP1, etc.), replace `api.datadoghq.com` with your region-specific hostname (e.g., `api.datadoghq.eu` for EU). See [Datadog's OTLP documentation](https://docs.datadoghq.com/opentelemetry/) for your region's endpoint.
|
||||
</Note>
|
||||
|
||||
### New Relic with HTTP
|
||||
|
||||
```bash
|
||||
export CLINE_OTEL_TELEMETRY_ENABLED=true
|
||||
export CLINE_OTEL_METRICS_EXPORTER=otlp
|
||||
export CLINE_OTEL_LOGS_EXPORTER=otlp
|
||||
export CLINE_OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf
|
||||
export CLINE_OTEL_EXPORTER_OTLP_ENDPOINT=https://otlp.nr-data.net:4318
|
||||
export CLINE_OTEL_EXPORTER_OTLP_HEADERS="api-key=YOUR_LICENSE_KEY"
|
||||
|
||||
code .
|
||||
```
|
||||
|
||||
### Local Development (Insecure)
|
||||
|
||||
```bash
|
||||
export CLINE_OTEL_TELEMETRY_ENABLED=true
|
||||
export CLINE_OTEL_METRICS_EXPORTER=otlp
|
||||
export CLINE_OTEL_LOGS_EXPORTER=otlp
|
||||
export CLINE_OTEL_EXPORTER_OTLP_PROTOCOL=grpc
|
||||
export CLINE_OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317
|
||||
export CLINE_OTEL_EXPORTER_OTLP_INSECURE=true
|
||||
|
||||
code .
|
||||
```
|
||||
|
||||
### Console Output (Testing)
|
||||
|
||||
```bash
|
||||
export CLINE_OTEL_TELEMETRY_ENABLED=true
|
||||
export CLINE_OTEL_METRICS_EXPORTER=console
|
||||
export CLINE_OTEL_LOGS_EXPORTER=console
|
||||
|
||||
code .
|
||||
```
|
||||
|
||||
## Debugging
|
||||
|
||||
Enable detailed OpenTelemetry diagnostic logging:
|
||||
|
||||
```bash
|
||||
export TEL_DEBUG_DIAGNOSTICS=true
|
||||
code .
|
||||
```
|
||||
|
||||
This outputs:
|
||||
- Configuration being used
|
||||
- Exporters being created
|
||||
- Connection attempts
|
||||
- Export successes/failures
|
||||
|
||||
Check the VS Code Developer Tools Console (Help > Toggle Developer Tools) for diagnostic output.
|
||||
|
||||
## Configuration Priority
|
||||
|
||||
When multiple configuration methods are present, Cline uses this priority order:
|
||||
|
||||
1. **Environment variables** (highest priority) - This method
|
||||
2. **Remote Configuration** - Dashboard settings
|
||||
3. **Default settings** - Built-in defaults
|
||||
|
||||
Environment variable configuration will override dashboard settings.
|
||||
|
||||
## See Also
|
||||
|
||||
<CardGroup cols={2}>
|
||||
<Card title="Dashboard Configuration" icon="globe" href="/enterprise-solutions/monitoring/opentelemetry">
|
||||
Configure OpenTelemetry via the web dashboard
|
||||
</Card>
|
||||
|
||||
<Card title="Remote Configuration" icon="server" href="/enterprise-solutions/configuration/remote-configuration/overview">
|
||||
Learn about Remote Configuration system
|
||||
</Card>
|
||||
</CardGroup>
|
||||
@@ -199,7 +199,8 @@ Understanding how seats work helps you manage your license effectively:
|
||||
|
||||
<Accordion title="Upgrading Your License" icon="arrow-up">
|
||||
Need more seats?
|
||||
- **Enterprise Plan:** Includes unlimited seats with no per-user restrictions. Contact your account manager or visit app.cline.bot/settings/billing to upgrade.
|
||||
- **Teams Plan:** Contact your account manager or visit app.cline.bot/settings/billing to upgrade your license.
|
||||
- **Enterprise Plan:** Includes unlimited seats with no per-user restrictions.
|
||||
</Accordion>
|
||||
</AccordionGroup>
|
||||
|
||||
|
||||
@@ -53,10 +53,6 @@ service AccountService {
|
||||
|
||||
// Signs out of OpenAI Codex and clears stored credentials
|
||||
rpc openAiCodexSignOut(EmptyRequest) returns (Empty);
|
||||
|
||||
// Submits a spend limit increase request to the user's org admin.
|
||||
// Called when the user hits a SPEND_LIMIT_EXCEEDED (429) error and clicks "Request Increase".
|
||||
rpc submitLimitIncreaseRequest(EmptyRequest) returns (SubmitLimitIncreaseResponse);
|
||||
}
|
||||
|
||||
message AuthStateChangedRequest {
|
||||
@@ -129,11 +125,6 @@ message UsageTransaction {
|
||||
string operation = 13;
|
||||
}
|
||||
|
||||
// Response from a spend limit increase request submission
|
||||
message SubmitLimitIncreaseResponse {
|
||||
bool success = 1;
|
||||
}
|
||||
|
||||
message PaymentTransaction {
|
||||
string paid_at = 1;
|
||||
string creator_id = 2;
|
||||
|
||||
@@ -1,97 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* mock-spend-limit-server.mjs
|
||||
*
|
||||
* Lightweight proxy for hands-on testing of the SpendLimitError UI.
|
||||
*
|
||||
* - POST /api/v1/chat/completions → 429 SPEND_LIMIT_EXCEEDED
|
||||
* - POST /api/v1/users/me/budget/request → 204 OK (simulates "Request Increase" success)
|
||||
* - Everything else → proxied to REAL_BACKEND
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/mock-spend-limit-server.mjs
|
||||
*
|
||||
* Then point the extension at http://localhost:7777 by adding to .vscode/launch.json:
|
||||
* "env": { "CLINE_API_BASE_URL": "http://localhost:7777" }
|
||||
*
|
||||
* See docs/testing/spend-limit-error-hands-on.md for the full guide.
|
||||
*/
|
||||
|
||||
import { createServer } from "node:http"
|
||||
import { request as httpsRequest } from "node:https"
|
||||
|
||||
const PORT = 7777
|
||||
const REAL_BACKEND = "https://api.cline.bot" // swap for your local backend if needed
|
||||
|
||||
// ── Tune these to change what the card shows ─────────────────────────────────
|
||||
const SPEND_LIMIT_BODY = JSON.stringify({
|
||||
error: {
|
||||
code: "SPEND_LIMIT_EXCEEDED",
|
||||
limit_scope: "user",
|
||||
budget_period: "daily", // "daily" | "monthly"
|
||||
limit_usd: 20.0,
|
||||
spent_usd: 20.5,
|
||||
resets_at: new Date(Date.now() + 8 * 60 * 60 * 1000).toISOString(), // 8h from now
|
||||
message: "Your daily spend limit of $20.00 has been reached.",
|
||||
},
|
||||
})
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
function proxyToReal(req, res, body) {
|
||||
const url = new URL(req.url, REAL_BACKEND)
|
||||
const options = {
|
||||
hostname: url.hostname,
|
||||
port: 443,
|
||||
path: url.pathname + url.search,
|
||||
method: req.method,
|
||||
headers: { ...req.headers, host: url.hostname },
|
||||
}
|
||||
const proxy = httpsRequest(options, (proxyRes) => {
|
||||
res.writeHead(proxyRes.statusCode, proxyRes.headers)
|
||||
proxyRes.pipe(res)
|
||||
})
|
||||
proxy.on("error", (e) => {
|
||||
console.error("[proxy] Error:", e.message)
|
||||
res.writeHead(502)
|
||||
res.end("Bad gateway")
|
||||
})
|
||||
if (body?.length) proxy.write(body)
|
||||
proxy.end()
|
||||
}
|
||||
|
||||
createServer((req, res) => {
|
||||
const chunks = []
|
||||
req.on("data", (c) => chunks.push(c))
|
||||
req.on("end", () => {
|
||||
const body = Buffer.concat(chunks)
|
||||
|
||||
if (req.url?.includes("/chat/completions")) {
|
||||
// ── Intercept: return SPEND_LIMIT_EXCEEDED ───────────────
|
||||
console.log(`\x1b[31m[mock]\x1b[0m 429 SPEND_LIMIT_EXCEEDED ${req.method} ${req.url}`)
|
||||
res.writeHead(429, { "Content-Type": "application/json" })
|
||||
res.end(SPEND_LIMIT_BODY)
|
||||
} else if (req.url?.includes("/budget/request") && req.method === "POST") {
|
||||
// ── Intercept: simulate successful limit-increase request ─
|
||||
console.log(`\x1b[32m[mock]\x1b[0m 204 OK POST ${req.url}`)
|
||||
res.writeHead(204)
|
||||
res.end()
|
||||
} else {
|
||||
// ── Proxy everything else to the real backend ─────────────
|
||||
console.log(`\x1b[90m[proxy]\x1b[0m ${req.method} ${req.url}`)
|
||||
proxyToReal(req, res, body)
|
||||
}
|
||||
})
|
||||
}).listen(PORT, () => {
|
||||
console.log(`
|
||||
\x1b[1mMock spend-limit server\x1b[0m → http://localhost:${PORT}
|
||||
|
||||
\x1b[31m✗\x1b[0m POST /api/v1/chat/completions 429 SPEND_LIMIT_EXCEEDED
|
||||
\x1b[32m✓\x1b[0m POST /api/v1/.../budget/request 204 OK
|
||||
\x1b[90m↗\x1b[0m everything else proxy → ${REAL_BACKEND}
|
||||
|
||||
Point the extension at this server:
|
||||
.vscode/launch.json → "env": { "CLINE_API_BASE_URL": "http://localhost:${PORT}" }
|
||||
|
||||
See docs/testing/spend-limit-error-hands-on.md for the full walkthrough.
|
||||
`)
|
||||
})
|
||||
@@ -1,28 +0,0 @@
|
||||
import { SubmitLimitIncreaseResponse } from "@shared/proto/cline/account"
|
||||
import type { EmptyRequest } from "@shared/proto/cline/common"
|
||||
import { Logger } from "@/shared/services/Logger"
|
||||
import type { Controller } from "../index"
|
||||
|
||||
/**
|
||||
* Submits a spend limit increase request to the user's org admin.
|
||||
* Called when the user clicks "Request Increase" on the SpendLimitError component.
|
||||
* @param controller The controller instance
|
||||
* @param _request Empty request
|
||||
* @returns SubmitLimitIncreaseResponse indicating success or failure
|
||||
*/
|
||||
export async function submitLimitIncreaseRequest(
|
||||
controller: Controller,
|
||||
_request: EmptyRequest,
|
||||
): Promise<SubmitLimitIncreaseResponse> {
|
||||
try {
|
||||
if (!controller.accountService) {
|
||||
throw new Error("Account service not available")
|
||||
}
|
||||
|
||||
await controller.accountService.submitLimitIncreaseRequestRPC()
|
||||
return SubmitLimitIncreaseResponse.create({ success: true })
|
||||
} catch (error) {
|
||||
Logger.error(`Failed to submit limit increase request: ${error}`)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
+7
-63
@@ -91,7 +91,6 @@ import {
|
||||
StandaloneTerminalManager,
|
||||
} from "@/integrations/terminal"
|
||||
import { ClineError, ClineErrorType, ErrorService } from "@/services/error"
|
||||
import { ThirdPartySpendLimitService } from "@/services/spend-limit/ThirdPartySpendLimitService"
|
||||
import { telemetryService } from "@/services/telemetry"
|
||||
import { ClineClient } from "@/shared/cline"
|
||||
import {
|
||||
@@ -1743,51 +1742,6 @@ export class Task {
|
||||
return { model, providerId, customPrompt, mode }
|
||||
}
|
||||
|
||||
/**
|
||||
* Enforces third-party (non-Cline) spend limits locally before dispatching
|
||||
* an API request. Throws a ClineError shaped like the Cline-provider 429
|
||||
* so the existing SpendLimitError UI handles it with no webview changes.
|
||||
*/
|
||||
private async checkThirdPartySpendLimit(providerId: string | undefined): Promise<void> {
|
||||
// Cline provider has server-side enforcement.
|
||||
if (providerId === "cline") {
|
||||
return
|
||||
}
|
||||
|
||||
const svc = ThirdPartySpendLimitService.getInstance()
|
||||
await svc.fetchIfNeeded()
|
||||
|
||||
const status = svc.getStatus()
|
||||
if (!status?.overbudget) {
|
||||
return
|
||||
}
|
||||
|
||||
// Daily limit takes precedence over monthly when both are set, since
|
||||
// it will reset sooner.
|
||||
const hitDaily = status.limits.dailyLimitUsd != null
|
||||
const budgetPeriod: "daily" | "monthly" = hitDaily ? "daily" : "monthly"
|
||||
const limitUsd = hitDaily ? status.limits.dailyLimitUsd : status.limits.monthlyLimitUsd
|
||||
const spentUsd = hitDaily ? status.usage.dailySpendUsd : status.usage.monthlySpendUsd
|
||||
const resetsAt = hitDaily ? status.usage.dayResetsAt : status.usage.monthResetsAt
|
||||
|
||||
const formattedLimit = typeof limitUsd === "number" ? `$${limitUsd.toFixed(2)} ` : ""
|
||||
const message = `Your organization's ${formattedLimit}${budgetPeriod} spend limit has been reached.`
|
||||
|
||||
throw ClineError.transform({
|
||||
status: 429,
|
||||
code: "SPEND_LIMIT_EXCEEDED",
|
||||
message,
|
||||
details: {
|
||||
code: "SPEND_LIMIT_EXCEEDED",
|
||||
budget_period: budgetPeriod,
|
||||
limit_usd: limitUsd,
|
||||
spent_usd: spentUsd,
|
||||
resets_at: resetsAt,
|
||||
message,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
private async writePromptMetadataArtifacts(params: { systemPrompt: string; providerInfo: ApiProviderInfo }): Promise<void> {
|
||||
const enabledFlag = process.env.CLINE_WRITE_PROMPT_ARTIFACTS?.toLowerCase()
|
||||
const enabled = enabledFlag === "1" || enabledFlag === "true" || enabledFlag === "yes"
|
||||
@@ -1914,9 +1868,6 @@ export class Task {
|
||||
})
|
||||
|
||||
const providerInfo = this.getCurrentProviderInfo()
|
||||
|
||||
// Block overbudget third-party requests before they reach the provider.
|
||||
await this.checkThirdPartySpendLimit(providerInfo.providerId)
|
||||
const host = await HostProvider.env.getHostVersion({})
|
||||
const ide = host?.platform || "Unknown"
|
||||
const isCliEnvironment = host.clineType === ClineClient.Cli
|
||||
@@ -2129,7 +2080,6 @@ export class Task {
|
||||
}
|
||||
|
||||
const isAuthError = clineError.isErrorType(ClineErrorType.Auth)
|
||||
const isSpendLimitError = clineError.isErrorType(ClineErrorType.SpendLimit)
|
||||
|
||||
// Check if this is a Cline provider insufficient credits error - don't auto-retry these
|
||||
const isClineProviderInsufficientCredits = (() => {
|
||||
@@ -2145,13 +2095,8 @@ export class Task {
|
||||
})()
|
||||
|
||||
let response: ClineAskResponse
|
||||
// Skip auto-retry for Cline provider insufficient credits, auth errors, or spend limit errors
|
||||
if (
|
||||
!isClineProviderInsufficientCredits &&
|
||||
!isAuthError &&
|
||||
!isSpendLimitError &&
|
||||
this.taskState.autoRetryAttempts < 3
|
||||
) {
|
||||
// Skip auto-retry for Cline provider insufficient credits or auth errors
|
||||
if (!isClineProviderInsufficientCredits && !isAuthError && this.taskState.autoRetryAttempts < 3) {
|
||||
// Auto-retry enabled with max 3 attempts: automatically approve the retry
|
||||
this.taskState.autoRetryAttempts++
|
||||
|
||||
@@ -2201,8 +2146,8 @@ export class Task {
|
||||
|
||||
await setTimeoutPromise(delay)
|
||||
} else {
|
||||
// Show error_retry with failed flag to indicate all retries exhausted (but not for insufficient credits or spend limit)
|
||||
if (!isClineProviderInsufficientCredits && !isAuthError && !isSpendLimitError) {
|
||||
// Show error_retry with failed flag to indicate all retries exhausted (but not for insufficient credits)
|
||||
if (!isClineProviderInsufficientCredits && !isAuthError) {
|
||||
await this.say(
|
||||
"error_retry",
|
||||
JSON.stringify({
|
||||
@@ -3058,9 +3003,8 @@ export class Task {
|
||||
if (!this.taskState.abandoned) {
|
||||
const clineError = ErrorService.get().toClineError(error, this.api.getModel().id)
|
||||
const errorMessage = clineError.serialize()
|
||||
const isStreamingSpendLimitError = clineError.isErrorType(ClineErrorType.SpendLimit)
|
||||
// Auto-retry for streaming failures (skip for spend limit errors)
|
||||
if (!isStreamingSpendLimitError && this.taskState.autoRetryAttempts < 3) {
|
||||
// Auto-retry for streaming failures (always enabled)
|
||||
if (this.taskState.autoRetryAttempts < 3) {
|
||||
this.taskState.autoRetryAttempts++
|
||||
|
||||
// Calculate exponential backoff for streaming failures: 2s, 4s, 8s
|
||||
@@ -3086,7 +3030,7 @@ export class Task {
|
||||
await this.controller.task.handleWebviewAskResponse("yesButtonClicked", "", [])
|
||||
}
|
||||
})
|
||||
} else if (!isStreamingSpendLimitError && this.taskState.autoRetryAttempts >= 3) {
|
||||
} else if (this.taskState.autoRetryAttempts >= 3) {
|
||||
// Show error_retry with failed flag to indicate all retries exhausted
|
||||
await this.say(
|
||||
"error_retry",
|
||||
|
||||
@@ -18,19 +18,16 @@ import { ToolResultUtils } from "../utils/ToolResultUtils"
|
||||
export const DEFAULT_MAX_LINES = 1000
|
||||
const FILE_TRUNCATED_MARKER = "\n\n---\n\n[FILE TRUNCATED:"
|
||||
|
||||
type DisplayedLineSlice = {
|
||||
start: number
|
||||
end: number
|
||||
totalLines: number
|
||||
lines: string[]
|
||||
truncationSuffix: string
|
||||
}
|
||||
|
||||
function getDisplayedLineSlice(content: string, startLine?: number, endLine?: number): DisplayedLineSlice | null {
|
||||
/**
|
||||
* Slice file content to the requested line range, add one-based `N |` line labels,
|
||||
* and append a continuation hint when the file has more lines to read.
|
||||
*/
|
||||
export function formatFileContentWithLineNumbers(content: string, startLine?: number, endLine?: number): string {
|
||||
if (!content) {
|
||||
return null
|
||||
return content
|
||||
}
|
||||
|
||||
// Separate any byte-truncation notice appended by content-limits.ts
|
||||
let body = content
|
||||
let truncationSuffix = ""
|
||||
const truncationIndex = content.indexOf(FILE_TRUNCATED_MARKER)
|
||||
@@ -51,42 +48,6 @@ function getDisplayedLineSlice(content: string, startLine?: number, endLine?: nu
|
||||
const start = shouldSwapBounds ? requestedEnd : requestedStart
|
||||
const end = Math.min(totalLines, shouldSwapBounds ? requestedStart : requestedEnd)
|
||||
|
||||
return { start, end, totalLines, lines, truncationSuffix }
|
||||
}
|
||||
|
||||
/**
|
||||
* Line range shown for a read_file result (matches formatFileContentWithLineNumbers). Omits image reads and empty files.
|
||||
*/
|
||||
export function getReadToolDisplayedLineRange(
|
||||
block: ToolUse,
|
||||
fileContent: FileContentResult,
|
||||
): { start: number; end: number } | undefined {
|
||||
if (fileContent.imageBlock) {
|
||||
return undefined
|
||||
}
|
||||
const { startLine, endLine } = parseRequestedLineRange(block)
|
||||
const slice = getDisplayedLineSlice(fileContent.text, startLine, endLine)
|
||||
if (!slice || slice.totalLines === 0) {
|
||||
return undefined
|
||||
}
|
||||
return { start: slice.start, end: slice.end }
|
||||
}
|
||||
|
||||
/**
|
||||
* Slice file content to the requested line range, add one-based `N |` line labels,
|
||||
* and append a continuation hint when the file has more lines to read.
|
||||
*/
|
||||
export function formatFileContentWithLineNumbers(content: string, startLine?: number, endLine?: number): string {
|
||||
if (!content) {
|
||||
return content
|
||||
}
|
||||
|
||||
const meta = getDisplayedLineSlice(content, startLine, endLine)
|
||||
if (!meta) {
|
||||
return content
|
||||
}
|
||||
|
||||
const { start, end, totalLines, lines, truncationSuffix } = meta
|
||||
const slice = lines.slice(start - 1, end)
|
||||
const labeled = slice.map((line, i) => `${start + i} | ${line}`).join("\n")
|
||||
|
||||
@@ -121,24 +82,6 @@ function buildReadResponse(block: ToolUse, fileContent: FileContentResult, prefi
|
||||
return prefix ? `${prefix}\n${text}` : text
|
||||
}
|
||||
|
||||
async function emitReadFileToolUiComplete(
|
||||
config: TaskConfig,
|
||||
sharedMessageProps: ClineSayTool,
|
||||
block: ToolUse,
|
||||
fileContent: FileContentResult,
|
||||
): Promise<void> {
|
||||
if (config.isSubagentExecution) {
|
||||
return
|
||||
}
|
||||
const range = getReadToolDisplayedLineRange(block, fileContent)
|
||||
const payload: ClineSayTool = { ...sharedMessageProps }
|
||||
if (range) {
|
||||
payload.readLineStart = range.start
|
||||
payload.readLineEnd = range.end
|
||||
}
|
||||
await config.callbacks.say("tool", JSON.stringify(payload), undefined, undefined, false)
|
||||
}
|
||||
|
||||
export class ReadFileToolHandler implements IFullyManagedTool {
|
||||
readonly name = ClineDefaultTool.FILE_READ
|
||||
|
||||
@@ -227,9 +170,10 @@ export class ReadFileToolHandler implements IFullyManagedTool {
|
||||
const shouldAutoApprove =
|
||||
config.isSubagentExecution || (await config.callbacks.shouldAutoApproveToolWithPath(block.name, relPath))
|
||||
if (shouldAutoApprove) {
|
||||
// Auto-approval flow (completed read is announced after extractFileContent so line range is known)
|
||||
// Auto-approval flow
|
||||
if (!config.isSubagentExecution) {
|
||||
await config.callbacks.removeLastPartialMessageIfExistsWithType("ask", "tool")
|
||||
await config.callbacks.say("tool", completeMessage, undefined, undefined, false)
|
||||
}
|
||||
|
||||
// Capture telemetry
|
||||
@@ -339,7 +283,6 @@ export class ReadFileToolHandler implements IFullyManagedTool {
|
||||
}
|
||||
|
||||
if (validCached.readCount >= 3) {
|
||||
await emitReadFileToolUiComplete(config, sharedMessageProps, block, fileContent)
|
||||
return buildReadResponse(
|
||||
block,
|
||||
fileContent,
|
||||
@@ -347,7 +290,6 @@ export class ReadFileToolHandler implements IFullyManagedTool {
|
||||
)
|
||||
}
|
||||
|
||||
await emitReadFileToolUiComplete(config, sharedMessageProps, block, fileContent)
|
||||
return buildReadResponse(
|
||||
block,
|
||||
fileContent,
|
||||
@@ -396,11 +338,9 @@ export class ReadFileToolHandler implements IFullyManagedTool {
|
||||
// Handle image blocks separately - they need to be pushed to userMessageContent
|
||||
if (fileContent.imageBlock) {
|
||||
config.taskState.userMessageContent.push(fileContent.imageBlock)
|
||||
await emitReadFileToolUiComplete(config, sharedMessageProps, block, fileContent)
|
||||
return buildReadResponse(block, fileContent)
|
||||
}
|
||||
|
||||
await emitReadFileToolUiComplete(config, sharedMessageProps, block, fileContent)
|
||||
return buildReadResponse(block, fileContent)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,34 +1,6 @@
|
||||
import assert from "node:assert/strict"
|
||||
import { ClineDefaultTool } from "@shared/tools"
|
||||
import { describe, it } from "mocha"
|
||||
import { DEFAULT_MAX_LINES, formatFileContentWithLineNumbers, getReadToolDisplayedLineRange } from "../ReadFileToolHandler"
|
||||
|
||||
describe("getReadToolDisplayedLineRange", () => {
|
||||
const block = (start?: string, end?: string) => ({
|
||||
type: "tool_use" as const,
|
||||
name: ClineDefaultTool.FILE_READ,
|
||||
params: {
|
||||
path: "f.txt",
|
||||
...(start !== undefined ? { start_line: start } : {}),
|
||||
...(end !== undefined ? { end_line: end } : {}),
|
||||
},
|
||||
partial: false,
|
||||
})
|
||||
|
||||
it("matches the slice shown for explicit start/end", () => {
|
||||
const text = Array.from({ length: 10 }, (_, i) => `L${i + 1}`).join("\n")
|
||||
const r = getReadToolDisplayedLineRange(block("3", "5"), { text })
|
||||
assert.deepEqual(r, { start: 3, end: 5 })
|
||||
})
|
||||
|
||||
it("returns undefined for image reads", () => {
|
||||
const r = getReadToolDisplayedLineRange(block(), {
|
||||
text: "ok",
|
||||
imageBlock: { type: "image", source: { type: "url", url: "x" } } as any,
|
||||
})
|
||||
assert.equal(r, undefined)
|
||||
})
|
||||
})
|
||||
import { DEFAULT_MAX_LINES, formatFileContentWithLineNumbers } from "../ReadFileToolHandler"
|
||||
|
||||
describe("formatFileContentWithLineNumbers", () => {
|
||||
describe("line labels", () => {
|
||||
|
||||
@@ -3,7 +3,6 @@ import type {
|
||||
FeaturebaseTokenResponse,
|
||||
OrganizationBalanceResponse,
|
||||
OrganizationUsageTransaction,
|
||||
OverbudgetStatus,
|
||||
PaymentTransaction,
|
||||
UsageTransaction,
|
||||
UserResponse,
|
||||
@@ -235,43 +234,6 @@ export class ClineAccountService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* RPC variant that fetches the overbudget status for the given org.
|
||||
* Returns undefined when the feature is not enabled (403/404) or on failure
|
||||
* so that spend-control checks never block tasks on transient errors.
|
||||
*/
|
||||
async fetchOverbudgetStatusRPC(organizationId: string): Promise<OverbudgetStatus | undefined> {
|
||||
try {
|
||||
return await this.authenticatedRequest<OverbudgetStatus>(`/api/v1/organizations/${organizationId}/budget/overbudget`)
|
||||
} catch (error) {
|
||||
// 403/404 = non-enterprise org, expected for most users
|
||||
if (axios.isAxiosError(error)) {
|
||||
const status = error.response?.status
|
||||
if (status === 403 || status === 404) {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
Logger.error("Failed to fetch overbudget status (RPC):", error)
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Submits a spend limit increase request to the user's org admin.
|
||||
* Called when the user hits a SPEND_LIMIT_EXCEEDED (429) error and clicks "Request Increase".
|
||||
* @returns void — the backend records the request; errors are logged and swallowed
|
||||
*/
|
||||
async submitLimitIncreaseRequestRPC(): Promise<void> {
|
||||
try {
|
||||
await this.authenticatedRequest<void>("/api/v1/users/me/budget/request", {
|
||||
method: "POST",
|
||||
})
|
||||
} catch (error) {
|
||||
Logger.error("Failed to submit limit increase request (RPC):", error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Switches the active account to the specified organization or personal account.
|
||||
* @param organizationId - Optional organization ID to switch to. If not provided, it will switch to the personal account.
|
||||
|
||||
@@ -316,14 +316,6 @@ export class AuthService {
|
||||
if (this._clineAuthInfo) {
|
||||
this._authenticated = true
|
||||
await this.sendAuthStatusUpdate()
|
||||
|
||||
// Warm the third-party spend-limit cache so the status is ready
|
||||
// before the user's first task. Fire-and-forget; failures are
|
||||
// fully handled inside the service (logged, not thrown).
|
||||
// Dynamic import to avoid a hard dependency cycle with task layer.
|
||||
import("../spend-limit/ThirdPartySpendLimitService")
|
||||
.then(({ ThirdPartySpendLimitService }) => ThirdPartySpendLimitService.getInstance().fetchIfNeeded())
|
||||
.catch((err) => Logger.debug(`[SpendControl] Cache-warm on login failed: ${err}`))
|
||||
} else {
|
||||
Logger.warn("No user found after restoring auth token")
|
||||
this._authenticated = false
|
||||
|
||||
@@ -6,7 +6,6 @@ export enum ClineErrorType {
|
||||
Network = "network",
|
||||
RateLimit = "rateLimit",
|
||||
Balance = "balance",
|
||||
SpendLimit = "spendLimit",
|
||||
}
|
||||
|
||||
interface ErrorDetails {
|
||||
@@ -145,12 +144,6 @@ export class ClineError extends Error {
|
||||
return ClineErrorType.Balance
|
||||
}
|
||||
|
||||
// Check spend limit exceeded (org-enforced budget cap, 429 SPEND_LIMIT_EXCEEDED)
|
||||
// Must be checked before the generic rate-limit check since both use 429
|
||||
if (code === "SPEND_LIMIT_EXCEEDED" || details?.code === "SPEND_LIMIT_EXCEEDED") {
|
||||
return ClineErrorType.SpendLimit
|
||||
}
|
||||
|
||||
// Check auth errors
|
||||
const isAuthStatus = status !== undefined && status > 400 && status < 429
|
||||
if (code === "ERR_BAD_REQUEST" || err instanceof AuthInvalidTokenError || isAuthStatus) {
|
||||
|
||||
@@ -1,177 +0,0 @@
|
||||
import type { OverbudgetStatus } from "@shared/ClineAccount"
|
||||
import { Logger } from "@/shared/services/Logger"
|
||||
import { ClineAccountService } from "../account/ClineAccountService"
|
||||
import { AuthService } from "../auth/AuthService"
|
||||
|
||||
/**
|
||||
* Default TTL (5 minutes) before a cached status is considered stale and
|
||||
* refreshed on the next `fetchIfNeeded()` call.
|
||||
*/
|
||||
export const DEFAULT_SPEND_LIMIT_TTL_MS = 5 * 60 * 1000
|
||||
|
||||
/**
|
||||
* Read a TTL override from the environment. Supports either a raw ms value
|
||||
* (e.g. "60000") or a humanised shorthand (e.g. "5m", "30s", "1m").
|
||||
* Returns `undefined` when the override is missing/invalid so the caller can
|
||||
* fall back to the default.
|
||||
*/
|
||||
function resolveTtlFromEnv(): number | undefined {
|
||||
const raw = process.env.CLINE_SPEND_LIMIT_TTL_MS?.trim()
|
||||
if (!raw) {
|
||||
return undefined
|
||||
}
|
||||
const shorthand = raw.match(/^(\d+)\s*(ms|s|m)?$/i)
|
||||
if (!shorthand) {
|
||||
return undefined
|
||||
}
|
||||
const value = Number(shorthand[1])
|
||||
if (!Number.isFinite(value) || value < 0) {
|
||||
return undefined
|
||||
}
|
||||
const unit = (shorthand[2] ?? "ms").toLowerCase()
|
||||
switch (unit) {
|
||||
case "s":
|
||||
return value * 1000
|
||||
case "m":
|
||||
return value * 60 * 1000
|
||||
default:
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Caches the active org's third-party spend-limit status so Task can check it
|
||||
* before dispatching non-Cline provider requests (Anthropic, OpenAI, etc.).
|
||||
* The Cline provider is enforced server-side and is not checked here.
|
||||
*
|
||||
* The cache is a single slot keyed by the active org ID with a configurable
|
||||
* TTL. Switching orgs always overwrites the slot; within the same org the
|
||||
* status is refreshed on the next call after the TTL elapses.
|
||||
*/
|
||||
export class ThirdPartySpendLimitService {
|
||||
private static instance: ThirdPartySpendLimitService
|
||||
|
||||
private cachedStatus: OverbudgetStatus | null = null
|
||||
private cachedOrgId: string | null = null
|
||||
private cachedAt = 0
|
||||
private fetchPromise: Promise<void> | null = null
|
||||
private ttlMs: number = resolveTtlFromEnv() ?? DEFAULT_SPEND_LIMIT_TTL_MS
|
||||
// Injection seam for tests; defaults to real wall clock.
|
||||
private now: () => number = () => Date.now()
|
||||
|
||||
private constructor() {}
|
||||
|
||||
public static getInstance(): ThirdPartySpendLimitService {
|
||||
if (!ThirdPartySpendLimitService.instance) {
|
||||
ThirdPartySpendLimitService.instance = new ThirdPartySpendLimitService()
|
||||
}
|
||||
return ThirdPartySpendLimitService.instance
|
||||
}
|
||||
|
||||
/**
|
||||
* Dynamically set the TTL (in ms). Useful for experimenting with shorter
|
||||
* windows (e.g. 1 minute) without redeploying. Setting 0 disables caching
|
||||
* entirely and forces every call to refetch.
|
||||
*/
|
||||
setTtlMs(ttlMs: number): void {
|
||||
if (!Number.isFinite(ttlMs) || ttlMs < 0) {
|
||||
throw new Error(`Invalid TTL: ${ttlMs}`)
|
||||
}
|
||||
this.ttlMs = ttlMs
|
||||
}
|
||||
|
||||
/** Current TTL in ms. Exposed for diagnostics + tests. */
|
||||
getTtlMs(): number {
|
||||
return this.ttlMs
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures the cache reflects the currently active org. Safe to call from
|
||||
* hot paths; a no-op while the cached entry is fresh. Never throws —
|
||||
* failures resolve to a null cache so they cannot block task execution.
|
||||
*/
|
||||
async fetchIfNeeded(): Promise<void> {
|
||||
const activeOrgId = this.getActiveOrgId()
|
||||
|
||||
if (!activeOrgId) {
|
||||
this.cachedStatus = null
|
||||
this.cachedOrgId = null
|
||||
this.cachedAt = 0
|
||||
return
|
||||
}
|
||||
|
||||
const cacheHit = this.cachedOrgId === activeOrgId && !this.isStale()
|
||||
if (cacheHit) {
|
||||
return
|
||||
}
|
||||
|
||||
if (this.fetchPromise) {
|
||||
await this.fetchPromise
|
||||
if (this.cachedOrgId === activeOrgId && !this.isStale()) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
this.fetchPromise = this.doFetch(activeOrgId).finally(() => {
|
||||
this.fetchPromise = null
|
||||
})
|
||||
return this.fetchPromise
|
||||
}
|
||||
|
||||
private isStale(): boolean {
|
||||
if (this.ttlMs === 0) {
|
||||
return true
|
||||
}
|
||||
return this.now() - this.cachedAt >= this.ttlMs
|
||||
}
|
||||
|
||||
private async doFetch(organizationId: string): Promise<void> {
|
||||
try {
|
||||
const status = await ClineAccountService.getInstance().fetchOverbudgetStatusRPC(organizationId)
|
||||
this.cachedStatus = status ?? null
|
||||
this.cachedOrgId = organizationId
|
||||
this.cachedAt = this.now()
|
||||
} catch (err) {
|
||||
// Double-guard: fetchOverbudgetStatusRPC already swallows errors, but
|
||||
// callers rely on this method never throwing.
|
||||
Logger.error("Unexpected error fetching overbudget status:", err)
|
||||
this.cachedStatus = null
|
||||
this.cachedOrgId = organizationId
|
||||
this.cachedAt = this.now()
|
||||
}
|
||||
}
|
||||
|
||||
/** Current cached status, or null if unavailable / feature not enabled. */
|
||||
getStatus(): OverbudgetStatus | null {
|
||||
return this.cachedStatus
|
||||
}
|
||||
|
||||
/** Quick check for blocking decisions. */
|
||||
isOverbudget(): boolean {
|
||||
return this.cachedStatus?.overbudget === true
|
||||
}
|
||||
|
||||
/** Clears the cache. Used on logout, active-org change, and in tests. */
|
||||
invalidate(): void {
|
||||
this.cachedStatus = null
|
||||
this.cachedOrgId = null
|
||||
this.cachedAt = 0
|
||||
this.fetchPromise = null
|
||||
}
|
||||
|
||||
/**
|
||||
* Test-only seam: override the clock used to evaluate freshness.
|
||||
* Not exported from the public surface beyond tests.
|
||||
*/
|
||||
_setClockForTest(now: () => number): void {
|
||||
this.now = now
|
||||
}
|
||||
|
||||
private getActiveOrgId(): string | null {
|
||||
try {
|
||||
return AuthService.getInstance().getActiveOrganizationId()
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -85,47 +85,3 @@ export interface OrganizationUsageTransaction {
|
||||
|
||||
// Used in cline.ts provider and in webview-ui/src/components/chat/ChatRow.tsx to display the login button
|
||||
export const CLINE_ACCOUNT_AUTH_ERROR_MESSAGE = "Unauthorized: Please sign in to Cline before trying again."
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Spend control (third-party API spend limits)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Source of the effective limits for a user.
|
||||
* - "none": no limits apply
|
||||
* - "org_default": org-wide defaults apply to this user
|
||||
* - "user_override": a per-user override has been set
|
||||
*/
|
||||
export type LimitSource = "none" | "org_default" | "user_override"
|
||||
|
||||
/**
|
||||
* Effective budget limits for a user within an organization.
|
||||
* All USD amounts. `null` means the limit is not set.
|
||||
*/
|
||||
export interface EffectiveLimits {
|
||||
monthlyLimitUsd: number | null
|
||||
dailyLimitUsd: number | null
|
||||
orgMonthlyUsd: number | null
|
||||
source: LimitSource
|
||||
}
|
||||
|
||||
/**
|
||||
* A user's current-period spend (for the active org).
|
||||
* ISO-8601 timestamps for reset times.
|
||||
*/
|
||||
export interface BudgetUserCurrentPeriod {
|
||||
monthlySpendUsd: number
|
||||
dailySpendUsd: number
|
||||
monthResetsAt?: string
|
||||
dayResetsAt?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Payload returned by the backend overbudget check endpoint.
|
||||
* See: GET /api/v1/organizations/{orgId}/budget/overbudget
|
||||
*/
|
||||
export interface OverbudgetStatus {
|
||||
overbudget: boolean
|
||||
limits: EffectiveLimits
|
||||
usage: BudgetUserCurrentPeriod
|
||||
}
|
||||
|
||||
@@ -216,9 +216,6 @@ export interface ClineSayTool {
|
||||
operationIsLocatedInWorkspace?: boolean
|
||||
/** Starting line numbers in the original file where each SEARCH block matched */
|
||||
startLineNumbers?: number[]
|
||||
/** Inclusive line range actually returned by read_file (for UI summaries). */
|
||||
readLineStart?: number
|
||||
readLineEnd?: number
|
||||
}
|
||||
|
||||
export interface ClineSayHook {
|
||||
|
||||
@@ -12,12 +12,12 @@ export const E2E_REGISTERED_MOCK_ENDPOINTS = {
|
||||
"/users/{userId}/usages",
|
||||
"/users/{userId}/payments",
|
||||
],
|
||||
POST: ["/chat/completions", "/auth/token", "/users/me/budget/request"],
|
||||
POST: ["/chat/completions", "/auth/token"],
|
||||
PUT: ["/users/active-account"],
|
||||
},
|
||||
"/.test": {
|
||||
GET: [],
|
||||
POST: ["/auth", "/setUserBalance", "/setUserHasOrganization", "/setOrgBalance", "/setSpendLimitExceeded"],
|
||||
POST: ["/auth", "/setUserBalance", "/setUserHasOrganization", "/setOrgBalance"],
|
||||
PUT: [],
|
||||
},
|
||||
"/health": {
|
||||
|
||||
@@ -24,7 +24,6 @@ export class ClineApiServerMock {
|
||||
private userBalance = 100.5 // Default sufficient balance
|
||||
private orgBalance = 500.0
|
||||
private userHasOrganization = false
|
||||
private spendLimitExceeded = false
|
||||
public generationCounter = 0
|
||||
|
||||
public readonly API_USER = new ClineDataMock("personal")
|
||||
@@ -50,16 +49,6 @@ export class ClineApiServerMock {
|
||||
this.orgBalance = balance
|
||||
}
|
||||
|
||||
/**
|
||||
* Puts the mock server into "spend limit exceeded" mode.
|
||||
* While true, POST /api/v1/chat/completions returns 429 SPEND_LIMIT_EXCEEDED
|
||||
* instead of a normal streaming response.
|
||||
* Toggle off to resume normal behaviour.
|
||||
*/
|
||||
public setSpendLimitExceeded(exceeded: boolean) {
|
||||
this.spendLimitExceeded = exceeded
|
||||
}
|
||||
|
||||
public setCurrentUser(user: UserResponse | null) {
|
||||
this.API_USER.setCurrentUser(user)
|
||||
this.currentUser = user
|
||||
@@ -380,35 +369,8 @@ export class ClineApiServerMock {
|
||||
})
|
||||
}
|
||||
|
||||
// Budget limit increase request endpoint
|
||||
if (endpoint === "/users/me/budget/request" && method === "POST") {
|
||||
log("Spend limit increase request received — recording and notifying admin")
|
||||
res.writeHead(204)
|
||||
res.end()
|
||||
return
|
||||
}
|
||||
|
||||
// Chat completions endpoint
|
||||
if (endpoint === "/chat/completions" && method === "POST") {
|
||||
// Spend limit check takes priority — org-enforced budget cap (429)
|
||||
if (controller.spendLimitExceeded) {
|
||||
log("Returning SPEND_LIMIT_EXCEEDED (429)")
|
||||
return sendJson(
|
||||
{
|
||||
error: {
|
||||
code: "SPEND_LIMIT_EXCEEDED",
|
||||
limit_scope: "user",
|
||||
budget_period: "daily",
|
||||
limit_usd: 20.0,
|
||||
spent_usd: 20.5,
|
||||
resets_at: new Date(Date.now() + 8 * 60 * 60 * 1000).toISOString(),
|
||||
message: "Your daily spend limit of $20.00 has been reached.",
|
||||
},
|
||||
},
|
||||
429,
|
||||
)
|
||||
}
|
||||
|
||||
if (!controller.userHasOrganization && controller.userBalance <= 0) {
|
||||
return sendApiError(
|
||||
JSON.stringify({
|
||||
@@ -577,15 +539,6 @@ export class ClineApiServerMock {
|
||||
res.end()
|
||||
return
|
||||
}
|
||||
|
||||
if (endpoint === "/setSpendLimitExceeded" && method === "POST") {
|
||||
const body = await readBody()
|
||||
const { exceeded } = JSON.parse(body)
|
||||
controller.setSpendLimitExceeded(!!exceeded)
|
||||
res.writeHead(200)
|
||||
res.end()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// If we get here, the route was matched but not handled
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import * as assert from "assert"
|
||||
import axios from "axios"
|
||||
import { afterEach, beforeEach, describe, it } from "mocha"
|
||||
import sinon from "sinon"
|
||||
import { ClineAccountService } from "@/services/account/ClineAccountService"
|
||||
@@ -50,73 +49,3 @@ describe("ClineAccountService.fetchFeaturebaseToken", () => {
|
||||
assert.strictEqual(result, undefined)
|
||||
})
|
||||
})
|
||||
|
||||
describe("ClineAccountService.fetchOverbudgetStatusRPC", () => {
|
||||
let service: ClineAccountService
|
||||
let sandbox: sinon.SinonSandbox
|
||||
|
||||
beforeEach(() => {
|
||||
sandbox = sinon.createSandbox()
|
||||
sandbox.stub(AuthService, "getInstance").returns({} as AuthService)
|
||||
service = new ClineAccountService()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
sandbox.restore()
|
||||
})
|
||||
|
||||
const buildAxiosError = (status: number): Error => {
|
||||
const err = new Error(`Request failed with status code ${status}`) as Error & {
|
||||
isAxiosError: boolean
|
||||
response: { status: number }
|
||||
}
|
||||
err.isAxiosError = true
|
||||
err.response = { status }
|
||||
return err
|
||||
}
|
||||
|
||||
it("returns the overbudget status on a successful authenticated request", async () => {
|
||||
const payload = {
|
||||
overbudget: true,
|
||||
limits: { monthlyLimitUsd: 500, dailyLimitUsd: 50, orgMonthlyUsd: 5000, source: "org_default" },
|
||||
usage: { monthlySpendUsd: 0, dailySpendUsd: 0 },
|
||||
}
|
||||
sandbox.stub(service as unknown as { authenticatedRequest: () => unknown }, "authenticatedRequest").resolves(payload)
|
||||
|
||||
const result = await service.fetchOverbudgetStatusRPC("org-123")
|
||||
|
||||
assert.deepStrictEqual(result, payload)
|
||||
})
|
||||
|
||||
it("returns undefined when the backend responds with 403 (feature not enabled)", async () => {
|
||||
sandbox.stub(axios, "isAxiosError").returns(true)
|
||||
sandbox
|
||||
.stub(service as unknown as { authenticatedRequest: () => unknown }, "authenticatedRequest")
|
||||
.rejects(buildAxiosError(403))
|
||||
|
||||
const result = await service.fetchOverbudgetStatusRPC("org-123")
|
||||
|
||||
assert.strictEqual(result, undefined)
|
||||
})
|
||||
|
||||
it("returns undefined when the backend responds with 404 (feature not enabled)", async () => {
|
||||
sandbox.stub(axios, "isAxiosError").returns(true)
|
||||
sandbox
|
||||
.stub(service as unknown as { authenticatedRequest: () => unknown }, "authenticatedRequest")
|
||||
.rejects(buildAxiosError(404))
|
||||
|
||||
const result = await service.fetchOverbudgetStatusRPC("org-123")
|
||||
|
||||
assert.strictEqual(result, undefined)
|
||||
})
|
||||
|
||||
it("returns undefined on transient network failure without throwing", async () => {
|
||||
sandbox
|
||||
.stub(service as unknown as { authenticatedRequest: () => unknown }, "authenticatedRequest")
|
||||
.rejects(new Error("Network error"))
|
||||
|
||||
const result = await service.fetchOverbudgetStatusRPC("org-123")
|
||||
|
||||
assert.strictEqual(result, undefined)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,183 +0,0 @@
|
||||
import type { OverbudgetStatus } from "@shared/ClineAccount"
|
||||
import * as assert from "assert"
|
||||
import { afterEach, beforeEach, describe, it } from "mocha"
|
||||
import sinon from "sinon"
|
||||
import { ClineAccountService } from "@/services/account/ClineAccountService"
|
||||
import { AuthService } from "@/services/auth/AuthService"
|
||||
import { DEFAULT_SPEND_LIMIT_TTL_MS, ThirdPartySpendLimitService } from "@/services/spend-limit/ThirdPartySpendLimitService"
|
||||
|
||||
const SAMPLE_STATUS: OverbudgetStatus = {
|
||||
overbudget: true,
|
||||
limits: { monthlyLimitUsd: 500, dailyLimitUsd: 50, orgMonthlyUsd: 5000, source: "org_default" },
|
||||
usage: { monthlySpendUsd: 0, dailySpendUsd: 0 },
|
||||
}
|
||||
|
||||
describe("ThirdPartySpendLimitService", () => {
|
||||
let sandbox: sinon.SinonSandbox
|
||||
let svc: ThirdPartySpendLimitService
|
||||
let fetchStub: sinon.SinonStub
|
||||
let getActiveOrgStub: sinon.SinonStub
|
||||
let fakeNow: number
|
||||
|
||||
beforeEach(() => {
|
||||
sandbox = sinon.createSandbox()
|
||||
|
||||
getActiveOrgStub = sandbox.stub().returns("org-123")
|
||||
sandbox.stub(AuthService, "getInstance").returns({ getActiveOrganizationId: getActiveOrgStub } as unknown as AuthService)
|
||||
|
||||
fetchStub = sandbox.stub(ClineAccountService.prototype, "fetchOverbudgetStatusRPC")
|
||||
sandbox.stub(ClineAccountService, "getInstance").returns(new ClineAccountService())
|
||||
|
||||
// Reset singleton between tests via the public invalidate hook.
|
||||
svc = ThirdPartySpendLimitService.getInstance()
|
||||
svc.invalidate()
|
||||
// Reset to the canonical default so each test starts from a known state.
|
||||
svc.setTtlMs(DEFAULT_SPEND_LIMIT_TTL_MS)
|
||||
|
||||
// Controllable clock so we can exercise TTL behaviour deterministically.
|
||||
fakeNow = 1_000_000
|
||||
svc._setClockForTest(() => fakeNow)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
sandbox.restore()
|
||||
svc.invalidate()
|
||||
svc.setTtlMs(DEFAULT_SPEND_LIMIT_TTL_MS)
|
||||
svc._setClockForTest(() => Date.now())
|
||||
})
|
||||
|
||||
it("caches the status after the first fetch and does not re-fetch for the same org", async () => {
|
||||
fetchStub.resolves(SAMPLE_STATUS)
|
||||
|
||||
await svc.fetchIfNeeded()
|
||||
await svc.fetchIfNeeded()
|
||||
await svc.fetchIfNeeded()
|
||||
|
||||
assert.strictEqual(fetchStub.callCount, 1, "should fetch exactly once per org per session")
|
||||
assert.deepStrictEqual(svc.getStatus(), SAMPLE_STATUS)
|
||||
assert.strictEqual(svc.isOverbudget(), true)
|
||||
})
|
||||
|
||||
it("de-dupes concurrent fetches", async () => {
|
||||
let resolveFetch: (value: OverbudgetStatus) => void = () => {}
|
||||
fetchStub.returns(
|
||||
new Promise<OverbudgetStatus>((resolve) => {
|
||||
resolveFetch = resolve
|
||||
}),
|
||||
)
|
||||
|
||||
const [p1, p2, p3] = [svc.fetchIfNeeded(), svc.fetchIfNeeded(), svc.fetchIfNeeded()]
|
||||
resolveFetch(SAMPLE_STATUS)
|
||||
await Promise.all([p1, p2, p3])
|
||||
|
||||
assert.strictEqual(fetchStub.callCount, 1, "concurrent calls must share a single in-flight fetch")
|
||||
})
|
||||
|
||||
it("re-fetches when the active org changes", async () => {
|
||||
fetchStub.onFirstCall().resolves(SAMPLE_STATUS)
|
||||
fetchStub.onSecondCall().resolves({ ...SAMPLE_STATUS, overbudget: false })
|
||||
|
||||
await svc.fetchIfNeeded()
|
||||
assert.strictEqual(svc.isOverbudget(), true)
|
||||
|
||||
getActiveOrgStub.returns("org-456")
|
||||
await svc.fetchIfNeeded()
|
||||
|
||||
assert.strictEqual(fetchStub.callCount, 2)
|
||||
assert.strictEqual(svc.isOverbudget(), false)
|
||||
})
|
||||
|
||||
it("clears the cache when there is no active org", async () => {
|
||||
fetchStub.resolves(SAMPLE_STATUS)
|
||||
await svc.fetchIfNeeded()
|
||||
assert.ok(svc.getStatus())
|
||||
|
||||
getActiveOrgStub.returns(null)
|
||||
await svc.fetchIfNeeded()
|
||||
|
||||
assert.strictEqual(svc.getStatus(), null)
|
||||
})
|
||||
|
||||
it("does not throw when the underlying fetch fails", async () => {
|
||||
fetchStub.rejects(new Error("boom"))
|
||||
|
||||
await svc.fetchIfNeeded()
|
||||
|
||||
assert.strictEqual(svc.getStatus(), null)
|
||||
assert.strictEqual(svc.isOverbudget(), false)
|
||||
})
|
||||
|
||||
it("treats an undefined RPC response (feature not enabled) as not-overbudget", async () => {
|
||||
fetchStub.resolves(undefined)
|
||||
|
||||
await svc.fetchIfNeeded()
|
||||
|
||||
assert.strictEqual(svc.getStatus(), null)
|
||||
assert.strictEqual(svc.isOverbudget(), false)
|
||||
})
|
||||
|
||||
describe("TTL behaviour", () => {
|
||||
it("does not refetch while the cached entry is still fresh", async () => {
|
||||
svc.setTtlMs(60_000) // 1 minute
|
||||
fetchStub.resolves(SAMPLE_STATUS)
|
||||
|
||||
await svc.fetchIfNeeded()
|
||||
fakeNow += 30_000 // half the TTL
|
||||
await svc.fetchIfNeeded()
|
||||
|
||||
assert.strictEqual(fetchStub.callCount, 1, "should be served from cache while fresh")
|
||||
})
|
||||
|
||||
it("refetches once the TTL has elapsed for the same org", async () => {
|
||||
svc.setTtlMs(60_000)
|
||||
fetchStub.onFirstCall().resolves(SAMPLE_STATUS)
|
||||
fetchStub.onSecondCall().resolves({ ...SAMPLE_STATUS, overbudget: false })
|
||||
|
||||
await svc.fetchIfNeeded()
|
||||
assert.strictEqual(svc.isOverbudget(), true)
|
||||
|
||||
fakeNow += 60_001 // just past the TTL
|
||||
await svc.fetchIfNeeded()
|
||||
|
||||
assert.strictEqual(fetchStub.callCount, 2, "should refetch after TTL expires")
|
||||
assert.strictEqual(svc.isOverbudget(), false)
|
||||
})
|
||||
|
||||
it("honours a 1-minute TTL override via setTtlMs (experimentation knob)", async () => {
|
||||
svc.setTtlMs(60_000)
|
||||
assert.strictEqual(svc.getTtlMs(), 60_000)
|
||||
fetchStub.resolves(SAMPLE_STATUS)
|
||||
|
||||
await svc.fetchIfNeeded()
|
||||
fakeNow += 59_999
|
||||
await svc.fetchIfNeeded()
|
||||
assert.strictEqual(fetchStub.callCount, 1, "still fresh at 59_999ms")
|
||||
|
||||
fakeNow += 2
|
||||
await svc.fetchIfNeeded()
|
||||
assert.strictEqual(fetchStub.callCount, 2, "stale at 60_001ms")
|
||||
})
|
||||
|
||||
it("ttlMs=0 disables caching entirely (every call refetches)", async () => {
|
||||
svc.setTtlMs(0)
|
||||
fetchStub.resolves(SAMPLE_STATUS)
|
||||
|
||||
await svc.fetchIfNeeded()
|
||||
await svc.fetchIfNeeded()
|
||||
await svc.fetchIfNeeded()
|
||||
|
||||
assert.strictEqual(fetchStub.callCount, 3)
|
||||
})
|
||||
|
||||
it("defaults to DEFAULT_SPEND_LIMIT_TTL_MS (5 minutes)", () => {
|
||||
assert.strictEqual(svc.getTtlMs(), DEFAULT_SPEND_LIMIT_TTL_MS)
|
||||
assert.strictEqual(DEFAULT_SPEND_LIMIT_TTL_MS, 5 * 60 * 1000)
|
||||
})
|
||||
|
||||
it("rejects negative or non-finite TTL values", () => {
|
||||
assert.throws(() => svc.setTtlMs(-1), /Invalid TTL/)
|
||||
assert.throws(() => svc.setTtlMs(Number.NaN), /Invalid TTL/)
|
||||
assert.throws(() => svc.setTtlMs(Number.POSITIVE_INFINITY), /Invalid TTL/)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -527,12 +527,6 @@ export const ChatRowContent = memo(
|
||||
{tool.path && !tool.path.startsWith(".") && <span>/</span>}
|
||||
<span className="ph-no-capture whitespace-nowrap overflow-hidden text-ellipsis mr-2 text-left [direction: rtl]">
|
||||
{cleanPathPrefix(tool.path ?? "") + "\u200E"}
|
||||
{tool.readLineStart != null && tool.readLineEnd != null ? (
|
||||
<span className="opacity-80">
|
||||
{" "}
|
||||
({tool.readLineStart}-{tool.readLineEnd})
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
<div className="grow" />
|
||||
{!isImage && <SquareArrowOutUpRightIcon className="size-2" />}
|
||||
|
||||
@@ -22,7 +22,7 @@ export const ErrorBlockTitle = ({
|
||||
}: ErrorBlockTitleProps): [React.ReactElement, React.ReactElement] => {
|
||||
const getIconSpan = (iconName: string, colorClass: string) => (
|
||||
<div className="w-4 h-4 flex items-center justify-center">
|
||||
<span className={`codicon codicon-${iconName} text-base -mb-0.5 ${colorClass}`} />
|
||||
<span className={`codicon codicon-${iconName} text-base -mb-0.5 ${colorClass}`}></span>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -58,11 +58,7 @@ export const ErrorBlockTitle = ({
|
||||
} else if (apiRequestFailedMessage) {
|
||||
// Handle failed request
|
||||
const clineError = ClineError.parse(apiRequestFailedMessage)
|
||||
const titleText = clineError?.isErrorType(ClineErrorType.Balance)
|
||||
? "Credit Limit Reached"
|
||||
: clineError?.isErrorType(ClineErrorType.SpendLimit)
|
||||
? "Spend Limit Reached"
|
||||
: "API Request Failed"
|
||||
const titleText = clineError?.isErrorType(ClineErrorType.Balance) ? "Credit Limit Reached" : "API Request Failed"
|
||||
details.title = titleText
|
||||
details.classNames.push("font-bold text-(--vscode-errorForeground)")
|
||||
} else if (retryStatus) {
|
||||
|
||||
@@ -153,67 +153,6 @@ export const ClineRateLimitError: Story = {
|
||||
},
|
||||
}
|
||||
|
||||
export const ClineSpendLimitDaily: Story = {
|
||||
args: {
|
||||
message: createMockMessage(),
|
||||
errorType: "error",
|
||||
apiRequestFailedMessage: JSON.stringify({
|
||||
message: "$20.00 daily limit has been reached.",
|
||||
status: 429,
|
||||
code: "SPEND_LIMIT_EXCEEDED",
|
||||
providerId: "cline",
|
||||
details: {
|
||||
code: "SPEND_LIMIT_EXCEEDED",
|
||||
limit_scope: "user",
|
||||
budget_period: "daily",
|
||||
limit_usd: 20.0,
|
||||
spent_usd: 20.5,
|
||||
resets_at: new Date(Date.now() + 8 * 60 * 60 * 1000).toISOString(),
|
||||
message: "$20.00 daily limit has been reached.",
|
||||
},
|
||||
}),
|
||||
},
|
||||
}
|
||||
|
||||
export const ClineSpendLimitMonthly: Story = {
|
||||
args: {
|
||||
message: createMockMessage(),
|
||||
errorType: "error",
|
||||
apiRequestFailedMessage: JSON.stringify({
|
||||
message: "$100.00 monthly limit has been reached.",
|
||||
status: 429,
|
||||
code: "SPEND_LIMIT_EXCEEDED",
|
||||
providerId: "cline",
|
||||
details: {
|
||||
code: "SPEND_LIMIT_EXCEEDED",
|
||||
limit_scope: "user",
|
||||
budget_period: "monthly",
|
||||
limit_usd: 100.0,
|
||||
spent_usd: 103.22,
|
||||
resets_at: null,
|
||||
message: "$100.00 monthly limit has been reached.",
|
||||
},
|
||||
}),
|
||||
},
|
||||
}
|
||||
|
||||
export const ClineSpendLimitMinimal: Story = {
|
||||
args: {
|
||||
message: createMockMessage(),
|
||||
errorType: "error",
|
||||
apiRequestFailedMessage: JSON.stringify({
|
||||
message: "Spend limit reached.",
|
||||
status: 429,
|
||||
code: "SPEND_LIMIT_EXCEEDED",
|
||||
providerId: "cline",
|
||||
details: {
|
||||
code: "SPEND_LIMIT_EXCEEDED",
|
||||
message: "Spend limit reached.",
|
||||
},
|
||||
}),
|
||||
},
|
||||
}
|
||||
|
||||
// Authentication-related errors with configurable scenarios
|
||||
export const AuthenticationErrors: Story = {
|
||||
args: {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { ClineMessage } from "@shared/ExtensionMessage"
|
||||
import { memo } from "react"
|
||||
import CreditLimitError from "@/components/chat/CreditLimitError"
|
||||
import SpendLimitError from "@/components/chat/SpendLimitError"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { useClineAuth, useClineSignIn } from "@/context/ClineAuthContext"
|
||||
import { ClineError, ClineErrorType } from "../../../../src/services/error/ClineError"
|
||||
@@ -48,19 +47,6 @@ const ErrorRow = memo(({ message, errorType, apiRequestFailedMessage, apiReqStre
|
||||
)
|
||||
}
|
||||
|
||||
if (clineError?.isErrorType(ClineErrorType.SpendLimit)) {
|
||||
const d = clineError._error?.details
|
||||
return (
|
||||
<SpendLimitError
|
||||
budgetPeriod={d?.budget_period}
|
||||
limitUsd={d?.limit_usd}
|
||||
message={d?.message || errorMessage}
|
||||
resetsAt={d?.resets_at}
|
||||
spentUsd={d?.spent_usd}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (clineError?.isErrorType(ClineErrorType.RateLimit)) {
|
||||
return (
|
||||
<p className="m-0 whitespace-pre-wrap text-error wrap-anywhere">
|
||||
|
||||
@@ -1,145 +0,0 @@
|
||||
import { VSCodeButton } from "@vscode/webview-ui-toolkit/react"
|
||||
import React, { useEffect, useState } from "react"
|
||||
import { AccountServiceClient } from "@/services/grpc-client"
|
||||
|
||||
const COOLDOWN_MS = 5 * 60 * 1000 // 5 minutes
|
||||
const COOLDOWN_KEY = "cline:spendLimitRequestCooldown"
|
||||
|
||||
type RequestButtonState = "idle" | "sending" | "sent"
|
||||
|
||||
function formatResetsAt(resetsAt?: string): string | null {
|
||||
if (!resetsAt) return null
|
||||
try {
|
||||
const date = new Date(resetsAt)
|
||||
if (isNaN(date.getTime())) return null
|
||||
return date.toLocaleDateString(undefined, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
interface SpendLimitErrorProps {
|
||||
/** Human-readable error message from the backend */
|
||||
message: string
|
||||
/** Which period the limit applies to: "daily" | "monthly" */
|
||||
budgetPeriod?: string
|
||||
/** The configured spend limit in USD */
|
||||
limitUsd?: number
|
||||
/** How much the user has spent in USD this period */
|
||||
spentUsd?: number
|
||||
/** ISO 8601 timestamp of when the limit resets (may be null for monthly) */
|
||||
resetsAt?: string
|
||||
}
|
||||
|
||||
const SpendLimitError: React.FC<SpendLimitErrorProps> = ({ message, budgetPeriod, limitUsd, spentUsd, resetsAt }) => {
|
||||
const displayMessage =
|
||||
limitUsd != null && budgetPeriod ? `$${limitUsd.toFixed(2)} ${budgetPeriod} limit has been reached.` : message
|
||||
|
||||
const [buttonState, setButtonState] = useState<RequestButtonState>(() => {
|
||||
try {
|
||||
const ts = localStorage.getItem(COOLDOWN_KEY)
|
||||
if (ts && Date.now() - Number(ts) < COOLDOWN_MS) return "sent"
|
||||
} catch {
|
||||
// localStorage may not be available in some environments
|
||||
}
|
||||
return "idle"
|
||||
})
|
||||
|
||||
// Reset button to idle once cooldown expires
|
||||
useEffect(() => {
|
||||
if (buttonState !== "sent") return
|
||||
try {
|
||||
const ts = localStorage.getItem(COOLDOWN_KEY)
|
||||
if (!ts) {
|
||||
setButtonState("idle")
|
||||
return
|
||||
}
|
||||
const remaining = COOLDOWN_MS - (Date.now() - Number(ts))
|
||||
if (remaining <= 0) {
|
||||
setButtonState("idle")
|
||||
return
|
||||
}
|
||||
const timer = setTimeout(() => setButtonState("idle"), remaining)
|
||||
return () => clearTimeout(timer)
|
||||
} catch {
|
||||
// Ignore localStorage errors
|
||||
}
|
||||
}, [buttonState])
|
||||
|
||||
const handleRequestIncrease = async () => {
|
||||
setButtonState("sending")
|
||||
try {
|
||||
await AccountServiceClient.submitLimitIncreaseRequest({})
|
||||
localStorage.setItem(COOLDOWN_KEY, String(Date.now()))
|
||||
setButtonState("sent")
|
||||
} catch (error) {
|
||||
console.error("Failed to submit limit increase request:", error)
|
||||
setButtonState("idle")
|
||||
}
|
||||
}
|
||||
|
||||
const periodLabel = budgetPeriod ? budgetPeriod.charAt(0).toUpperCase() + budgetPeriod.slice(1) : ""
|
||||
const resetsAtFormatted = formatResetsAt(resetsAt)
|
||||
|
||||
return (
|
||||
<div className="border-none rounded-md mb-2 bg-(--vscode-textBlockQuote-background)" style={{ padding: "10px 12px" }}>
|
||||
<div className="mb-3">
|
||||
<div className="text-error mb-2" style={{ fontSize: "calc(var(--vscode-font-size) + 2px)" }}>
|
||||
{displayMessage}
|
||||
</div>
|
||||
|
||||
<div className="mb-3">
|
||||
{spentUsd != null && limitUsd != null && (
|
||||
<div className="text-foreground" style={{ fontSize: "var(--vscode-font-size)", lineHeight: 1.3 }}>
|
||||
{periodLabel ? `${periodLabel} usage` : "Usage"}:{" "}
|
||||
<span className="font-bold">
|
||||
${spentUsd.toFixed(2)} / ${limitUsd.toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{resetsAtFormatted && (
|
||||
<div className="text-foreground" style={{ fontSize: "var(--vscode-font-size)", lineHeight: 1.3 }}>
|
||||
Resets: <span className="font-bold">{resetsAtFormatted}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="text-(--vscode-descriptionForeground) mt-2 text-xs inline-flex items-center">
|
||||
<span className="codicon codicon-organization mr-1" />
|
||||
Limits set by your organization.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<VSCodeButton
|
||||
appearance="primary"
|
||||
className="w-full"
|
||||
disabled={buttonState !== "idle"}
|
||||
onClick={handleRequestIncrease}>
|
||||
{buttonState === "sending" ? (
|
||||
<>
|
||||
<span className="codicon codicon-loading codicon-modifier-spin mr-1.5" />
|
||||
Sending…
|
||||
</>
|
||||
) : buttonState === "sent" ? (
|
||||
<>
|
||||
<span className="codicon codicon-check mr-1.5" />
|
||||
Request Sent
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="codicon codicon-arrow-up mr-1.5" />
|
||||
Request Increase
|
||||
</>
|
||||
)}
|
||||
</VSCodeButton>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default SpendLimitError
|
||||
-59
@@ -1,59 +0,0 @@
|
||||
import type { ClineMessage } from "@shared/ExtensionMessage"
|
||||
import { describe, expect, it } from "vitest"
|
||||
import { buildToolsWithReasoning, getToolGroupSummaryFromParsedTools } from "./ToolGroupRenderer"
|
||||
|
||||
const readToolMessage = (
|
||||
ts: number,
|
||||
type: "ask" | "say",
|
||||
path: string,
|
||||
range?: { start: number; end: number },
|
||||
): ClineMessage => ({
|
||||
ts,
|
||||
type,
|
||||
...(type === "ask" ? { ask: "tool" as const } : { say: "tool" as const }),
|
||||
text: JSON.stringify({
|
||||
tool: "readFile",
|
||||
path,
|
||||
...(range ? { readLineStart: range.start, readLineEnd: range.end } : {}),
|
||||
}),
|
||||
})
|
||||
|
||||
describe("buildToolsWithReasoning", () => {
|
||||
it("replaces an immediately-following read approval ask with the completed read", () => {
|
||||
const tools = buildToolsWithReasoning([
|
||||
readToolMessage(1, "ask", "src/a.ts"),
|
||||
readToolMessage(2, "say", "src/a.ts", { start: 1, end: 20 }),
|
||||
])
|
||||
|
||||
expect(tools).toHaveLength(1)
|
||||
expect(tools[0].tool.say).toBe("tool")
|
||||
expect(tools[0].parsedTool.readLineStart).toBe(1)
|
||||
expect(tools[0].parsedTool.readLineEnd).toBe(20)
|
||||
})
|
||||
|
||||
it("keeps separate reads of the same file when they are distinct operations", () => {
|
||||
const tools = buildToolsWithReasoning([
|
||||
readToolMessage(1, "ask", "src/a.ts"),
|
||||
readToolMessage(2, "say", "src/a.ts", { start: 1, end: 20 }),
|
||||
readToolMessage(3, "ask", "src/a.ts"),
|
||||
readToolMessage(4, "say", "src/a.ts", { start: 40, end: 60 }),
|
||||
])
|
||||
|
||||
expect(tools).toHaveLength(2)
|
||||
expect(tools.map((tool) => [tool.parsedTool.readLineStart, tool.parsedTool.readLineEnd])).toEqual([
|
||||
[1, 20],
|
||||
[40, 60],
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe("getToolGroupSummaryFromParsedTools", () => {
|
||||
it("counts rendered read tools once after ask/say collapse", () => {
|
||||
const tools = buildToolsWithReasoning([
|
||||
readToolMessage(1, "ask", "src/a.ts"),
|
||||
readToolMessage(2, "say", "src/a.ts", { start: 1, end: 20 }),
|
||||
])
|
||||
|
||||
expect(getToolGroupSummaryFromParsedTools(tools.map((tool) => tool.parsedTool))).toBe("Cline read 1 file")
|
||||
})
|
||||
})
|
||||
+13
-39
@@ -40,14 +40,8 @@ const getActivityText = (tool: ClineSayTool): string | null => {
|
||||
}
|
||||
|
||||
switch (tool.tool) {
|
||||
case "readFile": {
|
||||
if (!tool.path) {
|
||||
return null
|
||||
}
|
||||
const lineHint =
|
||||
tool.readLineStart != null && tool.readLineEnd != null ? ` (lines ${tool.readLineStart}-${tool.readLineEnd})` : ""
|
||||
return `Reading ${cleanedPath}${lineHint}...`
|
||||
}
|
||||
case "readFile":
|
||||
return tool.path ? `Reading ${cleanedPath}...` : null
|
||||
case "listFilesTopLevel":
|
||||
case "listFilesRecursive":
|
||||
return tool.path ? `Exploring ${cleanedPath}/...` : null
|
||||
@@ -150,7 +144,7 @@ export const ToolGroupRenderer = memo(({ messages, allMessages, isLastGroup }: T
|
||||
return [...dedupedCompleted, ...activeTools]
|
||||
}, [completedTools, activeTools])
|
||||
|
||||
const summary = getToolGroupSummaryFromParsedTools(completedTools.map((item) => item.parsedTool))
|
||||
const summary = getToolGroupSummary(filteredMessages)
|
||||
|
||||
const handleOpenFile = useCallback((filePath: string) => {
|
||||
FileServiceClient.openFileRelativePath(StringRequest.create({ value: filePath })).catch((err) =>
|
||||
@@ -240,7 +234,7 @@ export const ToolGroupRenderer = memo(({ messages, allMessages, isLastGroup }: T
|
||||
* Build tool items WITHOUT reasoning.
|
||||
* Reasoning should not be displayed in file lists - only file/folder content.
|
||||
*/
|
||||
export function buildToolsWithReasoning(messages: ClineMessage[]): ToolWithReasoning[] {
|
||||
function buildToolsWithReasoning(messages: ClineMessage[]): ToolWithReasoning[] {
|
||||
const result: ToolWithReasoning[] = []
|
||||
|
||||
for (const msg of messages) {
|
||||
@@ -251,23 +245,6 @@ export function buildToolsWithReasoning(messages: ClineMessage[]): ToolWithReaso
|
||||
|
||||
if (isLowStakesTool(msg)) {
|
||||
const parsedTool = parseToolSafe(msg.text)
|
||||
const previous = result.at(-1)
|
||||
const supersedesPreviousReadAsk =
|
||||
parsedTool.tool === "readFile" &&
|
||||
parsedTool.path &&
|
||||
msg.say === "tool" &&
|
||||
previous?.tool.ask === "tool" &&
|
||||
previous.parsedTool.tool === "readFile" &&
|
||||
previous.parsedTool.path === parsedTool.path
|
||||
|
||||
if (supersedesPreviousReadAsk) {
|
||||
result[result.length - 1] = {
|
||||
tool: msg,
|
||||
parsedTool,
|
||||
reasoning: undefined,
|
||||
}
|
||||
continue
|
||||
}
|
||||
result.push({
|
||||
tool: msg,
|
||||
parsedTool,
|
||||
@@ -299,16 +276,8 @@ function getToolDisplayInfo(tool: ClineSayTool) {
|
||||
const folderPath = filePath + "/"
|
||||
|
||||
switch (tool.tool) {
|
||||
case "readFile": {
|
||||
const lineNote =
|
||||
tool.readLineStart != null && tool.readLineEnd != null ? `lines ${tool.readLineStart}-${tool.readLineEnd}` : null
|
||||
return {
|
||||
icon,
|
||||
path: filePath,
|
||||
label: "read",
|
||||
displayText: lineNote ? `${cleanPathPrefix(filePath)} · ${lineNote}` : undefined,
|
||||
}
|
||||
}
|
||||
case "readFile":
|
||||
return { icon, path: filePath, label: "read" }
|
||||
case "listFilesTopLevel":
|
||||
return { icon, path: folderPath, label: "listed" }
|
||||
case "listFilesRecursive":
|
||||
@@ -350,10 +319,15 @@ function formatSearchDisplay(regex: string, path: string, filePattern?: string):
|
||||
/**
|
||||
* Get summary label for a tool group - shows what's been added to context.
|
||||
*/
|
||||
export function getToolGroupSummaryFromParsedTools(tools: ClineSayTool[]): string {
|
||||
function getToolGroupSummary(messages: ClineMessage[]): string {
|
||||
const counts = { read: 0, list: 0, search: 0, def: 0 }
|
||||
|
||||
for (const tool of tools) {
|
||||
for (const msg of messages) {
|
||||
if (!isLowStakesTool(msg)) {
|
||||
continue
|
||||
}
|
||||
|
||||
const tool = parseToolSafe(msg.text)
|
||||
switch (tool.tool) {
|
||||
case "readFile":
|
||||
counts.read++
|
||||
|
||||
Reference in New Issue
Block a user