Compare commits

..

36 Commits

Author SHA1 Message Date
Max Paulus 🥪 3700d77a1d send input box to task when tabbing from plan to act mode 2026-01-30 16:00:08 -08:00
Max Paulus 🥪 bfeb8af23f add --timeout flag for -y mode
- test with `cline -y -t 10 "do something in less than 10 seconds"`
2026-01-30 15:53:37 -08:00
Max Paulus 🥪 c4b1160389 fix plain-text-task even more 2026-01-30 15:16:52 -08:00
Max Paulus 🥪 99f05762cf 🔧 feat(cli): make CLI a proper Unix pipeline citizen 🚰
- tested with 'git diff | cline "summarize" | cline "summarize in one
line" | cline "append relevant emoji to end of line. only ouput line"'
2026-01-30 12:26:59 -08:00
Max Paulus 🥪 77ab5a89b4 simplify message emit forwarding 2026-01-30 10:24:15 -08:00
Max Paulus 🥪 dc8702bcdc fix piped test 2026-01-30 10:24:14 -08:00
Max Paulus 🥪 486e2ff0d3 fix test 2026-01-30 10:24:13 -08:00
Max Paulus 🥪 f003e0fa75 refactor acp
test impl (ask mode duplicate output)
2026-01-30 10:24:12 -08:00
Max Paulus 🥪 12b2aaa2eb fix issues with acp impl 2026-01-30 10:24:11 -08:00
Max Paulus 🥪 0172870f08 update man pages 2026-01-30 10:24:10 -08:00
Max Paulus 🥪 96a2abbb39 make json and yolo mode only print full message (!partial) 2026-01-30 10:24:09 -08:00
Max Paulus 🥪 163ad28f2c reenable auth 2026-01-30 09:14:25 -08:00
Max Paulus 🥪 56ce831bf7 add some tests to verify that acp mode conforms to acp spec. (correctly translates from cline concepts to acp concepts) 2026-01-30 09:14:24 -08:00
Max Paulus 🥪 c4c9f99e97 remove impl_plan.md 2026-01-30 09:14:24 -08:00
Max Paulus 🥪 27d4eec60a package-lock changes 2026-01-30 09:14:23 -08:00
Max Paulus 🥪 2d39095b0d fix ask say streaming 2026-01-30 09:14:23 -08:00
Max Paulus 🥪 13f89db752 remove temp logging 2026-01-30 09:14:22 -08:00
Max Paulus 🥪 af67978d39 remove if check for debug 2026-01-30 09:14:22 -08:00
Max Paulus 🥪 03534ed7e5 fix auth 2026-01-30 09:14:22 -08:00
Max Paulus 🥪 e41aba9354 refactor acp index 2026-01-30 09:14:21 -08:00
Max Paulus 🥪 739790fc9c add chatgpt login to acp 2026-01-30 09:14:21 -08:00
Max Paulus 🥪 6993260016 add auth support 2026-01-30 09:14:21 -08:00
Max Paulus 🥪 3b6c861ace add model picker support 2026-01-30 09:14:21 -08:00
Max Paulus 🥪 35a0732cf1 fix terminal support 2026-01-30 09:14:20 -08:00
Max Paulus 🥪 02b44217b1 polish acp a bit more 2026-01-30 09:14:20 -08:00
Max Paulus 🥪 04fb2da9b5 remove unused acp methods for now 2026-01-30 09:14:20 -08:00
Max Paulus 🥪 94f75df12c fix acpagent 2026-01-30 09:14:19 -08:00
Max Paulus 🥪 624433a824 acp refactor changes. partially working 2026-01-30 09:14:19 -08:00
Max Paulus 🥪 0a267bd078 fix nodeToWebStream 2026-01-30 09:14:19 -08:00
Max Paulus 🥪 e9c5882179 phase 8 2026-01-30 09:14:18 -08:00
Max Paulus 🥪 537ca97cf1 phase 7 2026-01-30 09:14:18 -08:00
Max Paulus 🥪 581c2a0282 phase 6 2026-01-30 09:14:17 -08:00
Max Paulus 🥪 f9d8262e31 phase 5 2026-01-30 09:14:17 -08:00
Max Paulus 🥪 c8927f1971 acp flag for cli 2026-01-30 09:14:15 -08:00
abeatrix dde21a0e8c fix search files issue caused by rg binary location 2026-01-31 00:19:32 +08:00
abeatrix 07d630b614 fix missing call id 2026-01-31 00:09:10 +08:00
29 changed files with 8024 additions and 468 deletions
+3 -1
View File
@@ -169,7 +169,9 @@
"!**/*.js",
"!**/scripts/**",
"!**/*.tsx",
"!**/testing-platform/**"
"!**/testing-platform/**",
// ACP mode must redirect console to stderr - this is intentional
"!cli-ts/src/acp/index.ts"
]
},
{
+13 -1
View File
@@ -66,6 +66,18 @@ const aliasResolverPlugin: esbuild.Plugin = {
}
}
// Handle .js -> .ts extension mapping (common in ESM TypeScript projects)
if (importPath.endsWith(".js")) {
const tsPath = importPath.replace(/\.js$/, ".ts")
if (fs.existsSync(tsPath)) {
return { path: tsPath }
}
const tsxPath = importPath.replace(/\.js$/, ".tsx")
if (fs.existsSync(tsxPath)) {
return { path: tsxPath }
}
}
// If nothing worked, return the original path and let esbuild handle the error
return { path: importPath }
})
@@ -255,6 +267,6 @@ async function main() {
}
main().catch((e) => {
console.log(e)
console.error(e)
process.exit(1)
})
+384
View File
@@ -0,0 +1,384 @@
.\" Automatically generated by Pandoc 3.8.3
.\"
.TH "CLINE" "1" "January 2026" "Cline CLI 2.0" "User Commands"
.SH NAME
cline \- AI coding assistant in your terminal
.SH SYNOPSIS
\f[B]cline\f[R] [\f[I]prompt\f[R]] [\f[I]options\f[R]]
.PP
\f[B]cline\f[R] \f[I]command\f[R] [\f[I]options\f[R]]
[\f[I]arguments\f[R]]
.SH DESCRIPTION
\f[B]cline\f[R] is a command\-line interface for the Cline AI coding
assistant.
It provides the same powerful AI capabilities as the VS Code extension,
directly in your terminal.
.PP
Cline is an autonomous AI agent that can read, write, and execute code
across your projects.
He can create and edit files, run terminal commands, use a headless
browser, and more\(emall while asking for your approval before taking
actions.
.PP
The CLI supports both interactive mode (with a rich terminal UI) and
plain text mode (for piped input and scripted workflows).
.SH MODES OF OPERATION
\f[B]Interactive Mode\f[R] : When you run \f[B]cline\f[R] without
arguments, it launches an interactive welcome prompt with a rich
terminal UI.
You can type your task, view conversation history, and interact with
Cline in real\-time.
.PP
\f[B]Task Mode\f[R] : Run \f[B]cline \(lqprompt\(rq\f[R] or \f[B]cline
task \(lqprompt\(rq\f[R] to immediately start a task.
If stdin is a TTY, you\(cqll see the interactive UI.
If stdin is piped or output is redirected, the CLI automatically
switches to plain text mode.
.PP
\f[B]Plain Text Mode\f[R] : Activated automatically when stdin is piped,
output is redirected, or \f[B]\-\-json\f[R]/\f[B]\-\-yolo\f[R] flags are
used.
Outputs clean text without the Ink UI, suitable for scripting and CI/CD
pipelines.
.SH AGENT BEHAVIOR
Cline operates in two primary modes:
.PP
\f[B]ACT MODE\f[R] : Cline actively uses tools to accomplish tasks.
He can read files, write code, execute commands, use a headless browser,
and more.
This is the default mode for task execution.
.PP
\f[B]PLAN MODE\f[R] : Cline gathers information and creates a detailed
plan before implementation.
He explores the codebase, asks clarifying questions, and presents a
strategy for user approval before switching to ACT MODE.
.SH COMMANDS
.SS task (alias: t)
Run a new task with a prompt.
.PP
\f[B]cline task\f[R] \f[I]prompt\f[R] [\f[I]options\f[R]]
.PP
\f[B]cline t\f[R] \f[I]prompt\f[R] [\f[I]options\f[R]] : Create and run
a new task.
Options:
.PP
\f[B]\-a\f[R], \f[B]\-\-act\f[R] : Run in act mode (default)
.PP
\f[B]\-p\f[R], \f[B]\-\-plan\f[R] : Run in plan mode
.PP
\f[B]\-y\f[R], \f[B]\-\-yolo\f[R] : Enable yolo/yes mode (auto\-approve
all actions, output in plain mode, exit process automatically when task
complete)
.PP
\f[B]\-m\f[R], \f[B]\-\-model\f[R] \f[I]model\f[R] : Model to use for
the task
.PP
\f[B]\-i\f[R], \f[B]\-\-images\f[R] \f[I]paths\&...\f[R] : Image file
paths to include with the task
.PP
\f[B]\-v\f[R], \f[B]\-\-verbose\f[R] : Show verbose output including
reasoning
.PP
\f[B]\-c\f[R], \f[B]\-\-cwd\f[R] \f[I]path\f[R] : Working directory for
the task
.PP
\f[B]\-\-config\f[R] \f[I]path\f[R] : Path to Cline configuration
directory
.PP
\f[B]\-\-thinking\f[R] : Enable extended thinking (1024 token budget)
.PP
\f[B]\-\-json\f[R] : Output messages as JSON instead of styled text
.SS history (alias: h)
List task history with pagination.
.PP
\f[B]cline history\f[R] [\f[I]options\f[R]]
.PP
\f[B]cline h\f[R] [\f[I]options\f[R]] : Display previous tasks.
Options:
.PP
\f[B]\-n\f[R], \f[B]\-\-limit\f[R] \f[I]number\f[R] : Number of tasks to
show (default: 10)
.PP
\f[B]\-p\f[R], \f[B]\-\-page\f[R] \f[I]number\f[R] : Page number,
1\-based (default: 1)
.PP
\f[B]\-\-config\f[R] \f[I]path\f[R] : Path to Cline configuration
directory
.SS config
Show current configuration.
.PP
\f[B]cline config\f[R] [\f[I]options\f[R]] : Display global and
workspace state.
Options:
.PP
\f[B]\-\-config\f[R] \f[I]path\f[R] : Path to Cline configuration
directory
.SS auth
Authenticate a provider and configure the model.
.PP
\f[B]cline auth\f[R] [\f[I]options\f[R]] : Launch interactive
authentication wizard, or use quick setup flags.
Options:
.PP
\f[B]\-p\f[R], \f[B]\-\-provider\f[R] \f[I]id\f[R] : Provider ID for
quick setup (e.g., openai\-native, anthropic, openrouter)
.PP
\f[B]\-k\f[R], \f[B]\-\-apikey\f[R] \f[I]key\f[R] : API key for the
provider
.PP
\f[B]\-m\f[R], \f[B]\-\-modelid\f[R] \f[I]id\f[R] : Model ID to
configure (e.g., gpt\-4o, claude\-sonnet\-4\-5\-20250929)
.PP
\f[B]\-b\f[R], \f[B]\-\-baseurl\f[R] \f[I]url\f[R] : Base URL (optional,
for OpenAI\-compatible providers)
.PP
\f[B]\-v\f[R], \f[B]\-\-verbose\f[R] : Show verbose output
.PP
\f[B]\-c\f[R], \f[B]\-\-cwd\f[R] \f[I]path\f[R] : Working directory
.PP
\f[B]\-\-config\f[R] \f[I]path\f[R] : Path to Cline configuration
directory
.SS update
Check for updates and install if available.
.PP
\f[B]cline update\f[R] [\f[I]options\f[R]] : Check npm for newer
versions.
Options:
.PP
\f[B]\-v\f[R], \f[B]\-\-verbose\f[R] : Show verbose output
.SS version
Show the CLI version number.
.PP
\f[B]cline version\f[R]
.SS dev
Developer tools and utilities.
.PP
\f[B]cline dev log\f[R] : Open the log file for debugging.
.SH DEFAULT COMMAND OPTIONS
When running \f[B]cline\f[R] with just a prompt (no subcommand), these
options are available:
.PP
\f[B]\-a\f[R], \f[B]\-\-act\f[R] : Run in act mode (default)
.PP
\f[B]\-p\f[R], \f[B]\-\-plan\f[R] : Run in plan mode
.PP
\f[B]\-y\f[R], \f[B]\-\-yolo\f[R] : Enable yolo mode (auto\-approve all
actions).
Also forces plain text output mode.
.PP
\f[B]\-m\f[R], \f[B]\-\-model\f[R] \f[I]model\f[R] : Model to use for
the task
.PP
\f[B]\-v\f[R], \f[B]\-\-verbose\f[R] : Show verbose output
.PP
\f[B]\-c\f[R], \f[B]\-\-cwd\f[R] \f[I]path\f[R] : Working directory
.PP
\f[B]\-\-config\f[R] \f[I]path\f[R] : Configuration directory
.PP
\f[B]\-\-thinking\f[R] : Enable extended thinking (1024 token budget)
.PP
\f[B]\-\-json\f[R] : Output messages as JSON instead of styled text.
Forces plain text mode.
.SH JSON OUTPUT FORMAT
When using \f[B]\-\-json\f[R], each message is output as a JSON object
with these fields:
.PP
\f[B]Required fields:\f[R]
.IP \(bu 2
\f[B]type\f[R]: \(lqask\(rq or \(lqsay\(rq
.IP \(bu 2
\f[B]text\f[R]: message text
.IP \(bu 2
\f[B]ts\f[R]: Unix epoch timestamp in milliseconds
.PP
\f[B]Optional fields:\f[R]
.IP \(bu 2
\f[B]reasoning\f[R]: reasoning text
.IP \(bu 2
\f[B]say\f[R]: say subtype (when type is \(lqsay\(rq)
.IP \(bu 2
\f[B]ask\f[R]: ask subtype (when type is \(lqask\(rq)
.IP \(bu 2
\f[B]partial\f[R]: streaming flag
.IP \(bu 2
\f[B]images\f[R]: list of image URIs
.IP \(bu 2
\f[B]files\f[R]: list of file paths
.SH EXAMPLES
.SS Basic Usage
.IP
.EX
\f[I]# Launch interactive mode\f[R]
cline
\f[I]# Run a task directly\f[R]
cline \(dqCreate a hello world function in Python\(dq
\f[I]# Run with verbose output and extended thinking\f[R]
cline \-v \-\-thinking \(dqAnalyze this codebase architecture\(dq
.EE
.SS Mode Selection
.IP
.EX
\f[I]# Run in plan mode (gather info before acting)\f[R]
cline \-p \(dqDesign a REST API for user management\(dq
\f[I]# Run in act mode with auto\-approval (yolo)\f[R]
cline \-y \(dqFix the typo in README.md\(dq
.EE
.SS Using Specific Models
.IP
.EX
\f[I]# Use a specific model\f[R]
cline \-m claude\-sonnet\-4\-5\-20250929 \(dqRefactor this function\(dq
\f[I]# Quick auth setup with model\f[R]
cline auth \-p anthropic \-k sk\-ant\-xxxxx \-m claude\-sonnet\-4\-5\-20250929
.EE
.SS Including Images
.IP
.EX
\f[I]# Include images with explicit flag\f[R]
cline task \-i screenshot.png diagram.jpg \(dqFix the UI based on these images\(dq
\f[I]# Or use inline image references in the prompt\f[R]
cline \(dqFix the layout shown in \(at./screenshot.png\(dq
.EE
.SS Piped Input
.IP
.EX
\f[I]# Pipe file contents to Cline\f[R]
cat README.md \f[B]|\f[R] cline \(dqSummarize this document\(dq
\f[I]# Pipe with additional prompt\f[R]
echo \(dqfunction add(a, b) { return a + b }\(dq \f[B]|\f[R] cline \(dqAdd TypeScript types to this\(dq
\f[I]# Combine piped input with a prompt\f[R]
git diff \f[B]|\f[R] cline \(dqReview these changes and suggest improvements\(dq
.EE
.SS Scripting and Automation
.IP
.EX
\f[I]# JSON output for parsing\f[R]
cline \-\-json \(dqWhat files are in this directory?\(dq \f[B]|\f[R] jq \(aq.text\(aq
\f[I]# Yolo mode for automated workflows (auto\-approves all actions), forces plain text output\f[R]
cline \-y \(dqRun the test suite and fix any failures\(dq
.EE
.SS Task History
.IP
.EX
\f[I]# List recent tasks\f[R]
cline history
\f[I]# Show more tasks with pagination\f[R]
cline history \-n 20 \-p 2
.EE
.SS Authentication
.IP
.EX
\f[I]# Interactive authentication wizard\f[R]
cline auth
\f[I]# Quick setup for Anthropic\f[R]
cline auth \-p anthropic \-k sk\-ant\-api\-xxxxx
\f[I]# Quick setup for OpenAI\f[R]
cline auth \-p openai\-native \-k sk\-xxxxx \-m gpt\-4o
\f[I]# OpenAI\-compatible provider with custom base URL\f[R]
cline auth \-p openai \-k your\-api\-key \-b https://api.example.com/v1
.EE
.SH ENVIRONMENT
\f[B]CLINE_DIR\f[R] : Override the default configuration directory.
When set, Cline stores all data in this directory instead of
\f[CR]\(ti/.cline/data/\f[R].
.PP
\f[B]CLINE_COMMAND_PERMISSIONS\f[R] : JSON configuration for restricting
which shell commands Cline can execute.
When set, commands are validated against allow/deny patternks before
execution.
When not set, all commands are allowed.
.PP
Format:
\f[CR]{\(dqallow\(dq: [\(dqpattern1\(dq, \(dqpattern2\(dq], \(dqdeny\(dq: [\(dqpattern3\(dq], \(dqallowRedirects\(dq: true}\f[R]
.PP
\f[B]Fields:\f[R]
.IP \(bu 2
\f[B]allow\f[R] (array of strings): Glob patterns for allowed commands.
If specified, only matching commands are permitted.
Uses \f[CR]*\f[R] to match any characters and \f[CR]?\f[R] to match a
single character.
Setting allow on anything will deny all others.
.IP \(bu 2
\f[B]deny\f[R] (array of strings): Glob patterns for denied commands.
Deny rules take precedence over allow rules.
.IP \(bu 2
\f[B]allowRedirects\f[R] (boolean): Whether to allow shell redirects
(\f[CR]>\f[R], \f[CR]>>\f[R], \f[CR]<\f[R], etc.).
Defaults to false.
.PP
\f[B]Rule evaluation:\f[R]
.IP "1." 3
Check for dangerous characters (backticks outside single quotes,
unquoted newlines)
.IP "2." 3
Parse command into segments split by operators (\f[CR]&&\f[R],
\f[CR]||\f[R], \f[CR]|\f[R], \f[CR];\f[R])
.IP "3." 3
If redirects detected and \f[CR]allowRedirects\f[R] is not true, command
is denied
.IP "4." 3
Each segment is validated against deny rules first, then allow rules
.IP "5." 3
Subshell contents (\f[CR]$(...)\f[R] and \f[CR](...)\f[R]) are
recursively validated
.IP "6." 3
All segments must pass for the command to be allowed
.PP
\f[B]Examples:\f[R]
.IP
.EX
\f[I]# Allow only npm and git commands.\f[R]
export CLINE_COMMAND_PERMISSIONS=\(aq{\(dqallow\(dq: [\(dqnpm *\(dq, \(dqgit *\(dq]}\(aq
\f[I]# Allow development commands but deny dangerous ones. Deny not strictly required here since allow is set.\f[R]
export CLINE_COMMAND_PERMISSIONS=\(aq{\(dqallow\(dq: [\(dqnpm *\(dq, \(dqgit *\(dq, \(dqnode *\(dq], \(dqdeny\(dq: [\(dqrm \-rf *\(dq, \(dqsudo *\(dq]}\(aq
\f[I]# Allow file operations with redirects\f[R]
export CLINE_COMMAND_PERMISSIONS=\(aq{\(dqallow\(dq: [\(dqcat *\(dq, \(dqecho *\(dq], \(dqallowRedirects\(dq: true}\(aq
.EE
.SH FILES
\f[B]\(ti/.cline/data/\f[R] : Default configuration directory
containing:
.PP
\f[B]globalState.json\f[R] : Global settings and state
.PP
\f[B]secrets.json\f[R] : API keys and secrets (stored securely)
.PP
\f[B]workspace/\f[R] : Workspace\-specific state
.PP
\f[B]tasks/\f[R] : Task history and conversation data
.PP
\f[B]\(ti/.cline/log/\f[R] : Log files for debugging.
View with \f[CR]cline dev log\f[R].
.SH BUGS
Report bugs at: \c
.UR https://github.com/cline/cline/issues
.UE \c
.PP
For real\-time help, join the Discord community at: \c
.UR https://discord.gg/cline
.UE \c
.SH SEE ALSO
Full documentation: \c
.UR https://docs.cline.bot
.UE \c
.PP
VS Code extension: \c
.UR https://marketplace.visualstudio.com/items?itemName=saoudrizwan.claude-dev
.UE \c
.SH AUTHORS
Cline is developed by Cline Bot Inc.\ and the open source community.
.SH COPYRIGHT
Copyright © 2025 Cline Bot Inc.\ Licensed under the Apache License 2.0.
+340
View File
@@ -0,0 +1,340 @@
---
title: CLINE
section: 1
header: User Commands
footer: Cline CLI 2.0
date: January 2026
---
# NAME
cline - AI coding assistant in your terminal
# SYNOPSIS
**cline** [*prompt*] [*options*]
**cline** *command* [*options*] [*arguments*]
# DESCRIPTION
**cline** is a command-line interface for the Cline AI coding assistant. It provides the same powerful AI capabilities as the VS Code extension, directly in your terminal.
Cline is an autonomous AI agent that can read, write, and execute code across your projects. He can create and edit files, run terminal commands, use a headless browser, and more—all while asking for your approval before taking actions.
The CLI supports both interactive mode (with a rich terminal UI) and plain text mode (for piped input and scripted workflows).
# MODES OF OPERATION
**Interactive Mode** : When you run **cline** without arguments, it launches an interactive welcome prompt with a rich terminal UI. You can type your task, view conversation history, and interact with Cline in real-time.
**Task Mode** : Run **cline "prompt"** or **cline task "prompt"** to immediately start a task. If stdin is a TTY, you'll see the interactive UI. If stdin is piped or output is redirected, the CLI automatically switches to plain text mode.
**Plain Text Mode** : Activated automatically when stdin is piped, output is redirected, or **\--json**/**\--yolo** flags are used. Outputs clean text without the Ink UI, suitable for scripting and CI/CD pipelines.
# AGENT BEHAVIOR
Cline operates in two primary modes:
**ACT MODE** : Cline actively uses tools to accomplish tasks. He can read files, write code, execute commands, use a headless browser, and more. This is the default mode for task execution.
**PLAN MODE** : Cline gathers information and creates a detailed plan before implementation. He explores the codebase, asks clarifying questions, and presents a strategy for user approval before switching to ACT MODE.
# COMMANDS
## task (alias: t)
Run a new task with a prompt.
**cline task** *prompt* [*options*]
**cline t** *prompt* [*options*] : Create and run a new task. Options:
**-a**, **\--act** : Run in act mode (default)
**-p**, **\--plan** : Run in plan mode
**-y**, **\--yolo** : Enable yolo/yes mode (auto-approve all actions, output in plain mode, exit process automatically when task complete)
**-m**, **\--model** *model* : Model to use for the task
**-i**, **\--images** *paths...* : Image file paths to include with the task
**-v**, **\--verbose** : Show verbose output including reasoning
**-c**, **\--cwd** *path* : Working directory for the task
**\--config** *path* : Path to Cline configuration directory
**\--thinking** : Enable extended thinking (1024 token budget)
**\--json** : Output messages as JSON instead of styled text
## history (alias: h)
List task history with pagination.
**cline history** [*options*]
**cline h** [*options*] : Display previous tasks. Options:
**-n**, **\--limit** *number* : Number of tasks to show (default: 10)
**-p**, **\--page** *number* : Page number, 1-based (default: 1)
**\--config** *path* : Path to Cline configuration directory
## config
Show current configuration.
**cline config** [*options*] : Display global and workspace state. Options:
**\--config** *path* : Path to Cline configuration directory
## auth
Authenticate a provider and configure the model.
**cline auth** [*options*] : Launch interactive authentication wizard, or use quick setup flags. Options:
**-p**, **\--provider** *id* : Provider ID for quick setup (e.g., openai-native, anthropic, openrouter)
**-k**, **\--apikey** *key* : API key for the provider
**-m**, **\--modelid** *id* : Model ID to configure (e.g., gpt-4o, claude-sonnet-4-5-20250929)
**-b**, **\--baseurl** *url* : Base URL (optional, for OpenAI-compatible providers)
**-v**, **\--verbose** : Show verbose output
**-c**, **\--cwd** *path* : Working directory
**\--config** *path* : Path to Cline configuration directory
## update
Check for updates and install if available.
**cline update** [*options*] : Check npm for newer versions. Options:
**-v**, **\--verbose** : Show verbose output
## version
Show the CLI version number.
**cline version**
## dev
Developer tools and utilities.
**cline dev log** : Open the log file for debugging.
# DEFAULT COMMAND OPTIONS
When running **cline** with just a prompt (no subcommand), these options are available:
**-a**, **\--act** : Run in act mode (default)
**-p**, **\--plan** : Run in plan mode
**-y**, **\--yolo** : Enable yolo mode (auto-approve all actions). Also forces plain text output mode.
**-m**, **\--model** *model* : Model to use for the task
**-v**, **\--verbose** : Show verbose output
**-c**, **\--cwd** *path* : Working directory
**\--config** *path* : Configuration directory
**\--thinking** : Enable extended thinking (1024 token budget)
**\--json** : Output messages as JSON instead of styled text. Forces plain text mode.
# JSON OUTPUT FORMAT
When using **\--json**, each message is output as a JSON object with these fields:
**Required fields:**
- **type**: "ask" or "say"
- **text**: message text
- **ts**: Unix epoch timestamp in milliseconds
**Optional fields:**
- **reasoning**: reasoning text
- **say**: say subtype (when type is "say")
- **ask**: ask subtype (when type is "ask")
- **partial**: streaming flag
- **images**: list of image URIs
- **files**: list of file paths
# EXAMPLES
## Basic Usage
```bash
# Launch interactive mode
cline
# Run a task directly
cline "Create a hello world function in Python"
# Run with verbose output and extended thinking
cline -v --thinking "Analyze this codebase architecture"
```
## Mode Selection
```bash
# Run in plan mode (gather info before acting)
cline -p "Design a REST API for user management"
# Run in act mode with auto-approval (yolo)
cline -y "Fix the typo in README.md"
```
## Using Specific Models
```bash
# Use a specific model
cline -m claude-sonnet-4-5-20250929 "Refactor this function"
# Quick auth setup with model
cline auth -p anthropic -k sk-ant-xxxxx -m claude-sonnet-4-5-20250929
```
## Including Images
```bash
# Include images with explicit flag
cline task -i screenshot.png diagram.jpg "Fix the UI based on these images"
# Or use inline image references in the prompt
cline "Fix the layout shown in @./screenshot.png"
```
## Piped Input
```bash
# Pipe file contents to Cline
cat README.md | cline "Summarize this document"
# Pipe with additional prompt
echo "function add(a, b) { return a + b }" | cline "Add TypeScript types to this"
# Combine piped input with a prompt
git diff | cline "Review these changes and suggest improvements"
```
## Scripting and Automation
```bash
# JSON output for parsing
cline --json "What files are in this directory?" | jq '.text'
# Yolo mode for automated workflows (auto-approves all actions), forces plain text output
cline -y "Run the test suite and fix any failures"
```
## Task History
```bash
# List recent tasks
cline history
# Show more tasks with pagination
cline history -n 20 -p 2
```
## Authentication
```bash
# Interactive authentication wizard
cline auth
# Quick setup for Anthropic
cline auth -p anthropic -k sk-ant-api-xxxxx
# Quick setup for OpenAI
cline auth -p openai-native -k sk-xxxxx -m gpt-4o
# OpenAI-compatible provider with custom base URL
cline auth -p openai -k your-api-key -b https://api.example.com/v1
```
# ENVIRONMENT
**CLINE_DIR** : Override the default configuration directory. When set, Cline stores all data in this directory instead of `~/.cline/data/`.
**CLINE_COMMAND_PERMISSIONS** : JSON configuration for restricting which shell commands Cline can execute. When set, commands are validated against allow/deny patternks before execution. When not set, all commands are allowed.
Format: `{"allow": ["pattern1", "pattern2"], "deny": ["pattern3"], "allowRedirects": true}`
**Fields:**
- **allow** (array of strings): Glob patterns for allowed commands. If specified, only matching commands are permitted. Uses `*` to match any characters and `?` to match a single character. Setting allow on anything will deny all others.
- **deny** (array of strings): Glob patterns for denied commands. Deny rules take precedence over allow rules.
- **allowRedirects** (boolean): Whether to allow shell redirects (`>`, `>>`, `<`, etc.). Defaults to false.
**Rule evaluation:**
1. Check for dangerous characters (backticks outside single quotes, unquoted newlines)
2. Parse command into segments split by operators (`&&`, `||`, `|`, `;`)
3. If redirects detected and `allowRedirects` is not true, command is denied
4. Each segment is validated against deny rules first, then allow rules
5. Subshell contents (`$(...)` and `(...)`) are recursively validated
6. All segments must pass for the command to be allowed
**Examples:**
```bash
# Allow only npm and git commands.
export CLINE_COMMAND_PERMISSIONS='{"allow": ["npm *", "git *"]}'
# Allow development commands but deny dangerous ones. Deny not strictly required here since allow is set.
export CLINE_COMMAND_PERMISSIONS='{"allow": ["npm *", "git *", "node *"], "deny": ["rm -rf *", "sudo *"]}'
# Allow file operations with redirects
export CLINE_COMMAND_PERMISSIONS='{"allow": ["cat *", "echo *"], "allowRedirects": true}'
```
# CONFIGURATION FILES
```
~/.cline/
├── data/ # Default configuration directory
│ ├── globalState.json # Global settings and state
│ ├── secrets.json # API keys and secrets (stored securely)
│ ├── workspace/ # Workspace-specific state
│ └── tasks/ # Task history and conversation data
└── log/ # Log files for debugging
```
View logs with `cline dev log`.
# BUGS
Report bugs at: <https://github.com/cline/cline/issues>
For real-time help, join the Discord community at: <https://discord.gg/cline>
# SEE ALSO
Full documentation: <https://docs.cline.bot>
VS Code extension: <https://marketplace.visualstudio.com/items?itemName=saoudrizwan.claude-dev>
# AUTHORS
Cline is developed by Cline Bot Inc. and the open source community.
# COPYRIGHT
Copyright © 2025 Cline Bot Inc. Licensed under the Apache License 2.0.
+1
View File
@@ -64,6 +64,7 @@
"vitest": "^4.0.17"
},
"dependencies": {
"@agentclientprotocol/sdk": "^0.13.1",
"aws4fetch": "^1.0.20",
"chalk": "^5.3.0",
"commander": "^12.1.0",
+248
View File
@@ -0,0 +1,248 @@
/**
* ACP-based implementation of DiffViewProvider that uses the ACP client's
* filesystem capabilities for reading and writing files.
*
* This provider attempts to use the ACP client's fs/read_text_file and
* fs/write_text_file methods when available, falling back to the
* FileEditProvider's local filesystem implementation otherwise.
*
* @module acp
*/
import type * as acp from "@agentclientprotocol/sdk"
import { workspaceResolver } from "@core/workspace"
import { createDirectoriesForFile } from "@utils/fs"
import { getCwd } from "@utils/path"
import * as fs from "fs/promises"
import * as iconv from "iconv-lite"
import { HostProvider } from "@/hosts/host-provider"
import { FileEditProvider } from "@/integrations/editor/FileEditProvider"
import { detectEncoding } from "@/integrations/misc/extract-text"
import type { FileDiagnostics } from "@/shared/proto/index.cline"
import { Logger } from "@/shared/services/Logger"
/**
* A function that resolves the current session ID.
* This is used by ACPDiffViewProvider to get the session ID at runtime,
* since the provider may be created before a session exists.
*/
export type SessionIdResolver = () => string | undefined
/**
* A DiffViewProvider implementation that uses the ACP client's filesystem
* capabilities when available, with fallback to local filesystem operations.
*
* This class extends FileEditProvider and overrides the file I/O methods to
* use the ACP protocol's fs/read_text_file and fs/write_text_file requests
* when the client supports these capabilities. This allows the editor (client)
* to handle file operations, which enables features like:
* - Reading unsaved editor state
* - Tracking file modifications in the editor
* - Proper integration with the client's undo/redo stack
*/
export class ACPDiffViewProvider extends FileEditProvider {
private readonly connection: acp.AgentSideConnection
private readonly clientCapabilities: acp.ClientCapabilities | undefined
private readonly sessionIdResolver: SessionIdResolver
/**
* Creates a new ACPDiffViewProvider.
*
* @param connection - The ACP agent-side connection for making requests
* @param clientCapabilities - The client's advertised capabilities
* @param sessionIdResolver - A function that returns the current session ID
*/
constructor(
connection: acp.AgentSideConnection,
clientCapabilities: acp.ClientCapabilities | undefined,
sessionIdResolver: SessionIdResolver,
) {
super()
this.connection = connection
this.clientCapabilities = clientCapabilities
this.sessionIdResolver = sessionIdResolver
}
/**
* Gets the current session ID, or throws if no session is active.
*/
private getSessionId(): string {
const sessionId = this.sessionIdResolver()
if (!sessionId) {
throw new Error("No active ACP session. Cannot perform file operation.")
}
return sessionId
}
/**
* Check if the client supports file read operations.
*/
private canReadFile(): boolean {
return this.clientCapabilities?.fs?.readTextFile === true
}
/**
* Check if the client supports file write operations.
*/
private canWriteFile(): boolean {
return this.clientCapabilities?.fs?.writeTextFile === true
}
/**
* Opens a file for editing, using ACP fs capabilities when available.
*
* If the client supports fs/read_text_file, this method will read the file
* content via the ACP connection, which may include unsaved editor state.
* Otherwise, it falls back to the FileEditProvider's local fs implementation.
*/
override async open(relPath: string, options?: { displayPath?: string }): Promise<void> {
// If we can't read files via ACP, fall back to FileEditProvider
if (!this.canReadFile()) {
Logger.debug("[ACPDiffViewProvider] Client does not support fs.readTextFile, falling back to local fs")
return super.open(relPath, options)
}
// Set up state - this replicates the DiffViewProvider.open() logic
// but uses ACP for file reading instead of local fs
this.isEditing = true
const cwd = await getCwd()
const absolutePathResolved = workspaceResolver.resolveWorkspacePath(cwd, relPath, "ACPDiffViewProvider.open.absolutePath")
this.absolutePath = typeof absolutePathResolved === "string" ? absolutePathResolved : absolutePathResolved.absolutePath
this.relPath = options?.displayPath ?? relPath
const fileExists = this.editType === "modify"
// Read file content
if (fileExists) {
// Try to save any dirty state in the editor first
try {
await HostProvider.workspace.saveOpenDocumentIfDirty({
filePath: this.absolutePath!,
})
} catch {
// Ignore errors - the host may not support this
}
// Read file content via ACP
try {
Logger.debug("[ACPDiffViewProvider] Reading file via ACP:", this.absolutePath)
const response = await this.connection.readTextFile({
sessionId: this.getSessionId(),
path: this.absolutePath!,
})
this.originalContent = response.content
// ACP always returns UTF-8 text content
this.fileEncoding = "utf8"
Logger.debug("[ACPDiffViewProvider] Read file successfully, length:", response.content.length)
} catch (error) {
// If ACP read fails, fall back to local fs
Logger.debug("[ACPDiffViewProvider] ACP read failed, falling back to local fs:", error)
const fileBuffer = await fs.readFile(this.absolutePath!)
this.fileEncoding = await detectEncoding(fileBuffer)
this.originalContent = iconv.decode(fileBuffer, this.fileEncoding)
}
} else {
this.originalContent = ""
this.fileEncoding = "utf8"
}
// Create directories for new files
const createdDirs = await createDirectoriesForFile(this.absolutePath!)
// Store for potential cleanup - access via the private field workaround
;(this as any).createdDirs = createdDirs
// Make sure the file exists before we proceed
if (!fileExists) {
// For new files, write via ACP if possible, otherwise local fs
if (this.canWriteFile()) {
try {
await this.connection.writeTextFile({
sessionId: this.getSessionId(),
path: this.absolutePath!,
content: "",
})
} catch {
// Fall back to local fs
await fs.writeFile(this.absolutePath!, "")
}
} else {
await fs.writeFile(this.absolutePath!, "")
}
}
// Get diagnostics before editing
let preDiagnostics: FileDiagnostics[] = []
try {
preDiagnostics = (await HostProvider.workspace.getDiagnostics({})).fileDiagnostics
} catch {
preDiagnostics = []
}
;(this as any).preDiagnostics = preDiagnostics
// Call the parent's openDiffEditor to set up in-memory document content
await this.openDiffEditor()
await this.scrollEditorToLine(0)
;(this as any).streamedLines = []
}
/**
* Scrolls the editor to a specific line.
* No-op for file-based providers, but needed for protected access.
*/
protected override async scrollEditorToLine(_line: number): Promise<void> {
// No-op: No visual editor to scroll
}
/**
* Opens the diff editor.
*/
protected override async openDiffEditor(): Promise<void> {
// Set up in-memory document content from the original content
// no-op: No visual editor to open
}
/**
* Saves the document content, using ACP fs capabilities when available.
*
* If the client supports fs/write_text_file, this method will write the file
* content via the ACP connection. Otherwise, it falls back to the
* FileEditProvider's local fs implementation.
*/
protected override async saveDocument(): Promise<Boolean> {
// If we can't write files via ACP, fall back to FileEditProvider
if (!this.canWriteFile()) {
Logger.debug("[ACPDiffViewProvider] Client does not support fs.writeTextFile, falling back to local fs")
return super.saveDocument()
}
const content = await this.getContent()
if (!this.absolutePath || content === undefined) {
return false
}
try {
Logger.debug("[ACPDiffViewProvider] Writing file via ACP:", {
path: this.absolutePath,
contentLength: content.length,
})
await this.connection.writeTextFile({
sessionId: this.getSessionId(),
path: this.absolutePath,
content: content,
})
Logger.debug("[ACPDiffViewProvider] Write file successfully")
return true
} catch (error) {
// If ACP write fails, fall back to local fs
Logger.debug("[ACPDiffViewProvider] ACP write failed, falling back to local fs:", error)
return super.saveDocument()
}
}
}
@@ -0,0 +1,402 @@
/**
* ACP Host Bridge Client Provider
*
* Implements HostBridgeClientProvider for ACP mode, providing stub implementations
* of the 4 required service clients. These clients conform to the interfaces in
* host-bridge-client-types.ts and will use ACP connection capabilities where applicable.
*
* @module acp
*/
import type * as acp from "@agentclientprotocol/sdk"
import type {
DiffServiceClientInterface,
EnvServiceClientInterface,
WindowServiceClientInterface,
WorkspaceServiceClientInterface,
} from "@generated/hosts/host-bridge-client-types"
import type { HostBridgeClientProvider, StreamingCallbacks } from "@hosts/host-provider-types"
import * as proto from "@shared/proto/index"
import { ClineClient } from "@/shared/cline"
import { Logger } from "@/shared/services/Logger"
/**
* Function type that resolves the current session ID.
* Returns undefined if no session is active.
*/
export type SessionIdResolver = () => string | undefined
/**
* Function type that resolves the current working directory.
* Returns undefined if no cwd is available (will fall back to process.cwd()).
*/
export type CwdResolver = () => string | undefined
/**
* ACP implementation of DiffService client.
*
* Handles diff operations for the ACP environment. Most operations are stubs
* that will be implemented in the next phase using ACP extension methods or
* the fs capabilities (readTextFile/writeTextFile).
*/
class ACPDiffServiceClient implements DiffServiceClientInterface {
async openDiff(_request: proto.host.OpenDiffRequest): Promise<proto.host.OpenDiffResponse> {
// Next phase: Could use ACP client capabilities to open a diff view in the editor.
// This would involve sending an ACP extension notification/request to the client
// to display a side-by-side diff of the original vs modified content.
Logger.debug("[ACPDiffServiceClient] openDiff called (stub)")
return proto.host.OpenDiffResponse.create({})
}
async getDocumentText(request: proto.host.GetDocumentTextRequest): Promise<proto.host.GetDocumentTextResponse> {
// Next phase: Use connection.readTextFile if clientCapabilities.fs.readTextFile is available.
// This would read the current document content from the editor, including any unsaved changes.
// For now, return empty content.
Logger.debug("[ACPDiffServiceClient] getDocumentText called (stub)", { diffId: request.diffId })
return proto.host.GetDocumentTextResponse.create({ content: "" })
}
async replaceText(_request: proto.host.ReplaceTextRequest): Promise<proto.host.ReplaceTextResponse> {
// Next phase: Use connection.writeTextFile if clientCapabilities.fs.writeTextFile is available.
// This would replace text in the document at the specified range.
Logger.debug("[ACPDiffServiceClient] replaceText called (stub)")
return proto.host.ReplaceTextResponse.create({})
}
async scrollDiff(_request: proto.host.ScrollDiffRequest): Promise<proto.host.ScrollDiffResponse> {
// Next phase: Send ACP extension notification to scroll the diff view to a specific line.
// No visual editor in ACP mode by default, so this is a no-op.
Logger.debug("[ACPDiffServiceClient] scrollDiff called (stub)")
return proto.host.ScrollDiffResponse.create({})
}
async truncateDocument(_request: proto.host.TruncateDocumentRequest): Promise<proto.host.TruncateDocumentResponse> {
// Next phase: Read file using readTextFile, truncate content, write back using writeTextFile.
// This is used to truncate a document to a specific line count.
Logger.debug("[ACPDiffServiceClient] truncateDocument called (stub)")
return proto.host.TruncateDocumentResponse.create({})
}
async saveDocument(_request: proto.host.SaveDocumentRequest): Promise<proto.host.SaveDocumentResponse> {
// Next phase: Use connection.writeTextFile to persist the document to disk.
// This saves the current document content to the file system.
Logger.debug("[ACPDiffServiceClient] saveDocument called (stub)")
return proto.host.SaveDocumentResponse.create({})
}
async closeAllDiffs(_request: proto.host.CloseAllDiffsRequest): Promise<proto.host.CloseAllDiffsResponse> {
// Next phase: Send ACP extension notification to close all diff views in the editor.
// No visual diff views in ACP mode by default, so this is a no-op.
Logger.debug("[ACPDiffServiceClient] closeAllDiffs called (stub)")
return proto.host.CloseAllDiffsResponse.create({})
}
async openMultiFileDiff(_request: proto.host.OpenMultiFileDiffRequest): Promise<proto.host.OpenMultiFileDiffResponse> {
// Next phase: Send ACP extension notification to open a multi-file diff view.
// This would display changes across multiple files in the editor.
Logger.debug("[ACPDiffServiceClient] openMultiFileDiff called (stub)")
return proto.host.OpenMultiFileDiffResponse.create({})
}
}
/**
* ACP implementation of EnvService client.
*
* Handles environment operations like clipboard access, version info, and telemetry.
* Most operations are stubs that will be implemented using ACP extension methods.
*/
class ACPEnvServiceClient implements EnvServiceClientInterface {
private readonly version: string
constructor(
_clientCapabilities: acp.ClientCapabilities | undefined,
_sessionIdResolver: SessionIdResolver,
version: string = "1.0.0",
) {
this.version = version
}
async debugLog(request: proto.cline.StringRequest): Promise<proto.cline.Empty> {
Logger.debug(request.value)
return proto.cline.Empty.create()
}
async clipboardWriteText(_request: proto.cline.StringRequest): Promise<proto.cline.Empty> {
Logger.debug("[ACPEnvServiceClient] clipboardWriteText called (stub)")
return proto.cline.Empty.create()
}
async clipboardReadText(_request: proto.cline.EmptyRequest): Promise<proto.cline.String> {
Logger.debug("[ACPEnvServiceClient] clipboardReadText called (stub)")
return proto.cline.String.create({ value: "" })
}
async getHostVersion(_request: proto.cline.EmptyRequest): Promise<proto.host.GetHostVersionResponse> {
// Return version info for the ACP agent.
return proto.host.GetHostVersionResponse.create({
version: this.version,
platform: "Cline ACP Agent",
clineType: ClineClient.Cli,
})
}
async getIdeRedirectUri(_request: proto.cline.EmptyRequest): Promise<proto.cline.String> {
Logger.debug("[ACPEnvServiceClient] getIdeRedirectUri called (stub)")
return proto.cline.String.create({ value: "" })
}
async getTelemetrySettings(_request: proto.cline.EmptyRequest): Promise<proto.host.GetTelemetrySettingsResponse> {
// Return telemetry as disabled by default in ACP mode.
return proto.host.GetTelemetrySettingsResponse.create({
isEnabled: proto.host.Setting.DISABLED,
})
}
subscribeToTelemetrySettings(
_request: proto.cline.EmptyRequest,
callbacks: StreamingCallbacks<proto.host.TelemetrySettingsEvent>,
): () => void {
// Send initial telemetry settings (disabled) and return unsubscribe function.
callbacks.onResponse(
proto.host.TelemetrySettingsEvent.create({
isEnabled: proto.host.Setting.DISABLED,
}),
)
// Return no-op unsubscribe function
return () => {}
}
async shutdown(_request: proto.cline.EmptyRequest): Promise<proto.cline.Empty> {
// Next phase: Graceful ACP connection shutdown.
// This would cleanly close the ACP connection and release resources.
Logger.debug("[ACPEnvServiceClient] shutdown called (stub)")
return proto.cline.Empty.create()
}
}
/**
* ACP implementation of WindowService client.
*
* Handles window/UI operations like showing documents, dialogs, and messages.
* Most operations are stubs that will be implemented using ACP extension methods.
*/
class ACPWindowServiceClient implements WindowServiceClientInterface {
constructor(_clientCapabilities: acp.ClientCapabilities | undefined, _sessionIdResolver: SessionIdResolver) {}
async showTextDocument(request: proto.host.ShowTextDocumentRequest): Promise<proto.host.TextEditorInfo> {
// Next phase: Send ACP extension request to open document in the editor.
// This would tell the ACP client to open the specified file.
Logger.debug("[ACPWindowServiceClient] showTextDocument called (stub)", { path: request.path })
return proto.host.TextEditorInfo.create({
documentPath: request.path,
})
}
async showOpenDialogue(_request: proto.host.ShowOpenDialogueRequest): Promise<proto.host.SelectedResources> {
// Next phase: Send ACP extension request for file picker dialog.
// This would display a file open dialog in the ACP client.
Logger.debug("[ACPWindowServiceClient] showOpenDialogue called (stub)")
return proto.host.SelectedResources.create({ paths: [] })
}
async showMessage(request: proto.host.ShowMessageRequest): Promise<proto.host.SelectedResponse> {
// Next phase: Send ACP extension notification to show message in the editor.
// This would display an information/warning/error message to the user.
Logger.debug("[ACPWindowServiceClient] showMessage called (stub)", {
message: request.message,
type: request.type,
})
return proto.host.SelectedResponse.create({})
}
async showInputBox(_request: proto.host.ShowInputBoxRequest): Promise<proto.host.ShowInputBoxResponse> {
// Next phase: Send ACP extension request for input dialog.
// This would display an input box for user text entry.
Logger.debug("[ACPWindowServiceClient] showInputBox called (stub)")
return proto.host.ShowInputBoxResponse.create({ response: "" })
}
async showSaveDialog(_request: proto.host.ShowSaveDialogRequest): Promise<proto.host.ShowSaveDialogResponse> {
// Next phase: Send ACP extension request for save dialog.
// This would display a file save dialog in the ACP client.
Logger.debug("[ACPWindowServiceClient] showSaveDialog called (stub)")
return proto.host.ShowSaveDialogResponse.create({ selectedPath: "" })
}
async openFile(request: proto.host.OpenFileRequest): Promise<proto.host.OpenFileResponse> {
// Next phase: Send ACP extension request to open file in the editor.
// This would open the specified file in the ACP client's editor.
Logger.debug("[ACPWindowServiceClient] openFile called (stub)", { filePath: request.filePath })
return proto.host.OpenFileResponse.create({})
}
async openSettings(_request: proto.host.OpenSettingsRequest): Promise<proto.host.OpenSettingsResponse> {
// Next phase: Send ACP extension request to open settings panel.
// This would open the settings/preferences in the ACP client.
Logger.debug("[ACPWindowServiceClient] openSettings called (stub)")
return proto.host.OpenSettingsResponse.create({})
}
async getOpenTabs(_request: proto.host.GetOpenTabsRequest): Promise<proto.host.GetOpenTabsResponse> {
// Next phase: Send ACP extension request to list open tabs/documents.
// This would return a list of currently open files in the editor.
Logger.debug("[ACPWindowServiceClient] getOpenTabs called (stub)")
return proto.host.GetOpenTabsResponse.create({ paths: [] })
}
async getVisibleTabs(_request: proto.host.GetVisibleTabsRequest): Promise<proto.host.GetVisibleTabsResponse> {
// Next phase: Send ACP extension request to list visible tabs.
// This would return a list of visible tabs/panes in the editor.
Logger.debug("[ACPWindowServiceClient] getVisibleTabs called (stub)")
return proto.host.GetVisibleTabsResponse.create({ paths: [] })
}
async getActiveEditor(_request: proto.host.GetActiveEditorRequest): Promise<proto.host.GetActiveEditorResponse> {
// Next phase: Send ACP extension request to get active editor info.
// This would return information about the currently focused editor.
Logger.debug("[ACPWindowServiceClient] getActiveEditor called (stub)")
return proto.host.GetActiveEditorResponse.create({})
}
}
/**
* ACP implementation of WorkspaceService client.
*
* Handles workspace operations like getting paths, diagnostics, and terminal commands.
* Uses the cwdResolver to get the current working directory, falling back to process.cwd().
*/
class ACPWorkspaceServiceClient implements WorkspaceServiceClientInterface {
private readonly _clientCapabilities: acp.ClientCapabilities | undefined
private readonly cwdResolver: CwdResolver
constructor(
clientCapabilities: acp.ClientCapabilities | undefined,
_sessionIdResolver: SessionIdResolver,
cwdResolver: CwdResolver,
) {
this._clientCapabilities = clientCapabilities
this.cwdResolver = cwdResolver
}
/**
* Get the current working directory, using the resolver if available,
* otherwise falling back to process.cwd().
*/
private getCwd(): string {
return this.cwdResolver() ?? process.cwd()
}
async getWorkspacePaths(_request: proto.host.GetWorkspacePathsRequest): Promise<proto.host.GetWorkspacePathsResponse> {
// Return the current working directory from the resolver.
const cwd = this.getCwd()
Logger.debug("[ACPWorkspaceServiceClient] getWorkspacePaths called", { cwd })
return proto.host.GetWorkspacePathsResponse.create({
paths: [cwd],
})
}
async saveOpenDocumentIfDirty(
_request: proto.host.SaveOpenDocumentIfDirtyRequest,
): Promise<proto.host.SaveOpenDocumentIfDirtyResponse> {
// Next phase: Use ACP extension or fs.writeTextFile to save dirty documents.
// This would save any unsaved changes in the specified document.
Logger.debug("[ACPWorkspaceServiceClient] saveOpenDocumentIfDirty called (stub)")
return proto.host.SaveOpenDocumentIfDirtyResponse.create({})
}
async getDiagnostics(_request: proto.host.GetDiagnosticsRequest): Promise<proto.host.GetDiagnosticsResponse> {
// Next phase: Send ACP extension request for diagnostics (errors, warnings).
// This would return linting/compilation errors from the ACP client.
Logger.debug("[ACPWorkspaceServiceClient] getDiagnostics called (stub)")
return proto.host.GetDiagnosticsResponse.create({ fileDiagnostics: [] })
}
async openProblemsPanel(_request: proto.host.OpenProblemsPanelRequest): Promise<proto.host.OpenProblemsPanelResponse> {
// Next phase: Send ACP extension notification to open the problems panel.
// This would show the diagnostics/problems view in the editor.
Logger.debug("[ACPWorkspaceServiceClient] openProblemsPanel called (stub)")
return proto.host.OpenProblemsPanelResponse.create({})
}
async openInFileExplorerPanel(
request: proto.host.OpenInFileExplorerPanelRequest,
): Promise<proto.host.OpenInFileExplorerPanelResponse> {
// Next phase: Send ACP extension notification to reveal file in explorer.
// This would highlight/reveal the specified path in the file tree.
Logger.debug("[ACPWorkspaceServiceClient] openInFileExplorerPanel called (stub)", { path: request.path })
return proto.host.OpenInFileExplorerPanelResponse.create({})
}
async openClineSidebarPanel(
_request: proto.host.OpenClineSidebarPanelRequest,
): Promise<proto.host.OpenClineSidebarPanelResponse> {
// Next phase: Send ACP extension notification to open Cline sidebar.
// This would show the Cline panel/sidebar in the editor.
Logger.debug("[ACPWorkspaceServiceClient] openClineSidebarPanel called (stub)")
return proto.host.OpenClineSidebarPanelResponse.create({})
}
async openTerminalPanel(_request: proto.host.OpenTerminalRequest): Promise<proto.host.OpenTerminalResponse> {
// Next phase: Send ACP extension notification or use createTerminal capability.
// This would open/show the terminal panel in the editor.
Logger.debug("[ACPWorkspaceServiceClient] openTerminalPanel called (stub)")
return proto.host.OpenTerminalResponse.create({})
}
async executeCommandInTerminal(
request: proto.host.ExecuteCommandInTerminalRequest,
): Promise<proto.host.ExecuteCommandInTerminalResponse> {
// Next phase: Use connection.createTerminal if clientCapabilities.terminal is available.
// This would execute the specified command in a terminal via the ACP client.
// The ACP SDK provides createTerminal() which returns a TerminalHandle with
// methods like currentOutput(), waitForExit(), kill(), and release().
Logger.debug("[ACPWorkspaceServiceClient] executeCommandInTerminal called (stub)", {
command: request.command,
hasTerminalCapability: this._clientCapabilities?.terminal,
})
return proto.host.ExecuteCommandInTerminalResponse.create({})
}
async openFolder(request: proto.host.OpenFolderRequest): Promise<proto.host.OpenFolderResponse> {
// Next phase: Send ACP extension request to change workspace/folder.
// This would open a new folder/workspace in the ACP client.
Logger.debug("[ACPWorkspaceServiceClient] openFolder called (stub)", { path: request.path })
return proto.host.OpenFolderResponse.create({ success: true })
}
}
/**
* ACP Host Bridge Client Provider
*
* Provides the 4 service clients required by HostBridgeClientProvider interface,
* implemented for the ACP environment. Uses the ACP connection and client capabilities
* to delegate operations to the ACP client where possible.
*/
export class ACPHostBridgeClientProvider implements HostBridgeClientProvider {
workspaceClient: WorkspaceServiceClientInterface
envClient: EnvServiceClientInterface
windowClient: WindowServiceClientInterface
diffClient: DiffServiceClientInterface
/**
* Creates a new ACPHostBridgeClientProvider.
*
* @param connection - The ACP agent-side connection for making requests
* @param clientCapabilities - The client's advertised capabilities
* @param sessionIdResolver - Function that returns the current session ID
* @param cwdResolver - Function that returns the current working directory
* @param debug - Whether to enable debug logging
* @param version - Version string for getHostVersion (optional)
*/
constructor(
clientCapabilities: acp.ClientCapabilities | undefined,
sessionIdResolver: SessionIdResolver,
cwdResolver: CwdResolver,
version: string = "1.0.0",
) {
this.workspaceClient = new ACPWorkspaceServiceClient(clientCapabilities, sessionIdResolver, cwdResolver)
this.envClient = new ACPEnvServiceClient(clientCapabilities, sessionIdResolver, version)
this.windowClient = new ACPWindowServiceClient(clientCapabilities, sessionIdResolver)
this.diffClient = new ACPDiffServiceClient()
}
}
+157
View File
@@ -0,0 +1,157 @@
/**
* AcpAgent - Thin wrapper that bridges stdio connection to ClineAgent.
*
* This class wraps the ClineAgent and connects it to an ACP AgentSideConnection
* for stdio-based communication. It:
* - Wires up the permission handler to call connection.requestPermission()
* - Subscribes to ClineAgent session events and forwards them to connection.sessionUpdate()
* - Delegates all acp.Agent methods to the internal ClineAgent
*
* For programmatic usage without stdio, use ClineAgent directly.
*
* @module acp
*/
import type * as acp from "@agentclientprotocol/sdk"
import { Logger } from "@/shared/services/Logger.js"
import { ClineAgent } from "../agent/ClineAgent.js"
import type { AcpAgentOptions, SessionUpdateType } from "../agent/types.js"
/**
* ACP Agent wrapper that bridges stdio connection to ClineAgent.
*
* This is the class used by runAcpMode() for stdio-based ACP communication.
* It creates an internal ClineAgent and wires up the connection for:
* - Permission requests (via connection.requestPermission)
* - Session updates (via connection.sessionUpdate)
*/
export class AcpAgent implements acp.Agent {
private readonly connection: acp.AgentSideConnection
private readonly clineAgent: ClineAgent
/** Track which sessions we've subscribed to for event forwarding */
private readonly subscribedSessions: Set<string> = new Set()
constructor(connection: acp.AgentSideConnection, options: AcpAgentOptions) {
this.connection = connection
// Create the internal ClineAgent
this.clineAgent = new ClineAgent(options)
// Wire up the permission handler to use the connection
this.clineAgent.setPermissionHandler(async (request, resolve) => {
try {
Logger.debug("[AcpAgent] Forwarding permission request to connection")
const response = await this.connection.requestPermission({
sessionId: this.getCurrentSessionId() ?? "",
toolCall: request.toolCall,
options: request.options,
})
resolve(response)
} catch (error) {
Logger.debug("[AcpAgent] Error requesting permission:", error)
resolve({ outcome: "rejected" as unknown as acp.RequestPermissionOutcome })
}
})
}
/**
* Get the current active session ID from the ClineAgent.
*/
private getCurrentSessionId(): string | undefined {
// Find the session that's currently processing
for (const [sessionId, session] of this.clineAgent.sessions) {
if (session.controller?.task) {
return sessionId
}
}
// Fall back to the first session if none is actively processing
const firstSession = this.clineAgent.sessions.keys().next()
return firstSession.done ? undefined : firstSession.value
}
/**
* Subscribe to session events and forward them to the connection.
*/
private subscribeToSessionEvents(sessionId: string): void {
if (this.subscribedSessions.has(sessionId)) {
return
}
const emitter = this.clineAgent.emitterForSession(sessionId)
// Forward session update by adding the sessionUpdate discriminator
const forwardSessionUpdate = <K extends SessionUpdateType>(eventName: K) => {
emitter.on(eventName, (payload: Record<string, unknown>) => {
const update = {
sessionUpdate: eventName,
...payload,
} as acp.SessionUpdate
this.connection.sessionUpdate({ sessionId, update }).catch((error) => {
Logger.error(`[AcpAgent] Error forwarding ${eventName}:`, error)
})
})
}
// Forward all standard session updates
forwardSessionUpdate("agent_message_chunk")
forwardSessionUpdate("agent_thought_chunk")
forwardSessionUpdate("tool_call")
forwardSessionUpdate("tool_call_update")
forwardSessionUpdate("available_commands_update")
forwardSessionUpdate("plan")
forwardSessionUpdate("current_mode_update")
forwardSessionUpdate("user_message_chunk")
forwardSessionUpdate("config_option_update")
forwardSessionUpdate("session_info_update")
// Handle errors specially (not part of ACP SessionUpdate)
emitter.on("error", (error) => {
Logger.error("[AcpAgent] Session error:", error)
})
this.subscribedSessions.add(sessionId)
}
// ============================================================
// acp.Agent Interface Implementation - Delegate to ClineAgent
// ============================================================
async initialize(params: acp.InitializeRequest): Promise<acp.InitializeResponse> {
return await this.clineAgent.initialize(params, this.connection)
}
async newSession(params: acp.NewSessionRequest): Promise<acp.NewSessionResponse> {
const response = await this.clineAgent.newSession(params)
// Subscribe to events for this new session
this.subscribeToSessionEvents(response.sessionId)
return response
}
async prompt(params: acp.PromptRequest): Promise<acp.PromptResponse> {
// Ensure we're subscribed to this session's events
this.subscribeToSessionEvents(params.sessionId)
return this.clineAgent.prompt(params)
}
async cancel(params: acp.CancelNotification): Promise<void> {
return this.clineAgent.cancel(params)
}
async setSessionMode(params: acp.SetSessionModeRequest): Promise<acp.SetSessionModeResponse> {
return this.clineAgent.setSessionMode(params)
}
async unstable_setSessionModel(params: acp.SetSessionModelRequest): Promise<acp.SetSessionModelResponse> {
return this.clineAgent.unstable_setSessionModel(params)
}
async authenticate(params: acp.AuthenticateRequest): Promise<acp.AuthenticateResponse> {
return this.clineAgent.authenticate(params)
}
async shutdown(): Promise<void> {
this.subscribedSessions.clear()
return this.clineAgent.shutdown()
}
}
File diff suppressed because it is too large Load Diff
+143
View File
@@ -0,0 +1,143 @@
/**
* Entry point for ACP (Agent Client Protocol) mode.
*
* When the CLI is invoked with `--acp`, this module sets up the ACP connection
* and runs Cline as an ACP-compliant agent communicating over stdio.
*
* This module exports:
* - `ClineAgent` - Decoupled agent for programmatic use (no stdio dependency)
* - `AcpAgent` - Thin wrapper that bridges stdio connection to ClineAgent
* - `ClineSessionEmitter` - Typed EventEmitter for per-session events
* - `runAcpMode` - Function to run Cline in stdio-based ACP mode
*
* @module acp
*/
import { AgentSideConnection, ndJsonStream } from "@agentclientprotocol/sdk"
import { Logger } from "@/shared/services/Logger"
import { version as CLI_VERSION } from "../../../package.json"
import { AcpAgent } from "./AcpAgent.js"
import { nodeToWebReadable, nodeToWebWritable } from "./streamUtils.js"
// Re-export classes for programmatic use
export { ClineAgent } from "../agent/ClineAgent.js"
export { ClineSessionEmitter } from "../agent/ClineSessionEmitter.js"
// Re-export types
export type {
AcpAgentOptions,
AcpSessionState,
ClineAcpSession,
ClineAgentOptions,
ClineSessionEvents,
PermissionHandler,
PermissionResolver,
} from "../agent/types.js"
export { AcpAgent } from "./AcpAgent.js"
/** Original console methods for restoration if needed */
const originalConsole = {
log: console.log,
info: console.info,
warn: console.warn,
debug: console.debug,
error: console.error,
}
/**
* Redirect all console output to stderr.
*
* In ACP mode, stdout is reserved exclusively for JSON-RPC communication.
* All logging must go to stderr to avoid corrupting the protocol stream.
*/
function redirectConsoleToStderr(): void {
console.log = (...args) => console.error(...args)
console.info = (...args) => console.error(...args)
console.warn = (...args) => console.error(...args)
console.debug = (...args) => console.error(...args)
// console.error already goes to stderr
}
/**
* Restore console methods to their original behavior.
*/
export function restoreConsole(): void {
console.log = originalConsole.log
console.info = originalConsole.info
console.warn = originalConsole.warn
console.debug = originalConsole.debug
console.error = originalConsole.error
}
export interface AcpModeOptions {
/** Path to Cline configuration directory */
config?: string
/** Working directory (default: process.cwd()) */
cwd?: string
/** Enable verbose/debug logging to stderr */
verbose?: boolean
}
/**
* Run Cline in ACP mode.
*
* This function:
* 1. Redirects console output to stderr (stdout reserved for JSON-RPC)
* 2. Sets up the ndJsonStream for stdio communication
* 3. Creates the AgentSideConnection with our AcpAgent factory
* 4. Initializes the CLI infrastructure (StateManager, Controller, etc.)
* 5. Keeps the process alive until the connection closes
*
* @param options - Configuration options for ACP mode
*/
export async function runAcpMode(options: AcpModeOptions = {}): Promise<void> {
redirectConsoleToStderr()
const outputStream = nodeToWebWritable(process.stdout)
const inputStream = nodeToWebReadable(process.stdin)
const stream = ndJsonStream(outputStream, inputStream)
let agent: AcpAgent | null = null
new AgentSideConnection((conn) => {
agent = new AcpAgent(conn, {
version: CLI_VERSION,
debug: Boolean(options.verbose),
})
return agent
}, stream)
let isShuttingDown = false
const shutdown = async () => {
if (isShuttingDown) {
// Force exit on second signal
process.exit(1)
}
isShuttingDown = true
try {
await agent?.shutdown()
restoreConsole()
} catch (error) {
Logger.error("[ACP] Error during shutdown:", error)
}
process.exit(0)
}
process.on("SIGINT", shutdown)
process.on("SIGTERM", shutdown)
// Keep the process alive
// The ndJsonStream will handle stdin events automatically.
// We need to ensure the process doesn't exit while waiting for input.
process.stdin.resume()
// Handle stdin end (client disconnected)
process.stdin.on("end", shutdown)
// Handle stdin errors
process.stdin.on("error", async (error) => {
Logger.error("[ACP] stdin error:", error)
await shutdown()
})
Logger.info("[ACP] Process is now listening for ACP requests on stdin")
}
+54
View File
@@ -0,0 +1,54 @@
/**
* Stream conversion utilities for ACP mode.
*
* The ACP SDK's ndJsonStream function expects Web Streams (ReadableStream/WritableStream),
* but Node.js provides its own stream types. These utilities convert between them.
*
* @module acp/streamUtils
*/
import type { Readable, Writable } from "node:stream"
/**
* Convert a Node.js Writable stream to a Web WritableStream.
*
* Used to convert process.stdout for ACP output.
*
* @param nodeStream - Node.js Writable stream (e.g., process.stdout)
* @returns Web WritableStream compatible with ndJsonStream
*/
export function nodeToWebWritable(nodeStream: Writable): WritableStream<Uint8Array> {
return new WritableStream<Uint8Array>({
write(chunk) {
return new Promise<void>((resolve, reject) => {
nodeStream.write(Buffer.from(chunk), (err) => {
if (err) {
reject(err)
} else {
resolve()
}
})
})
},
})
}
/**
* Convert a Node.js Readable stream to a Web ReadableStream.
*
* Used to convert process.stdin for ACP input.
*
* @param nodeStream - Node.js Readable stream (e.g., process.stdin)
* @returns Web ReadableStream compatible with ndJsonStream
*/
export function nodeToWebReadable(nodeStream: Readable): ReadableStream<Uint8Array> {
return new ReadableStream<Uint8Array>({
start(controller) {
nodeStream.on("data", (chunk: Buffer) => {
controller.enqueue(new Uint8Array(chunk))
})
nodeStream.on("end", () => controller.close())
nodeStream.on("error", (err) => controller.error(err))
},
})
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,274 @@
/**
* Tests for ClineSessionEmitter - Typed EventEmitter for per-session ACP events.
*/
import { beforeEach, describe, expect, it, vi } from "vitest"
import { ClineSessionEmitter } from "./ClineSessionEmitter.js"
import type { SessionUpdatePayload } from "./types.js"
describe("ClineSessionEmitter", () => {
let emitter: ClineSessionEmitter
beforeEach(() => {
emitter = new ClineSessionEmitter()
})
describe("on/emit", () => {
it("should emit and receive agent_message_chunk events", () => {
const listener = vi.fn()
const payload: SessionUpdatePayload<"agent_message_chunk"> = {
content: { type: "text", text: "Hello, world!" },
}
emitter.on("agent_message_chunk", listener)
emitter.emit("agent_message_chunk", payload)
expect(listener).toHaveBeenCalledTimes(1)
expect(listener).toHaveBeenCalledWith(payload)
})
it("should emit and receive agent_thought_chunk events", () => {
const listener = vi.fn()
const payload: SessionUpdatePayload<"agent_thought_chunk"> = {
content: { type: "text", text: "Thinking..." },
}
emitter.on("agent_thought_chunk", listener)
emitter.emit("agent_thought_chunk", payload)
expect(listener).toHaveBeenCalledTimes(1)
expect(listener).toHaveBeenCalledWith(payload)
})
it("should emit and receive tool_call events", () => {
const listener = vi.fn()
const payload: SessionUpdatePayload<"tool_call"> = {
toolCallId: "test-tool-call-id",
title: "Test Tool Call",
status: "in_progress",
}
emitter.on("tool_call", listener)
emitter.emit("tool_call", payload)
expect(listener).toHaveBeenCalledTimes(1)
expect(listener).toHaveBeenCalledWith(payload)
})
it("should emit and receive tool_call_update events", () => {
const listener = vi.fn()
const payload: SessionUpdatePayload<"tool_call_update"> = {
toolCallId: "test-tool-call-id",
status: "completed",
rawOutput: { result: "success" },
}
emitter.on("tool_call_update", listener)
emitter.emit("tool_call_update", payload)
expect(listener).toHaveBeenCalledTimes(1)
expect(listener).toHaveBeenCalledWith(payload)
})
it("should emit and receive available_commands_update events", () => {
const listener = vi.fn()
const payload: SessionUpdatePayload<"available_commands_update"> = {
availableCommands: [{ name: "test", description: "Test command" }],
}
emitter.on("available_commands_update", listener)
emitter.emit("available_commands_update", payload)
expect(listener).toHaveBeenCalledTimes(1)
expect(listener).toHaveBeenCalledWith(payload)
})
it("should emit and receive current_mode_update events", () => {
const listener = vi.fn()
const payload: SessionUpdatePayload<"current_mode_update"> = {
currentModeId: "act",
}
emitter.on("current_mode_update", listener)
emitter.emit("current_mode_update", payload)
expect(listener).toHaveBeenCalledTimes(1)
expect(listener).toHaveBeenCalledWith(payload)
})
it("should emit and receive plan events", () => {
const listener = vi.fn()
const payload: SessionUpdatePayload<"plan"> = {
entries: [{ content: "Step 1", status: "pending", priority: "high" }],
}
emitter.on("plan", listener)
emitter.emit("plan", payload)
expect(listener).toHaveBeenCalledTimes(1)
expect(listener).toHaveBeenCalledWith(payload)
})
it("should emit and receive error events", () => {
const listener = vi.fn()
const error = new Error("Test error")
emitter.on("error", listener)
emitter.emit("error", error)
expect(listener).toHaveBeenCalledTimes(1)
expect(listener).toHaveBeenCalledWith(error)
})
})
describe("multiple listeners", () => {
it("should support multiple listeners for the same event", () => {
const listener1 = vi.fn()
const listener2 = vi.fn()
const payload: SessionUpdatePayload<"agent_message_chunk"> = {
content: { type: "text", text: "Hello" },
}
emitter.on("agent_message_chunk", listener1)
emitter.on("agent_message_chunk", listener2)
emitter.emit("agent_message_chunk", payload)
expect(listener1).toHaveBeenCalledTimes(1)
expect(listener2).toHaveBeenCalledTimes(1)
})
it("should call listeners in order of registration", () => {
const order: number[] = []
const listener1 = vi.fn(() => order.push(1))
const listener2 = vi.fn(() => order.push(2))
const payload: SessionUpdatePayload<"agent_message_chunk"> = {
content: { type: "text", text: "Hello" },
}
emitter.on("agent_message_chunk", listener1)
emitter.on("agent_message_chunk", listener2)
emitter.emit("agent_message_chunk", payload)
expect(order).toEqual([1, 2])
})
})
describe("off", () => {
it("should remove a specific listener", () => {
const listener = vi.fn()
const payload: SessionUpdatePayload<"agent_message_chunk"> = {
content: { type: "text", text: "Hello" },
}
emitter.on("agent_message_chunk", listener)
emitter.off("agent_message_chunk", listener)
emitter.emit("agent_message_chunk", payload)
expect(listener).not.toHaveBeenCalled()
})
it("should only remove the specified listener", () => {
const listener1 = vi.fn()
const listener2 = vi.fn()
const payload: SessionUpdatePayload<"agent_message_chunk"> = {
content: { type: "text", text: "Hello" },
}
emitter.on("agent_message_chunk", listener1)
emitter.on("agent_message_chunk", listener2)
emitter.off("agent_message_chunk", listener1)
emitter.emit("agent_message_chunk", payload)
expect(listener1).not.toHaveBeenCalled()
expect(listener2).toHaveBeenCalledTimes(1)
})
})
describe("once", () => {
it("should only call the listener once", () => {
const listener = vi.fn()
const payload: SessionUpdatePayload<"agent_message_chunk"> = {
content: { type: "text", text: "Hello" },
}
emitter.once("agent_message_chunk", listener)
emitter.emit("agent_message_chunk", payload)
emitter.emit("agent_message_chunk", payload)
expect(listener).toHaveBeenCalledTimes(1)
})
})
describe("removeAllListeners", () => {
it("should remove all listeners for a specific event", () => {
const listener1 = vi.fn()
const listener2 = vi.fn()
const payload: SessionUpdatePayload<"agent_message_chunk"> = {
content: { type: "text", text: "Hello" },
}
emitter.on("agent_message_chunk", listener1)
emitter.on("agent_message_chunk", listener2)
emitter.removeAllListeners("agent_message_chunk")
emitter.emit("agent_message_chunk", payload)
expect(listener1).not.toHaveBeenCalled()
expect(listener2).not.toHaveBeenCalled()
})
it("should remove all listeners when no event is specified", () => {
const listener1 = vi.fn()
const listener2 = vi.fn()
emitter.on("agent_message_chunk", listener1)
emitter.on("tool_call", listener2)
emitter.removeAllListeners()
emitter.emit("agent_message_chunk", { content: { type: "text", text: "Hello" } })
emitter.emit("tool_call", { toolCallId: "test", title: "Test" })
expect(listener1).not.toHaveBeenCalled()
expect(listener2).not.toHaveBeenCalled()
})
})
describe("listenerCount", () => {
it("should return the correct number of listeners", () => {
const listener1 = vi.fn()
const listener2 = vi.fn()
expect(emitter.listenerCount("agent_message_chunk")).toBe(0)
emitter.on("agent_message_chunk", listener1)
expect(emitter.listenerCount("agent_message_chunk")).toBe(1)
emitter.on("agent_message_chunk", listener2)
expect(emitter.listenerCount("agent_message_chunk")).toBe(2)
emitter.off("agent_message_chunk", listener1)
expect(emitter.listenerCount("agent_message_chunk")).toBe(1)
})
})
describe("chaining", () => {
it("should support method chaining", () => {
const listener = vi.fn()
const result = emitter.on("agent_message_chunk", listener).on("error", vi.fn()).off("error", vi.fn())
expect(result).toBe(emitter)
})
})
describe("emit return value", () => {
it("should return true when there are listeners", () => {
emitter.on("agent_message_chunk", vi.fn())
const result = emitter.emit("agent_message_chunk", { content: { type: "text", text: "Hello" } })
expect(result).toBe(true)
})
it("should return false when there are no listeners", () => {
const result = emitter.emit("agent_message_chunk", { content: { type: "text", text: "Hello" } })
expect(result).toBe(false)
})
})
})
+114
View File
@@ -0,0 +1,114 @@
/**
* Typed EventEmitter for per-session ACP events.
*
* This class provides a type-safe wrapper around Node's EventEmitter
* for emitting and subscribing to session-specific ACP events.
*
* @module acp
*/
import { EventEmitter } from "events"
import type { ClineSessionEvents } from "./types.js"
/**
* Type-safe EventEmitter for ClineAgent session events.
*
* Each session has its own emitter instance, allowing consumers to
* subscribe to events for specific sessions without filtering.
*
* @example
* ```typescript
* const agent = new ClineAgent({ version: "1.0.0" })
* const session = await agent.newSession({ cwd: "/path/to/project" })
*
* // Subscribe to session events
* agent.session(session.sessionId).on("agent_message_chunk", (content) => {
* console.log("Agent says:", content.text)
* })
*
* agent.session(session.sessionId).on("tool_call", (toolCall) => {
* console.log("Tool called:", toolCall.toolName)
* })
* ```
*/
export class ClineSessionEmitter {
private readonly emitter: EventEmitter
constructor() {
this.emitter = new EventEmitter()
// Increase max listeners since we may have many event types
this.emitter.setMaxListeners(20)
}
/**
* Subscribe to a session event.
*
* @param event - The event name to subscribe to
* @param listener - The callback function to invoke when the event is emitted
* @returns This emitter instance for chaining
*/
on<K extends keyof ClineSessionEvents>(event: K, listener: ClineSessionEvents[K]): this {
this.emitter.on(event, listener as (...args: unknown[]) => void)
return this
}
/**
* Subscribe to a session event for a single invocation.
*
* @param event - The event name to subscribe to
* @param listener - The callback function to invoke when the event is emitted
* @returns This emitter instance for chaining
*/
once<K extends keyof ClineSessionEvents>(event: K, listener: ClineSessionEvents[K]): this {
this.emitter.once(event, listener as (...args: unknown[]) => void)
return this
}
/**
* Unsubscribe from a session event.
*
* @param event - The event name to unsubscribe from
* @param listener - The callback function to remove
* @returns This emitter instance for chaining
*/
off<K extends keyof ClineSessionEvents>(event: K, listener: ClineSessionEvents[K]): this {
this.emitter.off(event, listener as (...args: unknown[]) => void)
return this
}
/**
* Emit a session event.
*
* @param event - The event name to emit
* @param args - The arguments to pass to the event listeners
* @returns True if the event had listeners, false otherwise
*/
emit<K extends keyof ClineSessionEvents>(event: K, ...args: Parameters<ClineSessionEvents[K]>): boolean {
return this.emitter.emit(event, ...args)
}
/**
* Remove all listeners for a specific event or all events.
*
* @param event - Optional event name to remove listeners for
* @returns This emitter instance for chaining
*/
removeAllListeners<K extends keyof ClineSessionEvents>(event?: K): this {
if (event) {
this.emitter.removeAllListeners(event)
} else {
this.emitter.removeAllListeners()
}
return this
}
/**
* Get the number of listeners for a specific event.
*
* @param event - The event name to count listeners for
* @returns The number of listeners
*/
listenerCount<K extends keyof ClineSessionEvents>(event: K): number {
return this.emitter.listenerCount(event)
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+356
View File
@@ -0,0 +1,356 @@
/**
* Permission handling for ACP integration.
*
* This module handles the translation between ACP permission requests/responses
* and Cline's internal permission system. It maps ClineAsk types to appropriate
* ACP permission options and translates user responses back to Cline's format.
*
* @module acp/permissionHandler
*/
import type * as acp from "@agentclientprotocol/sdk"
import type { ClineAsk } from "@shared/ExtensionMessage"
import type { ClineAskResponse } from "@shared/WebviewMessage"
import { Logger } from "@/shared/services/Logger.js"
import type { AcpSessionState, ClinePermissionOption } from "./types.js"
/**
* Standard permission options for operations that support "always allow".
* Used for commands, tools, and MCP server operations.
*/
const STANDARD_PERMISSION_OPTIONS: ClinePermissionOption[] = [
{ kind: "allow_once", optionId: "allow_once", name: "Allow Once" },
{ kind: "allow_always", optionId: "allow_always", name: "Always Allow" },
{ kind: "reject_once", optionId: "reject_once", name: "Reject" },
]
/**
* Permission options for operations that don't support "always allow".
* Used for browser actions and other one-time operations.
*/
const RESTRICTED_PERMISSION_OPTIONS: ClinePermissionOption[] = [
{ kind: "allow_once", optionId: "allow_once", name: "Allow Once" },
{ kind: "reject_once", optionId: "reject_once", name: "Reject" },
]
/**
* Mapping of ClineAsk types to their permission option sets.
*/
const ASK_TYPE_PERMISSION_MAP: Partial<Record<ClineAsk, ClinePermissionOption[]>> = {
// Commands support "always allow" for auto-approval
command: STANDARD_PERMISSION_OPTIONS,
// Tool operations support "always allow"
tool: STANDARD_PERMISSION_OPTIONS,
// MCP server operations support "always allow"
use_mcp_server: STANDARD_PERMISSION_OPTIONS,
// Browser actions are one-time, no "always allow"
browser_action_launch: RESTRICTED_PERMISSION_OPTIONS,
// Command output continuation - simple allow/reject
command_output: RESTRICTED_PERMISSION_OPTIONS,
}
/**
* ClineAsk types that require permission handling.
* Other ask types (like followup, plan_mode_respond) don't need permission UI.
*/
const PERMISSION_REQUIRING_ASK_TYPES: Set<ClineAsk> = new Set([
"command",
"tool",
"browser_action_launch",
"use_mcp_server",
"command_output",
])
/**
* Result of handling a permission response.
*/
export interface PermissionHandlerResult {
/** Cline's internal response type */
response: ClineAskResponse
/** Optional text to pass with the response */
text?: string
/** Whether "always allow" was selected (for auto-approval tracking) */
alwaysAllow?: boolean
/** Whether the request was cancelled */
cancelled?: boolean
}
/**
* Check if a ClineAsk type requires permission handling.
*
* @param askType - The ClineAsk type to check
* @returns True if the ask type requires permission UI
*/
export function requiresPermission(askType: ClineAsk): boolean {
return PERMISSION_REQUIRING_ASK_TYPES.has(askType)
}
/**
* Get the appropriate permission options for a ClineAsk type.
*
* @param askType - The ClineAsk type
* @returns Array of permission options, or undefined if the ask type doesn't require permission
*/
export function getPermissionOptionsForAskType(askType: ClineAsk): acp.PermissionOption[] | undefined {
const options = ASK_TYPE_PERMISSION_MAP[askType]
if (!options) {
return undefined
}
// Convert to ACP PermissionOption format
return options.map((opt) => ({
kind: opt.kind,
optionId: opt.optionId,
name: opt.name,
}))
}
/**
* Handle an ACP permission response and translate it to Cline's format.
*
* @param response - The ACP permission response from the client
* @param askType - The original ClineAsk type that triggered the permission request
* @returns The translated result for Cline's handleWebviewAskResponse
*/
export function handlePermissionResponse(response: acp.RequestPermissionResponse, askType: ClineAsk): PermissionHandlerResult {
// Check if cancelled
if (response.outcome.outcome === "cancelled") {
return {
response: "noButtonClicked",
cancelled: true,
}
}
// Get the selected option ID
const optionId = response.outcome.optionId
// Translate the option to Cline's response format
switch (optionId) {
case "allow_once":
return {
response: "yesButtonClicked",
alwaysAllow: false,
}
case "allow_always":
return {
response: "yesButtonClicked",
alwaysAllow: true,
}
case "reject_once":
case "reject_always":
return {
response: "noButtonClicked",
alwaysAllow: false,
}
default:
// Unknown option ID - treat as rejection for safety
Logger.error(`[permissionHandler] Unknown permission option: ${optionId}`)
return {
response: "noButtonClicked",
}
}
}
/**
* Create a permission request for an ACP tool call.
*
* @param toolCall - The ACP tool call that needs permission
* @param askType - The Cline ask type
* @returns The permission request options, or null if no permission needed
*/
export function createPermissionRequest(
toolCall: acp.ToolCall,
askType: ClineAsk,
): { toolCall: acp.ToolCall; options: acp.PermissionOption[] } | null {
const options = getPermissionOptionsForAskType(askType)
if (!options) {
return null
}
return {
toolCall,
options,
}
}
/**
* Track "always allow" decisions for auto-approval.
* This maintains a set of tool/command patterns that have been auto-approved.
*/
export class AutoApprovalTracker {
/** Set of auto-approved command prefixes */
private autoApprovedCommands: Set<string> = new Set()
/** Set of auto-approved tool names */
private autoApprovedTools: Set<string> = new Set()
/** Set of auto-approved MCP servers */
private autoApprovedMcpServers: Set<string> = new Set()
/**
* Record an "always allow" decision for a permission request.
*
* @param askType - The Cline ask type that was auto-approved
* @param identifier - The identifier for the operation (command, tool name, etc.)
*/
recordAlwaysAllow(askType: ClineAsk, identifier: string): void {
switch (askType) {
case "command":
// Store the first word of the command as the key
const commandPrefix = identifier.split(" ")[0]
this.autoApprovedCommands.add(commandPrefix)
break
case "tool":
this.autoApprovedTools.add(identifier)
break
case "use_mcp_server":
this.autoApprovedMcpServers.add(identifier)
break
}
}
/**
* Check if an operation has been auto-approved.
*
* @param askType - The Cline ask type
* @param identifier - The identifier for the operation
* @returns True if the operation was previously auto-approved
*/
isAutoApproved(askType: ClineAsk, identifier: string): boolean {
switch (askType) {
case "command":
const commandPrefix = identifier.split(" ")[0]
return this.autoApprovedCommands.has(commandPrefix)
case "tool":
return this.autoApprovedTools.has(identifier)
case "use_mcp_server":
return this.autoApprovedMcpServers.has(identifier)
default:
return false
}
}
/**
* Clear all auto-approval records.
*/
clear(): void {
this.autoApprovedCommands.clear()
this.autoApprovedTools.clear()
this.autoApprovedMcpServers.clear()
}
}
/**
* Process a pending permission request for a session.
*
* This function coordinates the permission flow:
* 1. Checks if the operation is already auto-approved
* 2. If not, requests permission from the ACP client
* 3. Tracks "always allow" decisions
* 4. Returns the translated result for Cline
*
* @param requestPermission - Function to request permission from the ACP client
* @param sessionId - The session ID
* @param toolCall - The tool call requiring permission
* @param askType - The Cline ask type
* @param identifier - Identifier for auto-approval tracking
* @param autoApprovalTracker - The auto-approval tracker
* @returns The permission handler result
*/
export async function processPermissionRequest(
requestPermission: (
sessionId: string,
toolCall: acp.ToolCall,
options: acp.PermissionOption[],
) => Promise<acp.RequestPermissionResponse>,
sessionId: string,
toolCall: acp.ToolCall,
askType: ClineAsk,
identifier: string,
autoApprovalTracker?: AutoApprovalTracker,
): Promise<PermissionHandlerResult> {
// Check if already auto-approved
if (autoApprovalTracker?.isAutoApproved(askType, identifier)) {
return {
response: "yesButtonClicked",
alwaysAllow: true,
}
}
// Get permission options for this ask type
const options = getPermissionOptionsForAskType(askType)
if (!options) {
// No permission options defined - allow by default
return {
response: "yesButtonClicked",
}
}
// Request permission from the ACP client
const response = await requestPermission(sessionId, toolCall, options)
// Handle the response
const result = handlePermissionResponse(response, askType)
// Track "always allow" decisions
if (result.alwaysAllow && autoApprovalTracker) {
autoApprovalTracker.recordAlwaysAllow(askType, identifier)
}
return result
}
/**
* Get the identifier for auto-approval tracking from a tool call.
*
* @param toolCall - The ACP tool call
* @param askType - The Cline ask type
* @returns The identifier string for auto-approval tracking
*/
export function getAutoApprovalIdentifier(toolCall: acp.ToolCall, askType: ClineAsk): string {
const rawInput = toolCall.rawInput as Record<string, unknown> | undefined
switch (askType) {
case "command":
return (rawInput?.command as string) || toolCall.title
case "tool":
// Try to get tool name from raw input or title
return (rawInput?.tool as string) || toolCall.title
case "use_mcp_server":
return (rawInput?.serverName as string) || toolCall.title
default:
return toolCall.toolCallId
}
}
/**
* Update the session state's pending tool call after permission is handled.
*
* @param sessionState - The session state to update
* @param toolCallId - The tool call ID that was handled
* @param approved - Whether the permission was approved
*/
export function updateSessionStateAfterPermission(sessionState: AcpSessionState, toolCallId: string, approved: boolean): void {
// Remove from pending tool calls
sessionState.pendingToolCalls.delete(toolCallId)
// Clear current tool call ID if it matches
if (sessionState.currentToolCallId === toolCallId && !approved) {
sessionState.currentToolCallId = undefined
}
}
+247
View File
@@ -0,0 +1,247 @@
/**
* Custom types and extensions for ACP integration with Cline CLI.
*
* This file extends the base ACP types with Cline-specific functionality.
*/
import type * as acp from "@agentclientprotocol/sdk"
import type { Controller } from "@/core/controller"
// ============================================================
// Session Update Type Utilities
// ============================================================
/**
* Extract the sessionUpdate discriminator value from a SessionUpdate variant.
*/
export type SessionUpdateType = acp.SessionUpdate["sessionUpdate"]
/**
* Extract the payload type for a given sessionUpdate discriminator value.
* This removes the `sessionUpdate` discriminator field from the type.
*/
export type SessionUpdatePayload<T extends SessionUpdateType> = Omit<
Extract<acp.SessionUpdate, { sessionUpdate: T }>,
"sessionUpdate"
>
// ============================================================
// Permission Handler Callback Types
// ============================================================
/**
* Callback to resolve a permission request with the user's response.
*/
export type PermissionResolver = (response: acp.RequestPermissionResponse) => void
/**
* Handler function for permission requests.
* Called when the agent needs permission for a tool call.
* The handler should present the request to the user and call resolve() with their response.
*/
export type PermissionHandler = (request: Omit<acp.RequestPermissionRequest, "sessionId">, resolve: PermissionResolver) => void
// ============================================================
// Session Event Emitter Types
// ============================================================
/**
* Maps ACP SessionUpdate types to their event listener signatures.
* Uses the sessionUpdate discriminator to derive event names and payload types.
*/
export type ClineSessionEvents = {
[K in SessionUpdateType]: (payload: SessionUpdatePayload<K>) => void
} & {
/** Error event for session-level errors (not part of ACP SessionUpdate) */
error: (error: Error) => void
}
// ============================================================
// ClineAgent Options (decoupled from connection)
// ============================================================
/**
* Options for creating a ClineAgent instance (decoupled from connection).
*/
export interface ClineAgentOptions {
/** CLI version string */
version: string
/** Whether debug logging is enabled */
debug?: boolean
}
// Re-export common ACP types for convenience
export type {
Agent,
AgentSideConnection,
AudioContent,
CancelNotification,
ContentBlock,
ImageContent,
InitializeRequest,
InitializeResponse,
LoadSessionRequest,
LoadSessionResponse,
McpServer,
ModelInfo,
NewSessionRequest,
NewSessionResponse,
PermissionOption,
PermissionOptionKind,
PromptRequest,
PromptResponse,
ReadTextFileRequest,
ReadTextFileResponse,
RequestPermissionRequest,
RequestPermissionResponse,
SessionConfigOption,
SessionModelState,
SessionNotification,
SessionUpdate,
SetSessionConfigOptionRequest,
SetSessionConfigOptionResponse,
SetSessionModelRequest,
SetSessionModelResponse,
SetSessionModeRequest,
SetSessionModeResponse,
StopReason,
TextContent,
ToolCall,
ToolCallStatus,
ToolCallUpdate,
ToolKind,
WriteTextFileRequest,
WriteTextFileResponse,
} from "@agentclientprotocol/sdk"
/**
* Cline-specific agent capabilities extending the ACP base capabilities.
*/
export interface ClineAgentCapabilities {
/** Support for loading sessions from disk */
loadSession: boolean
/** Prompt capabilities for the agent */
promptCapabilities: {
/** Support for image inputs */
image: boolean
/** Support for audio inputs */
audio: boolean
/** Support for embedded context (file resources) */
embeddedContext: boolean
}
/** MCP server passthrough capabilities */
mcpCapabilities: {
/** Support for HTTP MCP servers */
http: boolean
/** Support for SSE MCP servers */
sse: boolean
}
}
/**
* Cline agent info for ACP initialization response.
*/
export interface ClineAgentInfo {
name: "cline"
title: "Cline"
version: string
}
/**
* Extended session data stored by Cline for ACP sessions.
* Maps to Cline's task history structure.
*/
export interface ClineAcpSession {
/** Unique session/task ID */
sessionId: string
/** Working directory for the session */
cwd: string
/** Current mode (plan/act) */
mode: "plan" | "act"
/** MCP servers passed from the client */
mcpServers: acp.McpServer[]
/** Timestamp when session was created */
createdAt: number
/** Timestamp of last activity */
lastActivityAt: number
/** Whether this session was loaded from history (needs resume on first prompt) */
isLoadedFromHistory?: boolean
/** Controller instance for this session (manages task execution) */
controller?: Controller
/** Model ID override for plan mode (format: "provider/modelId") */
planModeModelId?: string
/** Model ID override for act mode (format: "provider/modelId") */
actModeModelId?: string
}
/**
* Permission option as presented to the ACP client.
*/
export interface ClinePermissionOption {
kind: acp.PermissionOptionKind
name: string
optionId: string
}
/**
* Mapping of Cline message types to their ACP session update equivalents.
*/
export type ClineToAcpUpdateMapping = {
/** Text messages from the agent */
text: "agent_message_chunk"
/** Reasoning/thinking from the agent */
reasoning: "agent_thought_chunk"
/** Markdown content from the agent */
markdown: "agent_message_chunk"
/** Tool execution */
tool: "tool_call"
/** Command execution */
command: "tool_call"
/** Command output */
command_output: "tool_call_update"
/** Task completion */
completion_result: "end_turn"
/** Error messages */
error: "tool_call_update" | "error"
}
/**
* Options for creating an ACP agent instance.
*/
export interface AcpAgentOptions {
/** CLI version string */
version: string
/** Whether debug logging is enabled */
debug?: boolean
}
/**
* Result of translating a Cline message to ACP session update(s).
* A single Cline message may produce multiple ACP updates.
*/
export interface TranslatedMessage {
/** The session updates to send */
updates: acp.SessionUpdate[]
/** Whether this message requires a permission request */
requiresPermission?: boolean
/** Permission request details if required */
permissionRequest?: Omit<acp.RequestPermissionRequest, "sessionId">
/** The toolCallId that was created/used (for tracking across streaming updates) */
toolCallId?: string
}
/**
* State tracking for an active ACP session within Cline.
*/
export interface AcpSessionState {
/** Session ID */
sessionId: string
/** Whether the session is currently processing a prompt */
isProcessing: boolean
/** Current tool call ID being executed (if any) */
currentToolCallId?: string
/** Whether the session has been cancelled */
cancelled: boolean
/** Accumulated tool calls for permission batching */
pendingToolCalls: Map<string, acp.ToolCall>
}
+10 -2
View File
@@ -432,8 +432,16 @@ export const ChatView: React.FC<ChatViewProps> = ({
const toggleMode = useCallback(async () => {
const newMode: Mode = mode === "act" ? "plan" : "act"
setMode(newMode)
await ctrl.togglePlanActMode(newMode)
}, [mode, ctrl])
// When switching from plan to act, include any text in the input box
// Text stays visible in the input - don't clear it
if (newMode === "act" && textInput.trim()) {
const expandedText = expandPastedTexts(textInput, pastedTexts)
await ctrl.togglePlanActMode(newMode, { message: expandedText.trim() })
} else {
await ctrl.togglePlanActMode(newMode)
}
}, [mode, ctrl, textInput, pastedTexts])
// Clear the terminal view and reset task state (used by /clear and "Start New Task" button)
const clearViewAndResetTask = useCallback(() => {
+62 -12
View File
@@ -2,7 +2,6 @@
* Cline CLI - TypeScript implementation with React Ink
*/
import path from "node:path"
import { exit } from "node:process"
import type { ApiProvider } from "@shared/api"
import { Command } from "commander"
@@ -25,6 +24,7 @@ import { Session } from "@/shared/services/Session"
import { getProviderModelIdKey, ProviderToApiKeyMap } from "@/shared/storage"
import { secretStorage } from "@/shared/storage/ClineSecretStorage"
import { version as CLI_VERSION } from "../package.json"
import { runAcpMode } from "./acp/index.js"
import { App } from "./components/App"
import { checkRawModeSupport } from "./context/StdinContext"
import { createCliHostBridgeProvider } from "./controllers"
@@ -34,7 +34,7 @@ import { restoreConsole } from "./utils/console"
import { calculateRobotTopRow, queryCursorPos } from "./utils/cursor-position"
import { printInfo, printWarning } from "./utils/display"
import { parseImagesFromInput, processImagePaths } from "./utils/parser"
import { CLINE_CLI_DIR } from "./utils/path"
import { CLINE_CLI_DIR, getCliBinaryPath } from "./utils/path"
import { readStdinIfPiped } from "./utils/piped"
import { runPlainTextTask } from "./utils/plain-text-task"
import { printSessionSummary } from "./utils/session-summary"
@@ -45,6 +45,24 @@ import { CLI_LOG_FILE, shutdownEvent, window } from "./vscode-shim"
// Track active context for graceful shutdown
let activeContext: CliContext | null = null
let isShuttingDown = false
// Track if we're in plain text mode (no Ink UI) - set by runTask when piped stdin detected
let isPlainTextMode = false
/**
* Wait for stdout to fully drain before exiting.
* Critical for piping - ensures data is flushed to the next command in the pipe.
*/
async function drainStdout(): Promise<void> {
return new Promise<void>((resolve) => {
// Check if stdout needs draining
if (process.stdout.writableNeedDrain) {
process.stdout.once("drain", resolve)
} else {
// Give a small delay to ensure any pending writes complete
setImmediate(resolve)
}
})
}
function setupSignalHandlers() {
const shutdown = async (signal: string) => {
@@ -57,10 +75,15 @@ function setupSignalHandlers() {
// Notify components to hide UI before shutdown
shutdownEvent.fire()
// Clear several lines to remove the input field and footer from display
// Move cursor up and clear lines (input box + footer rows)
const linesToClear = 8 // Input box (3 lines with border) + footer (4-5 lines)
process.stdout.write(`\x1b[${linesToClear}A\x1b[J`)
// Only clear Ink UI lines if we're not in plain text mode
// In plain text mode, there's no Ink UI to clear and the ANSI codes
// would corrupt the streaming output
if (!isPlainTextMode) {
// Clear several lines to remove the input field and footer from display
// Move cursor up and clear lines (input box + footer rows)
const linesToClear = 8 // Input box (3 lines with border) + footer (4-5 lines)
process.stdout.write(`\x1b[${linesToClear}A\x1b[J`)
}
printWarning(`${signal} received, shutting down...`)
@@ -153,7 +176,7 @@ async function initializeCli(options: InitOptions): Promise<CliContext> {
createCliHostBridgeProvider(workspacePath),
logToChannel,
async () => (options.enableAuth ? AuthHandler.getInstance().getCallbackUrl() : ""),
async (name: string) => path.join(process.cwd(), name),
getCliBinaryPath,
EXTENSION_DIR,
DATA_DIR,
)
@@ -219,6 +242,7 @@ async function runTask(
config?: string
thinking?: boolean
yolo?: boolean
timeout?: string
images?: string[]
json?: boolean
stdinWasPiped?: boolean
@@ -288,11 +312,14 @@ async function runTask(
// Detect if output is a TTY (interactive terminal) or redirected to a file/pipe
const isTTY = process.stdout.isTTY === true
// Use plain text mode when output is redirected, stdin was piped, or JSON mode is enabled
// Use plain text mode when output is redirected, stdin was piped, JSON mode is enabled, or --yolo flag is used
// Ink requires raw mode on stdin which isn't available when stdin is piped
// Note: we use the stdinWasPiped flag passed from the caller because process.stdin.isTTY
// may not be reliable after stdin has been consumed by readStdinIfPiped()
if (!isTTY || options.stdinWasPiped || options.json) {
if (!isTTY || options.stdinWasPiped || options.json || options.yolo) {
// Set flag so shutdown handler knows not to clear Ink UI lines
isPlainTextMode = true
// Check if auth is configured before attempting to run the task
// In plain text mode we can't show the interactive auth flow
const hasAuth = await isAuthConfigured()
@@ -304,7 +331,13 @@ async function runTask(
exit(1)
}
const reason = options.json ? "json" : options.stdinWasPiped ? "piped_stdin" : "redirected_output"
const reason = options.yolo
? "yolo_flag"
: options.json
? "json"
: options.stdinWasPiped
? "piped_stdin"
: "redirected_output"
telemetryService.captureHostEvent("plain_text_mode", reason)
// Plain text mode: no Ink rendering, just clean text output
const success = await runPlainTextTask({
@@ -313,12 +346,16 @@ async function runTask(
imageDataUrls: imageDataUrls.length > 0 ? imageDataUrls : undefined,
verbose: options.verbose,
jsonOutput: options.json,
timeoutSeconds: options.timeout ? parseInt(options.timeout, 10) : undefined,
})
// Cleanup
await ctx.controller.stateManager.flushPendingState()
await ctx.controller.dispose()
await ErrorService.get().dispose()
// Ensure stdout is fully drained before exiting - critical for piping
await drainStdout()
exit(success ? 0 : 1)
}
@@ -505,7 +542,8 @@ program
.argument("<prompt>", "The task prompt")
.option("-a, --act", "Run in act mode")
.option("-p, --plan", "Run in plan mode")
.option("-y, --yolo", "Enable yolo mode (auto-approve actions)")
.option("-y, --yolo", "Enable yes/yolo mode (auto-approve actions)")
.option("-t, --timeout <seconds>", "Timeout in seconds for yes/yolo mode (default: 600)")
.option("-m, --model <model>", "Model to use for the task")
.option("-v, --verbose", "Show verbose output")
.option("-c, --cwd <path>", "Working directory for the task")
@@ -652,17 +690,29 @@ program
.option("-a, --act", "Run in act mode")
.option("-p, --plan", "Run in plan mode")
.option("-y, --yolo", "Enable yolo mode (auto-approve actions)")
.option("-t, --timeout <seconds>", "Timeout in seconds for yolo mode (default: 600)")
.option("-m, --model <model>", "Model to use for the task")
.option("-v, --verbose", "Show verbose output")
.option("-c, --cwd <path>", "Working directory")
.option("--config <path>", "Configuration directory")
.option("--thinking", "Enable extended thinking (1024 token budget)")
.option("--json", "Output messages as JSON instead of styled text")
.option("--acp", "Run in ACP (Agent Client Protocol) mode for editor integration")
.action(async (prompt, options) => {
// Check for ACP mode first - this takes precedence over everything else
if (options.acp) {
await runAcpMode({
config: options.config,
cwd: options.cwd,
verbose: options.verbose,
})
return
}
// Always check for piped stdin content
const stdinInput = await readStdinIfPiped()
// Combine stdin content with prompt argument
// If no prompt argument, check if input is piped via stdin
let effectivePrompt = prompt
if (stdinInput) {
if (effectivePrompt) {
+35
View File
@@ -1,3 +1,4 @@
import { execFileSync } from "node:child_process"
import os from "node:os"
import path from "node:path"
@@ -9,3 +10,37 @@ export const CLINE_CLI_DIR = {
data,
log,
}
/**
* Find binary location for CLI.
* Uses 'which' (Unix) or 'where' (Windows) to locate binaries in the system PATH.
* This is needed for tools like ripgrep that the search_files tool uses.
*/
export async function getCliBinaryPath(name: string): Promise<string> {
// The only binary currently supported is ripgrep (rg)
if (!name.startsWith("rg")) {
throw new Error(`Binary '${name}' is not supported`)
}
const isWindows = process.platform === "win32"
const whichCommand = isWindows ? "where" : "which"
try {
const result = execFileSync(whichCommand, [name], {
encoding: "utf-8",
stdio: ["pipe", "pipe", "pipe"],
})
// 'which' returns the path, 'where' on Windows may return multiple lines
const binPath = result.trim().split("\n")[0].trim()
if (binPath) {
return binPath
}
} catch {
// Binary not found in PATH
}
throw new Error(
`Could not find '${name}' in system PATH. ` +
`Please install ripgrep: https://github.com/BurntSushi/ripgrep#installation`,
)
}
+41 -235
View File
@@ -1,41 +1,54 @@
import { Readable } from "node:stream"
import * as fs from "node:fs"
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
import { readStdinIfPiped } from "./piped"
// Mock the fs module
vi.mock("node:fs", () => ({
readFileSync: vi.fn(),
}))
describe("readStdinIfPiped", () => {
let mockStdin: Readable & { isTTY?: boolean }
const mockReadFileSync = fs.readFileSync as ReturnType<typeof vi.fn>
let originalIsTTY: boolean | undefined
beforeEach(() => {
// Create a mock readable stream
mockStdin = new Readable({
read() {},
}) as Readable & { isTTY?: boolean }
// Mock process.stdin by stubbing its properties
vi.spyOn(process, "stdin", "get").mockReturnValue(mockStdin as any)
vi.clearAllMocks()
originalIsTTY = process.stdin.isTTY
})
afterEach(() => {
vi.restoreAllMocks()
// Restore original isTTY value
Object.defineProperty(process.stdin, "isTTY", {
value: originalIsTTY,
writable: true,
configurable: true,
})
})
function setTTY(value: boolean | undefined) {
Object.defineProperty(process.stdin, "isTTY", {
value,
writable: true,
configurable: true,
})
}
describe("TTY detection", () => {
it("should return null when stdin is a TTY (interactive terminal)", async () => {
mockStdin.isTTY = true
setTTY(true)
const result = await readStdinIfPiped()
expect(result).toBeNull()
expect(mockReadFileSync).not.toHaveBeenCalled()
})
it("should attempt to read when stdin is not a TTY (piped input)", async () => {
mockStdin.isTTY = false
// Simulate immediate end event (no data)
setImmediate(() => {
mockStdin.emit("end")
})
setTTY(false)
mockReadFileSync.mockReturnValue("")
const result = await readStdinIfPiped()
expect(result).toBeNull()
expect(mockReadFileSync).toHaveBeenCalledWith(0, "utf8")
})
})
@@ -112,14 +125,9 @@ describe("readStdinIfPiped", () => {
testCases.forEach(({ name, input, expected, description }) => {
it(`${name}${description ? ` - ${description}` : ""}`, async () => {
mockStdin.isTTY = false
// Simulate piped data
setImmediate(() => {
const data = Array.isArray(input) ? input.join("\n") : input
mockStdin.push(data)
mockStdin.push(null) // Signal end of stream
})
setTTY(false)
const data = Array.isArray(input) ? input.join("\n") : input
mockReadFileSync.mockReturnValue(data)
const result = await readStdinIfPiped()
expect(result).toBe(expected)
@@ -127,217 +135,19 @@ describe("readStdinIfPiped", () => {
})
})
describe("chunked data", () => {
it("should accumulate data from multiple chunks", async () => {
mockStdin.isTTY = false
setImmediate(() => {
mockStdin.emit("data", "chunk1 ")
mockStdin.emit("data", "chunk2 ")
mockStdin.emit("data", "chunk3")
mockStdin.emit("end")
})
const result = await readStdinIfPiped()
expect(result).toBe("chunk1 chunk2 chunk3")
})
it("should handle rapid successive chunks", async () => {
mockStdin.isTTY = false
setImmediate(() => {
for (let i = 0; i < 100; i++) {
mockStdin.emit("data", `${i} `)
}
mockStdin.emit("end")
})
const result = await readStdinIfPiped()
expect(result).toContain("0 ")
expect(result).toContain("99")
})
})
describe("timeout behavior", () => {
it("should timeout after 100ms if no data received", async () => {
mockStdin.isTTY = false
// Don't emit any events - let it timeout
const startTime = Date.now()
const result = await readStdinIfPiped()
const elapsed = Date.now() - startTime
expect(result).toBeNull()
expect(elapsed).toBeGreaterThanOrEqual(95) // Allow small margin
expect(elapsed).toBeLessThan(150)
})
it("should return data received before timeout", async () => {
mockStdin.isTTY = false
setTimeout(() => {
mockStdin.emit("data", "quick data")
// Don't emit end - let it timeout
}, 50)
const result = await readStdinIfPiped()
expect(result).toBe("quick data")
})
it("should not timeout if end event is received", async () => {
mockStdin.isTTY = false
// Delay end event but emit it before timeout
setTimeout(() => {
mockStdin.emit("data", "delayed data")
mockStdin.emit("end")
}, 50)
const result = await readStdinIfPiped()
expect(result).toBe("delayed data")
})
})
describe("error handling", () => {
it("should return null on stdin error", async () => {
mockStdin.isTTY = false
setImmediate(() => {
mockStdin.emit("error", new Error("stdin read error"))
it("should return null on fs.readFileSync error and fall back to async", async () => {
setTTY(false)
mockReadFileSync.mockImplementation(() => {
throw new Error("EAGAIN: resource temporarily unavailable")
})
// The async fallback will timeout since we can't easily mock process.stdin events
// But we can verify it doesn't throw
const result = await readStdinIfPiped()
// Result will be null because async path times out with no data
expect(result).toBeNull()
})
it("should handle error after partial data received", async () => {
mockStdin.isTTY = false
setImmediate(() => {
mockStdin.emit("data", "partial data")
mockStdin.emit("error", new Error("read error"))
})
const result = await readStdinIfPiped()
expect(result).toBeNull()
})
it("should clean up listeners on error", async () => {
mockStdin.isTTY = false
setImmediate(() => {
mockStdin.emit("error", new Error("test error"))
})
await readStdinIfPiped()
// Note: Implementation uses removeAllListeners() without event names
// which should remove all listeners, but in practice there may be one remaining
// This is acceptable behavior for error handling
expect(mockStdin.listenerCount("data")).toBeLessThanOrEqual(1)
expect(mockStdin.listenerCount("end")).toBeLessThanOrEqual(1)
expect(mockStdin.listenerCount("error")).toBeLessThanOrEqual(1)
})
})
describe("listener cleanup", () => {
it("should remove all listeners on successful completion", async () => {
mockStdin.isTTY = false
setImmediate(() => {
mockStdin.emit("data", "test data")
mockStdin.emit("end")
})
await readStdinIfPiped()
// Note: Implementation doesn't explicitly clean up listeners on normal end,
// so some listeners may remain attached. This is acceptable for one-time use.
expect(mockStdin.listenerCount("data")).toBeLessThanOrEqual(1)
expect(mockStdin.listenerCount("end")).toBeLessThanOrEqual(1)
expect(mockStdin.listenerCount("error")).toBeLessThanOrEqual(1)
})
it("should remove all listeners on timeout", async () => {
mockStdin.isTTY = false
// Let it timeout
await readStdinIfPiped()
// Verify listeners are cleaned up
expect(mockStdin.listenerCount("data")).toBe(0)
expect(mockStdin.listenerCount("end")).toBe(0)
expect(mockStdin.listenerCount("error")).toBe(0)
})
it("should clear timeout when data ends normally", async () => {
mockStdin.isTTY = false
const clearTimeoutSpy = vi.spyOn(global, "clearTimeout")
setImmediate(() => {
mockStdin.emit("data", "test")
mockStdin.emit("end")
})
await readStdinIfPiped()
expect(clearTimeoutSpy).toHaveBeenCalled()
})
it("should clear timeout when error occurs", async () => {
mockStdin.isTTY = false
const clearTimeoutSpy = vi.spyOn(global, "clearTimeout")
setImmediate(() => {
mockStdin.emit("error", new Error("test"))
})
await readStdinIfPiped()
expect(clearTimeoutSpy).toHaveBeenCalled()
})
})
describe("encoding", () => {
it("should handle UTF-8 encoded data", async () => {
mockStdin.isTTY = false
setImmediate(() => {
// The function sets utf8 encoding
mockStdin.setEncoding("utf8")
mockStdin.emit("data", "UTF-8: café ☕")
mockStdin.emit("end")
})
const result = await readStdinIfPiped()
expect(result).toBe("UTF-8: café ☕")
})
})
describe("stdin resume", () => {
it("should call resume on stdin when not TTY", async () => {
mockStdin.isTTY = false
const resumeSpy = vi.spyOn(mockStdin, "resume")
setImmediate(() => {
mockStdin.emit("end")
})
await readStdinIfPiped()
expect(resumeSpy).toHaveBeenCalled()
})
it("should not call resume when TTY", async () => {
mockStdin.isTTY = true
const resumeSpy = vi.spyOn(mockStdin, "resume")
const result = await readStdinIfPiped()
expect(result).toBeNull()
expect(resumeSpy).not.toHaveBeenCalled()
})
})
describe("real-world use cases", () => {
@@ -377,12 +187,8 @@ describe("readStdinIfPiped", () => {
useCases.forEach(({ name, input, expected }) => {
it(`should handle ${name}`, async () => {
mockStdin.isTTY = false
setImmediate(() => {
mockStdin.push(input)
mockStdin.push(null)
})
setTTY(false)
mockReadFileSync.mockReturnValue(input)
const result = await readStdinIfPiped()
expect(result).toBe(expected)
+39 -32
View File
@@ -1,7 +1,12 @@
import * as fs from "node:fs"
/**
* Read piped input from stdin (non-blocking)
*
* This function is designed to work with piped input, including chained commands:
* git diff | cline 'explain' | cline 'summarize'
*
* The challenge is that when chaining cline commands, the first command may take
* several seconds to complete, so we can't use a short timeout. Instead, we wait
* for EOF which signals that the previous command has finished writing.
*/
export async function readStdinIfPiped(): Promise<string | null> {
// Check if stdin is a TTY (interactive) or piped
@@ -9,39 +14,41 @@ export async function readStdinIfPiped(): Promise<string | null> {
return null
}
try {
// Use synchronous read for reliability with piped input
// fd 0 is stdin
const data = fs.readFileSync(0, "utf8")
return data.trim() || null
} catch {
// Fallback to async approach if sync read fails
return new Promise((resolve) => {
let data = ""
process.stdin.setEncoding("utf8")
// Use async approach - more reliable for piped input from other commands
// The synchronous readFileSync(0) can fail with EAGAIN when the pipe
// isn't ready yet (common when piping from another cline command)
return new Promise((resolve) => {
let data = ""
process.stdin.setEncoding("utf8")
// Set a timeout in case stdin is not actually providing data
const timeout = setTimeout(() => {
// For piped input, we wait for EOF (end event) which signals the
// previous command in the pipe has finished writing. We use a longer
// timeout as a safety net for cases where stdin is opened but never
// written to (e.g., some edge cases with file descriptors).
// 5 minutes should be more than enough for any reasonable pipeline.
const timeout = setTimeout(
() => {
process.stdin.removeAllListeners()
resolve(data.trim() || null)
}, 1000)
},
5 * 60 * 1000,
) // 5 minutes
process.stdin.on("data", (chunk) => {
data += chunk
})
process.stdin.on("end", () => {
clearTimeout(timeout)
resolve(data.trim() || null)
})
process.stdin.on("error", () => {
clearTimeout(timeout)
resolve(null)
})
// Resume stdin in case it's paused
process.stdin.resume()
process.stdin.on("data", (chunk) => {
data += chunk
})
}
process.stdin.on("end", () => {
clearTimeout(timeout)
resolve(data.trim() || null)
})
process.stdin.on("error", () => {
clearTimeout(timeout)
resolve(null)
})
// Resume stdin in case it's paused
process.stdin.resume()
})
}
+129 -172
View File
@@ -1,15 +1,20 @@
/**
* Plain-text task runner for non-TTY environments (piped output, file redirection)
* Outputs clean text without ANSI codes or Ink rendering
* Optimized for CI/CD and piping - only outputs the final completion result to stdout.
*
* Design goals:
* - stdout: Only the final completion result text (no prefix) - perfect for piping
* - stderr: Errors and verbose output (won't break pipes)
* - Enables workflows like: git diff | cline 'explain' | cline 'summarize'
*/
/* eslint-disable no-console */
// Console output is intentional here for plain text mode
import { registerPartialMessageCallback } from "@core/controller/ui/subscribeToPartialMessage"
import type { ClineMessage } from "@shared/ExtensionMessage"
import type { ClineMessage, ExtensionState } from "@shared/ExtensionMessage"
import type { Controller } from "@/core/controller"
import { setTerminalTitle } from "./display"
import { getRequestRegistry } from "@/core/controller/grpc-handler"
import { subscribeToState } from "@/core/controller/state/subscribeToState"
export interface PlainTextTaskOptions {
controller: Controller
@@ -17,209 +22,161 @@ export interface PlainTextTaskOptions {
imageDataUrls?: string[]
verbose?: boolean
jsonOutput?: boolean
/** Timeout in seconds (default: 600 = 10 minutes) */
timeoutSeconds?: number
}
/**
* Run a task with plain text output (no Ink, no ANSI codes)
* Returns true if task completed successfully, false if error
*
* Output behavior:
* - Non-JSON mode: Only writes final completion_result text to stdout
* - JSON mode: Streams JSON lines to stdout as messages arrive (unchanged)
* - Verbose mode: Progress info goes to stderr
* - Errors: Always go to stderr
*/
export async function runPlainTextTask(options: PlainTextTaskOptions): Promise<boolean> {
const { controller, prompt, imageDataUrls, verbose, jsonOutput } = options
// Track completion state
let isComplete = false
let hasError = false
const processedMessages = new Map<number, number>() // index -> last output text length
let lastStreamingMessageIndex = -1 // track open streaming line that needs closing
// Subscribe to state updates
const originalPostState = controller.postStateToWebview.bind(controller)
const handleStateUpdate = async () => {
try {
const state = await controller.getStateToPostToWebview()
const messages = state.clineMessages || []
// Process new messages
for (let i = 0; i < messages.length; i++) {
const message = messages[i]
const currentTextLength = message.text?.length ?? 0
const lastOutputLength = processedMessages.get(i) ?? 0
// Skip if no new content to output
if (currentTextLength <= lastOutputLength) continue
// Close previous streaming line if we're moving to a different message
if (lastStreamingMessageIndex >= 0 && lastStreamingMessageIndex !== i && !jsonOutput) {
process.stdout.write("\n")
lastStreamingMessageIndex = -1
}
processedMessages.set(i, currentTextLength)
// Output the message
if (jsonOutput) {
process.stdout.write(JSON.stringify(message) + "\n")
} else {
const isStreaming = outputMessageAsText(message, verbose || false, lastOutputLength)
// Track streaming state for text messages
if (isStreaming) {
lastStreamingMessageIndex = i
} else {
lastStreamingMessageIndex = -1
}
}
// Check for completion
if (
message.say === "completion_result" ||
message.ask === "completion_result" ||
message.say === "error" ||
message.ask === "api_req_failed"
) {
isComplete = true
if (message.say === "error" || message.ask === "api_req_failed") {
hasError = true
}
}
}
// Close streaming line on completion
if (isComplete && lastStreamingMessageIndex >= 0 && !jsonOutput) {
process.stdout.write("\n")
lastStreamingMessageIndex = -1
}
} catch (error) {
if (jsonOutput) {
process.stdout.write(
JSON.stringify({ type: "error", message: error instanceof Error ? error.message : String(error) }) + "\n",
)
} else {
process.stderr.write(`Error: ${error instanceof Error ? error.message : String(error)}` + "\n")
}
hasError = true
isComplete = true
}
}
// Override postStateToWebview to capture state updates
controller.postStateToWebview = async () => {
await originalPostState()
await handleStateUpdate()
}
// Subscribe to partial message updates (for streaming)
const unsubscribePartial = registerPartialMessageCallback(() => {
// Partial updates are handled via postStateToWebview
let completionResolve: () => void
let completionReject: (reason?: any) => void
const completionPromise = new Promise<void>((res, rej) => {
completionResolve = res
completionReject = rej
})
let hasError = false
// Track which messages have been processed (by timestamp)
const processedMessages = new Map<number, string>()
// Helper to process a message and track completion state
const processMessage = (message: ClineMessage) => {
const ts = message.ts || 0
if (message.partial || processedMessages.has(ts)) {
return
}
// JSON mode: stream all messages to stdout (existing behavior)
if (jsonOutput) {
process.stdout.write(JSON.stringify(message) + "\n")
} else {
handleMessageForPipeMode(message, verbose || false)
}
processedMessages.set(ts, message.text ?? "")
// Check for completion (only on non-partial messages)
if (message.say === "completion_result" || message.ask === "completion_result") {
completionResolve()
} else if (message.say === "error" || message.ask === "api_req_failed") {
completionReject(message.text ?? "message.say error || message.ask api_req_failed")
}
}
const requestId = "cline-cli-plain-text-task"
subscribeToState(
controller,
{},
async ({ stateJson }) => {
try {
const state = JSON.parse(stateJson) as ExtensionState
for (const message of state.clineMessages ?? []) {
processMessage(message)
}
} catch (error) {
if (jsonOutput) {
process.stdout.write(
JSON.stringify({ type: "error", message: error instanceof Error ? error.message : String(error) }) + "\n",
)
} else {
process.stderr.write(`Error: ${error instanceof Error ? error.message : String(error)}\n`)
}
completionReject(error)
}
},
requestId,
)
try {
// Get initial state
await handleStateUpdate()
// Set terminal title to the task prompt
setTerminalTitle(prompt)
// Start the task
await controller.initTask(prompt, imageDataUrls)
// Wait for completion with timeout
const timeout = 10 * 60 * 1000 // 10 minutes
const startTime = Date.now()
while (!isComplete && Date.now() - startTime < timeout) {
await new Promise((resolve) => setTimeout(resolve, 100))
}
if (!isComplete) {
// Close any open streaming line before error message
if (lastStreamingMessageIndex >= 0 && !jsonOutput) {
process.stdout.write("\n")
lastStreamingMessageIndex = -1
}
if (jsonOutput) {
process.stdout.write(JSON.stringify({ type: "error", message: "Task timeout" }) + "\n")
} else {
process.stderr.write("Error: Task timeout" + "\n")
}
hasError = true
}
const timeoutMs = (options.timeoutSeconds ?? 600) * 1000 // default 10 minutes
const timeoutPromise = new Promise((_, reject) => setTimeout(() => reject(new Error("Timeout")), timeoutMs))
await Promise.race([completionPromise, timeoutPromise])
} catch (error) {
const errMsg = error instanceof Error ? error.message : String(error)
if (jsonOutput) {
process.stdout.write(
JSON.stringify({ type: "error", message: error instanceof Error ? error.message : String(error) }) + "\n",
)
process.stdout.write(JSON.stringify({ type: "error", message: errMsg }) + "\n")
} else {
process.stderr.write(`Error: ${error instanceof Error ? error.message : String(error)}` + "\n")
process.stderr.write(`Error: ${errMsg}\n`)
}
hasError = true
} finally {
// Close any open streaming line
if (lastStreamingMessageIndex >= 0 && !jsonOutput) {
process.stdout.write("\n")
}
// Restore original postStateToWebview
controller.postStateToWebview = originalPostState
unsubscribePartial()
getRequestRegistry().cancelRequest(requestId)
}
// non json mode outputs only the final complete message
// (it should be the completion_result message)
if (!jsonOutput && !verbose) {
const msg = Array.from(processedMessages.entries())
.sort(([aTs], [bTs]) => aTs - bTs)
.map(([_, msg]) => msg)
.at(-1)
process.stdout.write(msg + "\n")
}
return !hasError
}
/**
* Format a Cline message as plain text
* @param previousLength - Length of text already output for this message (for streaming)
* @returns true if this is a streaming message (caller should track for newline), false otherwise
* Handle a message in pipe-optimized mode (non-JSON)
* - Assistant response text (say: "text") is passed to the callback for buffering
* - Errors go to stderr
* - Verbose output goes to stderr
* - Nothing else goes to stdout (stdout is reserved for final result only)
*/
function outputMessageAsText(message: ClineMessage, verbose: boolean, previousLength: number = 0): boolean {
const timestamp = new Date(message.ts || Date.now()).toLocaleTimeString()
function handleMessageForPipeMode(message: ClineMessage, verbose: boolean): void {
const fullText = message.text ?? ""
if (!fullText) {
// Skip partial messages without text
return false
}
// For streaming text continuations, output only new content
if (previousLength > 0 && message.type === "say" && message.say === "text") {
process.stdout.write(fullText.slice(previousLength))
return true // Still streaming
}
if (message.type === "say") {
if (message.say === "task") {
process.stdout.write(`[${timestamp}] Task: ${fullText}\n`)
} else if (message.say === "text") {
// First output of text message - write prefix but no newline (streaming)
process.stdout.write(`[${timestamp}] ${fullText}`)
return true // Streaming - newline will be added when stream ends
} else if (message.say === "completion_result" && fullText) {
process.stdout.write(`[${timestamp}] Completed: ${fullText}\n`)
} else if (message.say === "error") {
process.stderr.write(`[${timestamp}] Error: ${fullText}\n`)
} else if (message.say === "api_req_started") {
if (verbose) {
process.stdout.write(`[${timestamp}] API request started\n`)
}
} else if (message.say === "api_req_finished") {
if (verbose) {
process.stdout.write(`[${timestamp}] API request finished\n`)
}
if (message.say === "error") {
// Errors always go to stderr
process.stderr.write(`Error: ${fullText}\n`)
} else if (verbose) {
process.stdout.write(`[${timestamp}] ${message.say}: ${fullText}\n`)
// Verbose output goes to stderr so it doesn't interfere with piped stdout
if (message.say === "task") {
process.stderr.write(`${fullText}\n`)
} else if (message.say === "text" && fullText) {
process.stderr.write(`${fullText}\n`)
} else if (message.say === "api_req_started") {
process.stderr.write(`API request started\n`)
} else if (message.say === "api_req_finished") {
process.stderr.write(`API request finished\n`)
} else if (message.say === "completion_result" && fullText) {
process.stderr.write(`${fullText}\n`)
} else if (fullText) {
process.stderr.write(`${message.say}: ${fullText}\n`)
}
}
} else if (message.type === "ask") {
if (message.ask === "completion_result") {
process.stdout.write(`[${timestamp}] Task completed\n`)
} else if (message.ask === "api_req_failed") {
process.stderr.write(`[${timestamp}] API request failed: ${fullText}\n`)
if (message.ask === "api_req_failed") {
// Errors always go to stderr
process.stderr.write(`Error: API request failed: ${fullText}\n`)
} else if (message.ask === "tool" || message.ask === "command" || message.ask === "browser_action_launch") {
// These require approval - in non-interactive mode, warn the user
process.stderr.write(`[${timestamp}] Waiting for approval (use --yolo for auto-approve): ${message.ask}\n`)
// These require approval - warn via stderr
process.stderr.write(`Waiting for approval (use --yolo for auto-approve): ${message.ask}\n`)
} else if (verbose) {
process.stdout.write(`[${timestamp}] Question: ${fullText}\n`)
// Verbose output goes to stderr
if (message.ask === "plan_mode_respond" || message.ask === "act_mode_respond") {
if (fullText) {
process.stderr.write(`${fullText}\n`)
}
} else if (message.ask === "completion_result") {
process.stderr.write(`Task completed\n`)
} else if (fullText) {
process.stderr.write(`Question: ${fullText}\n`)
}
}
}
return false
}
+8 -1
View File
@@ -116,10 +116,17 @@ function hashString(str: string): string {
return Math.abs(hash).toString(16).substring(0, 8)
}
export interface CliContextResult {
extensionContext: ClineExtensionContext
DATA_DIR: string
EXTENSION_DIR: string
WORKSPACE_STORAGE_DIR: string
}
/**
* Initialize the VSCode-like context for CLI mode
*/
export function initializeCliContext(config: CliContextConfig = {}) {
export function initializeCliContext(config: CliContextConfig = {}): CliContextResult {
const CLINE_DIR = config.clineDir || process.env.CLINE_DIR || path.join(os.homedir(), ".cline")
const DATA_DIR = path.join(CLINE_DIR, SETTINGS_SUBFOLDER)
+10
View File
@@ -163,6 +163,7 @@
"version": "2.0.0",
"license": "Apache-2.0",
"dependencies": {
"@agentclientprotocol/sdk": "^0.13.1",
"aws4fetch": "^1.0.20",
"chalk": "^5.3.0",
"commander": "^12.1.0",
@@ -207,6 +208,15 @@
"url": "https://dotenvx.com"
}
},
"node_modules/@agentclientprotocol/sdk": {
"version": "0.13.1",
"resolved": "https://registry.npmjs.org/@agentclientprotocol/sdk/-/sdk-0.13.1.tgz",
"integrity": "sha512-6byvu+F/xc96GBkdAx4hq6/tB3vT63DSBO4i3gYCz8nuyZMerVFna2Gkhm8EHNpZX0J9DjUxzZCW+rnHXUg0FA==",
"license": "Apache-2.0",
"peerDependencies": {
"zod": "^3.25.0 || ^4.0.0"
}
},
"node_modules/@alcalzone/ansi-tokenize": {
"version": "0.2.3",
"resolved": "https://registry.npmjs.org/@alcalzone/ansi-tokenize/-/ansi-tokenize-0.2.3.tgz",
@@ -1,4 +1,5 @@
import { ClineDefaultTool, toolUseNames } from "@shared/tools"
import { nanoid } from "nanoid"
import { AssistantMessageContent, TextStreamContent, ToolParamName, ToolUse, toolParamNames } from "." // Assuming types are defined in index.ts or a similar file
// parseAssistantmessageV1 removed in https://github.com/cline/cline/pull/5425
@@ -175,6 +176,8 @@ export function parseAssistantMessageV2(assistantMessage: string): AssistantMess
name: toolName,
params: {},
partial: true, // Assume partial until closing tag is found
call_id: nanoid(8),
isNativeToolCall: false,
}
currentToolUseStart = currentCharIndex + 1 // Tool content starts after the opening tag
startedNewTool = true
+16 -11
View File
@@ -740,10 +740,14 @@ export class Task {
if (partial) {
if (isUpdatingPreviousPartial) {
// existing partial message, so update it
lastMessage.text = text
lastMessage.images = images
lastMessage.files = files
lastMessage.partial = partial
const lastIndex = this.messageStateHandler.getClineMessages().length - 1
await this.messageStateHandler.updateClineMessage(lastIndex, {
text,
images,
files,
partial,
})
const protoMessage = convertClineMessageToProto(lastMessage)
await sendPartialMessageEvent(protoMessage)
return undefined
@@ -769,14 +773,15 @@ export class Task {
if (isUpdatingPreviousPartial) {
// this is the complete version of a previously partial message, so replace the partial with the complete version
this.taskState.lastMessageTs = lastMessage.ts
// lastMessage.ts = sayTs
lastMessage.text = text
lastMessage.images = images
lastMessage.files = files // Ensure files is updated
lastMessage.partial = false
const lastIndex = this.messageStateHandler.getClineMessages().length - 1
// updateClineMessage emits the change event and saves to disk
await this.messageStateHandler.updateClineMessage(lastIndex, {
text,
images,
files,
partial: false,
})
// instead of streaming partialMessage events, we do a save and post like normal to persist to disk
await this.messageStateHandler.saveClineMessagesAndUpdateHistory()
// await this.postStateToWebview()
const protoMessage = convertClineMessageToProto(lastMessage)
await sendPartialMessageEvent(protoMessage) // more performant than an entire postStateToWebview
+72 -1
View File
@@ -1,4 +1,5 @@
import CheckpointTracker from "@integrations/checkpoints/CheckpointTracker"
import { EventEmitter } from "events"
import getFolderSize from "get-folder-size"
import Mutex from "p-mutex"
import { findLastIndex } from "@/shared/array"
@@ -13,6 +14,28 @@ import { getCwd, getDesktopDir } from "@/utils/path"
import { ensureTaskDirectoryExists, saveApiConversationHistory, saveClineMessages } from "../storage/disk"
import { TaskState } from "./TaskState"
// Event types for clineMessages changes
export type ClineMessageChangeType = "add" | "update" | "delete" | "set"
export interface ClineMessageChange {
type: ClineMessageChangeType
/** The full array after the change */
messages: ClineMessage[]
/** The affected index (for add/update/delete) */
index?: number
/** The new/updated message (for add/update) */
message?: ClineMessage
/** The old message before change (for update/delete) */
previousMessage?: ClineMessage
/** The entire previous array (for set) */
previousMessages?: ClineMessage[]
}
// Strongly-typed event emitter interface
export interface MessageStateHandlerEvents {
clineMessagesChanged: [change: ClineMessageChange]
}
interface MessageStateHandlerParams {
taskId: string
ulid: string
@@ -22,7 +45,7 @@ interface MessageStateHandlerParams {
checkpointManagerErrorMessage?: string
}
export class MessageStateHandler {
export class MessageStateHandler extends EventEmitter<MessageStateHandlerEvents> {
private apiConversationHistory: ClineStorageMessage[] = []
private clineMessages: ClineMessage[] = []
private taskIsFavorited: boolean
@@ -39,6 +62,7 @@ export class MessageStateHandler {
private stateMutex = new Mutex()
constructor(params: MessageStateHandlerParams) {
super()
this.taskId = params.taskId
this.ulid = params.ulid
this.taskState = params.taskState
@@ -46,6 +70,13 @@ export class MessageStateHandler {
this.updateTaskHistory = params.updateTaskHistory
}
/**
* Emit a clineMessagesChanged event with the change details
*/
private emitClineMessagesChanged(change: ClineMessageChange): void {
this.emit("clineMessagesChanged", change)
}
setCheckpointTracker(tracker: CheckpointTracker | undefined) {
this.checkpointTracker = tracker
}
@@ -72,7 +103,13 @@ export class MessageStateHandler {
}
setClineMessages(newMessages: ClineMessage[]) {
const previousMessages = this.clineMessages
this.clineMessages = newMessages
this.emitClineMessagesChanged({
type: "set",
messages: this.clineMessages,
previousMessages,
})
}
/**
@@ -166,7 +203,14 @@ export class MessageStateHandler {
// it's important that apiConversationHistory is initialized before we add cline messages
message.conversationHistoryIndex = this.apiConversationHistory.length - 1 // NOTE: this is the index of the last added message which is the user message, and once the clinemessages have been presented we update the apiconversationhistory with the completed assistant message. This means when resetting to a message, we need to +1 this index to get the correct assistant message that this tool use corresponds to
message.conversationHistoryDeletedRange = this.taskState.conversationHistoryDeletedRange
const index = this.clineMessages.length
this.clineMessages.push(message)
this.emitClineMessagesChanged({
type: "add",
messages: this.clineMessages,
index,
message,
})
await this.saveClineMessagesAndUpdateHistoryInternal()
})
}
@@ -177,7 +221,13 @@ export class MessageStateHandler {
*/
async overwriteClineMessages(newMessages: ClineMessage[]) {
return await this.withStateLock(async () => {
const previousMessages = this.clineMessages
this.clineMessages = newMessages
this.emitClineMessagesChanged({
type: "set",
messages: this.clineMessages,
previousMessages,
})
await this.saveClineMessagesAndUpdateHistoryInternal()
})
}
@@ -192,9 +242,20 @@ export class MessageStateHandler {
throw new Error(`Invalid message index: ${index}`)
}
// Capture previous state before mutation
const previousMessage = { ...this.clineMessages[index] }
// Apply updates to the message
Object.assign(this.clineMessages[index], updates)
this.emitClineMessagesChanged({
type: "update",
messages: this.clineMessages,
index,
previousMessage,
message: this.clineMessages[index],
})
// Save changes and update history
await this.saveClineMessagesAndUpdateHistoryInternal()
})
@@ -210,9 +271,19 @@ export class MessageStateHandler {
throw new Error(`Invalid message index: ${index}`)
}
// Capture the message before deletion
const previousMessage = this.clineMessages[index]
// Remove the message at the specified index
this.clineMessages.splice(index, 1)
this.emitClineMessagesChanged({
type: "delete",
messages: this.clineMessages,
index,
previousMessage,
})
// Save changes and update history
await this.saveClineMessagesAndUpdateHistoryInternal()
})