Merge pull request #10641 from cline/migration-update-sdk

Migrate from the Clinebot to the Cline org
This commit is contained in:
Tomás Barreiro
2026-05-12 02:16:32 +02:00
committed by GitHub
1402 changed files with 312551 additions and 0 deletions
@@ -0,0 +1,15 @@
### Worktree Dependency Hygiene
When working in a git worktree, verify dependency links before running CLI repros,
tests, hooks, or commits. `node_modules` symlinks can accidentally point at
another checkout, causing mixed-source type errors or runtime behavior.
Quick check:
```sh
realpath node_modules packages/core/node_modules packages/core/node_modules/@cline/llms
```
All paths should stay under the current worktree. If any path points to another
checkout, remove the bad `node_modules` symlinks and run `bun install` from the
worktree root before trusting test or hook results.
+892
View File
@@ -0,0 +1,892 @@
---
name: cline-plugin
description: Self-contained guide to designing, building, packaging, and distributing a plugin for any Cline-based agent (CLI, VS Code, Kanban, JetBrains, custom SDK hosts). Covers both single-file plugins and full plugin packages.
---
# Authoring a Cline Agent Plugin
A **Cline plugin** is a TypeScript module that extends any agent built on the Cline Core SDK. The same plugin runs in the Cline CLI, the VS Code and JetBrains extensions, the Kanban host, and any custom app built on `@cline/core` — write it once, every host gets the new behavior.
A plugin can:
- **Register tools** the model can call (the most common use).
- **Hook into the agent loop** before/after runs, model calls, and tool calls.
- **Rewrite provider messages** before they hit the model (custom compaction, redaction, context shaping).
- **Register slash commands**, **prompt rules**, **providers**, and **automation event types**.
A plugin ships in one of two shapes:
1. **Single-file plugin** — one `.ts` file that exports a default plugin object. Drop it in a discovery folder and it's loaded.
2. **Plugin package** — a directory with `package.json`, npm dependencies, and (optionally) bundled assets like markdown templates. Installable via `cline plugin install`.
Both shapes use the same plugin API. The package form just adds dependency management and asset bundling.
This guide is self-contained. By the end of it, you'll be able to build either kind from scratch.
---
## 1. The mental model
When the host starts a session, it builds a registry of plugins and runs four phases:
1. **resolve** — collect the plugin objects.
2. **validate** — check each plugin's `manifest`. Capabilities must be non-empty; declared hook stages must have matching handlers; if `hooks` is present, `"hooks"` must be in `capabilities`.
3. **setup** — call each plugin's `setup(api, ctx)` once. This is where you `registerTool`, `registerCommand`, etc.
4. **activate** — registry is frozen, the agent loop starts, and your hooks/tools are live.
Two invariants the registry enforces:
- **Every contribution requires a matching capability.** Calling `api.registerRule(...)` without `"rules"` in `manifest.capabilities` throws.
- **Capabilities and handlers must agree.** Declaring `"hooks"` without a `hooks` object, or vice versa, fails validation.
After validation, registration is one-shot — there's no dynamic register/unregister during the session.
---
## 2. The smallest working plugin
```ts
import type { AgentPlugin } from "@cline/core";
import { createTool } from "@cline/core";
const plugin: AgentPlugin = {
name: "hello-plugin", // required, unique within a session
manifest: {
capabilities: ["tools"], // declares what setup() will register
},
setup(api, ctx) {
api.registerTool(
createTool({
name: "say_hello",
description: "Greet a person by name.",
inputSchema: {
type: "object",
properties: { name: { type: "string" } },
required: ["name"],
},
async execute({ name }: { name: string }) {
return { greeting: `Hello, ${name}!` };
},
}),
);
},
};
export default plugin;
```
That's a complete plugin. The agent will see `say_hello` as a callable tool.
---
## 3. The manifest
```ts
manifest: {
capabilities: ["tools", "hooks"], // required — non-empty array
paths?: string[], // optional — multi-entry packages
providerIds?: string[], // optional — provider plugins
modelIds?: string[], // optional — model plugins
}
```
| Field | When to use |
| -------------- | ------------------------------------------------------------------------------------------------------- |
| `capabilities` | Always. Lists what the plugin contributes; gates the corresponding `api.register*` methods. |
| `paths` | Only inside a `package.json` `cline.plugins` entry — when one package exposes multiple plugin entry points. |
| `providerIds` | When `capabilities` includes `"providers"` — declares which provider IDs you register. |
| `modelIds` | When you contribute models tied to specific IDs. |
### The complete capability list
| Capability | What it unlocks in `api` |
| ------------------ | --------------------------------------------------------------------- |
| `tools` | `api.registerTool()` |
| `commands` | `api.registerCommand()` (slash commands in chat surfaces) |
| `rules` | `api.registerRule()` (string injected into the system prompt) |
| `messageBuilders` | `api.registerMessageBuilder()` (rewrites provider-bound messages) |
| `providers` | `api.registerProvider()` (e.g. a custom model provider) |
| `automationEvents` | `api.registerAutomationEventType()` and `ctx.automation?.ingestEvent()` |
| `hooks` | The runtime `hooks` object on the plugin (lifecycle callbacks) |
You declare any combination — most real plugins need 13 capabilities.
---
## 4. `setup(api, ctx)` — the registration phase
`setup()` runs **once per session** before the agent loop starts. Everything you register here is frozen for the lifetime of the session.
### 4.1 The `api` object
Each `register*` method requires the matching capability in your manifest:
```ts
api.registerTool(tool); // requires "tools"
api.registerCommand({ name, description, handler }); // requires "commands"
api.registerRule({ id, content, source }); // requires "rules"
api.registerMessageBuilder({ name, build }); // requires "messageBuilders"
api.registerProvider({ name, description }); // requires "providers"
api.registerAutomationEventType({ eventType, source, /* ... */ }); // requires "automationEvents"
```
### 4.2 The `ctx` object — host-provided session context
The second argument carries everything the host knows about the current session. **All fields are optional**, so feature-detect before using them — the same plugin must work in hosts that supply less context (unit tests, sandboxed plugin processes).
```ts
ctx.session?.sessionId // string — stable core session id
ctx.client?.name // host: "cline-cli", "cline-vscode", etc.
ctx.user // authenticated user/org info, when available
ctx.workspaceInfo // { rootPath, hint, latestGitBranchName,
// latestGitCommitHash, associatedRemoteUrls }
ctx.automation?.ingestEvent // emit normalized automation events
ctx.logger?.log // structured logger scoped to this plugin
ctx.telemetry // ITelemetryService — only present in-process
```
**Two big rules about `ctx.workspaceInfo`:**
1. **Always prefer `ctx.workspaceInfo?.rootPath` over `process.cwd()`.** The CLI may have been launched with `--cwd` without calling `chdir`, and VS Code workspaces don't share a single CWD. `workspaceInfo` is sourced from the session config and is always correct.
2. **Don't use `import.meta.url` tricks to find "the workspace".** That gives you the plugin's own location, not the user's project.
### 4.3 Persisting state across hooks
`setup()` runs first; hooks fire later. The simplest way to share state is module-level variables in your plugin file:
```ts
let sessionWorkspaceRoot: string | undefined;
let sessionBranch: string | undefined;
const plugin: AgentPlugin = {
name: "metrics",
manifest: { capabilities: ["hooks"] },
setup(api, ctx) {
sessionWorkspaceRoot = ctx.workspaceInfo?.rootPath;
sessionBranch = ctx.workspaceInfo?.latestGitBranchName;
},
hooks: {
beforeTool({ toolCall, input }) {
if (sessionBranch === "main" && toolCall.toolName === "run_commands") {
// Inspect input, optionally block.
}
return undefined;
},
},
};
```
A single Node process may host multiple sessions concurrently. If your plugin will run in a multi-session host, key your state by `ctx.session?.sessionId` instead of using module-level singletons:
```ts
const stateBySession = new Map<string, MyState>();
setup(api, ctx) {
const id = ctx.session?.sessionId;
if (id) stateBySession.set(id, /* ... */);
}
```
---
## 5. Tools — `api.registerTool`
Tools are how plugins give the agent new capabilities. Use the `createTool()` helper from `@cline/core`:
```ts
import { createTool } from "@cline/core";
api.registerTool(
createTool({
name: "get_weather", // visible to the model
description: "Get current weather for a city.",
inputSchema: {
type: "object",
properties: {
city: { type: "string", description: "The city name" },
},
required: ["city"],
},
async execute(input, context) {
const { city } = input as { city: string };
// context.sessionId, context.conversationId, context.cwd are available
return { city, temperature: "72°F", condition: "sunny" };
},
}),
);
```
Guidelines for good tools:
- **Names are snake_case verbs** — `goto_definition`, `start_background_command`.
- **Descriptions are written for the model**, not for humans. Include when to use the tool, what inputs mean, and what the output looks like.
- **Inputs are JSON Schema.** Mark `required` fields explicitly. Constrain enums where possible.
- **Return JSON-serializable values** — strings, numbers, plain objects, arrays. The host serializes results before passing them back to the model.
- **Throw on invalid input or hard failure.** The runtime turns thrown errors into tool error results the model can recover from.
- **Keep tools focused.** A `start / get / delete` triplet of small tools beats one mega-tool with a `mode` enum.
---
## 6. Runtime hooks — `hooks: { ... }`
Runtime hooks are typed in-process callbacks on the same hook layer the runtime uses internally. They run inside the agent loop with full type information — no IPC, no JSON marshaling.
Declare `"hooks"` in `manifest.capabilities`, then add a `hooks` property:
```ts
const plugin: AgentPlugin = {
name: "metrics",
manifest: { capabilities: ["hooks"] },
hooks: {
beforeRun(ctx) { /* ... */ },
beforeTool({ toolCall, input }) { /* ... */ },
afterTool({ toolCall, result }) { /* ... */ },
afterRun({ result }) { /* ... */ },
onEvent(event) { /* ... */ },
},
};
```
### 6.1 The seven hooks
| Hook | Fires | Can stop the loop? | Common uses |
| ------------- | ---------------------------------------------------------- | ------------------ | ------------------------------------------------------ |
| `beforeRun` | Before the runtime loop starts (one user turn) | Yes | Greet, log, attach session metadata |
| `afterRun` | After the runtime loop finishes (success, abort, or fail) | No | Notifications, metrics, persistent logs |
| `beforeModel` | Before each model request | Yes (mutate req) | Inject context, last-mile prompt edits |
| `afterModel` | After each model response, before tool execution | Yes | Block based on model output |
| `beforeTool` | Before each tool execution | Yes (`{ stop }`) | Audit, redact, block dangerous tools |
| `afterTool` | After each tool execution | Can replace result | Post-process, redact secrets in tool output |
| `onEvent` | On every `AgentRuntimeEvent` emitted by the runtime | No | Streaming UIs, telemetry pipes |
### 6.2 Stopping the loop from a hook
Several hooks return an optional control object. The most common pattern is `beforeTool` blocking a destructive tool call:
```ts
beforeTool({ toolCall, input }) {
if (toolCall.toolName === "run_commands") {
const { commands } = input as { commands?: string[] };
if (sessionBranch === "main" && commands?.some(c => c.startsWith("git push"))) {
return { stop: true, reason: "Blocked git push on protected branch" };
}
}
return undefined; // explicit "continue"
}
```
Returning `undefined` (or omitting `return`) lets execution continue normally.
### 6.3 `afterRun` semantics
`afterRun` fires for **every** terminal status — `completed`, `aborted`, `failed`. If you only want to act on success:
```ts
afterRun({ result }) {
if (result.status !== "completed") return;
// notify, log success metrics, etc.
}
```
### 6.4 Plugin hooks vs file hooks
The runtime supports two hook systems:
- **File hooks** — external scripts in `.cline/hooks/` invoked with serialized JSON. Right for user/workspace-specific scripts that don't ship with code.
- **Plugin runtime hooks** — typed in-process callbacks. Right when the behavior belongs to a reusable extension and needs typed access to the runtime.
Core adapts file hooks onto the runtime hook layer, so you don't need both. If you're shipping a plugin, write it as runtime hooks.
---
## 7. Message builders — `api.registerMessageBuilder`
Message builders rewrite the **provider-bound message list** before the model call. They run after runtime messages are converted into SDK message blocks but **before** core's built-in safety builder, which always has the final say on provider-safe truncation.
Use them for:
- Custom compaction policies (replace middle history with a summary).
- Redacting PII or secrets before they reach the provider.
- Reshaping context for a specific model's strengths.
```ts
api.registerMessageBuilder({
name: "summarize-middle-history",
build(messages) {
if (estimateTokens(messages) < THRESHOLD) return messages;
return [...prefix, summary, ...recent];
},
});
```
Multiple builders run in registration order; the output of one is the input of the next.
**When to use `beforeModel` instead.** Reach for the `beforeModel` hook only if you need the runtime snapshot or want to mutate the request object itself. Pure message rewrites belong in a builder.
---
## 8. Automation events — `api.registerAutomationEventType` + `ctx.automation`
Plugins can declare normalized event types and emit them into Cline automation. Hosts that don't have automation enabled simply ignore both — your plugin should feature-detect `ctx.automation`.
```ts
manifest: { capabilities: ["automationEvents"] },
setup(api, ctx) {
api.registerAutomationEventType({
eventType: "github.pull_request.opened",
source: "github",
description: "A new GitHub PR was opened",
attributesSchema: { /* JSON Schema for envelope.attributes */ },
});
if (!ctx.automation) return; // host has no automation
ctx.automation.ingestEvent({
eventId: "pr-1234",
eventType: "github.pull_request.opened",
source: "github",
subject: "owner/repo#1234",
occurredAt: new Date().toISOString(),
attributes: { /* ... */ },
});
}
```
---
## 9. Loading a plugin
There are three ways a plugin gets into a session:
### 9.1 Auto-discovery (CLI)
The CLI scans these directories on startup:
- `<workspace>/.cline/plugins/` — project-scoped plugins (committed or gitignored).
- `~/.cline/plugins/` — user-scoped plugins.
- The system "Plugins" folder — host-managed installs.
Drop a `.ts` or `.js` file in, run `cline`, done:
```bash
mkdir -p .cline/plugins
cp my-plugin.ts .cline/plugins/
cline -i "do the thing my plugin enables"
```
### 9.2 Explicit `extensions: [...]` in SDK config
When you build your own host with `ClineCore`, pass the plugin object directly:
```ts
import plugin from "./my-plugin";
import { ClineCore } from "@cline/core";
const host = await ClineCore.create({ backendMode: "local" });
await host.start({
config: {
providerId: "anthropic",
modelId: "claude-sonnet-4-6",
apiKey: process.env.ANTHROPIC_API_KEY ?? "",
cwd: process.cwd(),
enableTools: true,
systemPrompt: "You are a helpful assistant.",
extensions: [plugin],
// Required for ctx.workspaceInfo to be populated:
extensionContext: {
workspace: { rootPath: process.cwd(), cwd: process.cwd() },
},
},
prompt: "...",
interactive: false,
});
```
### 9.3 `pluginPaths: [...]` for directory-based plugins
When the plugin is a directory with `package.json`, point `pluginPaths` at the directory. The loader reads `package.json` and finds entry points from the `cline.plugins` field:
```ts
config: {
// ...
pluginPaths: ["./path/to/my-plugin-package"],
}
```
Or install one with the CLI:
```bash
cline plugin install ./path/to/my-plugin-package
cline plugin install @scope/my-cline-plugin # from npm
cline plugin install --git github.com/owner/repo # from git
```
---
## 10. Single-file plugin — full template
This is the full shape for a single-file plugin. Save as `my-plugin.ts`, drop in `.cline/plugins/`.
```ts
/**
* My Cline Plugin
*
* What it does: <one paragraph, written for users>.
*
* CLI usage:
* mkdir -p .cline/plugins
* cp my-plugin.ts .cline/plugins/
* cline -i "trigger something the plugin enables"
*
* Direct demo:
* ANTHROPIC_API_KEY=sk-... bun run my-plugin.ts
*/
import { type AgentPlugin, ClineCore, createTool } from "@cline/core";
let sessionRoot: string | undefined;
const plugin: AgentPlugin = {
name: "my-plugin",
manifest: {
capabilities: ["tools", "hooks"],
},
setup(api, ctx) {
sessionRoot = ctx.workspaceInfo?.rootPath;
api.registerTool(
createTool({
name: "do_thing",
description: "Do the thing this plugin exists for.",
inputSchema: {
type: "object",
properties: { target: { type: "string" } },
required: ["target"],
},
async execute(input) {
const { target } = input as { target: string };
return { ok: true, target, root: sessionRoot };
},
}),
);
},
hooks: {
beforeRun() {
console.log("[my-plugin] run started");
},
afterRun({ result }) {
if (result.status !== "completed") return;
console.log(`[my-plugin] done in ${result.iterations} iteration(s)`);
},
},
};
// Optional: a runnable demo so users can `bun run` this file directly.
async function runDemo(): Promise<void> {
const host = await ClineCore.create({ backendMode: "local" });
try {
const result = await host.start({
config: {
providerId: "anthropic",
modelId: "claude-sonnet-4-6",
apiKey: process.env.ANTHROPIC_API_KEY ?? "",
cwd: process.cwd(),
enableTools: true,
systemPrompt: "You are a helpful assistant. Use tools when needed.",
extensions: [plugin],
extensionContext: {
workspace: { rootPath: process.cwd(), cwd: process.cwd() },
},
},
prompt: "Use do_thing on the target 'world'.",
interactive: false,
});
console.log(result.result?.text ?? "");
} finally {
await host.dispose();
}
}
if (import.meta.main) {
await runDemo();
}
export { plugin, runDemo };
export default plugin;
```
That's the entire shape. Copy it, rename the tool, swap in your logic.
---
## 11. Plugin package — full walkthrough
A **plugin package** is a directory with a `package.json`. Use it when you need any of:
- npm dependencies (`zod`, `yaml`, `typescript`, etc.)
- multiple plugin entry points from one package
- bundled assets (markdown templates, agent definitions, schemas, fixtures)
- a way to ship and version the plugin via npm or git
The package is still just a normal npm package — what makes it a plugin is the `cline.plugins` field in `package.json`.
### 11.1 Layout
A typical package looks like:
```
my-cline-plugin/
├── package.json
├── tsconfig.json (optional — for local typechecking)
├── index.ts (the plugin entry point)
├── README.md (user-facing docs)
└── assets/ (optional — bundled content)
├── templates/
│ └── greeting.md
└── schemas/
└── input.json
```
For larger plugins, you can also organize by feature:
```
my-cline-plugin/
├── package.json
├── index.ts
├── tools/
│ ├── do-thing.ts
│ └── read-thing.ts
├── hooks/
│ └── audit.ts
├── lib/
│ └── helpers.ts
└── assets/
└── ...
```
### 11.2 `package.json` — the discovery contract
```json
{
"name": "my-cline-plugin",
"version": "0.1.0",
"private": true,
"description": "What this plugin does, in one sentence.",
"type": "module",
"scripts": {
"typecheck": "tsc --noEmit",
"clean": "rm -rf node_modules dist"
},
"exports": {
".": "./index.ts"
},
"cline": {
"plugins": [
{
"paths": ["./index.ts"],
"capabilities": ["tools", "hooks"]
}
]
},
"peerDependencies": {
"@cline/core": "*"
},
"peerDependenciesMeta": {
"@cline/core": { "optional": true }
},
"dependencies": {
"zod": "^4.1.5"
}
}
```
Field-by-field:
- **`type: "module"`** — required. Cline plugins are ES modules.
- **`exports`** — points npm consumers at the entry. For TypeScript-source plugins loaded by Cline at runtime, you can export `./index.ts` directly; the loader handles TS.
- **`cline.plugins`** — the discovery contract. An array of entries, each with:
- `paths` — entry files relative to the package root. For multiple plugin objects from one package, list all entries.
- `capabilities` — pre-declared capabilities, validated by the loader before importing the entry.
- **`peerDependencies` for `@cline/core`** — the host already provides `@cline/core`. Marking it a peer dep avoids version drift; marking it optional lets users typecheck the plugin in isolation without forcing a `@cline/core` install.
- **`dependencies`** — your own deps (parsers, schema libraries, SDKs you wrap).
### 11.3 `tsconfig.json` (optional)
For local typechecking only:
```json
{
"extends": "../../tsconfig.json",
"include": ["index.ts"]
}
```
If your plugin lives outside a monorepo, a minimal standalone `tsconfig.json` works too:
```json
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true
},
"include": ["index.ts"]
}
```
### 11.4 `index.ts` — package entry
The same plugin shape as the single-file version, just inside a package:
```ts
import { type AgentPlugin, createTool } from "@cline/core";
import { z } from "zod";
const InputSchema = z.object({
target: z.string().min(1),
});
const plugin: AgentPlugin = {
name: "my-cline-plugin",
manifest: {
capabilities: ["tools"],
},
setup(api, ctx) {
api.registerTool(
createTool({
name: "do_thing",
description: "Do the thing.",
inputSchema: {
type: "object",
properties: { target: { type: "string" } },
required: ["target"],
},
async execute(input) {
const { target } = InputSchema.parse(input);
return { ok: true, target };
},
}),
);
},
};
export default plugin;
```
### 11.5 Bundling assets
Anything next to `index.ts` ships with the package. Resolve asset paths with `import.meta.url`, **not** `process.cwd()`:
```ts
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { readFileSync, existsSync, readdirSync } from "node:fs";
const MODULE_DIR = dirname(fileURLToPath(import.meta.url));
const TEMPLATES_DIR = join(MODULE_DIR, "assets", "templates");
function loadTemplate(name: string): string | undefined {
const path = join(TEMPLATES_DIR, `${name}.md`);
return existsSync(path) ? readFileSync(path, "utf8") : undefined;
}
```
This is the only place `import.meta.url` is appropriate in a plugin — locating files **inside the plugin package**. For workspace paths, always use `ctx.workspaceInfo?.rootPath`.
### 11.6 The override pattern (bundled / global / project)
A package can ship default assets and let users override them with their own. The convention used across Cline plugins is a three-tier lookup, last write wins by `name`:
1. **bundled** — files inside the plugin package (defaults shipped with the plugin).
2. **global** — files under `~/.cline/data/settings/<kind>/` (user overrides).
3. **project** — files under `<workspace>/.cline/<kind>/` (project overrides).
Example: a plugin that supports user-defined "presets" via markdown files with YAML frontmatter:
```ts
import { existsSync, readdirSync, readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import YAML from "yaml";
const MODULE_DIR = dirname(fileURLToPath(import.meta.url));
const BUNDLED_DIR = join(MODULE_DIR, "presets");
function resolveDataDir(): string {
return process.env.CLINE_DATA_DIR ??
join(process.env.HOME ?? "~", ".cline", "data");
}
function readPresets(workspaceRoot: string) {
const sources = [
{ dir: BUNDLED_DIR, source: "bundled" as const },
{ dir: join(resolveDataDir(), "settings", "presets"), source: "global" as const },
{ dir: join(workspaceRoot, ".cline", "presets"), source: "project" as const },
];
const presets = new Map<string, { name: string; body: string; source: string }>();
for (const { dir, source } of sources) {
if (!existsSync(dir)) continue;
for (const entry of readdirSync(dir, { withFileTypes: true })) {
if (!entry.isFile() || !entry.name.endsWith(".md")) continue;
const raw = readFileSync(join(dir, entry.name), "utf8");
const match = raw.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/);
const data = match ? YAML.parse(match[1] ?? "") ?? {} : {};
const body = (match ? match[2] : raw).trim();
const name = data?.name ?? entry.name.replace(/\.md$/, "");
// Project overrides global overrides bundled — last write wins.
presets.set(name, { name, body, source });
}
}
return [...presets.values()];
}
```
This pattern lets users:
- Use the plugin out of the box (bundled defaults).
- Customize globally for all projects (drop a file in `~/.cline/data/settings/<kind>/`).
- Override per-project (drop a file in `<workspace>/.cline/<kind>/`).
### 11.7 Multiple plugin entries in one package
If your package exposes more than one plugin, list each in `cline.plugins`:
```json
"cline": {
"plugins": [
{ "paths": ["./tools-plugin.ts"], "capabilities": ["tools"] },
{ "paths": ["./hooks-plugin.ts"], "capabilities": ["hooks"] }
]
}
```
Each entry file should `export default` its own plugin object.
### 11.8 Installing the package
Once the package is on disk, on npm, or in a git repo, users install it with:
```bash
cline plugin install ./my-cline-plugin # local path
cline plugin install @scope/my-cline-plugin # npm
cline plugin install --git github.com/owner/repo # git
```
The CLI installs into `<workspace>/.cline/plugins/.installs/` (or `~/.cline/plugins/.installs/`) and auto-discovers it on the next session.
For SDK consumers, point `pluginPaths` at the package directory directly (see §9.3).
---
## 12. Testing your plugin
### 12.1 Unit tests
The plugin object is plain data. You can drive `setup()` against a minimal context and exercise tools directly:
```ts
import plugin from "../my-plugin";
const tools: unknown[] = [];
const api = {
registerTool: (t: unknown) => tools.push(t),
registerCommand: () => {},
registerRule: () => {},
registerMessageBuilder: () => {},
registerProvider: () => {},
registerAutomationEventType: () => {},
};
await plugin.setup?.(api as never, {
workspaceInfo: { rootPath: "/tmp/fake-workspace" },
});
// Now `tools` contains the registered tools — call tool.execute(input, ctx).
```
For higher fidelity, build a real registry (`new ContributionRegistry({ extensions: [plugin] })`) and call `initialize()` — that exercises validation too.
### 12.2 End-to-end with a `runDemo()`
Add a `runDemo()` in your plugin file (see §10) that boots a real `ClineCore` session against `ANTHROPIC_API_KEY`:
```bash
ANTHROPIC_API_KEY=sk-... bun run my-plugin.ts
```
This is the fastest way to verify the plugin works end-to-end.
### 12.3 CLI smoke test
```bash
mkdir -p .cline/plugins
cp my-plugin.ts .cline/plugins/
cline -i "trigger something that exercises the plugin"
```
For packages:
```bash
cline plugin install ./my-cline-plugin
cline -i "..."
```
If the plugin fails validation or setup, the CLI prints a clear error and continues without it.
---
## 13. Common gotchas
- **"capabilities must be a non-empty array"** — you forgot `manifest.capabilities`, or it's `[]`.
- **"registerRule requires the 'rules' capability"** — capability/handler drift. Add `"rules"` to capabilities, or stop calling `registerRule`.
- **Tool not visible to the model** — check `enableTools: true` on the session config, and that you're declaring `"tools"` in capabilities.
- **`ctx.workspaceInfo` is undefined in SDK tests** — the host didn't pass `extensionContext.workspace`. In SDK code, set it explicitly (see §9.2).
- **State leaking across sessions** — module-level variables are shared across sessions in the same process. Key by `ctx.session?.sessionId` if your host runs multiple sessions concurrently.
- **`afterRun` firing on aborts** — guard with `if (result.status !== "completed") return;`.
- **Heavy work in `setup()`** — `setup()` blocks session start. Defer expensive work into the first tool call or `beforeRun`.
- **Importing host internals** — only import from `@cline/core`. Reaching into host-specific packages (e.g. CLI internals) will break in non-CLI hosts.
- **Sandboxed plugins and `telemetry`** — telemetry is process-local. Feature-detect `ctx.telemetry` and expect it to be undefined in sandboxed plugin processes.
- **Resolving bundled assets** — use `import.meta.url` + `fileURLToPath` to find files inside your package; never `process.cwd()`. For workspace paths, do the opposite: use `ctx.workspaceInfo?.rootPath`, never `import.meta.url`.
- **Plugin name collisions** — `name` must be unique within a session. If two plugins share a name, validation fails. Namespace by package (`my-org-redactor`, not `redactor`).
---
## 14. Decision guide — which extension point?
| You want to… | Use |
| ----------------------------------------------------------- | ------------------------------------------------ |
| Give the model a new capability | `registerTool` |
| Add a slash command in chat surfaces | `registerCommand` |
| Inject text into the system prompt | `registerRule` |
| Rewrite messages before they hit the provider | `registerMessageBuilder` |
| Add a custom model provider | `registerProvider` |
| Emit normalized cron/webhook events | `registerAutomationEventType` + `ctx.automation` |
| Observe or steer the agent loop | `hooks.*` |
| Block a dangerous tool call | `hooks.beforeTool` returning `{ stop: true }` |
| Notify on completion | `hooks.afterRun` (gate on `status === "completed"`) |
| Tweak each model request | `hooks.beforeModel` |
| Stream events to a UI | `hooks.onEvent` |
| Ship reusable templates with the plugin | Bundle assets next to `index.ts`, resolve via `import.meta.url` |
| Let users override defaults globally or per-project | Three-tier lookup: bundled / global / project |
---
## 15. Quick checklist before you ship
- [ ] `manifest.capabilities` is a non-empty array.
- [ ] Every `api.register*` call has a matching capability declared.
- [ ] If `hooks` is present, `"hooks"` is in `capabilities`.
- [ ] `ctx.workspaceInfo?.rootPath` is used for workspace paths (not `process.cwd()`).
- [ ] Optional `ctx` fields are feature-detected.
- [ ] Tool names are snake_case verbs; descriptions are written for the model.
- [ ] Tool inputs have JSON Schema with `required` set.
- [ ] `afterRun` handlers gate on `result.status === "completed"` if they only want successes.
- [ ] State that must not leak between concurrent sessions is keyed by `ctx.session?.sessionId`.
- [ ] (Package) `package.json` has `type: "module"`, `cline.plugins`, and `@cline/core` as an optional peer dep.
- [ ] (Package) Bundled assets resolved via `import.meta.url`, not `process.cwd()`.
- [ ] Smoke test: drop the plugin into `.cline/plugins/` (or `cline plugin install`), run `cline -i "..."`, watch it work.
When in doubt, write a tiny tool, get it to fire end-to-end, then grow it.
+1
View File
@@ -0,0 +1 @@
/.github/ @saoudrizwan @abeatrix @BarreiroT
+369
View File
@@ -0,0 +1,369 @@
name: Publish CLI to NPM
on:
schedule:
- cron: "0 12 * * *"
workflow_dispatch:
inputs:
publish_target:
description: "Which publish flow to run"
required: true
default: "main"
type: choice
options:
- main
- nightly
git_tag:
description: "Existing release tag to publish when publish_target=main, for example cli-v0.1.0"
required: false
type: string
confirm_publish:
description: 'Required when publish_target=main. Type "publish" to confirm release publish.'
required: false
type: string
force_nightly_publish:
description: "Force nightly publish even with no commits in last 24h"
required: false
type: boolean
default: false
permissions:
contents: read
id-token: write
jobs:
publish-main:
name: Publish cline
permissions:
contents: write
id-token: write
if: |
github.repository == 'cline/sdk' &&
github.ref == 'refs/heads/main' &&
github.event_name == 'workflow_dispatch' &&
github.event.inputs.publish_target == 'main' &&
github.event.inputs.confirm_publish == 'publish' &&
!endsWith(github.actor, '[bot]')
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
ref: ${{ github.event.inputs.git_tag }}
fetch-depth: 0
fetch-tags: true
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: "1.3.13"
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "24.x"
registry-url: "https://registry.npmjs.org"
- name: Verify publish tooling
run: |
NPM_VERSION=$(npm --version)
echo "npm ${NPM_VERSION}"
IFS=. read -r major minor patch <<EOF
${NPM_VERSION}
EOF
if [ "$major" -lt 11 ] || { [ "$major" -eq 11 ] && [ "$minor" -lt 5 ]; } || { [ "$major" -eq 11 ] && [ "$minor" -eq 5 ] && [ "$patch" -lt 1 ]; }; then
echo "npm 11.5.1 or newer is required for trusted publishing"
exit 1
fi
- name: Install dependencies
run: bun install
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Validate release tag
id: version
run: |
TAG="${{ github.event.inputs.git_tag }}"
if [ -z "$TAG" ]; then
echo "git_tag is required when publish_target=main"
exit 1
fi
if ! printf "%s\n" "$TAG" | grep -Eq '^cli-v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$'; then
echo "git_tag must look like cli-vX.Y.Z, got: ${TAG}"
exit 1
fi
VERSION="${TAG#cli-v}"
PACKAGE_VERSION=$(node -p "require('./apps/cli/package.json').version")
if [ "$PACKAGE_VERSION" != "$VERSION" ]; then
echo "apps/cli/package.json version ${PACKAGE_VERSION} does not match ${TAG}"
exit 1
fi
if ! printf "%s\n" "$VERSION" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$'; then
echo "apps/cli/package.json has invalid version: ${VERSION}"
exit 1
fi
TAG_COMMIT=$(git rev-parse "${TAG}^{commit}")
HEAD_COMMIT=$(git rev-parse HEAD)
if [ "$TAG_COMMIT" != "$HEAD_COMMIT" ]; then
echo "${TAG} does not point at the checked out commit"
exit 1
fi
git fetch origin +main:refs/remotes/origin/main
if ! git merge-base --is-ancestor "$HEAD_COMMIT" origin/main; then
echo "${TAG} is not reachable from origin/main"
exit 1
fi
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
- name: Build SDK packages
run: bun run build:sdk
- name: Run tests
run: bun run test
- name: Build platform binaries
run: bun script/build.ts --install-native-variants --skip-sdk-build
working-directory: apps/cli
- name: Verify build output
run: |
VERSION="${{ steps.version.outputs.version }}"
EXPECTED=(
"@cline/cli-darwin-arm64"
"@cline/cli-darwin-x64"
"@cline/cli-linux-arm64"
"@cline/cli-linux-x64"
"@cline/cli-windows-arm64"
"@cline/cli-windows-x64"
)
for package_name in "${EXPECTED[@]}"; do
dir="apps/cli/dist/${package_name#@cline/}"
if [ ! -f "$dir/package.json" ]; then
echo "Missing package manifest: $dir/package.json"
exit 1
fi
actual_name=$(node -p "require('./$dir/package.json').name")
actual_version=$(node -p "require('./$dir/package.json').version")
if [ "$actual_name" != "$package_name" ]; then
echo "Expected $package_name, got $actual_name"
exit 1
fi
if [ "$actual_version" != "$VERSION" ]; then
echo "Expected $package_name@$VERSION, got $actual_version"
exit 1
fi
ls -lh "$dir/bin/"
done
# TODO: re-enable NPM_CONFIG_PROVENANCE: "true" when repo is public
- name: Publish to NPM with latest tag
run: bun script/publish-npm.ts --tag latest
working-directory: apps/cli
- name: Create GitHub release
env:
GH_TOKEN: ${{ github.token }}
run: |
TAG="${{ steps.version.outputs.tag }}"
VERSION="${{ steps.version.outputs.version }}"
gh release create "$TAG" \
--verify-tag \
--title "CLI v${VERSION}" \
--notes "Published cline@${VERSION} to npm."
- name: Summary
run: |
VERSION="${{ steps.version.outputs.version }}"
echo "Published cline@${VERSION} to npm with dist-tag 'latest'"
echo "Install with: npm install -g cline"
- name: Post release to Slack
uses: slackapi/slack-github-action@v3.0.1
with:
method: chat.postMessage
token: ${{ secrets.SLACK_RELEASE_BOT_TOKEN }}
payload: |
channel: "C0APVKGGZFC"
text: "Cline SDK CLI v${{ steps.version.outputs.version }}"
blocks:
- type: "section"
text:
type: "mrkdwn"
text: "Cline SDK CLI v${{ steps.version.outputs.version }}"
- type: "context"
elements:
- type: "mrkdwn"
text: "<https://www.npmjs.com/package/cline/v/${{ steps.version.outputs.version }}|View on npm>"
publish-nightly:
name: Publish cline nightly
permissions:
contents: read
id-token: write
if: |
github.repository == 'cline/sdk' &&
github.ref == 'refs/heads/main' &&
(
github.event_name == 'schedule' ||
(
github.event_name == 'workflow_dispatch' &&
github.event.inputs.publish_target == 'nightly'
)
)
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Check for recent commits
id: check_commits
env:
FORCE_PUBLISH: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.force_nightly_publish == 'true' }}
run: |
if [ "$FORCE_PUBLISH" = "true" ]; then
echo "force_nightly_publish enabled, proceeding with publish"
echo "skip=false" >> "$GITHUB_OUTPUT"
exit 0
fi
if [ "$(git rev-list --count HEAD --since='24 hours ago')" -eq 0 ]; then
echo "No commits in last 24 hours, skipping publish"
echo "skip=true" >> "$GITHUB_OUTPUT"
else
echo "Found recent commits, proceeding with publish"
echo "skip=false" >> "$GITHUB_OUTPUT"
fi
- name: Setup Bun
if: steps.check_commits.outputs.skip != 'true'
uses: oven-sh/setup-bun@v2
with:
bun-version: "1.3.13"
- name: Setup Node.js
if: steps.check_commits.outputs.skip != 'true'
uses: actions/setup-node@v4
with:
node-version: "24.x"
registry-url: "https://registry.npmjs.org"
- name: Verify publish tooling
if: steps.check_commits.outputs.skip != 'true'
run: |
NPM_VERSION=$(npm --version)
echo "npm ${NPM_VERSION}"
IFS=. read -r major minor patch <<EOF
${NPM_VERSION}
EOF
if [ "$major" -lt 11 ] || { [ "$major" -eq 11 ] && [ "$minor" -lt 5 ]; } || { [ "$major" -eq 11 ] && [ "$minor" -eq 5 ] && [ "$patch" -lt 1 ]; }; then
echo "npm 11.5.1 or newer is required for trusted publishing"
exit 1
fi
- name: Install dependencies
if: steps.check_commits.outputs.skip != 'true'
run: bun install
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Build SDK packages
if: steps.check_commits.outputs.skip != 'true'
run: bun run build:sdk
- name: Run tests
if: steps.check_commits.outputs.skip != 'true'
run: bun run test
- name: Generate nightly version
if: steps.check_commits.outputs.skip != 'true'
id: version
run: |
BASE_VERSION=$(node -p "require('./apps/cli/package.json').version")
TIMESTAMP=$(date +%s)
VERSION="${BASE_VERSION}-nightly.${TIMESTAMP}"
echo "Base version: ${BASE_VERSION}"
echo "Generated nightly version: ${VERSION}"
echo "base_version=${BASE_VERSION}" >> "$GITHUB_OUTPUT"
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
- name: Update nightly package version
if: steps.check_commits.outputs.skip != 'true'
run: |
VERSION="${{ steps.version.outputs.version }}"
node -e '
const fs = require("node:fs");
const path = "apps/cli/package.json";
const pkg = JSON.parse(fs.readFileSync(path, "utf8"));
pkg.version = process.env.VERSION;
fs.writeFileSync(path, `${JSON.stringify(pkg, null, "\t")}\n`);
'
cat apps/cli/package.json | grep '"version"'
env:
VERSION: ${{ steps.version.outputs.version }}
- name: Build platform binaries
if: steps.check_commits.outputs.skip != 'true'
run: bun script/build.ts --install-native-variants --skip-sdk-build
working-directory: apps/cli
- name: Verify build output
if: steps.check_commits.outputs.skip != 'true'
run: |
VERSION="${{ steps.version.outputs.version }}"
EXPECTED=(
"@cline/cli-darwin-arm64"
"@cline/cli-darwin-x64"
"@cline/cli-linux-arm64"
"@cline/cli-linux-x64"
"@cline/cli-windows-arm64"
"@cline/cli-windows-x64"
)
for package_name in "${EXPECTED[@]}"; do
dir="apps/cli/dist/${package_name#@cline/}"
if [ ! -f "$dir/package.json" ]; then
echo "Missing package manifest: $dir/package.json"
exit 1
fi
actual_name=$(node -p "require('./$dir/package.json').name")
actual_version=$(node -p "require('./$dir/package.json').version")
if [ "$actual_name" != "$package_name" ]; then
echo "Expected $package_name, got $actual_name"
exit 1
fi
if [ "$actual_version" != "$VERSION" ]; then
echo "Expected $package_name@$VERSION, got $actual_version"
exit 1
fi
ls -lh "$dir/bin/"
done
# TODO: re-enable NPM_CONFIG_PROVENANCE: "true" when repo is public
- name: Publish to NPM with nightly tag
if: steps.check_commits.outputs.skip != 'true'
run: bun script/publish-npm.ts --tag nightly
working-directory: apps/cli
- name: Summary
if: steps.check_commits.outputs.skip != 'true'
run: |
VERSION="${{ steps.version.outputs.version }}"
echo "Published cline@${VERSION} to npm with dist-tag 'nightly'"
echo "Install with: npm install -g cline@nightly"
+241
View File
@@ -0,0 +1,241 @@
name: Publish Main SDK Packages
on:
workflow_dispatch:
inputs:
channel:
description: "Publish channel"
required: true
type: choice
options:
- nightly
- latest
default: nightly
force_publish:
description: "Force publish even if there are no commits in the last 24 hours"
required: false
type: boolean
default: false
schedule:
# Run nightly at 2:00 AM UTC
- cron: "0 2 * * *"
jobs:
test:
permissions:
contents: read
uses: ./.github/workflows/test.yml
publish-sdk:
needs: test
name: Publish SDK Packages
permissions:
contents: write
id-token: write
if: github.repository == 'cline/sdk' && github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Determine publish channel
id: channel
run: |
# Default to nightly for scheduled runs
if [ "${{ github.event_name }}" = "schedule" ]; then
echo "channel=nightly" >> $GITHUB_OUTPUT
else
echo "channel=${{ inputs.channel }}" >> $GITHUB_OUTPUT
fi
- name: Check for recent commits
id: check_commits
run: |
CHANNEL="${{ steps.channel.outputs.channel }}"
# Always publish for latest (production) releases
if [ "$CHANNEL" = "latest" ]; then
echo "Production release requested, proceeding with publish"
echo "skip=false" >> $GITHUB_OUTPUT
exit 0
fi
if [ "${{ inputs.force_publish }}" = "true" ]; then
echo "force_publish enabled, proceeding with publish"
echo "skip=false" >> $GITHUB_OUTPUT
exit 0
fi
if [ $(git rev-list --count HEAD --since="24 hours ago") -eq 0 ]; then
echo "No commits in last 24 hours, skipping publish"
echo "skip=true" >> $GITHUB_OUTPUT
else
echo "Found recent commits, proceeding with publish"
echo "skip=false" >> $GITHUB_OUTPUT
fi
- name: Verify trusted publishing context
if: steps.check_commits.outputs.skip != 'true'
run: |
if [ -z "${ACTIONS_ID_TOKEN_REQUEST_TOKEN:-}" ] || [ -z "${ACTIONS_ID_TOKEN_REQUEST_URL:-}" ]; then
echo "GitHub OIDC request environment is unavailable. Ensure this job has id-token: write for npm trusted publishing."
exit 1
fi
echo "GitHub OIDC request environment is available for npm trusted publishing."
- name: Setup Bun
if: steps.check_commits.outputs.skip != 'true'
uses: oven-sh/setup-bun@v2
with:
bun-version: "1.3.13"
- name: Setup Node.js
if: steps.check_commits.outputs.skip != 'true'
uses: actions/setup-node@v4
with:
node-version: "24.x"
registry-url: "https://registry.npmjs.org"
- name: Verify publish tooling
if: steps.check_commits.outputs.skip != 'true'
run: |
NPM_VERSION=$(npm --version)
echo "npm ${NPM_VERSION}"
IFS=. read -r major minor patch <<EOF
${NPM_VERSION}
EOF
if [ "$major" -lt 11 ] || { [ "$major" -eq 11 ] && [ "$minor" -lt 5 ]; } || { [ "$major" -eq 11 ] && [ "$minor" -eq 5 ] && [ "$patch" -lt 1 ]; }; then
echo "npm 11.5.1 or newer is required for trusted publishing"
exit 1
fi
- name: Install dependencies
if: steps.check_commits.outputs.skip != 'true'
run: bun install
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Build SDK
if: steps.check_commits.outputs.skip != 'true'
run: bun run build:sdk
- name: Generate shared version
if: steps.check_commits.outputs.skip != 'true'
id: version
run: |
CHANNEL="${{ steps.channel.outputs.channel }}"
BASE_VERSION=$(node -p "require('./packages/llms/package.json').version")
if [ "$CHANNEL" = "nightly" ]; then
TIMESTAMP=$(date +%s)
VERSION="${BASE_VERSION}-nightly.${TIMESTAMP}"
else
VERSION="$BASE_VERSION"
fi
echo "Base version: $BASE_VERSION"
echo "Channel: $CHANNEL"
echo "Publish version: $VERSION"
echo "version=$VERSION" >> $GITHUB_OUTPUT
- name: Update all package versions and lockfile
if: steps.check_commits.outputs.skip != 'true'
run: bun scripts/version.ts "${{ steps.version.outputs.version }}"
- name: Verify publishability
if: steps.check_commits.outputs.skip != 'true'
run: bun scripts/check-publish.ts
- name: Prepare package tarball directory
if: steps.check_commits.outputs.skip != 'true'
run: mkdir -p "$RUNNER_TEMP/sdk-npm-packs"
# Pack with Bun so workspace/catalog protocols are resolved in the tarball,
# then publish that tarball with npm so npm trusted publishing can use GitHub OIDC.
# Publish sequentially in dependency order: shared → llms → agents → core → sdk
- name: Publish @cline/shared
if: steps.check_commits.outputs.skip != 'true'
run: |
CHANNEL="${{ steps.channel.outputs.channel }}"
echo "Publishing @cline/shared@${{ steps.version.outputs.version }} with tag '${CHANNEL}'..."
cd packages/shared
TARBALL=$(bun pm pack --destination "$RUNNER_TEMP/sdk-npm-packs" --quiet)
npm publish "$RUNNER_TEMP/sdk-npm-packs/$(basename "$TARBALL")" --tag "$CHANNEL" --access public
- name: Publish @cline/llms
if: steps.check_commits.outputs.skip != 'true'
run: |
CHANNEL="${{ steps.channel.outputs.channel }}"
echo "Publishing @cline/llms@${{ steps.version.outputs.version }} with tag '${CHANNEL}'..."
cd packages/llms
TARBALL=$(bun pm pack --destination "$RUNNER_TEMP/sdk-npm-packs" --quiet)
npm publish "$RUNNER_TEMP/sdk-npm-packs/$(basename "$TARBALL")" --tag "$CHANNEL" --access public
- name: Publish @cline/agents
if: steps.check_commits.outputs.skip != 'true'
run: |
CHANNEL="${{ steps.channel.outputs.channel }}"
echo "Publishing @cline/agents@${{ steps.version.outputs.version }} with tag '${CHANNEL}'..."
cd packages/agents
TARBALL=$(bun pm pack --destination "$RUNNER_TEMP/sdk-npm-packs" --quiet)
npm publish "$RUNNER_TEMP/sdk-npm-packs/$(basename "$TARBALL")" --tag "$CHANNEL" --access public
- name: Publish @cline/core
if: steps.check_commits.outputs.skip != 'true'
run: |
CHANNEL="${{ steps.channel.outputs.channel }}"
echo "Publishing @cline/core@${{ steps.version.outputs.version }} with tag '${CHANNEL}'..."
cd packages/core
TARBALL=$(bun pm pack --destination "$RUNNER_TEMP/sdk-npm-packs" --quiet)
npm publish "$RUNNER_TEMP/sdk-npm-packs/$(basename "$TARBALL")" --tag "$CHANNEL" --access public
- name: Publish @cline/sdk
if: steps.check_commits.outputs.skip != 'true'
run: |
CHANNEL="${{ steps.channel.outputs.channel }}"
echo "Publishing @cline/sdk@${{ steps.version.outputs.version }} with tag '${CHANNEL}'..."
cd packages/sdk
TARBALL=$(bun pm pack --destination "$RUNNER_TEMP/sdk-npm-packs" --quiet)
npm publish "$RUNNER_TEMP/sdk-npm-packs/$(basename "$TARBALL")" --tag "$CHANNEL" --access public
- name: Create package tags for production publish
if: steps.check_commits.outputs.skip != 'true' && steps.channel.outputs.channel == 'latest'
run: |
VERSION="${{ steps.version.outputs.version }}"
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
for PKG in shared llms agents core sdk; do
TAG="sdk/${PKG}/v${VERSION}"
if git rev-parse -q --verify "refs/tags/${TAG}" >/dev/null; then
echo "Tag already exists locally: ${TAG}"
else
git tag -a "${TAG}" -m "@cline/${PKG}@${VERSION}"
echo "Created tag: ${TAG}"
fi
# Ensure remote has the tag; this is idempotent if tag already exists remotely.
git push origin "refs/tags/${TAG}"
done
- name: Summary
if: steps.check_commits.outputs.skip != 'true'
run: |
VERSION="${{ steps.version.outputs.version }}"
CHANNEL="${{ steps.channel.outputs.channel }}"
echo "✅ Published SDK packages with tag '${CHANNEL}':"
echo " - @cline/shared@${VERSION}"
echo " - @cline/llms@${VERSION}"
echo " - @cline/agents@${VERSION}"
echo " - @cline/core@${VERSION}"
echo " - @cline/sdk@${VERSION}"
if [ "$CHANNEL" = "latest" ]; then
echo "✅ Created git tags:"
echo " - sdk/shared/v${VERSION}"
echo " - sdk/llms/v${VERSION}"
echo " - sdk/agents/v${VERSION}"
echo " - sdk/core/v${VERSION}"
echo " - sdk/sdk/v${VERSION}"
fi
+102
View File
@@ -0,0 +1,102 @@
name: Tests
on:
push:
branches:
- main
workflow_dispatch:
pull_request:
branches:
- main
workflow_call:
permissions:
contents: read
jobs:
quality-checks:
runs-on: ubuntu-latest
name: Quality Checks
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: "1.3.13"
- name: Install dependencies
run: bun install
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Typecheck
run: |
bun run build:sdk
bun run -F @cline/cli build
bun run types
- name: Lint & Format
run: bun run lint
test:
needs: quality-checks
strategy:
fail-fast: false
matrix:
include:
- os: ubuntu-latest
node-version: "24.x"
- os: windows-latest
node-version: "24.x"
runs-on: ${{ matrix.os }}
name: Test (${{ matrix.os }}, Node ${{ matrix.node-version }})
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: "1.3.13"
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
- name: Install dependencies
run: bun install
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Build SDK
id: build_sdk_step
run: bun run build:sdk
- name: Build CLI
id: build_cli_step
if: ${{ !cancelled() && steps.build_sdk_step.outcome == 'success' }}
run: bun -F @cline/cli build
- name: Run Tests
if: ${{ !cancelled() && steps.build_sdk_step.outcome == 'success' && steps.build_cli_step.outcome == 'success' && matrix.os != 'windows-latest' }}
run: bun run test
- name: Run SDK Tests (Windows)
if: ${{ !cancelled() && steps.build_sdk_step.outcome == 'success' && steps.build_cli_step.outcome == 'success' && matrix.os == 'windows-latest' }}
run: bun -F './packages/**' test
- name: Smoke test SQLite under Node
if: ${{ !cancelled() && steps.build_sdk_step.outcome == 'success' && matrix.os != 'windows-latest' }}
timeout-minutes: 10
run: bun scripts/ci-node-smoke.ts
- name: Run TUI e2e tests
if: ${{ !cancelled() && steps.build_sdk_step.outcome == 'success' && steps.build_cli_step.outcome == 'success' && matrix.os == 'ubuntu-latest' && matrix.node-version == '24.x' }}
run: bun -F @cline/cli test:e2e:cli:tui
- name: Verify packages are publishable
if: ${{ !cancelled() && steps.build_sdk_step.outcome == 'success' && steps.build_cli_step.outcome == 'success' && matrix.os == 'ubuntu-latest' && matrix.node-version == '24.x' }}
run: bun scripts/check-publish.ts
+61
View File
@@ -0,0 +1,61 @@
# dependencies (bun install)
node_modules
# output
out
dist
*.tgz
target
.next
.map
# code coverage
coverage
*.lcov
# logs
logs
_.log
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
# dotenv environment variable files
.env
.env*.local
# caches
.eslintcache
.cache
*.tsbuildinfo
next-env.d.ts
# IntelliJ based IDEs
.idea
# Finder (MacOS) folder config
.DS_Store
# Package lock files created by other package managers
package-lock.json
yarn.lock
pnpm-lock.yaml
# Session files / User data
.cline/data
.cline/tmp
*.db
*.db-shm
*.db-wal
# Protobuf generated code
packages/rpc/src/proto/generated
# Tauri generated code
apps/*/src-tauri/gen
apps/*/src-tauri/bin
apps/examples/*/src-tauri/gen
apps/examples/*/src-tauri/bin
# Tauri UI test snapshots
apps/*/src/tests/.tui-test
apps/*/src/tests/tui-traces
.cli-release-staging
+4
View File
@@ -0,0 +1,4 @@
title = "Cline SDK secret scanning"
[extend]
useDefault = true
+45
View File
@@ -0,0 +1,45 @@
{
"strictness": 2,
"triggerOnUpdates": true,
"statusCheck": true,
"rules": [
{
"id": "sdk-tool-handler-telemetry",
"rule": "Any new tool handler added to packages/agents/src or packages/core/src that performs a user-visible action (writes files, executes commands, modifies state, calls external APIs) must include a call to captureToolUsage() from packages/core/src/services/telemetry/core-events.ts, or emit a task.tool_used event via telemetry.capture(). Pure read-only helpers and getters are exempt. When in doubt, prefer instrumentation.",
"scope": ["packages/agents/src/**", "packages/core/src/**"],
"severity": "high"
},
{
"id": "sdk-session-lifecycle-telemetry",
"rule": "New session start, end, or state-transition code paths in packages/core/src must call the appropriate typed helper from packages/core/src/services/telemetry/core-events.ts (captureTaskCreated, captureTaskCompleted, captureConversationTurnEvent, captureTokenUsage, etc.). Do not inline raw telemetry.capture() calls for session lifecycle events — always use the typed helper, which guarantees a consistent payload shape.",
"scope": [
"packages/core/src/cline-core/**",
"packages/core/src/runtime/**"
],
"severity": "high"
},
{
"id": "sdk-no-raw-event-strings",
"rule": "All telemetry event name strings must be sourced from CORE_TELEMETRY_EVENTS in packages/core/src/services/telemetry/core-events.ts. If a PR introduces a string literal in a telemetry.capture(), telemetry.captureRequired(), or recordCounter()/recordHistogram()/recordGauge() call that does not reference CORE_TELEMETRY_EVENTS, flag it. New events must be added to CORE_TELEMETRY_EVENTS first, with a typed capture helper created alongside them.",
"scope": [
"packages/core/src/**",
"packages/agents/src/**",
"apps/cli/src/**",
"apps/vscode/src/**"
],
"severity": "medium"
},
{
"id": "sdk-auth-telemetry-completeness",
"rule": "Any new OAuth or authentication provider added under packages/core/src/auth must emit all four lifecycle events using the typed helpers from core-events.ts: captureAuthStarted (at flow entry), captureAuthSucceeded + identifyAccount (on token success), captureAuthFailed (on error), and captureAuthLoggedOut (on token invalidation or explicit logout). Flag PRs that introduce a new auth flow file without all four. Cross-reference packages/core/src/auth/cline.ts and packages/core/src/auth/codex.ts as canonical examples.",
"scope": ["packages/core/src/auth/**"],
"severity": "high"
},
{
"id": "sdk-telemetry-doc-update",
"rule": "Any PR that adds new event constants to CORE_TELEMETRY_EVENTS in packages/core/src/services/telemetry/core-events.ts, adds new capture* helper functions, or changes the payload shape of an existing event must update the Event Catalog section in DOC.md. Flag PRs that modify core-events.ts without a corresponding change to DOC.md.",
"scope": ["packages/core/src/services/telemetry/core-events.ts"],
"severity": "medium"
}
]
}
+32
View File
@@ -0,0 +1,32 @@
{
"files": [
{
"path": "packages/core/src/services/telemetry/core-events.ts",
"description": "Single source of truth for all telemetry event names (CORE_TELEMETRY_EVENTS) and their typed capture helper functions. Every PR touching telemetry must be evaluated against this catalog. New events must be defined here first."
},
{
"path": "packages/shared/src/services/telemetry.ts",
"description": "ITelemetryService interface definition. Defines the contract all telemetry implementations must satisfy (capture, captureRequired, recordCounter, recordHistogram, recordGauge, flush, dispose)."
},
{
"path": "packages/core/src/services/telemetry/TelemetryService.ts",
"description": "Reference implementation of ITelemetryService used by all hosts. Multi-adapter fan-out service that forwards events to OpenTelemetry."
},
{
"path": "packages/core/src/services/telemetry/OpenTelemetryProvider.ts",
"description": "OpenTelemetry-backed provider that wires logs/metrics/traces exporters. Contains createConfiguredTelemetryService and createConfiguredTelemetryHandle, the canonical factories every host should use."
},
{
"path": "DOC.md",
"description": "Public API and event documentation. The Event Catalog and 'Activation funnel' sections must be kept in sync with core-events.ts. Host integration rules (CLI dir ordering, hub daemon metadata forwarding) are documented here."
},
{
"path": "ARCHITECTURE.md",
"description": "Architecture reference. Telemetry design decisions, completion semantics (submit_and_exit anchoring), and hub-daemon telemetry forwarding are documented here. Use as ground truth for design intent."
},
{
"path": "AGENTS.md",
"description": "Package boundary rules. Telemetry runtime services live in @cline/core; @cline/agents must not own stateful telemetry. Use to evaluate whether a telemetry change is being made in the correct package."
}
]
}
+142
View File
@@ -0,0 +1,142 @@
# SDK Telemetry Standards
These rules supplement `config.json`. The structured rules describe **what** to enforce; this
document explains **why**, so Greptile has the context to avoid false positives.
## Telemetry Stack
The SDK uses OpenTelemetry (OTEL) as its sole telemetry transport. Events flow through:
```
core-events.ts (event catalog + typed helpers)
ITelemetryService (packages/shared) ← interface contract
TelemetryService (packages/core) ← multi-adapter fan-out
OpenTelemetryAdapter → OpenTelemetryProvider ← OTLP transport
OTLP endpoint (collector or vendor)
```
The SDK does **not** depend on the original `cline/cline` repo for telemetry. The two have
parallel-but-independent stacks; this `.greptile/` config covers only the SDK.
## The Single Source of Truth
`packages/core/src/services/telemetry/core-events.ts` is the single source of truth for all
event names. It exports:
- `CORE_TELEMETRY_EVENTS` — a frozen const object grouped by family
(`CLIENT`, `SESSION`, `USER`, `TASK`, `HOOKS`, `WORKSPACE`)
- A typed `capture*()` helper for every event family
(`captureExtensionActivated`, `captureTaskCreated`, `captureToolUsage`, etc.)
**Never use raw string literals for event names at call sites.** A new event always means:
1. Add the constant to `CORE_TELEMETRY_EVENTS`
2. Add a typed `capture*()` helper alongside it (with a typed `properties` parameter)
3. Update the Event Catalog section in `DOC.md`
4. Add a unit test in `core-events.test.ts` asserting the event is dropped when telemetry is opted out
## The Activation Funnel
The canonical funnel that downstream analytics depends on:
```
user.extension_activated
→ workspace.initialized
→ workspace.path_resolved (gated on multi-root)
→ task.created
→ task.conversation_turn (one per turn, source: "user" | "assistant")
→ task.completed (source: "submit_and_exit" | "shutdown")
```
Emission ownership:
- `user.extension_activated`: emitted **once per host process** by host-specific helpers
(`captureCliExtensionActivated` for the CLI, `captureExtensionActivated` for VS Code).
- `workspace.initialized` / `workspace.init_error`: emitted by a per-process de-duplicated
emitter in `prepareLocalRuntimeBootstrap`. Hosts must NOT re-emit these.
- `workspace.path_resolved`: emitted from default tool executors **only when**
`WorkspaceManager` exposes more than one root.
- `task.*`: emitted by core session lifecycle code in `packages/core/src/cline-core/` and
`packages/core/src/runtime/`. Hosts must not duplicate this emission.
## `task.completed` Semantics
`task.completed` marks the moment the **assistant declared the task done**, not the moment
the SDK session record was finalized. The local runtime emits it when it observes a successful
`submit_and_exit` tool call (the SDK analog of original Cline's `attempt_completion`). For
non-interactive runs that finish without invoking the explicit completion tool,
`shutdownSession` emits it as a fallback with `source: "shutdown"`.
Each session is guaranteed at most one `task.completed` emission. The `source` field
(`"submit_and_exit" | "shutdown"`) is required for analytics attribution.
## CLI Directory-Ordering Rule
The CLI accepts `--config <dir>`. The CLI **must** apply `setClineDir(...)` and
`setHomeDir(...)` from `@cline/shared/storage` **before** calling
`captureCliExtensionActivated()`. Otherwise the telemetry singleton's persisted distinct-id
and any other on-disk telemetry state lands under `~/.cline` instead of the user's chosen
config dir.
The canonical pattern is in `apps/cli/src/main.ts` (PR #357):
```ts
if (configDir) setClineDir(configDir);
setHomeDir(homedir());
captureCliExtensionActivated(); // <-- after dir overrides
```
## Hub Daemon Metadata Forwarding
Hosts that spawn a detached `@cline/core/hub/daemon-entry` process must forward telemetry
metadata into the daemon argv so the daemon can reconstruct an equivalent
`ITelemetryService`. The expected payload is base64-encoded JSON with snake_case keys:
```
{ extension_version, cline_type, platform, platform_version, os_type, os_version, is_remote_workspace }
```
The reference implementation is `apps/vscode/src/hub-daemon.ts` (PR #357). Without this
forwarding, hub-backed sessions silently drop their lifecycle telemetry.
## Auth Lifecycle Completeness
Every authentication provider in `packages/core/src/auth/` must emit all four auth lifecycle
events using the typed helpers:
| Phase | Helper | Where it fires |
|---|---|---|
| Flow entry | `captureAuthStarted(provider)` | Top of the OAuth flow function |
| Token success | `captureAuthSucceeded(provider)` + `identifyAccount(...)` | After successful token exchange |
| Token error | `captureAuthFailed(provider, errorMessage)` | In the catch block |
| Token invalidation | `captureAuthLoggedOut(provider, reason)` | On invalid_grant or explicit logout |
Cross-reference `packages/core/src/auth/cline.ts` and `packages/core/src/auth/codex.ts` as
canonical examples of all four phases.
## Single Telemetry Service Per Host
On VS Code, the telemetry handle is built **once** in `activate()`
(`apps/vscode/src/telemetry.ts`) and the same instance is passed into the sidebar, panel
command, and daemon spawn payload. Do not let individual controllers construct their own
`ITelemetryService` — that fragments distinct-id state, opt-out tracking, and flush ownership.
The CLI follows the same pattern via the `getCliTelemetryService()` singleton in
`apps/cli/src/utils/telemetry.ts`, which is memoized by the activation gate in
`telemetry.activation-gate.ts`.
## Common False-Positive Adjustments
If Greptile flags one of the following, the rule is **not** violated:
- A telemetry call that is wrapped in a host-specific helper (e.g.
`captureCliExtensionActivated` wrapping `captureExtensionActivated`) — the inner helper
is the typed call.
- `enterprise.*` events emitted from `apps/cli/src/utils/enterprise.ts` — these are
enterprise-side events not yet in `CORE_TELEMETRY_EVENTS`; they are tracked separately.
- A new test file that uses raw event name strings inside `expect(...)` assertions — tests
may reference event names as strings to assert what was emitted.
+9
View File
@@ -0,0 +1,9 @@
if ! command -v gitleaks >/dev/null 2>&1; then
echo "gitleaks is required for the pre-commit secret scan."
echo "Install it with: brew install gitleaks"
echo "Other install options: https://github.com/gitleaks/gitleaks#installing"
exit 1
fi
gitleaks git --pre-commit --redact --staged --verbose || exit 1
lint-staged
+9
View File
@@ -0,0 +1,9 @@
{
"shortcuts": [
{
"label": "Build & Link CLI",
"command": "bun -F @cline/cli build && bun -F @cline/cli link",
"icon": "play"
}
]
}
+1
View File
@@ -0,0 +1 @@
22
+2
View File
@@ -0,0 +1,2 @@
node 22
bun 1.3.13
+7
View File
@@ -0,0 +1,7 @@
{
"recommendations": [
"bradlc.vscode-tailwindcss",
"biomejs.biome",
"oven.bun-vscode"
]
}
+132
View File
@@ -0,0 +1,132 @@
{
"version": "0.2.0",
"configurations": [
{
"name": "Run VS Code Extension",
"type": "extensionHost",
"request": "launch",
"args": [
"--extensionDevelopmentPath=${workspaceFolder}/apps/examples/vscode",
"--disable-extensions"
],
"outFiles": ["${workspaceFolder}/apps/examples/vscode/dist/**/*.js"],
"preLaunchTask": "build-vscode-extension"
},
{
"name": "Run VS Code Extension (Dev Webview)",
"type": "extensionHost",
"request": "launch",
"args": [
"--extensionDevelopmentPath=${workspaceFolder}/apps/examples/vscode",
"--disable-extensions"
],
"outFiles": ["${workspaceFolder}/apps/examples/vscode/dist/**/*.js"],
"env": {
"VITE_DEV_SERVER_URL": "http://localhost:5173"
},
"preLaunchTask": "dev-all-vscode",
"postDebugTask": "kill-vscode-dev"
},
{
"name": "Launch Bun CLI (Prompt)",
"type": "bun",
"request": "launch",
"cwd": "${workspaceFolder}/apps/cli",
"runtime": "bun",
"runtimeArgs": ["--conditions=development"],
"program": "${workspaceFolder}/apps/cli/src/index.ts",
"args": ["${input:cliPrompt}"],
"env": {
"CLINE_BUILD_ENV": "development"
},
"presentation": {
"hidden": true
}
},
{
"name": "Launch RPC Server",
"type": "bun",
"request": "launch",
"cwd": "${workspaceFolder}/apps/cli",
"runtime": "bun",
"runtimeArgs": ["--conditions=development"],
"program": "${workspaceFolder}/apps/cli/src/index.ts",
"args": ["rpc", "start"],
"env": {
"CLINE_BUILD_ENV": "development",
"CLINE_DEBUG_PORT_BASE": "9230"
},
"presentation": {
"hidden": true
}
},
{
"name": "Attach RPC Runtime (9230)",
"type": "bun",
"request": "attach",
"url": "ws://127.0.0.1:9230",
"localRoot": "${workspaceFolder}",
"remoteRoot": "${workspaceFolder}",
"presentation": {
"hidden": true
}
},
{
"name": "Attach Hook Worker (9231)",
"type": "bun",
"request": "attach",
"url": "ws://127.0.0.1:9231",
"localRoot": "${workspaceFolder}",
"remoteRoot": "${workspaceFolder}",
"presentation": {
"hidden": true
}
},
{
"name": "Attach Plugin Sandbox (9232)",
"type": "bun",
"request": "attach",
"url": "ws://127.0.0.1:9232",
"localRoot": "${workspaceFolder}",
"remoteRoot": "${workspaceFolder}",
"presentation": {
"hidden": true
}
},
{
"name": "Attach Connector Child (9233)",
"type": "bun",
"request": "attach",
"url": "ws://127.0.0.1:9233",
"localRoot": "${workspaceFolder}",
"remoteRoot": "${workspaceFolder}",
"presentation": {
"hidden": true
}
}
],
"compounds": [
{
"name": "Launch RPC Server Debugger",
"configurations": ["Launch RPC Server", "Attach RPC Runtime (9230)"]
},
{
"name": "Launch CLI Debugger",
"configurations": [
"Launch Bun CLI (Prompt)",
"Attach RPC Runtime (9230)",
"Attach Hook Worker (9231)",
"Attach Plugin Sandbox (9232)",
"Attach Connector Child (9233)"
]
}
],
"inputs": [
{
"id": "cliPrompt",
"type": "promptString",
"description": "Prompt to send to the CLI",
"default": "hey"
}
]
}
+11
View File
@@ -0,0 +1,11 @@
{
"files.insertFinalNewline": true,
"biome.enabled": true,
"editor.defaultFormatter": "biomejs.biome",
"editor.codeActionsOnSave": {
"source.fixAll.biome": "explicit",
"source.removeUnused.biome": "always",
"source.removeUnusedImports": "always",
"source.organizeImports.biome": "always"
}
}
+88
View File
@@ -0,0 +1,88 @@
{
"version": "2.0.0",
"tasks": [
{
"label": "build-sdk",
"type": "shell",
"command": "bun run build:sdk",
"problemMatcher": ["$tsc"],
"options": {
"cwd": "${workspaceFolder}"
}
},
{
"label": "build-vscode-extension",
"type": "shell",
"command": "bun run build",
"group": {
"kind": "build",
"isDefault": false
},
"dependsOn": ["build-sdk"],
"problemMatcher": ["$tsc"],
"options": {
"cwd": "${workspaceFolder}/apps/examples/vscode"
}
},
{
"label": "watch-vscode-extension",
"type": "shell",
"command": "bun run watch",
"isBackground": true,
"dependsOn": ["build-sdk"],
"problemMatcher": {
"pattern": {
"regexp": "^.*$",
"file": 0,
"location": 0,
"message": 0
},
"background": {
"activeOnStart": true,
"beginsPattern": "^Bundled",
"endsPattern": "^\\s*extension\\.js"
}
},
"options": {
"cwd": "${workspaceFolder}/apps/examples/vscode"
}
},
{
"label": "dev-vscode-webview",
"type": "shell",
"command": "cd src/webview && bun run dev",
"isBackground": true,
"problemMatcher": {
"pattern": {
"regexp": "^.*$",
"file": 0,
"location": 0,
"message": 0
},
"background": {
"activeOnStart": true,
"beginsPattern": "VITE",
"endsPattern": "Local:"
}
},
"options": {
"cwd": "${workspaceFolder}/apps/examples/vscode"
}
},
{
"label": "dev-all-vscode",
"dependsOn": ["watch-vscode-extension", "dev-vscode-webview"],
"dependsOrder": "parallel",
"problemMatcher": []
},
{
"label": "kill-vscode-dev",
"type": "shell",
"command": "kill $(lsof -ti:5173) 2>/dev/null; exit 0",
"problemMatcher": [],
"presentation": {
"reveal": "silent"
}
}
]
}
+77
View File
@@ -0,0 +1,77 @@
---
description: Development reference for the Cline SDK workspace.
globs: "*.ts,*.tsx,*.js,*.jsx,*.json,*.md"
alwaysApply: true
---
# Cline SDK — Development Reference
Quick-reference for active development. For onboarding, workspace setup, publishing, and detailed workflow see [CONTRIBUTING.md](./CONTRIBUTING.md). For architecture and runtime flows see [ARCHITECTURE.md](./ARCHITECTURE.md). For API details see [DOC.md](./DOC.md).
## Package Boundaries
### Published SDK Packages
- `@cline/shared`: shared contracts, schemas, path helpers, hook engine, extension registry, low-level utilities
- `@cline/llms`: provider settings/config, model catalogs, provider manifests, gateway contracts, handler creation
- `@cline/agents`: stateless agent loop, tool orchestration, hook/extension runtime, event streaming
- `@cline/core`: stateful orchestration, session lifecycle, storage, config watching, plugin loading, default tools, telemetry. Exposes `@cline/core/hub` for discovery, the detached daemon entry, WebSocket clients, and session/UI client adapters, plus `@cline/core/hub/daemon-entry` for launching the shared daemon
### Dependency Direction
```mermaid
flowchart TD
shared["@cline/shared"] --> llms["@cline/llms"] & agents["@cline/agents"] & core["@cline/core"]
llms --> agents & core
agents --> core
core --> apps["CLI / VS Code / Code App"]
```
Rules:
- `shared` stays low-level and reusable
- `agents` stays stateless — no session/storage/config concerns
- `core` owns stateful orchestration, including the shared-hub daemon, server, and client adapters under `src/hub/`
## Change Routing
Route changes to the package that owns the concern:
- model/provider schemas or handler behavior: `@cline/llms`
- stateless loop, tool orchestration, streaming, hook/extension runtime: `@cline/agents`
- session lifecycle, storage, config watching, default tools, plugin loading, telemetry, hub runtime services, hub discovery, hub daemon spawn, and session-oriented client helpers (`HubSessionClient`, `HubUIClient`, `connectToHub`): `@cline/core` (hub pieces live under `src/hub/`)
- remote-config schemas, managed instruction materialization, blob upload metadata, and OpenTelemetry config normalization: `@cline/shared/src/remote-config`
- host-specific UX or shell behavior: app package
## Verifying Changes
Root commands for cross-package confidence:
```sh
bun run types # typecheck all packages
bun run test # run all tests
bun run check # lint + build + typecheck + check-publish
```
If you touch hub/bootstrap/session flows, please update `ARCHITECTURE.md`.
## Practical Guidance
### Keep Boundaries Clean
- Don't move stateful logic down into `agents`
- Don't put app-specific behavior into `core` unless it is truly shared host behavior
- Keep remote-config primitives generic in `shared`; host-facing session integration belongs in `core`
### Refactor Standard
- Prefer direct architectural cleanup over compatibility shims
- Move code to the layer that owns the concern and update all call sites
- If a helper just projects watcher state, keep it with the config layer instead of creating thin runtime wrappers
## Documentation Responsibilities
- `README.md`: visitor-facing overview. Update when the repo story or package inventory changes.
- `CONTRIBUTING.md`: onboarding, workflow, publishing. Update when contributor setup or release process changes.
- `AGENTS.md` (this file): development reference. Update when package boundaries, dependency rules, or change routing changes.
- `ARCHITECTURE.md`: design, boundaries, runtime flows. Update when system design or architectural constraints change.
- `DOC.md`: API and behavior reference. Update when exported surfaces, lifecycle semantics, or runtime behavior changes.
+525
View File
@@ -0,0 +1,525 @@
# Cline SDK Architecture
This document is the architecture source of truth for the Cline SDK repository. It describes how the system is organized, how components interact, and the design principles that guide development decisions.
**Who should read this?**
- SDK contributors working across multiple packages
- Developers building integrations or host applications using `@cline/core`
- Plugin authors understanding the runtime and extension systems
**What this covers:**
- Package boundaries and responsibilities
- Dependency direction and layering rules
- Runtime flows (local, hub-backed, remote-config managed)
- Design seams (repeated patterns instead of one-off integrations)
- Architectural constraints and why they exist
**What this is NOT:**
- An onboarding guide for new contributors (see README.md and CONTRIBUTING.md)
- A detailed API reference (see package READMEs and inline JSDoc)
- A user guide (see the main documentation)
## Layered Model
The workspace is organized as a layered runtime stack.
```mermaid
flowchart LR
shared["@cline/shared"]
llms["@cline/llms"]
agents["@cline/agents"]
core["@cline/core"]
apps["Host Apps"]
llms --> shared
agents --> llms
agents --> shared
core --> agents
core --> llms
core --> shared
apps --> core
```
## Package Responsibilities
### `@cline/shared`
Owns reusable low-level contracts and infrastructure:
- shared types and schemas
- path resolution
- hook contracts/engine
- extension registry contracts
- prompt and parsing helpers
- storage path helpers
- remote-config schemas, managed instruction materialization, telemetry normalization, and blob upload primitives
Design rule:
- `shared` should not depend on higher-level runtime packages.
### `@cline/llms`
Owns model/provider runtime concerns:
- provider settings/config resolution
- model catalogs and manifests
- shared gateway-style provider contracts
- handler creation via an internal gateway registry
- AI SDK-backed provider execution code
Design rule:
- provider-specific behavior should be isolated here, not spread across `core` or apps.
### `@cline/agents`
Owns the stateless runtime loop:
- agent iteration loop
- tool orchestration
- runtime event emission
- hook/extension execution
- turn preparation before provider calls
- in-memory team/runtime primitives
Design rule:
- `agents` should not own persistent storage or host lifecycle concerns.
### `@cline/core`
Owns stateful orchestration:
- runtime composition
- session lifecycle
- storage and persistence
- config watching/loading and watcher projections
- settings listing and mutation orchestration
- default host tool assembly
- plugin discovery/loading
- default context compaction policy
- telemetry integration
- hub server and scheduled-runtime services under `src/hub/`
- hub discovery, the detached hub daemon, and the `@cline/core/hub/daemon-entry` subpath
- host-side hub client adapters (`NodeHubClient`, `HubSessionClient`, `HubUIClient`, `connectToHub`) exported from `@cline/core/hub`
Design rules:
- `core` is the app-facing orchestration layer over `agents`.
- hub-related modules live under `packages/core/src/hub/`, grouped by service:
- `client/` contains host-facing hub clients and browser connection helpers
- `daemon/` contains detached daemon startup, entrypoint, and local runtime handler wiring
- `discovery/` contains endpoint defaults, discovery records, and workspace owner resolution
- `server/` contains WebSocket server startup, native/browser socket adapters, server transport, server helpers, and `handlers/` for hub command dispatch
- settings mutations belong in core services and hub commands, not in host-specific file writes. Hosts should call the core settings facade or the `settings.*` hub command family and react to `settings.changed`.
## Runtime Flows
### Local In-Process Runtime
1. Host constructs a `RuntimeHost` through `@cline/core`.
2. `@cline/core` selects `LocalRuntimeHost` through `packages/core/src/runtime/host.ts`.
3. Hosts normalize broad local config into `RuntimeSessionConfig` plus `localRuntime` overrides before calling `RuntimeHost.start(...)`.
4. `@cline/core` prepares a local bootstrap artifact from `localRuntime`, then builds the runtime from it.
5. `@cline/core` creates an `Agent` from `@cline/agents`.
6. `@cline/agents` runs the loop using `@cline/llms` handlers.
7. `@cline/core` persists state, artifacts, and metadata.
Completion telemetry is anchored to the assistant's explicit completion
declaration, not session shutdown. After each agent turn, the local
runtime inspects `AgentResult.toolCalls` and emits `task.completed` the
moment a successful `submit_and_exit` (the SDK analog of original
Cline's `attempt_completion`) is observed. `shutdownSession(...)`
retains a fallback emission for completed sessions that finished
without an explicit completion-tool observation, so non-interactive
runs not using the yolo preset still produce a `task.completed` signal.
Each session emits at most one `task.completed`. See `DOC.md` for the
event payload and `source` field.
### Hub-Backed Runtime
1. Host constructs a `RuntimeHost` through `@cline/core`.
2. `@cline/core` selects `HubRuntimeHost` or `RemoteRuntimeHost` through `packages/core/src/runtime/host.ts`.
3. When no compatible local hub is already discovered, `@cline/core` can spawn a detached hub daemon and reconnect through discovery.
4. Hosts attach and detach from shared sessions without stopping the authority runtime, so another client can keep streaming or resume the same session later.
5. The hub-hosted runtime executes the agent loop using `@cline/agents` and `@cline/llms`.
6. `@cline/core` hub services broker sessions, events, approvals, schedules, and client-owned runtime capabilities such as session-local tool executors.
7. Hub event forwarding preserves structured streaming lifecycle boundaries: text/reasoning deltas, final text/reasoning completion, tool start/finish, and agent done events are translated across the hub transport so host UIs can reliably close loading/streaming state.
8. Hub client adapters exported from `@cline/core/hub` (`NodeHubClient`, `HubSessionClient`, `HubUIClient`, `connectToHub`) translate command/reply and event streams into host-facing APIs.
9. Hub `session.get` records include both canonical root-session usage and explicit aggregate usage from the hub-owned `RuntimeHost`, so attached clients can intentionally render either root-only or root-plus-teammate costs without replaying event streams.
Detached daemon startup retries transient `ETXTBSY` spawn failures before
polling discovery. This covers package-manager updates that replace the CLI
binary immediately before a command restarts the shared hub.
Local hub discovery also carries the authentication contract for the shared
daemon. On startup, the hub server generates a cryptographically random
per-process auth token, stores it in the owner discovery record, and writes that
record with owner-only file permissions. Local clients resolve the token from
the discovery file at connection time rather than embedding it in endpoint URLs.
The server validates the token with a constant-time comparison before accepting
`/hub` WebSocket upgrades or `/shutdown` requests; WebSocket clients send it via
the `Sec-WebSocket-Protocol` header and shutdown requests use an
`Authorization: Bearer` header. Unauthenticated local processes can still probe
public health/build metadata, but they cannot attach to sessions, issue
commands, or stop the daemon.
Local hub rediscovery is limited to managed shared-daemon endpoints obtained
through discovery or `ensure*HubServer(...)` startup paths. Explicit endpoints,
including loopback URLs such as `ws://127.0.0.1:<port>/hub`, are sticky exact
targets: reconnects may retry the same socket URL, but command recovery and
startup-deadlock recovery must not replace them with the workspace-discovered
hub. This keeps custom local hubs and remote hubs from silently drifting to a
different process.
### Interactive CLI Startup
1. `apps/cli` owns OpenTUI startup and must render the first frame without waiting for detached hub startup.
2. Interactive sessions use `backendMode: "auto"` so an already-compatible hub can be reused immediately, while a missing hub is only prewarmed in the background and the TUI falls back to a local runtime for responsiveness.
3. Hub-required flows such as `cline hub`, schedules, connectors, and `--zen` may still call the explicit ensure path because those commands require a live hub before proceeding.
4. Resume hydration is deferred until after `renderOpenTui()` so loading previous messages cannot block initial TUI paint.
5. Any future CLI/TUI startup work should follow the same rule: daemon startup, discovery polling, provider catalog refreshes, file indexing, and resume reads must be background or user-action gated unless a command explicitly requires their result before output.
### Remote-Config Managed Runtime
1. A host or core wrapper fetches a normalized `RemoteConfigBundle`.
2. `@cline/shared/remote-config` caches the bundle when configured.
3. Shared remote-config materializes managed rules/workflows/skills under workspace-local `.cline/<plugin>/`.
4. Shared remote-config derives generic OpenTelemetry config and session blob upload metadata from the bundle.
5. `@cline/core` exposes the app-facing integration wrapper that applies extensions, telemetry, and session metadata to `StartSessionInput`.
6. `@cline/core` consumes the prepared local overrides during local bootstrap.
This keeps reusable remote-config behavior in `shared` while the session-specific bridge remains in `core`.
## Design Seams
The codebase relies on a few repeated seams instead of one-off integration paths.
### 1. Config Watchers
Core uses file-based discovery and watchers for:
- rules
- workflows
- skills
- agents
- hooks
- plugins
Design implication:
- new instruction sources should usually materialize into files and reuse watcher-based loading instead of inventing parallel in-memory execution paths.
- in `packages/core`, config-facing discovery, parsing, watching, and slash-command projection live under `src/extensions/config`
### 2. Runtime Builder Inputs
`DefaultRuntimeBuilder` composes a runtime from generic inputs:
- tools
- hooks
- extensions
- user instruction watcher
- telemetry
Design implication:
- higher-level integrations should prefer feeding those seams rather than patching agent internals directly.
- the local runtime bootstrap lives in `packages/core/src/services/local-runtime-bootstrap.ts` and feeds the builder rather than bypassing it
### 3. Runtime Host Boundary
Core exposes one shared execution boundary: `RuntimeHost`.
Concrete implementations:
- `LocalRuntimeHost` for in-process execution
- `HubRuntimeHost` for shared local hub execution
- `RemoteRuntimeHost` for explicit remote hub endpoints
Design implication:
- host selection happens in `packages/core/src/runtime/host.ts`
- `ClineCore` delegates uniformly to `RuntimeHost` and does not branch on local vs hub behavior
- transport-specific translation belongs inside concrete hosts, not in top-level orchestration
- `RuntimeHost` inputs stay transport-safe, while `ClineCore.start(...)` is the app-facing facade that normalizes broad local config before delegation
- `RuntimeSessionConfig` is transport-neutral across local, shared hub, and remote hub modes; host-local bootstrap concerns stay under `localRuntime`
- client-local runtime behaviors that must survive hub mode, such as `defaultToolExecutors`, are attached at session start and proxied through hub capability requests instead of changing host selection
- pending prompt list/update/delete are exposed through the grouped
`ClineCore.pendingPrompts` service. Usage summary lookup and active-session
model switching are also service-style capabilities exposed through
`ClineCore` when the concrete transport implements them. These service APIs
are intentionally outside the minimal `RuntimeHost` primitive vocabulary.
- The usage service's `getAccumulatedUsage(sessionId)` method returns a summary
with two explicit buckets: `usage` for the root/lead agent and
`aggregateUsage` for root plus teammates/subagents. Local execution tracks
root usage and teammate usage as separate buckets, then derives aggregate
totals from those buckets while telemetry remains scoped to the primary
lead/root agent.
### 4. Settings Mutation Boundary
Core owns settings snapshots and mutations through `packages/core/src/settings`.
The hub exposes the same path through `settings.list` and `settings.toggle`.
Design implication:
- hosts should not mutate skill, tool, MCP, provider, or other settings files directly
- domain-specific persistence helpers, such as skill markdown frontmatter writes, stay internal to the owning settings provider/service
- successful hub-backed mutations return an updated settings snapshot and publish `settings.changed` with the changed settings types
- CLI settings surfaces may keep local snapshot rendering for startup responsiveness, but mutation flow must refresh the relevant watcher before reloading UI data
### 5. Session Startup Bootstrap
`ClineCore.create(...)` exposes a generic `prepare(input)` hook.
Design implication:
- higher-level packages can prepare workspace-scoped runtime state before a session starts
- core stays unaware of enterprise-specific contracts
- cleanup stays at the host boundary rather than inside the agent loop
### 6. Logging
Cross-package logging uses a small injected interface exported from `@cline/shared`:
- **`BasicLogger`** — required `debug` and `log`; optional `error`. Hosts map these to their backend (Pino, VS Code `OutputChannel`, etc.). Many runtime options take `logger?: BasicLogger`; when omitted, components skip logging or use `noopBasicLogger` where a full object is required.
- **`BasicLogMetadata`** — optional structured fields (`sessionId`, `runId`, `providerId`, `toolName`, `durationMs`, …) plus `severity` on `log` when a single method must represent both informational and warning-style messages (for example the CLI Pino bridge maps `severity: "warn"` to Pino `warn`).
Naming clarity:
- **`CliLoggerAdapter` (CLI)** — a **host bundle**: holds the raw `pino` logger (for file paths, rotation, and CLI-only concerns) and exposes `.core: BasicLogger` for anything that consumes the SDK contract. It is not an `ITelemetryAdapter`.
- **`TelemetryLoggerSink` (`@cline/core`)** — an **`ITelemetryAdapter`** that mirrors telemetry events and metrics into a `BasicLogger`. It is a telemetry sink, not a host logging implementation.
The agent and other call sites route former `info` / `warn` semantics through `log` (warnings include `severity: "warn"` in metadata). Errors prefer `error` when implemented; otherwise `log` with `severity: "error"` is used as a fallback.
Design implication:
- logging is injectable and transport-agnostic, allowing host environments (CLI, VS Code, browser) to wire their own backends
- do not hardcode logging calls; accept a `logger?: BasicLogger` parameter instead
### 7. Storage Adapters
Stateful persistence should be isolated behind adapter/service layers.
Design implication:
- file-backed, SQLite-backed, RPC-backed, and enterprise-specific persistence should share service logic where possible and isolate backend differences in adapters.
### 8. Extension and Hook System
Extensibility is split deliberately:
- extensions register runtime contributions
- hooks intercept lifecycle stages
Design implication:
- additive runtime behavior should usually enter through these extension points instead of bespoke special-case host code.
### 9. Context Compaction
Context compaction is owned by `core`.
- `@cline/agents` owns the generic turn-preparation seam:
- run normal lifecycle hooks
- allow hosts to rewrite message history or system prompt before the provider call
- `@cline/core` owns compaction policy:
- inject a prepare-turn pipeline for root sessions
- choose between built-in strategies through a registry map
- keep compaction logic out of the low-level agent message builder
Design implications:
- compaction is a context-pipeline concern owned by `core`
- `agents` stays focused on the stateless loop and provider/tool orchestration
- delegated/subagent flows should inherit compaction behavior through core session config, not through a separate agent-level compaction hook surface
### 10. Extension Layering Inside Core
`packages/core/src/extensions` is split by concern:
- `extensions/config`: config loaders, parsers, watchers, and watcher projections such as runtime slash-command expansion
- `extensions/plugin`: runtime plugin discovery, loading, and sandboxing
- `extensions/context`: core-owned context/message pipeline concerns such as compaction
Design implications:
- avoid mixing config discovery code into runtime/plugin code
- avoid creating thin runtime wrapper files when a helper is fundamentally projecting watcher state
## Architectural Constraints
### Keep `agents` Stateless
Do not move these concerns into `@cline/agents`:
- session persistence
- provider settings storage
- RPC lifecycle
- host-specific approvals
- remote-config policy caching
### Keep `core` Generic
Do not make `@cline/core` organization- or provider-specific.
If a capability is truly generic and app-facing, add a generic core seam. Reusable remote-config parsing, materialization, and upload primitives belong in `@cline/shared/remote-config`.
### Use One-Way Optional Layers
Optional higher-level integrations may depend on lower layers.
Lower layers should not depend on optional feature packages.
For remote config, that means shared owns the reusable bundle/materialization/blob primitives and core owns only the session-oriented wrapper exported to apps.
## File-Based And Event-Driven Automation (`ClineCore` / `CronService`)
`@cline/core` ships a file-based automation subsystem under
`packages/core/src/cron/`. It lets operators author recurring and one-off
tasks as Markdown files under global `~/.cline/cron/` by default, and
event-driven tasks as `events/*.event.md` specs. All trigger kinds run
through the same durable queue and runtime handlers. `ClineCore` exposes the
SDK-facing `cline.automation.*` entry points; `CronService` is the internal
orchestrator used by core and hub layers.
### Layers
1. **Spec parser** (`cron/specs/cron-spec-parser.ts`): parses YAML frontmatter + body
into a `CronSpec` discriminated union (`one_off | schedule | event`).
Types live in `@cline/shared` under `src/cron/cron-spec-types.ts`
so other packages can consume them without the YAML parser. Schedule
expressions and timezones are validated before a spec can become
runnable.
2. **Store** (`cron/store/sqlite-cron-store.ts`): owns `cron.db` at
`resolveCronDbPath()` (default `.cline/data/db/cron.db`). Schema is
bootstrapped from `cron/store/cron-schema.ts` — sessions and cron live in separate
DBs so their lifecycles stay decoupled.
3. **Reconciler** (`cron/specs/cron-reconciler.ts`): scans the configured cron specs
directory (global `~/.cline/cron/` by default, or workspace-scoped when
configured), parses each file independently, and upserts spec state.
Invalid specs are recorded
with `parse_status='invalid'` so state is durable rather than silently
dropped. Files that disappear between scans get `removed=1` and their
queued runs are cancelled.
4. **Watcher** (`cron/specs/cron-watcher.ts`): `node:fs watch({ recursive: true })`
with a ~250ms per-path debounce. Watcher events always trigger a
re-reconcile — the reconciler is always the source of truth, not the
watcher stream.
5. **Materializer** (`cron/runner/cron-materializer.ts`): turns file-triggered specs into
queued `cron_runs`. One-off: at most one run record per `(spec_id,
revision)`, including failed runs so specs do not retry accidentally.
Schedule: "one overdue catch-up on startup then advance" using
timezone-aware `getNextCronTime`.
6. **Event ingress** (`cron/events/cron-event-ingress.ts`): accepts already-normalized
`AutomationEventEnvelope` values, persists them into `cron_event_log`,
matches enabled event specs by `event_type` plus declarative filters,
applies dedupe/debounce/cooldown policy, and enqueues `cron_runs` with
`trigger_kind='event'`. It never executes agents directly. Plugins can
declare `automationEvents` and submit normalized events through
`ctx.automation.ingestEvent(...)`; sandboxed plugins forward those events
through the core plugin event bridge.
7. **Runner** (`cron/runner/cron-runner.ts`): polls `cron.db`, atomically claims
queued runs, executes them via the existing `HubScheduleRuntimeHandlers`
(`startSession``sendSession``stopSession` / `abortSession`),
renews the run claim while execution is active, writes a markdown report
per run, and transactionally updates status. File specs can constrain
tool availability, config extension loading (`rules`, `skills`,
`plugins`), session source, and a notes directory that is injected into
the system prompt. Event runs include the normalized trigger event context
in the prompt.
8. **Reports** (`cron/reports/cron-report-writer.ts`): writes
`.cline/cron/reports/<run-id>.md` with run frontmatter plus
`## Summary`, `## Usage`, `## Tool Calls`, and, for event runs,
`## Trigger Event` sections.
9. **Service** (`cron/service/cron-service.ts`): orchestrates all of the above.
`ClineCore.create({ automation })` owns the SDK-facing lifecycle and exposes
`cline.automation.*` methods. Hub-side callers can submit normalized events
through the `cron.event.ingest` command.
The detached hub daemon passes its workspace root as `cronOptions`, so
normal CLI/hub startup watches `${workspaceRoot}/.cline/cron/` without a
custom host needing to opt in.
Programmatic hub schedules are stored as `cron_specs` with source
`hub-schedule` and execute through the same `cron_runs`
claim/requeue/report flow as file-backed one-off, recurring, and
event-driven specs. The hub schedule command surface remains a thin adapter;
there is no separate schedules table, schedule store, or schedule runner.
## Navigating the Codebase
### Starting Points by Task
**I want to understand the agent loop and tool execution:**
- Start: `packages/agents/src/agent.ts` — the stateless runtime loop
- Then: `packages/agents/src/agent-step.ts` — individual iteration steps
- Extensions: `packages/core/src/extensions/plugin/` — plugin discovery and sandboxing
**I want to understand session persistence and state:**
- Start: `packages/core/src/runtime/host/local-runtime-host.ts` — local session lifecycle
- Then: `packages/core/src/runtime/orchestration/` — session orchestration
- Settings: `packages/core/src/settings/` — settings mutation and state
**I want to understand the hub system:**
- Start: `packages/core/src/hub/server/` — WebSocket server and hub command handlers
- Clients: `packages/core/src/hub/client/` — host-side hub clients
- Transport: `packages/core/src/hub/runtime-host/` — hub-backed runtime hosts
**I want to add a new tool:**
- Tools registry: `packages/core/src/extensions/tools/` — built-in tool definitions
- Tool execution: `packages/agents/src/tool-use.ts` — how tools are called
- Plugin tools: `packages/core/src/extensions/plugin/` — plugin-registered tools
**I want to understand settings and configuration:**
- Watcher system: `packages/core/src/extensions/config/` — file watching and loading
- Provider config: `packages/core/src/runtime/config/` — provider settings resolution
- Settings services: `packages/core/src/settings/` — settings state and mutation
**I want to add a new runtime feature (hook/extension):**
- Hook contracts: `packages/shared/src/hooks/` — hook types and engine
- Plugin system: `packages/core/src/extensions/plugin/` — plugin discovery and execution
- Runtime builder: `packages/core/src/services/local-runtime-bootstrap.ts` — how runtime is composed
### File Naming Conventions
- `*.ts` — TypeScript source
- `*.test.ts` — unit tests (Vitest)
- `*.e2e.test.ts` — end-to-end tests requiring full integration
- `*.ts` in examples — runnable example files (plugins, hooks)
- `*.md` files in `apps/examples/` — documentation and markdown-based specs (cron, events)
### Key Type Locations
- **`ClineCore`** — `packages/core/src/index.ts` — the main SDK orchestrator
- **`Agent`** — `packages/agents/src/agent.ts` — the agent loop
- **`RuntimeHost`** — `packages/core/src/runtime/host/runtime-host.ts` — execution abstraction
- **`AgentPlugin`** — `packages/shared/src/plugin/` — plugin contract
- **`CronSpec`** — `packages/shared/src/cron/cron-spec-types.ts` — automation specs
## Publishability Constraint
This repo has both publishable SDK packages and internal workspace packages.
Architectural consequence:
- internal packages must not accidentally become part of the publishable SDK surface
- release automation should only target the intended published packages
- internal code may compose with published packages, but published packages should not take hard dependencies on internal-only workspace layers unless you explicitly intend to publish that integration
### Published Packages
The following packages are published to npm:
- `@cline/shared` — shared types, contracts, and low-level utilities
- `@cline/llms` — provider integrations and model manifests
- `@cline/agents` — the agent loop and tool orchestration
- `@cline/core` — the main SDK with session management, hub, and configuration
### Internal Apps
The following workspace apps are internal and not published as SDK packages:
- `apps/cli` — CLI implementation
- `apps/webview` — VS Code webview
- `apps/examples` — example plugins and integrations
+196
View File
@@ -0,0 +1,196 @@
# Contributing to the Cline SDK
This document covers onboarding, development workflow, and publishing. For package boundaries and change routing during development, see [AGENTS.md](./AGENTS.md). For architecture and runtime flows, see [ARCHITECTURE.md](./ARCHITECTURE.md).
This repo is a WIP framework for building and orchestrating AI agents. Full refactors are acceptable when they improve the architecture and all call sites are updated.
## Workspace Overview
### Published SDK Packages
| Package | Owns |
|---------|------|
| `@cline/shared` | Contracts, schemas, path helpers, hook engine, extension registry |
| `@cline/llms` | Provider settings, model catalogs, manifests, handler creation |
| `@cline/agents` | Stateless agent loop, tool orchestration, hook/extension runtime |
| `@cline/core` | Stateful orchestration, session lifecycle, storage, config, telemetry, hub runtime services, hub discovery, detached daemon, and hub client adapters (`@cline/core/hub`, `@cline/core/hub/daemon-entry`) |
### Apps
- `apps/cli`: CLI host and local hub management
- `apps/examples/desktop-app`: Tauri + Next.js desktop app example
- `apps/examples/vscode`: VS Code extension example
- `apps/examples/menubar`: hub notification menubar example
- `examples`: plugin, hook, and cron automation examples (customizations upon Cline SDK)
## Development Workflow
### Essential Commands
| Command | Purpose |
|---------|---------|
| `bun install` | Install dependencies |
| `bun run build` | Build SDK and CLI |
| `bun run build:sdk` | Build SDK packages only |
| `bun run dev` | Build in development mode |
| `bun run cli` | Run CLI interactively |
| `bun run test` | Run the Vitest suite |
| `bun run types` | Typecheck all packages |
| `bun run lint` / `format` / `fix` | Code quality and formatting |
Package-scoped commands:
```sh
bun -F @cline/core build|test|typecheck
bun -F @cline/agents build|test|typecheck
```
### Rebuilding
Changes to published SDK packages require `bun run build:sdk`. Direct CLI runs pick up rebuilt packages immediately. Use `dev:*` scripts for automatic rebuilding during development.
The CLI build (`bun -F @cline/cli build`) bundles packages from their compiled `dist/`, not their TypeScript source. If you edit a package and then build the CLI without rebuilding the package first, the CLI binary will silently include the old package code. Always run `bun run build:sdk` (or the relevant `bun -F @cline/<pkg> build`) before building the CLI when testing changes end-to-end.
Hub-backed hosts use shared workspace discovery and owned daemon startup logic. If you touch hub bootstrap, preserve the startup lock and owner-scoped discovery behavior so multiple builds can coexist safely.
### Debug Builds
- Set `CLINE_BUILD_ENV=development` for debug builds. Spawned Node/Bun subprocesses get an inspector endpoint plus `--enable-source-maps`.
- By default, child-process inspector ports are ephemeral (`--inspect=127.0.0.1:0`) to avoid collisions across parallel dev runs.
- Set `CLINE_DEBUG_HOST` and `CLINE_DEBUG_PORT_BASE` to opt into deterministic role-based ports. With `CLINE_DEBUG_PORT_BASE=9230`, the roles map to hub `9230`, hook worker `9231`, plugin sandbox `9232`, connector child `9233`, fallback sandbox `9234`.
- Fallback chain: `CLINE_BUILD_ENV``NODE_ENV` → Bun `--conditions=development`.
- To debug the CLI process itself: `cd apps/cli && CLINE_BUILD_ENV=development bun --conditions=development --inspect-brk=6499 ./src/index.ts "hey"`.
- The workspace includes a VS Code launch config (`Launch CLI Debugger`) that uses `"type": "bun"` (requires `oven.bun-vscode`).
### Testing
Root commands for cross-package confidence:
```sh
bun run test # all tests
bun run types # typecheck all packages
bun run check # lint + build + typecheck + check-publish
```
If you touch hub/bootstrap/session flows, prefer both unit coverage and an end-to-end sanity check.
## Publishing
### SDK Release
The `bun release sdk` script automates the SDK publish flow: versioning, lockfile regeneration, verification, and publishing.
```sh
bun release sdk # auto-increment patch version
bun release sdk 0.1.0 # explicit version
bun release sdk --tag next # publish with a custom npm dist-tag
bun release sdk --dry-run # preview without side effects
```
Additional SDK flags: `--skip-tests`, `--skip-git-tags`.
The script checks out `main` (and pulls latest) before starting. If the working tree is dirty it aborts.
The SDK flow runs: tests → version bump → lockfile regeneration → tarball verification → publish (shared → llms → agents → core) → optional `sdk-v{VERSION}` tag creation.
### CLI Release
The CLI is published through npm. Start releases from `apps/cli` with the `publish-cli` skill. The skill should guide the release prep, then offer the GitHub Actions publish path and the local publish path.
Under the hood, every release starts the same way: prepare one release commit, then choose how to publish it.
Prepare the release commit from the code you want to release:
1. Draft user-facing release notes from the commits since the last `cli-vX.Y.Z` tag.
2. Choose the release version.
3. Update `apps/cli/package.json`.
4. Add the approved notes to `apps/cli/CHANGELOG.md`.
5. Run the requested checks.
6. Commit the version and changelog changes.
Then publish that release commit with one of these paths.
Path A: publish from GitHub Actions.
Use this for normal releases. Merge the release commit to `main`, create and push the matching release tag, then run:
```sh
git tag -a cli-vX.Y.Z -m "CLI vX.Y.Z"
git push origin refs/tags/cli-vX.Y.Z
gh workflow run publish-cli.yaml -f publish_target=main -f git_tag=cli-vX.Y.Z -f confirm_publish=publish
```
The workflow checks out the provided `cli-vX.Y.Z` tag, verifies it matches `apps/cli/package.json`, builds the platform packages, publishes to npm with the `latest` dist-tag, creates the GitHub release, and posts to Slack.
Path B: publish locally.
Use this when publishing from an authenticated local machine. Start from a clean checkout at the release commit:
```sh
gh auth status
npm whoami
git tag -a cli-vX.Y.Z -m "CLI vX.Y.Z"
git push origin refs/tags/cli-vX.Y.Z
bun release cli
gh release create cli-vX.Y.Z --verify-tag --title "CLI vX.Y.Z" --notes "Paste the approved release notes here."
```
The local helper verifies the working tree is clean, verifies `cli-vX.Y.Z` points at `HEAD` locally and on `origin`, runs tests, builds platform packages, and publishes to npm.
Nightly release:
```sh
gh workflow run publish-cli.yaml -f publish_target=nightly
```
Nightly also runs on a schedule. It publishes `X.Y.Z-nightly.TIMESTAMP` to npm with the `nightly` dist-tag and skips if there were no commits in the last 24 hours unless forced.
### Manual SDK Publish
If you need fine-grained control over individual steps:
1. `bun run test`
2. `bun version <version>` — updates all workspace package versions, regenerates models, formats, and builds.
3. `rm bun.lock && bun install --lockfile-only` — regenerate the lockfile so `bun pm pack` resolves `workspace:*` to the new versions.
4. `bun scripts/check-publish.ts` — pack tarballs, verify dependency alignment, test isolated install and module resolution.
5. `npm login` — ensure you're authenticated with the npm registry.
6. Publish in dependency order:
```sh
cd packages/shared && bun publish && cd ../llms && bun publish && cd ../agents && bun publish && cd ../core && bun publish && cd ../../
```
7. For tagged production releases, create and push a git tag: `git tag -a sdk-v{VERSION} -m "SDK v{VERSION}" && git push origin sdk-v{VERSION}`.
### Workspace Dependency Rules
- Source manifests use `workspace:*` so `bun install` and local builds resolve correctly.
- Published runtime workspace packages stay in `dependencies`. Bundled internals go in `devDependencies` so they don't leak into packed manifests.
- `bun publish` resolves `workspace:*` to concrete versions when packing.
### Verifying a Single Package
Inspect the exact manifest that will be published:
```sh
cd ./packages/core
tmpdir=$(mktemp -d)
bun pm pack --destination "$tmpdir" >/dev/null
tar -xOf "$tmpdir"/*.tgz package/package.json | jq '.version, .dependencies'
```
Check installed versions in a consuming project:
```sh
bun pm ls @cline/core @cline/agents @cline/llms
```
### CI
The CI publish workflow (`.github/workflows/publish-sdk.yaml`) follows the same order: build → version → check-publish → publish (shared → llms → agents → core). It supports `nightly` and `latest` channels and is triggered by manual dispatch or a daily cron.
### Root Automation Scope
Root scripts are intentionally narrower than the full workspace:
- Root SDK build/test/version/publish flows target the publishable SDK packages only.
- Internal packages can still be built/tested directly, but should not be swept into release automation by accident.
- If you add a new internal package, keep it out of root publish/version/build sweeps unless you explicitly intend to publish it.
+201
View File
@@ -0,0 +1,201 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright 2026 Cline Bot Inc.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+266
View File
@@ -0,0 +1,266 @@
<p align="center">
<img src="https://github.com/user-attachments/assets/a05da977-2cb7-498a-88ca-20f24c9562e1" width="100%" />
</p>
<div align="center">
<table>
<tbody>
<td align="center">
<a href="https://discord.gg/cline" target="_blank"><strong>Discord</strong></a>
</td>
<td align="center">
<a href="https://www.reddit.com/r/cline/" target="_blank"><strong>r/cline</strong></a>
</td>
<td align="center">
<a href="https://github.com/cline/cline/discussions/categories/feature-requests?discussions_q=is%3Aopen+category%3A%22Feature+Requests%22+sort%3Atop" target="_blank"><strong>Feature Requests</strong></a>
</td>
</tbody>
</table>
</div>
> **⚠️ Disclaimer:** This repo is currently still under active development. We will move this repo to be under the `cline/cline` repository soon. We currently do not accept public contributions given the early stage.
The Cline SDK is a TypeScript framework for building AI agents that can edit files, run shell commands, browse the web, call APIs, and use any custom tool you give them. It's the same engine that powers [Cline](https://github.com/cline/cline), packaged as a library you can embed in your own applications.
```typescript
import { Agent } from "@cline/sdk"
const agent = new Agent({
providerId: "cline",
modelId: "openai/gpt-5.5",
systemPrompt: "You are a helpful coding assistant.",
tools: [],
})
const result = await agent.run("Create a REST API with Express and TypeScript")
console.log(result.text)
```
That's it. The agent streams its response, calls tools if you give it any, and returns when the task is done.
## Install
```bash
npm install @cline/sdk
```
## What You Can Build
Coding agents, Slack bots, scheduled automations, code review pipelines, multi-agent teams, IDE integrations -- anything that benefits from an LLM that can take actions, not just generate text.
```typescript
// Slack bot: each thread gets its own agent with conversation memory
const agents = new Map<string, Agent>()
async function handleMessage(threadId: string, message: string) {
let agent = agents.get(threadId)
if (!agent) {
agent = new Agent({
providerId: "gemini",
modelId: "gemini-3.1-pro-preview",
systemPrompt: "You are a concise Slack assistant.",
tools: [],
})
agents.set(threadId, agent)
}
const result = agent.hasRun
? await agent.continue(message)
: await agent.run(message)
return result.text
}
```
Explore full working examples in [`examples/`](examples) and app examples in [`apps/examples/`](apps/examples):
| Example | Description |
|---------|-------------|
| [Plugins](examples/plugins) | Custom tools with workspace-aware context, lifecycle hooks, and branch-level safety policies |
| [Subagent Orchestration](examples/plugins/agents-squad) | Spawn and manage background agents with presets, skills, and cross-agent handoffs |
| [Hooks](examples/hooks) | File-based and runtime hooks for logging, review gates, context injection, and lifecycle automation |
| [Cron Automations](examples/cron) | Recurring and event-driven automation specs for scheduled quality checks and PR workflows |
| [Desktop App](apps/examples/desktop-app) | Tauri desktop shell with a Bun sidecar backend and Next.js UI |
| [VS Code Extension App](apps/examples/vscode) | VS Code extension example that runs Cline sessions over the RPC runtime |
## Custom Tools
Tools are how agents interact with the world. Define a tool with a name, a description the model reads, a JSON Schema for inputs, and a function that does the work:
```typescript
import { createTool } from "@cline/sdk"
const deploy = createTool({
name: "deploy",
description: "Deploy the app to staging or production.",
inputSchema: {
type: "object",
properties: {
environment: { type: "string", enum: ["staging", "production"] },
},
required: ["environment"],
},
execute: async (input) => {
const result = await runDeployment(input.environment)
return { url: result.url, status: "success" }
},
})
const agent = new Agent({
providerId: "moonshot",
modelId: "kimi-k2.5",
systemPrompt: "You are a deployment assistant.",
tools: [deploy],
})
```
The agent decides when to call the tool based on the description. It sees the result and incorporates it into its response.
## Streaming Events
Every event during execution is observable in real time:
```typescript
const agent = new Agent({
providerId: "anthropic",
modelId: "claude-opus-4-7",
systemPrompt: "You are a helpful assistant.",
tools: [myTool],
onEvent: (event) => {
switch (event.type) {
case "content_update":
if (event.contentType === "text") process.stdout.write(event.text)
break
case "content_start":
if (event.contentType === "tool") console.log(`\n[${event.toolName}]`)
break
case "usage":
console.log(`\ntokens: ${event.inputTokens} in, ${event.outputTokens} out`)
break
}
},
})
```
## Plugins
Package reusable capabilities as extensions. An extension can register tools, observe lifecycle events, and modify agent behavior:
```typescript
const metrics: AgentPlugin = {
name: "metrics",
manifest: { capabilities: ["tools", "hooks"] },
setup(api) {
api.registerTool(myCustomTool)
},
hooks: {
beforeRun() {
console.time("agent")
},
beforeTool({ toolCall }) {
console.log(`tool: ${toolCall.toolName}`)
},
afterRun({ result }) {
console.timeEnd("agent")
console.log(`${result.iterations} iterations, ${result.usage.outputTokens} tokens`)
},
},
}
```
## ClineCore: Full Runtime
When you need session persistence, built-in tools, config discovery, and multi-process support, use `ClineCore`:
```typescript
import { ClineCore } from "@cline/sdk"
const cline = await ClineCore.create({ clientName: "my-app" })
const session = await cline.start({
prompt: "Set up CI with GitHub Actions",
config: {
providerId: "anthropic",
modelId: "claude-sonnet-4-6",
apiKey: process.env.ANTHROPIC_API_KEY,
cwd: "/path/to/project",
enableTools: true,
},
})
console.log(session.result?.text)
```
`ClineCore` gives the agent built-in tools (`bash`, `editor`, `read_files`, `apply_patch`, `search`, `fetch_web`), persists sessions to SQLite, discovers config from `.cline/` directories, and optionally connects to an RPC sidecar for scheduled agents and cross-process session management.
## Packages
The SDK is a layered stack. Use as much or as little as you need:
| Package | What it does |
|---------|-------------|
| `@cline/sdk` | Everything you need -- install this one |
| `@cline/core` | Sessions, persistence, built-in tools, config discovery, RPC |
| `@cline/agents` | Stateless agent loop with tool execution and streaming |
| `@cline/llms` | LLM provider gateway (Anthropic, OpenAI, Google, Bedrock, Mistral, and more) |
| `@cline/shared` | Types, tool creation helpers, hook engine |
`@cline/sdk` is an alias for `@cline/core` that re-exports from all packages, so a single install gives you the full API. The individual packages are available if you want a minimal dependency footprint.
## CLI
The Cline CLI gives you terminal access to the full SDK:
```bash
# Interactive agent
cline
# Single prompt
cline "Refactor the auth module to use JWT"
# Schedule an agent to run daily
cline schedule create "PR summary" --cron "0 9 * * MON-FRI" --prompt "Summarize open PRs"
# Connect a Telegram bot created with @BotFather
cline connect telegram -m my_bot -k "$TELEGRAM_BOT_TOKEN"
# Then send /help or /start to the bot in Telegram
```
For Telegram-specific connector behavior, see [`apps/cli/src/connectors/adapters/telegram.md`](./apps/cli/src/connectors/adapters/telegram.md).
## Providers
Works with every major LLM provider out of the box:
| Provider | Models |
|----------|--------|
| Anthropic | Claude Opus 4.7, Sonnet 4.6, Haiku 4.5 |
| OpenAI | GPT-5.5, GPT-5.3 Codex |
| Google | Gemini 3.1 Pro Preview, Gemini 3 Flash Preview |
| AWS Bedrock | Claude, Llama |
| Mistral | Mistral Large, Codestral |
| Any OpenAI-compatible | vLLM, Together, Fireworks, Groq, etc. |
## Documentation
Full documentation at [docs.cline.bot/sdk](https://docs.cline.bot/sdk/overview):
- [Quickstart](https://docs.cline.bot/sdk/quickstart) -- zero to running agent in 5 minutes
- [Core Concepts](https://docs.cline.bot/sdk/agents) -- agents, sessions, tools, events, extensions, hooks
- [Guides](https://docs.cline.bot/sdk/guides/building-an-agent) -- end-to-end tutorials for common patterns
- [Architecture](https://docs.cline.bot/sdk/architecture/overview) -- how the SDK is structured and why
- [API Reference](https://docs.cline.bot/sdk/reference/cline-core) -- every method, type, and config option
## Contributing
To contribute to the project, start with our [Contributing Guide](CONTRIBUTING.md) to learn the basics. You can also join our [Discord](https://discord.gg/cline) to chat with other contributors in the `#contributors` channel. If you're looking for full-time work, check out our open positions on our [careers page](https://cline.bot/join-us)!
## License
[Apache 2.0 © 2026 Cline Bot Inc.](./LICENSE)
+25
View File
@@ -0,0 +1,25 @@
# Security Policy
## Supported Versions
We actively patch only the most recent minor release of Cline. Older versions receive fixes at our discretion.
## Reporting a Vulnerability
We appreciate your efforts to responsibly disclose your findings and will make every effort to acknowledge your contributions.
To report a security issue, please submit your report through our [Bugcrowd Vulnerability Disclosure Program](https://bugcrowd.com/engagements/clinebot-vdp-ess). Bugcrowd will manage communication and triage on our behalf.
When reporting, please include:
- A short summary of the issue
- Steps to reproduce or a proof of concept
- Any logs, stack traces, or screenshots that might help us understand the problem
Please keep the details private until a resolution has been reached.
## Escalation
If you are unable to submit through Bugcrowd, you may send an email to security@cline.bot.
Thank you for helping us keep Cline users safe.
@@ -0,0 +1,200 @@
---
name: opentui
description: Comprehensive OpenTUI skill for building terminal user interfaces. Covers the core imperative API, React reconciler, and Solid reconciler. Use for any TUI development task including components, layout, keyboard handling, animations, and testing.
metadata:
references: core, react, solid
---
# OpenTUI Platform Skill
Consolidated skill for building terminal user interfaces with OpenTUI. Use decision trees below to find the right framework and components, then load detailed references.
## Critical Rules
**Follow these rules in all OpenTUI code:**
1. **Use `create-tui` for new projects.** See framework `REFERENCE.md` quick starts.
2. **`create-tui` options must come before arguments.** `bunx create-tui -t react my-app` works, `bunx create-tui my-app -t react` does NOT.
3. **Never call `process.exit()` directly.** Use `renderer.destroy()` (see `core/gotchas.md`).
4. **Text styling requires nested tags in React/Solid.** Use modifier elements, not props (see `components/text-display.md`).
## How to Use This Skill
### Reference File Structure
Framework references follow a 5-file pattern. Cross-cutting concepts are single-file guides.
Each framework in `./references/<framework>/` contains:
| File | Purpose | When to Read |
|------|---------|--------------|
| `REFERENCE.md` | Overview, when to use, quick start | **Always read first** |
| `api.md` | Runtime API, components, hooks | Writing code |
| `configuration.md` | Setup, tsconfig, bundling | Configuring a project |
| `patterns.md` | Common patterns, best practices | Implementation guidance |
| `gotchas.md` | Pitfalls, limitations, debugging | Troubleshooting |
Cross-cutting concepts in `./references/<concept>/` have `REFERENCE.md` as the entry point.
### Reading Order
1. Start with `REFERENCE.md` for your chosen framework
2. Then read additional files relevant to your task:
- Building components -> `api.md` + `components/<category>.md`
- Setting up project -> `configuration.md`
- Layout/positioning -> `layout/REFERENCE.md`
- Keyboard/input handling -> `keyboard/REFERENCE.md`
- Animations -> `animation/REFERENCE.md`
- Troubleshooting -> `gotchas.md` + `testing/REFERENCE.md`
### Example Paths
```
./references/react/REFERENCE.md # Start here for React
./references/react/api.md # React components and hooks
./references/solid/configuration.md # Solid project setup
./references/components/inputs.md # Input, Textarea, Select docs
./references/core/gotchas.md # Core debugging tips
```
### Runtime Notes
OpenTUI runs on Bun and uses Zig for native builds. Read `./references/core/gotchas.md` for runtime requirements and build guidance.
## Quick Decision Trees
### "Which framework should I use?"
```
Which framework?
├─ I want full control, maximum performance, no framework overhead
│ └─ core/ (imperative API)
├─ I know React, want familiar component patterns
│ └─ react/ (React reconciler)
├─ I want fine-grained reactivity, optimal re-renders
│ └─ solid/ (Solid reconciler)
└─ I'm building a library/framework on top of OpenTUI
└─ core/ (imperative API)
```
### "I need to display content"
```
Display content?
├─ Plain or styled text -> components/text-display.md
├─ Container with borders/background -> components/containers.md
├─ Scrollable content area -> components/containers.md (scrollbox)
├─ ASCII art banner/title -> components/text-display.md (ascii-font)
├─ Data table with borders/wrapping -> components/code-diff.md (TextTable)
├─ Code with syntax highlighting -> components/code-diff.md
├─ Diff viewer (unified/split) -> components/code-diff.md
├─ Line numbers with diagnostics -> components/code-diff.md
└─ Markdown content (streaming) -> components/code-diff.md (markdown)
```
### "I need user input"
```
User input?
├─ Single-line text field -> components/inputs.md (input)
├─ Multi-line text editor -> components/inputs.md (textarea)
├─ Select from a list (vertical) -> components/inputs.md (select)
├─ Tab-based selection (horizontal) -> components/inputs.md (tab-select)
└─ Custom keyboard shortcuts -> keyboard/REFERENCE.md
```
### "I need layout/positioning"
```
Layout?
├─ Flexbox-style layouts (row, column, wrap) -> layout/REFERENCE.md
├─ Absolute positioning -> layout/patterns.md
├─ Responsive to terminal size -> layout/patterns.md
├─ Centering content -> layout/patterns.md
└─ Complex nested layouts -> layout/patterns.md
```
### "I need animations"
```
Animations?
├─ Timeline-based animations -> animation/REFERENCE.md
├─ Easing functions -> animation/REFERENCE.md
├─ Property transitions -> animation/REFERENCE.md
└─ Looping animations -> animation/REFERENCE.md
```
### "I need to handle input"
```
Input handling?
├─ Keyboard events (keypress, release) -> keyboard/REFERENCE.md
├─ Focus management -> keyboard/REFERENCE.md
├─ Paste events -> keyboard/REFERENCE.md
├─ Mouse events -> components/containers.md
├─ Text selection & copy-on-select -> keyboard/REFERENCE.md (selection)
└─ Clipboard (OSC 52) -> keyboard/REFERENCE.md (clipboard)
```
### "I need to test my TUI"
```
Testing?
├─ Snapshot testing -> testing/REFERENCE.md
├─ Interaction testing -> testing/REFERENCE.md
├─ Test renderer setup -> testing/REFERENCE.md
└─ Debugging tests -> testing/REFERENCE.md
```
### "I need to debug/troubleshoot"
```
Troubleshooting?
├─ Runtime errors, crashes -> <framework>/gotchas.md
├─ Layout issues -> layout/REFERENCE.md + layout/patterns.md
├─ Input/focus issues -> keyboard/REFERENCE.md
└─ Repro + regression tests -> testing/REFERENCE.md
```
### Troubleshooting Index
- Terminal cleanup, crashes -> `core/gotchas.md`
- Text styling not applying -> `components/text-display.md`
- Input focus/shortcuts -> `keyboard/REFERENCE.md`
- Layout misalignment -> `layout/REFERENCE.md`
- Flaky snapshots -> `testing/REFERENCE.md`
For component naming differences and text modifiers, see `components/REFERENCE.md`.
## Product Index
### Frameworks
| Framework | Entry File | Description |
|-----------|------------|-------------|
| Core | `./references/core/REFERENCE.md` | Imperative API, all primitives |
| React | `./references/react/REFERENCE.md` | React reconciler for declarative TUI |
| Solid | `./references/solid/REFERENCE.md` | SolidJS reconciler for declarative TUI |
### Cross-Cutting Concepts
| Concept | Entry File | Description |
|---------|------------|-------------|
| Layout | `./references/layout/REFERENCE.md` | Yoga/Flexbox layout system |
| Components | `./references/components/REFERENCE.md` | Component reference by category |
| Keyboard | `./references/keyboard/REFERENCE.md` | Keyboard input handling |
| Animation | `./references/animation/REFERENCE.md` | Timeline-based animations |
| Testing | `./references/testing/REFERENCE.md` | Test renderer and snapshots |
### Component Categories
| Category | Entry File | Components |
|----------|------------|------------|
| Text & Display | `./references/components/text-display.md` | text, ascii-font, styled text |
| Containers | `./references/components/containers.md` | box, scrollbox, borders |
| Inputs | `./references/components/inputs.md` | input, textarea, select, tab-select |
| Code & Diff | `./references/components/code-diff.md` | code, line-number, diff, markdown, text-table |
## Resources
**Repository**: https://github.com/anomalyco/opentui
**Core Docs**: https://github.com/anomalyco/opentui/tree/main/packages/core/docs
**Examples**: https://github.com/anomalyco/opentui/tree/main/packages/core/src/examples
**Awesome List**: https://github.com/msmps/awesome-opentui
@@ -0,0 +1,431 @@
# Animation System
OpenTUI provides a timeline-based animation system for smooth property transitions.
## Overview
Animations in OpenTUI use:
- **Timeline**: Orchestrates multiple animations
- **Animation Engine**: Manages timelines and rendering
- **Easing Functions**: Control animation curves
## When to Use
Use this reference when you need timeline-driven animations, easing curves, or progressive transitions.
## Basic Usage
### React
```tsx
import { useTimeline } from "@opentui/react"
import { useEffect, useState } from "react"
function AnimatedBox() {
const [width, setWidth] = useState(0)
const timeline = useTimeline({
duration: 2000,
})
useEffect(() => {
timeline.add(
{ width: 0 },
{
width: 50,
duration: 2000,
ease: "easeOutQuad",
onUpdate: (anim) => {
setWidth(Math.round(anim.targets[0].width))
},
}
)
}, [])
return (
<box
width={width}
height={3}
backgroundColor="#6a5acd"
/>
)
}
```
### Solid
```tsx
import { useTimeline } from "@opentui/solid"
import { createSignal, onMount } from "solid-js"
function AnimatedBox() {
const [width, setWidth] = createSignal(0)
const timeline = useTimeline({
duration: 2000,
})
onMount(() => {
timeline.add(
{ width: 0 },
{
width: 50,
duration: 2000,
ease: "easeOutQuad",
onUpdate: (anim) => {
setWidth(Math.round(anim.targets[0].width))
},
}
)
})
return (
<box
width={width()}
height={3}
backgroundColor="#6a5acd"
/>
)
}
```
### Core
```typescript
import { createCliRenderer, Timeline, engine } from "@opentui/core"
const renderer = await createCliRenderer()
engine.attach(renderer)
const timeline = new Timeline({
duration: 2000,
autoplay: true,
})
timeline.add(
{ x: 0 },
{
x: 50,
duration: 2000,
ease: "easeOutQuad",
onUpdate: (anim) => {
box.setLeft(Math.round(anim.targets[0].x))
},
}
)
engine.addTimeline(timeline)
```
## Timeline Options
```typescript
const timeline = useTimeline({
duration: 2000, // Total duration in ms
loop: false, // Loop the timeline
autoplay: true, // Start automatically
onComplete: () => {}, // Called when timeline completes
onPause: () => {}, // Called when timeline pauses
})
```
## Timeline Methods
```typescript
// Add animation
timeline.add(target, properties, startTime?)
// Control playback
timeline.play() // Start/resume
timeline.pause() // Pause
timeline.restart() // Restart from beginning
// State
timeline.progress // Current progress (0-1)
timeline.duration // Total duration
```
## Animation Properties
```typescript
timeline.add(
{ value: 0 }, // Target object with initial values
{
value: 100, // Final value
duration: 1000, // Animation duration in ms
ease: "linear", // Easing function
delay: 0, // Delay before starting
onUpdate: (anim) => {
// Called each frame
const current = anim.targets[0].value
},
onComplete: () => {
// Called when this animation completes
},
},
0 // Start time in timeline (optional)
)
```
## Easing Functions
Available easing functions:
### Linear
| Name | Description |
|------|-------------|
| `linear` | Constant speed |
### Quad (Power of 2)
| Name | Description |
|------|-------------|
| `easeInQuad` | Slow start |
| `easeOutQuad` | Slow end |
| `easeInOutQuad` | Slow start and end |
### Cubic (Power of 3)
| Name | Description |
|------|-------------|
| `easeInCubic` | Slower start |
| `easeOutCubic` | Slower end |
| `easeInOutCubic` | Slower start and end |
### Quart (Power of 4)
| Name | Description |
|------|-------------|
| `easeInQuart` | Even slower start |
| `easeOutQuart` | Even slower end |
| `easeInOutQuart` | Even slower start and end |
### Expo (Exponential)
| Name | Description |
|------|-------------|
| `easeInExpo` | Exponential start |
| `easeOutExpo` | Exponential end |
| `easeInOutExpo` | Exponential start and end |
### Back (Overshoot)
| Name | Description |
|------|-------------|
| `easeInBack` | Pull back, then forward |
| `easeOutBack` | Overshoot, then settle |
| `easeInOutBack` | Both |
### Elastic
| Name | Description |
|------|-------------|
| `easeInElastic` | Elastic start |
| `easeOutElastic` | Elastic end (bouncy) |
| `easeInOutElastic` | Both |
### Bounce
| Name | Description |
|------|-------------|
| `easeInBounce` | Bounce at start |
| `easeOutBounce` | Bounce at end |
| `easeInOutBounce` | Both |
## Patterns
### Progress Bar
```tsx
function ProgressBar({ progress }: { progress: number }) {
const [width, setWidth] = useState(0)
const maxWidth = 50
const timeline = useTimeline()
useEffect(() => {
timeline.add(
{ value: width },
{
value: (progress / 100) * maxWidth,
duration: 300,
ease: "easeOutQuad",
onUpdate: (anim) => {
setWidth(Math.round(anim.targets[0].value))
},
}
)
}, [progress])
return (
<box flexDirection="column" gap={1}>
<text>Progress: {progress}%</text>
<box width={maxWidth} height={1} backgroundColor="#333">
<box width={width} height={1} backgroundColor="#00FF00" />
</box>
</box>
)
}
```
### Fade In
```tsx
function FadeIn({ children }) {
const [opacity, setOpacity] = useState(0)
const timeline = useTimeline()
useEffect(() => {
timeline.add(
{ opacity: 0 },
{
opacity: 1,
duration: 500,
ease: "easeOutQuad",
onUpdate: (anim) => {
setOpacity(anim.targets[0].opacity)
},
}
)
}, [])
return (
<box style={{ opacity }}>
{children}
</box>
)
}
```
### Looping Animation
```tsx
function Spinner() {
const [frame, setFrame] = useState(0)
const frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
useEffect(() => {
const interval = setInterval(() => {
setFrame(f => (f + 1) % frames.length)
}, 80)
return () => clearInterval(interval)
}, [])
return <text>{frames[frame]} Loading...</text>
}
```
### Staggered Animation
```tsx
function StaggeredList({ items }) {
const [visibleCount, setVisibleCount] = useState(0)
useEffect(() => {
let count = 0
const interval = setInterval(() => {
count++
setVisibleCount(count)
if (count >= items.length) {
clearInterval(interval)
}
}, 100)
return () => clearInterval(interval)
}, [items.length])
return (
<box flexDirection="column">
{items.slice(0, visibleCount).map((item, i) => (
<text key={i}>{item}</text>
))}
</box>
)
}
```
### Slide In
```tsx
function SlideIn({ children, from = "left" }) {
const [offset, setOffset] = useState(from === "left" ? -20 : 20)
const timeline = useTimeline()
useEffect(() => {
timeline.add(
{ offset: from === "left" ? -20 : 20 },
{
offset: 0,
duration: 300,
ease: "easeOutCubic",
onUpdate: (anim) => {
setOffset(Math.round(anim.targets[0].offset))
},
}
)
}, [])
return (
<box position="relative" left={offset}>
{children}
</box>
)
}
```
## Performance Tips
### Batch Updates
Timeline automatically batches updates within the render loop.
### Use Integer Values
Round animated values for character-based positioning:
```typescript
onUpdate: (anim) => {
setX(Math.round(anim.targets[0].x))
}
```
### Clean Up Timelines
Hooks automatically clean up, but for core:
```typescript
// When done with timeline
engine.removeTimeline(timeline)
```
## Gotchas
### Terminal Refresh Rate
Terminal UIs typically refresh at 60 FPS max. Very fast animations may appear choppy.
### Character Grid
Animations are constrained to character cells. Sub-pixel positioning isn't possible.
### Cleanup in Effects
Always clean up intervals and timelines:
```tsx
useEffect(() => {
const interval = setInterval(...)
return () => clearInterval(interval)
}, [])
```
## See Also
- [React API](../react/api.md) - `useTimeline` hook reference
- [Solid API](../solid/api.md) - `useTimeline` hook reference
- [Core API](../core/api.md) - `AnimationEngine` and `Timeline` classes
- [Layout Patterns](../layout/patterns.md) - Animated positioning and transitions
@@ -0,0 +1,144 @@
# OpenTUI Components
Reference for all OpenTUI components, organized by category. Components are available in all three frameworks (Core, React, Solid) with slight API differences.
## When to Use
Use this reference when you need to find the right component category or compare naming across Core, React, and Solid.
## Component Categories
| Category | Components | File |
|----------|------------|------|
| Text & Display | text, ascii-font, styled text | [text-display.md](./text-display.md) |
| Containers | box, scrollbox, borders | [containers.md](./containers.md) |
| Inputs | input, textarea, select, tab-select | [inputs.md](./inputs.md) |
| Code & Diff | code, line-number, diff, markdown, text-table | [code-diff.md](./code-diff.md) |
## Component Chooser
```
Need a component?
├─ Styled text or ASCII art -> text-display.md
├─ Containers, borders, scrolling -> containers.md
├─ Forms or input controls -> inputs.md
└─ Code blocks, diffs, line numbers, markdown -> code-diff.md
```
## Component Naming
Components have different names across frameworks:
| Concept | Core (Class) | React (JSX) | Solid (JSX) |
|---------|--------------|-------------|-------------|
| Text | `TextRenderable` | `<text>` | `<text>` |
| Box | `BoxRenderable` | `<box>` | `<box>` |
| ScrollBox | `ScrollBoxRenderable` | `<scrollbox>` | `<scrollbox>` |
| Input | `InputRenderable` | `<input>` | `<input>` |
| Textarea | `TextareaRenderable` | `<textarea>` | `<textarea>` |
| Select | `SelectRenderable` | `<select>` | `<select>` |
| Tab Select | `TabSelectRenderable` | `<tab-select>` | `<tab_select>` |
| ASCII Font | `ASCIIFontRenderable` | `<ascii-font>` | `<ascii_font>` |
| Code | `CodeRenderable` | `<code>` | `<code>` |
| Line Number | `LineNumberRenderable` | `<line-number>` | `<line_number>` |
| Diff | `DiffRenderable` | `<diff>` | `<diff>` |
| Markdown | `MarkdownRenderable` | `<markdown>` | `<markdown>` |
| TextTable | `TextTableRenderable` | N/A (Core only) | N/A (Core only) |
**Note**: Solid uses underscores (`tab_select`) while React uses hyphens (`tab-select`). `TextTableRenderable` is used internally by `MarkdownRenderable` for table rendering and is also available as a standalone Core component.
## Common Properties
All components share these layout properties (see [Layout](../layout/REFERENCE.md)):
```tsx
// Positioning
position="relative" | "absolute"
left, top, right, bottom
// Dimensions
width, height
minWidth, maxWidth, minHeight, maxHeight
// Flexbox
flexDirection, flexGrow, flexShrink, flexBasis
justifyContent, alignItems, alignSelf
flexWrap, gap
// Spacing
padding, paddingTop, paddingRight, paddingBottom, paddingLeft
paddingX, paddingY // Axis shorthand (horizontal/vertical)
margin, marginTop, marginRight, marginBottom, marginLeft
marginX, marginY // Axis shorthand (horizontal/vertical)
// Display
display="flex" | "none"
overflow="visible" | "hidden" | "scroll"
zIndex
```
## Quick Examples
### Core (Imperative)
```typescript
import { createCliRenderer, TextRenderable, BoxRenderable } from "@opentui/core"
const renderer = await createCliRenderer()
const box = new BoxRenderable(renderer, {
id: "container",
border: true,
padding: 2,
})
const text = new TextRenderable(renderer, {
id: "greeting",
content: "Hello!",
fg: "#00FF00",
})
box.add(text)
renderer.root.add(box)
```
### React
```tsx
import { createCliRenderer } from "@opentui/core"
import { createRoot } from "@opentui/react"
function App() {
return (
<box border padding={2}>
<text fg="#00FF00">Hello!</text>
</box>
)
}
const renderer = await createCliRenderer()
createRoot(renderer).render(<App />)
```
### Solid
```tsx
import { render } from "@opentui/solid"
function App() {
return (
<box border padding={2}>
<text fg="#00FF00">Hello!</text>
</box>
)
}
render(() => <App />)
```
## See Also
- [Core API](../core/api.md) - Imperative component classes
- [React API](../react/api.md) - React component props
- [Solid API](../solid/api.md) - Solid component props
- [Layout](../layout/REFERENCE.md) - Layout system details
@@ -0,0 +1,672 @@
# Code & Diff Components
Components for displaying code with syntax highlighting and diffs in OpenTUI.
## Code Component
Display syntax-highlighted code blocks.
### Basic Usage
```tsx
// React
<code
code={`function hello() {
console.log("Hello, World!");
}`}
language="typescript"
/>
// Solid
<code
code={sourceCode}
language="javascript"
/>
// Core
const codeBlock = new CodeRenderable(renderer, {
id: "code",
code: sourceCode,
language: "typescript",
})
```
### Supported Languages
OpenTUI uses Tree-sitter for syntax highlighting. Common languages:
- `typescript`, `javascript`
- `python`
- `rust`
- `go`
- `json`
- `html`, `css`
- `markdown`
- `bash`, `shell`
### Styling
```tsx
<code
code={sourceCode}
language="typescript"
backgroundColor="#1a1a2e"
showLineNumbers
/>
```
### onHighlight Callback
Intercept and modify syntax highlights before rendering:
```tsx
// Core
const codeBlock = new CodeRenderable(renderer, {
id: "code",
code: sourceCode,
language: "typescript",
onHighlight: (highlights, context) => {
// Add custom highlights
highlights.push([10, 20, "custom.error", {}])
return highlights
},
})
// React/Solid
<code
code={sourceCode}
language="typescript"
onHighlight={(highlights, context) => {
// context: { content, filetype, syntaxStyle }
// Modify and return highlights array
return highlights.filter(h => h[2] !== "comment")
}}
/>
```
**Callback signature:**
- `highlights: SimpleHighlight[]` - Array of `[start, end, scope, metadata]`
- `context: { content, filetype, syntaxStyle }` - Highlighting context
- Return modified highlights array or `undefined` to use original
Supports async callbacks for fetching additional highlight data.
### onChunks Callback
Post-process rendered text chunks after syntax highlighting. Runs after `onHighlight` and receives fully resolved chunks:
```tsx
// Core
const codeBlock = new CodeRenderable(renderer, {
id: "code",
code: sourceCode,
language: "typescript",
onChunks: (chunks, context) => {
// Transform chunks (e.g., add link detection)
return chunks
},
})
// React/Solid
<code
code={sourceCode}
language="typescript"
onChunks={(chunks, context) => {
// context: { content, filetype, syntaxStyle, highlights }
return chunks
}}
/>
```
### Link Detection Utility
Auto-detect URLs in code and add clickable hyperlinks:
```typescript
import { detectLinks } from "@opentui/core"
<code
code={sourceCode}
language="typescript"
onChunks={(chunks, context) => detectLinks(chunks, context)}
/>
```
`detectLinks` examines Tree-sitter highlights to find URL tokens and sets `chunk.link` on matching chunks. Supports async usage.
## TextTable Component
Render data tables with borders, word wrapping, and selection support.
### Basic Usage
```typescript
// Core
import { TextTableRenderable, type TextTableContent } from "@opentui/core"
const content: TextTableContent = [
[[ { text: "Name" } ], [ { text: "Age" } ], [ { text: "Role" } ]],
[[ { text: "Alice" } ], [ { text: "30" } ], [ { text: "Engineer" } ]],
[[ { text: "Bob" } ], [ { text: "25" } ], [ { text: "Designer" } ]],
]
const table = new TextTableRenderable(renderer, {
id: "table",
content,
wrapMode: "word", // "none" | "char" | "word"
columnWidthMode: "content", // "content" | "fill"
cellPadding: 0,
border: true,
outerBorder: true,
borderStyle: "single", // single | double | rounded | bold
selectable: true, // Allow text selection
columnFitter: "balanced", // "proportional" | "balanced"
})
```
### Options
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `content` | `TextTableContent` | - | 2D array of cell content |
| `wrapMode` | `"none" \| "char" \| "word"` | `"none"` | Text wrapping in cells |
| `columnWidthMode` | `"content" \| "fill"` | `"content"` | Column sizing strategy |
| `cellPadding` | `number` | `0` | Padding inside cells |
| `border` | `boolean` | `true` | Show inner borders |
| `outerBorder` | `boolean` | `true` | Show outer borders |
| `borderStyle` | `string` | `"single"` | Border style |
| `borderColor` | `string \| RGBA` | - | Border color |
| `selectable` | `boolean` | `false` | Allow text selection |
| `columnFitter` | `"proportional" \| "balanced"` | `"proportional"` | Column width distribution |
### Cell Content Format
Each cell is an array of styled text chunks:
```typescript
type TextTableCellContent = { text: string; fg?: RGBA; bg?: RGBA }[]
type TextTableContent = TextTableCellContent[][] // rows -> cells -> chunks
```
### Selection
```typescript
table.getSelectedText() // Get selected text
table.hasSelection() // Check if text is selected
```
Columnar selection is supported: dragging vertically within a single column selects only that column's content.
## Line Number Component
Code display with line numbers, highlighting, and diagnostics.
### Basic Usage
```tsx
// React
<line-number
code={sourceCode}
language="typescript"
/>
// Solid (note underscore)
<line_number
code={sourceCode}
language="typescript"
/>
// Core
const codeView = new LineNumberRenderable(renderer, {
id: "code-view",
code: sourceCode,
language: "typescript",
})
```
### Line Number Options
```tsx
// React
<line-number
code={sourceCode}
language="typescript"
startLine={1} // Starting line number
showLineNumbers={true} // Display line numbers
/>
// Solid
<line_number
code={sourceCode}
language="typescript"
startLine={1}
showLineNumbers={true}
/>
```
### Line Highlighting
Highlight specific lines:
```tsx
// React
<line-number
code={sourceCode}
language="typescript"
highlightedLines={[5, 10, 15]} // Highlight these lines
/>
// Solid
<line_number
code={sourceCode}
language="typescript"
highlightedLines={[5, 10, 15]}
/>
```
### Diagnostics
Show errors, warnings, and info on specific lines:
```tsx
// React
<line-number
code={sourceCode}
language="typescript"
diagnostics={[
{ line: 3, severity: "error", message: "Unexpected token" },
{ line: 7, severity: "warning", message: "Unused variable" },
{ line: 12, severity: "info", message: "Consider using const" },
]}
/>
// Solid
<line_number
code={sourceCode}
language="typescript"
diagnostics={[
{ line: 3, severity: "error", message: "Unexpected token" },
]}
/>
```
**Diagnostic severity levels:**
- `error` - Red indicator
- `warning` - Yellow indicator
- `info` - Blue indicator
- `hint` - Gray indicator
### Diff Highlighting
Show added/removed lines:
```tsx
<line-number
code={sourceCode}
language="typescript"
addedLines={[5, 6, 7]} // Green background
removedLines={[10, 11]} // Red background
/>
```
## Diff Component
Unified or split diff viewer with syntax highlighting.
### Basic Usage
```tsx
// React
<diff
oldCode={originalCode}
newCode={modifiedCode}
language="typescript"
/>
// Solid
<diff
oldCode={originalCode}
newCode={modifiedCode}
language="typescript"
/>
// Core
const diffView = new DiffRenderable(renderer, {
id: "diff",
oldCode: originalCode,
newCode: modifiedCode,
language: "typescript",
})
```
### Display Modes
```tsx
// Unified diff (default)
<diff
oldCode={old}
newCode={new}
mode="unified"
/>
// Split/side-by-side diff
<diff
oldCode={old}
newCode={new}
mode="split"
/>
```
### Synchronized Scrolling (Split View)
In split view, enable synchronized scrolling between left and right panes:
```tsx
// React/Solid
<diff
oldCode={old}
newCode={new}
mode="split"
syncScroll // Scrolling one pane syncs the other
/>
// Core
const diffView = new DiffRenderable(renderer, {
id: "diff",
diff: unifiedDiff,
view: "split",
syncScroll: true,
})
// Toggle at runtime
diffView.syncScroll = true
diffView.syncScroll = false
```
### Options
```tsx
<diff
oldCode={originalCode}
newCode={modifiedCode}
language="typescript"
mode="unified"
showLineNumbers
context={3} // Lines of context around changes
/>
```
### Styling
```tsx
<diff
oldCode={old}
newCode={new}
addedLineColor="#2d4f2d" // Background for added lines
removedLineColor="#4f2d2d" // Background for removed lines
unchangedLineColor="transparent"
/>
```
### Line Highlighting API (Core)
Programmatically highlight specific lines in a diff:
```typescript
// Set a single line's color
diffView.setLineColor(5, "#2d4f2d")
diffView.setLineColor(5, { gutter: "#333", content: "#2d4f2d" })
// Clear a single line's color
diffView.clearLineColor(5)
// Set multiple lines at once
diffView.setLineColors(new Map([
[1, "#2d4f2d"],
[2, "#4f2d2d"],
]))
// Highlight a range
diffView.highlightLines(10, 20, "#2d4f2d")
diffView.clearHighlightLines(10, 20)
// Clear all line colors
diffView.clearAllLineColors()
```
The `LineNumberRenderable` also supports programmatic highlighting:
```typescript
lineNumberView.highlightLines(5, 10, "#2d4f2d")
lineNumberView.clearHighlightLines(5, 10)
```
```
## Markdown Component
Render markdown content with syntax highlighting for code blocks.
### Basic Usage
```tsx
// React
<markdown
content={markdownText}
syntaxStyle={mySyntaxStyle}
/>
// Solid
<markdown
content={markdownText}
syntaxStyle={mySyntaxStyle}
/>
// Core
import { MarkdownRenderable } from "@opentui/core"
const md = new MarkdownRenderable(renderer, {
id: "markdown",
content: "# Hello\n\nThis is **markdown**.",
syntaxStyle: mySyntaxStyle,
})
```
### Options
```tsx
<markdown
content={markdownText}
syntaxStyle={syntaxStyle}
treeSitterClient={client} // Optional: custom tree-sitter client
conceal={true} // Hide markdown syntax characters
streaming={true} // Enable streaming mode for incremental updates
tableOptions={{ // Customize markdown table rendering
widthMode: "full", // "content" | "full"
wrapMode: "word", // "none" | "char" | "word"
cellPadding: 0,
borders: true,
outerBorder: true,
borderStyle: "single",
borderColor: "#555",
selectable: true, // Tables are selectable by default
}}
/>
```
### Custom Node Rendering
```tsx
// Core
const md = new MarkdownRenderable(renderer, {
id: "markdown",
content: "# Custom Heading",
syntaxStyle,
renderNode: (node, ctx, defaultRender) => {
if (node.type === "heading") {
// Return custom renderable for headings
return new TextRenderable(ctx, {
content: `>> ${node.content} <<`,
})
}
return null // Use default rendering
},
})
```
### Streaming Mode
For real-time content like LLM output:
```tsx
const [content, setContent] = useState("")
// Append text as it arrives
useEffect(() => {
llmStream.on("token", (token) => {
setContent(c => c + token)
})
}, [])
<markdown
content={content}
syntaxStyle={syntaxStyle}
streaming={true} // Optimizes for incremental updates
/>
```
## Use Cases
### Code Editor
```tsx
function CodeEditor() {
const [code, setCode] = useState(`function hello() {
console.log("Hello!");
}`)
return (
<box flexDirection="column" height="100%">
<box height={1}>
<text>editor.ts</text>
</box>
<textarea
value={code}
onChange={setCode}
language="typescript"
showLineNumbers
flexGrow={1}
focused
/>
</box>
)
}
```
### Code Review
```tsx
function CodeReview({ oldCode, newCode }) {
return (
<box flexDirection="column" height="100%">
<box height={1} backgroundColor="#333">
<text>Changes in src/utils.ts</text>
</box>
<diff
oldCode={oldCode}
newCode={newCode}
language="typescript"
mode="split"
showLineNumbers
/>
</box>
)
}
```
### Syntax-Highlighted Preview
```tsx
function MarkdownPreview({ content }) {
// Extract code blocks from markdown
const codeBlocks = extractCodeBlocks(content)
return (
<scrollbox height={20}>
{codeBlocks.map((block, i) => (
<box key={i} marginBottom={1}>
<code
code={block.code}
language={block.language}
/>
</box>
))}
</scrollbox>
)
}
```
### Error Display
```tsx
function ErrorView({ errors, code }) {
const diagnostics = errors.map(err => ({
line: err.line,
severity: "error",
message: err.message,
}))
return (
<line-number
code={code}
language="typescript"
diagnostics={diagnostics}
highlightedLines={errors.map(e => e.line)}
/>
)
}
```
## Gotchas
### Solid Uses Underscores
```tsx
// React
<line-number />
// Solid
<line_number />
```
### Language Required for Highlighting
```tsx
// No highlighting (plain text)
<code code={text} />
// With highlighting
<code code={text} language="typescript" />
```
### Large Files
For very large files, consider:
- Pagination or virtual scrolling
- Loading only visible portion
- Using `scrollbox` wrapper
```tsx
<scrollbox height={30}>
<line-number
code={largeFile}
language="typescript"
/>
</scrollbox>
```
### Tree-sitter Loading
Syntax highlighting requires Tree-sitter grammars. If highlighting isn't working:
1. Check the language is supported
2. Verify grammars are installed
3. Check `OTUI_TREE_SITTER_WORKER_PATH` if using custom path
@@ -0,0 +1,417 @@
# Container Components
Components for grouping and organizing content in OpenTUI.
## Box Component
The primary container component with borders, backgrounds, and layout capabilities.
### Basic Usage
```tsx
// React/Solid
<box>
<text>Content inside box</text>
</box>
// Core
const box = new BoxRenderable(renderer, {
id: "container",
})
box.add(child)
```
### Borders
```tsx
<box border>
Simple border
</box>
<box
border
borderStyle="single" // single | double | rounded | bold | none
borderColor="#FFFFFF"
>
Styled border
</box>
// Individual borders
<box
borderTop
borderBottom
borderLeft={false}
borderRight={false}
>
Top and bottom only
</box>
```
**Border Styles:**
| Style | Appearance |
|-------|------------|
| `single` | `┌─┐│ │└─┘` |
| `double` | `╔═╗║ ║╚═╝` |
| `rounded` | `╭─╮│ │╰─╯` |
| `bold` | `┏━┓┃ ┃┗━┛` |
### Title
```tsx
<box
border
title="Settings"
titleAlignment="center" // left | center | right
>
Panel content
</box>
```
### Background
```tsx
<box backgroundColor="#1a1a2e">
Dark background
</box>
<box backgroundColor="transparent">
No background
</box>
```
### Layout
Boxes are flex containers by default:
```tsx
<box
flexDirection="row" // row | column | row-reverse | column-reverse
justifyContent="center" // flex-start | flex-end | center | space-between | space-around
alignItems="center" // flex-start | flex-end | center | stretch | baseline
gap={2} // Space between children
>
<text>Item 1</text>
<text>Item 2</text>
</box>
```
### Spacing
```tsx
<box
padding={2} // All sides
paddingTop={1}
paddingRight={2}
paddingBottom={1}
paddingLeft={2}
paddingX={2} // Horizontal (left + right)
paddingY={1} // Vertical (top + bottom)
margin={1}
marginTop={1}
marginX={2} // Horizontal (left + right)
marginY={1} // Vertical (top + bottom)
>
Spaced content
</box>
```
### Dimensions
```tsx
<box
width={40} // Fixed width
height={10} // Fixed height
width="50%" // Percentage of parent
minWidth={20} // Minimum width
maxWidth={80} // Maximum width
flexGrow={1} // Grow to fill space
>
Sized box
</box>
```
### Mouse Events
```tsx
<box
onMouseDown={(event) => {
console.log("Clicked at:", event.x, event.y)
}}
onMouseUp={(event) => {}}
onMouseMove={(event) => {}}
>
Clickable box
</box>
```
### Focusable Boxes
By default, Box elements are not focusable. Set the `focusable` prop to enable focus behavior:
```tsx
// Make a box focusable - it can receive focus via mouse click
<box focusable border>
<text>Click to focus</text>
</box>
// Controlled focus state
const [focused, setFocused] = useState(false)
<box
focusable
focused={focused}
border
borderColor={focused ? "#00ff00" : "#888"}
>
<text>{focused ? "Focused!" : "Not focused"}</text>
</box>
```
When a focusable Box is clicked, focus bubbles up from the click target to the nearest focusable parent. Use `event.preventDefault()` in `onMouseDown` to prevent auto-focus.
## ScrollBox Component
A scrollable container for content that exceeds the viewport.
### Basic Usage
```tsx
// React
<scrollbox height={10}>
{items.map((item, i) => (
<text key={i}>{item}</text>
))}
</scrollbox>
// Solid
<scrollbox height={10}>
<For each={items()}>
{(item) => <text>{item}</text>}
</For>
</scrollbox>
// Core
const scrollbox = new ScrollBoxRenderable(renderer, {
id: "list",
height: 10,
})
items.forEach(item => {
scrollbox.add(new TextRenderable(renderer, { content: item }))
})
```
### Focus for Keyboard Scrolling
```tsx
<scrollbox focused height={20}>
{/* Use arrow keys to scroll */}
</scrollbox>
```
### Scrollbar Styling
```tsx
// React
<scrollbox
style={{
rootOptions: {
backgroundColor: "#24283b",
},
wrapperOptions: {
backgroundColor: "#1f2335",
},
viewportOptions: {
backgroundColor: "#1a1b26",
},
contentOptions: {
backgroundColor: "#16161e",
},
scrollbarOptions: {
showArrows: true,
trackOptions: {
foregroundColor: "#7aa2f7",
backgroundColor: "#414868",
},
},
}}
>
{content}
</scrollbox>
```
### Scroll Position (Core)
```typescript
const scrollbox = new ScrollBoxRenderable(renderer, {
id: "list",
height: 20,
})
// Scroll programmatically
scrollbox.scrollTo(0) // Scroll to top
scrollbox.scrollTo(100) // Scroll to position
scrollbox.scrollBy(10) // Scroll relative
scrollbox.scrollToBottom() // Scroll to end
// Scroll a child into view (nearest alignment)
scrollbox.scrollChildIntoView("child-id") // Searches descendants by ID
```
`scrollChildIntoView(childId)` scrolls the minimum amount needed to make the identified descendant visible. It mirrors `Element.scrollIntoView({ block: "nearest" })` from the CSSOM View spec. Works with nested descendants and handles both horizontal and vertical scrolling.
## Composition Patterns
### Card Component
```tsx
function Card({ title, children }) {
return (
<box
border
borderStyle="rounded"
padding={2}
marginBottom={1}
>
{title && (
<text fg="#00FFFF" bold>
{title}
</text>
)}
<box marginTop={title ? 1 : 0}>
{children}
</box>
</box>
)
}
```
### Panel Component
```tsx
function Panel({ title, children, width = 40 }) {
return (
<box
border
borderStyle="double"
width={width}
backgroundColor="#1a1a2e"
>
{title && (
<box
borderBottom
padding={1}
backgroundColor="#2a2a4e"
>
<text bold>{title}</text>
</box>
)}
<box padding={2}>
{children}
</box>
</box>
)
}
```
### List Container
```tsx
function List({ items, renderItem }) {
return (
<scrollbox height={15} focused>
{items.map((item, i) => (
<box
key={i}
padding={1}
backgroundColor={i % 2 === 0 ? "#222" : "#333"}
>
{renderItem(item, i)}
</box>
))}
</scrollbox>
)
}
```
## Nesting Containers
```tsx
<box flexDirection="column" height="100%">
{/* Header */}
<box height={3} border>
<text>Header</text>
</box>
{/* Main area with sidebar */}
<box flexDirection="row" flexGrow={1}>
<box width={20} border>
<text>Sidebar</text>
</box>
<box flexGrow={1}>
<scrollbox height="100%">
{/* Scrollable content */}
</scrollbox>
</box>
</box>
{/* Footer */}
<box height={1}>
<text>Footer</text>
</box>
</box>
```
## Gotchas
### Percentage Dimensions Need Parent Size
```tsx
// WRONG - parent has no explicit size
<box>
<box width="50%">Won't work</box>
</box>
// CORRECT
<box width="100%">
<box width="50%">Works</box>
</box>
```
### FlexGrow Needs Sized Parent
```tsx
// WRONG
<box>
<box flexGrow={1}>Won't grow</box>
</box>
// CORRECT
<box height="100%">
<box flexGrow={1}>Will grow</box>
</box>
```
### ScrollBox Needs Height
```tsx
// WRONG - no height constraint
<scrollbox>
{items}
</scrollbox>
// CORRECT
<scrollbox height={20}>
{items}
</scrollbox>
```
### Borders Add to Size
Borders take up space inside the box:
```tsx
<box width={10} border>
{/* Inner content area is 8 chars (10 - 2 for borders) */}
</box>
```
@@ -0,0 +1,531 @@
# Input Components
Components for user input in OpenTUI.
## Input Component
Single-line text input field.
### Basic Usage
```tsx
// React
<input
value={value}
onChange={(newValue) => setValue(newValue)}
placeholder="Enter text..."
focused
/>
// Solid
<input
value={value()}
onInput={(newValue) => setValue(newValue)}
placeholder="Enter text..."
focused
/>
// Core
const input = new InputRenderable(renderer, {
id: "name",
placeholder: "Enter text...",
})
input.on(InputRenderableEvents.CHANGE, (value) => {
console.log("Value:", value)
})
input.focus()
```
### Styling
```tsx
<input
width={30}
backgroundColor="#1a1a1a"
textColor="#FFFFFF"
cursorColor="#00FF00"
focusedBackgroundColor="#2a2a2a"
placeholderColor="#666666"
/>
```
### Events
```tsx
// React
<input
onChange={(value) => console.log("Changed:", value)}
onFocus={() => console.log("Focused")}
onBlur={() => console.log("Blurred")}
/>
// Core
input.on(InputRenderableEvents.CHANGE, (value) => {})
input.on(InputRenderableEvents.FOCUS, () => {})
input.on(InputRenderableEvents.BLUR, () => {})
```
### Controlled Input
```tsx
// React
function ControlledInput() {
const [value, setValue] = useState("")
return (
<input
value={value}
onChange={setValue}
focused
/>
)
}
// Solid
function ControlledInput() {
const [value, setValue] = createSignal("")
return (
<input
value={value()}
onInput={setValue}
focused
/>
)
}
```
## Textarea Component
Multi-line text input field.
### Basic Usage
```tsx
// React
<textarea
value={text}
onChange={(newText) => setText(newText)}
placeholder="Enter multiple lines..."
width={40}
height={10}
focused
/>
// Solid
<textarea
value={text()}
onInput={(newText) => setText(newText)}
placeholder="Enter multiple lines..."
width={40}
height={10}
focused
/>
// Core
const textarea = new TextareaRenderable(renderer, {
id: "editor",
width: 40,
height: 10,
placeholder: "Enter text...",
})
```
### Features
```tsx
<textarea
showLineNumbers // Display line numbers
wrapText // Wrap long lines
readOnly // Disable editing
tabSize={2} // Tab character width
/>
```
### Syntax Highlighting
```tsx
<textarea
language="typescript"
value={code}
onChange={setCode}
/>
```
## Select Component
List selection for choosing from options.
### Basic Usage
```tsx
// React
<select
options={[
{ name: "Option 1", description: "First option", value: "1" },
{ name: "Option 2", description: "Second option", value: "2" },
{ name: "Option 3", description: "Third option", value: "3" },
]}
onSelect={(index, option) => {
console.log("Selected:", option.name) // Called when Enter is pressed
}}
focused
/>
// Solid
<select
options={[
{ name: "Option 1", description: "First option", value: "1" },
{ name: "Option 2", description: "Second option", value: "2" },
]}
onSelect={(index, option) => {
console.log("Selected:", option.name) // Called when Enter is pressed
}}
focused
/>
// Core
const select = new SelectRenderable(renderer, {
id: "menu",
options: [
{ name: "Option 1", description: "First option", value: "1" },
{ name: "Option 2", description: "Second option", value: "2" },
],
})
select.on(SelectRenderableEvents.ITEM_SELECTED, (index, option) => {
console.log("Selected:", option.name) // Called when Enter is pressed
})
select.focus()
```
### Option Format
```typescript
interface SelectOption {
name: string // Display text
description?: string // Optional description shown below
value?: any // Associated value
}
```
### Styling
```tsx
<select
height={8} // Visible height
selectedIndex={0} // Initially selected
showScrollIndicator // Show scroll arrows
selectedBackgroundColor="#333"
selectedTextColor="#fff"
highlightBackgroundColor="#444"
/>
```
### Navigation
Default keybindings:
- `Up` / `k` - Move up
- `Down` / `j` - Move down
- `Enter` - Select item
### Events
**Important**: `onSelect` and `onChange` serve different purposes:
| Event | Trigger | Use Case |
|-------|---------|----------|
| `onSelect` | **Enter key pressed** - user confirms selection | Perform action with selected item |
| `onChange` | **Arrow keys** - user navigates list | Preview, update UI as user browses |
```tsx
// React/Solid
<select
onSelect={(index, option) => {
// Called when Enter is pressed - selection confirmed
console.log("User selected:", option.name)
performAction(option)
}}
onChange={(index, option) => {
// Called when navigating with arrow keys
console.log("Browsing:", option.name)
showPreview(option)
}}
/>
// Core
select.on(SelectRenderableEvents.ITEM_SELECTED, (index, option) => {
// Called when Enter is pressed
})
select.on(SelectRenderableEvents.SELECTION_CHANGED, (index, option) => {
// Called when navigating with arrow keys
})
```
## Tab Select Component
Horizontal tab-based selection.
### Basic Usage
```tsx
// React
<tab-select
options={[
{ name: "Home", description: "Dashboard view" },
{ name: "Settings", description: "Configuration" },
{ name: "Help", description: "Documentation" },
]}
onSelect={(index, option) => {
console.log("Tab selected:", option.name) // Called when Enter is pressed
}}
focused
/>
// Solid (note underscore)
<tab_select
options={[
{ name: "Home", description: "Dashboard view" },
{ name: "Settings", description: "Configuration" },
]}
onSelect={(index, option) => {
console.log("Tab selected:", option.name) // Called when Enter is pressed
}}
focused
/>
// Core
const tabs = new TabSelectRenderable(renderer, {
id: "tabs",
options: [...],
tabWidth: 20,
})
tabs.on(TabSelectRenderableEvents.ITEM_SELECTED, (index, option) => {
console.log("Tab selected:", option.name) // Called when Enter is pressed
})
tabs.focus()
```
### Events
Same pattern as Select - `onSelect` for Enter key, `onChange` for navigation:
```tsx
<tab-select
onSelect={(index, option) => {
// Called when Enter is pressed - switch to tab
setActiveTab(index)
}}
onChange={(index, option) => {
// Called when navigating with arrow keys
showTabPreview(option)
}}
/>
```
### Styling
```tsx
// React
<tab-select
tabWidth={20} // Width of each tab
selectedIndex={0} // Initially selected tab
/>
// Solid
<tab_select
tabWidth={20}
selectedIndex={0}
/>
```
### Navigation
Default keybindings:
- `Left` / `[` - Previous tab
- `Right` / `]` - Next tab
- `Enter` - Select tab
## Focus Management
### Single Focused Input
```tsx
function SingleInput() {
return <input placeholder="I'm focused" focused />
}
```
### Multiple Inputs with Focus State
```tsx
// React
function Form() {
const [focusIndex, setFocusIndex] = useState(0)
const fields = ["name", "email", "message"]
useKeyboard((key) => {
if (key.name === "tab") {
setFocusIndex(i => (i + 1) % fields.length)
}
})
return (
<box flexDirection="column" gap={1}>
{fields.map((field, i) => (
<input
key={field}
placeholder={`Enter ${field}`}
focused={i === focusIndex}
/>
))}
</box>
)
}
```
### Focus Methods (Core)
```typescript
input.focus() // Give focus
input.blur() // Remove focus
input.isFocused() // Check focus state
```
## Form Patterns
### Login Form
```tsx
function LoginForm() {
const [username, setUsername] = useState("")
const [password, setPassword] = useState("")
const [focusField, setFocusField] = useState<"username" | "password">("username")
useKeyboard((key) => {
if (key.name === "tab") {
setFocusField(f => f === "username" ? "password" : "username")
}
if (key.name === "enter") {
handleLogin()
}
})
return (
<box flexDirection="column" gap={1} border padding={2}>
<box flexDirection="row" gap={1}>
<text>Username:</text>
<input
value={username}
onChange={setUsername}
focused={focusField === "username"}
width={20}
/>
</box>
<box flexDirection="row" gap={1}>
<text>Password:</text>
<input
value={password}
onChange={setPassword}
focused={focusField === "password"}
width={20}
/>
</box>
</box>
)
}
```
### Search with Results
```tsx
function SearchableList({ items, onItemSelected }) {
const [query, setQuery] = useState("")
const [focusSearch, setFocusSearch] = useState(true)
const [preview, setPreview] = useState(null)
const filtered = items.filter(item =>
item.toLowerCase().includes(query.toLowerCase())
)
useKeyboard((key) => {
if (key.name === "tab") {
setFocusSearch(f => !f)
}
})
return (
<box flexDirection="column">
<input
value={query}
onChange={setQuery}
placeholder="Search..."
focused={focusSearch}
/>
<select
options={filtered.map(item => ({ name: item }))}
focused={!focusSearch}
height={10}
onSelect={(index, option) => {
// Enter pressed - confirm selection
onItemSelected(option)
}}
onChange={(index, option) => {
// Navigating - show preview
setPreview(option)
}}
/>
</box>
)
}
```
## Gotchas
### Focus Required
Inputs must be focused to receive keyboard input:
```tsx
// WRONG - won't receive input
<input placeholder="Type here" />
// CORRECT
<input placeholder="Type here" focused />
```
### Select Options Format
Options must be objects with `name` property:
```tsx
// WRONG
<select options={["a", "b", "c"]} />
// CORRECT
<select options={[
{ name: "A", description: "Option A" },
{ name: "B", description: "Option B" },
]} />
```
### Solid Uses Underscores
```tsx
// React
<tab-select />
// Solid
<tab_select />
```
### Value vs onInput (Solid)
Solid uses `onInput` instead of `onChange`:
```tsx
// React
<input value={value} onChange={setValue} />
// Solid
<input value={value()} onInput={setValue} />
```
@@ -0,0 +1,386 @@
# Text & Display Components
Components for displaying text content in OpenTUI.
## Text Component
The primary component for displaying styled text.
### Basic Usage
```tsx
// React/Solid
<text>Hello, World!</text>
// With content prop
<text content="Hello, World!" />
// Core
const text = new TextRenderable(renderer, {
id: "greeting",
content: "Hello, World!",
})
```
### Styling (React/Solid)
For React and Solid, use **nested modifier tags** for text styling:
```tsx
<text fg="#FFFFFF" bg="#000000">
<strong>Bold</strong>, <em>italic</em>, and <u>underlined</u>
</text>
```
> **Important**: Do NOT use `bold`, `italic`, `underline`, `dim`, `strikethrough` as props on `<text>` — they don't work. Always use nested tags like `<strong>`, `<em>`, `<u>`, or `<span>` with styling.
### Styling (Core) - Text Attributes
```typescript
import { TextRenderable, TextAttributes } from "@opentui/core"
const text = new TextRenderable(renderer, {
content: "Styled",
attributes: TextAttributes.BOLD | TextAttributes.UNDERLINE,
})
```
**Available attributes:**
- `TextAttributes.BOLD`
- `TextAttributes.DIM`
- `TextAttributes.ITALIC`
- `TextAttributes.UNDERLINE`
- `TextAttributes.BLINK`
- `TextAttributes.INVERSE`
- `TextAttributes.HIDDEN`
- `TextAttributes.STRIKETHROUGH`
### Text Selection
```tsx
<text selectable>
This text can be selected by the user
</text>
<text selectable={false}>
This text cannot be selected
</text>
```
For copy-on-selection and the full selection API, see `keyboard/REFERENCE.md` (selection).
## Text Modifiers
Inline styling elements that must be used inside `<text>`:
### Span
Inline styled text:
```tsx
<text>
Normal text with <span fg="red">red text</span> inline
</text>
```
### Bold/Strong
```tsx
<text>
<strong>Bold text</strong>
<b>Also bold</b>
</text>
```
### Italic/Emphasis
```tsx
<text>
<em>Italic text</em>
<i>Also italic</i>
</text>
```
### Underline
```tsx
<text>
<u>Underlined text</u>
</text>
```
### Line Break
```tsx
<text>
Line one
<br />
Line two
</text>
```
### Link
```tsx
<text>
Visit <a href="https://example.com">our website</a>
</text>
```
### Combined Modifiers
```tsx
<text>
<span fg="#00FF00">
<strong>Bold green</strong>
</span>
and
<span fg="#FF0000">
<em><u>italic underlined red</u></em>
</span>
</text>
```
## Styled Text Template (Core)
The `t` template literal for complex styling:
```typescript
import { t, bold, italic, underline, fg, bg, dim } from "@opentui/core"
const styled = t`
${bold("Bold")} and ${italic("italic")} text.
${fg("#FF0000")("Red text")} with ${bg("#0000FF")("blue background")}.
${dim("Dimmed")} and ${underline("underlined")}.
`
const text = new TextRenderable(renderer, {
content: styled,
})
```
### Style Functions
| Function | Description |
|----------|-------------|
| `bold(text)` | Bold text |
| `italic(text)` | Italic text |
| `underline(text)` | Underlined text |
| `dim(text)` | Dimmed text |
| `strikethrough(text)` | Strikethrough text |
| `fg(color)(text)` | Set foreground color |
| `bg(color)(text)` | Set background color |
## ASCII Font Component
Display large ASCII art text banners.
### Basic Usage
```tsx
// React
<ascii-font text="TITLE" font="tiny" />
// Solid
<ascii_font text="TITLE" font="tiny" />
// Core
const title = new ASCIIFontRenderable(renderer, {
id: "title",
text: "TITLE",
font: "tiny",
})
```
### Available Fonts
| Font | Description |
|------|-------------|
| `tiny` | Compact ASCII font |
| `block` | Block-style letters |
| `slick` | Sleek modern style |
| `shade` | Shaded 3D effect |
### Styling
```tsx
// React
<ascii-font
text="HELLO"
font="block"
color="#00FF00"
/>
// Core
import { RGBA } from "@opentui/core"
const title = new ASCIIFontRenderable(renderer, {
text: "HELLO",
font: "block",
color: RGBA.fromHex("#00FF00"),
})
```
### Example Output
```
Font: tiny
╭─╮╭─╮╭─╮╭╮╭╮╭─╮╶╮╶ ╶╮
│ ││─┘├┤ │╰╯││ │ │
╰─╯╵ ╰─╯╵ ╵╰─╯╶╯╶╰─╯
Font: block
█▀▀█ █▀▀█ █▀▀ █▀▀▄
█ █ █▀▀▀ █▀▀ █ █
▀▀▀▀ ▀ ▀▀▀ ▀ ▀
```
## Colors
### Color Formats
```tsx
// Hex colors
<text fg="#FF0000">Red</text>
<text fg="#F00">Short hex</text>
// Named colors
<text fg="red">Red</text>
<text fg="blue">Blue</text>
// Transparent
<text bg="transparent">No background</text>
```
### RGBA Class
The `RGBA` class from `@opentui/core` can be used in **all frameworks** (Core, React, Solid) for programmatic color manipulation:
```typescript
import { RGBA } from "@opentui/core"
// From hex string (most common)
const red = RGBA.fromHex("#FF0000")
const shortHex = RGBA.fromHex("#F00") // Short form supported
// From integers (0-255 range for each channel)
const green = RGBA.fromInts(0, 255, 0, 255) // r, g, b, a
const semiGreen = RGBA.fromInts(0, 255, 0, 128) // 50% transparent
// From normalized floats (0.0-1.0 range)
const blue = RGBA.fromValues(0.0, 0.0, 1.0, 1.0) // r, g, b, a
const overlay = RGBA.fromValues(0.1, 0.1, 0.1, 0.7) // Dark semi-transparent
// Common use cases
const backgroundColor = RGBA.fromHex("#1a1a2e")
const textColor = RGBA.fromHex("#FFFFFF")
const borderColor = RGBA.fromInts(122, 162, 247, 255) // Tokyo Night blue
const shadowColor = RGBA.fromValues(0.0, 0.0, 0.0, 0.5) // 50% black
```
**When to use each method:**
- `fromHex()` - When working with design specs or CSS colors
- `fromInts()` - When you have 8-bit color values (0-255)
- `fromValues()` - When doing color math or interpolation (normalized 0.0-1.0)
### Using RGBA in React/Solid
```tsx
// React or Solid - RGBA works with color props
import { RGBA } from "@opentui/core"
const primaryColor = RGBA.fromHex("#7aa2f7")
function MyComponent() {
return (
<box backgroundColor={primaryColor} borderColor={primaryColor}>
<text fg={RGBA.fromHex("#c0caf5")}>Styled with RGBA</text>
</box>
)
}
```
Most props that accept color strings (`"#FF0000"`, `"red"`) also accept `RGBA` objects directly.
## Text Wrapping
Text wraps based on parent container:
```tsx
<box width={40}>
<text>
This long text will wrap when it reaches the edge of the
40-character wide parent container.
</text>
</box>
```
## Dynamic Content
### React
```tsx
function Counter() {
const [count, setCount] = useState(0)
return <text>Count: {count}</text>
}
```
### Solid
```tsx
function Counter() {
const [count, setCount] = createSignal(0)
return <text>Count: {count()}</text>
}
```
### Core
```typescript
const text = new TextRenderable(renderer, {
id: "counter",
content: "Count: 0",
})
// Update later
text.setContent("Count: 1")
```
## Gotchas
### Text Modifiers Outside Text
```tsx
// WRONG - modifiers only work inside <text>
<box>
<strong>Won't work</strong>
</box>
// CORRECT
<box>
<text>
<strong>This works</strong>
</text>
</box>
```
### Empty Text
```tsx
// May cause layout issues
<text></text>
// Better - use space or conditional
<text>{content || " "}</text>
```
### Color Format
```tsx
// WRONG
<text fg="FF0000">Missing #</text>
// CORRECT
<text fg="#FF0000">With #</text>
```
@@ -0,0 +1,145 @@
# OpenTUI Core (@opentui/core)
The foundational library for building terminal user interfaces. Provides an imperative API with all primitives, giving you maximum control over rendering, state, and behavior.
## Overview
OpenTUI Core runs on Bun with native Zig bindings for performance-critical operations:
- **Renderer**: Manages terminal output, input events, and the rendering loop
- **Renderables**: Hierarchical UI building blocks with Yoga layout
- **Constructs**: Declarative wrappers for composing Renderables
- **FrameBuffer**: Low-level 2D rendering surface for custom graphics
## When to Use Core
Use the core imperative API when:
- Building a library or framework on top of OpenTUI
- Need maximum control over rendering and state
- Want smallest possible bundle size (no React/Solid runtime)
- Building performance-critical applications
- Integrating with existing imperative codebases
## When NOT to Use Core
| Scenario | Use Instead |
|----------|-------------|
| Familiar with React patterns | `@opentui/react` |
| Want fine-grained reactivity | `@opentui/solid` |
| Building typical applications | React or Solid reconciler |
| Rapid prototyping | React or Solid reconciler |
## Quick Start
### Using create-tui (Recommended)
```bash
bunx create-tui@latest -t core my-app
cd my-app
bun run src/index.ts
```
The CLI creates the `my-app` directory for you - it must **not already exist**.
**Agent guidance**: Always use autonomous mode with `-t <template>` flag. Never use interactive mode (`bunx create-tui@latest my-app` without `-t`) as it requires user prompts that agents cannot respond to.
### Manual Setup
```bash
mkdir my-tui && cd my-tui
bun init
bun install @opentui/core
```
```typescript
import { createCliRenderer, TextRenderable, BoxRenderable } from "@opentui/core"
const renderer = await createCliRenderer()
// Create a box container
const container = new BoxRenderable(renderer, {
id: "container",
width: 40,
height: 10,
border: true,
borderStyle: "rounded",
padding: 1,
})
// Create text inside the box
const greeting = new TextRenderable(renderer, {
id: "greeting",
content: "Hello, OpenTUI!",
fg: "#00FF00",
})
// Compose the tree
container.add(greeting)
renderer.root.add(container)
```
## Core Concepts
### Renderer
The `CliRenderer` orchestrates everything:
- Manages the terminal viewport and alternate screen
- Handles input events (keyboard, mouse, paste)
- Runs the rendering loop (configurable FPS)
- Provides the root node for the renderable tree
### Renderables vs Constructs
| Renderables (Imperative) | Constructs (Declarative) |
|--------------------------|--------------------------|
| `new TextRenderable(renderer, {...})` | `Text({...})` |
| Requires renderer at creation | Creates VNode, instantiated later |
| Direct mutation via methods | Chained calls recorded, replayed on instantiation |
| Full control | Cleaner composition |
### Storage Options
Renderables can be composed in two ways:
1. **Imperative**: Create instances, call `.add()` to compose
2. **Declarative (Constructs)**: Create VNodes, pass children as arguments
## Essential Commands
```bash
bun install @opentui/core # Install
bun run src/index.ts # Run directly (no build needed)
bun test # Run tests
```
## Runtime Requirements
OpenTUI runs on Bun and uses Zig for native builds.
```bash
# Package management
bun install @opentui/core
# Running
bun run src/index.ts
bun test
# Building (only needed for native code changes)
bun run build
```
**Zig** is required for building native components.
## In This Reference
- [Configuration](./configuration.md) - Renderer options, environment variables
- [API](./api.md) - Renderer, Renderables, types, utilities
- [Patterns](./patterns.md) - Composition, events, state management
- [Gotchas](./gotchas.md) - Common issues, debugging, limitations
## See Also
- [React](../react/REFERENCE.md) - React reconciler for declarative TUI
- [Solid](../solid/REFERENCE.md) - Solid reconciler for declarative TUI
- [Layout](../layout/REFERENCE.md) - Yoga/Flexbox layout system
- [Components](../components/REFERENCE.md) - Component reference by category
- [Keyboard](../keyboard/REFERENCE.md) - Input handling and shortcuts
- [Testing](../testing/REFERENCE.md) - Test renderer and snapshots
@@ -0,0 +1,543 @@
# Core API Reference
## Renderer
### createCliRenderer(config?)
Creates and initializes the CLI renderer.
```typescript
import { createCliRenderer, type CliRendererConfig } from "@opentui/core"
const renderer = await createCliRenderer({
targetFPS: 60, // Target frames per second
exitOnCtrlC: true, // Exit process on Ctrl+C
consoleOptions: { // Debug console overlay
position: ConsolePosition.BOTTOM,
sizePercent: 30,
startInDebugMode: false,
},
onDestroy: () => {}, // Cleanup callback
})
```
### CliRenderer Instance
```typescript
renderer.root // Root renderable node
renderer.width // Terminal width in columns
renderer.height // Terminal height in rows
renderer.keyInput // Keyboard event emitter
renderer.console // Console overlay controller
renderer.start() // Start render loop
renderer.stop() // Stop render loop
renderer.destroy() // Cleanup and exit alternate screen
renderer.requestRender() // Request a re-render
renderer.setCursorStyle(options) // Set cursor style
renderer.setCursorColor(color) // Set cursor color
renderer.setMousePointer(style) // Set mouse pointer shape
```
### Cursor & Mouse Pointer
```typescript
import { type CursorStyleOptions, type MousePointerStyle } from "@opentui/core"
// Set cursor style (options object)
renderer.setCursorStyle({
style: "block", // "block" | "line" | "underline" | "default"
blinking: true, // Cursor blink
color: RGBA.fromHex("#FF0000"), // Cursor color
cursor: "pointer", // Mouse pointer shape
})
// Set mouse pointer shape (OSC 22)
renderer.setMousePointer("pointer")
// Available: "default" | "pointer" | "text" | "crosshair" | "move" | "not-allowed"
```
### Renderer Events
```typescript
renderer.on("resize", (width, height) => {}) // Terminal resized
renderer.on("focus", () => {}) // Terminal window gained focus
renderer.on("blur", () => {}) // Terminal window lost focus
renderer.on("theme_mode", (mode) => {}) // "dark" | "light"
renderer.on("capabilities", (caps) => {}) // Terminal capabilities detected
renderer.on("selection", (selection) => {}) // Text selection finished (mouse-up)
renderer.on("destroy", () => {}) // Renderer destroyed
renderer.on("memory:snapshot", (snapshot) => {}) // Memory snapshot
renderer.on("debugOverlay:toggle", () => {}) // Debug overlay toggled
```
### Console Overlay
```typescript
renderer.console.show() // Show console overlay
renderer.console.hide() // Hide console overlay
renderer.console.toggle() // Toggle visibility/focus
renderer.console.clear() // Clear console contents
```
## Renderables
All renderables extend the base `Renderable` class and share common properties.
### Common Properties
```typescript
interface CommonProps {
id?: string // Unique identifier
// Positioning
position?: "relative" | "absolute"
left?: number | string
top?: number | string
right?: number | string
bottom?: number | string
// Dimensions
width?: number | string | "auto"
height?: number | string | "auto"
minWidth?: number
minHeight?: number
maxWidth?: number
maxHeight?: number
// Flexbox
flexDirection?: "row" | "column" | "row-reverse" | "column-reverse"
flexGrow?: number
flexShrink?: number
flexBasis?: number | string
flexWrap?: "nowrap" | "wrap" | "wrap-reverse"
justifyContent?: "flex-start" | "flex-end" | "center" | "space-between" | "space-around" | "space-evenly"
alignItems?: "flex-start" | "flex-end" | "center" | "stretch" | "baseline"
alignSelf?: "auto" | "flex-start" | "flex-end" | "center" | "stretch" | "baseline"
alignContent?: "flex-start" | "flex-end" | "center" | "stretch" | "space-between" | "space-around"
// Spacing
padding?: number
paddingTop?: number
paddingRight?: number
paddingBottom?: number
paddingLeft?: number
margin?: number
marginTop?: number
marginRight?: number
marginBottom?: number
marginLeft?: number
gap?: number
// Display
display?: "flex" | "none"
overflow?: "visible" | "hidden" | "scroll"
zIndex?: number
}
```
### Renderable Methods
```typescript
renderable.add(child) // Add child renderable
renderable.remove(child) // Remove child renderable
renderable.getRenderable(id) // Find child by ID
renderable.focus() // Focus this renderable
renderable.blur() // Remove focus
renderable.destroy() // Destroy and cleanup
renderable.on(event, handler) // Add event listener
renderable.off(event, handler) // Remove event listener
renderable.emit(event, ...args) // Emit event
```
### TextRenderable
Display styled text content.
```typescript
import { TextRenderable, TextAttributes, t, bold, fg, underline } from "@opentui/core"
const text = new TextRenderable(renderer, {
id: "text",
content: "Hello World",
fg: "#FFFFFF", // Foreground color
bg: "#000000", // Background color
attributes: TextAttributes.BOLD | TextAttributes.UNDERLINE,
selectable: true, // Allow text selection
})
// Styled text with template literals
const styled = new TextRenderable(renderer, {
content: t`${bold("Bold")} and ${fg("#FF0000")(underline("red underlined"))}`,
})
```
**TextAttributes flags:**
- `TextAttributes.BOLD`
- `TextAttributes.DIM`
- `TextAttributes.ITALIC`
- `TextAttributes.UNDERLINE`
- `TextAttributes.BLINK`
- `TextAttributes.INVERSE`
- `TextAttributes.HIDDEN`
- `TextAttributes.STRIKETHROUGH`
### BoxRenderable
Container with borders and layout.
```typescript
import { BoxRenderable } from "@opentui/core"
const box = new BoxRenderable(renderer, {
id: "box",
width: 40,
height: 10,
backgroundColor: "#1a1a2e",
border: true,
borderStyle: "single" | "double" | "rounded" | "bold" | "none",
borderColor: "#FFFFFF",
title: "Panel Title",
titleAlignment: "left" | "center" | "right",
onMouseDown: (event) => {},
onMouseUp: (event) => {},
onMouseMove: (event) => {},
})
```
### InputRenderable
Single-line text input.
```typescript
import { InputRenderable, InputRenderableEvents } from "@opentui/core"
const input = new InputRenderable(renderer, {
id: "input",
width: 30,
placeholder: "Enter text...",
value: "", // Initial value
backgroundColor: "#1a1a1a",
textColor: "#FFFFFF",
cursorColor: "#00FF00",
focusedBackgroundColor: "#2a2a2a",
})
input.on(InputRenderableEvents.CHANGE, (value: string) => {
console.log("Value:", value)
})
input.focus() // Must be focused to receive input
```
### SelectRenderable
List selection component.
```typescript
import { SelectRenderable, SelectRenderableEvents } from "@opentui/core"
const select = new SelectRenderable(renderer, {
id: "select",
width: 30,
height: 10,
options: [
{ name: "Option 1", description: "First option", value: "1" },
{ name: "Option 2", description: "Second option", value: "2" },
],
selectedIndex: 0,
})
// Called when Enter is pressed - selection confirmed
select.on(SelectRenderableEvents.ITEM_SELECTED, (index, option) => {
console.log("Selected:", option.name)
performAction(option)
})
// Called when navigating with arrow keys
select.on(SelectRenderableEvents.SELECTION_CHANGED, (index, option) => {
console.log("Browsing:", option.name)
showPreview(option)
})
select.focus() // Navigate with up/down/j/k, select with enter
```
**Event distinction:**
- `ITEM_SELECTED` - Enter key pressed, user confirms selection
- `SELECTION_CHANGED` - Arrow keys, user navigating/browsing options
### TabSelectRenderable
Horizontal tab selection.
```typescript
import { TabSelectRenderable, TabSelectRenderableEvents } from "@opentui/core"
const tabs = new TabSelectRenderable(renderer, {
id: "tabs",
width: 60,
options: [
{ name: "Home", description: "Dashboard" },
{ name: "Settings", description: "Configuration" },
],
tabWidth: 20,
})
// Called when Enter is pressed - tab selected
tabs.on(TabSelectRenderableEvents.ITEM_SELECTED, (index, option) => {
console.log("Tab selected:", option.name)
switchToTab(index)
})
// Called when navigating with arrow keys
tabs.on(TabSelectRenderableEvents.SELECTION_CHANGED, (index, option) => {
console.log("Browsing tab:", option.name)
})
tabs.focus() // Navigate with left/right/[/], select with enter
```
**Event distinction** (same as SelectRenderable):
- `ITEM_SELECTED` - Enter key pressed, user confirms tab
- `SELECTION_CHANGED` - Arrow keys, user navigating tabs
### ScrollBoxRenderable
Scrollable container.
```typescript
import { ScrollBoxRenderable } from "@opentui/core"
const scrollbox = new ScrollBoxRenderable(renderer, {
id: "scrollbox",
width: 40,
height: 20,
showScrollbar: true,
scrollbarOptions: {
showArrows: true,
trackOptions: {
foregroundColor: "#7aa2f7",
backgroundColor: "#414868",
},
},
})
// Add content that exceeds viewport
for (let i = 0; i < 100; i++) {
scrollbox.add(new TextRenderable(renderer, {
id: `line-${i}`,
content: `Line ${i}`,
}))
}
scrollbox.focus() // Scroll with arrow keys
```
### ASCIIFontRenderable
ASCII art text.
```typescript
import { ASCIIFontRenderable, RGBA } from "@opentui/core"
const title = new ASCIIFontRenderable(renderer, {
id: "title",
text: "OPENTUI",
font: "tiny" | "block" | "slick" | "shade",
color: RGBA.fromHex("#FFFFFF"),
})
```
### FrameBufferRenderable
Low-level 2D rendering surface.
```typescript
import { FrameBufferRenderable, RGBA } from "@opentui/core"
const canvas = new FrameBufferRenderable(renderer, {
id: "canvas",
width: 50,
height: 20,
})
// Direct pixel manipulation
canvas.frameBuffer.fillRect(10, 5, 20, 8, RGBA.fromHex("#FF0000"))
canvas.frameBuffer.drawText("Custom", 12, 7, RGBA.fromHex("#FFFFFF"))
canvas.frameBuffer.setCell(x, y, char, fg, bg)
```
## Constructs (VNode API)
Declarative wrappers that create VNodes instead of direct instances.
```typescript
import { Text, Box, Input, Select, instantiate, delegate } from "@opentui/core"
// Create VNode tree
const ui = Box(
{ border: true, padding: 1 },
Text({ content: "Hello" }),
Input({ placeholder: "Type here..." }),
)
// Instantiate onto renderer
renderer.root.add(ui)
// Delegate focus to nested element
const form = delegate(
{ focus: "email-input" },
Box(
{},
Text({ content: "Email:" }),
Input({ id: "email-input", placeholder: "you@example.com" }),
),
)
form.focus() // Focuses the input, not the box
```
## Colors (RGBA)
The `RGBA` class is exported from `@opentui/core` but works across **all frameworks** (Core, React, Solid). Use it for programmatic color manipulation.
### Creating Colors
```typescript
import { RGBA, parseColor } from "@opentui/core"
// From hex string (most common)
RGBA.fromHex("#FF0000") // Full hex
RGBA.fromHex("#F00") // Short hex
// From integers (0-255 range)
RGBA.fromInts(255, 0, 0, 255) // r, g, b, a - fully opaque red
RGBA.fromInts(255, 0, 0, 128) // 50% transparent red
RGBA.fromInts(0, 0, 0, 0) // Fully transparent
// From normalized floats (0.0-1.0 range)
RGBA.fromValues(1.0, 0.0, 0.0, 1.0) // Fully opaque red
RGBA.fromValues(0.1, 0.1, 0.1, 0.7) // Dark gray, 70% opaque
RGBA.fromValues(0.0, 0.5, 1.0, 1.0) // Light blue
```
### Common Color Patterns
```typescript
// Theme colors
const primary = RGBA.fromHex("#7aa2f7") // Tokyo Night blue
const background = RGBA.fromHex("#1a1a2e")
const foreground = RGBA.fromHex("#c0caf5")
const error = RGBA.fromHex("#f7768e")
// Overlays and shadows
const modalOverlay = RGBA.fromValues(0.0, 0.0, 0.0, 0.5) // 50% black
const shadow = RGBA.fromInts(0, 0, 0, 77) // 30% black
// Borders
const activeBorder = RGBA.fromHex("#7aa2f7")
const inactiveBorder = RGBA.fromInts(65, 72, 104, 255)
```
### parseColor Utility
```typescript
// Accepts multiple formats
parseColor("#FF0000") // Hex string
parseColor("red") // CSS color name
parseColor("transparent") // Special values
parseColor(RGBA.fromHex("#F00")) // Pass-through RGBA objects
```
### When to Use Each Method
| Method | Use When |
|--------|----------|
| `fromHex()` | Working with design specs, CSS colors, config files |
| `fromInts()` | You have 8-bit values (0-255), common in graphics |
| `fromValues()` | Doing color interpolation, animations, math |
| `parseColor()` | Accepting user input or config that could be any format |
### Using RGBA in React/Solid
```tsx
// Import from @opentui/core, use in any framework
import { RGBA } from "@opentui/core"
// React or Solid component
function ThemedBox() {
const bg = RGBA.fromHex("#1a1a2e")
const border = RGBA.fromInts(122, 162, 247, 255)
return (
<box backgroundColor={bg} borderColor={border} border>
<text fg={RGBA.fromHex("#c0caf5")}>Works everywhere!</text>
</box>
)
}
```
Color props in React/Solid accept both string formats (`"#FF0000"`, `"red"`) and `RGBA` objects.
## Keyboard Input
```typescript
import { type KeyEvent } from "@opentui/core"
renderer.keyInput.on("keypress", (key: KeyEvent) => {
console.log(key.name) // "a", "escape", "f1", etc.
console.log(key.sequence) // Raw escape sequence
console.log(key.ctrl) // Ctrl held
console.log(key.shift) // Shift held
console.log(key.meta) // Alt held
console.log(key.option) // Option held (macOS)
console.log(key.eventType) // "press" | "release" | "repeat"
})
renderer.keyInput.on("paste", (event: PasteEvent) => {
const text = decodePasteBytes(event.bytes)
console.log("Pasted:", text)
})
```
## Animation Timeline
```typescript
import { Timeline, engine } from "@opentui/core"
const timeline = new Timeline({
duration: 2000,
loop: false,
autoplay: true,
})
timeline.add(
{ width: 0 },
{
width: 50,
duration: 1000,
ease: "easeOutQuad",
onUpdate: (anim) => {
box.setWidth(anim.targets[0].width)
},
},
)
engine.attach(renderer)
engine.addTimeline(timeline)
```
## Type Exports
```typescript
import type {
CliRenderer,
CliRendererConfig,
RenderContext,
KeyEvent,
Renderable,
// ... and more
} from "@opentui/core"
```
@@ -0,0 +1,168 @@
# Core Configuration
## Renderer Configuration
### createCliRenderer Options
```typescript
import { createCliRenderer, ConsolePosition } from "@opentui/core"
const renderer = await createCliRenderer({
// Rendering
targetFPS: 60, // Target frames per second (default: 60)
// Behavior
exitOnCtrlC: true, // Exit on Ctrl+C (default: true)
// Console overlay
consoleOptions: {
position: ConsolePosition.BOTTOM, // BOTTOM | TOP | LEFT | RIGHT
sizePercent: 30, // Percentage of screen
colorInfo: "#00FFFF",
colorWarn: "#FFFF00",
colorError: "#FF0000",
colorDebug: "#888888",
startInDebugMode: false,
},
// Lifecycle
onDestroy: () => {
// Cleanup callback
},
})
```
## Environment Variables
OpenTUI respects several environment variables for configuration and debugging.
### Debug & Development
| Variable | Type | Default | Description |
|----------|------|---------|-------------|
| `OTUI_DEBUG` | boolean | false | Enable debug mode, capture raw input |
| `OTUI_DEBUG_FFI` | boolean | false | Debug logging for FFI bindings |
| `OTUI_TRACE_FFI` | boolean | false | Tracing for FFI bindings |
| `OTUI_SHOW_STATS` | boolean | false | Show debug overlay at startup |
| `OTUI_DUMP_CAPTURES` | boolean | false | Dump captured output on exit |
### Console
| Variable | Type | Default | Description |
|----------|------|---------|-------------|
| `OTUI_USE_CONSOLE` | boolean | true | Enable console capture |
| `SHOW_CONSOLE` | boolean | false | Show console at startup |
### Rendering
| Variable | Type | Default | Description |
|----------|------|---------|-------------|
| `OTUI_NO_NATIVE_RENDER` | boolean | false | Disable ANSI output (for debugging) |
| `OTUI_USE_ALTERNATE_SCREEN` | boolean | true | Use alternate screen buffer |
| `OTUI_OVERRIDE_STDOUT` | boolean | true | Override stdout stream |
### Terminal Capabilities
| Variable | Type | Default | Description |
|----------|------|---------|-------------|
| `OPENTUI_NO_GRAPHICS` | boolean | false | Disable Kitty graphics protocol |
| `OPENTUI_FORCE_UNICODE` | boolean | false | Force Mode 2026 Unicode support |
| `OPENTUI_FORCE_WCWIDTH` | boolean | false | Use wcwidth for character width |
| `OPENTUI_FORCE_NOZWJ` | boolean | false | Disable ZWJ emoji joining |
| `OPENTUI_FORCE_EXPLICIT_WIDTH` | string | - | Force explicit width ("true"/"false") |
### Tree-sitter (Syntax Highlighting)
| Variable | Type | Default | Description |
|----------|------|---------|-------------|
| `OTUI_TS_STYLE_WARN` | boolean | false | Warn on missing syntax styles |
| `OTUI_TREE_SITTER_WORKER_PATH` | string | "" | Custom tree-sitter worker path |
### XDG Paths
| Variable | Type | Default | Description |
|----------|------|---------|-------------|
| `XDG_CONFIG_HOME` | string | "" | User config directory |
| `XDG_DATA_HOME` | string | "" | User data directory |
## Usage Examples
### Development Mode
```bash
# Show debug overlay and console
OTUI_SHOW_STATS=true SHOW_CONSOLE=true bun run src/index.ts
# Debug FFI issues
OTUI_DEBUG_FFI=true OTUI_TRACE_FFI=true bun run src/index.ts
# Disable native rendering for testing
OTUI_NO_NATIVE_RENDER=true bun run src/index.ts
```
### Terminal Compatibility
```bash
# Force wcwidth for problematic terminals
OPENTUI_FORCE_WCWIDTH=true bun run src/index.ts
# Disable graphics for SSH sessions
OPENTUI_NO_GRAPHICS=true bun run src/index.ts
```
## Project Setup
### package.json
```json
{
"name": "my-tui-app",
"type": "module",
"scripts": {
"start": "bun run src/index.ts",
"dev": "bun --watch run src/index.ts",
"test": "bun test"
},
"dependencies": {
"@opentui/core": "latest"
},
"devDependencies": {
"@types/bun": "latest",
"typescript": "latest"
}
}
```
### tsconfig.json
```json
{
"compilerOptions": {
"lib": ["ESNext"],
"target": "ESNext",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"skipLibCheck": true,
"noEmit": true,
"types": ["bun-types"]
},
"include": ["src/**/*"]
}
```
> **Note**: OpenTUI uses `NodeNext` module resolution. All internal imports use `.js` extensions. If you use `bundler` resolution, imports still work but `NodeNext` is recommended for compatibility.
## Building Native Code
Native code changes require rebuilding:
```bash
# From repo root (if developing OpenTUI itself)
bun run build
# Zig is required for native compilation
# Install: https://ziglang.org/learn/getting-started/
```
**Note**: TypeScript changes do NOT require building. Bun runs TypeScript directly.
@@ -0,0 +1,393 @@
# Core Gotchas
## Runtime Environment
### Use Bun, Not Node.js
OpenTUI is built for Bun. Always use Bun commands:
```bash
# CORRECT
bun install @opentui/core
bun run src/index.ts
bun test
# WRONG
npm install @opentui/core
node src/index.ts
npx jest
```
### Bun APIs to Use
Prefer Bun's built-in APIs for your application code:
```typescript
// CORRECT - Bun APIs
Bun.serve({ ... }) // Instead of express
Bun.$`ls -la` // Instead of execa
import { Database } from "bun:sqlite" // Instead of better-sqlite3
// WRONG - Node.js patterns
import express from "express"
```
> **Note**: OpenTUI itself uses `node:fs` internally for file I/O (for broader compatibility), but your application code should still prefer Bun APIs where available.
### Avoid process.exit()
**Never use `process.exit()` directly** - it prevents proper terminal cleanup and can leave the terminal in a broken state (alternate screen mode, raw input mode, etc.).
```typescript
// WRONG - Terminal may be left in broken state
if (error) {
console.error("Fatal error")
process.exit(1)
}
// CORRECT - Use renderer.destroy() for cleanup
if (error) {
console.error("Fatal error")
await renderer.destroy()
process.exit(1) // Only after destroy
}
// BETTER - Let destroy handle exit
const renderer = await createCliRenderer({
exitOnCtrlC: true, // Handles Ctrl+C properly
})
// For programmatic exit
renderer.destroy() // Cleans up and exits
```
`renderer.destroy()` restores the terminal to its original state before exiting.
### Environment Variables
Bun auto-loads `.env` files. Don't use dotenv:
```typescript
// CORRECT
const apiKey = process.env.API_KEY
// WRONG
import dotenv from "dotenv"
dotenv.config()
```
## Debugging TUIs
### Cannot See console.log Output
OpenTUI captures console output for the debug overlay. You can't see logs in the terminal while the TUI is running.
**Solutions:**
1. **Use the console overlay:**
```typescript
const renderer = await createCliRenderer()
renderer.console.show()
console.log("This appears in the overlay")
```
2. **Toggle with keyboard:**
```typescript
renderer.keyInput.on("keypress", (key) => {
if (key.name === "f12") {
renderer.console.toggle()
}
})
```
3. **Write to a file:**
```typescript
import { appendFileSync } from "node:fs"
function debugLog(msg: string) {
appendFileSync("debug.log", `${new Date().toISOString()} ${msg}\n`)
}
```
4. **Disable console capture:**
```bash
OTUI_USE_CONSOLE=false bun run src/index.ts
```
### Reproduce Issues in Tests
Don't guess at bugs. Create a reproducible test:
```typescript
import { test, expect } from "bun:test"
import { createTestRenderer } from "@opentui/core/testing"
test("reproduces the issue", async () => {
const { renderer, snapshot } = await createTestRenderer({
width: 40,
height: 10,
})
// Setup that reproduces the bug
const box = new BoxRenderable(renderer, { ... })
renderer.root.add(box)
// Verify with snapshot
expect(snapshot()).toMatchSnapshot()
})
```
## Focus Management
### Components Must Be Focused
Input components only receive keyboard input when focused:
```typescript
const input = new InputRenderable(renderer, {
id: "input",
placeholder: "Type here...",
})
renderer.root.add(input)
// WRONG - input won't receive keystrokes
// (no focus call)
// CORRECT
input.focus()
```
### Focus in Nested Components
When a component is inside a container, focus the component directly:
```typescript
const container = new BoxRenderable(renderer, { id: "container" })
const input = new InputRenderable(renderer, { id: "input" })
container.add(input)
renderer.root.add(container)
// WRONG
container.focus()
// CORRECT
input.focus()
// Or use getRenderable
container.getRenderable("input")?.focus()
// Or use delegate (constructs)
const form = delegate(
{ focus: "input" },
Box({}, Input({ id: "input" })),
)
form.focus() // Routes to the input
```
## Build Requirements
### Zig is Required
Native code compilation requires Zig:
```bash
# Install Zig first
# macOS
brew install zig
# Linux
# Download from https://ziglang.org/download/
# Then build
bun run build
```
### When to Build
- **TypeScript changes**: NO build needed (Bun runs TS directly)
- **Native code changes**: Build required
```bash
# Only needed when changing native (Zig) code
cd packages/core
bun run build
```
## Common Errors
### "Cannot read properties of undefined"
Usually means a renderable wasn't added to the tree:
```typescript
// WRONG - not added to tree
const text = new TextRenderable(renderer, { content: "Hello" })
// text.someMethod() // May fail
// CORRECT
const text = new TextRenderable(renderer, { content: "Hello" })
renderer.root.add(text)
text.someMethod()
```
### Layout Not Updating
Yoga layout is calculated lazily. Force a recalculation:
```typescript
// After changing layout properties
box.setWidth(newWidth)
renderer.requestRender()
```
### Text Overflow/Clipping
Text doesn't wrap by default. Set explicit width:
```typescript
// May overflow
const text = new TextRenderable(renderer, {
content: "Very long text that might overflow the terminal...",
})
// Contained within width
const text = new TextRenderable(renderer, {
content: "Very long text that might overflow the terminal...",
width: 40, // Will clip or wrap based on parent
})
```
### Colors Not Showing
Check terminal capability and color format:
```typescript
// CORRECT formats
fg: "#FF0000" // Hex
fg: "red" // CSS color name
fg: RGBA.fromHex("#FF0000")
// WRONG
fg: "FF0000" // Missing #
fg: 0xFF0000 // Number (not supported)
```
## Performance
### Avoid Frequent Re-renders
Batch updates when possible:
```typescript
// WRONG - multiple render calls
item1.setContent("...")
item2.setContent("...")
item3.setContent("...")
// BETTER - single render after all updates
// (OpenTUI batches automatically, but be mindful)
items.forEach((item, i) => {
item.setContent(data[i])
})
```
### Minimize Tree Depth
Deep nesting impacts layout calculation:
```typescript
// Avoid unnecessary wrappers
// WRONG
Box({}, Box({}, Box({}, Text({ content: "Hello" }))))
// CORRECT
Box({}, Text({ content: "Hello" }))
```
### Use display: none
Hide elements instead of removing/re-adding:
```typescript
// For toggling visibility
element.setDisplay("none") // Hidden
element.setDisplay("flex") // Visible
// Instead of
parent.remove(element)
parent.add(element)
```
## Testing
### Test Runner
Use Bun's test runner:
```typescript
import { test, expect, beforeEach, afterEach } from "bun:test"
test("my test", () => {
expect(1 + 1).toBe(2)
})
```
### Test from Package Directories
Run tests from the specific package directory:
```bash
# CORRECT
cd packages/core
bun test
# For native tests
cd packages/core
bun run test:native
```
### Filter Tests
```bash
# Bun test filter
bun test --filter "component name"
# Native test filter
bun run test:native -Dtest-filter="test name"
```
## Keyboard Handling
### Key Names
Common key names for `KeyEvent.name`:
```typescript
// Letters/numbers
"a", "b", ..., "z"
"1", "2", ..., "0"
// Special keys
"escape", "enter", "return", "tab", "backspace", "delete"
"up", "down", "left", "right"
"home", "end", "pageup", "pagedown"
"f1", "f2", ..., "f12"
"space"
// Modifiers (check boolean properties)
key.ctrl // Ctrl held
key.shift // Shift held
key.meta // Alt held
key.option // Option held (macOS)
```
### Key Event Types
```typescript
renderer.keyInput.on("keypress", (key) => {
// eventType: "press" | "release" | "repeat"
if (key.eventType === "repeat") {
// Key being held down
}
})
```
@@ -0,0 +1,449 @@
# Core Patterns
## Composition Patterns
### Imperative Composition
Create renderables and compose with `.add()`:
```typescript
import { createCliRenderer, BoxRenderable, TextRenderable } from "@opentui/core"
const renderer = await createCliRenderer()
// Create parent
const container = new BoxRenderable(renderer, {
id: "container",
flexDirection: "column",
padding: 1,
})
// Create children
const header = new TextRenderable(renderer, {
id: "header",
content: "Header",
fg: "#00FF00",
})
const body = new TextRenderable(renderer, {
id: "body",
content: "Body content",
})
// Compose tree
container.add(header)
container.add(body)
renderer.root.add(container)
```
### Declarative Composition (Constructs)
Use VNode functions for cleaner composition:
```typescript
import { createCliRenderer, Box, Text, Input, delegate } from "@opentui/core"
const renderer = await createCliRenderer()
// Compose as function calls
const ui = Box(
{ flexDirection: "column", padding: 1 },
Text({ content: "Header", fg: "#00FF00" }),
Box(
{ flexDirection: "row", gap: 2 },
Text({ content: "Name:" }),
Input({ id: "name", placeholder: "Enter name..." }),
),
)
renderer.root.add(ui)
```
### Reusable Components
Create factory functions for reusable UI pieces:
```typescript
// Imperative factory
function createLabeledInput(
renderer: RenderContext,
props: { id: string; label: string; placeholder: string }
) {
const container = new BoxRenderable(renderer, {
id: `${props.id}-container`,
flexDirection: "row",
gap: 1,
})
container.add(new TextRenderable(renderer, {
id: `${props.id}-label`,
content: props.label,
}))
container.add(new InputRenderable(renderer, {
id: `${props.id}-input`,
placeholder: props.placeholder,
width: 20,
}))
return container
}
// Declarative factory
function LabeledInput(props: { id: string; label: string; placeholder: string }) {
return delegate(
{ focus: `${props.id}-input` },
Box(
{ flexDirection: "row", gap: 1 },
Text({ content: props.label }),
Input({
id: `${props.id}-input`,
placeholder: props.placeholder,
width: 20,
}),
),
)
}
```
### Focus Delegation
Route focus calls to nested elements:
```typescript
import { delegate, Box, Input, Text } from "@opentui/core"
const form = delegate(
{
focus: "email-input", // Route .focus() to this child
blur: "email-input", // Route .blur() to this child
},
Box(
{ border: true, padding: 1 },
Text({ content: "Email:" }),
Input({ id: "email-input", placeholder: "you@example.com" }),
),
)
// This focuses the input inside, not the box
form.focus()
```
## Event Handling
### Keyboard Events
```typescript
const renderer = await createCliRenderer()
// Global keyboard handler
renderer.keyInput.on("keypress", (key) => {
if (key.name === "escape") {
renderer.destroy()
process.exit(0)
}
if (key.ctrl && key.name === "c") {
// Ctrl+C handling (if exitOnCtrlC is false)
}
if (key.name === "tab") {
// Tab navigation
focusNext()
}
})
// Paste events
renderer.keyInput.on("paste", (event) => {
const text = decodePasteBytes(event.bytes)
currentInput?.setValue(currentInput.value + text)
})
```
### Component Events
```typescript
import { InputRenderable, InputRenderableEvents } from "@opentui/core"
const input = new InputRenderable(renderer, {
id: "search",
placeholder: "Search...",
})
input.on(InputRenderableEvents.CHANGE, (value) => {
performSearch(value)
})
// Select events
const select = new SelectRenderable(renderer, {
id: "menu",
options: [...],
})
select.on(SelectRenderableEvents.ITEM_SELECTED, (index, option) => {
handleSelection(option)
})
select.on(SelectRenderableEvents.SELECTION_CHANGED, (index, option) => {
showPreview(option)
})
```
### Mouse Events
```typescript
const button = new BoxRenderable(renderer, {
id: "button",
border: true,
onMouseDown: (event) => {
button.setBackgroundColor("#444444")
},
onMouseUp: (event) => {
button.setBackgroundColor("#222222")
handleClick()
},
onMouseMove: (event) => {
// Hover effect
},
})
```
## State Management
### Local State
Manage state in closures or objects:
```typescript
// Closure-based state
function createCounter(renderer: RenderContext) {
let count = 0
const display = new TextRenderable(renderer, {
id: "count",
content: `Count: ${count}`,
})
const increment = () => {
count++
display.setContent(`Count: ${count}`)
}
return { display, increment }
}
// Class-based state
class CounterWidget {
private count = 0
private display: TextRenderable
constructor(renderer: RenderContext) {
this.display = new TextRenderable(renderer, {
id: "count",
content: this.formatCount(),
})
}
private formatCount() {
return `Count: ${this.count}`
}
increment() {
this.count++
this.display.setContent(this.formatCount())
}
getRenderable() {
return this.display
}
}
```
### Focus Management
Track and manage focus across components:
```typescript
class FocusManager {
private focusables: Renderable[] = []
private currentIndex = 0
register(renderable: Renderable) {
this.focusables.push(renderable)
}
focusNext() {
this.focusables[this.currentIndex]?.blur()
this.currentIndex = (this.currentIndex + 1) % this.focusables.length
this.focusables[this.currentIndex]?.focus()
}
focusPrevious() {
this.focusables[this.currentIndex]?.blur()
this.currentIndex = (this.currentIndex - 1 + this.focusables.length) % this.focusables.length
this.focusables[this.currentIndex]?.focus()
}
}
// Usage
const focusManager = new FocusManager()
focusManager.register(input1)
focusManager.register(input2)
focusManager.register(select1)
renderer.keyInput.on("keypress", (key) => {
if (key.name === "tab") {
key.shift ? focusManager.focusPrevious() : focusManager.focusNext()
}
})
```
## Lifecycle Patterns
### Cleanup
Always clean up resources:
```typescript
const renderer = await createCliRenderer()
// Track intervals/timeouts
const intervals: Timer[] = []
intervals.push(setInterval(() => {
updateClock()
}, 1000))
// Cleanup on exit
process.on("SIGINT", () => {
intervals.forEach(clearInterval)
renderer.destroy()
process.exit(0)
})
// Or use onDestroy callback
const renderer = await createCliRenderer({
onDestroy: () => {
intervals.forEach(clearInterval)
},
})
```
### Dynamic Updates
Update UI based on external data:
```typescript
async function createDashboard(renderer: RenderContext) {
const statsText = new TextRenderable(renderer, {
id: "stats",
content: "Loading...",
})
// Poll for updates
const updateStats = async () => {
const data = await fetchStats()
statsText.setContent(`CPU: ${data.cpu}% | Memory: ${data.memory}%`)
}
// Initial load
await updateStats()
// Periodic updates
setInterval(updateStats, 5000)
return statsText
}
```
## Layout Patterns
### Responsive Layout
Adapt to terminal size:
```typescript
const renderer = await createCliRenderer()
const mainPanel = new BoxRenderable(renderer, {
id: "main",
width: "100%",
height: "100%",
flexDirection: renderer.width > 80 ? "row" : "column",
})
// Listen for resize
process.stdout.on("resize", () => {
mainPanel.setFlexDirection(renderer.width > 80 ? "row" : "column")
})
```
### Split Panels
```typescript
function createSplitView(renderer: RenderContext, ratio = 0.3) {
const container = new BoxRenderable(renderer, {
id: "split",
flexDirection: "row",
width: "100%",
height: "100%",
})
const left = new BoxRenderable(renderer, {
id: "left",
width: `${ratio * 100}%`,
border: true,
})
const right = new BoxRenderable(renderer, {
id: "right",
flexGrow: 1,
border: true,
})
container.add(left)
container.add(right)
return { container, left, right }
}
```
## Debugging Patterns
### Console Overlay
Use the built-in console for debugging:
```typescript
const renderer = await createCliRenderer({
consoleOptions: {
startInDebugMode: true,
},
})
// Show console
renderer.console.show()
// All console methods work
console.log("Debug info")
console.warn("Warning")
console.error("Error")
// Toggle with keyboard
renderer.keyInput.on("keypress", (key) => {
if (key.name === "f12") {
renderer.console.toggle()
}
})
```
### State Inspection
```typescript
function debugState(label: string, state: unknown) {
console.log(`[${label}]`, JSON.stringify(state, null, 2))
}
// In your update logic
debugState("form", { name: nameInput.value, email: emailInput.value })
```
@@ -0,0 +1,617 @@
# Keyboard Input Handling
How to handle keyboard input in OpenTUI applications.
## Overview
OpenTUI provides keyboard input handling through:
- **Core**: `renderer.keyInput` EventEmitter
- **React**: `useKeyboard()` hook
- **Solid**: `useKeyboard()` hook
## When to Use
Use this reference when you need keyboard shortcuts, focus-aware input handling, or custom keybindings.
## KeyEvent Object
All keyboard handlers receive a `KeyEvent` object:
```typescript
interface KeyEvent {
name: string // Key name: "a", "escape", "f1", etc.
sequence: string // Raw escape sequence
ctrl: boolean // Ctrl modifier held
shift: boolean // Shift modifier held
meta: boolean // Alt modifier held
option: boolean // Option modifier held (macOS)
eventType: "press" | "release" | "repeat"
repeated: boolean // Key is being held (repeat event)
}
```
## Basic Usage
### Core
```typescript
import { createCliRenderer, type KeyEvent } from "@opentui/core"
const renderer = await createCliRenderer()
renderer.keyInput.on("keypress", (key: KeyEvent) => {
if (key.name === "escape") {
renderer.destroy()
return
}
if (key.ctrl && key.name === "s") {
saveDocument()
}
})
```
### React
```tsx
import { useKeyboard, useRenderer } from "@opentui/react"
function App() {
const renderer = useRenderer()
useKeyboard((key) => {
if (key.name === "escape") {
renderer.destroy()
}
})
return <text>Press ESC to exit</text>
}
```
### Solid
```tsx
import { useKeyboard, useRenderer } from "@opentui/solid"
function App() {
const renderer = useRenderer()
useKeyboard((key) => {
if (key.name === "escape") {
renderer.destroy()
}
})
return <text>Press ESC to exit</text>
}
```
## Key Names
### Alphabetic Keys
Lowercase: `a`, `b`, `c`, ... `z`
With Shift: Check `key.shift && key.name === "a"` for uppercase
### Numeric Keys
`0`, `1`, `2`, ... `9`
### Function Keys
`f1`, `f2`, `f3`, ... `f12`
### Special Keys
| Key Name | Description |
|----------|-------------|
| `escape` | Escape key |
| `enter` | Enter/Return |
| `return` | Enter/Return (alias) |
| `tab` | Tab key |
| `backspace` | Backspace |
| `delete` | Delete key |
| `space` | Spacebar |
### Arrow Keys
| Key Name | Description |
|----------|-------------|
| `up` | Up arrow |
| `down` | Down arrow |
| `left` | Left arrow |
| `right` | Right arrow |
### Navigation Keys
| Key Name | Description |
|----------|-------------|
| `home` | Home key |
| `end` | End key |
| `pageup` | Page Up |
| `pagedown` | Page Down |
| `insert` | Insert key |
## Modifier Keys
Check modifier properties on `KeyEvent`:
```typescript
renderer.keyInput.on("keypress", (key) => {
if (key.ctrl && key.name === "c") {
// Ctrl+C
}
if (key.shift && key.name === "tab") {
// Shift+Tab
}
if (key.meta && key.name === "s") {
// Alt+S (meta = Alt on most systems)
}
if (key.option && key.name === "a") {
// Option+A (macOS)
}
})
```
### Modifier Combinations
```typescript
// Ctrl+Shift+S
if (key.ctrl && key.shift && key.name === "s") {
saveAs()
}
// Ctrl+Alt+Delete (careful with system shortcuts!)
if (key.ctrl && key.meta && key.name === "delete") {
// ...
}
```
## Event Types
### Press Events (Default)
Normal key press:
```typescript
renderer.keyInput.on("keypress", (key) => {
if (key.eventType === "press") {
// Initial key press
}
})
```
### Repeat Events
Key held down:
```typescript
renderer.keyInput.on("keypress", (key) => {
if (key.eventType === "repeat" || key.repeated) {
// Key is being held
}
})
```
### Release Events
Key released (opt-in):
```tsx
// React
useKeyboard(
(key) => {
if (key.eventType === "release") {
// Key released
}
},
{ release: true } // Enable release events
)
// Solid
useKeyboard(
(key) => {
if (key.eventType === "release") {
// Key released
}
},
{ release: true }
)
```
## Patterns
### Navigation Menu
```tsx
function Menu() {
const [selectedIndex, setSelectedIndex] = useState(0)
const items = ["Home", "Settings", "Help", "Quit"]
useKeyboard((key) => {
switch (key.name) {
case "up":
case "k":
setSelectedIndex(i => Math.max(0, i - 1))
break
case "down":
case "j":
setSelectedIndex(i => Math.min(items.length - 1, i + 1))
break
case "enter":
handleSelect(items[selectedIndex])
break
}
})
return (
<box flexDirection="column">
{items.map((item, i) => (
<text
key={item}
fg={i === selectedIndex ? "#00FF00" : "#FFFFFF"}
>
{i === selectedIndex ? "> " : " "}{item}
</text>
))}
</box>
)
}
```
### Modal Escape
```tsx
function Modal({ onClose, children }) {
useKeyboard((key) => {
if (key.name === "escape") {
onClose()
}
})
return (
<box border padding={2}>
{children}
</box>
)
}
```
### Vim-style Modes
```tsx
function Editor() {
const [mode, setMode] = useState<"normal" | "insert">("normal")
const [content, setContent] = useState("")
useKeyboard((key) => {
if (mode === "normal") {
switch (key.name) {
case "i":
setMode("insert")
break
case "escape":
// Already in normal mode
break
case "j":
moveCursorDown()
break
case "k":
moveCursorUp()
break
}
} else if (mode === "insert") {
if (key.name === "escape") {
setMode("normal")
}
// Input component handles text in insert mode
}
})
return (
<box flexDirection="column">
<text>Mode: {mode}</text>
<textarea
value={content}
onChange={setContent}
focused={mode === "insert"}
/>
</box>
)
}
```
### Game Controls
```tsx
function Game() {
const [pressed, setPressed] = useState(new Set<string>())
useKeyboard(
(key) => {
setPressed(keys => {
const newKeys = new Set(keys)
if (key.eventType === "release") {
newKeys.delete(key.name)
} else {
newKeys.add(key.name)
}
return newKeys
})
},
{ release: true }
)
// Game logic uses pressed set
useEffect(() => {
if (pressed.has("up") || pressed.has("w")) {
moveUp()
}
if (pressed.has("down") || pressed.has("s")) {
moveDown()
}
}, [pressed])
return <text>WASD or arrows to move</text>
}
```
### Keyboard Shortcuts Help
```tsx
function ShortcutsHelp() {
const shortcuts = [
{ keys: "Ctrl+S", action: "Save" },
{ keys: "Ctrl+Q", action: "Quit" },
{ keys: "Ctrl+F", action: "Find" },
{ keys: "Tab", action: "Next field" },
{ keys: "Shift+Tab", action: "Previous field" },
]
return (
<box border title="Keyboard Shortcuts" padding={1}>
{shortcuts.map(({ keys, action }) => (
<box key={keys} flexDirection="row">
<text width={15} fg="#00FFFF">{keys}</text>
<text>{action}</text>
</box>
))}
</box>
)
}
```
## Paste Events
Handle pasted content. Paste events deliver raw bytes, not decoded text.
### PasteEvent Object
```typescript
import { type PasteEvent } from "@opentui/core"
interface PasteEvent {
type: "paste" // Always "paste"
bytes: Uint8Array // Raw pasted bytes
metadata?: PasteMetadata // Optional metadata
preventDefault(): void // Prevent default paste handling
defaultPrevented: boolean // Whether preventDefault was called
}
interface PasteMetadata {
mimeType?: string // MIME type if available
kind?: PasteKind // Paste kind
}
```
### Decoding Paste Bytes
Use `decodePasteBytes` to convert raw bytes to a string, and `stripAnsiSequences` to remove ANSI escape codes:
```typescript
import { decodePasteBytes, stripAnsiSequences } from "@opentui/core"
const text = decodePasteBytes(event.bytes) // Decode UTF-8
const clean = stripAnsiSequences(decodePasteBytes(event.bytes)) // Decode + strip ANSI
```
### Core
```typescript
import { type PasteEvent, decodePasteBytes } from "@opentui/core"
renderer.keyInput.on("paste", (event: PasteEvent) => {
const text = decodePasteBytes(event.bytes)
console.log("Pasted:", text)
})
```
### Solid
Solid provides a dedicated `usePaste` hook:
```tsx
import { usePaste } from "@opentui/solid"
import { decodePasteBytes } from "@opentui/core"
function App() {
usePaste((event) => {
const text = decodePasteBytes(event.bytes)
console.log("Pasted:", text)
})
return <text>Paste something</text>
}
```
> **Note**: `usePaste` is **Solid-only**. React does not have this hook - handle paste via the Core event emitter or input component's `onChange`.
## Text Selection
Text selection is renderer-managed. The renderer owns a single `Selection` object, walks the renderable tree to find selectable children, and emits a `"selection"` event when the user finishes selecting (mouse-up). The `Selection` object aggregates text from all selected renderables automatically.
### Making Renderables Selectable
A renderable must have `selectable` set to `true` to participate in selection. Text-based renderables (`TextRenderable`, `TextareaRenderable`, `ASCIIFontRenderable`, `TextTableRenderable`) support this:
```tsx
// React / Solid
<text selectable>This text can be selected</text>
// Core
const text = new TextRenderable(renderer, {
id: "label",
content: "This text can be selected",
selectable: true,
})
```
### Copy-on-Selection (Core)
Listen to the renderer's `"selection"` event. The `Selection` object's `getSelectedText()` returns text aggregated from all selected renderables in reading order:
```typescript
import type { Selection } from "@opentui/core"
renderer.on("selection", (selection: Selection) => {
const text = selection.getSelectedText()
if (text) {
renderer.copyToClipboardOSC52(text)
}
})
```
> **Important**: Call `selection.getSelectedText()` on the `Selection` object from the event -- not `renderer.root.getSelectedText()`. Individual renderables only return their own selected text. The `Selection` object aggregates across the tree.
### Copy-on-Selection (Solid)
```tsx
import { useSelectionHandler } from "@opentui/solid"
function App() {
useSelectionHandler((selection) => {
const text = selection.getSelectedText()
if (text) {
renderer.copyToClipboardOSC52(text)
}
})
return <text selectable>Select this text</text>
}
```
> **Note**: `useSelectionHandler` is **Solid-only**. React does not have this hook -- use the Core `renderer.on("selection", ...)` event.
### Selection Object
The `Selection` object passed to the event callback:
```typescript
selection.getSelectedText() // Aggregated text from all selected renderables
selection.bounds // { startX, startY, endX, endY } bounding rect
selection.selectedRenderables // Renderable[] with active selections
selection.isActive // Whether selection is still active
```
Individual renderables also expose:
```typescript
renderable.hasSelection() // Does this renderable have selected text?
renderable.getSelectedText() // Selected text in this renderable only
```
### How Selection Traversal Works
When the user drags to select, the renderer:
1. Identifies the selection container (common ancestor of start and end points)
2. Walks all `selectable` descendants within the selection bounds
3. Calls `onSelectionChanged(selection)` on each, which computes local selection
4. Tracks which renderables have active selections in `selection.selectedRenderables`
This means selection works across multiple renderables. Dragging across two `<text selectable>` elements selects text in both, and `selection.getSelectedText()` joins them with newlines.
## Clipboard API (OSC 52)
Copy text to the system clipboard using OSC 52 escape sequences. Works over SSH and in most modern terminal emulators.
```typescript
// Copy to clipboard
const success = renderer.copyToClipboardOSC52("text to copy")
// Check if OSC 52 is supported
if (renderer.isOsc52Supported()) {
renderer.copyToClipboardOSC52("Hello!")
}
// Clear clipboard
renderer.clearClipboardOSC52()
// Target specific clipboard (X11)
import { ClipboardTarget } from "@opentui/core"
renderer.copyToClipboardOSC52("text", ClipboardTarget.Primary) // X11 primary
renderer.copyToClipboardOSC52("text", ClipboardTarget.Clipboard) // System clipboard (default)
```
## Focus and Input Components
Input components (`<input>`, `<textarea>`, `<select>`) capture keyboard events when focused:
```tsx
<input focused /> // Receives keyboard input
// Global useKeyboard still fires, but input consumes characters
```
To prevent conflicts, check if an input is focused before handling global shortcuts:
```tsx
function App() {
const renderer = useRenderer()
const [inputFocused, setInputFocused] = useState(false)
useKeyboard((key) => {
if (inputFocused) return // Let input handle it
// Global shortcuts
if (key.name === "escape") {
renderer.destroy()
}
})
return (
<input
focused={inputFocused}
onFocus={() => setInputFocused(true)}
onBlur={() => setInputFocused(false)}
/>
)
}
```
## Gotchas
### Terminal Limitations
Some key combinations are captured by the terminal or OS:
- `Ctrl+C` often sends SIGINT (use `exitOnCtrlC: false` to handle)
- `Ctrl+Z` suspends the process
- Some function keys may be intercepted
### SSH and Remote Sessions
Key detection may vary over SSH. Test on target environments.
### Multiple Handlers
Multiple `useKeyboard` calls all receive events. Coordinate handlers to prevent conflicts.
## See Also
- [React API](../react/api.md) - `useKeyboard` hook reference
- [Solid API](../solid/api.md) - `useKeyboard` hook reference
- [Input Components](../components/inputs.md) - Focus management with input, textarea, select
- [Testing](../testing/REFERENCE.md) - Simulating key presses in tests
@@ -0,0 +1,337 @@
# OpenTUI Layout System
OpenTUI uses the Yoga layout engine, providing CSS Flexbox-like capabilities for positioning and sizing components in the terminal.
## Overview
Key concepts:
- **Flexbox model**: Familiar CSS Flexbox properties
- **Yoga engine**: Facebook's cross-platform layout engine
- **Terminal units**: Dimensions are in character cells (columns x rows)
- **Percentage support**: Relative sizing based on parent
## Flex Container Properties
### flexDirection
Controls the main axis direction:
```tsx
// Row (default) - children flow horizontally
<box flexDirection="row">
<text>1</text>
<text>2</text>
<text>3</text>
</box>
// Output: 1 2 3
// Column - children flow vertically
<box flexDirection="column">
<text>1</text>
<text>2</text>
<text>3</text>
</box>
// Output:
// 1
// 2
// 3
// Reverse variants
<box flexDirection="row-reverse">...</box> // 3 2 1
<box flexDirection="column-reverse">...</box> // Bottom to top
```
### justifyContent
Aligns children along the main axis:
```tsx
<box flexDirection="row" width={40} justifyContent="flex-start">
{/* Children at start (left for row) */}
</box>
<box flexDirection="row" width={40} justifyContent="flex-end">
{/* Children at end (right for row) */}
</box>
<box flexDirection="row" width={40} justifyContent="center">
{/* Children centered */}
</box>
<box flexDirection="row" width={40} justifyContent="space-between">
{/* First at start, last at end, rest evenly distributed */}
</box>
<box flexDirection="row" width={40} justifyContent="space-around">
{/* Equal space around each child */}
</box>
<box flexDirection="row" width={40} justifyContent="space-evenly">
{/* Equal space between all children and edges */}
</box>
```
### alignItems
Aligns children along the cross axis:
```tsx
<box flexDirection="row" height={10} alignItems="flex-start">
{/* Children at top */}
</box>
<box flexDirection="row" height={10} alignItems="flex-end">
{/* Children at bottom */}
</box>
<box flexDirection="row" height={10} alignItems="center">
{/* Children vertically centered */}
</box>
<box flexDirection="row" height={10} alignItems="stretch">
{/* Children stretch to fill height */}
</box>
<box flexDirection="row" height={10} alignItems="baseline">
{/* Children aligned by text baseline */}
</box>
```
### flexWrap
Controls whether children wrap to new lines:
```tsx
<box flexDirection="row" flexWrap="nowrap" width={20}>
{/* Children overflow (default) */}
</box>
<box flexDirection="row" flexWrap="wrap" width={20}>
{/* Children wrap to next row */}
</box>
<box flexDirection="row" flexWrap="wrap-reverse" width={20}>
{/* Children wrap upward */}
</box>
```
### gap
Space between children:
```tsx
<box flexDirection="row" gap={2}>
<text>A</text>
<text>B</text>
<text>C</text>
</box>
// Output: A B C (2 spaces between)
```
## Flex Item Properties
### flexGrow
How much a child should grow relative to siblings:
```tsx
<box flexDirection="row" width={30}>
<box flexGrow={1}><text>1</text></box>
<box flexGrow={2}><text>2</text></box>
<box flexGrow={1}><text>1</text></box>
</box>
// Widths: 7.5 | 15 | 7.5 (1:2:1 ratio)
```
### flexShrink
How much a child should shrink when space is limited:
```tsx
<box flexDirection="row" width={20}>
<box width={15} flexShrink={1}><text>Shrinks</text></box>
<box width={15} flexShrink={0}><text>Fixed</text></box>
</box>
```
### flexBasis
Initial size before growing/shrinking:
```tsx
<box flexDirection="row">
<box flexBasis={20} flexGrow={1}>Starts at 20, can grow</box>
<box flexBasis="50%">Half of parent</box>
</box>
```
### alignSelf
Override parent's alignItems for this child:
```tsx
<box flexDirection="row" height={10} alignItems="center">
<text>Centered</text>
<text alignSelf="flex-start">Top</text>
<text alignSelf="flex-end">Bottom</text>
</box>
```
## Dimensions
### Fixed Dimensions
```tsx
<box width={40} height={10}>
{/* Exactly 40 columns by 10 rows */}
</box>
```
### Percentage Dimensions
Parent must have explicit size:
```tsx
<box width="100%" height="100%">
<box width="50%" height="50%">
{/* Half of parent */}
</box>
</box>
```
### Min/Max Constraints
```tsx
<box
minWidth={20}
maxWidth={60}
minHeight={5}
maxHeight={20}
>
{/* Constrained sizing */}
</box>
```
## Spacing
### Padding (inside)
```tsx
// All sides
<box padding={2}>Content</box>
// Individual sides
<box
paddingTop={1}
paddingRight={2}
paddingBottom={1}
paddingLeft={2}
>
Content
</box>
```
### Margin (outside)
```tsx
// All sides
<box margin={1}>Content</box>
// Individual sides
<box
marginTop={1}
marginRight={2}
marginBottom={1}
marginLeft={2}
>
Content
</box>
```
## Positioning
### Relative (default)
Element flows in normal document order:
```tsx
<box position="relative">
{/* Normal flow */}
</box>
```
### Absolute
Element positioned relative to nearest positioned ancestor:
```tsx
<box position="relative" width="100%" height="100%">
<box
position="absolute"
left={10}
top={5}
width={20}
height={5}
>
Positioned at (10, 5)
</box>
</box>
```
### Position Properties
```tsx
<box
position="absolute"
left={10} // From left edge
top={5} // From top edge
right={10} // From right edge
bottom={5} // From bottom edge
>
Content
</box>
```
## Display
### Visibility Control
```tsx
// Visible (default)
<box display="flex">Visible</box>
// Hidden (removed from layout)
<box display="none">Hidden</box>
```
## Overflow
```tsx
<box overflow="visible">
{/* Content can extend beyond bounds (default) */}
</box>
<box overflow="hidden">
{/* Content clipped at bounds */}
</box>
<box overflow="scroll">
{/* Scrollable when content exceeds bounds */}
</box>
```
## Z-Index
Control stacking order for overlapping elements:
```tsx
<box position="relative">
<box position="absolute" zIndex={1}>Behind</box>
<box position="absolute" zIndex={2}>In front</box>
</box>
```
## See Also
- [Layout Patterns](./patterns.md) - Common layout recipes
- [Components/Containers](../components/containers.md) - Box and ScrollBox details
@@ -0,0 +1,444 @@
# Layout Patterns
Common layout recipes for terminal user interfaces.
## Full-Screen App
Fill the entire terminal:
```tsx
function App() {
return (
<box width="100%" height="100%">
{/* Content fills terminal */}
</box>
)
}
```
## Header/Content/Footer
Classic app layout:
```tsx
function AppLayout() {
return (
<box flexDirection="column" width="100%" height="100%">
{/* Header - fixed height */}
<box height={3} borderStyle="single" borderBottom>
<text>Header</text>
</box>
{/* Content - fills remaining space */}
<box flexGrow={1}>
<text>Main Content</text>
</box>
{/* Footer - fixed height */}
<box height={1}>
<text>Status: Ready</text>
</box>
</box>
)
}
```
## Sidebar Layout
```tsx
function SidebarLayout() {
return (
<box flexDirection="row" width="100%" height="100%">
{/* Sidebar - fixed width */}
<box width={25} borderStyle="single" borderRight>
<text>Sidebar</text>
</box>
{/* Main - fills remaining space */}
<box flexGrow={1}>
<text>Main Content</text>
</box>
</box>
)
}
```
## Resizable Sidebar
Responsive based on terminal width:
```tsx
function ResponsiveSidebar() {
const dims = useTerminalDimensions() // React: useTerminalDimensions()
const showSidebar = dims.width > 60
const sidebarWidth = Math.min(30, Math.floor(dims.width * 0.3))
return (
<box flexDirection="row" width="100%" height="100%">
{showSidebar && (
<box width={sidebarWidth} border>
<text>Sidebar</text>
</box>
)}
<box flexGrow={1}>
<text>Main</text>
</box>
</box>
)
}
```
## Centered Content
### Horizontally Centered
```tsx
<box width="100%" justifyContent="center">
<box width={40}>
<text>Centered horizontally</text>
</box>
</box>
```
### Vertically Centered
```tsx
<box height="100%" alignItems="center">
<text>Centered vertically</text>
</box>
```
### Both Axes
```tsx
<box
width="100%"
height="100%"
justifyContent="center"
alignItems="center"
>
<box width={40} height={10} border>
<text>Centered both ways</text>
</box>
</box>
```
## Modal/Dialog
Centered overlay:
```tsx
function Modal({ children, visible }) {
if (!visible) return null
return (
<box
position="absolute"
left={0}
top={0}
width="100%"
height="100%"
justifyContent="center"
alignItems="center"
backgroundColor="rgba(0,0,0,0.5)"
>
<box
width={50}
height={15}
border
borderStyle="double"
backgroundColor="#1a1a2e"
padding={2}
>
{children}
</box>
</box>
)
}
```
## Grid Layout
Using flexWrap:
```tsx
function Grid({ items, columns = 3 }) {
const itemWidth = `${Math.floor(100 / columns)}%`
return (
<box flexDirection="row" flexWrap="wrap" width="100%">
{items.map((item, i) => (
<box key={i} width={itemWidth} padding={1}>
<text>{item}</text>
</box>
))}
</box>
)
}
```
## Split Panels
### Horizontal Split
```tsx
function HorizontalSplit({ ratio = 0.5 }) {
return (
<box flexDirection="row" width="100%" height="100%">
<box width={`${ratio * 100}%`} border>
<text>Left Panel</text>
</box>
<box flexGrow={1} border>
<text>Right Panel</text>
</box>
</box>
)
}
```
### Vertical Split
```tsx
function VerticalSplit({ ratio = 0.5 }) {
return (
<box flexDirection="column" width="100%" height="100%">
<box height={`${ratio * 100}%`} border>
<text>Top Panel</text>
</box>
<box flexGrow={1} border>
<text>Bottom Panel</text>
</box>
</box>
)
}
```
## Form Layout
Label + Input pairs:
```tsx
function FormField({ label, children }) {
return (
<box flexDirection="row" marginBottom={1}>
<box width={15}>
<text>{label}:</text>
</box>
<box flexGrow={1}>
{children}
</box>
</box>
)
}
function LoginForm() {
return (
<box flexDirection="column" padding={2} border width={50}>
<FormField label="Username">
<input placeholder="Enter username" />
</FormField>
<FormField label="Password">
<input placeholder="Enter password" />
</FormField>
<box marginTop={2} justifyContent="flex-end">
<box border padding={1}>
<text>Login</text>
</box>
</box>
</box>
)
}
```
## Navigation Tabs
```tsx
function TabBar({ tabs, activeIndex, onSelect }) {
return (
<box flexDirection="row" borderBottom>
{tabs.map((tab, i) => (
<box
key={i}
padding={1}
backgroundColor={i === activeIndex ? "#333" : "transparent"}
onMouseDown={() => onSelect(i)}
>
<text fg={i === activeIndex ? "#fff" : "#888"}>
{tab}
</text>
</box>
))}
</box>
)
}
```
## Sticky Footer
Footer always at bottom:
```tsx
function StickyFooterLayout() {
return (
<box flexDirection="column" width="100%" height="100%">
{/* Content area */}
<box flexGrow={1} flexDirection="column">
{/* Your content here */}
<text>Content that might be short</text>
</box>
{/* Footer pushed to bottom */}
<box height={1}>
<text fg="#888">Press ? for help | q to quit</text>
</box>
</box>
)
}
```
## Absolute Positioning Overlay
Tooltip or popup:
```tsx
function Tooltip({ x, y, children }) {
return (
<box
position="absolute"
left={x}
top={y}
border
backgroundColor="#333"
padding={1}
zIndex={100}
>
{children}
</box>
)
}
```
## Responsive Breakpoints
Different layouts based on terminal size:
```tsx
function ResponsiveApp() {
const { width, height } = useTerminalDimensions()
// Define breakpoints
const isSmall = width < 60
const isMedium = width >= 60 && width < 100
const isLarge = width >= 100
if (isSmall) {
// Mobile-like: stacked layout
return (
<box flexDirection="column">
<Navigation />
<Content />
</box>
)
}
if (isMedium) {
// Tablet-like: sidebar + content
return (
<box flexDirection="row">
<box width={20}><Navigation /></box>
<box flexGrow={1}><Content /></box>
</box>
)
}
// Large: full layout
return (
<box flexDirection="row">
<box width={25}><Navigation /></box>
<box flexGrow={1}><Content /></box>
<box width={30}><Sidebar /></box>
</box>
)
}
```
## Equal Height Columns
```tsx
function EqualColumns() {
return (
<box flexDirection="row" alignItems="stretch" height={20}>
<box flexGrow={1} border>
<text>Short content</text>
</box>
<box flexGrow={1} border>
<text>
Longer content that
spans multiple lines
and takes up space
</text>
</box>
<box flexGrow={1} border>
<text>Medium content</text>
</box>
</box>
)
}
```
## Spacing Utilities
Consistent spacing patterns:
```tsx
// Spacer component
function Spacer({ size = 1 }) {
return <box height={size} width={size} />
}
// Divider component
function Divider() {
return <box height={1} width="100%" backgroundColor="#333" />
}
// Usage
<box flexDirection="column">
<text>Section 1</text>
<Spacer size={2} />
<Divider />
<Spacer size={2} />
<text>Section 2</text>
</box>
```
### Axis Shorthand Props
Use `paddingX`/`paddingY` and `marginX`/`marginY` for horizontal/vertical spacing:
```tsx
// Horizontal padding (left + right)
<box paddingX={4}>
<text>4 chars padding left and right</text>
</box>
// Vertical padding (top + bottom)
<box paddingY={2}>
<text>2 lines padding top and bottom</text>
</box>
// Horizontal margin for centering-like effect
<box marginX={10}>
<text>Indented content</text>
</box>
// Combined for card-like spacing
<box paddingX={3} paddingY={1} marginY={1} border>
<text>Nicely spaced card</text>
</box>
```
These are shorthand for:
- `paddingX={n}` = `paddingLeft={n}` + `paddingRight={n}`
- `paddingY={n}` = `paddingTop={n}` + `paddingBottom={n}`
- `marginX={n}` = `marginLeft={n}` + `marginRight={n}`
- `marginY={n}` = `marginTop={n}` + `marginBottom={n}`
@@ -0,0 +1,174 @@
# OpenTUI React (@opentui/react)
A React reconciler for building terminal user interfaces with familiar React patterns. Write TUIs using JSX, hooks, and component composition.
## Overview
OpenTUI React provides:
- **Custom reconciler**: React components render to OpenTUI renderables
- **JSX intrinsics**: `<text>`, `<box>`, `<input>`, etc.
- **Hooks**: `useKeyboard`, `useRenderer`, `useTimeline`, etc.
- **Full React compatibility**: useState, useEffect, context, and more
## When to Use React
Use the React reconciler when:
- You're familiar with React patterns
- You want declarative UI composition
- You need React's ecosystem (context, state management libraries)
- Building applications with complex state
- Team knows React already
## When NOT to Use React
| Scenario | Use Instead |
|----------|-------------|
| Maximum performance critical | `@opentui/core` (imperative) |
| Fine-grained reactivity | `@opentui/solid` |
| Smallest bundle size | `@opentui/core` |
| Building a framework/library | `@opentui/core` |
## Quick Start
```bash
bunx create-tui@latest -t react my-app
cd my-app
bun run src/index.tsx
```
The CLI creates the `my-app` directory for you - it must **not already exist**.
**Agent guidance**: Always use autonomous mode with `-t <template>` flag. Never use interactive mode (`bunx create-tui@latest my-app` without `-t`) as it requires user prompts that agents cannot respond to.
Or manual setup:
```bash
mkdir my-tui && cd my-tui
bun init
bun install @opentui/react @opentui/core react
```
```tsx
import { createCliRenderer } from "@opentui/core"
import { createRoot } from "@opentui/react"
import { useState } from "react"
function App() {
const [count, setCount] = useState(0)
return (
<box border padding={2}>
<text>Count: {count}</text>
<box
border
onMouseDown={() => setCount(c => c + 1)}
>
<text>Click me!</text>
</box>
</box>
)
}
const renderer = await createCliRenderer()
createRoot(renderer).render(<App />)
```
## Core Concepts
### JSX Elements
React maps JSX intrinsic elements to OpenTUI renderables:
```tsx
// These are not HTML elements!
<text>Hello</text> // TextRenderable
<box border>Content</box> // BoxRenderable
<input placeholder="..." /> // InputRenderable
<select options={[...]} /> // SelectRenderable
```
### Text Modifiers
Inside `<text>`, use modifier elements:
```tsx
<text>
<strong>Bold</strong>, <em>italic</em>, and <u>underlined</u>
<span fg="red">Colored text</span>
<br />
New line with <a href="https://example.com">link</a>
</text>
```
### Styling
Two approaches to styling:
```tsx
// Direct props
<box backgroundColor="blue" padding={2} border>
<text fg="#00FF00">Green text</text>
</box>
// Style prop
<box style={{ backgroundColor: "blue", padding: 2, border: true }}>
<text style={{ fg: "#00FF00" }}>Green text</text>
</box>
```
## Available Components
### Layout & Display
- `<text>` - Styled text content
- `<box>` - Container with borders and layout
- `<scrollbox>` - Scrollable container
- `<ascii-font>` - ASCII art text
### Input
- `<input>` - Single-line text input
- `<textarea>` - Multi-line text input
- `<select>` - List selection
- `<tab-select>` - Tab-based selection
### Code & Diff
- `<code>` - Syntax-highlighted code
- `<line-number>` - Code with line numbers
- `<diff>` - Unified or split diff viewer
### Text Modifiers (inside `<text>`)
- `<span>` - Inline styled text
- `<strong>`, `<b>` - Bold
- `<em>`, `<i>` - Italic
- `<u>` - Underline
- `<br>` - Line break
- `<a>` - Link
## Essential Hooks
```tsx
import {
useRenderer,
useKeyboard,
useOnResize,
useTerminalDimensions,
useTimeline,
} from "@opentui/react"
```
See [API Reference](./api.md) for detailed hook documentation.
## In This Reference
- [Configuration](./configuration.md) - Project setup, tsconfig, bundling
- [API](./api.md) - Components, hooks, createRoot
- [Patterns](./patterns.md) - State management, keyboard handling, forms
- [Gotchas](./gotchas.md) - Common issues, debugging, limitations
## See Also
- [Core](../core/REFERENCE.md) - Underlying imperative API
- [Solid](../solid/REFERENCE.md) - Alternative declarative approach
- [Components](../components/REFERENCE.md) - Component reference by category
- [Layout](../layout/REFERENCE.md) - Flexbox layout system
- [Keyboard](../keyboard/REFERENCE.md) - Input handling and shortcuts
- [Testing](../testing/REFERENCE.md) - Test renderer and snapshots
@@ -0,0 +1,436 @@
# React API Reference
## Rendering
### createRoot(renderer)
Creates a React root for rendering.
```tsx
import { createCliRenderer } from "@opentui/core"
import { createRoot } from "@opentui/react"
const renderer = await createCliRenderer({
exitOnCtrlC: false, // Handle Ctrl+C yourself
})
const root = createRoot(renderer)
root.render(<App />)
```
## Hooks
### useRenderer()
Access the OpenTUI renderer instance.
```tsx
import { useRenderer } from "@opentui/react"
import { useEffect } from "react"
function App() {
const renderer = useRenderer()
useEffect(() => {
// Access renderer properties
console.log(`Terminal: ${renderer.width}x${renderer.height}`)
// Show debug console
renderer.console.show()
// Access theme mode (dark/light based on terminal settings)
console.log(`Theme: ${renderer.themeMode}`) // "dark" | "light" | null
}, [renderer])
return <text>Hello</text>
}
// Listen for theme mode changes
function ThemedApp() {
const renderer = useRenderer()
const [theme, setTheme] = useState(renderer.themeMode ?? "dark")
useEffect(() => {
const handler = (mode: "dark" | "light") => setTheme(mode)
renderer.on("theme_mode", handler)
return () => renderer.off("theme_mode", handler)
}, [renderer])
return (
<box backgroundColor={theme === "dark" ? "#1a1a2e" : "#ffffff"}>
<text fg={theme === "dark" ? "#fff" : "#000"}>
Current theme: {theme}
</text>
</box>
)
}
```
### useKeyboard(handler, options?)
Handle keyboard events.
```tsx
import { useKeyboard, useRenderer } from "@opentui/react"
function App() {
const renderer = useRenderer()
useKeyboard((key) => {
if (key.name === "escape") {
renderer.destroy() // Never use process.exit() directly!
}
if (key.ctrl && key.name === "s") {
saveDocument()
}
})
return <text>Press ESC to exit</text>
}
// With release events
function GameControls() {
const [pressed, setPressed] = useState(new Set<string>())
useKeyboard(
(event) => {
setPressed(keys => {
const newKeys = new Set(keys)
if (event.eventType === "release") {
newKeys.delete(event.name)
} else {
newKeys.add(event.name)
}
return newKeys
})
},
{ release: true } // Include release events
)
return <text>Pressed: {Array.from(pressed).join(", ")}</text>
}
```
**Options:**
- `release?: boolean` - Include key release events (default: false)
**KeyEvent properties:**
- `name: string` - Key name ("a", "escape", "f1", etc.)
- `sequence: string` - Raw escape sequence
- `ctrl: boolean` - Ctrl modifier
- `shift: boolean` - Shift modifier
- `meta: boolean` - Alt modifier
- `option: boolean` - Option modifier (macOS)
- `eventType: "press" | "release" | "repeat"`
- `repeated: boolean` - Key is being held
### useOnResize(callback)
Handle terminal resize events.
```tsx
import { useOnResize } from "@opentui/react"
function App() {
useOnResize((width, height) => {
console.log(`Resized to ${width}x${height}`)
})
return <text>Resize the terminal</text>
}
```
### useTerminalDimensions()
Get reactive terminal dimensions.
```tsx
import { useTerminalDimensions } from "@opentui/react"
function ResponsiveLayout() {
const { width, height } = useTerminalDimensions()
return (
<box flexDirection={width > 80 ? "row" : "column"}>
<box flexGrow={1}>
<text>Width: {width}</text>
</box>
<box flexGrow={1}>
<text>Height: {height}</text>
</box>
</box>
)
}
```
### useTimeline(options?)
Create animations with the timeline system.
```tsx
import { useTimeline } from "@opentui/react"
import { useEffect, useState } from "react"
function AnimatedBox() {
const [width, setWidth] = useState(0)
const timeline = useTimeline({
duration: 2000,
loop: false,
})
useEffect(() => {
timeline.add(
{ width: 0 },
{
width: 50,
duration: 2000,
ease: "easeOutQuad",
onUpdate: (anim) => {
setWidth(Math.round(anim.targets[0].width))
},
}
)
}, [timeline])
return <box style={{ width, height: 3, backgroundColor: "#6a5acd" }} />
}
```
**Options:**
- `duration?: number` - Default duration (ms)
- `loop?: boolean` - Loop the timeline
- `autoplay?: boolean` - Auto-start (default: true)
- `onComplete?: () => void` - Completion callback
- `onPause?: () => void` - Pause callback
**Timeline methods:**
- `add(target, properties, startTime?)` - Add animation
- `play()` - Start playback
- `pause()` - Pause playback
- `restart()` - Restart from beginning
## Components
### Text Component
```tsx
<text
content="Hello" // Or use children
fg="#FFFFFF" // Foreground color
bg="#000000" // Background color
selectable={true} // Allow text selection
>
{/* Use nested modifier tags for styling */}
<span fg="red">Red</span>
<strong>Bold</strong>
<em>Italic</em>
<u>Underline</u>
<br />
<a href="https://...">Link</a>
</text>
```
> **Note**: Do NOT use `bold`, `italic`, `underline` as props on `<text>`. Use nested modifier tags like `<strong>`, `<em>`, `<u>` instead.
### Box Component
```tsx
<box
// Borders
border // Enable border
borderStyle="single" // single | double | rounded | bold
borderColor="#FFFFFF"
title="Title"
titleAlignment="center" // left | center | right
// Colors
backgroundColor="#1a1a2e"
// Layout (see layout/REFERENCE.md)
flexDirection="row"
justifyContent="center"
alignItems="center"
gap={2}
// Spacing
padding={2}
paddingTop={1}
paddingX={2} // Horizontal (left + right)
paddingY={1} // Vertical (top + bottom)
margin={1}
marginX={2} // Horizontal (left + right)
marginY={1} // Vertical (top + bottom)
// Dimensions
width={40}
height={10}
flexGrow={1}
// Focus
focusable // Allow box to receive focus
focused={isFocused} // Controlled focus state
// Events
onMouseDown={(e) => {}}
onMouseUp={(e) => {}}
onMouseMove={(e) => {}}
>
{children}
</box>
```
### Scrollbox Component
```tsx
<scrollbox
focused // Enable keyboard scrolling
style={{
rootOptions: { backgroundColor: "#24283b" },
wrapperOptions: { backgroundColor: "#1f2335" },
viewportOptions: { backgroundColor: "#1a1b26" },
contentOptions: { backgroundColor: "#16161e" },
scrollbarOptions: {
showArrows: true,
trackOptions: {
foregroundColor: "#7aa2f7",
backgroundColor: "#414868",
},
},
}}
>
{/* Scrollable content */}
{items.map((item, i) => (
<box key={i}>
<text>{item}</text>
</box>
))}
</scrollbox>
```
### Input Component
```tsx
<input
value={value}
onChange={(newValue) => setValue(newValue)}
placeholder="Enter text..."
focused // Start focused
width={30}
backgroundColor="#1a1a1a"
textColor="#FFFFFF"
cursorColor="#00FF00"
focusedBackgroundColor="#2a2a2a"
/>
```
### Textarea Component
```tsx
<textarea
value={text}
onChange={(newValue) => setText(newValue)}
placeholder="Enter multiple lines..."
focused
width={40}
height={10}
showLineNumbers
wrapText
/>
```
### Select Component
```tsx
<select
options={[
{ name: "Option 1", description: "First option", value: "1" },
{ name: "Option 2", description: "Second option", value: "2" },
]}
onChange={(index, option) => setSelected(option)}
selectedIndex={0}
focused
showScrollIndicator
height={8}
/>
```
### Tab Select Component
```tsx
<tab-select
options={[
{ name: "Home", description: "Dashboard" },
{ name: "Settings", description: "Configuration" },
]}
onChange={(index, option) => setTab(option)}
tabWidth={20}
focused
/>
```
### ASCII Font Component
```tsx
<ascii-font
text="TITLE"
font="tiny" // tiny | block | slick | shade
color="#FFFFFF"
/>
```
### Code Component
```tsx
<code
code={sourceCode}
language="typescript"
showLineNumbers
highlightLines={[1, 5, 10]}
/>
```
### Line Number Component
```tsx
<line-number
code={sourceCode}
language="typescript"
startLine={1}
highlightedLines={[5]}
diagnostics={[
{ line: 3, severity: "error", message: "Syntax error" }
]}
/>
```
### Diff Component
```tsx
<diff
oldCode={originalCode}
newCode={modifiedCode}
language="typescript"
mode="unified" // unified | split
syncScroll // Sync scroll between split view panes
showLineNumbers
/>
```
## Type Exports
```tsx
import type {
// Component props
TextProps,
BoxProps,
InputProps,
SelectProps,
// Hook types
KeyEvent,
// From core
CliRenderer,
} from "@opentui/react"
```
@@ -0,0 +1,302 @@
# React Configuration
## Project Setup
### Quick Start
```bash
bunx create-tui@latest -t react my-app
cd my-app && bun install
```
The CLI creates the `my-app` directory for you - it must **not already exist**.
Options: `--no-git` (skip git init), `--no-install` (skip bun install)
### Manual Setup
```bash
mkdir my-tui && cd my-tui
bun init
bun install @opentui/react @opentui/core react
```
## TypeScript Configuration
### tsconfig.json
```json
{
"compilerOptions": {
"lib": ["ESNext", "DOM"],
"target": "ESNext",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"jsx": "react-jsx",
"jsxImportSource": "@opentui/react",
"strict": true,
"skipLibCheck": true,
"noEmit": true,
"types": ["bun-types"]
},
"include": ["src/**/*"]
}
```
**Critical settings:**
- `jsx: "react-jsx"` - Use the new JSX transform
- `jsxImportSource: "@opentui/react"` - Import JSX runtime from OpenTUI
- `module` / `moduleResolution: "NodeNext"` - Recommended for OpenTUI compatibility
### Why DOM lib?
The `DOM` lib is needed for React types. OpenTUI's JSX types extend React's.
## Package Configuration
### package.json
```json
{
"name": "my-tui-app",
"type": "module",
"scripts": {
"start": "bun run src/index.tsx",
"dev": "bun --watch run src/index.tsx",
"test": "bun test",
"build": "bun build src/index.tsx --outdir=dist --target=bun"
},
"dependencies": {
"@opentui/core": "latest",
"@opentui/react": "latest",
"react": ">=19.0.0"
},
"devDependencies": {
"@types/bun": "latest",
"@types/react": ">=19.0.0",
"typescript": "latest"
}
}
```
## Project Structure
Recommended structure:
```
my-tui-app/
├── src/
│ ├── components/
│ │ ├── Header.tsx
│ │ ├── Sidebar.tsx
│ │ └── MainContent.tsx
│ ├── hooks/
│ │ └── useAppState.ts
│ ├── App.tsx
│ └── index.tsx
├── package.json
└── tsconfig.json
```
### Entry Point (src/index.tsx)
```tsx
import { createCliRenderer } from "@opentui/core"
import { createRoot } from "@opentui/react"
import { App } from "./App"
const renderer = await createCliRenderer({
exitOnCtrlC: true,
})
createRoot(renderer).render(<App />)
```
### App Component (src/App.tsx)
```tsx
import { Header } from "./components/Header"
import { Sidebar } from "./components/Sidebar"
import { MainContent } from "./components/MainContent"
export function App() {
return (
<box flexDirection="column" width="100%" height="100%">
<Header />
<box flexDirection="row" flexGrow={1}>
<Sidebar />
<MainContent />
</box>
</box>
)
}
```
## Renderer Configuration
### createCliRenderer Options
```tsx
import { createCliRenderer, ConsolePosition } from "@opentui/core"
const renderer = await createCliRenderer({
// Rendering
targetFPS: 60,
// Behavior
exitOnCtrlC: true, // Set false to handle Ctrl+C yourself
autoFocus: true, // Auto-focus elements on click (default: true)
useMouse: true, // Enable mouse support (default: true)
// Debug console
consoleOptions: {
position: ConsolePosition.BOTTOM,
sizePercent: 30,
startInDebugMode: false,
},
// Cleanup
onDestroy: () => {
// Cleanup code
},
})
```
## Building for Distribution
### Bundling with Bun
```typescript
// build.ts
await Bun.build({
entrypoints: ["./src/index.tsx"],
outdir: "./dist",
target: "bun",
minify: true,
})
```
Run: `bun run build.ts`
### Creating Executables
```typescript
// build.ts
await Bun.build({
entrypoints: ["./src/index.tsx"],
outdir: "./dist",
target: "bun",
compile: {
target: "bun-darwin-arm64", // or bun-linux-x64, etc.
outfile: "my-app",
},
})
```
## Environment Variables
Create `.env` for development:
```env
# Debug settings
OTUI_SHOW_STATS=false
SHOW_CONSOLE=false
# App settings
API_URL=https://api.example.com
```
Bun auto-loads `.env` files. Access via `process.env`:
```tsx
const apiUrl = process.env.API_URL
```
## React DevTools
OpenTUI React supports React DevTools for debugging.
### Setup
1. Install DevTools as a dev dependency (must use version 7):
```bash
bun add react-devtools-core@7 -d
```
2. Run DevTools standalone app:
```bash
npx react-devtools@7
```
3. Start your app with `DEV=true` environment variable:
```bash
DEV=true bun run src/index.tsx
```
**Important**: Auto-connect to DevTools ONLY happens when `DEV=true` is set. Without this environment variable, the DevTools connection code is not loaded.
### How It Works
OpenTUI checks for `process.env["DEV"] === "true"` at startup. When true, it dynamically imports `react-devtools-core` and connects to the standalone DevTools app.
## Testing Configuration
### Test Setup
```typescript
// src/test-utils.tsx
import { createTestRenderer } from "@opentui/core/testing"
import { createRoot } from "@opentui/react"
export async function renderForTest(
element: React.ReactElement,
options = { width: 80, height: 24 }
) {
const testSetup = await createTestRenderer(options)
createRoot(testSetup.renderer).render(element)
return testSetup
}
```
### Test Example
```typescript
// src/components/Counter.test.tsx
import { test, expect } from "bun:test"
import { renderForTest } from "../test-utils"
import { Counter } from "./Counter"
test("Counter renders initial value", async () => {
const { snapshot } = await renderForTest(<Counter initialValue={5} />)
expect(snapshot()).toContain("Count: 5")
})
```
## Common Issues
### JSX Types Not Working
Ensure `jsxImportSource` is set:
```json
{
"compilerOptions": {
"jsx": "react-jsx",
"jsxImportSource": "@opentui/react"
}
}
```
### React Version Mismatch
Ensure React 19+:
```bash
bun install react@19 @types/react@19
```
### Module Resolution Errors
Use `moduleResolution: "bundler"` for Bun compatibility.
@@ -0,0 +1,443 @@
# React Gotchas
## Critical
### Never use `process.exit()` directly
**This is the most common mistake.** Using `process.exit()` leaves the terminal in a broken state (cursor hidden, raw mode, alternate screen).
```tsx
// WRONG - Terminal left in broken state
process.exit(0)
// CORRECT - Use renderer.destroy()
import { useRenderer } from "@opentui/react"
function App() {
const renderer = useRenderer()
const handleExit = () => {
renderer.destroy() // Cleans up and exits properly
}
}
```
`renderer.destroy()` restores the terminal (exits alternate screen, restores cursor, etc.) before exiting.
### Signal Handling
OpenTUI automatically handles cleanup for these signals:
- `SIGINT` (Ctrl+C), `SIGTERM`, `SIGQUIT` - Standard termination
- `SIGHUP` - Terminal closed/hangup
- `SIGBREAK` - Ctrl+Break (Windows)
- `SIGPIPE` - Broken pipe (output closed)
- `SIGBUS`, `SIGFPE` - Hardware errors
This ensures terminal state is restored even on unexpected termination. If you need custom signal handling, use `exitOnCtrlC: false` and handle signals yourself while still calling `renderer.destroy()`.
## JSX Configuration
### Missing jsxImportSource
**Symptom**: JSX elements have wrong types, components don't render
```
// Error: Property 'text' does not exist on type 'JSX.IntrinsicElements'
```
**Fix**: Configure tsconfig.json:
```json
{
"compilerOptions": {
"jsx": "react-jsx",
"jsxImportSource": "@opentui/react"
}
}
```
### HTML Elements vs TUI Elements
OpenTUI's JSX elements are **not** HTML elements:
```tsx
// WRONG - These are HTML concepts
<div>Not supported</div>
<button>Not supported</button>
<span>Only works inside <text></span>
// CORRECT - OpenTUI elements
<box>Container</box>
<text>Display text</text>
<text><span>Inline styled</span></text>
```
## Component Issues
### Text Modifiers Outside Text
Text modifiers only work inside `<text>`:
```tsx
// WRONG
<box>
<strong>This won't work</strong>
</box>
// CORRECT
<box>
<text>
<strong>This works</strong>
</text>
</box>
```
### Focus Not Working
Components must be explicitly focused:
```tsx
// WRONG - Won't receive keyboard input
<input placeholder="Type here..." />
// CORRECT
<input placeholder="Type here..." focused />
// Or manage focus state
const [isFocused, setIsFocused] = useState(true)
<input placeholder="Type here..." focused={isFocused} />
```
### Select Not Responding
Select requires focus and proper options format:
```tsx
// WRONG - Missing required properties
<select options={["a", "b", "c"]} />
// CORRECT
<select
options={[
{ name: "Option A", description: "First option", value: "a" },
{ name: "Option B", description: "Second option", value: "b" },
]}
onSelect={(index, option) => {
// Called when Enter is pressed
console.log("Selected:", option.name)
}}
focused
/>
```
### Select Events Confusion
Remember: `onSelect` fires on Enter (selection confirmed), `onChange` fires on navigation:
```tsx
// WRONG - expecting onChange to fire on Enter
<select
options={options}
onChange={(i, opt) => submitForm(opt)} // This fires on arrow keys!
/>
// CORRECT
<select
options={options}
onSelect={(i, opt) => submitForm(opt)} // Enter pressed - submit
onChange={(i, opt) => showPreview(opt)} // Arrow keys - preview
/>
```
## Hook Issues
### useKeyboard Not Firing
Multiple `useKeyboard` hooks can conflict:
```tsx
// Both handlers fire - may cause issues
function App() {
useKeyboard((key) => { /* parent handler */ })
return <ChildWithKeyboard />
}
function ChildWithKeyboard() {
useKeyboard((key) => { /* child handler */ })
return <text>Child</text>
}
```
**Solution**: Use a single keyboard handler or implement event stopping:
```tsx
function App() {
const [handled, setHandled] = useState(false)
useKeyboard((key) => {
if (handled) {
setHandled(false)
return
}
// Handle at app level
})
return <Child onKeyHandled={() => setHandled(true)} />
}
```
### useEffect Cleanup
Always clean up intervals and listeners:
```tsx
// WRONG - Memory leak
useEffect(() => {
setInterval(() => updateData(), 1000)
}, [])
// CORRECT
useEffect(() => {
const interval = setInterval(() => updateData(), 1000)
return () => clearInterval(interval) // Cleanup!
}, [])
```
## Styling Issues
### Colors Not Applying
Check color format:
```tsx
// CORRECT formats
<text fg="#FF0000">Red</text>
<text fg="red">Red</text>
<box backgroundColor="#1a1a2e">Box</box>
// WRONG
<text fg="FF0000">Missing #</text>
<text color="#FF0000">Wrong prop name (use fg)</text>
```
### Layout Not Working
Ensure parent has dimensions:
```tsx
// WRONG - Parent has no height
<box flexDirection="column">
<box flexGrow={1}>Won't grow</box>
</box>
// CORRECT
<box flexDirection="column" height="100%">
<box flexGrow={1}>Will grow</box>
</box>
```
### Percentage Widths Not Working
Parent must have explicit dimensions:
```tsx
// WRONG
<box>
<box width="50%">Won't work</box>
</box>
// CORRECT
<box width="100%">
<box width="50%">Works</box>
</box>
```
## Performance Issues
### Too Many Re-renders
Avoid inline objects/functions in props:
```tsx
// WRONG - New object every render
<box style={{ padding: 2 }}>Content</box>
// BETTER - Use direct props
<box padding={2}>Content</box>
// OR memoize style objects
const style = useMemo(() => ({ padding: 2 }), [])
<box style={style}>Content</box>
```
### Heavy Components
Use React.memo for expensive components:
```tsx
const ExpensiveList = React.memo(function ExpensiveList({
items
}: {
items: Item[]
}) {
return (
<box flexDirection="column">
{items.map(item => (
<text key={item.id}>{item.name}</text>
))}
</box>
)
})
```
### State Updates During Render
Don't update state during render:
```tsx
// WRONG
function Component({ value }: { value: number }) {
const [count, setCount] = useState(0)
// This causes infinite loop!
if (value > 10) {
setCount(value)
}
return <text>{count}</text>
}
// CORRECT
function Component({ value }: { value: number }) {
const [count, setCount] = useState(0)
useEffect(() => {
if (value > 10) {
setCount(value)
}
}, [value])
return <text>{count}</text>
}
```
## Debugging
### Console Not Visible
OpenTUI captures console output. Show the overlay:
```tsx
import { useRenderer } from "@opentui/react"
import { useEffect } from "react"
function App() {
const renderer = useRenderer()
useEffect(() => {
renderer.console.show()
console.log("Now you can see this!")
}, [renderer])
return <box>{/* ... */}</box>
}
```
### Component Not Rendering
Check if component is in the tree:
```tsx
// WRONG - Conditional returns nothing
function MaybeComponent({ show }: { show: boolean }) {
if (!show) return // Returns undefined!
return <text>Visible</text>
}
// CORRECT
function MaybeComponent({ show }: { show: boolean }) {
if (!show) return null // Explicit null
return <text>Visible</text>
}
```
### Events Not Firing
Check event handler names:
```tsx
// WRONG
<box onClick={() => {}}>Click</box> // No onClick in TUI
// CORRECT
<box onMouseDown={() => {}}>Click</box>
<box onMouseUp={() => {}}>Click</box>
```
## Runtime Issues
### Use Bun, Not Node
```bash
# WRONG
node src/index.tsx
npm run start
# CORRECT
bun run src/index.tsx
bun run start
```
### Async Top-level
Bun supports top-level await, but be careful:
```tsx
// index.tsx - This works in Bun
const renderer = await createCliRenderer()
createRoot(renderer).render(<App />)
// If you need to handle errors
try {
const renderer = await createCliRenderer()
createRoot(renderer).render(<App />)
} catch (error) {
console.error("Failed to initialize:", error)
process.exit(1)
}
```
## Common Error Messages
### "Cannot read properties of undefined (reading 'root')"
Renderer not initialized:
```tsx
// WRONG
const renderer = createCliRenderer() // Missing await!
createRoot(renderer).render(<App />)
// CORRECT
const renderer = await createCliRenderer()
createRoot(renderer).render(<App />)
```
### "Invalid hook call"
Hooks called outside component:
```tsx
// WRONG
const dimensions = useTerminalDimensions() // Outside component!
function App() {
return <text>{dimensions.width}</text>
}
// CORRECT
function App() {
const dimensions = useTerminalDimensions()
return <text>{dimensions.width}</text>
}
```
@@ -0,0 +1,501 @@
# React Patterns
## State Management
### Local State with useState
```tsx
import { useState } from "react"
function Counter() {
const [count, setCount] = useState(0)
return (
<box flexDirection="row" gap={2}>
<text>Count: {count}</text>
<box border onMouseDown={() => setCount(c => c - 1)}>
<text>-</text>
</box>
<box border onMouseDown={() => setCount(c => c + 1)}>
<text>+</text>
</box>
</box>
)
}
```
### Complex State with useReducer
```tsx
import { useReducer } from "react"
type State = {
items: string[]
selectedIndex: number
}
type Action =
| { type: "ADD_ITEM"; item: string }
| { type: "REMOVE_ITEM"; index: number }
| { type: "SELECT"; index: number }
function reducer(state: State, action: Action): State {
switch (action.type) {
case "ADD_ITEM":
return { ...state, items: [...state.items, action.item] }
case "REMOVE_ITEM":
return {
...state,
items: state.items.filter((_, i) => i !== action.index),
}
case "SELECT":
return { ...state, selectedIndex: action.index }
}
}
function ItemList() {
const [state, dispatch] = useReducer(reducer, {
items: [],
selectedIndex: 0,
})
// Use state and dispatch...
}
```
### Context for Global State
```tsx
import { createContext, useContext, useState, ReactNode } from "react"
type Theme = "dark" | "light"
const ThemeContext = createContext<{
theme: Theme
setTheme: (theme: Theme) => void
} | null>(null)
function ThemeProvider({ children }: { children: ReactNode }) {
const [theme, setTheme] = useState<Theme>("dark")
return (
<ThemeContext.Provider value={{ theme, setTheme }}>
{children}
</ThemeContext.Provider>
)
}
function useTheme() {
const context = useContext(ThemeContext)
if (!context) throw new Error("useTheme must be used within ThemeProvider")
return context
}
// Usage
function App() {
return (
<ThemeProvider>
<ThemedBox />
</ThemeProvider>
)
}
function ThemedBox() {
const { theme } = useTheme()
return (
<box backgroundColor={theme === "dark" ? "#1a1a2e" : "#f0f0f0"}>
<text fg={theme === "dark" ? "#fff" : "#000"}>
Current theme: {theme}
</text>
</box>
)
}
```
## Focus Management
### Focus State
```tsx
import { useState } from "react"
import { useKeyboard } from "@opentui/react"
function FocusableForm() {
const [focusIndex, setFocusIndex] = useState(0)
const fields = ["name", "email", "message"]
useKeyboard((key) => {
if (key.name === "tab") {
setFocusIndex(i => (i + 1) % fields.length)
}
if (key.shift && key.name === "tab") {
setFocusIndex(i => (i - 1 + fields.length) % fields.length)
}
})
return (
<box flexDirection="column" gap={1}>
{fields.map((field, i) => (
<input
key={field}
placeholder={`Enter ${field}...`}
focused={i === focusIndex}
/>
))}
</box>
)
}
```
### Ref-based Focus
```tsx
import { useRef, useEffect } from "react"
function AutoFocusInput() {
const inputRef = useRef<any>(null)
useEffect(() => {
// Focus on mount
inputRef.current?.focus()
}, [])
return <input ref={inputRef} placeholder="Auto-focused" />
}
```
## Keyboard Navigation
### Global Shortcuts
```tsx
import { useKeyboard, useRenderer } from "@opentui/react"
function App() {
const renderer = useRenderer()
useKeyboard((key) => {
// Quit on Escape or Ctrl+C - use renderer.destroy(), never process.exit()
if (key.name === "escape" || (key.ctrl && key.name === "c")) {
renderer.destroy()
return
}
// Toggle help on ?
if (key.name === "?" || (key.shift && key.name === "/")) {
setShowHelp(h => !h)
}
// Vim-style navigation
if (key.name === "j") moveDown()
if (key.name === "k") moveUp()
})
return <box>{/* ... */}</box>
}
```
### Component-level Shortcuts
```tsx
function Editor() {
const [mode, setMode] = useState<"normal" | "insert">("normal")
useKeyboard((key) => {
if (mode === "normal") {
if (key.name === "i") setMode("insert")
if (key.name === "escape") setMode("normal")
} else {
if (key.name === "escape") setMode("normal")
// Handle text input in insert mode
}
})
return (
<box>
<text>Mode: {mode}</text>
<textarea focused={mode === "insert"} />
</box>
)
}
```
## Form Handling
### Controlled Inputs
```tsx
import { useState } from "react"
function LoginForm() {
const [username, setUsername] = useState("")
const [password, setPassword] = useState("")
const handleSubmit = () => {
console.log("Login:", { username, password })
}
return (
<box flexDirection="column" gap={1} padding={2} border>
<text>Login</text>
<box flexDirection="row" gap={1}>
<text>Username:</text>
<input
value={username}
onChange={setUsername}
width={20}
/>
</box>
<box flexDirection="row" gap={1}>
<text>Password:</text>
<input
value={password}
onChange={setPassword}
width={20}
/>
</box>
<box border onMouseDown={handleSubmit}>
<text>Submit</text>
</box>
</box>
)
}
```
### Form Validation
```tsx
function ValidatedForm() {
const [email, setEmail] = useState("")
const [error, setError] = useState("")
const validateEmail = (value: string) => {
if (!value.includes("@")) {
setError("Invalid email address")
} else {
setError("")
}
setEmail(value)
}
return (
<box flexDirection="column" gap={1}>
<input
value={email}
onChange={validateEmail}
placeholder="Email"
/>
{error && <text fg="red">{error}</text>}
</box>
)
}
```
## Responsive Design
### Terminal-size Responsive
```tsx
import { useTerminalDimensions } from "@opentui/react"
function ResponsiveLayout() {
const { width } = useTerminalDimensions()
// Stack vertically on narrow terminals
const isNarrow = width < 80
return (
<box flexDirection={isNarrow ? "column" : "row"}>
<box flexGrow={isNarrow ? 0 : 1} height={isNarrow ? 10 : "100%"}>
<text>Sidebar</text>
</box>
<box flexGrow={1}>
<text>Main Content</text>
</box>
</box>
)
}
```
### Dynamic Layouts
```tsx
function DynamicGrid({ items }: { items: string[] }) {
const { width } = useTerminalDimensions()
const columns = Math.max(1, Math.floor(width / 20))
return (
<box flexDirection="row" flexWrap="wrap">
{items.map((item, i) => (
<box key={i} width={`${100 / columns}%`} padding={1}>
<text>{item}</text>
</box>
))}
</box>
)
}
```
## Async Data Loading
### Loading States
```tsx
import { useState, useEffect } from "react"
function DataDisplay() {
const [data, setData] = useState<string[] | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
async function load() {
try {
const response = await fetch("https://api.example.com/data")
const json = await response.json()
setData(json.items)
} catch (e) {
setError(e instanceof Error ? e.message : "Unknown error")
} finally {
setLoading(false)
}
}
load()
}, [])
if (loading) {
return <text>Loading...</text>
}
if (error) {
return <text fg="red">Error: {error}</text>
}
return (
<box flexDirection="column">
{data?.map((item, i) => (
<text key={i}>{item}</text>
))}
</box>
)
}
```
## Animation Patterns
### Simple Animations
```tsx
import { useState, useEffect } from "react"
import { useTimeline } from "@opentui/react"
function ProgressBar() {
const [progress, setProgress] = useState(0)
const timeline = useTimeline({ duration: 3000 })
useEffect(() => {
timeline.add(
{ value: 0 },
{
value: 100,
duration: 3000,
ease: "linear",
onUpdate: (anim) => {
setProgress(Math.round(anim.targets[0].value))
},
}
)
}, [])
return (
<box flexDirection="column" gap={1}>
<text>Progress: {progress}%</text>
<box width={50} height={1} backgroundColor="#333">
<box
width={`${progress}%`}
height={1}
backgroundColor="#00ff00"
/>
</box>
</box>
)
}
```
### Interval-based Updates
```tsx
function Clock() {
const [time, setTime] = useState(new Date())
useEffect(() => {
const interval = setInterval(() => {
setTime(new Date())
}, 1000)
return () => clearInterval(interval)
}, [])
return <text>{time.toLocaleTimeString()}</text>
}
```
## Component Composition
### Render Props
```tsx
function Focusable({
children
}: {
children: (focused: boolean) => React.ReactNode
}) {
const [focused, setFocused] = useState(false)
return (
<box
onMouseDown={() => setFocused(true)}
onMouseUp={() => setFocused(false)}
>
{children(focused)}
</box>
)
}
// Usage
<Focusable>
{(focused) => (
<text fg={focused ? "#00ff00" : "#ffffff"}>
{focused ? "Focused!" : "Click me"}
</text>
)}
</Focusable>
```
### Higher-Order Components
```tsx
function withBorder<P extends object>(
Component: React.ComponentType<P>,
borderStyle: string = "single"
) {
return function BorderedComponent(props: P) {
return (
<box border borderStyle={borderStyle} padding={1}>
<Component {...props} />
</box>
)
}
}
// Usage
const BorderedText = withBorder(({ content }: { content: string }) => (
<text>{content}</text>
))
<BorderedText content="Hello!" />
```
@@ -0,0 +1,201 @@
# OpenTUI Solid (@opentui/solid)
A SolidJS reconciler for building terminal user interfaces with fine-grained reactivity. Get optimal performance with Solid's signal-based approach.
## Overview
OpenTUI Solid provides:
- **Custom reconciler**: Solid components render to OpenTUI renderables
- **JSX intrinsics**: `<text>`, `<box>`, `<input>`, etc.
- **Hooks**: `useKeyboard`, `useRenderer`, `useTimeline`, etc.
- **Fine-grained reactivity**: Only what changes re-renders
- **Portal & Dynamic**: Advanced composition primitives
## When to Use Solid
Use the Solid reconciler when:
- You want optimal re-rendering performance
- You prefer signal-based reactivity
- You need fine-grained control over updates
- Building performance-critical applications
- You already know SolidJS
## When NOT to Use Solid
| Scenario | Use Instead |
|----------|-------------|
| Team knows React, not Solid | `@opentui/react` |
| Maximum control needed | `@opentui/core` |
| Smallest bundle size | `@opentui/core` |
| Building a framework/library | `@opentui/core` |
## Quick Start
```bash
bunx create-tui@latest -t solid my-app
cd my-app && bun install
```
The CLI creates the `my-app` directory for you - it must **not already exist**.
Options: `--no-git` (skip git init), `--no-install` (skip bun install)
**Agent guidance**: Always use autonomous mode with `-t <template>` flag. Never use interactive mode (`bunx create-tui@latest my-app` without `-t`) as it requires user prompts that agents cannot respond to.
Or manually:
```bash
bun install @opentui/solid @opentui/core solid-js
```
```tsx
import { render } from "@opentui/solid"
import { createSignal } from "solid-js"
function App() {
const [count, setCount] = createSignal(0)
return (
<box border padding={2}>
<text>Count: {count()}</text>
<box
border
onMouseDown={() => setCount(c => c + 1)}
>
<text>Click me!</text>
</box>
</box>
)
}
render(() => <App />)
```
## Core Concepts
### Signals
Solid uses signals for reactive state:
```tsx
import { createSignal, createEffect } from "solid-js"
function Counter() {
const [count, setCount] = createSignal(0)
// Effect runs when count changes
createEffect(() => {
console.log("Count is now:", count())
})
return <text>Count: {count()}</text>
}
```
### JSX Elements
Solid maps JSX intrinsic elements to OpenTUI renderables:
```tsx
// Note: Some use underscores (Solid convention)
<text>Hello</text> // TextRenderable
<box border>Content</box> // BoxRenderable
<input placeholder="..." /> // InputRenderable
<select options={[...]} /> // SelectRenderable
<tab_select /> // TabSelectRenderable (underscore!)
<ascii_font /> // ASCIIFontRenderable (underscore!)
<line_number /> // LineNumberRenderable (underscore!)
```
### Text Modifiers
Inside `<text>`, use modifier elements:
```tsx
<text>
<strong>Bold</strong>, <em>italic</em>, and <u>underlined</u>
<span fg="red">Colored text</span>
<br />
New line with <a href="https://example.com">link</a>
</text>
```
## Available Components
### Layout & Display
- `<text>` - Styled text content
- `<box>` - Container with borders and layout
- `<scrollbox>` - Scrollable container
- `<ascii_font>` - ASCII art text (note underscore)
### Input
- `<input>` - Single-line text input
- `<textarea>` - Multi-line text input
- `<select>` - List selection
- `<tab_select>` - Tab-based selection (note underscore)
### Code & Diff
- `<code>` - Syntax-highlighted code
- `<line_number>` - Code with line numbers (note underscore)
- `<diff>` - Unified or split diff viewer
### Text Modifiers (inside `<text>`)
- `<span>` - Inline styled text
- `<strong>`, `<b>` - Bold
- `<em>`, `<i>` - Italic
- `<u>` - Underline
- `<br>` - Line break
- `<a>` - Link
## Special Components
### Portal
Render children to a different mount node:
```tsx
import { Portal } from "@opentui/solid"
function Overlay() {
return (
<Portal mount={renderer.root}>
<box position="absolute" left={10} top={5} border>
<text>Overlay content</text>
</box>
</Portal>
)
}
```
### Dynamic
Render components dynamically:
```tsx
import { Dynamic } from "@opentui/solid"
function DynamicInput(props: { multiline: boolean }) {
return (
<Dynamic
component={props.multiline ? "textarea" : "input"}
placeholder="Enter text..."
/>
)
}
```
## In This Reference
- [Configuration](./configuration.md) - Project setup, tsconfig, bunfig, building
- [API](./api.md) - Components, hooks, render function
- [Patterns](./patterns.md) - Signals, stores, control flow, composition
- [Gotchas](./gotchas.md) - Common issues, debugging, limitations
## See Also
- [Core](../core/REFERENCE.md) - Underlying imperative API
- [React](../react/REFERENCE.md) - Alternative declarative approach
- [Components](../components/REFERENCE.md) - Component reference by category
- [Layout](../layout/REFERENCE.md) - Flexbox layout system
- [Keyboard](../keyboard/REFERENCE.md) - Input handling and shortcuts
- [Testing](../testing/REFERENCE.md) - Test renderer and snapshots
@@ -0,0 +1,564 @@
# Solid API Reference
## Rendering
### render(node, rendererOrConfig?)
Renders a Solid component tree into a CLI renderer.
```tsx
import { render } from "@opentui/solid"
// Simple usage - creates renderer automatically
render(() => <App />)
// With config
render(() => <App />, {
exitOnCtrlC: false,
targetFPS: 60,
})
// With existing renderer
import { createCliRenderer } from "@opentui/core"
const renderer = await createCliRenderer()
render(() => <App />, renderer)
```
### testRender(node, options?)
Create a test renderer for snapshots and tests.
```tsx
import { testRender } from "@opentui/solid"
const testSetup = await testRender(() => <App />, {
width: 40,
height: 10,
})
// Access test utilities
testSetup.snapshot() // Get current render
testSetup.renderer // Access renderer
```
### extend(components)
Register custom renderables as JSX intrinsic elements.
```tsx
import { extend } from "@opentui/solid"
import { CustomRenderable } from "./custom"
extend({
custom: CustomRenderable,
})
// Now usable in JSX
<custom prop="value" />
```
### getComponentCatalogue()
Returns the current component catalogue.
```tsx
import { getComponentCatalogue } from "@opentui/solid"
const catalogue = getComponentCatalogue()
console.log(Object.keys(catalogue))
```
## Hooks
### useRenderer()
Access the OpenTUI renderer instance.
```tsx
import { useRenderer } from "@opentui/solid"
import { onMount } from "solid-js"
function App() {
const renderer = useRenderer()
onMount(() => {
console.log(`Terminal: ${renderer.width}x${renderer.height}`)
renderer.console.show()
// Access theme mode (dark/light based on terminal settings)
console.log(`Theme: ${renderer.themeMode}`) // "dark" | "light" | null
})
return <text>Hello</text>
}
// Listen for theme mode changes
function ThemedApp() {
const renderer = useRenderer()
const [theme, setTheme] = createSignal(renderer.themeMode ?? "dark")
onMount(() => {
renderer.on("theme_mode", (mode: "dark" | "light") => setTheme(mode))
})
return (
<box backgroundColor={theme() === "dark" ? "#1a1a2e" : "#ffffff"}>
<text fg={theme() === "dark" ? "#fff" : "#000"}>
Current theme: {theme()}
</text>
</box>
)
}
```
### useKeyboard(handler, options?)
Handle keyboard events.
```tsx
import { useKeyboard, useRenderer } from "@opentui/solid"
function App() {
const renderer = useRenderer()
useKeyboard((key) => {
if (key.name === "escape") {
renderer.destroy() // Never use process.exit() directly!
}
if (key.ctrl && key.name === "s") {
saveDocument()
}
})
return <text>Press ESC to exit</text>
}
// With release events
function GameControls() {
const [pressed, setPressed] = createSignal(new Set<string>())
useKeyboard(
(event) => {
setPressed(keys => {
const newKeys = new Set(keys)
if (event.eventType === "release") {
newKeys.delete(event.name)
} else {
newKeys.add(event.name)
}
return newKeys
})
},
{ release: true }
)
return <text>Pressed: {Array.from(pressed()).join(", ")}</text>
}
```
### usePaste(handler)
Handle paste events. Receives a `PasteEvent` with raw bytes.
```tsx
import { usePaste } from "@opentui/solid"
import { decodePasteBytes } from "@opentui/core"
function PasteHandler() {
usePaste((event) => {
const text = decodePasteBytes(event.bytes)
console.log("Pasted:", text)
})
return <text>Paste something</text>
}
```
### onResize(callback)
Handle terminal resize events.
```tsx
import { onResize } from "@opentui/solid"
function App() {
onResize((width, height) => {
console.log(`Resized to ${width}x${height}`)
})
return <text>Resize the terminal</text>
}
```
### useTerminalDimensions()
Get reactive terminal dimensions.
```tsx
import { useTerminalDimensions } from "@opentui/solid"
function ResponsiveLayout() {
const dimensions = useTerminalDimensions()
return (
<box flexDirection={dimensions().width > 80 ? "row" : "column"}>
<text>Width: {dimensions().width}</text>
<text>Height: {dimensions().height}</text>
</box>
)
}
```
### onFocus(callback) / onBlur(callback)
Handle terminal window focus and blur events. Solid-only hooks.
```tsx
import { onFocus, onBlur } from "@opentui/solid"
function App() {
onFocus(() => {
console.log("Terminal window gained focus")
})
onBlur(() => {
console.log("Terminal window lost focus")
})
return <text>Focus/blur tracking</text>
}
```
These hooks fire when the terminal emulator window gains or loses operating system focus. The renderer deduplicates events (won't re-emit the same focus state).
### useSelectionHandler(handler)
Handle text selection events. Fires when the user finishes a mouse selection (mouse-up). Solid-only hook - React does not have this.
```tsx
import { useSelectionHandler } from "@opentui/solid"
import type { Selection } from "@opentui/core"
function SelectableText() {
const [selected, setSelected] = createSignal("")
const renderer = useRenderer()
useSelectionHandler((selection: Selection) => {
const text = selection.getSelectedText()
if (text) {
setSelected(text)
renderer.copyToClipboardOSC52(text)
}
})
return (
<box flexDirection="column">
<text selectable>Select this text with your mouse</text>
<text fg="#888">Selected: {selected()}</text>
</box>
)
}
```
The `Selection` object aggregates selected text from all selectable renderables in the tree. See `keyboard/REFERENCE.md` (selection) for full details on the selection API and traversal model.
### useTimeline(options?)
Create animations with the timeline system.
```tsx
import { useTimeline } from "@opentui/solid"
import { createSignal, onMount } from "solid-js"
function AnimatedBox() {
const [width, setWidth] = createSignal(0)
const timeline = useTimeline({
duration: 2000,
loop: false,
})
onMount(() => {
timeline.add(
{ width: 0 },
{
width: 50,
duration: 2000,
ease: "easeOutQuad",
onUpdate: (anim) => {
setWidth(Math.round(anim.targets[0].width))
},
}
)
})
return <box style={{ width: width(), height: 3, backgroundColor: "#6a5acd" }} />
}
```
## Components
### Text Component
```tsx
<text
content="Hello" // Or use children
fg="#FFFFFF" // Foreground color
bg="#000000" // Background color
selectable={true} // Allow text selection
>
{/* Use nested modifier tags for styling */}
<span fg="red">Red</span>
<strong>Bold</strong>
<em>Italic</em>
<u>Underline</u>
<br />
<a href="https://...">Link</a>
</text>
```
> **Note**: Do NOT use `bold`, `italic`, `underline` as props on `<text>`. Use nested modifier tags like `<strong>`, `<em>`, `<u>` instead.
### Box Component
```tsx
<box
// Borders
border // Enable border
borderStyle="single" // single | double | rounded | bold
borderColor="#FFFFFF"
title="Title"
titleAlignment="center" // left | center | right
// Colors
backgroundColor="#1a1a2e"
// Layout
flexDirection="row"
justifyContent="center"
alignItems="center"
gap={2}
// Spacing
padding={2}
paddingX={2} // Horizontal (left + right)
paddingY={1} // Vertical (top + bottom)
margin={1}
marginX={2} // Horizontal (left + right)
marginY={1} // Vertical (top + bottom)
// Dimensions
width={40}
height={10}
flexGrow={1}
// Focus
focusable // Allow box to receive focus
focused={isFocused()} // Controlled focus state
// Events
onMouseDown={(e) => {}}
onMouseUp={(e) => {}}
>
{children}
</box>
```
### Scrollbox Component
```tsx
<scrollbox
focused // Enable keyboard scrolling
style={{
scrollbarOptions: {
showArrows: true,
trackOptions: {
foregroundColor: "#7aa2f7",
backgroundColor: "#414868",
},
},
}}
>
<For each={items()}>
{(item) => <text>{item}</text>}
</For>
</scrollbox>
```
### Input Component
```tsx
<input
value={value()}
onInput={(newValue) => setValue(newValue)}
placeholder="Enter text..."
focused
width={30}
/>
```
### Textarea Component
```tsx
<textarea
value={text()}
onInput={(newValue) => setText(newValue)}
placeholder="Enter multiple lines..."
focused
width={40}
height={10}
/>
```
### Select Component
```tsx
<select
options={[
{ name: "Option 1", description: "First", value: "1" },
{ name: "Option 2", description: "Second", value: "2" },
]}
onChange={(index, option) => setSelected(option)}
selectedIndex={0}
focused
/>
```
### Tab Select Component (Note: underscore)
```tsx
<tab_select
options={[
{ name: "Home", description: "Dashboard" },
{ name: "Settings", description: "Configuration" },
]}
onChange={(index, option) => setTab(option)}
tabWidth={20}
focused
/>
```
### ASCII Font Component (Note: underscore)
```tsx
<ascii_font
text="TITLE"
font="tiny" // tiny | block | slick | shade
color="#FFFFFF"
/>
```
### Code Component
```tsx
<code
code={sourceCode}
language="typescript"
/>
```
### Line Number Component (Note: underscore)
```tsx
<line_number
code={sourceCode}
language="typescript"
startLine={1}
highlightedLines={[5]}
/>
```
### Diff Component
```tsx
<diff
oldCode={originalCode}
newCode={modifiedCode}
language="typescript"
mode="unified" // unified | split
syncScroll // Sync scroll between split view panes
/>
```
## Control Flow
Solid's control flow components work with OpenTUI:
### For
```tsx
import { For } from "solid-js"
<For each={items()}>
{(item, index) => (
<box key={index()}>
<text>{item.name}</text>
</box>
)}
</For>
```
### Show
```tsx
import { Show } from "solid-js"
<Show when={isVisible()} fallback={<text>Hidden</text>}>
<text>Visible content</text>
</Show>
```
### Switch/Match
```tsx
import { Switch, Match } from "solid-js"
<Switch>
<Match when={status() === "loading"}>
<text>Loading...</text>
</Match>
<Match when={status() === "error"}>
<text fg="red">Error!</text>
</Match>
<Match when={status() === "success"}>
<text fg="green">Success!</text>
</Match>
</Switch>
```
### Index
```tsx
import { Index } from "solid-js"
<Index each={items()}>
{(item, index) => (
<text>{index}: {item().name}</text>
)}
</Index>
```
## Special Components
### Portal
```tsx
import { Portal } from "@opentui/solid"
<Portal mount={targetNode}>
<box>Portal content</box>
</Portal>
```
### Dynamic
```tsx
import { Dynamic } from "@opentui/solid"
<Dynamic
component={isMultiline() ? "textarea" : "input"}
placeholder="Enter text..."
focused
/>
```
@@ -0,0 +1,316 @@
# Solid Configuration
## Project Setup
### Quick Start
```bash
bunx create-tui@latest -t solid my-app
cd my-app && bun install
```
The CLI creates the `my-app` directory for you - it must **not already exist**.
Options: `--no-git` (skip git init), `--no-install` (skip bun install)
### Manual Setup
```bash
mkdir my-tui && cd my-tui
bun init
bun install @opentui/solid @opentui/core solid-js
```
## TypeScript Configuration
### tsconfig.json
```json
{
"compilerOptions": {
"lib": ["ESNext"],
"target": "ESNext",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"jsx": "preserve",
"jsxImportSource": "@opentui/solid",
"strict": true,
"skipLibCheck": true,
"noEmit": true,
"types": ["bun-types"]
},
"include": ["src/**/*"]
}
```
**Critical settings:**
- `jsx: "preserve"` - Let Solid's compiler handle JSX
- `jsxImportSource: "@opentui/solid"` - Import JSX runtime from OpenTUI Solid
- `module` / `moduleResolution: "NodeNext"` - Recommended for OpenTUI compatibility
## Bun Configuration
### bunfig.toml
**Required** for the Solid compiler:
```toml
preload = ["@opentui/solid/preload"]
```
This loads the Solid JSX transform before your code runs.
## Package Configuration
### package.json
```json
{
"name": "my-tui-app",
"type": "module",
"scripts": {
"start": "bun run src/index.tsx",
"dev": "bun --watch run src/index.tsx",
"test": "bun test",
"build": "bun run build.ts"
},
"dependencies": {
"@opentui/core": "latest",
"@opentui/solid": "latest",
"solid-js": "latest"
},
"devDependencies": {
"@types/bun": "latest",
"typescript": "latest"
}
}
```
## Project Structure
Recommended structure:
```
my-tui-app/
├── src/
│ ├── components/
│ │ ├── Header.tsx
│ │ ├── Sidebar.tsx
│ │ └── MainContent.tsx
│ ├── stores/
│ │ └── appStore.ts
│ ├── App.tsx
│ └── index.tsx
├── bunfig.toml # Required!
├── package.json
└── tsconfig.json
```
### Entry Point (src/index.tsx)
```tsx
import { render } from "@opentui/solid"
import { App } from "./App"
render(() => <App />)
```
### App Component (src/App.tsx)
```tsx
import { Header } from "./components/Header"
import { Sidebar } from "./components/Sidebar"
import { MainContent } from "./components/MainContent"
export function App() {
return (
<box flexDirection="column" width="100%" height="100%">
<Header />
<box flexDirection="row" flexGrow={1}>
<Sidebar />
<MainContent />
</box>
</box>
)
}
```
## Renderer Configuration
### render() Options
```tsx
import { render } from "@opentui/solid"
import { ConsolePosition } from "@opentui/core"
render(() => <App />, {
// Rendering
targetFPS: 60,
// Behavior
exitOnCtrlC: true,
autoFocus: true, // Auto-focus elements on click (default: true)
useMouse: true, // Enable mouse support (default: true)
// Debug console
consoleOptions: {
position: ConsolePosition.BOTTOM,
sizePercent: 30,
startInDebugMode: false,
},
// Cleanup
onDestroy: () => {
// Cleanup code
},
})
```
### Using Existing Renderer
```tsx
import { render } from "@opentui/solid"
import { createCliRenderer } from "@opentui/core"
const renderer = await createCliRenderer({
exitOnCtrlC: false,
})
render(() => <App />, renderer)
```
## Building for Distribution
### Build Script (build.ts)
```typescript
import solidPlugin from "@opentui/solid/bun-plugin"
await Bun.build({
entrypoints: ["./src/index.tsx"],
outdir: "./dist",
target: "bun",
minify: true,
plugins: [solidPlugin],
})
console.log("Build complete!")
```
Run: `bun run build.ts`
### Creating Executables
```typescript
import solidPlugin from "@opentui/solid/bun-plugin"
await Bun.build({
entrypoints: ["./src/index.tsx"],
target: "bun",
plugins: [solidPlugin],
compile: {
target: "bun-darwin-arm64", // or bun-linux-x64, etc.
outfile: "my-app",
},
})
```
**Available targets:**
- `bun-darwin-arm64` - macOS Apple Silicon
- `bun-darwin-x64` - macOS Intel
- `bun-linux-x64` - Linux x64
- `bun-linux-arm64` - Linux ARM64
- `bun-windows-x64` - Windows x64
## Environment Variables
Create `.env` for development:
```env
# Debug settings
OTUI_SHOW_STATS=false
SHOW_CONSOLE=false
# App settings
API_URL=https://api.example.com
```
Bun auto-loads `.env` files:
```tsx
const apiUrl = process.env.API_URL
```
## Testing Configuration
### Test Setup
```typescript
// src/test-utils.tsx
import { testRender } from "@opentui/solid"
export async function renderForTest(
Component: () => JSX.Element,
options = { width: 80, height: 24 }
) {
return await testRender(Component, options)
}
```
### Test Example
```typescript
// src/components/Counter.test.tsx
import { test, expect } from "bun:test"
import { renderForTest } from "../test-utils"
import { Counter } from "./Counter"
test("Counter renders initial value", async () => {
const { snapshot } = await renderForTest(() => <Counter initialValue={5} />)
expect(snapshot()).toContain("Count: 5")
})
```
## Common Configuration Issues
### Missing bunfig.toml
**Symptom**: JSX not transformed, syntax errors
**Fix**: Create `bunfig.toml` with preload:
```toml
preload = ["@opentui/solid/preload"]
```
### Wrong JSX Settings
**Symptom**: JSX compiles to React calls
**Fix**: Ensure tsconfig has:
```json
{
"compilerOptions": {
"jsx": "preserve",
"jsxImportSource": "@opentui/solid"
}
}
```
### Build Missing Plugin
**Symptom**: Built output has untransformed JSX
**Fix**: Add Solid plugin to build:
```typescript
import solidPlugin from "@opentui/solid/bun-plugin"
await Bun.build({
// ...
plugins: [solidPlugin],
})
```
@@ -0,0 +1,427 @@
# Solid Gotchas
## Critical
### Never use `process.exit()` directly
**This is the most common mistake.** Using `process.exit()` leaves the terminal in a broken state (cursor hidden, raw mode, alternate screen).
```tsx
// WRONG - Terminal left in broken state
process.exit(0)
// CORRECT - Use renderer.destroy()
import { useRenderer } from "@opentui/solid"
function App() {
const renderer = useRenderer()
const handleExit = () => {
renderer.destroy() // Cleans up and exits properly
}
}
```
`renderer.destroy()` restores the terminal (exits alternate screen, restores cursor, etc.) before exiting.
## Configuration Issues
### Missing bunfig.toml
**Symptom**: JSX syntax errors, components not rendering
```
SyntaxError: Unexpected token '<'
```
**Fix**: Create `bunfig.toml` in project root:
```toml
preload = ["@opentui/solid/preload"]
```
### Wrong JSX Settings
**Symptom**: JSX compiles to React, errors about React not found
**Fix**: Ensure tsconfig.json has:
```json
{
"compilerOptions": {
"jsx": "preserve",
"jsxImportSource": "@opentui/solid"
}
}
```
### Build Without Plugin
**Symptom**: Built bundle has raw JSX
**Fix**: Add Solid plugin to build:
```typescript
import solidPlugin from "@opentui/solid/bun-plugin"
await Bun.build({
// ...
plugins: [solidPlugin],
})
```
## Reactivity Issues
### Accessing Signals Without Calling
**Symptom**: Value never updates, shows `[Function]`
```tsx
// WRONG - Missing ()
const [count, setCount] = createSignal(0)
<text>Count: {count}</text> // Shows [Function]
// CORRECT
<text>Count: {count()}</text>
```
### Breaking Reactivity with Destructuring
**Symptom**: Props stop being reactive
```tsx
// WRONG - Breaks reactivity
function Component(props: { value: number }) {
const { value } = props // Destructured once, never updates!
return <text>{value}</text>
}
// CORRECT - Keep props reactive
function Component(props: { value: number }) {
return <text>{props.value}</text>
}
// OR use splitProps
function Component(props: { value: number; other: string }) {
const [local, rest] = splitProps(props, ["value"])
return <text>{local.value}</text>
}
```
### Effects Not Running
**Symptom**: createEffect doesn't trigger
```tsx
// WRONG - Signal not accessed in effect
const [count, setCount] = createSignal(0)
createEffect(() => {
console.log("Count changed") // Never runs after initial!
})
// CORRECT - Access the signal
createEffect(() => {
console.log("Count:", count()) // Runs when count changes
})
```
## HTML Entity Decoding
Solid's reconciler automatically decodes HTML entities in JSX text content. This means `&lt;`, `&gt;`, `&amp;`, etc. render as their literal characters:
```tsx
// These render correctly in Solid
<text>Use &lt;box&gt; for containers</text> // Displays: Use <box> for containers
<text>A &amp; B</text> // Displays: A & B
```
This applies to text nodes, the `content` prop, and the `text` prop.
## Component Naming
### Underscore vs Hyphen
Solid uses underscores for multi-word component names:
```tsx
// WRONG - React-style naming
<tab-select /> // Error!
<ascii-font /> // Error!
<line-number /> // Error!
// CORRECT - Solid naming
<tab_select />
<ascii_font />
<line_number />
```
**Component mapping:**
| Concept | React | Solid |
|---------|-------|-------|
| Tab Select | `<tab-select>` | `<tab_select>` |
| ASCII Font | `<ascii-font>` | `<ascii_font>` |
| Line Number | `<line-number>` | `<line_number>` |
## Focus Issues
### Focus Not Working
Components need explicit focus:
```tsx
// WRONG
<input placeholder="Type here..." />
// CORRECT
<input placeholder="Type here..." focused />
```
### Select Not Responding
```tsx
// WRONG
<select options={["a", "b"]} />
// CORRECT
<select
options={[
{ name: "A", description: "Option A", value: "a" },
{ name: "B", description: "Option B", value: "b" },
]}
onSelect={(index, option) => {
// Called when Enter is pressed
console.log("Selected:", option.name)
}}
focused
/>
```
### Select Events Confusion
Remember: `onSelect` fires on Enter (selection confirmed), `onChange` fires on navigation:
```tsx
// WRONG - expecting onChange to fire on Enter
<select
options={options()}
onChange={(i, opt) => submitForm(opt)} // This fires on arrow keys!
/>
// CORRECT
<select
options={options()}
onSelect={(i, opt) => submitForm(opt)} // Enter pressed - submit
onChange={(i, opt) => showPreview(opt)} // Arrow keys - preview
/>
```
## Control Flow Issues
### For vs Index
Use `For` for arrays of objects, `Index` for primitives:
```tsx
// For objects - item is reactive
<For each={objects()}>
{(obj) => <text>{obj.name}</text>}
</For>
// For primitives - use Index, item() is reactive
<Index each={strings()}>
{(str, index) => <text>{index}: {str()}</text>}
</Index>
```
### Missing Fallback
Show requires fallback for proper rendering:
```tsx
// May cause issues
<Show when={data()}>
<Component />
</Show>
// Better - explicit fallback
<Show when={data()} fallback={<text>Loading...</text>}>
<Component />
</Show>
```
## Cleanup Issues
### Forgetting onCleanup
**Symptom**: Memory leaks, multiple intervals running
```tsx
// WRONG - Interval never cleared
function Timer() {
const [time, setTime] = createSignal(0)
setInterval(() => setTime(t => t + 1), 1000)
return <text>{time()}</text>
}
// CORRECT
function Timer() {
const [time, setTime] = createSignal(0)
const interval = setInterval(() => setTime(t => t + 1), 1000)
onCleanup(() => clearInterval(interval))
return <text>{time()}</text>
}
```
### Effect Cleanup
```tsx
createEffect(() => {
const subscription = subscribe(data())
// WRONG - No cleanup
// subscription stays active
// CORRECT
onCleanup(() => subscription.unsubscribe())
})
```
## Store Issues
### Mutating Store Directly
**Symptom**: Changes don't trigger updates
```tsx
const [state, setState] = createStore({ items: [] })
// WRONG - Direct mutation
state.items.push(newItem) // Won't trigger updates!
// CORRECT - Use setState
setState("items", items => [...items, newItem])
```
### Nested Updates
```tsx
const [state, setState] = createStore({
user: { profile: { name: "John" } }
})
// WRONG
state.user.profile.name = "Jane"
// CORRECT
setState("user", "profile", "name", "Jane")
```
## Debugging
### Console Not Visible
OpenTUI captures console output:
```tsx
import { useRenderer } from "@opentui/solid"
import { onMount } from "solid-js"
function App() {
const renderer = useRenderer()
onMount(() => {
renderer.console.show()
console.log("Now visible!")
})
return <box>{/* ... */}</box>
}
```
### Tracking Reactivity
Use `createEffect` to debug:
```tsx
createEffect(() => {
console.log("State:", {
count: count(),
items: items(),
})
})
```
## Runtime Issues
### Use Bun
```bash
# WRONG
node src/index.tsx
npm run start
# CORRECT
bun run src/index.tsx
bun run start
```
### Async render()
The render function is async when creating a renderer:
```tsx
// This is fine - Bun supports top-level await
render(() => <App />)
// If you need the renderer
import { createCliRenderer } from "@opentui/core"
import { render } from "@opentui/solid"
const renderer = await createCliRenderer()
render(() => <App />, renderer)
```
## Common Error Messages
### "Cannot read properties of undefined"
Usually a missing reactive access:
```tsx
// Check if signal is being called
<text>{count()}</text> // Note the ()
// Check if props are being accessed correctly
<text>{props.value}</text> // Not destructured
```
### "JSX element has no corresponding closing tag"
Check component naming:
```tsx
// Wrong
<tab-select></tab-select>
// Correct
<tab_select></tab_select>
```
### "store is not a function"
Stores aren't called like signals:
```tsx
const [store, setStore] = createStore({ count: 0 })
// WRONG
<text>{store().count}</text>
// CORRECT
<text>{store.count}</text>
```
@@ -0,0 +1,560 @@
# Solid Patterns
## Reactive State
### Signals
Basic reactive state with signals:
```tsx
import { createSignal } from "solid-js"
function Counter() {
const [count, setCount] = createSignal(0)
return (
<box flexDirection="row" gap={2}>
<text>Count: {count()}</text>
<box border onMouseDown={() => setCount(c => c - 1)}>
<text>-</text>
</box>
<box border onMouseDown={() => setCount(c => c + 1)}>
<text>+</text>
</box>
</box>
)
}
```
### Derived State
Compute values from signals:
```tsx
import { createSignal, createMemo } from "solid-js"
function PriceCalculator() {
const [quantity, setQuantity] = createSignal(1)
const [price, setPrice] = createSignal(9.99)
// Derived value - only recalculates when dependencies change
const total = createMemo(() => quantity() * price())
const formatted = createMemo(() => `$${total().toFixed(2)}`)
return (
<box flexDirection="column">
<text>Quantity: {quantity()}</text>
<text>Price: ${price()}</text>
<text>Total: {formatted()}</text>
</box>
)
}
```
### Effects
React to state changes:
```tsx
import { createSignal, createEffect, onCleanup } from "solid-js"
function AutoSave() {
const [content, setContent] = createSignal("")
createEffect(() => {
const text = content()
// Debounced save
const timeout = setTimeout(() => {
saveToFile(text)
}, 1000)
// Cleanup on next run or disposal
onCleanup(() => clearTimeout(timeout))
})
return (
<textarea
value={content()}
onInput={setContent}
placeholder="Auto-saves after 1 second..."
/>
)
}
```
## Stores
### createStore for Complex State
```tsx
import { createStore } from "solid-js/store"
interface AppState {
user: { name: string; email: string } | null
items: Array<{ id: number; name: string; done: boolean }>
settings: { theme: "dark" | "light" }
}
function App() {
const [state, setState] = createStore<AppState>({
user: null,
items: [],
settings: { theme: "dark" },
})
const addItem = (name: string) => {
setState("items", items => [
...items,
{ id: Date.now(), name, done: false }
])
}
const toggleItem = (id: number) => {
setState("items", item => item.id === id, "done", done => !done)
}
const setTheme = (theme: "dark" | "light") => {
setState("settings", "theme", theme)
}
return (
<box backgroundColor={state.settings.theme === "dark" ? "#1a1a2e" : "#f0f0f0"}>
<For each={state.items}>
{(item) => (
<text
fg={item.done ? "#888" : "#fff"}
onMouseDown={() => toggleItem(item.id)}
>
{item.done ? "[x]" : "[ ]"} {item.name}
</text>
)}
</For>
</box>
)
}
```
### Store with Context
Share state across components:
```tsx
import { createStore } from "solid-js/store"
import { createContext, useContext, ParentComponent } from "solid-js"
interface Store {
count: number
items: string[]
}
type StoreContextValue = [
Store,
{
increment: () => void
addItem: (item: string) => void
}
]
const StoreContext = createContext<StoreContextValue>()
const StoreProvider: ParentComponent = (props) => {
const [state, setState] = createStore<Store>({
count: 0,
items: [],
})
const actions = {
increment: () => setState("count", c => c + 1),
addItem: (item: string) => setState("items", i => [...i, item]),
}
return (
<StoreContext.Provider value={[state, actions]}>
{props.children}
</StoreContext.Provider>
)
}
function useStore() {
const context = useContext(StoreContext)
if (!context) throw new Error("useStore must be used within StoreProvider")
return context
}
// Usage
function Counter() {
const [state, { increment }] = useStore()
return (
<box onMouseDown={increment}>
<text>Count: {state.count}</text>
</box>
)
}
```
## Control Flow
### Conditional Rendering with Show
```tsx
import { Show, createSignal } from "solid-js"
function ToggleableContent() {
const [visible, setVisible] = createSignal(false)
return (
<box flexDirection="column">
<box border onMouseDown={() => setVisible(v => !v)}>
<text>Toggle</text>
</box>
<Show
when={visible()}
fallback={<text fg="#888">Content is hidden</text>}
>
<text fg="#0f0">Content is visible!</text>
</Show>
</box>
)
}
```
### Lists with For
```tsx
import { For, createSignal } from "solid-js"
function TodoList() {
const [todos, setTodos] = createSignal([
{ id: 1, text: "Learn Solid", done: false },
{ id: 2, text: "Build TUI", done: false },
])
const toggle = (id: number) => {
setTodos(todos =>
todos.map(t =>
t.id === id ? { ...t, done: !t.done } : t
)
)
}
return (
<box flexDirection="column">
<For each={todos()}>
{(todo) => (
<box onMouseDown={() => toggle(todo.id)}>
<text fg={todo.done ? "#888" : "#fff"}>
{todo.done ? "[x]" : "[ ]"} {todo.text}
</text>
</box>
)}
</For>
</box>
)
}
```
### Index for Primitive Arrays
Use `Index` when array items are primitives:
```tsx
import { Index, createSignal } from "solid-js"
function StringList() {
const [items, setItems] = createSignal(["apple", "banana", "cherry"])
return (
<box flexDirection="column">
<Index each={items()}>
{(item, index) => (
<text>{index}: {item()}</text>
)}
</Index>
</box>
)
}
```
### Switch/Match for Multiple Conditions
```tsx
import { Switch, Match, createSignal } from "solid-js"
type Status = "idle" | "loading" | "success" | "error"
function StatusDisplay() {
const [status, setStatus] = createSignal<Status>("idle")
return (
<Switch>
<Match when={status() === "idle"}>
<text>Ready</text>
</Match>
<Match when={status() === "loading"}>
<text fg="#ff0">Loading...</text>
</Match>
<Match when={status() === "success"}>
<text fg="#0f0">Success!</text>
</Match>
<Match when={status() === "error"}>
<text fg="#f00">Error occurred</text>
</Match>
</Switch>
)
}
```
## Focus Management
### Focus State
```tsx
import { createSignal } from "solid-js"
import { useKeyboard } from "@opentui/solid"
function FocusableForm() {
const [focusIndex, setFocusIndex] = createSignal(0)
const fields = ["name", "email", "message"]
useKeyboard((key) => {
if (key.name === "tab") {
setFocusIndex(i => (i + 1) % fields.length)
}
if (key.shift && key.name === "tab") {
setFocusIndex(i => (i - 1 + fields.length) % fields.length)
}
})
return (
<box flexDirection="column" gap={1}>
<Index each={fields}>
{(field, i) => (
<input
placeholder={`Enter ${field()}...`}
focused={i === focusIndex()}
/>
)}
</Index>
</box>
)
}
```
## Keyboard Navigation
### Global Shortcuts
```tsx
import { useKeyboard } from "@opentui/solid"
function App() {
const renderer = useRenderer()
useKeyboard((key) => {
if (key.name === "escape") {
renderer.destroy() // Never use process.exit() directly!
}
if (key.ctrl && key.name === "s") {
save()
}
// Vim-style
if (key.name === "j") moveDown()
if (key.name === "k") moveUp()
})
return <box>{/* ... */}</box>
}
```
## Responsive Design
### Terminal-size Responsive
```tsx
import { useTerminalDimensions } from "@opentui/solid"
function ResponsiveLayout() {
const dims = useTerminalDimensions()
return (
<box flexDirection={dims().width > 80 ? "row" : "column"}>
<box flexGrow={1}>
<text>Panel 1</text>
</box>
<box flexGrow={1}>
<text>Panel 2</text>
</box>
</box>
)
}
```
## Async Data
### Resources
```tsx
import { createResource, Suspense } from "solid-js"
async function fetchData() {
const response = await fetch("https://api.example.com/data")
return response.json()
}
function DataDisplay() {
const [data] = createResource(fetchData)
return (
<Suspense fallback={<text>Loading...</text>}>
<Show when={data()}>
{(items) => (
<For each={items()}>
{(item) => <text>{item.name}</text>}
</For>
)}
</Show>
</Suspense>
)
}
```
### Error Handling
```tsx
import { createResource, Show, ErrorBoundary } from "solid-js"
function SafeDataDisplay() {
const [data] = createResource(fetchData)
return (
<ErrorBoundary fallback={(err) => <text fg="red">Error: {err.message}</text>}>
<Show
when={!data.loading}
fallback={<text>Loading...</text>}
>
<Show
when={!data.error}
fallback={<text fg="red">Failed to load</text>}
>
<For each={data()}>
{(item) => <text>{item.name}</text>}
</For>
</Show>
</Show>
</ErrorBoundary>
)
}
```
## Component Composition
### Props and Children
```tsx
import { ParentComponent, JSX } from "solid-js"
interface PanelProps {
title: string
children: JSX.Element
}
const Panel: ParentComponent<{ title: string }> = (props) => {
return (
<box border padding={1} flexDirection="column">
<text fg="#0ff">{props.title}</text>
<box marginTop={1}>
{props.children}
</box>
</box>
)
}
// Usage
<Panel title="Settings">
<text>Panel content here</text>
</Panel>
```
### Spread Props
```tsx
import { splitProps } from "solid-js"
interface ButtonProps {
label: string
onClick: () => void
// ...rest goes to box
}
function Button(props: ButtonProps) {
const [local, rest] = splitProps(props, ["label", "onClick"])
return (
<box border onMouseDown={local.onClick} {...rest}>
<text>{local.label}</text>
</box>
)
}
```
## Animation
### With Timeline
```tsx
import { createSignal, onMount } from "solid-js"
import { useTimeline } from "@opentui/solid"
function AnimatedProgress() {
const [width, setWidth] = createSignal(0)
const timeline = useTimeline({
duration: 2000,
})
onMount(() => {
timeline.add(
{ value: 0 },
{
value: 50,
duration: 2000,
ease: "easeOutQuad",
onUpdate: (anim) => {
setWidth(Math.round(anim.targets[0].value))
},
}
)
})
return (
<box flexDirection="column" gap={1}>
<text>Progress: {width()}%</text>
<box width={50} height={1} backgroundColor="#333">
<box width={width()} height={1} backgroundColor="#0f0" />
</box>
</box>
)
}
```
### Interval-based
```tsx
import { createSignal, onCleanup } from "solid-js"
function Clock() {
const [time, setTime] = createSignal(new Date())
const interval = setInterval(() => {
setTime(new Date())
}, 1000)
onCleanup(() => clearInterval(interval))
return <text>{time().toLocaleTimeString()}</text>
}
```
@@ -0,0 +1,614 @@
# Testing OpenTUI Applications
How to test terminal user interfaces built with OpenTUI.
## Overview
OpenTUI provides:
- **Test Renderer**: Headless renderer for testing
- **Snapshot Testing**: Verify visual output
- **Interaction Testing**: Simulate user input
## When to Use
Use this reference when you need snapshot tests, interaction testing, or renderer-based regression checks.
## Test Setup
### Bun Test Runner
OpenTUI uses Bun's built-in test runner:
```typescript
import { test, expect, beforeEach, afterEach } from "bun:test"
```
### Test Renderer
Create a test renderer for headless testing:
```typescript
import { createTestRenderer } from "@opentui/core/testing"
const testSetup = await createTestRenderer({
width: 80, // Terminal width
height: 24, // Terminal height
})
```
## Core Testing
### Basic Test
```typescript
import { test, expect } from "bun:test"
import { createTestRenderer } from "@opentui/core/testing"
import { TextRenderable } from "@opentui/core"
test("renders text", async () => {
const testSetup = await createTestRenderer({
width: 40,
height: 10,
})
const text = new TextRenderable(testSetup.renderer, {
id: "greeting",
content: "Hello, World!",
})
testSetup.renderer.root.add(text)
await testSetup.renderOnce()
expect(testSetup.captureCharFrame()).toContain("Hello, World!")
})
```
### Snapshot Testing
```typescript
import { test, expect, afterEach } from "bun:test"
import { createTestRenderer } from "@opentui/core/testing"
import { BoxRenderable, TextRenderable } from "@opentui/core"
let testSetup: Awaited<ReturnType<typeof createTestRenderer>>
afterEach(() => {
if (testSetup) {
testSetup.renderer.destroy()
}
})
test("component matches snapshot", async () => {
testSetup = await createTestRenderer({
width: 40,
height: 10,
})
const box = new BoxRenderable(testSetup.renderer, {
id: "box",
border: true,
width: 20,
height: 5,
})
box.add(new TextRenderable(testSetup.renderer, {
content: "Content",
}))
testSetup.renderer.root.add(box)
await testSetup.renderOnce()
expect(testSetup.captureCharFrame()).toMatchSnapshot()
})
```
## React Testing
### Test Utilities
React provides a built-in `testRender` utility via the `@opentui/react/test-utils` subpath export:
```tsx
import { testRender } from "@opentui/react/test-utils"
```
This utility:
- Creates a headless test renderer
- Sets up the React Act environment automatically
- Handles proper unmounting on destroy
- Returns the standard test setup object
### Basic Component Test
```tsx
import { test, expect } from "bun:test"
import { testRender } from "@opentui/react/test-utils"
function Greeting({ name }: { name: string }) {
return <text>Hello, {name}!</text>
}
test("Greeting renders name", async () => {
const testSetup = await testRender(
<Greeting name="World" />,
{ width: 80, height: 24 }
)
await testSetup.renderOnce()
const frame = testSetup.captureCharFrame()
expect(frame).toContain("Hello, World!")
})
```
### Snapshot Testing
```tsx
import { test, expect, afterEach } from "bun:test"
import { testRender } from "@opentui/react/test-utils"
let testSetup: Awaited<ReturnType<typeof testRender>>
afterEach(() => {
if (testSetup) {
testSetup.renderer.destroy()
}
})
test("component matches snapshot", async () => {
testSetup = await testRender(
<box style={{ width: 20, height: 5, border: true }}>
<text>Content</text>
</box>,
{ width: 25, height: 8 }
)
await testSetup.renderOnce()
const frame = testSetup.captureCharFrame()
expect(frame).toMatchSnapshot()
})
```
### State Testing
```tsx
import { test, expect, afterEach } from "bun:test"
import { useState } from "react"
import { testRender } from "@opentui/react/test-utils"
let testSetup: Awaited<ReturnType<typeof testRender>>
afterEach(() => {
if (testSetup) {
testSetup.renderer.destroy()
}
})
function Counter() {
const [count, setCount] = useState(0)
return (
<box>
<text>Count: {count}</text>
</box>
)
}
test("Counter shows initial value", async () => {
testSetup = await testRender(
<Counter />,
{ width: 20, height: 5 }
)
await testSetup.renderOnce()
const frame = testSetup.captureCharFrame()
expect(frame).toContain("Count: 0")
})
```
### Test Setup/Teardown Pattern
For multiple tests, use beforeEach/afterEach to manage the renderer lifecycle:
```tsx
import { describe, test, expect, beforeEach, afterEach } from "bun:test"
import { testRender } from "@opentui/react/test-utils"
let testSetup: Awaited<ReturnType<typeof testRender>>
describe("MyComponent", () => {
beforeEach(async () => {
if (testSetup) {
testSetup.renderer.destroy()
}
})
afterEach(() => {
if (testSetup) {
testSetup.renderer.destroy()
}
})
test("renders correctly", async () => {
testSetup = await testRender(<MyComponent />, {
width: 40,
height: 10,
})
await testSetup.renderOnce()
const frame = testSetup.captureCharFrame()
expect(frame).toMatchSnapshot()
})
})
```
### Test Setup Return Object
The `testRender` function returns a test setup object with these properties:
| Property | Type | Description |
|----------|------|-------------|
| `renderer` | `Renderer` | The headless renderer instance |
| `renderOnce` | `() => Promise<void>` | Triggers a single render cycle |
| `captureCharFrame` | `() => string` | Captures current output as text |
| `resize` | `(width, height) => void` | Resize the virtual terminal |
## Solid Testing
### Test Utilities
Solid exports `testRender` directly from the main package:
```tsx
import { testRender } from "@opentui/solid"
```
Note: Unlike React, Solid's `testRender` takes a **function component** (not a JSX element).
### Basic Component Test
```tsx
import { test, expect } from "bun:test"
import { testRender } from "@opentui/solid"
function Greeting(props: { name: string }) {
return <text>Hello, {props.name}!</text>
}
test("Greeting renders name", async () => {
const testSetup = await testRender(
() => <Greeting name="World" />,
{ width: 80, height: 24 }
)
await testSetup.renderOnce()
const frame = testSetup.captureCharFrame()
expect(frame).toContain("Hello, World!")
})
```
### Snapshot Testing
```tsx
import { test, expect, afterEach } from "bun:test"
import { testRender } from "@opentui/solid"
let testSetup: Awaited<ReturnType<typeof testRender>>
afterEach(() => {
if (testSetup) {
testSetup.renderer.destroy()
}
})
test("component matches snapshot", async () => {
testSetup = await testRender(
() => (
<box style={{ width: 20, height: 5, border: true }}>
<text>Content</text>
</box>
),
{ width: 25, height: 8 }
)
await testSetup.renderOnce()
const frame = testSetup.captureCharFrame()
expect(frame).toMatchSnapshot()
})
```
## Snapshot Format
Snapshots capture the rendered terminal output as text:
```
┌──────────────────┐
│ Hello, World! │
│ │
└──────────────────┘
```
### Updating Snapshots
```bash
bun test --update-snapshots
```
## Interaction Testing
### Simulating Key Presses
```typescript
import { test, expect, afterEach } from "bun:test"
import { createTestRenderer } from "@opentui/core/testing"
let testSetup: Awaited<ReturnType<typeof createTestRenderer>>
afterEach(() => {
if (testSetup) {
testSetup.renderer.destroy()
}
})
test("responds to keyboard", async () => {
testSetup = await createTestRenderer({
width: 40,
height: 10,
})
// Create component that responds to keys
// ...
// Simulate keypress
testSetup.renderer.keyInput.emit("keypress", {
name: "enter",
sequence: "\r",
ctrl: false,
shift: false,
meta: false,
option: false,
eventType: "press",
repeated: false,
})
// Render after the keypress
await testSetup.renderOnce()
expect(testSetup.captureCharFrame()).toContain("Selected")
})
```
### Testing Focus
```typescript
import { test, expect, afterEach } from "bun:test"
import { createTestRenderer } from "@opentui/core/testing"
import { InputRenderable } from "@opentui/core"
let testSetup: Awaited<ReturnType<typeof createTestRenderer>>
afterEach(() => {
if (testSetup) {
testSetup.renderer.destroy()
}
})
test("input receives focus", async () => {
testSetup = await createTestRenderer({
width: 40,
height: 10,
})
const input = new InputRenderable(testSetup.renderer, {
id: "test-input",
placeholder: "Type here",
})
testSetup.renderer.root.add(input)
input.focus()
expect(input.isFocused()).toBe(true)
})
```
## Test Organization
### File Structure
```
src/
├── components/
│ ├── Button.tsx
│ └── Button.test.tsx
├── hooks/
│ ├── useCounter.ts
│ └── useCounter.test.ts
└── test-utils.tsx
```
### Running Tests
```bash
# Run all tests
bun test
# Run specific test file
bun test src/components/Button.test.tsx
# Run with filter
bun test --filter "Button"
# Watch mode
bun test --watch
```
## Patterns
### Testing Conditional Rendering (React)
```tsx
import { test, expect, afterEach } from "bun:test"
import { testRender } from "@opentui/react/test-utils"
let testSetup: Awaited<ReturnType<typeof testRender>>
afterEach(() => {
if (testSetup) {
testSetup.renderer.destroy()
}
})
test("shows loading state", async () => {
testSetup = await testRender(
<DataLoader loading={true} />,
{ width: 40, height: 10 }
)
await testSetup.renderOnce()
expect(testSetup.captureCharFrame()).toContain("Loading...")
})
test("shows data when loaded", async () => {
testSetup = await testRender(
<DataLoader loading={false} data={["Item 1", "Item 2"]} />,
{ width: 40, height: 10 }
)
await testSetup.renderOnce()
const frame = testSetup.captureCharFrame()
expect(frame).toContain("Item 1")
expect(frame).toContain("Item 2")
})
```
### Testing Lists
```tsx
test("renders all items", async () => {
const items = ["Apple", "Banana", "Cherry"]
testSetup = await testRender(
<ItemList items={items} />,
{ width: 40, height: 10 }
)
await testSetup.renderOnce()
const frame = testSetup.captureCharFrame()
items.forEach(item => {
expect(frame).toContain(item)
})
})
```
### Testing Layouts
```tsx
test("matches layout snapshot", async () => {
testSetup = await testRender(
<AppLayout />,
{ width: 120, height: 40 } // Larger viewport
)
await testSetup.renderOnce()
expect(testSetup.captureCharFrame()).toMatchSnapshot()
})
```
## Debugging Tests
### Print Frame Output
```tsx
import { testRender } from "@opentui/react/test-utils"
test("debug output", async () => {
const testSetup = await testRender(
<MyComponent />,
{ width: 40, height: 10 }
)
await testSetup.renderOnce()
const frame = testSetup.captureCharFrame()
// Print to see what's rendered
console.log(frame)
expect(frame).toContain("expected")
})
```
### Verbose Mode
```bash
bun test --verbose
```
## Gotchas
### Async Rendering
Always call `renderOnce()` after setting up your component to ensure rendering is complete:
```typescript
const testSetup = await testRender(<MyComponent />, { width: 40, height: 10 })
await testSetup.renderOnce() // Required before capturing frame
const frame = testSetup.captureCharFrame()
```
### Test Isolation and Cleanup
Always destroy the renderer after each test to avoid resource leaks:
```typescript
import { afterEach } from "bun:test"
let testSetup: Awaited<ReturnType<typeof testRender>>
afterEach(() => {
if (testSetup) {
testSetup.renderer.destroy()
}
})
test("test 1", async () => {
testSetup = await testRender(<Component1 />, { width: 40, height: 10 })
// ...
})
test("test 2", async () => {
testSetup = await testRender(<Component2 />, { width: 40, height: 10 })
// ...
})
```
### Snapshot Dimensions
Be consistent with test dimensions for stable snapshots:
```typescript
const testSetup = await createTestRenderer({
width: 80, // Standard width
height: 24, // Standard height
})
```
### Running from Package Directory
Run tests from the package directory:
```bash
cd packages/core
bun test
# Not from repo root for package-specific tests
```
## See Also
- [Core API](../core/api.md) - `createTestRenderer` and renderable classes
- [React Configuration](../react/configuration.md) - React test setup
- [Solid Configuration](../solid/configuration.md) - Solid test setup
- [Keyboard](../keyboard/REFERENCE.md) - Simulating key events in tests
+1
View File
@@ -0,0 +1 @@
../../.cline/skills/publish-cli
+1
View File
@@ -0,0 +1 @@
../../.agents/skills/opentui
+1
View File
@@ -0,0 +1 @@
../../.cline/skills/publish-cli
@@ -0,0 +1,176 @@
---
name: publish-cli
description: Use when preparing, tagging, and publishing an apps/cli npm release. Guides changelog drafting, apps/cli/package.json version bumps, cli-vX.Y.Z tags, local npm publishing, and the publish-cli GitHub workflow.
---
# CLI Release
Use this skill when the user asks to release the CLI, publish `cline`, bump the CLI version, draft release notes, create a `cli-vX.Y.Z` tag, or trigger the CLI publish workflow.
The CLI is npm-only. Do not add alternate distribution or signing steps.
The skill should guide the user through one release preparation flow, then offer the publish path options. The two normal publish paths are GitHub Actions and local publishing from an authenticated machine.
## Release contract
- Version source: `apps/cli/package.json`.
- Main release tag: `cli-vX.Y.Z`, where `X.Y.Z` matches `apps/cli/package.json`.
- Nightly release version: `X.Y.Z-nightly.TIMESTAMP`.
- Release prep includes approved release notes, a version bump, and an `apps/cli/CHANGELOG.md` update.
- Publish paths:
- GitHub workflow: `.github/workflows/publish-cli.yaml`.
- Local publish helper: `bun release cli`.
- npm dist-tags and git tags are separate. `--tag latest` and `--tag nightly` are npm registry channels. `cli-vX.Y.Z` is a git tag for source history and GitHub releases.
- The GitHub main release workflow runs from `main`, requires an existing `cli-vX.Y.Z` tag, checks out that tag, and publishes from it.
- The GitHub nightly workflow publishes to npm with the `nightly` dist-tag and does not create a tag.
- The local release helper requires a clean checkout and `cli-vX.Y.Z` to point at `HEAD` locally and on `origin` before publishing.
- Local GitHub release creation requires `gh` to be authenticated with release permissions for the repo.
- Always ask before pushing commits or tags.
- Do not amend commits unless explicitly requested.
## Workflow
1. Gather context.
```sh
git status --short --branch
git fetch origin --tags
git tag --list 'cli-v*' --sort=-v:refname | head -10
node -p "require('./apps/cli/package.json').version"
```
Find the latest CLI tag. If there is no `cli-v*` tag, use the first relevant CLI release commit as the baseline and say that the baseline is inferred.
2. Collect release commits.
```sh
git log <last-cli-tag>..HEAD --oneline --no-merges -- apps/cli packages scripts .github/workflows/publish-cli.yaml
```
If the release includes broader SDK changes that affect the CLI, also inspect commits outside `apps/cli`.
3. Draft user-facing release notes.
Include user-facing features, fixes, behavior changes, compatibility changes, and notable install or release changes. Exclude pure refactors, tests, style, chores, and internal file moves unless they matter to users.
Write a flat bullet list. Translate commit messages into user-facing language. If a commit is unclear, read the full commit before summarizing it.
Present the draft and wait for approval before editing files.
4. Decide the version bump.
Ask whether this should be patch, minor, major, or an explicit version. Do not guess if the user has not made it clear.
5. Update release files.
Update `apps/cli/package.json` to the approved version.
Prepend a section to `apps/cli/CHANGELOG.md` for the approved version using the approved release notes.
6. Verify before committing.
Run focused checks first:
```sh
bun -F @cline/cli typecheck
bun -F @cline/cli test:unit
```
For higher confidence, run:
```sh
bun run types
bun --cwd apps/cli run build:platforms:single
```
If the user wants full release confidence before tagging, run:
```sh
bun run test
bun --cwd apps/cli run build:platforms
```
7. Commit release changes.
Only after the user approves the notes and version:
```sh
git add apps/cli/package.json apps/cli/CHANGELOG.md
git commit -m "chore(cli): release vX.Y.Z"
```
Ask before pushing the release commit:
```sh
git push origin HEAD
```
For the GitHub main release path, ask before creating and pushing the release tag:
```sh
git tag -a cli-vX.Y.Z -m "CLI vX.Y.Z"
git push origin refs/tags/cli-vX.Y.Z
```
8. Publish.
Ask the user which path to use:
- GitHub main release. Use this after the release commit is on `main` and the matching `cli-vX.Y.Z` tag has been pushed. The workflow publishes to npm from that tag, creates the GitHub release, and posts to Slack.
- Local release. Use this when the user wants to publish from this machine. The local machine must be authenticated to npm and GitHub.
- GitHub nightly release.
- Stop after the version commit.
For GitHub main release:
```sh
gh workflow run publish-cli.yaml -f publish_target=main -f git_tag=cli-vX.Y.Z -f confirm_publish=publish
gh run list --workflow=publish-cli.yaml --limit=1 --json url,status,conclusion,createdAt --jq '.[0]'
```
For GitHub nightly release:
```sh
gh workflow run publish-cli.yaml -f publish_target=nightly
```
For forced GitHub nightly release:
```sh
gh workflow run publish-cli.yaml -f publish_target=nightly -f force_nightly_publish=true
```
For local publish:
```sh
gh auth status
npm whoami
git tag -a cli-vX.Y.Z -m "CLI vX.Y.Z"
git push origin refs/tags/cli-vX.Y.Z
bun release cli
```
After a successful local publish, ask before running:
```sh
gh release create cli-vX.Y.Z --verify-tag --title "CLI vX.Y.Z" --notes "Paste the approved release notes here."
```
If publishing with another npm dist-tag:
```sh
bun release cli --tag next
```
9. Final response.
Report:
- version
- tag
- changelog file updated
- commit hash
- whether anything was pushed
- publish path selected
- workflow URL or local publish result
- tests and builds run
+5
View File
@@ -0,0 +1,5 @@
# Cached binary created by postinstall
bin/.cline
# Bun compile temp files
*.bun-build
+126
View File
@@ -0,0 +1,126 @@
# Cline CLI Changelog
## 3.0.0 (2026-05-11)
- Publish the SDK CLI as `cline` for the public package handoff
- Keep platform-specific binaries under `@cline/cli-*` and resolve them from the `cline` wrapper package
## 0.0.13 (2026-05-07)
- Detect prompt-cache support from cache write pricing so providers with write-only caching are represented correctly in the model catalog
- Dual-publish `@clinebot/cli` mirror wrapper so existing users who installed via `npm i -g @clinebot/cli` continue receiving updates
- Fix response truncation for OpenAI Codex model responses
## 0.0.12 (2026-05-06)
- Fix markdown rendering in the published binary: headers, inline code, blockquotes, bold, italic, and lists now render with proper syntax highlighting (tables were the only element working before)
- Add keyboard shortcuts for scrolling through the chat transcript (Page Up/Down, Home/End)
- Preserve typed input when selecting slash command skills instead of clearing the prompt
- Fix `--thinking none` being ignored when persisted reasoning settings existed, which caused DeepSeek API errors
- Fix terminal cleanup on exit so the summary prints cleanly
- Fix onboarding provider model resolution
- Hide ChatGPT subscription provider usage costs
- Handle file index prewarm timeouts gracefully instead of hanging
## 0.0.11 (2026-05-06)
- Add `/skills` slash command for browsing and toggling available skills interactively
- System prompts from AI SDK are now passed via the dedicated `system` option instead of being embedded in message history
- Context compaction can now be triggered manually and runs more reliably
- Disable the search tool in yolo mode so the model uses bash for searching instead
- Fix `submit_and_exit` completion policy not being wired through to the runtime
- Fix resumed sessions losing tool results when an abort interrupted tool execution mid-turn
- Fix interactive sessions becoming unusable after aborting a running turn
- Fix strict JSON schema mode rejecting valid tool schemas with unions, optional fields, and nullable types
- Fix stray log output appearing over the TUI when the log file fallback wrote directly to the stderr file descriptor, bypassing the TUI's stdio capture
- Refresh the built-in model catalog with the latest available models and pricing
## 0.0.10 (2026-05-04)
- Improve local provider onboarding: setting up Ollama, LM Studio, or other local providers now prompts for the endpoint URL directly, supports typing a model ID manually when the provider returns no models, and correctly discovers models from your saved endpoint
- Ctrl+C no longer cancels a running turn -- it now clears the input field or exits the CLI, matching standard terminal behavior. Use Escape to cancel a running turn instead
- Thinking level chosen in the model picker now persists across CLI restarts instead of resetting to off
- The context bar now shows visible progress as tokens are used, instead of appearing empty on some terminal themes
- The status bar token count now shows actual context window usage instead of over-counting across multiple model calls in a turn
- Resuming a saved session now correctly displays the accumulated cost
- Sessions are now saved to disk after each assistant response, so conversation progress survives crashes or unexpected exits
- Auto-compaction now runs inline during model requests, keeping long conversations within the context window automatically
- The home screen robot now follows the cursor while you type
- Hub websocket connections now automatically reconnect after going idle, so sessions no longer silently lose their connection to the hub daemon
- MCP stdio servers on Windows no longer spawn visible console windows
- Tool input schemas containing `allOf` clauses are now handled correctly instead of being rejected
- Login now uses device auth exclusively
- Fix chat input and chat view text losing its indent on wrapped lines
## 0.0.9 (2026-05-03)
- Fix stray text appearing over the TUI when background operations (like hub restart messages) write directly to stdout/stderr during interactive sessions
- Fix hub connection recovery: when a newer CLI instance restarts the shared hub daemon, already-running CLI sessions now automatically reconnect to the new hub endpoint instead of failing with transport errors
## 0.0.8 (2026-05-03)
- Fix crash when pressing Escape to cancel a running turn
- Add plugin and SDK tool toggles to the settings panel
- Add `@cline/sdk` as a user-facing alias for `@cline/core`
- Improve hub recovery with better error handling, logging, and recovery timeouts
- Show session summary (ID, model, cost, resume command) on exit
- Fix OAuth browser-launch failure
- Fix compact no-op being reported indistinctly
- Fix CLI history resume being non-transactional (could leave blank UI or corrupt session on disk)
- Fix cross-client session history not loading Code/VS Code sessions, and fix interactive turn status showing stale state
- Fix configuration file paths for hooks and rules (now resolve from `~/.cline/hooks` and `~/.cline/rules`)
- Fix Telegram connector: honor `--no-tools` flag, lock tool-disabled mode across state changes, post replies as raw text to avoid markdown parse failures, add `/help` and `/start` commands
- Clean up CLI program description and compact slash command descriptions
- Clean up CLI flags
## 0.0.7 (2026-04-30)
- Fix graceful recovery when the model returns malformed tool call inputs, preventing crashes mid-conversation
- Add settings toggles for core skills (enable/disable individual skills from the settings panel)
- Secure the local hub daemon with a discovery auth token, preventing unauthorized local access
- Fix auto-approve tool policies being incorrectly reset after session restore
- Fix npm wrapper detection for auto updates, so self-update works when the CLI is invoked through npm/npx shims
- Improve fork session UX with clearer prompts and smoother flow
- Fix manual thinking budget not being applied when using Anthropic models directly
- Improve account onboarding flow with better error messages and step sequencing
- Add enable/disable controls for individual tools and plugins
- Fix abort handling so the public run promise resolves correctly when a run is cancelled
- Fix markdown token styling in chat output
- Fix chat auto-scrolling to bottom on message submit
- Fix hub tool capabilities being routed to the wrong session
- Revert loading extension-created sessions from history (was causing issues)
## 0.0.6 (2026-04-29)
- Add checkpoint restore: press Esc twice or type `/undo` to rewind to a previous checkpoint, with options to restore chat only or chat + workspace
- Fix clipboard: fall back to system clipboard (pbcopy, PowerShell, wl-copy, xclip) when OSC 52 fails, fixing copy for longer text selections
- Fix prompt focus: restore focus to the prompt input after dialogs close, preventing the input from becoming unresponsive after using `/settings`
## 0.0.5 (2026-04-28)
- The input field has been completely redesigned -- the old bordered box is replaced with a clean chevron-prompt style that adapts its background color to any terminal theme using perceptual OKLAB color math. Light terminals are fully supported now.
- Pasting 5+ lines into the input shows a compact preview marker instead of flooding the textarea. The full content is still submitted.
- Arrow-key history navigation respects cursor position so you don't lose your place when scrolling through previous prompts.
- The TUI renders immediately instead of blocking while the hub daemon boots. Hub readiness and session hydration happen in the background.
- Listing previous sessions no longer hydrates every full session, making `cline history` and the history picker snappy even with hundreds of sessions.
- Updating the CLI no longer leaves you connected to a stale hub daemon. Incompatible versions are detected and replaced automatically, eliminating the "Unsupported hub schedule command" class of errors.
- Schedules can now trigger on external events (webhooks, GitHub events, plugin-emitted signals) in addition to cron intervals, with deduplication, filtering, and retry policies.
- Plugins can register automation event types that feed into the scheduling system, enabling custom triggers from any source.
- Resuming a session automatically picks up any in-flight team runs without needing to remember or pass `--team-name`.
- `providers.json` (which stores API keys and OAuth tokens) is now written with 0600 permissions, preventing other processes on the machine from reading it.
- Models that emit `command` or `cmd` instead of `commands` (or `paths` instead of `path`) no longer fail. Common aliases are normalized before execution.
## 0.0.4 (2026-04-28)
- Fix compiled binary spawning infinite hub daemon recursion loop
## 0.0.3 (2026-04-28)
- Rewritten TUI from Ink to OpenTUI with streaming markdown, syntax-highlighted diffs, scrollable chat, and mouse support
- Dialog system for model picker, tool approval, settings browser, session history, and onboarding
- Interactive setup wizards: `cline connect`, `cline schedule`, `cline mcp`
- Plan/Act mode toggle with system prompt and tool rebuilding on switch
- Input autocomplete for slash commands and file mentions
- Message queuing and steer messages during running turns
- Platform-specific compiled binaries for macOS, Linux, and Windows (arm64 and x64)
- npm trusted publishing via GitHub Actions OIDC
+415
View File
@@ -0,0 +1,415 @@
# CLI Development Guide
This guide covers everything you need to build and run the Cline CLI locally after cloning the repository. It includes setup instructions, a tech stack overview, and a walkthrough of the TUI architecture.
For CLI command reference and usage, see [DOC.md](./DOC.md) and [README.md](./README.md).
## Prerequisites
Install these before starting:
1. [Bun](https://bun.sh) (v1.0.0+) - Package manager, runtime, and bundler
2. [Zig](https://ziglang.org/download/) - Required by OpenTUI's native core. The `@opentui/core` package includes a Zig-compiled native binary that builds from source on install. Without Zig, `bun install` will fail for OpenTUI packages.
3. Node.js 22+ - Required for some build tooling and test infrastructure
Verify your setup:
```bash
bun --version # should be >= 1.0.0
zig version # any recent stable release
node --version # should be >= 22
```
## First-Time Setup
From the repository root:
```bash
# Install all workspace dependencies (including native OpenTUI build)
bun install
# Build the SDK packages and CLI
bun run build
# Run the CLI in dev mode (interactive)
bun run cli
```
That last command is a shortcut for `cd apps/cli && bun run dev`, which runs:
```bash
CLINE_BUILD_ENV=development bun --conditions=development ./src/index.ts
```
### Linking for Global Access
To use the CLI from anywhere on your system, first build the SDK packages, then link:
```bash
# From the repo root -- build all workspace packages
bun run build:sdk
# Then link the CLI binary
cd apps/cli
bun link
```
The `build:sdk` step is required because `bun link` runs without the `--conditions=development` flag, so Bun resolves workspace packages (`@cline/llms`, `@cline/core`, etc.) via their `package.json` exports which point to `dist/`. Without the build, those dist files don't exist and you'll get "Cannot find module" errors.
After linking, you can run `cline` from any directory:
```bash
cline # interactive mode
cline "prompt" # single-prompt mode
cline auth # authenticate a provider
```
If you prefer to skip the build step, use `bun run dev` from `apps/cli/` instead -- it passes `--conditions=development` which resolves packages directly from source.
### Rebuilding After SDK Changes
If you modify any package in `packages/` (shared, llms, agents, core, etc.), rebuild the SDK:
```bash
bun run build:sdk
```
If you're using `bun run dev`, you don't need to rebuild after every SDK change -- dev mode resolves packages from source. But if you're using the linked `cline` binary, you do need to rebuild for changes to take effect.
## Monorepo Structure
```
cline-sdk/
packages/ # SDK packages (published to npm)
shared/ # Contracts, schemas, path helpers, runtime utilities
llms/ # Provider settings, model catalogs, AI SDK handlers
agents/ # Stateless agent loop, tool orchestration, hooks
scheduler/ # Scheduled execution, concurrency control
core/ # Stateful orchestration, sessions, hub, storage, config
enterprise/ # Internal enterprise integrations (not published)
apps/
cli/ # This package - CLI host and TUI
code/ # Tauri + Next.js desktop app
vscode/ # VS Code extension
desktop/ # Desktop application
examples/ # Sample integrations
biome.json # Linter and formatter config (Biome)
```
## Tech Stack
| Layer | Technology | Purpose |
|-------|-----------|---------|
| Runtime | Bun | Package management, script execution, bundling |
| Language | TypeScript (strict) | All source code |
| CLI Framework | Commander.js | Argument parsing, subcommands |
| TUI Renderer | OpenTUI (`@opentui/core`) | Native terminal rendering engine (Zig + C ABI) |
| TUI Components | OpenTUI React (`@opentui/react`) | React 19 reconciler for declarative terminal UI |
| TUI Dialogs | `@opentui-ui/dialog` | Modal dialog system (model picker, tool approval, etc.) |
| Linter/Formatter | Biome | Code quality and formatting |
| Testing | Vitest | Unit and E2E tests |
| Logging | Pino | Runtime file logging |
### Why OpenTUI?
OpenTUI is a native terminal UI core written in Zig with TypeScript bindings. Compared to the previous terminal renderer, OpenTUI provides:
- Native diff rendering with syntax highlighting
- Streaming markdown rendering
- Scrollable content areas
- Mouse interaction (click, hover, drag-to-select, scroll)
- Built-in clipboard support (OSC52)
- Higher performance through native rendering
OpenTUI exposes a C ABI from its Zig core. The `@opentui/core` package provides TypeScript bindings, and `@opentui/react` provides a React reconciler so you can write terminal UIs with JSX.
## CLI Source Structure
```
apps/cli/src/
index.ts # Entry point (shebang, signal handling)
main.ts # CLI command definitions, argument parsing
runtime/
run-interactive.ts # Interactive mode runtime (session lifecycle, event wiring)
run-agent.ts # Single-prompt runtime
session-events.ts # Event bridge types and pub/sub
active-runtime.ts # Abort registry
tool-policies.ts # Auto-approve toggle logic
prompt.ts # System prompt and user input assembly
defaults.ts # Default config values
tui/ # Terminal UI (OpenTUI + React)
index.tsx # Renderer entry point
root.tsx # Provider tree, view routing, global keyboard
types.ts # ChatEntry union, TuiProps, shared constants
interactive-config.ts # Config data loading
interactive-welcome.ts # Welcome line, slash command resolution
components/ # Reusable UI components
contexts/ # React context providers
hooks/ # Custom React hooks
views/ # Full-screen view components
utils/ # TUI-specific utilities
session/ # Session state management
commands/ # CLI subcommands (auth, config, history, etc.)
connectors/ # Chat adapter bridges (Telegram, Slack, etc.)
utils/ # Shared utilities
wizards/ # Interactive setup flows
logging/ # Pino logger adapter
```
## TUI Architecture
The TUI lives at `src/tui/` and uses React with OpenTUI's reconciler. Every `.tsx` file in this directory uses a per-file JSX pragma:
```tsx
// @jsxImportSource @opentui/react
```
This tells TypeScript to use OpenTUI's JSX runtime instead of React DOM. The `tsconfig.json` sets `jsxImportSource: "@opentui/react"` globally, but the per-file pragma makes the intent explicit and avoids conflicts with any non-TUI React code.
### Entry Point: `index.tsx`
The TUI boots through `renderOpenTui()`:
```tsx
const renderer = await createCliRenderer({
exitOnCtrlC: false, // We handle Ctrl+C ourselves
autoFocus: false, // Prevents click-anywhere from stealing focus
enableMouseMovement: true,
});
const root = createRoot(renderer);
root.render(<Root {...props} />);
```
The renderer returns `destroy()` and `waitUntilExit()` methods. The runtime calls `destroy()` on exit and awaits `waitUntilExit()` for cleanup.
### Runtime Bridge: `run-interactive.ts`
This file is the bridge between the SDK and the TUI. It:
1. Creates a `SessionManager` via `createCliCore()`
2. Sets up event subscriptions (agent events, pending prompts, team events)
3. Passes callbacks to the TUI as props (`onSubmit`, `onAbort`, `onModelChange`, etc.)
4. Manages session lifecycle (start, stop, restart, resume, compact)
The TUI never talks to the SDK directly. All communication flows through the callback props defined in `TuiProps` (see `types.ts`).
### Component Tree
```
Root (root.tsx)
DialogProvider # Modal dialog system
SessionProvider # Chat entries, running state, mode
EventBridgeProvider # Subscribes to SDK events
View Router
HomeView # Welcome screen (before first prompt)
ChatView # Message list + input bar + status
OnboardingView # First-run provider setup
ConfigView (dialog) # Settings browser
HistoryView (dialog) # Session history
```
### Context Providers
Each context owns a slice of state. Components subscribe only to what they need.
`SessionContext` - Core chat state:
- `entries: ChatEntry[]` - All messages in the conversation
- `isRunning` / `abortRequested` - Agent execution state
- `mode` (plan/act), `autoApproveAll`, `hasSubmitted`
- `lastTotalTokens`, `lastTotalCost`, `turnStartTime`
`EventBridgeContext` - SDK event subscription:
- Subscribes to `subscribeToEvents` prop once via useEffect
- Forwards agent events to session context handlers via stable refs
- Handles pending prompts, team events
### Event Flow
```
SDK (AgentLoop)
--> AgentEvent emitted
--> subscribeToAgentEvents() fires
--> UIEventEmitter.emit("agent", event)
--> EventBridgeProvider receives event
--> useAgentEventHandlers processes event
--> SessionContext.entries updated
--> React re-renders affected components
```
### ChatEntry Type
All messages in the conversation are represented as a discriminated union:
```typescript
type ChatEntry =
| { kind: "user"; text: string }
| { kind: "assistant_text"; text: string; streaming: boolean }
| { kind: "reasoning"; text: string; streaming: boolean }
| { kind: "tool_call"; toolName: string; inputSummary: string; ... }
| { kind: "error"; text: string }
| { kind: "status"; text: string }
| { kind: "team"; text: string }
| { kind: "user_submitted"; text: string; delivery?: "queue" | "steer" }
| { kind: "done"; tokens: number; cost: number; elapsed: string; iterations: number }
```
### Dialog System
Dialogs use `@opentui-ui/dialog`. The pattern:
```tsx
import { useDialog } from "@opentui-ui/dialog/react";
const dialog = useDialog();
const result = await dialog.choice<string>({
style: { maxHeight: termHeight - 2 },
content: (ctx) => <MyDialogContent {...ctx} />,
});
```
Dialog content components receive `resolve` and `dismiss` callbacks through the context. They use `useDialogKeyboard` for keyboard handling scoped to the dialog.
Important gotcha: async data loading inside a dialog (via useEffect/useState) causes layout gaps between flex children in OpenTUI. Always fetch data before opening the dialog and pass it as props.
### Key Components
`components/input-bar.tsx` - Text input with submit handling:
- Uncontrolled `<textarea>` with `key={inputKey}` for reset
- `ref` callback wires `node.onSubmit` (React reconciler pattern)
- Supports newlines (Shift+Enter) and autocomplete integration
`components/chat-entry.tsx` - Renders a single ChatEntry based on its `kind`:
- Markdown rendering for assistant text (`<markdown>`)
- Diff rendering for file edits (`<diff>`)
- Code highlighting for file reads (`<code>`)
- Spinner for streaming states
`components/status-bar.tsx` - Bottom status display:
- Model name, context bar, token/cost
- Plan/Act mode indicator
- Workspace, branch, auto-approve state
`components/tool-output.tsx` - Rich tool result rendering:
- Unified diffs with syntax highlighting
- Expandable/collapsible output sections
- File read with line numbers
`views/home-view.tsx` - Welcome screen with animated robot and centered input
`views/chat-view.tsx` - Main conversation view (scrollbox + input + status)
`views/onboarding-view.tsx` - First-run provider/model setup wizard
### OpenTUI Elements
OpenTUI provides these built-in elements (used like HTML tags in JSX):
- `<box>` - Flexbox container (like `<div>`)
- `<text>` - Text display (like `<span>`)
- `<span>` - Inline text modifier (for coloring nested text)
- `<scrollbox>` - Scrollable container
- `<textarea>` - Multi-line text input
- `<input>` - Single-line text input
- `<select>` - List selection
- `<code>` - Syntax-highlighted code block
- `<diff>` - Unified/split diff viewer
- `<markdown>` - Streaming markdown renderer
Styling uses named terminal colors as props:
```tsx
<text fg="cyan">colored text</text>
<box backgroundColor="gray" paddingX={1}>padded box</box>
```
Layout follows flexbox conventions: `flexDirection`, `flexGrow`, `flexShrink`, `gap`, `padding`, `margin`, etc.
## Testing
```bash
# Unit tests
bun run test:unit
# E2E tests
bun run test:e2e
bun run test:e2e:interactive
# TUI-specific E2E tests (uses @microsoft/tui-test)
bun run test:e2e:cli:tui
# Type checking
bun run typecheck
# Lint and format
cd ../.. && bun run fix # auto-fix from repo root
```
## Common Development Tasks
### Running in interactive mode
```bash
bun run dev
```
### Testing onboarding flow
Use a temporary config directory to simulate a fresh install:
```bash
bun run dev -- --interactive --config /tmp/cline-test
```
Or set `CLINE_FORCE_ONBOARDING=1` to force the onboarding view regardless of existing config.
### Adding a new TUI component
1. Create a `.tsx` file in `src/tui/components/`
2. Add the JSX pragma at the top: `// @jsxImportSource @opentui/react`
3. Use OpenTUI elements (`<box>`, `<text>`, etc.) for layout
4. Import and use in the parent view or root
### Adding a new dialog
1. Create a content component that receives `ChoiceContext<T>` props
2. Use `useDialogKeyboard` for keyboard handling
3. Call `resolve(value)` to return a result, `dismiss()` to cancel
4. Open it from a hook or view: `const result = await dialog.choice<T>({ content: ... })`
5. Fetch any async data before calling `dialog.choice()`, not inside the dialog
### Adding a new slash command
1. Define the command handler in `root.tsx` (in the slash command processing section)
2. Add the command to the help dialog in `components/dialogs/help-dialog.tsx`
3. Add autocomplete entry in `hooks/use-autocomplete.ts`
### Debugging the TUI
```bash
# Run with React DevTools (requires react-devtools-core@7)
DEV=true bun run dev
# In another terminal
npx react-devtools@7
```
### Debugging the CLI process
```bash
cd apps/cli
CLINE_BUILD_ENV=development bun --conditions=development --inspect-brk=6499 ./src/index.ts
```
Then attach VS Code or Chrome DevTools to `ws://127.0.0.1:6499`.
## OpenTUI Resources
- OpenTUI docs: https://opentui.com/docs/getting-started
- Repository: https://github.com/anomalyco/opentui
- Packages used by CLI:
- `@opentui/core` - Native renderer and built-in elements
- `@opentui/react` - React reconciler (`createRoot`, hooks)
- `@opentui-ui/dialog` - Dialog/modal system
- `opentui-spinner` - Spinner component
+277
View File
@@ -0,0 +1,277 @@
# CLI Distribution
The Cline CLI (`cline`) is distributed as compiled binaries via npm. Users run `npm i -g cline` and get a working `cline` command without needing Bun, Zig, or any other runtime installed.
## Why Compiled Binaries?
The CLI depends on OpenTUI (`@opentui/core`), which uses `bun:ffi` to call into a native Zig binary for terminal rendering. This means:
- The CLI cannot run on Node.js (Node doesn't support `bun:ffi`)
- If shipped as a JS bundle (`dist/index.js`), users must have Bun installed
- Compiled binaries (`bun build --compile`) embed the Bun runtime, so users need nothing pre-installed
Bun's `--compile` flag produces a single self-contained executable that includes the Bun runtime, all JS/TS code, and native addons.
## What Gets Published
Publishing the CLI publishes 7 packages to npm:
| Package | Description |
|---|---|
| `@cline/cli-darwin-arm64` | macOS Apple Silicon binary |
| `@cline/cli-darwin-x64` | macOS Intel binary |
| `@cline/cli-linux-arm64` | Linux ARM binary |
| `@cline/cli-linux-x64` | Linux x64 binary |
| `@cline/cli-windows-x64` | Windows x64 binary |
| `@cline/cli-windows-arm64` | Windows ARM binary |
| `cline` | Wrapper package (pulls the right binary via `optionalDependencies`) |
Each platform package contains a compiled binary and a minimal `package.json` with `os` and `cpu` fields:
```json
{
"name": "@cline/cli-darwin-arm64",
"version": "0.1.0",
"os": ["darwin"],
"cpu": ["arm64"],
"bin": {
"cline": "bin/cline"
}
}
```
The `os` and `cpu` fields tell npm to skip this package on non-matching platforms. A macOS ARM user gets ~30-60MB, not ~200MB of binaries for every platform.
The `cline` wrapper package contains no binary -- just the resolver script, postinstall script, and `optionalDependencies` pointing to all platform packages:
```json
{
"name": "cline",
"version": "0.1.0",
"bin": {
"cline": "./bin/cline"
},
"scripts": {
"postinstall": "node ./postinstall.mjs || true"
},
"optionalDependencies": {
"@cline/cli-darwin-arm64": "0.1.0",
"@cline/cli-darwin-x64": "0.1.0",
"@cline/cli-linux-arm64": "0.1.0",
"@cline/cli-linux-x64": "0.1.0",
"@cline/cli-windows-x64": "0.1.0",
"@cline/cli-windows-arm64": "0.1.0"
}
}
```
After installing, users run `cline`:
```bash
npm i -g cline
cline # interactive mode
cline "prompt" # single-prompt mode
cline auth # authenticate a provider
```
## How to Publish
Every release starts by preparing one release commit from the code you want to publish:
1. Draft user-facing release notes from the commits since the last `cli-vX.Y.Z` tag.
2. Choose the release version. Because this publishes over the existing `cline` package, the version must be greater than the current published `cline` version. The handoff release is `3.0.0`.
3. Update `apps/cli/package.json`.
4. Add the approved notes to `apps/cli/CHANGELOG.md`.
5. Run checks.
6. Commit the release changes.
Then publish that release commit with one of these paths.
### Publish From GitHub Actions
Use this path for normal releases.
```bash
git tag -a cli-vX.Y.Z -m "CLI vX.Y.Z"
git push origin refs/tags/cli-vX.Y.Z
gh workflow run publish-cli.yaml -f publish_target=main -f git_tag=cli-vX.Y.Z -f confirm_publish=publish
```
This path requires the release commit to be on `main` and the matching `cli-vX.Y.Z` tag to exist before the workflow runs. The workflow checks out the tag, publishes to npm with the `latest` dist-tag, creates the GitHub release, and posts to Slack.
### Publish Locally
Use this path when publishing from an authenticated local machine.
Start from a clean checkout at the release commit:
```bash
gh auth status
npm whoami
git tag -a cli-vX.Y.Z -m "CLI vX.Y.Z"
git push origin refs/tags/cli-vX.Y.Z
bun release cli
gh release create cli-vX.Y.Z --verify-tag --title "CLI vX.Y.Z" --notes "Paste the approved release notes here."
```
The release helper checks the working tree, verifies the tag points at `HEAD` locally and on `origin`, runs tests, builds all platform packages, and publishes the platform packages plus the generated `cline` wrapper package to npm. The package version and tag must match.
By default, `bun release cli` publishes with the npm dist-tag `latest` (what users get with `npm i -g cline`). To publish under a different dist-tag like `next`, pass `--tag`:
```bash
bun release cli --tag next
```
## CI Workflow
The GitHub workflow at `.github/workflows/publish-cli.yaml` automates publishing:
- Main releases are manual. Select `publish_target=main` and set `confirm_publish=publish`.
- Main releases require `git_tag=cli-vX.Y.Z`, check out that tag, verify it matches `apps/cli/package.json`, run tests, build all platform packages, publish to npm with the `latest` dist-tag using trusted publishing, create a GitHub release, and post to Slack.
- Nightly releases run on a schedule or manually with `publish_target=nightly`.
- Nightly releases publish `X.Y.Z-nightly.TIMESTAMP` to npm with the `nightly` dist-tag and skip if there were no commits in the last 24 hours unless forced.
CI publishing uses npm trusted publishing. Configure npm trusted publishers for the `cline` wrapper package and every platform package before relying on the workflow.
## How It Works Under the Hood
```
User runs: npm i -g cline
|
v
npm installs cline (wrapper package)
+ optionalDependencies (only the matching platform gets installed):
- @cline/cli-darwin-arm64
- @cline/cli-darwin-x64
- @cline/cli-linux-arm64
- @cline/cli-linux-x64
- @cline/cli-windows-x64
- @cline/cli-windows-arm64
|
v
postinstall script runs:
- Detects platform/arch
- Finds the installed platform package
- Creates a cached hard link for fast startup
|
v
User runs: cline
|
v
bin/cline (Node.js resolver) executes:
1. Check CLINE_BIN_PATH env var override
2. Check cached binary at bin/.cline
3. Walk up node_modules for the platform package
4. Execute the compiled binary
```
## File Layout
```
apps/cli/
bin/
cline # Node.js resolver script (npm entry point)
script/
build.ts # Cross-compile for all platforms
publish-npm.ts # npm publish orchestration
postinstall.mjs # Post-install binary caching
```
## Scripts Reference
From `apps/cli/`:
```bash
bun run build:platforms:single # build only current platform
bun run build:platforms # build all 6 platform binaries
bun run publish:npm:dry # preview generated npm package publishing
```
Direct `bun pm pack` and `bun pm pack --dry-run` from `apps/cli` are blocked because the source package is not the npm release package. Build platform packages first, then use `bun run publish:npm:dry` to preview the generated packages under `dist/`.
## Build Script (`script/build.ts`)
Cross-compiles the CLI for all target platforms:
1. When `--install-native-variants` is passed, pre-installs all platform variants of `@opentui/core` using `bun install --os="*" --cpu="*"` so Bun can resolve native FFI binaries for cross-compilation. Without this, Bun only has the host platform's native binary and cross-compiled builds fail.
2. Builds SDK packages (`bun run build:sdk`) and the CLI JS bundle (`bun -F @cline/cli build`)
3. For each target platform:
- Runs `bun build --compile --target bun-{os}-{arch}` to create a standalone executable
- Generates a `package.json` with `os` and `cpu` fields for npm platform filtering
- Runs a smoke test on the current platform's binary (`cline --version`)
- Copies the plugin sandbox bootstrap file if present
Flags:
- `--single` -- build only for the current platform (faster for local testing)
- `--install-native-variants` -- allow the script to download all OpenTUI native packages required for cross-platform builds
- `--skip-install` -- skip re-downloading platform-specific native packages if they're already installed
- `--skip-sdk-build` -- skip rebuilding SDK packages (if already built)
## Publish Script (`script/publish-npm.ts`)
Orchestrates publishing all packages to npm:
1. Reads built packages from `dist/`
2. Publishes all 6 platform packages in parallel (`@cline/cli-darwin-arm64`, etc.)
3. Generates a clean main package (`cline`) with:
- `bin.cline` pointing to the resolver script
- `postinstall` running the binary caching script
- `optionalDependencies` listing all platform packages
4. Publishes the generated `cline` wrapper package
Platform packages must be published before the generated `cline` wrapper package because npm validates that `optionalDependencies` exist.
The publish script generates a separate `package.json` for the published `cline` wrapper package. The development `package.json` (with `bin` pointing to `src/index.ts` for `bun link`) is never published directly.
## Binary Resolver (`bin/cline`)
A Node.js script that serves as the entry point when users run `cline`. It finds and executes the correct platform-specific binary.
The shebang is `#!/usr/bin/env node` because Node.js is guaranteed to be available wherever npm is. The resolver uses only CommonJS (`require`) and Node.js APIs -- no `bun:` imports or Bun-specific APIs. It then spawns the compiled binary which has Bun embedded.
Resolution chain:
1. `CLINE_BIN_PATH` env var (for development or custom deployments)
2. `bin/.cline` cached hard link (created by postinstall for fast startup)
3. Walk up `node_modules` from the script directory to find the platform package
## Postinstall (`script/postinstall.mjs`)
Runs after `npm install cline`. Creates a hard link from the platform binary to `bin/.cline` for fast startup on subsequent runs. Falls back to file copy if hard linking fails (NFS, cross-device, network-mounted filesystems).
The postinstall is defensive: it wraps everything in try/catch and always exits 0 (the `|| true` in the npm script). If postinstall fails, the resolver script has its own fallback logic to find the binary at runtime, so the cached binary is just an optimization.
On Windows, the postinstall is a no-op because npm handles `.cmd` shim generation from the `bin` field.
## Development vs Distribution
During development, `bin` in package.json points to `src/index.ts` for `bun link` to work. The publish script generates a separate package.json for the published package that points to the resolver script. The development package.json is never modified during publish.
| Mode | bin target | Runtime | Needs Bun? |
|---|---|---|---|
| `bun run dev` | src/index.ts | Bun (source) | Yes |
| `bun link` + `cline` | src/index.ts | Bun (source) | Yes |
| `npm i -g cline` | bin/cline resolver | Compiled binary | No |
## Gotchas
### Native addon cross-compilation
When building for a different platform (e.g., compiling for Linux on a Mac), Bun needs the target platform's native binaries for `@opentui/core`. The build script handles this by pre-downloading all platform variants with `bun install --os="*" --cpu="*"`.
### Version synchronization
All 7 packages (6 platform + 1 wrapper) must have the same version. The build script reads the version from `apps/cli/package.json`. The publish script verifies that the built package versions match each other and `apps/cli/package.json`.
### Package naming and scoping
Platform packages are published under the `@cline` scope. The generated wrapper package is published as `cline`, so npm trusted publishing must be configured for all 7 package names.
### postinstall reliability
The postinstall script runs in diverse environments (CI, Docker, restricted permissions, network-mounted filesystems where hard links fail). It always wraps operations in try/catch and exits 0. The resolver script is the ultimate fallback.
### Windows
Windows binaries are `.exe` files. The build script appends `.exe` to the output filename on Windows targets. The resolver handles this. npm on Windows generates `.cmd` shims for bin entries automatically.
### File permissions
Compiled binaries need to be executable (`chmod 755`). The build script sets this after copying. The postinstall also sets permissions on the cached binary. Some npm packaging steps can strip permissions, so both handle this defensively.
### Package size
Each compiled binary is ~30-60MB (Bun runtime + all bundled code + native addons). This is normal for compiled CLI tools. Users only download their platform's variant thanks to `optionalDependencies`.
+496
View File
@@ -0,0 +1,496 @@
# Cline CLI Lite
Cline CLI built with Cline SDK.
Streams output in real time and includes built-in tools, sub-agent spawning, and team runtime support by default.
Detailed CLI command/feature reference is centralized in [`DOC.md`](./DOC.md).
## Requirements
- [Bun](https://bun.com/docs/installation) (for development, build, and running `cline`)
## Installation
```bash
npm i -g cline
# or
bun i -g cline
```
## Development
Quick Start:
```bash
# From Root of the repository
bun install
bun run build
bun run cli # Run Dev script for the CLI package
# or
bun run -F @cline/cli dev "your prompt" # Run the CLI from the package workspace
# or
bun link # Link the package globally for easy access from anywhere
# Run from the linked binary
cline auth
# Run built CLI with Bun
bun cli/dist/index.js "your prompt"
```
Dev runtime note:
- Distinct host ID resolution is handled by `@cline/core` `createRuntimeHost(...)`.
- When no explicit `distinctId` is provided, core uses `node-machine-id` first and only persists a generated fallback at `<session-data-dir>/machine-id` if machine ID lookup is unavailable.
## Publishing
From the @cline/cli package workspace:
```bash
# Package the latest model list from models.dev
bun run build:models
# Dry run for checking package size and build output
bun publish --dry-run
# Example Output: Total files: 3 / Unpacked size: 2.28MB
# Publish to npm with Bun (version bump required)
bun run release
```
## Testing
```bash
# Run CLI unit tests
bun -F @cline/cli test:unit
# Run CLI e2e tests
bun -F @cline/cli test:e2e
bun -F @cline/cli test:e2e:interactive
```
## Usage
```bash
# Start Cline CLI without a prompt to enter interactive mode
cline
# Single prompt / One-shot - includes tools + spawn + teams
cline "Audit this package and propose fixes"
# NOTE: Single-prompt runs are non-interactive and exit when the turn finishes
# Interactive mode
cline -i
# With custom system prompt
cline -i -s "You are a pirate" "Tell me about the sea"
cline -i "Let's work on this together. First, analyze the current state and suggest next steps."
# Require approval before each tool call
cline --auto-approve false "Inspect and modify this repository"
# Explicitly enable auto-approval for all tools
cline --auto-approve true "Refactor src/index.ts for readability"
# Pipe input
cat file.txt | cline "Summarize this"
# Team workflow with persistent name
cline --team-name my-team "Plan, implement, and verify release checklist"
cline --team-name my-team "Continue yesterday's team workflow"
# Show verbose run stats (includes elapsed time, tokens, and estimated cost when available)
cline -v "Explain quantum computing"
# Override consecutive internal mistake (retry) limit for this run (default: 3)
cline --retries 5 "Fix failing tests"
# Common with auto-approve/yolo-style runs
cline --auto-approve true --retries 5 "Refactor this package"
# Explicit yolo also enables submit_and_exit and disables spawn/team tools by default
cline --yolo --retries 5 "Refactor this package"
# Zen mode: fire-and-forget a task to the background hub and exit the CLI immediately
# The hub keeps running the task; the menubar app (if installed) will notify you on
# completion. Otherwise check `cline history` later to see the result.
cline --zen "Refactor the authentication module"
# Stream structured NDJSON output
cline --json "Summarize this repository"
# Use a specific provider, model, and access token for a single prompt/task
cline -P openrouter -m google/gemini-3-pro -k sk-your-google-gemini-api-key "Set up a storybook for the frontend react ui components"
# Use a different model with the last used provider
cline -m anthropic/claude-opus-4-6 "Explain string theory"
# Quick setup with API key/model
cline auth --provider anthropic --apikey sk-... --modelid claude-sonnet-4-6
cline auth --provider openai-native --apikey sk-... --modelid gpt-5 --baseurl https://api.example.com/v1
# Authenticate OAuth providers explicitly
cline auth <cline|openai-codex|oca>
# Bridge a Telegram Bot API bot into RPC-backed chat sessions (polling mode)
# Create the bot with @BotFather, copy the username without @ and the token,
# then keep this connector process running while you want Telegram access.
cline connect telegram -m my_bot -k 123456:ABCDEF...
# Foreground mode for local debugging / logs in the active terminal
cline connect telegram -i -m my_bot -k 123456:ABCDEF...
# Tools are enabled by default for Telegram. Use --no-tools when the chat surface is not trusted.
cline connect telegram -m my_bot -k 123456:ABCDEF... --no-tools
# Provider/model default to the CLI's last-used provider settings. Override them if needed.
cline connect telegram -m my_bot -k 123456:ABCDEF... --provider cline --model openai/gpt-5.3-codex
# Dispatch connector lifecycle/message events to an external hook command
cline connect telegram -m my_bot -k 123456:ABCDEF... --hook-command '/Users/me/bin/on-connector-event'
# In Telegram chats, use /help, /start, /new, /clear, /whereami, /tools,
# /yolo, /cwd <path>, /schedule, /abort, and /exit.
# In groups, bot-addressed commands like /help@my_bot are recognized only for this bot.
# Final assistant replies use Telegram entity payloads with raw-text fallback.
# Detailed Telegram connector docs: apps/cli/src/connectors/adapters/telegram.md
# Bridge a Google Chat app into RPC-backed chat sessions (webhook mode)
cline connect gchat --base-url https://your-domain.com
# Foreground mode for local debugging / logs in the active terminal
cline connect gchat -i --base-url https://your-domain.com --port 8787
# Receive all-space messages through Workspace Events / Pub/Sub
cline connect gchat --base-url https://your-domain.com --pubsub-topic projects/my-project/topics/chat-events --impersonate-user admin@example.com
# Enable tools explicitly only if you trust the Google Chat surface
cline connect gchat --base-url https://your-domain.com --enable-tools
# Bridge a WhatsApp Business webhook into RPC-backed chat sessions
cline connect whatsapp --base-url https://your-domain.com
# Foreground mode for local debugging / logs in the active terminal
cline connect whatsapp -i --base-url https://your-domain.com --port 8787
# Override Meta credentials directly instead of relying on environment variables
cline connect whatsapp --base-url https://your-domain.com --phone-number-id 1234567890 --access-token token --app-secret secret --verify-token verify
# Enable tools explicitly only if you trust the WhatsApp surface
cline connect whatsapp --base-url https://your-domain.com --enable-tools
# Stop connector bridges and delete their sessions
cline connect --stop
cline connect --stop telegram
cline connect --stop gchat
cline connect --stop whatsapp
# Connector implementation notes
# - adapter files keep transport-specific setup and schedule-delivery rules
# - shared logic for flags/process helpers, thread bindings, session bootstrap,
# and turn/approval handling lives under apps/cli/src/connectors/
# Open the CLI runtime log file
cline doctor log
# Inspect local CLI/RPC process health
cline doctor
# Include historical spawn records from the shared CLI log
cline doctor --verbose
# Kill stale local RPC listeners and old CLI processes
cline doctor fix
# Open interactive config view directly
cline config
# Running `cline` with no prompt also enters interactive mode.
# Interactive mode is rendered with the OpenTUI TUI.
# The initial screen uses a WelcomeView-style layout before the first prompt.
# Inline composer supports completion menus:
# - `@` opens workspace file mention search (arrow keys to move, Enter/Tab to insert)
# - `/` opens workflow slash command search (arrow keys to move, Enter/Tab to insert)
# - Ctrl+P opens the command palette for common CLI actions
# - `/config` (or `/settings`) opens the interactive config browser
# with general settings plus tabs for workflows, rules, skills, hooks, and agents
# - Settings > General includes a Compaction row for switching agentic compaction,
# basic compaction, or disabling compaction
# Footer rows mirror the legacy CLI layout:
# 1) command/file hint + Plan/Act badges (Tab)
# 2) provider/model + context bar + token/cost
# 3) repo/branch + git diff stats
# 4) auto-approve state (Shift+Tab toggles)
# For one-shot auto-exit behavior, pass a prompt argument.
# Exit interactive mode with Ctrl+D (or Ctrl+C when idle).
# Schedule agents on cron-like intervals
cline schedule create "Daily code review" \
--cron "0 9 * * MON-FRI" \
--prompt "Review PRs opened yesterday and summarize issues." \
--workspace /path/to/repo \
--provider cline \
--model openai/gpt-5.3-codex \
--timeout 3600 \
--tags automation,review
# Route a scheduled result back to a Telegram thread handled by the connector
# First, send /whereami to your bot in Telegram to get the thread id
# Keep the Telegram connector running when the scheduled result is delivered
cline schedule create "Daily summary" \
--cron "0 9 * * *" \
--prompt "Summarize yesterday's activity in this workspace." \
--workspace /path/to/repo \
--delivery-adapter telegram \
--delivery-bot my_bot \
--delivery-thread telegram:123456789
cline schedule list
cline schedule get <schedule-id>
cline schedule trigger <schedule-id>
cline schedule history <schedule-id> --limit 20
cline schedule stats <schedule-id>
cline schedule active
cline schedule upcoming --limit 10
cline schedule export <schedule-id> > daily-review.yaml
cline schedule import ./daily-review.yaml
```
Telegram connector details live next to the adapter implementation: [`src/connectors/adapters/telegram.md`](./src/connectors/adapters/telegram.md).
## OAuth Authentication
`cline` supports OAuth login for:
- `cline`
- `openai-codex`
- `oca`
`cline` does not auto-start OAuth during normal command startup. Authenticate explicitly first with `cline auth <provider>`.
For non-interactive runs, if one of these providers is selected and no saved credentials are available, `cline` fails fast with an authentication message instead of launching a hidden browser flow.
During OAuth login, `cline` tries to open the authorization URL in your default browser automatically and still prints the URL for manual fallback.
OAuth refresh is handled by `@cline/core` during session turns. If refresh cannot recover credentials, the run fails with a re-authentication message; clients are not sent a separate auth-request event that can mutate provider config on their behalf.
`cline auth` (without a provider) opens the interactive auth TUI with the same auth options as the old CLI flow:
- Sign in with Cline
- Sign in with ChatGPT Subscription (`openai-codex`)
- Sign in with OCA
- Use your own API key (provider + model + optional base URL)
Runtime note:
- Hook dispatch now runs in-process against the active runtime session instead of spinning up a separate `cline hook-worker` service.
- Hook commands are adapters over the SDK runtime hook bag; hook payload `taskId` uses the stable conversation id when available, not the per-run id.
## Options
| Flag | Description |
|------|-------------|
| `-s, --system <prompt>` | Override the system prompt |
| `-P, --provider <id>` | Provider id (default: `cline`) |
| `-m, --model <id>` | Model id (default: `anthropic/claude-sonnet-4.6`) |
| `-k, --key <api-key>` | API key override for this run |
| `-p, --plan` | Run in plan mode. Default to act mode. |
| `-i, --tui` | Interactive TUI multi-turn mode |
| `-t, --timeout <seconds>` | Optional run timeout in seconds |
| `-c, --cwd <path>` | Working directory for tools |
| `--config <path>` | Configuration directory (used for CLI home resolution) |
| `--hooks-dir <path>` | Additional hooks directory hint for runtime hook injection |
| `--acp` | ACP (Agent Client Protocol) mode |
| `--thinking [none\|low\|medium\|high\|xhigh]` | Set model thinking level when supported. Defaults to `medium` when the flag is provided without a level; thinking is off when the flag is omitted. |
| `--compaction <agentic\|basic\|off>` | Context compaction mode. Defaults to `basic`; use `agentic` for LLM compaction or `off` to disable. |
| `--retries <count>` | Maximum consecutive mistakes (retries) before halting (default: `3`) |
| `--json` | Output NDJSON instead of styled text |
| `--data-dir <path>` | Use isolated local state at `<path>` instead of `~/.cline` (enables sandbox mode automatically) |
| `--auto-approve [true\|false]` | Set tool auto-approval for all tools |
| `--kanban` | Run the external `kanban` app |
| `-y, --yolo` | Skip tool approval prompts, enable `submit_and_exit`, and disable spawn/team tools by default |
| `-z, --zen` | Dispatch the task to the background hub and exit the CLI immediately (see "Zen mode" below) |
| `--team-name <name>` | Override the runtime team state name |
| `-h, --help` | Show help (exits immediately) |
| `-v, --verbose` | Show verbose runtime diagnostics |
| `-V, --version` | Show version (exits immediately) |
`--json` is non-interactive and requires either a prompt argument or piped stdin.
Top-level commands:
- `cline config` - Open the interactive config view
- `cline history|h [options]` - List session history or manage saved sessions
- `cline version` - Show CLI version
- `cline update [options]` - Check for CLI and kanban updates
- `cline auth <provider>` - Authenticate or seed provider credentials
- `cline connect <adapter>` - Run a chat connector bridge (`telegram`, `gchat`, `whatsapp`)
- `cline connect --stop [adapter]` - Stop connector bridge processes and their sessions
- `cline schedule <command>` - Create and manage scheduled runs
- `cline doctor` - Inspect local CLI health and stale processes
- `cline doctor fix` - Kill stale local RPC listeners and old CLI processes
- `cline doctor log` - Open the CLI runtime log file
- `cline hook` - Handle a hook payload from stdin
- `cline hub` - Manage the local hub daemon
- `cline kanban` - Run the external `kanban` app, installing it first when needed
Connector shortcuts:
- `cline connect telegram -m <bot> -k <token>` - Start the Telegram bridge
- `cline connect gchat --base-url <url>` - Start the Google Chat webhook bridge
- `cline connect whatsapp --base-url <url>` - Start the WhatsApp webhook bridge
- `cline connect <adapter> --help` - Show adapter-specific options and examples
- `--hook-command <command>` - Run a shell command for connector events
Schedule shortcuts:
- `cline schedule create <name> --cron "<expr>" --prompt "<text>" --workspace <path>` - Create a scheduled run
- `cline schedule <create|list|get|update|pause|resume|delete|trigger|history|stats|active|upcoming|import|export>` - Manage schedules and execution history
Behavior notes:
- `cline auth` without a provider opens the interactive auth setup TUI.
- Connector slash commands are shared across connector chat surfaces: `/help`, `/start`, `/new`, `/clear`, `/whereami`, `/tools`, `/yolo`, `/cwd <path>`, `/schedule`, `/abort`, `/exit`.
- Telegram group commands addressed to the bot, such as `/help@my_bot`, are normalized only when the suffix matches the configured bot username.
- Interactive CLI can use the shared slash-command parser when `CLINE_ENABLE_CHAT_COMMANDS=1`.
- `/team <task>` is handled directly by the CLI in both interactive and non-interactive runs, even when chat commands are otherwise disabled.
Auth quick-setup flags:
- `-P, --provider <id>`
- `-k, --apikey <key>`
- `-m, --modelid <id>`
- `-b, --baseurl <url>` (OpenAI/OpenAI-compatible quick setup)
## Zen Mode
`--zen` (alias `-z`) runs a task in the background hub daemon and exits the CLI immediately. It is intended for long-running tasks you want to fire off and walk away from.
```bash
# Fire off a task and return to your shell right away
cline --zen "Refactor the authentication module and add unit tests"
```
Behavior:
- The CLI starts (or reuses) the local hub daemon, submits the task, then exits. It does not stream output or stay attached to the session.
- Because there is no human in the loop once the CLI exits, zen sessions run with full tool auto-approval (same semantics as `--yolo`). `spawn`/`team` tools are disabled by default for safety, consistent with yolo-mode defaults.
- If the Cline menubar app is running, it subscribes to hub `ui.notify` events and will surface a system notification when the task completes.
- If the menubar app is not running, there is no live UI for the task. Use `cline history` later to find the session and inspect the result.
- `--zen` is incompatible with `--data-dir` (the implicit sandbox requires a local backend that exits with the CLI) and with `--tui` (there is no terminal UI to render into).
## Tool Approval
Tool calls are auto-approved by default. Use `--auto-approve false` to require review before tool execution.
```bash
# Require approval for all tools
cline --auto-approve false "Inspect and modify this repository"
# Explicitly keep approvals disabled for this run
cline --auto-approve true "Audit the current workspace"
```
When approval is required, the CLI prompts in TTY mode:
```text
Approve tool "<tool_name>" with input <preview>? [y/N]
```
- Enter `y` or `yes` to approve.
- Enter anything else (or press Enter) to reject.
- If stdin/stdout is not a TTY, required-approval calls are denied in terminal mode.
Desktop-integrated approval mode is also supported via env wiring:
- `CLINE_TOOL_APPROVAL_MODE=desktop`
- `CLINE_TOOL_APPROVAL_DIR=<path>`
In desktop mode, CLI writes a request JSON file and waits for a matching decision JSON file.
## Environment Variables
- `ANTHROPIC_API_KEY` - API key for Anthropic
- `CLINE_API_KEY` - API key for Cline (when using `-P cline`)
- `CLINE_DATA_DIR` - Base data directory for sessions/settings/teams/hooks
- `CLINE_SANDBOX` - Set to `1` to force sandbox mode
- `CLINE_SANDBOX_DATA_DIR` - Override sandbox state directory
- `CLINE_TEAM_DATA_DIR` - Override team persistence directory
- `CLINE_BUILD_ENV` - Runtime build mode for SDK-owned subprocess launches (`development` adds `node|bun --inspect=127.0.0.1:0 --enable-source-maps` by default; falls back to `NODE_ENV` or `--conditions=development`)
- `CLINE_DEBUG_HOST` - Override the host used for development inspector listeners (default `127.0.0.1`)
- `CLINE_DEBUG_PORT_BASE` - Override the base inspector port for development child processes; when unset, child processes use ephemeral inspector ports
- `CLINE_TOOL_APPROVAL_MODE` - Approval mode (`desktop` uses file IPC; unset uses terminal prompt)
- `CLINE_TOOL_APPROVAL_DIR` - Directory for desktop approval request/decision files
- `CLINE_LOG_ENABLED` - Set to `0`/`false` to disable runtime file logging
- `CLINE_LOG_LEVEL` - Runtime log level (`trace|debug|info|warn|error|fatal|silent`, default `info`)
- `CLINE_LOG_PATH` - Runtime log file path (default `<CLINE_DATA_DIR>/logs/cline.log`)
- `CLINE_LOG_NAME` - Logger name embedded in runtime log records
- `OPENAI_API_KEY` - API key for OpenAI (when using `-p openai`)
- `OPENROUTER_API_KEY` - API key for OpenRouter (when using `-P openrouter`)
- `AI_GATEWAY_API_KEY` - API key for Vercel AI Gateway (when using `-p vercel-ai-gateway`)
- `V0_API_KEY` - API key for v0 (when using `-P v0`)
`--key` takes precedence over environment variables.
For OAuth providers (`cline`, `openai-codex`, `oca`), authenticate explicitly with `cline auth <provider>`. Normal command startup does not auto-launch OAuth.
## Debugging
- `CLINE_BUILD_ENV=development` enables debugger ports for SDK-owned spawned Node/Bun subprocesses.
- By default, child processes use ephemeral inspector ports to avoid collisions.
- Set `CLINE_DEBUG_PORT_BASE=9230` if you want deterministic role-based ports such as hook worker `9231`, plugin sandbox `9232`, connector child `9233`.
- Those ports do not apply to the top-level CLI when it is running under Bun. To debug the Bun CLI process itself, launch the real CLI entrypoint under Bun with an inspector port such as:
```bash
cd apps/cli
CLINE_BUILD_ENV=development bun --conditions=development --inspect-brk=6499 ./src/index.ts "hey"
```
- The workspace includes [.vscode/launch.json](./.vscode/launch.json) with a single `Launch CLI Debugger` compound entry for VS Code. It launches `apps/cli/src/index.ts` directly under Bun in development mode and attaches the common SDK child-process debuggers.
- The launch config uses `"type": "bun"` (requires the [`oven.bun-vscode`](https://marketplace.visualstudio.com/items?itemName=oven.bun-vscode) extension). Using `type: node` will not work because breakpoints in the CLI and workspace packages like `packages/core` will be silently ignored.
- Attach configs use `"url": "ws://127.0.0.1:<port>"` with `localRoot`/`remoteRoot` both set to `${workspaceFolder}`. This lets the Bun debug adapter resolve source maps for files loaded through workspace symlinks (for example `node_modules/@cline/core` to `packages/core/src/...`), so breakpoints set in `packages/core` hit correctly.
## Logging Adapter
`cline` uses a `pino`-backed adapter that targets the core `BasicLogger` contract:
- CLI runtime passes `logger` directly into local `@cline/core` sessions.
- Hub-backed sessions include a serialized logger payload in `ChatStartSessionRequest.logger`; the runtime reconstructs the same `pino` settings and injects them into core.
- Hosts can attach stable runtime logger bindings (for example `clientId`, `clientType`, `clientApp`) through `RuntimeLoggerConfig.bindings`.
After login, OAuth credentials are persisted with `auth.expiresAt`, and `@cline/core` refreshes these tokens automatically during session turns. Provider auth and model settings should be changed through `cline auth`, the interactive config UI, or core provider-settings APIs rather than editing provider settings files directly.
On startup, `cline` also attempts a legacy settings import:
- Source files: `<CLINE_DATA_DIR>/globalState.json` and `<CLINE_DATA_DIR>/secrets.json`
- Target file: `<CLINE_DATA_DIR>/settings/providers.json` (or `CLINE_PROVIDER_SETTINGS_PATH`)
- Existing providers in `providers.json` are never overwritten
- Missing providers discovered in legacy files are merged into `providers.json`
- Migrated provider entries are annotated with `tokenSource: "migration"`
Custom provider registry notes:
- Provider runtime settings continue to persist in `<CLINE_DATA_DIR>/settings/providers.json`.
- Providers in `providers.json` can opt into the OpenAI Responses API with `"protocol": "openai-responses"`; this routes the runtime through the OpenAI client while keeping the user-defined provider ID, base URL, and model catalog.
- User-added OpenAI-compatible provider model catalogs are persisted in `<CLINE_DATA_DIR>/settings/models.json` (or alongside `CLINE_PROVIDER_SETTINGS_PATH`).
- `models.json` stores model lists by provider ID and is loaded by the runtime provider actions.
- Entries with only `models` extend an existing provider; entries with `provider` metadata register or override a custom provider.
## Features
- Streaming output - Responses stream in real-time
- Stable stream rendering - Prefers structured agent events and avoids duplicate text/tool output when chunk mirrors are also emitted
- Sub-agent spawning - `spawn_agent` is available by default unless disabled
- Recursive delegation - Sub-agents spawned via `spawn_agent` also receive `spawn_agent` when spawn is enabled
- Agent teams runtime - Team tools (tasks/mailbox/mission log) are available by default unless disabled
- Team tools keep related operations grouped where that improves usability (for example `team_task` uses an `action` field, while teammate/runs/mailbox/outcome tools stay separate)
- Pipe support - Accepts piped input for processing files
- Interactive mode - Multi-turn conversations
- JSON output mode - NDJSON records for run lifecycle, agent/team events, and final result (`--json`)
- Minimal dependencies - Fast startup time
- Multiple providers - Works with Anthropic, OpenAI, and more
- Configurable reasoning effort - Adjust model reasoning depth per run
## Runtime Ownership
- CLI renders runtime events and handles terminal UX.
- Core owns agent creation, runtime composition, and session message persistence.
- CLI does not directly instantiate `Agent` for chat/task execution.
- CLI does not perform direct file/db message persistence in run/interactive paths.
- CLI owns the user-instruction watcher (rules/workflows/skills) because prompt assembly uses rule context before session start; the watcher is disposed on all exit paths.
- RPC runtime uses the same prompt resolver and accepts optional `rules` in runtime config (or `systemPrompt` when fully prebuilt by the caller).
### Connector runtime behavior
- Telegram final assistant replies are sent through Telegram entity payloads with raw-text fallback; Google Chat and WhatsApp use the shared connector runtime formatting path.
- Assistant text streams incrementally into chat surfaces that use the shared runtime streaming path; Telegram sends final assistant replies after the turn completes.
- Tool activity is summarized as compact start/error messages with short argument previews.
- Required tool approvals are posted back into the chat thread and accept `Y` / `N` replies.
- Google Chat serves its webhook at `/api/webhooks/gchat`; configure the Google Chat App URL as `<base-url>/api/webhooks/gchat`.
- Webhook-based connectors are hosted through a shared CLI `node:http` server helper rather than `Bun.serve`.
- WhatsApp serves its webhook at `/api/webhooks/whatsapp`; configure the Meta callback URL as `<base-url>/api/webhooks/whatsapp`.
+122
View File
@@ -0,0 +1,122 @@
#!/usr/bin/env node
// Binary resolver for Cline CLI.
//
// This script runs with Node.js (available everywhere npm is) and finds the
// correct platform-specific compiled binary to execute. The compiled binary
// has Bun embedded, so users don't need Bun installed.
//
// Resolution order:
// 1. CLINE_BIN_PATH env var override
// 2. Cached binary at bin/.cline (created by postinstall)
// 3. Walk up node_modules to find the platform-specific package
const childProcess = require("child_process");
const fs = require("fs");
const path = require("path");
const os = require("os");
const scriptPath = fs.realpathSync(__filename);
const scriptDir = path.dirname(scriptPath);
const childEnv = {
...process.env,
CLINE_WRAPPER_PATH: scriptPath,
};
function run(target) {
const result = childProcess.spawnSync(target, process.argv.slice(2), {
stdio: "inherit",
env: childEnv,
});
if (result.error) {
console.error(result.error.message);
process.exit(1);
}
if (typeof result.status === "number") {
process.exit(result.status);
}
if (result.signal) {
process.kill(process.pid, result.signal);
process.exit(128);
}
process.exit(1);
}
// 1. Check env var override
const envPath = process.env.CLINE_BIN_PATH;
if (envPath) {
run(envPath);
}
// 2. Check cached binary
const cached = path.join(scriptDir, ".cline");
if (fs.existsSync(cached)) {
run(cached);
}
// 3. Detect platform and architecture
const platformMap = {
darwin: "darwin",
linux: "linux",
win32: "windows",
};
const archMap = {
x64: "x64",
arm64: "arm64",
};
let platform = platformMap[os.platform()];
if (!platform) {
platform = os.platform();
}
let arch = archMap[os.arch()];
if (!arch) {
arch = os.arch();
}
const base = "@cline/cli-" + platform + "-" + arch;
const binary = platform === "windows" ? "cline.exe" : "cline";
// Build fallback chain of package names to try
const names = [base];
function findBinary(startDir) {
let current = startDir;
for (;;) {
const modules = path.join(current, "node_modules");
if (fs.existsSync(modules)) {
for (const name of names) {
// Scoped package: @cline/cli-darwin-arm64 lives at
// node_modules/@cline/cli-darwin-arm64
const candidate = path.join(modules, name, "bin", binary);
if (fs.existsSync(candidate)) return candidate;
}
}
const parent = path.dirname(current);
if (parent === current) {
return undefined;
}
current = parent;
}
}
const resolved = findBinary(scriptDir);
if (!resolved) {
console.error(
"Could not find the Cline CLI binary for your platform.\n" +
"Your platform: " +
os.platform() +
" " +
os.arch() +
"\n" +
"Looked for: " +
names.map(function (n) {
return '"' + n + '"';
}).join(" or ") +
"\n\n" +
"Try reinstalling: npm install -g cline",
);
process.exit(1);
}
run(resolved);
+76
View File
@@ -0,0 +1,76 @@
import { copyFileSync, mkdirSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
function defineProcessEnv(name: string): string {
return JSON.stringify(process.env[name] ?? "");
}
const sourcemap = Bun.env.CLINE_SOURCEMAPS === "1" ? "linked" : "none";
const result = await Bun.build({
entrypoints: ["./src/index.ts"],
outdir: "./dist",
target: "node",
format: "esm",
sourcemap,
packages: "bundle", // Keep private workspace packages bundled so npm consumers do not need @cline/* at runtime.
external: [
// OpenTUI resolves a platform-specific native package at runtime.
// Bundling through that resolution path rewrites the import in a way that
// breaks Linux e2e runs from dist/. Keep React external too so OpenTUI and
// the CLI share one React runtime instead of ending up with duplicate hook
// dispatchers in the bundle.
"@opentui/core",
"@opentui/react",
"@opentui-ui/dialog",
"opentui-spinner",
"react",
"react/jsx-runtime",
"react/jsx-dev-runtime",
"react-devtools-core",
],
define: {
"process.env.NODE_ENV": '"production"',
"process.env.OTEL_TELEMETRY_ENABLED": defineProcessEnv(
"OTEL_TELEMETRY_ENABLED",
),
"process.env.OTEL_EXPORTER_OTLP_ENDPOINT": defineProcessEnv(
"OTEL_EXPORTER_OTLP_ENDPOINT",
),
"process.env.OTEL_METRICS_EXPORTER": defineProcessEnv(
"OTEL_METRICS_EXPORTER",
),
"process.env.OTEL_LOGS_EXPORTER": defineProcessEnv("OTEL_LOGS_EXPORTER"),
"process.env.OTEL_EXPORTER_OTLP_PROTOCOL": defineProcessEnv(
"OTEL_EXPORTER_OTLP_PROTOCOL",
),
"process.env.OTEL_METRIC_EXPORT_INTERVAL": defineProcessEnv(
"OTEL_METRIC_EXPORT_INTERVAL",
),
"process.env.OTEL_EXPORTER_OTLP_HEADERS": defineProcessEnv(
"OTEL_EXPORTER_OTLP_HEADERS",
),
},
env: "OTEL_*",
banner:
'import { createRequire as __createRequire } from "node:module"; const require = __createRequire(import.meta.url);',
});
if (result.logs.length > 0) {
for (const log of result.logs) {
console.warn(log);
}
}
const rootDir = dirname(fileURLToPath(import.meta.url));
const coreBootstrapPath = join(
rootDir,
"../../packages/core/dist/extensions/plugin-sandbox-bootstrap.js",
);
const cliBootstrapPath = join(
rootDir,
"./dist/extensions/plugin-sandbox-bootstrap.js",
);
mkdirSync(dirname(cliBootstrapPath), { recursive: true });
copyFileSync(coreBootstrapPath, cliBootstrapPath);
+79
View File
@@ -0,0 +1,79 @@
{
"name": "@cline/cli",
"displayName": "cline",
"version": "3.0.0",
"description": "[EXPERIMENTAL] A lightweight Cline CLI built with the Cline SDKs",
"type": "module",
"publishConfig": {
"access": "public"
},
"repository": {
"type": "git",
"url": "git+https://github.com/cline/sdk.git",
"directory": "apps/cli"
},
"bin": {
"cline": "src/index.ts"
},
"engines": {
"node": ">=22"
},
"main": "dist/index.js",
"exports": {
".": "./dist/index.js"
},
"files": [
"dist"
],
"scripts": {
"build": "rm -rf dist && bun run bun.mts",
"build:platforms": "bun script/build.ts --install-native-variants",
"build:platforms:single": "bun script/build.ts --single",
"prepack": "bun script/guard-direct-publish.ts",
"prepublishOnly": "bun script/guard-direct-publish.ts",
"publish:npm": "bun script/publish-npm.ts",
"publish:npm:dry": "bun script/publish-npm.ts --dry-run",
"dev": "CLINE_BUILD_ENV=development bun --conditions=development ./src/index.ts",
"clean": "rm -rf dist node_modules",
"typecheck": "tsc --noEmit",
"test": "bun run test:unit",
"test:unit": "vitest run --config vitest.config.ts",
"test:e2e": "vitest run --config vitest.e2e.config.ts",
"test:e2e:interactive": "vitest run --config vitest.interactive.e2e.config.ts",
"test:watch": "vitest --config vitest.config.ts",
"test:e2e:cli:tui": "cd src/tests && tui-test",
"link": "bun unlink && bun link"
},
"dependencies": {
"@agentclientprotocol/sdk": "^0.16.1",
"@clack/prompts": "^1.2.0",
"@chat-adapter/discord": "^4.23.0",
"@chat-adapter/gchat": "^4.23.0",
"@chat-adapter/linear": "^4.23.0",
"@chat-adapter/slack": "^4.23.0",
"@chat-adapter/telegram": "^4.23.0",
"@chat-adapter/whatsapp": "^4.23.0",
"@gramio/format": "^0.7.0",
"chat": "^4.23.0",
"commander": "^14.0.3",
"@opentui/core": "0.1.102",
"@opentui/react": "0.1.102",
"@opentui-ui/dialog": "^0.1.2",
"marked": "^15.0.12",
"open": "^10.2.0",
"opentui-spinner": "^0.0.6",
"pino": "^10.3.1",
"react": "19.2.4",
"react-reconciler": "0.32.0",
"react-devtools-core": "^7.0.1",
"yaml": "^2.8.2",
"zod": "^4.1.11"
},
"license": "Apache-2.0",
"devDependencies": {
"@cline/core": "workspace:*",
"@cline/shared": "workspace:*",
"@microsoft/tui-test": "^0.0.2",
"@types/react": "19.2.14"
}
}
+49
View File
@@ -0,0 +1,49 @@
export interface BuildOptions {
single: boolean;
skipInstall: boolean;
skipSdkBuild: boolean;
installNativeVariants: boolean;
}
export function parseBuildOptions(args: readonly string[]): BuildOptions {
return {
single: args.includes("--single"),
skipInstall: args.includes("--skip-install"),
skipSdkBuild: args.includes("--skip-sdk-build"),
installNativeVariants: args.includes("--install-native-variants"),
};
}
export function shouldInstallNativeVariants(input: {
options: BuildOptions;
opentuiVersion: string | undefined;
}): boolean {
return Boolean(
input.opentuiVersion &&
input.options.installNativeVariants &&
!input.options.skipInstall,
);
}
export function validateBuildOptions(input: {
options: BuildOptions;
opentuiVersion: string | undefined;
targetCount: number;
}): string | undefined {
if (input.targetCount === 0) {
return "No matching targets for this platform.";
}
if (
input.opentuiVersion &&
!input.options.single &&
!input.options.skipInstall &&
!input.options.installNativeVariants
) {
return [
"Cross-platform OpenTUI builds require native package variants.",
"Pass --install-native-variants to allow the build script to run bun install for all OpenTUI native packages.",
"Pass --skip-install only when those packages are already installed.",
].join("\n");
}
return undefined;
}
+224
View File
@@ -0,0 +1,224 @@
#!/usr/bin/env bun
import { existsSync, mkdirSync, readFileSync, realpathSync } from "node:fs";
import { join, relative, resolve } from "node:path";
import { $ } from "bun";
import {
parseBuildOptions,
shouldInstallNativeVariants,
validateBuildOptions,
} from "./build-options";
const cliDir = resolve(import.meta.dir, "..");
const rootDir = resolve(cliDir, "../..");
process.chdir(cliDir);
const pkg = JSON.parse(readFileSync(join(cliDir, "package.json"), "utf-8"));
const version: string = pkg.version;
const repository: unknown = pkg.repository;
console.log(`Building @cline/cli v${version}`);
const buildOptions = parseBuildOptions(process.argv.slice(2));
const allTargets: {
os: string;
arch: "arm64" | "x64";
}[] = [
{ os: "linux", arch: "arm64" },
{ os: "linux", arch: "x64" },
{ os: "darwin", arch: "arm64" },
{ os: "darwin", arch: "x64" },
{ os: "win32", arch: "x64" },
{ os: "win32", arch: "arm64" },
];
const targets = buildOptions.single
? allTargets.filter(
(item) => item.os === process.platform && item.arch === process.arch,
)
: allTargets;
const opentuiVersion = pkg.dependencies["@opentui/core"];
const optionsError = validateBuildOptions({
options: buildOptions,
opentuiVersion,
targetCount: targets.length,
});
if (optionsError) {
console.error(optionsError);
process.exit(1);
}
await $`rm -rf dist`;
// Pre-install all platform variants of native packages so cross-compilation
// can resolve them. Without this, Bun only has the host platform's native
// binary and cross-compiled builds fail to resolve @opentui/core's FFI layer.
if (shouldInstallNativeVariants({ options: buildOptions, opentuiVersion })) {
console.log(
`Installing all platform variants of @opentui/core@${opentuiVersion}...`,
);
await $`bun install --os="*" --cpu="*" @opentui/core@${opentuiVersion}`;
}
// Build the SDK first (the CLI bundles workspace packages)
if (!buildOptions.skipSdkBuild) {
console.log("Building SDK packages...");
await $`bun run build:sdk`.cwd(rootDir);
console.log("Building CLI bundle...");
await $`bun -F @cline/cli build`.cwd(rootDir);
}
const binaries: Record<string, string> = {};
function findOpenTuiParserWorker(): string {
const localPath = resolve(
cliDir,
"node_modules/@opentui/core/parser.worker.js",
);
const rootPath = resolve(
rootDir,
"node_modules/@opentui/core/parser.worker.js",
);
const parserWorkerPath = existsSync(localPath) ? localPath : rootPath;
return realpathSync(parserWorkerPath);
}
function getBunTarget(
item: (typeof allTargets)[number],
): Bun.Build.CompileTarget {
const targetOs = item.os === "win32" ? "windows" : item.os;
return `bun-${targetOs}-${item.arch}` as Bun.Build.CompileTarget;
}
async function buildCompiledBinary(input: {
bunTarget: Bun.Build.CompileTarget;
dirName: string;
outfile: string;
}): Promise<void> {
const parserWorker = findOpenTuiParserWorker();
const targetOs = input.bunTarget.includes("windows") ? "windows" : "posix";
const bunfsRoot = targetOs === "windows" ? "B:/~BUN/root/" : "/$bunfs/root/";
const parserWorkerPath = relative(rootDir, parserWorker).replaceAll(
"\\",
"/",
);
// Build to /tmp first so Bun's temp-file rename stays on one filesystem
// layer in containerized environments (virtiofs, overlayfs).
const entrypoint = join(cliDir, "src/index.ts");
const tmpDir = join("/tmp", `cline-build-${input.dirName}`);
const tmpOutfile = join(
tmpDir,
input.outfile.endsWith(".exe") ? "cline.exe" : "cline",
);
mkdirSync(tmpDir, { recursive: true });
process.chdir("/tmp");
const result = await Bun.build({
entrypoints: [entrypoint, parserWorker],
splitting: true,
compile: {
target: input.bunTarget,
outfile: tmpOutfile,
},
minify: true,
external: ["@anthropic-ai/vertex-sdk"],
define: {
OTUI_TREE_SITTER_WORKER_PATH: bunfsRoot + parserWorkerPath,
},
throw: false,
});
process.chdir(cliDir);
if (!result.success) {
console.error(`Build failed for ${input.dirName}:`);
for (const log of result.logs) {
console.error(log);
}
process.exit(1);
}
await $`cp ${tmpOutfile} ${input.outfile} && chmod 755 ${input.outfile}`;
await $`rm -rf ${tmpDir}`;
}
for (const item of targets) {
// npm treats "win32" specially in os field, but for package naming use "windows"
const displayOs = item.os === "win32" ? "windows" : item.os;
const name = `@cline/cli-${displayOs}-${item.arch}`;
const dirName = `cli-${displayOs}-${item.arch}`;
const binaryName = item.os === "win32" ? "cline.exe" : "cline";
const bunTarget = getBunTarget(item);
console.log(`\nBuilding ${name} (target: ${bunTarget})...`);
const outDir = join(cliDir, `dist/${dirName}/bin`);
mkdirSync(outDir, { recursive: true });
const outfile = join(outDir, binaryName);
await buildCompiledBinary({ bunTarget, dirName, outfile });
// Smoke test: only run on current platform
if (item.os === process.platform && item.arch === process.arch) {
console.log(` Smoke test: ${outfile} --version`);
try {
const output = await $`${outfile} --version`.text();
const actualVersion = output.trim();
if (actualVersion !== version) {
throw new Error(
`Expected --version to print ${version}, got ${actualVersion}`,
);
}
console.log(` Passed: ${actualVersion}`);
} catch (e) {
console.error(` Smoke test FAILED for ${name}:`, e);
process.exit(1);
}
}
// Copy plugin sandbox bootstrap if it exists
const bootstrapSrc = join(
rootDir,
"packages/core/dist/extensions/plugin-sandbox-bootstrap.js",
);
if (existsSync(bootstrapSrc)) {
const bootstrapDir = join(cliDir, `dist/${dirName}/extensions`);
mkdirSync(bootstrapDir, { recursive: true });
const content = readFileSync(bootstrapSrc);
await Bun.write(join(bootstrapDir, "plugin-sandbox-bootstrap.js"), content);
}
// Generate platform package.json
await Bun.write(
join(cliDir, `dist/${dirName}/package.json`),
`${JSON.stringify(
{
name,
version,
description: `Cline CLI binary for ${displayOs} ${item.arch}`,
os: [item.os],
cpu: [item.arch],
...(repository ? { repository } : {}),
bin: {
cline: `bin/${binaryName}`,
},
},
null,
2,
)}\n`,
);
binaries[name] = version;
console.log(` Built ${name}`);
}
console.log(`\nBuild complete. ${Object.keys(binaries).length} targets built.`);
console.log("Packages:");
for (const [name, ver] of Object.entries(binaries)) {
console.log(` ${name}@${ver}`);
}
export { binaries, version };
@@ -0,0 +1,17 @@
#!/usr/bin/env bun
export const DIRECT_PUBLISH_GUARD_MESSAGE = [
"Direct packaging or publishing from apps/cli is disabled.",
"The source package points its development bin at src/index.ts, while the npm package is generated under dist/cli.",
"Run `bun run build:platforms` first, then `bun run publish:npm:dry` to preview the generated npm packages.",
"Use `bun run publish:npm` to publish those generated packages.",
].join("\n");
export function shouldAllowDirectPublish(env: NodeJS.ProcessEnv): boolean {
return env.CLINE_ALLOW_DIRECT_PUBLISH === "1";
}
if (import.meta.main && !shouldAllowDirectPublish(process.env)) {
console.error(DIRECT_PUBLISH_GUARD_MESSAGE);
process.exit(1);
}
+89
View File
@@ -0,0 +1,89 @@
#!/usr/bin/env node
// Post-install script for Cline CLI.
//
// Creates a hard link (or copy fallback) from the platform-specific binary
// to bin/.cline for fast startup on subsequent runs.
//
// This script must use only Node.js APIs (no Bun) since it runs via
// "node script/postinstall.mjs" in the npm lifecycle.
import fs from "node:fs";
import { createRequire } from "node:module";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const require = createRequire(import.meta.url);
function main() {
if (os.platform() === "win32") {
// On Windows, npm creates .cmd shims from the bin field.
// The resolver script handles binary lookup at runtime.
console.log("Windows detected: skipping binary cache setup");
return;
}
const platformMap = {
darwin: "darwin",
linux: "linux",
};
const platform = platformMap[os.platform()] || os.platform();
const arch = os.arch();
const packageName = `@cline/cli-${platform}-${arch}`;
const binaryName = "cline";
let binaryPath;
try {
const packageJsonPath = require.resolve(`${packageName}/package.json`);
const packageDir = path.dirname(packageJsonPath);
binaryPath = path.join(packageDir, "bin", binaryName);
if (!fs.existsSync(binaryPath)) {
throw new Error(`Binary not found at ${binaryPath}`);
}
} catch (_error) {
// Platform package not available. The resolver script will find
// it at runtime by walking node_modules. This is expected on
// platforms we don't ship binaries for.
console.log(`Note: ${packageName} not found, skipping binary cache`);
return;
}
const binDir =
path.basename(__dirname) === "script"
? path.join(__dirname, "..", "bin")
: path.join(__dirname, "bin");
const target = path.join(binDir, ".cline");
// Ensure bin directory exists
if (!fs.existsSync(binDir)) {
fs.mkdirSync(binDir, { recursive: true });
}
// Remove existing cached binary
if (fs.existsSync(target)) {
fs.unlinkSync(target);
}
// Hard link preferred (shares disk space), copy as fallback
// (hard links fail on some filesystems like NFS or cross-device)
try {
fs.linkSync(binaryPath, target);
} catch {
fs.copyFileSync(binaryPath, target);
}
fs.chmodSync(target, 0o755);
console.log(`Cached cline binary at ${target}`);
}
try {
main();
} catch (error) {
// postinstall failures should never block npm install.
// The resolver script will find the binary at runtime.
console.error(`postinstall: ${error.message}`);
process.exit(0);
}
+272
View File
@@ -0,0 +1,272 @@
#!/usr/bin/env bun
// Publishes cline and all platform-specific binary packages to npm.
//
// Usage:
// bun script/publish-npm.ts # publish with "latest" tag
// bun script/publish-npm.ts --tag next # publish with "next" tag
// bun script/publish-npm.ts --dry-run # preview without publishing
//
// Prerequisites:
// - Run script/build.ts first to generate dist/ packages
// - GitHub trusted publishing or `npm login` for authentication
import { existsSync, readdirSync, readFileSync, rmSync } from "node:fs";
import { join } from "node:path";
import { parseArgs } from "node:util";
import { $ } from "bun";
const cliDir = join(import.meta.dir, "..");
process.chdir(cliDir);
const { values } = parseArgs({
args: Bun.argv.slice(2),
options: {
"dry-run": { type: "boolean", default: false },
tag: { type: "string", default: "latest" },
},
strict: true,
});
const dryRun = values["dry-run"] ?? false;
const npmTag = values.tag ?? "latest";
const wrapperPackageName = "cline";
const expectedPlatformPackages = [
"@cline/cli-darwin-arm64",
"@cline/cli-darwin-x64",
"@cline/cli-linux-arm64",
"@cline/cli-linux-x64",
"@cline/cli-windows-arm64",
"@cline/cli-windows-x64",
] as const;
interface PlatformPackageManifest {
name: string;
version: string;
os: string[];
}
function isRecord(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
function isStringArray(value: unknown): value is string[] {
return (
Array.isArray(value) && value.every((item) => typeof item === "string")
);
}
function isPlatformPackageManifest(
value: unknown,
): value is PlatformPackageManifest {
return (
isRecord(value) &&
typeof value.name === "string" &&
typeof value.version === "string" &&
isStringArray(value.os)
);
}
function removePackedTarballs(dir: string): void {
for (const entry of readdirSync(dir)) {
if (entry.endsWith(".tgz")) {
rmSync(join(dir, entry), { force: true });
}
}
}
async function npmPackageVersionExists(
name: string,
version: string,
): Promise<boolean> {
const result = Bun.spawnSync(
["npm", "view", `${name}@${version}`, "version"],
{
cwd: cliDir,
stdout: "ignore",
stderr: "ignore",
},
);
return result.exitCode === 0;
}
async function publishPackage(input: {
name: string;
version: string;
dir: string;
tag: string;
dryRun: boolean;
}): Promise<void> {
if (process.platform !== "win32") {
await $`chmod -R 755 .`.cwd(input.dir);
}
if (input.dryRun) {
console.log(` [dry-run] Would publish ${input.name}@${input.version}`);
return;
}
if (await npmPackageVersionExists(input.name, input.version)) {
console.log(` ${input.name}@${input.version} already exists, skipping`);
return;
}
console.log(` Publishing ${input.name}@${input.version}...`);
removePackedTarballs(input.dir);
await $`bun pm pack`.cwd(input.dir);
await $`npm publish *.tgz --access public --tag ${input.tag}`.cwd(input.dir);
console.log(` Published ${input.name}@${input.version}`);
}
// Discover built platform packages from dist/
const binaries: Record<string, string> = {};
for await (const filepath of new Bun.Glob("*/package.json").scan({
cwd: join(cliDir, "dist"),
})) {
const pkg: unknown = JSON.parse(
readFileSync(join(cliDir, "dist", filepath), "utf-8"),
);
if (isPlatformPackageManifest(pkg)) {
binaries[pkg.name] = pkg.version;
}
}
if (Object.keys(binaries).length === 0) {
console.error("No platform packages found in dist/.");
console.error("Run `bun script/build.ts` first.");
process.exit(1);
}
const missingPackages = expectedPlatformPackages.filter(
(name) => !(name in binaries),
);
if (missingPackages.length > 0) {
console.error("Missing platform packages in dist/:");
for (const name of missingPackages) {
console.error(` ${name}`);
}
process.exit(1);
}
const versions = new Set(Object.values(binaries));
if (versions.size !== 1) {
console.error("Platform package versions do not match:");
for (const [name, packageVersion] of Object.entries(binaries).sort()) {
console.error(` ${name}@${packageVersion}`);
}
process.exit(1);
}
const version = Object.values(binaries)[0];
const sourcePkg: unknown = JSON.parse(
readFileSync(join(cliDir, "package.json"), "utf-8"),
);
const sourcePkgRecord = isRecord(sourcePkg) ? sourcePkg : {};
const sourceVersion =
"version" in sourcePkgRecord && typeof sourcePkgRecord.version === "string"
? sourcePkgRecord.version
: undefined;
if (sourceVersion !== version) {
console.error(
`Built package version ${version} does not match apps/cli/package.json version ${sourceVersion ?? "(missing)"}.`,
);
process.exit(1);
}
const sourceRepository =
"repository" in sourcePkgRecord ? sourcePkgRecord.repository : undefined;
console.log(`Publishing ${wrapperPackageName} v${version}`);
console.log(` Tag: ${npmTag}`);
console.log(` Dry run: ${dryRun}`);
console.log(` Platform packages: ${Object.keys(binaries).length}`);
for (const name of Object.keys(binaries)) {
console.log(` ${name}`);
}
// Step 1: Publish platform-specific packages (in parallel)
console.log("\nPublishing platform packages...");
const platformTasks = Object.keys(binaries)
.sort()
.map(async (name) => {
const dirName = name.replace("@cline/", "");
const pkgDir = join(cliDir, "dist", dirName);
await publishPackage({
name,
version,
dir: pkgDir,
tag: npmTag,
dryRun,
});
});
await Promise.all(platformTasks);
// Step 2: Generate and publish the main wrapper package
console.log("\nPreparing main package...");
const mainPkgDir = join(cliDir, "dist", "cli");
await $`rm -rf ${mainPkgDir}`;
await $`mkdir -p ${mainPkgDir}`;
await $`cp -r ${join(cliDir, "bin")} ${join(mainPkgDir, "bin")}`;
await $`cp ${join(cliDir, "script/postinstall.mjs")} ${join(mainPkgDir, "postinstall.mjs")}`;
// Copy LICENSE from repo root if it exists
const licenseFrom = join(cliDir, "../../LICENSE");
if (existsSync(licenseFrom)) {
await $`cp ${licenseFrom} ${join(mainPkgDir, "LICENSE")}`;
}
const mainPkg: unknown = JSON.parse(
readFileSync(join(cliDir, "package.json"), "utf-8"),
);
const mainPkgRecord = isRecord(mainPkg) ? mainPkg : {};
const description =
"description" in mainPkgRecord &&
typeof mainPkgRecord.description === "string"
? mainPkgRecord.description
: undefined;
const license =
"license" in mainPkgRecord && typeof mainPkgRecord.license === "string"
? mainPkgRecord.license
: undefined;
const wrapperPackageJson = {
name: wrapperPackageName,
version,
description: description || "Cline CLI",
license: license || "Apache-2.0",
...(sourceRepository ? { repository: sourceRepository } : {}),
bin: {
cline: "./bin/cline",
},
scripts: {
postinstall: "node ./postinstall.mjs || true",
},
optionalDependencies: binaries,
};
await Bun.write(
join(mainPkgDir, "package.json"),
`${JSON.stringify(wrapperPackageJson, null, 2)}\n`,
);
if (dryRun) {
console.log(
` [dry-run] Would publish ${wrapperPackageName}@${version} with tag ${npmTag}`,
);
console.log("\nDry run complete. No packages were published.");
} else {
await publishPackage({
name: wrapperPackageName,
version,
dir: mainPkgDir,
tag: npmTag,
dryRun: false,
});
console.log(
`\nPublished ${wrapperPackageName}@${version} with tag ${npmTag}`,
);
console.log("\nInstall with:");
console.log(` npm install -g ${wrapperPackageName}`);
}
+10
View File
@@ -0,0 +1,10 @@
{
"version": 1,
"skills": {
"opentui": {
"source": "msmps/opentui-skill",
"sourceType": "github",
"computedHash": "2915038bb3297e759f740f9e6bfa885ed80a4a7fe945825f5874b48db9ce0fd2"
}
}
}
+644
View File
@@ -0,0 +1,644 @@
import type {
Agent,
AgentSideConnection,
AuthenticateRequest,
AuthenticateResponse,
CancelNotification,
ContentBlock,
InitializeRequest,
InitializeResponse,
NewSessionRequest,
NewSessionResponse,
PromptRequest,
PromptResponse,
SessionConfigOption,
SetSessionConfigOptionRequest,
SetSessionConfigOptionResponse,
SetSessionModelRequest,
SetSessionModelResponse,
SetSessionModeRequest,
SetSessionModeResponse,
StopReason,
} from "@agentclientprotocol/sdk";
import { PROTOCOL_VERSION, RequestError } from "@agentclientprotocol/sdk";
import {
type AgentEvent,
type ClineCore,
Llms,
ProviderSettingsManager,
SessionSource,
} from "@cline/core";
import type { Message } from "@cline/shared";
import { getPersistedProviderApiKey } from "../commands/auth";
import { resolveSystemPrompt } from "../runtime/prompt";
import { subscribeToAgentEvents } from "../runtime/session-events";
import { createCliCore } from "../session/session";
import { getCliBuildInfo } from "../utils/common";
import { randomSessionId, resolveWorkspaceRoot } from "../utils/helpers";
import type { Config } from "../utils/types";
import {
ACP_AUTH_METHODS,
type AcpAuthMethodId,
type AcpAuthResult,
authenticateAcpProvider,
isAcpAuthMethodId,
} from "./auth";
import { requestAcpToolApproval } from "./permissions";
import {
forwardAgentEvent,
sendConfigOptionUpdate,
sendCurrentModeUpdate,
sendSessionInfoUpdate,
} from "./session-updates";
interface SessionState {
id: string;
cwd: string;
mcpServers: NewSessionRequest["mcpServers"];
/** Current agent mode — "plan" (read-only) or "act" (full). */
currentMode: "plan" | "act";
/** Current provider id for the session. */
currentProviderId: string;
/** Current model id for the session. */
currentModelId: string;
/** Active session manager for the running agent, if any. */
sessionManager?: ClineCore;
/** Internal session id within the session manager. */
activeSessionId?: string;
/** Abort controller for the current prompt, if running. */
abortController?: AbortController;
/** Unsubscribe function for the agent event listener. */
unsubscribe?: () => void;
/** Messages to inject into the next session manager for conversation continuity. */
pendingInitialMessages?: Message[];
}
export class AcpAgent implements Agent {
private sessions = new Map<string, SessionState>();
private readonly conn: AgentSideConnection;
private readonly providerSettingsManager = new ProviderSettingsManager();
/** Set after a successful `authenticate` call. */
private authResult?: AcpAuthResult;
constructor(conn: AgentSideConnection) {
this.conn = conn;
}
async initialize(_params: InitializeRequest): Promise<InitializeResponse> {
const { version, name } = getCliBuildInfo();
return {
protocolVersion: PROTOCOL_VERSION,
agentCapabilities: {
loadSession: true,
promptCapabilities: {
image: true,
audio: false,
embeddedContext: false,
},
},
agentInfo: {
name,
version,
},
authMethods: ACP_AUTH_METHODS.map((m) => ({
id: m.id,
name: m.name,
})),
};
}
async newSession(params: NewSessionRequest): Promise<NewSessionResponse> {
// Require authentication unless an API key is provided via env var.
if (!this.authResult && !process.env.CLINE_API_KEY) {
// Check for valid persisted credentials from a previous session
// before forcing the client to re-authenticate.
this.authResult = this.tryRestoreAuth();
if (!this.authResult) {
throw RequestError.authRequired(
undefined,
"Call authenticate before creating a session",
);
}
}
const sessionId = randomSessionId();
const defaultMode = "act";
const providerId =
process.env.CLINE_PROVIDER ?? this.authResult?.providerId ?? "cline";
const defaultModelId =
process.env.CLINE_MODEL ?? "anthropic/claude-sonnet-4.6";
this.sessions.set(sessionId, {
id: sessionId,
cwd: params.cwd,
mcpServers: params.mcpServers,
currentMode: defaultMode,
currentProviderId: providerId,
currentModelId: defaultModelId,
});
const providerModels = await Llms.getModelsForProvider(providerId);
const availableModels = Object.entries(providerModels).map(
([modelId, info]) => ({
modelId,
name: info.name ?? modelId,
description: info.description,
}),
);
return {
sessionId,
modes: {
availableModes: [
{
id: "plan",
name: "Plan",
description:
"Explore the codebase and plan changes without modifying files",
},
{
id: "act",
name: "Act",
description: "Make changes to the codebase",
},
],
currentModeId: defaultMode,
},
models: {
availableModels,
currentModelId: defaultModelId,
},
configOptions: [
await buildProviderConfigOption(providerId),
buildModelConfigOption(defaultModelId, providerModels),
buildModeConfigOption(defaultMode),
],
};
}
async prompt(params: PromptRequest): Promise<PromptResponse> {
const session = this.sessions.get(params.sessionId);
if (!session) {
throw new Error(`unknown session: ${params.sessionId}`);
}
const promptText = extractTextFromContentBlocks(params.prompt);
if (!promptText) {
return { stopReason: "end_turn" };
}
const abortController = new AbortController();
session.abortController = abortController;
// If cancel() was already called before prompt() started, bail early.
if (abortController.signal.aborted) {
session.abortController = undefined;
return { stopReason: "cancelled" };
}
await this.ensureSessionManager(session, params.sessionId);
// Re-check after async initialization.
if (abortController.signal.aborted) {
session.abortController = undefined;
return { stopReason: "cancelled" };
}
let stopReason: StopReason = "end_turn";
try {
const onAbort = () => {
if (session.activeSessionId && session.sessionManager) {
session.sessionManager
.abort(session.activeSessionId, abortController.signal.reason)
.catch(() => {});
}
};
abortController.signal.addEventListener("abort", onAbort, {
once: true,
});
const activeSessionId = session.activeSessionId;
const sessionManager = session.sessionManager;
if (!activeSessionId || !sessionManager) {
throw new Error("Session manager was not initialized");
}
const result = await sessionManager.send({
sessionId: activeSessionId,
prompt: promptText,
});
if (result) {
stopReason = mapFinishReason(result.finishReason);
}
} finally {
session.abortController = undefined;
}
sendSessionInfoUpdate(this.conn, params.sessionId, {
updatedAt: new Date().toISOString(),
});
return { stopReason };
}
async cancel(params: CancelNotification): Promise<void> {
const session = this.sessions.get(params.sessionId);
if (!session) {
return;
}
// Abort the controller — this handles all stages of prompt():
// - If prompt() hasn't started the agent yet, the signal check will
// short-circuit and resolve with stopReason: 'cancelled'.
// - If the agent is running, the "abort" event listener on the signal
// will call sessionManager.abort() to stop it.
if (session.abortController) {
session.abortController.abort();
}
}
async setSessionMode(
params: SetSessionModeRequest,
): Promise<SetSessionModeResponse> {
if (params.modeId !== "plan" && params.modeId !== "act") {
throw new Error(
`invalid modeId: ${params.modeId} (must be "plan" or "act")`,
);
}
const session = this.sessions.get(params.sessionId);
if (!session) {
throw new Error(`unknown session: ${params.sessionId}`);
}
session.currentMode = params.modeId;
sendCurrentModeUpdate(this.conn, params.sessionId, params.modeId);
return {};
}
async unstable_setSessionModel(
params: SetSessionModelRequest,
): Promise<SetSessionModelResponse> {
const session = this.sessions.get(params.sessionId);
if (!session) {
throw new Error(`unknown session: ${params.sessionId}`);
}
session.currentModelId = params.modelId;
if (session.sessionManager && session.activeSessionId) {
await session.sessionManager.updateSessionModel?.(
session.activeSessionId,
params.modelId,
);
}
return {};
}
async setSessionConfigOption(
params: SetSessionConfigOptionRequest,
): Promise<SetSessionConfigOptionResponse> {
const session = this.sessions.get(params.sessionId);
if (!session) {
throw new Error(`unknown session: ${params.sessionId}`);
}
const value = params.value as string;
switch (params.configId) {
case "provider": {
if (process.env.CLINE_PROVIDER) {
throw RequestError.invalidParams(
undefined,
"Cannot change provider: CLINE_PROVIDER environment variable is set",
);
}
if (!isAcpAuthMethodId(value)) {
throw RequestError.invalidParams(
undefined,
`Unknown provider: ${value}`,
);
}
session.currentProviderId = value;
// Tear down the old session manager so ensureSessionManager()
// creates a fresh one with the new provider on the next prompt().
await this.teardownSessionManager(session);
// If current model doesn't exist in new provider, reset to first available
const providerModels = await Llms.getModelsForProvider(value);
const modelIds = Object.keys(providerModels);
const fallbackModelId = modelIds[0];
if (
!modelIds.includes(session.currentModelId) &&
fallbackModelId !== undefined
) {
session.currentModelId = fallbackModelId;
}
break;
}
case "model": {
session.currentModelId = value;
if (session.sessionManager && session.activeSessionId) {
await session.sessionManager.updateSessionModel?.(
session.activeSessionId,
value,
);
}
break;
}
case "mode": {
if (value !== "plan" && value !== "act") {
throw RequestError.invalidParams(
undefined,
`Invalid mode: ${value} (must be "plan" or "act")`,
);
}
session.currentMode = value;
sendCurrentModeUpdate(this.conn, params.sessionId, value);
break;
}
default:
throw RequestError.invalidParams(
undefined,
`Unknown config option: ${params.configId}`,
);
}
const configOptions = await buildAllConfigOptions(session);
sendConfigOptionUpdate(this.conn, params.sessionId, configOptions);
return { configOptions };
}
async authenticate(
params: AuthenticateRequest,
): Promise<AuthenticateResponse | undefined> {
if (!isAcpAuthMethodId(params.methodId)) {
throw RequestError.invalidParams(
undefined,
`Unsupported auth method: ${params.methodId}`,
);
}
this.authResult = await authenticateAcpProvider(
params.methodId,
this.providerSettingsManager,
);
return {};
}
async shutdown(): Promise<void> {
for (const session of this.sessions.values()) {
if (session.abortController) {
session.abortController.abort();
}
if (session.unsubscribe) {
session.unsubscribe();
}
if (session.sessionManager && session.activeSessionId) {
await session.sessionManager
.abort(session.activeSessionId)
.catch(() => {});
await session.sessionManager.dispose("acp_shutdown").catch(() => {});
}
}
this.sessions.clear();
}
/**
* Attempt to restore authentication from persisted provider settings.
*
* When a previous session already completed an OAuth login the credentials
* are saved to disk via `ProviderSettingsManager`. On a fresh ACP
* connection we check each known auth method for a persisted API key so
* the client doesn't have to re-authenticate every time.
*/
private tryRestoreAuth(): AcpAuthResult | undefined {
for (const method of ACP_AUTH_METHODS) {
const settings = this.providerSettingsManager.getProviderSettings(
method.id,
);
const apiKey = getPersistedProviderApiKey(method.id, settings);
if (apiKey) {
return { providerId: method.id as AcpAuthMethodId, apiKey };
}
}
return undefined;
}
/**
* Tear down the current session manager, preserving conversation messages
* so they can be replayed into a new session manager.
*/
private async teardownSessionManager(session: SessionState): Promise<void> {
if (!session.sessionManager) {
return;
}
// Save conversation history before teardown.
if (session.activeSessionId) {
session.pendingInitialMessages =
await session.sessionManager.readMessages(session.activeSessionId);
}
if (session.abortController) {
session.abortController.abort();
}
if (session.unsubscribe) {
session.unsubscribe();
session.unsubscribe = undefined;
}
if (session.activeSessionId) {
await session.sessionManager
.abort(session.activeSessionId)
.catch(() => {});
}
await session.sessionManager.dispose("provider_change").catch(() => {});
session.sessionManager = undefined;
session.activeSessionId = undefined;
}
/**
* Lazily create and start the session manager for this ACP session.
* After the first call the manager persists across prompt() calls so that
* conversation history is maintained.
*/
private async ensureSessionManager(
session: SessionState,
acpSessionId: string,
): Promise<void> {
if (session.sessionManager) {
return;
}
const config = await this.buildConfig(session);
const sessionManager = await createCliCore({
toolPolicies: config.toolPolicies,
capabilities: {
requestToolApproval: (request) =>
requestAcpToolApproval(this.conn, acpSessionId, request),
},
cwd: config.cwd,
workspaceRoot: config.workspaceRoot,
});
session.unsubscribe = subscribeToAgentEvents(
sessionManager,
(event: AgentEvent) => {
forwardAgentEvent(this.conn, acpSessionId, event);
},
);
const initialMessages = session.pendingInitialMessages;
session.pendingInitialMessages = undefined;
const started = await sessionManager.start({
source: SessionSource.CLI,
config,
interactive: true,
initialMessages,
});
session.sessionManager = sessionManager;
session.activeSessionId = started.sessionId;
}
private async buildConfig(session: SessionState): Promise<Config> {
const cwd = session.cwd || process.cwd();
// Resolve credentials: env vars take precedence, then session provider.
const providerId = process.env.CLINE_PROVIDER ?? session.currentProviderId;
const apiKey = process.env.CLINE_API_KEY ?? this.authResult?.apiKey ?? "";
const systemPrompt = await resolveSystemPrompt({
cwd,
providerId,
mode: session.currentMode,
});
return {
providerId,
modelId: session.currentModelId,
apiKey,
systemPrompt,
execution: undefined,
verbose: false,
sandbox: false,
thinking: false,
outputMode: "text",
mode: session.currentMode,
defaultToolAutoApprove: false,
toolPolicies: { "*": { autoApprove: false } },
enableSpawnAgent: true,
enableAgentTeams: false,
enableTools: true,
cwd,
workspaceRoot: resolveWorkspaceRoot(cwd),
};
}
}
async function buildProviderConfigOption(
currentProviderId: string,
): Promise<SessionConfigOption> {
const options = await Promise.all(
ACP_AUTH_METHODS.map(async (m) => {
const provider = await Llms.getProvider(m.id);
return {
value: m.id,
name: provider?.name ?? m.id,
};
}),
);
return {
type: "select",
id: "provider",
name: "Provider",
description: "The authentication provider to use",
category: "model",
currentValue: currentProviderId,
options,
};
}
function buildModelConfigOption(
currentModelId: string,
providerModels: Record<string, { name?: string; description?: string }>,
): SessionConfigOption {
return {
type: "select",
id: "model",
name: "Model",
category: "model",
currentValue: currentModelId,
options: Object.entries(providerModels).map(([modelId, info]) => ({
value: modelId,
name: info.name ?? modelId,
description: info.description,
})),
};
}
function buildModeConfigOption(currentMode: string): SessionConfigOption {
return {
type: "select",
id: "mode",
name: "Session Mode",
description: "Controls whether the agent can modify files",
category: "mode",
currentValue: currentMode,
options: [
{
value: "plan",
name: "Plan",
description:
"Explore the codebase and plan changes without modifying files",
},
{
value: "act",
name: "Act",
description: "Make changes to the codebase",
},
],
};
}
async function buildAllConfigOptions(
session: SessionState,
): Promise<SessionConfigOption[]> {
const [providerOption, providerModels] = await Promise.all([
buildProviderConfigOption(session.currentProviderId),
Llms.getModelsForProvider(session.currentProviderId),
]);
return [
providerOption,
buildModelConfigOption(session.currentModelId, providerModels),
buildModeConfigOption(session.currentMode),
];
}
function extractTextFromContentBlocks(blocks: ContentBlock[]): string {
return blocks
.filter((b): b is ContentBlock & { type: "text" } => b.type === "text")
.map((b) => b.text)
.join("\n");
}
function mapFinishReason(reason: string): StopReason {
switch (reason) {
case "completed":
return "end_turn";
case "aborted":
return "cancelled";
case "max_iterations":
return "max_turn_requests";
case "mistake_limit":
return "end_turn";
default:
return "end_turn";
}
}
+137
View File
@@ -0,0 +1,137 @@
import type { ProviderSettings, ProviderSettingsManager } from "@cline/core";
import { getClineEnvironmentConfig } from "@cline/shared";
import type { OAuthCredentials } from "../commands/auth";
import {
getPersistedProviderApiKey,
saveOAuthProviderSettings,
toProviderApiKey,
} from "../commands/auth";
import { writeErr } from "../utils/output";
/**
* Supported ACP OAuth provider IDs.
*/
export const ACP_AUTH_METHODS = [
{ id: "cline", name: "Sign in with Cline" },
{ id: "openai-codex", name: "Sign in with ChatGPT Subscription" },
] as const;
export type AcpAuthMethodId = (typeof ACP_AUTH_METHODS)[number]["id"];
export function isAcpAuthMethodId(id: string): id is AcpAuthMethodId {
return ACP_AUTH_METHODS.some((m) => m.id === id);
}
/**
* Perform an OAuth login for the given provider in ACP mode.
*
* Since stdin/stdout are used for the JSON-RPC transport, all user-facing
* output is written to stderr and URLs are opened via the `open` package.
* If the OAuth flow requires interactive prompts (rare), defaults are used
* when available; otherwise an error is thrown.
*/
async function performOAuthLogin(
providerId: AcpAuthMethodId,
existingSettings: ProviderSettings | undefined,
): Promise<OAuthCredentials> {
const [{ createOAuthClientCallbacks }, { default: open }, coreOAuth] =
await Promise.all([
import("@cline/core"),
import("open"),
import("@cline/core").then((m) => ({
loginClineOAuth: m.loginClineOAuth as (input: {
useWorkOSDeviceAuth?: boolean;
apiBaseUrl: string;
callbacks: {
onAuth: (info: { url: string; instructions?: string }) => void;
onPrompt: (prompt: {
message: string;
defaultValue?: string;
}) => Promise<string>;
onManualCodeInput?: () => Promise<string>;
};
}) => Promise<OAuthCredentials>,
loginOpenAICodex: m.loginOpenAICodex as (input: {
onAuth: (info: { url: string; instructions?: string }) => void;
onPrompt: (prompt: {
message: string;
defaultValue?: string;
}) => Promise<string>;
onManualCodeInput?: () => Promise<string>;
}) => Promise<OAuthCredentials>,
})),
]);
const callbacks = createOAuthClientCallbacks({
onPrompt: ({ defaultValue }) => {
if (defaultValue) {
return Promise.resolve(defaultValue);
}
return Promise.reject(
new Error(
"OAuth flow requires interactive input which is unavailable in ACP mode",
),
);
},
onOutput: (message) => writeErr(`[acp/auth] ${message}`),
openUrl: (url) => open(url, { wait: false }).then(() => undefined),
onOpenUrlError: ({ url }) => {
writeErr(
`[acp/auth] Could not open browser automatically. Open this URL manually:\n${url}`,
);
},
});
if (providerId === "cline") {
return coreOAuth.loginClineOAuth({
apiBaseUrl:
existingSettings?.baseUrl?.trim() ||
getClineEnvironmentConfig().apiBaseUrl,
callbacks,
useWorkOSDeviceAuth: true,
});
}
// openai-codex
return coreOAuth.loginOpenAICodex(callbacks);
}
export interface AcpAuthResult {
providerId: AcpAuthMethodId;
apiKey: string;
}
/**
* Authenticate via OAuth for the given ACP auth method.
*
* Uses `ProviderSettingsManager` to check for existing credentials first,
* falling back to a fresh OAuth login if needed.
*/
export async function authenticateAcpProvider(
methodId: AcpAuthMethodId,
providerSettingsManager: ProviderSettingsManager,
): Promise<AcpAuthResult> {
const existing = providerSettingsManager.getProviderSettings(methodId);
// Check for already-stored credentials.
const existingKey = getPersistedProviderApiKey(methodId, existing);
if (existingKey) {
writeErr(`[acp/auth] Using existing credentials for ${methodId}`);
return { providerId: methodId, apiKey: existingKey };
}
// Perform a fresh OAuth login.
writeErr(`[acp/auth] Starting OAuth login for ${methodId}`);
const credentials = await performOAuthLogin(methodId, existing);
saveOAuthProviderSettings(
providerSettingsManager,
methodId,
existing,
credentials,
);
const apiKey = toProviderApiKey(methodId, credentials);
writeErr(`[acp/auth] Successfully authenticated with ${methodId}`);
return { providerId: methodId, apiKey };
}
+23
View File
@@ -0,0 +1,23 @@
import { Readable, Writable } from "node:stream";
import { writeErr } from "../utils/output";
export async function runAcpMode(): Promise<void> {
const { AgentSideConnection, ndJsonStream } = await import(
"@agentclientprotocol/sdk"
);
const { AcpAgent } = await import("./acpAgent");
writeErr("[acp] starting ACP mode over stdio…");
const stream = ndJsonStream(
Writable.toWeb(process.stdout) as WritableStream<Uint8Array>,
Readable.toWeb(process.stdin) as ReadableStream<Uint8Array>,
);
const connection = new AgentSideConnection((conn) => {
return new AcpAgent(conn);
}, stream);
// Keep the process alive until the connection closes
await connection.closed;
}
+133
View File
@@ -0,0 +1,133 @@
import type {
AgentSideConnection,
PermissionOption,
PermissionOptionKind,
RequestPermissionRequest,
ToolCallUpdate,
} from "@agentclientprotocol/sdk";
import type { ToolApprovalRequest, ToolApprovalResult } from "@cline/shared";
import { buildToolTitle, mapToolKind } from "./tool-utils";
// ---------------------------------------------------------------------------
// Standard permission options presented to the user
// ---------------------------------------------------------------------------
const PERMISSION_OPTIONS: PermissionOption[] = [
{
optionId: "allow_once",
name: "Allow once",
kind: "allow_once" as PermissionOptionKind,
},
{
optionId: "allow_always",
name: "Allow always",
kind: "allow_always" as PermissionOptionKind,
},
{
optionId: "reject_once",
name: "Reject",
kind: "reject_once" as PermissionOptionKind,
},
];
// ---------------------------------------------------------------------------
// Translate a CLI tool approval request into an ACP permission request
// ---------------------------------------------------------------------------
export function translateToolToPermissionRequest(
request: ToolApprovalRequest,
sessionId: string,
): RequestPermissionRequest {
const toolCall: ToolCallUpdate = {
toolCallId: request.toolCallId,
title: buildToolTitle(request.toolName, request.input),
kind: mapToolKind(request.toolName),
status: "pending",
rawInput: request.input,
};
return {
sessionId,
toolCall,
options: PERMISSION_OPTIONS,
};
}
// ---------------------------------------------------------------------------
// Interpret the ACP permission response
// ---------------------------------------------------------------------------
export function handlePermissionResponse(
outcome:
| {
outcome: "cancelled";
}
| {
outcome: "selected";
optionId: string;
},
): ToolApprovalResult {
if (outcome.outcome === "cancelled") {
return { approved: false, reason: "Permission request was cancelled" };
}
const optionId = outcome.optionId;
switch (optionId) {
case "allow_once":
case "allow_always":
return { approved: true };
case "reject_once":
case "reject_always":
return { approved: false, reason: "User rejected the tool call" };
default:
return {
approved: false,
reason: `Unknown permission option: ${optionId}`,
};
}
}
// ---------------------------------------------------------------------------
// Combined: request permission from the ACP client and return CLI result
// ---------------------------------------------------------------------------
export async function requestAcpToolApproval(
conn: AgentSideConnection,
sessionId: string,
request: ToolApprovalRequest,
): Promise<ToolApprovalResult> {
const permissionRequest = translateToolToPermissionRequest(
request,
sessionId,
);
// Emit a tool_call update with "pending" status before requesting permission
void conn.sessionUpdate({
sessionId,
update: {
...permissionRequest.toolCall,
sessionUpdate: "tool_call_update",
},
});
let response: Awaited<ReturnType<AgentSideConnection["requestPermission"]>>;
try {
response = await conn.requestPermission(permissionRequest);
} catch {
return { approved: false, reason: "Permission request failed" };
}
const result = handlePermissionResponse(response.outcome);
// Emit a tool_call_update reflecting the decision
void conn.sessionUpdate({
sessionId,
update: {
sessionUpdate: "tool_call_update",
toolCallId: request.toolCallId,
status: result.approved ? "in_progress" : "failed",
},
});
return result;
}
+163
View File
@@ -0,0 +1,163 @@
import type {
AgentSideConnection,
SessionConfigOption,
SessionUpdate,
} from "@agentclientprotocol/sdk";
import type { AgentEvent } from "@cline/core";
import { buildToolTitle, mapToolKind } from "./tool-utils";
/**
* Maps an AgentEvent to zero or more ACP SessionUpdate notifications,
* sending each via the connection's sessionUpdate method.
*/
export function forwardAgentEvent(
conn: AgentSideConnection,
sessionId: string,
event: AgentEvent,
): void {
const updates = translateEvent(event);
for (const update of updates) {
void conn.sessionUpdate({ sessionId, update });
}
}
function translateEvent(event: AgentEvent): SessionUpdate[] {
switch (event.type) {
case "content_start":
return translateContentStart(event);
case "content_end":
return translateContentEnd(event);
case "done":
return [];
case "error":
return [];
case "iteration_start":
case "iteration_end":
case "usage":
return [];
default:
return [];
}
}
function translateContentStart(
event: AgentEvent & { type: "content_start" },
): SessionUpdate[] {
switch (event.contentType) {
case "text": {
if (!event.text) return [];
return [
{
sessionUpdate: "agent_message_chunk",
content: { type: "text", text: event.text },
},
];
}
case "reasoning": {
if (!event.reasoning) return [];
return [
{
sessionUpdate: "agent_thought_chunk",
content: { type: "text", text: event.reasoning },
},
];
}
case "tool": {
const toolCallId = event.toolCallId ?? "unknown";
const toolName = event.toolName ?? "unknown";
return [
{
sessionUpdate: "tool_call",
toolCallId,
title: buildToolTitle(toolName, event.input),
kind: mapToolKind(toolName),
status: "pending",
rawInput: event.input,
},
];
}
default:
return [];
}
}
function translateContentEnd(
event: AgentEvent & { type: "content_end" },
): SessionUpdate[] {
const e = event as {
type: "content_end";
contentType: string;
text?: string;
reasoning?: string;
toolName?: string;
toolCallId?: string;
output?: unknown;
error?: string;
durationMs?: number;
};
switch (e.contentType) {
case "text":
// Text was already streamed via content_start chunks; don't re-send.
return [];
case "reasoning":
// Reasoning was already streamed via content_start chunks; don't re-send.
return [];
case "tool": {
const toolCallId = e.toolCallId ?? "unknown";
const failed = !!e.error;
return [
{
sessionUpdate: "tool_call_update",
toolCallId,
status: failed ? "failed" : "completed",
rawOutput: e.error ?? e.output,
},
];
}
default:
return [];
}
}
/**
* Send a current_mode_update notification to the client.
*/
export function sendCurrentModeUpdate(
conn: AgentSideConnection,
sessionId: string,
modeId: string,
): void {
void conn.sessionUpdate({
sessionId,
update: { sessionUpdate: "current_mode_update", currentModeId: modeId },
});
}
/**
* Send a config_option_update notification to the client.
*/
export function sendConfigOptionUpdate(
conn: AgentSideConnection,
sessionId: string,
configOptions: Array<SessionConfigOption>,
): void {
void conn.sessionUpdate({
sessionId,
update: { sessionUpdate: "config_option_update", configOptions },
});
}
/**
* Send a session_info_update notification to the client.
*/
export function sendSessionInfoUpdate(
conn: AgentSideConnection,
sessionId: string,
info: { title?: string | null; updatedAt?: string | null },
): void {
void conn.sessionUpdate({
sessionId,
update: { sessionUpdate: "session_info_update", ...info },
});
}
+41
View File
@@ -0,0 +1,41 @@
import type { ToolKind } from "@agentclientprotocol/sdk";
import { formatToolInput } from "../utils/helpers";
const TOOL_KIND_MAP: Record<string, ToolKind> = {
Read: "read",
read_files: "read",
Glob: "search",
Grep: "search",
search_codebase: "search",
Edit: "edit",
Write: "edit",
editor: "edit",
Delete: "delete",
Move: "move",
Bash: "execute",
run_commands: "execute",
WebFetch: "fetch",
fetch_web_content: "fetch",
WebSearch: "search",
Agent: "think",
spawn_agent: "think",
NotebookEdit: "edit",
skills: "other",
};
export function mapToolKind(toolName: string): ToolKind {
return TOOL_KIND_MAP[toolName] ?? "other";
}
/**
* Build a human-readable title for a tool call so that IDE UIs show
* something more useful than just the raw tool name.
*
* Delegates to {@link formatToolInput} for the input summary and
* prefixes with the tool name when a summary is available.
*/
export function buildToolTitle(toolName: string, input: unknown): string {
const summary = formatToolInput(toolName, input);
if (!summary) return toolName;
return `${toolName}: ${summary}`;
}
+914
View File
@@ -0,0 +1,914 @@
import { spawnSync } from "node:child_process";
import {
existsSync,
mkdirSync,
mkdtempSync,
readFileSync,
rmSync,
writeFileSync,
} from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
const cliRoot = path.resolve(__dirname, "..");
const cliEntry = path.join(cliRoot, "src", "index.ts");
const cliPackage = JSON.parse(
readFileSync(path.join(cliRoot, "package.json"), "utf8"),
) as { version: string };
const bunExec = process.env.BUN_EXEC_PATH ?? "bun";
type CliResult = ReturnType<typeof spawnSync>;
function asText(value: string | Buffer): string {
return typeof value === "string" ? value : value.toString("utf8");
}
function parseJsonArrayFromOutput(output: string): unknown[] {
const trimmed = output.trim();
if (trimmed.length > 0) {
try {
const parsed = JSON.parse(trimmed) as unknown;
if (Array.isArray(parsed)) {
return parsed;
}
} catch {
// Fall through.
}
}
const lines = output
.split("\n")
.map((line) => line.trim())
.filter((line) => line.length > 0);
for (let index = lines.length - 1; index >= 0; index -= 1) {
try {
const parsed = JSON.parse(lines[index] ?? "") as unknown;
if (Array.isArray(parsed)) {
return parsed;
}
} catch {
// Ignore non-JSON lines.
}
}
const start = output.indexOf("[");
const end = output.lastIndexOf("]");
if (start >= 0 && end > start) {
const parsed = JSON.parse(output.slice(start, end + 1)) as unknown;
if (Array.isArray(parsed)) {
return parsed;
}
}
throw new Error("expected a JSON array in CLI output");
}
function runCli(
args: string[],
options?: {
cwd?: string;
env?: NodeJS.ProcessEnv;
stdin?: string;
timeout?: number;
},
): CliResult {
return spawnSync(bunExec, [cliEntry, ...args], {
cwd: options?.cwd ?? cliRoot,
encoding: "utf8",
input: options?.stdin,
env: options?.env,
timeout: options?.timeout ?? 90_000,
maxBuffer: 10 * 1024 * 1024,
});
}
describe("cli e2e", () => {
const tempDirs: string[] = [];
const createIsolatedEnv = (
overrides: NodeJS.ProcessEnv = {},
): NodeJS.ProcessEnv => {
const homeDir = mkdtempSync(path.join(os.tmpdir(), "cli-e2e-home-"));
const dataDir = mkdtempSync(path.join(os.tmpdir(), "cli-e2e-data-"));
const sessionDir = mkdtempSync(path.join(os.tmpdir(), "cli-e2e-sessions-"));
const teamDir = mkdtempSync(path.join(os.tmpdir(), "cli-e2e-teams-"));
tempDirs.push(homeDir, dataDir, sessionDir, teamDir);
return {
...process.env,
HOME: homeDir,
CLINE_DIR: path.join(homeDir, ".cline"),
CLINE_DATA_DIR: dataDir,
CLINE_DB_DATA_DIR: path.join(dataDir, "db"),
CLINE_SESSION_DATA_DIR: sessionDir,
CLINE_TEAM_DATA_DIR: teamDir,
CLINE_SESSION_BACKEND_MODE: "local",
CLINE_PROVIDER_SETTINGS_PATH: path.join(
dataDir,
"settings",
"providers.json",
),
CLINE_HOOKS_LOG_PATH: path.join(dataDir, "logs", "hooks.jsonl"),
...overrides,
};
};
afterEach(() => {
for (const dir of tempDirs.splice(0)) {
rmSync(dir, { recursive: true, force: true });
}
});
it("prints help output", () => {
const result = runCli(["--help"], { env: createIsolatedEnv() });
expect(result.status).toBe(0);
expect(asText(result.stderr)).toBe("");
expect(asText(result.stdout)).toContain("Usage:");
expect(asText(result.stdout)).toContain("--auto-approve [value]");
expect(asText(result.stdout)).toContain("--data-dir");
expect(asText(result.stdout)).toContain("--thinking [level]");
expect(asText(result.stdout)).not.toContain("--reasoning-effort");
expect(asText(result.stdout)).not.toContain("--act");
expect(asText(result.stdout)).toContain("Show current configuration");
});
it("prints version output", () => {
const result = runCli(["--version"], { env: createIsolatedEnv() });
expect(result.status).toBe(0);
expect(asText(result.stdout).trim()).toBe(cliPackage.version);
});
it("prints version output via version command", () => {
const result = runCli(["version"], { env: createIsolatedEnv() });
expect(result.status).toBe(0);
expect(asText(result.stdout).trim()).toBe(cliPackage.version);
});
it("exits promptly on success path without hanging", () => {
const result = runCli(["version"], {
env: createIsolatedEnv(),
timeout: 10_000,
});
expect(result.signal).toBeNull();
expect(result.status).toBe(0);
});
it("exits promptly on error path without hanging", () => {
const result = runCli(["--timeout", "xml", "hello"], {
env: createIsolatedEnv(),
timeout: 10_000,
});
expect(result.signal).toBeNull();
expect(result.status).toBe(1);
});
it("propagates subcommand exit codes through process.exit", () => {
const result = runCli(["history", "--json"], {
env: createIsolatedEnv(),
timeout: 10_000,
});
expect(result.signal).toBeNull();
expect(result.status).toBe(0);
});
it("rejects invalid timeout values", () => {
const result = runCli(["--timeout", "xml", "hello"], {
env: createIsolatedEnv(),
});
expect(result.status).toBe(1);
expect(asText(result.stderr)).toContain("invalid timeout");
});
it("rejects json mode without prompt or piped input", () => {
const homeDir = mkdtempSync(path.join(os.tmpdir(), "cli-e2e-home-"));
tempDirs.push(homeDir);
const result = runCli(["--json"], {
env: {
...createIsolatedEnv(),
HOME: homeDir,
},
});
expect(result.status).toBe(1);
expect(asText(result.stderr)).toContain(
"JSON output mode requires a prompt argument or piped stdin",
);
});
it("rejects interactive mode with json output", () => {
const homeDir = mkdtempSync(path.join(os.tmpdir(), "cli-e2e-home-"));
tempDirs.push(homeDir);
const result = runCli(["--json", "--tui"], {
env: {
...createIsolatedEnv(),
HOME: homeDir,
},
});
expect(result.status).toBe(1);
expect(asText(result.stderr)).toContain(
"JSON output mode requires a prompt argument or piped stdin",
);
});
it("rejects zen mode without a prompt using a prompt-specific error", () => {
const result = runCli(["--zen"], {
env: createIsolatedEnv(),
});
expect(result.status).toBe(1);
expect(asText(result.stderr)).toContain("--zen requires a prompt.");
expect(asText(result.stderr)).not.toContain("interactive mode");
});
it("returns an error for unknown config targets", () => {
const result = runCli(["config", "unknown-target"], {
env: createIsolatedEnv(),
});
expect(result.status).toBe(1);
expect(asText(result.stderr)).toContain(
'config requires one of: workflows, rules, skills, agents, plugins, hooks, mcp, tools (got "unknown-target")',
);
});
it("returns an error for unknown hub subcommands", () => {
const result = runCli(["hub", "nonesuch"], { env: createIsolatedEnv() });
expect(result.status).toBe(1);
expect(asText(result.stderr)).toContain('unknown command "nonesuch"');
});
it("returns an error for interactive auth when no TTY is available", () => {
const homeDir = mkdtempSync(path.join(os.tmpdir(), "cli-e2e-home-"));
const dataDir = mkdtempSync(path.join(os.tmpdir(), "cli-e2e-data-"));
tempDirs.push(homeDir, dataDir);
const result = runCli(["auth"], {
env: {
...createIsolatedEnv(),
HOME: homeDir,
CLINE_DATA_DIR: dataDir,
},
});
expect(result.status).toBe(1);
expect(asText(result.stderr)).toContain(
"interactive auth setup requires a TTY",
);
});
it("lists sessions from isolated storage", () => {
const homeDir = mkdtempSync(path.join(os.tmpdir(), "cli-e2e-home-"));
const sessionDir = mkdtempSync(path.join(os.tmpdir(), "cli-e2e-sessions-"));
tempDirs.push(homeDir, sessionDir);
const result = runCli(["history", "--json", "--limit", "1"], {
env: {
...createIsolatedEnv(),
HOME: homeDir,
CLINE_SESSION_DATA_DIR: sessionDir,
},
});
expect(result.status).toBe(0);
const parsed = parseJsonArrayFromOutput(asText(result.stdout));
expect(Array.isArray(parsed)).toBe(true);
});
it("prints empty history state from isolated storage", () => {
const homeDir = mkdtempSync(path.join(os.tmpdir(), "cli-e2e-home-"));
const sessionDir = mkdtempSync(path.join(os.tmpdir(), "cli-e2e-sessions-"));
tempDirs.push(homeDir, sessionDir);
const result = runCli(["history", "--limit", "5"], {
env: {
...createIsolatedEnv(),
HOME: homeDir,
CLINE_SESSION_DATA_DIR: sessionDir,
},
});
expect(result.status).toBe(0);
expect(asText(result.stdout)).toContain("No history found.");
});
it("returns an error when deleting a session without --session-id", () => {
const result = runCli(["history", "delete"], {
env: createIsolatedEnv(),
});
expect(result.status).toBe(1);
expect(asText(result.stderr)).toContain(
"history delete requires --session-id <id>",
);
});
it("lists enabled workflows in text mode", () => {
const workspace = mkdtempSync(path.join(os.tmpdir(), "cli-e2e-workflows-"));
tempDirs.push(workspace);
const workflowsDir = path.join(workspace, ".clinerules", "workflows");
mkdirSync(workflowsDir, { recursive: true });
writeFileSync(
path.join(workflowsDir, "release.md"),
`---
name: release
---
Release checklist.`,
"utf8",
);
writeFileSync(
path.join(workflowsDir, "disabled.md"),
`---
name: disabled
disabled: true
---
Do not list this.`,
"utf8",
);
const result = runCli(["config", "workflows"], {
cwd: workspace,
env: createIsolatedEnv(),
});
expect(result.status).toBe(0);
expect(asText(result.stdout)).toContain("Available workflows:");
expect(asText(result.stdout)).toContain("/release");
expect(asText(result.stdout)).toContain(
path.join(workflowsDir, "release.md"),
);
expect(asText(result.stdout)).not.toContain("/disabled");
});
it("lists workflows from workspace root when run in a subdirectory", () => {
const workspace = mkdtempSync(path.join(os.tmpdir(), "cli-e2e-workflows-"));
tempDirs.push(workspace);
const workflowsDir = path.join(workspace, ".clinerules", "workflows");
const nestedDir = path.join(workspace, "packages", "app");
mkdirSync(workflowsDir, { recursive: true });
mkdirSync(nestedDir, { recursive: true });
writeFileSync(
path.join(workflowsDir, "release.md"),
`---
name: release
---
Release checklist.`,
"utf8",
);
spawnSync("git", ["init"], {
cwd: workspace,
encoding: "utf8",
});
const result = runCli(["config", "workflows"], {
cwd: nestedDir,
env: createIsolatedEnv(),
});
expect(result.status).toBe(0);
expect(asText(result.stdout)).toContain("/release");
});
it("lists enabled workflows in json mode", () => {
const workspace = mkdtempSync(path.join(os.tmpdir(), "cli-e2e-workflows-"));
tempDirs.push(workspace);
const workflowsDir = path.join(workspace, ".clinerules", "workflows");
mkdirSync(workflowsDir, { recursive: true });
writeFileSync(
path.join(workflowsDir, "review.md"),
`---
name: review
---
Review checklist.`,
"utf8",
);
const result = runCli(["config", "workflows", "--json"], {
cwd: workspace,
env: createIsolatedEnv(),
});
expect(result.status).toBe(0);
const parsed = JSON.parse(asText(result.stdout)) as Array<{
name: string;
}>;
expect(parsed.some((workflow) => workflow.name === "review")).toBe(true);
});
it("includes Documents/Cline workflows", () => {
const homeDir = mkdtempSync(path.join(os.tmpdir(), "cli-e2e-home-"));
const workspace = mkdtempSync(path.join(os.tmpdir(), "cli-e2e-workspace-"));
tempDirs.push(homeDir, workspace);
const docsWorkflowsDir = path.join(
homeDir,
"Documents",
"Cline",
"Workflows",
);
mkdirSync(docsWorkflowsDir, { recursive: true });
writeFileSync(
path.join(docsWorkflowsDir, "docs-release.md"),
`---
name: docs-release
---
Release from docs path.`,
"utf8",
);
const result = runCli(["config", "workflows"], {
cwd: workspace,
env: {
...createIsolatedEnv(),
HOME: homeDir,
},
});
expect(result.status).toBe(0);
expect(asText(result.stdout)).toContain("/docs-release");
});
it("lists enabled rules", () => {
const workspace = mkdtempSync(path.join(os.tmpdir(), "cli-e2e-rules-"));
tempDirs.push(workspace);
const rulesDir = path.join(workspace, ".clinerules");
mkdirSync(rulesDir, { recursive: true });
writeFileSync(
path.join(rulesDir, "rule.md"),
`---
name: no-force-push
---
Do not force push.`,
"utf8",
);
const result = runCli(["config", "rules"], {
cwd: workspace,
env: createIsolatedEnv(),
});
expect(result.status).toBe(0);
expect(asText(result.stdout)).toContain("Enabled rules:");
expect(asText(result.stdout)).toContain("no-force-push");
expect(asText(result.stdout)).toContain(path.join(rulesDir, "rule.md"));
});
it("lists enabled skills", () => {
const workspace = mkdtempSync(path.join(os.tmpdir(), "cli-e2e-skills-"));
tempDirs.push(workspace);
const skillsDir = path.join(workspace, ".clinerules", "skills", "commit");
mkdirSync(skillsDir, { recursive: true });
writeFileSync(
path.join(skillsDir, "SKILL.md"),
`---
name: commit
---
Create a concise commit message.`,
"utf8",
);
const result = runCli(["config", "skills"], {
cwd: workspace,
env: createIsolatedEnv(),
});
expect(result.status).toBe(0);
expect(asText(result.stdout)).toContain("Enabled skills:");
expect(asText(result.stdout)).toContain("commit");
expect(asText(result.stdout)).toContain(path.join(skillsDir, "SKILL.md"));
});
it("includes Documents/Cline rules and skills", () => {
const homeDir = mkdtempSync(path.join(os.tmpdir(), "cli-e2e-home-"));
const workspace = mkdtempSync(path.join(os.tmpdir(), "cli-e2e-workspace-"));
tempDirs.push(homeDir, workspace);
const docsRulesDir = path.join(homeDir, "Documents", "Cline", "Rules");
const docsSkillsDir = path.join(
homeDir,
"Documents",
"Cline",
"Skills",
"review",
);
mkdirSync(docsRulesDir, { recursive: true });
mkdirSync(docsSkillsDir, { recursive: true });
writeFileSync(
path.join(docsRulesDir, "docs-rule.md"),
`---
name: docs-rule
---
Rule from docs path.`,
"utf8",
);
writeFileSync(
path.join(docsSkillsDir, "SKILL.md"),
`---
name: docs-skill
---
Skill from docs path.`,
"utf8",
);
const rulesResult = runCli(["config", "rules"], {
cwd: workspace,
env: {
...createIsolatedEnv(),
HOME: homeDir,
},
});
expect(rulesResult.status).toBe(0);
expect(asText(rulesResult.stdout)).toContain("docs-rule");
const skillsResult = runCli(["config", "skills"], {
cwd: workspace,
env: {
...createIsolatedEnv(),
HOME: homeDir,
},
});
expect(skillsResult.status).toBe(0);
expect(asText(skillsResult.stdout)).toContain("docs-skill");
});
it("lists configured agents with source paths", () => {
const homeDir = mkdtempSync(path.join(os.tmpdir(), "cli-e2e-home-"));
const workspace = mkdtempSync(path.join(os.tmpdir(), "cli-e2e-workspace-"));
tempDirs.push(homeDir, workspace);
const globalAgentsDir = path.join(homeDir, ".cline", "agents");
const workspaceAgentsDir = path.join(workspace, ".cline", "agents");
mkdirSync(globalAgentsDir, { recursive: true });
mkdirSync(workspaceAgentsDir, { recursive: true });
writeFileSync(
path.join(globalAgentsDir, "reviewer.yaml"),
`---
name: Reviewer
description: Reviews code changes
---
Review diffs thoroughly.`,
"utf8",
);
writeFileSync(
path.join(workspaceAgentsDir, "planner.yaml"),
`---
name: Planner
description: Plans implementation tasks
---
Break work into clear steps.`,
"utf8",
);
const textResult = runCli(["config", "agents"], {
cwd: workspace,
env: {
...createIsolatedEnv(),
HOME: homeDir,
CLINE_DIR: path.join(homeDir, ".cline"),
},
});
expect(textResult.status).toBe(0);
expect(asText(textResult.stdout)).toContain("Configured agents:");
expect(asText(textResult.stdout)).toContain("Reviewer");
expect(asText(textResult.stdout)).toContain("Planner");
expect(asText(textResult.stdout)).toContain(
path.join(globalAgentsDir, "reviewer.yaml"),
);
expect(asText(textResult.stdout)).toContain(
path.join(workspaceAgentsDir, "planner.yaml"),
);
const jsonResult = runCli(["config", "agents", "--json"], {
cwd: workspace,
env: {
...createIsolatedEnv(),
HOME: homeDir,
CLINE_DIR: path.join(homeDir, ".cline"),
},
});
expect(jsonResult.status).toBe(0);
const parsed = JSON.parse(asText(jsonResult.stdout)) as Array<{
name: string;
path: string;
}>;
expect(parsed.some((agent) => agent.name === "Reviewer")).toBe(true);
expect(parsed.some((agent) => agent.name === "Planner")).toBe(true);
expect(
parsed.some(
(agent) => agent.path === path.join(globalAgentsDir, "reviewer.yaml"),
),
).toBe(true);
});
it("lists discovered plugins", () => {
const homeDir = mkdtempSync(path.join(os.tmpdir(), "cli-e2e-home-"));
const dataDir = mkdtempSync(path.join(os.tmpdir(), "cli-e2e-data-"));
const workspace = mkdtempSync(path.join(os.tmpdir(), "cli-e2e-workspace-"));
tempDirs.push(homeDir, dataDir, workspace);
const workspacePluginsDir = path.join(workspace, ".cline", "plugins");
const userPluginsDir = path.join(homeDir, ".cline", "plugins");
const documentsPluginsDir = path.join(
homeDir,
"Documents",
"Cline",
"Plugins",
);
mkdirSync(workspacePluginsDir, { recursive: true });
mkdirSync(userPluginsDir, { recursive: true });
mkdirSync(documentsPluginsDir, { recursive: true });
writeFileSync(
path.join(workspacePluginsDir, "workspace-plugin.ts"),
"export default { name: 'workspace-plugin', manifest: { capabilities: ['tools'] } };",
"utf8",
);
writeFileSync(
path.join(userPluginsDir, "user-plugin.js"),
"export default { name: 'user-plugin', manifest: { capabilities: ['tools'] } };",
"utf8",
);
writeFileSync(
path.join(documentsPluginsDir, "docs-plugin.ts"),
"export default { name: 'docs-plugin', manifest: { capabilities: ['tools'] } };",
"utf8",
);
const textResult = runCli(["config", "plugins"], {
cwd: workspace,
env: {
...createIsolatedEnv(),
HOME: homeDir,
CLINE_DIR: path.join(homeDir, ".cline"),
CLINE_DATA_DIR: dataDir,
},
});
expect(textResult.status).toBe(0);
expect(asText(textResult.stdout)).toContain("Discovered plugins:");
expect(asText(textResult.stdout)).toContain("workspace-plugin");
expect(asText(textResult.stdout)).toContain("user-plugin");
expect(asText(textResult.stdout)).toContain("docs-plugin");
expect(asText(textResult.stdout)).toContain(
path.join(workspacePluginsDir, "workspace-plugin.ts"),
);
expect(asText(textResult.stdout)).toContain(
path.join(userPluginsDir, "user-plugin.js"),
);
expect(asText(textResult.stdout)).toContain(
path.join(documentsPluginsDir, "docs-plugin.ts"),
);
const jsonResult = runCli(["config", "plugins", "--json"], {
cwd: workspace,
env: {
...createIsolatedEnv(),
HOME: homeDir,
CLINE_DIR: path.join(homeDir, ".cline"),
CLINE_DATA_DIR: dataDir,
},
});
expect(jsonResult.status).toBe(0);
const parsed = JSON.parse(asText(jsonResult.stdout)) as Array<{
name: string;
path: string;
}>;
expect(parsed.some((plugin) => plugin.name === "workspace-plugin")).toBe(
true,
);
expect(parsed.some((plugin) => plugin.name === "user-plugin")).toBe(true);
expect(parsed.some((plugin) => plugin.name === "docs-plugin")).toBe(true);
expect(
parsed.some((plugin) =>
plugin.path.endsWith(
path.join(".cline", "plugins", "workspace-plugin.ts"),
),
),
).toBe(true);
expect(
parsed.some((plugin) =>
plugin.path.endsWith(path.join(".cline", "plugins", "user-plugin.js")),
),
).toBe(true);
expect(
parsed.some((plugin) =>
plugin.path.endsWith(
path.join("Documents", "Cline", "Plugins", "docs-plugin.ts"),
),
),
).toBe(true);
});
it("lists configured mcp servers", () => {
const tempRoot = mkdtempSync(path.join(os.tmpdir(), "cli-e2e-mcp-"));
tempDirs.push(tempRoot);
const settingsPath = path.join(tempRoot, "cline_mcp_settings.json");
writeFileSync(
settingsPath,
JSON.stringify(
{
mcpServers: {
docs: {
transport: {
type: "stdio",
command: "node",
},
},
remote: {
transport: {
type: "streamableHttp",
url: "https://mcp.example.com",
},
disabled: true,
},
},
},
null,
2,
),
"utf8",
);
const textResult = runCli(["config", "mcp"], {
env: {
...createIsolatedEnv(),
CLINE_MCP_SETTINGS_PATH: settingsPath,
},
});
expect(textResult.status).toBe(0);
expect(asText(textResult.stdout)).toContain("Configured MCP servers");
expect(asText(textResult.stdout)).toContain("docs [stdio]");
expect(asText(textResult.stdout)).toContain(
"remote [streamableHttp] (disabled)",
);
const jsonResult = runCli(["config", "mcp", "--json"], {
env: {
...createIsolatedEnv(),
CLINE_MCP_SETTINGS_PATH: settingsPath,
},
});
expect(jsonResult.status).toBe(0);
const parsed = JSON.parse(asText(jsonResult.stdout)) as Array<{
name: string;
transportType: string;
disabled: boolean;
path: string;
}>;
expect(parsed.some((server) => server.name === "docs")).toBe(true);
expect(parsed.some((server) => server.name === "remote")).toBe(true);
expect(
parsed.some(
(server) =>
server.name === "remote" &&
server.transportType === "streamableHttp" &&
server.disabled === true &&
server.path === settingsPath,
),
).toBe(true);
});
it("lists available tools", () => {
const homeDir = mkdtempSync(path.join(os.tmpdir(), "cli-e2e-home-"));
const dataDir = mkdtempSync(path.join(os.tmpdir(), "cli-e2e-data-"));
const workspace = mkdtempSync(path.join(os.tmpdir(), "cli-e2e-workspace-"));
tempDirs.push(homeDir, dataDir, workspace);
const workspacePluginsDir = path.join(workspace, ".cline", "plugins");
const globalSettingsPath = path.join(
dataDir,
"settings",
"global-settings.json",
);
mkdirSync(workspacePluginsDir, { recursive: true });
mkdirSync(path.dirname(globalSettingsPath), { recursive: true });
writeFileSync(
path.join(workspacePluginsDir, "workspace-plugin.ts"),
[
"export default {",
" name: 'workspace-plugin',",
" manifest: { capabilities: ['tools'] },",
" setup(api) {",
" api.registerTool({",
" name: 'plugin_echo',",
" description: 'Echo from plugin',",
" inputSchema: { type: 'object', properties: {}, required: [] },",
" execute: async () => ({ ok: true }),",
" });",
" },",
"};",
].join("\n"),
"utf8",
);
writeFileSync(
globalSettingsPath,
JSON.stringify({ disabledTools: ["plugin_echo"] }, null, 2),
"utf8",
);
const textResult = runCli(["config", "tools"], {
cwd: workspace,
env: {
...createIsolatedEnv(),
HOME: homeDir,
CLINE_DATA_DIR: dataDir,
CLINE_GLOBAL_SETTINGS_PATH: globalSettingsPath,
},
});
expect(textResult.status).toBe(0);
expect(asText(textResult.stdout)).toContain("Available tools:");
expect(asText(textResult.stdout)).toContain(
"read_files [default: enabled]",
);
expect(asText(textResult.stdout)).toContain(
"spawn_agent [default: enabled]",
);
expect(asText(textResult.stdout)).toContain("teams [default: enabled]");
expect(asText(textResult.stdout)).not.toContain("submit_and_exit");
expect(asText(textResult.stdout)).not.toContain("apply_patch");
expect(asText(textResult.stdout)).toContain("Plugin tools:");
expect(asText(textResult.stdout)).toContain("plugin_echo");
expect(asText(textResult.stdout)).toContain("[disabled]");
const jsonResult = runCli(["config", "tools", "--json"], {
cwd: workspace,
env: {
...createIsolatedEnv(),
HOME: homeDir,
CLINE_DATA_DIR: dataDir,
CLINE_GLOBAL_SETTINGS_PATH: globalSettingsPath,
},
});
expect(jsonResult.status).toBe(0);
const parsed = JSON.parse(asText(jsonResult.stdout)) as Array<{
id?: string;
name: string;
type: string;
enabled?: boolean;
defaultEnabled?: boolean;
headlessToolNames?: string[];
}>;
expect(
parsed.some(
(tool) => tool.id === "run_commands" && tool.defaultEnabled === true,
),
).toBe(true);
expect(
parsed.some(
(tool) =>
tool.id === "editor" &&
tool.headlessToolNames?.includes("editor") &&
!tool.headlessToolNames?.includes("apply_patch"),
),
).toBe(true);
expect(
parsed.some(
(tool) =>
tool.id === "teams" &&
tool.defaultEnabled === true &&
tool.headlessToolNames?.includes("team_status"),
),
).toBe(true);
expect(parsed.some((tool) => tool.id === "apply_patch")).toBe(false);
expect(parsed.some((tool) => tool.id === "submit_and_exit")).toBe(false);
expect(
parsed.some(
(tool) =>
tool.name === "plugin_echo" &&
tool.type === "plugin" &&
tool.enabled === false,
),
).toBe(true);
});
it("rejects invalid hook payloads", () => {
const result = runCli(["hook"], {
env: createIsolatedEnv(),
stdin: JSON.stringify({ bad: "payload" }),
});
expect(result.status).toBe(1);
expect(asText(result.stderr)).toContain("invalid hook payload");
});
it("accepts valid hook payloads and writes audit log", () => {
const homeDir = mkdtempSync(path.join(os.tmpdir(), "cli-e2e-home-"));
const sessionDir = mkdtempSync(path.join(os.tmpdir(), "cli-e2e-sessions-"));
const logDir = mkdtempSync(path.join(os.tmpdir(), "cli-e2e-hooks-"));
tempDirs.push(homeDir, sessionDir, logDir);
const hookPath = path.join(logDir, "hook-events.jsonl");
const defaultHookPath = path.join(
homeDir,
".cline",
"data",
"logs",
"hooks.jsonl",
);
const result = runCli(["hook"], {
env: {
...createIsolatedEnv(),
HOME: homeDir,
CLINE_SESSION_DATA_DIR: sessionDir,
CLINE_HOOKS_LOG_PATH: hookPath,
},
stdin: JSON.stringify({
hookName: "tool_call",
taskId: "conversation_1",
clineVersion: "",
timestamp: new Date().toISOString(),
workspaceRoots: [],
userId: "agent_1",
agent_id: "agent_1",
parent_agent_id: null,
tool_call: {
id: "call_1",
name: "read_files",
input: { file_paths: ["README.md"] },
},
}),
});
expect(result.status).toBe(0);
expect(asText(result.stdout).trim()).toBe("{}");
const logPath = existsSync(hookPath) ? hookPath : defaultHookPath;
const log = readFileSync(logPath, "utf8");
expect(log).toContain('"hookName":"tool_call"');
expect(log).toContain('"agent_id":"agent_1"');
});
});
@@ -0,0 +1,202 @@
import { spawnSync } from "node:child_process";
import { mkdtempSync, rmSync } from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
const cliRoot = path.resolve(__dirname, "..");
const cliEntry = path.join(cliRoot, "src", "index.ts");
const bunExec = process.env.BUN_EXEC_PATH ?? "bun";
type CliResult = ReturnType<typeof spawnSync>;
interface KeyStep {
delaySeconds: number;
input: string;
}
const INITIAL_RENDER_DELAY_SECONDS = 2.5;
const POST_ACTION_SETTLE_SECONDS = 1.0;
const INTERACTIVE_TEST_TIMEOUT_MS = 40_000;
function normalizeTerminalOutput(output: string): string {
// biome-ignore lint/suspicious/noControlCharactersInRegex: this regex intentionally strips ANSI escape sequences
const ansiCsiRegex = /\u001b\[[0-9;?]*[ -/]*[@-~]/g;
// biome-ignore lint/suspicious/noControlCharactersInRegex: this regex intentionally strips OSC sequences
const ansiOscRegex = /\u001b\][^\u0007]*(?:\u0007|\u001b\\)/g;
const carriageReturnRegex = /\r/g;
// biome-ignore lint/suspicious/noControlCharactersInRegex: this regex intentionally strips backspace control bytes
const backspaceRegex = /\u0008/g;
return (
output
// Strip ANSI CSI/OSC escapes.
.replace(ansiCsiRegex, "")
.replace(ansiOscRegex, "")
// Remove CR + backspace artifacts from `script`.
.replace(carriageReturnRegex, "")
.replace(backspaceRegex, "")
);
}
function toShellSingleQuotedLiteral(value: string): string {
return `'${value.replaceAll("'", `'\\''`)}'`;
}
function buildScriptCommand(scriptedInput: string, launchArgs: string): string {
const quietFlag = "-q";
if (process.platform === "linux") {
return `(${scriptedInput}) | script ${quietFlag} /dev/null -- ${toShellSingleQuotedLiteral(bunExec)} ${launchArgs}`;
}
return `(${scriptedInput}) | script ${quietFlag} /dev/null ${toShellSingleQuotedLiteral(bunExec)} ${launchArgs}`;
}
function runInteractiveCli(
steps: KeyStep[],
options?: { launchConfigView?: boolean },
): CliResult {
const homeDir = mkdtempSync(path.join(os.tmpdir(), "cli-int-home-"));
const dataDir = mkdtempSync(path.join(os.tmpdir(), "cli-int-data-"));
const sessionDir = mkdtempSync(path.join(os.tmpdir(), "cli-int-sessions-"));
const teamDir = mkdtempSync(path.join(os.tmpdir(), "cli-int-teams-"));
tempDirs.push(homeDir, dataDir, sessionDir, teamDir);
const scriptedInput = [
...steps,
// Exit each interactive run explicitly so tests do not idle until timeout.
{ delaySeconds: 0.2, input: "\u0003" },
]
.map(
(step) =>
`sleep ${step.delaySeconds}; printf ${toShellSingleQuotedLiteral(step.input)}`,
)
.join("; ");
const baseArgs = [
cliEntry,
"--provider",
"anthropic",
"-m",
"claude-sonnet-4-6",
"-k",
"test-key",
];
const launchArgs = [
...(options?.launchConfigView ? [...baseArgs, "config"] : baseArgs),
]
.map((arg) => toShellSingleQuotedLiteral(arg))
.join(" ");
const command = buildScriptCommand(scriptedInput, launchArgs);
return spawnSync("bash", ["-lc", command], {
cwd: cliRoot,
encoding: "utf8",
env: {
...process.env,
HOME: homeDir,
CLINE_DATA_DIR: dataDir,
CLINE_DB_DATA_DIR: path.join(dataDir, "db"),
CLINE_SESSION_DATA_DIR: sessionDir,
CLINE_TEAM_DATA_DIR: teamDir,
CLINE_SESSION_BACKEND_MODE: "local",
CLINE_PROVIDER_SETTINGS_PATH: path.join(
dataDir,
"settings",
"providers.json",
),
CLINE_HOOKS_LOG_PATH: path.join(dataDir, "logs", "hooks.jsonl"),
},
timeout: INTERACTIVE_TEST_TIMEOUT_MS,
maxBuffer: 10 * 1024 * 1024,
});
}
function outputOf(result: CliResult): string {
return normalizeTerminalOutput(
`${typeof result.stdout === "string" ? result.stdout : result.stdout.toString("utf8")}\n${
typeof result.stderr === "string"
? result.stderr
: result.stderr.toString("utf8")
}`,
);
}
const tempDirs: string[] = [];
describe("cli interactive e2e", () => {
afterEach(() => {
for (const dir of tempDirs.splice(0)) {
rmSync(dir, { recursive: true, force: true });
}
});
it("shows the interactive chat view on launch", () => {
const result = runInteractiveCli([
{ delaySeconds: INITIAL_RENDER_DELAY_SECONDS, input: "" },
]);
const output = outputOf(result);
expect(output).toContain("What can I do for you?");
expect(output).toContain("○ Plan ● Act (Tab)");
expect(output).toContain("Auto-approve all enabled (Shift+Tab)");
});
it("toggles plan/act mode with Tab", () => {
const result = runInteractiveCli([
{ delaySeconds: INITIAL_RENDER_DELAY_SECONDS, input: "\t" },
{ delaySeconds: POST_ACTION_SETTLE_SECONDS, input: "" },
]);
const output = outputOf(result);
expect(output).toContain("○ Plan ● Act (Tab)");
expect(output).toContain("● Plan ○ Act (Tab)");
});
it("toggles auto-approve-all with Shift+Tab", () => {
const result = runInteractiveCli([
{ delaySeconds: INITIAL_RENDER_DELAY_SECONDS, input: "\u001b[Z" },
{ delaySeconds: POST_ACTION_SETTLE_SECONDS, input: "" },
]);
const output = outputOf(result);
expect(output).toContain("Auto-approve all enabled (Shift+Tab)");
expect(output).toContain("Auto-approve all disabled (Shift+Tab)");
});
it("opens /settings and navigates tabs with Tab", () => {
const result = runInteractiveCli([
{ delaySeconds: INITIAL_RENDER_DELAY_SECONDS, input: "/settings" },
{ delaySeconds: 0.25, input: "\r" }, // accept slash completion
{ delaySeconds: 0.25, input: "\r" }, // submit command
{ delaySeconds: 0.7, input: "\t" },
{ delaySeconds: POST_ACTION_SETTLE_SECONDS, input: "" },
]);
const output = outputOf(result);
expect(output).toContain("Configuration");
expect(output).toContain("[Tools] Plugins Agents Hooks Skills Rules MCP");
expect(output).toContain("Tools [Plugins] Agents Hooks Skills Rules MCP");
});
it("closes /settings with Escape", () => {
const result = runInteractiveCli([
{ delaySeconds: INITIAL_RENDER_DELAY_SECONDS, input: "/settings" },
{ delaySeconds: 0.25, input: "\r" },
{ delaySeconds: 0.25, input: "\r" },
{ delaySeconds: POST_ACTION_SETTLE_SECONDS, input: "\u001b" },
{ delaySeconds: POST_ACTION_SETTLE_SECONDS, input: "" },
]);
const output = outputOf(result);
expect(output).toContain(
"Config mode: Tab tabs · ↑/↓ navigate · Esc close",
);
expect(output).toContain("/ for commands · @ for files");
});
it("launches config view directly with `cline config`", () => {
const result = runInteractiveCli(
[{ delaySeconds: INITIAL_RENDER_DELAY_SECONDS, input: "" }],
{
launchConfigView: true,
},
);
const output = outputOf(result);
expect(output).toContain("Configuration");
expect(output).toContain("[Tools] Plugins Agents Hooks Skills Rules MCP");
});
});
+98
View File
@@ -0,0 +1,98 @@
import { spawnSync } from "node:child_process";
import { fileURLToPath } from "node:url";
import type { ProviderSettingsManager } from "@cline/core";
import { describe, expect, it, vi } from "vitest";
import { getPersistedProviderApiKey, saveOAuthProviderSettings } from "./auth";
describe("saveOAuthProviderSettings", () => {
it("preserves existing manual apiKey while updating OAuth tokens", () => {
const save = vi.fn();
const manager = {
saveProviderSettings: save,
} as unknown as ProviderSettingsManager;
const merged = saveOAuthProviderSettings(
manager,
"cline",
{
provider: "cline",
apiKey: "manual-key",
auth: {
accessToken: "workos:old-access",
refreshToken: "old-refresh",
accountId: "acct-old",
},
},
{
access: "new-access",
refresh: "new-refresh",
expires: 4_000_000_000_000,
accountId: "acct-new",
},
);
expect(merged).toMatchObject({
provider: "cline",
apiKey: "manual-key",
auth: {
accessToken: "workos:new-access",
refreshToken: "new-refresh",
accountId: "acct-new",
expiresAt: 4_000_000_000_000,
},
});
expect(save).toHaveBeenCalledWith(
expect.objectContaining({
provider: "cline",
apiKey: "manual-key",
auth: expect.objectContaining({
accessToken: "workos:new-access",
}),
}),
{ tokenSource: "oauth" },
);
});
});
describe("getPersistedProviderApiKey", () => {
it("does not double-prefix persisted Cline OAuth tokens", () => {
expect(
getPersistedProviderApiKey("cline", {
provider: "cline",
auth: {
accessToken: "workos:oauth-access",
},
}),
).toBe("workos:oauth-access");
});
});
describe("loadAuthTuiRuntime", () => {
it("loads OpenTUI React after provider catalog initialization", async () => {
const cliRoot = fileURLToPath(new URL("../..", import.meta.url));
const script = `
import { ProviderSettingsManager, ensureCustomProvidersLoaded, listLocalProviders } from "@cline/core";
import { loadAuthTuiRuntime } from "./src/commands/auth.ts";
const manager = new ProviderSettingsManager();
await ensureCustomProvidersLoaded(manager);
await listLocalProviders(manager);
const runtime = await loadAuthTuiRuntime();
if (typeof runtime.createCliRenderer !== "function") throw new Error("missing createCliRenderer");
if (typeof runtime.createRoot !== "function") throw new Error("missing createRoot");
if (typeof runtime.OnboardingView !== "function") throw new Error("missing OnboardingView");
`;
const result = spawnSync(
"bun",
["--conditions=development", "-e", script],
{
cwd: cliRoot,
encoding: "utf8",
},
);
expect(result.error).toBeUndefined();
expect(result.stderr).toBe("");
expect(result.status).toBe(0);
});
});
+533
View File
@@ -0,0 +1,533 @@
import { createInterface } from "node:readline";
import {
createOAuthClientCallbacks,
ensureCustomProvidersLoaded,
listLocalProviders,
type ProviderSettings,
type ProviderSettingsManager,
} from "@cline/core";
import { getClineEnvironmentConfig } from "@cline/shared";
import { Command } from "commander";
import open from "open";
import React from "react";
import { disableOpenTuiGraphicsProbe } from "../tui/opentui-env";
import {
getPersistedProviderApiKey,
isOAuthProvider,
normalizeAuthProviderId,
normalizeProviderId,
type OAuthCredentials,
toProviderApiKey,
} from "../utils/provider-auth";
export {
getPersistedProviderApiKey,
isOAuthProvider,
normalizeAuthProviderId,
normalizeProviderId,
toProviderApiKey,
};
export type { OAuthCredentials };
const c = {
reset: "\x1b[0m",
dim: "\x1b[2m",
cyan: "\x1b[36m",
green: "\x1b[32m",
};
type CoreOAuthApi = {
loginClineOAuth: (input: {
apiBaseUrl: string;
useWorkOSDeviceAuth?: boolean;
callbacks: {
onAuth: (info: { url: string; instructions?: string }) => void;
onPrompt: (prompt: {
message: string;
defaultValue?: string;
}) => Promise<string>;
onManualCodeInput?: () => Promise<string>;
};
}) => Promise<OAuthCredentials>;
loginOcaOAuth: (input: {
mode?: "internal" | "external";
callbacks: {
onAuth: (info: { url: string; instructions?: string }) => void;
onPrompt: (prompt: {
message: string;
defaultValue?: string;
}) => Promise<string>;
onManualCodeInput?: () => Promise<string>;
};
}) => Promise<OAuthCredentials>;
loginOpenAICodex: (input: {
onAuth: (info: { url: string; instructions?: string }) => void;
onPrompt: (prompt: {
message: string;
defaultValue?: string;
}) => Promise<string>;
onManualCodeInput?: () => Promise<string>;
}) => Promise<OAuthCredentials>;
};
type AuthIo = {
writeln: (text?: string) => void;
writeErr: (text: string) => void;
};
type AuthQuickSetupInput = {
provider: string;
apikey: string;
modelid: string;
baseurl?: string;
};
type AuthCommandInput = {
providerSettingsManager: ProviderSettingsManager;
io: AuthIo;
explicitProvider?: string;
apikey?: string;
modelid?: string;
baseurl?: string;
};
type ParsedAuthCommandArgs = {
explicitProvider?: string;
apikey?: string;
modelid?: string;
baseurl?: string;
parseError?: string;
};
let cachedCoreOAuthApi: Promise<CoreOAuthApi> | undefined;
async function getCoreOAuthApi(): Promise<CoreOAuthApi> {
if (!cachedCoreOAuthApi) {
cachedCoreOAuthApi = import("@cline/core").then((module) => {
const runtimeApi = module as Partial<CoreOAuthApi>;
if (
typeof runtimeApi.loginClineOAuth !== "function" ||
typeof runtimeApi.loginOcaOAuth !== "function" ||
typeof runtimeApi.loginOpenAICodex !== "function"
) {
throw new Error(
"Installed @cline/core does not expose OAuth login helpers required by the CLI",
);
}
return runtimeApi as CoreOAuthApi;
});
}
return cachedCoreOAuthApi;
}
/**
* Create the `auth` subcommand for Commander.
*
* In the auth context, `-p` means `--provider` and `-m` means `--modelid`,
* which intentionally shadows the global `-p` (--plan) and `-m` (--model)
* short flags. Commander scopes options per-command, so there is no conflict.
*/
export function createAuthCommand(): Command {
const cmd = new Command("auth")
.description("Authenticate with an LLM provider")
.exitOverride()
.configureOutput({ writeOut: () => {}, writeErr: () => {} })
.argument("[provider]", "provider id (positional shorthand for -p)")
.option("-p, --provider <id>", "provider id")
.option("-k, --apikey <key>", "API key")
.option("-m, --modelid <id>", "model id")
.option("-b, --baseurl <url>", "base URL");
return cmd;
}
export function parseAuthCommandArgs(args: string[]): ParsedAuthCommandArgs {
const cmd = createAuthCommand();
try {
cmd.parse(args, { from: "user" });
} catch {
// Commander throws on --help / --version / unknown flags via exitOverride
return { parseError: `unknown auth option in: ${args.join(" ")}` };
}
const opts = cmd.opts<{
provider?: string;
apikey?: string;
modelid?: string;
baseurl?: string;
}>();
const positionalProvider = cmd.args[0];
return {
explicitProvider: opts.provider ?? positionalProvider,
apikey: opts.apikey,
modelid: opts.modelid,
baseurl: opts.baseurl,
};
}
async function loadProviderCatalog(
providerSettingsManager: ProviderSettingsManager,
): Promise<Array<{ id: string; name: string }>> {
await ensureCustomProvidersLoaded(providerSettingsManager);
const catalog = await listLocalProviders(providerSettingsManager);
return catalog.providers
.map((provider) => ({
id: provider.id.trim(),
name: provider.name.trim() || provider.id.trim(),
}))
.filter((provider) => provider.id.length > 0)
.sort((a, b) => a.id.localeCompare(b.id));
}
async function ensureQuickSetupInputValid(
input: AuthQuickSetupInput,
providerSettingsManager: ProviderSettingsManager,
): Promise<string | undefined> {
const normalizedProvider = normalizeProviderId(input.provider);
const providerCatalog = await loadProviderCatalog(providerSettingsManager);
if (!providerCatalog.some((provider) => provider.id === normalizedProvider)) {
return `invalid provider "${input.provider}"`;
}
if (!input.apikey.trim()) {
return "auth quick setup requires --apikey <key>";
}
if (!input.modelid.trim()) {
return "auth quick setup requires --modelid <id>";
}
if (
input.baseurl?.trim() &&
normalizedProvider !== "openai" &&
normalizedProvider !== "openai-native"
) {
return "base URL is only supported for OpenAI and OpenAI-compatible providers";
}
return undefined;
}
function saveQuickAuthProviderSettings(input: {
providerSettingsManager: ProviderSettingsManager;
providerId: string;
apikey: string;
modelid: string;
baseurl?: string;
}): void {
const existing = input.providerSettingsManager.getProviderSettings(
input.providerId,
);
const nextSettings: ProviderSettings = {
...(existing ?? {
provider: input.providerId as ProviderSettings["provider"],
}),
provider: input.providerId as ProviderSettings["provider"],
apiKey: input.apikey,
model: input.modelid,
};
if (input.baseurl?.trim()) {
nextSettings.baseUrl = input.baseurl.trim();
}
input.providerSettingsManager.saveProviderSettings(nextSettings);
}
async function askForInputInTerminal(question: string): Promise<string> {
if (!process.stdin.isTTY || !process.stdout.isTTY) {
throw new Error("OAuth login requires an interactive terminal session");
}
return new Promise<string>((resolve) => {
const rl = createInterface({
input: process.stdin,
output: process.stdout,
});
rl.question(`${question} `, (value) => {
rl.close();
resolve(value);
});
});
}
function createOAuthCallbacks(io: AuthIo): {
onAuth: (info: { url: string; instructions?: string }) => void;
onPrompt: (prompt: {
message: string;
defaultValue?: string;
}) => Promise<string>;
} {
return createOAuthClientCallbacks({
onPrompt: ({ message, defaultValue }) =>
askForInputInTerminal(message).then((value) => {
const trimmed = value.trim();
return trimmed || defaultValue || "";
}),
onOutput: (message) => {
io.writeln(`${c.dim}[auth] ${message}${c.reset}`);
},
openUrl: (url) => open(url, { wait: false }).then(() => undefined),
onOpenUrlError: ({ error }) => {
io.writeln(
`${c.dim}[auth] Could not open browser automatically; open the URL above manually.${c.reset}`,
);
io.writeln(
`${c.dim}[auth] Browser open failed: ${error instanceof Error ? error.message : String(error)}${c.reset}`,
);
},
});
}
async function loginWithOAuthProvider(
providerId: string,
existing: ProviderSettings | undefined,
io: AuthIo,
): Promise<OAuthCredentials> {
const oauthApi = await getCoreOAuthApi();
const callbacks = createOAuthCallbacks(io);
if (providerId === "cline") {
return oauthApi.loginClineOAuth({
apiBaseUrl:
existing?.baseUrl?.trim() || getClineEnvironmentConfig().apiBaseUrl,
useWorkOSDeviceAuth: true,
callbacks,
});
}
if (providerId === "oca") {
const mode = existing?.oca?.mode;
return oauthApi.loginOcaOAuth({
mode,
callbacks,
});
}
if (providerId === "openai-codex") {
return oauthApi.loginOpenAICodex(callbacks);
}
throw new Error(
`Provider "${providerId}" does not support CLI OAuth flow (supported: cline, openai-codex, oca)`,
);
}
export function saveOAuthProviderSettings(
providerSettingsManager: ProviderSettingsManager,
providerId: string,
existing: ProviderSettings | undefined,
credentials: OAuthCredentials,
): ProviderSettings {
const auth = {
...(existing?.auth ?? {}),
accessToken: toProviderApiKey(providerId, credentials),
refreshToken: credentials.refresh,
accountId: credentials.accountId,
} as ProviderSettings["auth"] & { expiresAt?: number };
auth.expiresAt = credentials.expires;
const merged: ProviderSettings = {
...(existing ?? {
provider: providerId as ProviderSettings["provider"],
}),
provider: providerId as ProviderSettings["provider"],
auth,
};
providerSettingsManager.saveProviderSettings(merged, {
tokenSource: "oauth",
});
return merged;
}
export async function ensureOAuthProviderApiKey(input: {
providerId: string;
currentApiKey?: string;
existingSettings?: ProviderSettings;
providerSettingsManager: ProviderSettingsManager;
io: AuthIo;
}): Promise<{
apiKey?: string;
selectedProviderSettings?: ProviderSettings;
}> {
if (input.currentApiKey || !isOAuthProvider(input.providerId)) {
return {
apiKey: input.currentApiKey,
selectedProviderSettings: input.existingSettings,
};
}
const credentials = await loginWithOAuthProvider(
input.providerId,
input.existingSettings,
input.io,
);
const selectedProviderSettings = saveOAuthProviderSettings(
input.providerSettingsManager,
input.providerId,
input.existingSettings,
credentials,
);
return {
apiKey: toProviderApiKey(input.providerId, credentials),
selectedProviderSettings,
};
}
async function runQuickAuthSetup(input: AuthCommandInput): Promise<number> {
const providerId = normalizeProviderId((input.explicitProvider ?? "").trim());
const apikey = input.apikey?.trim() ?? "";
const modelid = input.modelid?.trim() ?? "";
const baseurl = input.baseurl?.trim();
const validationError = await ensureQuickSetupInputValid(
{
provider: providerId,
apikey,
modelid,
baseurl,
},
input.providerSettingsManager,
);
if (validationError) {
input.io.writeErr(validationError);
return 1;
}
saveQuickAuthProviderSettings({
providerSettingsManager: input.providerSettingsManager,
providerId,
apikey,
modelid,
baseurl,
});
input.io.writeln(
`${c.green}Provider configured:${c.reset} ${c.cyan}${providerId}${c.reset} (${modelid})`,
);
return 0;
}
export async function loadAuthTuiRuntime() {
disableOpenTuiGraphicsProbe();
const { createCliRenderer } = await import("@opentui/core");
const { createRoot } = await import("@opentui/react");
const { OnboardingView } = await import("../tui/views/onboarding");
return { createCliRenderer, createRoot, OnboardingView };
}
async function runInteractiveAuthTui(input: AuthCommandInput): Promise<number> {
if (!process.stdin.isTTY || !process.stdout.isTTY) {
input.io.writeErr(
"interactive auth setup requires a TTY (use --provider/--apikey/--modelid for non-interactive setup)",
);
return 1;
}
const { createCliRenderer, createRoot, OnboardingView } =
await loadAuthTuiRuntime();
const renderer = await createCliRenderer({
exitOnCtrlC: false,
autoFocus: false,
enableMouseMovement: true,
});
return await new Promise<number>((resolve, reject) => {
let root: ReturnType<typeof createRoot>;
try {
root = createRoot(renderer);
} catch (error) {
renderer.destroy();
reject(error);
return;
}
let settled = false;
let unmounted = false;
const unmountRoot = () => {
if (unmounted) {
return;
}
unmounted = true;
root.unmount();
};
const settle = (code: number) => {
if (settled) {
return;
}
settled = true;
unmountRoot();
renderer.destroy();
resolve(code);
};
renderer.on("destroy", () => {
unmountRoot();
if (!settled) {
settled = true;
resolve(1);
}
});
try {
root.render(
React.createElement(OnboardingView, {
providerSettingsManager: input.providerSettingsManager,
onComplete: () => settle(0),
onExit: () => settle(1),
}),
);
} catch (error) {
unmountRoot();
renderer.destroy();
reject(error);
}
});
}
export async function runAuthCommand(input: AuthCommandInput): Promise<number> {
const hasQuickSetupFlags =
typeof input.apikey === "string" ||
typeof input.modelid === "string" ||
typeof input.baseurl === "string";
if (hasQuickSetupFlags) {
if (!input.explicitProvider?.trim()) {
input.io.writeErr(
"auth quick setup requires --provider <id> when using --apikey/--modelid/--baseurl",
);
return 1;
}
return runQuickAuthSetup(input);
}
if (input.explicitProvider?.trim()) {
const providerId = normalizeAuthProviderId(input.explicitProvider);
if (isOAuthProvider(providerId)) {
return runAuthProviderCommand(
input.providerSettingsManager,
providerId,
input.io,
);
}
input.io.writeErr(
`provider "${providerId}" requires API key setup (use subcommand: auth --provider ${providerId} --apikey <key> --modelid <id>)`,
);
return 1;
}
return runInteractiveAuthTui(input);
}
export async function runAuthProviderCommand(
providerSettingsManager: ProviderSettingsManager,
providerId: string,
io: AuthIo,
): Promise<number> {
if (!isOAuthProvider(providerId)) {
io.writeErr(
`provider "${providerId}" does not support OAuth login (supported: cline, openai-codex, oca)`,
);
return 1;
}
try {
const existing = providerSettingsManager.getProviderSettings(providerId);
const credentials = await loginWithOAuthProvider(providerId, existing, io);
saveOAuthProviderSettings(
providerSettingsManager,
providerId,
existing,
credentials,
);
io.writeln(
`${c.green}You are now logged in to ${c.cyan}${providerId}${c.reset}`,
);
return 0;
} catch (error) {
io.writeErr(error instanceof Error ? error.message : String(error));
return 1;
}
}
@@ -0,0 +1,87 @@
import { spawnSync } from "node:child_process";
import {
chmodSync,
copyFileSync,
mkdirSync,
mkdtempSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
const sourceWrapperPath = fileURLToPath(
new URL("../../bin/cline", import.meta.url),
);
function createWrapperCopy(): string {
const dir = mkdtempSync(join(tmpdir(), "cline-bin-package-"));
const binDir = join(dir, "bin");
mkdirSync(binDir, { recursive: true });
const wrapperPath = join(binDir, "cline");
copyFileSync(sourceWrapperPath, wrapperPath);
chmodSync(wrapperPath, 0o755);
return wrapperPath;
}
function createExecutableScript(contents: string): string {
const dir = mkdtempSync(join(tmpdir(), "cline-bin-wrapper-"));
const scriptPath = join(dir, "child.js");
writeFileSync(scriptPath, `#!/usr/bin/env node\n${contents}`);
chmodSync(scriptPath, 0o755);
return scriptPath;
}
function runWrapper(target: string, args: string[] = []) {
const wrapperPath = createWrapperCopy();
return spawnSync(process.execPath, [wrapperPath, ...args], {
env: {
...process.env,
CLINE_BIN_PATH: target,
},
encoding: "utf8",
});
}
describe("bin/cline wrapper", () => {
it("preserves the child process exit status", () => {
const target = createExecutableScript(`
process.exit(Number(process.argv[2] ?? "0"));
`);
const result = runWrapper(target, ["7"]);
expect(result.error).toBeUndefined();
expect(result.status).toBe(7);
expect(result.signal).toBeNull();
});
it("passes the wrapper path to the compiled binary", () => {
const target = createExecutableScript(`
console.log(process.env.CLINE_WRAPPER_PATH ?? "");
`);
const result = runWrapper(target);
expect(result.error).toBeUndefined();
expect(result.status).toBe(0);
expect(result.stdout.trim()).toMatch(/bin[/\\]cline$/);
});
it.skipIf(process.platform === "win32")(
"propagates child process signal termination on POSIX",
() => {
const target = createExecutableScript(`
process.kill(process.pid, "SIGTERM");
setTimeout(() => {}, 1000);
`);
const result = runWrapper(target);
expect(result.error).toBeUndefined();
expect(result.status).toBeNull();
expect(result.signal).toBe("SIGTERM");
},
);
});
@@ -0,0 +1,57 @@
import { describe, expect, it } from "vitest";
import {
parseBuildOptions,
shouldInstallNativeVariants,
validateBuildOptions,
} from "../../script/build-options";
describe("CLI build options", () => {
it("does not install native variants during single-platform builds by default", () => {
const options = parseBuildOptions(["--single"]);
expect(options.single).toBe(true);
expect(
shouldInstallNativeVariants({
options,
opentuiVersion: "0.1.102",
}),
).toBe(false);
expect(
validateBuildOptions({
options,
opentuiVersion: "0.1.102",
targetCount: 1,
}),
).toBeUndefined();
});
it("requires explicit native variant install for cross-platform OpenTUI builds", () => {
const options = parseBuildOptions([]);
expect(
validateBuildOptions({
options,
opentuiVersion: "0.1.102",
targetCount: 6,
}),
).toContain("--install-native-variants");
});
it("allows cross-platform builds to opt into native variant installation", () => {
const options = parseBuildOptions(["--install-native-variants"]);
expect(
shouldInstallNativeVariants({
options,
opentuiVersion: "0.1.102",
}),
).toBe(true);
expect(
validateBuildOptions({
options,
opentuiVersion: "0.1.102",
targetCount: 6,
}),
).toBeUndefined();
});
});
+540
View File
@@ -0,0 +1,540 @@
import { existsSync, readdirSync, readFileSync } from "node:fs";
import { homedir } from "node:os";
import { basename, extname, join } from "node:path";
import {
type BuiltinToolAvailabilityContext,
createUserInstructionConfigService,
discoverPluginModulePaths,
hasMcpSettingsFile,
listHookConfigFiles,
listPluginTools,
type RuleConfig,
resolveDefaultMcpSettingsPath,
resolveMcpServerRegistrations,
resolvePluginConfigSearchPaths,
type SkillConfig,
type WorkflowConfig,
} from "@cline/core";
import { Command } from "commander";
import { getToolCatalog } from "../runtime/tools";
import { loadInteractiveConfigData } from "../tui/interactive-config";
import type { CliOutputMode } from "../utils/types";
type ConfigIo = {
writeln: (text?: string) => void;
writeErr: (text: string) => void;
};
function resolveCliAgentConfigSearchPaths(cwd: string): string[] {
const clineDir = process.env.CLINE_DIR?.trim() || join(homedir(), ".cline");
return [join(cwd, ".cline", "agents"), join(clineDir, "agents")];
}
async function runWorkflowsConfigCommand(
cwd: string,
outputMode: CliOutputMode,
io: ConfigIo,
): Promise<number> {
const workflowsById = new Map<
string,
{ id: string; name: string; instructions: string; path: string }
>();
const service = createUserInstructionConfigService({
skills: { workspacePath: cwd },
rules: { workspacePath: cwd },
workflows: { workspacePath: cwd },
});
try {
await service.start();
for (const record of service.listRecords<WorkflowConfig>("workflow")) {
const workflow = record.item;
if (workflow.disabled === true || workflowsById.has(record.id)) {
continue;
}
workflowsById.set(record.id, {
id: record.id,
name: workflow.name,
instructions: workflow.instructions,
path: record.filePath,
});
}
} catch {
// Best-effort listing across config roots.
} finally {
service.stop();
}
const workflows = [...workflowsById.values()].sort((a, b) =>
a.name.localeCompare(b.name),
);
if (outputMode === "json") {
process.stdout.write(JSON.stringify(workflows));
return 0;
}
if (workflows.length === 0) {
io.writeln("No enabled workflows found.");
return 0;
}
io.writeln("Available workflows:");
for (const workflow of workflows) {
io.writeln(` /${workflow.name} (${workflow.path})`);
}
return 0;
}
async function runRulesConfigCommand(
cwd: string,
outputMode: CliOutputMode,
io: ConfigIo,
): Promise<number> {
const rulesByName = new Map<
string,
{ name: string; instructions: string; path: string }
>();
const service = createUserInstructionConfigService({
skills: { workspacePath: cwd },
rules: { workspacePath: cwd },
workflows: { workspacePath: cwd },
});
try {
await service.start();
for (const record of service.listRecords<RuleConfig>("rule")) {
const rule = record.item;
if (rule.disabled === true || rulesByName.has(rule.name)) {
continue;
}
rulesByName.set(rule.name, {
name: rule.name,
instructions: rule.instructions,
path: record.filePath,
});
}
} catch {
// Best-effort listing across config roots.
} finally {
service.stop();
}
const rules = [...rulesByName.values()].sort((a, b) =>
a.name.localeCompare(b.name),
);
if (outputMode === "json") {
process.stdout.write(JSON.stringify(rules));
return 0;
}
if (rules.length === 0) {
io.writeln("No enabled rules found.");
return 0;
}
io.writeln("Enabled rules:");
for (const rule of rules) {
io.writeln(` ${rule.name} (${rule.path})`);
}
return 0;
}
async function runSkillsConfigCommand(
cwd: string,
outputMode: CliOutputMode,
io: ConfigIo,
): Promise<number> {
const skillsByName = new Map<
string,
SkillConfig & {
path: string;
}
>();
const service = createUserInstructionConfigService({
skills: { workspacePath: cwd },
rules: { workspacePath: cwd },
workflows: { workspacePath: cwd },
});
try {
await service.start();
for (const record of service.listRecords<SkillConfig>("skill")) {
const skill = record.item;
if (skill.disabled === true || skillsByName.has(skill.name)) {
continue;
}
skillsByName.set(skill.name, {
...skill,
path: record.filePath,
});
}
} catch {
// Best-effort listing across config roots.
} finally {
service.stop();
}
const skills = [...skillsByName.values()].sort((a, b) =>
a.name.localeCompare(b.name),
);
if (outputMode === "json") {
process.stdout.write(JSON.stringify(skills));
return 0;
}
if (skills.length === 0) {
io.writeln("No enabled skills found.");
return 0;
}
io.writeln("Enabled skills:");
for (const skill of skills) {
io.writeln(` ${skill.name} (${skill.path})`);
}
return 0;
}
async function runAgentsConfigCommand(
cwd: string,
outputMode: CliOutputMode,
io: ConfigIo,
): Promise<number> {
const agentsById = new Map<
string,
{
name: string;
path: string;
}
>();
const directories = resolveCliAgentConfigSearchPaths(cwd).filter(
(directory) => existsSync(directory),
);
for (const directory of directories) {
try {
const entries = readdirSync(directory, { withFileTypes: true });
for (const entry of entries) {
if (!entry.isFile()) {
continue;
}
const extension = extname(entry.name).toLowerCase();
if (extension !== ".yml" && extension !== ".yaml") {
continue;
}
const filePath = join(directory, entry.name);
const raw = readFileSync(filePath, "utf8");
const frontmatterMatch = raw.match(/^---\r?\n([\s\S]*?)\r?\n---/);
const frontmatter = frontmatterMatch?.[1] ?? "";
const nameMatch = frontmatter.match(/^\s*name:\s*(.+?)\s*$/m);
const parsedName = nameMatch?.[1]?.replace(/^["']|["']$/g, "").trim();
const name =
parsedName && parsedName.length > 0
? parsedName
: basename(entry.name, extension);
const id = name.toLowerCase();
if (agentsById.has(id)) {
continue;
}
agentsById.set(id, { name, path: filePath });
}
} catch {
// Best-effort listing across config roots.
}
}
const agents = [...agentsById.values()].sort((a, b) =>
a.name.localeCompare(b.name),
);
if (outputMode === "json") {
process.stdout.write(JSON.stringify(agents));
return 0;
}
if (agents.length === 0) {
io.writeln("No configured agents found.");
return 0;
}
io.writeln("Configured agents:");
for (const agent of agents) {
io.writeln(` ${agent.name} (${agent.path})`);
}
return 0;
}
async function runPluginsConfigCommand(
cwd: string,
outputMode: CliOutputMode,
io: ConfigIo,
): Promise<number> {
const pluginsByPath = new Map<
string,
{
name: string;
path: string;
}
>();
const directories = resolvePluginConfigSearchPaths(cwd).filter((directory) =>
existsSync(directory),
);
for (const directory of directories) {
try {
for (const filePath of discoverPluginModulePaths(directory)) {
if (pluginsByPath.has(filePath)) {
continue;
}
pluginsByPath.set(filePath, {
name: basename(filePath, extname(filePath)),
path: filePath,
});
}
} catch {
// Best-effort listing across config roots.
}
}
const plugins = [...pluginsByPath.values()].sort((a, b) =>
a.name.localeCompare(b.name),
);
if (outputMode === "json") {
process.stdout.write(JSON.stringify(plugins));
return 0;
}
if (plugins.length === 0) {
io.writeln("No plugins found.");
return 0;
}
io.writeln("Discovered plugins:");
for (const plugin of plugins) {
io.writeln(` ${plugin.name} (${plugin.path})`);
}
return 0;
}
async function runHooksConfigCommand(
cwd: string,
outputMode: CliOutputMode,
io: ConfigIo,
): Promise<number> {
const hooks = listHookConfigFiles(cwd);
if (outputMode === "json") {
process.stdout.write(JSON.stringify(hooks));
return 0;
}
if (hooks.length === 0) {
io.writeln("No hook files found.");
return 0;
}
io.writeln("Hook files:");
for (const item of hooks) {
const mapped = item.hookEventName ? ` -> ${item.hookEventName}` : "";
io.writeln(` ${item.fileName}${mapped} (${item.path})`);
}
return 0;
}
async function runMcpConfigCommand(
outputMode: CliOutputMode,
io: ConfigIo,
): Promise<number> {
const settingsPath = resolveDefaultMcpSettingsPath();
if (!hasMcpSettingsFile({ filePath: settingsPath })) {
if (outputMode === "json") {
process.stdout.write(JSON.stringify([]));
return 0;
}
io.writeln(`No MCP settings file found at ${settingsPath}`);
return 0;
}
try {
const servers = resolveMcpServerRegistrations({ filePath: settingsPath })
.map((registration) => ({
name: registration.name,
transportType: registration.transport.type,
disabled: registration.disabled === true,
path: settingsPath,
}))
.sort((a, b) => a.name.localeCompare(b.name));
if (outputMode === "json") {
process.stdout.write(JSON.stringify(servers));
return 0;
}
if (servers.length === 0) {
io.writeln(`No MCP servers configured in ${settingsPath}`);
return 0;
}
io.writeln(`Configured MCP servers (${settingsPath}):`);
for (const server of servers) {
const disabledSuffix = server.disabled ? " (disabled)" : "";
io.writeln(` ${server.name} [${server.transportType}]${disabledSuffix}`);
}
return 0;
} catch (error) {
io.writeErr(error instanceof Error ? error.message : String(error));
return 1;
}
}
async function runToolsConfigCommand(
cwd: string,
outputMode: CliOutputMode,
io: ConfigIo,
availabilityContext?: BuiltinToolAvailabilityContext,
): Promise<number> {
const tools = getToolCatalog(availabilityContext);
const pluginTools = await listPluginTools({
workspacePath: cwd,
cwd,
});
if (outputMode === "json") {
process.stdout.write(
JSON.stringify([
...tools,
...pluginTools.map((tool) => ({
name: tool.name,
type: "plugin" as const,
pluginName: tool.pluginName,
path: tool.path,
source: tool.source,
enabled: tool.enabled,
description: tool.description,
})),
]),
);
return 0;
}
if (tools.length === 0 && pluginTools.length === 0) {
io.writeln("No tools found.");
return 0;
}
io.writeln("Available tools:");
for (const tool of tools) {
const state = tool.defaultEnabled ? "enabled" : "disabled";
const names =
tool.headlessToolNames.length === 1 &&
tool.headlessToolNames[0] === tool.id
? ""
: ` -> ${tool.headlessToolNames.join(", ")}`;
io.writeln(` ${tool.id} [${state}]${names}`);
}
if (pluginTools.length > 0) {
io.writeln();
io.writeln("Plugin tools:");
for (const tool of pluginTools) {
io.writeln(
` ${tool.name} [plugin: ${tool.pluginName}] [${tool.enabled ? "enabled" : "disabled"}] (${tool.path})`,
);
}
}
return 0;
}
async function loadInteractiveConfigDataForCommand(
cwd: string,
): Promise<Awaited<ReturnType<typeof loadInteractiveConfigData>>> {
const userInstructionService = createUserInstructionConfigService({
skills: { workspacePath: cwd },
rules: { workspacePath: cwd },
workflows: { workspacePath: cwd },
});
try {
await userInstructionService.start();
return await loadInteractiveConfigData({
userInstructionService,
cwd,
workspaceRoot: cwd,
availabilityContext: {
mode: "act",
},
});
} finally {
userInstructionService.stop();
}
}
export function createConfigCommand(
getCwd: () => string,
getOutputMode: () => CliOutputMode,
io: ConfigIo,
setExitCode: (code: number) => void,
launchInteractiveConfigView: () => void,
): Command {
let actionExitCode: number | undefined;
const config = new Command("config")
.description("Show current configuration")
.argument("[target]")
.option("--json", "Output as JSON")
.option("--config <dir>", "configuration directory")
.exitOverride()
.action(async (target?: string) => {
if (!target) {
if (getOutputMode() === "json") {
process.stdout.write(
`${JSON.stringify(await loadInteractiveConfigDataForCommand(getCwd()))}\n`,
);
actionExitCode = 0;
return;
}
actionExitCode = undefined;
launchInteractiveConfigView();
return;
}
switch (target) {
case "workflows":
actionExitCode = await runWorkflowsConfigCommand(
getCwd(),
getOutputMode(),
io,
);
break;
case "rules":
actionExitCode = await runRulesConfigCommand(
getCwd(),
getOutputMode(),
io,
);
break;
case "skills":
actionExitCode = await runSkillsConfigCommand(
getCwd(),
getOutputMode(),
io,
);
break;
case "agents":
actionExitCode = await runAgentsConfigCommand(
getCwd(),
getOutputMode(),
io,
);
break;
case "plugins":
actionExitCode = await runPluginsConfigCommand(
getCwd(),
getOutputMode(),
io,
);
break;
case "hooks":
actionExitCode = await runHooksConfigCommand(
getCwd(),
getOutputMode(),
io,
);
break;
case "mcp":
actionExitCode = await runMcpConfigCommand(getOutputMode(), io);
break;
case "tools":
actionExitCode = await runToolsConfigCommand(
getCwd(),
getOutputMode(),
io,
{ mode: "act" },
);
break;
default:
io.writeErr(
`config requires one of: workflows, rules, skills, agents, plugins, hooks, mcp, tools (got "${target}")`,
);
actionExitCode = 1;
}
})
.hook("postAction", () => {
if (typeof actionExitCode === "number") {
setExitCode(actionExitCode);
}
});
return config;
}
+78
View File
@@ -0,0 +1,78 @@
import { getConnector, listConnectors } from "../connectors/registry";
import type { ConnectIo, ConnectStopResult } from "../connectors/types";
export async function stopAllConnectors(
io: ConnectIo,
): Promise<ConnectStopResult & { executed: number }> {
let stoppedProcesses = 0;
let stoppedSessions = 0;
let executed = 0;
for (const entry of listConnectors()) {
const connector = await getConnector(entry.name);
if (!connector) {
continue;
}
if (!connector.stopAll) {
continue;
}
executed += 1;
const result = await connector.stopAll(io);
stoppedProcesses += result.stoppedProcesses;
stoppedSessions += result.stoppedSessions;
}
return { stoppedProcesses, stoppedSessions, executed };
}
export async function runStopAllConnectors(io: ConnectIo): Promise<number> {
const { stoppedProcesses, stoppedSessions, executed } =
await stopAllConnectors(io);
if (executed === 0) {
io.writeln("[connect] no adapters support stop yet");
return 0;
}
io.writeln(
`[connect] stopped processes=${stoppedProcesses} sessions=${stoppedSessions}`,
);
return 0;
}
export async function runStopConnector(
adapterName: string,
io: ConnectIo,
): Promise<number> {
const connector = await getConnector(adapterName);
if (!connector) {
io.writeErr(`unknown connect adapter "${adapterName}"`);
return 1;
}
if (!connector.stopAll) {
io.writeErr(`connect adapter "${adapterName}" does not support stop`);
return 1;
}
const result: ConnectStopResult = await connector.stopAll(io);
io.writeln(
`[connect] ${connector.name} stopped processes=${result.stoppedProcesses} sessions=${result.stoppedSessions}`,
);
return 0;
}
export async function runConnectAdapter(
adapterName: string,
passthroughArgs: string[],
io: ConnectIo,
): Promise<number> {
const connector = await getConnector(adapterName);
if (!connector) {
io.writeErr(`unknown connect adapter "${adapterName}"`);
return 1;
}
return connector.run(passthroughArgs, io);
}
export function formatAdapterList(): string {
const lines: string[] = [];
for (const connector of listConnectors()) {
lines.push(` ${connector.name.padEnd(12)} ${connector.description}`);
}
return lines.join("\n");
}
@@ -0,0 +1,94 @@
import { spawnSync } from "node:child_process";
import {
chmod,
mkdir,
mkdtemp,
readdir,
rm,
writeFile,
} from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
import {
DIRECT_PUBLISH_GUARD_MESSAGE,
shouldAllowDirectPublish,
} from "../../script/guard-direct-publish";
describe("CLI distribution package shape", () => {
it("rejects direct source package publishing by default", () => {
expect(shouldAllowDirectPublish({})).toBe(false);
expect(shouldAllowDirectPublish({ CLINE_ALLOW_DIRECT_PUBLISH: "1" })).toBe(
true,
);
expect(DIRECT_PUBLISH_GUARD_MESSAGE).toContain(
"Direct packaging or publishing from apps/cli is disabled.",
);
});
it("rejects direct source package packing by default", () => {
const cliRoot = fileURLToPath(new URL("../..", import.meta.url));
const result = spawnSync("bun", ["pm", "pack", "--dry-run"], {
cwd: cliRoot,
encoding: "utf8",
});
expect(result.status).not.toBe(0);
expect(result.stderr).toContain(DIRECT_PUBLISH_GUARD_MESSAGE);
});
it("packs the generated npm wrapper package", async () => {
const packageDir = await mkdtemp(join(tmpdir(), "cline-cli-pack-"));
try {
await mkdir(join(packageDir, "bin"), { recursive: true });
await writeFile(
join(packageDir, "package.json"),
`${JSON.stringify(
{
name: "cline",
version: "1.2.3",
description: "CLI test package",
license: "Apache-2.0",
bin: {
cline: "./bin/cline",
},
scripts: {
postinstall: "node ./postinstall.mjs || true",
},
optionalDependencies: {
"@cline/cli-linux-x64": "1.2.3",
},
},
null,
2,
)}\n`,
);
await writeFile(
join(packageDir, "bin", "cline"),
[
"#!/usr/bin/env node",
'console.log("cline wrapper smoke test");',
"",
].join("\n"),
);
await chmod(join(packageDir, "bin", "cline"), 0o755);
await writeFile(
join(packageDir, "postinstall.mjs"),
"process.exit(0);\n",
);
const result = spawnSync("bun", ["pm", "pack"], {
cwd: packageDir,
encoding: "utf8",
});
expect(result.status).toBe(0);
const files = await readdir(packageDir);
expect(files.some((file) => file.endsWith(".tgz"))).toBe(true);
} finally {
await rm(packageDir, { recursive: true, force: true });
}
});
});
+389
View File
@@ -0,0 +1,389 @@
import {
appendFileSync,
existsSync,
mkdirSync,
mkdtempSync,
rmSync,
writeFileSync,
} from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { getCliBuildInfo } from "../utils/common";
const {
mockSpawnSync,
mockResolveClineDataDir,
mockResolveSharedHubOwnerContext,
mockReadHubDiscovery,
mockProbeHubServer,
mockClearHubDiscovery,
mockStopLocalHubServerGracefully,
mockEnsureFileExists,
mockStopAllConnectors,
} = vi.hoisted(() => ({
mockSpawnSync: vi.fn(),
mockResolveClineDataDir: vi.fn(() => "/tmp/cline-data"),
mockResolveSharedHubOwnerContext: vi.fn(() => ({
ownerId: "hub-owner",
discoveryPath: path.join(
"/tmp/cline-data",
"locks",
"hub",
"owners",
"hub-owner.json",
),
})),
mockReadHubDiscovery: vi.fn(),
mockProbeHubServer: vi.fn(),
mockClearHubDiscovery: vi.fn(),
mockStopLocalHubServerGracefully: vi.fn(async () => false),
mockEnsureFileExists: vi.fn(),
mockStopAllConnectors: vi.fn(async () => ({
stoppedProcesses: 0,
stoppedSessions: 0,
executed: 0,
})),
}));
vi.mock("node:child_process", () => ({
spawnSync: mockSpawnSync,
}));
vi.mock("@cline/core", () => ({
resolveClineDataDir: mockResolveClineDataDir,
resolveSharedHubOwnerContext: mockResolveSharedHubOwnerContext,
clearHubDiscovery: mockClearHubDiscovery,
probeHubServer: mockProbeHubServer,
readHubDiscovery: mockReadHubDiscovery,
stopLocalHubServerGracefully: mockStopLocalHubServerGracefully,
ensureFileExists: mockEnsureFileExists,
}));
vi.mock("../connectors/common", () => ({
isProcessRunning: vi.fn(() => false),
}));
vi.mock("./connect", () => ({
stopAllConnectors: mockStopAllConnectors,
}));
import { createDoctorCommand, runDoctorCommand } from "./doctor";
describe("runDoctorCommand", () => {
const tempDirs: string[] = [];
afterEach(() => {
vi.clearAllMocks();
mockResolveClineDataDir.mockReturnValue("/tmp/cline-data");
mockStopLocalHubServerGracefully.mockResolvedValue(false);
mockStopAllConnectors.mockResolvedValue({
stoppedProcesses: 0,
stoppedSessions: 0,
executed: 0,
});
for (const dir of tempDirs.splice(0)) {
rmSync(dir, { recursive: true, force: true });
}
});
it("does not report hub processes as stale cli processes", async () => {
const cwd = "/workspace";
mockReadHubDiscovery.mockResolvedValue({
url: "ws://127.0.0.1:25463/hub",
port: 25463,
pid: 50174,
});
mockProbeHubServer.mockResolvedValue({
url: "ws://127.0.0.1:25463/hub",
port: 25463,
pid: 50174,
});
mockSpawnSync.mockImplementation((command: string, args?: string[]) => {
if (command === "lsof") {
return {
status: 0,
stdout: "50174\n",
};
}
if (
command === "pgrep" &&
Array.isArray(args) &&
args[0] === "-fal" &&
args[1] === "/apps/cli/src/index.ts"
) {
return {
status: 0,
stdout: [
"50174 /Users/example/.bun/bin/bun /Users/example/dev/sdk/apps/cli/src/index.ts hub start --cwd /workspace",
"50190 /Users/example/.bun/bin/bun /Users/example/dev/sdk/apps/cli/src/index.ts hey",
].join("\n"),
};
}
return { status: 1, stdout: "" };
});
const output: string[] = [];
const code = await runDoctorCommand(
{ cwd, json: true },
{
writeln: (text) => {
output.push(text ?? "");
},
writeErr: () => {},
},
);
expect(code).toBe(0);
expect(output).toHaveLength(1);
expect(JSON.parse(output[0] || "")).toMatchObject(
process.platform === "win32"
? {
listeningPids: [],
hubStartupLocks: [],
staleCliPids: [],
staleSidecarPids: [],
}
: {
listeningPids: [50174],
hubStartupLocks: [],
staleCliPids: [50190],
staleSidecarPids: [],
},
);
});
it("doctor --fix clears wedged hub startup artifacts when no server is actually running", async () => {
const cwd = mkdtempSync(path.join(os.tmpdir(), "doctor-hub-fix-"));
tempDirs.push(cwd);
const discoveryPath = path.join(cwd, ".hub-discovery.json");
mockResolveSharedHubOwnerContext.mockReturnValue({
ownerId: "hub-owner",
discoveryPath,
});
mockReadHubDiscovery.mockResolvedValue({
url: "ws://127.0.0.1:25463/hub",
port: 25463,
pid: 50000,
});
mockProbeHubServer.mockResolvedValue(undefined);
mockSpawnSync.mockReturnValue({ status: 1, stdout: "" });
const startupLockDir = `${discoveryPath}.lock`;
writeFileSync(
discoveryPath,
JSON.stringify({
url: "ws://127.0.0.1:25463/hub",
port: 25463,
pid: 50000,
}),
"utf8",
);
mkdirSync(startupLockDir, { recursive: true });
writeFileSync(
path.join(startupLockDir, "owner.json"),
JSON.stringify({
pid: process.pid,
acquiredAt: new Date().toISOString(),
}),
"utf8",
);
const output: string[] = [];
const code = await runDoctorCommand(
{ cwd, json: true, fix: true },
{
writeln: (text) => {
output.push(text ?? "");
},
writeErr: () => {},
},
);
expect(code).toBe(0);
expect(output).toHaveLength(1);
expect(JSON.parse(output[0] || "")).toMatchObject({
killed: {
hubListeners: 0,
cliProcesses: 0,
sidecarProcesses: 0,
hubStartupLocks: 1,
hubDiscovery: 1,
},
after: {
hubHealthy: false,
listeningPids: [],
hubStartupLocks: [],
staleSidecarPids: [],
},
});
expect(mockClearHubDiscovery).toHaveBeenCalledWith(discoveryPath);
});
it("doctor --fix stops connector adapters and reports counts in JSON", async () => {
const cwd = "/workspace";
mockReadHubDiscovery.mockResolvedValue(undefined);
mockProbeHubServer.mockResolvedValue(undefined);
mockSpawnSync.mockReturnValue({ status: 1, stdout: "" });
mockStopAllConnectors.mockResolvedValue({
stoppedProcesses: 2,
stoppedSessions: 5,
executed: 3,
});
const output: string[] = [];
const code = await runDoctorCommand(
{ cwd, json: true, fix: true },
{
writeln: (text) => {
output.push(text ?? "");
},
writeErr: () => {},
},
);
expect(code).toBe(0);
expect(mockStopAllConnectors).toHaveBeenCalledTimes(1);
expect(JSON.parse(output[0] || "")).toMatchObject({
killed: {
connectorProcesses: 2,
connectorSessions: 5,
},
});
});
it("doctor --fix kills stale code sidecar processes", async () => {
const cwd = "/workspace";
mockReadHubDiscovery.mockResolvedValue(undefined);
mockProbeHubServer.mockResolvedValue(undefined);
mockSpawnSync.mockImplementation((command: string, args?: string[]) => {
if (
command === "pgrep" &&
Array.isArray(args) &&
args[0] === "-fal" &&
args[1] === "/src-tauri/bin/code-sidecar"
) {
return {
status: 0,
stdout:
"60123 /Users/example/dev/sdk/apps/examples/desktop-app/src-tauri/bin/code-sidecar\n",
};
}
return { status: 1, stdout: "" };
});
const killSpy = vi.spyOn(process, "kill").mockImplementation(() => true);
const output: string[] = [];
const code = await runDoctorCommand(
{ cwd, json: true, fix: true },
{
writeln: (text) => {
output.push(text ?? "");
},
writeErr: () => {},
},
);
expect(code).toBe(0);
expect(killSpy).toHaveBeenCalledWith(60123, "SIGKILL");
expect(JSON.parse(output[0] || "")).toMatchObject({
before: {
staleSidecarPids: [60123],
},
killed: {
sidecarProcesses: 1,
},
});
killSpy.mockRestore();
});
});
describe("createDoctorCommand log subcommand", () => {
const tempDirs: string[] = [];
const commandName = getCliBuildInfo().name;
afterEach(() => {
for (const dir of tempDirs.splice(0)) {
rmSync(dir, { recursive: true, force: true });
}
});
it("opens the log file for doctor log", async () => {
const dataDir = mkdtempSync(
path.join(os.tmpdir(), `${commandName}-doctor-log-test-`),
);
tempDirs.push(dataDir);
mockResolveClineDataDir.mockReturnValue(dataDir);
mockEnsureFileExists.mockImplementation((filePath: string) => {
mkdirSync(path.dirname(filePath), { recursive: true });
appendFileSync(filePath, "");
});
const opened: string[] = [];
const output: string[] = [];
const errors: string[] = [];
let exitCode = 0;
const cmd = createDoctorCommand(
{
writeln: (text) => {
output.push(text ?? "");
},
writeErr: (text) => {
errors.push(text);
},
},
(code) => {
exitCode = code;
},
{
openPath: async (target) => {
opened.push(target);
},
},
);
await cmd.parseAsync(["log"], { from: "user" });
const expectedPath = path.join(dataDir, "logs", `${commandName}.log`);
expect(exitCode).toBe(0);
expect(errors).toHaveLength(0);
expect(opened).toEqual([expectedPath]);
expect(output).toEqual([`Opening logs stored at ${expectedPath}`]);
expect(existsSync(expectedPath)).toBe(true);
});
it("returns an error if opening log file fails", async () => {
const dataDir = mkdtempSync(
path.join(os.tmpdir(), `${commandName}-doctor-log-test-`),
);
tempDirs.push(dataDir);
mockResolveClineDataDir.mockReturnValue(dataDir);
const errors: string[] = [];
let exitCode = 0;
const cmd = createDoctorCommand(
{
writeln: () => {},
writeErr: (text) => {
errors.push(text);
},
},
(code) => {
exitCode = code;
},
{
openPath: async () => {
throw new Error("open failed");
},
},
);
await cmd.parseAsync(["log"], { from: "user" });
expect(exitCode).toBe(1);
expect(errors[0]).toContain("failed to open log file");
expect(errors[0]).toContain("open failed");
});
});
+700
View File
@@ -0,0 +1,700 @@
import { spawnSync } from "node:child_process";
import { existsSync, readdirSync, readFileSync, rmSync } from "node:fs";
import { dirname, join } from "node:path";
import {
clearHubDiscovery,
ensureFileExists,
probeHubServer,
readHubDiscovery,
resolveClineDataDir,
resolveSharedHubOwnerContext,
stopLocalHubServerGracefully,
} from "@cline/core";
import { formatUptime } from "@cline/shared";
import { Command } from "commander";
import open from "open";
import { isProcessRunning } from "../connectors/common";
import { getCliBuildInfo } from "../utils/common";
import { c, writeln } from "../utils/output";
import { stopAllConnectors } from "./connect";
type DoctorIo = {
writeln: (text?: string) => void;
writeErr: (text: string) => void;
};
export type DoctorCommandDeps = {
openPath?: (target: string) => Promise<void> | void;
};
type StartupArtifact = {
path: string;
pid?: number;
acquiredAt?: string;
stale: boolean;
};
type ActiveConnectorRecord = {
type: string;
pid: number;
hubUrl: string;
startedAt?: string;
applicationId?: string;
botUsername?: string;
userName?: string;
phoneNumberId?: string;
port?: number;
baseUrl?: string;
};
type SpawnedProcessRecord = {
timestamp?: string;
pid?: number;
command?: string;
component?: string;
detached?: boolean;
};
type DoctorStatus = {
cwd: string;
hubUrl?: string;
hubHealthy: boolean;
hubPid?: number;
hubStartedAt?: string;
hubUptime?: string;
listeningPids: number[];
hubStartupLocks: StartupArtifact[];
staleCliPids: number[];
staleSidecarPids: number[];
activeConnectors: ActiveConnectorRecord[];
recentSpawnedProcesses: SpawnedProcessRecord[];
};
type ProcessRecord = {
pid: number;
command: string;
};
function parsePids(raw: string): number[] {
return raw
.split(/\r?\n/)
.map((line) => Number.parseInt(line.trim(), 10))
.filter((pid) => Number.isInteger(pid) && pid > 0);
}
function listMatchingProcesses(pattern: string): ProcessRecord[] {
if (process.platform === "win32") {
return [];
}
const result = spawnSync("pgrep", ["-fal", pattern], { encoding: "utf8" });
if (result.status !== 0 && result.status !== 1) {
return [];
}
const records = new Map<number, ProcessRecord>();
for (const line of (result.stdout || "").split(/\r?\n/)) {
const trimmed = line.trim();
if (!trimmed) {
continue;
}
const match = trimmed.match(/^(\d+)\s+(.*)$/);
if (!match) {
continue;
}
const pid = Number.parseInt(match[1] || "", 10);
const command = match[2]?.trim();
if (
!Number.isInteger(pid) ||
pid <= 0 ||
!command ||
pid === process.pid ||
pid === process.ppid
) {
continue;
}
records.set(pid, { pid, command });
}
return [...records.values()].sort((a, b) => a.pid - b.pid);
}
function resolveCliLogPath(): string {
const { name } = getCliBuildInfo();
return join(resolveClineDataDir(), "logs", `${name}.log`);
}
async function defaultOpenPath(target: string): Promise<void> {
await open(target, { wait: false });
}
function listListeningPids(port: number | undefined): number[] {
if (!port || process.platform === "win32") {
return [];
}
const result = spawnSync("lsof", ["-nP", `-tiTCP:${port}`, "-sTCP:LISTEN"], {
encoding: "utf8",
});
if (result.status !== 0) {
return [];
}
return parsePids(result.stdout);
}
function listStaleCliPids(): number[] {
const patterns = [
"/apps/cli/src/index.ts",
"/apps/cli/dist/index.js",
"/dist/cline",
];
const records = new Map<number, ProcessRecord>();
for (const pattern of patterns) {
for (const record of listMatchingProcesses(pattern)) {
records.set(record.pid, record);
}
}
return [...records.values()]
.filter(
(record) => !/(?:^|\s)(?:hub|rpc|connect)(?:\s|$)/.test(record.command),
)
.map((record) => record.pid);
}
function listStaleSidecarPids(): number[] {
const patterns = [
"/apps/examples/desktop-app/sidecar/index.ts",
"/apps/examples/desktop-app/dist/sidecar/index.js",
// Keep the pre-example-reorg paths so `doctor --fix` can still clean up
// stale sidecars that were launched from older checkouts.
"/apps/code/sidecar/index.ts",
"/apps/code/dist/sidecar/index.js",
"/src-tauri/bin/code-sidecar",
"/Resources/code-sidecar",
" code-sidecar",
];
const records = new Map<number, ProcessRecord>();
for (const pattern of patterns) {
for (const record of listMatchingProcesses(pattern)) {
records.set(record.pid, record);
}
}
return [...records.values()].map((record) => record.pid);
}
function readRecentSpawnedProcesses(limit = 20): SpawnedProcessRecord[] {
const logPath = resolveCliLogPath();
if (!existsSync(logPath)) {
return [];
}
try {
const raw = readFileSync(logPath, "utf8");
const lines = raw
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean);
const records: SpawnedProcessRecord[] = [];
for (let index = lines.length - 1; index >= 0; index -= 1) {
try {
const parsed = JSON.parse(lines[index]) as Record<string, unknown>;
if (parsed.msg !== "Process spawned") {
continue;
}
records.push({
timestamp: typeof parsed.time === "string" ? parsed.time : undefined,
pid:
typeof parsed.childPid === "number" ? parsed.childPid : undefined,
command:
typeof parsed.command === "string" ? parsed.command : undefined,
component:
typeof parsed.component === "string" ? parsed.component : undefined,
detached:
typeof parsed.detached === "boolean" ? parsed.detached : undefined,
});
} catch {
// Ignore malformed lines.
}
if (records.length >= limit) {
break;
}
}
return records.reverse();
} catch {
return [];
}
}
function readStartupArtifact(path: string): StartupArtifact | undefined {
try {
const raw = JSON.parse(readFileSync(path, "utf8")) as Record<
string,
unknown
>;
const pid = typeof raw.pid === "number" ? raw.pid : undefined;
const acquiredAt =
typeof raw.acquiredAt === "string" ? raw.acquiredAt : undefined;
return {
path,
pid,
acquiredAt,
stale: !isProcessRunning(pid ?? -1),
};
} catch {
return {
path,
stale: true,
};
}
}
function listHubStartupLocks(_cwd: string): StartupArtifact[] {
const owner = resolveSharedHubOwnerContext();
const ownerPath = join(`${owner.discoveryPath}.lock`, "owner.json");
if (!existsSync(ownerPath)) {
return [];
}
return [readStartupArtifact(ownerPath) ?? { path: ownerPath, stale: true }];
}
function clearPathIfExists(path: string): boolean {
if (!existsSync(path)) {
return false;
}
try {
rmSync(path, { recursive: true, force: true });
return true;
} catch {
return false;
}
}
async function clearHubStartupArtifacts(
_cwd: string,
options?: { clearDiscovery?: boolean },
): Promise<{ startupLocks: number; discovery: number }> {
const owner = resolveSharedHubOwnerContext();
const startupLocks = listHubStartupLocks(_cwd);
let clearedStartupLocks = 0;
for (const artifact of startupLocks) {
if (artifact.stale && clearPathIfExists(dirname(artifact.path))) {
clearedStartupLocks += 1;
}
}
let clearedDiscovery = 0;
if (options?.clearDiscovery && existsSync(owner.discoveryPath)) {
await clearHubDiscovery(owner.discoveryPath);
clearedDiscovery = 1;
}
return {
startupLocks: clearedStartupLocks,
discovery: clearedDiscovery,
};
}
function listConnectorStatePaths(
type: ActiveConnectorRecord["type"],
): string[] {
const dir = join(resolveClineDataDir(), "connectors", type);
if (!existsSync(dir)) {
return [];
}
return readdirSync(dir)
.filter((name) => name.endsWith(".json") && !name.endsWith(".threads.json"))
.map((name) => join(dir, name));
}
function readJsonRecord(path: string): Record<string, unknown> | undefined {
if (!existsSync(path)) {
return undefined;
}
try {
const raw = readFileSync(path, "utf8");
const parsed = JSON.parse(raw) as unknown;
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
return parsed as Record<string, unknown>;
}
} catch {
// Ignore malformed connector state.
}
return undefined;
}
type ConnectorFieldKey = keyof Omit<
ActiveConnectorRecord,
"type" | "pid" | "hubUrl"
>;
const connectorFieldExtractors: Record<
ConnectorFieldKey,
(p: Record<string, unknown>) => string | number | undefined
> = {
startedAt: (p) => (typeof p.startedAt === "string" ? p.startedAt : undefined),
port: (p) => (typeof p.port === "number" ? p.port : undefined),
baseUrl: (p) => (typeof p.baseUrl === "string" ? p.baseUrl : undefined),
userName: (p) => (typeof p.userName === "string" ? p.userName : undefined),
botUsername: (p) =>
typeof p.botUsername === "string" ? p.botUsername : undefined,
applicationId: (p) =>
typeof p.applicationId === "string" ? p.applicationId : undefined,
phoneNumberId: (p) =>
typeof p.phoneNumberId === "string" ? p.phoneNumberId : undefined,
};
const connectorConfigs: Record<
string,
{ required: ConnectorFieldKey[]; optional: ConnectorFieldKey[] }
> = {
discord: {
required: ["userName", "applicationId"],
optional: ["startedAt", "port", "baseUrl"],
},
telegram: { required: ["botUsername"], optional: ["startedAt"] },
gchat: { required: ["userName"], optional: ["startedAt", "port", "baseUrl"] },
linear: {
required: ["userName"],
optional: ["startedAt", "port", "baseUrl"],
},
whatsapp: {
required: ["userName"],
optional: ["startedAt", "phoneNumberId", "port", "baseUrl"],
},
};
function readActiveConnectorRecord(
type: ActiveConnectorRecord["type"],
statePath: string,
): ActiveConnectorRecord | undefined {
const parsed = readJsonRecord(statePath);
if (!parsed) {
return undefined;
}
const pid = typeof parsed.pid === "number" ? parsed.pid : undefined;
const hubUrl =
typeof parsed.hubUrl === "string"
? parsed.hubUrl
: typeof parsed.rpcAddress === "string"
? parsed.rpcAddress
: undefined;
if (!pid || !hubUrl || !isProcessRunning(pid)) {
return undefined;
}
const config = connectorConfigs[type];
if (!config) {
return undefined;
}
const fields: Partial<
Omit<ActiveConnectorRecord, "type" | "pid" | "hubUrl">
> = {};
for (const key of config.required) {
const value = connectorFieldExtractors[key](parsed);
if (!value || (typeof value === "string" && !value.trim())) {
return undefined;
}
(fields as Record<string, unknown>)[key] = value;
}
for (const key of config.optional) {
const value = connectorFieldExtractors[key](parsed);
if (value !== undefined) {
(fields as Record<string, unknown>)[key] = value;
}
}
return { type, pid, hubUrl, ...fields } as ActiveConnectorRecord;
}
function listActiveConnectors(): ActiveConnectorRecord[] {
const connectorTypes: ActiveConnectorRecord["type"][] = [
"telegram",
"gchat",
"linear",
"whatsapp",
];
const records: ActiveConnectorRecord[] = [];
for (const type of connectorTypes) {
for (const statePath of listConnectorStatePaths(type)) {
const record = readActiveConnectorRecord(type, statePath);
if (record) {
records.push(record);
}
}
}
return records.sort((left, right) => {
if (left.type !== right.type) {
return left.type.localeCompare(right.type);
}
const leftName = left.botUsername ?? left.userName ?? "";
const rightName = right.botUsername ?? right.userName ?? "";
return leftName.localeCompare(rightName);
});
}
function formatHubUptimeFromStartedAt(
startedAt: string | undefined,
): string | undefined {
if (!startedAt) {
return undefined;
}
const timestamp = Date.parse(startedAt);
if (Number.isNaN(timestamp)) {
return undefined;
}
return formatUptime(Date.now() - timestamp);
}
async function collectDoctorStatus(cwd: string): Promise<DoctorStatus> {
const owner = resolveSharedHubOwnerContext();
const discovery = await readHubDiscovery(owner.discoveryPath);
const health = discovery?.url
? await probeHubServer(discovery.url)
: undefined;
const current = health ?? discovery;
const hubUptime = formatHubUptimeFromStartedAt(health?.startedAt);
return {
cwd,
hubUrl: current?.url,
hubHealthy: !!health?.url,
hubPid: current?.pid,
hubStartedAt: health?.startedAt,
hubUptime,
listeningPids: listListeningPids(current?.port),
hubStartupLocks: listHubStartupLocks(cwd),
staleCliPids: listStaleCliPids(),
staleSidecarPids: listStaleSidecarPids(),
activeConnectors: listActiveConnectors(),
recentSpawnedProcesses: readRecentSpawnedProcesses(),
};
}
function formatPidList(label: string, pids: number[]): string {
if (pids.length === 0) {
return `${label} ${c.dim}0${c.reset}`;
}
return `${label} ${c.dim}${pids.join(", ")}${c.reset}`;
}
function formatRecentSpawnedProcess(record: SpawnedProcessRecord): string {
const pieces = [
record.timestamp ?? "unknown-time",
record.component ?? "unknown-component",
record.pid ? `pid=${record.pid}` : undefined,
record.detached === undefined
? undefined
: `detached=${record.detached ? "yes" : "no"}`,
record.command,
].filter(Boolean);
return pieces.join(" | ");
}
function formatActiveConnector(record: ActiveConnectorRecord): string {
const identity =
record.type === "telegram"
? `bot=@${record.botUsername ?? "unknown"}`
: record.type === "discord"
? `user=${record.userName ?? "unknown"} app=${record.applicationId ?? "unknown"}`
: `user=${record.userName ?? "unknown"}`;
const pieces = [
record.type,
identity,
`pid=${record.pid}`,
`hub=${record.hubUrl}`,
record.phoneNumberId ? `phone=${record.phoneNumberId}` : undefined,
record.port ? `port=${record.port}` : undefined,
record.baseUrl ? `url=${record.baseUrl}` : undefined,
record.startedAt ? `started=${record.startedAt}` : undefined,
].filter(Boolean);
return pieces.join(" | ");
}
function killPids(pids: number[]): number {
let killed = 0;
for (const pid of pids) {
try {
process.kill(pid, "SIGKILL");
killed += 1;
} catch {
// Best-effort cleanup.
}
}
return killed;
}
export async function runDoctorCommand(
opts: { cwd: string; json?: boolean; fix?: boolean; verbose?: boolean },
io: DoctorIo,
): Promise<number> {
const jsonOutput = opts.json === true;
const fix = opts.fix === true;
const verbose = opts.verbose === true;
const before = await collectDoctorStatus(opts.cwd);
if (!fix) {
if (jsonOutput) {
io.writeln(JSON.stringify(before));
return 0;
}
writeln(`hub url ${c.dim}${before.hubUrl ?? "none"}${c.reset}`);
writeln(
`hub healthy ${c.dim}${before.hubHealthy ? "yes" : "no"}${before.hubPid ? ` (pid=${before.hubPid})` : ""}${c.reset}`,
);
writeln(`hub uptime ${c.dim}${before.hubUptime ?? "n/a"}${c.reset}`);
writeln(formatPidList("hub listeners", before.listeningPids));
writeln(
formatPidList(
"hub startup locks",
before.hubStartupLocks.map((a) => a.pid ?? -1).filter((pid) => pid > 0),
),
);
writeln(formatPidList("cli processes", before.staleCliPids));
writeln(formatPidList("sidecar processes", before.staleSidecarPids));
if (before.activeConnectors.length === 0) {
writeln(`active connectors ${c.dim}0${c.reset}`);
} else {
writeln("active connectors:");
for (const record of before.activeConnectors) {
writeln(`- ${c.dim}${formatActiveConnector(record)}${c.reset}`);
}
}
if (verbose && before.recentSpawnedProcesses.length > 0) {
writeln("recent spawned processes:");
for (const record of before.recentSpawnedProcesses) {
writeln(`- ${c.dim}${formatRecentSpawnedProcess(record)}${c.reset}`);
}
}
if (
before.listeningPids.length > 0 ||
before.staleCliPids.length > 0 ||
before.staleSidecarPids.length > 0
) {
io.writeln(
"\nRun `cline doctor fix` to kill all stale local processes, including stale sidecars.",
);
}
return 0;
}
const gracefullyStoppedHub = before.hubHealthy
? await stopLocalHubServerGracefully().catch(() => false)
: false;
const refreshedAfterGracefulStop = gracefullyStoppedHub
? await collectDoctorStatus(opts.cwd)
: before;
const killedHub = gracefullyStoppedHub
? 0
: killPids(refreshedAfterGracefulStop.listeningPids);
const staleCliTargets = before.staleCliPids.filter(
(pid) => !refreshedAfterGracefulStop.listeningPids.includes(pid),
);
const killedCli = killPids(staleCliTargets);
const staleSidecarTargets = before.staleSidecarPids.filter(
(pid) =>
!refreshedAfterGracefulStop.listeningPids.includes(pid) &&
!staleCliTargets.includes(pid),
);
const killedSidecars = killPids(staleSidecarTargets);
const stoppedConnectors = await stopAllConnectors({
writeln: () => {},
writeErr: () => {},
});
const postKillStatus = await collectDoctorStatus(opts.cwd);
const clearedArtifacts = await clearHubStartupArtifacts(opts.cwd, {
clearDiscovery:
!postKillStatus.hubHealthy && postKillStatus.listeningPids.length === 0,
});
const after = await collectDoctorStatus(opts.cwd);
if (jsonOutput) {
io.writeln(
JSON.stringify({
before,
after,
killed: {
hubListeners: killedHub,
cliProcesses: killedCli,
sidecarProcesses: killedSidecars,
connectorProcesses: stoppedConnectors.stoppedProcesses,
connectorSessions: stoppedConnectors.stoppedSessions,
hubStartupLocks: clearedArtifacts.startupLocks,
hubDiscovery: clearedArtifacts.discovery,
},
}),
);
return 0;
}
writeln(`killed hub listeners ${c.dim}${killedHub}${c.reset}`);
writeln(`killed cli processes ${c.dim}${killedCli}${c.reset}`);
writeln(`killed sidecar processes ${c.dim}${killedSidecars}${c.reset}`);
writeln(
`stopped connector processes ${c.dim}${stoppedConnectors.stoppedProcesses}${c.reset}`,
);
writeln(
`stopped connector sessions ${c.dim}${stoppedConnectors.stoppedSessions}${c.reset}`,
);
writeln(
`cleared hub startup locks ${c.dim}${clearedArtifacts.startupLocks}${c.reset}`,
);
writeln(
`cleared hub discovery records ${c.dim}${clearedArtifacts.discovery}${c.reset}`,
);
writeln(`hub healthy after fix: ${after.hubHealthy ? "yes" : "no"}`);
writeln(formatPidList("remaining hub listeners", after.listeningPids));
writeln(
formatPidList(
"remaining hub startup locks",
after.hubStartupLocks.map((a) => a.pid ?? -1).filter((pid) => pid > 0),
),
);
writeln(formatPidList("remaining cli processes", after.staleCliPids));
writeln(formatPidList("remaining sidecar processes", after.staleSidecarPids));
return 0;
}
export function createDoctorCommand(
io: DoctorIo,
setExitCode: (code: number) => void,
deps: DoctorCommandDeps = {},
): Command {
const doctor = new Command("doctor")
.description("Diagnose and fix local process issues")
.exitOverride()
.option("--cwd <path>", "Workspace root", process.cwd())
.option("--json", "Output as JSON")
.option("-v, --verbose", "Show additional diagnostic details")
.action(async function (this: Command) {
const opts = this.opts<{
cwd: string;
json?: boolean;
verbose?: boolean;
}>();
setExitCode(await runDoctorCommand(opts, io));
});
doctor
.command("fix")
.description("Kill all running processes")
.option("--cwd <path>", "Workspace root", process.cwd())
.option("--json", "Output as JSON")
.option("-v, --verbose", "Show additional diagnostic details")
.action(async function (this: Command) {
const opts = this.opts<{
cwd: string;
json?: boolean;
verbose?: boolean;
}>();
setExitCode(await runDoctorCommand({ ...opts, fix: true }, io));
});
doctor
.command("log")
.description("Open the CLI log file")
.action(async () => {
const logPath = resolveCliLogPath();
const openPath = deps.openPath ?? defaultOpenPath;
try {
ensureFileExists(logPath);
await openPath(logPath);
io.writeln(`Opening logs stored at ${logPath}`);
setExitCode(0);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
io.writeErr(`failed to open log file "${logPath}": ${message}`);
setExitCode(1);
}
});
return doctor;
}
+6
View File
@@ -0,0 +1,6 @@
import { getCliBuildInfo } from "../utils/common";
import { writeln } from "../utils/output";
export function showVersion(): void {
writeln(getCliBuildInfo().version);
}
+300
View File
@@ -0,0 +1,300 @@
import { mkdtemp, readFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { SessionHistoryRecord } from "@cline/core";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
formatCheckpointDetail,
formatHistoryListLine,
runHistoryExport,
runHistoryList,
} from "./history";
vi.mock("../session/session", () => ({
listSessions: vi.fn(),
readSessionMessagesArtifact: vi.fn(),
}));
vi.mock("../tui/history-standalone", () => ({
renderHistoryStandalone: vi.fn(async () => 0),
}));
import { listSessions, readSessionMessagesArtifact } from "../session/session";
import { renderHistoryStandalone } from "../tui/history-standalone";
const mockedReadSessionMessagesArtifact = vi.mocked(
readSessionMessagesArtifact,
);
const mockedListSessions = vi.mocked(listSessions);
const mockedRenderHistoryStandalone = vi.mocked(renderHistoryStandalone);
function createHistoryRow(
overrides: Partial<SessionHistoryRecord> = {},
): SessionHistoryRecord {
return {
sessionId: "sess_1",
source: "cli",
pid: 1,
startedAt: "2026-01-01T00:00:00.000Z",
status: "completed",
interactive: false,
provider: "mock-provider",
model: "mock-model",
cwd: "/tmp/workspace",
workspaceRoot: "/tmp/workspace",
enableTools: true,
enableSpawn: false,
enableTeams: false,
isSubagent: false,
prompt: "hello world",
updatedAt: "2026-01-01T00:00:00.000Z",
...overrides,
};
}
describe("formatHistoryListLine", () => {
it("includes checkpoint metadata when present", () => {
const line = formatHistoryListLine(
createHistoryRow({
metadata: {
title: "hello world",
totalCost: 0.25,
checkpoint: {
latest: {
ref: "abc123",
createdAt: 1767196800000,
runCount: 3,
},
history: [
{ ref: "a", createdAt: 1, runCount: 1 },
{ ref: "b", createdAt: 2, runCount: 2 },
{ ref: "c", createdAt: 3, runCount: 3 },
],
},
},
}),
);
expect(line).toContain(
"12/31/2025 16:00 mock-provider:mock-model | $0.25 | hello world",
);
});
it("formats a compact checkpoint badge summary in the line", () => {
const line = formatHistoryListLine(
createHistoryRow({
metadata: {
title: "hello world",
totalCost: 0.25,
checkpoint: {
latest: {
ref: "abc123",
createdAt: 1767196800000,
runCount: 3,
},
history: [
{ ref: "a", createdAt: 1, runCount: 1 },
{ ref: "b", createdAt: 2, runCount: 2 },
{ ref: "c", createdAt: 3, runCount: 3 },
],
},
},
}),
);
expect(line).toContain(
"12/31/2025 16:00 mock-provider:mock-model | $0.25 | hello world",
);
expect(line).toMatch(/^\d{2}\/\d{2}\/\d{4} \d{2}:\d{2}/);
});
it("formats checkpoint detail text for the selected row footer", () => {
const detail = formatCheckpointDetail(
createHistoryRow({
metadata: {
title: "hello world",
totalCost: 0.25,
checkpoint: {
latest: {
ref: "abc123def4567890",
createdAt: 1767196800000,
runCount: 3,
},
history: [
{ ref: "a", createdAt: 1, runCount: 1 },
{ ref: "b", createdAt: 2, runCount: 2 },
{ ref: "c", createdAt: 3, runCount: 3 },
],
},
},
}),
);
expect(detail).toContain("Checkpoint");
expect(detail).toContain("run 3");
expect(detail).toContain("3 total");
});
it("omits checkpoint summary when absent", () => {
const line = formatHistoryListLine(
createHistoryRow({
metadata: {
title: "hello world",
totalCost: 0.25,
},
}),
);
expect(line).not.toContain("checkpoints:");
});
it("omits cost for subscription-backed providers", () => {
const line = formatHistoryListLine(
createHistoryRow({
provider: "openai-codex",
model: "gpt-5.4",
metadata: {
title: "hello world",
totalCost: 0.25,
},
}),
);
expect(line).toContain("openai-codex:gpt-5.4 | hello world");
expect(line).not.toContain("$0.25");
});
});
describe("runHistoryList", () => {
afterEach(() => {
vi.clearAllMocks();
});
it("hydrates interactive history rows so titles can be inferred from messages", async () => {
const row = createHistoryRow({ prompt: undefined, metadata: undefined });
mockedListSessions.mockResolvedValue([row]);
const io = {
writeln: vi.fn(),
writeErr: vi.fn(),
};
const code = await runHistoryList({
limit: 25,
outputMode: "text",
io,
});
expect(code).toBe(0);
expect(mockedListSessions).toHaveBeenCalledWith(25, {
hydrate: true,
});
expect(mockedRenderHistoryStandalone).toHaveBeenCalledWith(
expect.objectContaining({ rows: [row] }),
);
});
it("keeps json history listing unhydrated", async () => {
const row = createHistoryRow({ prompt: undefined, metadata: undefined });
mockedListSessions.mockResolvedValue([row]);
const writeSpy = vi
.spyOn(process.stdout, "write")
.mockImplementation(() => true);
const code = await runHistoryList({
limit: 25,
outputMode: "json",
});
expect(code).toBe(0);
expect(mockedListSessions).toHaveBeenCalledWith(25, {
hydrate: false,
});
expect(writeSpy).toHaveBeenCalledWith(JSON.stringify([row]));
writeSpy.mockRestore();
});
it("defaults history listing to 50 rows", async () => {
mockedListSessions.mockResolvedValue([]);
const io = {
writeln: vi.fn(),
writeErr: vi.fn(),
};
const code = await runHistoryList({
limit: Number.NaN,
outputMode: "text",
io,
});
expect(code).toBe(0);
expect(mockedListSessions).toHaveBeenCalledWith(50, {
hydrate: true,
});
expect(io.writeln).toHaveBeenCalledWith("No history found.");
});
});
describe("runHistoryExport", () => {
let tempDir = "";
afterEach(async () => {
vi.clearAllMocks();
if (tempDir) {
await rm(tempDir, { recursive: true, force: true });
tempDir = "";
}
});
it("writes standalone html from a persisted messages artifact", async () => {
tempDir = await mkdtemp(join(tmpdir(), "cline-history-export-"));
const outputPath = join(tempDir, "export.html");
const artifact = {
version: 1,
updated_at: "2026-04-22T17:42:10.123Z",
sessionId: "sess_1",
messages: [
{
id: "m1",
role: "user",
content: [{ type: "text", text: "hello" }],
},
{
id: "m2",
role: "assistant",
content: [{ type: "text", text: "world" }],
},
],
} satisfies NonNullable<
Awaited<ReturnType<typeof readSessionMessagesArtifact>>
>;
mockedReadSessionMessagesArtifact.mockResolvedValue(artifact);
const io = {
writeln: vi.fn(),
writeErr: vi.fn(),
};
const code = await runHistoryExport("sess_1", outputPath, "text", io);
expect(code).toBe(0);
expect(io.writeErr).not.toHaveBeenCalled();
expect(io.writeln).toHaveBeenCalledWith(
expect.stringContaining(outputPath),
);
await expect(readFile(outputPath, "utf8")).resolves.toContain("world");
});
it("fails when the session artifact is missing", async () => {
mockedReadSessionMessagesArtifact.mockResolvedValue(undefined);
const io = {
writeln: vi.fn(),
writeErr: vi.fn(),
};
const code = await runHistoryExport("sess_missing", undefined, "text", io);
expect(code).toBe(1);
expect(io.writeErr).toHaveBeenCalledWith(
"Session sess_missing not found or has no messages.json",
);
});
});
+202
View File
@@ -0,0 +1,202 @@
import { mkdir, writeFile } from "node:fs/promises";
import { dirname, resolve } from "node:path";
import { generateConversationHTML } from "../session/export";
import {
deleteSession,
listSessions,
readSessionMessagesArtifact,
updateSession,
} from "../session/session";
import { disableOpenTuiGraphicsProbe } from "../tui/opentui-env";
import { writeln } from "../utils/output";
import type { CliOutputMode } from "../utils/types";
export {
formatCheckpointDetail,
formatHistoryListLine,
} from "../utils/history-format";
type HistoryIo = {
writeln: (text?: string) => void;
writeErr: (text: string) => void;
};
async function exportHistorySession(
sessionId: string,
outputPath?: string,
): Promise<string> {
const data = await readSessionMessagesArtifact(sessionId);
if (!data) {
throw new Error(`Session ${sessionId} not found or has no messages.json`);
}
const targetPath = resolve(outputPath?.trim() || `${sessionId}.html`);
const html = generateConversationHTML(data, sessionId);
await mkdir(dirname(targetPath), { recursive: true });
await writeFile(targetPath, html, "utf8");
return targetPath;
}
async function runHistoryDelete(
sessionId: string | undefined,
outputMode: CliOutputMode,
io: HistoryIo,
): Promise<number> {
if (!sessionId) {
io.writeErr("history delete requires --session-id <id>");
return 1;
}
try {
const result = await deleteSession(sessionId);
if (outputMode === "json") {
process.stdout.write(JSON.stringify(result));
return result.deleted ? 0 : 1;
}
if (result.deleted) {
io.writeln(`Deleted session ${sessionId}`);
return 0;
}
io.writeErr(`Session ${sessionId} not found`);
return 1;
} catch (error) {
io.writeErr(error instanceof Error ? error.message : String(error));
return 1;
}
}
async function runHistoryUpdate(
sessionId: string | undefined,
prompt: string | undefined,
title: string | undefined,
metadataStr: string | undefined,
outputMode: CliOutputMode,
io: HistoryIo,
): Promise<number> {
if (!sessionId) {
io.writeErr("history update requires --session-id <id>");
return 1;
}
let metadata: Record<string, unknown> | undefined;
if (metadataStr) {
try {
metadata = JSON.parse(metadataStr);
} catch (error) {
io.writeErr(
`Invalid metadata JSON: ${error instanceof Error ? error.message : String(error)}`,
);
return 1;
}
}
if (title !== undefined) {
if (metadata) {
delete metadata.title;
}
}
if (metadata && Object.keys(metadata).length === 0) {
metadata = undefined;
}
if (prompt === undefined && metadata === undefined && title === undefined) {
io.writeErr(
"history update requires --prompt <text>, --title <text>, or --metadata <json>",
);
return 1;
}
try {
const result = await updateSession(sessionId, { prompt, metadata, title });
if (outputMode === "json") {
process.stdout.write(JSON.stringify(result));
return result.updated ? 0 : 1;
}
if (result.updated) {
io.writeln(`Updated session ${sessionId}`);
return 0;
}
io.writeErr(`Session ${sessionId} not found`);
return 1;
} catch (error) {
io.writeErr(error instanceof Error ? error.message : String(error));
return 1;
}
}
async function runHistoryExport(
sessionId: string | undefined,
outputPath: string | undefined,
outputMode: CliOutputMode,
io: HistoryIo,
): Promise<number> {
if (!sessionId) {
io.writeErr("history export requires <session-id>");
return 1;
}
try {
const targetPath = await exportHistorySession(sessionId, outputPath);
if (outputMode === "json") {
process.stdout.write(
JSON.stringify({
sessionId,
outputPath: targetPath,
}),
);
return 0;
}
io.writeln(`Exported to ${targetPath}`);
return 0;
} catch (error) {
io.writeErr(error instanceof Error ? error.message : String(error));
return 1;
}
}
export async function runHistoryList(input: {
limit: number;
outputMode: CliOutputMode;
workspaceRoot?: string;
io?: HistoryIo;
}): Promise<number | string> {
const io = input.io ?? {
writeln,
writeErr: (text: string) => process.stderr.write(`${text}\n`),
};
const limit = Number.isFinite(input.limit) ? input.limit : 50;
const rows = await listSessions(limit, {
workspaceRoot: input.workspaceRoot,
hydrate: input.outputMode !== "json",
});
if (rows.length === 0) {
if (input.outputMode === "json") {
process.stdout.write(JSON.stringify([]));
} else {
io.writeln("No history found.");
}
return 0;
}
if (input.outputMode === "json") {
process.stdout.write(JSON.stringify(rows));
return 0;
}
disableOpenTuiGraphicsProbe();
const { renderHistoryStandalone } = await import("../tui/history-standalone");
return await renderHistoryStandalone({
rows,
onExport: async (sessionId: string) =>
await exportHistorySession(sessionId, undefined),
});
}
export {
exportHistorySession,
runHistoryDelete,
runHistoryExport,
runHistoryUpdate,
};
+58
View File
@@ -0,0 +1,58 @@
import type { HookEventPayload } from "@cline/core";
import { handleSessionHookEvent } from "../session/session";
import {
appendHookAudit,
parseCliHookPayload,
readStdinUtf8,
writeHookJson,
} from "../utils/helpers";
async function handleHookPayload(payload: HookEventPayload): Promise<unknown> {
await appendHookAudit(payload);
await handleSessionHookEvent(payload);
switch (payload.hookName) {
case "tool_call":
case "tool_result":
case "agent_end":
case "agent_start":
case "agent_resume":
case "agent_abort":
case "prompt_submit":
case "pre_compact":
case "session_shutdown":
return {};
default:
throw new Error(
`unsupported hookName: ${(payload as { hookName: string }).hookName}`,
);
}
}
type HookIo = {
writeln: (text?: string) => void;
writeErr: (text: string) => void;
};
export async function runHookCommand(io: HookIo) {
try {
const raw = (await readStdinUtf8()).trim();
if (!raw) {
io.writeErr("hook command expects JSON payload on stdin");
return 1;
}
const parsed = JSON.parse(raw) as unknown;
const payload = await parseCliHookPayload(parsed);
if (!payload) {
io.writeErr("invalid hook payload");
return 1;
}
writeHookJson(await handleHookPayload(payload));
return 0;
} catch (error) {
io.writeErr(error instanceof Error ? error.message : String(error));
return 1;
}
}
+76
View File
@@ -0,0 +1,76 @@
import { describe, expect, it, vi } from "vitest";
const {
mockClearHubDiscovery,
mockEnsureDetachedHubServer,
mockProbeHubServer,
mockReadHubDiscovery,
mockResolveSharedHubOwnerContext,
mockStopLocalHubServerGracefully,
} = vi.hoisted(() => ({
mockClearHubDiscovery: vi.fn(),
mockEnsureDetachedHubServer: vi.fn(),
mockProbeHubServer: vi.fn(),
mockReadHubDiscovery: vi.fn(),
mockResolveSharedHubOwnerContext: vi.fn(() => ({
ownerId: "hub-owner",
discoveryPath: "/tmp/cline-data/locks/hub/owners/hub-owner.json",
})),
mockStopLocalHubServerGracefully: vi.fn(),
}));
vi.mock("@cline/core", () => ({
clearHubDiscovery: mockClearHubDiscovery,
ensureDetachedHubServer: mockEnsureDetachedHubServer,
probeHubServer: mockProbeHubServer,
readHubDiscovery: mockReadHubDiscovery,
resolveSharedHubOwnerContext: mockResolveSharedHubOwnerContext,
stopLocalHubServerGracefully: mockStopLocalHubServerGracefully,
}));
import { createHubCommand } from "./hub";
describe("createHubCommand", () => {
it("includes uptime in hub status output", async () => {
vi.spyOn(Date, "now").mockReturnValue(
new Date("2026-01-01T00:01:05.000Z").getTime(),
);
mockReadHubDiscovery.mockResolvedValue({
url: "ws://127.0.0.1:25463/hub",
port: 25463,
pid: 50174,
startedAt: "2026-01-01T00:00:00.000Z",
});
mockProbeHubServer.mockResolvedValue({
url: "ws://127.0.0.1:25463/hub",
port: 25463,
pid: 50174,
startedAt: "2026-01-01T00:00:00.000Z",
});
const output: string[] = [];
let exitCode = 0;
const cmd = createHubCommand(
{
writeln: (text) => {
output.push(text ?? "");
},
writeErr: () => {},
},
(code) => {
exitCode = code;
},
);
await cmd.parseAsync(["status"], { from: "user" });
expect(exitCode).toBe(0);
expect(JSON.parse(output[0] || "")).toMatchObject({
running: true,
url: "ws://127.0.0.1:25463/hub",
pid: 50174,
startedAt: "2026-01-01T00:00:00.000Z",
uptime: "1m 5s",
});
});
});
+142
View File
@@ -0,0 +1,142 @@
import {
clearHubDiscovery,
ensureDetachedHubServer,
probeHubServer,
readHubDiscovery,
resolveSharedHubOwnerContext,
stopLocalHubServerGracefully,
} from "@cline/core";
import { formatUptime } from "@cline/shared";
import { Command } from "commander";
interface HubCommandIo {
writeln: (text?: string) => void;
writeErr: (text: string) => void;
}
async function stopHubServer(_workspaceRoot: string): Promise<boolean> {
const owner = resolveSharedHubOwnerContext();
const discovery = await readHubDiscovery(owner.discoveryPath);
if (await stopLocalHubServerGracefully()) {
await clearHubDiscovery(owner.discoveryPath);
return true;
}
const pid = discovery?.pid;
if (pid) {
try {
process.kill(pid, "SIGTERM");
} catch {
// best effort
}
}
await clearHubDiscovery(owner.discoveryPath);
return !!pid;
}
function formatHubUptimeFromStartedAt(
startedAt: string | undefined,
): string | undefined {
if (!startedAt) {
return undefined;
}
const timestamp = Date.parse(startedAt);
if (Number.isNaN(timestamp)) {
return undefined;
}
return formatUptime(Date.now() - timestamp);
}
export function createHubCommand(
io: HubCommandIo,
setExitCode: (code: number) => void,
): Command {
let actionExitCode = 0;
const fail = () => {
actionExitCode = 1;
};
const action =
<T extends unknown[]>(fn: (...args: T) => Promise<void>) =>
async (...args: T) => {
try {
await fn(...args);
} catch (error) {
io.writeErr(error instanceof Error ? error.message : String(error));
fail();
}
};
const hub = new Command("hub")
.description("Manage the local hub daemon")
.exitOverride()
.hook("postAction", () => {
setExitCode(actionExitCode);
})
.option("--cwd <path>", "Workspace root", process.cwd())
.option("--host <host>", "Hub host")
.option("--port <port>", "Hub port", (value) => Number.parseInt(value, 10))
.option("--pathname <path>", "Hub websocket path");
hub.command("ensure").action(
action(async () => {
const opts = hub.opts<{
cwd: string;
host?: string;
port?: number;
pathname?: string;
}>();
const { url } = await ensureDetachedHubServer(opts.cwd, {
host: opts.host,
port: opts.port,
pathname: opts.pathname,
});
io.writeln(url);
}),
);
hub.command("start").action(
action(async () => {
const opts = hub.opts<{
cwd: string;
host?: string;
port?: number;
pathname?: string;
}>();
const { url } = await ensureDetachedHubServer(opts.cwd, {
host: opts.host,
port: opts.port,
pathname: opts.pathname,
});
io.writeln(url);
}),
);
hub.command("status").action(
action(async () => {
const owner = resolveSharedHubOwnerContext();
const discovery = await readHubDiscovery(owner.discoveryPath);
const health = discovery?.url
? await probeHubServer(discovery.url)
: undefined;
const uptime = formatHubUptimeFromStartedAt(health?.startedAt);
io.writeln(
JSON.stringify({
running: !!health?.url,
url: health?.url,
pid: health?.pid,
startedAt: health?.startedAt,
uptime,
}),
);
}),
);
hub.command("stop").action(
action(async () => {
const opts = hub.opts<{ cwd: string }>();
const stopped = await stopHubServer(opts.cwd);
io.writeln(JSON.stringify({ stopped }));
}),
);
return hub;
}
+214
View File
@@ -0,0 +1,214 @@
import { chmodSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
buildKanbanSpawnOptions,
forwardSignalToKanbanProcess,
isCommandAvailable,
launchKanban,
resolveKanbanInstallCommand,
shouldDetachKanbanProcess,
} from "./kanban";
const tempDirs: string[] = [];
const originalPath = process.env.PATH;
function createTempDir(): string {
const dir = mkdtempSync(join(tmpdir(), "cline-kanban-test-"));
tempDirs.push(dir);
return dir;
}
function writeExecutable(dir: string, name: string): void {
writeExecutableScript(dir, name, "#!/bin/sh\necho ok\n");
}
function writeExecutableScript(
dir: string,
name: string,
content: string,
): void {
const filePath = join(dir, name);
writeFileSync(filePath, content, "utf8");
chmodSync(filePath, 0o755);
}
describe("kanban command helpers", () => {
afterEach(() => {
if (originalPath === undefined) {
delete process.env.PATH;
} else {
process.env.PATH = originalPath;
}
for (const dir of tempDirs.splice(0)) {
rmSync(dir, { recursive: true, force: true });
}
});
it("detects commands in PATH", () => {
const dir = createTempDir();
writeExecutable(dir, "kanban");
expect(isCommandAvailable("kanban", { PATH: dir }, "linux")).toBe(true);
expect(isCommandAvailable("missing", { PATH: dir }, "linux")).toBe(false);
});
it("detaches kanban into a process group on unix-like platforms", () => {
expect(shouldDetachKanbanProcess("darwin")).toBe(true);
expect(shouldDetachKanbanProcess("linux")).toBe(true);
expect(buildKanbanSpawnOptions({}, "darwin")).toMatchObject({
stdio: "inherit",
detached: true,
});
});
it("keeps kanban attached on windows", () => {
expect(shouldDetachKanbanProcess("win32")).toBe(false);
expect(buildKanbanSpawnOptions({}, "win32")).toMatchObject({
stdio: "inherit",
detached: false,
shell: true,
});
});
it("prefers npm for kanban installs", () => {
const dir = createTempDir();
writeExecutable(dir, "npm");
writeExecutable(dir, "pnpm");
writeExecutable(dir, "bun");
expect(resolveKanbanInstallCommand({ PATH: dir }, "linux")).toEqual({
packageManager: "npm",
command: "npm",
args: ["install", "-g", "kanban@latest"],
displayCommand: "npm install -g kanban@latest",
});
});
it("uses the preferred package manager when available", () => {
const dir = createTempDir();
writeExecutable(dir, "npm");
writeExecutable(dir, "pnpm");
writeExecutable(dir, "bun");
expect(resolveKanbanInstallCommand({ PATH: dir }, "linux", "pnpm")).toEqual(
{
packageManager: "pnpm",
command: "pnpm",
args: ["add", "-g", "kanban@latest"],
displayCommand: "pnpm add -g kanban@latest",
},
);
expect(resolveKanbanInstallCommand({ PATH: dir }, "linux", "bun")).toEqual({
packageManager: "bun",
command: "bun",
args: ["add", "-g", "kanban@latest"],
displayCommand: "bun add -g kanban@latest",
});
});
it("falls back when the preferred package manager is unavailable", () => {
const dir = createTempDir();
writeExecutable(dir, "npm");
expect(resolveKanbanInstallCommand({ PATH: dir }, "linux", "pnpm")).toEqual(
{
packageManager: "npm",
command: "npm",
args: ["install", "-g", "kanban@latest"],
displayCommand: "npm install -g kanban@latest",
},
);
});
it("falls back to pnpm and bun for kanban installs", () => {
const pnpmDir = createTempDir();
writeExecutable(pnpmDir, "pnpm");
expect(
resolveKanbanInstallCommand({ PATH: pnpmDir }, "linux")?.displayCommand,
).toBe("pnpm add -g kanban@latest");
const bunDir = createTempDir();
writeExecutable(bunDir, "bun");
expect(
resolveKanbanInstallCommand({ PATH: bunDir }, "linux")?.displayCommand,
).toBe("bun add -g kanban@latest");
});
it("fails when kanban is missing and no installer is available", async () => {
process.env.PATH = "";
await expect(launchKanban()).resolves.toBe(1);
});
it("returns the kanban process exit code", async () => {
const dir = createTempDir();
writeExecutableScript(dir, "kanban", "#!/bin/sh\nexit 7\n");
process.env.PATH = dir;
await expect(launchKanban()).resolves.toBe(7);
});
it("installs kanban before launch when missing", async () => {
const dir = createTempDir();
writeExecutableScript(
dir,
"npm",
`#!/bin/sh
/bin/cat > "${dir}/kanban" <<'EOF'
#!/bin/sh
exit 6
EOF
/bin/chmod +x "${dir}/kanban"
exit 0
`,
);
process.env.PATH = dir;
const stdoutWrite = vi
.spyOn(process.stdout, "write")
.mockImplementation(() => true);
try {
await expect(launchKanban()).resolves.toBe(6);
} finally {
stdoutWrite.mockRestore();
}
});
it("signals the detached kanban process group on unix-like platforms", () => {
const killProcess = vi.fn();
const child = {
pid: 4321,
kill: vi.fn(),
};
forwardSignalToKanbanProcess({
child,
signal: "SIGINT",
platform: "darwin",
killProcess,
});
expect(killProcess).toHaveBeenCalledWith(-4321, "SIGINT");
expect(child.kill).not.toHaveBeenCalled();
});
it("signals the child process directly on windows", () => {
const killProcess = vi.fn();
const child = {
pid: 4321,
kill: vi.fn(),
};
forwardSignalToKanbanProcess({
child,
signal: "SIGTERM",
platform: "win32",
killProcess,
});
expect(killProcess).not.toHaveBeenCalled();
expect(child.kill).toHaveBeenCalledWith("SIGTERM");
});
});
+399
View File
@@ -0,0 +1,399 @@
import {
type ChildProcess,
type SpawnOptions,
spawn,
spawnSync,
} from "node:child_process";
import { accessSync, constants as fsConstants } from "node:fs";
import { delimiter, extname, join } from "node:path";
import { c, writeErr, writeln } from "../utils/output";
export type KanbanInstaller = "npm" | "pnpm" | "bun";
export interface KanbanInstallCommand {
packageManager: KanbanInstaller;
command: string;
args: readonly string[];
displayCommand: string;
}
export interface LaunchKanbanOptions {
preferredInstaller?: KanbanInstaller;
}
const KANBAN_SHUTDOWN_TIMEOUT_MS = 10_000;
interface SignalableKanbanProcess {
pid?: number;
kill: (signal?: NodeJS.Signals | number) => boolean;
}
const KANBAN_INSTALL_COMMANDS: ReadonlyArray<
Omit<KanbanInstallCommand, "displayCommand">
> = [
{
packageManager: "npm",
command: "npm",
args: ["install", "-g", "kanban@latest"],
},
{
packageManager: "pnpm",
command: "pnpm",
args: ["add", "-g", "kanban@latest"],
},
{
packageManager: "bun",
command: "bun",
args: ["add", "-g", "kanban@latest"],
},
];
function getKanbanCommand(
platform: NodeJS.Platform = process.platform,
): string {
return platform === "win32" ? "kanban.cmd" : "kanban";
}
function getPackageManagerCommand(
packageManager: KanbanInstaller,
platform: NodeJS.Platform = process.platform,
): string {
if (platform !== "win32") {
return packageManager;
}
return packageManager === "bun" ? "bun" : `${packageManager}.cmd`;
}
function getPathEntries(env: NodeJS.ProcessEnv): string[] {
const pathValue = env.PATH ?? env.Path ?? env.path;
if (!pathValue) {
return [];
}
return pathValue
.split(delimiter)
.map((entry) => entry.trim().replace(/^"(.*)"$/u, "$1"))
.filter((entry) => entry.length > 0);
}
function pathExists(candidatePath: string, platform: NodeJS.Platform): boolean {
try {
if (platform === "win32") {
accessSync(candidatePath, fsConstants.F_OK);
} else {
accessSync(candidatePath, fsConstants.X_OK);
}
return true;
} catch {
return false;
}
}
export function isCommandAvailable(
command: string,
env: NodeJS.ProcessEnv = process.env,
platform: NodeJS.Platform = process.platform,
): boolean {
const commandHasExtension = extname(command).length > 0;
const pathExtensions =
platform === "win32"
? (env.PATHEXT ?? ".EXE;.CMD;.BAT;.COM")
.split(";")
.filter((extension) => extension.length > 0)
: [];
for (const pathEntry of getPathEntries(env)) {
const commandPath = join(pathEntry, command);
if (pathExists(commandPath, platform)) {
return true;
}
if (!commandHasExtension && platform === "win32") {
for (const extension of pathExtensions) {
if (pathExists(`${commandPath}${extension}`, platform)) {
return true;
}
}
}
}
return false;
}
export function resolveKanbanInstallCommand(
env: NodeJS.ProcessEnv = process.env,
platform: NodeJS.Platform = process.platform,
preferredInstaller?: KanbanInstaller,
): KanbanInstallCommand | null {
if (preferredInstaller) {
const preferredCommand = KANBAN_INSTALL_COMMANDS.find(
(installCommand) => installCommand.packageManager === preferredInstaller,
);
if (
preferredCommand &&
isCommandAvailable(preferredCommand.command, env, platform)
) {
return {
...preferredCommand,
displayCommand: `${preferredCommand.command} ${preferredCommand.args.join(" ")}`,
};
}
}
for (const installCommand of KANBAN_INSTALL_COMMANDS) {
if (isCommandAvailable(installCommand.command, env, platform)) {
return {
...installCommand,
displayCommand: `${installCommand.command} ${installCommand.args.join(" ")}`,
};
}
}
return null;
}
export function shouldDetachKanbanProcess(
platform: NodeJS.Platform = process.platform,
): boolean {
return platform !== "win32";
}
export function buildKanbanSpawnOptions(
options: SpawnOptions = {},
platform: NodeJS.Platform = process.platform,
): SpawnOptions {
return {
stdio: "inherit",
detached: shouldDetachKanbanProcess(platform),
...(platform === "win32" ? { shell: true } : {}),
...options,
};
}
function buildKanbanInstallSpawnOptions(
options: SpawnOptions = {},
platform: NodeJS.Platform = process.platform,
): SpawnOptions {
return {
detached: false,
stdio: "inherit",
...(platform === "win32" ? { shell: true } : {}),
...options,
};
}
export function spawnKanbanProcess(options: SpawnOptions = {}): ChildProcess {
return spawn(getKanbanCommand(), [], buildKanbanSpawnOptions(options));
}
export function spawnKanbanInstallProcess(
installCommand: KanbanInstallCommand,
options: SpawnOptions = {},
): ChildProcess {
return spawn(
getPackageManagerCommand(installCommand.packageManager),
[...installCommand.args],
buildKanbanInstallSpawnOptions(options),
);
}
export function getInstalledKanbanVersion(): string | null {
try {
const result = spawnSync(getKanbanCommand(), ["--version"], {
encoding: "utf8",
shell: process.platform === "win32",
});
if (result.status !== 0) {
return null;
}
const output = `${result.stdout ?? ""}\n${result.stderr ?? ""}`.trim();
const versionMatch = output.match(/\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?/);
return versionMatch?.[0] ?? null;
} catch {
return null;
}
}
export function forwardSignalToKanbanProcess(options: {
child: SignalableKanbanProcess;
signal: NodeJS.Signals;
platform?: NodeJS.Platform;
killProcess?: (pid: number, signal: NodeJS.Signals | number) => boolean;
}): void {
if (options.child.pid == null) {
return;
}
if (shouldDetachKanbanProcess(options.platform)) {
try {
(options.killProcess ?? process.kill)(-options.child.pid, options.signal);
return;
} catch (error) {
if (
error &&
typeof error === "object" &&
"code" in error &&
error.code === "ESRCH"
) {
return;
}
}
}
options.child.kill(options.signal);
}
function resolveProcessExitCode(
code: number | null,
signal: NodeJS.Signals | null,
): number {
if (code !== null) {
return code;
}
switch (signal) {
case "SIGINT":
return 130;
case "SIGTERM":
return 143;
default:
return 1;
}
}
function waitForProcessExit(child: ChildProcess): Promise<number> {
return new Promise<number>((resolve, reject) => {
child.once("close", (code, signal) => {
resolve(resolveProcessExitCode(code, signal));
});
child.once("error", reject);
});
}
async function ensureKanbanInstalled(
command: string,
options: LaunchKanbanOptions = {},
): Promise<boolean> {
if (isCommandAvailable(command)) {
return true;
}
const installCommand = resolveKanbanInstallCommand(
process.env,
process.platform,
options.preferredInstaller,
);
if (!installCommand) {
writeErr('kanban is not installed. Install it with "npm i -g kanban"');
return false;
}
writeln(`${c.cyan}Installing kanban@latest…${c.reset}`);
const installProcess = spawnKanbanInstallProcess(installCommand, {
env: process.env,
windowsHide: true,
});
const installExitCode = await waitForProcessExit(installProcess).catch(
(error: unknown) => {
const message = error instanceof Error ? error.message : String(error);
writeErr(`Failed to run ${installCommand.displayCommand}: ${message}`);
return 1;
},
);
if (installExitCode !== 0) {
writeErr(
`Failed to install kanban. Try running: ${installCommand.displayCommand}`,
);
return false;
}
if (!isCommandAvailable(command)) {
writeErr(
`Installed kanban, but ${command} was not found in PATH. Try opening a new terminal.`,
);
return false;
}
return true;
}
/**
* Launch the external `kanban` app as a foreground child process.
*
* Returns a Promise that resolves with the exit code:
* kanban's exit code after the foreground process exits
* 1 if kanban cannot be installed or spawned
*/
export async function launchKanban(
options: LaunchKanbanOptions = {},
): Promise<number> {
const command = getKanbanCommand();
if (!(await ensureKanbanInstalled(command, options))) {
return 1;
}
return new Promise<number>((resolve) => {
const child = spawnKanbanProcess();
let shutdownTimer: NodeJS.Timeout | null = null;
let settled = false;
const clearShutdownTimer = () => {
if (!shutdownTimer) {
return;
}
clearTimeout(shutdownTimer);
shutdownTimer = null;
};
const cleanup = () => {
clearShutdownTimer();
process.off("SIGINT", handleSigint);
process.off("SIGTERM", handleSigterm);
};
const settle = (code: number) => {
if (settled) {
return;
}
settled = true;
cleanup();
resolve(code);
};
const requestShutdown = (signal: NodeJS.Signals) => {
forwardSignalToKanbanProcess({ child, signal });
clearShutdownTimer();
if (signal === "SIGKILL") {
return;
}
shutdownTimer = setTimeout(() => {
forwardSignalToKanbanProcess({ child, signal: "SIGKILL" });
}, KANBAN_SHUTDOWN_TIMEOUT_MS);
shutdownTimer.unref?.();
};
function handleSigint() {
requestShutdown("SIGINT");
}
function handleSigterm() {
requestShutdown("SIGTERM");
}
process.on("SIGINT", handleSigint);
process.on("SIGTERM", handleSigterm);
child.once("error", (error) => {
const message = error instanceof Error ? error.message : String(error);
writeErr(`Failed to run kanban: ${message}`);
settle(1);
});
child.once("close", (code, signal) => {
settle(resolveProcessExitCode(code, signal));
});
});
}
+353
View File
@@ -0,0 +1,353 @@
import {
existsSync,
mkdtempSync,
readFileSync,
rmSync,
writeFileSync,
} from "node:fs";
import { mkdir, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
discoverPluginModulePaths,
resolvePluginConfigSearchPaths,
setClineDir,
setHomeDir,
} from "@cline/shared/storage";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import {
installPlugin,
parsePluginSource,
runPluginInstallCommand,
} from "./plugin";
describe("plugin install command", () => {
let root = "";
let home = "";
let workspace = "";
let originalHome: string | undefined;
let originalClineDir: string | undefined;
let originalClineDataDir: string | undefined;
beforeEach(() => {
root = mkdtempSync(join(tmpdir(), "cli-plugin-install-"));
home = join(root, "home");
workspace = join(root, "workspace");
originalHome = process.env.HOME;
originalClineDir = process.env.CLINE_DIR;
originalClineDataDir = process.env.CLINE_DATA_DIR;
process.env.HOME = home;
process.env.CLINE_DIR = join(home, ".cline");
process.env.CLINE_DATA_DIR = join(home, ".cline", "data");
setHomeDir(home);
setClineDir(process.env.CLINE_DIR);
});
afterEach(() => {
if (originalHome === undefined) {
delete process.env.HOME;
} else {
process.env.HOME = originalHome;
}
if (originalClineDir === undefined) {
delete process.env.CLINE_DIR;
} else {
process.env.CLINE_DIR = originalClineDir;
}
if (originalClineDataDir === undefined) {
delete process.env.CLINE_DATA_DIR;
} else {
process.env.CLINE_DATA_DIR = originalClineDataDir;
}
rmSync(root, { recursive: true, force: true });
});
it("parses explicit npm source type without the npm prefix", () => {
expect(parsePluginSource("@scope/plugin@1.2.3", "npm")).toEqual({
type: "npm",
spec: "@scope/plugin@1.2.3",
name: "@scope/plugin",
});
});
it("parses explicit git source type without the git prefix", () => {
expect(parsePluginSource("github.com/acme/plugin", "git")).toMatchObject({
type: "git",
repo: "https://github.com/acme/plugin",
host: "github.com",
path: "acme/plugin",
});
});
it("rejects hostname-style sources without --git guidance", () => {
expect(() => parsePluginSource("github.com/acme/plugin")).toThrow(
/Use --git/,
);
});
it("installs a local plugin file into the global plugin root", async () => {
const source = join(root, "weather.ts");
writeFileSync(
source,
"export const plugin = { name: 'weather', manifest: { capabilities: ['tools'] } };",
"utf8",
);
const result = await installPlugin({ source });
expect(result.installPath).toContain(join(home, ".cline", "plugins"));
expect(result.entryPaths).toHaveLength(1);
expect(existsSync(result.entryPaths[0] ?? "")).toBe(true);
const discovered = discoverPluginModulePaths(
join(home, ".cline", "plugins"),
);
expect(discovered).toEqual(result.entryPaths);
});
it("installs into cwd plugin root when cwd is provided", async () => {
const source = join(root, "plugin-package");
const npmLogPath = join(root, "npm-install.log");
const npmCommandPath = join(root, "fake-npm.sh");
writeFileSync(
npmCommandPath,
`#!/bin/sh\nprintf '%s\\n' "$PWD $*" >> "${npmLogPath}"\nexit 0\n`,
{ encoding: "utf8", mode: 0o755 },
);
await mkdir(join(source, "node_modules", "dependency"), {
recursive: true,
});
await writeFile(
join(source, "package.json"),
JSON.stringify(
{
name: "plugin-package",
cline: {
plugins: [{ paths: ["./index.ts"], capabilities: ["tools"] }],
},
dependencies: {
"@cline/core": "latest",
yaml: "^2.8.1",
},
peerDependencies: {
"@cline/shared": "*",
},
peerDependenciesMeta: {
"@cline/shared": {
optional: true,
},
},
},
null,
2,
),
"utf8",
);
await writeFile(
join(source, "index.ts"),
"export default { name: 'plugin-package', manifest: { capabilities: ['tools'] } };",
"utf8",
);
await writeFile(
join(source, "node_modules", "dependency", "noise.ts"),
"export default { name: 'noise', manifest: { capabilities: ['tools'] } };",
"utf8",
);
await mkdir(join(source, ".git", "objects"), { recursive: true });
await writeFile(join(source, ".git", "HEAD"), "ref: refs/heads/main\n");
const result = await installPlugin({
source,
cwd: workspace,
npmCommand: npmCommandPath,
});
const wrapperManifest = JSON.parse(
readFileSync(join(result.installPath, "package.json"), "utf8"),
) as { cline?: { plugins?: Array<{ paths?: string[] }> } };
expect(wrapperManifest.cline?.plugins?.[0]?.paths).toHaveLength(1);
expect(wrapperManifest.cline?.plugins?.[0]?.paths?.[0]).toContain(
"package/index.ts",
);
const packageManifest = JSON.parse(
readFileSync(join(result.installPath, "package", "package.json"), "utf8"),
) as {
dependencies?: Record<string, string>;
peerDependencies?: Record<string, string>;
peerDependenciesMeta?: Record<string, unknown>;
};
expect(packageManifest.dependencies).toEqual({ yaml: "^2.8.1" });
expect(packageManifest.peerDependencies).toBeUndefined();
expect(packageManifest.peerDependenciesMeta).toBeUndefined();
const npmLog = readFileSync(npmLogPath, "utf8");
expect(npmLog).toContain(`${join(".tmp")}/`);
expect(npmLog).toContain(
"package install --omit=dev --no-audit --no-fund --package-lock=false",
);
expect(existsSync(join(result.installPath, "package", ".git"))).toBe(false);
expect(
existsSync(join(result.installPath, "package", "node_modules")),
).toBe(false);
const discovered = discoverPluginModulePaths(
join(workspace, ".cline", "plugins"),
);
expect(discovered).toEqual(result.entryPaths);
expect(discovered.some((path) => path.includes("noise.ts"))).toBe(false);
});
it("omits and removes host SDK packages from npm-sourced installs", async () => {
const npmLogPath = join(root, "npm-source-install.log");
const npmCommandPath = join(root, "fake-npm-source.sh");
writeFileSync(
npmCommandPath,
[
"#!/bin/sh",
`printf '%s\\n' "$*" >> "${npmLogPath}"`,
"prefix=''",
"while [ $# -gt 0 ]; do",
" if [ \"$1\" = '--prefix' ]; then",
" shift",
' prefix="$1"',
" fi",
" shift",
"done",
'mkdir -p "$prefix/node_modules/published-plugin"',
'mkdir -p "$prefix/node_modules/@cline/core"',
'printf \'%s\\n\' \'{"name":"published-plugin","type":"module","cline":{"plugins":["index.ts"]}}\' > "$prefix/node_modules/published-plugin/package.json"',
"printf '%s\\n' \"export default { name: 'published-plugin', manifest: { capabilities: ['tools'] } };\" > \"$prefix/node_modules/published-plugin/index.ts\"",
'printf \'%s\\n\' \'{"name":"@cline/core"}\' > "$prefix/node_modules/@cline/core/package.json"',
"exit 0",
].join("\n"),
{ encoding: "utf8", mode: 0o755 },
);
const result = await installPlugin({
source: "npm:published-plugin@1.0.0",
npmCommand: npmCommandPath,
});
const npmLog = readFileSync(npmLogPath, "utf8");
expect(npmLog).toContain("install published-plugin@1.0.0");
expect(npmLog).toContain("--omit=peer");
expect(
existsSync(
join(result.installPath, "package", "node_modules", "@cline", "core"),
),
).toBe(false);
expect(existsSync(result.entryPaths[0] ?? "")).toBe(true);
});
it("requires --force before replacing an existing install", async () => {
const source = join(root, "replace.ts");
writeFileSync(
source,
"export default { name: 'replace', manifest: { capabilities: ['tools'] } };",
"utf8",
);
const first = await installPlugin({ source });
await expect(installPlugin({ source })).rejects.toThrow(/Use --force/);
const second = await installPlugin({ source, force: true });
expect(second.installPath).toBe(first.installPath);
});
it("keeps an existing install when a forced replacement fails during staging", async () => {
const source = join(root, "replace-package");
const npmCommandPath = join(root, "fake-npm.sh");
await mkdir(source, { recursive: true });
await writeFile(
join(source, "package.json"),
JSON.stringify(
{
name: "replace-package",
cline: {
plugins: [{ paths: ["./index.ts"], capabilities: ["tools"] }],
},
},
null,
2,
),
"utf8",
);
await writeFile(
join(source, "index.ts"),
"export default { name: 'installed-v1', manifest: { capabilities: ['tools'] } };",
"utf8",
);
writeFileSync(npmCommandPath, "#!/bin/sh\nexit 0\n", {
encoding: "utf8",
mode: 0o755,
});
const first = await installPlugin({ source, npmCommand: npmCommandPath });
await writeFile(
join(source, "index.ts"),
"export default { name: 'installed-v2', manifest: { capabilities: ['tools'] } };",
"utf8",
);
writeFileSync(npmCommandPath, "#!/bin/sh\nprintf 'offline' >&2\nexit 1\n", {
encoding: "utf8",
mode: 0o755,
});
await expect(
installPlugin({ source, force: true, npmCommand: npmCommandPath }),
).rejects.toThrow(/offline/);
expect(existsSync(first.installPath)).toBe(true);
expect(
readFileSync(join(first.installPath, "package", "index.ts"), "utf8"),
).toContain("installed-v1");
});
it("prints JSON output for command callers", async () => {
const source = join(root, "json.ts");
writeFileSync(
source,
"export default { name: 'json', manifest: { capabilities: ['tools'] } };",
"utf8",
);
const stdout: string[] = [];
const originalWrite = process.stdout.write;
process.stdout.write = ((chunk: string | Uint8Array) => {
stdout.push(String(chunk));
return true;
}) as typeof process.stdout.write;
try {
const code = await runPluginInstallCommand({
source,
json: true,
io: {
writeln: () => {},
writeErr: () => {},
},
});
expect(code).toBe(0);
const parsed = JSON.parse(stdout.join("")) as { installPath: string };
expect(parsed.installPath).toContain(join(home, ".cline", "plugins"));
} finally {
process.stdout.write = originalWrite;
}
});
it("uses shared search paths for cwd installs", async () => {
const source = join(root, "workspace.ts");
writeFileSync(
source,
"export default { name: 'workspace', manifest: { capabilities: ['tools'] } };",
"utf8",
);
await installPlugin({
source,
cwd: workspace,
});
expect(resolvePluginConfigSearchPaths(workspace)[0]).toBe(
join(workspace, ".cline", "plugins"),
);
expect(
discoverPluginModulePaths(join(workspace, ".cline", "plugins")),
).toHaveLength(1);
});
});
+788
View File
@@ -0,0 +1,788 @@
import { spawn } from "node:child_process";
import { createHash } from "node:crypto";
import {
type Dirent,
existsSync,
mkdirSync,
readdirSync,
readFileSync,
renameSync,
rmSync,
statSync,
} from "node:fs";
import { cp, mkdir, writeFile } from "node:fs/promises";
import { homedir } from "node:os";
import { basename, dirname, join, relative, resolve, sep } from "node:path";
import {
isPluginModulePath,
resolveClineDir,
resolvePluginModuleEntries,
} from "@cline/shared/storage";
export interface PluginInstallOptions {
source: string;
sourceType?: PluginInstallSourceType;
cwd?: string;
force?: boolean;
npmCommand?: string;
io?: PluginInstallIo;
}
export interface PluginInstallResult {
source: string;
installPath: string;
entryPaths: string[];
}
export interface PluginInstallIo {
writeln: (text?: string) => void;
writeErr: (text: string) => void;
}
type ParsedPluginSource =
| {
type: "npm";
spec: string;
name: string;
}
| {
type: "git";
repo: string;
ref?: string;
host: string;
path: string;
}
| {
type: "local";
path: string;
};
type PluginInstallSourceType = "npm" | "git" | "local";
interface PluginPackageManifest {
cline?: {
plugins?: Array<{ paths?: string[] } | string>;
};
dependencies?: Record<string, string>;
devDependencies?: Record<string, string>;
optionalDependencies?: Record<string, string>;
peerDependencies?: Record<string, string>;
peerDependenciesMeta?: Record<string, unknown>;
}
const INSTALLS_DIRECTORY_NAME = "_installed";
const PACKAGE_DIRECTORY_NAME = "package";
const HOST_PROVIDED_SDK_PREFIX = "@cline/";
const DEPENDENCY_FIELDS = [
"dependencies",
"devDependencies",
"optionalDependencies",
"peerDependencies",
] as const;
const WRAPPER_PACKAGE_JSON = {
name: "cline-installed-plugin",
private: true,
cline: {
plugins: [] as Array<{ paths: string[] }>,
},
};
function resolveHomePath(value: string): string {
if (value === "~") {
return homedir();
}
if (value.startsWith("~/")) {
return join(homedir(), value.slice(2));
}
return value;
}
function toPosixPath(path: string): string {
return path.split(sep).join("/");
}
function hashSource(source: string): string {
return createHash("sha256").update(source).digest("hex").slice(0, 12);
}
function sanitizeSegment(value: string): string {
const sanitized = value
.replace(/^@/, "")
.replace(/[^a-zA-Z0-9._-]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 80);
return sanitized || "plugin";
}
function parseNpmSpec(spec: string): { name: string } {
const trimmed = spec.trim();
const match = trimmed.match(/^(@?[^@/]+(?:\/[^@/]+)?)(?:@.+)?$/);
if (!match?.[1]) {
throw new Error(`Invalid npm plugin source: npm:${spec}`);
}
return { name: match[1] };
}
function looksLikeHostnamePath(source: string): boolean {
if (
source.startsWith(".") ||
source.startsWith("/") ||
source === "~" ||
source.startsWith("~/") ||
/^[A-Za-z]:[\\/]|^\\\\/.test(source)
) {
return false;
}
const [host, ...pathParts] = source.split("/");
return (
!!host &&
pathParts.length >= 2 &&
host.includes(".") &&
!host.startsWith(".") &&
!host.endsWith(".")
);
}
function splitGitRef(input: string): { repo: string; ref?: string } {
const scpLike = input.match(/^git@([^:]+):(.+)$/);
if (scpLike) {
const path = scpLike[2] ?? "";
const refAt = path.indexOf("@");
if (refAt < 0) {
return { repo: input };
}
return {
repo: `git@${scpLike[1]}:${path.slice(0, refAt)}`,
ref: path.slice(refAt + 1) || undefined,
};
}
if (input.includes("://")) {
try {
const parsed = new URL(input);
const path = parsed.pathname.replace(/^\/+/, "");
const refAt = path.indexOf("@");
if (refAt < 0) {
return { repo: input };
}
parsed.pathname = `/${path.slice(0, refAt)}`;
return {
repo: parsed.toString().replace(/\/$/, ""),
ref: path.slice(refAt + 1) || undefined,
};
} catch {
return { repo: input };
}
}
const slash = input.indexOf("/");
if (slash < 0) {
return { repo: input };
}
const host = input.slice(0, slash);
const path = input.slice(slash + 1);
const refAt = path.indexOf("@");
if (refAt < 0) {
return { repo: input };
}
return {
repo: `${host}/${path.slice(0, refAt)}`,
ref: path.slice(refAt + 1) || undefined,
};
}
function parseGitSource(
source: string,
options: { force?: boolean } = {},
): ParsedPluginSource | null {
const trimmed = source.trim();
const hasGitPrefix =
trimmed.startsWith("git:") && !trimmed.startsWith("git://");
const raw = hasGitPrefix ? trimmed.slice("git:".length).trim() : trimmed;
if (!options.force && !hasGitPrefix && !/^(https?|ssh|git):\/\//i.test(raw)) {
return null;
}
const { repo, ref } = splitGitRef(raw);
let host = "";
let repoPath = "";
if (repo.startsWith("git@")) {
const match = repo.match(/^git@([^:]+):(.+)$/);
host = match?.[1] ?? "";
repoPath = match?.[2] ?? "";
} else if (/^(https?|ssh|git):\/\//i.test(repo)) {
const parsed = new URL(repo);
host = parsed.hostname;
repoPath = parsed.pathname.replace(/^\/+/, "");
} else {
const slash = repo.indexOf("/");
if (slash < 0) {
return null;
}
host = repo.slice(0, slash);
repoPath = repo.slice(slash + 1);
}
const normalizedPath = repoPath.replace(/\.git$/, "").replace(/^\/+/, "");
if (!host || !normalizedPath || normalizedPath.split("/").length < 2) {
return null;
}
const cloneRepo =
repo.startsWith("git@") || /^(https?|ssh|git):\/\//i.test(repo)
? repo
: `https://${repo}`;
return {
type: "git",
repo: cloneRepo,
ref,
host,
path: normalizedPath,
};
}
export function parsePluginSource(
source: string,
sourceType?: PluginInstallSourceType,
): ParsedPluginSource {
const trimmed = source.trim();
if (!trimmed) {
throw new Error("plugin install requires a source");
}
if (sourceType === "npm") {
const spec = trimmed.startsWith("npm:")
? trimmed.slice("npm:".length).trim()
: trimmed;
const { name } = parseNpmSpec(spec);
return { type: "npm", spec, name };
}
if (sourceType === "git") {
const git = parseGitSource(trimmed, { force: true });
if (!git) {
throw new Error(`Invalid git plugin source: ${source}`);
}
return git;
}
if (sourceType === "local") {
return { type: "local", path: source };
}
if (trimmed.startsWith("npm:")) {
const spec = trimmed.slice("npm:".length).trim();
const { name } = parseNpmSpec(spec);
return { type: "npm", spec, name };
}
const localPathLike =
trimmed.startsWith(".") ||
trimmed.startsWith("/") ||
trimmed === "~" ||
trimmed.startsWith("~/") ||
/^[A-Za-z]:[\\/]|^\\\\/.test(trimmed);
if (localPathLike) {
return { type: "local", path: source };
}
const git = parseGitSource(trimmed);
if (git) {
return git;
}
if (looksLikeHostnamePath(trimmed)) {
throw new Error(
`Unrecognized plugin source "${source}". Use --git for hostname-style repositories or pass an explicit local path such as ./github.com/owner/repo.`,
);
}
return { type: "local", path: source };
}
function getPluginRoot(cwd: string | undefined): string {
return cwd
? join(cwd, ".cline", "plugins")
: join(resolveClineDir(), "plugins");
}
function getInstallPath(
pluginRoot: string,
parsed: ParsedPluginSource,
sourceKey: string,
): string {
if (parsed.type === "npm") {
return join(
pluginRoot,
INSTALLS_DIRECTORY_NAME,
"npm",
`${sanitizeSegment(parsed.name)}-${hashSource(sourceKey)}`,
);
}
if (parsed.type === "git") {
return join(
pluginRoot,
INSTALLS_DIRECTORY_NAME,
"git",
sanitizeSegment(parsed.host),
`${sanitizeSegment(parsed.path)}-${hashSource(sourceKey)}`,
);
}
return join(
pluginRoot,
INSTALLS_DIRECTORY_NAME,
"local",
`${sanitizeSegment(basename(resolveHomePath(parsed.path)))}-${hashSource(sourceKey)}`,
);
}
function getInstallSourceKey(parsed: ParsedPluginSource, cwd: string): string {
if (parsed.type === "npm") {
return `npm:${parsed.spec}`;
}
if (parsed.type === "git") {
return `git:${parsed.repo}${parsed.ref ? `#${parsed.ref}` : ""}`;
}
return `local:${resolve(cwd, resolveHomePath(parsed.path))}`;
}
async function runCommand(
command: string,
args: string[],
options: { cwd?: string } = {},
): Promise<void> {
await new Promise<void>((resolvePromise, reject) => {
const child = spawn(command, args, {
cwd: options.cwd,
stdio: ["ignore", "ignore", "pipe"],
env: process.env,
});
let stderr = "";
child.stderr.on("data", (chunk) => {
stderr += String(chunk);
});
child.on("error", reject);
child.on("close", (code) => {
if (code === 0) {
resolvePromise();
return;
}
const details = stderr.trim();
reject(
new Error(
`${command} ${args.join(" ")} failed with exit code ${code}${details ? `: ${details}` : ""}`,
),
);
});
});
}
function readPackageManifest(
packageRoot: string,
): PluginPackageManifest | null {
const packageJsonPath = join(packageRoot, "package.json");
if (!existsSync(packageJsonPath)) {
return null;
}
try {
return JSON.parse(
readFileSync(packageJsonPath, "utf8"),
) as PluginPackageManifest;
} catch {
return null;
}
}
function getManifestPaths(manifest: PluginPackageManifest | null): string[] {
const entries = manifest?.cline?.plugins;
if (!Array.isArray(entries)) {
return [];
}
return entries.flatMap((entry) => {
if (typeof entry === "string") {
return [entry];
}
return entry.paths ?? [];
});
}
async function removeHostProvidedSdkDependencies(
packageRoot: string,
): Promise<void> {
const packageJsonPath = join(packageRoot, "package.json");
const manifest = readPackageManifest(packageRoot);
if (!manifest) {
return;
}
let changed = false;
for (const field of DEPENDENCY_FIELDS) {
const dependencies = manifest[field];
if (!dependencies || typeof dependencies !== "object") {
continue;
}
for (const dependencyName of Object.keys(dependencies)) {
if (!dependencyName.startsWith(HOST_PROVIDED_SDK_PREFIX)) {
continue;
}
delete dependencies[dependencyName];
delete manifest.peerDependenciesMeta?.[dependencyName];
changed = true;
}
if (Object.keys(dependencies).length === 0) {
delete manifest[field];
}
}
if (manifest.peerDependenciesMeta) {
for (const dependencyName of Object.keys(manifest.peerDependenciesMeta)) {
if (!dependencyName.startsWith(HOST_PROVIDED_SDK_PREFIX)) {
continue;
}
delete manifest.peerDependenciesMeta[dependencyName];
changed = true;
}
if (Object.keys(manifest.peerDependenciesMeta).length === 0) {
delete manifest.peerDependenciesMeta;
}
}
if (!changed) {
return;
}
await writeFile(
packageJsonPath,
`${JSON.stringify(manifest, null, 2)}\n`,
"utf8",
);
}
function removeInstalledHostProvidedSdkDependencies(
packageRoot: string,
preservePackageName?: string,
): void {
const clineScopeDir = join(packageRoot, "node_modules", "@cline");
if (!existsSync(clineScopeDir)) {
return;
}
for (const entry of statSafeReadDir(clineScopeDir)) {
const packageName = `@cline/${entry.name}`;
if (packageName === preservePackageName) {
continue;
}
rmSync(join(clineScopeDir, entry.name), {
recursive: true,
force: true,
});
}
}
function collectPluginEntries(packageRoot: string): string[] {
const manifestPaths = getManifestPaths(readPackageManifest(packageRoot))
.map((entry) => resolve(packageRoot, entry))
.filter(
(entry) =>
existsSync(entry) &&
statSync(entry).isFile() &&
isPluginModulePath(entry),
);
if (manifestPaths.length > 0) {
return manifestPaths;
}
const directEntries = resolvePluginModuleEntries(packageRoot);
if (directEntries?.length) {
return directEntries;
}
const entries: string[] = [];
const stack = [packageRoot];
while (stack.length > 0) {
const current = stack.pop();
if (!current) {
continue;
}
for (const entry of statSafeReadDir(current)) {
const entryPath = join(current, entry.name);
if (entry.name === "node_modules" || entry.name === ".git") {
continue;
}
if (entry.isDirectory()) {
stack.push(entryPath);
continue;
}
if (
entry.isFile() &&
!entry.name.startsWith(".") &&
isPluginModulePath(entryPath)
) {
entries.push(entryPath);
}
}
}
return entries.sort((left, right) => left.localeCompare(right));
}
function statSafeReadDir(dir: string): Dirent[] {
try {
return readdirSync(dir, { withFileTypes: true });
} catch {
return [];
}
}
function toWrapperEntryPaths(
wrapperRoot: string,
packageRoot: string,
): string[] {
const entries = collectPluginEntries(packageRoot);
if (entries.length === 0) {
throw new Error(`No plugin entry files found in ${packageRoot}`);
}
return entries.map(
(entry) => `./${toPosixPath(relative(wrapperRoot, entry))}`,
);
}
async function writeWrapperManifest(
wrapperRoot: string,
packageRoot: string,
): Promise<string[]> {
const entryPaths = toWrapperEntryPaths(wrapperRoot, packageRoot);
await writeFile(
join(wrapperRoot, "package.json"),
JSON.stringify(
{
...WRAPPER_PACKAGE_JSON,
name: `cline-installed-plugin-${hashSource(wrapperRoot)}`,
cline: {
plugins: [{ paths: entryPaths }],
},
},
null,
2,
),
"utf8",
);
return entryPaths;
}
async function installNpmPackage(
parsed: Extract<ParsedPluginSource, { type: "npm" }>,
stagingRoot: string,
npmCommand: string,
): Promise<string> {
const packageRoot = join(stagingRoot, PACKAGE_DIRECTORY_NAME);
await mkdir(packageRoot, { recursive: true });
await writeFile(
join(packageRoot, "package.json"),
JSON.stringify({ name: "cline-plugin-install", private: true }, null, 2),
"utf8",
);
await runCommand(npmCommand, [
"install",
parsed.spec,
"--prefix",
packageRoot,
"--omit=dev",
"--omit=peer",
"--no-audit",
"--no-fund",
"--package-lock=false",
]);
removeInstalledHostProvidedSdkDependencies(packageRoot, parsed.name);
return join(packageRoot, "node_modules", parsed.name);
}
async function installPackageDependencies(
packageRoot: string,
npmCommand: string,
): Promise<void> {
if (!existsSync(join(packageRoot, "package.json"))) {
return;
}
await removeHostProvidedSdkDependencies(packageRoot);
await runCommand(
npmCommand,
[
"install",
"--omit=dev",
"--no-audit",
"--no-fund",
"--package-lock=false",
],
{ cwd: packageRoot },
);
}
async function installGitPackage(
parsed: Extract<ParsedPluginSource, { type: "git" }>,
stagingRoot: string,
npmCommand: string,
): Promise<string> {
const packageRoot = join(stagingRoot, PACKAGE_DIRECTORY_NAME);
const cloneArgs = ["clone", "--filter=blob:none"];
if (parsed.ref) {
cloneArgs.push("--branch", parsed.ref);
}
cloneArgs.push(parsed.repo, packageRoot);
try {
await runCommand("git", cloneArgs);
} catch (error) {
if (!parsed.ref) {
throw error;
}
await runCommand("git", [
"clone",
"--filter=blob:none",
parsed.repo,
packageRoot,
]);
await runCommand("git", ["checkout", parsed.ref], { cwd: packageRoot });
}
await installPackageDependencies(packageRoot, npmCommand);
return packageRoot;
}
async function installLocalPackage(
parsed: Extract<ParsedPluginSource, { type: "local" }>,
stagingRoot: string,
cwd: string,
npmCommand: string,
): Promise<string> {
const absolutePath = resolve(cwd, resolveHomePath(parsed.path));
if (!existsSync(absolutePath)) {
throw new Error(`Plugin source path does not exist: ${absolutePath}`);
}
const stats = statSync(absolutePath);
if (stats.isFile()) {
if (!isPluginModulePath(absolutePath)) {
throw new Error(`Plugin file must be .js or .ts: ${absolutePath}`);
}
mkdirSync(stagingRoot, { recursive: true });
const targetPath = join(stagingRoot, basename(absolutePath));
await cp(absolutePath, targetPath);
return stagingRoot;
}
if (!stats.isDirectory()) {
throw new Error(
`Plugin source must be a file or directory: ${absolutePath}`,
);
}
const packageRoot = join(stagingRoot, PACKAGE_DIRECTORY_NAME);
await cp(absolutePath, packageRoot, {
recursive: true,
filter: (sourcePath) => {
const name = basename(sourcePath);
return name !== ".git" && name !== "node_modules";
},
});
await installPackageDependencies(packageRoot, npmCommand);
return packageRoot;
}
function assertCanInstall(targetPath: string, force: boolean): void {
if (existsSync(targetPath) && !force) {
throw new Error(
`Plugin is already installed at ${targetPath}. Use --force to replace it.`,
);
}
}
function replaceInstallPath(
stagingRoot: string,
installPath: string,
force: boolean,
): void {
mkdirSync(dirname(installPath), { recursive: true });
if (!existsSync(installPath)) {
renameSync(stagingRoot, installPath);
return;
}
if (!force) {
throw new Error(
`Plugin is already installed at ${installPath}. Use --force to replace it.`,
);
}
const backupPath = join(
dirname(installPath),
`.replace-${basename(installPath)}-${Date.now()}-${process.pid}-${hashSource(
`${installPath}:${Math.random()}`,
)}`,
);
renameSync(installPath, backupPath);
try {
renameSync(stagingRoot, installPath);
} catch (error) {
if (!existsSync(installPath) && existsSync(backupPath)) {
renameSync(backupPath, installPath);
}
throw error;
}
try {
rmSync(backupPath, { recursive: true, force: true });
} catch {
// The replacement already succeeded; leftover backup cleanup is best effort.
}
}
export async function installPlugin(
options: PluginInstallOptions,
): Promise<PluginInstallResult> {
const source = options.source.trim();
const parsed = parsePluginSource(source, options.sourceType);
const explicitCwd = options.cwd?.trim();
const cwd = explicitCwd ? resolve(explicitCwd) : process.cwd();
const pluginRoot = getPluginRoot(explicitCwd ? cwd : undefined);
const sourceKey = getInstallSourceKey(parsed, cwd);
const installPath = getInstallPath(pluginRoot, parsed, sourceKey);
const stagingParent = join(pluginRoot, INSTALLS_DIRECTORY_NAME, ".tmp");
const stagingRoot = join(
stagingParent,
`${Date.now()}-${process.pid}-${hashSource(`${source}:${Math.random()}`)}`,
);
const npmCommand =
options.npmCommand ?? (process.env.CLINE_NPM_COMMAND?.trim() || "npm");
const force = options.force === true;
assertCanInstall(installPath, force);
await mkdir(stagingParent, { recursive: true });
let packageRoot: string;
try {
if (parsed.type === "npm") {
packageRoot = await installNpmPackage(parsed, stagingRoot, npmCommand);
} else if (parsed.type === "git") {
packageRoot = await installGitPackage(parsed, stagingRoot, npmCommand);
} else {
packageRoot = await installLocalPackage(
parsed,
stagingRoot,
cwd,
npmCommand,
);
}
const entryPaths =
parsed.type === "local" && packageRoot === stagingRoot
? collectPluginEntries(stagingRoot).map(
(entry) => `./${toPosixPath(relative(stagingRoot, entry))}`,
)
: await writeWrapperManifest(stagingRoot, packageRoot);
if (entryPaths.length === 0) {
throw new Error(`No plugin entry files found for ${source}`);
}
replaceInstallPath(stagingRoot, installPath, force);
return {
source,
installPath,
entryPaths: entryPaths.map((entry) => resolve(installPath, entry)),
};
} catch (error) {
rmSync(stagingRoot, { recursive: true, force: true });
throw error;
}
}
export async function runPluginInstallCommand(
options: PluginInstallOptions & { json?: boolean },
): Promise<number> {
try {
const result = await installPlugin(options);
if (options.json) {
process.stdout.write(JSON.stringify(result));
return 0;
}
options.io?.writeln(`Installed plugin from ${result.source}`);
options.io?.writeln(` Path: ${result.installPath}`);
return 0;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
options.io?.writeErr(message);
return 1;
}
}
+236
View File
@@ -0,0 +1,236 @@
import { Command, CommanderError, Option } from "commander";
import { version } from "../../package.json";
import {
CLI_COMPACTION_MODE_OPTION_DESCRIPTION,
parseCliCompactionMode,
} from "../utils/compaction-mode";
import type { ParsedArgs } from "../utils/types";
export { CommanderError };
function normalizeAutoApproveValue(
value: string | boolean | undefined,
): string {
if (value === undefined || value === true) {
return "true";
}
return String(value);
}
/**
* Add the shared root-level options to any command.
*/
export function addRootOptions(cmd: Command): Command {
return (
cmd
.option("-p, --plan", "Run in plan mode")
.option("--json", "Output messages as JSON instead of styled text")
.option(
"--auto-approve <boolean>",
"Set tool auto-approval for all tools (default: true)",
normalizeAutoApproveValue,
)
.option("-c, --cwd <path>", "Working directory")
.option(
"--thinking <level>",
"Set reasoning effort level between none|low|medium|high|xhigh (default: medium)",
)
.option("--compaction <mode>", CLI_COMPACTION_MODE_OPTION_DESCRIPTION)
.option(
"-i, --tui",
"Open the terminal user interface (TUI) for interactive sessions",
)
.option("--id <session-id>", "Resume an existing session by ID")
.option("-P, --provider <id>", "Provider id (default: cline)")
.option("-k, --key <api-key>", "API key override for this run")
.option(
"-m, --model <model-id>",
"Model to use for the session with the selected provider",
)
.option(
"-s, --system <system-prompt>",
"Override the default system prompt",
)
.option("-z, --zen", "Start a session that runs in the background hub")
.option(
"--retries [value]",
"Number of maximum consecutive mistakes (retries) before exiting (default: 6)",
)
.option(
"-t, --timeout <seconds>",
"Optional timeout in seconds (default: 0 for no timeout)",
)
.option(
"--acp",
"Run in Agent Client Protocol (ACP) mode for editor integration",
)
.option(
"--config <path>",
"Configuration directory (default: ~/.cline/data/settings)",
)
.option(
"--data-dir <path>",
"Use isolated local state at this directory path (default: ~/.cline)",
)
.option(
"--hooks-dir <path>",
"Directory path to additional hooks for runtime hook injection (default: ~/.cline/hooks)",
)
.option("--update", "Check for updates and install if available")
.option("--kanban", "Run the kanban app")
.option("-v, --verbose", "Show verbose output")
// HIDDEN/LEGACY OPTIONS BELOW
.addOption(
// Act mode is the default. Keep the legacy flags accepted for users who
// still pass them, but do not advertise them in help output.
new Option("-a, --act", "Run in act mode").hideHelp(),
)
.addOption(
// `-y, --yolo` is still accepted (and behaves the same as before) but
// hidden from `--help` output.
new Option(
"-y, --yolo",
"Enable yolo mode where agents can use tools without approval with only a small set of tools available.",
).hideHelp(),
)
.addOption(
// TODO: Refactor teams to resume session without team name
new Option(
"--team-name <name>",
"Override the runtime team state name",
).hideHelp(),
)
);
}
export function createProgram(): Command {
const program = new Command("cline")
.description("Cline CLI - AI coding assistant in your terminal")
.version(version, "-V, --version", "Output the version number")
.exitOverride() // don't call process.exit
.configureOutput({
writeOut: () => {}, // suppress by default; main.ts re-enables for routing
writeErr: () => {},
})
.allowUnknownOption()
.allowExcessArguments()
.enablePositionalOptions()
.argument(
"[prompt]",
"Your prompt. Default to start in act mode with auto-approve enabled.",
);
addRootOptions(program);
return program;
}
export function commanderToParsedArgs(program: Command): ParsedArgs {
const opts = program.opts();
const result: ParsedArgs = {
verbose: !!opts.verbose,
interactive: !!opts.tui,
outputMode: opts.json ? "json" : "text",
mode: opts.plan ? "plan" : opts.yolo ? "yolo" : opts.zen ? "zen" : "act",
sandbox: !!opts.dataDir,
acpMode: !!opts.acp,
thinking: false,
reasoningEffort: undefined,
defaultToolAutoApprove: true,
id: opts.id,
};
// Approval: last-wins semantics
if (opts.autoApprove !== undefined) {
const raw = String(opts.autoApprove).trim().toLowerCase();
if (raw === "true") {
result.defaultToolAutoApprove = true;
result.autoApproveOverride = true;
} else if (raw === "false") {
result.defaultToolAutoApprove = false;
result.autoApproveOverride = false;
} else if (raw) {
result.invalidAutoApprove = raw;
}
}
if (opts.yolo) {
result.defaultToolAutoApprove = true;
result.autoApproveOverride = true;
}
// Timeout validation
if (opts.timeout !== undefined) {
const raw = opts.timeout.trim();
const parsed = Number.parseInt(raw, 10);
if (raw && Number.isInteger(parsed) && parsed >= 1) {
result.timeoutSeconds = parsed;
} else if (raw) {
result.invalidTimeoutSeconds = raw;
}
}
if (opts.thinking !== undefined) {
const effort = String(opts.thinking).trim().toLowerCase();
if (
effort === "none" ||
effort === "low" ||
effort === "medium" ||
effort === "high" ||
effort === "xhigh"
) {
result.thinkingExplicitlySet = true;
if (effort === "none") {
result.thinking = false;
result.reasoningEffort = undefined;
} else {
result.thinking = true;
result.reasoningEffort = effort;
}
} else if (effort) {
result.invalidThinkingLevel = effort;
}
}
if (opts.compaction !== undefined) {
const mode = String(opts.compaction).trim().toLowerCase();
const compactionMode = parseCliCompactionMode(mode);
if (compactionMode) {
result.compactionMode = compactionMode;
} else if (mode) {
result.invalidCompactionMode = mode;
}
}
// Retries (max consecutive mistakes) validation
if (opts.retries !== undefined) {
const raw = opts.retries.trim();
const parsed = Number.parseInt(raw, 10);
if (raw && Number.isInteger(parsed) && parsed >= 1) {
result.retries = parsed;
} else if (raw) {
result.invalidRetries = raw;
}
}
// Simple string/number options
if (opts.dataDir !== undefined) result.dataDir = opts.dataDir;
if (opts.config !== undefined) result.configDir = opts.config;
if (opts.hooksDir !== undefined) result.hooksDir = opts.hooksDir;
if (opts.cwd !== undefined) result.cwd = opts.cwd;
if (opts.teamName !== undefined) result.teamName = opts.teamName;
if (opts.system !== undefined) result.systemPrompt = opts.system;
if (opts.model !== undefined) result.model = opts.model;
if (opts.provider !== undefined) result.provider = opts.provider;
if (opts.key !== undefined) result.key = opts.key;
else if (opts.apiKey !== undefined) result.key = opts.apiKey;
if (opts.id !== undefined) result.id = opts.id;
// Positional args → prompt
const positional = program.args.filter((a) => !a.startsWith("-"));
if (positional.length > 0) {
result.prompt = positional.join(" ");
}
return result;
}
@@ -0,0 +1,124 @@
import type { SaveProviderSettingsActionRequest } from "@cline/core";
import {
type ProviderSettingsManager,
saveLocalProviderSettings,
} from "@cline/core";
import { describe, expect, it, vi } from "vitest";
describe("saveLocalProviderSettings", () => {
it("ignores null apiKey/baseUrl updates", () => {
const save = vi.fn();
const manager = {
read: vi.fn().mockReturnValue({
providers: {},
}),
write: vi.fn(),
getFilePath: vi.fn().mockReturnValue("/tmp/providers.json"),
getProviderSettings: vi.fn().mockReturnValue({
provider: "openai",
apiKey: "existing-key",
baseUrl: "https://api.example.com",
}),
saveProviderSettings: save,
};
saveLocalProviderSettings(
manager as unknown as ProviderSettingsManager,
{
action: "saveProviderSettings",
providerId: "openai",
apiKey: null,
baseUrl: null,
} as unknown as SaveProviderSettingsActionRequest,
);
expect(save).toHaveBeenCalledTimes(1);
expect(save).toHaveBeenCalledWith(
{
provider: "openai",
apiKey: "existing-key",
baseUrl: "https://api.example.com",
},
{ setLastUsed: false },
);
});
it("clears apiKey/baseUrl when explicit blank strings are provided", () => {
const save = vi.fn();
const manager = {
read: vi.fn().mockReturnValue({
providers: {},
}),
write: vi.fn(),
getFilePath: vi.fn().mockReturnValue("/tmp/providers.json"),
getProviderSettings: vi.fn().mockReturnValue({
provider: "openai",
apiKey: "existing-key",
baseUrl: "https://api.example.com",
}),
saveProviderSettings: save,
};
saveLocalProviderSettings(
manager as unknown as ProviderSettingsManager,
{
action: "saveProviderSettings",
providerId: "openai",
apiKey: " ",
baseUrl: "",
} as SaveProviderSettingsActionRequest,
);
expect(save).toHaveBeenCalledTimes(1);
expect(save).toHaveBeenCalledWith(
{
provider: "openai",
},
{ setLastUsed: false },
);
});
it("keeps OAuth auth fields when updating manual apiKey", () => {
const save = vi.fn();
const manager = {
read: vi.fn().mockReturnValue({
providers: {},
}),
write: vi.fn(),
getFilePath: vi.fn().mockReturnValue("/tmp/providers.json"),
getProviderSettings: vi.fn().mockReturnValue({
provider: "cline",
apiKey: "manual-old",
auth: {
accessToken: "workos:oauth-access",
refreshToken: "oauth-refresh",
accountId: "acct-1",
},
}),
saveProviderSettings: save,
};
saveLocalProviderSettings(
manager as unknown as ProviderSettingsManager,
{
action: "saveProviderSettings",
providerId: "cline",
apiKey: "manual-new",
} as SaveProviderSettingsActionRequest,
);
expect(save).toHaveBeenCalledTimes(1);
expect(save).toHaveBeenCalledWith(
{
provider: "cline",
apiKey: "manual-new",
auth: {
accessToken: "workos:oauth-access",
refreshToken: "oauth-refresh",
accountId: "acct-1",
},
},
{ setLastUsed: false },
);
});
});
+314
View File
@@ -0,0 +1,314 @@
import { readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { createScheduleCommand } from "./schedule";
const mockSendHubCommand = vi.hoisted(() => vi.fn());
const mockEnsureCliHubServer = vi.hoisted(() => vi.fn());
vi.mock("@cline/core", () => ({
sendHubCommand: mockSendHubCommand,
}));
vi.mock("../utils/hub-runtime", () => ({
ensureCliHubServer: mockEnsureCliHubServer,
parseHubEndpointOverride: (rawAddress: string | undefined) => {
const trimmed = rawAddress?.trim();
if (!trimmed) {
return {};
}
const parsed = new URL(
trimmed.includes("://") ? trimmed : `ws://${trimmed}`,
);
return {
host: parsed.hostname || undefined,
port: parsed.port ? Number(parsed.port) : undefined,
pathname:
parsed.pathname && parsed.pathname !== "/"
? parsed.pathname
: undefined,
};
},
}));
async function runScheduleCommand(
args: string[],
io: { writeln: (text?: string) => void; writeErr: (text: string) => void },
): Promise<number> {
let exitCode = 0;
const cmd = createScheduleCommand(io, (code) => {
exitCode = code;
});
await cmd.parseAsync(args, { from: "user" });
return exitCode;
}
describe("runScheduleCommand list output", () => {
afterEach(() => {
vi.clearAllMocks();
});
it('prints "No schedules found." for empty non-json list output', async () => {
mockEnsureCliHubServer.mockResolvedValue({
url: "ws://127.0.0.1:25463/hub",
authToken: "test-token",
});
mockSendHubCommand.mockResolvedValue({
ok: true,
payload: { schedules: [] },
});
const output: string[] = [];
const errors: string[] = [];
const code = await runScheduleCommand(
["list", "--address", "127.0.0.1:25463"],
{
writeln: (text?: string) => {
output.push(text ?? "");
},
writeErr: (text: string) => {
errors.push(text);
},
},
);
expect(code).toBe(0);
expect(errors).toEqual([]);
expect(output).toEqual(["No schedules found."]);
expect(mockSendHubCommand).toHaveBeenCalledWith(
{ host: "127.0.0.1", port: 25463, pathname: "/hub" },
{
clientId: "cline-schedule",
command: "schedule.list",
payload: {
limit: 100,
enabled: undefined,
tags: undefined,
},
},
);
});
it("keeps JSON list output unchanged when --json is provided", async () => {
mockEnsureCliHubServer.mockResolvedValue({
url: "ws://127.0.0.1:25463/hub",
authToken: "test-token",
});
mockSendHubCommand.mockResolvedValue({
ok: true,
payload: { schedules: [] },
});
const output: string[] = [];
const errors: string[] = [];
const code = await runScheduleCommand(
["list", "--json", "--address", "127.0.0.1:25463"],
{
writeln: (text?: string) => {
output.push(text ?? "");
},
writeErr: (text: string) => {
errors.push(text);
},
},
);
expect(code).toBe(0);
expect(errors).toEqual([]);
expect(output).toEqual(["[]"]);
expect(mockSendHubCommand).toHaveBeenCalled();
});
});
describe("runScheduleCommand import", () => {
afterEach(() => {
vi.clearAllMocks();
});
it("preserves exported modelSelection providerId/modelId values", async () => {
mockEnsureCliHubServer.mockResolvedValue({
url: "ws://127.0.0.1:25463/hub",
authToken: "test-token",
});
mockSendHubCommand.mockResolvedValue({
ok: true,
payload: { schedule: { scheduleId: "sched_123" } },
});
const sourcePath = join(
tmpdir(),
`cline-schedule-import-${Date.now()}.json`,
);
await writeFile(
sourcePath,
JSON.stringify({
name: "Daily Review",
cronPattern: "0 9 * * *",
prompt: "review status",
workspaceRoot: "/tmp/workspace",
modelSelection: {
providerId: "anthropic",
modelId: "claude-sonnet-4-6",
},
}),
"utf8",
);
const output: string[] = [];
const errors: string[] = [];
const code = await runScheduleCommand(
["import", sourcePath, "--address", "127.0.0.1:25463"],
{
writeln: (text?: string) => {
output.push(text ?? "");
},
writeErr: (text: string) => {
errors.push(text);
},
},
);
expect(code).toBe(0);
expect(errors).toEqual([]);
expect(output).toEqual(['{\n "scheduleId": "sched_123"\n}']);
expect(mockSendHubCommand).toHaveBeenCalledWith(
{ host: "127.0.0.1", port: 25463, pathname: "/hub" },
{
clientId: "cline-schedule",
command: "schedule.create",
payload: expect.objectContaining({
provider: "anthropic",
model: "claude-sonnet-4-6",
}),
},
);
});
});
describe("runScheduleCommand export", () => {
afterEach(() => {
vi.clearAllMocks();
});
it("writes JSON content to the --to file path", async () => {
mockEnsureCliHubServer.mockResolvedValue({
url: "ws://127.0.0.1:25463/hub",
authToken: "test-token",
});
const scheduleRecord = {
scheduleId: "sched_abc",
name: "Daily Review",
cronPattern: "0 9 * * *",
prompt: "review status",
workspaceRoot: "/tmp/workspace",
};
mockSendHubCommand.mockResolvedValue({
ok: true,
payload: { schedule: scheduleRecord },
});
const targetPath = join(
tmpdir(),
`cline-schedule-export-${Date.now()}-${Math.random()
.toString(36)
.slice(2)}.json`,
);
const output: string[] = [];
const errors: string[] = [];
try {
const code = await runScheduleCommand(
[
"export",
"sched_abc",
"--to",
targetPath,
"--address",
"127.0.0.1:25463",
],
{
writeln: (text?: string) => {
output.push(text ?? "");
},
writeErr: (text: string) => {
errors.push(text);
},
},
);
expect(code).toBe(0);
expect(errors).toEqual([]);
expect(output).toEqual([`Exported schedule sched_abc to ${targetPath}`]);
const written = await readFile(targetPath, "utf8");
expect(written).toBe(JSON.stringify(scheduleRecord, null, 2));
expect(mockSendHubCommand).toHaveBeenCalledWith(
{ host: "127.0.0.1", port: 25463, pathname: "/hub" },
{
clientId: "cline-schedule",
command: "schedule.get",
payload: { scheduleId: "sched_abc" },
},
);
} finally {
await rm(targetPath, { force: true });
}
});
it("writes YAML content when --to has a non-json extension", async () => {
mockEnsureCliHubServer.mockResolvedValue({
url: "ws://127.0.0.1:25463/hub",
authToken: "test-token",
});
const scheduleRecord = {
scheduleId: "sched_yaml",
name: "Weekly Sync",
cronPattern: "0 9 * * 1",
};
mockSendHubCommand.mockResolvedValue({
ok: true,
payload: { schedule: scheduleRecord },
});
const targetPath = join(
tmpdir(),
`cline-schedule-export-${Date.now()}-${Math.random()
.toString(36)
.slice(2)}.yaml`,
);
const output: string[] = [];
const errors: string[] = [];
try {
const code = await runScheduleCommand(
[
"export",
"sched_yaml",
"--to",
targetPath,
"--address",
"127.0.0.1:25463",
],
{
writeln: (text?: string) => {
output.push(text ?? "");
},
writeErr: (text: string) => {
errors.push(text);
},
},
);
expect(code).toBe(0);
expect(errors).toEqual([]);
expect(output).toEqual([`Exported schedule sched_yaml to ${targetPath}`]);
const yaml = await import("yaml");
const written = await readFile(targetPath, "utf8");
expect(written).toBe(yaml.stringify(scheduleRecord));
} finally {
await rm(targetPath, { force: true });
}
});
});
+36
View File
@@ -0,0 +1,36 @@
import { Command } from "commander";
import { registerScheduleCommands } from "./schedule/handlers";
import type { CommandIo } from "./schedule/types";
export function createScheduleCommand(
io: CommandIo,
setExitCode: (code: number) => void,
): Command {
let actionExitCode = 0;
const fail = () => {
actionExitCode = 1;
};
function action<T extends unknown[]>(
fn: (...args: T) => Promise<void>,
): (...args: T) => Promise<void> {
return async (...args: T) => {
try {
await fn(...args);
} catch (error) {
io.writeErr(error instanceof Error ? error.message : String(error));
fail();
}
};
}
const schedule = new Command("schedule")
.description("Create and manage scheduled runs")
.exitOverride()
.hook("postAction", () => {
setExitCode(actionExitCode);
});
registerScheduleCommands(schedule, io, fail, action);
return schedule;
}

Some files were not shown because too many files have changed in this diff Show More