mirror of
https://github.com/cline/cline.git
synced 2026-09-01 15:11:04 +08:00
Compare commits
45 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0c99fc7ebe | |||
| 9c1a16e372 | |||
| 219bc0621a | |||
| 6c8f81e34f | |||
| 0ea3dcd09a | |||
| 08d15da1a1 | |||
| 683d88fba0 | |||
| 9409cf9b19 | |||
| 79df99de35 | |||
| 72dd95e74e | |||
| 257b78e0bc | |||
| 2a4a4c0d46 | |||
| 3fedf08c7f | |||
| 93334679d8 | |||
| 2d9b7e5e07 | |||
| 46f0b77d43 | |||
| 94e4e320ca | |||
| f5961d8529 | |||
| 5eaece8413 | |||
| b391ded91d | |||
| 7fbcf4fa31 | |||
| a8df52b34e | |||
| 118a80ea6c | |||
| d97ef02718 | |||
| bdb1638cbd | |||
| 71d585ab2b | |||
| 4b21ca939c | |||
| c5410701df | |||
| 6a1ac78736 | |||
| 349158b3d2 | |||
| c4aa50dc45 | |||
| 8f64cf4a34 | |||
| 84c02e215e | |||
| d57cbaf63d | |||
| 1a54e375a3 | |||
| 2eacade09e | |||
| 798fc87a96 | |||
| c34112b2b7 | |||
| a108ca2154 | |||
| 061039c811 | |||
| efc2c70a1a | |||
| c9dd4ae226 | |||
| d0ebe85535 | |||
| d8de30c330 | |||
| 9cd424523c |
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"extension": [
|
||||
"ts"
|
||||
],
|
||||
"spec": [
|
||||
"tests/unit/core/**/*.test.ts",
|
||||
"tests/unit/commands/version.test.ts"
|
||||
],
|
||||
"ignore": [
|
||||
"tests/unit/commands/auth/**",
|
||||
"tests/unit/commands/task/**"
|
||||
],
|
||||
"node-option": [
|
||||
"import=tsx",
|
||||
"experimental-specifier-resolution=node"
|
||||
],
|
||||
"timeout": 10000
|
||||
}
|
||||
@@ -0,0 +1,473 @@
|
||||
# CLI Features Implementation Plan
|
||||
|
||||
Based on the cline.1.md man page, this document outlines the implementation plan for all CLI features. The Cline man page indicates that cline cli runs as a client-server architecture where **Cline Core** runs as a standalone service, but for the typescript version of the CLI, that won't be the case. The Typescript CLI will just import the necessary objects it needs from cline core src directly.
|
||||
|
||||
## Overview
|
||||
|
||||
The TypeScript CLI scaffold is complete. This plan covers implementing the full feature set from the man page, prioritized by dependency order and user value.
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Core Infrastructure (Prerequisites)
|
||||
**Status: ✅ Completed (tests passing)**
|
||||
|
||||
### 1.1 Output Formatting System
|
||||
**Priority: High** | **Complexity: Medium**
|
||||
|
||||
Implement the `-F/--output-format` global option to support `rich`, `json`, and `plain` output formats.
|
||||
|
||||
**Files to create:**
|
||||
- `cli-ts/src/core/output/formatter.ts` - Base formatter interface
|
||||
- `cli-ts/src/core/output/rich-formatter.ts` - Rich terminal output with colors/styling
|
||||
- `cli-ts/src/core/output/json-formatter.ts` - JSON output for scripting
|
||||
- `cli-ts/src/core/output/plain-formatter.ts` - Plain text output
|
||||
|
||||
**Types:**
|
||||
```typescript
|
||||
interface OutputFormatter {
|
||||
message(msg: ClineMessage): void
|
||||
error(err: Error): void
|
||||
success(text: string): void
|
||||
table(data: Record<string, unknown>[]): void
|
||||
list(items: string[]): void
|
||||
}
|
||||
|
||||
interface ClineMessage {
|
||||
type: 'ask' | 'say'
|
||||
text: string
|
||||
ts: number // Unix epoch milliseconds
|
||||
reasoning?: string
|
||||
say?: string // say subtype
|
||||
ask?: string // ask subtype
|
||||
partial?: boolean
|
||||
images?: string[]
|
||||
files?: string[]
|
||||
lastCheckpointHash?: string
|
||||
isCheckpointCheckedOut?: boolean
|
||||
isOperationOutsideWorkspace?: boolean
|
||||
}
|
||||
```
|
||||
|
||||
**Tests:**
|
||||
- JSON formatter outputs valid JSON per message
|
||||
- Rich formatter uses colors when TTY available
|
||||
- Plain formatter strips all formatting
|
||||
|
||||
---
|
||||
|
||||
### 1.2 Configuration System
|
||||
**Priority: High** | **Complexity: Medium**
|
||||
|
||||
Implement persistent configuration storage and the `cline config` command group.
|
||||
|
||||
**Commands:**
|
||||
- `cline config set <key> <value>`
|
||||
- `cline config get <key>`
|
||||
- `cline config list`
|
||||
|
||||
**Files to create:**
|
||||
- `cli-ts/src/core/config-storage.ts` - Persistent config storage (JSON file in ~/.cline)
|
||||
- `cli-ts/src/commands/config/index.ts` - Config command group
|
||||
- `cli-ts/src/commands/config/set.ts`
|
||||
- `cli-ts/src/commands/config/get.ts`
|
||||
- `cli-ts/src/commands/config/list.ts`
|
||||
|
||||
**Config storage location:** `~/.cline/config.json`
|
||||
|
||||
**Tests:**
|
||||
- Config persists across CLI invocations
|
||||
- Config values can be overridden
|
||||
- Invalid keys produce helpful errors
|
||||
|
||||
---
|
||||
|
||||
### 1.3 Instance Registry & Lifecycle (DEPRECATED -- DO NOT IMPLEMENT)
|
||||
**Priority: Deprecated** | **Complexity: High**
|
||||
|
||||
Implement the instance management system for tracking running Cline Core instances.
|
||||
|
||||
**Files to create:**
|
||||
- `cli-ts/src/core/instance-registry.ts` - Track running instances (SQLite or JSON)
|
||||
- `cli-ts/src/core/instance-client.ts` - gRPC client for communicating with Cline Core
|
||||
- `cli-ts/src/commands/instance/index.ts` - Instance command group
|
||||
- `cli-ts/src/commands/instance/new.ts`
|
||||
- `cli-ts/src/commands/instance/list.ts`
|
||||
- `cli-ts/src/commands/instance/default.ts`
|
||||
- `cli-ts/src/commands/instance/kill.ts`
|
||||
|
||||
**Commands:**
|
||||
- `cline instance new [--default]` / `cline i n`
|
||||
- `cline instance list` / `cline i l`
|
||||
- `cline instance default <address>` / `cline i d`
|
||||
- `cline instance kill <address> [--all]` / `cline i k`
|
||||
|
||||
**Architecture notes:**
|
||||
- The CLI spawns `cline-core` as a child process
|
||||
- Instances are tracked in `~/.cline/instances.json` with addresses and PIDs
|
||||
- Default instance is stored in config
|
||||
|
||||
**Tests:**
|
||||
- New instance spawns cline-core process
|
||||
- List shows all running instances
|
||||
- Kill terminates specific or all instances
|
||||
- Default instance is used when --address not specified
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Authentication
|
||||
**Status: ✅ Completed (tests passing)**
|
||||
|
||||
### 2.1 Auth Command
|
||||
**Priority: High** | **Complexity: Medium**
|
||||
|
||||
Implement provider authentication system.
|
||||
|
||||
**Commands:**
|
||||
- `cline auth [provider] [key]` / `cline a`
|
||||
|
||||
**Files to create:**
|
||||
- `cli-ts/src/commands/auth/index.ts` - Auth command with interactive wizard
|
||||
- `cli-ts/src/core/auth/providers.ts` - Provider definitions (Anthropic, OpenRouter, etc.)
|
||||
- `cli-ts/src/core/auth/wizard.ts` - Interactive provider selection
|
||||
- `cli-ts/src/core/auth/oauth.ts` - OAuth flow handler (for providers that support it)
|
||||
|
||||
**Behavior:**
|
||||
- No args: Launch interactive wizard
|
||||
- Provider only: Prompt for key or launch OAuth
|
||||
- Provider + key: Store key directly
|
||||
|
||||
**Storage:** Keys stored in `~/.cline/secrets.json` (with appropriate permissions)
|
||||
|
||||
**Tests:**
|
||||
- Interactive wizard presents provider choices
|
||||
- API keys are securely stored
|
||||
- Keys can be updated
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: Task Management
|
||||
**Status: ✅ Completed (207 tests passing)**
|
||||
|
||||
### 3.1 Task Command Group Base
|
||||
**Priority: High** | **Complexity: Medium** | **Status: ✅ Complete**
|
||||
|
||||
Implement the task command infrastructure.
|
||||
|
||||
**Files created:**
|
||||
- `cli-ts/src/commands/task/index.ts` - Task command group
|
||||
- `cli-ts/src/core/task-client.ts` - Task storage and management
|
||||
- `cli-ts/src/types/task.ts` - Task-related types
|
||||
|
||||
**Commands:**
|
||||
- `cline task` / `cline t` - Display help
|
||||
|
||||
---
|
||||
|
||||
### 3.2 Task Creation & History
|
||||
**Priority: High** | **Complexity: Medium** | **Status: ✅ Complete**
|
||||
|
||||
**Commands:**
|
||||
- `cline task new <prompt> [options]` / `cline t n`
|
||||
- `cline task list` / `cline t l` / `cline t ls`
|
||||
- `cline task open <task-id>` / `cline t o`
|
||||
|
||||
**Files created:**
|
||||
- `cli-ts/src/commands/task/new.ts`
|
||||
- `cli-ts/src/commands/task/list.ts`
|
||||
- `cli-ts/src/commands/task/open.ts`
|
||||
|
||||
**Options for task new/open:**
|
||||
- `-s, --setting <key=value>` - Override settings (repeatable)
|
||||
- `-y, --yolo` / `--no-interactive` - Autonomous mode
|
||||
- `-m, --mode <mode>` - Starting mode (act/plan)
|
||||
- `-w, --workspace <path>` - Working directory (new only)
|
||||
|
||||
**Options for task list:**
|
||||
- `-n, --limit <number>` - Limit results (default: 20)
|
||||
- `-a, --all` - Show all tasks
|
||||
- `--status <status>` - Filter by status
|
||||
|
||||
**Tests (53 new tests):**
|
||||
- ✅ TaskStorage: create, get, update, delete, list, findByPartialId
|
||||
- ✅ task new: creates task, validates mode, parses settings
|
||||
- ✅ task list: shows history, respects limit, filters by status, JSON output
|
||||
- ✅ task open: finds by full/partial ID, overrides mode/settings, resumes paused tasks
|
||||
|
||||
---
|
||||
|
||||
### 3.3 Task Communication - Embedded Controller Architecture
|
||||
**Priority: High** | **Complexity: High**
|
||||
|
||||
**Architecture Decision: In-Process Embedded Controller**
|
||||
|
||||
The CLI chat REPL will embed the Cline Controller directly in the CLI process (not via gRPC). This approach:
|
||||
- Uses direct method calls instead of gRPC serialization
|
||||
- Reuses infrastructure from `src/standalone/cline-core.ts`
|
||||
- Shares state via `~/.cline/` with VSCode extension
|
||||
- Outputs to terminal instead of webview
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────┐
|
||||
│ CLI Process │
|
||||
│ │
|
||||
│ ┌────────────┐ ┌────────────┐ ┌──────────────────┐│
|
||||
│ │ CLI Chat │───>│ Controller │───>│ Task + AI API ││
|
||||
│ │ REPL │<───│ │<───│ ││
|
||||
│ └────────────┘ └────────────┘ └──────────────────┘│
|
||||
│ │ │ │
|
||||
│ v v │
|
||||
│ ┌────────────┐ ┌──────────────┐ │
|
||||
│ │ Terminal │ │ StateManager │ │
|
||||
│ │ Output │ │ (~/.cline/) │ │
|
||||
│ └────────────┘ └──────────────┘ │
|
||||
└──────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Key Source Files to Understand:**
|
||||
- `src/core/controller/index.ts` - Controller class with initTask(), cancelTask(), postStateToWebview()
|
||||
- `src/standalone/cline-core.ts` - Shows how to run Controller outside VSCode
|
||||
- `src/standalone/vscode-context.ts` - initializeContext() creates mock ExtensionContext
|
||||
- `src/standalone/protobus-service.ts` - Shows Controller methods exposed via gRPC
|
||||
- `src/generated/hosts/standalone/protobus-server-setup.ts` - All available RPC methods
|
||||
|
||||
**Key Controller Methods for CLI:**
|
||||
- `controller.initTask(prompt)` - Start a new task with prompt
|
||||
- `controller.task?.handleWebviewAskResponse('messageResponse', userInput)` - Send user input
|
||||
- `controller.task?.messageStateHandler.getClineMessages()` - Get conversation messages
|
||||
- `controller.cancelTask()` - Cancel current task
|
||||
- `controller.getStateToPostToWebview()` - Get full state (clineMessages, etc.)
|
||||
|
||||
**Commands:**
|
||||
- `cline task chat` / `cline t c` - Interactive chat mode with embedded Controller
|
||||
- `cline task send [message] [options]` / `cline t s` - Send single message
|
||||
- `cline task view [--follow] [--follow-complete]` / `cline t v` - View/stream conversation
|
||||
|
||||
**Files to create/modify:**
|
||||
- `cli-ts/src/core/embedded-controller.ts` - Initialize Controller in CLI process
|
||||
- `cli-ts/src/core/cli-webview-adapter.ts` - Adapter that outputs to terminal instead of webview
|
||||
- `cli-ts/src/commands/task/chat.ts` - Updated to use embedded Controller
|
||||
- `cli-ts/src/commands/task/send.ts` - Updated to use embedded Controller
|
||||
- `cli-ts/src/commands/task/view.ts` - Updated to use embedded Controller
|
||||
|
||||
**Existing file to leverage:**
|
||||
- `cli-ts/src/core/host-provider-setup.ts` - Already sets up HostProvider for CLI
|
||||
|
||||
**Options for task send:**
|
||||
- `-a, --approve` - Approve proposed action
|
||||
- `-d, --deny` - Deny proposed action
|
||||
- `-f, --file <FILE>` - Attach file
|
||||
- `-y, --no-interactive, --yolo` - Autonomous mode
|
||||
- `-m, --mode <mode>` - Switch mode
|
||||
|
||||
**Options for task view:**
|
||||
- `-f, --follow` - Stream updates in real-time
|
||||
- `-c, --follow-complete` - Follow until completion
|
||||
|
||||
**Implementation Steps:**
|
||||
1. Create `embedded-controller.ts` to initialize Controller using:
|
||||
- `initializeContext()` from `src/standalone/vscode-context.ts`
|
||||
- `setupHostProvider()` from `cli-ts/src/core/host-provider-setup.ts`
|
||||
- Direct Controller import from `src/core/controller/index.ts`
|
||||
|
||||
2. Create `cli-webview-adapter.ts` to handle state updates:
|
||||
- Listen to `controller.task?.messageStateHandler` events
|
||||
- Format ClineMessages for terminal output
|
||||
- Handle streaming partial messages
|
||||
|
||||
3. Update `chat.ts` to use embedded Controller:
|
||||
- Initialize Controller on command start
|
||||
- Send prompts via `controller.initTask(prompt)`
|
||||
- Receive messages via state handler events
|
||||
- Handle user input via `handleWebviewAskResponse()`
|
||||
|
||||
4. Update `send.ts` and `view.ts` similarly
|
||||
|
||||
**Tests:**
|
||||
- Chat mode provides REPL interface with real Controller
|
||||
- Messages stream to terminal in real-time
|
||||
- Approve/deny call correct Controller methods
|
||||
- State persists to ~/.cline/ and is readable by VSCode
|
||||
|
||||
---
|
||||
|
||||
### 3.4 Task Control
|
||||
**Priority: Medium** | **Complexity: Medium**
|
||||
|
||||
**Commands:**
|
||||
- `cline task restore <checkpoint>` / `cline t r`
|
||||
- `cline task pause` / `cline t p`
|
||||
|
||||
**Files to create:**
|
||||
- `cli-ts/src/commands/task/restore.ts`
|
||||
- `cli-ts/src/commands/task/pause.ts`
|
||||
|
||||
**Tests:**
|
||||
- Restore reverts to checkpoint
|
||||
- Pause suspends execution
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: Instant Task Mode
|
||||
|
||||
### 4.1 Instant Task Shorthand
|
||||
**Priority: High** | **Complexity: Medium**
|
||||
|
||||
Implement `cline "prompt"` instant task mode that combines instance + task + chat.
|
||||
|
||||
**Modify:**
|
||||
- `cli-ts/src/index.ts` - Detect prompt argument and route to instant task
|
||||
|
||||
**Options:**
|
||||
- `-o, --oneshot` - Complete and stop
|
||||
- `-s, --setting <key> <value>` - Override settings
|
||||
- `-y, --no-interactive, --yolo` - Autonomous mode
|
||||
- `-m, --mode <mode>` - Starting mode
|
||||
- `-w, --workspace <path>` - Additional workspace paths (can repeat)
|
||||
|
||||
**Behavior:**
|
||||
1. Get or spawn default instance
|
||||
2. Create new task with prompt
|
||||
3. Enter chat mode (or oneshot if -o)
|
||||
|
||||
**Tests:**
|
||||
- Instant task spawns instance if needed
|
||||
- Oneshot completes and exits
|
||||
- Workspace paths are passed correctly
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: Global Options Enhancement
|
||||
|
||||
### 5.1 Address Flag
|
||||
**Priority: Medium** | **Complexity: Low**
|
||||
|
||||
Add `-a, --address <ADDR>` global option to specify which Cline Core instance to use.
|
||||
|
||||
**Modify:**
|
||||
- `cli-ts/src/index.ts` - Add --address option
|
||||
- All task commands to use address or default
|
||||
|
||||
---
|
||||
|
||||
### 5.2 Verbose Flag Enhancement
|
||||
**Priority: Low** | **Complexity: Low**
|
||||
|
||||
Enhance `-v, --verbose` to show debug output including gRPC communication details.
|
||||
|
||||
---
|
||||
|
||||
## Implementation Order (Recommended)
|
||||
|
||||
### Sprint 1: Foundation (Completed)
|
||||
1. [x] 1.1 Output Formatting System
|
||||
2. [x] 1.2 Configuration System
|
||||
3. [x] 2.1 Auth Command
|
||||
|
||||
### Sprint 2: Instance Management (Deprecated)
|
||||
4. [x] 1.3 Instance Registry & Lifecycle (Deprecated, skipped)
|
||||
|
||||
### Sprint 3: Task Basics (Completed)
|
||||
5. [x] 3.1 Task Command Group Base
|
||||
6. [x] 3.2 Task Creation & History
|
||||
|
||||
### Sprint 4: Task Communication (✅ Complete)
|
||||
7. [x] 3.3 Task Communication (chat, send, view) - Embedded Controller architecture implemented
|
||||
|
||||
### Sprint 5: Advanced Features
|
||||
8. [ ] 4.1 Instant Task Mode
|
||||
9. [ ] 3.4 Task Control
|
||||
10. [ ] 5.1 Address Flag
|
||||
11. [ ] 5.2 Verbose Flag Enhancement
|
||||
|
||||
---
|
||||
|
||||
## File Structure Summary
|
||||
|
||||
```
|
||||
cli-ts/
|
||||
├── src/
|
||||
│ ├── index.ts # Main entry (enhanced)
|
||||
│ ├── commands/
|
||||
│ │ ├── version.ts # ✓ Complete
|
||||
│ │ ├── auth/
|
||||
│ │ │ └── index.ts
|
||||
│ │ ├── config/
|
||||
│ │ │ ├── index.ts
|
||||
│ │ │ ├── set.ts
|
||||
│ │ │ ├── get.ts
|
||||
│ │ │ └── list.ts
|
||||
│ │ ├── instance/
|
||||
│ │ │ ├── index.ts
|
||||
│ │ │ ├── new.ts
|
||||
│ │ │ ├── list.ts
|
||||
│ │ │ ├── default.ts
|
||||
│ │ │ └── kill.ts
|
||||
│ │ └── task/
|
||||
│ │ ├── index.ts
|
||||
│ │ ├── new.ts
|
||||
│ │ ├── list.ts
|
||||
│ │ ├── open.ts
|
||||
│ │ ├── chat.ts
|
||||
│ │ ├── send.ts
|
||||
│ │ ├── view.ts
|
||||
│ │ ├── restore.ts
|
||||
│ │ └── pause.ts
|
||||
│ ├── core/
|
||||
│ │ ├── config.ts # ✓ Complete
|
||||
│ │ ├── logger.ts # ✓ Complete
|
||||
│ │ ├── context.ts # ✓ Complete
|
||||
│ │ ├── host-provider-setup.ts # ✓ Complete
|
||||
│ │ ├── config-storage.ts # NEW
|
||||
│ │ ├── instance-registry.ts # NEW
|
||||
│ │ ├── instance-client.ts # NEW
|
||||
│ │ ├── task-client.ts # NEW
|
||||
│ │ ├── output/
|
||||
│ │ │ ├── formatter.ts
|
||||
│ │ │ ├── rich-formatter.ts
|
||||
│ │ │ ├── json-formatter.ts
|
||||
│ │ │ └── plain-formatter.ts
|
||||
│ │ └── auth/
|
||||
│ │ ├── providers.ts
|
||||
│ │ ├── wizard.ts
|
||||
│ │ └── oauth.ts
|
||||
│ └── types/
|
||||
│ ├── config.ts # ✓ Complete
|
||||
│ ├── logger.ts # ✓ Complete
|
||||
│ ├── task.ts # NEW
|
||||
│ └── message.ts # NEW (ClineMessage)
|
||||
└── tests/
|
||||
└── unit/
|
||||
├── commands/
|
||||
│ └── version.test.ts # ✓ Complete
|
||||
├── core/
|
||||
│ ├── config.test.ts # ✓ Complete
|
||||
│ └── logger.test.ts # ✓ Complete
|
||||
└── ... (new tests for each module)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. Start Sprint 3: Task Basics (Task Command Group Base + Task Creation & History)
|
||||
2. Follow with Sprint 4: Task Communication (chat/send/view)
|
||||
3. Finish with Sprint 5: Advanced Features (instant task mode, task control, address flag, verbose enhancement)
|
||||
|
||||
The plan is designed so each phase delivers working functionality that can be tested independently before moving to the next phase.
|
||||
|
||||
### New Task (Phase 3 Kickoff)
|
||||
**Objective:** Implement Task Command Group Base and Task Creation & History.
|
||||
|
||||
**Planned files to create/modify:**
|
||||
- `cli-ts/src/commands/task/index.ts`
|
||||
- `cli-ts/src/core/task-client.ts`
|
||||
- `cli-ts/src/types/task.ts`
|
||||
- `cli-ts/src/commands/task/new.ts`
|
||||
- `cli-ts/src/commands/task/list.ts`
|
||||
- `cli-ts/src/commands/task/open.ts`
|
||||
- Update `cli-ts/src/index.ts` to register the task command group
|
||||
|
||||
**Test requirements:**
|
||||
- New task creates task in instance
|
||||
- List shows task history with IDs and snippets
|
||||
- Open resumes task with saved settings
|
||||
@@ -0,0 +1,305 @@
|
||||
import fs from "node:fs"
|
||||
import path from "node:path"
|
||||
import { fileURLToPath } from "node:url"
|
||||
import * as esbuild from "esbuild"
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url)
|
||||
const __dirname = path.dirname(__filename)
|
||||
const rootDir = path.resolve(__dirname, "..")
|
||||
|
||||
const production = process.argv.includes("--production")
|
||||
const watch = process.argv.includes("--watch")
|
||||
|
||||
/**
|
||||
* Alias resolver plugin - resolves path aliases from tsconfig.json
|
||||
* This is adapted from the root esbuild.mjs to work with the CLI's directory structure
|
||||
* @type {import('esbuild').Plugin}
|
||||
*/
|
||||
/**
|
||||
* Plugin to resolve 'vscode' imports to the standalone shim
|
||||
* The shim provides stub implementations for vscode APIs in standalone mode
|
||||
* @type {import('esbuild').Plugin}
|
||||
*/
|
||||
const vscodeShimPlugin = {
|
||||
name: "vscode-shim",
|
||||
setup(build) {
|
||||
const vscodeShimPath = path.resolve(rootDir, "standalone/runtime-files/vscode/index.js")
|
||||
|
||||
// Resolve 'vscode' to our virtual shim entry
|
||||
build.onResolve({ filter: /^vscode$/ }, () => {
|
||||
return {
|
||||
path: vscodeShimPath,
|
||||
// Use sideEffects: false to avoid issues with initialization order
|
||||
}
|
||||
})
|
||||
|
||||
// The vscode-stubs.js uses implicit global assignment (vscode = {})
|
||||
// which fails in strict mode. We need to load it without strict mode
|
||||
// by marking it and its dependencies as external
|
||||
build.onLoad({ filter: /vscode-stubs\.js$/ }, async (args) => {
|
||||
const contents = fs.readFileSync(args.path, "utf8")
|
||||
// Wrap the contents to declare vscode as a local variable
|
||||
return {
|
||||
contents: `var vscode;\n${contents}`,
|
||||
loader: "js",
|
||||
}
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
const aliasResolverPlugin = {
|
||||
name: "alias-resolver",
|
||||
setup(build) {
|
||||
// Aliases point to the root src directory (parent of cli-ts)
|
||||
const aliases = {
|
||||
"@": path.resolve(rootDir, "src"),
|
||||
"@core": path.resolve(rootDir, "src/core"),
|
||||
"@integrations": path.resolve(rootDir, "src/integrations"),
|
||||
"@services": path.resolve(rootDir, "src/services"),
|
||||
"@shared": path.resolve(rootDir, "src/shared"),
|
||||
"@utils": path.resolve(rootDir, "src/utils"),
|
||||
"@packages": path.resolve(rootDir, "src/packages"),
|
||||
"@hosts": path.resolve(rootDir, "src/hosts"),
|
||||
"@generated": path.resolve(rootDir, "src/generated"),
|
||||
"@api": path.resolve(rootDir, "src/core/api"),
|
||||
// CLI-specific aliases
|
||||
"@cli": path.resolve(__dirname, "src"),
|
||||
}
|
||||
|
||||
// For each alias entry, create a resolver
|
||||
Object.entries(aliases).forEach(([alias, aliasPath]) => {
|
||||
const aliasRegex = new RegExp(`^${alias}($|/.*)`)
|
||||
build.onResolve({ filter: aliasRegex }, (args) => {
|
||||
const importPath = args.path.replace(alias, aliasPath)
|
||||
|
||||
// First, check if the path exists as is
|
||||
if (fs.existsSync(importPath)) {
|
||||
const stats = fs.statSync(importPath)
|
||||
if (stats.isDirectory()) {
|
||||
// If it's a directory, try to find index files
|
||||
const extensions = [".ts", ".tsx", ".js", ".jsx"]
|
||||
for (const ext of extensions) {
|
||||
const indexFile = path.join(importPath, `index${ext}`)
|
||||
if (fs.existsSync(indexFile)) {
|
||||
return { path: indexFile }
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// It's a file that exists, so return it
|
||||
return { path: importPath }
|
||||
}
|
||||
}
|
||||
|
||||
// If the path doesn't exist, try appending extensions
|
||||
const extensions = [".ts", ".tsx", ".js", ".jsx"]
|
||||
for (const ext of extensions) {
|
||||
const pathWithExtension = `${importPath}${ext}`
|
||||
if (fs.existsSync(pathWithExtension)) {
|
||||
return { path: pathWithExtension }
|
||||
}
|
||||
}
|
||||
|
||||
// If nothing worked, return the original path and let esbuild handle the error
|
||||
return { path: importPath }
|
||||
})
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* Problem matcher plugin for watch mode
|
||||
*/
|
||||
const esbuildProblemMatcherPlugin = {
|
||||
name: "esbuild-problem-matcher",
|
||||
setup(build) {
|
||||
build.onStart(() => {
|
||||
console.log("[watch] build started")
|
||||
})
|
||||
build.onEnd((result) => {
|
||||
result.errors.forEach(({ text, location }) => {
|
||||
console.error(`✘ [ERROR] ${text}`)
|
||||
if (location) {
|
||||
console.error(` ${location.file}:${location.line}:${location.column}:`)
|
||||
}
|
||||
})
|
||||
console.log("[watch] build finished")
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* Plugin to copy tree-sitter WASM files to dist directory
|
||||
* These are required at runtime for code parsing functionality
|
||||
* @type {import('esbuild').Plugin}
|
||||
*/
|
||||
const copyWasmFiles = {
|
||||
name: "copy-wasm-files",
|
||||
setup(build) {
|
||||
build.onEnd(() => {
|
||||
const targetDir = path.resolve(__dirname, "dist")
|
||||
|
||||
// Copy tree-sitter.wasm from web-tree-sitter
|
||||
const treeSitterSource = path.join(rootDir, "node_modules", "web-tree-sitter", "tree-sitter.wasm")
|
||||
if (fs.existsSync(treeSitterSource)) {
|
||||
fs.copyFileSync(treeSitterSource, path.join(targetDir, "tree-sitter.wasm"))
|
||||
} else {
|
||||
console.warn("Warning: tree-sitter.wasm not found in node_modules/web-tree-sitter")
|
||||
}
|
||||
|
||||
// Copy language-specific WASM files from tree-sitter-wasms
|
||||
const languageWasmDir = path.join(rootDir, "node_modules", "tree-sitter-wasms", "out")
|
||||
const languages = [
|
||||
"typescript",
|
||||
"tsx",
|
||||
"python",
|
||||
"rust",
|
||||
"javascript",
|
||||
"go",
|
||||
"cpp",
|
||||
"c",
|
||||
"c_sharp",
|
||||
"ruby",
|
||||
"java",
|
||||
"php",
|
||||
"swift",
|
||||
"kotlin",
|
||||
]
|
||||
|
||||
if (fs.existsSync(languageWasmDir)) {
|
||||
languages.forEach((lang) => {
|
||||
const filename = `tree-sitter-${lang}.wasm`
|
||||
const sourcePath = path.join(languageWasmDir, filename)
|
||||
if (fs.existsSync(sourcePath)) {
|
||||
fs.copyFileSync(sourcePath, path.join(targetDir, filename))
|
||||
}
|
||||
})
|
||||
} else {
|
||||
console.warn("Warning: tree-sitter-wasms/out directory not found")
|
||||
}
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
// Read package.json for version injection
|
||||
const rootPackageJson = JSON.parse(fs.readFileSync(path.resolve(rootDir, "package.json"), "utf8"))
|
||||
|
||||
// Build environment variables
|
||||
const buildEnvVars = {
|
||||
"import.meta.url": "_importMetaUrl",
|
||||
"process.env.IS_STANDALONE": JSON.stringify("true"),
|
||||
// Inject the Cline version at build time to avoid runtime package.json loading
|
||||
__CLINE_VERSION__: JSON.stringify(rootPackageJson.version),
|
||||
}
|
||||
|
||||
if (production) {
|
||||
buildEnvVars["process.env.IS_DEV"] = "false"
|
||||
}
|
||||
|
||||
/**
|
||||
* Plugin to handle package.json requires by bundling them inline
|
||||
* This handles JSON files that may have broken relative paths after bundling
|
||||
* @type {import('esbuild').Plugin}
|
||||
*/
|
||||
const jsonResolverPlugin = {
|
||||
name: "json-resolver",
|
||||
setup(build) {
|
||||
// Handle requires to package.json files by resolving and loading them inline
|
||||
build.onResolve({ filter: /\.json$/ }, (args) => {
|
||||
// Only handle relative paths
|
||||
if (args.path.startsWith(".")) {
|
||||
const resolvedPath = path.resolve(args.resolveDir, args.path)
|
||||
if (fs.existsSync(resolvedPath)) {
|
||||
return {
|
||||
path: resolvedPath,
|
||||
namespace: "json-inline",
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
})
|
||||
|
||||
// Load JSON files and emit them as CommonJS modules with the JSON data
|
||||
build.onLoad({ filter: /.*/, namespace: "json-inline" }, (args) => {
|
||||
const contents = fs.readFileSync(args.path, "utf8")
|
||||
return {
|
||||
contents: `module.exports = ${contents}`,
|
||||
loader: "js",
|
||||
}
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
// CLI-specific configuration
|
||||
const cliConfig = {
|
||||
entryPoints: [path.resolve(__dirname, "src/index.ts")],
|
||||
outfile: path.resolve(__dirname, "dist/index.cjs"),
|
||||
bundle: true,
|
||||
minify: production,
|
||||
sourcemap: !production,
|
||||
logLevel: "silent",
|
||||
define: buildEnvVars,
|
||||
tsconfig: path.resolve(__dirname, "tsconfig.json"),
|
||||
plugins: [vscodeShimPlugin, jsonResolverPlugin, aliasResolverPlugin, copyWasmFiles, esbuildProblemMatcherPlugin],
|
||||
format: "cjs",
|
||||
sourcesContent: false,
|
||||
platform: "node",
|
||||
loader: {
|
||||
".json": "json", // Bundle JSON files inline
|
||||
},
|
||||
banner: {
|
||||
js: "const _importMetaUrl=require('url').pathToFileURL(__filename)",
|
||||
},
|
||||
// These modules need to load files from the module directory at runtime,
|
||||
// so they cannot be bundled. Note: vscode is handled by vscodeShimPlugin
|
||||
// @vscode/ripgrep provides platform-specific binaries that must be resolved at runtime
|
||||
external: ["@grpc/reflection", "grpc-health-check", "better-sqlite3", "@vscode/ripgrep"],
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy runtime files needed for standalone mode
|
||||
* The vscode-context.ts expects package.json at INSTALL_DIR/extension/package.json
|
||||
*/
|
||||
async function copyRuntimeFiles() {
|
||||
const extensionDir = path.resolve(__dirname, "dist/extension")
|
||||
|
||||
// Create extension directory
|
||||
if (!fs.existsSync(extensionDir)) {
|
||||
fs.mkdirSync(extensionDir, { recursive: true })
|
||||
}
|
||||
|
||||
// Copy package.json from standalone/runtime-files to dist/extension
|
||||
const sourcePackageJson = path.resolve(rootDir, "standalone/runtime-files/package.json")
|
||||
const destPackageJson = path.resolve(extensionDir, "package.json")
|
||||
|
||||
if (fs.existsSync(sourcePackageJson)) {
|
||||
fs.copyFileSync(sourcePackageJson, destPackageJson)
|
||||
} else {
|
||||
console.warn(`Warning: ${sourcePackageJson} not found, creating minimal package.json`)
|
||||
// Fallback: create a minimal package.json with version from root
|
||||
const minimalPackageJson = {
|
||||
name: "cline",
|
||||
version: rootPackageJson.version,
|
||||
displayName: "Cline",
|
||||
}
|
||||
fs.writeFileSync(destPackageJson, JSON.stringify(minimalPackageJson, null, 2))
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const ctx = await esbuild.context(cliConfig)
|
||||
if (watch) {
|
||||
await ctx.watch()
|
||||
await copyRuntimeFiles()
|
||||
console.log("Watching for changes...")
|
||||
} else {
|
||||
await ctx.rebuild()
|
||||
await copyRuntimeFiles()
|
||||
await ctx.dispose()
|
||||
console.log("Build completed successfully!")
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error(e)
|
||||
process.exit(1)
|
||||
})
|
||||
Generated
+2840
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"name": "@cline/cli",
|
||||
"version": "1.0.0",
|
||||
"description": "Cline CLI - Command-line interface for Cline AI assistant",
|
||||
"main": "dist/index.cjs",
|
||||
"bin": {
|
||||
"clt": "./dist/index.cjs"
|
||||
},
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "node esbuild.mjs",
|
||||
"build:watch": "node esbuild.mjs --watch",
|
||||
"build:prod": "node esbuild.mjs --production",
|
||||
"start": "node dist/index.cjs",
|
||||
"dev": "node esbuild.mjs && node dist/index.cjs",
|
||||
"test": "mocha",
|
||||
"test:watch": "mocha --watch",
|
||||
"test:coverage": "c8 mocha"
|
||||
},
|
||||
"dependencies": {
|
||||
"@vscode/ripgrep": "^1.15.9",
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.1.0",
|
||||
"marked": "^15.0.12",
|
||||
"marked-terminal": "^7.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/chai": "^4.3.16",
|
||||
"@types/marked-terminal": "^6.1.1",
|
||||
"@types/mocha": "^10.0.7",
|
||||
"@types/node": "^20.14.10",
|
||||
"@types/sinon": "^17.0.3",
|
||||
"c8": "^10.1.2",
|
||||
"chai": "^4.4.1",
|
||||
"esbuild": "^0.27.0",
|
||||
"mocha": "^10.6.0",
|
||||
"sinon": "^17.0.1",
|
||||
"tsconfig-paths": "^4.2.0",
|
||||
"tsx": "^4.16.2",
|
||||
"typescript": "^5.5.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,290 @@
|
||||
/**
|
||||
* Config command group - manage persistent CLI configuration
|
||||
*
|
||||
* This command uses Cline's StateManager to read/write settings directly,
|
||||
* ensuring CLI config changes are reflected in the extension and vice versa.
|
||||
*/
|
||||
|
||||
import { Command } from "commander"
|
||||
import { disposeEmbeddedController, getEmbeddedController } from "../../core/embedded-controller.js"
|
||||
import type { OutputFormatter } from "../../core/output/types.js"
|
||||
import type { CliConfig } from "../../types/config.js"
|
||||
import type { Logger } from "../../types/logger.js"
|
||||
|
||||
/**
|
||||
* Parse a string value into the appropriate type based on the key
|
||||
*/
|
||||
export function parseValue(key: string, value: string): unknown {
|
||||
// Handle boolean values
|
||||
const lowerValue = value.toLowerCase()
|
||||
if (lowerValue === "true" || lowerValue === "1" || lowerValue === "yes") {
|
||||
return true
|
||||
}
|
||||
if (lowerValue === "false" || lowerValue === "0" || lowerValue === "no") {
|
||||
return false
|
||||
}
|
||||
|
||||
// Try to parse as JSON (for arrays and objects)
|
||||
const trimmed = value.trim()
|
||||
if ((trimmed.startsWith("[") && trimmed.endsWith("]")) || (trimmed.startsWith("{") && trimmed.endsWith("}"))) {
|
||||
try {
|
||||
return JSON.parse(value)
|
||||
} catch {
|
||||
// If JSON parsing fails, fall through to other parsing
|
||||
}
|
||||
}
|
||||
|
||||
// Handle numeric values - try to parse as number
|
||||
const numValue = Number(value)
|
||||
if (!Number.isNaN(numValue) && value.trim() !== "") {
|
||||
return numValue
|
||||
}
|
||||
|
||||
// Default: return as string
|
||||
return value
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a nested value from an object using dot notation
|
||||
* e.g., getNestedValue(obj, "browserSettings.viewport.width")
|
||||
*/
|
||||
export function getNestedValue(obj: Record<string, unknown>, path: string): unknown {
|
||||
const parts = path.split(".")
|
||||
let current: unknown = obj
|
||||
|
||||
for (const part of parts) {
|
||||
if (current === null || current === undefined || typeof current !== "object") {
|
||||
return undefined
|
||||
}
|
||||
current = (current as Record<string, unknown>)[part]
|
||||
}
|
||||
|
||||
return current
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a nested value in an object using dot notation
|
||||
* e.g., setNestedValue(obj, "browserSettings.viewport.width", 1200)
|
||||
* Returns the modified root object for the top-level key
|
||||
*/
|
||||
export function setNestedValue(
|
||||
obj: Record<string, unknown>,
|
||||
path: string,
|
||||
value: unknown,
|
||||
): { rootKey: string; rootValue: unknown } {
|
||||
const parts = path.split(".")
|
||||
const rootKey = parts[0]
|
||||
|
||||
if (parts.length === 1) {
|
||||
// Simple case: top-level key
|
||||
return { rootKey, rootValue: value }
|
||||
}
|
||||
|
||||
// Clone the root object to avoid mutating the original
|
||||
const rootValue = JSON.parse(JSON.stringify(obj[rootKey] ?? {}))
|
||||
|
||||
// Navigate to the parent of the target, creating objects as needed
|
||||
let current = rootValue as Record<string, unknown>
|
||||
for (let i = 1; i < parts.length - 1; i++) {
|
||||
const part = parts[i]
|
||||
if (current[part] === undefined || current[part] === null || typeof current[part] !== "object") {
|
||||
current[part] = {}
|
||||
}
|
||||
current = current[part] as Record<string, unknown>
|
||||
}
|
||||
|
||||
// Set the final value
|
||||
current[parts[parts.length - 1]] = value
|
||||
return { rootKey, rootValue }
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the config set command
|
||||
*/
|
||||
function createConfigSetCommand(config: CliConfig, logger: Logger, formatter: OutputFormatter): Command {
|
||||
return new Command("set")
|
||||
.description("Set a configuration value (supports dot notation for nested values, e.g., browserSettings.viewport.width)")
|
||||
.argument("<key>", "Configuration key to set")
|
||||
.argument("<value>", "Value to set")
|
||||
.action(async (key: string, value: string) => {
|
||||
logger.debug(`Setting config: ${key} = ${value}`)
|
||||
|
||||
try {
|
||||
// Initialize embedded controller to access StateManager
|
||||
const controller = await getEmbeddedController(logger, config.configDir)
|
||||
|
||||
// Parse value to appropriate type
|
||||
const parsedValue = parseValue(key, value)
|
||||
|
||||
// Check if this is a nested path
|
||||
if (key.includes(".")) {
|
||||
// For nested paths, get the current root object, modify it, and save the whole thing
|
||||
const rootKey = key.split(".")[0]
|
||||
let rootValue = controller.stateManager.getGlobalSettingsKey(rootKey as any)
|
||||
if (rootValue === undefined) {
|
||||
rootValue = controller.stateManager.getGlobalStateKey(rootKey as any)
|
||||
}
|
||||
|
||||
// Build the updated root object
|
||||
const currentRoot = rootValue !== undefined && typeof rootValue === "object" ? rootValue : {}
|
||||
const { rootValue: newRootValue } = setNestedValue({ [rootKey]: currentRoot }, key, parsedValue)
|
||||
|
||||
// Save the updated root object
|
||||
controller.stateManager.setGlobalState(rootKey as any, newRootValue as any)
|
||||
} else {
|
||||
// Simple top-level key
|
||||
controller.stateManager.setGlobalState(key as any, parsedValue as any)
|
||||
}
|
||||
|
||||
// Flush pending state to ensure changes are persisted before exit
|
||||
await controller.stateManager.flushPendingState()
|
||||
|
||||
formatter.success(`Set ${key} = ${String(parsedValue)}`)
|
||||
|
||||
// Cleanup and exit
|
||||
await disposeEmbeddedController(logger)
|
||||
process.exit(0)
|
||||
} catch (err) {
|
||||
formatter.error(err as Error)
|
||||
await disposeEmbeddedController(logger)
|
||||
process.exit(1)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the config get command
|
||||
*/
|
||||
function createConfigGetCommand(config: CliConfig, logger: Logger, formatter: OutputFormatter): Command {
|
||||
return new Command("get")
|
||||
.description("Get a configuration value (supports dot notation for nested values, e.g., browserSettings.viewport.width)")
|
||||
.argument("<key>", "Configuration key to get")
|
||||
.action(async (key: string) => {
|
||||
logger.debug(`Getting config: ${key}`)
|
||||
|
||||
try {
|
||||
// Initialize embedded controller to access StateManager
|
||||
const controller = await getEmbeddedController(logger, config.configDir)
|
||||
|
||||
let value: unknown
|
||||
|
||||
// Check if this is a nested path
|
||||
if (key.includes(".")) {
|
||||
// For nested paths, get the root object first
|
||||
const rootKey = key.split(".")[0]
|
||||
let rootValue = controller.stateManager.getGlobalSettingsKey(rootKey as any)
|
||||
if (rootValue === undefined) {
|
||||
rootValue = controller.stateManager.getGlobalStateKey(rootKey as any)
|
||||
}
|
||||
|
||||
if (rootValue !== undefined && typeof rootValue === "object") {
|
||||
// Get the nested value
|
||||
value = getNestedValue({ [rootKey]: rootValue }, key)
|
||||
}
|
||||
} else {
|
||||
// Simple top-level key
|
||||
value = controller.stateManager.getGlobalSettingsKey(key as any)
|
||||
if (value === undefined) {
|
||||
value = controller.stateManager.getGlobalStateKey(key as any)
|
||||
}
|
||||
}
|
||||
|
||||
if (value === undefined) {
|
||||
formatter.info(`${key} is not set`)
|
||||
} else {
|
||||
// Format objects/arrays as JSON for display
|
||||
const displayValue = typeof value === "object" ? JSON.stringify(value, null, 2) : value
|
||||
formatter.keyValue({ [key]: displayValue })
|
||||
}
|
||||
|
||||
// Cleanup and exit
|
||||
await disposeEmbeddedController(logger)
|
||||
process.exit(0)
|
||||
} catch (err) {
|
||||
formatter.error(err as Error)
|
||||
await disposeEmbeddedController(logger)
|
||||
process.exit(1)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the config list command
|
||||
*/
|
||||
function createConfigListCommand(config: CliConfig, logger: Logger, formatter: OutputFormatter): Command {
|
||||
return new Command("list").description("List all configuration values").action(async () => {
|
||||
logger.debug("Listing all config")
|
||||
|
||||
try {
|
||||
// Read the globalState.json file directly to get all settings
|
||||
const fs = await import("fs")
|
||||
const path = await import("path")
|
||||
const globalStatePath = path.join(config.configDir || `${process.env.HOME}/.cline`, "data", "globalState.json")
|
||||
|
||||
let allSettings: Record<string, unknown> = {}
|
||||
if (fs.existsSync(globalStatePath)) {
|
||||
const content = fs.readFileSync(globalStatePath, "utf-8")
|
||||
allSettings = JSON.parse(content)
|
||||
}
|
||||
|
||||
formatter.raw("")
|
||||
formatter.raw(JSON.stringify(allSettings, null, 2))
|
||||
formatter.raw("")
|
||||
|
||||
// Cleanup and exit
|
||||
process.exit(0)
|
||||
} catch (err) {
|
||||
formatter.error(err as Error)
|
||||
process.exit(1)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the config delete command
|
||||
*/
|
||||
function createConfigDeleteCommand(config: CliConfig, logger: Logger, formatter: OutputFormatter): Command {
|
||||
return new Command("delete")
|
||||
.alias("rm")
|
||||
.description("Delete a configuration value (reset to default)")
|
||||
.argument("<key>", "Configuration key to delete")
|
||||
.action(async (key: string) => {
|
||||
logger.debug(`Deleting config: ${key}`)
|
||||
|
||||
try {
|
||||
// Initialize embedded controller to access StateManager
|
||||
const controller = await getEmbeddedController(logger, config.configDir)
|
||||
|
||||
// Set the value to undefined to reset to default
|
||||
// Using type assertion since key is dynamic
|
||||
controller.stateManager.setGlobalState(key as any, undefined)
|
||||
|
||||
// Flush pending state to ensure changes are persisted before exit
|
||||
await controller.stateManager.flushPendingState()
|
||||
|
||||
formatter.success(`Reset ${key} to default`)
|
||||
|
||||
// Cleanup and exit
|
||||
await disposeEmbeddedController(logger)
|
||||
process.exit(0)
|
||||
} catch (err) {
|
||||
formatter.error(err as Error)
|
||||
await disposeEmbeddedController(logger)
|
||||
process.exit(1)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the config command group
|
||||
*/
|
||||
export function createConfigCommand(config: CliConfig, logger: Logger, formatter: OutputFormatter): Command {
|
||||
const configCommand = new Command("config").alias("c").description("Manage CLI configuration")
|
||||
|
||||
configCommand.addCommand(createConfigSetCommand(config, logger, formatter))
|
||||
configCommand.addCommand(createConfigGetCommand(config, logger, formatter))
|
||||
configCommand.addCommand(createConfigListCommand(config, logger, formatter))
|
||||
configCommand.addCommand(createConfigDeleteCommand(config, logger, formatter))
|
||||
|
||||
return configCommand
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* Task chat command - interactive REPL mode with embedded Controller
|
||||
*
|
||||
* This command provides an interactive chat interface using Cline's
|
||||
* embedded Controller, allowing real-time AI interactions directly
|
||||
* from the terminal.
|
||||
*/
|
||||
|
||||
import { Command } from "commander"
|
||||
import { disposeEmbeddedController, getEmbeddedController } from "../../../core/embedded-controller.js"
|
||||
import type { OutputFormatter } from "../../../core/output/types.js"
|
||||
import { parseAtPaths, processExplicitFiles, processExplicitImages } from "../../../core/path-parser.js"
|
||||
import type { CliConfig } from "../../../types/config.js"
|
||||
import type { Logger } from "../../../types/logger.js"
|
||||
import { startRepl } from "./repl.js"
|
||||
import { createSession } from "./session.js"
|
||||
|
||||
/**
|
||||
* Collect multiple option values into an array
|
||||
* Used for -f and -i options that can be specified multiple times
|
||||
*/
|
||||
function collectOption(value: string, previous: string[]): string[] {
|
||||
return previous.concat([value])
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the task chat command
|
||||
*/
|
||||
export function createTaskChatCommand(config: CliConfig, logger: Logger, formatter: OutputFormatter): Command {
|
||||
const chatCommand = new Command("chat")
|
||||
.alias("c")
|
||||
.description("Interactive chat mode with embedded Cline Controller")
|
||||
.argument("[prompt]", "Initial prompt to start a new task (optional)")
|
||||
.option("-m, --mode <mode>", "Start in specific mode: act or plan")
|
||||
.option("-t, --task <id>", "Resume an existing task by ID")
|
||||
.option("-f, --file <path>", "Attach file to initial prompt (can be repeated)", collectOption, [])
|
||||
.option("-i, --image <path>", "Attach image to initial prompt (can be repeated)", collectOption, [])
|
||||
.option("-y, --yolo", "Enable autonomous mode (no confirmations)", false)
|
||||
.action(async (promptArg: string | undefined, options) => {
|
||||
logger.debug("Task chat command called", { promptArg, options })
|
||||
|
||||
try {
|
||||
// Process explicit file and image attachments from CLI options
|
||||
const cwd = process.cwd()
|
||||
let initialFiles: string[] = []
|
||||
let initialImages: string[] = []
|
||||
|
||||
// Process -f/--file options (can be files or images, auto-detected)
|
||||
if (options.file && options.file.length > 0) {
|
||||
const processed = processExplicitFiles(options.file, cwd)
|
||||
initialFiles = processed.files
|
||||
initialImages = processed.images
|
||||
}
|
||||
|
||||
// Process -i/--image options (must be images)
|
||||
if (options.image && options.image.length > 0) {
|
||||
const images = processExplicitImages(options.image, cwd)
|
||||
initialImages = initialImages.concat(images)
|
||||
}
|
||||
|
||||
// Parse @path references from the initial prompt if provided
|
||||
let processedPrompt = promptArg
|
||||
if (promptArg) {
|
||||
const parsed = parseAtPaths(promptArg, cwd)
|
||||
|
||||
// Show warnings for any files that couldn't be processed
|
||||
for (const warning of parsed.warnings) {
|
||||
formatter.warn(warning)
|
||||
}
|
||||
|
||||
processedPrompt = parsed.cleanedMessage
|
||||
initialFiles = initialFiles.concat(parsed.files)
|
||||
initialImages = initialImages.concat(parsed.images)
|
||||
}
|
||||
|
||||
// Initialize embedded controller
|
||||
const controller = await getEmbeddedController(logger, config.configDir)
|
||||
|
||||
// Set up mode if specified
|
||||
if (options.mode) {
|
||||
if (options.mode !== "plan" && options.mode !== "act") {
|
||||
throw new Error(`Invalid mode: "${options.mode}". Valid options are: act, plan`)
|
||||
}
|
||||
await controller.togglePlanActMode(options.mode as "plan" | "act")
|
||||
}
|
||||
|
||||
if (options.yolo) {
|
||||
controller.stateManager.setGlobalState("yoloModeToggled", true)
|
||||
// Increase mistake limit for autonomous operation (matches Go CLI behavior)
|
||||
controller.stateManager.setGlobalState("maxConsecutiveMistakes", 6)
|
||||
// Ensure we're in Act mode for autonomous execution (unless user explicitly chose Plan mode)
|
||||
if (!options.mode) {
|
||||
await controller.togglePlanActMode("act")
|
||||
}
|
||||
}
|
||||
|
||||
// Create chat session with yolo mode if specified
|
||||
const session = createSession(options.yolo)
|
||||
|
||||
if (options.yolo) {
|
||||
formatter.info("[YOLO] Autonomous mode enabled - no confirmations required")
|
||||
}
|
||||
|
||||
// Start the REPL
|
||||
await startRepl({
|
||||
session,
|
||||
controller,
|
||||
formatter,
|
||||
logger,
|
||||
config,
|
||||
initialPrompt: processedPrompt,
|
||||
initialImages: initialImages.length > 0 ? initialImages : undefined,
|
||||
initialFiles: initialFiles.length > 0 ? initialFiles : undefined,
|
||||
resumeTaskId: options.task,
|
||||
})
|
||||
} catch (error) {
|
||||
formatter.error((error as Error).message)
|
||||
await disposeEmbeddedController(logger)
|
||||
process.exit(1)
|
||||
}
|
||||
})
|
||||
|
||||
return chatCommand
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
/**
|
||||
* Tab completion for @ file/folder mentions in chat REPL
|
||||
*
|
||||
* Provides file and folder path completion when users type @ followed by a partial path.
|
||||
*/
|
||||
|
||||
import fs from "fs"
|
||||
import path from "path"
|
||||
|
||||
/**
|
||||
* Result of finding an @ mention to complete
|
||||
*/
|
||||
interface AtMentionMatch {
|
||||
/** The text before the @ mention (to preserve in completion) */
|
||||
prefix: string
|
||||
/** The partial path after @ that needs completion */
|
||||
partial: string
|
||||
/** Character index where the @ starts */
|
||||
atIndex: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the @ mention being completed in the input line
|
||||
*
|
||||
* Handles multiple @ mentions by finding the last one that appears
|
||||
* to be incomplete (user is still typing it).
|
||||
*/
|
||||
function findAtMentionToComplete(line: string): AtMentionMatch | null {
|
||||
// Find the last @ that could be a file mention
|
||||
// We look for @ that's either at start or preceded by whitespace
|
||||
let atIndex = -1
|
||||
for (let i = line.length - 1; i >= 0; i--) {
|
||||
if (line[i] === "@") {
|
||||
// Check if it's at start or preceded by whitespace
|
||||
if (i === 0 || /\s/.test(line[i - 1])) {
|
||||
atIndex = i
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (atIndex === -1) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Extract the partial path after @
|
||||
const afterAt = line.slice(atIndex + 1)
|
||||
|
||||
// If there's whitespace after @, this mention is complete, not being typed
|
||||
if (/\s/.test(afterAt)) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
prefix: line.slice(0, atIndex),
|
||||
partial: afterAt,
|
||||
atIndex,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get completions for a partial file/folder path
|
||||
*/
|
||||
function getPathCompletions(partial: string, cwd: string): string[] {
|
||||
try {
|
||||
// Determine the directory to search and the prefix to match
|
||||
let searchDir: string
|
||||
let namePrefix: string
|
||||
|
||||
if (partial === "") {
|
||||
// Empty partial - list cwd contents
|
||||
searchDir = cwd
|
||||
namePrefix = ""
|
||||
} else if (partial.endsWith("/")) {
|
||||
// Ends with / - list that directory's contents
|
||||
searchDir = path.resolve(cwd, partial)
|
||||
namePrefix = ""
|
||||
} else {
|
||||
// Partial filename - list parent directory and filter
|
||||
const partialPath = path.resolve(cwd, partial)
|
||||
searchDir = path.dirname(partialPath)
|
||||
namePrefix = path.basename(partial)
|
||||
}
|
||||
|
||||
// Check if directory exists
|
||||
if (!fs.existsSync(searchDir) || !fs.statSync(searchDir).isDirectory()) {
|
||||
return []
|
||||
}
|
||||
|
||||
// Read directory contents
|
||||
const entries = fs.readdirSync(searchDir, { withFileTypes: true })
|
||||
|
||||
// Filter and map entries
|
||||
const completions: string[] = []
|
||||
for (const entry of entries) {
|
||||
// Skip hidden files unless explicitly searching for them
|
||||
if (entry.name.startsWith(".") && !namePrefix.startsWith(".")) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if name matches prefix
|
||||
if (!entry.name.toLowerCase().startsWith(namePrefix.toLowerCase())) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Build the completion path
|
||||
let completionPath: string
|
||||
if (partial === "") {
|
||||
completionPath = entry.name
|
||||
} else if (partial.endsWith("/")) {
|
||||
completionPath = partial + entry.name
|
||||
} else {
|
||||
// Replace the partial filename with the full name
|
||||
const dirPart = partial.slice(0, partial.length - namePrefix.length)
|
||||
completionPath = dirPart + entry.name
|
||||
}
|
||||
|
||||
// Append / for directories
|
||||
if (entry.isDirectory()) {
|
||||
completionPath += "/"
|
||||
}
|
||||
|
||||
completions.push(completionPath)
|
||||
}
|
||||
|
||||
// Sort: directories first, then alphabetically
|
||||
completions.sort((a, b) => {
|
||||
const aIsDir = a.endsWith("/")
|
||||
const bIsDir = b.endsWith("/")
|
||||
if (aIsDir && !bIsDir) return -1
|
||||
if (!aIsDir && bIsDir) return 1
|
||||
return a.localeCompare(b)
|
||||
})
|
||||
|
||||
return completions
|
||||
} catch {
|
||||
// If anything goes wrong, return no completions
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for creating a completer
|
||||
*/
|
||||
export interface CompleterOptions {
|
||||
/** The current working directory for path resolution */
|
||||
cwd: string
|
||||
/** Callback invoked when Tab is pressed on an empty line */
|
||||
onEmptyTab?: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a readline completer function for @ file mentions
|
||||
*
|
||||
* Also supports triggering a callback when Tab is pressed on an empty line,
|
||||
* which is used for mode toggling.
|
||||
*
|
||||
* @param options - Completer options including cwd and callbacks
|
||||
* @returns A completer function compatible with readline
|
||||
*/
|
||||
export function createCompleter(options: CompleterOptions): (line: string) => [string[], string] {
|
||||
const { cwd, onEmptyTab } = options
|
||||
|
||||
return (line: string): [string[], string] => {
|
||||
// Check for empty input - trigger mode toggle callback if provided
|
||||
if (line === "" && onEmptyTab) {
|
||||
onEmptyTab()
|
||||
return [[], line]
|
||||
}
|
||||
|
||||
const match = findAtMentionToComplete(line)
|
||||
|
||||
if (!match) {
|
||||
// No @ mention being typed - no completions
|
||||
return [[], line]
|
||||
}
|
||||
|
||||
const pathCompletions = getPathCompletions(match.partial, cwd)
|
||||
|
||||
if (pathCompletions.length === 0) {
|
||||
return [[], line]
|
||||
}
|
||||
|
||||
// Build full line completions (prefix + @ + completed path)
|
||||
const fullCompletions = pathCompletions.map((p) => `${match.prefix}@${p}`)
|
||||
|
||||
// The "substring" is what readline uses to determine what to replace
|
||||
// We want to replace from the @ onwards
|
||||
const substring = `@${match.partial}`
|
||||
|
||||
// Return format: [completions, substring being completed]
|
||||
// If there's only one completion, readline will auto-complete
|
||||
// If multiple, it will show them as options
|
||||
return [fullCompletions, line]
|
||||
}
|
||||
}
|
||||
|
||||
// Export for testing
|
||||
export { findAtMentionToComplete, getPathCompletions }
|
||||
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* Chat command module
|
||||
*
|
||||
* Re-exports the main command factory for backward compatibility.
|
||||
*/
|
||||
|
||||
export { createTaskChatCommand } from "./command.js"
|
||||
|
||||
// Also export utilities for testing
|
||||
export { checkForPendingInput, isCompletionState, isFailureState, type PendingInputState } from "./input-checker.js"
|
||||
export { getModelIdForProvider, getModelIdKey } from "./model-utils.js"
|
||||
export { buildPromptString } from "./prompt.js"
|
||||
export { type ChatSession, createSession } from "./session.js"
|
||||
@@ -0,0 +1,168 @@
|
||||
/**
|
||||
* Input state checker for chat REPL
|
||||
*
|
||||
* Analyzes message history to determine if user input is needed.
|
||||
*/
|
||||
|
||||
import type { ClineMessage, ClineSayTool } from "@shared/ExtensionMessage"
|
||||
|
||||
/**
|
||||
* Auto-approval action keys that can be enabled for "don't ask again" functionality
|
||||
*/
|
||||
export type AutoApprovalAction = "readFiles" | "editFiles" | "executeAllCommands" | "useBrowser" | "useMcp"
|
||||
|
||||
/**
|
||||
* Result of checking for pending input
|
||||
*/
|
||||
export interface PendingInputState {
|
||||
awaitingApproval: boolean
|
||||
awaitingInput: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the last message requires user input
|
||||
*/
|
||||
export function checkForPendingInput(messages: ClineMessage[]): PendingInputState {
|
||||
if (messages.length === 0) {
|
||||
return { awaitingApproval: false, awaitingInput: false }
|
||||
}
|
||||
|
||||
const lastMessage = messages[messages.length - 1]
|
||||
|
||||
// Skip partial messages
|
||||
if (lastMessage.partial) {
|
||||
return { awaitingApproval: false, awaitingInput: false }
|
||||
}
|
||||
|
||||
// Check if this is an "ask" type message
|
||||
if (lastMessage.type === "ask") {
|
||||
const ask = lastMessage.ask
|
||||
|
||||
// These require approval (yes/no response)
|
||||
const approvalAsks = ["command", "tool", "browser_action_launch", "use_mcp_server"]
|
||||
|
||||
// These require free-form input
|
||||
const inputAsks = ["followup", "plan_mode_respond", "act_mode_respond"]
|
||||
|
||||
if (approvalAsks.includes(ask || "")) {
|
||||
return { awaitingApproval: true, awaitingInput: false }
|
||||
}
|
||||
|
||||
if (inputAsks.includes(ask || "")) {
|
||||
return { awaitingApproval: false, awaitingInput: true }
|
||||
}
|
||||
|
||||
// Special cases
|
||||
if (ask === "api_req_failed") {
|
||||
return { awaitingApproval: true, awaitingInput: false }
|
||||
}
|
||||
|
||||
if (ask === "completion_result" || ask === "resume_task" || ask === "resume_completed_task") {
|
||||
return { awaitingApproval: false, awaitingInput: true }
|
||||
}
|
||||
}
|
||||
|
||||
return { awaitingApproval: false, awaitingInput: false }
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the last message indicates a failure state (for yolo mode)
|
||||
*/
|
||||
export function isFailureState(messages: ClineMessage[]): { isFailure: boolean; actionKey: string | null } {
|
||||
if (messages.length === 0) {
|
||||
return { isFailure: false, actionKey: null }
|
||||
}
|
||||
|
||||
const lastMessage = messages[messages.length - 1]
|
||||
|
||||
// Skip partial messages
|
||||
if (lastMessage.partial) {
|
||||
return { isFailure: false, actionKey: null }
|
||||
}
|
||||
|
||||
// Check for failure indicators
|
||||
if (
|
||||
lastMessage.ask === "api_req_failed" ||
|
||||
lastMessage.ask === "mistake_limit_reached" ||
|
||||
lastMessage.say === "error" ||
|
||||
lastMessage.say === "diff_error"
|
||||
) {
|
||||
// Use the message text as the action key for tracking consecutive failures
|
||||
const actionKey = lastMessage.text || lastMessage.ask || lastMessage.say || "unknown"
|
||||
return { isFailure: true, actionKey }
|
||||
}
|
||||
|
||||
return { isFailure: false, actionKey: null }
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the last message indicates task completion (for yolo mode)
|
||||
*/
|
||||
export function isCompletionState(messages: ClineMessage[]): boolean {
|
||||
if (messages.length === 0) {
|
||||
return false
|
||||
}
|
||||
|
||||
const lastMessage = messages[messages.length - 1]
|
||||
|
||||
// Skip partial messages
|
||||
if (lastMessage.partial) {
|
||||
return false
|
||||
}
|
||||
|
||||
return lastMessage.ask === "completion_result" || lastMessage.say === "completion_result"
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine which auto-approval action to enable based on the ask message
|
||||
*
|
||||
* @param msg - The pending ask message
|
||||
* @returns The auto-approval action key, or null if not applicable
|
||||
*/
|
||||
export function determineAutoApprovalAction(msg: ClineMessage): AutoApprovalAction | null {
|
||||
const ask = msg.ask
|
||||
|
||||
switch (ask) {
|
||||
case "tool": {
|
||||
// Parse tool message to determine if it's a read or edit operation
|
||||
if (!msg.text) {
|
||||
return null
|
||||
}
|
||||
try {
|
||||
const tool = JSON.parse(msg.text) as ClineSayTool
|
||||
switch (tool.tool) {
|
||||
case "readFile":
|
||||
case "listFilesTopLevel":
|
||||
case "listFilesRecursive":
|
||||
case "listCodeDefinitionNames":
|
||||
case "searchFiles":
|
||||
case "webFetch":
|
||||
case "webSearch":
|
||||
return "readFiles"
|
||||
case "editedExistingFile":
|
||||
case "newFileCreated":
|
||||
return "editFiles"
|
||||
case "fileDeleted":
|
||||
// File deletion uses editFiles permission
|
||||
return "editFiles"
|
||||
default:
|
||||
return null
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
case "command":
|
||||
return "executeAllCommands"
|
||||
|
||||
case "browser_action_launch":
|
||||
return "useBrowser"
|
||||
|
||||
case "use_mcp_server":
|
||||
return "useMcp"
|
||||
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
/**
|
||||
* Model ID utilities for chat command
|
||||
*
|
||||
* Functions to map providers to their corresponding model ID configuration keys.
|
||||
*/
|
||||
|
||||
import type { ApiConfiguration, ApiProvider } from "@shared/api"
|
||||
import type { Mode } from "@shared/storage/types"
|
||||
|
||||
/**
|
||||
* Get the model ID for the current provider and mode
|
||||
*/
|
||||
export function getModelIdForProvider(
|
||||
apiConfiguration: ApiConfiguration | undefined,
|
||||
provider: ApiProvider | undefined,
|
||||
mode: Mode,
|
||||
): string | undefined {
|
||||
if (!apiConfiguration || !provider) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const prefix = mode === "plan" ? "planMode" : "actMode"
|
||||
|
||||
// Map provider to the corresponding model ID field
|
||||
switch (provider) {
|
||||
case "openrouter":
|
||||
case "cline":
|
||||
return apiConfiguration[`${prefix}OpenRouterModelId`]
|
||||
case "anthropic":
|
||||
case "claude-code":
|
||||
case "bedrock":
|
||||
case "vertex":
|
||||
case "gemini":
|
||||
case "openai-native":
|
||||
case "deepseek":
|
||||
case "qwen":
|
||||
case "qwen-code":
|
||||
case "doubao":
|
||||
case "mistral":
|
||||
case "asksage":
|
||||
case "xai":
|
||||
case "moonshot":
|
||||
case "nebius":
|
||||
case "sambanova":
|
||||
case "cerebras":
|
||||
case "sapaicore":
|
||||
case "zai":
|
||||
case "fireworks":
|
||||
case "minimax":
|
||||
return apiConfiguration[`${prefix}ApiModelId`]
|
||||
case "openai":
|
||||
return apiConfiguration[`${prefix}OpenAiModelId`]
|
||||
case "ollama":
|
||||
return apiConfiguration[`${prefix}OllamaModelId`]
|
||||
case "lmstudio":
|
||||
return apiConfiguration[`${prefix}LmStudioModelId`]
|
||||
case "requesty":
|
||||
return apiConfiguration[`${prefix}RequestyModelId`]
|
||||
case "together":
|
||||
return apiConfiguration[`${prefix}TogetherModelId`]
|
||||
case "litellm":
|
||||
return apiConfiguration[`${prefix}LiteLlmModelId`]
|
||||
case "groq":
|
||||
return apiConfiguration[`${prefix}GroqModelId`]
|
||||
case "baseten":
|
||||
return apiConfiguration[`${prefix}BasetenModelId`]
|
||||
case "huggingface":
|
||||
return apiConfiguration[`${prefix}HuggingFaceModelId`]
|
||||
case "huawei-cloud-maas":
|
||||
return apiConfiguration[`${prefix}HuaweiCloudMaasModelId`]
|
||||
case "oca":
|
||||
return apiConfiguration[`${prefix}OcaModelId`]
|
||||
case "hicap":
|
||||
return apiConfiguration[`${prefix}HicapModelId`]
|
||||
case "aihubmix":
|
||||
return apiConfiguration[`${prefix}AihubmixModelId`]
|
||||
case "nousResearch":
|
||||
return apiConfiguration[`${prefix}NousResearchModelId`]
|
||||
case "vercel-ai-gateway":
|
||||
return apiConfiguration[`${prefix}VercelAiGatewayModelId`]
|
||||
case "vscode-lm":
|
||||
case "dify":
|
||||
default:
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the model ID state key for a given provider and mode
|
||||
* Some providers use provider-specific model ID keys (e.g., openRouterModelId),
|
||||
* while others use the generic apiModelId
|
||||
*/
|
||||
export function getModelIdKey(provider: string | undefined, mode: Mode): string {
|
||||
const modePrefix = mode === "plan" ? "planMode" : "actMode"
|
||||
|
||||
switch (provider) {
|
||||
case "openrouter":
|
||||
case "cline":
|
||||
return `${modePrefix}OpenRouterModelId`
|
||||
case "openai":
|
||||
return `${modePrefix}OpenAiModelId`
|
||||
case "ollama":
|
||||
return `${modePrefix}OllamaModelId`
|
||||
case "lmstudio":
|
||||
return `${modePrefix}LmStudioModelId`
|
||||
case "litellm":
|
||||
return `${modePrefix}LiteLlmModelId`
|
||||
case "requesty":
|
||||
return `${modePrefix}RequestyModelId`
|
||||
case "together":
|
||||
return `${modePrefix}TogetherModelId`
|
||||
case "fireworks":
|
||||
return `${modePrefix}FireworksModelId`
|
||||
case "groq":
|
||||
return `${modePrefix}GroqModelId`
|
||||
case "baseten":
|
||||
return `${modePrefix}BasetenModelId`
|
||||
case "huggingface":
|
||||
return `${modePrefix}HuggingFaceModelId`
|
||||
case "huawei-cloud-maas":
|
||||
return `${modePrefix}HuaweiCloudMaasModelId`
|
||||
case "oca":
|
||||
return `${modePrefix}OcaModelId`
|
||||
case "hicap":
|
||||
return `${modePrefix}HicapModelId`
|
||||
case "aihubmix":
|
||||
return `${modePrefix}AihubmixModelId`
|
||||
case "nousResearch":
|
||||
return `${modePrefix}NousResearchModelId`
|
||||
case "vercel-ai-gateway":
|
||||
return `${modePrefix}VercelAiGatewayModelId`
|
||||
default:
|
||||
return `${modePrefix}ApiModelId`
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* Prompt string builder for chat REPL
|
||||
*
|
||||
* Builds the CLI prompt that shows current mode, provider, and model.
|
||||
*/
|
||||
|
||||
import type { ApiProvider } from "@shared/api"
|
||||
import type { Mode } from "@shared/storage/types"
|
||||
import chalk from "chalk"
|
||||
|
||||
/**
|
||||
* Build the CLI prompt string with mode, provider, and model
|
||||
* Format: [mode] provider/model >
|
||||
*/
|
||||
export function buildPromptString(mode: Mode, provider: ApiProvider | undefined, modelId: string | undefined): string {
|
||||
const modeStr = mode === "plan" ? chalk.yellow("[plan]") : chalk.cyan("[act]")
|
||||
const providerStr = provider || "unknown"
|
||||
|
||||
// Shorten very long model IDs for display (keep last part after last /)
|
||||
let modelStr = modelId || "unknown"
|
||||
if (modelStr.length > 40) {
|
||||
const lastSlash = modelStr.lastIndexOf("/")
|
||||
if (lastSlash > 0 && lastSlash < modelStr.length - 1) {
|
||||
modelStr = "..." + modelStr.substring(lastSlash)
|
||||
} else {
|
||||
modelStr = modelStr.substring(0, 37) + "..."
|
||||
}
|
||||
}
|
||||
|
||||
const providerModelStr = chalk.dim(`${providerStr}/${modelStr}`)
|
||||
|
||||
return `${modeStr} ${providerModelStr} ${chalk.white(">")} `
|
||||
}
|
||||
@@ -0,0 +1,491 @@
|
||||
/**
|
||||
* REPL (Read-Eval-Print Loop) for chat command
|
||||
*
|
||||
* Handles readline setup, event handling, and the main interaction loop.
|
||||
*/
|
||||
|
||||
import type { ApiProvider } from "@shared/api"
|
||||
import type { Mode } from "@shared/storage/types"
|
||||
import readline from "readline"
|
||||
import type { Controller } from "@/core/controller"
|
||||
import { CliWebviewAdapter } from "../../../core/cli-webview-adapter.js"
|
||||
import { disposeEmbeddedController } from "../../../core/embedded-controller.js"
|
||||
import type { OutputFormatter } from "../../../core/output/types.js"
|
||||
import { parseAtPaths } from "../../../core/path-parser.js"
|
||||
import type { CliConfig } from "../../../types/config.js"
|
||||
import type { Logger } from "../../../types/logger.js"
|
||||
import { createCompleter } from "./completer.js"
|
||||
import { checkForPendingInput, determineAutoApprovalAction, isCompletionState, isFailureState } from "./input-checker.js"
|
||||
import { getModelIdForProvider } from "./model-utils.js"
|
||||
import { buildPromptString } from "./prompt.js"
|
||||
import type { ChatSession } from "./session.js"
|
||||
import { processSlashCommand } from "./slash-commands/index.js"
|
||||
|
||||
/** Yolo mode timeout: 5 minutes in milliseconds */
|
||||
const YOLO_TIMEOUT_MS = 5 * 60 * 1000
|
||||
|
||||
/** Yolo mode max consecutive failures before abort */
|
||||
const YOLO_MAX_FAILURES = 3
|
||||
|
||||
/**
|
||||
* Options for starting the REPL
|
||||
*/
|
||||
export interface ReplOptions {
|
||||
session: ChatSession
|
||||
controller: Controller
|
||||
formatter: OutputFormatter
|
||||
logger: Logger
|
||||
config: CliConfig
|
||||
initialPrompt?: string
|
||||
initialImages?: string[]
|
||||
initialFiles?: string[]
|
||||
resumeTaskId?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the interactive REPL loop
|
||||
*/
|
||||
export async function startRepl(options: ReplOptions): Promise<void> {
|
||||
const { session, controller, formatter, logger, config, initialPrompt, initialImages, initialFiles, resumeTaskId } = options
|
||||
|
||||
// Create webview adapter for output
|
||||
session.adapter = new CliWebviewAdapter(controller, formatter)
|
||||
|
||||
// Track if we started with a prompt (AI will be processing)
|
||||
let startedWithPrompt = false
|
||||
|
||||
// Start or resume task
|
||||
if (resumeTaskId) {
|
||||
// Resume existing task
|
||||
const history = await controller.getTaskWithId(resumeTaskId)
|
||||
if (!history) {
|
||||
throw new Error(`Task not found: ${resumeTaskId}`)
|
||||
}
|
||||
session.taskId = await controller.initTask(undefined, undefined, undefined, history.historyItem)
|
||||
formatter.info(`Resumed task: ${session.taskId}`)
|
||||
} else if (initialPrompt) {
|
||||
// Start new task with prompt and any initial attachments
|
||||
startedWithPrompt = true
|
||||
|
||||
// Log attachment info
|
||||
if (initialFiles && initialFiles.length > 0) {
|
||||
formatter.info(`Attaching ${initialFiles.length} file(s)`)
|
||||
}
|
||||
if (initialImages && initialImages.length > 0) {
|
||||
formatter.info(`Attaching ${initialImages.length} image(s)`)
|
||||
}
|
||||
|
||||
session.taskId = await controller.initTask(
|
||||
initialPrompt,
|
||||
initialImages && initialImages.length > 0 ? initialImages : undefined,
|
||||
initialFiles && initialFiles.length > 0 ? initialFiles : undefined,
|
||||
)
|
||||
formatter.info(`Started task: ${session.taskId}`)
|
||||
// Enable spinner since AI will be processing
|
||||
session.adapter?.setProcessing(true)
|
||||
}
|
||||
|
||||
// Display welcome message
|
||||
displayWelcome(formatter, session, controller)
|
||||
|
||||
// Output existing messages if resuming
|
||||
if (session.taskId && session.adapter) {
|
||||
session.adapter.outputAllMessages()
|
||||
}
|
||||
|
||||
// Helper to toggle between act and plan mode
|
||||
const toggleMode = async (): Promise<void> => {
|
||||
// Only toggle when awaiting user input (not while AI is processing)
|
||||
if (isProcessing) {
|
||||
return
|
||||
}
|
||||
const state = await controller.getStateToPostToWebview()
|
||||
const currentMode = (state.mode || "act") as Mode
|
||||
const newMode = currentMode === "act" ? "plan" : "act"
|
||||
await controller.togglePlanActMode(newMode)
|
||||
await updatePromptString()
|
||||
showPrompt()
|
||||
}
|
||||
|
||||
// Create readline interface with @ file completion and mode toggle on empty Tab
|
||||
const rl = readline.createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout,
|
||||
prompt: "> ", // Default prompt, will be updated dynamically
|
||||
completer: createCompleter({
|
||||
cwd: process.cwd(),
|
||||
onEmptyTab: () => {
|
||||
// Use setImmediate to allow async operation outside completer
|
||||
setImmediate(() => toggleMode())
|
||||
},
|
||||
}),
|
||||
})
|
||||
|
||||
// Track if we're currently processing (AI is working)
|
||||
let isProcessing = startedWithPrompt
|
||||
// Track previous awaiting states to detect transitions
|
||||
let wasAwaitingInput = false
|
||||
|
||||
// Helper to set processing state and update spinner
|
||||
function setProcessingState(processing: boolean): void {
|
||||
isProcessing = processing
|
||||
session.adapter?.setProcessing(processing)
|
||||
}
|
||||
|
||||
// Helper to update the prompt string (but not necessarily show it)
|
||||
async function updatePromptString(): Promise<void> {
|
||||
const currentState = await controller.getStateToPostToWebview()
|
||||
const mode = (currentState.mode || "act") as Mode
|
||||
const provider = (
|
||||
mode === "plan"
|
||||
? currentState.apiConfiguration?.planModeApiProvider
|
||||
: currentState.apiConfiguration?.actModeApiProvider
|
||||
) as ApiProvider | undefined
|
||||
const modelId = getModelIdForProvider(currentState.apiConfiguration, provider, mode)
|
||||
const promptStr = buildPromptString(mode, provider, modelId)
|
||||
rl.setPrompt(promptStr)
|
||||
}
|
||||
|
||||
// Helper to show the prompt (call after updating)
|
||||
function showPrompt(): void {
|
||||
rl.prompt()
|
||||
}
|
||||
|
||||
// Start listening for state updates
|
||||
session.adapter.startListening((messages) => {
|
||||
const pendingState = checkForPendingInput(messages)
|
||||
session.awaitingApproval = pendingState.awaitingApproval
|
||||
session.awaitingInput = pendingState.awaitingInput
|
||||
|
||||
// Store the pending ask message for auto-approval determination
|
||||
if (pendingState.awaitingApproval && messages.length > 0) {
|
||||
const lastMessage = messages[messages.length - 1]
|
||||
if (lastMessage.type === "ask" && !lastMessage.partial) {
|
||||
session.pendingAskMessage = lastMessage
|
||||
}
|
||||
} else if (!pendingState.awaitingApproval) {
|
||||
session.pendingAskMessage = null
|
||||
}
|
||||
|
||||
// YOLO MODE: Auto-respond to pending inputs
|
||||
if (session.yoloMode && controller.task && !session.yoloCompleted) {
|
||||
// Check for task completion first
|
||||
if (isCompletionState(messages)) {
|
||||
// Guard against processing completion multiple times
|
||||
session.yoloCompleted = true
|
||||
formatter.success("\n[YOLO] Task completed!")
|
||||
// Respond to the completion_result ask to unblock the handler
|
||||
const task = controller.task
|
||||
task.handleWebviewAskResponse("yesButtonClicked")
|
||||
// Schedule exit after brief delay to let response process
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
await task.abortTask()
|
||||
} catch {
|
||||
// Task may already be cleaned up, ignore
|
||||
}
|
||||
// Exit successfully - task has completed
|
||||
process.exit(0)
|
||||
}, 200)
|
||||
return
|
||||
}
|
||||
|
||||
// Check for timeout (5 minutes)
|
||||
if (session.yoloActionStartTime && Date.now() - session.yoloActionStartTime > YOLO_TIMEOUT_MS) {
|
||||
formatter.error("\n[YOLO] Action timed out after 5 minutes. Aborting.")
|
||||
session.isRunning = false
|
||||
rl.close()
|
||||
process.exit(0)
|
||||
return
|
||||
}
|
||||
|
||||
// Check for failure state
|
||||
const failureCheck = isFailureState(messages)
|
||||
if (failureCheck.isFailure) {
|
||||
if (session.yoloLastFailedAction === failureCheck.actionKey) {
|
||||
session.yoloFailureCount++
|
||||
} else {
|
||||
session.yoloLastFailedAction = failureCheck.actionKey
|
||||
session.yoloFailureCount = 1
|
||||
}
|
||||
|
||||
if (session.yoloFailureCount >= YOLO_MAX_FAILURES) {
|
||||
formatter.error(`\n[YOLO] Same action failed ${YOLO_MAX_FAILURES} times. Aborting.`)
|
||||
session.isRunning = false
|
||||
rl.close()
|
||||
process.exit(0)
|
||||
return
|
||||
}
|
||||
|
||||
// Auto-retry: approve the retry
|
||||
formatter.warn(`[YOLO] Action failed (attempt ${session.yoloFailureCount}/${YOLO_MAX_FAILURES}), retrying...`)
|
||||
session.yoloActionStartTime = Date.now()
|
||||
setProcessingState(true)
|
||||
wasAwaitingInput = false
|
||||
controller.task.handleWebviewAskResponse("yesButtonClicked")
|
||||
return
|
||||
} else {
|
||||
// Reset failure tracking on success
|
||||
session.yoloFailureCount = 0
|
||||
session.yoloLastFailedAction = null
|
||||
}
|
||||
|
||||
// Auto-approve pending approvals
|
||||
if (pendingState.awaitingApproval) {
|
||||
logger.debug("[YOLO] Auto-approving action")
|
||||
session.yoloActionStartTime = Date.now()
|
||||
setProcessingState(true)
|
||||
wasAwaitingInput = false
|
||||
controller.task.handleWebviewAskResponse("yesButtonClicked")
|
||||
session.awaitingApproval = false
|
||||
return
|
||||
}
|
||||
|
||||
// Auto-respond to input requests with "proceed"
|
||||
if (pendingState.awaitingInput) {
|
||||
logger.debug("[YOLO] Auto-responding with 'proceed'")
|
||||
session.yoloActionStartTime = Date.now()
|
||||
setProcessingState(true)
|
||||
wasAwaitingInput = false
|
||||
controller.task.handleWebviewAskResponse("messageResponse", "proceed")
|
||||
session.awaitingInput = false
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Normal mode: Detect transition from processing to awaiting input
|
||||
const nowAwaitingInput = pendingState.awaitingApproval || pendingState.awaitingInput
|
||||
if (isProcessing && nowAwaitingInput && !wasAwaitingInput) {
|
||||
// AI just finished and is now waiting for input - show prompt
|
||||
setProcessingState(false)
|
||||
updatePromptString().then(() => showPrompt())
|
||||
}
|
||||
wasAwaitingInput = nowAwaitingInput
|
||||
})
|
||||
|
||||
// Build command context
|
||||
const commandContext = {
|
||||
session,
|
||||
fmt: formatter,
|
||||
logger,
|
||||
config,
|
||||
controller,
|
||||
}
|
||||
|
||||
// Handle line input
|
||||
rl.on("line", async (line: string) => {
|
||||
const input = line.trim()
|
||||
|
||||
if (!input) {
|
||||
// Empty input - just show prompt again
|
||||
await updatePromptString()
|
||||
showPrompt()
|
||||
return
|
||||
}
|
||||
|
||||
// Check for chat commands
|
||||
if (input.startsWith("/")) {
|
||||
await processSlashCommand(input, commandContext)
|
||||
if (!session.isRunning) {
|
||||
rl.close()
|
||||
return
|
||||
}
|
||||
// Commands complete immediately, show prompt
|
||||
await updatePromptString()
|
||||
showPrompt()
|
||||
return
|
||||
}
|
||||
|
||||
// Handle approval shortcuts
|
||||
if (session.awaitingApproval) {
|
||||
const lowerInput = input.toLowerCase()
|
||||
|
||||
// Check for "don't ask again" approval (yy, yes!, or approve!)
|
||||
const isAutoApprove = lowerInput === "yy" || lowerInput === "yes!" || lowerInput === "approve!"
|
||||
if (isAutoApprove) {
|
||||
if (controller.task && session.pendingAskMessage) {
|
||||
// Determine which auto-approval action to enable
|
||||
const actionKey = determineAutoApprovalAction(session.pendingAskMessage)
|
||||
if (actionKey) {
|
||||
// Enable auto-approval for this action type
|
||||
const currentAutoApproval = controller.stateManager.getGlobalSettingsKey("autoApprovalSettings")
|
||||
const updatedActions = {
|
||||
...currentAutoApproval.actions,
|
||||
[actionKey]: true,
|
||||
}
|
||||
controller.stateManager.setTaskSettings(session.taskId!, "autoApprovalSettings", {
|
||||
...currentAutoApproval,
|
||||
actions: updatedActions,
|
||||
})
|
||||
formatter.info(`Auto-approval enabled for ${actionKey}`)
|
||||
}
|
||||
|
||||
setProcessingState(true) // AI will start processing
|
||||
wasAwaitingInput = false
|
||||
await controller.task.handleWebviewAskResponse("yesButtonClicked")
|
||||
session.awaitingApproval = false
|
||||
session.pendingAskMessage = null
|
||||
}
|
||||
// Don't show prompt - wait for AI to finish
|
||||
return
|
||||
}
|
||||
|
||||
if (lowerInput === "y" || lowerInput === "yes" || lowerInput === "approve") {
|
||||
if (controller.task) {
|
||||
setProcessingState(true) // AI will start processing
|
||||
wasAwaitingInput = false
|
||||
await controller.task.handleWebviewAskResponse("yesButtonClicked")
|
||||
session.awaitingApproval = false
|
||||
session.pendingAskMessage = null
|
||||
}
|
||||
// Don't show prompt - wait for AI to finish
|
||||
return
|
||||
}
|
||||
if (lowerInput === "n" || lowerInput === "no" || lowerInput === "deny") {
|
||||
if (controller.task) {
|
||||
setProcessingState(true) // AI will start processing
|
||||
wasAwaitingInput = false
|
||||
await controller.task.handleWebviewAskResponse("noButtonClicked")
|
||||
session.awaitingApproval = false
|
||||
session.pendingAskMessage = null
|
||||
}
|
||||
// Don't show prompt - wait for AI to finish
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// If no active task, start a new one
|
||||
if (!session.taskId) {
|
||||
// Parse @path references from the input
|
||||
const parsed = parseAtPaths(input, process.cwd())
|
||||
|
||||
// Show warnings for any files that couldn't be processed
|
||||
for (const warning of parsed.warnings) {
|
||||
formatter.warn(warning)
|
||||
}
|
||||
|
||||
// Log attachment info
|
||||
if (parsed.files.length > 0) {
|
||||
formatter.info(`Attaching ${parsed.files.length} file(s)`)
|
||||
}
|
||||
if (parsed.images.length > 0) {
|
||||
formatter.info(`Attaching ${parsed.images.length} image(s)`)
|
||||
}
|
||||
|
||||
setProcessingState(true) // AI will start processing
|
||||
wasAwaitingInput = false
|
||||
session.taskId = await controller.initTask(
|
||||
parsed.cleanedMessage,
|
||||
parsed.images.length > 0 ? parsed.images : undefined,
|
||||
parsed.files.length > 0 ? parsed.files : undefined,
|
||||
)
|
||||
formatter.info(`Started task: ${session.taskId}`)
|
||||
session.adapter?.resetMessageCounter()
|
||||
// Don't show prompt - wait for AI to finish
|
||||
} else if (controller.task) {
|
||||
// Check if input is a numbered option selection
|
||||
let messageToSend = input
|
||||
let imagesToSend: string[] | undefined
|
||||
let filesToSend: string[] | undefined
|
||||
|
||||
if (session.awaitingInput && session.adapter) {
|
||||
const options = session.adapter.currentOptions
|
||||
const num = parseInt(input, 10)
|
||||
if (!Number.isNaN(num) && num >= 1 && num <= options.length) {
|
||||
messageToSend = options[num - 1]
|
||||
}
|
||||
}
|
||||
|
||||
// Parse @path references from the input (unless it's a numbered option)
|
||||
if (messageToSend === input) {
|
||||
const parsed = parseAtPaths(input, process.cwd())
|
||||
|
||||
// Show warnings for any files that couldn't be processed
|
||||
for (const warning of parsed.warnings) {
|
||||
formatter.warn(warning)
|
||||
}
|
||||
|
||||
// Log attachment info
|
||||
if (parsed.files.length > 0) {
|
||||
formatter.info(`Attaching ${parsed.files.length} file(s)`)
|
||||
}
|
||||
if (parsed.images.length > 0) {
|
||||
formatter.info(`Attaching ${parsed.images.length} image(s)`)
|
||||
}
|
||||
|
||||
messageToSend = parsed.cleanedMessage
|
||||
imagesToSend = parsed.images.length > 0 ? parsed.images : undefined
|
||||
filesToSend = parsed.files.length > 0 ? parsed.files : undefined
|
||||
}
|
||||
|
||||
setProcessingState(true) // AI will start processing
|
||||
wasAwaitingInput = false
|
||||
// Send message to existing task with any attachments
|
||||
await controller.task.handleWebviewAskResponse("messageResponse", messageToSend, imagesToSend, filesToSend)
|
||||
// Don't show prompt - wait for AI to finish
|
||||
}
|
||||
})
|
||||
|
||||
// Handle close
|
||||
rl.on("close", async () => {
|
||||
formatter.raw("")
|
||||
formatter.info("Chat session ended")
|
||||
|
||||
// Reset yolo mode settings if they were enabled for this session
|
||||
if (session.yoloMode) {
|
||||
controller.stateManager.setGlobalState("yoloModeToggled", false)
|
||||
// Reset maxConsecutiveMistakes to default
|
||||
controller.stateManager.setGlobalState("maxConsecutiveMistakes", 3)
|
||||
}
|
||||
|
||||
// Stop listening and cleanup
|
||||
session.adapter?.stopListening()
|
||||
await disposeEmbeddedController(logger)
|
||||
|
||||
process.exit(0)
|
||||
})
|
||||
|
||||
// Handle Ctrl+C
|
||||
rl.on("SIGINT", () => {
|
||||
formatter.raw("")
|
||||
formatter.info("Chat session ended (interrupted)")
|
||||
rl.close()
|
||||
})
|
||||
|
||||
// Start prompt with current state (only if not already processing)
|
||||
await updatePromptString()
|
||||
if (!isProcessing) {
|
||||
showPrompt()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Display the welcome message
|
||||
*/
|
||||
async function displayWelcome(formatter: OutputFormatter, session: ChatSession, controller: Controller): Promise<void> {
|
||||
formatter.raw("")
|
||||
formatter.info("═".repeat(60))
|
||||
if (session.yoloMode) {
|
||||
formatter.info(" Cline Interactive Chat Mode [YOLO]")
|
||||
} else {
|
||||
formatter.info(" Cline Interactive Chat Mode")
|
||||
}
|
||||
formatter.info("═".repeat(60))
|
||||
if (session.taskId) {
|
||||
formatter.info(`Task: ${session.taskId}`)
|
||||
}
|
||||
const state = await controller.getStateToPostToWebview()
|
||||
formatter.info(`Mode: ${state.mode || "act"}`)
|
||||
if (session.yoloMode) {
|
||||
formatter.info("YOLO: Auto-approving all actions (5min timeout, 3 retries max)")
|
||||
}
|
||||
formatter.raw("")
|
||||
if (!session.yoloMode) {
|
||||
formatter.info("Type your message and press Enter to send.")
|
||||
formatter.info("Use @path to attach files (e.g., @./file.txt, @image.png)")
|
||||
formatter.info("Press Tab to toggle between act/plan mode or complete @paths.")
|
||||
formatter.info("Type /help for available commands, /quit to exit.")
|
||||
}
|
||||
formatter.raw("─".repeat(60))
|
||||
formatter.raw("")
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* Chat session state management
|
||||
*
|
||||
* Defines the session interface and factory function.
|
||||
*/
|
||||
|
||||
import type { ClineMessage } from "@shared/ExtensionMessage"
|
||||
import type { CliWebviewAdapter } from "../../../core/cli-webview-adapter.js"
|
||||
|
||||
/**
|
||||
* Chat session state
|
||||
*/
|
||||
export interface ChatSession {
|
||||
taskId: string | null
|
||||
isRunning: boolean
|
||||
awaitingApproval: boolean
|
||||
awaitingInput: boolean
|
||||
adapter: CliWebviewAdapter | null
|
||||
yoloMode: boolean
|
||||
yoloFailureCount: number
|
||||
yoloLastFailedAction: string | null
|
||||
yoloActionStartTime: number | null
|
||||
yoloCompleted: boolean
|
||||
/** The current pending ask message (for determining auto-approval action type) */
|
||||
pendingAskMessage: ClineMessage | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new chat session with default state
|
||||
*/
|
||||
export function createSession(yoloMode = false): ChatSession {
|
||||
return {
|
||||
taskId: null,
|
||||
isRunning: true,
|
||||
awaitingApproval: false,
|
||||
awaitingInput: false,
|
||||
adapter: null,
|
||||
yoloMode,
|
||||
yoloFailureCount: 0,
|
||||
yoloLastFailedAction: null,
|
||||
yoloActionStartTime: null,
|
||||
yoloCompleted: false,
|
||||
pendingAskMessage: null,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* Checkpoints command handler - list available checkpoints
|
||||
*/
|
||||
|
||||
import { formatCheckpointList } from "../../restore.js"
|
||||
import type { CommandContext, CommandHandler } from "./types.js"
|
||||
|
||||
/**
|
||||
* Handle /checkpoints command - list available checkpoints in current task
|
||||
*/
|
||||
export const handleCheckpoints: CommandHandler = async (_args: string[], ctx: CommandContext): Promise<boolean> => {
|
||||
if (!ctx.controller.task) {
|
||||
ctx.fmt.warn("No active task")
|
||||
return true
|
||||
}
|
||||
|
||||
const messages = ctx.controller.task.messageStateHandler.getClineMessages()
|
||||
const checkpoints = formatCheckpointList(messages)
|
||||
|
||||
if (checkpoints.length === 0) {
|
||||
ctx.fmt.info("No checkpoints found in current task")
|
||||
return true
|
||||
}
|
||||
|
||||
ctx.fmt.info(`Checkpoints (${checkpoints.length}):\n`)
|
||||
|
||||
const idWidth = 16
|
||||
const timeWidth = 16
|
||||
const wsWidth = 12
|
||||
|
||||
const header = "ID".padEnd(idWidth) + "Time".padEnd(timeWidth) + "Workspace".padEnd(wsWidth) + "Context"
|
||||
ctx.fmt.raw(header)
|
||||
ctx.fmt.raw("-".repeat(header.length + 30))
|
||||
|
||||
for (const cp of checkpoints) {
|
||||
const row =
|
||||
String(cp.id).padEnd(idWidth) +
|
||||
cp.timeAgo.padEnd(timeWidth) +
|
||||
(cp.hasWorkspaceRestore ? "Yes" : "No").padEnd(wsWidth) +
|
||||
cp.context
|
||||
ctx.fmt.raw(row)
|
||||
}
|
||||
|
||||
ctx.fmt.raw("")
|
||||
ctx.fmt.info('Use "/restore <checkpoint-id>" to restore')
|
||||
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* Config command handler
|
||||
*/
|
||||
|
||||
import fs from "fs"
|
||||
import path from "path"
|
||||
import { getNestedValue, parseValue, setNestedValue } from "../../../config/index.js"
|
||||
import type { CommandContext, CommandHandler } from "./types.js"
|
||||
|
||||
/**
|
||||
* Handle /config, /cfg commands
|
||||
*/
|
||||
export const handleConfig: CommandHandler = async (args: string[], ctx: CommandContext): Promise<boolean> => {
|
||||
const subCmd = args[0]?.toLowerCase()
|
||||
const configKey = args[1]
|
||||
const configValue = args.slice(2).join(" ")
|
||||
|
||||
if (!subCmd || subCmd === "list" || subCmd === "ls") {
|
||||
// List all config values
|
||||
try {
|
||||
const configDir = ctx.config.configDir || `${process.env.HOME}/.cline`
|
||||
const globalStatePath = path.join(configDir, "data", "globalState.json")
|
||||
|
||||
if (fs.existsSync(globalStatePath)) {
|
||||
const content = fs.readFileSync(globalStatePath, "utf-8")
|
||||
const allSettings = JSON.parse(content)
|
||||
ctx.fmt.raw("")
|
||||
ctx.fmt.raw(JSON.stringify(allSettings, null, 2))
|
||||
ctx.fmt.raw("")
|
||||
} else {
|
||||
ctx.fmt.info("No configuration file found")
|
||||
}
|
||||
} catch (err) {
|
||||
ctx.fmt.error(`Failed to list config: ${(err as Error).message}`)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
if (subCmd === "get") {
|
||||
if (!configKey) {
|
||||
ctx.fmt.error("Usage: /config get <key>")
|
||||
return true
|
||||
}
|
||||
|
||||
try {
|
||||
let value: unknown
|
||||
|
||||
if (configKey.includes(".")) {
|
||||
// For nested paths, get the root object first
|
||||
const rootKey = configKey.split(".")[0]
|
||||
let rootValue = ctx.controller.stateManager.getGlobalSettingsKey(rootKey as any)
|
||||
if (rootValue === undefined) {
|
||||
rootValue = ctx.controller.stateManager.getGlobalStateKey(rootKey as any)
|
||||
}
|
||||
|
||||
if (rootValue !== undefined && typeof rootValue === "object") {
|
||||
value = getNestedValue({ [rootKey]: rootValue }, configKey)
|
||||
}
|
||||
} else {
|
||||
value = ctx.controller.stateManager.getGlobalSettingsKey(configKey as any)
|
||||
if (value === undefined) {
|
||||
value = ctx.controller.stateManager.getGlobalStateKey(configKey as any)
|
||||
}
|
||||
}
|
||||
|
||||
if (value === undefined) {
|
||||
ctx.fmt.info(`${configKey} is not set`)
|
||||
} else {
|
||||
const displayValue = typeof value === "object" ? JSON.stringify(value, null, 2) : String(value)
|
||||
ctx.fmt.keyValue({ [configKey]: displayValue })
|
||||
}
|
||||
} catch (err) {
|
||||
ctx.fmt.error(`Failed to get config: ${(err as Error).message}`)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
if (subCmd === "set") {
|
||||
if (!configKey || !configValue) {
|
||||
ctx.fmt.error("Usage: /config set <key> <value>")
|
||||
return true
|
||||
}
|
||||
|
||||
try {
|
||||
const parsedValue = parseValue(configKey, configValue)
|
||||
|
||||
if (configKey.includes(".")) {
|
||||
// For nested paths, get the current root object, modify it, and save the whole thing
|
||||
const rootKey = configKey.split(".")[0]
|
||||
let rootValue = ctx.controller.stateManager.getGlobalSettingsKey(rootKey as any)
|
||||
if (rootValue === undefined) {
|
||||
rootValue = ctx.controller.stateManager.getGlobalStateKey(rootKey as any)
|
||||
}
|
||||
|
||||
const currentRoot = rootValue !== undefined && typeof rootValue === "object" ? rootValue : {}
|
||||
const { rootValue: newRootValue } = setNestedValue({ [rootKey]: currentRoot }, configKey, parsedValue)
|
||||
|
||||
ctx.controller.stateManager.setGlobalState(rootKey as any, newRootValue as any)
|
||||
} else {
|
||||
ctx.controller.stateManager.setGlobalState(configKey as any, parsedValue as any)
|
||||
}
|
||||
|
||||
await ctx.controller.stateManager.flushPendingState()
|
||||
ctx.fmt.success(`Set ${configKey} = ${String(parsedValue)}`)
|
||||
} catch (err) {
|
||||
ctx.fmt.error(`Failed to set config: ${(err as Error).message}`)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
if (subCmd === "delete" || subCmd === "rm") {
|
||||
if (!configKey) {
|
||||
ctx.fmt.error("Usage: /config delete <key>")
|
||||
return true
|
||||
}
|
||||
|
||||
try {
|
||||
ctx.controller.stateManager.setGlobalState(configKey as any, undefined)
|
||||
await ctx.controller.stateManager.flushPendingState()
|
||||
ctx.fmt.success(`Reset ${configKey} to default`)
|
||||
} catch (err) {
|
||||
ctx.fmt.error(`Failed to delete config: ${(err as Error).message}`)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
ctx.fmt.error(`Unknown config subcommand: ${subCmd}`)
|
||||
ctx.fmt.raw("Usage: /config <list|get|set|delete> [key] [value]")
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* Help command handler
|
||||
*/
|
||||
|
||||
import type { CommandContext, CommandHandler } from "./types.js"
|
||||
|
||||
/**
|
||||
* Handle /help, /h, /? commands
|
||||
*/
|
||||
export const handleHelp: CommandHandler = async (_args: string[], ctx: CommandContext): Promise<boolean> => {
|
||||
ctx.fmt.raw("")
|
||||
ctx.fmt.info("Chat commands:")
|
||||
ctx.fmt.raw(" /help, /h, /? - Show this help")
|
||||
ctx.fmt.raw(" /plan - Switch to plan mode")
|
||||
ctx.fmt.raw(" /act - Switch to act mode")
|
||||
ctx.fmt.raw(" /mode <plan|act> - Switch mode")
|
||||
ctx.fmt.raw(" /model - Show current model")
|
||||
ctx.fmt.raw(" /model <id> - Set model for current mode")
|
||||
ctx.fmt.raw(" /model list - List available models (OpenRouter/Cline)")
|
||||
ctx.fmt.raw(" /status, /s - Show task status")
|
||||
ctx.fmt.raw(" /usage, /u - Show token usage and cost")
|
||||
ctx.fmt.raw(" /cancel - Cancel current task")
|
||||
ctx.fmt.raw(" /approve, /a, /y - Approve pending action")
|
||||
ctx.fmt.raw(" /deny, /d, /n - Deny pending action")
|
||||
ctx.fmt.raw(" /checkpoints, /cp - List available checkpoints")
|
||||
ctx.fmt.raw(" /restore, /r <id> [type] - Restore to checkpoint")
|
||||
ctx.fmt.raw(" types: task (default), workspace, taskAndWorkspace")
|
||||
ctx.fmt.raw(" /config, /cfg - Manage configuration")
|
||||
ctx.fmt.raw(" /config list - List all configuration values")
|
||||
ctx.fmt.raw(" /config get <key> - Get a config value")
|
||||
ctx.fmt.raw(" /config set <key> <value> - Set a config value")
|
||||
ctx.fmt.raw(" /config delete <key> - Reset a config value")
|
||||
ctx.fmt.raw(" /quit, /q, /exit - Exit chat mode")
|
||||
ctx.fmt.raw("")
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* Command dispatcher for chat REPL
|
||||
*
|
||||
* Maps command names to their handlers and dispatches incoming commands.
|
||||
*/
|
||||
|
||||
import { handleCheckpoints } from "./checkpoints.js"
|
||||
import { handleConfig } from "./config.js"
|
||||
import { handleHelp } from "./help.js"
|
||||
import { handleAct, handleMode, handlePlan } from "./mode.js"
|
||||
import { handleModel } from "./model.js"
|
||||
import { handleQuit } from "./quit.js"
|
||||
import { handleRestore } from "./restore.js"
|
||||
import { handleStatus } from "./status.js"
|
||||
import { handleApprove, handleCancel, handleDeny } from "./task.js"
|
||||
import type { CommandContext, CommandHandler } from "./types.js"
|
||||
import { handleUsage } from "./usage.js"
|
||||
|
||||
/**
|
||||
* Map of command names to their handlers
|
||||
*/
|
||||
const handlers: Record<string, CommandHandler> = {
|
||||
// Help
|
||||
help: handleHelp,
|
||||
h: handleHelp,
|
||||
"?": handleHelp,
|
||||
|
||||
// Mode
|
||||
plan: handlePlan,
|
||||
act: handleAct,
|
||||
mode: handleMode,
|
||||
m: handleMode,
|
||||
|
||||
// Model
|
||||
model: handleModel,
|
||||
|
||||
// Status
|
||||
status: handleStatus,
|
||||
s: handleStatus,
|
||||
|
||||
// Task control
|
||||
cancel: handleCancel,
|
||||
approve: handleApprove,
|
||||
a: handleApprove,
|
||||
y: handleApprove,
|
||||
deny: handleDeny,
|
||||
d: handleDeny,
|
||||
n: handleDeny,
|
||||
|
||||
// Config
|
||||
config: handleConfig,
|
||||
cfg: handleConfig,
|
||||
|
||||
// Usage
|
||||
usage: handleUsage,
|
||||
u: handleUsage,
|
||||
|
||||
// Quit
|
||||
quit: handleQuit,
|
||||
q: handleQuit,
|
||||
exit: handleQuit,
|
||||
|
||||
// Checkpoints
|
||||
checkpoints: handleCheckpoints,
|
||||
cp: handleCheckpoints,
|
||||
restore: handleRestore,
|
||||
r: handleRestore,
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a chat command (input starting with /)
|
||||
*
|
||||
* @param input - Full command input including the leading /
|
||||
* @param ctx - Command context
|
||||
* @returns true if the command was handled
|
||||
*/
|
||||
export async function processSlashCommand(input: string, ctx: CommandContext): Promise<boolean> {
|
||||
const parts = input.slice(1).split(/\s+/)
|
||||
const cmd = parts[0].toLowerCase()
|
||||
const args = parts.slice(1)
|
||||
|
||||
const handler = handlers[cmd]
|
||||
if (!handler) {
|
||||
ctx.fmt.warn(`Unknown command: /${cmd}. Type /help for available commands.`)
|
||||
return true
|
||||
}
|
||||
|
||||
return handler(args, ctx)
|
||||
}
|
||||
|
||||
// Re-export types for convenience
|
||||
export type { CommandContext, CommandHandler } from "./types.js"
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* Mode command handlers
|
||||
*/
|
||||
|
||||
import type { CommandContext, CommandHandler } from "./types.js"
|
||||
|
||||
/**
|
||||
* Handle /plan command
|
||||
*/
|
||||
export const handlePlan: CommandHandler = async (_args: string[], ctx: CommandContext): Promise<boolean> => {
|
||||
await ctx.controller.togglePlanActMode("plan")
|
||||
ctx.fmt.success("Switched to plan mode")
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle /act command
|
||||
*/
|
||||
export const handleAct: CommandHandler = async (_args: string[], ctx: CommandContext): Promise<boolean> => {
|
||||
await ctx.controller.togglePlanActMode("act")
|
||||
ctx.fmt.success("Switched to act mode")
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle /mode command
|
||||
*/
|
||||
export const handleMode: CommandHandler = async (args: string[], ctx: CommandContext): Promise<boolean> => {
|
||||
if (args.length === 0) {
|
||||
const state = await ctx.controller.getStateToPostToWebview()
|
||||
ctx.fmt.info(`Current mode: ${state.mode || "unknown"}`)
|
||||
} else {
|
||||
const newMode = args[0].toLowerCase()
|
||||
if (newMode !== "plan" && newMode !== "act") {
|
||||
ctx.fmt.error("Invalid mode. Use 'plan' or 'act'")
|
||||
} else {
|
||||
await ctx.controller.togglePlanActMode(newMode as "plan" | "act")
|
||||
ctx.fmt.success(`Switched to ${newMode} mode`)
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* Model command handler
|
||||
*/
|
||||
|
||||
import type { Mode } from "@shared/storage/types"
|
||||
import { getModelIdForProvider, getModelIdKey } from "../model-utils.js"
|
||||
import type { CommandContext, CommandHandler } from "./types.js"
|
||||
|
||||
/**
|
||||
* Handle /model command
|
||||
*/
|
||||
export const handleModel: CommandHandler = async (args: string[], ctx: CommandContext): Promise<boolean> => {
|
||||
const state = await ctx.controller.getStateToPostToWebview()
|
||||
const currentMode: Mode = (state.mode as Mode) || "act"
|
||||
const apiConfig = state.apiConfiguration
|
||||
|
||||
// Get current provider for this mode
|
||||
const provider = currentMode === "plan" ? apiConfig?.planModeApiProvider : apiConfig?.actModeApiProvider
|
||||
|
||||
const subCmd = args[0]?.toLowerCase()
|
||||
|
||||
if (!subCmd) {
|
||||
// Show current model
|
||||
const modelId = getModelIdForProvider(apiConfig, provider, currentMode)
|
||||
ctx.fmt.raw("")
|
||||
ctx.fmt.info(`Mode: ${currentMode}`)
|
||||
ctx.fmt.info(`Provider: ${provider || "(not set)"}`)
|
||||
ctx.fmt.info(`Model: ${modelId || "(not set)"}`)
|
||||
ctx.fmt.raw("")
|
||||
return true
|
||||
}
|
||||
|
||||
if (subCmd === "list") {
|
||||
// Fetch models from OpenRouter if applicable
|
||||
if (provider === "openrouter" || provider === "cline") {
|
||||
ctx.fmt.info("Fetching models from OpenRouter...")
|
||||
try {
|
||||
const response = await fetch("https://openrouter.ai/api/v1/models")
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`)
|
||||
}
|
||||
const data = (await response.json()) as {
|
||||
data?: Array<{
|
||||
id: string
|
||||
name?: string
|
||||
pricing?: { prompt?: string; completion?: string }
|
||||
}>
|
||||
}
|
||||
const models = (data.data || []).sort((a, b) => a.id.localeCompare(b.id))
|
||||
|
||||
ctx.fmt.raw("")
|
||||
ctx.fmt.info(`Available models (${models.length} total):`)
|
||||
ctx.fmt.raw("")
|
||||
|
||||
// Show all models with pricing info (alphabetized)
|
||||
for (const model of models) {
|
||||
const promptPrice = model.pricing?.prompt
|
||||
? `$${(parseFloat(model.pricing.prompt) * 1_000_000).toFixed(2)}/M`
|
||||
: "N/A"
|
||||
const completionPrice = model.pricing?.completion
|
||||
? `$${(parseFloat(model.pricing.completion) * 1_000_000).toFixed(2)}/M`
|
||||
: "N/A"
|
||||
ctx.fmt.raw(` ${model.id}`)
|
||||
ctx.fmt.raw(` Input: ${promptPrice}, Output: ${completionPrice}`)
|
||||
}
|
||||
|
||||
ctx.fmt.raw("")
|
||||
ctx.fmt.info("Use '/model <model-id>' to set the model")
|
||||
ctx.fmt.raw("")
|
||||
} catch (err) {
|
||||
ctx.fmt.error(`Failed to fetch models: ${(err as Error).message}`)
|
||||
}
|
||||
} else {
|
||||
ctx.fmt.warn(`Model listing not available for provider: ${provider || "none"}`)
|
||||
ctx.fmt.info("Model listing is only supported for OpenRouter and Cline providers.")
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// Set model - args is the model ID (may contain slashes like "anthropic/claude-3")
|
||||
const newModelId = args.join(" ")
|
||||
|
||||
if (!provider) {
|
||||
ctx.fmt.error("No provider configured for current mode.")
|
||||
ctx.fmt.info("Run 'cline auth' to configure a provider first.")
|
||||
return true
|
||||
}
|
||||
|
||||
const modelIdKey = getModelIdKey(provider, currentMode)
|
||||
ctx.controller.stateManager.setGlobalState(modelIdKey as any, newModelId)
|
||||
await ctx.controller.stateManager.flushPendingState()
|
||||
ctx.fmt.success(`Set ${currentMode} mode model to: ${newModelId}`)
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* Quit command handler
|
||||
*/
|
||||
|
||||
import type { CommandContext, CommandHandler } from "./types.js"
|
||||
|
||||
/**
|
||||
* Handle /quit, /q, /exit commands
|
||||
*/
|
||||
export const handleQuit: CommandHandler = async (_args: string[], ctx: CommandContext): Promise<boolean> => {
|
||||
ctx.session.isRunning = false
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
/**
|
||||
* Restore command handler - restore task to a checkpoint
|
||||
*/
|
||||
|
||||
import { validateCheckpoint } from "../../restore.js"
|
||||
import { handleCheckpoints } from "./checkpoints.js"
|
||||
import type { CommandContext, CommandHandler } from "./types.js"
|
||||
|
||||
/** Valid restore types */
|
||||
type RestoreType = "task" | "workspace" | "taskAndWorkspace"
|
||||
|
||||
const VALID_RESTORE_TYPES: RestoreType[] = ["task", "workspace", "taskAndWorkspace"]
|
||||
|
||||
/**
|
||||
* Get relative time string (e.g., "2 hours ago")
|
||||
*/
|
||||
function getTimeAgo(timestamp: number): string {
|
||||
const now = Date.now()
|
||||
const diff = now - timestamp
|
||||
const seconds = Math.floor(diff / 1000)
|
||||
const minutes = Math.floor(seconds / 60)
|
||||
const hours = Math.floor(minutes / 60)
|
||||
const days = Math.floor(hours / 24)
|
||||
|
||||
if (days > 0) {
|
||||
return days === 1 ? "1 day ago" : `${days} days ago`
|
||||
}
|
||||
if (hours > 0) {
|
||||
return hours === 1 ? "1 hour ago" : `${hours} hours ago`
|
||||
}
|
||||
if (minutes > 0) {
|
||||
return minutes === 1 ? "1 minute ago" : `${minutes} minutes ago`
|
||||
}
|
||||
return "just now"
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle /restore command - restore task to a checkpoint
|
||||
*
|
||||
* Usage:
|
||||
* /restore <checkpoint-id> [type]
|
||||
* /restore list - List available checkpoints (alias for /checkpoints)
|
||||
*
|
||||
* Types:
|
||||
* task - Restore conversation only (default)
|
||||
* workspace - Restore files only
|
||||
* taskAndWorkspace - Restore both
|
||||
*/
|
||||
export const handleRestore: CommandHandler = async (args: string[], ctx: CommandContext): Promise<boolean> => {
|
||||
// Handle "list" subcommand
|
||||
if (args[0] === "list" || args[0] === "ls") {
|
||||
return handleCheckpoints(args.slice(1), ctx)
|
||||
}
|
||||
|
||||
// Check for active task
|
||||
if (!ctx.controller.task) {
|
||||
ctx.fmt.warn("No active task")
|
||||
return true
|
||||
}
|
||||
|
||||
// Validate arguments
|
||||
if (args.length === 0) {
|
||||
ctx.fmt.warn("Usage: /restore <checkpoint-id> [type]")
|
||||
ctx.fmt.info(" checkpoint-id: The timestamp ID of the checkpoint")
|
||||
ctx.fmt.info(" type: task (default), workspace, or taskAndWorkspace")
|
||||
ctx.fmt.info("")
|
||||
ctx.fmt.info('Use "/checkpoints" or "/restore list" to see available checkpoints')
|
||||
return true
|
||||
}
|
||||
|
||||
// Parse checkpoint ID
|
||||
const checkpointIdArg = args[0]
|
||||
const checkpointId = parseInt(checkpointIdArg, 10)
|
||||
if (isNaN(checkpointId)) {
|
||||
ctx.fmt.error(`Invalid checkpoint ID: "${checkpointIdArg}". Must be a number (timestamp).`)
|
||||
return true
|
||||
}
|
||||
|
||||
// Parse restore type (default: task)
|
||||
let restoreType: RestoreType = "task"
|
||||
if (args[1]) {
|
||||
const providedType = args[1].toLowerCase()
|
||||
if (!VALID_RESTORE_TYPES.includes(providedType as RestoreType)) {
|
||||
ctx.fmt.error(`Invalid restore type: "${args[1]}". Valid options: ${VALID_RESTORE_TYPES.join(", ")}`)
|
||||
return true
|
||||
}
|
||||
restoreType = providedType as RestoreType
|
||||
}
|
||||
|
||||
// Get messages and validate checkpoint exists
|
||||
const messages = ctx.controller.task.messageStateHandler.getClineMessages()
|
||||
const checkpoint = validateCheckpoint(messages, checkpointId)
|
||||
|
||||
if (!checkpoint) {
|
||||
// Check if the timestamp exists but is not a checkpoint
|
||||
const anyMessage = messages.find((m) => m.ts === checkpointId)
|
||||
if (anyMessage) {
|
||||
ctx.fmt.error(`Timestamp ${checkpointId} exists but is not a checkpoint (type: ${anyMessage.say || anyMessage.ask})`)
|
||||
} else {
|
||||
ctx.fmt.error(`Checkpoint ${checkpointId} not found in task history`)
|
||||
}
|
||||
ctx.fmt.info('Use "/checkpoints" to see available checkpoints')
|
||||
return true
|
||||
}
|
||||
|
||||
// Check if workspace restore is possible
|
||||
if ((restoreType === "workspace" || restoreType === "taskAndWorkspace") && !checkpoint.lastCheckpointHash) {
|
||||
ctx.fmt.warn("Warning: This checkpoint does not have workspace restore data.")
|
||||
if (restoreType === "workspace") {
|
||||
ctx.fmt.error("Cannot restore workspace: no checkpoint hash available")
|
||||
return true
|
||||
}
|
||||
ctx.fmt.info("Falling back to task-only restore.")
|
||||
restoreType = "task"
|
||||
}
|
||||
|
||||
// Perform the restore
|
||||
ctx.fmt.info(`Restoring to checkpoint ${checkpointId} (${getTimeAgo(checkpointId)})...`)
|
||||
ctx.fmt.info(`Restore type: ${restoreType}`)
|
||||
|
||||
try {
|
||||
// Cancel any active task first (required before restore)
|
||||
await ctx.controller.cancelTask()
|
||||
|
||||
// Call restoreCheckpoint on the checkpoint manager
|
||||
const checkpointManager = ctx.controller.task?.checkpointManager
|
||||
if (!checkpointManager) {
|
||||
ctx.fmt.error("Checkpoint manager not available")
|
||||
return true
|
||||
}
|
||||
|
||||
await checkpointManager.restoreCheckpoint(checkpointId, restoreType)
|
||||
|
||||
ctx.fmt.success("Checkpoint restored successfully")
|
||||
|
||||
// Show post-restore state
|
||||
const newMessages = ctx.controller.task?.messageStateHandler.getClineMessages() || []
|
||||
ctx.fmt.info(`Task now has ${newMessages.length} messages`)
|
||||
} catch (error) {
|
||||
ctx.fmt.error(`Failed to restore checkpoint: ${(error as Error).message}`)
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* Status command handler
|
||||
*/
|
||||
|
||||
import type { CommandContext, CommandHandler } from "./types.js"
|
||||
|
||||
/**
|
||||
* Handle /status, /s commands
|
||||
*/
|
||||
export const handleStatus: CommandHandler = async (_args: string[], ctx: CommandContext): Promise<boolean> => {
|
||||
const state = await ctx.controller.getStateToPostToWebview()
|
||||
ctx.fmt.raw("")
|
||||
ctx.fmt.info(`Task ID: ${ctx.session.taskId || "none"}`)
|
||||
ctx.fmt.info(`Mode: ${state.mode || "unknown"}`)
|
||||
ctx.fmt.info(`Messages: ${state.clineMessages?.length || 0}`)
|
||||
if (ctx.session.awaitingApproval) {
|
||||
ctx.fmt.warn("Awaiting approval (use /approve or /deny)")
|
||||
}
|
||||
if (ctx.session.awaitingInput) {
|
||||
ctx.fmt.warn("Awaiting user input")
|
||||
}
|
||||
ctx.fmt.raw("")
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* Task-related command handlers (cancel, approve, deny)
|
||||
*/
|
||||
|
||||
import type { CommandContext, CommandHandler } from "./types.js"
|
||||
|
||||
/**
|
||||
* Handle /cancel command
|
||||
*/
|
||||
export const handleCancel: CommandHandler = async (_args: string[], ctx: CommandContext): Promise<boolean> => {
|
||||
if (ctx.controller.task) {
|
||||
await ctx.controller.cancelTask()
|
||||
ctx.fmt.success("Task cancelled")
|
||||
} else {
|
||||
ctx.fmt.warn("No active task to cancel")
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle /approve, /a, /y commands
|
||||
*/
|
||||
export const handleApprove: CommandHandler = async (_args: string[], ctx: CommandContext): Promise<boolean> => {
|
||||
if (!ctx.session.awaitingApproval) {
|
||||
ctx.fmt.warn("No pending approval request")
|
||||
} else if (ctx.controller.task) {
|
||||
await ctx.controller.task.handleWebviewAskResponse("yesButtonClicked")
|
||||
ctx.session.awaitingApproval = false
|
||||
ctx.fmt.success("Action approved")
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle /deny, /d, /n commands
|
||||
*/
|
||||
export const handleDeny: CommandHandler = async (_args: string[], ctx: CommandContext): Promise<boolean> => {
|
||||
if (!ctx.session.awaitingApproval) {
|
||||
ctx.fmt.warn("No pending approval request")
|
||||
} else if (ctx.controller.task) {
|
||||
await ctx.controller.task.handleWebviewAskResponse("noButtonClicked")
|
||||
ctx.session.awaitingApproval = false
|
||||
ctx.fmt.success("Action denied")
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* Types for chat command handlers
|
||||
*/
|
||||
|
||||
import type { Controller } from "@/core/controller"
|
||||
import type { OutputFormatter } from "../../../../core/output/types.js"
|
||||
import type { CliConfig } from "../../../../types/config.js"
|
||||
import type { Logger } from "../../../../types/logger.js"
|
||||
import type { ChatSession } from "../session.js"
|
||||
|
||||
/**
|
||||
* Context passed to all command handlers
|
||||
*/
|
||||
export interface CommandContext {
|
||||
session: ChatSession
|
||||
fmt: OutputFormatter
|
||||
logger: Logger
|
||||
config: CliConfig
|
||||
controller: Controller
|
||||
}
|
||||
|
||||
/**
|
||||
* Handler function for a chat command
|
||||
* @param args - Arguments after the command name
|
||||
* @param ctx - Command context with session, formatter, etc.
|
||||
* @returns true if the command was handled (input should not be passed to AI)
|
||||
*/
|
||||
export type CommandHandler = (args: string[], ctx: CommandContext) => Promise<boolean>
|
||||
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* Usage command handler
|
||||
*
|
||||
* Displays token usage and cost for the current conversation
|
||||
*/
|
||||
|
||||
import type { ClineMessage } from "@shared/ExtensionMessage"
|
||||
import { getApiMetrics } from "@shared/getApiMetrics"
|
||||
import type { CommandContext, CommandHandler } from "./types.js"
|
||||
|
||||
/**
|
||||
* Count API requests from messages
|
||||
*/
|
||||
function countApiRequests(messages: ClineMessage[]): number {
|
||||
return messages.filter((msg) => msg.type === "say" && msg.say === "api_req_started").length
|
||||
}
|
||||
|
||||
/**
|
||||
* Format number with commas
|
||||
*/
|
||||
function formatNumber(n: number): string {
|
||||
return n.toLocaleString()
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle /usage, /u commands
|
||||
*/
|
||||
export const handleUsage: CommandHandler = async (_args: string[], ctx: CommandContext): Promise<boolean> => {
|
||||
// Get messages from the current session
|
||||
const messages = ctx.controller.task?.messageStateHandler.getClineMessages() || []
|
||||
|
||||
if (messages.length === 0) {
|
||||
ctx.fmt.warn("No messages in current conversation")
|
||||
return true
|
||||
}
|
||||
|
||||
const metrics = getApiMetrics(messages)
|
||||
const requestCount = countApiRequests(messages)
|
||||
|
||||
ctx.fmt.raw("")
|
||||
ctx.fmt.info("📊 Token Usage & Cost")
|
||||
ctx.fmt.raw("")
|
||||
ctx.fmt.raw(` Input tokens: ${formatNumber(metrics.totalTokensIn)}`)
|
||||
ctx.fmt.raw(` Output tokens: ${formatNumber(metrics.totalTokensOut)}`)
|
||||
ctx.fmt.raw(` Total tokens: ${formatNumber(metrics.totalTokensIn + metrics.totalTokensOut)}`)
|
||||
|
||||
// Show cache metrics if available
|
||||
if (metrics.totalCacheWrites !== undefined || metrics.totalCacheReads !== undefined) {
|
||||
ctx.fmt.raw("")
|
||||
ctx.fmt.raw(` Cache writes: ${formatNumber(metrics.totalCacheWrites ?? 0)}`)
|
||||
ctx.fmt.raw(` Cache reads: ${formatNumber(metrics.totalCacheReads ?? 0)}`)
|
||||
}
|
||||
|
||||
ctx.fmt.raw("")
|
||||
ctx.fmt.raw(` API requests: ${requestCount}`)
|
||||
ctx.fmt.raw(` Total cost: $${metrics.totalCost.toFixed(4)}`)
|
||||
ctx.fmt.raw("")
|
||||
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* Task dump command - output raw JSON of conversation messages
|
||||
*
|
||||
* This command outputs the raw JSON of a task's ClineMessages array,
|
||||
* useful for debugging or external processing.
|
||||
*/
|
||||
|
||||
import { getSavedClineMessages, readTaskHistoryFromState } from "@core/storage/disk"
|
||||
import { Command } from "commander"
|
||||
import { initializeHostProviderOnly } from "../../core/embedded-controller.js"
|
||||
import type { OutputFormatter } from "../../core/output/types.js"
|
||||
import type { CliConfig } from "../../types/config.js"
|
||||
import type { Logger } from "../../types/logger.js"
|
||||
|
||||
/**
|
||||
* Create the task dump command
|
||||
*/
|
||||
export function createTaskDumpCommand(config: CliConfig, logger: Logger, formatter: OutputFormatter): Command {
|
||||
const dumpCommand = new Command("dump")
|
||||
.alias("d")
|
||||
.description("Output raw JSON of task conversation messages")
|
||||
.argument("[taskId]", "Task ID to dump (defaults to current or most recent task)")
|
||||
.action(async (taskIdArg: string | undefined) => {
|
||||
logger.debug("Task dump command called", { taskIdArg })
|
||||
|
||||
try {
|
||||
// Initialize HostProvider only (lightweight, no full controller)
|
||||
initializeHostProviderOnly(logger, config.configDir)
|
||||
|
||||
// Read task history directly from disk
|
||||
const taskHistory = await readTaskHistoryFromState()
|
||||
|
||||
// Determine which task to dump
|
||||
let taskId = taskIdArg
|
||||
|
||||
if (taskId) {
|
||||
// Find task by ID (support partial ID match)
|
||||
const historyItem = taskHistory.find((t) => t.id === taskId || t.id.startsWith(taskId || ""))
|
||||
if (!historyItem) {
|
||||
throw new Error(`Task not found: ${taskId}`)
|
||||
}
|
||||
taskId = historyItem.id
|
||||
} else {
|
||||
// Use most recent task
|
||||
if (taskHistory.length > 0) {
|
||||
taskId = taskHistory[0].id
|
||||
} else {
|
||||
throw new Error("No tasks found. Create a task with 'cline task new'")
|
||||
}
|
||||
}
|
||||
|
||||
// Read messages directly from disk storage
|
||||
const messages = await getSavedClineMessages(taskId)
|
||||
formatter.raw(JSON.stringify(messages, null, 2))
|
||||
} catch (error) {
|
||||
formatter.error((error as Error).message)
|
||||
process.exit(1)
|
||||
}
|
||||
})
|
||||
|
||||
return dumpCommand
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* Task command group - manage Cline tasks
|
||||
*/
|
||||
|
||||
import { Command } from "commander"
|
||||
import type { OutputFormatter } from "../../core/output/types.js"
|
||||
import type { CliConfig } from "../../types/config.js"
|
||||
import type { Logger } from "../../types/logger.js"
|
||||
import { createTaskChatCommand } from "./chat/index.js"
|
||||
import { createTaskDumpCommand } from "./dump.js"
|
||||
import { createTaskListCommand } from "./list.js"
|
||||
import { createTaskRestoreCommand } from "./restore.js"
|
||||
import { createTaskSendCommand } from "./send.js"
|
||||
import { createTaskViewCommand } from "./view.js"
|
||||
|
||||
/**
|
||||
* Create the task command group
|
||||
*/
|
||||
export function createTaskCommand(config: CliConfig, logger: Logger, formatter: OutputFormatter): Command {
|
||||
const taskCommand = new Command("task").alias("t").description("Manage Cline tasks")
|
||||
|
||||
// Add subcommands
|
||||
taskCommand.addCommand(createTaskListCommand(config, logger, formatter))
|
||||
taskCommand.addCommand(createTaskChatCommand(config, logger, formatter))
|
||||
taskCommand.addCommand(createTaskSendCommand(config, logger, formatter))
|
||||
taskCommand.addCommand(createTaskViewCommand(config, logger, formatter))
|
||||
taskCommand.addCommand(createTaskRestoreCommand(config, logger, formatter))
|
||||
taskCommand.addCommand(createTaskDumpCommand(config, logger, formatter))
|
||||
|
||||
return taskCommand
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
/**
|
||||
* Task list command - list task history
|
||||
*
|
||||
* This command uses Cline's EmbeddedController to read task history directly,
|
||||
* ensuring CLI task list matches what the extension shows.
|
||||
*/
|
||||
|
||||
import { Command } from "commander"
|
||||
import { disposeEmbeddedController, getEmbeddedController } from "../../core/embedded-controller.js"
|
||||
import type { OutputFormatter } from "../../core/output/types.js"
|
||||
import type { CliConfig } from "../../types/config.js"
|
||||
import type { Logger } from "../../types/logger.js"
|
||||
|
||||
/**
|
||||
* Get relative time string (e.g., "2 hours ago")
|
||||
*/
|
||||
function getTimeAgo(timestamp: number): string {
|
||||
const now = Date.now()
|
||||
const diff = now - timestamp
|
||||
const seconds = Math.floor(diff / 1000)
|
||||
const minutes = Math.floor(seconds / 60)
|
||||
const hours = Math.floor(minutes / 60)
|
||||
const days = Math.floor(hours / 24)
|
||||
const weeks = Math.floor(days / 7)
|
||||
const months = Math.floor(days / 30)
|
||||
|
||||
if (months > 0) {
|
||||
return months === 1 ? "1 month ago" : `${months} months ago`
|
||||
}
|
||||
if (weeks > 0) {
|
||||
return weeks === 1 ? "1 week ago" : `${weeks} weeks ago`
|
||||
}
|
||||
if (days > 0) {
|
||||
return days === 1 ? "1 day ago" : `${days} days ago`
|
||||
}
|
||||
if (hours > 0) {
|
||||
return hours === 1 ? "1 hour ago" : `${hours} hours ago`
|
||||
}
|
||||
if (minutes > 0) {
|
||||
return minutes === 1 ? "1 minute ago" : `${minutes} minutes ago`
|
||||
}
|
||||
return "just now"
|
||||
}
|
||||
|
||||
/**
|
||||
* Truncate a string to a maximum length with ellipsis
|
||||
*/
|
||||
function truncate(str: string, maxLength: number): string {
|
||||
if (str.length <= maxLength) {
|
||||
return str
|
||||
}
|
||||
return str.slice(0, maxLength - 3) + "..."
|
||||
}
|
||||
|
||||
/**
|
||||
* Format cost as a currency string
|
||||
*/
|
||||
function formatCost(cost: number): string {
|
||||
if (cost === 0) {
|
||||
return "$0.00"
|
||||
}
|
||||
if (cost < 0.01) {
|
||||
return `$${cost.toFixed(4)}`
|
||||
}
|
||||
return `$${cost.toFixed(2)}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the task list command
|
||||
*/
|
||||
export function createTaskListCommand(config: CliConfig, logger: Logger, formatter: OutputFormatter): Command {
|
||||
const listCommand = new Command("list")
|
||||
.alias("l")
|
||||
.alias("ls")
|
||||
.description("List task history")
|
||||
.option("-n, --limit <number>", "Maximum number of tasks to show", "20")
|
||||
.option("-a, --all", "Show all tasks (no limit)", false)
|
||||
.action(async (options) => {
|
||||
logger.debug("Task list command called", { options })
|
||||
|
||||
try {
|
||||
// Initialize embedded controller to access task history
|
||||
const controller = await getEmbeddedController(logger, config.configDir)
|
||||
|
||||
// Get task history from the state
|
||||
const state = await controller.getStateToPostToWebview()
|
||||
let tasks = state.taskHistory || []
|
||||
|
||||
// Parse limit
|
||||
const limit = options.all ? undefined : parseInt(options.limit, 10)
|
||||
if (limit !== undefined && (Number.isNaN(limit) || limit < 1)) {
|
||||
formatter.error("Invalid limit value")
|
||||
await disposeEmbeddedController(logger)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
// Apply limit if specified
|
||||
if (limit !== undefined) {
|
||||
tasks = tasks.slice(0, limit)
|
||||
}
|
||||
|
||||
logger.debug(`Found ${tasks.length} tasks`)
|
||||
|
||||
// Handle empty list
|
||||
if (tasks.length === 0) {
|
||||
formatter.info("No tasks found")
|
||||
if (config.outputFormat === "json") {
|
||||
formatter.raw("[]")
|
||||
}
|
||||
await disposeEmbeddedController(logger)
|
||||
process.exit(0)
|
||||
return
|
||||
}
|
||||
|
||||
// Output based on format
|
||||
if (config.outputFormat === "json") {
|
||||
// JSON output: full task info
|
||||
formatter.raw(JSON.stringify(tasks, null, 2))
|
||||
} else {
|
||||
// Rich/plain output: formatted table
|
||||
formatter.info(`Task History (${tasks.length} task${tasks.length === 1 ? "" : "s"}):\n`)
|
||||
|
||||
// Calculate column widths for alignment
|
||||
const idWidth = 15
|
||||
const timeWidth = 16
|
||||
const costWidth = 10
|
||||
const modelWidth = 20
|
||||
|
||||
// Header
|
||||
const header =
|
||||
"ID".padEnd(idWidth) +
|
||||
"Time".padEnd(timeWidth) +
|
||||
"Cost".padEnd(costWidth) +
|
||||
"Model".padEnd(modelWidth) +
|
||||
"Prompt"
|
||||
formatter.raw(header)
|
||||
formatter.raw("-".repeat(header.length + 20))
|
||||
|
||||
// Rows
|
||||
for (const task of tasks) {
|
||||
const row =
|
||||
task.id.padEnd(idWidth) +
|
||||
getTimeAgo(task.ts).padEnd(timeWidth) +
|
||||
formatCost(task.totalCost).padEnd(costWidth) +
|
||||
truncate(task.modelId || "unknown", modelWidth - 2).padEnd(modelWidth) +
|
||||
truncate(task.task.replace(/\n/g, " "), 50)
|
||||
formatter.raw(row)
|
||||
}
|
||||
|
||||
formatter.raw("")
|
||||
formatter.info('Use "cline task open <id>" to resume a task')
|
||||
}
|
||||
|
||||
// Cleanup and exit
|
||||
await disposeEmbeddedController(logger)
|
||||
process.exit(0)
|
||||
} catch (error) {
|
||||
formatter.error((error as Error).message)
|
||||
await disposeEmbeddedController(logger)
|
||||
process.exit(1)
|
||||
}
|
||||
})
|
||||
|
||||
return listCommand
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
/**
|
||||
* Task restore command - restore a task to a specific checkpoint
|
||||
*
|
||||
* This command restores a task to a previous checkpoint, optionally
|
||||
* restoring both the conversation state and workspace files.
|
||||
*/
|
||||
|
||||
import type { ClineMessage } from "@shared/ExtensionMessage"
|
||||
import { Command } from "commander"
|
||||
import { disposeEmbeddedController, getEmbeddedController } from "../../core/embedded-controller.js"
|
||||
import type { OutputFormatter } from "../../core/output/types.js"
|
||||
import type { CliConfig } from "../../types/config.js"
|
||||
import type { Logger } from "../../types/logger.js"
|
||||
|
||||
/** Valid restore types */
|
||||
type RestoreType = "task" | "workspace" | "taskAndWorkspace"
|
||||
|
||||
const VALID_RESTORE_TYPES: RestoreType[] = ["task", "workspace", "taskAndWorkspace"]
|
||||
|
||||
/**
|
||||
* Get relative time string (e.g., "2 hours ago")
|
||||
*/
|
||||
function getTimeAgo(timestamp: number): string {
|
||||
const now = Date.now()
|
||||
const diff = now - timestamp
|
||||
const seconds = Math.floor(diff / 1000)
|
||||
const minutes = Math.floor(seconds / 60)
|
||||
const hours = Math.floor(minutes / 60)
|
||||
const days = Math.floor(hours / 24)
|
||||
|
||||
if (days > 0) {
|
||||
return days === 1 ? "1 day ago" : `${days} days ago`
|
||||
}
|
||||
if (hours > 0) {
|
||||
return hours === 1 ? "1 hour ago" : `${hours} hours ago`
|
||||
}
|
||||
if (minutes > 0) {
|
||||
return minutes === 1 ? "1 minute ago" : `${minutes} minutes ago`
|
||||
}
|
||||
return "just now"
|
||||
}
|
||||
|
||||
/**
|
||||
* Truncate a string to a maximum length with ellipsis
|
||||
*/
|
||||
function truncate(str: string, maxLength: number): string {
|
||||
if (str.length <= maxLength) {
|
||||
return str
|
||||
}
|
||||
return str.slice(0, maxLength - 3) + "..."
|
||||
}
|
||||
|
||||
/**
|
||||
* Find checkpoints in a list of messages
|
||||
*/
|
||||
export function findCheckpoints(messages: ClineMessage[]): ClineMessage[] {
|
||||
return messages.filter((m) => m.say === "checkpoint_created")
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that a checkpoint ID exists in the messages
|
||||
*/
|
||||
export function validateCheckpoint(messages: ClineMessage[], checkpointId: number): ClineMessage | null {
|
||||
return messages.find((m) => m.ts === checkpointId && m.say === "checkpoint_created") || null
|
||||
}
|
||||
|
||||
/**
|
||||
* Get context for a checkpoint (the preceding user message)
|
||||
*/
|
||||
function getCheckpointContext(messages: ClineMessage[], checkpointIndex: number): string {
|
||||
// Look backwards for the most recent user message
|
||||
for (let i = checkpointIndex - 1; i >= 0; i--) {
|
||||
const msg = messages[i]
|
||||
if (msg.type === "say" && msg.say === "text" && msg.text) {
|
||||
return truncate(msg.text.replace(/\n/g, " "), 50)
|
||||
}
|
||||
if (msg.type === "ask" && msg.text) {
|
||||
return truncate(msg.text.replace(/\n/g, " "), 50)
|
||||
}
|
||||
}
|
||||
return "(no context)"
|
||||
}
|
||||
|
||||
/**
|
||||
* Format checkpoints for display
|
||||
*/
|
||||
export function formatCheckpointList(messages: ClineMessage[]): Array<{
|
||||
id: number
|
||||
timeAgo: string
|
||||
context: string
|
||||
hasWorkspaceRestore: boolean
|
||||
}> {
|
||||
const checkpoints = findCheckpoints(messages)
|
||||
return checkpoints.map((cp) => {
|
||||
const index = messages.indexOf(cp)
|
||||
return {
|
||||
id: cp.ts,
|
||||
timeAgo: getTimeAgo(cp.ts),
|
||||
context: getCheckpointContext(messages, index),
|
||||
hasWorkspaceRestore: !!cp.lastCheckpointHash,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the task restore command
|
||||
*/
|
||||
export function createTaskRestoreCommand(config: CliConfig, logger: Logger, formatter: OutputFormatter): Command {
|
||||
const restoreCommand = new Command("restore")
|
||||
.alias("r")
|
||||
.description("Restore task to a specific checkpoint")
|
||||
.argument("<checkpoint-id>", "Checkpoint ID (timestamp) to restore to")
|
||||
.option(
|
||||
"-t, --type <type>",
|
||||
"Restore type: task (conversation only), workspace (files only), taskAndWorkspace (both)",
|
||||
"task",
|
||||
)
|
||||
.option("-l, --list", "List available checkpoints instead of restoring", false)
|
||||
.action(async (checkpointIdArg: string, options) => {
|
||||
logger.debug("Task restore command called", { checkpointIdArg, options })
|
||||
|
||||
try {
|
||||
// Initialize embedded controller
|
||||
const controller = await getEmbeddedController(logger, config.configDir)
|
||||
|
||||
// Check if there's an active task
|
||||
if (!controller.task) {
|
||||
// Try to get the most recent task
|
||||
const state = await controller.getStateToPostToWebview()
|
||||
const taskHistory = state.taskHistory || []
|
||||
|
||||
if (taskHistory.length === 0) {
|
||||
throw new Error("No tasks found. Create a task first.")
|
||||
}
|
||||
|
||||
// Initialize the most recent task
|
||||
const historyItem = taskHistory[0]
|
||||
const taskData = await controller.getTaskWithId(historyItem.id)
|
||||
await controller.initTask(undefined, undefined, undefined, taskData.historyItem)
|
||||
}
|
||||
|
||||
// Get messages from the task
|
||||
const messages = controller.task?.messageStateHandler.getClineMessages() || []
|
||||
|
||||
if (messages.length === 0) {
|
||||
throw new Error("No messages in current task")
|
||||
}
|
||||
|
||||
// Handle --list option
|
||||
if (options.list) {
|
||||
const checkpoints = formatCheckpointList(messages)
|
||||
|
||||
if (checkpoints.length === 0) {
|
||||
formatter.info("No checkpoints found in current task")
|
||||
await disposeEmbeddedController(logger)
|
||||
return
|
||||
}
|
||||
|
||||
if (config.outputFormat === "json") {
|
||||
formatter.raw(JSON.stringify(checkpoints, null, 2))
|
||||
} else {
|
||||
formatter.info(`Checkpoints (${checkpoints.length}):\n`)
|
||||
|
||||
const idWidth = 16
|
||||
const timeWidth = 16
|
||||
const wsWidth = 12
|
||||
|
||||
const header = "ID".padEnd(idWidth) + "Time".padEnd(timeWidth) + "Workspace".padEnd(wsWidth) + "Context"
|
||||
formatter.raw(header)
|
||||
formatter.raw("-".repeat(header.length + 30))
|
||||
|
||||
for (const cp of checkpoints) {
|
||||
const row =
|
||||
String(cp.id).padEnd(idWidth) +
|
||||
cp.timeAgo.padEnd(timeWidth) +
|
||||
(cp.hasWorkspaceRestore ? "Yes" : "No").padEnd(wsWidth) +
|
||||
cp.context
|
||||
formatter.raw(row)
|
||||
}
|
||||
|
||||
formatter.raw("")
|
||||
formatter.info('Use "cline task restore <checkpoint-id>" to restore')
|
||||
}
|
||||
|
||||
await disposeEmbeddedController(logger)
|
||||
return
|
||||
}
|
||||
|
||||
// Parse and validate checkpoint ID
|
||||
const checkpointId = parseInt(checkpointIdArg, 10)
|
||||
if (isNaN(checkpointId)) {
|
||||
throw new Error(`Invalid checkpoint ID: "${checkpointIdArg}". Must be a number (timestamp).`)
|
||||
}
|
||||
|
||||
// Validate restore type
|
||||
const restoreType = options.type as RestoreType
|
||||
if (!VALID_RESTORE_TYPES.includes(restoreType)) {
|
||||
throw new Error(`Invalid restore type: "${restoreType}". Valid options: ${VALID_RESTORE_TYPES.join(", ")}`)
|
||||
}
|
||||
|
||||
// Validate checkpoint exists
|
||||
const checkpoint = validateCheckpoint(messages, checkpointId)
|
||||
if (!checkpoint) {
|
||||
// Check if the timestamp exists but is not a checkpoint
|
||||
const anyMessage = messages.find((m) => m.ts === checkpointId)
|
||||
if (anyMessage) {
|
||||
throw new Error(
|
||||
`Timestamp ${checkpointId} exists but is not a checkpoint (type: ${anyMessage.say || anyMessage.ask})`,
|
||||
)
|
||||
}
|
||||
throw new Error(`Checkpoint ${checkpointId} not found in task history`)
|
||||
}
|
||||
|
||||
// Check if workspace restore is possible
|
||||
if ((restoreType === "workspace" || restoreType === "taskAndWorkspace") && !checkpoint.lastCheckpointHash) {
|
||||
formatter.warn("Warning: This checkpoint does not have workspace restore data.")
|
||||
if (restoreType === "workspace") {
|
||||
throw new Error("Cannot restore workspace: no checkpoint hash available")
|
||||
}
|
||||
formatter.info("Falling back to task-only restore.")
|
||||
}
|
||||
|
||||
// Perform the restore
|
||||
formatter.info(`Restoring to checkpoint ${checkpointId} (${getTimeAgo(checkpointId)})...`)
|
||||
formatter.info(`Restore type: ${restoreType}`)
|
||||
|
||||
// Cancel any active task first (required before restore)
|
||||
await controller.cancelTask()
|
||||
|
||||
// Call restoreCheckpoint on the checkpoint manager
|
||||
const checkpointManager = controller.task?.checkpointManager
|
||||
if (!checkpointManager) {
|
||||
throw new Error("Checkpoint manager not available")
|
||||
}
|
||||
|
||||
await checkpointManager.restoreCheckpoint(checkpointId, restoreType)
|
||||
|
||||
formatter.success("Checkpoint restored successfully")
|
||||
|
||||
// Show post-restore state
|
||||
const newMessages = controller.task?.messageStateHandler.getClineMessages() || []
|
||||
formatter.info(`Task now has ${newMessages.length} messages`)
|
||||
|
||||
await disposeEmbeddedController(logger)
|
||||
} catch (error) {
|
||||
formatter.error((error as Error).message)
|
||||
await disposeEmbeddedController(logger)
|
||||
process.exit(1)
|
||||
}
|
||||
})
|
||||
|
||||
return restoreCommand
|
||||
}
|
||||
@@ -0,0 +1,474 @@
|
||||
/**
|
||||
* Task send command - send a message to the current task using embedded Controller
|
||||
*
|
||||
* This command sends a single message to an active task using Cline's
|
||||
* embedded Controller, allowing non-interactive AI interactions.
|
||||
*/
|
||||
|
||||
import type { ClineMessage } from "@shared/ExtensionMessage"
|
||||
import { Command } from "commander"
|
||||
import { CliWebviewAdapter } from "../../core/cli-webview-adapter.js"
|
||||
import { disposeEmbeddedController, getControllerIfInitialized, getEmbeddedController } from "../../core/embedded-controller.js"
|
||||
import type { OutputFormatter } from "../../core/output/types.js"
|
||||
import { parseAtPaths, processExplicitFiles, processExplicitImages } from "../../core/path-parser.js"
|
||||
import type { CliConfig } from "../../types/config.js"
|
||||
import type { Logger } from "../../types/logger.js"
|
||||
import { checkForPendingInput, isCompletionState, isFailureState } from "./chat/input-checker.js"
|
||||
|
||||
/** Yolo mode timeout: 5 minutes in milliseconds */
|
||||
const YOLO_TIMEOUT_MS = 5 * 60 * 1000
|
||||
|
||||
/** Yolo mode max consecutive failures before abort */
|
||||
const YOLO_MAX_FAILURES = 3
|
||||
|
||||
/**
|
||||
* Validate mode option
|
||||
*/
|
||||
function validateMode(mode: string | undefined): "act" | "plan" | undefined {
|
||||
if (!mode) {
|
||||
return undefined
|
||||
}
|
||||
if (mode !== "act" && mode !== "plan") {
|
||||
throw new Error(`Invalid mode: "${mode}". Valid options are: act, plan`)
|
||||
}
|
||||
return mode
|
||||
}
|
||||
|
||||
/**
|
||||
* Read input from stdin if available
|
||||
*/
|
||||
async function readStdin(): Promise<string | null> {
|
||||
// Check if stdin is a TTY (interactive terminal)
|
||||
if (process.stdin.isTTY) {
|
||||
return null
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
let data = ""
|
||||
process.stdin.setEncoding("utf-8")
|
||||
|
||||
process.stdin.on("readable", () => {
|
||||
let chunk: string | null
|
||||
while ((chunk = process.stdin.read() as string | null) !== null) {
|
||||
data += chunk
|
||||
}
|
||||
})
|
||||
|
||||
process.stdin.on("end", () => {
|
||||
resolve(data.trim() || null)
|
||||
})
|
||||
|
||||
// Timeout after 100ms if no data
|
||||
setTimeout(() => {
|
||||
if (!data) {
|
||||
resolve(null)
|
||||
}
|
||||
}, 100)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the last message requires user input
|
||||
*/
|
||||
function isAwaitingResponse(messages: ClineMessage[]): boolean {
|
||||
if (messages.length === 0) {
|
||||
return false
|
||||
}
|
||||
|
||||
const lastMessage = messages[messages.length - 1]
|
||||
|
||||
// Skip partial messages
|
||||
if (lastMessage.partial) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check if this is an "ask" type message
|
||||
return lastMessage.type === "ask"
|
||||
}
|
||||
|
||||
/**
|
||||
* Yolo mode state for tracking failures
|
||||
*/
|
||||
interface YoloState {
|
||||
failureCount: number
|
||||
lastFailedAction: string | null
|
||||
actionStartTime: number
|
||||
completed: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for task to reach a stopping point (either completion or awaiting input)
|
||||
* In yolo mode, auto-approves actions and continues until completion
|
||||
*/
|
||||
async function waitForTaskResponse(
|
||||
controller: Awaited<ReturnType<typeof getEmbeddedController>>,
|
||||
formatter: OutputFormatter,
|
||||
timeoutMs = 300000, // 5 minutes default timeout
|
||||
yoloMode = false,
|
||||
): Promise<void> {
|
||||
const adapter = new CliWebviewAdapter(controller, formatter)
|
||||
adapter.startListening()
|
||||
|
||||
const yoloState: YoloState = {
|
||||
failureCount: 0,
|
||||
lastFailedAction: null,
|
||||
actionStartTime: Date.now(),
|
||||
completed: false,
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const startTime = Date.now()
|
||||
|
||||
const checkInterval = setInterval(async () => {
|
||||
const messages = adapter.getMessages()
|
||||
|
||||
// YOLO MODE: Auto-respond and continue until completion
|
||||
if (yoloMode && controller.task && !yoloState.completed) {
|
||||
// Check for task completion first
|
||||
if (isCompletionState(messages)) {
|
||||
// Guard against processing completion multiple times
|
||||
yoloState.completed = true
|
||||
formatter.success("\n[YOLO] Task completed!")
|
||||
clearInterval(checkInterval)
|
||||
adapter.stopListening()
|
||||
// Respond to the completion_result ask to unblock the handler
|
||||
const task = controller.task
|
||||
await task.handleWebviewAskResponse("yesButtonClicked")
|
||||
// Give time for the response to be fully processed
|
||||
await new Promise((r) => setTimeout(r, 200))
|
||||
// Abort the task to stop the loop - this is expected after completion
|
||||
try {
|
||||
await task.abortTask()
|
||||
} catch {
|
||||
// Task may already be cleaned up, ignore
|
||||
}
|
||||
// Exit successfully - don't wait for full cleanup in yolo mode
|
||||
// The task has completed successfully, so exit code 0
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
// Check for yolo timeout (5 minutes per action)
|
||||
if (Date.now() - yoloState.actionStartTime > YOLO_TIMEOUT_MS) {
|
||||
clearInterval(checkInterval)
|
||||
adapter.stopListening()
|
||||
reject(new Error("[YOLO] Action timed out after 5 minutes"))
|
||||
return
|
||||
}
|
||||
|
||||
// Check for failure state
|
||||
const failureCheck = isFailureState(messages)
|
||||
if (failureCheck.isFailure) {
|
||||
if (yoloState.lastFailedAction === failureCheck.actionKey) {
|
||||
yoloState.failureCount++
|
||||
} else {
|
||||
yoloState.lastFailedAction = failureCheck.actionKey
|
||||
yoloState.failureCount = 1
|
||||
}
|
||||
|
||||
if (yoloState.failureCount >= YOLO_MAX_FAILURES) {
|
||||
clearInterval(checkInterval)
|
||||
adapter.stopListening()
|
||||
reject(new Error(`[YOLO] Same action failed ${YOLO_MAX_FAILURES} times`))
|
||||
return
|
||||
}
|
||||
|
||||
// Auto-retry
|
||||
formatter.warn(`[YOLO] Action failed (attempt ${yoloState.failureCount}/${YOLO_MAX_FAILURES}), retrying...`)
|
||||
yoloState.actionStartTime = Date.now()
|
||||
await controller.task.handleWebviewAskResponse("yesButtonClicked")
|
||||
return
|
||||
} else if (failureCheck.actionKey === null) {
|
||||
// Reset failure tracking on non-failure state
|
||||
yoloState.failureCount = 0
|
||||
yoloState.lastFailedAction = null
|
||||
}
|
||||
|
||||
// Check for pending input and auto-respond
|
||||
const pendingState = checkForPendingInput(messages)
|
||||
|
||||
if (pendingState.awaitingApproval) {
|
||||
yoloState.actionStartTime = Date.now()
|
||||
await controller.task.handleWebviewAskResponse("yesButtonClicked")
|
||||
return
|
||||
}
|
||||
|
||||
if (pendingState.awaitingInput) {
|
||||
yoloState.actionStartTime = Date.now()
|
||||
await controller.task.handleWebviewAskResponse("messageResponse", "proceed")
|
||||
return
|
||||
}
|
||||
|
||||
// Continue waiting for next state
|
||||
return
|
||||
}
|
||||
|
||||
// Normal mode: Check if task completed or awaiting response
|
||||
if (isAwaitingResponse(messages)) {
|
||||
clearInterval(checkInterval)
|
||||
adapter.stopListening()
|
||||
resolve()
|
||||
return
|
||||
}
|
||||
|
||||
// Check for task completion (no task or task finished)
|
||||
if (!controller.task) {
|
||||
clearInterval(checkInterval)
|
||||
adapter.stopListening()
|
||||
resolve()
|
||||
return
|
||||
}
|
||||
|
||||
// Check timeout
|
||||
if (Date.now() - startTime > timeoutMs) {
|
||||
clearInterval(checkInterval)
|
||||
adapter.stopListening()
|
||||
reject(new Error("Task timed out waiting for response"))
|
||||
}
|
||||
}, 100)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect multiple option values into an array
|
||||
* Used for -f and -i options that can be specified multiple times
|
||||
*/
|
||||
function collectOption(value: string, previous: string[]): string[] {
|
||||
return previous.concat([value])
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the task send command
|
||||
*/
|
||||
export function createTaskSendCommand(config: CliConfig, logger: Logger, formatter: OutputFormatter): Command {
|
||||
const sendCommand = new Command("send")
|
||||
.alias("s")
|
||||
.description("Send a message to the current task using embedded Controller")
|
||||
.argument("[message]", "Message to send (reads from stdin if not provided)")
|
||||
.option("-t, --task <id>", "Target task ID (starts new task if not specified)")
|
||||
.option("-a, --approve", "Approve a proposed action", false)
|
||||
.option("-d, --deny", "Deny a proposed action", false)
|
||||
.option("-f, --file <path>", "Attach file to message (can be repeated)", collectOption, [])
|
||||
.option("-i, --image <path>", "Attach image to message (can be repeated)", collectOption, [])
|
||||
.option("-y, --yolo", "Enable autonomous mode (no confirmations)", false)
|
||||
.option("--no-interactive", "Same as --yolo")
|
||||
.option("-m, --mode <mode>", "Switch to mode: act or plan")
|
||||
.option("-w, --wait", "Wait for task to complete or await input", false)
|
||||
.option("--timeout <ms>", "Timeout in milliseconds when using --wait (default: 300000)")
|
||||
.action(async (messageArg: string | undefined, options) => {
|
||||
logger.debug("Task send command called", { messageArg, options })
|
||||
|
||||
try {
|
||||
// Validate mutual exclusivity of approve/deny
|
||||
if (options.approve && options.deny) {
|
||||
throw new Error("Cannot use both --approve and --deny options")
|
||||
}
|
||||
|
||||
// Validate mode if provided
|
||||
const mode = validateMode(options.mode)
|
||||
|
||||
// Process explicit file and image attachments from CLI options
|
||||
const cwd = process.cwd()
|
||||
let explicitFiles: string[] = []
|
||||
let explicitImages: string[] = []
|
||||
|
||||
// Process -f/--file options (can be files or images, auto-detected)
|
||||
if (options.file && options.file.length > 0) {
|
||||
const processed = processExplicitFiles(options.file, cwd)
|
||||
explicitFiles = processed.files
|
||||
explicitImages = processed.images
|
||||
}
|
||||
|
||||
// Process -i/--image options (must be images)
|
||||
if (options.image && options.image.length > 0) {
|
||||
const images = processExplicitImages(options.image, cwd)
|
||||
explicitImages = explicitImages.concat(images)
|
||||
}
|
||||
|
||||
// Initialize embedded controller
|
||||
const controller = await getEmbeddedController(logger, config.configDir)
|
||||
|
||||
// Handle mode switch
|
||||
if (mode) {
|
||||
await controller.togglePlanActMode(mode)
|
||||
formatter.info(`Switched to ${mode} mode`)
|
||||
}
|
||||
|
||||
// Set up YOLO mode in Cline core settings if --yolo flag is set
|
||||
// This enables the core to:
|
||||
// 1. Modify system prompt to not ask followup questions
|
||||
// 2. Auto-switch from Plan to Act mode
|
||||
// 3. Auto-approve tools based on auto-approval settings
|
||||
if (options.yolo) {
|
||||
controller.stateManager.setGlobalState("yoloModeToggled", true)
|
||||
// Increase mistake limit for autonomous operation (matches Go CLI behavior)
|
||||
controller.stateManager.setGlobalState("maxConsecutiveMistakes", 6)
|
||||
// Ensure we're in Act mode for autonomous execution (unless user explicitly chose a mode)
|
||||
if (!mode) {
|
||||
await controller.togglePlanActMode("act")
|
||||
}
|
||||
}
|
||||
|
||||
// Handle approve/deny for existing task
|
||||
if (options.approve || options.deny) {
|
||||
if (!controller.task) {
|
||||
throw new Error("No active task to approve/deny")
|
||||
}
|
||||
|
||||
const response = options.approve ? "yesButtonClicked" : "noButtonClicked"
|
||||
await controller.task.handleWebviewAskResponse(response)
|
||||
formatter.success(options.approve ? "Action approved" : "Action denied")
|
||||
|
||||
if (options.wait || options.yolo) {
|
||||
await waitForTaskResponse(controller, formatter, parseInt(options.timeout) || 300000, options.yolo)
|
||||
}
|
||||
|
||||
// Output result in JSON format if requested
|
||||
if (config.outputFormat === "json") {
|
||||
const state = await controller.getStateToPostToWebview()
|
||||
formatter.raw(
|
||||
JSON.stringify(
|
||||
{
|
||||
taskId: controller.task?.taskId,
|
||||
action: options.approve ? "approved" : "denied",
|
||||
messageCount: state.clineMessages?.length || 0,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
await disposeEmbeddedController(logger)
|
||||
return
|
||||
}
|
||||
|
||||
// Determine message content
|
||||
let message = messageArg
|
||||
|
||||
// Try to read from stdin if no message argument
|
||||
if (!message) {
|
||||
const stdinMessage = await readStdin()
|
||||
if (stdinMessage) {
|
||||
message = stdinMessage
|
||||
}
|
||||
}
|
||||
|
||||
if (!message) {
|
||||
throw new Error("No message provided. Use argument or pipe via stdin")
|
||||
}
|
||||
|
||||
// Parse @path references from the message
|
||||
const parsedPaths = parseAtPaths(message, cwd)
|
||||
|
||||
// Show warnings for any files that couldn't be processed (non-fatal for @paths)
|
||||
for (const warning of parsedPaths.warnings) {
|
||||
formatter.warn(warning)
|
||||
}
|
||||
|
||||
// Use cleaned message (with @paths removed)
|
||||
const cleanedMessage = parsedPaths.cleanedMessage
|
||||
|
||||
// Combine explicit attachments with @path attachments
|
||||
const allFiles = [...explicitFiles, ...parsedPaths.files]
|
||||
const allImages = [...explicitImages, ...parsedPaths.images]
|
||||
|
||||
// Log attachment info
|
||||
if (allFiles.length > 0) {
|
||||
formatter.info(`Attaching ${allFiles.length} file(s)`)
|
||||
}
|
||||
if (allImages.length > 0) {
|
||||
formatter.info(`Attaching ${allImages.length} image(s)`)
|
||||
}
|
||||
|
||||
// Start or continue task
|
||||
let taskId: string | undefined
|
||||
|
||||
if (options.task) {
|
||||
// Resume existing task
|
||||
const history = await controller.getTaskWithId(options.task)
|
||||
if (!history) {
|
||||
throw new Error(`Task not found: ${options.task}`)
|
||||
}
|
||||
taskId = await controller.initTask(undefined, undefined, undefined, history.historyItem)
|
||||
formatter.info(`Resumed task: ${taskId}`)
|
||||
|
||||
// Send the message with attachments
|
||||
if (controller.task) {
|
||||
await controller.task.handleWebviewAskResponse(
|
||||
"messageResponse",
|
||||
cleanedMessage,
|
||||
allImages.length > 0 ? allImages : undefined,
|
||||
allFiles.length > 0 ? allFiles : undefined,
|
||||
)
|
||||
}
|
||||
} else if (controller.task) {
|
||||
// Send to existing active task
|
||||
taskId = controller.task.taskId
|
||||
await controller.task.handleWebviewAskResponse(
|
||||
"messageResponse",
|
||||
cleanedMessage,
|
||||
allImages.length > 0 ? allImages : undefined,
|
||||
allFiles.length > 0 ? allFiles : undefined,
|
||||
)
|
||||
formatter.info(`Message sent to task ${taskId.slice(0, 8)}`)
|
||||
} else {
|
||||
// Start new task with the message as prompt
|
||||
taskId = await controller.initTask(
|
||||
cleanedMessage,
|
||||
allImages.length > 0 ? allImages : undefined,
|
||||
allFiles.length > 0 ? allFiles : undefined,
|
||||
)
|
||||
formatter.info(`Started new task: ${taskId}`)
|
||||
}
|
||||
|
||||
// Wait for response if requested (yolo mode always waits for completion)
|
||||
if (options.wait || options.yolo) {
|
||||
if (options.yolo) {
|
||||
formatter.info("[YOLO] Autonomous mode - running until completion...")
|
||||
} else {
|
||||
formatter.info("Waiting for task response...")
|
||||
}
|
||||
await waitForTaskResponse(controller, formatter, parseInt(options.timeout) || 300000, options.yolo)
|
||||
}
|
||||
|
||||
// Output result in JSON format if requested
|
||||
if (config.outputFormat === "json") {
|
||||
const state = await controller.getStateToPostToWebview()
|
||||
formatter.raw(
|
||||
JSON.stringify(
|
||||
{
|
||||
taskId,
|
||||
message,
|
||||
messageCount: state.clineMessages?.length || 0,
|
||||
mode: state.mode,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// Reset yolo mode settings if they were enabled for this command
|
||||
if (options.yolo) {
|
||||
controller.stateManager.setGlobalState("yoloModeToggled", false)
|
||||
controller.stateManager.setGlobalState("maxConsecutiveMistakes", 3)
|
||||
}
|
||||
|
||||
await disposeEmbeddedController(logger)
|
||||
} catch (error) {
|
||||
formatter.error((error as Error).message)
|
||||
// Reset yolo mode settings on error too
|
||||
if (options.yolo) {
|
||||
const controller = getControllerIfInitialized()
|
||||
if (controller) {
|
||||
controller.stateManager.setGlobalState("yoloModeToggled", false)
|
||||
controller.stateManager.setGlobalState("maxConsecutiveMistakes", 3)
|
||||
}
|
||||
}
|
||||
await disposeEmbeddedController(logger)
|
||||
process.exit(1)
|
||||
}
|
||||
})
|
||||
|
||||
return sendCommand
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
/**
|
||||
* Task view command - view conversation history using embedded Controller
|
||||
*
|
||||
* This command displays task conversation history from the embedded
|
||||
* Controller, with options for real-time streaming and following.
|
||||
*/
|
||||
|
||||
import type { ClineMessage } from "@shared/ExtensionMessage"
|
||||
import { Command } from "commander"
|
||||
import { CliWebviewAdapter } from "../../core/cli-webview-adapter.js"
|
||||
import { disposeEmbeddedController, getEmbeddedController } from "../../core/embedded-controller.js"
|
||||
import type { OutputFormatter } from "../../core/output/types.js"
|
||||
import type { CliConfig } from "../../types/config.js"
|
||||
import type { Logger } from "../../types/logger.js"
|
||||
|
||||
/**
|
||||
* Format a ClineMessage for display
|
||||
*/
|
||||
function formatMessageSummary(msg: ClineMessage): string {
|
||||
const timestamp = new Date(msg.ts).toLocaleTimeString()
|
||||
const type = msg.type.toUpperCase()
|
||||
|
||||
let subtype = ""
|
||||
if (msg.say) {
|
||||
subtype = ` [${msg.say}]`
|
||||
} else if (msg.ask) {
|
||||
subtype = ` [${msg.ask}]`
|
||||
}
|
||||
|
||||
// Truncate long messages
|
||||
let content = msg.text || ""
|
||||
if (content.length > 100) {
|
||||
content = content.slice(0, 97) + "..."
|
||||
}
|
||||
|
||||
// Handle special message types
|
||||
if (msg.say === "api_req_started" || msg.say === "api_req_finished") {
|
||||
try {
|
||||
const info = JSON.parse(msg.text || "{}")
|
||||
if (info.tokensIn || info.tokensOut) {
|
||||
content = `tokens: ${info.tokensIn || 0} in / ${info.tokensOut || 0} out`
|
||||
}
|
||||
} catch {
|
||||
// Keep original content
|
||||
}
|
||||
}
|
||||
|
||||
return `[${timestamp}] ${type}${subtype}: ${content.replace(/\n/g, " ")}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if task is complete or awaiting input
|
||||
*/
|
||||
function isTaskComplete(messages: ClineMessage[]): boolean {
|
||||
if (messages.length === 0) {
|
||||
return false
|
||||
}
|
||||
|
||||
const lastMessage = messages[messages.length - 1]
|
||||
|
||||
// Task is complete if last message is completion_result
|
||||
if (lastMessage.ask === "completion_result" || lastMessage.say === "completion_result") {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Sleep for a given number of milliseconds
|
||||
*/
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the task view command
|
||||
*/
|
||||
export function createTaskViewCommand(config: CliConfig, logger: Logger, formatter: OutputFormatter): Command {
|
||||
const viewCommand = new Command("view")
|
||||
.alias("v")
|
||||
.description("View task conversation history using embedded Controller")
|
||||
.argument("[taskId]", "Task ID to view (defaults to current or most recent task)")
|
||||
.option("-f, --follow", "Stream updates in real-time", false)
|
||||
.option("-c, --follow-complete", "Follow until task completion", false)
|
||||
.option("-n, --last <count>", "Show only last N messages")
|
||||
.option("--since <timestamp>", "Show messages since timestamp (Unix ms)")
|
||||
.option("-r, --raw", "Show raw message data (useful for debugging)", false)
|
||||
.action(async (taskIdArg: string | undefined, options) => {
|
||||
logger.debug("Task view command called", { taskIdArg, options })
|
||||
|
||||
try {
|
||||
// Initialize embedded controller
|
||||
const controller = await getEmbeddedController(logger, config.configDir)
|
||||
|
||||
// Get task history to find the task
|
||||
const state = await controller.getStateToPostToWebview()
|
||||
const taskHistory = state.taskHistory || []
|
||||
|
||||
// Determine which task to view
|
||||
let taskId = taskIdArg
|
||||
let historyItem = null
|
||||
|
||||
if (taskId) {
|
||||
// Find task by ID (support partial ID match)
|
||||
historyItem = taskHistory.find((t) => t.id === taskId || t.id.startsWith(taskId || ""))
|
||||
if (!historyItem) {
|
||||
throw new Error(`Task not found: ${taskId}`)
|
||||
}
|
||||
taskId = historyItem.id
|
||||
} else {
|
||||
// Use current task or most recent
|
||||
if (controller.task) {
|
||||
taskId = controller.task.taskId
|
||||
historyItem = taskHistory.find((t) => t.id === taskId)
|
||||
} else if (taskHistory.length > 0) {
|
||||
historyItem = taskHistory[0] // Most recent
|
||||
taskId = historyItem.id
|
||||
} else {
|
||||
throw new Error("No tasks found. Create a task with 'cline task new'")
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize task if not already active
|
||||
if (!controller.task || controller.task.taskId !== taskId) {
|
||||
if (historyItem) {
|
||||
const taskData = await controller.getTaskWithId(taskId)
|
||||
await controller.initTask(undefined, undefined, undefined, taskData.historyItem)
|
||||
}
|
||||
}
|
||||
|
||||
// Display task info header
|
||||
formatter.info(`\nTask: ${taskId}`)
|
||||
if (historyItem) {
|
||||
formatter.info(`Status: ${historyItem.size ? "has content" : "empty"}`)
|
||||
if (historyItem.task) {
|
||||
const promptPreview = historyItem.task.slice(0, 60) + (historyItem.task.length > 60 ? "..." : "")
|
||||
formatter.info(`Prompt: ${promptPreview}`)
|
||||
}
|
||||
}
|
||||
formatter.raw("─".repeat(60))
|
||||
|
||||
// Get messages
|
||||
let messages = controller.task?.messageStateHandler.getClineMessages() || []
|
||||
|
||||
// Filter by timestamp if provided
|
||||
if (options.since) {
|
||||
const sinceTs = parseInt(options.since, 10)
|
||||
if (isNaN(sinceTs)) {
|
||||
throw new Error(`Invalid timestamp: ${options.since}`)
|
||||
}
|
||||
messages = messages.filter((m) => m.ts > sinceTs)
|
||||
}
|
||||
|
||||
// Limit to last N messages if specified
|
||||
if (options.last) {
|
||||
const count = parseInt(options.last, 10)
|
||||
if (isNaN(count) || count < 1) {
|
||||
throw new Error(`Invalid count: ${options.last}`)
|
||||
}
|
||||
messages = messages.slice(-count)
|
||||
}
|
||||
|
||||
// Display messages
|
||||
if (messages.length === 0) {
|
||||
formatter.info("No messages yet")
|
||||
} else {
|
||||
if (options.raw) {
|
||||
// Raw JSON output
|
||||
for (const msg of messages) {
|
||||
formatter.raw(JSON.stringify(msg, null, 2))
|
||||
formatter.raw("")
|
||||
}
|
||||
} else {
|
||||
// Formatted output using the adapter
|
||||
const adapter = new CliWebviewAdapter(controller, formatter)
|
||||
for (const msg of messages) {
|
||||
adapter.outputMessage(msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// JSON output for non-follow mode
|
||||
if (config.outputFormat === "json" && !options.follow && !options.followComplete) {
|
||||
formatter.raw(
|
||||
JSON.stringify(
|
||||
{
|
||||
taskId,
|
||||
prompt: historyItem?.task,
|
||||
messageCount: messages.length,
|
||||
messages: options.raw ? messages : messages.map(formatMessageSummary),
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
)
|
||||
await disposeEmbeddedController(logger)
|
||||
return
|
||||
}
|
||||
|
||||
// Handle follow mode
|
||||
if (options.follow || options.followComplete) {
|
||||
formatter.raw("")
|
||||
formatter.info("Watching for new messages... (Ctrl+C to stop)")
|
||||
formatter.raw("─".repeat(60))
|
||||
|
||||
let isRunning = true
|
||||
let lastMessageCount = messages.length
|
||||
|
||||
// Create adapter for streaming output
|
||||
const adapter = new CliWebviewAdapter(controller, formatter)
|
||||
|
||||
// Handle Ctrl+C gracefully
|
||||
const cleanup = async () => {
|
||||
isRunning = false
|
||||
formatter.raw("")
|
||||
formatter.info("Stopped watching")
|
||||
adapter.stopListening()
|
||||
await disposeEmbeddedController(logger)
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
process.on("SIGINT", cleanup)
|
||||
process.on("SIGTERM", cleanup)
|
||||
|
||||
// Poll for new messages
|
||||
const pollInterval = 100 // ms
|
||||
|
||||
while (isRunning) {
|
||||
await sleep(pollInterval)
|
||||
|
||||
// Get current messages
|
||||
const currentMessages = controller.task?.messageStateHandler.getClineMessages() || []
|
||||
|
||||
// Output new messages
|
||||
if (currentMessages.length > lastMessageCount) {
|
||||
const newMessages = currentMessages.slice(lastMessageCount)
|
||||
for (const msg of newMessages) {
|
||||
adapter.outputMessage(msg)
|
||||
}
|
||||
lastMessageCount = currentMessages.length
|
||||
}
|
||||
|
||||
// Check if task completed (for --follow-complete)
|
||||
if (options.followComplete && isTaskComplete(currentMessages)) {
|
||||
formatter.raw("")
|
||||
formatter.info("Task completed")
|
||||
break
|
||||
}
|
||||
|
||||
// Check if task was cleared
|
||||
if (!controller.task) {
|
||||
formatter.warn("Task was cleared")
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Remove listeners
|
||||
process.removeListener("SIGINT", cleanup)
|
||||
process.removeListener("SIGTERM", cleanup)
|
||||
}
|
||||
|
||||
await disposeEmbeddedController(logger)
|
||||
} catch (error) {
|
||||
formatter.error((error as Error).message)
|
||||
await disposeEmbeddedController(logger)
|
||||
process.exit(1)
|
||||
}
|
||||
})
|
||||
|
||||
return viewCommand
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { Command } from "commander"
|
||||
import type { CliConfig } from "../types/config.js"
|
||||
import type { Logger } from "../types/logger.js"
|
||||
|
||||
// Version is injected at build time via esbuild define
|
||||
declare const __CLINE_VERSION__: string
|
||||
|
||||
/**
|
||||
* Get the Cline version from the build-time injected value
|
||||
*/
|
||||
export function getVersion(): string {
|
||||
return __CLINE_VERSION__
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute the version command - displays the Cline version
|
||||
*/
|
||||
export function runVersionCommand(config: CliConfig, logger: Logger): void {
|
||||
const version = getVersion()
|
||||
logger.debug(`Displaying version: ${version}`)
|
||||
console.log(`cline ${version}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the version subcommand
|
||||
*/
|
||||
export function createVersionCommand(config: CliConfig, logger: Logger): Command {
|
||||
return new Command("version").description("Display the Cline version").action(() => {
|
||||
runVersionCommand(config, logger)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* API Provider definitions for authentication
|
||||
*/
|
||||
|
||||
/**
|
||||
* Provider information for authentication
|
||||
*/
|
||||
export interface ProviderInfo {
|
||||
/** Provider identifier */
|
||||
id: string
|
||||
/** Display name */
|
||||
name: string
|
||||
/** Description for the interactive wizard */
|
||||
description: string
|
||||
/** Whether this provider requires an API key */
|
||||
requiresApiKey: boolean
|
||||
/** Environment variable name for API key (if any) */
|
||||
envVar?: string
|
||||
/** URL to get an API key */
|
||||
keyUrl?: string
|
||||
/** Whether this provider supports OAuth */
|
||||
supportsOAuth?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Available API providers
|
||||
*/
|
||||
export const PROVIDERS: ProviderInfo[] = [
|
||||
{
|
||||
id: "anthropic",
|
||||
name: "Anthropic",
|
||||
description: "Direct access to Claude models via Anthropic API",
|
||||
requiresApiKey: true,
|
||||
envVar: "ANTHROPIC_API_KEY",
|
||||
keyUrl: "https://console.anthropic.com/settings/keys",
|
||||
},
|
||||
{
|
||||
id: "openrouter",
|
||||
name: "OpenRouter",
|
||||
description: "Access multiple AI providers through a single API",
|
||||
requiresApiKey: true,
|
||||
envVar: "OPENROUTER_API_KEY",
|
||||
keyUrl: "https://openrouter.ai/keys",
|
||||
},
|
||||
{
|
||||
id: "openai",
|
||||
name: "OpenAI",
|
||||
description: "Access to GPT models via OpenAI API",
|
||||
requiresApiKey: true,
|
||||
envVar: "OPENAI_API_KEY",
|
||||
keyUrl: "https://platform.openai.com/api-keys",
|
||||
},
|
||||
{
|
||||
id: "bedrock",
|
||||
name: "AWS Bedrock",
|
||||
description: "AWS Bedrock with Claude and other models (uses AWS credentials from environment or ~/.aws/credentials)",
|
||||
requiresApiKey: false,
|
||||
},
|
||||
{
|
||||
id: "gemini",
|
||||
name: "Google Gemini",
|
||||
description: "Access to Gemini models via Google AI API",
|
||||
requiresApiKey: true,
|
||||
envVar: "GOOGLE_API_KEY",
|
||||
keyUrl: "https://aistudio.google.com/app/apikey",
|
||||
},
|
||||
{
|
||||
id: "ollama",
|
||||
name: "Ollama",
|
||||
description: "Local models via Ollama (no API key required)",
|
||||
requiresApiKey: false,
|
||||
},
|
||||
{
|
||||
id: "lmstudio",
|
||||
name: "LM Studio",
|
||||
description: "Local models via LM Studio (no API key required)",
|
||||
requiresApiKey: false,
|
||||
},
|
||||
]
|
||||
|
||||
/**
|
||||
* Get provider by ID
|
||||
*/
|
||||
export function getProviderById(id: string): ProviderInfo | undefined {
|
||||
return PROVIDERS.find((p) => p.id === id)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all provider IDs
|
||||
*/
|
||||
export function getProviderIds(): string[] {
|
||||
return PROVIDERS.map((p) => p.id)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a provider ID is valid
|
||||
*/
|
||||
export function isValidProviderId(id: string): boolean {
|
||||
return PROVIDERS.some((p) => p.id === id)
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* Secrets storage for API keys
|
||||
* Stores API keys in ~/.cline/secrets.json with restricted permissions
|
||||
*/
|
||||
|
||||
import fs from "fs"
|
||||
import path from "path"
|
||||
import { getDefaultConfigDir } from "../config.js"
|
||||
|
||||
/**
|
||||
* Stored secrets schema
|
||||
*/
|
||||
export interface StoredSecrets {
|
||||
[providerId: string]: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Secrets storage class
|
||||
*/
|
||||
export class SecretsStorage {
|
||||
private secretsPath: string
|
||||
private configDir: string
|
||||
|
||||
constructor(configDir?: string) {
|
||||
this.configDir = configDir || getDefaultConfigDir()
|
||||
this.secretsPath = path.join(this.configDir, "secrets.json")
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the config directory exists with proper permissions
|
||||
*/
|
||||
private ensureConfigDir(): void {
|
||||
if (!fs.existsSync(this.configDir)) {
|
||||
fs.mkdirSync(this.configDir, { recursive: true, mode: 0o700 })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load secrets from disk
|
||||
*/
|
||||
load(): StoredSecrets {
|
||||
try {
|
||||
if (fs.existsSync(this.secretsPath)) {
|
||||
const content = fs.readFileSync(this.secretsPath, "utf-8")
|
||||
return JSON.parse(content) as StoredSecrets
|
||||
}
|
||||
} catch {
|
||||
// Return empty on error
|
||||
}
|
||||
return {}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save secrets to disk with restricted permissions
|
||||
*/
|
||||
save(secrets: StoredSecrets): void {
|
||||
this.ensureConfigDir()
|
||||
fs.writeFileSync(this.secretsPath, JSON.stringify(secrets, null, 2), {
|
||||
mode: 0o600, // Read/write for owner only
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Get API key for a provider
|
||||
*/
|
||||
getApiKey(providerId: string): string | undefined {
|
||||
const secrets = this.load()
|
||||
return secrets[providerId]
|
||||
}
|
||||
|
||||
/**
|
||||
* Set API key for a provider
|
||||
*/
|
||||
setApiKey(providerId: string, apiKey: string): void {
|
||||
const secrets = this.load()
|
||||
secrets[providerId] = apiKey
|
||||
this.save(secrets)
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete API key for a provider
|
||||
*/
|
||||
deleteApiKey(providerId: string): boolean {
|
||||
const secrets = this.load()
|
||||
if (providerId in secrets) {
|
||||
delete secrets[providerId]
|
||||
this.save(secrets)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* List all providers with stored keys
|
||||
*/
|
||||
listProviders(): string[] {
|
||||
const secrets = this.load()
|
||||
return Object.keys(secrets)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a provider has a stored key
|
||||
*/
|
||||
hasApiKey(providerId: string): boolean {
|
||||
const secrets = this.load()
|
||||
return providerId in secrets
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the path to the secrets file
|
||||
*/
|
||||
getSecretsPath(): string {
|
||||
return this.secretsPath
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all secrets
|
||||
*/
|
||||
clear(): void {
|
||||
this.save({})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a secrets storage instance
|
||||
*/
|
||||
export function createSecretsStorage(configDir?: string): SecretsStorage {
|
||||
return new SecretsStorage(configDir)
|
||||
}
|
||||
|
||||
/**
|
||||
* Mask an API key for display (show first/last 4 chars)
|
||||
*/
|
||||
export function maskApiKey(key: string): string {
|
||||
if (key.length <= 8) {
|
||||
return "****"
|
||||
}
|
||||
return `${key.slice(0, 4)}...${key.slice(-4)}`
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
/**
|
||||
* CLI Webview Adapter
|
||||
*
|
||||
* This module bridges the Controller's state updates with terminal output.
|
||||
* It coordinates between state subscriptions and message renderers to format
|
||||
* ClineMessages for display in the terminal.
|
||||
*
|
||||
* Architecture:
|
||||
* - StateSubscriber: Handles gRPC subscriptions and message tracking
|
||||
* - SayMessageRenderer: Renders "say" type messages
|
||||
* - AskMessageRenderer: Renders "ask" type messages
|
||||
* - ToolRenderer: Renders tool operations and approvals
|
||||
* - BrowserActionRenderer: Renders browser actions
|
||||
*/
|
||||
|
||||
import type { ClineMessage } from "@shared/ExtensionMessage"
|
||||
import type { Controller } from "@/core/controller"
|
||||
import {
|
||||
AskMessageRenderer,
|
||||
BrowserActionRenderer,
|
||||
type RenderContext,
|
||||
SayMessageRenderer,
|
||||
ToolRenderer,
|
||||
} from "./message-rendering/index.js"
|
||||
import type { OutputFormatter } from "./output/types.js"
|
||||
import { type ActivitySpinner, createActivitySpinner } from "./spinner.js"
|
||||
import { type StateChangeHandler, StateSubscriber } from "./state-subscription/index.js"
|
||||
|
||||
// Re-export for consumers
|
||||
export type { StateChangeHandler } from "./state-subscription/index.js"
|
||||
|
||||
/**
|
||||
* CLI Webview Adapter class
|
||||
*
|
||||
* Subscribes to Controller state updates and outputs messages to the terminal.
|
||||
* Acts as a coordinator between state subscriptions and message rendering.
|
||||
*/
|
||||
export class CliWebviewAdapter {
|
||||
private stateSubscriber: StateSubscriber
|
||||
private sayRenderer: SayMessageRenderer
|
||||
private askRenderer: AskMessageRenderer
|
||||
private _currentOptions: string[] = []
|
||||
private activitySpinner: ActivitySpinner
|
||||
private isProcessing = false
|
||||
private onStateChange?: StateChangeHandler
|
||||
|
||||
constructor(
|
||||
private controller: Controller,
|
||||
private formatter: OutputFormatter,
|
||||
) {
|
||||
// Create activity spinner that shows after 1 second of inactivity
|
||||
this.activitySpinner = createActivitySpinner({
|
||||
message: "Working hard...",
|
||||
delayMs: 1000,
|
||||
})
|
||||
|
||||
// Create render context for all renderers
|
||||
const renderContext: RenderContext = {
|
||||
formatter: this.formatter,
|
||||
getMessages: () => this.getMessages(),
|
||||
setCurrentOptions: (options: string[]) => {
|
||||
this._currentOptions = options
|
||||
},
|
||||
}
|
||||
|
||||
// Create renderers
|
||||
const toolRenderer = new ToolRenderer(renderContext)
|
||||
const browserRenderer = new BrowserActionRenderer(renderContext)
|
||||
this.sayRenderer = new SayMessageRenderer(renderContext, toolRenderer, browserRenderer)
|
||||
this.askRenderer = new AskMessageRenderer(renderContext, toolRenderer)
|
||||
|
||||
// Create state subscriber
|
||||
this.stateSubscriber = new StateSubscriber(this.controller, {
|
||||
onStateChange: (messages) => this.onStateChange?.(messages),
|
||||
onCompleteMessage: (msg) => this.outputMessage(msg),
|
||||
getMessages: () => this.getMessages(),
|
||||
onActivity: () => {
|
||||
if (this.isProcessing) {
|
||||
this.activitySpinner.reportActivity()
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current options for numbered selection
|
||||
*/
|
||||
get currentOptions(): string[] {
|
||||
return this._currentOptions
|
||||
}
|
||||
|
||||
/**
|
||||
* Set whether the AI is currently processing
|
||||
*
|
||||
* When processing is true, the spinner will start monitoring for inactivity.
|
||||
* When processing is false (e.g., waiting for user input), the spinner is disabled.
|
||||
*/
|
||||
setProcessing(processing: boolean): void {
|
||||
this.isProcessing = processing
|
||||
this.activitySpinner.setEnabled(processing)
|
||||
|
||||
if (processing) {
|
||||
// Start monitoring for inactivity
|
||||
this.activitySpinner.startMonitoring("Processing...")
|
||||
} else {
|
||||
// Stop spinner when not processing
|
||||
this.activitySpinner.stop()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start listening for state updates
|
||||
*
|
||||
* @param onStateChange - Optional callback for raw state changes
|
||||
*/
|
||||
startListening(onStateChange?: StateChangeHandler): void {
|
||||
this.onStateChange = onStateChange
|
||||
this.stateSubscriber.start()
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop listening for state updates
|
||||
*/
|
||||
stopListening(): void {
|
||||
this.stateSubscriber.stop()
|
||||
this.activitySpinner.stop()
|
||||
}
|
||||
|
||||
/**
|
||||
* Output a ClineMessage to the terminal
|
||||
*/
|
||||
outputMessage(msg: ClineMessage): void {
|
||||
if (msg.type === "say") {
|
||||
this.sayRenderer.render(msg)
|
||||
} else if (msg.type === "ask") {
|
||||
this.askRenderer.render(msg)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current messages from the Controller
|
||||
*/
|
||||
getMessages(): ClineMessage[] {
|
||||
return this.controller.task?.messageStateHandler.getClineMessages() || []
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the message counter (useful when starting a new task)
|
||||
*/
|
||||
resetMessageCounter(): void {
|
||||
this.stateSubscriber.reset()
|
||||
}
|
||||
|
||||
/**
|
||||
* Output all current messages (useful for initial display)
|
||||
*/
|
||||
outputAllMessages(): void {
|
||||
const messages = this.getMessages()
|
||||
for (const msg of messages) {
|
||||
if (!msg.partial && !this.stateSubscriber.hasBeenPrinted(msg.ts)) {
|
||||
this.outputMessage(msg)
|
||||
this.stateSubscriber.markPrinted(msg.ts)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import os from "os"
|
||||
import path from "path"
|
||||
import type { CliConfig, PartialCliConfig } from "../types/config.js"
|
||||
import { getDefaultFormat } from "./output/index.js"
|
||||
|
||||
/**
|
||||
* Get the default Cline configuration directory
|
||||
* @returns Path to ~/.cline
|
||||
*/
|
||||
export function getDefaultConfigDir(): string {
|
||||
return path.join(os.homedir(), ".cline")
|
||||
}
|
||||
|
||||
/**
|
||||
* Default CLI configuration values
|
||||
*/
|
||||
export const DEFAULT_CLI_CONFIG: CliConfig = {
|
||||
verbose: false,
|
||||
configDir: getDefaultConfigDir(),
|
||||
outputFormat: getDefaultFormat(),
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a CLI configuration by merging defaults with provided options
|
||||
*/
|
||||
export function createConfig(options: PartialCliConfig = {}): CliConfig {
|
||||
return {
|
||||
...DEFAULT_CLI_CONFIG,
|
||||
...options,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* Console output filtering for CLI mode
|
||||
*
|
||||
* Intercepts all console methods (log, info, debug, warn, error) to suppress
|
||||
* noisy operational messages unless verbose mode is enabled.
|
||||
*
|
||||
* This must be called EARLY in CLI startup, before any other code runs,
|
||||
* to ensure all console output is filtered.
|
||||
*/
|
||||
|
||||
// Store original console methods for restoration
|
||||
const originalConsole = {
|
||||
log: console.log,
|
||||
info: console.info,
|
||||
debug: console.debug,
|
||||
warn: console.warn,
|
||||
error: console.error,
|
||||
}
|
||||
|
||||
// Patterns that indicate noisy operational output
|
||||
export const NOISE_PATTERNS = [
|
||||
// Telemetry & Feature Flags
|
||||
"Telemetry distinct ID",
|
||||
"Changing telemetry ID",
|
||||
"TelemetryService",
|
||||
"TelemetryProviderFactory",
|
||||
"NoOpTelemetryProvider",
|
||||
"NoOpFeatureFlagsProvider",
|
||||
"NoOpErrorProvider",
|
||||
"identifyUser",
|
||||
|
||||
// Storage & Migration
|
||||
"Storage Migration",
|
||||
"FileContextTracker",
|
||||
|
||||
// Checkpoints & Git Operations
|
||||
"CheckpointTracker",
|
||||
"checkpoint",
|
||||
"Checkpoint",
|
||||
"Repository ID",
|
||||
"cwdHash",
|
||||
"shadow git",
|
||||
"Shadow git",
|
||||
"Getting diff count between commits",
|
||||
"diff count",
|
||||
|
||||
// Task & Lock Management
|
||||
"Lock manager not available",
|
||||
"Task lock",
|
||||
"Skipping Checkpoints lock",
|
||||
"Todo file watcher",
|
||||
"[Task",
|
||||
|
||||
// Workspace & Terminal
|
||||
"WorkspaceManager",
|
||||
"TerminalManager",
|
||||
"StandaloneTerminalRegistry",
|
||||
"StandaloneTerminal",
|
||||
|
||||
// Focus Chain
|
||||
"focus chain",
|
||||
"Focus Chain",
|
||||
|
||||
// Server & Initialization
|
||||
"#bot.cline.server.ts",
|
||||
"instantiated",
|
||||
"for legacy",
|
||||
|
||||
// Registry
|
||||
"Registry health check",
|
||||
|
||||
// Debug markers
|
||||
"[DEBUG]",
|
||||
"[OTEL",
|
||||
|
||||
// MCP
|
||||
"[MCP",
|
||||
|
||||
// Component warnings that are not errors
|
||||
"Component '",
|
||||
"Warning: Component",
|
||||
|
||||
// Controller lifecycle (not actual errors)
|
||||
"Controller disposed",
|
||||
|
||||
"[INFO ] Executing command",
|
||||
]
|
||||
|
||||
/**
|
||||
* Check if a message should be suppressed based on noise patterns
|
||||
*/
|
||||
function shouldSuppress(args: unknown[]): boolean {
|
||||
const message = args.map(String).join(" ")
|
||||
return NOISE_PATTERNS.some((pattern) => message.includes(pattern))
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply console filtering to suppress noisy output
|
||||
*
|
||||
* @param verbose - If true, no filtering is applied (all output shown)
|
||||
*/
|
||||
export function applyConsoleFilter(verbose: boolean): void {
|
||||
if (verbose) {
|
||||
// In verbose mode, restore original methods (no filtering)
|
||||
restoreConsole()
|
||||
return
|
||||
}
|
||||
|
||||
// Replace console methods with filtered versions
|
||||
console.log = (...args: unknown[]) => {
|
||||
if (!shouldSuppress(args)) {
|
||||
originalConsole.log.apply(console, args)
|
||||
}
|
||||
}
|
||||
|
||||
console.info = (...args: unknown[]) => {
|
||||
if (!shouldSuppress(args)) {
|
||||
originalConsole.info.apply(console, args)
|
||||
}
|
||||
}
|
||||
|
||||
console.warn = (...args: unknown[]) => {
|
||||
if (!shouldSuppress(args)) {
|
||||
originalConsole.warn.apply(console, args)
|
||||
}
|
||||
}
|
||||
|
||||
console.error = (...args: unknown[]) => {
|
||||
if (!shouldSuppress(args)) {
|
||||
originalConsole.error.apply(console, args)
|
||||
}
|
||||
}
|
||||
|
||||
// Always suppress debug in non-verbose mode
|
||||
console.debug = () => {
|
||||
// No-op
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore original console methods
|
||||
*
|
||||
* Useful for testing or when verbose mode is toggled
|
||||
*/
|
||||
export function restoreConsole(): void {
|
||||
console.log = originalConsole.log
|
||||
console.info = originalConsole.info
|
||||
console.debug = originalConsole.debug
|
||||
console.warn = originalConsole.warn
|
||||
console.error = originalConsole.error
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* Re-export the VSCode context initialization from the standalone module
|
||||
*
|
||||
* This reuses the existing implementation that creates a VSCode-like
|
||||
* ExtensionContext for standalone (non-VSCode) mode.
|
||||
*/
|
||||
export { initializeContext } from "@/standalone/vscode-context"
|
||||
@@ -0,0 +1,188 @@
|
||||
/**
|
||||
* Embedded Controller for CLI
|
||||
*
|
||||
* This module initializes a Cline Controller directly in the CLI process,
|
||||
* allowing CLI commands (chat, send, view) to interact with Cline's AI
|
||||
* without requiring a separate gRPC server.
|
||||
*/
|
||||
|
||||
import { initialize, tearDown } from "@/common"
|
||||
import { Controller } from "@/core/controller"
|
||||
import type { WebviewProvider } from "@/core/webview"
|
||||
import { initializeContext } from "@/standalone/vscode-context"
|
||||
import type { Logger } from "../types/logger.js"
|
||||
import { isHostProviderInitialized, setupHostProvider } from "./host-provider-setup.js"
|
||||
|
||||
// Singleton instance of the embedded controller
|
||||
let embeddedController: Controller | undefined
|
||||
let webviewProvider: WebviewProvider | undefined
|
||||
let initializationPromise: Promise<Controller> | undefined
|
||||
let isInitializing = false
|
||||
|
||||
/**
|
||||
* Get or create an embedded Controller instance for CLI usage
|
||||
*
|
||||
* This function is idempotent - calling it multiple times will return
|
||||
* the same Controller instance.
|
||||
*
|
||||
* @param logger - Logger instance for CLI output
|
||||
* @param configDir - Optional custom config directory (defaults to ~/.cline)
|
||||
* @returns Promise resolving to the Controller instance
|
||||
*/
|
||||
export async function getEmbeddedController(logger: Logger, configDir?: string): Promise<Controller> {
|
||||
// Return existing instance if available
|
||||
if (embeddedController) {
|
||||
return embeddedController
|
||||
}
|
||||
|
||||
// Return in-progress initialization if one exists
|
||||
if (initializationPromise) {
|
||||
return initializationPromise
|
||||
}
|
||||
|
||||
// Start new initialization
|
||||
initializationPromise = initializeEmbeddedController(logger, configDir)
|
||||
|
||||
try {
|
||||
embeddedController = await initializationPromise
|
||||
return embeddedController
|
||||
} catch (error) {
|
||||
// Clear the promise so we can retry
|
||||
initializationPromise = undefined
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the embedded Controller
|
||||
*
|
||||
* @param logger - Logger instance for CLI output
|
||||
* @param configDir - Optional custom config directory
|
||||
* @returns Promise resolving to the Controller instance
|
||||
*/
|
||||
async function initializeEmbeddedController(logger: Logger, configDir?: string): Promise<Controller> {
|
||||
if (isInitializing) {
|
||||
throw new Error("Controller initialization already in progress")
|
||||
}
|
||||
|
||||
isInitializing = true
|
||||
|
||||
try {
|
||||
logger.debug("Initializing embedded controller...")
|
||||
|
||||
// Initialize VSCode-like context with storage directories
|
||||
const { extensionContext, DATA_DIR, EXTENSION_DIR } = initializeContext(configDir)
|
||||
|
||||
logger.debug(`Using data directory: ${DATA_DIR}`)
|
||||
logger.debug(`Using extension directory: ${EXTENSION_DIR}`)
|
||||
|
||||
// Setup HostProvider if not already initialized
|
||||
if (!isHostProviderInitialized()) {
|
||||
setupHostProvider(extensionContext, EXTENSION_DIR, DATA_DIR, logger)
|
||||
logger.debug("HostProvider initialized")
|
||||
}
|
||||
|
||||
// Initialize the extension common components and get WebviewProvider
|
||||
webviewProvider = await initialize(extensionContext)
|
||||
|
||||
// The controller is available via the webviewProvider
|
||||
const controller = webviewProvider.controller
|
||||
|
||||
logger.debug("Embedded controller initialized successfully")
|
||||
|
||||
return controller
|
||||
} catch (error) {
|
||||
logger.error(`Failed to initialize embedded controller: ${error}`)
|
||||
throw error
|
||||
} finally {
|
||||
isInitializing = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current Controller instance without initializing
|
||||
*
|
||||
* @returns The Controller instance if initialized, undefined otherwise
|
||||
*/
|
||||
export function getControllerIfInitialized(): Controller | undefined {
|
||||
return embeddedController
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the embedded Controller is initialized
|
||||
*
|
||||
* @returns true if initialized, false otherwise
|
||||
*/
|
||||
export function isControllerInitialized(): boolean {
|
||||
return embeddedController !== undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispose the embedded Controller and clean up resources
|
||||
*
|
||||
* This should be called when the CLI process exits to ensure
|
||||
* proper cleanup of resources.
|
||||
*
|
||||
* @param logger - Logger instance for output
|
||||
*/
|
||||
export async function disposeEmbeddedController(logger: Logger): Promise<void> {
|
||||
if (!embeddedController) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
logger.debug("Disposing embedded controller...")
|
||||
|
||||
// Dispose the controller
|
||||
await embeddedController.dispose()
|
||||
|
||||
// Tear down common services
|
||||
await tearDown()
|
||||
|
||||
embeddedController = undefined
|
||||
webviewProvider = undefined
|
||||
initializationPromise = undefined
|
||||
|
||||
logger.debug("Embedded controller disposed")
|
||||
} catch (error) {
|
||||
logger.error(`Error disposing embedded controller: ${error}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the WebviewProvider instance
|
||||
*
|
||||
* The WebviewProvider wraps the Controller and provides access to
|
||||
* the webview-related functionality.
|
||||
*
|
||||
* @returns The WebviewProvider instance if initialized, undefined otherwise
|
||||
*/
|
||||
export function getWebviewProvider(): WebviewProvider | undefined {
|
||||
return webviewProvider
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize only the HostProvider for lightweight CLI operations
|
||||
*
|
||||
* This is a minimal initialization that sets up just enough infrastructure
|
||||
* to read task history and messages from disk, without initializing the
|
||||
* full Controller (which starts MCP servers, etc.)
|
||||
*
|
||||
* Use this for read-only operations like `task dump` and `task list`.
|
||||
*
|
||||
* @param logger - Logger instance for CLI output
|
||||
* @param configDir - Optional custom config directory (defaults to ~/.cline)
|
||||
*/
|
||||
export function initializeHostProviderOnly(logger: Logger, configDir?: string): void {
|
||||
if (isHostProviderInitialized()) {
|
||||
return
|
||||
}
|
||||
|
||||
const { extensionContext, DATA_DIR, EXTENSION_DIR } = initializeContext(configDir)
|
||||
|
||||
logger.debug(`Using data directory: ${DATA_DIR}`)
|
||||
logger.debug(`Using extension directory: ${EXTENSION_DIR}`)
|
||||
|
||||
setupHostProvider(extensionContext, EXTENSION_DIR, DATA_DIR, logger)
|
||||
logger.debug("HostProvider initialized (lightweight mode)")
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
import { ExternalCommentReviewController } from "@hosts/external/ExternalCommentReviewController"
|
||||
import { ExternalWebviewProvider } from "@hosts/external/ExternalWebviewProvider"
|
||||
import { execSync } from "child_process"
|
||||
import type { ExtensionContext } from "vscode"
|
||||
import type { WebviewProvider } from "@/core/webview"
|
||||
import { AuthHandler } from "@/hosts/external/AuthHandler"
|
||||
import { HostProvider } from "@/hosts/host-provider"
|
||||
import type { DiffViewProvider } from "@/integrations/editor/DiffViewProvider"
|
||||
import { StandaloneTerminalManager } from "@/integrations/terminal"
|
||||
import { StandaloneDiffViewProvider } from "../integrations/editor/StandaloneDiffViewProvider.js"
|
||||
import type { Logger } from "../types/logger.js"
|
||||
import { NOISE_PATTERNS } from "./console-filter.js"
|
||||
import { StandaloneHostBridgeClient } from "./standalone-hostbridge-client.js"
|
||||
|
||||
/**
|
||||
* Initialize the HostProvider for CLI mode
|
||||
*
|
||||
* This sets up the host provider with CLI-appropriate implementations
|
||||
* of the various providers (webview, diff view, terminal, etc.)
|
||||
*
|
||||
* @param extensionContext - VSCode-like extension context
|
||||
* @param extensionDir - Directory where the extension is installed
|
||||
* @param dataDir - Directory for Cline data storage
|
||||
* @param logger - Logger instance for output
|
||||
*/
|
||||
export function setupHostProvider(
|
||||
extensionContext: ExtensionContext,
|
||||
extensionDir: string,
|
||||
dataDir: string,
|
||||
logger: Logger,
|
||||
): void {
|
||||
const createWebview = (): WebviewProvider => {
|
||||
return new ExternalWebviewProvider(extensionContext)
|
||||
}
|
||||
|
||||
const createDiffView = (): DiffViewProvider => {
|
||||
return new StandaloneDiffViewProvider((message) => logger.info(message))
|
||||
}
|
||||
|
||||
const createCommentReview = () => new ExternalCommentReviewController()
|
||||
|
||||
const createTerminalManager = () => new StandaloneTerminalManager()
|
||||
|
||||
const getCallbackUrl = async (): Promise<string> => {
|
||||
return AuthHandler.getInstance().getCallbackUrl()
|
||||
}
|
||||
|
||||
const getBinaryLocation = async (name: string): Promise<string> => {
|
||||
// For ripgrep, use the bundled @vscode/ripgrep package
|
||||
if (name === "rg") {
|
||||
try {
|
||||
// Dynamic import to handle the external module
|
||||
const { rgPath } = await import("@vscode/ripgrep")
|
||||
return rgPath
|
||||
} catch {
|
||||
// Fallback to system ripgrep if @vscode/ripgrep is not available
|
||||
try {
|
||||
return execSync("which rg", { encoding: "utf-8" }).trim()
|
||||
} catch {
|
||||
throw new Error("ripgrep (rg) not found. Please install it via 'brew install ripgrep' or equivalent.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// For other binaries, try to find them in PATH
|
||||
try {
|
||||
return execSync(`which ${name}`, { encoding: "utf-8" }).trim()
|
||||
} catch {
|
||||
throw new Error(`Binary '${name}' not found in PATH.`)
|
||||
}
|
||||
}
|
||||
|
||||
const logToChannel = (message: string): void => {
|
||||
// Parse log level from message (format: "LEVEL message...")
|
||||
// Core Logger outputs messages as "${level} ${fullMessage}"
|
||||
const parts = message.split(" ")
|
||||
const level = parts[0]?.toUpperCase()
|
||||
const content = parts.slice(1).join(" ")
|
||||
|
||||
// Route to appropriate logger method based on parsed level
|
||||
// This respects the CLI's --verbose flag for DEBUG messages
|
||||
switch (level) {
|
||||
case "DEBUG":
|
||||
case "TRACE":
|
||||
logger.debug(content)
|
||||
break
|
||||
case "WARN":
|
||||
logger.warn(content)
|
||||
break
|
||||
case "ERROR":
|
||||
logger.error(content)
|
||||
break
|
||||
case "INFO":
|
||||
case "LOG":
|
||||
default: {
|
||||
// Filter out noisy INFO patterns (they go to debug instead)
|
||||
const messageToCheck = content || message
|
||||
const isNoise = NOISE_PATTERNS.some((pattern) => messageToCheck.includes(pattern))
|
||||
if (isNoise) {
|
||||
logger.debug(messageToCheck)
|
||||
} else {
|
||||
logger.info(messageToCheck)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
HostProvider.initialize(
|
||||
createWebview,
|
||||
createDiffView,
|
||||
createCommentReview,
|
||||
createTerminalManager,
|
||||
new StandaloneHostBridgeClient(),
|
||||
logToChannel,
|
||||
getCallbackUrl,
|
||||
getBinaryLocation,
|
||||
extensionDir,
|
||||
dataDir,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if HostProvider is already initialized
|
||||
*/
|
||||
export function isHostProviderInitialized(): boolean {
|
||||
return HostProvider.isInitialized()
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the HostProvider (primarily for testing)
|
||||
*/
|
||||
export function resetHostProvider(): void {
|
||||
HostProvider.reset()
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable the AuthHandler for OAuth callback support
|
||||
* Must be called before initiating any OAuth flows
|
||||
*/
|
||||
export function enableAuthHandler(): void {
|
||||
AuthHandler.getInstance().setEnabled(true)
|
||||
}
|
||||
|
||||
/**
|
||||
* Disable and stop the AuthHandler
|
||||
* Should be called when auth is complete or on cleanup
|
||||
*/
|
||||
export function disableAuthHandler(): void {
|
||||
const handler = AuthHandler.getInstance()
|
||||
handler.setEnabled(false)
|
||||
handler.stop()
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import type { Logger } from "../types/logger.js"
|
||||
import { LogLevel } from "../types/logger.js"
|
||||
|
||||
/**
|
||||
* Console-based logger with verbose mode support
|
||||
*/
|
||||
export class ConsoleLogger implements Logger {
|
||||
private verbose: boolean
|
||||
|
||||
constructor(verbose: boolean = false) {
|
||||
this.verbose = verbose
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a message at the given level should be logged
|
||||
*/
|
||||
private shouldLog(level: LogLevel): boolean {
|
||||
// TODO - implement log level filtering
|
||||
if (level === LogLevel.DEBUG) {
|
||||
return this.verbose
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a log message with timestamp and level
|
||||
*/
|
||||
private formatMessage(level: LogLevel, message: string): string {
|
||||
const timestamp = new Date().toISOString()
|
||||
const levelUpper = level.toUpperCase().padEnd(5)
|
||||
return `[${timestamp}] [${levelUpper}] ${message}`
|
||||
}
|
||||
|
||||
debug(message: string, ...args: unknown[]): void {
|
||||
if (this.shouldLog(LogLevel.DEBUG)) {
|
||||
console.debug(this.formatMessage(LogLevel.DEBUG, message), ...args)
|
||||
}
|
||||
}
|
||||
|
||||
info(message: string, ...args: unknown[]): void {
|
||||
if (this.shouldLog(LogLevel.INFO)) {
|
||||
console.info(this.formatMessage(LogLevel.INFO, message), ...args)
|
||||
}
|
||||
}
|
||||
|
||||
warn(message: string, ...args: unknown[]): void {
|
||||
if (this.shouldLog(LogLevel.WARN)) {
|
||||
console.warn(this.formatMessage(LogLevel.WARN, message), ...args)
|
||||
}
|
||||
}
|
||||
|
||||
error(message: string, ...args: unknown[]): void {
|
||||
if (this.shouldLog(LogLevel.ERROR)) {
|
||||
console.error(this.formatMessage(LogLevel.ERROR, message), ...args)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory function to create a logger instance
|
||||
*/
|
||||
export function createLogger(verbose: boolean = false): Logger {
|
||||
return new ConsoleLogger(verbose)
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
/**
|
||||
* Ask Message Renderer
|
||||
*
|
||||
* Handles rendering of "ask" type ClineMessages, which require user input
|
||||
* or approval. Also includes MCP server approval rendering.
|
||||
*/
|
||||
|
||||
import type { ClineAskQuestion, ClineAskUseMcpServer, ClineMessage, ClinePlanModeResponse } from "@shared/ExtensionMessage"
|
||||
import { renderMarkdown } from "./markdown-renderer.js"
|
||||
import type { ToolRenderer } from "./tool-renderer.js"
|
||||
import type { RenderContext } from "./types.js"
|
||||
|
||||
/**
|
||||
* AskMessageRenderer class
|
||||
*
|
||||
* Renders "ask" type messages to the terminal, including:
|
||||
* - Followup questions with options
|
||||
* - Command approval requests
|
||||
* - Tool approval requests
|
||||
* - API failure prompts
|
||||
* - Browser launch approval
|
||||
* - MCP server approval
|
||||
* - Plan mode responses
|
||||
*/
|
||||
export class AskMessageRenderer {
|
||||
constructor(
|
||||
private ctx: RenderContext,
|
||||
private toolRenderer: ToolRenderer,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Render an "ask" type message
|
||||
*
|
||||
* @param msg - The ClineMessage to render
|
||||
*/
|
||||
render(msg: ClineMessage): void {
|
||||
const ask = msg.ask
|
||||
|
||||
switch (ask) {
|
||||
case "followup":
|
||||
this.renderFollowupQuestion(msg)
|
||||
break
|
||||
|
||||
case "plan_mode_respond":
|
||||
this.renderPlanModeResponse(msg)
|
||||
break
|
||||
|
||||
case "command":
|
||||
this.ctx.formatter.raw(`\n💻 Execute command?`)
|
||||
this.ctx.formatter.raw(` $ ${msg.text || ""}`)
|
||||
this.ctx.formatter.info(" [y/n] or [yy to auto-approve commands]")
|
||||
break
|
||||
|
||||
case "tool":
|
||||
this.toolRenderer.renderToolApproval(msg)
|
||||
break
|
||||
|
||||
case "api_req_failed":
|
||||
this.ctx.formatter.error(`\n❌ API request failed`)
|
||||
if (msg.text) {
|
||||
this.ctx.formatter.raw(` ${msg.text}`)
|
||||
}
|
||||
this.ctx.formatter.info(" [retry/cancel]")
|
||||
break
|
||||
|
||||
case "resume_task":
|
||||
// this.ctx.formatter.info(`\n⏸ Task paused. Resume?`)
|
||||
// this.ctx.formatter.info(" [yes/no]")
|
||||
break
|
||||
|
||||
case "completion_result":
|
||||
// TODO end process if yolo mode
|
||||
this.ctx.formatter.success(`\n✅ Task Complete!`)
|
||||
if (msg.text) {
|
||||
this.ctx.formatter.raw(msg.text)
|
||||
}
|
||||
break
|
||||
|
||||
case "browser_action_launch":
|
||||
this.ctx.formatter.raw(`\n🌐 Launch browser?`)
|
||||
if (msg.text) {
|
||||
this.ctx.formatter.raw(` URL: ${msg.text}`)
|
||||
}
|
||||
this.ctx.formatter.info(" [y/n] or [yy to auto-approve browser]")
|
||||
break
|
||||
|
||||
case "use_mcp_server":
|
||||
this.renderMcpServerApproval(msg)
|
||||
break
|
||||
|
||||
case "mistake_limit_reached":
|
||||
this.ctx.formatter.warn(`\n! Mistake limit reached`)
|
||||
if (msg.text) {
|
||||
this.ctx.formatter.raw(msg.text)
|
||||
}
|
||||
this.ctx.formatter.info(" [continue/stop]")
|
||||
break
|
||||
|
||||
default:
|
||||
if (msg.text) {
|
||||
this.ctx.formatter.raw(`\n❓ ${msg.text}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a followup question with numbered options
|
||||
*
|
||||
* @param msg - The ClineMessage with question information
|
||||
*/
|
||||
private renderFollowupQuestion(msg: ClineMessage): void {
|
||||
// Clear previous options
|
||||
this.ctx.setCurrentOptions([])
|
||||
|
||||
if (!msg.text) {
|
||||
this.ctx.formatter.raw(`\n❓ Question`)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const question = JSON.parse(msg.text) as ClineAskQuestion
|
||||
this.ctx.formatter.raw(`\n❓ ${question.question}`)
|
||||
|
||||
// Display options as numbered list if present
|
||||
if (question.options && question.options.length > 0) {
|
||||
this.ctx.setCurrentOptions(question.options)
|
||||
this.ctx.formatter.raw("")
|
||||
for (let i = 0; i < question.options.length; i++) {
|
||||
this.ctx.formatter.raw(` ${i + 1}. ${question.options[i]}`)
|
||||
}
|
||||
this.ctx.formatter.raw("")
|
||||
this.ctx.formatter.info(" Enter a number to select, or type your response:")
|
||||
}
|
||||
} catch {
|
||||
// Not JSON, output as plain text
|
||||
this.ctx.formatter.raw(`\n❓ ${msg.text}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a plan mode response with markdown rendering
|
||||
*
|
||||
* @param msg - The ClineMessage with plan mode response
|
||||
*/
|
||||
private renderPlanModeResponse(msg: ClineMessage): void {
|
||||
// Clear previous options
|
||||
this.ctx.setCurrentOptions([])
|
||||
|
||||
if (!msg.text) {
|
||||
this.ctx.formatter.info(`\n📝 Plan Mode Response Required`)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const planResponse = JSON.parse(msg.text) as ClinePlanModeResponse
|
||||
|
||||
this.ctx.formatter.raw("")
|
||||
// Render the markdown response
|
||||
const rendered = renderMarkdown(planResponse.response)
|
||||
this.ctx.formatter.raw(rendered)
|
||||
|
||||
// Display options as numbered list if present
|
||||
if (planResponse.options && planResponse.options.length > 0) {
|
||||
this.ctx.setCurrentOptions(planResponse.options)
|
||||
this.ctx.formatter.raw("")
|
||||
for (let i = 0; i < planResponse.options.length; i++) {
|
||||
this.ctx.formatter.raw(` ${i + 1}. ${planResponse.options[i]}`)
|
||||
}
|
||||
this.ctx.formatter.raw("")
|
||||
this.ctx.formatter.info(" Enter a number to select, or type your response:")
|
||||
} else {
|
||||
this.ctx.formatter.raw("")
|
||||
this.ctx.formatter.info(" Toggle to Act mode to execute, or provide feedback:")
|
||||
}
|
||||
} catch {
|
||||
// Not JSON, output as plain text
|
||||
this.ctx.formatter.info(`\n📝 Plan Mode Response Required`)
|
||||
this.ctx.formatter.raw(msg.text)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render MCP server approval request
|
||||
*
|
||||
* @param msg - The ClineMessage with MCP server information
|
||||
*/
|
||||
private renderMcpServerApproval(msg: ClineMessage): void {
|
||||
if (!msg.text) {
|
||||
this.ctx.formatter.raw(`\n🔌 MCP server approval required`)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const mcp = JSON.parse(msg.text) as ClineAskUseMcpServer
|
||||
this.ctx.formatter.raw(`\n🔌 MCP: ${mcp.serverName}`)
|
||||
if (mcp.type === "use_mcp_tool" && mcp.toolName) {
|
||||
this.ctx.formatter.raw(` Tool: ${mcp.toolName}`)
|
||||
if (mcp.arguments) {
|
||||
this.ctx.formatter.raw(` Args: ${mcp.arguments}`)
|
||||
}
|
||||
} else if (mcp.type === "access_mcp_resource" && mcp.uri) {
|
||||
this.ctx.formatter.raw(` Resource: ${mcp.uri}`)
|
||||
}
|
||||
this.ctx.formatter.info(" [y/n] or [yy to auto-approve MCP]")
|
||||
} catch {
|
||||
this.ctx.formatter.raw(`\n🔌 MCP approval: ${msg.text}`)
|
||||
this.ctx.formatter.info(" [y/n] or [yy to auto-approve MCP]")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* Browser Action Renderer
|
||||
*
|
||||
* Handles rendering of browser-related messages including browser actions
|
||||
* and their results.
|
||||
*/
|
||||
|
||||
import type { BrowserActionResult, ClineMessage, ClineSayBrowserAction } from "@shared/ExtensionMessage"
|
||||
import type { RenderContext } from "./types.js"
|
||||
|
||||
/**
|
||||
* BrowserActionRenderer class
|
||||
*
|
||||
* Renders browser action messages to the terminal, including:
|
||||
* - Browser launch, click, type, scroll, and close actions
|
||||
* - Browser action results with URL and console output
|
||||
*/
|
||||
export class BrowserActionRenderer {
|
||||
constructor(private ctx: RenderContext) {}
|
||||
|
||||
/**
|
||||
* Render a browser action message
|
||||
*
|
||||
* @param msg - The ClineMessage with browser action information
|
||||
*/
|
||||
renderBrowserAction(msg: ClineMessage): void {
|
||||
if (!msg.text) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const action = JSON.parse(msg.text) as ClineSayBrowserAction
|
||||
switch (action.action) {
|
||||
case "launch":
|
||||
this.ctx.formatter.raw(`\n🌐 Browser: Launching...`)
|
||||
break
|
||||
case "click":
|
||||
this.ctx.formatter.raw(`\n🖱 Browser: Click at ${action.coordinate || "position"}`)
|
||||
break
|
||||
case "type":
|
||||
this.ctx.formatter.raw(`\n⌨ Browser: Type "${action.text || ""}"`)
|
||||
break
|
||||
case "scroll_down":
|
||||
this.ctx.formatter.raw(`\n📜 Browser: Scroll down`)
|
||||
break
|
||||
case "scroll_up":
|
||||
this.ctx.formatter.raw(`\n📜 Browser: Scroll up`)
|
||||
break
|
||||
case "close":
|
||||
this.ctx.formatter.raw(`\n🌐 Browser: Closing...`)
|
||||
break
|
||||
}
|
||||
} catch {
|
||||
this.ctx.formatter.raw(`\n🌐 Browser: ${msg.text}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a browser action result message
|
||||
*
|
||||
* @param msg - The ClineMessage with browser action result
|
||||
*/
|
||||
renderBrowserActionResult(msg: ClineMessage): void {
|
||||
if (!msg.text) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const result = JSON.parse(msg.text) as BrowserActionResult
|
||||
if (result.currentUrl) {
|
||||
this.ctx.formatter.raw(` URL: ${result.currentUrl}`)
|
||||
}
|
||||
if (result.logs) {
|
||||
this.ctx.formatter.raw(` Console: ${result.logs}`)
|
||||
}
|
||||
// Note: Screenshots are not displayed in terminal
|
||||
if (result.screenshot) {
|
||||
this.ctx.formatter.raw(` 📷 Screenshot captured`)
|
||||
}
|
||||
} catch {
|
||||
this.ctx.formatter.raw(` ${msg.text}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* Message Rendering Module
|
||||
*
|
||||
* Provides modular, testable renderers for formatting ClineMessages
|
||||
* for terminal output.
|
||||
*/
|
||||
|
||||
// Renderers
|
||||
export { AskMessageRenderer } from "./ask-message-renderer.js"
|
||||
export { BrowserActionRenderer } from "./browser-action-renderer.js"
|
||||
// Utilities
|
||||
export { renderMarkdown } from "./markdown-renderer.js"
|
||||
export { SayMessageRenderer } from "./say-message-renderer.js"
|
||||
export { ToolRenderer } from "./tool-renderer.js"
|
||||
// Types
|
||||
export type { MessageRenderer, RenderContext } from "./types.js"
|
||||
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* Markdown Renderer
|
||||
*
|
||||
* Handles converting markdown text to terminal-formatted output
|
||||
* using the marked library with terminal-specific styling.
|
||||
*/
|
||||
|
||||
import chalk from "chalk"
|
||||
import { type MarkedExtension, marked } from "marked"
|
||||
import { markedTerminal } from "marked-terminal"
|
||||
|
||||
// Configure marked with terminal renderer
|
||||
// Note: @types/marked-terminal is outdated and returns wrong type, cast to MarkedExtension
|
||||
marked.use(
|
||||
markedTerminal({
|
||||
heading: chalk.cyan.bold,
|
||||
firstHeading: chalk.magenta.bold.underline,
|
||||
strong: chalk.yellow.bold,
|
||||
em: chalk.blue.italic,
|
||||
codespan: chalk.greenBright,
|
||||
}) as unknown as MarkedExtension,
|
||||
)
|
||||
|
||||
/**
|
||||
* Render markdown text to terminal-formatted output
|
||||
*
|
||||
* @param text - Markdown text to render
|
||||
* @returns Terminal-formatted string
|
||||
*/
|
||||
export function renderMarkdown(text: string): string {
|
||||
try {
|
||||
const rendered = marked.parse(text)
|
||||
// marked.parse returns string | Promise<string>, we only use sync mode
|
||||
return (typeof rendered === "string" ? rendered : text).trim()
|
||||
} catch {
|
||||
return text
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
/**
|
||||
* Say Message Renderer
|
||||
*
|
||||
* Handles rendering of "say" type ClineMessages, which are informational
|
||||
* messages that don't require user input.
|
||||
*/
|
||||
|
||||
import type { ClineApiReqInfo, ClineMessage } from "@shared/ExtensionMessage"
|
||||
import { getApiMetrics } from "@shared/getApiMetrics"
|
||||
import type { BrowserActionRenderer } from "./browser-action-renderer.js"
|
||||
import { renderMarkdown } from "./markdown-renderer.js"
|
||||
import type { ToolRenderer } from "./tool-renderer.js"
|
||||
import type { RenderContext } from "./types.js"
|
||||
|
||||
/**
|
||||
* SayMessageRenderer class
|
||||
*
|
||||
* Renders "say" type messages to the terminal, including:
|
||||
* - Task information
|
||||
* - AI text and reasoning
|
||||
* - Errors and retries
|
||||
* - API request status
|
||||
* - Command output
|
||||
* - Tool and browser actions
|
||||
* - Checkpoints
|
||||
*/
|
||||
export class SayMessageRenderer {
|
||||
constructor(
|
||||
private ctx: RenderContext,
|
||||
private toolRenderer: ToolRenderer,
|
||||
private browserRenderer: BrowserActionRenderer,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Render a "say" type message
|
||||
*
|
||||
* @param msg - The ClineMessage to render
|
||||
*/
|
||||
render(msg: ClineMessage): void {
|
||||
const say = msg.say
|
||||
|
||||
switch (say) {
|
||||
case "task":
|
||||
this.ctx.formatter.info(`\n📋 Task: ${msg.text || ""}`)
|
||||
break
|
||||
|
||||
case "text":
|
||||
case "reasoning":
|
||||
if (msg.text) {
|
||||
// Check if this is reasoning content
|
||||
if (say === "reasoning" || msg.reasoning) {
|
||||
this.ctx.formatter.raw(`💭 ${msg.reasoning || msg.text}`)
|
||||
} else {
|
||||
this.ctx.formatter.raw(msg.text)
|
||||
}
|
||||
}
|
||||
break
|
||||
|
||||
case "error":
|
||||
this.ctx.formatter.error(`❌ ${msg.text || "An error occurred"}`)
|
||||
break
|
||||
|
||||
case "error_retry":
|
||||
this.ctx.formatter.warn(`🔄 Retrying: ${msg.text || ""}`)
|
||||
break
|
||||
|
||||
case "api_req_started":
|
||||
this.renderApiReqStarted()
|
||||
break
|
||||
|
||||
case "api_req_finished":
|
||||
this.renderApiReqFinished(msg)
|
||||
break
|
||||
|
||||
case "completion_result":
|
||||
this.ctx.formatter.success(`\n✨ "Task completed"`)
|
||||
this.ctx.formatter.raw(renderMarkdown(msg.text || ""))
|
||||
break
|
||||
|
||||
case "user_feedback":
|
||||
this.ctx.formatter.info(`📝 User: ${msg.text || ""}`)
|
||||
break
|
||||
|
||||
case "command":
|
||||
this.ctx.formatter.code(`\n$ ${msg.text || ""}`)
|
||||
break
|
||||
|
||||
case "command_output":
|
||||
if (msg.text) {
|
||||
// Indent command output
|
||||
const lines = msg.text.split("\n")
|
||||
for (const line of lines) {
|
||||
this.ctx.formatter.raw(` ${line}`)
|
||||
}
|
||||
}
|
||||
break
|
||||
|
||||
case "tool":
|
||||
this.toolRenderer.renderToolMessage(msg)
|
||||
break
|
||||
|
||||
case "browser_action":
|
||||
this.browserRenderer.renderBrowserAction(msg)
|
||||
break
|
||||
|
||||
case "browser_action_result":
|
||||
this.browserRenderer.renderBrowserActionResult(msg)
|
||||
break
|
||||
|
||||
case "mcp_server_request_started":
|
||||
this.ctx.formatter.info(`🔌 MCP request: ${msg.text || ""}`)
|
||||
break
|
||||
|
||||
case "mcp_server_response":
|
||||
this.ctx.formatter.raw(` Response: ${msg.text || ""}`)
|
||||
break
|
||||
|
||||
case "checkpoint_created":
|
||||
// Display checkpoint ID (timestamp) so users can reference it for /restore
|
||||
const hashInfo = msg.lastCheckpointHash ? ` (${msg.lastCheckpointHash.slice(0, 8)})` : ""
|
||||
this.ctx.formatter.info(`💾 Checkpoint created [ID: ${msg.ts}]${hashInfo}`)
|
||||
break
|
||||
|
||||
case "shell_integration_warning":
|
||||
this.ctx.formatter.warn(`! Shell integration: ${msg.text || ""}`)
|
||||
break
|
||||
|
||||
case "diff_error":
|
||||
this.ctx.formatter.error(`❌ Diff error: ${msg.text || ""}`)
|
||||
break
|
||||
|
||||
case "task_progress":
|
||||
this.ctx.formatter.info("Making Progress...")
|
||||
break
|
||||
|
||||
default:
|
||||
// Handle any other say types
|
||||
if (msg.text) {
|
||||
this.ctx.formatter.raw(msg.text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render API request started message with cumulative session metrics
|
||||
*/
|
||||
private renderApiReqStarted(): void {
|
||||
// Show cumulative session token usage
|
||||
const messages = this.ctx.getMessages()
|
||||
const metrics = getApiMetrics(messages)
|
||||
const parts: string[] = []
|
||||
|
||||
// Token counts
|
||||
parts.push(`${metrics.totalTokensIn.toLocaleString()} in / ${metrics.totalTokensOut.toLocaleString()} out`)
|
||||
|
||||
// Cache info if available
|
||||
if (metrics.totalCacheReads !== undefined || metrics.totalCacheWrites !== undefined) {
|
||||
const cacheReads = metrics.totalCacheReads ?? 0
|
||||
const cacheWrites = metrics.totalCacheWrites ?? 0
|
||||
parts.push(`cache: ${cacheReads.toLocaleString()}r/${cacheWrites.toLocaleString()}w`)
|
||||
}
|
||||
|
||||
// Cost
|
||||
if (metrics.totalCost > 0) {
|
||||
parts.push(`$${metrics.totalCost.toFixed(4)}`)
|
||||
}
|
||||
|
||||
this.ctx.formatter.info(`🔄 API request started... [Session: ${parts.join(" | ")}]`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Render API request finished message with token counts and cost
|
||||
*/
|
||||
private renderApiReqFinished(msg: ClineMessage): void {
|
||||
if (msg.text) {
|
||||
try {
|
||||
const info = JSON.parse(msg.text) as ClineApiReqInfo
|
||||
const tokens = `${info.tokensIn || 0} in / ${info.tokensOut || 0} out`
|
||||
const cost = info.cost ? ` ($${info.cost.toFixed(4)})` : ""
|
||||
this.ctx.formatter.success(`✅ API request complete: ${tokens}${cost}`)
|
||||
} catch {
|
||||
this.ctx.formatter.success("✅ API request complete")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* Tool Renderer
|
||||
*
|
||||
* Handles rendering of tool-related messages including tool operations,
|
||||
* tool approval requests, and diff output formatting.
|
||||
*/
|
||||
|
||||
import type { ClineMessage, ClineSayTool } from "@shared/ExtensionMessage"
|
||||
import type { RenderContext } from "./types.js"
|
||||
|
||||
/**
|
||||
* ToolRenderer class
|
||||
*
|
||||
* Renders tool-related messages to the terminal, including:
|
||||
* - Tool operation results (file edits, reads, searches, etc.)
|
||||
* - Tool approval requests with diffs
|
||||
* - Diff formatting with color-coded additions/deletions
|
||||
*/
|
||||
export class ToolRenderer {
|
||||
constructor(private ctx: RenderContext) {}
|
||||
|
||||
/**
|
||||
* Render a tool operation message (say type)
|
||||
*
|
||||
* @param msg - The ClineMessage with tool information
|
||||
*/
|
||||
renderToolMessage(msg: ClineMessage): void {
|
||||
if (!msg.text) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const tool = JSON.parse(msg.text) as ClineSayTool
|
||||
switch (tool.tool) {
|
||||
case "editedExistingFile":
|
||||
this.ctx.formatter.raw(`\n📝 Edited: ${tool.path || "file"}`)
|
||||
if (tool.diff) {
|
||||
this.renderDiff(tool.diff)
|
||||
}
|
||||
break
|
||||
|
||||
case "newFileCreated":
|
||||
this.ctx.formatter.raw(`\n📄 Created: ${tool.path || "file"}`)
|
||||
break
|
||||
|
||||
case "fileDeleted":
|
||||
this.ctx.formatter.raw(`\n🗑 Deleted: ${tool.path || "file"}`)
|
||||
break
|
||||
|
||||
case "readFile":
|
||||
this.ctx.formatter.raw(`\n📖 Read: ${tool.path || "file"}`)
|
||||
break
|
||||
|
||||
case "listFilesTopLevel":
|
||||
case "listFilesRecursive":
|
||||
this.ctx.formatter.raw(`\n📂 Listed: ${tool.path || "directory"}`)
|
||||
break
|
||||
|
||||
case "searchFiles":
|
||||
this.ctx.formatter.raw(`\n🔍 Searched: ${tool.regex || "pattern"} in ${tool.path || "directory"}`)
|
||||
break
|
||||
|
||||
case "webFetch":
|
||||
case "webSearch":
|
||||
this.ctx.formatter.raw(`\n🌐 Web: ${tool.content || ""}`)
|
||||
break
|
||||
|
||||
default:
|
||||
this.ctx.formatter.raw(`\n🔧 Tool: ${tool.tool}`)
|
||||
}
|
||||
} catch {
|
||||
// Not JSON, just output raw
|
||||
this.ctx.formatter.raw(`\n🔧 ${msg.text}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a tool approval request (ask type)
|
||||
*
|
||||
* @param msg - The ClineMessage with tool approval information
|
||||
*/
|
||||
renderToolApproval(msg: ClineMessage): void {
|
||||
if (!msg.text) {
|
||||
this.ctx.formatter.raw(`\n🔧 Tool approval required`)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const tool = JSON.parse(msg.text) as ClineSayTool
|
||||
this.ctx.formatter.raw(`\n🔧 Approve ${tool.tool}?`)
|
||||
if (tool.path) {
|
||||
this.ctx.formatter.raw(` Path: ${tool.path}`)
|
||||
}
|
||||
// Check both diff and content fields - the extension stores diffs in content field
|
||||
const diffContent = tool.diff || tool.content
|
||||
if (diffContent && (tool.tool === "editedExistingFile" || tool.tool === "newFileCreated")) {
|
||||
this.renderDiff(diffContent)
|
||||
}
|
||||
// Show appropriate auto-approve hint based on tool type
|
||||
const autoApproveHint = this.getAutoApproveHint(tool.tool)
|
||||
this.ctx.formatter.info(` [y/n]${autoApproveHint}`)
|
||||
} catch {
|
||||
this.ctx.formatter.raw(`\n🔧 Tool approval: ${msg.text}`)
|
||||
this.ctx.formatter.info(" [y/n] or [yy to auto-approve]")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render diff content with color-coded additions and deletions
|
||||
*
|
||||
* @param diff - The diff string to render
|
||||
*/
|
||||
renderDiff(diff: string): void {
|
||||
const lines = diff.split("\n")
|
||||
for (const line of lines) {
|
||||
if (line.startsWith("+")) {
|
||||
this.ctx.formatter.raw(` \x1b[32m${line}\x1b[0m`) // Green for additions
|
||||
} else if (line.startsWith("-")) {
|
||||
this.ctx.formatter.raw(` \x1b[31m${line}\x1b[0m`) // Red for deletions
|
||||
} else {
|
||||
this.ctx.formatter.raw(` ${line}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the auto-approve hint text based on tool type
|
||||
*
|
||||
* @param toolType - The type of tool being approved
|
||||
* @returns Hint text for auto-approval
|
||||
*/
|
||||
getAutoApproveHint(toolType: string): string {
|
||||
switch (toolType) {
|
||||
case "editedExistingFile":
|
||||
case "newFileCreated":
|
||||
case "fileDeleted":
|
||||
return " or [yy to auto-approve edits]"
|
||||
case "readFile":
|
||||
case "listFilesTopLevel":
|
||||
case "listFilesRecursive":
|
||||
case "listCodeDefinitionNames":
|
||||
case "searchFiles":
|
||||
case "webFetch":
|
||||
case "webSearch":
|
||||
return " or [yy to auto-approve reads]"
|
||||
default:
|
||||
return " or [yy to auto-approve]"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* Types and interfaces for message rendering
|
||||
*
|
||||
* These types define the contract between the CliWebviewAdapter and
|
||||
* the various message renderers, enabling modular and testable rendering.
|
||||
*/
|
||||
|
||||
import type { ClineMessage } from "@shared/ExtensionMessage"
|
||||
import type { OutputFormatter } from "../output/types.js"
|
||||
|
||||
/**
|
||||
* Context provided to renderers for outputting messages
|
||||
*
|
||||
* This allows renderers to access shared resources without
|
||||
* creating circular dependencies with the adapter.
|
||||
*/
|
||||
export interface RenderContext {
|
||||
/** Formatter for outputting to the terminal */
|
||||
formatter: OutputFormatter
|
||||
|
||||
/** Get current messages from the controller */
|
||||
getMessages: () => ClineMessage[]
|
||||
|
||||
/** Set the current options for numbered selection (used by followup questions) */
|
||||
setCurrentOptions: (options: string[]) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Interface for message renderers
|
||||
*
|
||||
* Each renderer is responsible for a specific category of messages
|
||||
* (e.g., say messages, ask messages, tool messages).
|
||||
*/
|
||||
export interface MessageRenderer {
|
||||
/**
|
||||
* Render a message to the terminal
|
||||
*
|
||||
* @param msg - The ClineMessage to render
|
||||
*/
|
||||
render(msg: ClineMessage): void
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* Output formatting system - factory and exports
|
||||
*/
|
||||
|
||||
import { createJsonFormatter } from "./json-formatter.js"
|
||||
import { createPlainFormatter } from "./plain-formatter.js"
|
||||
import { createRichFormatter } from "./rich-formatter.js"
|
||||
import type { OutputFormat, OutputFormatter } from "./types.js"
|
||||
|
||||
// Re-export formatter classes for direct use if needed
|
||||
export { JsonFormatter } from "./json-formatter.js"
|
||||
export { PlainFormatter } from "./plain-formatter.js"
|
||||
export { RichFormatter } from "./rich-formatter.js"
|
||||
// Re-export types
|
||||
export type { ClineMessage, OutputFormat, OutputFormatter, TaskInfo } from "./types.js"
|
||||
|
||||
/**
|
||||
* Default output format based on TTY detection
|
||||
*/
|
||||
export function getDefaultFormat(): OutputFormat {
|
||||
// Use rich format if stdout is a TTY, otherwise plain
|
||||
return process.stdout.isTTY ? "rich" : "plain"
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that a string is a valid output format
|
||||
*/
|
||||
export function isValidFormat(format: string): format is OutputFormat {
|
||||
return format === "rich" || format === "json" || format === "plain"
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse output format from string, with validation
|
||||
*/
|
||||
export function parseOutputFormat(format: string | undefined): OutputFormat {
|
||||
if (!format) {
|
||||
return getDefaultFormat()
|
||||
}
|
||||
|
||||
if (!isValidFormat(format)) {
|
||||
throw new Error(`Invalid output format: ${format}. Valid options are: rich, json, plain`)
|
||||
}
|
||||
|
||||
return format
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an output formatter based on the specified format
|
||||
*/
|
||||
export function createFormatter(format: OutputFormat): OutputFormatter {
|
||||
switch (format) {
|
||||
case "json":
|
||||
return createJsonFormatter()
|
||||
case "plain":
|
||||
return createPlainFormatter()
|
||||
case "rich":
|
||||
return createRichFormatter()
|
||||
default:
|
||||
// TypeScript exhaustiveness check
|
||||
const _exhaustive: never = format
|
||||
throw new Error(`Unknown output format: ${_exhaustive}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an output formatter from an optional format string.
|
||||
* Uses default format if undefined or invalid.
|
||||
*/
|
||||
export function createFormatterFromOption(format: string | undefined): OutputFormatter {
|
||||
return createFormatter(parseOutputFormat(format))
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* JSON output formatter - structured output for scripting/automation
|
||||
* Each output is a single JSON line for easy parsing
|
||||
*/
|
||||
|
||||
import type { ClineMessage, OutputFormatter, TaskInfo } from "./types.js"
|
||||
|
||||
/**
|
||||
* JSON output wrapper type
|
||||
*/
|
||||
interface JsonOutput {
|
||||
type: "message" | "error" | "success" | "warn" | "info" | "table" | "list" | "tasks" | "keyValue" | "raw" | "code"
|
||||
data: unknown
|
||||
ts: number
|
||||
}
|
||||
|
||||
/**
|
||||
* JSON formatter implementation
|
||||
*/
|
||||
export class JsonFormatter implements OutputFormatter {
|
||||
/**
|
||||
* Output a JSON line to stdout
|
||||
*/
|
||||
private output(type: JsonOutput["type"], data: unknown): void {
|
||||
const output: JsonOutput = {
|
||||
type,
|
||||
data,
|
||||
ts: Date.now(),
|
||||
}
|
||||
console.log(JSON.stringify(output))
|
||||
}
|
||||
|
||||
message(msg: ClineMessage): void {
|
||||
this.output("message", msg)
|
||||
}
|
||||
|
||||
error(err: Error | string): void {
|
||||
const data = err instanceof Error ? { message: err.message, name: err.name, stack: err.stack } : { message: err }
|
||||
this.output("error", data)
|
||||
}
|
||||
|
||||
success(text: string): void {
|
||||
this.output("success", { message: text })
|
||||
}
|
||||
|
||||
warn(text: string): void {
|
||||
this.output("warn", { message: text })
|
||||
}
|
||||
|
||||
info(text: string): void {
|
||||
this.output("info", { message: text })
|
||||
}
|
||||
|
||||
table(data: Record<string, unknown>[], columns?: string[]): void {
|
||||
this.output("table", { rows: data, columns: columns || (data.length > 0 ? Object.keys(data[0]) : []) })
|
||||
}
|
||||
|
||||
list(items: string[]): void {
|
||||
this.output("list", { items })
|
||||
}
|
||||
|
||||
tasks(tasks: TaskInfo[]): void {
|
||||
this.output("tasks", { tasks })
|
||||
}
|
||||
|
||||
keyValue(data: Record<string, unknown>): void {
|
||||
this.output("keyValue", data)
|
||||
}
|
||||
|
||||
raw(text: string): void {
|
||||
this.output("raw", { content: text })
|
||||
}
|
||||
|
||||
code(code: any): void {
|
||||
this.output("code", { code })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a JSON formatter instance
|
||||
*/
|
||||
export function createJsonFormatter(): OutputFormatter {
|
||||
return new JsonFormatter()
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* Plain text output formatter - no colors, no styling
|
||||
* Suitable for piping to other commands or redirecting to files
|
||||
*/
|
||||
|
||||
import type { ClineMessage, OutputFormatter, TaskInfo } from "./types.js"
|
||||
|
||||
// Store reference to original stdout.write to bypass console filtering
|
||||
const stdoutWrite = process.stdout.write.bind(process.stdout)
|
||||
|
||||
/**
|
||||
* Plain text formatter implementation
|
||||
*/
|
||||
export class PlainFormatter implements OutputFormatter {
|
||||
message(msg: ClineMessage): void {
|
||||
const timestamp = new Date(msg.ts).toISOString()
|
||||
const prefix = msg.type === "ask" ? "[?]" : "[>]"
|
||||
const subtype = msg.say || msg.ask || ""
|
||||
const subtypeStr = subtype ? ` (${subtype})` : ""
|
||||
|
||||
if (msg.text) {
|
||||
console.log(`${prefix}${subtypeStr} ${msg.text}`)
|
||||
}
|
||||
|
||||
if (msg.reasoning) {
|
||||
console.log(`[thinking] ${msg.reasoning}`)
|
||||
}
|
||||
}
|
||||
|
||||
error(err: Error | string): void {
|
||||
const message = err instanceof Error ? err.message : err
|
||||
console.error(`ERROR: ${message}`)
|
||||
}
|
||||
|
||||
success(text: string): void {
|
||||
console.log(`OK: ${text}`)
|
||||
}
|
||||
|
||||
warn(text: string): void {
|
||||
console.warn(`WARN: ${text}`)
|
||||
}
|
||||
|
||||
info(text: string): void {
|
||||
console.log(`INFO: ${text}`)
|
||||
}
|
||||
|
||||
table(data: Record<string, unknown>[], columns?: string[]): void {
|
||||
if (data.length === 0) {
|
||||
console.log("(no data)")
|
||||
return
|
||||
}
|
||||
|
||||
// Determine columns from first row if not specified
|
||||
const cols = columns || Object.keys(data[0])
|
||||
|
||||
// Print header
|
||||
console.log(cols.join("\t"))
|
||||
|
||||
// Print rows
|
||||
for (const row of data) {
|
||||
const values = cols.map((col) => String(row[col] ?? ""))
|
||||
console.log(values.join("\t"))
|
||||
}
|
||||
}
|
||||
|
||||
list(items: string[]): void {
|
||||
for (const item of items) {
|
||||
console.log(`- ${item}`)
|
||||
}
|
||||
}
|
||||
|
||||
tasks(tasks: TaskInfo[]): void {
|
||||
if (tasks.length === 0) {
|
||||
console.log("No tasks found")
|
||||
return
|
||||
}
|
||||
|
||||
for (const task of tasks) {
|
||||
const date = new Date(task.ts).toISOString().split("T")[0]
|
||||
const status = task.completed ? "[done]" : "[active]"
|
||||
const snippet = task.task.length > 50 ? task.task.substring(0, 47) + "..." : task.task
|
||||
console.log(`${task.id}\t${date}\t${status}\t${snippet}`)
|
||||
}
|
||||
}
|
||||
|
||||
keyValue(data: Record<string, unknown>): void {
|
||||
for (const [key, value] of Object.entries(data)) {
|
||||
console.log(`${key}: ${String(value)}`)
|
||||
}
|
||||
}
|
||||
|
||||
raw(text: string): void {
|
||||
stdoutWrite(text + "\n")
|
||||
}
|
||||
|
||||
code(code: string): void {
|
||||
stdoutWrite(code + "\n")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a plain text formatter instance
|
||||
*/
|
||||
export function createPlainFormatter(): OutputFormatter {
|
||||
return new PlainFormatter()
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
/**
|
||||
* Rich text output formatter - colorful terminal output with styling
|
||||
* Uses chalk for ANSI color support
|
||||
*/
|
||||
|
||||
import chalk from "chalk"
|
||||
import type { ClineMessage, OutputFormatter, TaskInfo } from "./types.js"
|
||||
|
||||
// Store reference to original stdout.write to bypass console filtering
|
||||
const stdoutWrite = process.stdout.write.bind(process.stdout)
|
||||
|
||||
/**
|
||||
* Rich formatter implementation with colors and styling
|
||||
*/
|
||||
export class RichFormatter implements OutputFormatter {
|
||||
message(msg: ClineMessage): void {
|
||||
const timestamp = chalk.gray(new Date(msg.ts).toLocaleTimeString())
|
||||
|
||||
// Determine icon and color based on message type and subtype
|
||||
let icon: string
|
||||
let color: typeof chalk
|
||||
|
||||
if (msg.type === "ask") {
|
||||
icon = chalk.yellow("?")
|
||||
color = chalk.yellow
|
||||
} else {
|
||||
// Say messages
|
||||
switch (msg.say) {
|
||||
case "error":
|
||||
icon = chalk.red("✗")
|
||||
color = chalk.red
|
||||
break
|
||||
case "completion_result":
|
||||
icon = chalk.green("✓")
|
||||
color = chalk.green
|
||||
break
|
||||
case "tool":
|
||||
icon = chalk.blue("🔧")
|
||||
color = chalk.blue
|
||||
break
|
||||
case "command":
|
||||
icon = chalk.cyan("$")
|
||||
color = chalk.cyan
|
||||
break
|
||||
case "command_output":
|
||||
icon = chalk.gray(">")
|
||||
color = chalk.gray
|
||||
break
|
||||
case "api_req_started":
|
||||
icon = chalk.magenta("→")
|
||||
color = chalk.magenta
|
||||
break
|
||||
case "api_req_finished":
|
||||
icon = chalk.magenta("←")
|
||||
color = chalk.magenta
|
||||
break
|
||||
default:
|
||||
icon = chalk.white("●")
|
||||
color = chalk.white
|
||||
}
|
||||
}
|
||||
|
||||
// Output message text
|
||||
if (msg.text) {
|
||||
const subtypeLabel = msg.say || msg.ask
|
||||
const label = subtypeLabel ? chalk.dim(`[${subtypeLabel}]`) : ""
|
||||
console.log(`${icon} ${timestamp} ${label}`)
|
||||
console.log(` ${color(msg.text)}`)
|
||||
}
|
||||
|
||||
// Output reasoning in a distinct style
|
||||
if (msg.reasoning) {
|
||||
console.log(chalk.dim.italic(` 💭 ${msg.reasoning}`))
|
||||
}
|
||||
|
||||
// Show partial indicator for streaming
|
||||
if (msg.partial) {
|
||||
console.log(chalk.dim(" ⋯ (streaming)"))
|
||||
}
|
||||
}
|
||||
|
||||
error(err: Error | string): void {
|
||||
const message = err instanceof Error ? err.message : err
|
||||
console.error(chalk.red.bold("✗ Error:"), chalk.red(message))
|
||||
if (err instanceof Error && err.stack) {
|
||||
console.error(chalk.dim(err.stack.split("\n").slice(1).join("\n")))
|
||||
}
|
||||
}
|
||||
|
||||
success(text: string): void {
|
||||
console.log(chalk.green.bold("✓"), chalk.green(text))
|
||||
}
|
||||
|
||||
warn(text: string): void {
|
||||
console.warn(chalk.yellow.bold("!"), chalk.yellow(text))
|
||||
}
|
||||
|
||||
info(text: string): void {
|
||||
console.log(chalk.blue.bold("i"), chalk.blue(text))
|
||||
}
|
||||
|
||||
table(data: Record<string, unknown>[], columns?: string[]): void {
|
||||
if (data.length === 0) {
|
||||
console.log(chalk.dim("(no data)"))
|
||||
return
|
||||
}
|
||||
|
||||
// Determine columns from first row if not specified
|
||||
const cols = columns || Object.keys(data[0])
|
||||
|
||||
// Calculate column widths
|
||||
const widths = cols.map((col) => {
|
||||
const values = data.map((row) => String(row[col] ?? ""))
|
||||
return Math.max(col.length, ...values.map((v) => v.length))
|
||||
})
|
||||
|
||||
// Print header
|
||||
const header = cols.map((col, i) => chalk.bold(col.padEnd(widths[i]))).join(" ")
|
||||
console.log(header)
|
||||
console.log(chalk.dim("─".repeat(header.length)))
|
||||
|
||||
// Print rows
|
||||
for (const row of data) {
|
||||
const values = cols.map((col, i) => String(row[col] ?? "").padEnd(widths[i]))
|
||||
console.log(values.join(" "))
|
||||
}
|
||||
}
|
||||
|
||||
list(items: string[]): void {
|
||||
for (const item of items) {
|
||||
console.log(chalk.cyan(" •"), item)
|
||||
}
|
||||
}
|
||||
|
||||
tasks(tasks: TaskInfo[]): void {
|
||||
if (tasks.length === 0) {
|
||||
console.log(chalk.dim("No tasks found"))
|
||||
return
|
||||
}
|
||||
|
||||
console.log(chalk.bold("Tasks:\n"))
|
||||
|
||||
for (const task of tasks) {
|
||||
const date = new Date(task.ts).toLocaleDateString()
|
||||
const time = new Date(task.ts).toLocaleTimeString()
|
||||
const status = task.completed ? chalk.green("✓ done") : chalk.yellow("◉ active")
|
||||
|
||||
// Truncate task text if too long
|
||||
const maxLen = 60
|
||||
const snippet = task.task.length > maxLen ? task.task.substring(0, maxLen - 3) + "..." : task.task
|
||||
|
||||
console.log(` ${chalk.bold(task.id)} ${chalk.dim(`(${date} ${time})`)} ${status}`)
|
||||
console.log(` ${chalk.white(snippet)}`)
|
||||
|
||||
if (task.totalTokens || task.totalCost) {
|
||||
const tokens = task.totalTokens ? `${task.totalTokens.toLocaleString()} tokens` : ""
|
||||
const cost = task.totalCost ? `$${task.totalCost.toFixed(4)}` : ""
|
||||
console.log(` ${chalk.dim([tokens, cost].filter(Boolean).join(" • "))}`)
|
||||
}
|
||||
console.log()
|
||||
}
|
||||
}
|
||||
|
||||
keyValue(data: Record<string, unknown>): void {
|
||||
const maxKeyLen = Math.max(...Object.keys(data).map((k) => k.length))
|
||||
|
||||
for (const [key, value] of Object.entries(data)) {
|
||||
const paddedKey = key.padEnd(maxKeyLen)
|
||||
console.log(`${chalk.bold(paddedKey)} ${chalk.white(String(value))}`)
|
||||
}
|
||||
}
|
||||
|
||||
raw(text: string): void {
|
||||
// Use stdout.write directly to bypass console filtering
|
||||
// This is important for commands like `dump` that output JSON
|
||||
// containing strings that would otherwise be filtered
|
||||
stdoutWrite(text + "\n")
|
||||
}
|
||||
|
||||
code(codeText: string): void {
|
||||
console.log(chalk.green(codeText))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a rich text formatter instance
|
||||
*/
|
||||
export function createRichFormatter(): OutputFormatter {
|
||||
return new RichFormatter()
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* Output formatting types for the CLI
|
||||
*/
|
||||
|
||||
/**
|
||||
* Supported output formats
|
||||
*/
|
||||
export type OutputFormat = "rich" | "json" | "plain"
|
||||
|
||||
/**
|
||||
* Cline message structure matching the extension's message format
|
||||
*/
|
||||
export interface ClineMessage {
|
||||
/** Message type - ask requires user response, say is informational */
|
||||
type: "ask" | "say"
|
||||
/** Message text content */
|
||||
text?: string
|
||||
/** Unix epoch milliseconds timestamp */
|
||||
ts: number
|
||||
/** AI reasoning/thinking content */
|
||||
reasoning?: string
|
||||
/** Say message subtype */
|
||||
say?:
|
||||
| "text"
|
||||
| "user_feedback"
|
||||
| "user_feedback_diff"
|
||||
| "error"
|
||||
| "completion_result"
|
||||
| "tool"
|
||||
| "command"
|
||||
| "command_output"
|
||||
| "api_req_started"
|
||||
| "api_req_finished"
|
||||
| "api_req_retried"
|
||||
/** Ask message subtype */
|
||||
ask?:
|
||||
| "followup"
|
||||
| "command"
|
||||
| "command_output"
|
||||
| "completion_result"
|
||||
| "tool"
|
||||
| "api_req_failed"
|
||||
| "resume_task"
|
||||
| "resume_completed_task"
|
||||
| "mistake_limit_reached"
|
||||
| "auto_approval_max_req_reached"
|
||||
/** Whether this is a partial/streaming message */
|
||||
partial?: boolean
|
||||
/** Attached image paths */
|
||||
images?: string[]
|
||||
/** Attached file paths */
|
||||
files?: string[]
|
||||
/** Last checkpoint hash for restore operations */
|
||||
lastCheckpointHash?: string
|
||||
/** Whether a checkpoint is currently checked out */
|
||||
isCheckpointCheckedOut?: boolean
|
||||
/** Whether operation is outside workspace */
|
||||
isOperationOutsideWorkspace?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Task information for list/display operations
|
||||
*/
|
||||
export interface TaskInfo {
|
||||
/** Unique task identifier */
|
||||
id: string
|
||||
/** Task creation timestamp */
|
||||
ts: number
|
||||
/** Initial task prompt/description */
|
||||
task: string
|
||||
/** Total tokens used in task */
|
||||
totalTokens?: number
|
||||
/** Total cost of task */
|
||||
totalCost?: number
|
||||
/** Whether task is completed */
|
||||
completed?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Output formatter interface - all formatters must implement this
|
||||
*/
|
||||
export interface OutputFormatter {
|
||||
/** Format and output a Cline message */
|
||||
message(msg: ClineMessage): void
|
||||
|
||||
/** Format and output an error */
|
||||
error(err: Error | string): void
|
||||
|
||||
/** Format and output a success message */
|
||||
success(text: string): void
|
||||
|
||||
/** Format and output a warning */
|
||||
warn(text: string): void
|
||||
|
||||
/** Format and output an info message */
|
||||
info(text: string): void
|
||||
|
||||
/** Format and output tabular data */
|
||||
table(data: Record<string, unknown>[], columns?: string[]): void
|
||||
|
||||
/** Format and output a list of items */
|
||||
list(items: string[]): void
|
||||
|
||||
/** Format and output a task list */
|
||||
tasks(tasks: TaskInfo[]): void
|
||||
|
||||
/** Format and output key-value pairs */
|
||||
keyValue(data: Record<string, unknown>): void
|
||||
|
||||
code(codeText: string): void
|
||||
|
||||
/** Output raw text without formatting */
|
||||
raw(text: string): void
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
/**
|
||||
* Path parser for @path syntax and file attachment handling
|
||||
*
|
||||
* Provides utilities for:
|
||||
* - Parsing @path references from message text
|
||||
* - Detecting image files by extension
|
||||
* - Converting image files to base64 data URLs
|
||||
*/
|
||||
|
||||
import fs from "fs"
|
||||
import path from "path"
|
||||
|
||||
/**
|
||||
* Supported image extensions (what Anthropic API accepts)
|
||||
*/
|
||||
const IMAGE_EXTENSIONS = new Set([".png", ".jpg", ".jpeg", ".gif", ".webp"])
|
||||
|
||||
/**
|
||||
* MIME types for image extensions
|
||||
*/
|
||||
const IMAGE_MIME_TYPES: Record<string, string> = {
|
||||
".png": "image/png",
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".gif": "image/gif",
|
||||
".webp": "image/webp",
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of parsing @path references from a message
|
||||
*/
|
||||
export interface ParsedAttachments {
|
||||
/** Message with @paths removed */
|
||||
cleanedMessage: string
|
||||
/** Non-image file paths (absolute) */
|
||||
files: string[]
|
||||
/** Base64 data URLs for images */
|
||||
images: string[]
|
||||
/** Warnings for files that couldn't be processed */
|
||||
warnings: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a file path is an image based on extension
|
||||
*/
|
||||
export function isImageFile(filePath: string): boolean {
|
||||
const ext = path.extname(filePath).toLowerCase()
|
||||
return IMAGE_EXTENSIONS.has(ext)
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert an image file to a base64 data URL
|
||||
*
|
||||
* @param filePath - Path to the image file
|
||||
* @returns Base64 data URL (data:image/type;base64,...)
|
||||
* @throws Error if file doesn't exist or isn't a valid image type
|
||||
*/
|
||||
export function fileToBase64DataUrl(filePath: string): string {
|
||||
const ext = path.extname(filePath).toLowerCase()
|
||||
const mimeType = IMAGE_MIME_TYPES[ext]
|
||||
|
||||
if (!mimeType) {
|
||||
throw new Error(`Unsupported image format: ${ext}. Supported formats: ${Array.from(IMAGE_EXTENSIONS).join(", ")}`)
|
||||
}
|
||||
|
||||
if (!fs.existsSync(filePath)) {
|
||||
throw new Error(`Image file not found: ${filePath}`)
|
||||
}
|
||||
|
||||
const buffer = fs.readFileSync(filePath)
|
||||
const base64 = buffer.toString("base64")
|
||||
|
||||
return `data:${mimeType};base64,${base64}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse @path references from a message string
|
||||
*
|
||||
* Supports:
|
||||
* - @./relative/path - relative to cwd
|
||||
* - @/absolute/path - absolute path
|
||||
* - @path/without/dot - relative to cwd
|
||||
*
|
||||
* @paths must be preceded by whitespace or be at start of string
|
||||
* @paths end at whitespace or end of string
|
||||
*
|
||||
* @param message - The message text to parse
|
||||
* @param cwd - Current working directory for resolving relative paths
|
||||
* @returns Parsed attachments with cleaned message, files, images, and warnings
|
||||
*/
|
||||
export function parseAtPaths(message: string, cwd: string): ParsedAttachments {
|
||||
const files: string[] = []
|
||||
const images: string[] = []
|
||||
const warnings: string[] = []
|
||||
|
||||
// Regex to match @path patterns
|
||||
// Must be at start or preceded by whitespace
|
||||
// Path continues until whitespace or end of string
|
||||
// Path must contain at least one character after @
|
||||
const atPathRegex = /(?:^|\s)@([^\s@]+)/g
|
||||
|
||||
const matches: Array<{ fullMatch: string; path: string; index: number }> = []
|
||||
let match: RegExpExecArray | null
|
||||
|
||||
while ((match = atPathRegex.exec(message)) !== null) {
|
||||
const fullMatch = match[0]
|
||||
const pathPart = match[1]
|
||||
|
||||
// Skip if path is empty or just whitespace
|
||||
if (!pathPart || !pathPart.trim()) {
|
||||
continue
|
||||
}
|
||||
|
||||
matches.push({
|
||||
fullMatch,
|
||||
path: pathPart,
|
||||
index: match.index,
|
||||
})
|
||||
}
|
||||
|
||||
// Process matches in reverse order so we can safely remove them from the message
|
||||
let cleanedMessage = message
|
||||
for (let i = matches.length - 1; i >= 0; i--) {
|
||||
const { fullMatch, path: pathPart } = matches[i]
|
||||
|
||||
// Resolve the path
|
||||
let absolutePath: string
|
||||
if (path.isAbsolute(pathPart)) {
|
||||
absolutePath = pathPart
|
||||
} else {
|
||||
absolutePath = path.resolve(cwd, pathPart)
|
||||
}
|
||||
|
||||
// Check if file exists
|
||||
if (!fs.existsSync(absolutePath)) {
|
||||
warnings.push(`File not found: ${pathPart}`)
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if it's a directory
|
||||
if (fs.statSync(absolutePath).isDirectory()) {
|
||||
warnings.push(`Cannot attach directory: ${pathPart}`)
|
||||
continue
|
||||
}
|
||||
|
||||
// Determine if it's an image or regular file
|
||||
if (isImageFile(absolutePath)) {
|
||||
try {
|
||||
const dataUrl = fileToBase64DataUrl(absolutePath)
|
||||
images.push(dataUrl)
|
||||
// Remove the @path from the message (preserve leading whitespace if present)
|
||||
const hasLeadingSpace = fullMatch.startsWith(" ") || fullMatch.startsWith("\t")
|
||||
cleanedMessage = cleanedMessage.replace(fullMatch, hasLeadingSpace ? " " : "")
|
||||
} catch (error) {
|
||||
warnings.push(`Failed to read image: ${pathPart} - ${(error as Error).message}`)
|
||||
}
|
||||
} else {
|
||||
files.push(absolutePath)
|
||||
// Remove the @path from the message (preserve leading whitespace if present)
|
||||
const hasLeadingSpace = fullMatch.startsWith(" ") || fullMatch.startsWith("\t")
|
||||
cleanedMessage = cleanedMessage.replace(fullMatch, hasLeadingSpace ? " " : "")
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up multiple spaces that may have been left
|
||||
cleanedMessage = cleanedMessage.replace(/\s+/g, " ").trim()
|
||||
|
||||
return {
|
||||
cleanedMessage,
|
||||
files,
|
||||
images,
|
||||
warnings,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process explicit file paths from CLI options
|
||||
*
|
||||
* Unlike parseAtPaths, this throws errors for missing files (strict mode)
|
||||
*
|
||||
* @param filePaths - Array of file paths from CLI options
|
||||
* @param cwd - Current working directory for resolving relative paths
|
||||
* @returns Object with files (paths) and images (base64 data URLs)
|
||||
* @throws Error if any file doesn't exist
|
||||
*/
|
||||
export function processExplicitFiles(
|
||||
filePaths: string[],
|
||||
cwd: string,
|
||||
): {
|
||||
files: string[]
|
||||
images: string[]
|
||||
} {
|
||||
const files: string[] = []
|
||||
const images: string[] = []
|
||||
|
||||
for (const filePath of filePaths) {
|
||||
// Resolve the path
|
||||
let absolutePath: string
|
||||
if (path.isAbsolute(filePath)) {
|
||||
absolutePath = filePath
|
||||
} else {
|
||||
absolutePath = path.resolve(cwd, filePath)
|
||||
}
|
||||
|
||||
// Check if file exists (strict - throw error)
|
||||
if (!fs.existsSync(absolutePath)) {
|
||||
throw new Error(`File not found: ${filePath}`)
|
||||
}
|
||||
|
||||
// Check if it's a directory
|
||||
if (fs.statSync(absolutePath).isDirectory()) {
|
||||
throw new Error(`Cannot attach directory: ${filePath}`)
|
||||
}
|
||||
|
||||
// Determine if it's an image or regular file
|
||||
if (isImageFile(absolutePath)) {
|
||||
const dataUrl = fileToBase64DataUrl(absolutePath)
|
||||
images.push(dataUrl)
|
||||
} else {
|
||||
files.push(absolutePath)
|
||||
}
|
||||
}
|
||||
|
||||
return { files, images }
|
||||
}
|
||||
|
||||
/**
|
||||
* Process explicit image paths from CLI options
|
||||
*
|
||||
* @param imagePaths - Array of image paths from CLI options
|
||||
* @param cwd - Current working directory for resolving relative paths
|
||||
* @returns Array of base64 data URLs
|
||||
* @throws Error if any file doesn't exist or isn't a valid image
|
||||
*/
|
||||
export function processExplicitImages(imagePaths: string[], cwd: string): string[] {
|
||||
const images: string[] = []
|
||||
|
||||
for (const imagePath of imagePaths) {
|
||||
// Resolve the path
|
||||
let absolutePath: string
|
||||
if (path.isAbsolute(imagePath)) {
|
||||
absolutePath = imagePath
|
||||
} else {
|
||||
absolutePath = path.resolve(cwd, imagePath)
|
||||
}
|
||||
|
||||
// Check if file exists (strict - throw error)
|
||||
if (!fs.existsSync(absolutePath)) {
|
||||
throw new Error(`Image file not found: ${imagePath}`)
|
||||
}
|
||||
|
||||
// Check if it's actually an image
|
||||
if (!isImageFile(absolutePath)) {
|
||||
throw new Error(
|
||||
`Not a supported image format: ${imagePath}. Supported formats: ${Array.from(IMAGE_EXTENSIONS).join(", ")}`,
|
||||
)
|
||||
}
|
||||
|
||||
const dataUrl = fileToBase64DataUrl(absolutePath)
|
||||
images.push(dataUrl)
|
||||
}
|
||||
|
||||
return images
|
||||
}
|
||||
@@ -0,0 +1,356 @@
|
||||
/**
|
||||
* Animated loading spinner for CLI output
|
||||
*
|
||||
* Displays a rotating spinner animation when no output has been
|
||||
* received for a configurable delay period. Uses ANSI escape codes
|
||||
* to update in-place without scrolling the terminal.
|
||||
*/
|
||||
|
||||
import chalk from "chalk"
|
||||
|
||||
/**
|
||||
* Spinner animation frames (Braille pattern)
|
||||
*/
|
||||
const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
|
||||
|
||||
/**
|
||||
* Default configuration values
|
||||
*/
|
||||
const DEFAULT_INTERVAL_MS = 80 // Animation speed
|
||||
const DEFAULT_DELAY_MS = 1000 // Delay before showing spinner
|
||||
|
||||
/**
|
||||
* Spinner configuration options
|
||||
*/
|
||||
export interface SpinnerOptions {
|
||||
/** Message to display alongside spinner */
|
||||
message?: string
|
||||
/** Animation frame interval in milliseconds (default: 80) */
|
||||
intervalMs?: number
|
||||
/** Delay before showing spinner in milliseconds (default: 2000) */
|
||||
delayMs?: number
|
||||
/** Stream to write to (default: process.stdout) */
|
||||
stream?: NodeJS.WriteStream
|
||||
}
|
||||
|
||||
/**
|
||||
* Animated spinner class
|
||||
*
|
||||
* Manages the spinner animation lifecycle including delayed start,
|
||||
* frame animation, and clean stop with line clearing.
|
||||
*/
|
||||
export class Spinner {
|
||||
private message: string
|
||||
private intervalMs: number
|
||||
private delayMs: number
|
||||
private stream: NodeJS.WriteStream
|
||||
|
||||
private frameIndex = 0
|
||||
private animationTimer: NodeJS.Timeout | null = null
|
||||
private delayTimer: NodeJS.Timeout | null = null
|
||||
private isSpinning = false
|
||||
private isVisible = false
|
||||
|
||||
constructor(options: SpinnerOptions = {}) {
|
||||
this.message = options.message || "Thinking..."
|
||||
this.intervalMs = options.intervalMs || DEFAULT_INTERVAL_MS
|
||||
this.delayMs = options.delayMs || DEFAULT_DELAY_MS
|
||||
this.stream = options.stream || process.stdout
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the output stream is a TTY (supports animations)
|
||||
*/
|
||||
private isTTY(): boolean {
|
||||
return this.stream.isTTY === true
|
||||
}
|
||||
|
||||
/**
|
||||
* Write text to the stream
|
||||
*/
|
||||
private write(text: string): void {
|
||||
this.stream.write(text)
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the current line and move cursor to start
|
||||
*/
|
||||
private clearLine(): void {
|
||||
if (this.isTTY()) {
|
||||
// \r = carriage return (move to start of line)
|
||||
// \x1B[K = clear from cursor to end of line
|
||||
this.write("\r\x1B[K")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the current spinner frame
|
||||
*/
|
||||
private render(): void {
|
||||
if (!this.isTTY()) {
|
||||
return
|
||||
}
|
||||
|
||||
const frame = SPINNER_FRAMES[this.frameIndex]
|
||||
const text = chalk.cyan(frame) + " " + chalk.dim(this.message)
|
||||
|
||||
this.clearLine()
|
||||
this.write(text)
|
||||
this.isVisible = true
|
||||
|
||||
// Advance to next frame
|
||||
this.frameIndex = (this.frameIndex + 1) % SPINNER_FRAMES.length
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the spinner animation after the configured delay
|
||||
*
|
||||
* The spinner will not appear immediately - it waits for the delay
|
||||
* period first. This prevents flickering for quick operations.
|
||||
*/
|
||||
start(message?: string): void {
|
||||
// Don't start if already spinning or not a TTY
|
||||
if (this.isSpinning || !this.isTTY()) {
|
||||
return
|
||||
}
|
||||
|
||||
if (message !== undefined) {
|
||||
this.message = message
|
||||
}
|
||||
|
||||
this.isSpinning = true
|
||||
this.frameIndex = 0
|
||||
|
||||
// Start delay timer - spinner becomes visible after delay
|
||||
this.delayTimer = setTimeout(() => {
|
||||
this.delayTimer = null
|
||||
|
||||
// Start animation timer
|
||||
this.animationTimer = setInterval(() => {
|
||||
this.render()
|
||||
}, this.intervalMs)
|
||||
|
||||
// Render first frame immediately
|
||||
this.render()
|
||||
}, this.delayMs)
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the spinner immediately without delay
|
||||
*
|
||||
* Use this when you know the operation will take a while
|
||||
* and want immediate feedback.
|
||||
*/
|
||||
startImmediate(message?: string): void {
|
||||
if (this.isSpinning || !this.isTTY()) {
|
||||
return
|
||||
}
|
||||
|
||||
if (message !== undefined) {
|
||||
this.message = message
|
||||
}
|
||||
|
||||
this.isSpinning = true
|
||||
this.frameIndex = 0
|
||||
|
||||
// Start animation timer immediately
|
||||
this.animationTimer = setInterval(() => {
|
||||
this.render()
|
||||
}, this.intervalMs)
|
||||
|
||||
// Render first frame immediately
|
||||
this.render()
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the spinner and clear the line
|
||||
*
|
||||
* Cleans up all timers and removes the spinner from display.
|
||||
*/
|
||||
stop(): void {
|
||||
if (!this.isSpinning) {
|
||||
return
|
||||
}
|
||||
|
||||
// Clear delay timer if still waiting
|
||||
if (this.delayTimer) {
|
||||
clearTimeout(this.delayTimer)
|
||||
this.delayTimer = null
|
||||
}
|
||||
|
||||
// Clear animation timer
|
||||
if (this.animationTimer) {
|
||||
clearInterval(this.animationTimer)
|
||||
this.animationTimer = null
|
||||
}
|
||||
|
||||
// Clear the line if we rendered anything
|
||||
if (this.isVisible) {
|
||||
this.clearLine()
|
||||
this.isVisible = false
|
||||
}
|
||||
|
||||
this.isSpinning = false
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the spinner is currently active (spinning or waiting to spin)
|
||||
*/
|
||||
get active(): boolean {
|
||||
return this.isSpinning
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the spinner is currently visible on screen
|
||||
*/
|
||||
get visible(): boolean {
|
||||
return this.isVisible
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the spinner message while it's running
|
||||
*/
|
||||
setMessage(message: string): void {
|
||||
this.message = message
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the delay timer
|
||||
*
|
||||
* Call this when activity is detected but you want to restart
|
||||
* the delay countdown. The spinner will stop if visible and
|
||||
* restart the delay timer.
|
||||
*/
|
||||
reset(): void {
|
||||
if (!this.isSpinning) {
|
||||
return
|
||||
}
|
||||
|
||||
// Stop current animation
|
||||
this.stop()
|
||||
|
||||
// Start again (will wait for delay)
|
||||
this.start()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new spinner instance
|
||||
*/
|
||||
export function createSpinner(options?: SpinnerOptions): Spinner {
|
||||
return new Spinner(options)
|
||||
}
|
||||
|
||||
/**
|
||||
* ActivitySpinner - automatically manages spinner based on activity
|
||||
*
|
||||
* This is a higher-level wrapper that:
|
||||
* - Starts spinner after inactivity timeout
|
||||
* - Automatically stops when activity is reported
|
||||
* - Restarts the timer after each activity
|
||||
*/
|
||||
export class ActivitySpinner {
|
||||
private spinner: Spinner
|
||||
private inactivityTimer: NodeJS.Timeout | null = null
|
||||
private delayMs: number
|
||||
private enabled = true
|
||||
|
||||
constructor(options: SpinnerOptions = {}) {
|
||||
this.delayMs = options.delayMs || DEFAULT_DELAY_MS
|
||||
// Create spinner with no delay - we handle delay ourselves
|
||||
this.spinner = new Spinner({ ...options, delayMs: 0 })
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable or disable the spinner
|
||||
*/
|
||||
setEnabled(enabled: boolean): void {
|
||||
this.enabled = enabled
|
||||
if (!enabled) {
|
||||
this.stop()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Report activity - resets the inactivity timer
|
||||
*
|
||||
* Call this whenever output is received or activity is detected.
|
||||
* The spinner will be stopped if visible and the timer will be reset.
|
||||
*/
|
||||
reportActivity(): void {
|
||||
// Stop spinner if it's showing
|
||||
if (this.spinner.active) {
|
||||
this.spinner.stop()
|
||||
}
|
||||
|
||||
// Clear existing timer
|
||||
if (this.inactivityTimer) {
|
||||
clearTimeout(this.inactivityTimer)
|
||||
this.inactivityTimer = null
|
||||
}
|
||||
|
||||
// Start new inactivity timer if enabled
|
||||
if (this.enabled) {
|
||||
this.inactivityTimer = setTimeout(() => {
|
||||
this.inactivityTimer = null
|
||||
this.spinner.startImmediate()
|
||||
}, this.delayMs)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start monitoring for inactivity
|
||||
*
|
||||
* Begins the inactivity timer. If no activity is reported
|
||||
* within the delay period, the spinner will appear.
|
||||
*/
|
||||
startMonitoring(message?: string): void {
|
||||
if (message !== undefined) {
|
||||
this.spinner.setMessage(message)
|
||||
}
|
||||
|
||||
this.reportActivity() // Start the timer
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop monitoring and hide spinner
|
||||
*/
|
||||
stop(): void {
|
||||
if (this.inactivityTimer) {
|
||||
clearTimeout(this.inactivityTimer)
|
||||
this.inactivityTimer = null
|
||||
}
|
||||
|
||||
if (this.spinner.active) {
|
||||
this.spinner.stop()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the spinner message
|
||||
*/
|
||||
setMessage(message: string): void {
|
||||
this.spinner.setMessage(message)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if spinner is currently visible
|
||||
*/
|
||||
get visible(): boolean {
|
||||
return this.spinner.visible
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if monitoring is active
|
||||
*/
|
||||
get monitoring(): boolean {
|
||||
return this.inactivityTimer !== null || this.spinner.active
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new activity spinner instance
|
||||
*/
|
||||
export function createActivitySpinner(options?: SpinnerOptions): ActivitySpinner {
|
||||
return new ActivitySpinner(options)
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
/**
|
||||
* Standalone Host Bridge Client for CLI
|
||||
*
|
||||
* This provides in-process implementations of the HostBridgeClientProvider interface
|
||||
* instead of making gRPC calls to an external host bridge server.
|
||||
*
|
||||
* For the TypeScript CLI's embedded controller architecture, everything runs in-process,
|
||||
* so we don't need network calls - just direct method implementations.
|
||||
*/
|
||||
|
||||
import type {
|
||||
DiffServiceClientInterface,
|
||||
EnvServiceClientInterface,
|
||||
WindowServiceClientInterface,
|
||||
WorkspaceServiceClientInterface,
|
||||
} from "@/generated/hosts/host-bridge-client-types"
|
||||
import type { HostBridgeClientProvider, StreamingCallbacks } from "@/hosts/host-provider-types"
|
||||
import { Setting } from "@/shared/proto/host/env"
|
||||
import * as proto from "@/shared/proto/index"
|
||||
|
||||
// Get the Cline version injected at build time
|
||||
declare const __CLINE_VERSION__: string
|
||||
|
||||
/**
|
||||
* In-process EnvService client for CLI
|
||||
*/
|
||||
class StandaloneEnvServiceClient implements EnvServiceClientInterface {
|
||||
async clipboardWriteText(_request: proto.cline.StringRequest): Promise<proto.cline.Empty> {
|
||||
// CLI doesn't support clipboard operations directly
|
||||
// Could be extended to use a library like 'clipboardy' if needed
|
||||
return proto.cline.Empty.create()
|
||||
}
|
||||
|
||||
async clipboardReadText(_request: proto.cline.EmptyRequest): Promise<proto.cline.String> {
|
||||
// CLI doesn't support clipboard operations directly
|
||||
return proto.cline.String.create({ value: "" })
|
||||
}
|
||||
|
||||
async getHostVersion(_request: proto.cline.EmptyRequest): Promise<proto.host.GetHostVersionResponse> {
|
||||
return proto.host.GetHostVersionResponse.create({
|
||||
platform: process.platform,
|
||||
version: process.version, // Node.js version
|
||||
clineType: "CLI",
|
||||
clineVersion: typeof __CLINE_VERSION__ !== "undefined" ? __CLINE_VERSION__ : "unknown",
|
||||
})
|
||||
}
|
||||
|
||||
async getIdeRedirectUri(_request: proto.cline.EmptyRequest): Promise<proto.cline.String> {
|
||||
// CLI doesn't have an IDE redirect URI
|
||||
return proto.cline.String.create({ value: "" })
|
||||
}
|
||||
|
||||
async getTelemetrySettings(_request: proto.cline.EmptyRequest): Promise<proto.host.GetTelemetrySettingsResponse> {
|
||||
// Telemetry is disabled by default in CLI mode
|
||||
return proto.host.GetTelemetrySettingsResponse.create({
|
||||
isEnabled: Setting.DISABLED,
|
||||
})
|
||||
}
|
||||
|
||||
subscribeToTelemetrySettings(
|
||||
_request: proto.cline.EmptyRequest,
|
||||
_callbacks: StreamingCallbacks<proto.host.TelemetrySettingsEvent>,
|
||||
): () => void {
|
||||
// No-op subscription - CLI telemetry settings don't change
|
||||
return () => {}
|
||||
}
|
||||
|
||||
async shutdown(_request: proto.cline.EmptyRequest): Promise<proto.cline.Empty> {
|
||||
// No-op - CLI process exits normally
|
||||
return proto.cline.Empty.create()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* In-process WorkspaceService client for CLI
|
||||
*/
|
||||
class StandaloneWorkspaceServiceClient implements WorkspaceServiceClientInterface {
|
||||
async getWorkspacePaths(_request: proto.host.GetWorkspacePathsRequest): Promise<proto.host.GetWorkspacePathsResponse> {
|
||||
// Return current working directory as the workspace
|
||||
return proto.host.GetWorkspacePathsResponse.create({
|
||||
paths: [process.cwd()],
|
||||
})
|
||||
}
|
||||
|
||||
async saveOpenDocumentIfDirty(
|
||||
_request: proto.host.SaveOpenDocumentIfDirtyRequest,
|
||||
): Promise<proto.host.SaveOpenDocumentIfDirtyResponse> {
|
||||
// No-op - CLI doesn't have open documents in an editor
|
||||
return proto.host.SaveOpenDocumentIfDirtyResponse.create()
|
||||
}
|
||||
|
||||
async getDiagnostics(_request: proto.host.GetDiagnosticsRequest): Promise<proto.host.GetDiagnosticsResponse> {
|
||||
// No-op - CLI doesn't have IDE diagnostics
|
||||
return proto.host.GetDiagnosticsResponse.create({
|
||||
diagnostics: [],
|
||||
})
|
||||
}
|
||||
|
||||
async openProblemsPanel(_request: proto.host.OpenProblemsPanelRequest): Promise<proto.host.OpenProblemsPanelResponse> {
|
||||
// No-op - CLI doesn't have a problems panel
|
||||
return proto.host.OpenProblemsPanelResponse.create()
|
||||
}
|
||||
|
||||
async openInFileExplorerPanel(
|
||||
_request: proto.host.OpenInFileExplorerPanelRequest,
|
||||
): Promise<proto.host.OpenInFileExplorerPanelResponse> {
|
||||
// No-op - CLI doesn't have a file explorer panel
|
||||
return proto.host.OpenInFileExplorerPanelResponse.create()
|
||||
}
|
||||
|
||||
async openClineSidebarPanel(
|
||||
_request: proto.host.OpenClineSidebarPanelRequest,
|
||||
): Promise<proto.host.OpenClineSidebarPanelResponse> {
|
||||
// No-op - CLI doesn't have a sidebar
|
||||
return proto.host.OpenClineSidebarPanelResponse.create()
|
||||
}
|
||||
|
||||
async openTerminalPanel(_request: proto.host.OpenTerminalRequest): Promise<proto.host.OpenTerminalResponse> {
|
||||
// No-op - CLI is already in a terminal
|
||||
return proto.host.OpenTerminalResponse.create()
|
||||
}
|
||||
|
||||
async executeCommandInTerminal(
|
||||
_request: proto.host.ExecuteCommandInTerminalRequest,
|
||||
): Promise<proto.host.ExecuteCommandInTerminalResponse> {
|
||||
// No-op - terminal execution is handled differently in CLI
|
||||
return proto.host.ExecuteCommandInTerminalResponse.create()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* In-process WindowService client for CLI
|
||||
*/
|
||||
class StandaloneWindowServiceClient implements WindowServiceClientInterface {
|
||||
async showTextDocument(_request: proto.host.ShowTextDocumentRequest): Promise<proto.host.TextEditorInfo> {
|
||||
// No-op - CLI doesn't have a text editor UI
|
||||
return proto.host.TextEditorInfo.create()
|
||||
}
|
||||
|
||||
async showOpenDialogue(_request: proto.host.ShowOpenDialogueRequest): Promise<proto.host.SelectedResources> {
|
||||
// No-op - CLI doesn't have file dialogs
|
||||
return proto.host.SelectedResources.create({ uris: [] })
|
||||
}
|
||||
|
||||
async showMessage(_request: proto.host.ShowMessageRequest): Promise<proto.host.SelectedResponse> {
|
||||
// No-op - messages are shown via console output
|
||||
return proto.host.SelectedResponse.create()
|
||||
}
|
||||
|
||||
async showInputBox(_request: proto.host.ShowInputBoxRequest): Promise<proto.host.ShowInputBoxResponse> {
|
||||
// No-op - input is handled via CLI prompts
|
||||
return proto.host.ShowInputBoxResponse.create()
|
||||
}
|
||||
|
||||
async showSaveDialog(_request: proto.host.ShowSaveDialogRequest): Promise<proto.host.ShowSaveDialogResponse> {
|
||||
// No-op - CLI doesn't have save dialogs
|
||||
return proto.host.ShowSaveDialogResponse.create()
|
||||
}
|
||||
|
||||
async openFile(_request: proto.host.OpenFileRequest): Promise<proto.host.OpenFileResponse> {
|
||||
// No-op - CLI doesn't open files in an editor
|
||||
return proto.host.OpenFileResponse.create()
|
||||
}
|
||||
|
||||
async openSettings(_request: proto.host.OpenSettingsRequest): Promise<proto.host.OpenSettingsResponse> {
|
||||
// No-op - CLI doesn't have a settings UI
|
||||
return proto.host.OpenSettingsResponse.create()
|
||||
}
|
||||
|
||||
async getOpenTabs(_request: proto.host.GetOpenTabsRequest): Promise<proto.host.GetOpenTabsResponse> {
|
||||
// No-op - CLI doesn't have tabs
|
||||
return proto.host.GetOpenTabsResponse.create({ tabs: [] })
|
||||
}
|
||||
|
||||
async getVisibleTabs(_request: proto.host.GetVisibleTabsRequest): Promise<proto.host.GetVisibleTabsResponse> {
|
||||
// No-op - CLI doesn't have tabs
|
||||
return proto.host.GetVisibleTabsResponse.create({ tabs: [] })
|
||||
}
|
||||
|
||||
async getActiveEditor(_request: proto.host.GetActiveEditorRequest): Promise<proto.host.GetActiveEditorResponse> {
|
||||
// No-op - CLI doesn't have an active editor
|
||||
return proto.host.GetActiveEditorResponse.create()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* In-process DiffService client for CLI
|
||||
*/
|
||||
class StandaloneDiffServiceClient implements DiffServiceClientInterface {
|
||||
async openDiff(_request: proto.host.OpenDiffRequest): Promise<proto.host.OpenDiffResponse> {
|
||||
// No-op - CLI doesn't have visual diffs
|
||||
return proto.host.OpenDiffResponse.create()
|
||||
}
|
||||
|
||||
async getDocumentText(_request: proto.host.GetDocumentTextRequest): Promise<proto.host.GetDocumentTextResponse> {
|
||||
// No-op - document text retrieval is not supported in CLI mode
|
||||
return proto.host.GetDocumentTextResponse.create()
|
||||
}
|
||||
|
||||
async replaceText(_request: proto.host.ReplaceTextRequest): Promise<proto.host.ReplaceTextResponse> {
|
||||
// No-op - text replacement in diffs is not supported in CLI mode
|
||||
return proto.host.ReplaceTextResponse.create()
|
||||
}
|
||||
|
||||
async scrollDiff(_request: proto.host.ScrollDiffRequest): Promise<proto.host.ScrollDiffResponse> {
|
||||
// No-op - CLI doesn't have visual diffs to scroll
|
||||
return proto.host.ScrollDiffResponse.create()
|
||||
}
|
||||
|
||||
async truncateDocument(_request: proto.host.TruncateDocumentRequest): Promise<proto.host.TruncateDocumentResponse> {
|
||||
// No-op - document truncation is not supported in CLI mode
|
||||
return proto.host.TruncateDocumentResponse.create()
|
||||
}
|
||||
|
||||
async saveDocument(_request: proto.host.SaveDocumentRequest): Promise<proto.host.SaveDocumentResponse> {
|
||||
// No-op - document saving is handled differently in CLI mode
|
||||
return proto.host.SaveDocumentResponse.create()
|
||||
}
|
||||
|
||||
async closeAllDiffs(_request: proto.host.CloseAllDiffsRequest): Promise<proto.host.CloseAllDiffsResponse> {
|
||||
// No-op - CLI doesn't have diffs to close
|
||||
return proto.host.CloseAllDiffsResponse.create()
|
||||
}
|
||||
|
||||
async openMultiFileDiff(_request: proto.host.OpenMultiFileDiffRequest): Promise<proto.host.OpenMultiFileDiffResponse> {
|
||||
// No-op - CLI doesn't support multi-file diffs
|
||||
return proto.host.OpenMultiFileDiffResponse.create()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Standalone Host Bridge Client for CLI
|
||||
*
|
||||
* Provides in-process implementations of all host bridge services
|
||||
* instead of making gRPC calls to an external server.
|
||||
*/
|
||||
export class StandaloneHostBridgeClient implements HostBridgeClientProvider {
|
||||
workspaceClient: WorkspaceServiceClientInterface
|
||||
envClient: EnvServiceClientInterface
|
||||
windowClient: WindowServiceClientInterface
|
||||
diffClient: DiffServiceClientInterface
|
||||
|
||||
constructor() {
|
||||
this.workspaceClient = new StandaloneWorkspaceServiceClient()
|
||||
this.envClient = new StandaloneEnvServiceClient()
|
||||
this.windowClient = new StandaloneWindowServiceClient()
|
||||
this.diffClient = new StandaloneDiffServiceClient()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* State Subscription Module
|
||||
*
|
||||
* Provides subscription management for Controller state updates
|
||||
* and partial message events.
|
||||
*/
|
||||
|
||||
// Classes
|
||||
export { StateSubscriber } from "./state-subscriber.js"
|
||||
// Types
|
||||
export type { MessageCallback, StateChangeHandler, StateSubscriberConfig } from "./types.js"
|
||||
@@ -0,0 +1,173 @@
|
||||
/**
|
||||
* State Subscriber
|
||||
*
|
||||
* Manages gRPC subscriptions to Controller state updates and
|
||||
* partial message events. Tracks which messages have been output
|
||||
* and notifies callbacks when new complete messages are available.
|
||||
*/
|
||||
|
||||
import type { ClineMessage, ExtensionState } from "@shared/ExtensionMessage"
|
||||
import { EmptyRequest } from "@shared/proto/cline/common"
|
||||
import type { State } from "@shared/proto/cline/state"
|
||||
import type { ClineMessage as ProtoClineMessage } from "@shared/proto/cline/ui"
|
||||
import { convertProtoToClineMessage } from "@shared/proto-conversions/cline-message"
|
||||
import type { Controller } from "@/core/controller"
|
||||
import type { StreamingResponseHandler } from "@/core/controller/grpc-handler"
|
||||
import { subscribeToState } from "@/core/controller/state/subscribeToState"
|
||||
import { subscribeToPartialMessage } from "@/core/controller/ui/subscribeToPartialMessage"
|
||||
import type { StateSubscriberConfig } from "./types.js"
|
||||
|
||||
/**
|
||||
* StateSubscriber class
|
||||
*
|
||||
* Handles subscription to Controller state updates and tracks
|
||||
* which messages have been output to avoid duplicates.
|
||||
*/
|
||||
export class StateSubscriber {
|
||||
private printedMessageTs = new Set<number>()
|
||||
private subscriptionActive = false
|
||||
private config: StateSubscriberConfig
|
||||
|
||||
constructor(
|
||||
private controller: Controller,
|
||||
config: StateSubscriberConfig,
|
||||
) {
|
||||
this.config = config
|
||||
}
|
||||
|
||||
/**
|
||||
* Start listening for state updates
|
||||
*/
|
||||
start(): void {
|
||||
this.subscriptionActive = true
|
||||
|
||||
// Create a streaming response handler for state updates
|
||||
const stateResponseHandler: StreamingResponseHandler<State> = async (state: State) => {
|
||||
if (!this.subscriptionActive) {
|
||||
return
|
||||
}
|
||||
|
||||
if (state.stateJson) {
|
||||
try {
|
||||
const parsedState = JSON.parse(state.stateJson) as ExtensionState
|
||||
const messages = parsedState.clineMessages || []
|
||||
this.handleStateUpdate(messages)
|
||||
} catch {
|
||||
// JSON parse error - ignore malformed state
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create a streaming response handler for partial message updates
|
||||
const partialMessageHandler: StreamingResponseHandler<ProtoClineMessage> = async (protoMessage: ProtoClineMessage) => {
|
||||
if (!this.subscriptionActive) {
|
||||
return
|
||||
}
|
||||
|
||||
// Convert proto message to app message and handle it
|
||||
const message = convertProtoToClineMessage(protoMessage)
|
||||
this.handleSingleMessage(message)
|
||||
}
|
||||
|
||||
// Subscribe to both state updates and partial message events
|
||||
subscribeToState(this.controller, EmptyRequest.create(), stateResponseHandler)
|
||||
subscribeToPartialMessage(this.controller, EmptyRequest.create(), partialMessageHandler)
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop listening for state updates
|
||||
*/
|
||||
stop(): void {
|
||||
this.subscriptionActive = false
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the printed message tracking
|
||||
*/
|
||||
reset(): void {
|
||||
this.printedMessageTs.clear()
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a message has been printed
|
||||
*/
|
||||
hasBeenPrinted(ts: number): boolean {
|
||||
return this.printedMessageTs.has(ts)
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark a message as printed
|
||||
*/
|
||||
markPrinted(ts: number): void {
|
||||
this.printedMessageTs.add(ts)
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle a state update with new messages
|
||||
*
|
||||
* Messages are only output when they are complete (partial === false).
|
||||
*/
|
||||
private handleStateUpdate(messages: ClineMessage[]): void {
|
||||
// Report activity
|
||||
this.config.onActivity?.()
|
||||
|
||||
// Notify callback of all messages
|
||||
if (this.config.onStateChange) {
|
||||
this.config.onStateChange(messages)
|
||||
}
|
||||
|
||||
// Process messages in order, only outputting complete ones we haven't printed yet
|
||||
for (const msg of messages) {
|
||||
// Skip if already printed
|
||||
if (this.printedMessageTs.has(msg.ts)) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Skip partial messages - wait until they're complete
|
||||
if (msg.partial) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Output the complete message
|
||||
this.config.onCompleteMessage(msg)
|
||||
this.printedMessageTs.add(msg.ts)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle a single message update from the partial message stream
|
||||
*
|
||||
* This is called when sendPartialMessageEvent is used instead of postStateToWebview.
|
||||
*/
|
||||
private handleSingleMessage(msg: ClineMessage): void {
|
||||
// Report activity
|
||||
this.config.onActivity?.()
|
||||
|
||||
// Notify callback with current state (append the new message)
|
||||
if (this.config.onStateChange) {
|
||||
const currentMessages = this.config.getMessages()
|
||||
// Check if this message already exists and update it, or append if new
|
||||
const existingIndex = currentMessages.findIndex((m) => m.ts === msg.ts)
|
||||
if (existingIndex >= 0) {
|
||||
currentMessages[existingIndex] = msg
|
||||
} else {
|
||||
currentMessages.push(msg)
|
||||
}
|
||||
this.config.onStateChange(currentMessages)
|
||||
}
|
||||
|
||||
// Skip if already printed
|
||||
if (this.printedMessageTs.has(msg.ts)) {
|
||||
return
|
||||
}
|
||||
|
||||
// Skip partial messages - wait until they're complete
|
||||
if (msg.partial) {
|
||||
return
|
||||
}
|
||||
|
||||
// Output the complete message
|
||||
this.config.onCompleteMessage(msg)
|
||||
this.printedMessageTs.add(msg.ts)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* Types for state subscription management
|
||||
*/
|
||||
|
||||
import type { ClineMessage } from "@shared/ExtensionMessage"
|
||||
|
||||
/**
|
||||
* Callback type for state change notifications
|
||||
*/
|
||||
export type StateChangeHandler = (messages: ClineMessage[]) => void
|
||||
|
||||
/**
|
||||
* Callback type for individual message handling
|
||||
*/
|
||||
export type MessageCallback = (msg: ClineMessage) => void
|
||||
|
||||
/**
|
||||
* Configuration for the state subscriber
|
||||
*/
|
||||
export interface StateSubscriberConfig {
|
||||
/** Callback when state changes */
|
||||
onStateChange?: StateChangeHandler
|
||||
|
||||
/** Callback for complete (non-partial) messages to output */
|
||||
onCompleteMessage: MessageCallback
|
||||
|
||||
/** Function to get current messages from the controller */
|
||||
getMessages: () => ClineMessage[]
|
||||
|
||||
/** Callback for activity (used by spinner) */
|
||||
onActivity?: () => void
|
||||
}
|
||||
@@ -0,0 +1,461 @@
|
||||
/**
|
||||
* Task storage and management system
|
||||
* Stores task history in ~/.cline/tasks/
|
||||
*/
|
||||
|
||||
import crypto from "crypto"
|
||||
import fs from "fs"
|
||||
import path from "path"
|
||||
import type {
|
||||
MessageRole,
|
||||
MessageType,
|
||||
TaskCreateOptions,
|
||||
TaskInfo,
|
||||
TaskListItem,
|
||||
TaskMessage,
|
||||
TaskMode,
|
||||
TaskStatus,
|
||||
} from "../types/task.js"
|
||||
import { getDefaultConfigDir } from "./config.js"
|
||||
|
||||
/**
|
||||
* Generate a short unique task ID
|
||||
*/
|
||||
function generateTaskId(): string {
|
||||
return crypto.randomBytes(8).toString("hex")
|
||||
}
|
||||
|
||||
/**
|
||||
* Truncate a string to a maximum length with ellipsis
|
||||
*/
|
||||
function truncate(str: string, maxLength: number): string {
|
||||
if (str.length <= maxLength) {
|
||||
return str
|
||||
}
|
||||
return str.slice(0, maxLength - 3) + "..."
|
||||
}
|
||||
|
||||
/**
|
||||
* Get relative time string (e.g., "2 hours ago")
|
||||
*/
|
||||
function getTimeAgo(timestamp: number): string {
|
||||
const now = Date.now()
|
||||
const diff = now - timestamp
|
||||
const seconds = Math.floor(diff / 1000)
|
||||
const minutes = Math.floor(seconds / 60)
|
||||
const hours = Math.floor(minutes / 60)
|
||||
const days = Math.floor(hours / 24)
|
||||
const weeks = Math.floor(days / 7)
|
||||
const months = Math.floor(days / 30)
|
||||
|
||||
if (months > 0) {
|
||||
return months === 1 ? "1 month ago" : `${months} months ago`
|
||||
}
|
||||
if (weeks > 0) {
|
||||
return weeks === 1 ? "1 week ago" : `${weeks} weeks ago`
|
||||
}
|
||||
if (days > 0) {
|
||||
return days === 1 ? "1 day ago" : `${days} days ago`
|
||||
}
|
||||
if (hours > 0) {
|
||||
return hours === 1 ? "1 hour ago" : `${hours} hours ago`
|
||||
}
|
||||
if (minutes > 0) {
|
||||
return minutes === 1 ? "1 minute ago" : `${minutes} minutes ago`
|
||||
}
|
||||
return "just now"
|
||||
}
|
||||
|
||||
/**
|
||||
* Task storage class
|
||||
*/
|
||||
export class TaskStorage {
|
||||
private tasksDir: string
|
||||
private indexPath: string
|
||||
|
||||
constructor(configDir?: string) {
|
||||
const baseDir = configDir || getDefaultConfigDir()
|
||||
this.tasksDir = path.join(baseDir, "tasks")
|
||||
this.indexPath = path.join(this.tasksDir, "index.json")
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the tasks directory exists
|
||||
*/
|
||||
private ensureTasksDir(): void {
|
||||
if (!fs.existsSync(this.tasksDir)) {
|
||||
fs.mkdirSync(this.tasksDir, { recursive: true, mode: 0o700 })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get path to a task file
|
||||
*/
|
||||
private getTaskPath(taskId: string): string {
|
||||
return path.join(this.tasksDir, `${taskId}.json`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the task index (list of all task IDs and metadata)
|
||||
*/
|
||||
private loadIndex(): TaskInfo[] {
|
||||
try {
|
||||
if (fs.existsSync(this.indexPath)) {
|
||||
const content = fs.readFileSync(this.indexPath, "utf-8")
|
||||
return JSON.parse(content) as TaskInfo[]
|
||||
}
|
||||
} catch {
|
||||
// Return empty index on error
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
/**
|
||||
* Save the task index
|
||||
*/
|
||||
private saveIndex(index: TaskInfo[]): void {
|
||||
this.ensureTasksDir()
|
||||
fs.writeFileSync(this.indexPath, JSON.stringify(index, null, 2), {
|
||||
mode: 0o600,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new task
|
||||
*/
|
||||
create(options: TaskCreateOptions): TaskInfo {
|
||||
const now = Date.now()
|
||||
const taskInfo: TaskInfo = {
|
||||
id: generateTaskId(),
|
||||
prompt: options.prompt,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
status: "active",
|
||||
mode: options.mode || "act",
|
||||
messageCount: 0,
|
||||
workingDirectory: options.workingDirectory || process.cwd(),
|
||||
settings: options.settings,
|
||||
}
|
||||
|
||||
// Save task file
|
||||
this.ensureTasksDir()
|
||||
fs.writeFileSync(this.getTaskPath(taskInfo.id), JSON.stringify(taskInfo, null, 2), {
|
||||
mode: 0o600,
|
||||
})
|
||||
|
||||
// Update index
|
||||
const index = this.loadIndex()
|
||||
index.unshift(taskInfo) // Add to beginning (most recent first)
|
||||
this.saveIndex(index)
|
||||
|
||||
return taskInfo
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a task by ID
|
||||
*/
|
||||
get(taskId: string): TaskInfo | null {
|
||||
const taskPath = this.getTaskPath(taskId)
|
||||
try {
|
||||
if (fs.existsSync(taskPath)) {
|
||||
const content = fs.readFileSync(taskPath, "utf-8")
|
||||
return JSON.parse(content) as TaskInfo
|
||||
}
|
||||
} catch {
|
||||
// Return null on error
|
||||
}
|
||||
|
||||
// Try to find by partial ID
|
||||
const index = this.loadIndex()
|
||||
const match = index.find((t) => t.id.startsWith(taskId))
|
||||
if (match) {
|
||||
return this.get(match.id)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a task
|
||||
*/
|
||||
update(taskId: string, updates: Partial<TaskInfo>): TaskInfo | null {
|
||||
const task = this.get(taskId)
|
||||
if (!task) {
|
||||
return null
|
||||
}
|
||||
|
||||
const updatedTask: TaskInfo = {
|
||||
...task,
|
||||
...updates,
|
||||
id: task.id, // Ensure ID doesn't change
|
||||
updatedAt: Date.now(),
|
||||
}
|
||||
|
||||
// Save task file
|
||||
fs.writeFileSync(this.getTaskPath(task.id), JSON.stringify(updatedTask, null, 2), {
|
||||
mode: 0o600,
|
||||
})
|
||||
|
||||
// Update index
|
||||
const index = this.loadIndex()
|
||||
const indexPos = index.findIndex((t) => t.id === task.id)
|
||||
if (indexPos >= 0) {
|
||||
index[indexPos] = updatedTask
|
||||
this.saveIndex(index)
|
||||
}
|
||||
|
||||
return updatedTask
|
||||
}
|
||||
|
||||
/**
|
||||
* Update task status
|
||||
*/
|
||||
updateStatus(taskId: string, status: TaskStatus): TaskInfo | null {
|
||||
return this.update(taskId, { status })
|
||||
}
|
||||
|
||||
/**
|
||||
* Update task mode
|
||||
*/
|
||||
updateMode(taskId: string, mode: TaskMode): TaskInfo | null {
|
||||
return this.update(taskId, { mode })
|
||||
}
|
||||
|
||||
/**
|
||||
* Increment message count
|
||||
*/
|
||||
incrementMessageCount(taskId: string): TaskInfo | null {
|
||||
const task = this.get(taskId)
|
||||
if (!task) {
|
||||
return null
|
||||
}
|
||||
return this.update(taskId, { messageCount: task.messageCount + 1 })
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a task
|
||||
*/
|
||||
delete(taskId: string): boolean {
|
||||
const task = this.get(taskId)
|
||||
if (!task) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Delete task file
|
||||
const taskPath = this.getTaskPath(task.id)
|
||||
if (fs.existsSync(taskPath)) {
|
||||
fs.unlinkSync(taskPath)
|
||||
}
|
||||
|
||||
// Update index
|
||||
const index = this.loadIndex()
|
||||
const filteredIndex = index.filter((t) => t.id !== task.id)
|
||||
this.saveIndex(filteredIndex)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* List all tasks
|
||||
*/
|
||||
list(limit?: number): TaskInfo[] {
|
||||
const index = this.loadIndex()
|
||||
if (limit) {
|
||||
return index.slice(0, limit)
|
||||
}
|
||||
return index
|
||||
}
|
||||
|
||||
/**
|
||||
* List tasks formatted for display
|
||||
*/
|
||||
listForDisplay(limit?: number, idLength = 8, promptLength = 50): TaskListItem[] {
|
||||
const tasks = this.list(limit)
|
||||
return tasks.map((task) => ({
|
||||
id: task.id.slice(0, idLength),
|
||||
fullId: task.id,
|
||||
promptSnippet: truncate(task.prompt.replace(/\n/g, " "), promptLength),
|
||||
prompt: task.prompt,
|
||||
status: task.status,
|
||||
mode: task.mode,
|
||||
timeAgo: getTimeAgo(task.createdAt),
|
||||
createdAt: task.createdAt,
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* Find task by partial ID
|
||||
*/
|
||||
findByPartialId(partialId: string): TaskInfo | null {
|
||||
const index = this.loadIndex()
|
||||
const matches = index.filter((t) => t.id.startsWith(partialId))
|
||||
if (matches.length === 1) {
|
||||
return this.get(matches[0].id)
|
||||
}
|
||||
if (matches.length > 1) {
|
||||
// Return the most recent match
|
||||
return this.get(matches[0].id)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the tasks directory path
|
||||
*/
|
||||
getTasksDir(): string {
|
||||
return this.tasksDir
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all tasks (for testing)
|
||||
*/
|
||||
clear(): void {
|
||||
const tasks = this.list()
|
||||
for (const task of tasks) {
|
||||
this.delete(task.id)
|
||||
}
|
||||
}
|
||||
|
||||
// ========== Message Storage Methods ==========
|
||||
|
||||
/**
|
||||
* Get path to a task's messages file
|
||||
*/
|
||||
private getMessagesPath(taskId: string): string {
|
||||
return path.join(this.tasksDir, `${taskId}-messages.json`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Load messages for a task
|
||||
*/
|
||||
private loadMessages(taskId: string): TaskMessage[] {
|
||||
const messagesPath = this.getMessagesPath(taskId)
|
||||
try {
|
||||
if (fs.existsSync(messagesPath)) {
|
||||
const content = fs.readFileSync(messagesPath, "utf-8")
|
||||
return JSON.parse(content) as TaskMessage[]
|
||||
}
|
||||
} catch {
|
||||
// Return empty array on error
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
/**
|
||||
* Save messages for a task
|
||||
*/
|
||||
private saveMessages(taskId: string, messages: TaskMessage[]): void {
|
||||
this.ensureTasksDir()
|
||||
fs.writeFileSync(this.getMessagesPath(taskId), JSON.stringify(messages, null, 2), {
|
||||
mode: 0o600,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a message to a task
|
||||
*/
|
||||
addMessage(
|
||||
taskId: string,
|
||||
role: MessageRole,
|
||||
type: MessageType,
|
||||
content: string,
|
||||
attachments?: string[],
|
||||
metadata?: Record<string, unknown>,
|
||||
): TaskMessage | null {
|
||||
const task = this.get(taskId)
|
||||
if (!task) {
|
||||
return null
|
||||
}
|
||||
|
||||
const message: TaskMessage = {
|
||||
id: crypto.randomBytes(8).toString("hex"),
|
||||
taskId: task.id,
|
||||
role,
|
||||
type,
|
||||
content,
|
||||
timestamp: Date.now(),
|
||||
attachments,
|
||||
metadata,
|
||||
}
|
||||
|
||||
// Load existing messages and append
|
||||
const messages = this.loadMessages(task.id)
|
||||
messages.push(message)
|
||||
this.saveMessages(task.id, messages)
|
||||
|
||||
// Update message count on task
|
||||
this.incrementMessageCount(task.id)
|
||||
|
||||
return message
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all messages for a task
|
||||
*/
|
||||
getMessages(taskId: string): TaskMessage[] {
|
||||
const task = this.get(taskId)
|
||||
if (!task) {
|
||||
return []
|
||||
}
|
||||
return this.loadMessages(task.id)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the latest message for a task
|
||||
*/
|
||||
getLatestMessage(taskId: string): TaskMessage | null {
|
||||
const messages = this.getMessages(taskId)
|
||||
if (messages.length === 0) {
|
||||
return null
|
||||
}
|
||||
return messages[messages.length - 1]
|
||||
}
|
||||
|
||||
/**
|
||||
* Get messages since a given timestamp
|
||||
*/
|
||||
getMessagesSince(taskId: string, sinceTimestamp: number): TaskMessage[] {
|
||||
const messages = this.getMessages(taskId)
|
||||
return messages.filter((m) => m.timestamp > sinceTimestamp)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if task has pending approval request
|
||||
*/
|
||||
hasPendingApproval(taskId: string): TaskMessage | null {
|
||||
const messages = this.getMessages(taskId)
|
||||
// Look for the last message that's an approval request without a response
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
const msg = messages[i]
|
||||
if (msg.type === "approval_request") {
|
||||
// Check if there's a response after this
|
||||
const hasResponse = messages.slice(i + 1).some((m) => m.type === "approval_response")
|
||||
if (!hasResponse) {
|
||||
return msg
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear messages for a task (for testing)
|
||||
*/
|
||||
clearMessages(taskId: string): void {
|
||||
const task = this.get(taskId)
|
||||
if (task) {
|
||||
const messagesPath = this.getMessagesPath(task.id)
|
||||
if (fs.existsSync(messagesPath)) {
|
||||
fs.unlinkSync(messagesPath)
|
||||
}
|
||||
this.update(task.id, { messageCount: 0 })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a task storage instance
|
||||
*/
|
||||
export function createTaskStorage(configDir?: string): TaskStorage {
|
||||
return new TaskStorage(configDir)
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
#!/usr/bin/env node
|
||||
// Suppress Node.js deprecation warnings (e.g., punycode) before any imports
|
||||
process.noDeprecation = true
|
||||
|
||||
import { Command } from "commander"
|
||||
import { createAuthCommand } from "./commands/auth/index.js"
|
||||
import { createConfigCommand } from "./commands/config/index.js"
|
||||
import { createTaskChatCommand } from "./commands/task/chat/index.js"
|
||||
import { createTaskCommand } from "./commands/task/index.js"
|
||||
import { createTaskSendCommand } from "./commands/task/send.js"
|
||||
import { createVersionCommand, getVersion } from "./commands/version.js"
|
||||
import { createConfig } from "./core/config.js"
|
||||
import { applyConsoleFilter } from "./core/console-filter.js"
|
||||
import { createLogger } from "./core/logger.js"
|
||||
import { createFormatter, parseOutputFormat } from "./core/output/index.js"
|
||||
import type { OutputFormat, OutputFormatter } from "./core/output/types.js"
|
||||
import type { CliConfig } from "./types/config.js"
|
||||
import type { Logger } from "./types/logger.js"
|
||||
|
||||
/**
|
||||
* Read input from stdin if available (non-blocking check)
|
||||
*/
|
||||
async function readStdin(): Promise<string | null> {
|
||||
// Check if stdin is a TTY (interactive terminal)
|
||||
if (process.stdin.isTTY) {
|
||||
return null
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
let data = ""
|
||||
process.stdin.setEncoding("utf-8")
|
||||
|
||||
process.stdin.on("readable", () => {
|
||||
let chunk: string | null
|
||||
while ((chunk = process.stdin.read() as string | null) !== null) {
|
||||
data += chunk
|
||||
}
|
||||
})
|
||||
|
||||
process.stdin.on("end", () => {
|
||||
resolve(data.trim() || null)
|
||||
})
|
||||
|
||||
// Timeout after 100ms if no data
|
||||
setTimeout(() => {
|
||||
if (!data) {
|
||||
resolve(null)
|
||||
}
|
||||
}, 100)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Create and configure the commander program
|
||||
*/
|
||||
export function createProgram(): Command {
|
||||
const program = new Command()
|
||||
|
||||
program
|
||||
.name("cline")
|
||||
.description("Cline CLI - AI assistant for software development")
|
||||
.version(getVersion(), "-v, --version", "Display version number")
|
||||
.option("--verbose", "Enable verbose debug output", false)
|
||||
.option("--config-dir <path>", "Directory for Cline data storage")
|
||||
.option("-F, --output-format <format>", "Output format: rich, json, or plain (default: rich for TTY, plain otherwise)")
|
||||
.option("-y, --yolo", "Enable autonomous mode (no confirmations)", false)
|
||||
|
||||
return program
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an argument looks like a subcommand
|
||||
*/
|
||||
function isKnownSubcommand(arg: string): boolean {
|
||||
const subcommands = ["version", "config", "auth", "task", "t", "c", "help", "-h", "--help", "-v", "--version"]
|
||||
return subcommands.includes(arg)
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the default command (chat or send based on context)
|
||||
*/
|
||||
async function runDefaultCommand(
|
||||
prompt: string,
|
||||
yoloMode: boolean,
|
||||
hasPipedInput: boolean,
|
||||
config: CliConfig,
|
||||
logger: Logger,
|
||||
formatter: OutputFormatter,
|
||||
): Promise<void> {
|
||||
// Decision matrix:
|
||||
// - Piped input + yolo = send command (non-interactive, exit on completion)
|
||||
// - Piped input + no yolo = chat command (REPL stays open for interaction)
|
||||
// - Direct arg + yolo = chat command with yolo (REPL with auto-approve)
|
||||
// - Direct arg + no yolo = chat command (normal REPL)
|
||||
if (hasPipedInput && yoloMode) {
|
||||
// Create and run send command with --yolo flag (implies --wait behavior)
|
||||
const sendCommand = createTaskSendCommand(config, logger, formatter)
|
||||
// Using { from: "user" } means args are treated as user-provided (no stripping of argv[0,1])
|
||||
await sendCommand.parseAsync([prompt, "--yolo"], { from: "user" })
|
||||
} else {
|
||||
// Create and run chat command
|
||||
const chatCommand = createTaskChatCommand(config, logger, formatter)
|
||||
// Using { from: "user" } means args are treated as user-provided (no stripping of argv[0,1])
|
||||
const args = [prompt]
|
||||
if (yoloMode) {
|
||||
args.push("--yolo")
|
||||
}
|
||||
await chatCommand.parseAsync(args, { from: "user" })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Main entry point for the CLI
|
||||
*/
|
||||
export async function main(): Promise<void> {
|
||||
const program = createProgram()
|
||||
|
||||
// Use parseOptions to extract global options without consuming subcommand args
|
||||
// This allows us to get --verbose, --config-dir, etc. before registering subcommands
|
||||
const { operands, unknown } = program.parseOptions(process.argv.slice(2))
|
||||
|
||||
const opts = program.opts()
|
||||
|
||||
// Apply console filtering early to suppress noisy operational output
|
||||
// This must happen before any other code runs that might output to console
|
||||
applyConsoleFilter(opts.verbose)
|
||||
|
||||
// Parse and validate output format
|
||||
let outputFormat: OutputFormat
|
||||
try {
|
||||
outputFormat = parseOutputFormat(opts.outputFormat)
|
||||
} catch (err) {
|
||||
console.error((err as Error).message)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
// Create config from command line options
|
||||
const config: CliConfig = createConfig({
|
||||
verbose: opts.verbose,
|
||||
configDir: opts.configDir,
|
||||
outputFormat,
|
||||
})
|
||||
|
||||
// Create logger based on config
|
||||
const logger = createLogger(config.verbose)
|
||||
|
||||
// Create output formatter
|
||||
const formatter: OutputFormatter = createFormatter(outputFormat)
|
||||
|
||||
logger.debug("CLI started with config:", config)
|
||||
|
||||
// Check for stdin input (piped data)
|
||||
const stdinInput = await readStdin()
|
||||
const hasPipedInput = stdinInput !== null
|
||||
|
||||
// Determine if we should handle as default command
|
||||
// This happens when:
|
||||
// 1. There's piped stdin input, OR
|
||||
// 2. There's a positional argument that's not a known subcommand
|
||||
const firstOperand = operands[0]
|
||||
const hasPromptArg = firstOperand && !isKnownSubcommand(firstOperand)
|
||||
const prompt = stdinInput || (hasPromptArg ? firstOperand : null)
|
||||
|
||||
if (prompt) {
|
||||
// Handle as default command (route to chat or send)
|
||||
// Key logic: piped input + yolo = non-interactive send (exit on completion)
|
||||
// All other cases = chat (REPL, stays open)
|
||||
logger.debug("Running default command with prompt", { prompt, yolo: opts.yolo, hasPipedInput })
|
||||
await runDefaultCommand(prompt, opts.yolo, hasPipedInput, config, logger, formatter)
|
||||
return
|
||||
}
|
||||
|
||||
// Add subcommands BEFORE parsing
|
||||
program.addCommand(createVersionCommand(config, logger))
|
||||
program.addCommand(createConfigCommand(config, logger, formatter))
|
||||
program.addCommand(createAuthCommand(config, logger, formatter))
|
||||
program.addCommand(createTaskCommand(config, logger, formatter))
|
||||
|
||||
// Now parse the full command line with all subcommands registered
|
||||
program.parse(process.argv)
|
||||
|
||||
// If no subcommand provided, show help
|
||||
if (process.argv.length === 2 || (operands.length === 0 && unknown.length === 0)) {
|
||||
program.help()
|
||||
}
|
||||
}
|
||||
|
||||
// Run main function
|
||||
main().catch((error) => {
|
||||
console.error("Fatal error:", error)
|
||||
process.exit(1)
|
||||
})
|
||||
@@ -0,0 +1,123 @@
|
||||
import * as fs from "fs/promises"
|
||||
import * as iconv from "iconv-lite"
|
||||
import { DiffViewProvider } from "@/integrations/editor/DiffViewProvider"
|
||||
|
||||
/**
|
||||
* Standalone DiffViewProvider for CLI mode.
|
||||
*
|
||||
* This implementation works directly with the filesystem instead of
|
||||
* relying on VSCode's diff editor UI. It stores content in memory
|
||||
* during editing and writes directly to disk on save.
|
||||
*/
|
||||
export class StandaloneDiffViewProvider extends DiffViewProvider {
|
||||
/** Accumulated content during streaming edits */
|
||||
private accumulatedContent: string = ""
|
||||
|
||||
/** Logger function for CLI output */
|
||||
private log: (message: string) => void
|
||||
|
||||
constructor(log?: (message: string) => void) {
|
||||
super()
|
||||
this.log = log ?? console.log
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens a diff editor - no-op for CLI since we don't have a visual editor.
|
||||
* We just log that editing has started.
|
||||
*/
|
||||
protected override async openDiffEditor(): Promise<void> {
|
||||
// Initialize accumulated content with original content (or empty for new files)
|
||||
this.accumulatedContent = this.originalContent ?? ""
|
||||
this.log(`Editing: ${this.relPath}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces text in the document. For CLI mode, we just store the content in memory.
|
||||
*/
|
||||
override async replaceText(
|
||||
content: string,
|
||||
_rangeToReplace: { startLine: number; endLine: number },
|
||||
_currentLine: number | undefined,
|
||||
): Promise<void> {
|
||||
this.accumulatedContent = content
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the current document content from memory.
|
||||
*/
|
||||
protected override async getDocumentText(): Promise<string | undefined> {
|
||||
return this.accumulatedContent
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the line count of the current document in memory.
|
||||
*/
|
||||
protected override async getDocumentLineCount(): Promise<number> {
|
||||
if (!this.accumulatedContent) {
|
||||
return 0
|
||||
}
|
||||
return this.accumulatedContent.split("\n").length
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves the document to disk with proper encoding.
|
||||
*/
|
||||
protected override async saveDocument(): Promise<Boolean> {
|
||||
if (!this.absolutePath) {
|
||||
return false
|
||||
}
|
||||
try {
|
||||
const encoded = iconv.encode(this.accumulatedContent, this.fileEncoding)
|
||||
await fs.writeFile(this.absolutePath, encoded)
|
||||
this.log(`Saved: ${this.relPath}`)
|
||||
return true
|
||||
} catch (error) {
|
||||
this.log(`Error saving ${this.relPath}: ${error}`)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Truncates the document to the specified line number.
|
||||
* For CLI mode, we truncate the in-memory content.
|
||||
*/
|
||||
protected override async truncateDocument(lineNumber: number): Promise<void> {
|
||||
const lines = this.accumulatedContent.split("\n")
|
||||
this.accumulatedContent = lines.slice(0, lineNumber).join("\n")
|
||||
}
|
||||
|
||||
/**
|
||||
* Scrolls to a specific line - no-op for CLI since there's no visual editor.
|
||||
*/
|
||||
protected override async scrollEditorToLine(_line: number): Promise<void> {
|
||||
// No-op - CLI doesn't have a visual editor to scroll
|
||||
}
|
||||
|
||||
/**
|
||||
* Scroll animation - no-op for CLI since there's no visual editor.
|
||||
*/
|
||||
override async scrollAnimation(_startLine: number, _endLine: number): Promise<void> {
|
||||
// No-op - CLI doesn't have a visual editor to animate
|
||||
}
|
||||
|
||||
/**
|
||||
* Closes all diff views - no-op for CLI since there's no visual editor.
|
||||
*/
|
||||
protected override async closeAllDiffViews(): Promise<void> {
|
||||
// No-op - CLI doesn't have diff views to close
|
||||
}
|
||||
|
||||
/**
|
||||
* Resets the diff view state.
|
||||
*/
|
||||
protected override async resetDiffView(): Promise<void> {
|
||||
this.accumulatedContent = ""
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows a file - for CLI, we just log that the file was modified.
|
||||
*/
|
||||
override async showFile(_absolutePath: string): Promise<void> {
|
||||
// No-op for CLI - we don't open files in an editor
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { OutputFormat } from "../core/output/types.js"
|
||||
|
||||
/**
|
||||
* CLI configuration options
|
||||
*/
|
||||
export interface CliConfig {
|
||||
/** Enable verbose debug output */
|
||||
verbose: boolean
|
||||
/** Directory for Cline data storage (default: ~/.cline) */
|
||||
configDir: string
|
||||
/** Output format: rich, json, or plain */
|
||||
outputFormat: OutputFormat
|
||||
}
|
||||
|
||||
/**
|
||||
* Partial CLI config used when creating config with overrides
|
||||
*/
|
||||
export type PartialCliConfig = Partial<CliConfig>
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from "./config.js"
|
||||
export * from "./logger.js"
|
||||
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* Logger interface for CLI output
|
||||
*/
|
||||
export interface Logger {
|
||||
/** Log debug messages (only shown when verbose=true) */
|
||||
debug(message: string, ...args: unknown[]): void
|
||||
/** Log informational messages */
|
||||
info(message: string, ...args: unknown[]): void
|
||||
/** Log warning messages */
|
||||
warn(message: string, ...args: unknown[]): void
|
||||
/** Log error messages */
|
||||
error(message: string, ...args: unknown[]): void
|
||||
}
|
||||
|
||||
/**
|
||||
* Log level enum for internal use
|
||||
*/
|
||||
export enum LogLevel {
|
||||
DEBUG = "debug",
|
||||
INFO = "info",
|
||||
WARN = "warn",
|
||||
ERROR = "error",
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
/**
|
||||
* Task-related type definitions
|
||||
*/
|
||||
|
||||
/**
|
||||
* Task status
|
||||
*/
|
||||
export type TaskStatus = "active" | "paused" | "completed"
|
||||
|
||||
/**
|
||||
* Task mode (plan or act)
|
||||
*/
|
||||
export type TaskMode = "plan" | "act"
|
||||
|
||||
/**
|
||||
* Task information stored in task history
|
||||
*/
|
||||
export interface TaskInfo {
|
||||
/** Unique task identifier */
|
||||
id: string
|
||||
/** The initial prompt/task description */
|
||||
prompt: string
|
||||
/** Creation timestamp (Unix epoch ms) */
|
||||
createdAt: number
|
||||
/** Last updated timestamp (Unix epoch ms) */
|
||||
updatedAt: number
|
||||
/** Current task status */
|
||||
status: TaskStatus
|
||||
/** Current mode (plan or act) */
|
||||
mode: TaskMode
|
||||
/** Number of messages in the conversation */
|
||||
messageCount: number
|
||||
/** Working directory for the task */
|
||||
workingDirectory?: string
|
||||
/** Custom settings overrides */
|
||||
settings?: Record<string, string>
|
||||
}
|
||||
|
||||
/**
|
||||
* Task creation options
|
||||
*/
|
||||
export interface TaskCreateOptions {
|
||||
/** Initial prompt */
|
||||
prompt: string
|
||||
/** Starting mode */
|
||||
mode?: TaskMode
|
||||
/** Enable autonomous/yolo mode */
|
||||
noInteractive?: boolean
|
||||
/** Custom settings overrides */
|
||||
settings?: Record<string, string>
|
||||
/** Working directory */
|
||||
workingDirectory?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Task open options
|
||||
*/
|
||||
export interface TaskOpenOptions {
|
||||
/** Override mode */
|
||||
mode?: TaskMode
|
||||
/** Enable autonomous/yolo mode */
|
||||
noInteractive?: boolean
|
||||
/** Custom settings overrides */
|
||||
settings?: Record<string, string>
|
||||
}
|
||||
|
||||
/**
|
||||
* Task list item for display purposes
|
||||
*/
|
||||
export interface TaskListItem {
|
||||
/** Task ID (possibly truncated for display) */
|
||||
id: string
|
||||
/** Full task ID */
|
||||
fullId: string
|
||||
/** Prompt snippet (truncated) */
|
||||
promptSnippet: string
|
||||
/** Full prompt */
|
||||
prompt: string
|
||||
/** Task status */
|
||||
status: TaskStatus
|
||||
/** Task mode */
|
||||
mode: TaskMode
|
||||
/** Relative time string (e.g., "2 hours ago") */
|
||||
timeAgo: string
|
||||
/** Creation timestamp */
|
||||
createdAt: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Message role (who sent the message)
|
||||
*/
|
||||
export type MessageRole = "user" | "assistant" | "system"
|
||||
|
||||
/**
|
||||
* Message type for categorization
|
||||
*/
|
||||
export type MessageType = "text" | "command" | "error" | "tool_use" | "tool_result" | "approval_request" | "approval_response"
|
||||
|
||||
/**
|
||||
* A single message in a task conversation
|
||||
*/
|
||||
export interface TaskMessage {
|
||||
/** Unique message ID */
|
||||
id: string
|
||||
/** Task ID this message belongs to */
|
||||
taskId: string
|
||||
/** Who sent the message */
|
||||
role: MessageRole
|
||||
/** Message type */
|
||||
type: MessageType
|
||||
/** Message content */
|
||||
content: string
|
||||
/** Timestamp (Unix epoch ms) */
|
||||
timestamp: number
|
||||
/** Optional file attachments (paths) */
|
||||
attachments?: string[]
|
||||
/** Optional metadata */
|
||||
metadata?: Record<string, unknown>
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for sending a message
|
||||
*/
|
||||
export interface SendMessageOptions {
|
||||
/** Approve a proposed action */
|
||||
approve?: boolean
|
||||
/** Deny a proposed action */
|
||||
deny?: boolean
|
||||
/** Attach file(s) */
|
||||
files?: string[]
|
||||
/** Enable autonomous mode */
|
||||
noInteractive?: boolean
|
||||
/** Switch to mode */
|
||||
mode?: TaskMode
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* Test setup file for Mocha
|
||||
*
|
||||
* This file is loaded before all tests run and can be used
|
||||
* for global test configuration and utilities.
|
||||
*/
|
||||
|
||||
// Store original console methods for tests that need them
|
||||
const originalConsole = { ...console }
|
||||
|
||||
/**
|
||||
* Test utilities for console management
|
||||
*/
|
||||
export const testUtils = {
|
||||
/**
|
||||
* Restore the original console methods
|
||||
*/
|
||||
restoreConsole: () => {
|
||||
Object.assign(console, originalConsole)
|
||||
},
|
||||
|
||||
/**
|
||||
* Silence console output for cleaner test output
|
||||
*/
|
||||
silenceConsole: () => {
|
||||
console.log = () => {}
|
||||
console.info = () => {}
|
||||
console.debug = () => {}
|
||||
console.warn = () => {}
|
||||
// Keep console.error for debugging test failures
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* Tests for auth status command
|
||||
*/
|
||||
|
||||
import { expect } from "chai"
|
||||
import sinon from "sinon"
|
||||
import { createAuthCommand } from "../../../../src/commands/auth/index.js"
|
||||
import type { OutputFormatter } from "../../../../src/core/output/types.js"
|
||||
import type { CliConfig } from "../../../../src/types/config.js"
|
||||
import type { Logger } from "../../../../src/types/logger.js"
|
||||
|
||||
describe("auth status command", () => {
|
||||
let config: CliConfig
|
||||
let logger: Logger
|
||||
let formatter: OutputFormatter
|
||||
|
||||
beforeEach(() => {
|
||||
// Create mock config
|
||||
config = {
|
||||
verbose: false,
|
||||
configDir: "/tmp/cline-test",
|
||||
outputFormat: "plain",
|
||||
}
|
||||
|
||||
// Create mock logger
|
||||
logger = {
|
||||
debug: sinon.stub(),
|
||||
info: sinon.stub(),
|
||||
warn: sinon.stub(),
|
||||
error: sinon.stub(),
|
||||
}
|
||||
|
||||
// Create mock formatter
|
||||
formatter = {
|
||||
message: sinon.stub(),
|
||||
success: sinon.stub(),
|
||||
error: sinon.stub(),
|
||||
info: sinon.stub(),
|
||||
warn: sinon.stub(),
|
||||
table: sinon.stub(),
|
||||
list: sinon.stub(),
|
||||
tasks: sinon.stub(),
|
||||
keyValue: sinon.stub(),
|
||||
raw: sinon.stub(),
|
||||
}
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
sinon.restore()
|
||||
})
|
||||
|
||||
it("should create command with --status option", () => {
|
||||
const cmd = createAuthCommand(config, logger, formatter)
|
||||
|
||||
// Get the options from the command
|
||||
const options = cmd.options
|
||||
const statusOption = options.find((opt) => opt.short === "-s" || opt.long === "--status")
|
||||
|
||||
expect(statusOption).to.exist
|
||||
expect(statusOption?.long).to.equal("--status")
|
||||
expect(statusOption?.short).to.equal("-s")
|
||||
})
|
||||
|
||||
it("should have correct command name and alias", () => {
|
||||
const cmd = createAuthCommand(config, logger, formatter)
|
||||
|
||||
expect(cmd.name()).to.equal("auth")
|
||||
expect(cmd.aliases()).to.include("a")
|
||||
})
|
||||
|
||||
it("should have description for status option", () => {
|
||||
const cmd = createAuthCommand(config, logger, formatter)
|
||||
|
||||
const options = cmd.options
|
||||
const statusOption = options.find((opt) => opt.long === "--status")
|
||||
|
||||
expect(statusOption?.description).to.include("status")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,213 @@
|
||||
/**
|
||||
* Tests for task chat command with embedded Controller
|
||||
*/
|
||||
|
||||
import { expect } from "chai"
|
||||
import fs from "fs"
|
||||
import os from "os"
|
||||
import path from "path"
|
||||
import sinon from "sinon"
|
||||
import { createTaskChatCommand } from "../../../../../src/commands/task/chat/index.js"
|
||||
// Mock the embedded controller module
|
||||
import * as embeddedController from "../../../../../src/core/embedded-controller.js"
|
||||
import type { OutputFormatter } from "../../../../../src/core/output/types.js"
|
||||
import type { CliConfig } from "../../../../../src/types/config.js"
|
||||
import type { Logger } from "../../../../../src/types/logger.js"
|
||||
|
||||
describe("task chat command", () => {
|
||||
let tempDir: string
|
||||
let config: CliConfig
|
||||
let logger: Logger
|
||||
let formatter: OutputFormatter
|
||||
let exitStub: sinon.SinonStub
|
||||
let getControllerStub: sinon.SinonStub
|
||||
let disposeControllerStub: sinon.SinonStub
|
||||
|
||||
// Mock controller
|
||||
const mockController = {
|
||||
task: null as any,
|
||||
initTask: sinon.stub(),
|
||||
cancelTask: sinon.stub(),
|
||||
togglePlanActMode: sinon.stub(),
|
||||
getTaskWithId: sinon.stub(),
|
||||
getStateToPostToWebview: sinon.stub(),
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
// Create temp directory
|
||||
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "cline-test-"))
|
||||
|
||||
// Create mock config
|
||||
config = {
|
||||
verbose: false,
|
||||
configDir: tempDir,
|
||||
outputFormat: "plain",
|
||||
}
|
||||
|
||||
// Create mock logger
|
||||
logger = {
|
||||
debug: sinon.stub(),
|
||||
info: sinon.stub(),
|
||||
warn: sinon.stub(),
|
||||
error: sinon.stub(),
|
||||
}
|
||||
|
||||
// Create mock formatter
|
||||
formatter = {
|
||||
message: sinon.stub(),
|
||||
success: sinon.stub(),
|
||||
error: sinon.stub(),
|
||||
info: sinon.stub(),
|
||||
warn: sinon.stub(),
|
||||
table: sinon.stub(),
|
||||
list: sinon.stub(),
|
||||
tasks: sinon.stub(),
|
||||
keyValue: sinon.stub(),
|
||||
raw: sinon.stub(),
|
||||
}
|
||||
|
||||
// Stub process.exit
|
||||
exitStub = sinon.stub(process, "exit")
|
||||
|
||||
// Reset mock controller
|
||||
mockController.task = null
|
||||
mockController.initTask.reset()
|
||||
mockController.cancelTask.reset()
|
||||
mockController.togglePlanActMode.reset()
|
||||
mockController.getTaskWithId.reset()
|
||||
mockController.getStateToPostToWebview.reset()
|
||||
|
||||
// Setup default stubs
|
||||
mockController.getStateToPostToWebview.resolves({
|
||||
mode: "act",
|
||||
clineMessages: [],
|
||||
taskHistory: [],
|
||||
})
|
||||
mockController.initTask.resolves("test-task-123")
|
||||
|
||||
// Stub the embedded controller functions
|
||||
getControllerStub = sinon.stub(embeddedController, "getEmbeddedController").resolves(mockController as any)
|
||||
disposeControllerStub = sinon.stub(embeddedController, "disposeEmbeddedController").resolves()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
// Restore stubs
|
||||
sinon.restore()
|
||||
// Clean up temp directory
|
||||
fs.rmSync(tempDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it("should create command with correct name and alias", () => {
|
||||
const cmd = createTaskChatCommand(config, logger, formatter)
|
||||
|
||||
expect(cmd.name()).to.equal("chat")
|
||||
expect(cmd.aliases()).to.include("c")
|
||||
})
|
||||
|
||||
it("should initialize controller on command start", async () => {
|
||||
const cmd = createTaskChatCommand(config, logger, formatter)
|
||||
|
||||
// Start chat with a prompt (this will initialize controller but hang on readline)
|
||||
// We need to cause it to error to avoid hanging
|
||||
mockController.initTask.rejects(new Error("Test error"))
|
||||
|
||||
await cmd.parseAsync(["node", "test", "Hello Cline"])
|
||||
|
||||
expect(getControllerStub.calledOnce).to.be.true
|
||||
})
|
||||
|
||||
it("should error on invalid mode option", async () => {
|
||||
const cmd = createTaskChatCommand(config, logger, formatter)
|
||||
|
||||
// Make initTask throw to exit the command
|
||||
mockController.initTask.rejects(new Error("Test"))
|
||||
|
||||
await cmd.parseAsync(["node", "test", "-m", "invalid", "prompt"])
|
||||
|
||||
expect((formatter.error as sinon.SinonStub).calledOnce).to.be.true
|
||||
expect((formatter.error as sinon.SinonStub).firstCall.args[0]).to.include("Invalid mode")
|
||||
expect(exitStub.calledWith(1)).to.be.true
|
||||
})
|
||||
|
||||
it("should start new task with prompt argument", async () => {
|
||||
const cmd = createTaskChatCommand(config, logger, formatter)
|
||||
|
||||
// Make initTask throw after being called to exit the command
|
||||
mockController.initTask.callsFake(async () => {
|
||||
throw new Error("Exit after init")
|
||||
})
|
||||
|
||||
await cmd.parseAsync(["node", "test", "Hello Cline"])
|
||||
|
||||
expect(mockController.initTask.calledWith("Hello Cline")).to.be.true
|
||||
})
|
||||
|
||||
it("should resume existing task with --task option", async () => {
|
||||
const historyItem = { id: "existing-task-456", task: "Previous task" }
|
||||
mockController.getTaskWithId.resolves({ historyItem })
|
||||
mockController.initTask.callsFake(async () => {
|
||||
throw new Error("Exit after init")
|
||||
})
|
||||
|
||||
const cmd = createTaskChatCommand(config, logger, formatter)
|
||||
await cmd.parseAsync(["node", "test", "-t", "existing-task-456"])
|
||||
|
||||
expect(mockController.getTaskWithId.calledWith("existing-task-456")).to.be.true
|
||||
expect(mockController.initTask.calledWith(undefined, undefined, undefined, historyItem)).to.be.true
|
||||
})
|
||||
|
||||
it("should switch mode when --mode option provided", async () => {
|
||||
mockController.initTask.callsFake(async () => {
|
||||
throw new Error("Exit after init")
|
||||
})
|
||||
|
||||
const cmd = createTaskChatCommand(config, logger, formatter)
|
||||
await cmd.parseAsync(["node", "test", "-m", "plan", "prompt"])
|
||||
|
||||
expect(mockController.togglePlanActMode.calledWith("plan")).to.be.true
|
||||
})
|
||||
|
||||
it("should dispose controller on error", async () => {
|
||||
mockController.initTask.rejects(new Error("Test error"))
|
||||
|
||||
const cmd = createTaskChatCommand(config, logger, formatter)
|
||||
await cmd.parseAsync(["node", "test", "prompt"])
|
||||
|
||||
expect(disposeControllerStub.calledOnce).to.be.true
|
||||
})
|
||||
|
||||
describe("command options", () => {
|
||||
it("should have -m/--mode option", () => {
|
||||
const cmd = createTaskChatCommand(config, logger, formatter)
|
||||
const modeOption = cmd.options.find((opt) => opt.short === "-m" || opt.long === "--mode")
|
||||
|
||||
expect(modeOption).to.exist
|
||||
})
|
||||
|
||||
it("should have -t/--task option", () => {
|
||||
const cmd = createTaskChatCommand(config, logger, formatter)
|
||||
const taskOption = cmd.options.find((opt) => opt.short === "-t" || opt.long === "--task")
|
||||
|
||||
expect(taskOption).to.exist
|
||||
})
|
||||
|
||||
it("should have -y/--yolo option", () => {
|
||||
const cmd = createTaskChatCommand(config, logger, formatter)
|
||||
const yoloOption = cmd.options.find((opt) => opt.short === "-y" || opt.long === "--yolo")
|
||||
|
||||
expect(yoloOption).to.exist
|
||||
})
|
||||
})
|
||||
|
||||
describe("task resume", () => {
|
||||
it("should error when task not found", async () => {
|
||||
mockController.getTaskWithId.rejects(new Error("Task not found"))
|
||||
|
||||
const cmd = createTaskChatCommand(config, logger, formatter)
|
||||
await cmd.parseAsync(["node", "test", "-t", "nonexistent"])
|
||||
|
||||
expect((formatter.error as sinon.SinonStub).calledOnce).to.be.true
|
||||
expect(exitStub.calledWith(1)).to.be.true
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,232 @@
|
||||
/**
|
||||
* Tests for @ file/folder completion and Tab mode toggle
|
||||
*/
|
||||
|
||||
import { expect } from "chai"
|
||||
import fs from "fs"
|
||||
import os from "os"
|
||||
import path from "path"
|
||||
import sinon from "sinon"
|
||||
import { createCompleter, findAtMentionToComplete, getPathCompletions } from "../../../../../src/commands/task/chat/completer.js"
|
||||
|
||||
describe("completer", () => {
|
||||
describe("findAtMentionToComplete", () => {
|
||||
it("should return null when no @ present", () => {
|
||||
const result = findAtMentionToComplete("hello world")
|
||||
expect(result).to.be.null
|
||||
})
|
||||
|
||||
it("should find @ at the start of line", () => {
|
||||
const result = findAtMentionToComplete("@src/file")
|
||||
expect(result).to.not.be.null
|
||||
expect(result!.prefix).to.equal("")
|
||||
expect(result!.partial).to.equal("src/file")
|
||||
expect(result!.atIndex).to.equal(0)
|
||||
})
|
||||
|
||||
it("should find @ after whitespace", () => {
|
||||
const result = findAtMentionToComplete("look at @src/file")
|
||||
expect(result).to.not.be.null
|
||||
expect(result!.prefix).to.equal("look at ")
|
||||
expect(result!.partial).to.equal("src/file")
|
||||
expect(result!.atIndex).to.equal(8)
|
||||
})
|
||||
|
||||
it("should return null when @ is in middle of word", () => {
|
||||
const result = findAtMentionToComplete("email@example.com")
|
||||
expect(result).to.be.null
|
||||
})
|
||||
|
||||
it("should return null when @ mention is complete (followed by space)", () => {
|
||||
const result = findAtMentionToComplete("@file.ts and more text")
|
||||
expect(result).to.be.null
|
||||
})
|
||||
|
||||
it("should find last incomplete @ mention when multiple present", () => {
|
||||
const result = findAtMentionToComplete("@file1.ts @src/")
|
||||
expect(result).to.not.be.null
|
||||
expect(result!.prefix).to.equal("@file1.ts ")
|
||||
expect(result!.partial).to.equal("src/")
|
||||
})
|
||||
|
||||
it("should handle empty partial after @", () => {
|
||||
const result = findAtMentionToComplete("check @")
|
||||
expect(result).to.not.be.null
|
||||
expect(result!.prefix).to.equal("check ")
|
||||
expect(result!.partial).to.equal("")
|
||||
})
|
||||
|
||||
it("should handle @ at start with empty partial", () => {
|
||||
const result = findAtMentionToComplete("@")
|
||||
expect(result).to.not.be.null
|
||||
expect(result!.prefix).to.equal("")
|
||||
expect(result!.partial).to.equal("")
|
||||
})
|
||||
})
|
||||
|
||||
describe("getPathCompletions", () => {
|
||||
let tempDir: string
|
||||
|
||||
beforeEach(() => {
|
||||
// Create temp directory with test files
|
||||
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "completer-test-"))
|
||||
fs.writeFileSync(path.join(tempDir, "file1.ts"), "")
|
||||
fs.writeFileSync(path.join(tempDir, "file2.ts"), "")
|
||||
fs.writeFileSync(path.join(tempDir, "readme.md"), "")
|
||||
fs.mkdirSync(path.join(tempDir, "src"))
|
||||
fs.writeFileSync(path.join(tempDir, "src", "index.ts"), "")
|
||||
fs.mkdirSync(path.join(tempDir, "tests"))
|
||||
fs.writeFileSync(path.join(tempDir, ".hidden"), "")
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
// Clean up temp directory
|
||||
fs.rmSync(tempDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it("should list directory contents when partial is empty", () => {
|
||||
const completions = getPathCompletions("", tempDir)
|
||||
expect(completions).to.include("src/")
|
||||
expect(completions).to.include("tests/")
|
||||
expect(completions).to.include("file1.ts")
|
||||
expect(completions).to.include("file2.ts")
|
||||
expect(completions).to.include("readme.md")
|
||||
})
|
||||
|
||||
it("should not include hidden files by default", () => {
|
||||
const completions = getPathCompletions("", tempDir)
|
||||
expect(completions).to.not.include(".hidden")
|
||||
})
|
||||
|
||||
it("should include hidden files when prefix starts with dot", () => {
|
||||
const completions = getPathCompletions(".", tempDir)
|
||||
expect(completions).to.include(".hidden")
|
||||
})
|
||||
|
||||
it("should filter by prefix", () => {
|
||||
const completions = getPathCompletions("file", tempDir)
|
||||
expect(completions).to.include("file1.ts")
|
||||
expect(completions).to.include("file2.ts")
|
||||
expect(completions).to.not.include("readme.md")
|
||||
})
|
||||
|
||||
it("should list subdirectory contents", () => {
|
||||
const completions = getPathCompletions("src/", tempDir)
|
||||
expect(completions).to.include("src/index.ts")
|
||||
})
|
||||
|
||||
it("should complete partial paths in subdirectory", () => {
|
||||
const completions = getPathCompletions("src/ind", tempDir)
|
||||
expect(completions).to.include("src/index.ts")
|
||||
})
|
||||
|
||||
it("should return empty array for non-existent path", () => {
|
||||
const completions = getPathCompletions("nonexistent/", tempDir)
|
||||
expect(completions).to.be.empty
|
||||
})
|
||||
|
||||
it("should sort directories before files", () => {
|
||||
const completions = getPathCompletions("", tempDir)
|
||||
const srcIndex = completions.indexOf("src/")
|
||||
const file1Index = completions.indexOf("file1.ts")
|
||||
expect(srcIndex).to.be.lessThan(file1Index)
|
||||
})
|
||||
|
||||
it("should be case-insensitive when filtering", () => {
|
||||
const completions = getPathCompletions("FILE", tempDir)
|
||||
expect(completions).to.include("file1.ts")
|
||||
expect(completions).to.include("file2.ts")
|
||||
})
|
||||
})
|
||||
|
||||
describe("createCompleter", () => {
|
||||
let tempDir: string
|
||||
|
||||
beforeEach(() => {
|
||||
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "completer-test-"))
|
||||
fs.writeFileSync(path.join(tempDir, "file.ts"), "")
|
||||
fs.mkdirSync(path.join(tempDir, "src"))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it("should return no completions when no @ present", () => {
|
||||
const completer = createCompleter({ cwd: tempDir })
|
||||
const [completions] = completer("hello world")
|
||||
expect(completions).to.be.empty
|
||||
})
|
||||
|
||||
it("should return completions with full line prefix", () => {
|
||||
const completer = createCompleter({ cwd: tempDir })
|
||||
const [completions] = completer("look at @fi")
|
||||
expect(completions).to.include("look at @file.ts")
|
||||
})
|
||||
|
||||
it("should handle empty @ mention", () => {
|
||||
const completer = createCompleter({ cwd: tempDir })
|
||||
const [completions] = completer("@")
|
||||
expect(completions.length).to.be.greaterThan(0)
|
||||
expect(completions.some((c) => c.startsWith("@"))).to.be.true
|
||||
})
|
||||
|
||||
it("should preserve prefix for multiple @ mentions", () => {
|
||||
const completer = createCompleter({ cwd: tempDir })
|
||||
const [completions] = completer("@file.ts @sr")
|
||||
expect(completions).to.include("@file.ts @src/")
|
||||
})
|
||||
})
|
||||
|
||||
describe("onEmptyTab callback", () => {
|
||||
let tempDir: string
|
||||
|
||||
beforeEach(() => {
|
||||
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "completer-test-"))
|
||||
fs.writeFileSync(path.join(tempDir, "file.ts"), "")
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it("should call onEmptyTab when line is empty", () => {
|
||||
const onEmptyTab = sinon.stub()
|
||||
const completer = createCompleter({ cwd: tempDir, onEmptyTab })
|
||||
const [completions] = completer("")
|
||||
expect(onEmptyTab.calledOnce).to.be.true
|
||||
expect(completions).to.be.empty
|
||||
})
|
||||
|
||||
it("should NOT call onEmptyTab when line has whitespace", () => {
|
||||
const onEmptyTab = sinon.stub()
|
||||
const completer = createCompleter({ cwd: tempDir, onEmptyTab })
|
||||
const [completions] = completer(" ")
|
||||
expect(onEmptyTab.called).to.be.false
|
||||
expect(completions).to.be.empty
|
||||
})
|
||||
|
||||
it("should NOT call onEmptyTab when line has content", () => {
|
||||
const onEmptyTab = sinon.stub()
|
||||
const completer = createCompleter({ cwd: tempDir, onEmptyTab })
|
||||
completer("hello")
|
||||
expect(onEmptyTab.called).to.be.false
|
||||
})
|
||||
|
||||
it("should NOT call onEmptyTab when line is just @", () => {
|
||||
const onEmptyTab = sinon.stub()
|
||||
const completer = createCompleter({ cwd: tempDir, onEmptyTab })
|
||||
const [completions] = completer("@")
|
||||
expect(onEmptyTab.called).to.be.false
|
||||
// Should still do file completion
|
||||
expect(completions.length).to.be.greaterThan(0)
|
||||
})
|
||||
|
||||
it("should work without onEmptyTab callback", () => {
|
||||
const completer = createCompleter({ cwd: tempDir })
|
||||
const [completions] = completer("")
|
||||
// Should just return empty completions, no error
|
||||
expect(completions).to.be.empty
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,204 @@
|
||||
/**
|
||||
* Tests for input-checker functions
|
||||
*/
|
||||
|
||||
import { expect } from "chai"
|
||||
import { checkForPendingInput } from "../../../../../src/commands/task/chat/input-checker.js"
|
||||
|
||||
describe("input-checker", () => {
|
||||
describe("checkForPendingInput", () => {
|
||||
it("should return no pending input for empty messages", () => {
|
||||
const result = checkForPendingInput([])
|
||||
expect(result.awaitingApproval).to.be.false
|
||||
expect(result.awaitingInput).to.be.false
|
||||
})
|
||||
|
||||
it("should return no pending input for partial messages", () => {
|
||||
const messages = [
|
||||
{
|
||||
ts: Date.now(),
|
||||
type: "ask" as const,
|
||||
ask: "command",
|
||||
partial: true,
|
||||
},
|
||||
]
|
||||
const result = checkForPendingInput(messages)
|
||||
expect(result.awaitingApproval).to.be.false
|
||||
expect(result.awaitingInput).to.be.false
|
||||
})
|
||||
|
||||
it("should return awaitingApproval for command ask", () => {
|
||||
const messages = [
|
||||
{
|
||||
ts: Date.now(),
|
||||
type: "ask" as const,
|
||||
ask: "command" as const,
|
||||
},
|
||||
]
|
||||
const result = checkForPendingInput(messages)
|
||||
expect(result.awaitingApproval).to.be.true
|
||||
expect(result.awaitingInput).to.be.false
|
||||
})
|
||||
|
||||
it("should return awaitingApproval for tool ask", () => {
|
||||
const messages = [
|
||||
{
|
||||
ts: Date.now(),
|
||||
type: "ask" as const,
|
||||
ask: "tool" as const,
|
||||
},
|
||||
]
|
||||
const result = checkForPendingInput(messages)
|
||||
expect(result.awaitingApproval).to.be.true
|
||||
expect(result.awaitingInput).to.be.false
|
||||
})
|
||||
|
||||
it("should return awaitingApproval for browser_action_launch ask", () => {
|
||||
const messages = [
|
||||
{
|
||||
ts: Date.now(),
|
||||
type: "ask" as const,
|
||||
ask: "browser_action_launch" as const,
|
||||
},
|
||||
]
|
||||
const result = checkForPendingInput(messages)
|
||||
expect(result.awaitingApproval).to.be.true
|
||||
expect(result.awaitingInput).to.be.false
|
||||
})
|
||||
|
||||
it("should return awaitingApproval for use_mcp_server ask", () => {
|
||||
const messages = [
|
||||
{
|
||||
ts: Date.now(),
|
||||
type: "ask" as const,
|
||||
ask: "use_mcp_server" as const,
|
||||
},
|
||||
]
|
||||
const result = checkForPendingInput(messages)
|
||||
expect(result.awaitingApproval).to.be.true
|
||||
expect(result.awaitingInput).to.be.false
|
||||
})
|
||||
|
||||
it("should return awaitingApproval for api_req_failed ask", () => {
|
||||
const messages = [
|
||||
{
|
||||
ts: Date.now(),
|
||||
type: "ask" as const,
|
||||
ask: "api_req_failed" as const,
|
||||
},
|
||||
]
|
||||
const result = checkForPendingInput(messages)
|
||||
expect(result.awaitingApproval).to.be.true
|
||||
expect(result.awaitingInput).to.be.false
|
||||
})
|
||||
|
||||
it("should return awaitingInput for followup ask", () => {
|
||||
const messages = [
|
||||
{
|
||||
ts: Date.now(),
|
||||
type: "ask" as const,
|
||||
ask: "followup" as const,
|
||||
},
|
||||
]
|
||||
const result = checkForPendingInput(messages)
|
||||
expect(result.awaitingApproval).to.be.false
|
||||
expect(result.awaitingInput).to.be.true
|
||||
})
|
||||
|
||||
it("should return awaitingInput for plan_mode_respond ask", () => {
|
||||
const messages = [
|
||||
{
|
||||
ts: Date.now(),
|
||||
type: "ask" as const,
|
||||
ask: "plan_mode_respond" as const,
|
||||
},
|
||||
]
|
||||
const result = checkForPendingInput(messages)
|
||||
expect(result.awaitingApproval).to.be.false
|
||||
expect(result.awaitingInput).to.be.true
|
||||
})
|
||||
|
||||
it("should return awaitingInput for act_mode_respond ask", () => {
|
||||
const messages = [
|
||||
{
|
||||
ts: Date.now(),
|
||||
type: "ask" as const,
|
||||
ask: "act_mode_respond" as const,
|
||||
},
|
||||
]
|
||||
const result = checkForPendingInput(messages)
|
||||
expect(result.awaitingApproval).to.be.false
|
||||
expect(result.awaitingInput).to.be.true
|
||||
})
|
||||
|
||||
it("should return awaitingInput for completion_result ask", () => {
|
||||
const messages = [
|
||||
{
|
||||
ts: Date.now(),
|
||||
type: "ask" as const,
|
||||
ask: "completion_result" as const,
|
||||
},
|
||||
]
|
||||
const result = checkForPendingInput(messages)
|
||||
expect(result.awaitingApproval).to.be.false
|
||||
expect(result.awaitingInput).to.be.true
|
||||
})
|
||||
|
||||
it("should return awaitingInput for resume_task ask", () => {
|
||||
const messages = [
|
||||
{
|
||||
ts: Date.now(),
|
||||
type: "ask" as const,
|
||||
ask: "resume_task" as const,
|
||||
},
|
||||
]
|
||||
const result = checkForPendingInput(messages)
|
||||
expect(result.awaitingApproval).to.be.false
|
||||
expect(result.awaitingInput).to.be.true
|
||||
})
|
||||
|
||||
it("should return awaitingInput for resume_completed_task ask", () => {
|
||||
const messages = [
|
||||
{
|
||||
ts: Date.now(),
|
||||
type: "ask" as const,
|
||||
ask: "resume_completed_task" as const,
|
||||
},
|
||||
]
|
||||
const result = checkForPendingInput(messages)
|
||||
expect(result.awaitingApproval).to.be.false
|
||||
expect(result.awaitingInput).to.be.true
|
||||
})
|
||||
|
||||
it("should return no pending input for say type messages", () => {
|
||||
const messages = [
|
||||
{
|
||||
ts: Date.now(),
|
||||
type: "say" as const,
|
||||
say: "text" as const,
|
||||
},
|
||||
]
|
||||
const result = checkForPendingInput(messages)
|
||||
expect(result.awaitingApproval).to.be.false
|
||||
expect(result.awaitingInput).to.be.false
|
||||
})
|
||||
|
||||
it("should only check the last message", () => {
|
||||
const messages = [
|
||||
{
|
||||
ts: Date.now() - 1000,
|
||||
type: "ask" as const,
|
||||
ask: "command" as const,
|
||||
},
|
||||
{
|
||||
ts: Date.now(),
|
||||
type: "say" as const,
|
||||
say: "text" as const,
|
||||
},
|
||||
]
|
||||
const result = checkForPendingInput(messages)
|
||||
expect(result.awaitingApproval).to.be.false
|
||||
expect(result.awaitingInput).to.be.false
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* Tests for model-utils functions
|
||||
*/
|
||||
|
||||
import { expect } from "chai"
|
||||
import { getModelIdForProvider, getModelIdKey } from "../../../../../src/commands/task/chat/model-utils.js"
|
||||
|
||||
describe("model-utils", () => {
|
||||
describe("getModelIdForProvider", () => {
|
||||
it("should return undefined for undefined configuration", () => {
|
||||
const result = getModelIdForProvider(undefined, "openrouter", "act")
|
||||
expect(result).to.be.undefined
|
||||
})
|
||||
|
||||
it("should return undefined for undefined provider", () => {
|
||||
const apiConfig = {
|
||||
actModeApiModelId: "test-model",
|
||||
}
|
||||
const result = getModelIdForProvider(apiConfig as any, undefined, "act")
|
||||
expect(result).to.be.undefined
|
||||
})
|
||||
|
||||
it("should return OpenRouter model ID for openrouter provider in act mode", () => {
|
||||
const apiConfig = {
|
||||
actModeOpenRouterModelId: "anthropic/claude-3",
|
||||
}
|
||||
const result = getModelIdForProvider(apiConfig as any, "openrouter", "act")
|
||||
expect(result).to.equal("anthropic/claude-3")
|
||||
})
|
||||
|
||||
it("should return OpenRouter model ID for openrouter provider in plan mode", () => {
|
||||
const apiConfig = {
|
||||
planModeOpenRouterModelId: "anthropic/claude-3-opus",
|
||||
}
|
||||
const result = getModelIdForProvider(apiConfig as any, "openrouter", "plan")
|
||||
expect(result).to.equal("anthropic/claude-3-opus")
|
||||
})
|
||||
|
||||
it("should return OpenRouter model ID for cline provider", () => {
|
||||
const apiConfig = {
|
||||
actModeOpenRouterModelId: "anthropic/claude-3",
|
||||
}
|
||||
const result = getModelIdForProvider(apiConfig as any, "cline", "act")
|
||||
expect(result).to.equal("anthropic/claude-3")
|
||||
})
|
||||
|
||||
it("should return API model ID for anthropic provider", () => {
|
||||
const apiConfig = {
|
||||
actModeApiModelId: "claude-3-sonnet",
|
||||
}
|
||||
const result = getModelIdForProvider(apiConfig as any, "anthropic", "act")
|
||||
expect(result).to.equal("claude-3-sonnet")
|
||||
})
|
||||
|
||||
it("should return OpenAI model ID for openai provider", () => {
|
||||
const apiConfig = {
|
||||
actModeOpenAiModelId: "gpt-4",
|
||||
}
|
||||
const result = getModelIdForProvider(apiConfig as any, "openai", "act")
|
||||
expect(result).to.equal("gpt-4")
|
||||
})
|
||||
|
||||
it("should return Ollama model ID for ollama provider", () => {
|
||||
const apiConfig = {
|
||||
actModeOllamaModelId: "llama2",
|
||||
}
|
||||
const result = getModelIdForProvider(apiConfig as any, "ollama", "act")
|
||||
expect(result).to.equal("llama2")
|
||||
})
|
||||
|
||||
it("should return LiteLLM model ID for litellm provider", () => {
|
||||
const apiConfig = {
|
||||
planModeLiteLlmModelId: "gpt-4-turbo",
|
||||
}
|
||||
const result = getModelIdForProvider(apiConfig as any, "litellm", "plan")
|
||||
expect(result).to.equal("gpt-4-turbo")
|
||||
})
|
||||
|
||||
it("should return undefined for vscode-lm provider", () => {
|
||||
const apiConfig = {
|
||||
actModeApiModelId: "test-model",
|
||||
}
|
||||
const result = getModelIdForProvider(apiConfig as any, "vscode-lm", "act")
|
||||
expect(result).to.be.undefined
|
||||
})
|
||||
|
||||
it("should return undefined for dify provider", () => {
|
||||
const apiConfig = {
|
||||
actModeApiModelId: "test-model",
|
||||
}
|
||||
const result = getModelIdForProvider(apiConfig as any, "dify", "act")
|
||||
expect(result).to.be.undefined
|
||||
})
|
||||
})
|
||||
|
||||
describe("getModelIdKey", () => {
|
||||
it("should return OpenRouter key for openrouter provider in act mode", () => {
|
||||
const result = getModelIdKey("openrouter", "act")
|
||||
expect(result).to.equal("actModeOpenRouterModelId")
|
||||
})
|
||||
|
||||
it("should return OpenRouter key for openrouter provider in plan mode", () => {
|
||||
const result = getModelIdKey("openrouter", "plan")
|
||||
expect(result).to.equal("planModeOpenRouterModelId")
|
||||
})
|
||||
|
||||
it("should return OpenRouter key for cline provider", () => {
|
||||
const result = getModelIdKey("cline", "act")
|
||||
expect(result).to.equal("actModeOpenRouterModelId")
|
||||
})
|
||||
|
||||
it("should return OpenAI key for openai provider", () => {
|
||||
const result = getModelIdKey("openai", "act")
|
||||
expect(result).to.equal("actModeOpenAiModelId")
|
||||
})
|
||||
|
||||
it("should return Ollama key for ollama provider", () => {
|
||||
const result = getModelIdKey("ollama", "plan")
|
||||
expect(result).to.equal("planModeOllamaModelId")
|
||||
})
|
||||
|
||||
it("should return LmStudio key for lmstudio provider", () => {
|
||||
const result = getModelIdKey("lmstudio", "act")
|
||||
expect(result).to.equal("actModeLmStudioModelId")
|
||||
})
|
||||
|
||||
it("should return LiteLLM key for litellm provider", () => {
|
||||
const result = getModelIdKey("litellm", "act")
|
||||
expect(result).to.equal("actModeLiteLlmModelId")
|
||||
})
|
||||
|
||||
it("should return Groq key for groq provider", () => {
|
||||
const result = getModelIdKey("groq", "plan")
|
||||
expect(result).to.equal("planModeGroqModelId")
|
||||
})
|
||||
|
||||
it("should return default ApiModelId key for unknown provider", () => {
|
||||
const result = getModelIdKey("unknown-provider", "act")
|
||||
expect(result).to.equal("actModeApiModelId")
|
||||
})
|
||||
|
||||
it("should return default ApiModelId key for undefined provider", () => {
|
||||
const result = getModelIdKey(undefined, "act")
|
||||
expect(result).to.equal("actModeApiModelId")
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* Tests for prompt builder functions
|
||||
*/
|
||||
|
||||
import { expect } from "chai"
|
||||
import { buildPromptString } from "../../../../../src/commands/task/chat/prompt.js"
|
||||
|
||||
describe("prompt", () => {
|
||||
describe("buildPromptString", () => {
|
||||
it("should include mode indicator for act mode", () => {
|
||||
const result = buildPromptString("act", "openrouter", "anthropic/claude-3")
|
||||
expect(result).to.include("[act]")
|
||||
})
|
||||
|
||||
it("should include mode indicator for plan mode", () => {
|
||||
const result = buildPromptString("plan", "openrouter", "anthropic/claude-3")
|
||||
expect(result).to.include("[plan]")
|
||||
})
|
||||
|
||||
it("should include provider in prompt", () => {
|
||||
const result = buildPromptString("act", "openrouter", "anthropic/claude-3")
|
||||
expect(result).to.include("openrouter")
|
||||
})
|
||||
|
||||
it("should include model ID in prompt", () => {
|
||||
const result = buildPromptString("act", "openrouter", "anthropic/claude-3")
|
||||
expect(result).to.include("anthropic/claude-3")
|
||||
})
|
||||
|
||||
it("should show unknown for undefined provider", () => {
|
||||
const result = buildPromptString("act", undefined, "model-id")
|
||||
expect(result).to.include("unknown")
|
||||
})
|
||||
|
||||
it("should show unknown for undefined model ID", () => {
|
||||
const result = buildPromptString("act", "openrouter", undefined)
|
||||
expect(result).to.include("unknown")
|
||||
})
|
||||
|
||||
it("should truncate very long model IDs", () => {
|
||||
const longModelId = "organization/very-long-model-name-that-exceeds-the-forty-character-limit-for-display"
|
||||
const result = buildPromptString("act", "openrouter", longModelId)
|
||||
// Should be truncated
|
||||
expect(result.length).to.be.lessThan(longModelId.length + 50) // Account for other parts
|
||||
expect(result).to.include("...")
|
||||
})
|
||||
|
||||
it("should preserve last part of model ID after slash when truncating", () => {
|
||||
const longModelId = "organization/subpath/very-specific-model-version-name"
|
||||
const result = buildPromptString("act", "openrouter", longModelId)
|
||||
// Should keep the part after the last slash
|
||||
expect(result).to.include("/very-specific-model-version-name")
|
||||
})
|
||||
|
||||
it("should end with > prompt character", () => {
|
||||
const result = buildPromptString("act", "openrouter", "model")
|
||||
expect(result).to.include(">")
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* Tests for session management
|
||||
*/
|
||||
|
||||
import { expect } from "chai"
|
||||
import { createSession } from "../../../../../src/commands/task/chat/session.js"
|
||||
|
||||
describe("session", () => {
|
||||
describe("createSession", () => {
|
||||
it("should create session with null taskId", () => {
|
||||
const session = createSession()
|
||||
expect(session.taskId).to.be.null
|
||||
})
|
||||
|
||||
it("should create session with isRunning true", () => {
|
||||
const session = createSession()
|
||||
expect(session.isRunning).to.be.true
|
||||
})
|
||||
|
||||
it("should create session with awaitingApproval false", () => {
|
||||
const session = createSession()
|
||||
expect(session.awaitingApproval).to.be.false
|
||||
})
|
||||
|
||||
it("should create session with awaitingInput false", () => {
|
||||
const session = createSession()
|
||||
expect(session.awaitingInput).to.be.false
|
||||
})
|
||||
|
||||
it("should create session with null adapter", () => {
|
||||
const session = createSession()
|
||||
expect(session.adapter).to.be.null
|
||||
})
|
||||
|
||||
it("should create independent session instances", () => {
|
||||
const session1 = createSession()
|
||||
const session2 = createSession()
|
||||
|
||||
session1.taskId = "task-1"
|
||||
session1.isRunning = false
|
||||
|
||||
expect(session2.taskId).to.be.null
|
||||
expect(session2.isRunning).to.be.true
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* Tests for task list command
|
||||
*/
|
||||
|
||||
import { expect } from "chai"
|
||||
import fs from "fs"
|
||||
import os from "os"
|
||||
import path from "path"
|
||||
import sinon from "sinon"
|
||||
import { createTaskListCommand } from "../../../../src/commands/task/list.js"
|
||||
import type { OutputFormatter } from "../../../../src/core/output/types.js"
|
||||
import type { CliConfig } from "../../../../src/types/config.js"
|
||||
import type { Logger } from "../../../../src/types/logger.js"
|
||||
|
||||
describe("task list command", () => {
|
||||
let tempDir: string
|
||||
let config: CliConfig
|
||||
let logger: Logger
|
||||
let formatter: OutputFormatter
|
||||
let exitStub: sinon.SinonStub
|
||||
|
||||
beforeEach(() => {
|
||||
// Create temp directory
|
||||
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "cline-test-"))
|
||||
|
||||
// Create mock config
|
||||
config = {
|
||||
verbose: false,
|
||||
configDir: tempDir,
|
||||
outputFormat: "plain",
|
||||
}
|
||||
|
||||
// Create mock logger
|
||||
logger = {
|
||||
debug: sinon.stub(),
|
||||
info: sinon.stub(),
|
||||
warn: sinon.stub(),
|
||||
error: sinon.stub(),
|
||||
}
|
||||
|
||||
// Create mock formatter
|
||||
formatter = {
|
||||
message: sinon.stub(),
|
||||
success: sinon.stub(),
|
||||
error: sinon.stub(),
|
||||
info: sinon.stub(),
|
||||
warn: sinon.stub(),
|
||||
table: sinon.stub(),
|
||||
list: sinon.stub(),
|
||||
tasks: sinon.stub(),
|
||||
keyValue: sinon.stub(),
|
||||
raw: sinon.stub(),
|
||||
}
|
||||
|
||||
// Stub process.exit
|
||||
exitStub = sinon.stub(process, "exit")
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
// Restore stubs
|
||||
sinon.restore()
|
||||
// Clean up temp directory
|
||||
fs.rmSync(tempDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it("should create command with correct name and aliases", () => {
|
||||
const cmd = createTaskListCommand(config, logger, formatter)
|
||||
|
||||
expect(cmd.name()).to.equal("list")
|
||||
expect(cmd.aliases()).to.include("l")
|
||||
expect(cmd.aliases()).to.include("ls")
|
||||
})
|
||||
|
||||
it("should show message when no tasks exist", async () => {
|
||||
const cmd = createTaskListCommand(config, logger, formatter)
|
||||
|
||||
await cmd.parseAsync(["node", "test"])
|
||||
|
||||
expect((formatter.info as sinon.SinonStub).calledWith("No tasks found")).to.be.true
|
||||
})
|
||||
|
||||
it("should error on invalid status filter", async () => {
|
||||
const cmd = createTaskListCommand(config, logger, formatter)
|
||||
|
||||
await cmd.parseAsync(["node", "test", "--status", "invalid"])
|
||||
|
||||
expect((formatter.error as sinon.SinonStub).calledOnce).to.be.true
|
||||
expect((formatter.error as sinon.SinonStub).firstCall.args[0]).to.include("Invalid status")
|
||||
expect(exitStub.calledWith(1)).to.be.true
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,526 @@
|
||||
/**
|
||||
* Tests for task restore command
|
||||
*/
|
||||
|
||||
import { expect } from "chai"
|
||||
import fs from "fs"
|
||||
import os from "os"
|
||||
import path from "path"
|
||||
import sinon from "sinon"
|
||||
import {
|
||||
createTaskRestoreCommand,
|
||||
findCheckpoints,
|
||||
formatCheckpointList,
|
||||
validateCheckpoint,
|
||||
} from "../../../../src/commands/task/restore.js"
|
||||
// Mock the embedded controller module
|
||||
import * as embeddedController from "../../../../src/core/embedded-controller.js"
|
||||
import type { OutputFormatter } from "../../../../src/core/output/types.js"
|
||||
import type { CliConfig } from "../../../../src/types/config.js"
|
||||
import type { Logger } from "../../../../src/types/logger.js"
|
||||
|
||||
describe("task restore command", () => {
|
||||
let tempDir: string
|
||||
let config: CliConfig
|
||||
let logger: Logger
|
||||
let formatter: OutputFormatter
|
||||
let exitStub: sinon.SinonStub
|
||||
let getControllerStub: sinon.SinonStub
|
||||
let disposeControllerStub: sinon.SinonStub
|
||||
|
||||
// Mock checkpoint manager
|
||||
const mockCheckpointManager = {
|
||||
restoreCheckpoint: sinon.stub().resolves({}),
|
||||
}
|
||||
|
||||
// Mock task
|
||||
const mockTask = {
|
||||
taskId: "test-task-123",
|
||||
handleWebviewAskResponse: sinon.stub(),
|
||||
messageStateHandler: {
|
||||
getClineMessages: sinon.stub().returns([]),
|
||||
},
|
||||
checkpointManager: mockCheckpointManager,
|
||||
}
|
||||
|
||||
// Mock controller
|
||||
const mockController = {
|
||||
task: null as any,
|
||||
initTask: sinon.stub(),
|
||||
cancelTask: sinon.stub(),
|
||||
togglePlanActMode: sinon.stub(),
|
||||
getTaskWithId: sinon.stub(),
|
||||
getStateToPostToWebview: sinon.stub(),
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
// Create temp directory
|
||||
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "cline-test-"))
|
||||
|
||||
// Create mock config
|
||||
config = {
|
||||
verbose: false,
|
||||
configDir: tempDir,
|
||||
outputFormat: "plain",
|
||||
}
|
||||
|
||||
// Create mock logger
|
||||
logger = {
|
||||
debug: sinon.stub(),
|
||||
info: sinon.stub(),
|
||||
warn: sinon.stub(),
|
||||
error: sinon.stub(),
|
||||
}
|
||||
|
||||
// Create mock formatter
|
||||
formatter = {
|
||||
message: sinon.stub(),
|
||||
success: sinon.stub(),
|
||||
error: sinon.stub(),
|
||||
info: sinon.stub(),
|
||||
warn: sinon.stub(),
|
||||
table: sinon.stub(),
|
||||
list: sinon.stub(),
|
||||
tasks: sinon.stub(),
|
||||
keyValue: sinon.stub(),
|
||||
raw: sinon.stub(),
|
||||
}
|
||||
|
||||
// Stub process.exit
|
||||
exitStub = sinon.stub(process, "exit")
|
||||
|
||||
// Reset mock task
|
||||
mockTask.handleWebviewAskResponse.reset()
|
||||
mockTask.messageStateHandler.getClineMessages.returns([])
|
||||
mockCheckpointManager.restoreCheckpoint.reset()
|
||||
mockCheckpointManager.restoreCheckpoint.resolves({})
|
||||
|
||||
// Reset mock controller
|
||||
mockController.task = null
|
||||
mockController.initTask.reset()
|
||||
mockController.cancelTask.reset()
|
||||
mockController.togglePlanActMode.reset()
|
||||
mockController.getTaskWithId.reset()
|
||||
mockController.getStateToPostToWebview.reset()
|
||||
|
||||
// Setup default stubs
|
||||
mockController.getStateToPostToWebview.resolves({
|
||||
mode: "act",
|
||||
clineMessages: [],
|
||||
taskHistory: [],
|
||||
})
|
||||
mockController.initTask.resolves("test-task-123")
|
||||
mockController.cancelTask.resolves()
|
||||
|
||||
// Stub the embedded controller functions
|
||||
getControllerStub = sinon.stub(embeddedController, "getEmbeddedController").resolves(mockController as any)
|
||||
disposeControllerStub = sinon.stub(embeddedController, "disposeEmbeddedController").resolves()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
// Restore stubs
|
||||
sinon.restore()
|
||||
// Clean up temp directory
|
||||
fs.rmSync(tempDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
describe("command setup", () => {
|
||||
it("should create command with correct name and alias", () => {
|
||||
const cmd = createTaskRestoreCommand(config, logger, formatter)
|
||||
|
||||
expect(cmd.name()).to.equal("restore")
|
||||
expect(cmd.aliases()).to.include("r")
|
||||
})
|
||||
|
||||
it("should have -t/--type option", () => {
|
||||
const cmd = createTaskRestoreCommand(config, logger, formatter)
|
||||
const typeOption = cmd.options.find((opt) => opt.short === "-t" || opt.long === "--type")
|
||||
|
||||
expect(typeOption).to.exist
|
||||
})
|
||||
|
||||
it("should have -l/--list option", () => {
|
||||
const cmd = createTaskRestoreCommand(config, logger, formatter)
|
||||
const listOption = cmd.options.find((opt) => opt.short === "-l" || opt.long === "--list")
|
||||
|
||||
expect(listOption).to.exist
|
||||
})
|
||||
})
|
||||
|
||||
describe("checkpoint validation", () => {
|
||||
it("should error on invalid checkpoint ID format", async () => {
|
||||
mockController.task = mockTask
|
||||
const taskHistory = [{ id: "task-1", task: "Test task" }]
|
||||
mockController.getStateToPostToWebview.resolves({
|
||||
mode: "act",
|
||||
clineMessages: [],
|
||||
taskHistory,
|
||||
})
|
||||
|
||||
const cmd = createTaskRestoreCommand(config, logger, formatter)
|
||||
await cmd.parseAsync(["node", "test", "not-a-number"])
|
||||
|
||||
expect((formatter.error as sinon.SinonStub).calledOnce).to.be.true
|
||||
expect((formatter.error as sinon.SinonStub).firstCall.args[0]).to.include("Invalid checkpoint ID")
|
||||
expect(exitStub.calledWith(1)).to.be.true
|
||||
})
|
||||
|
||||
it("should error when checkpoint not found", async () => {
|
||||
const messages = [{ ts: 1000, type: "say" as const, say: "text" as const, text: "Hello" }]
|
||||
mockTask.messageStateHandler.getClineMessages.returns(messages)
|
||||
mockController.task = mockTask
|
||||
|
||||
const taskHistory = [{ id: "task-1", task: "Test task" }]
|
||||
mockController.getStateToPostToWebview.resolves({
|
||||
mode: "act",
|
||||
clineMessages: messages,
|
||||
taskHistory,
|
||||
})
|
||||
|
||||
const cmd = createTaskRestoreCommand(config, logger, formatter)
|
||||
await cmd.parseAsync(["node", "test", "9999"])
|
||||
|
||||
expect((formatter.error as sinon.SinonStub).calledOnce).to.be.true
|
||||
expect((formatter.error as sinon.SinonStub).firstCall.args[0]).to.include("not found")
|
||||
expect(exitStub.calledWith(1)).to.be.true
|
||||
})
|
||||
|
||||
it("should error when timestamp exists but is not a checkpoint", async () => {
|
||||
const messages = [{ ts: 1000, type: "say" as const, say: "text" as const, text: "Hello" }]
|
||||
mockTask.messageStateHandler.getClineMessages.returns(messages)
|
||||
mockController.task = mockTask
|
||||
|
||||
const taskHistory = [{ id: "task-1", task: "Test task" }]
|
||||
mockController.getStateToPostToWebview.resolves({
|
||||
mode: "act",
|
||||
clineMessages: messages,
|
||||
taskHistory,
|
||||
})
|
||||
|
||||
const cmd = createTaskRestoreCommand(config, logger, formatter)
|
||||
await cmd.parseAsync(["node", "test", "1000"])
|
||||
|
||||
expect((formatter.error as sinon.SinonStub).calledOnce).to.be.true
|
||||
expect((formatter.error as sinon.SinonStub).firstCall.args[0]).to.include("not a checkpoint")
|
||||
expect(exitStub.calledWith(1)).to.be.true
|
||||
})
|
||||
|
||||
it("should error on invalid restore type", async () => {
|
||||
const messages = [{ ts: 1000, type: "say" as const, say: "checkpoint_created" as const, text: "" }]
|
||||
mockTask.messageStateHandler.getClineMessages.returns(messages)
|
||||
mockController.task = mockTask
|
||||
|
||||
const taskHistory = [{ id: "task-1", task: "Test task" }]
|
||||
mockController.getStateToPostToWebview.resolves({
|
||||
mode: "act",
|
||||
clineMessages: messages,
|
||||
taskHistory,
|
||||
})
|
||||
|
||||
const cmd = createTaskRestoreCommand(config, logger, formatter)
|
||||
await cmd.parseAsync(["node", "test", "1000", "-t", "invalid"])
|
||||
|
||||
expect((formatter.error as sinon.SinonStub).calledOnce).to.be.true
|
||||
expect((formatter.error as sinon.SinonStub).firstCall.args[0]).to.include("Invalid restore type")
|
||||
expect(exitStub.calledWith(1)).to.be.true
|
||||
})
|
||||
})
|
||||
|
||||
describe("successful restore", () => {
|
||||
it("should restore to checkpoint with default type (task)", async () => {
|
||||
const messages = [
|
||||
{ ts: 1000, type: "say" as const, say: "checkpoint_created" as const, text: "" },
|
||||
{ ts: 1001, type: "say" as const, say: "text" as const, text: "After checkpoint" },
|
||||
]
|
||||
mockTask.messageStateHandler.getClineMessages.returns(messages)
|
||||
mockController.task = mockTask
|
||||
|
||||
const taskHistory = [{ id: "task-1", task: "Test task" }]
|
||||
mockController.getStateToPostToWebview.resolves({
|
||||
mode: "act",
|
||||
clineMessages: messages,
|
||||
taskHistory,
|
||||
})
|
||||
|
||||
const cmd = createTaskRestoreCommand(config, logger, formatter)
|
||||
await cmd.parseAsync(["node", "test", "1000"])
|
||||
|
||||
expect(mockController.cancelTask.calledOnce).to.be.true
|
||||
expect(mockCheckpointManager.restoreCheckpoint.calledOnce).to.be.true
|
||||
expect(mockCheckpointManager.restoreCheckpoint.firstCall.args[0]).to.equal(1000)
|
||||
expect(mockCheckpointManager.restoreCheckpoint.firstCall.args[1]).to.equal("task")
|
||||
expect((formatter.success as sinon.SinonStub).calledWith("Checkpoint restored successfully")).to.be.true
|
||||
})
|
||||
|
||||
it("should restore with taskAndWorkspace type when checkpoint has hash", async () => {
|
||||
const messages = [
|
||||
{ ts: 1000, type: "say" as const, say: "checkpoint_created" as const, text: "", lastCheckpointHash: "abc123" },
|
||||
]
|
||||
mockTask.messageStateHandler.getClineMessages.returns(messages)
|
||||
mockController.task = mockTask
|
||||
|
||||
const taskHistory = [{ id: "task-1", task: "Test task" }]
|
||||
mockController.getStateToPostToWebview.resolves({
|
||||
mode: "act",
|
||||
clineMessages: messages,
|
||||
taskHistory,
|
||||
})
|
||||
|
||||
const cmd = createTaskRestoreCommand(config, logger, formatter)
|
||||
await cmd.parseAsync(["node", "test", "1000", "-t", "taskAndWorkspace"])
|
||||
|
||||
expect(mockCheckpointManager.restoreCheckpoint.calledOnce).to.be.true
|
||||
expect(mockCheckpointManager.restoreCheckpoint.firstCall.args[1]).to.equal("taskAndWorkspace")
|
||||
})
|
||||
|
||||
it("should warn when workspace restore requested but no hash available", async () => {
|
||||
const messages = [{ ts: 1000, type: "say" as const, say: "checkpoint_created" as const, text: "" }]
|
||||
mockTask.messageStateHandler.getClineMessages.returns(messages)
|
||||
mockController.task = mockTask
|
||||
|
||||
const taskHistory = [{ id: "task-1", task: "Test task" }]
|
||||
mockController.getStateToPostToWebview.resolves({
|
||||
mode: "act",
|
||||
clineMessages: messages,
|
||||
taskHistory,
|
||||
})
|
||||
|
||||
const cmd = createTaskRestoreCommand(config, logger, formatter)
|
||||
await cmd.parseAsync(["node", "test", "1000", "-t", "taskAndWorkspace"])
|
||||
|
||||
expect((formatter.warn as sinon.SinonStub).called).to.be.true
|
||||
expect((formatter.warn as sinon.SinonStub).firstCall.args[0]).to.include("does not have workspace restore data")
|
||||
})
|
||||
|
||||
it("should error when workspace-only restore requested but no hash available", async () => {
|
||||
const messages = [{ ts: 1000, type: "say" as const, say: "checkpoint_created" as const, text: "" }]
|
||||
mockTask.messageStateHandler.getClineMessages.returns(messages)
|
||||
mockController.task = mockTask
|
||||
|
||||
const taskHistory = [{ id: "task-1", task: "Test task" }]
|
||||
mockController.getStateToPostToWebview.resolves({
|
||||
mode: "act",
|
||||
clineMessages: messages,
|
||||
taskHistory,
|
||||
})
|
||||
|
||||
const cmd = createTaskRestoreCommand(config, logger, formatter)
|
||||
await cmd.parseAsync(["node", "test", "1000", "-t", "workspace"])
|
||||
|
||||
expect((formatter.error as sinon.SinonStub).calledOnce).to.be.true
|
||||
expect((formatter.error as sinon.SinonStub).firstCall.args[0]).to.include("Cannot restore workspace")
|
||||
expect(exitStub.calledWith(1)).to.be.true
|
||||
})
|
||||
})
|
||||
|
||||
describe("list checkpoints (--list option)", () => {
|
||||
it("should list checkpoints when --list is provided", async () => {
|
||||
const messages = [
|
||||
{ ts: 1000, type: "say" as const, say: "text" as const, text: "User message" },
|
||||
{ ts: 1001, type: "say" as const, say: "checkpoint_created" as const, text: "", lastCheckpointHash: "abc" },
|
||||
{ ts: 1002, type: "say" as const, say: "text" as const, text: "Another message" },
|
||||
{ ts: 1003, type: "say" as const, say: "checkpoint_created" as const, text: "" },
|
||||
]
|
||||
mockTask.messageStateHandler.getClineMessages.returns(messages)
|
||||
mockController.task = mockTask
|
||||
|
||||
const taskHistory = [{ id: "task-1", task: "Test task" }]
|
||||
mockController.getStateToPostToWebview.resolves({
|
||||
mode: "act",
|
||||
clineMessages: messages,
|
||||
taskHistory,
|
||||
})
|
||||
|
||||
const cmd = createTaskRestoreCommand(config, logger, formatter)
|
||||
// Use a dummy checkpoint ID since --list overrides
|
||||
await cmd.parseAsync(["node", "test", "dummy", "--list"])
|
||||
|
||||
expect((formatter.info as sinon.SinonStub).calledWith(sinon.match(/Checkpoints \(2\)/))).to.be.true
|
||||
})
|
||||
|
||||
it("should show no checkpoints message when none exist", async () => {
|
||||
const messages = [{ ts: 1000, type: "say" as const, say: "text" as const, text: "Just a message" }]
|
||||
mockTask.messageStateHandler.getClineMessages.returns(messages)
|
||||
mockController.task = mockTask
|
||||
|
||||
const taskHistory = [{ id: "task-1", task: "Test task" }]
|
||||
mockController.getStateToPostToWebview.resolves({
|
||||
mode: "act",
|
||||
clineMessages: messages,
|
||||
taskHistory,
|
||||
})
|
||||
|
||||
const cmd = createTaskRestoreCommand(config, logger, formatter)
|
||||
await cmd.parseAsync(["node", "test", "dummy", "--list"])
|
||||
|
||||
expect((formatter.info as sinon.SinonStub).calledWith("No checkpoints found in current task")).to.be.true
|
||||
})
|
||||
})
|
||||
|
||||
describe("helper functions", () => {
|
||||
describe("findCheckpoints", () => {
|
||||
it("should find all checkpoint messages", () => {
|
||||
const messages = [
|
||||
{ ts: 1000, type: "say" as const, say: "text" as const, text: "Hello" },
|
||||
{ ts: 1001, type: "say" as const, say: "checkpoint_created" as const, text: "" },
|
||||
{ ts: 1002, type: "say" as const, say: "text" as const, text: "World" },
|
||||
{ ts: 1003, type: "say" as const, say: "checkpoint_created" as const, text: "" },
|
||||
] as any[]
|
||||
|
||||
const checkpoints = findCheckpoints(messages)
|
||||
|
||||
expect(checkpoints).to.have.length(2)
|
||||
expect(checkpoints[0].ts).to.equal(1001)
|
||||
expect(checkpoints[1].ts).to.equal(1003)
|
||||
})
|
||||
|
||||
it("should return empty array when no checkpoints", () => {
|
||||
const messages = [{ ts: 1000, type: "say" as const, say: "text" as const, text: "Hello" }] as any[]
|
||||
|
||||
const checkpoints = findCheckpoints(messages)
|
||||
|
||||
expect(checkpoints).to.have.length(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe("validateCheckpoint", () => {
|
||||
it("should return checkpoint when valid", () => {
|
||||
const messages = [{ ts: 1000, type: "say" as const, say: "checkpoint_created" as const, text: "" }] as any[]
|
||||
|
||||
const result = validateCheckpoint(messages, 1000)
|
||||
|
||||
expect(result).to.not.be.null
|
||||
expect(result?.ts).to.equal(1000)
|
||||
})
|
||||
|
||||
it("should return null when timestamp not found", () => {
|
||||
const messages = [{ ts: 1000, type: "say" as const, say: "checkpoint_created" as const, text: "" }] as any[]
|
||||
|
||||
const result = validateCheckpoint(messages, 9999)
|
||||
|
||||
expect(result).to.be.null
|
||||
})
|
||||
|
||||
it("should return null when timestamp exists but not a checkpoint", () => {
|
||||
const messages = [{ ts: 1000, type: "say" as const, say: "text" as const, text: "Hello" }] as any[]
|
||||
|
||||
const result = validateCheckpoint(messages, 1000)
|
||||
|
||||
expect(result).to.be.null
|
||||
})
|
||||
})
|
||||
|
||||
describe("formatCheckpointList", () => {
|
||||
it("should format checkpoints with context", () => {
|
||||
const now = Date.now()
|
||||
const messages = [
|
||||
{ ts: now - 60000, type: "say" as const, say: "text" as const, text: "User asked something" },
|
||||
{
|
||||
ts: now - 59000,
|
||||
type: "say" as const,
|
||||
say: "checkpoint_created" as const,
|
||||
text: "",
|
||||
lastCheckpointHash: "abc",
|
||||
},
|
||||
] as any[]
|
||||
|
||||
const formatted = formatCheckpointList(messages)
|
||||
|
||||
expect(formatted).to.have.length(1)
|
||||
expect(formatted[0].id).to.equal(now - 59000)
|
||||
expect(formatted[0].hasWorkspaceRestore).to.be.true
|
||||
expect(formatted[0].context).to.include("User asked")
|
||||
})
|
||||
|
||||
it("should indicate when workspace restore is not available", () => {
|
||||
const now = Date.now()
|
||||
const messages = [
|
||||
{ ts: now - 60000, type: "say" as const, say: "text" as const, text: "User message" },
|
||||
{ ts: now - 59000, type: "say" as const, say: "checkpoint_created" as const, text: "" },
|
||||
] as any[]
|
||||
|
||||
const formatted = formatCheckpointList(messages)
|
||||
|
||||
expect(formatted[0].hasWorkspaceRestore).to.be.false
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("no active task", () => {
|
||||
it("should initialize most recent task when no task is active", async () => {
|
||||
mockController.task = null
|
||||
const taskHistory = [{ id: "task-1", task: "Test task" }]
|
||||
mockController.getStateToPostToWebview.resolves({
|
||||
mode: "act",
|
||||
clineMessages: [],
|
||||
taskHistory,
|
||||
})
|
||||
mockController.getTaskWithId.resolves({
|
||||
historyItem: taskHistory[0],
|
||||
})
|
||||
mockController.initTask.callsFake(async () => {
|
||||
mockController.task = mockTask
|
||||
return "task-1"
|
||||
})
|
||||
|
||||
const messages = [{ ts: 1000, type: "say" as const, say: "checkpoint_created" as const, text: "" }]
|
||||
mockTask.messageStateHandler.getClineMessages.returns(messages)
|
||||
|
||||
const cmd = createTaskRestoreCommand(config, logger, formatter)
|
||||
await cmd.parseAsync(["node", "test", "1000"])
|
||||
|
||||
expect(mockController.initTask.calledOnce).to.be.true
|
||||
})
|
||||
|
||||
it("should error when no tasks exist", async () => {
|
||||
mockController.task = null
|
||||
mockController.getStateToPostToWebview.resolves({
|
||||
mode: "act",
|
||||
clineMessages: [],
|
||||
taskHistory: [],
|
||||
})
|
||||
|
||||
const cmd = createTaskRestoreCommand(config, logger, formatter)
|
||||
await cmd.parseAsync(["node", "test", "1000"])
|
||||
|
||||
expect((formatter.error as sinon.SinonStub).calledOnce).to.be.true
|
||||
expect((formatter.error as sinon.SinonStub).firstCall.args[0]).to.include("No tasks found")
|
||||
expect(exitStub.calledWith(1)).to.be.true
|
||||
})
|
||||
})
|
||||
|
||||
describe("cleanup", () => {
|
||||
it("should dispose controller after success", async () => {
|
||||
const messages = [{ ts: 1000, type: "say" as const, say: "checkpoint_created" as const, text: "" }]
|
||||
mockTask.messageStateHandler.getClineMessages.returns(messages)
|
||||
mockController.task = mockTask
|
||||
|
||||
const taskHistory = [{ id: "task-1", task: "Test task" }]
|
||||
mockController.getStateToPostToWebview.resolves({
|
||||
mode: "act",
|
||||
clineMessages: messages,
|
||||
taskHistory,
|
||||
})
|
||||
|
||||
const cmd = createTaskRestoreCommand(config, logger, formatter)
|
||||
await cmd.parseAsync(["node", "test", "1000"])
|
||||
|
||||
expect(disposeControllerStub.calledOnce).to.be.true
|
||||
})
|
||||
|
||||
it("should dispose controller after error", async () => {
|
||||
mockController.task = mockTask
|
||||
const taskHistory = [{ id: "task-1", task: "Test task" }]
|
||||
mockController.getStateToPostToWebview.resolves({
|
||||
mode: "act",
|
||||
clineMessages: [],
|
||||
taskHistory,
|
||||
})
|
||||
|
||||
const cmd = createTaskRestoreCommand(config, logger, formatter)
|
||||
await cmd.parseAsync(["node", "test", "invalid-id"])
|
||||
|
||||
expect(disposeControllerStub.calledOnce).to.be.true
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,308 @@
|
||||
/**
|
||||
* Tests for task send command with embedded Controller
|
||||
*/
|
||||
|
||||
import { expect } from "chai"
|
||||
import fs from "fs"
|
||||
import os from "os"
|
||||
import path from "path"
|
||||
import sinon from "sinon"
|
||||
import { createTaskSendCommand } from "../../../../src/commands/task/send.js"
|
||||
// Mock the embedded controller module
|
||||
import * as embeddedController from "../../../../src/core/embedded-controller.js"
|
||||
import type { OutputFormatter } from "../../../../src/core/output/types.js"
|
||||
import type { CliConfig } from "../../../../src/types/config.js"
|
||||
import type { Logger } from "../../../../src/types/logger.js"
|
||||
|
||||
describe("task send command", () => {
|
||||
let tempDir: string
|
||||
let config: CliConfig
|
||||
let logger: Logger
|
||||
let formatter: OutputFormatter
|
||||
let exitStub: sinon.SinonStub
|
||||
let getControllerStub: sinon.SinonStub
|
||||
let disposeControllerStub: sinon.SinonStub
|
||||
|
||||
// Mock task
|
||||
const mockTask = {
|
||||
taskId: "test-task-123",
|
||||
handleWebviewAskResponse: sinon.stub(),
|
||||
messageStateHandler: {
|
||||
getClineMessages: sinon.stub().returns([]),
|
||||
},
|
||||
}
|
||||
|
||||
// Mock controller
|
||||
const mockController = {
|
||||
task: null as any,
|
||||
initTask: sinon.stub(),
|
||||
cancelTask: sinon.stub(),
|
||||
togglePlanActMode: sinon.stub(),
|
||||
getTaskWithId: sinon.stub(),
|
||||
getStateToPostToWebview: sinon.stub(),
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
// Create temp directory
|
||||
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "cline-test-"))
|
||||
|
||||
// Create mock config
|
||||
config = {
|
||||
verbose: false,
|
||||
configDir: tempDir,
|
||||
outputFormat: "plain",
|
||||
}
|
||||
|
||||
// Create mock logger
|
||||
logger = {
|
||||
debug: sinon.stub(),
|
||||
info: sinon.stub(),
|
||||
warn: sinon.stub(),
|
||||
error: sinon.stub(),
|
||||
}
|
||||
|
||||
// Create mock formatter
|
||||
formatter = {
|
||||
message: sinon.stub(),
|
||||
success: sinon.stub(),
|
||||
error: sinon.stub(),
|
||||
info: sinon.stub(),
|
||||
warn: sinon.stub(),
|
||||
table: sinon.stub(),
|
||||
list: sinon.stub(),
|
||||
tasks: sinon.stub(),
|
||||
keyValue: sinon.stub(),
|
||||
raw: sinon.stub(),
|
||||
}
|
||||
|
||||
// Stub process.exit
|
||||
exitStub = sinon.stub(process, "exit")
|
||||
|
||||
// Reset mock task
|
||||
mockTask.handleWebviewAskResponse.reset()
|
||||
mockTask.messageStateHandler.getClineMessages.returns([])
|
||||
|
||||
// Reset mock controller
|
||||
mockController.task = null
|
||||
mockController.initTask.reset()
|
||||
mockController.cancelTask.reset()
|
||||
mockController.togglePlanActMode.reset()
|
||||
mockController.getTaskWithId.reset()
|
||||
mockController.getStateToPostToWebview.reset()
|
||||
|
||||
// Setup default stubs
|
||||
mockController.getStateToPostToWebview.resolves({
|
||||
mode: "act",
|
||||
clineMessages: [],
|
||||
taskHistory: [],
|
||||
})
|
||||
mockController.initTask.resolves("test-task-123")
|
||||
|
||||
// Stub the embedded controller functions
|
||||
getControllerStub = sinon.stub(embeddedController, "getEmbeddedController").resolves(mockController as any)
|
||||
disposeControllerStub = sinon.stub(embeddedController, "disposeEmbeddedController").resolves()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
// Restore stubs
|
||||
sinon.restore()
|
||||
// Clean up temp directory
|
||||
fs.rmSync(tempDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it("should create command with correct name and alias", () => {
|
||||
const cmd = createTaskSendCommand(config, logger, formatter)
|
||||
|
||||
expect(cmd.name()).to.equal("send")
|
||||
expect(cmd.aliases()).to.include("s")
|
||||
})
|
||||
|
||||
it("should initialize controller on command start", async () => {
|
||||
const cmd = createTaskSendCommand(config, logger, formatter)
|
||||
await cmd.parseAsync(["node", "test", "Hello Cline"])
|
||||
|
||||
expect(getControllerStub.calledOnce).to.be.true
|
||||
})
|
||||
|
||||
it("should error when no message provided", async () => {
|
||||
const cmd = createTaskSendCommand(config, logger, formatter)
|
||||
await cmd.parseAsync(["node", "test"])
|
||||
|
||||
expect((formatter.error as sinon.SinonStub).calledOnce).to.be.true
|
||||
expect((formatter.error as sinon.SinonStub).firstCall.args[0]).to.include("No message provided")
|
||||
expect(exitStub.calledWith(1)).to.be.true
|
||||
})
|
||||
|
||||
it("should error when using both --approve and --deny", async () => {
|
||||
const cmd = createTaskSendCommand(config, logger, formatter)
|
||||
await cmd.parseAsync(["node", "test", "--approve", "--deny"])
|
||||
|
||||
expect((formatter.error as sinon.SinonStub).calledOnce).to.be.true
|
||||
expect((formatter.error as sinon.SinonStub).firstCall.args[0]).to.include("Cannot use both --approve and --deny")
|
||||
expect(exitStub.calledWith(1)).to.be.true
|
||||
})
|
||||
|
||||
it("should error when file not found", async () => {
|
||||
const cmd = createTaskSendCommand(config, logger, formatter)
|
||||
await cmd.parseAsync(["node", "test", "-f", "/nonexistent/file.txt", "message"])
|
||||
|
||||
expect((formatter.error as sinon.SinonStub).calledOnce).to.be.true
|
||||
expect((formatter.error as sinon.SinonStub).firstCall.args[0]).to.include("File not found")
|
||||
expect(exitStub.calledWith(1)).to.be.true
|
||||
})
|
||||
|
||||
it("should error on invalid mode option", async () => {
|
||||
const cmd = createTaskSendCommand(config, logger, formatter)
|
||||
await cmd.parseAsync(["node", "test", "-m", "invalid", "message"])
|
||||
|
||||
expect((formatter.error as sinon.SinonStub).calledOnce).to.be.true
|
||||
expect((formatter.error as sinon.SinonStub).firstCall.args[0]).to.include("Invalid mode")
|
||||
expect(exitStub.calledWith(1)).to.be.true
|
||||
})
|
||||
|
||||
it("should start new task with message when no active task", async () => {
|
||||
const cmd = createTaskSendCommand(config, logger, formatter)
|
||||
await cmd.parseAsync(["node", "test", "Hello Cline"])
|
||||
|
||||
expect(mockController.initTask.calledWith("Hello Cline")).to.be.true
|
||||
expect((formatter.info as sinon.SinonStub).calledWith(sinon.match(/Started new task/))).to.be.true
|
||||
})
|
||||
|
||||
it("should send message to existing active task", async () => {
|
||||
mockController.task = mockTask
|
||||
mockTask.handleWebviewAskResponse.resolves()
|
||||
|
||||
const cmd = createTaskSendCommand(config, logger, formatter)
|
||||
await cmd.parseAsync(["node", "test", "Hello Cline"])
|
||||
|
||||
expect(mockTask.handleWebviewAskResponse.calledWith("messageResponse", "Hello Cline")).to.be.true
|
||||
expect((formatter.info as sinon.SinonStub).calledWith(sinon.match(/Message sent/))).to.be.true
|
||||
})
|
||||
|
||||
it("should resume task with --task option", async () => {
|
||||
const historyItem = { id: "existing-task-456", task: "Previous task" }
|
||||
mockController.getTaskWithId.resolves({ historyItem })
|
||||
mockController.initTask.resolves("existing-task-456")
|
||||
|
||||
// Set up task after init
|
||||
mockController.initTask.callsFake(async () => {
|
||||
mockController.task = mockTask
|
||||
return "existing-task-456"
|
||||
})
|
||||
|
||||
const cmd = createTaskSendCommand(config, logger, formatter)
|
||||
await cmd.parseAsync(["node", "test", "-t", "existing-task-456", "Hello"])
|
||||
|
||||
expect(mockController.getTaskWithId.calledWith("existing-task-456")).to.be.true
|
||||
expect(mockController.initTask.calledWith(undefined, undefined, undefined, historyItem)).to.be.true
|
||||
})
|
||||
|
||||
it("should approve action with --approve flag", async () => {
|
||||
mockController.task = mockTask
|
||||
|
||||
const cmd = createTaskSendCommand(config, logger, formatter)
|
||||
await cmd.parseAsync(["node", "test", "--approve"])
|
||||
|
||||
expect(mockTask.handleWebviewAskResponse.calledWith("yesButtonClicked")).to.be.true
|
||||
expect((formatter.success as sinon.SinonStub).calledWith("Action approved")).to.be.true
|
||||
})
|
||||
|
||||
it("should deny action with --deny flag", async () => {
|
||||
mockController.task = mockTask
|
||||
|
||||
const cmd = createTaskSendCommand(config, logger, formatter)
|
||||
await cmd.parseAsync(["node", "test", "--deny"])
|
||||
|
||||
expect(mockTask.handleWebviewAskResponse.calledWith("noButtonClicked")).to.be.true
|
||||
expect((formatter.success as sinon.SinonStub).calledWith("Action denied")).to.be.true
|
||||
})
|
||||
|
||||
it("should error when approving without active task", async () => {
|
||||
const cmd = createTaskSendCommand(config, logger, formatter)
|
||||
await cmd.parseAsync(["node", "test", "--approve"])
|
||||
|
||||
expect((formatter.error as sinon.SinonStub).calledOnce).to.be.true
|
||||
expect((formatter.error as sinon.SinonStub).firstCall.args[0]).to.include("No active task")
|
||||
expect(exitStub.calledWith(1)).to.be.true
|
||||
})
|
||||
|
||||
it("should switch mode when --mode option provided", async () => {
|
||||
const cmd = createTaskSendCommand(config, logger, formatter)
|
||||
await cmd.parseAsync(["node", "test", "-m", "plan", "Hello"])
|
||||
|
||||
expect(mockController.togglePlanActMode.calledWith("plan")).to.be.true
|
||||
expect((formatter.info as sinon.SinonStub).calledWith("Switched to plan mode")).to.be.true
|
||||
})
|
||||
|
||||
it("should dispose controller after completion", async () => {
|
||||
const cmd = createTaskSendCommand(config, logger, formatter)
|
||||
await cmd.parseAsync(["node", "test", "Hello"])
|
||||
|
||||
expect(disposeControllerStub.calledOnce).to.be.true
|
||||
})
|
||||
|
||||
describe("command options", () => {
|
||||
it("should have -t/--task option", () => {
|
||||
const cmd = createTaskSendCommand(config, logger, formatter)
|
||||
const taskOption = cmd.options.find((opt) => opt.short === "-t" || opt.long === "--task")
|
||||
|
||||
expect(taskOption).to.exist
|
||||
})
|
||||
|
||||
it("should have -a/--approve option", () => {
|
||||
const cmd = createTaskSendCommand(config, logger, formatter)
|
||||
const approveOption = cmd.options.find((opt) => opt.short === "-a" || opt.long === "--approve")
|
||||
|
||||
expect(approveOption).to.exist
|
||||
})
|
||||
|
||||
it("should have -d/--deny option", () => {
|
||||
const cmd = createTaskSendCommand(config, logger, formatter)
|
||||
const denyOption = cmd.options.find((opt) => opt.short === "-d" || opt.long === "--deny")
|
||||
|
||||
expect(denyOption).to.exist
|
||||
})
|
||||
|
||||
it("should have -f/--file option", () => {
|
||||
const cmd = createTaskSendCommand(config, logger, formatter)
|
||||
const fileOption = cmd.options.find((opt) => opt.short === "-f" || opt.long === "--file")
|
||||
|
||||
expect(fileOption).to.exist
|
||||
})
|
||||
|
||||
it("should have -m/--mode option", () => {
|
||||
const cmd = createTaskSendCommand(config, logger, formatter)
|
||||
const modeOption = cmd.options.find((opt) => opt.short === "-m" || opt.long === "--mode")
|
||||
|
||||
expect(modeOption).to.exist
|
||||
})
|
||||
|
||||
it("should have -w/--wait option", () => {
|
||||
const cmd = createTaskSendCommand(config, logger, formatter)
|
||||
const waitOption = cmd.options.find((opt) => opt.short === "-w" || opt.long === "--wait")
|
||||
|
||||
expect(waitOption).to.exist
|
||||
})
|
||||
})
|
||||
|
||||
describe("JSON output", () => {
|
||||
it("should output JSON when format is json", async () => {
|
||||
config.outputFormat = "json"
|
||||
|
||||
const cmd = createTaskSendCommand(config, logger, formatter)
|
||||
await cmd.parseAsync(["node", "test", "Hello Cline"])
|
||||
|
||||
// Check that raw was called with JSON
|
||||
const rawCalls = (formatter.raw as sinon.SinonStub).getCalls()
|
||||
const jsonCall = rawCalls.find((call) => {
|
||||
try {
|
||||
JSON.parse(call.args[0])
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
})
|
||||
expect(jsonCall).to.exist
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,411 @@
|
||||
/**
|
||||
* Tests for task view command with embedded Controller
|
||||
*/
|
||||
|
||||
import { expect } from "chai"
|
||||
import fs from "fs"
|
||||
import os from "os"
|
||||
import path from "path"
|
||||
import sinon from "sinon"
|
||||
import { createTaskViewCommand } from "../../../../src/commands/task/view.js"
|
||||
// Mock the embedded controller module
|
||||
import * as embeddedController from "../../../../src/core/embedded-controller.js"
|
||||
import type { OutputFormatter } from "../../../../src/core/output/types.js"
|
||||
import type { CliConfig } from "../../../../src/types/config.js"
|
||||
import type { Logger } from "../../../../src/types/logger.js"
|
||||
|
||||
describe("task view command", () => {
|
||||
let tempDir: string
|
||||
let config: CliConfig
|
||||
let logger: Logger
|
||||
let formatter: OutputFormatter
|
||||
let exitStub: sinon.SinonStub
|
||||
let getControllerStub: sinon.SinonStub
|
||||
let disposeControllerStub: sinon.SinonStub
|
||||
|
||||
// Mock task
|
||||
const mockTask = {
|
||||
taskId: "test-task-123",
|
||||
handleWebviewAskResponse: sinon.stub(),
|
||||
messageStateHandler: {
|
||||
getClineMessages: sinon.stub().returns([]),
|
||||
},
|
||||
}
|
||||
|
||||
// Mock controller
|
||||
const mockController = {
|
||||
task: null as any,
|
||||
initTask: sinon.stub(),
|
||||
cancelTask: sinon.stub(),
|
||||
togglePlanActMode: sinon.stub(),
|
||||
getTaskWithId: sinon.stub(),
|
||||
getStateToPostToWebview: sinon.stub(),
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
// Create temp directory
|
||||
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "cline-test-"))
|
||||
|
||||
// Create mock config
|
||||
config = {
|
||||
verbose: false,
|
||||
configDir: tempDir,
|
||||
outputFormat: "plain",
|
||||
}
|
||||
|
||||
// Create mock logger
|
||||
logger = {
|
||||
debug: sinon.stub(),
|
||||
info: sinon.stub(),
|
||||
warn: sinon.stub(),
|
||||
error: sinon.stub(),
|
||||
}
|
||||
|
||||
// Create mock formatter
|
||||
formatter = {
|
||||
message: sinon.stub(),
|
||||
success: sinon.stub(),
|
||||
error: sinon.stub(),
|
||||
info: sinon.stub(),
|
||||
warn: sinon.stub(),
|
||||
table: sinon.stub(),
|
||||
list: sinon.stub(),
|
||||
tasks: sinon.stub(),
|
||||
keyValue: sinon.stub(),
|
||||
raw: sinon.stub(),
|
||||
}
|
||||
|
||||
// Stub process.exit
|
||||
exitStub = sinon.stub(process, "exit")
|
||||
|
||||
// Reset mock task
|
||||
mockTask.handleWebviewAskResponse.reset()
|
||||
mockTask.messageStateHandler.getClineMessages.returns([])
|
||||
|
||||
// Reset mock controller
|
||||
mockController.task = null
|
||||
mockController.initTask.reset()
|
||||
mockController.cancelTask.reset()
|
||||
mockController.togglePlanActMode.reset()
|
||||
mockController.getTaskWithId.reset()
|
||||
mockController.getStateToPostToWebview.reset()
|
||||
|
||||
// Setup default stubs
|
||||
mockController.getStateToPostToWebview.resolves({
|
||||
mode: "act",
|
||||
clineMessages: [],
|
||||
taskHistory: [],
|
||||
})
|
||||
mockController.initTask.resolves("test-task-123")
|
||||
|
||||
// Stub the embedded controller functions
|
||||
getControllerStub = sinon.stub(embeddedController, "getEmbeddedController").resolves(mockController as any)
|
||||
disposeControllerStub = sinon.stub(embeddedController, "disposeEmbeddedController").resolves()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
// Restore stubs
|
||||
sinon.restore()
|
||||
// Clean up temp directory
|
||||
fs.rmSync(tempDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it("should create command with correct name and alias", () => {
|
||||
const cmd = createTaskViewCommand(config, logger, formatter)
|
||||
|
||||
expect(cmd.name()).to.equal("view")
|
||||
expect(cmd.aliases()).to.include("v")
|
||||
})
|
||||
|
||||
it("should initialize controller on command start", async () => {
|
||||
mockController.getStateToPostToWebview.resolves({
|
||||
mode: "act",
|
||||
clineMessages: [],
|
||||
taskHistory: [{ id: "task-1", task: "Test task" }],
|
||||
})
|
||||
mockController.getTaskWithId.resolves({
|
||||
historyItem: { id: "task-1", task: "Test task" },
|
||||
})
|
||||
mockController.task = mockTask
|
||||
|
||||
const cmd = createTaskViewCommand(config, logger, formatter)
|
||||
await cmd.parseAsync(["node", "test"])
|
||||
|
||||
expect(getControllerStub.calledOnce).to.be.true
|
||||
})
|
||||
|
||||
it("should error when no tasks found", async () => {
|
||||
mockController.getStateToPostToWebview.resolves({
|
||||
mode: "act",
|
||||
clineMessages: [],
|
||||
taskHistory: [],
|
||||
})
|
||||
|
||||
const cmd = createTaskViewCommand(config, logger, formatter)
|
||||
await cmd.parseAsync(["node", "test"])
|
||||
|
||||
expect((formatter.error as sinon.SinonStub).calledOnce).to.be.true
|
||||
expect((formatter.error as sinon.SinonStub).firstCall.args[0]).to.include("No tasks found")
|
||||
expect(exitStub.calledWith(1)).to.be.true
|
||||
})
|
||||
|
||||
it("should error when task ID not found", async () => {
|
||||
mockController.getStateToPostToWebview.resolves({
|
||||
mode: "act",
|
||||
clineMessages: [],
|
||||
taskHistory: [{ id: "task-1", task: "Test task" }],
|
||||
})
|
||||
|
||||
const cmd = createTaskViewCommand(config, logger, formatter)
|
||||
await cmd.parseAsync(["node", "test", "nonexistent-task-id"])
|
||||
|
||||
expect((formatter.error as sinon.SinonStub).calledOnce).to.be.true
|
||||
expect((formatter.error as sinon.SinonStub).firstCall.args[0]).to.include("Task not found")
|
||||
expect(exitStub.calledWith(1)).to.be.true
|
||||
})
|
||||
|
||||
it("should view task by ID", async () => {
|
||||
const taskHistory = [
|
||||
{ id: "task-1", task: "First task" },
|
||||
{ id: "task-2", task: "Second task" },
|
||||
]
|
||||
mockController.getStateToPostToWebview.resolves({
|
||||
mode: "act",
|
||||
clineMessages: [],
|
||||
taskHistory,
|
||||
})
|
||||
mockController.getTaskWithId.resolves({
|
||||
historyItem: taskHistory[1],
|
||||
})
|
||||
|
||||
// Simulate task being initialized
|
||||
mockController.initTask.callsFake(async () => {
|
||||
mockController.task = mockTask
|
||||
return "task-2"
|
||||
})
|
||||
|
||||
const cmd = createTaskViewCommand(config, logger, formatter)
|
||||
await cmd.parseAsync(["node", "test", "task-2"])
|
||||
|
||||
expect(mockController.getTaskWithId.calledWith("task-2")).to.be.true
|
||||
})
|
||||
|
||||
it("should view task by partial ID", async () => {
|
||||
const taskHistory = [{ id: "task-123456789", task: "Test task" }]
|
||||
mockController.getStateToPostToWebview.resolves({
|
||||
mode: "act",
|
||||
clineMessages: [],
|
||||
taskHistory,
|
||||
})
|
||||
mockController.getTaskWithId.resolves({
|
||||
historyItem: taskHistory[0],
|
||||
})
|
||||
mockController.initTask.callsFake(async () => {
|
||||
mockController.task = mockTask
|
||||
return "task-123456789"
|
||||
})
|
||||
|
||||
const cmd = createTaskViewCommand(config, logger, formatter)
|
||||
await cmd.parseAsync(["node", "test", "task-1234"])
|
||||
|
||||
// Should find the task by partial match
|
||||
expect((formatter.info as sinon.SinonStub).calledWith(sinon.match(/Task: task-123456789/))).to.be.true
|
||||
})
|
||||
|
||||
it("should display messages from task", async () => {
|
||||
const messages = [
|
||||
{ ts: Date.now(), type: "say" as const, say: "task" as const, text: "Test task prompt" },
|
||||
{ ts: Date.now() + 1, type: "say" as const, say: "text" as const, text: "Hello from AI" },
|
||||
]
|
||||
mockTask.messageStateHandler.getClineMessages.returns(messages)
|
||||
mockController.task = mockTask
|
||||
|
||||
const taskHistory = [{ id: "task-1", task: "Test task" }]
|
||||
mockController.getStateToPostToWebview.resolves({
|
||||
mode: "act",
|
||||
clineMessages: messages,
|
||||
taskHistory,
|
||||
})
|
||||
|
||||
const cmd = createTaskViewCommand(config, logger, formatter)
|
||||
await cmd.parseAsync(["node", "test"])
|
||||
|
||||
// Should display messages
|
||||
expect(disposeControllerStub.calledOnce).to.be.true
|
||||
})
|
||||
|
||||
it("should filter messages by --last option", async () => {
|
||||
const messages = [
|
||||
{ ts: Date.now(), type: "say" as const, say: "task" as const, text: "Task 1" },
|
||||
{ ts: Date.now() + 1, type: "say" as const, say: "text" as const, text: "Message 2" },
|
||||
{ ts: Date.now() + 2, type: "say" as const, say: "text" as const, text: "Message 3" },
|
||||
]
|
||||
mockTask.messageStateHandler.getClineMessages.returns(messages)
|
||||
mockController.task = mockTask
|
||||
|
||||
const taskHistory = [{ id: "task-1", task: "Test task" }]
|
||||
mockController.getStateToPostToWebview.resolves({
|
||||
mode: "act",
|
||||
clineMessages: messages,
|
||||
taskHistory,
|
||||
})
|
||||
|
||||
const cmd = createTaskViewCommand(config, logger, formatter)
|
||||
await cmd.parseAsync(["node", "test", "-n", "2"])
|
||||
|
||||
// Should only process last 2 messages
|
||||
expect(disposeControllerStub.calledOnce).to.be.true
|
||||
})
|
||||
|
||||
it("should error on invalid --last count", async () => {
|
||||
const taskHistory = [{ id: "task-1", task: "Test task" }]
|
||||
mockController.getStateToPostToWebview.resolves({
|
||||
mode: "act",
|
||||
clineMessages: [],
|
||||
taskHistory,
|
||||
})
|
||||
mockController.getTaskWithId.resolves({
|
||||
historyItem: taskHistory[0],
|
||||
})
|
||||
mockController.task = mockTask
|
||||
|
||||
const cmd = createTaskViewCommand(config, logger, formatter)
|
||||
await cmd.parseAsync(["node", "test", "-n", "invalid"])
|
||||
|
||||
expect((formatter.error as sinon.SinonStub).calledOnce).to.be.true
|
||||
expect((formatter.error as sinon.SinonStub).firstCall.args[0]).to.include("Invalid count")
|
||||
expect(exitStub.calledWith(1)).to.be.true
|
||||
})
|
||||
|
||||
it("should error on invalid --since timestamp", async () => {
|
||||
const taskHistory = [{ id: "task-1", task: "Test task" }]
|
||||
mockController.getStateToPostToWebview.resolves({
|
||||
mode: "act",
|
||||
clineMessages: [],
|
||||
taskHistory,
|
||||
})
|
||||
mockController.getTaskWithId.resolves({
|
||||
historyItem: taskHistory[0],
|
||||
})
|
||||
mockController.task = mockTask
|
||||
|
||||
const cmd = createTaskViewCommand(config, logger, formatter)
|
||||
await cmd.parseAsync(["node", "test", "--since", "invalid"])
|
||||
|
||||
expect((formatter.error as sinon.SinonStub).calledOnce).to.be.true
|
||||
expect((formatter.error as sinon.SinonStub).firstCall.args[0]).to.include("Invalid timestamp")
|
||||
expect(exitStub.calledWith(1)).to.be.true
|
||||
})
|
||||
|
||||
it("should dispose controller after completion", async () => {
|
||||
const taskHistory = [{ id: "task-1", task: "Test task" }]
|
||||
mockController.getStateToPostToWebview.resolves({
|
||||
mode: "act",
|
||||
clineMessages: [],
|
||||
taskHistory,
|
||||
})
|
||||
mockController.getTaskWithId.resolves({
|
||||
historyItem: taskHistory[0],
|
||||
})
|
||||
mockController.task = mockTask
|
||||
|
||||
const cmd = createTaskViewCommand(config, logger, formatter)
|
||||
await cmd.parseAsync(["node", "test"])
|
||||
|
||||
expect(disposeControllerStub.calledOnce).to.be.true
|
||||
})
|
||||
|
||||
describe("command options", () => {
|
||||
it("should have -f/--follow option", () => {
|
||||
const cmd = createTaskViewCommand(config, logger, formatter)
|
||||
const followOption = cmd.options.find((opt) => opt.short === "-f" || opt.long === "--follow")
|
||||
|
||||
expect(followOption).to.exist
|
||||
})
|
||||
|
||||
it("should have -c/--follow-complete option", () => {
|
||||
const cmd = createTaskViewCommand(config, logger, formatter)
|
||||
const followCompleteOption = cmd.options.find((opt) => opt.short === "-c" || opt.long === "--follow-complete")
|
||||
|
||||
expect(followCompleteOption).to.exist
|
||||
})
|
||||
|
||||
it("should have -n/--last option", () => {
|
||||
const cmd = createTaskViewCommand(config, logger, formatter)
|
||||
const lastOption = cmd.options.find((opt) => opt.short === "-n" || opt.long === "--last")
|
||||
|
||||
expect(lastOption).to.exist
|
||||
})
|
||||
|
||||
it("should have --since option", () => {
|
||||
const cmd = createTaskViewCommand(config, logger, formatter)
|
||||
const sinceOption = cmd.options.find((opt) => opt.long === "--since")
|
||||
|
||||
expect(sinceOption).to.exist
|
||||
})
|
||||
|
||||
it("should have -r/--raw option", () => {
|
||||
const cmd = createTaskViewCommand(config, logger, formatter)
|
||||
const rawOption = cmd.options.find((opt) => opt.short === "-r" || opt.long === "--raw")
|
||||
|
||||
expect(rawOption).to.exist
|
||||
})
|
||||
})
|
||||
|
||||
describe("JSON output", () => {
|
||||
it("should output JSON when format is json", async () => {
|
||||
config.outputFormat = "json"
|
||||
|
||||
const taskHistory = [{ id: "task-1", task: "Test task" }]
|
||||
mockController.getStateToPostToWebview.resolves({
|
||||
mode: "act",
|
||||
clineMessages: [],
|
||||
taskHistory,
|
||||
})
|
||||
mockController.getTaskWithId.resolves({
|
||||
historyItem: taskHistory[0],
|
||||
})
|
||||
mockController.task = mockTask
|
||||
|
||||
const cmd = createTaskViewCommand(config, logger, formatter)
|
||||
await cmd.parseAsync(["node", "test"])
|
||||
|
||||
// Check that raw was called with JSON
|
||||
const rawCalls = (formatter.raw as sinon.SinonStub).getCalls()
|
||||
const jsonCall = rawCalls.find((call) => {
|
||||
try {
|
||||
const parsed = JSON.parse(call.args[0])
|
||||
return parsed.taskId !== undefined
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
})
|
||||
expect(jsonCall).to.exist
|
||||
})
|
||||
})
|
||||
|
||||
describe("current task", () => {
|
||||
it("should use current task when no ID provided and task is active", async () => {
|
||||
mockController.task = {
|
||||
...mockTask,
|
||||
taskId: "active-task-123",
|
||||
}
|
||||
|
||||
const taskHistory = [
|
||||
{ id: "active-task-123", task: "Active task" },
|
||||
{ id: "older-task", task: "Older task" },
|
||||
]
|
||||
mockController.getStateToPostToWebview.resolves({
|
||||
mode: "act",
|
||||
clineMessages: [],
|
||||
taskHistory,
|
||||
})
|
||||
|
||||
const cmd = createTaskViewCommand(config, logger, formatter)
|
||||
await cmd.parseAsync(["node", "test"])
|
||||
|
||||
// Should show the active task, not the most recent from history
|
||||
expect((formatter.info as sinon.SinonStub).calledWith(sinon.match(/Task: active-task-123/))).to.be.true
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,75 @@
|
||||
import { expect } from "chai"
|
||||
import sinon from "sinon"
|
||||
|
||||
// Define the build-time constant for tests
|
||||
declare global {
|
||||
var __CLINE_VERSION__: string
|
||||
}
|
||||
globalThis.__CLINE_VERSION__ = "1.0.0-test"
|
||||
|
||||
import { createVersionCommand, getVersion, runVersionCommand } from "../../../src/commands/version.js"
|
||||
import { createConfig } from "../../../src/core/config.js"
|
||||
import { createLogger } from "../../../src/core/logger.js"
|
||||
import type { Logger } from "../../../src/types/logger.js"
|
||||
|
||||
describe("Version Command", () => {
|
||||
let consoleLogStub: sinon.SinonStub
|
||||
let mockLogger: Logger
|
||||
|
||||
beforeEach(() => {
|
||||
consoleLogStub = sinon.stub(console, "log")
|
||||
mockLogger = {
|
||||
debug: sinon.stub(),
|
||||
info: sinon.stub(),
|
||||
warn: sinon.stub(),
|
||||
error: sinon.stub(),
|
||||
}
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
sinon.restore()
|
||||
})
|
||||
|
||||
describe("getVersion", () => {
|
||||
it("should return a semantic version string", () => {
|
||||
const version = getVersion()
|
||||
expect(version).to.match(/^\d+\.\d+\.\d+/)
|
||||
})
|
||||
})
|
||||
|
||||
describe("runVersionCommand", () => {
|
||||
it("should output version in format 'cline <version>'", () => {
|
||||
const config = createConfig()
|
||||
runVersionCommand(config, mockLogger)
|
||||
|
||||
expect(consoleLogStub.called).to.be.true
|
||||
const output = consoleLogStub.firstCall.args[0]
|
||||
expect(output).to.match(/^cline \d+\.\d+\.\d+/)
|
||||
})
|
||||
|
||||
it("should log debug message when logger is verbose", () => {
|
||||
const config = createConfig({ verbose: true })
|
||||
runVersionCommand(config, mockLogger)
|
||||
|
||||
expect((mockLogger.debug as sinon.SinonStub).called).to.be.true
|
||||
})
|
||||
})
|
||||
|
||||
describe("createVersionCommand", () => {
|
||||
it("should create a command named 'version'", () => {
|
||||
const config = createConfig()
|
||||
const logger = createLogger()
|
||||
const cmd = createVersionCommand(config, logger)
|
||||
|
||||
expect(cmd.name()).to.equal("version")
|
||||
})
|
||||
|
||||
it("should have a description", () => {
|
||||
const config = createConfig()
|
||||
const logger = createLogger()
|
||||
const cmd = createVersionCommand(config, logger)
|
||||
|
||||
expect(cmd.description()).to.include("version")
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,82 @@
|
||||
import { expect } from "chai"
|
||||
import { getProviderById, getProviderIds, isValidProviderId, PROVIDERS } from "../../../../src/core/auth/providers.js"
|
||||
|
||||
describe("Providers", () => {
|
||||
describe("PROVIDERS", () => {
|
||||
it("should have at least 5 providers defined", () => {
|
||||
expect(PROVIDERS.length).to.be.at.least(5)
|
||||
})
|
||||
|
||||
it("should have anthropic as the first provider", () => {
|
||||
expect(PROVIDERS[0].id).to.equal("anthropic")
|
||||
})
|
||||
|
||||
it("should have required fields for each provider", () => {
|
||||
for (const provider of PROVIDERS) {
|
||||
expect(provider.id).to.be.a("string").and.not.empty
|
||||
expect(provider.name).to.be.a("string").and.not.empty
|
||||
expect(provider.description).to.be.a("string").and.not.empty
|
||||
expect(provider.requiresApiKey).to.be.a("boolean")
|
||||
}
|
||||
})
|
||||
|
||||
it("should have keyUrl for providers requiring API keys", () => {
|
||||
const providersRequiringKeys = PROVIDERS.filter((p) => p.requiresApiKey)
|
||||
for (const provider of providersRequiringKeys) {
|
||||
// Most should have keyUrl, but not all
|
||||
if (provider.keyUrl) {
|
||||
expect(provider.keyUrl).to.match(/^https?:\/\//)
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe("getProviderById", () => {
|
||||
it("should return provider for valid id", () => {
|
||||
const provider = getProviderById("anthropic")
|
||||
expect(provider).to.exist
|
||||
expect(provider?.name).to.equal("Anthropic")
|
||||
})
|
||||
|
||||
it("should return undefined for invalid id", () => {
|
||||
const provider = getProviderById("nonexistent")
|
||||
expect(provider).to.be.undefined
|
||||
})
|
||||
|
||||
it("should return openrouter provider", () => {
|
||||
const provider = getProviderById("openrouter")
|
||||
expect(provider).to.exist
|
||||
expect(provider?.name).to.equal("OpenRouter")
|
||||
})
|
||||
})
|
||||
|
||||
describe("getProviderIds", () => {
|
||||
it("should return array of provider ids", () => {
|
||||
const ids = getProviderIds()
|
||||
expect(ids).to.be.an("array")
|
||||
expect(ids).to.include("anthropic")
|
||||
expect(ids).to.include("openrouter")
|
||||
expect(ids).to.include("openai")
|
||||
})
|
||||
|
||||
it("should have same length as PROVIDERS", () => {
|
||||
const ids = getProviderIds()
|
||||
expect(ids.length).to.equal(PROVIDERS.length)
|
||||
})
|
||||
})
|
||||
|
||||
describe("isValidProviderId", () => {
|
||||
it("should return true for valid provider ids", () => {
|
||||
expect(isValidProviderId("anthropic")).to.be.true
|
||||
expect(isValidProviderId("openrouter")).to.be.true
|
||||
expect(isValidProviderId("openai")).to.be.true
|
||||
expect(isValidProviderId("ollama")).to.be.true
|
||||
})
|
||||
|
||||
it("should return false for invalid provider ids", () => {
|
||||
expect(isValidProviderId("invalid")).to.be.false
|
||||
expect(isValidProviderId("")).to.be.false
|
||||
expect(isValidProviderId("ANTHROPIC")).to.be.false
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,179 @@
|
||||
import { expect } from "chai"
|
||||
import fs from "fs"
|
||||
import os from "os"
|
||||
import path from "path"
|
||||
import { createSecretsStorage, maskApiKey, SecretsStorage } from "../../../../src/core/auth/secrets.js"
|
||||
|
||||
describe("SecretsStorage", () => {
|
||||
let tempDir: string
|
||||
let storage: SecretsStorage
|
||||
|
||||
beforeEach(() => {
|
||||
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "cline-secrets-test-"))
|
||||
storage = new SecretsStorage(tempDir)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
try {
|
||||
fs.rmSync(tempDir, { recursive: true })
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
})
|
||||
|
||||
describe("load", () => {
|
||||
it("should return empty object when secrets file does not exist", () => {
|
||||
const secrets = storage.load()
|
||||
expect(secrets).to.deep.equal({})
|
||||
})
|
||||
|
||||
it("should load existing secrets from file", () => {
|
||||
const testSecrets = { anthropic: "sk-ant-123", openai: "sk-456" }
|
||||
fs.mkdirSync(tempDir, { recursive: true })
|
||||
fs.writeFileSync(path.join(tempDir, "secrets.json"), JSON.stringify(testSecrets))
|
||||
|
||||
const secrets = storage.load()
|
||||
expect(secrets).to.deep.equal(testSecrets)
|
||||
})
|
||||
|
||||
it("should return empty object on invalid JSON", () => {
|
||||
fs.mkdirSync(tempDir, { recursive: true })
|
||||
fs.writeFileSync(path.join(tempDir, "secrets.json"), "not valid json")
|
||||
|
||||
const secrets = storage.load()
|
||||
expect(secrets).to.deep.equal({})
|
||||
})
|
||||
})
|
||||
|
||||
describe("save", () => {
|
||||
it("should create secrets directory if it does not exist", () => {
|
||||
const nestedDir = path.join(tempDir, "nested", "secrets")
|
||||
const nestedStorage = new SecretsStorage(nestedDir)
|
||||
|
||||
nestedStorage.save({ testProvider: "test-key" })
|
||||
|
||||
expect(fs.existsSync(path.join(nestedDir, "secrets.json"))).to.be.true
|
||||
})
|
||||
|
||||
it("should save secrets as formatted JSON", () => {
|
||||
storage.save({ anthropic: "sk-ant-123" })
|
||||
|
||||
const content = fs.readFileSync(path.join(tempDir, "secrets.json"), "utf-8")
|
||||
const parsed = JSON.parse(content)
|
||||
|
||||
expect(parsed.anthropic).to.equal("sk-ant-123")
|
||||
})
|
||||
})
|
||||
|
||||
describe("getApiKey", () => {
|
||||
it("should return undefined for non-existent provider", () => {
|
||||
expect(storage.getApiKey("nonexistent")).to.be.undefined
|
||||
})
|
||||
|
||||
it("should return API key for existing provider", () => {
|
||||
storage.save({ anthropic: "sk-ant-123" })
|
||||
expect(storage.getApiKey("anthropic")).to.equal("sk-ant-123")
|
||||
})
|
||||
})
|
||||
|
||||
describe("setApiKey", () => {
|
||||
it("should set API key for new provider", () => {
|
||||
storage.setApiKey("anthropic", "sk-ant-123")
|
||||
expect(storage.getApiKey("anthropic")).to.equal("sk-ant-123")
|
||||
})
|
||||
|
||||
it("should update API key for existing provider", () => {
|
||||
storage.setApiKey("anthropic", "old-key")
|
||||
storage.setApiKey("anthropic", "new-key")
|
||||
expect(storage.getApiKey("anthropic")).to.equal("new-key")
|
||||
})
|
||||
|
||||
it("should preserve other keys when setting", () => {
|
||||
storage.setApiKey("anthropic", "sk-ant-123")
|
||||
storage.setApiKey("openai", "sk-456")
|
||||
expect(storage.getApiKey("anthropic")).to.equal("sk-ant-123")
|
||||
expect(storage.getApiKey("openai")).to.equal("sk-456")
|
||||
})
|
||||
})
|
||||
|
||||
describe("deleteApiKey", () => {
|
||||
it("should return false when provider does not exist", () => {
|
||||
expect(storage.deleteApiKey("nonexistent")).to.be.false
|
||||
})
|
||||
|
||||
it("should delete existing key and return true", () => {
|
||||
storage.setApiKey("anthropic", "sk-ant-123")
|
||||
expect(storage.deleteApiKey("anthropic")).to.be.true
|
||||
expect(storage.getApiKey("anthropic")).to.be.undefined
|
||||
})
|
||||
|
||||
it("should preserve other keys when deleting", () => {
|
||||
storage.setApiKey("anthropic", "sk-ant-123")
|
||||
storage.setApiKey("openai", "sk-456")
|
||||
storage.deleteApiKey("anthropic")
|
||||
expect(storage.getApiKey("openai")).to.equal("sk-456")
|
||||
})
|
||||
})
|
||||
|
||||
describe("listProviders", () => {
|
||||
it("should return empty array when no secrets", () => {
|
||||
expect(storage.listProviders()).to.deep.equal([])
|
||||
})
|
||||
|
||||
it("should return array of provider ids", () => {
|
||||
storage.setApiKey("anthropic", "sk-ant-123")
|
||||
storage.setApiKey("openai", "sk-456")
|
||||
const providers = storage.listProviders()
|
||||
expect(providers).to.include("anthropic")
|
||||
expect(providers).to.include("openai")
|
||||
})
|
||||
})
|
||||
|
||||
describe("hasApiKey", () => {
|
||||
it("should return false when provider has no key", () => {
|
||||
expect(storage.hasApiKey("anthropic")).to.be.false
|
||||
})
|
||||
|
||||
it("should return true when provider has key", () => {
|
||||
storage.setApiKey("anthropic", "sk-ant-123")
|
||||
expect(storage.hasApiKey("anthropic")).to.be.true
|
||||
})
|
||||
})
|
||||
|
||||
describe("getSecretsPath", () => {
|
||||
it("should return path to secrets.json", () => {
|
||||
expect(storage.getSecretsPath()).to.equal(path.join(tempDir, "secrets.json"))
|
||||
})
|
||||
})
|
||||
|
||||
describe("clear", () => {
|
||||
it("should remove all secrets", () => {
|
||||
storage.setApiKey("anthropic", "sk-ant-123")
|
||||
storage.setApiKey("openai", "sk-456")
|
||||
storage.clear()
|
||||
expect(storage.listProviders()).to.deep.equal([])
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("createSecretsStorage", () => {
|
||||
it("should create a SecretsStorage instance", () => {
|
||||
const storage = createSecretsStorage("/tmp/test")
|
||||
expect(storage).to.be.instanceOf(SecretsStorage)
|
||||
})
|
||||
})
|
||||
|
||||
describe("maskApiKey", () => {
|
||||
it("should mask long keys showing first/last 4 chars", () => {
|
||||
expect(maskApiKey("sk-ant-api0123456789abcd")).to.equal("sk-a...abcd")
|
||||
})
|
||||
|
||||
it("should return **** for short keys", () => {
|
||||
expect(maskApiKey("short")).to.equal("****")
|
||||
expect(maskApiKey("12345678")).to.equal("****")
|
||||
})
|
||||
|
||||
it("should handle keys just over threshold", () => {
|
||||
expect(maskApiKey("123456789")).to.equal("1234...6789")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,56 @@
|
||||
import { expect } from "chai"
|
||||
import os from "os"
|
||||
import path from "path"
|
||||
import { createConfig, DEFAULT_CLI_CONFIG, getDefaultConfigDir } from "../../../src/core/config.js"
|
||||
|
||||
describe("Config", () => {
|
||||
describe("getDefaultConfigDir", () => {
|
||||
it("should return ~/.cline path", () => {
|
||||
const result = getDefaultConfigDir()
|
||||
const expected = path.join(os.homedir(), ".cline")
|
||||
expect(result).to.equal(expected)
|
||||
})
|
||||
})
|
||||
|
||||
describe("DEFAULT_CLI_CONFIG", () => {
|
||||
it("should have verbose=false by default", () => {
|
||||
expect(DEFAULT_CLI_CONFIG.verbose).to.be.false
|
||||
})
|
||||
|
||||
it("should have configDir set to ~/.cline", () => {
|
||||
const expected = path.join(os.homedir(), ".cline")
|
||||
expect(DEFAULT_CLI_CONFIG.configDir).to.equal(expected)
|
||||
})
|
||||
})
|
||||
|
||||
describe("createConfig", () => {
|
||||
it("should return default config when called without arguments", () => {
|
||||
const config = createConfig()
|
||||
expect(config.verbose).to.equal(DEFAULT_CLI_CONFIG.verbose)
|
||||
expect(config.configDir).to.equal(DEFAULT_CLI_CONFIG.configDir)
|
||||
})
|
||||
|
||||
it("should merge provided options with defaults", () => {
|
||||
const config = createConfig({ verbose: true })
|
||||
expect(config.verbose).to.be.true
|
||||
expect(config.configDir).to.equal(DEFAULT_CLI_CONFIG.configDir)
|
||||
})
|
||||
|
||||
it("should override configDir when provided", () => {
|
||||
const customDir = "/custom/path"
|
||||
const config = createConfig({ configDir: customDir })
|
||||
expect(config.configDir).to.equal(customDir)
|
||||
expect(config.verbose).to.be.false
|
||||
})
|
||||
|
||||
it("should allow overriding all options", () => {
|
||||
const customDir = "/custom/path"
|
||||
const config = createConfig({
|
||||
verbose: true,
|
||||
configDir: customDir,
|
||||
})
|
||||
expect(config.verbose).to.be.true
|
||||
expect(config.configDir).to.equal(customDir)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,84 @@
|
||||
import { expect } from "chai"
|
||||
import sinon from "sinon"
|
||||
import { ConsoleLogger, createLogger } from "../../../src/core/logger.js"
|
||||
|
||||
describe("Logger", () => {
|
||||
let consoleDebugStub: sinon.SinonStub
|
||||
let consoleInfoStub: sinon.SinonStub
|
||||
let consoleWarnStub: sinon.SinonStub
|
||||
let consoleErrorStub: sinon.SinonStub
|
||||
|
||||
beforeEach(() => {
|
||||
consoleDebugStub = sinon.stub(console, "debug")
|
||||
consoleInfoStub = sinon.stub(console, "info")
|
||||
consoleWarnStub = sinon.stub(console, "warn")
|
||||
consoleErrorStub = sinon.stub(console, "error")
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
sinon.restore()
|
||||
})
|
||||
|
||||
describe("ConsoleLogger", () => {
|
||||
describe("with verbose=false", () => {
|
||||
it("should not output debug messages", () => {
|
||||
const logger = new ConsoleLogger(false)
|
||||
logger.debug("test debug message")
|
||||
expect(consoleDebugStub.called).to.be.false
|
||||
})
|
||||
|
||||
it("should output info messages", () => {
|
||||
const logger = new ConsoleLogger(false)
|
||||
logger.info("test info message")
|
||||
expect(consoleInfoStub.called).to.be.true
|
||||
})
|
||||
|
||||
it("should output warn messages", () => {
|
||||
const logger = new ConsoleLogger(false)
|
||||
logger.warn("test warn message")
|
||||
expect(consoleWarnStub.called).to.be.true
|
||||
})
|
||||
|
||||
it("should output error messages", () => {
|
||||
const logger = new ConsoleLogger(false)
|
||||
logger.error("test error message")
|
||||
expect(consoleErrorStub.called).to.be.true
|
||||
})
|
||||
})
|
||||
|
||||
describe("with verbose=true", () => {
|
||||
it("should output debug messages", () => {
|
||||
const logger = new ConsoleLogger(true)
|
||||
logger.debug("test debug message")
|
||||
expect(consoleDebugStub.called).to.be.true
|
||||
})
|
||||
|
||||
it("should include timestamp and level in formatted message", () => {
|
||||
const logger = new ConsoleLogger(true)
|
||||
logger.debug("test message")
|
||||
|
||||
const call = consoleDebugStub.firstCall
|
||||
const formattedMessage = call.args[0]
|
||||
|
||||
// Check message contains timestamp pattern and level
|
||||
expect(formattedMessage).to.match(/\[\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/)
|
||||
expect(formattedMessage).to.include("[DEBUG]")
|
||||
expect(formattedMessage).to.include("test message")
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("createLogger", () => {
|
||||
it("should create a logger with verbose=false by default", () => {
|
||||
const logger = createLogger()
|
||||
logger.debug("test")
|
||||
expect(consoleDebugStub.called).to.be.false
|
||||
})
|
||||
|
||||
it("should create a verbose logger when verbose=true", () => {
|
||||
const logger = createLogger(true)
|
||||
logger.debug("test")
|
||||
expect(consoleDebugStub.called).to.be.true
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,112 @@
|
||||
import { expect } from "chai"
|
||||
import {
|
||||
createFormatter,
|
||||
createFormatterFromOption,
|
||||
getDefaultFormat,
|
||||
isValidFormat,
|
||||
parseOutputFormat,
|
||||
} from "../../../../src/core/output/index.js"
|
||||
import { JsonFormatter } from "../../../../src/core/output/json-formatter.js"
|
||||
import { PlainFormatter } from "../../../../src/core/output/plain-formatter.js"
|
||||
import { RichFormatter } from "../../../../src/core/output/rich-formatter.js"
|
||||
|
||||
describe("Output Formatter Factory", () => {
|
||||
describe("getDefaultFormat", () => {
|
||||
let originalIsTTY: boolean | undefined
|
||||
|
||||
beforeEach(() => {
|
||||
originalIsTTY = process.stdout.isTTY
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
// Restore original value
|
||||
Object.defineProperty(process.stdout, "isTTY", {
|
||||
value: originalIsTTY,
|
||||
writable: true,
|
||||
})
|
||||
})
|
||||
|
||||
it("should return 'rich' when stdout is a TTY", () => {
|
||||
Object.defineProperty(process.stdout, "isTTY", {
|
||||
value: true,
|
||||
writable: true,
|
||||
})
|
||||
expect(getDefaultFormat()).to.equal("rich")
|
||||
})
|
||||
|
||||
it("should return 'plain' when stdout is not a TTY", () => {
|
||||
Object.defineProperty(process.stdout, "isTTY", {
|
||||
value: false,
|
||||
writable: true,
|
||||
})
|
||||
expect(getDefaultFormat()).to.equal("plain")
|
||||
})
|
||||
})
|
||||
|
||||
describe("isValidFormat", () => {
|
||||
it("should return true for 'rich'", () => {
|
||||
expect(isValidFormat("rich")).to.be.true
|
||||
})
|
||||
|
||||
it("should return true for 'json'", () => {
|
||||
expect(isValidFormat("json")).to.be.true
|
||||
})
|
||||
|
||||
it("should return true for 'plain'", () => {
|
||||
expect(isValidFormat("plain")).to.be.true
|
||||
})
|
||||
|
||||
it("should return false for invalid formats", () => {
|
||||
expect(isValidFormat("invalid")).to.be.false
|
||||
expect(isValidFormat("")).to.be.false
|
||||
expect(isValidFormat("RICH")).to.be.false
|
||||
})
|
||||
})
|
||||
|
||||
describe("parseOutputFormat", () => {
|
||||
it("should return the format when valid", () => {
|
||||
expect(parseOutputFormat("rich")).to.equal("rich")
|
||||
expect(parseOutputFormat("json")).to.equal("json")
|
||||
expect(parseOutputFormat("plain")).to.equal("plain")
|
||||
})
|
||||
|
||||
it("should return default format when undefined", () => {
|
||||
const result = parseOutputFormat(undefined)
|
||||
expect(["rich", "plain"]).to.include(result)
|
||||
})
|
||||
|
||||
it("should throw error for invalid format", () => {
|
||||
expect(() => parseOutputFormat("invalid")).to.throw("Invalid output format: invalid")
|
||||
})
|
||||
})
|
||||
|
||||
describe("createFormatter", () => {
|
||||
it("should create RichFormatter for 'rich'", () => {
|
||||
const formatter = createFormatter("rich")
|
||||
expect(formatter).to.be.instanceOf(RichFormatter)
|
||||
})
|
||||
|
||||
it("should create JsonFormatter for 'json'", () => {
|
||||
const formatter = createFormatter("json")
|
||||
expect(formatter).to.be.instanceOf(JsonFormatter)
|
||||
})
|
||||
|
||||
it("should create PlainFormatter for 'plain'", () => {
|
||||
const formatter = createFormatter("plain")
|
||||
expect(formatter).to.be.instanceOf(PlainFormatter)
|
||||
})
|
||||
})
|
||||
|
||||
describe("createFormatterFromOption", () => {
|
||||
it("should create formatter from string option", () => {
|
||||
const formatter = createFormatterFromOption("json")
|
||||
expect(formatter).to.be.instanceOf(JsonFormatter)
|
||||
})
|
||||
|
||||
it("should use default format when undefined", () => {
|
||||
const formatter = createFormatterFromOption(undefined)
|
||||
// Should be either Rich or Plain depending on TTY
|
||||
expect(formatter).to.satisfy((f: unknown) => f instanceof RichFormatter || f instanceof PlainFormatter)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,229 @@
|
||||
import { expect } from "chai"
|
||||
import sinon from "sinon"
|
||||
import { JsonFormatter } from "../../../../src/core/output/json-formatter.js"
|
||||
import type { ClineMessage, TaskInfo } from "../../../../src/core/output/types.js"
|
||||
|
||||
describe("JsonFormatter", () => {
|
||||
let formatter: JsonFormatter
|
||||
let consoleLogStub: sinon.SinonStub
|
||||
let capturedOutput: string[]
|
||||
|
||||
beforeEach(() => {
|
||||
formatter = new JsonFormatter()
|
||||
capturedOutput = []
|
||||
consoleLogStub = sinon.stub(console, "log").callsFake((output: string) => {
|
||||
capturedOutput.push(output)
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
sinon.restore()
|
||||
})
|
||||
|
||||
function getLastOutput(): Record<string, unknown> {
|
||||
const lastOutput = capturedOutput[capturedOutput.length - 1]
|
||||
return JSON.parse(lastOutput)
|
||||
}
|
||||
|
||||
describe("message", () => {
|
||||
it("should output message as valid JSON", () => {
|
||||
const msg: ClineMessage = {
|
||||
type: "say",
|
||||
text: "Hello world",
|
||||
ts: 1705344000000,
|
||||
say: "text",
|
||||
}
|
||||
formatter.message(msg)
|
||||
|
||||
const output = getLastOutput()
|
||||
expect(output.type).to.equal("message")
|
||||
expect(output.data).to.deep.include({
|
||||
type: "say",
|
||||
text: "Hello world",
|
||||
ts: 1705344000000,
|
||||
say: "text",
|
||||
})
|
||||
expect(output.ts).to.be.a("number")
|
||||
})
|
||||
|
||||
it("should preserve all message fields", () => {
|
||||
const msg: ClineMessage = {
|
||||
type: "ask",
|
||||
text: "Question?",
|
||||
ts: 1705344000000,
|
||||
ask: "followup",
|
||||
reasoning: "thinking...",
|
||||
partial: true,
|
||||
}
|
||||
formatter.message(msg)
|
||||
|
||||
const output = getLastOutput()
|
||||
const data = output.data as ClineMessage
|
||||
expect(data.type).to.equal("ask")
|
||||
expect(data.ask).to.equal("followup")
|
||||
expect(data.reasoning).to.equal("thinking...")
|
||||
expect(data.partial).to.be.true
|
||||
})
|
||||
})
|
||||
|
||||
describe("error", () => {
|
||||
it("should output error string as JSON", () => {
|
||||
formatter.error("Something went wrong")
|
||||
|
||||
const output = getLastOutput()
|
||||
expect(output.type).to.equal("error")
|
||||
expect((output.data as Record<string, unknown>).message).to.equal("Something went wrong")
|
||||
})
|
||||
|
||||
it("should output error object with stack", () => {
|
||||
const err = new Error("Test error")
|
||||
formatter.error(err)
|
||||
|
||||
const output = getLastOutput()
|
||||
expect(output.type).to.equal("error")
|
||||
const data = output.data as Record<string, unknown>
|
||||
expect(data.message).to.equal("Test error")
|
||||
expect(data.name).to.equal("Error")
|
||||
expect(data.stack).to.be.a("string")
|
||||
})
|
||||
})
|
||||
|
||||
describe("success", () => {
|
||||
it("should output success message", () => {
|
||||
formatter.success("Operation completed")
|
||||
|
||||
const output = getLastOutput()
|
||||
expect(output.type).to.equal("success")
|
||||
expect((output.data as Record<string, unknown>).message).to.equal("Operation completed")
|
||||
})
|
||||
})
|
||||
|
||||
describe("warn", () => {
|
||||
it("should output warning message", () => {
|
||||
formatter.warn("Be careful")
|
||||
|
||||
const output = getLastOutput()
|
||||
expect(output.type).to.equal("warn")
|
||||
expect((output.data as Record<string, unknown>).message).to.equal("Be careful")
|
||||
})
|
||||
})
|
||||
|
||||
describe("info", () => {
|
||||
it("should output info message", () => {
|
||||
formatter.info("Some information")
|
||||
|
||||
const output = getLastOutput()
|
||||
expect(output.type).to.equal("info")
|
||||
expect((output.data as Record<string, unknown>).message).to.equal("Some information")
|
||||
})
|
||||
})
|
||||
|
||||
describe("table", () => {
|
||||
it("should output table data with rows and columns", () => {
|
||||
const data = [
|
||||
{ name: "Alice", age: 30 },
|
||||
{ name: "Bob", age: 25 },
|
||||
]
|
||||
formatter.table(data)
|
||||
|
||||
const output = getLastOutput()
|
||||
expect(output.type).to.equal("table")
|
||||
const tableData = output.data as { rows: unknown[]; columns: string[] }
|
||||
expect(tableData.rows).to.deep.equal(data)
|
||||
expect(tableData.columns).to.deep.equal(["name", "age"])
|
||||
})
|
||||
|
||||
it("should use custom columns when specified", () => {
|
||||
const data = [{ name: "Alice", age: 30, city: "NYC" }]
|
||||
formatter.table(data, ["name", "city"])
|
||||
|
||||
const output = getLastOutput()
|
||||
const tableData = output.data as { rows: unknown[]; columns: string[] }
|
||||
expect(tableData.columns).to.deep.equal(["name", "city"])
|
||||
})
|
||||
|
||||
it("should handle empty data", () => {
|
||||
formatter.table([])
|
||||
|
||||
const output = getLastOutput()
|
||||
const tableData = output.data as { rows: unknown[]; columns: string[] }
|
||||
expect(tableData.rows).to.deep.equal([])
|
||||
expect(tableData.columns).to.deep.equal([])
|
||||
})
|
||||
})
|
||||
|
||||
describe("list", () => {
|
||||
it("should output items array", () => {
|
||||
formatter.list(["item1", "item2", "item3"])
|
||||
|
||||
const output = getLastOutput()
|
||||
expect(output.type).to.equal("list")
|
||||
expect((output.data as { items: string[] }).items).to.deep.equal(["item1", "item2", "item3"])
|
||||
})
|
||||
})
|
||||
|
||||
describe("tasks", () => {
|
||||
it("should output task list", () => {
|
||||
const tasks: TaskInfo[] = [
|
||||
{
|
||||
id: "task-1",
|
||||
ts: 1705344000000,
|
||||
task: "Fix the bug",
|
||||
completed: true,
|
||||
totalTokens: 1000,
|
||||
totalCost: 0.05,
|
||||
},
|
||||
]
|
||||
formatter.tasks(tasks)
|
||||
|
||||
const output = getLastOutput()
|
||||
expect(output.type).to.equal("tasks")
|
||||
expect((output.data as { tasks: TaskInfo[] }).tasks).to.deep.equal(tasks)
|
||||
})
|
||||
})
|
||||
|
||||
describe("keyValue", () => {
|
||||
it("should output key-value object", () => {
|
||||
formatter.keyValue({ name: "test", count: 42 })
|
||||
|
||||
const output = getLastOutput()
|
||||
expect(output.type).to.equal("keyValue")
|
||||
expect(output.data).to.deep.equal({ name: "test", count: 42 })
|
||||
})
|
||||
})
|
||||
|
||||
describe("raw", () => {
|
||||
it("should output raw content wrapped in JSON", () => {
|
||||
formatter.raw("raw text here")
|
||||
|
||||
const output = getLastOutput()
|
||||
expect(output.type).to.equal("raw")
|
||||
expect((output.data as { content: string }).content).to.equal("raw text here")
|
||||
})
|
||||
})
|
||||
|
||||
describe("JSON validity", () => {
|
||||
it("should always output valid JSON lines", () => {
|
||||
formatter.message({ type: "say", text: "test", ts: Date.now() })
|
||||
formatter.error("error")
|
||||
formatter.success("success")
|
||||
formatter.table([{ a: 1 }])
|
||||
|
||||
for (const line of capturedOutput) {
|
||||
expect(() => JSON.parse(line)).to.not.throw()
|
||||
}
|
||||
})
|
||||
|
||||
it("should include timestamp in all outputs", () => {
|
||||
formatter.message({ type: "say", text: "test", ts: Date.now() })
|
||||
formatter.error("error")
|
||||
formatter.success("success")
|
||||
|
||||
for (const line of capturedOutput) {
|
||||
const parsed = JSON.parse(line)
|
||||
expect(parsed.ts).to.be.a("number")
|
||||
expect(parsed.ts).to.be.greaterThan(0)
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,188 @@
|
||||
import { expect } from "chai"
|
||||
import sinon from "sinon"
|
||||
import { PlainFormatter } from "../../../../src/core/output/plain-formatter.js"
|
||||
import type { ClineMessage, TaskInfo } from "../../../../src/core/output/types.js"
|
||||
|
||||
describe("PlainFormatter", () => {
|
||||
let formatter: PlainFormatter
|
||||
let consoleLogStub: sinon.SinonStub
|
||||
let consoleErrorStub: sinon.SinonStub
|
||||
let consoleWarnStub: sinon.SinonStub
|
||||
let stdoutWriteStub: sinon.SinonStub
|
||||
|
||||
beforeEach(() => {
|
||||
formatter = new PlainFormatter()
|
||||
consoleLogStub = sinon.stub(console, "log")
|
||||
consoleErrorStub = sinon.stub(console, "error")
|
||||
consoleWarnStub = sinon.stub(console, "warn")
|
||||
stdoutWriteStub = sinon.stub(process.stdout, "write")
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
sinon.restore()
|
||||
})
|
||||
|
||||
describe("message", () => {
|
||||
it("should output say message with prefix", () => {
|
||||
const msg: ClineMessage = {
|
||||
type: "say",
|
||||
text: "Hello world",
|
||||
ts: Date.now(),
|
||||
say: "text",
|
||||
}
|
||||
formatter.message(msg)
|
||||
expect(consoleLogStub.calledWith("[>] (text) Hello world")).to.be.true
|
||||
})
|
||||
|
||||
it("should output ask message with ? prefix", () => {
|
||||
const msg: ClineMessage = {
|
||||
type: "ask",
|
||||
text: "What should I do?",
|
||||
ts: Date.now(),
|
||||
ask: "followup",
|
||||
}
|
||||
formatter.message(msg)
|
||||
expect(consoleLogStub.calledWith("[?] (followup) What should I do?")).to.be.true
|
||||
})
|
||||
|
||||
it("should output reasoning when present", () => {
|
||||
const msg: ClineMessage = {
|
||||
type: "say",
|
||||
text: "Result",
|
||||
ts: Date.now(),
|
||||
reasoning: "I thought about this",
|
||||
}
|
||||
formatter.message(msg)
|
||||
expect(consoleLogStub.calledWith("[thinking] I thought about this")).to.be.true
|
||||
})
|
||||
})
|
||||
|
||||
describe("error", () => {
|
||||
it("should output error string", () => {
|
||||
formatter.error("Something went wrong")
|
||||
expect(consoleErrorStub.calledWith("ERROR: Something went wrong")).to.be.true
|
||||
})
|
||||
|
||||
it("should output error object message", () => {
|
||||
formatter.error(new Error("Test error"))
|
||||
expect(consoleErrorStub.calledWith("ERROR: Test error")).to.be.true
|
||||
})
|
||||
})
|
||||
|
||||
describe("success", () => {
|
||||
it("should output success message with OK prefix", () => {
|
||||
formatter.success("Operation completed")
|
||||
expect(consoleLogStub.calledWith("OK: Operation completed")).to.be.true
|
||||
})
|
||||
})
|
||||
|
||||
describe("warn", () => {
|
||||
it("should output warning message", () => {
|
||||
formatter.warn("Be careful")
|
||||
expect(consoleWarnStub.calledWith("WARN: Be careful")).to.be.true
|
||||
})
|
||||
})
|
||||
|
||||
describe("info", () => {
|
||||
it("should output info message", () => {
|
||||
formatter.info("Some information")
|
||||
expect(consoleLogStub.calledWith("INFO: Some information")).to.be.true
|
||||
})
|
||||
})
|
||||
|
||||
describe("table", () => {
|
||||
it("should output table with headers and rows", () => {
|
||||
const data = [
|
||||
{ name: "Alice", age: 30 },
|
||||
{ name: "Bob", age: 25 },
|
||||
]
|
||||
formatter.table(data)
|
||||
expect(consoleLogStub.calledWith("name\tage")).to.be.true
|
||||
expect(consoleLogStub.calledWith("Alice\t30")).to.be.true
|
||||
expect(consoleLogStub.calledWith("Bob\t25")).to.be.true
|
||||
})
|
||||
|
||||
it("should handle empty data", () => {
|
||||
formatter.table([])
|
||||
expect(consoleLogStub.calledWith("(no data)")).to.be.true
|
||||
})
|
||||
|
||||
it("should use custom columns when specified", () => {
|
||||
const data = [{ name: "Alice", age: 30, city: "NYC" }]
|
||||
formatter.table(data, ["name", "city"])
|
||||
expect(consoleLogStub.calledWith("name\tcity")).to.be.true
|
||||
expect(consoleLogStub.calledWith("Alice\tNYC")).to.be.true
|
||||
})
|
||||
})
|
||||
|
||||
describe("list", () => {
|
||||
it("should output items with dash prefix", () => {
|
||||
formatter.list(["item1", "item2", "item3"])
|
||||
expect(consoleLogStub.calledWith("- item1")).to.be.true
|
||||
expect(consoleLogStub.calledWith("- item2")).to.be.true
|
||||
expect(consoleLogStub.calledWith("- item3")).to.be.true
|
||||
})
|
||||
})
|
||||
|
||||
describe("tasks", () => {
|
||||
it("should output task list", () => {
|
||||
const tasks: TaskInfo[] = [
|
||||
{
|
||||
id: "task-1",
|
||||
ts: new Date("2024-01-15").getTime(),
|
||||
task: "Fix the bug",
|
||||
completed: true,
|
||||
},
|
||||
{
|
||||
id: "task-2",
|
||||
ts: new Date("2024-01-16").getTime(),
|
||||
task: "Add new feature",
|
||||
completed: false,
|
||||
},
|
||||
]
|
||||
formatter.tasks(tasks)
|
||||
// Check that task IDs are in output
|
||||
const calls = consoleLogStub.getCalls().map((c) => c.args[0])
|
||||
expect(calls.some((c: string) => c.includes("task-1"))).to.be.true
|
||||
expect(calls.some((c: string) => c.includes("[done]"))).to.be.true
|
||||
expect(calls.some((c: string) => c.includes("[active]"))).to.be.true
|
||||
})
|
||||
|
||||
it("should handle empty task list", () => {
|
||||
formatter.tasks([])
|
||||
expect(consoleLogStub.calledWith("No tasks found")).to.be.true
|
||||
})
|
||||
|
||||
it("should truncate long task descriptions", () => {
|
||||
const longTask = "A".repeat(100)
|
||||
const tasks: TaskInfo[] = [
|
||||
{
|
||||
id: "task-1",
|
||||
ts: Date.now(),
|
||||
task: longTask,
|
||||
},
|
||||
]
|
||||
formatter.tasks(tasks)
|
||||
const calls = consoleLogStub.getCalls().map((c) => c.args[0])
|
||||
// Should be truncated with ...
|
||||
expect(calls.some((c: string) => c.includes("..."))).to.be.true
|
||||
})
|
||||
})
|
||||
|
||||
describe("keyValue", () => {
|
||||
it("should output key-value pairs", () => {
|
||||
formatter.keyValue({ name: "test", count: 42 })
|
||||
expect(consoleLogStub.calledWith("name: test")).to.be.true
|
||||
expect(consoleLogStub.calledWith("count: 42")).to.be.true
|
||||
})
|
||||
})
|
||||
|
||||
describe("raw", () => {
|
||||
it("should output text as-is", () => {
|
||||
// raw() uses stdout.write which is bound at module load time,
|
||||
// so we can't easily stub it. Just verify the method exists
|
||||
// and doesn't throw.
|
||||
expect(() => formatter.raw("raw text here")).to.not.throw()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,276 @@
|
||||
import { expect } from "chai"
|
||||
import sinon from "sinon"
|
||||
import { RichFormatter } from "../../../../src/core/output/rich-formatter.js"
|
||||
import type { ClineMessage, TaskInfo } from "../../../../src/core/output/types.js"
|
||||
|
||||
describe("RichFormatter", () => {
|
||||
let formatter: RichFormatter
|
||||
let consoleLogStub: sinon.SinonStub
|
||||
let consoleErrorStub: sinon.SinonStub
|
||||
let consoleWarnStub: sinon.SinonStub
|
||||
let stdoutWriteStub: sinon.SinonStub
|
||||
let capturedOutput: string[]
|
||||
|
||||
beforeEach(() => {
|
||||
formatter = new RichFormatter()
|
||||
capturedOutput = []
|
||||
consoleLogStub = sinon.stub(console, "log").callsFake((...args: unknown[]) => {
|
||||
capturedOutput.push(args.join(" "))
|
||||
})
|
||||
consoleErrorStub = sinon.stub(console, "error").callsFake((...args: unknown[]) => {
|
||||
capturedOutput.push(args.join(" "))
|
||||
})
|
||||
consoleWarnStub = sinon.stub(console, "warn").callsFake((...args: unknown[]) => {
|
||||
capturedOutput.push(args.join(" "))
|
||||
})
|
||||
stdoutWriteStub = sinon.stub(process.stdout, "write")
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
sinon.restore()
|
||||
})
|
||||
|
||||
describe("message", () => {
|
||||
it("should output say message", () => {
|
||||
const msg: ClineMessage = {
|
||||
type: "say",
|
||||
text: "Hello world",
|
||||
ts: Date.now(),
|
||||
say: "text",
|
||||
}
|
||||
formatter.message(msg)
|
||||
expect(consoleLogStub.called).to.be.true
|
||||
// Check that text appears in output
|
||||
const output = capturedOutput.join("\n")
|
||||
expect(output).to.include("Hello world")
|
||||
})
|
||||
|
||||
it("should output ask message with question indicator", () => {
|
||||
const msg: ClineMessage = {
|
||||
type: "ask",
|
||||
text: "What should I do?",
|
||||
ts: Date.now(),
|
||||
ask: "followup",
|
||||
}
|
||||
formatter.message(msg)
|
||||
expect(consoleLogStub.called).to.be.true
|
||||
const output = capturedOutput.join("\n")
|
||||
expect(output).to.include("What should I do?")
|
||||
})
|
||||
|
||||
it("should output reasoning when present", () => {
|
||||
const msg: ClineMessage = {
|
||||
type: "say",
|
||||
text: "Result",
|
||||
ts: Date.now(),
|
||||
reasoning: "I thought about this",
|
||||
}
|
||||
formatter.message(msg)
|
||||
const output = capturedOutput.join("\n")
|
||||
expect(output).to.include("I thought about this")
|
||||
})
|
||||
|
||||
it("should indicate streaming for partial messages", () => {
|
||||
const msg: ClineMessage = {
|
||||
type: "say",
|
||||
text: "Partial content",
|
||||
ts: Date.now(),
|
||||
partial: true,
|
||||
}
|
||||
formatter.message(msg)
|
||||
const output = capturedOutput.join("\n")
|
||||
expect(output).to.include("streaming")
|
||||
})
|
||||
|
||||
it("should handle error message type", () => {
|
||||
const msg: ClineMessage = {
|
||||
type: "say",
|
||||
text: "An error occurred",
|
||||
ts: Date.now(),
|
||||
say: "error",
|
||||
}
|
||||
formatter.message(msg)
|
||||
expect(consoleLogStub.called).to.be.true
|
||||
})
|
||||
|
||||
it("should handle completion_result message type", () => {
|
||||
const msg: ClineMessage = {
|
||||
type: "say",
|
||||
text: "Task completed",
|
||||
ts: Date.now(),
|
||||
say: "completion_result",
|
||||
}
|
||||
formatter.message(msg)
|
||||
expect(consoleLogStub.called).to.be.true
|
||||
})
|
||||
})
|
||||
|
||||
describe("error", () => {
|
||||
it("should output error string", () => {
|
||||
formatter.error("Something went wrong")
|
||||
expect(consoleErrorStub.called).to.be.true
|
||||
const output = capturedOutput.join("\n")
|
||||
expect(output).to.include("Something went wrong")
|
||||
})
|
||||
|
||||
it("should output error object with stack trace", () => {
|
||||
const err = new Error("Test error")
|
||||
formatter.error(err)
|
||||
expect(consoleErrorStub.called).to.be.true
|
||||
const output = capturedOutput.join("\n")
|
||||
expect(output).to.include("Test error")
|
||||
})
|
||||
})
|
||||
|
||||
describe("success", () => {
|
||||
it("should output success message with checkmark", () => {
|
||||
formatter.success("Operation completed")
|
||||
expect(consoleLogStub.called).to.be.true
|
||||
const output = capturedOutput.join("\n")
|
||||
expect(output).to.include("Operation completed")
|
||||
})
|
||||
})
|
||||
|
||||
describe("warn", () => {
|
||||
it("should output warning message", () => {
|
||||
formatter.warn("Be careful")
|
||||
expect(consoleWarnStub.called).to.be.true
|
||||
const output = capturedOutput.join("\n")
|
||||
expect(output).to.include("Be careful")
|
||||
})
|
||||
})
|
||||
|
||||
describe("info", () => {
|
||||
it("should output info message", () => {
|
||||
formatter.info("Some information")
|
||||
expect(consoleLogStub.called).to.be.true
|
||||
const output = capturedOutput.join("\n")
|
||||
expect(output).to.include("Some information")
|
||||
})
|
||||
})
|
||||
|
||||
describe("table", () => {
|
||||
it("should output formatted table with headers", () => {
|
||||
const data = [
|
||||
{ name: "Alice", age: 30 },
|
||||
{ name: "Bob", age: 25 },
|
||||
]
|
||||
formatter.table(data)
|
||||
expect(consoleLogStub.called).to.be.true
|
||||
const output = capturedOutput.join("\n")
|
||||
expect(output).to.include("name")
|
||||
expect(output).to.include("age")
|
||||
expect(output).to.include("Alice")
|
||||
expect(output).to.include("Bob")
|
||||
})
|
||||
|
||||
it("should handle empty data", () => {
|
||||
formatter.table([])
|
||||
const output = capturedOutput.join("\n")
|
||||
expect(output).to.include("no data")
|
||||
})
|
||||
|
||||
it("should use custom columns when specified", () => {
|
||||
const data = [{ name: "Alice", age: 30, city: "NYC" }]
|
||||
formatter.table(data, ["name", "city"])
|
||||
const output = capturedOutput.join("\n")
|
||||
expect(output).to.include("name")
|
||||
expect(output).to.include("city")
|
||||
})
|
||||
})
|
||||
|
||||
describe("list", () => {
|
||||
it("should output items with bullets", () => {
|
||||
formatter.list(["item1", "item2", "item3"])
|
||||
expect(consoleLogStub.called).to.be.true
|
||||
const output = capturedOutput.join("\n")
|
||||
expect(output).to.include("item1")
|
||||
expect(output).to.include("item2")
|
||||
expect(output).to.include("item3")
|
||||
})
|
||||
})
|
||||
|
||||
describe("tasks", () => {
|
||||
it("should output formatted task list", () => {
|
||||
const tasks: TaskInfo[] = [
|
||||
{
|
||||
id: "task-1",
|
||||
ts: new Date("2024-01-15").getTime(),
|
||||
task: "Fix the bug",
|
||||
completed: true,
|
||||
totalTokens: 1000,
|
||||
totalCost: 0.05,
|
||||
},
|
||||
{
|
||||
id: "task-2",
|
||||
ts: new Date("2024-01-16").getTime(),
|
||||
task: "Add new feature",
|
||||
completed: false,
|
||||
},
|
||||
]
|
||||
formatter.tasks(tasks)
|
||||
const output = capturedOutput.join("\n")
|
||||
expect(output).to.include("task-1")
|
||||
expect(output).to.include("task-2")
|
||||
expect(output).to.include("Fix the bug")
|
||||
expect(output).to.include("done")
|
||||
expect(output).to.include("active")
|
||||
})
|
||||
|
||||
it("should handle empty task list", () => {
|
||||
formatter.tasks([])
|
||||
const output = capturedOutput.join("\n")
|
||||
expect(output).to.include("No tasks found")
|
||||
})
|
||||
|
||||
it("should show token and cost info when available", () => {
|
||||
const tasks: TaskInfo[] = [
|
||||
{
|
||||
id: "task-1",
|
||||
ts: Date.now(),
|
||||
task: "Test task",
|
||||
totalTokens: 5000,
|
||||
totalCost: 0.25,
|
||||
},
|
||||
]
|
||||
formatter.tasks(tasks)
|
||||
const output = capturedOutput.join("\n")
|
||||
expect(output).to.include("5,000")
|
||||
expect(output).to.include("0.25")
|
||||
})
|
||||
|
||||
it("should truncate long task descriptions", () => {
|
||||
const longTask = "A".repeat(100)
|
||||
const tasks: TaskInfo[] = [
|
||||
{
|
||||
id: "task-1",
|
||||
ts: Date.now(),
|
||||
task: longTask,
|
||||
},
|
||||
]
|
||||
formatter.tasks(tasks)
|
||||
const output = capturedOutput.join("\n")
|
||||
expect(output).to.include("...")
|
||||
})
|
||||
})
|
||||
|
||||
describe("keyValue", () => {
|
||||
it("should output aligned key-value pairs", () => {
|
||||
formatter.keyValue({ name: "test", count: 42 })
|
||||
const output = capturedOutput.join("\n")
|
||||
expect(output).to.include("name")
|
||||
expect(output).to.include("test")
|
||||
expect(output).to.include("count")
|
||||
expect(output).to.include("42")
|
||||
})
|
||||
})
|
||||
|
||||
describe("raw", () => {
|
||||
it("should output text without formatting", () => {
|
||||
// raw() uses stdout.write which is bound at module load time,
|
||||
// so we can't easily stub it. Just verify the method exists
|
||||
// and doesn't throw.
|
||||
expect(() => formatter.raw("raw text here")).to.not.throw()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,285 @@
|
||||
import { expect } from "chai"
|
||||
import fs from "fs"
|
||||
import os from "os"
|
||||
import path from "path"
|
||||
import {
|
||||
fileToBase64DataUrl,
|
||||
isImageFile,
|
||||
parseAtPaths,
|
||||
processExplicitFiles,
|
||||
processExplicitImages,
|
||||
} from "../../../src/core/path-parser.js"
|
||||
|
||||
describe("path-parser", () => {
|
||||
// Create a temp directory for test files
|
||||
let tempDir: string
|
||||
|
||||
before(() => {
|
||||
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "path-parser-test-"))
|
||||
})
|
||||
|
||||
after(() => {
|
||||
// Clean up temp directory
|
||||
fs.rmSync(tempDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
describe("isImageFile", () => {
|
||||
it("should return true for supported image extensions", () => {
|
||||
expect(isImageFile("test.png")).to.be.true
|
||||
expect(isImageFile("test.jpg")).to.be.true
|
||||
expect(isImageFile("test.jpeg")).to.be.true
|
||||
expect(isImageFile("test.gif")).to.be.true
|
||||
expect(isImageFile("test.webp")).to.be.true
|
||||
})
|
||||
|
||||
it("should return true for uppercase extensions", () => {
|
||||
expect(isImageFile("test.PNG")).to.be.true
|
||||
expect(isImageFile("test.JPG")).to.be.true
|
||||
expect(isImageFile("test.JPEG")).to.be.true
|
||||
})
|
||||
|
||||
it("should return false for non-image extensions", () => {
|
||||
expect(isImageFile("test.txt")).to.be.false
|
||||
expect(isImageFile("test.js")).to.be.false
|
||||
expect(isImageFile("test.ts")).to.be.false
|
||||
expect(isImageFile("test.pdf")).to.be.false
|
||||
expect(isImageFile("test")).to.be.false
|
||||
})
|
||||
|
||||
it("should handle paths with directories", () => {
|
||||
expect(isImageFile("/path/to/image.png")).to.be.true
|
||||
expect(isImageFile("./relative/path/image.jpg")).to.be.true
|
||||
expect(isImageFile("/path/to/file.txt")).to.be.false
|
||||
})
|
||||
})
|
||||
|
||||
describe("fileToBase64DataUrl", () => {
|
||||
it("should convert a PNG file to base64 data URL", () => {
|
||||
// Create a minimal valid PNG file (1x1 transparent pixel)
|
||||
const pngData = Buffer.from([
|
||||
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00,
|
||||
0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x06, 0x00, 0x00, 0x00, 0x1f, 0x15, 0xc4, 0x89, 0x00, 0x00, 0x00, 0x0a, 0x49,
|
||||
0x44, 0x41, 0x54, 0x78, 0x9c, 0x63, 0x00, 0x01, 0x00, 0x00, 0x05, 0x00, 0x01, 0x0d, 0x0a, 0x2d, 0xb4, 0x00, 0x00,
|
||||
0x00, 0x00, 0x49, 0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82,
|
||||
])
|
||||
|
||||
const pngPath = path.join(tempDir, "test.png")
|
||||
fs.writeFileSync(pngPath, pngData)
|
||||
|
||||
const result = fileToBase64DataUrl(pngPath)
|
||||
|
||||
expect(result).to.match(/^data:image\/png;base64,/)
|
||||
expect(result).to.include(pngData.toString("base64"))
|
||||
})
|
||||
|
||||
it("should throw error for non-existent file", () => {
|
||||
expect(() => fileToBase64DataUrl("/non/existent/file.png")).to.throw("Image file not found")
|
||||
})
|
||||
|
||||
it("should throw error for unsupported format", () => {
|
||||
const txtPath = path.join(tempDir, "test.txt")
|
||||
fs.writeFileSync(txtPath, "hello")
|
||||
|
||||
expect(() => fileToBase64DataUrl(txtPath)).to.throw("Unsupported image format")
|
||||
})
|
||||
|
||||
it("should use correct MIME type for different formats", () => {
|
||||
const jpgPath = path.join(tempDir, "test.jpg")
|
||||
fs.writeFileSync(jpgPath, Buffer.from([0xff, 0xd8, 0xff, 0xe0])) // Minimal JPEG header
|
||||
|
||||
const result = fileToBase64DataUrl(jpgPath)
|
||||
expect(result).to.match(/^data:image\/jpeg;base64,/)
|
||||
})
|
||||
})
|
||||
|
||||
describe("parseAtPaths", () => {
|
||||
beforeEach(() => {
|
||||
// Create test files
|
||||
fs.writeFileSync(path.join(tempDir, "file1.txt"), "content1")
|
||||
fs.writeFileSync(path.join(tempDir, "file2.js"), "content2")
|
||||
// Create a minimal PNG for image testing
|
||||
const pngData = Buffer.from([
|
||||
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00,
|
||||
0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x06, 0x00, 0x00, 0x00, 0x1f, 0x15, 0xc4, 0x89, 0x00, 0x00, 0x00, 0x0a, 0x49,
|
||||
0x44, 0x41, 0x54, 0x78, 0x9c, 0x63, 0x00, 0x01, 0x00, 0x00, 0x05, 0x00, 0x01, 0x0d, 0x0a, 0x2d, 0xb4, 0x00, 0x00,
|
||||
0x00, 0x00, 0x49, 0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82,
|
||||
])
|
||||
fs.writeFileSync(path.join(tempDir, "image.png"), pngData)
|
||||
})
|
||||
|
||||
it("should parse single @path at start of message", () => {
|
||||
const result = parseAtPaths("@file1.txt check this file", tempDir)
|
||||
|
||||
expect(result.cleanedMessage).to.equal("check this file")
|
||||
expect(result.files).to.have.lengthOf(1)
|
||||
expect(result.files[0]).to.equal(path.join(tempDir, "file1.txt"))
|
||||
expect(result.images).to.have.lengthOf(0)
|
||||
expect(result.warnings).to.have.lengthOf(0)
|
||||
})
|
||||
|
||||
it("should parse single @path in middle of message", () => {
|
||||
const result = parseAtPaths("please check @file1.txt and report", tempDir)
|
||||
|
||||
expect(result.cleanedMessage).to.equal("please check and report")
|
||||
expect(result.files).to.have.lengthOf(1)
|
||||
})
|
||||
|
||||
it("should parse multiple @paths", () => {
|
||||
const result = parseAtPaths("check @file1.txt and @file2.js", tempDir)
|
||||
|
||||
expect(result.cleanedMessage).to.equal("check and")
|
||||
expect(result.files).to.have.lengthOf(2)
|
||||
})
|
||||
|
||||
it("should separate images from files", () => {
|
||||
const result = parseAtPaths("look at @image.png and @file1.txt", tempDir)
|
||||
|
||||
expect(result.files).to.have.lengthOf(1)
|
||||
expect(result.files[0]).to.equal(path.join(tempDir, "file1.txt"))
|
||||
expect(result.images).to.have.lengthOf(1)
|
||||
expect(result.images[0]).to.match(/^data:image\/png;base64,/)
|
||||
})
|
||||
|
||||
it("should handle relative paths with ./", () => {
|
||||
const result = parseAtPaths("check @./file1.txt", tempDir)
|
||||
|
||||
expect(result.files).to.have.lengthOf(1)
|
||||
expect(result.files[0]).to.equal(path.join(tempDir, "file1.txt"))
|
||||
})
|
||||
|
||||
it("should handle absolute paths", () => {
|
||||
const absolutePath = path.join(tempDir, "file1.txt")
|
||||
const result = parseAtPaths(`check @${absolutePath}`, "/some/other/cwd")
|
||||
|
||||
expect(result.files).to.have.lengthOf(1)
|
||||
expect(result.files[0]).to.equal(absolutePath)
|
||||
})
|
||||
|
||||
it("should warn about non-existent files", () => {
|
||||
const result = parseAtPaths("check @nonexistent.txt", tempDir)
|
||||
|
||||
expect(result.files).to.have.lengthOf(0)
|
||||
expect(result.warnings).to.have.lengthOf(1)
|
||||
expect(result.warnings[0]).to.include("File not found")
|
||||
})
|
||||
|
||||
it("should warn about directories", () => {
|
||||
fs.mkdirSync(path.join(tempDir, "subdir"), { recursive: true })
|
||||
const result = parseAtPaths("check @subdir", tempDir)
|
||||
|
||||
expect(result.files).to.have.lengthOf(0)
|
||||
expect(result.warnings).to.have.lengthOf(1)
|
||||
expect(result.warnings[0]).to.include("Cannot attach directory")
|
||||
})
|
||||
|
||||
it("should not match @ in email addresses", () => {
|
||||
const result = parseAtPaths("send to user@example.com", tempDir)
|
||||
|
||||
// The regex requires whitespace before @, so email should not be matched
|
||||
expect(result.cleanedMessage).to.equal("send to user@example.com")
|
||||
expect(result.files).to.have.lengthOf(0)
|
||||
})
|
||||
|
||||
it("should handle message with no @paths", () => {
|
||||
const result = parseAtPaths("just a regular message", tempDir)
|
||||
|
||||
expect(result.cleanedMessage).to.equal("just a regular message")
|
||||
expect(result.files).to.have.lengthOf(0)
|
||||
expect(result.images).to.have.lengthOf(0)
|
||||
expect(result.warnings).to.have.lengthOf(0)
|
||||
})
|
||||
|
||||
it("should handle empty message", () => {
|
||||
const result = parseAtPaths("", tempDir)
|
||||
|
||||
expect(result.cleanedMessage).to.equal("")
|
||||
expect(result.files).to.have.lengthOf(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe("processExplicitFiles", () => {
|
||||
beforeEach(() => {
|
||||
fs.writeFileSync(path.join(tempDir, "explicit.txt"), "content")
|
||||
const pngData = Buffer.from([
|
||||
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00,
|
||||
0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x06, 0x00, 0x00, 0x00, 0x1f, 0x15, 0xc4, 0x89, 0x00, 0x00, 0x00, 0x0a, 0x49,
|
||||
0x44, 0x41, 0x54, 0x78, 0x9c, 0x63, 0x00, 0x01, 0x00, 0x00, 0x05, 0x00, 0x01, 0x0d, 0x0a, 0x2d, 0xb4, 0x00, 0x00,
|
||||
0x00, 0x00, 0x49, 0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82,
|
||||
])
|
||||
fs.writeFileSync(path.join(tempDir, "explicit.png"), pngData)
|
||||
})
|
||||
|
||||
it("should process regular files", () => {
|
||||
const result = processExplicitFiles(["explicit.txt"], tempDir)
|
||||
|
||||
expect(result.files).to.have.lengthOf(1)
|
||||
expect(result.files[0]).to.equal(path.join(tempDir, "explicit.txt"))
|
||||
expect(result.images).to.have.lengthOf(0)
|
||||
})
|
||||
|
||||
it("should auto-detect images and convert to base64", () => {
|
||||
const result = processExplicitFiles(["explicit.png"], tempDir)
|
||||
|
||||
expect(result.files).to.have.lengthOf(0)
|
||||
expect(result.images).to.have.lengthOf(1)
|
||||
expect(result.images[0]).to.match(/^data:image\/png;base64,/)
|
||||
})
|
||||
|
||||
it("should throw error for non-existent file (strict mode)", () => {
|
||||
expect(() => processExplicitFiles(["nonexistent.txt"], tempDir)).to.throw("File not found")
|
||||
})
|
||||
|
||||
it("should throw error for directories", () => {
|
||||
fs.mkdirSync(path.join(tempDir, "explicitdir"), { recursive: true })
|
||||
expect(() => processExplicitFiles(["explicitdir"], tempDir)).to.throw("Cannot attach directory")
|
||||
})
|
||||
|
||||
it("should process multiple files", () => {
|
||||
const result = processExplicitFiles(["explicit.txt", "explicit.png"], tempDir)
|
||||
|
||||
expect(result.files).to.have.lengthOf(1)
|
||||
expect(result.images).to.have.lengthOf(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe("processExplicitImages", () => {
|
||||
beforeEach(() => {
|
||||
const pngData = Buffer.from([
|
||||
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00,
|
||||
0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x06, 0x00, 0x00, 0x00, 0x1f, 0x15, 0xc4, 0x89, 0x00, 0x00, 0x00, 0x0a, 0x49,
|
||||
0x44, 0x41, 0x54, 0x78, 0x9c, 0x63, 0x00, 0x01, 0x00, 0x00, 0x05, 0x00, 0x01, 0x0d, 0x0a, 0x2d, 0xb4, 0x00, 0x00,
|
||||
0x00, 0x00, 0x49, 0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82,
|
||||
])
|
||||
fs.writeFileSync(path.join(tempDir, "image2.png"), pngData)
|
||||
fs.writeFileSync(path.join(tempDir, "notimage.txt"), "text")
|
||||
})
|
||||
|
||||
it("should process image files", () => {
|
||||
const result = processExplicitImages(["image2.png"], tempDir)
|
||||
|
||||
expect(result).to.have.lengthOf(1)
|
||||
expect(result[0]).to.match(/^data:image\/png;base64,/)
|
||||
})
|
||||
|
||||
it("should throw error for non-image files", () => {
|
||||
expect(() => processExplicitImages(["notimage.txt"], tempDir)).to.throw("Not a supported image format")
|
||||
})
|
||||
|
||||
it("should throw error for non-existent file", () => {
|
||||
expect(() => processExplicitImages(["nonexistent.png"], tempDir)).to.throw("Image file not found")
|
||||
})
|
||||
|
||||
it("should process multiple images", () => {
|
||||
const pngData = Buffer.from([
|
||||
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00,
|
||||
0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x06, 0x00, 0x00, 0x00, 0x1f, 0x15, 0xc4, 0x89, 0x00, 0x00, 0x00, 0x0a, 0x49,
|
||||
0x44, 0x41, 0x54, 0x78, 0x9c, 0x63, 0x00, 0x01, 0x00, 0x00, 0x05, 0x00, 0x01, 0x0d, 0x0a, 0x2d, 0xb4, 0x00, 0x00,
|
||||
0x00, 0x00, 0x49, 0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82,
|
||||
])
|
||||
fs.writeFileSync(path.join(tempDir, "image3.png"), pngData)
|
||||
|
||||
const result = processExplicitImages(["image2.png", "image3.png"], tempDir)
|
||||
expect(result).to.have.lengthOf(2)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,59 @@
|
||||
{
|
||||
"extends": "../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "..",
|
||||
"outDir": "dist",
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"../src/*"
|
||||
],
|
||||
"@core/*": [
|
||||
"../src/core/*"
|
||||
],
|
||||
"@shared/*": [
|
||||
"../src/shared/*"
|
||||
],
|
||||
"@hosts/*": [
|
||||
"../src/hosts/*"
|
||||
],
|
||||
"@utils/*": [
|
||||
"../src/utils/*"
|
||||
],
|
||||
"@standalone/*": [
|
||||
"../src/standalone/*"
|
||||
],
|
||||
"@cli/*": [
|
||||
"src/*"
|
||||
],
|
||||
"@integrations/*": [
|
||||
"../src/integrations/*"
|
||||
],
|
||||
"@services/*": [
|
||||
"../src/services/*"
|
||||
],
|
||||
"@packages/*": [
|
||||
"../src/packages/*"
|
||||
],
|
||||
"@generated/*": [
|
||||
"../src/generated/*"
|
||||
],
|
||||
"@api/*": [
|
||||
"../src/core/api/*"
|
||||
],
|
||||
"vscode": [
|
||||
"../standalone/runtime-files/vscode/index.js"
|
||||
]
|
||||
},
|
||||
"noEmit": true
|
||||
},
|
||||
"include": [
|
||||
"src/**/*",
|
||||
"../src/**/*"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
"dist",
|
||||
"tests"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
# Implementation Plan
|
||||
|
||||
[Overview]
|
||||
Create the core scaffold for the TypeScript CLI that replaces the Go CLI.
|
||||
|
||||
This initial phase establishes the foundational architecture for the new TypeScript CLI located in `cli-ts/`. The scope is intentionally minimal: entry point, commander setup, HostProvider initialization, and a `cline --version` command. This scaffold will serve as the foundation for all future CLI functionality, designed with testability, modularity, and debuggability as primary concerns.
|
||||
|
||||
The key architectural decision is to use the Controller directly (in-process) rather than communicating over gRPC like the Go CLI. This eliminates the need for the protobus service and allows direct reuse of types from `src/shared/` and `src/core/`.
|
||||
|
||||
[Types]
|
||||
Define TypeScript interfaces for CLI configuration and host provider abstraction.
|
||||
|
||||
```typescript
|
||||
// cli-ts/src/types/config.ts
|
||||
export interface CliConfig {
|
||||
verbose: boolean
|
||||
configDir: string // Directory for Cline data storage (default: ~/.cline)
|
||||
}
|
||||
|
||||
// cli-ts/src/types/logger.ts
|
||||
export interface Logger {
|
||||
debug(message: string, ...args: unknown[]): void
|
||||
info(message: string, ...args: unknown[]): void
|
||||
warn(message: string, ...args: unknown[]): void
|
||||
error(message: string, ...args: unknown[]): void
|
||||
}
|
||||
```
|
||||
|
||||
[Files]
|
||||
Create the foundational file structure for the TypeScript CLI.
|
||||
|
||||
**New files to be created:**
|
||||
|
||||
1. `cli-ts/package.json` - Package configuration with commander dependency
|
||||
2. `cli-ts/tsconfig.json` - TypeScript configuration extending root tsconfig
|
||||
3. `cli-ts/src/index.ts` - Main entry point, commander setup
|
||||
4. `cli-ts/src/commands/version.ts` - Version command implementation
|
||||
5. `cli-ts/src/core/config.ts` - CLI configuration management
|
||||
6. `cli-ts/src/core/logger.ts` - Logging utility with verbose mode support
|
||||
7. `cli-ts/src/core/host-provider-setup.ts` - HostProvider initialization (adapted from src/standalone/cline-core.ts)
|
||||
8. `cli-ts/src/core/context.ts` - VSCode-like context for standalone mode
|
||||
9. `cli-ts/src/types/config.ts` - Type definitions for CLI configuration
|
||||
10. `cli-ts/src/types/logger.ts` - Type definitions for logger interface
|
||||
11. `cli-ts/tests/unit/commands/version.test.ts` - Unit test for version command
|
||||
12. `cli-ts/tests/unit/core/logger.test.ts` - Unit test for logger
|
||||
13. `cli-ts/tests/unit/core/config.test.ts` - Unit test for config
|
||||
14. `cli-ts/tests/setup.ts` - Test setup file for mocha
|
||||
15. `cli-ts/.mocharc.json` - Mocha configuration for tests
|
||||
|
||||
**Existing files to reference (read-only):**
|
||||
|
||||
- `src/standalone/cline-core.ts` - Reference for HostProvider setup pattern
|
||||
- `src/standalone/vscode-context.ts` - Reference for context initialization
|
||||
- `src/hosts/host-provider.ts` - HostProvider interface
|
||||
- `src/registry.ts` - ExtensionRegistryInfo for version
|
||||
|
||||
[Functions]
|
||||
Define the core functions for CLI initialization and command execution.
|
||||
|
||||
**New functions:**
|
||||
|
||||
1. `cli-ts/src/index.ts`:
|
||||
- `main(): Promise<void>` - Entry point, initializes commander and parses args
|
||||
- `createProgram(): Command` - Creates and configures the commander program
|
||||
|
||||
2. `cli-ts/src/commands/version.ts`:
|
||||
- `createVersionCommand(): Command` - Creates the version subcommand
|
||||
- `runVersionCommand(config: CliConfig): void` - Executes version display
|
||||
|
||||
3. `cli-ts/src/core/config.ts`:
|
||||
- `createConfig(options: Partial<CliConfig>): CliConfig` - Creates CLI config with defaults
|
||||
- `getDefaultConfigDir(): string` - Returns ~/.cline path
|
||||
|
||||
4. `cli-ts/src/core/logger.ts`:
|
||||
- `createLogger(verbose: boolean): Logger` - Factory for logger instance
|
||||
- `ConsoleLogger` class implementing `Logger` interface
|
||||
|
||||
5. `cli-ts/src/core/host-provider-setup.ts`:
|
||||
- `setupHostProvider(config: CliConfig, logger: Logger): Promise<void>` - Initializes HostProvider
|
||||
- `createCliWebviewProvider(context: ExtensionContext): WebviewProvider` - Stub webview provider
|
||||
- `createCliDiffViewProvider(): DiffViewProvider` - Stub diff view provider
|
||||
- `createCliTerminalManager(): StandaloneTerminalManager` - Terminal manager instance
|
||||
|
||||
6. `cli-ts/src/core/context.ts`:
|
||||
- `initializeContext(configDir?: string): { extensionContext, DATA_DIR, EXTENSION_DIR }` - Creates VSCode-like context
|
||||
|
||||
[Classes]
|
||||
Define classes for structured components.
|
||||
|
||||
**New classes:**
|
||||
|
||||
1. `cli-ts/src/core/logger.ts`:
|
||||
- `ConsoleLogger implements Logger` - Console-based logger with verbose mode
|
||||
- Constructor: `(verbose: boolean)`
|
||||
- Methods: `debug()`, `info()`, `warn()`, `error()`
|
||||
- Private: `shouldLog(level: string): boolean`
|
||||
|
||||
2. `cli-ts/src/core/host-provider-setup.ts`:
|
||||
- `CliWebviewProvider implements WebviewProvider` - Minimal stub for CLI mode
|
||||
- Purpose: Satisfies HostProvider interface without VSCode webview
|
||||
|
||||
- `CliDiffViewProvider implements DiffViewProvider` - Minimal stub for CLI mode
|
||||
- Purpose: Satisfies HostProvider interface without VSCode diff view
|
||||
|
||||
[Dependencies]
|
||||
Add required npm packages for the CLI.
|
||||
|
||||
**New dependencies for cli-ts/package.json:**
|
||||
|
||||
- `commander` (^12.x) - CLI argument parsing
|
||||
- `chalk` (^5.x) - Terminal styling (for future use)
|
||||
|
||||
**Dev dependencies:**
|
||||
|
||||
- `@types/node` (^20.x) - Node.js types
|
||||
- `mocha` (^10.x) - Test framework
|
||||
- `chai` (^4.x) - Assertion library
|
||||
- `sinon` (^17.x) - Mocking library
|
||||
- `@types/mocha` - Mocha types
|
||||
- `@types/chai` - Chai types
|
||||
- `@types/sinon` - Sinon types
|
||||
- `tsx` (^4.x) - TypeScript execution for development
|
||||
- `typescript` (^5.x) - TypeScript compiler
|
||||
|
||||
**Shared dependencies (from root package.json, accessed via path aliases):**
|
||||
|
||||
- Types from `@shared/*` - ExtensionMessage types, etc.
|
||||
- HostProvider from `@/hosts/host-provider`
|
||||
- Registry from `@/registry` - Version info
|
||||
|
||||
[Testing]
|
||||
Establish testing infrastructure with unit tests for core components.
|
||||
|
||||
**Test framework:** Mocha + Chai + Sinon (consistent with root project)
|
||||
|
||||
**Test files:**
|
||||
|
||||
1. `cli-ts/tests/unit/commands/version.test.ts`:
|
||||
- Test that version command outputs correct version from ExtensionRegistryInfo
|
||||
- Test verbose flag behavior
|
||||
- Test JSON output format (future-proofing)
|
||||
|
||||
2. `cli-ts/tests/unit/core/logger.test.ts`:
|
||||
- Test debug messages only shown when verbose=true
|
||||
- Test info/warn/error always shown
|
||||
- Test message formatting
|
||||
|
||||
3. `cli-ts/tests/unit/core/config.test.ts`:
|
||||
- Test default config values
|
||||
- Test config override merging
|
||||
- Test getDefaultConfigDir() returns correct path
|
||||
|
||||
**Test commands (to add to cli-ts/package.json):**
|
||||
|
||||
```json
|
||||
{
|
||||
"scripts": {
|
||||
"test": "mocha",
|
||||
"test:watch": "mocha --watch",
|
||||
"test:coverage": "c8 mocha"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
[Implementation Order]
|
||||
Execute implementation in dependency order to maintain buildable state at each step.
|
||||
|
||||
1. **Create package.json and tsconfig.json** - Establish project structure and dependencies
|
||||
2. **Create type definitions** (types/config.ts, types/logger.ts) - Define interfaces first
|
||||
3. **Create logger module** (core/logger.ts) - Needed by all other modules
|
||||
4. **Create config module** (core/config.ts) - Needed by host-provider-setup
|
||||
5. **Create context module** (core/context.ts) - VSCode context abstraction
|
||||
6. **Create host-provider-setup module** (core/host-provider-setup.ts) - Core initialization
|
||||
7. **Create version command** (commands/version.ts) - First working command
|
||||
8. **Create main entry point** (src/index.ts) - Wire everything together
|
||||
9. **Create test setup** (tests/setup.ts, .mocharc.json) - Test infrastructure
|
||||
10. **Create unit tests** - Verify all components work correctly
|
||||
11. **Manual verification** - Run `npx tsx cli-ts/src/index.ts --version` and confirm output
|
||||
@@ -267,6 +267,10 @@ export class ToolExecutor {
|
||||
* @param block The tool use block that caused the error
|
||||
*/
|
||||
private async handleError(action: string, error: Error, block: ToolUse): Promise<void> {
|
||||
// Skip error handling if task was aborted - this is expected behavior
|
||||
if (this.taskState.abort) {
|
||||
return
|
||||
}
|
||||
console.log(error)
|
||||
const errorString = `Error ${action}: ${error.message}`
|
||||
await this.say("error", errorString)
|
||||
|
||||
@@ -86,13 +86,17 @@ export class MessageStateHandler {
|
||||
// combined as they are in ChatView
|
||||
const apiMetrics = getApiMetrics(combineApiRequests(combineCommandSequences(this.clineMessages.slice(1))))
|
||||
const taskMessage = this.clineMessages[0] // first message is always the task say
|
||||
const lastRelevantMessage =
|
||||
this.clineMessages[
|
||||
findLastIndex(
|
||||
this.clineMessages,
|
||||
(message) => !(message.ask === "resume_task" || message.ask === "resume_completed_task"),
|
||||
)
|
||||
]
|
||||
const lastRelevantMessageIndex = findLastIndex(
|
||||
this.clineMessages,
|
||||
(message) => !(message.ask === "resume_task" || message.ask === "resume_completed_task"),
|
||||
)
|
||||
const lastRelevantMessage = lastRelevantMessageIndex !== -1 ? this.clineMessages[lastRelevantMessageIndex] : undefined
|
||||
|
||||
// Skip history update if we don't have valid messages to extract metadata from
|
||||
if (!taskMessage || !lastRelevantMessage) {
|
||||
return
|
||||
}
|
||||
|
||||
const lastModelInfo = [...this.apiConversationHistory].reverse().find((msg) => msg.modelInfo !== undefined)
|
||||
const taskDir = await ensureTaskDirectoryExists(this.taskId)
|
||||
let taskDirSize = 0
|
||||
|
||||
@@ -8,8 +8,8 @@ import { ExtensionRegistryInfo } from "@/registry"
|
||||
import { log } from "./utils"
|
||||
import { EnvironmentVariableCollection, MementoStore, readJson, SecretStore } from "./vscode-context-utils"
|
||||
|
||||
log("Running standalone cline", ExtensionRegistryInfo.version)
|
||||
log(`CLINE_ENVIRONMENT: ${process.env.CLINE_ENVIRONMENT}`)
|
||||
// log("Running standalone cline", ExtensionRegistryInfo.version)
|
||||
// log(`CLINE_ENVIRONMENT: ${process.env.CLINE_ENVIRONMENT}`)
|
||||
|
||||
// WE WILL HAVE TO MIGRATE THIS FROM DATA TO v1 LATER
|
||||
const SETTINGS_SUBFOLDER = "data"
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
console.log("Loading stub impls...")
|
||||
|
||||
const { createStub } = require("./stub-utils")
|
||||
|
||||
// Import the base vscode object from stubs
|
||||
@@ -154,5 +152,3 @@ vscode.Uri = {
|
||||
return vscode.Uri.file("/" + joined.replace(/\/+/g, "/"))
|
||||
},
|
||||
}
|
||||
|
||||
console.log("Finished loading stub impls...")
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
// GENERATED CODE -- DO NOT EDIT!
|
||||
console.log("Loading stubs...")
|
||||
const { createStub } = require("./stub-utils")
|
||||
vscode = {}
|
||||
vscode.version = createStub("vscode.version")
|
||||
@@ -1329,4 +1328,3 @@ vscode.TelemetryTrustedValue = class {
|
||||
}
|
||||
}
|
||||
module.exports = vscode
|
||||
console.log("Finished loading stubs")
|
||||
|
||||
Reference in New Issue
Block a user