mirror of
https://github.com/cline/cline.git
synced 2026-09-19 02:05:44 +08:00
chore: rename example folders; fix references (#60)
* rename folders * fix examples reorg references * move over files * having agent fixing and validating all examples are still working * fix sidecar paths --------- Co-authored-by: abeatrix <beatrix@cline.bot>
This commit is contained in:
committed by
GitHub
co-authored by
abeatrix
parent
2b2ff9667d
commit
e215d3d91c
@@ -0,0 +1,269 @@
|
||||
# Cline Custom Plugin Example
|
||||
|
||||
Shows how to author a reusable plugin module that works in both the SDK and the CLI. A plugin can:
|
||||
|
||||
- **Register tools** — give the agent new capabilities it can invoke
|
||||
- **Hook into the lifecycle** — observe or influence execution at key points
|
||||
- **Rewrite provider messages** — add custom context compaction before the model call
|
||||
- **Emit automation events** — normalize plugin-owned events into ClineCore automation
|
||||
|
||||
Example plugins:
|
||||
|
||||
- [weather-plugin.example.ts](./weather-plugin.example.ts) - weather tool plus lifecycle metrics hooks
|
||||
- [mac-notify.ts](./mac-notify.ts) - macOS Notification Center alert on successful run completion
|
||||
- [automation-events.ts](./automation-events.ts) - local plugin-emitted automation event example
|
||||
- [custom-compaction.ts](./custom-compaction.ts) - custom summary-based message compaction
|
||||
- [../hooks/custom-compaction-hook.example.ts](../hooks/custom-compaction-hook.example.ts) - equivalent compaction using a runtime `beforeModel` hook
|
||||
- [background-terminal.ts](./background-terminal.ts) - detached background shell jobs with persisted logs and optional session steering
|
||||
|
||||
## Use It With The CLI
|
||||
|
||||
The CLI does not have a `--plugin` flag yet. It discovers plugin modules from `.cline/plugins` in the workspace.
|
||||
|
||||
```bash
|
||||
mkdir -p .cline/plugins
|
||||
cp examples/plugins/weather-plugin.example.ts .cline/plugins/weather-metrics.ts
|
||||
|
||||
cline -i "What's the weather like in Tokyo and Paris?"
|
||||
```
|
||||
|
||||
The module exports `default` and `plugin`, so the CLI loader can import it directly.
|
||||
|
||||
To send a macOS Notification Center alert when a run completes successfully:
|
||||
|
||||
```bash
|
||||
mkdir -p .cline/plugins
|
||||
cp examples/plugins/mac-notify.ts .cline/plugins/mac-notify.ts
|
||||
|
||||
cline -i "Run the test suite"
|
||||
```
|
||||
|
||||
The notification example uses the `afterRun` hook and `/usr/bin/osascript`. macOS may ask you to allow notifications for the terminal or host process the first time it fires.
|
||||
|
||||
To add custom provider-message compaction before each model call:
|
||||
|
||||
```bash
|
||||
mkdir -p .cline/plugins
|
||||
cp examples/plugins/custom-compaction.ts .cline/plugins/custom-compaction.ts
|
||||
|
||||
cline -i "Search the codebase for dispatcher usage, then summarize it"
|
||||
```
|
||||
|
||||
To add background shell jobs that keep running after the tool call returns:
|
||||
|
||||
```bash
|
||||
mkdir -p .cline/plugins
|
||||
cp examples/plugins/background-terminal.ts .cline/plugins/background-terminal.ts
|
||||
|
||||
cline -i "Start the dev server in the background, then continue with the next task"
|
||||
```
|
||||
|
||||
The background terminal plugin registers three tools:
|
||||
|
||||
| Tool | Purpose |
|
||||
| ---- | ------- |
|
||||
| `start_background_command` | starts a detached shell command, returns a job id immediately, and stores stdout/stderr under Cline's data directory |
|
||||
| `get_background_command` | reads job status plus recent stdout/stderr tails |
|
||||
| `delete_background_command` | deletes saved job metadata, and optionally deletes captured logs |
|
||||
|
||||
When `notifyParent` is true or omitted, the plugin emits a `steer_message`
|
||||
through the host bridge after the command exits. That pushes a completion
|
||||
summary back into the active session, so the agent can react to long-running
|
||||
commands without blocking the original tool call.
|
||||
|
||||
## Run The Demo Directly
|
||||
|
||||
```bash
|
||||
ANTHROPIC_API_KEY=sk-... bun run examples/plugins/weather-plugin.example.ts
|
||||
```
|
||||
|
||||
## How it works
|
||||
|
||||
A plugin is a plain object with four parts:
|
||||
|
||||
```ts
|
||||
const myPlugin: AgentPlugin = {
|
||||
// 1. Identity
|
||||
name: "my-plugin",
|
||||
|
||||
// 2. Manifest — declare what the plugin does
|
||||
manifest: {
|
||||
capabilities: ["tools", "hooks"],
|
||||
},
|
||||
|
||||
// 3. Setup — register tools, commands, etc.
|
||||
setup(api, ctx) {
|
||||
api.registerTool(createTool({ ... }));
|
||||
},
|
||||
|
||||
// 4. Runtime hooks — observe or influence agent execution
|
||||
hooks: {
|
||||
beforeRun({ snapshot }) { ... },
|
||||
afterRun({ result }) { ... },
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
Then pass it to the agent:
|
||||
|
||||
```ts
|
||||
import plugin from "./weather-plugin.example";
|
||||
|
||||
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(),
|
||||
mode: "act",
|
||||
enableTools: true,
|
||||
enableSpawnAgent: false,
|
||||
enableAgentTeams: false,
|
||||
systemPrompt: "You are a helpful assistant. Use tools when needed.",
|
||||
extensions: [plugin],
|
||||
},
|
||||
prompt: "What's the weather like in Tokyo and Paris?",
|
||||
interactive: false,
|
||||
});
|
||||
```
|
||||
|
||||
## Available capabilities
|
||||
|
||||
| Capability | What it unlocks |
|
||||
| ------------------ | -------------------------------------------- |
|
||||
| `tools` | `api.registerTool()` |
|
||||
| `commands` | `api.registerCommand()` |
|
||||
| `providers` | `api.registerProvider()` |
|
||||
| `messageBuilders` | `api.registerMessageBuilder()` |
|
||||
| `automationEvents` | `api.registerAutomationEventType()` and `ctx.automation?.ingestEvent()` |
|
||||
| `hooks` | runtime lifecycle hook handlers (see below) |
|
||||
|
||||
## Automation event plugins
|
||||
|
||||
Plugins can contribute normalized automation event types and, when running in a
|
||||
`ClineCore` host with automation enabled, emit events through setup context:
|
||||
|
||||
```ts
|
||||
const plugin: AgentPlugin = {
|
||||
name: "local-events",
|
||||
manifest: { capabilities: ["automationEvents"] },
|
||||
setup(api, ctx) {
|
||||
api.registerAutomationEventType({
|
||||
eventType: "local.plugin_event",
|
||||
source: "local-plugin",
|
||||
description: "Local normalized event emitted by a plugin",
|
||||
});
|
||||
|
||||
void ctx.automation?.ingestEvent({
|
||||
eventId: `local-plugin-${Date.now()}`,
|
||||
eventType: "local.plugin_event",
|
||||
source: "local-plugin",
|
||||
occurredAt: new Date().toISOString(),
|
||||
});
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
The setup context can also include `session`, `client`, `user`, `workspaceInfo`,
|
||||
`logger`, and `telemetry` when provided by the host. See
|
||||
[`automation-events.ts`](./automation-events.ts) for a local timer-based demo.
|
||||
|
||||
## Runtime Hooks
|
||||
|
||||
Plugins use the same runtime hook names as `@cline/agents`. These are
|
||||
in-process callbacks, not file hook event names:
|
||||
|
||||
- Runtime hooks: typed in-process plugin/agent lifecycle callbacks such as
|
||||
`beforeRun`, `beforeModel`, and `afterTool`.
|
||||
- File hooks: external scripts discovered from hook config directories and run
|
||||
with serialized JSON payloads.
|
||||
- Hook events: serialized payload names used by file hooks, such as
|
||||
`agent_end`, `tool_call`, and `prompt_submit`.
|
||||
|
||||
| Hook | When it fires |
|
||||
| ------------- | -------------------------------------------------- |
|
||||
| `beforeRun` | before the runtime loop starts |
|
||||
| `afterRun` | after the runtime loop finishes |
|
||||
| `beforeModel` | before each model request |
|
||||
| `afterModel` | after each model response, before tool execution |
|
||||
| `beforeTool` | before each tool execution |
|
||||
| `afterTool` | after each tool execution |
|
||||
| `onEvent` | on every `AgentRuntimeEvent` emitted by the runtime |
|
||||
|
||||
`beforeRun` and `afterRun` wrap one `run()` / `continue()` invocation. In an
|
||||
interactive session, that maps to one submitted user turn. `afterRun` is the
|
||||
right plugin hook for task completion notifications, but it also fires for
|
||||
aborted and failed runs, so check `result.status === "completed"` when you only
|
||||
want successful completion. The equivalent file-hook event is `agent_end`.
|
||||
|
||||
## Runtime hooks vs file hooks
|
||||
|
||||
File hooks are external scripts discovered from hook config directories such as
|
||||
`.cline/hooks`. They use serialized event names from `@cline/shared`, while
|
||||
plugin runtime hooks use the typed in-process runtime lifecycle names above.
|
||||
Core adapts file hooks onto the runtime hook layer before executing the scripts.
|
||||
|
||||
| File hook file name | File hook event | Plugin runtime hook backing it |
|
||||
| ------------------- | --------------- | ------------------------------ |
|
||||
| `TaskStart` | `agent_start` | `beforeRun` |
|
||||
| `TaskResume` | `agent_resume` | `beforeRun` with resume context |
|
||||
| `UserPromptSubmit` | `prompt_submit` | `beforeRun` plus submitted prompt context |
|
||||
| `PreToolUse` | `tool_call` | `beforeTool` |
|
||||
| `PostToolUse` | `tool_result` | `afterTool` |
|
||||
| `TaskComplete` | `agent_end` | `afterRun` when completed |
|
||||
| `TaskError` | `agent_error` | `afterRun` when failed |
|
||||
| `TaskCancel` | `agent_abort` | `afterRun` or session shutdown with abort/cancel reason |
|
||||
| `SessionShutdown` | `session_shutdown` | session cleanup / runtime shutdown |
|
||||
| `PreCompact` | not wired for file hooks today | none |
|
||||
|
||||
Use file hooks for user/workspace-configured scripts. Use plugin runtime hooks
|
||||
when the behavior belongs to a reusable extension and needs typed access to the
|
||||
runtime snapshot, model request, tool context, or emitted runtime events.
|
||||
|
||||
For custom message compaction, use a plugin runtime hook such as `beforeModel`
|
||||
or the `messageBuilders` API. That is separate from the serialized
|
||||
`pre_compact` hook-event payload type, and `PreCompact` files are not currently
|
||||
wired into file-hook execution.
|
||||
|
||||
### Naming note
|
||||
|
||||
The current public plugin field is `hooks` because it is the runtime-native
|
||||
extension field consumed by the agent. In user-facing docs and examples, call
|
||||
these **runtime hooks** to avoid confusing them with file hook events. If the
|
||||
plugin API is renamed before the SDK has external consumers, prefer
|
||||
`runtimeHooks` for `AgentPlugin` and reserve **file hooks** for the external
|
||||
script/event system.
|
||||
|
||||
## Custom message compaction
|
||||
|
||||
Use `messageBuilders` when a plugin needs to transform the provider-bound
|
||||
message list before the model call. Message builders run after runtime messages
|
||||
are converted into SDK message blocks and before the built-in API safety pass,
|
||||
so core still applies final provider-safe truncation afterward.
|
||||
|
||||
See [`custom-compaction.ts`](./custom-compaction.ts) for a full
|
||||
plugin that estimates context size, preserves the first user message and recent
|
||||
working context, and replaces older middle history with one continuation summary.
|
||||
|
||||
There is also a runtime-hook version at
|
||||
[`../hooks/custom-compaction-hook.example.ts`](../hooks/custom-compaction-hook.example.ts).
|
||||
Both examples perform similar compaction, but they run at different layers:
|
||||
|
||||
| Example | Extension point | Message shape | Best for |
|
||||
| ------- | --------------- | ------------- | -------- |
|
||||
| `custom-compaction.ts` | `api.registerMessageBuilder()` | SDK/provider-bound `Message[]` after runtime messages are converted for model delivery | most reusable plugin-owned message rewrites and compaction policies |
|
||||
| `../hooks/custom-compaction-hook.example.ts` | `hooks.beforeModel` runtime hook | Agent runtime request messages with runtime parts such as `tool-call`, `tool-result`, `reasoning`, `image`, and `file` | cases that need runtime-hook context, the current runtime snapshot, or direct request mutation |
|
||||
|
||||
Prefer the message-builder version for normal plugin-owned compaction because
|
||||
it runs in the core message pipeline before the built-in provider-safety
|
||||
builder. Use the `beforeModel` runtime-hook version when the logic needs access
|
||||
to runtime hook context or the exact runtime request object.
|
||||
|
||||
Notes:
|
||||
|
||||
- message builders receive and return `Message[]`
|
||||
- builders may be sync or async
|
||||
- multiple builders run in plugin registration order
|
||||
- the built-in core message builder runs last to normalize input and enforce provider-safe truncation
|
||||
- use `beforeModel` hooks for runtime request changes; use message builders for message-list rewrites
|
||||
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* Automation Event Plugin Example
|
||||
*
|
||||
* Shows how a plugin can declare normalized event types and emit events into
|
||||
* ClineCore automation without importing cron internals.
|
||||
*
|
||||
* Local demo:
|
||||
* mkdir -p .cline/plugins .cline/cron/events
|
||||
* cp examples/plugins/automation-events.ts .cline/plugins/automation-events.ts
|
||||
* cp examples/cron/events/local-plugin-event.event.md .cline/cron/events/local-plugin-event.event.md
|
||||
* perl -0pi -e "s#/absolute/path/to/repo#$PWD#g" .cline/cron/events/local-plugin-event.event.md
|
||||
* CLINE_LOCAL_EVENT_INTERVAL_MS=2000 cline -i "wait for the plugin event"
|
||||
*/
|
||||
|
||||
import type { AgentPlugin } from "@cline/core";
|
||||
|
||||
const stopLocalEmitters = new Map<string, () => void>();
|
||||
|
||||
function emitterKey(sessionId: string | undefined): string | undefined {
|
||||
return sessionId?.trim() || undefined;
|
||||
}
|
||||
|
||||
export const plugin: AgentPlugin = {
|
||||
name: "local-automation-events",
|
||||
manifest: {
|
||||
capabilities: ["automationEvents"],
|
||||
},
|
||||
|
||||
setup(api, ctx) {
|
||||
api.registerAutomationEventType({
|
||||
eventType: "local.plugin_event",
|
||||
source: "local-plugin",
|
||||
description: "Local normalized event emitted by a plugin",
|
||||
attributesSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
topic: { type: "string" },
|
||||
},
|
||||
required: ["topic"],
|
||||
},
|
||||
examples: [
|
||||
{
|
||||
eventId: "local-plugin-demo-1",
|
||||
eventType: "local.plugin_event",
|
||||
source: "local-plugin",
|
||||
subject: "plugin-demo",
|
||||
occurredAt: "2026-04-24T10:00:00.000Z",
|
||||
attributes: { topic: "plugin-demo" },
|
||||
},
|
||||
],
|
||||
});
|
||||
ctx.logger?.log("local automation event source registered", {
|
||||
sessionId: ctx.session?.sessionId,
|
||||
client: ctx.client?.name,
|
||||
});
|
||||
|
||||
const intervalMs = Number(process.env.CLINE_LOCAL_EVENT_INTERVAL_MS ?? 0);
|
||||
if (!ctx.automation || !Number.isFinite(intervalMs) || intervalMs <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const key = emitterKey(ctx.session?.sessionId);
|
||||
if (!key) {
|
||||
ctx.logger?.log(
|
||||
"local automation event emitter disabled; setup context has no session id",
|
||||
{ severity: "warn" },
|
||||
);
|
||||
return;
|
||||
}
|
||||
stopLocalEmitters.get(key)?.();
|
||||
|
||||
const timer = setInterval(() => {
|
||||
void ctx.automation?.ingestEvent({
|
||||
eventId: `local-plugin-${Date.now()}`,
|
||||
eventType: "local.plugin_event",
|
||||
source: "local-plugin",
|
||||
subject: "plugin-demo",
|
||||
occurredAt: new Date().toISOString(),
|
||||
dedupeKey: "local-plugin:plugin-demo",
|
||||
attributes: { topic: "plugin-demo" },
|
||||
payload: {
|
||||
message: "Hello from a plugin-emitted automation event.",
|
||||
},
|
||||
});
|
||||
}, intervalMs);
|
||||
|
||||
stopLocalEmitters.set(key, () => clearInterval(timer));
|
||||
},
|
||||
};
|
||||
|
||||
export default plugin;
|
||||
@@ -0,0 +1,432 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import {
|
||||
appendFileSync,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
import {
|
||||
type AgentPlugin,
|
||||
type AgentToolContext,
|
||||
createTool,
|
||||
} from "@cline/core";
|
||||
|
||||
/**
|
||||
* Background Terminal Plugin Example
|
||||
*
|
||||
* Starts shell commands in detached background processes, stores stdout/stderr
|
||||
* under Cline's data directory, and optionally steers a completion summary back
|
||||
* into the current session when the command exits.
|
||||
*
|
||||
* CLI usage:
|
||||
* mkdir -p .cline/plugins
|
||||
* cp examples/plugins/background-terminal.ts .cline/plugins/background-terminal.ts
|
||||
* cline -i "Start the dev server in the background and keep working"
|
||||
*/
|
||||
|
||||
type JobStatus = "running" | "completed" | "failed";
|
||||
|
||||
type JobRecord = {
|
||||
jobId: string;
|
||||
command: string;
|
||||
cwd: string;
|
||||
shell: string;
|
||||
startedAt: string;
|
||||
completedAt?: string;
|
||||
status: JobStatus;
|
||||
exitCode?: number | null;
|
||||
signal?: string | null;
|
||||
notifyParent: boolean;
|
||||
sessionId?: string;
|
||||
pid?: number;
|
||||
stdoutPath: string;
|
||||
stderrPath: string;
|
||||
metaPath: string;
|
||||
};
|
||||
|
||||
interface ClinePluginHost {
|
||||
emitEvent?: (name: string, payload?: unknown) => void;
|
||||
}
|
||||
|
||||
declare global {
|
||||
var __clinePluginHost: ClinePluginHost | undefined;
|
||||
}
|
||||
|
||||
const DEFAULT_SHELL = process.env.SHELL || "/bin/zsh";
|
||||
const CLINE_DATA_DIR =
|
||||
process.env.CLINE_DATA_DIR || join(homedir(), ".cline", "data");
|
||||
const JOBS_DIR = join(CLINE_DATA_DIR, "plugins", "background-shell", "jobs");
|
||||
let sessionDefaultCwd = process.cwd();
|
||||
let setupSessionId: string | undefined;
|
||||
|
||||
function ensureJobsDir() {
|
||||
mkdirSync(JOBS_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
function asObject(value: unknown): Record<string, unknown> {
|
||||
return value && typeof value === "object"
|
||||
? (value as Record<string, unknown>)
|
||||
: {};
|
||||
}
|
||||
|
||||
function requireString(value: unknown, field: string) {
|
||||
if (typeof value !== "string" || !value.trim()) {
|
||||
throw new Error(`${field} must be a non-empty string`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function optionalString(value: unknown) {
|
||||
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
||||
}
|
||||
|
||||
function optionalBoolean(value: unknown) {
|
||||
return typeof value === "boolean" ? value : undefined;
|
||||
}
|
||||
|
||||
function optionalInt(value: unknown) {
|
||||
return typeof value === "number" && Number.isInteger(value)
|
||||
? value
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function resolveToolSessionId(context: AgentToolContext): string | undefined {
|
||||
return context.sessionId?.trim() || setupSessionId;
|
||||
}
|
||||
|
||||
function jobDir(jobId: string) {
|
||||
return join(JOBS_DIR, jobId);
|
||||
}
|
||||
|
||||
function metaPath(jobId: string) {
|
||||
return join(jobDir(jobId), "job.json");
|
||||
}
|
||||
|
||||
function stdoutPath(jobId: string) {
|
||||
return join(jobDir(jobId), "stdout.log");
|
||||
}
|
||||
|
||||
function stderrPath(jobId: string) {
|
||||
return join(jobDir(jobId), "stderr.log");
|
||||
}
|
||||
|
||||
function readTextIfExists(path: string) {
|
||||
return existsSync(path) ? readFileSync(path, "utf8") : "";
|
||||
}
|
||||
|
||||
function tail(text: string, lineCount: number) {
|
||||
const lines = text.split(/\r?\n/);
|
||||
return lines
|
||||
.slice(Math.max(0, lines.length - lineCount))
|
||||
.join("\n")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function writeJob(record: JobRecord) {
|
||||
ensureJobsDir();
|
||||
mkdirSync(jobDir(record.jobId), { recursive: true });
|
||||
writeFileSync(
|
||||
record.metaPath,
|
||||
`${JSON.stringify(record, null, 2)}\n`,
|
||||
"utf8",
|
||||
);
|
||||
}
|
||||
|
||||
/** @returns {JobRecord} */
|
||||
function readJob(jobId: string) {
|
||||
const path = metaPath(jobId);
|
||||
if (!existsSync(path)) {
|
||||
throw new Error(`Unknown background command job: ${jobId}`);
|
||||
}
|
||||
return /** @type {JobRecord} */ (JSON.parse(readFileSync(path, "utf8")));
|
||||
}
|
||||
|
||||
function formatCompletionMessage(record: JobRecord) {
|
||||
const stdout = tail(readTextIfExists(record.stdoutPath), 80);
|
||||
const stderr = tail(readTextIfExists(record.stderrPath), 80);
|
||||
const statusLine =
|
||||
record.status === "completed"
|
||||
? `Background command completed successfully (exit ${record.exitCode ?? 0}).`
|
||||
: `Background command failed (exit ${record.exitCode ?? "unknown"}${record.signal ? `, signal ${record.signal}` : ""}).`;
|
||||
|
||||
return [
|
||||
"Background shell job finished.",
|
||||
`Job ID: ${record.jobId}`,
|
||||
`Command: ${record.command}`,
|
||||
`CWD: ${record.cwd}`,
|
||||
statusLine,
|
||||
stdout ? `STDOUT:\n${stdout}` : "STDOUT: <empty>",
|
||||
stderr ? `STDERR:\n${stderr}` : "STDERR: <empty>",
|
||||
].join("\n\n");
|
||||
}
|
||||
|
||||
function emitSteer(sessionId: string | undefined, prompt: string) {
|
||||
if (!sessionId || !prompt.trim()) {
|
||||
return;
|
||||
}
|
||||
globalThis.__clinePluginHost?.emitEvent?.("steer_message", {
|
||||
sessionId,
|
||||
prompt,
|
||||
});
|
||||
}
|
||||
|
||||
function startCommand(
|
||||
command: string,
|
||||
cwd: string,
|
||||
shell: string,
|
||||
notifyParent: boolean,
|
||||
sessionId: string | undefined,
|
||||
) {
|
||||
ensureJobsDir();
|
||||
const jobId = randomUUID();
|
||||
mkdirSync(jobDir(jobId), { recursive: true });
|
||||
|
||||
const outPath = stdoutPath(jobId);
|
||||
const errPath = stderrPath(jobId);
|
||||
|
||||
const child = spawn(shell, ["-lc", command], {
|
||||
cwd,
|
||||
detached: true,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
env: process.env,
|
||||
});
|
||||
|
||||
const record: JobRecord = {
|
||||
jobId,
|
||||
command,
|
||||
cwd,
|
||||
shell,
|
||||
startedAt: new Date().toISOString(),
|
||||
status: "running",
|
||||
notifyParent,
|
||||
sessionId,
|
||||
pid: child.pid,
|
||||
stdoutPath: outPath,
|
||||
stderrPath: errPath,
|
||||
metaPath: metaPath(jobId),
|
||||
};
|
||||
writeJob(record);
|
||||
|
||||
child.stdout?.on("data", (chunk) => {
|
||||
appendFileSync(outPath, chunk);
|
||||
});
|
||||
child.stderr?.on("data", (chunk) => {
|
||||
appendFileSync(errPath, chunk);
|
||||
});
|
||||
child.on("error", (error) => {
|
||||
const current = readJob(jobId);
|
||||
const updated = {
|
||||
...current,
|
||||
status: /** @type {JobStatus} */ ("failed"),
|
||||
completedAt: new Date().toISOString(),
|
||||
exitCode: null,
|
||||
signal: null,
|
||||
};
|
||||
appendFileSync(errPath, `${error.message}\n`);
|
||||
writeJob(updated);
|
||||
if (updated.notifyParent) {
|
||||
emitSteer(updated.sessionId, formatCompletionMessage(updated));
|
||||
}
|
||||
});
|
||||
child.on("close", (code, signal) => {
|
||||
const current = readJob(jobId);
|
||||
const updated = {
|
||||
...current,
|
||||
status: /** @type {JobStatus} */ (code === 0 ? "completed" : "failed"),
|
||||
completedAt: new Date().toISOString(),
|
||||
exitCode: code,
|
||||
signal,
|
||||
};
|
||||
writeJob(updated);
|
||||
if (updated.notifyParent) {
|
||||
emitSteer(updated.sessionId, formatCompletionMessage(updated));
|
||||
}
|
||||
});
|
||||
child.unref();
|
||||
|
||||
return record;
|
||||
}
|
||||
|
||||
const plugin: AgentPlugin = {
|
||||
name: "background-terminal",
|
||||
manifest: {
|
||||
capabilities: ["tools"],
|
||||
},
|
||||
|
||||
setup(api, ctx) {
|
||||
const workspaceContext = ctx.workspaceInfo as
|
||||
| { rootPath?: string; cwd?: string }
|
||||
| undefined;
|
||||
sessionDefaultCwd =
|
||||
workspaceContext?.cwd?.trim() ||
|
||||
workspaceContext?.rootPath?.trim() ||
|
||||
sessionDefaultCwd;
|
||||
setupSessionId = ctx.session?.sessionId?.trim() || undefined;
|
||||
|
||||
api.registerTool(
|
||||
createTool<unknown, Record<string, unknown>>({
|
||||
name: "start_background_command",
|
||||
description:
|
||||
"Start a shell command in the background, return a job ID immediately, persist stdout/stderr, and optionally push a completion summary back into the current session when it exits.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
command: {
|
||||
type: "string",
|
||||
description: "Shell command to execute.",
|
||||
},
|
||||
cwd: {
|
||||
type: "string",
|
||||
description: `Working directory for the command. Defaults to ${sessionDefaultCwd}.`,
|
||||
},
|
||||
shell: {
|
||||
type: "string",
|
||||
description: `Shell binary to use. Defaults to ${DEFAULT_SHELL}.`,
|
||||
},
|
||||
notifyParent: {
|
||||
type: "boolean",
|
||||
description:
|
||||
"When true or omitted, send the final command result back into the session as a steer message.",
|
||||
},
|
||||
},
|
||||
required: ["command"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
timeoutMs: 5000,
|
||||
retryable: false,
|
||||
async execute(input, context) {
|
||||
const args = asObject(input);
|
||||
const command = requireString(args.command, "command").trim();
|
||||
const cwd = resolve(optionalString(args.cwd) || sessionDefaultCwd);
|
||||
const shell = optionalString(args.shell) || DEFAULT_SHELL;
|
||||
const notifyParent = optionalBoolean(args.notifyParent) !== false;
|
||||
const record = startCommand(
|
||||
command,
|
||||
cwd,
|
||||
shell,
|
||||
notifyParent,
|
||||
resolveToolSessionId(context),
|
||||
);
|
||||
|
||||
return {
|
||||
jobId: record.jobId,
|
||||
status: record.status,
|
||||
command: record.command,
|
||||
cwd: record.cwd,
|
||||
shell: record.shell,
|
||||
pid: record.pid,
|
||||
startedAt: record.startedAt,
|
||||
notifyParent: record.notifyParent,
|
||||
stdoutPath: record.stdoutPath,
|
||||
stderrPath: record.stderrPath,
|
||||
note: "This tool returns immediately. Use get_background_command to poll, or rely on the automatic completion message when the command exits.",
|
||||
};
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
api.registerTool(
|
||||
createTool<unknown, Record<string, unknown>>({
|
||||
name: "get_background_command",
|
||||
description:
|
||||
"Read the current state and recent logs for a background shell job.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
jobId: {
|
||||
type: "string",
|
||||
description: "Job ID returned by start_background_command.",
|
||||
},
|
||||
tailLines: {
|
||||
type: "integer",
|
||||
description:
|
||||
"How many lines of stdout/stderr to include. Defaults to 40.",
|
||||
},
|
||||
},
|
||||
required: ["jobId"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
timeoutMs: 5000,
|
||||
retryable: false,
|
||||
async execute(input) {
|
||||
const args = asObject(input);
|
||||
const jobId = requireString(args.jobId, "jobId").trim();
|
||||
const tailLines = Math.min(
|
||||
200,
|
||||
Math.max(1, optionalInt(args.tailLines) || 40),
|
||||
);
|
||||
const record = readJob(jobId);
|
||||
|
||||
return {
|
||||
jobId: record.jobId,
|
||||
status: record.status,
|
||||
command: record.command,
|
||||
cwd: record.cwd,
|
||||
shell: record.shell,
|
||||
pid: record.pid,
|
||||
startedAt: record.startedAt,
|
||||
completedAt: record.completedAt,
|
||||
exitCode: record.exitCode,
|
||||
signal: record.signal,
|
||||
stdoutPath: record.stdoutPath,
|
||||
stderrPath: record.stderrPath,
|
||||
stdoutTail: tail(readTextIfExists(record.stdoutPath), tailLines),
|
||||
stderrTail: tail(readTextIfExists(record.stderrPath), tailLines),
|
||||
};
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
api.registerTool(
|
||||
createTool<unknown, Record<string, unknown>>({
|
||||
name: "delete_background_command",
|
||||
description:
|
||||
"Delete saved metadata for a background shell job. Optionally remove its stdout/stderr log files too.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
jobId: {
|
||||
type: "string",
|
||||
description: "Job ID to delete from local job storage.",
|
||||
},
|
||||
deleteLogs: {
|
||||
type: "boolean",
|
||||
description:
|
||||
"When true, also delete the captured stdout/stderr files.",
|
||||
},
|
||||
},
|
||||
required: ["jobId"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
timeoutMs: 5000,
|
||||
retryable: false,
|
||||
async execute(input) {
|
||||
const args = asObject(input);
|
||||
const jobId = requireString(args.jobId, "jobId").trim();
|
||||
const deleteLogs = optionalBoolean(args.deleteLogs) === true;
|
||||
const record = readJob(jobId);
|
||||
|
||||
if (deleteLogs && existsSync(jobDir(jobId))) {
|
||||
rmSync(jobDir(jobId), { recursive: true, force: true });
|
||||
} else if (existsSync(record.metaPath)) {
|
||||
rmSync(record.metaPath, { force: true });
|
||||
}
|
||||
|
||||
return {
|
||||
deleted: true,
|
||||
jobId,
|
||||
deleteLogs,
|
||||
};
|
||||
},
|
||||
}),
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export default plugin;
|
||||
export { plugin };
|
||||
@@ -0,0 +1,225 @@
|
||||
/**
|
||||
* Custom Message Compaction Plugin Example
|
||||
*
|
||||
* Shows how a plugin can use registerMessageBuilder() to rewrite provider-bound
|
||||
* messages before the model call. This example mirrors the shape of core
|
||||
* compaction: it estimates context size, preserves the first user message and
|
||||
* recent working context, and replaces older middle history with one concise
|
||||
* continuation summary message.
|
||||
*
|
||||
* Core still runs its built-in API-safety message builder after plugin builders,
|
||||
* so provider-safe normalization and hard truncation remain the final pass.
|
||||
*
|
||||
* CLI usage:
|
||||
* mkdir -p .cline/plugins
|
||||
* cp examples/plugins/custom-compaction.ts .cline/plugins/custom-compaction.ts
|
||||
* cline -i "Search the codebase for dispatcher usage, then summarize it"
|
||||
*/
|
||||
|
||||
import type { AgentPlugin, Message, ToolResultContent } from "@cline/core";
|
||||
|
||||
const CONTEXT_WINDOW_TOKENS = 120_000;
|
||||
const COMPACT_AT_RATIO = 0.75;
|
||||
const PRESERVE_RECENT_TOKENS = 24_000;
|
||||
const SUMMARY_PREVIEW_CHARS = 800;
|
||||
|
||||
function estimateTokens(text: string): number {
|
||||
return Math.max(1, Math.ceil(text.length / 4));
|
||||
}
|
||||
|
||||
function preview(text: string, limit = SUMMARY_PREVIEW_CHARS): string {
|
||||
if (text.length <= limit) {
|
||||
return text.trim();
|
||||
}
|
||||
return `${text.slice(0, limit).trim()}\n...[${text.length - limit} more chars summarized]`;
|
||||
}
|
||||
|
||||
function stringifyContent(content: ToolResultContent["content"]): string {
|
||||
return typeof content === "string" ? content : JSON.stringify(content);
|
||||
}
|
||||
|
||||
function serializeMessage(message: Message): string {
|
||||
if (typeof message.content === "string") {
|
||||
return `[${message.role}]: ${message.content}`;
|
||||
}
|
||||
|
||||
const lines: string[] = [];
|
||||
for (const block of message.content) {
|
||||
switch (block.type) {
|
||||
case "text":
|
||||
lines.push(`[${message.role}]: ${block.text ?? ""}`);
|
||||
break;
|
||||
case "thinking":
|
||||
lines.push(
|
||||
`[assistant thinking]: ${preview(block.thinking ?? "", 300)}`,
|
||||
);
|
||||
break;
|
||||
case "tool_use":
|
||||
lines.push(
|
||||
`[assistant tool call]: ${block.name ?? "tool"}(${JSON.stringify(block.input ?? {})})`,
|
||||
);
|
||||
break;
|
||||
case "tool_result":
|
||||
lines.push(
|
||||
`[tool result ${block.tool_use_id ?? "unknown"}]: ${preview(stringifyContent(block.content), 500)}`,
|
||||
);
|
||||
break;
|
||||
case "file":
|
||||
lines.push(
|
||||
`[file ${block.path ?? "unknown"}]: ${preview(String(block.content ?? ""), 500)}`,
|
||||
);
|
||||
break;
|
||||
default:
|
||||
lines.push(`[${message.role} ${block.type} block]`);
|
||||
}
|
||||
}
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function estimateMessageTokens(message: Message): number {
|
||||
return estimateTokens(serializeMessage(message));
|
||||
}
|
||||
|
||||
function findFirstUserIndex(messages: Message[]): number {
|
||||
return messages.findIndex((message) => message.role === "user");
|
||||
}
|
||||
|
||||
function findRecentStartIndex(messages: Message[]): number {
|
||||
let tokens = 0;
|
||||
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
||||
const message = messages[index];
|
||||
if (!message) {
|
||||
continue;
|
||||
}
|
||||
tokens += estimateMessageTokens(message);
|
||||
if (tokens >= PRESERVE_RECENT_TOKENS) {
|
||||
return index;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
function collectToolNames(messages: Message[]): string[] {
|
||||
const names = new Set<string>();
|
||||
for (const message of messages) {
|
||||
if (!Array.isArray(message.content)) {
|
||||
continue;
|
||||
}
|
||||
for (const block of message.content) {
|
||||
if (block.type === "tool_use" && block.name) {
|
||||
names.add(block.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
return [...names].sort((left, right) => left.localeCompare(right));
|
||||
}
|
||||
|
||||
function collectTouchedFiles(messages: Message[]): string[] {
|
||||
const paths = new Set<string>();
|
||||
for (const message of messages) {
|
||||
if (!Array.isArray(message.content)) {
|
||||
continue;
|
||||
}
|
||||
for (const block of message.content) {
|
||||
if (block.type === "file" && block.path) {
|
||||
paths.add(block.path);
|
||||
}
|
||||
if (block.type === "tool_use") {
|
||||
for (const value of Object.values(block.input ?? {})) {
|
||||
if (typeof value === "string" && value.includes("/")) {
|
||||
paths.add(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return [...paths].sort((left, right) => left.localeCompare(right));
|
||||
}
|
||||
|
||||
function buildCompactionSummary(
|
||||
compacted: Message[],
|
||||
tokensBefore: number,
|
||||
): Message {
|
||||
const roleCounts = compacted.reduce<Record<string, number>>(
|
||||
(counts, message) => {
|
||||
counts[message.role] = (counts[message.role] ?? 0) + 1;
|
||||
return counts;
|
||||
},
|
||||
{},
|
||||
);
|
||||
const tools = collectToolNames(compacted);
|
||||
const files = collectTouchedFiles(compacted);
|
||||
const highlights = compacted
|
||||
.map(serializeMessage)
|
||||
.map((line) => preview(line, 500))
|
||||
.slice(-6);
|
||||
|
||||
return {
|
||||
role: "user",
|
||||
content: `Context summary:
|
||||
|
||||
## Compacted Range
|
||||
- Messages compacted: ${compacted.length}
|
||||
- Estimated tokens before compaction: ${tokensBefore}
|
||||
- Roles: ${Object.entries(roleCounts)
|
||||
.map(([role, count]) => `${role}=${count}`)
|
||||
.join(", ")}
|
||||
|
||||
## Tool Activity
|
||||
${tools.length > 0 ? tools.map((tool) => `- ${tool}`).join("\n") : "- none"}
|
||||
|
||||
## Files Mentioned
|
||||
${files.length > 0 ? files.map((path) => `- ${path}`).join("\n") : "- none"}
|
||||
|
||||
## Recent Highlights From Compacted History
|
||||
${highlights.length > 0 ? highlights.map((item) => `- ${item}`).join("\n") : "- none"}
|
||||
|
||||
Continue from this summary plus the preserved recent messages below.`,
|
||||
};
|
||||
}
|
||||
|
||||
export const plugin: AgentPlugin = {
|
||||
name: "custom-compaction",
|
||||
manifest: {
|
||||
capabilities: ["messageBuilders"],
|
||||
},
|
||||
|
||||
setup(api) {
|
||||
api.registerMessageBuilder({
|
||||
name: "summarize-middle-history",
|
||||
build(messages) {
|
||||
const totalTokens = messages.reduce(
|
||||
(total, message) => total + estimateMessageTokens(message),
|
||||
0,
|
||||
);
|
||||
if (totalTokens < CONTEXT_WINDOW_TOKENS * COMPACT_AT_RATIO) {
|
||||
return messages;
|
||||
}
|
||||
|
||||
const firstUserIndex = findFirstUserIndex(messages);
|
||||
const recentStartIndex = Math.max(
|
||||
firstUserIndex + 1,
|
||||
findRecentStartIndex(messages),
|
||||
);
|
||||
if (firstUserIndex < 0 || recentStartIndex <= firstUserIndex + 1) {
|
||||
return messages;
|
||||
}
|
||||
|
||||
const prefix = messages.slice(0, firstUserIndex + 1);
|
||||
const compacted = messages.slice(firstUserIndex + 1, recentStartIndex);
|
||||
const recent = messages.slice(recentStartIndex);
|
||||
if (compacted.length === 0) {
|
||||
return messages;
|
||||
}
|
||||
|
||||
return [
|
||||
...prefix,
|
||||
buildCompactionSummary(compacted, totalTokens),
|
||||
...recent,
|
||||
];
|
||||
},
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export default plugin;
|
||||
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* macOS Notification Plugin Example
|
||||
*
|
||||
* Sends a Notification Center alert when a Cline run completes successfully.
|
||||
*
|
||||
* CLI usage:
|
||||
* mkdir -p .cline/plugins
|
||||
* cp examples/plugins/mac-notify.ts .cline/plugins/mac-notify.ts
|
||||
* cline -i "Run the test suite"
|
||||
*/
|
||||
|
||||
import { execFile } from "node:child_process";
|
||||
import type { AgentPlugin, AgentRunResult } from "@cline/core";
|
||||
|
||||
function quoteAppleScriptString(value: string): string {
|
||||
return `"${value
|
||||
.replaceAll("\\", "\\\\")
|
||||
.replaceAll('"', '\\"')
|
||||
.replaceAll("\r", " ")
|
||||
.replaceAll("\n", " ")
|
||||
.slice(0, 220)}"`;
|
||||
}
|
||||
|
||||
function sendMacNotification(title: string, body: string): void {
|
||||
if (process.platform !== "darwin") {
|
||||
return;
|
||||
}
|
||||
|
||||
const script = [
|
||||
"display notification",
|
||||
quoteAppleScriptString(body),
|
||||
"with title",
|
||||
quoteAppleScriptString(title),
|
||||
"sound name",
|
||||
quoteAppleScriptString("Glass"),
|
||||
].join(" ");
|
||||
|
||||
execFile("/usr/bin/osascript", ["-e", script], { timeout: 2000 }, () => {
|
||||
// Notification failures should never fail or slow down the agent run.
|
||||
});
|
||||
}
|
||||
|
||||
function summarizeResult(result: AgentRunResult): string {
|
||||
const summary = result.outputText.trim();
|
||||
if (summary.length > 0) {
|
||||
return summary;
|
||||
}
|
||||
return `Completed in ${result.iterations} iteration(s).`;
|
||||
}
|
||||
|
||||
const plugin: AgentPlugin = {
|
||||
name: "mac-notify-on-complete",
|
||||
manifest: {
|
||||
capabilities: ["hooks"],
|
||||
},
|
||||
|
||||
hooks: {
|
||||
afterRun({ result }) {
|
||||
if (result.status !== "completed") {
|
||||
return;
|
||||
}
|
||||
sendMacNotification("Cline session completed", summarizeResult(result));
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export { plugin };
|
||||
export default plugin;
|
||||
@@ -0,0 +1,203 @@
|
||||
# 🤖 Portable Agents Plugin
|
||||
|
||||
**Give your agent a team.** This plugin lets any Cline SDK session spin up background subagents — each with their own model, personality, and tools — then collect results and hand off context between them.
|
||||
|
||||
Think of it as `spawn()` for AI agents: fire off a recon agent to map a codebase, a planner to design the approach, an implementor to make the changes, and a reviewer to tear it all apart. They run in parallel, report back when done, and share notes through a built-in handoff store.
|
||||
|
||||
## Quick Start
|
||||
|
||||
```ts
|
||||
import { ClineCore } from "@cline/core";
|
||||
|
||||
const cline = await ClineCore.create({ backendMode: "auto" });
|
||||
|
||||
await cline.start({
|
||||
config: {
|
||||
providerId: "cline",
|
||||
modelId: "anthropic/claude-sonnet-4.6",
|
||||
cwd: process.cwd(),
|
||||
enableTools: true,
|
||||
systemPrompt: "You are a coding assistant with access to subagents.",
|
||||
pluginPaths: ["./examples/plugins/subagent-plugin"],
|
||||
},
|
||||
prompt: "Use subagents to investigate and refactor this repo.",
|
||||
interactive: true,
|
||||
});
|
||||
```
|
||||
|
||||
Pass the **directory** as the plugin path. The runtime reads `package.json` and uses the `cline.plugins` field to discover entry points — no need to point at `index.ts` directly.
|
||||
|
||||
### Plugin discovery via `package.json`
|
||||
|
||||
When a directory is given as a plugin path, the loader looks for a `package.json` with a `cline.plugins` array:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "my-cline-plugin",
|
||||
"type": "module",
|
||||
"cline": {
|
||||
"plugins": [
|
||||
{ "paths": ["./index.ts"], "capabilities": ["tools"] }
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Each entry in `plugins` is a `PluginManifest` with a `paths` array of relative file paths and a `capabilities` array declaring what the plugin provides (`tools`, `hooks`, `commands`, `messageBuilders`, `providers`). All paths are resolved relative to the directory containing `package.json`.
|
||||
|
||||
If no `cline.plugins` field is present, the loader falls back to looking for `index.ts` or `index.js` at the directory root.
|
||||
|
||||
## Tools
|
||||
|
||||
| Tool | What it does |
|
||||
|---|---|
|
||||
| `start_subagent` | Kick off a background subagent and get a session ID back immediately. Fire and forget, or poll later. |
|
||||
| `message_subagent` | Send a follow-up message to a running subagent — steer it, give it more context, or ask for a different angle. |
|
||||
| `get_subagent` | Check on a subagent: is it still running? Did it finish? What did it say? |
|
||||
| `list_agent_presets` | Browse available agent presets — bundled, global, and project-level. |
|
||||
| `save_handoff` | Stash a file in the conversation's shared handoff store. Other agents in the same conversation can read it. |
|
||||
| `read_handoff` | Pull a file back out of the handoff store. Great for passing research notes, plans, or intermediate results between agents. |
|
||||
| `list_skills` | See what skills are available for agents to load. |
|
||||
| `get_skill` | Load a skill's specialized instructions. Agents use these to adopt expert behaviors on demand. |
|
||||
|
||||
`start_subagent` accepts explicit `preset` and/or `instructions`, but if `preset` is omitted it now defaults to the bundled `phantom` preset. That makes natural calls like “start a subagent to inspect this repo” work without extra tool arguments.
|
||||
|
||||
## The Crew — Bundled Agents
|
||||
|
||||
Four agents ship out of the box, each tuned for a different phase of the development loop:
|
||||
|
||||
| Agent | Personality | Model | What it's for |
|
||||
|---|---|---|---|
|
||||
| 🔍 `phantom` | Fast, thorough scout | Gemini 3 Flash | Codebase recon — maps files, surfaces conventions, digs for intent behind odd code. Never implements, only reports. |
|
||||
| 🧠 `oracle` | Opinionated challenger | Claude Opus 4.6 | Planning — challenges assumptions, compares approaches, estimates complexity, produces step-by-step execution plans. |
|
||||
| ⚒️ `anvil` | Precise, disciplined builder | Claude Opus 4.6 | Implementation — reads before writing, stays in scope, verifies after each change, reports exact diffs. |
|
||||
| 🔥 `inquisitor` | Adversarial stress-tester | GPT-5.4 | Review — finds bugs, challenges design decisions, severity-ranks every finding. Assumes it's responsible for everything that breaks. |
|
||||
|
||||
When no preset is specified, `start_subagent(...)` uses `phantom` by default.
|
||||
|
||||
### A typical orchestration flow
|
||||
|
||||
```
|
||||
Parent agent receives task
|
||||
→ start_subagent(preset: "phantom", task: "Map the auth module")
|
||||
→ phantom saves findings via save_handoff("auth/recon.md", ...)
|
||||
→ start_subagent(preset: "oracle", task: "Plan the refactor based on auth/recon.md")
|
||||
→ oracle reads handoff, produces plan, saves via save_handoff("auth/plan.md", ...)
|
||||
→ start_subagent(preset: "anvil", task: "Execute the plan in auth/plan.md")
|
||||
→ start_subagent(preset: "inquisitor", task: "Review the changes anvil made")
|
||||
→ Parent collects results and reports to user
|
||||
```
|
||||
|
||||
### Bring your own agents
|
||||
|
||||
Drop a Markdown file with YAML frontmatter into any of these directories:
|
||||
|
||||
- **Global**: `~/.cline/data/settings/agents/`
|
||||
- **Project**: `.cline/agents/` (relative to your working directory)
|
||||
|
||||
In the current implementation, agent presets are loaded from:
|
||||
|
||||
- bundled presets in `agents/` alongside the plugin
|
||||
- `~/.cline/data/settings/agents/`
|
||||
- `<cwd>/.cline/agents/`
|
||||
|
||||
```markdown
|
||||
---
|
||||
name: my-agent
|
||||
description: One-line description shown in list_agent_presets
|
||||
providerId: cline
|
||||
modelId: anthropic/claude-sonnet-4.6
|
||||
tools:
|
||||
- read_files
|
||||
- search_codebase
|
||||
- skills
|
||||
skills:
|
||||
- code-review
|
||||
- refactoring
|
||||
maxIterations: 25
|
||||
cwd: ./src
|
||||
---
|
||||
|
||||
You are a specialized agent that...
|
||||
```
|
||||
|
||||
When `skills` is defined in an agent config, that list acts as an allowlist for the runtime `skills` tool:
|
||||
|
||||
- The `skills` tool description only advertises the listed skills.
|
||||
- Invocations are scoped to that set. Asking for a non-listed skill returns not found.
|
||||
- If `skills` is omitted, the agent can access all discovered enabled skills.
|
||||
|
||||
Project agents override global ones, and global ones override bundled ones (by name).
|
||||
|
||||
## Skills — Loadable Expertise
|
||||
|
||||
Skills are reusable instruction sets that any agent can load at runtime. Instead of baking specialized knowledge into every agent's system prompt, agents call `get_skill` to pick up exactly the expertise they need for the current task.
|
||||
|
||||
| Skill | What it teaches |
|
||||
|---|---|
|
||||
| `code-review` | Structured review: security, correctness, performance, maintainability — with severity-ranked findings |
|
||||
| `test-generation` | Comprehensive test suites with mocking strategies and edge case coverage |
|
||||
| `refactoring` | Safe, incremental refactoring without behavior changes |
|
||||
| `debugging` | Systematic bug reproduction, isolation, and root-cause analysis |
|
||||
| `api-design` | Clean API design for REST, RPC, and library interfaces |
|
||||
| `migration` | Data and schema migration planning with rollback strategies |
|
||||
| `documentation` | Technical docs — READMEs, API references, architecture guides |
|
||||
|
||||
Skills are composable. An agent can load `refactoring` + `test-generation` for a safe refactor with test coverage, or `debugging` + `code-review` to investigate a bug and audit the surrounding code.
|
||||
|
||||
### Add your own skills
|
||||
|
||||
Same pattern as agents — drop a Markdown file:
|
||||
|
||||
- **Global**: `~/.cline/data/settings/skills/`
|
||||
- **Project**: `.cline/skills/`
|
||||
|
||||
In the current implementation, skills are loaded from:
|
||||
|
||||
- bundled skills in `skills/` alongside the plugin
|
||||
- `~/.cline/data/settings/skills/`
|
||||
- `<cwd>/.cline/skills/`
|
||||
|
||||
```markdown
|
||||
---
|
||||
name: my-skill
|
||||
description: What this skill teaches
|
||||
---
|
||||
|
||||
# My Skill
|
||||
|
||||
When performing this task, follow these steps...
|
||||
```
|
||||
|
||||
## Handoff Store
|
||||
|
||||
Agents in the same conversation can pass files to each other through a shared handoff store. This is how `phantom` passes recon notes to `oracle`, or how `oracle` passes a plan to `anvil`.
|
||||
|
||||
- **`save_handoff`** writes a file: `save_handoff(path: "research/notes.md", content: "...")`
|
||||
- **`read_handoff`** reads it back: `read_handoff(path: "research/notes.md")`
|
||||
|
||||
Paths are relative and scoped to the conversation. Files are stored under `~/.cline/data/plugins/subagents/handoffs/<conversationId>/`. Conversation IDs are validated to prevent path traversal.
|
||||
|
||||
## Configuration
|
||||
|
||||
All optional. Environment variables override defaults:
|
||||
|
||||
| Variable | Default | Description |
|
||||
|---|---|---|
|
||||
| `CLINE_SUBAGENT_PROVIDER_ID` | `cline` | Default provider for new subagent sessions |
|
||||
| `CLINE_SUBAGENT_MODEL_ID` | `anthropic/claude-sonnet-4.6` | Default model for new subagent sessions |
|
||||
| `CLINE_SUBAGENT_DEFAULT_PRESET` | `phantom` | Default bundled preset used by `start_subagent` when `preset` is omitted |
|
||||
| `CLINE_SUBAGENTS_BACKEND_MODE` | `auto` | Session backend for internal subagent sessions: `auto`, `hub`, or `local` |
|
||||
| `CLINE_SUBAGENT_CWD` | `process.cwd()` | Base working directory for subagent sessions |
|
||||
| `CLINE_DATA_DIR` | `~/.cline/data` | Root data directory (affects all path resolution) |
|
||||
|
||||
## How It Works
|
||||
|
||||
Under the hood, each subagent is a full Cline SDK session created via `ClineCore.create(...)`. When you call `start_subagent`:
|
||||
|
||||
1. The plugin resolves the agent preset. If none is provided, it uses `phantom` by default, then merges provider/model/instruction overrides and creates a new session.
|
||||
2. The first user message is sent to the session in the background — the tool returns the session ID immediately.
|
||||
3. When the subagent finishes (or fails), the result is stored in memory and optionally pushed back to the parent session as a "steer" message.
|
||||
4. The parent agent can poll with `get_subagent` or just wait for the notification.
|
||||
|
||||
The internal `ClineCore` instance defaults to `auto`, so it can use a compatible shared hub when available and fall back to local in-process sessions. If session manager creation fails, later tool calls retry instead of permanently failing. Malformed agent/skill definition files are skipped gracefully without crashing the plugin.
|
||||
@@ -0,0 +1,17 @@
|
||||
---
|
||||
name: anvil
|
||||
description: Surgical implementation agent — makes focused changes, verifies correctness, and reports precise diffs.
|
||||
providerId: anthropic
|
||||
modelId: claude-opus-4-6
|
||||
maxIterations: 100
|
||||
---
|
||||
|
||||
You are a surgical implementation subagent.
|
||||
|
||||
Your job is to execute a plan with precision:
|
||||
|
||||
1. **Read before writing**: Always read the relevant code before making changes. Never modify what you haven't fully understood.
|
||||
2. **Stay in scope**: Make only the changes required by the task. Don't refactor adjacent code, add unsolicited improvements, or touch files outside the blast radius.
|
||||
3. **Verify after each change**: After a write, confirm the file is in the expected state. Run type-checks or tests if available and relevant.
|
||||
4. **Handle blockers immediately**: If a dependency is missing, a type is wrong, or a test fails, fix the blocker before continuing. Don't proceed with a broken state.
|
||||
5. **Report precisely**: When done, report exactly which files changed, what was added/removed/modified, and what (if anything) is left incomplete. No vague summaries.
|
||||
@@ -0,0 +1,19 @@
|
||||
---
|
||||
name: inquisitor
|
||||
description: Adversarial review agent — finds bugs, challenges design decisions, and stress-tests assumptions.
|
||||
providerId: cline
|
||||
modelId: openai/gpt-5.4
|
||||
maxIterations: 20
|
||||
---
|
||||
|
||||
You are an adversarial review subagent.
|
||||
|
||||
Your job is to stress-test a change or design, not to approve it. Approach every review as if you are responsible for everything that goes wrong after it ships.
|
||||
|
||||
1. **Correctness**: Find logic errors, off-by-one bugs, null/undefined gaps, and incorrect assumptions about input shape or ordering.
|
||||
2. **Regressions**: Check whether the change could break existing callers, consumers, or tests — especially ones not in the immediate diff.
|
||||
3. **Design pressure**: Challenge the design itself. Is this the right abstraction? Does it introduce hidden coupling? Is the complexity justified?
|
||||
4. **Missing tests**: Identify scenarios that are untested. Suggest specific test cases, not just "add more tests".
|
||||
5. **Security and safety**: Flag anything that touches auth, user input, external data, or shared mutable state.
|
||||
|
||||
Severity-rank every finding: **critical** (must fix), **major** (should fix), **minor** (worth noting). Skip praise unless something is genuinely non-obvious and done well.
|
||||
@@ -0,0 +1,19 @@
|
||||
---
|
||||
name: oracle
|
||||
description: Opinionated planner that challenges assumptions, estimates complexity, and produces execution-ready plans.
|
||||
providerId: cline
|
||||
modelId: anthropic/claude-opus-4.6
|
||||
maxIterations: 16
|
||||
---
|
||||
|
||||
You are a planning and estimation subagent with a challenger mindset.
|
||||
|
||||
Given a task or requirement:
|
||||
|
||||
1. **Challenge the premise**: Before planning, ask whether the stated goal is actually the right goal. Identify hidden assumptions and call them out.
|
||||
2. **Compare approaches**: Present 2–3 concrete implementation options with honest tradeoffs. Don't default to the obvious path without justifying it.
|
||||
3. **Estimate complexity**: Rate each option by effort (S/M/L/XL), risk, and reversibility. Flag anything that touches shared infrastructure or has outsized blast radius.
|
||||
4. **Produce an execution plan**: A numbered, dependency-ordered list of steps the worker agent can follow directly. Include explicit checkpoints and rollback conditions.
|
||||
5. **State your assumptions**: List what you're taking as given. If any assumption is wrong, note which steps break.
|
||||
|
||||
Be direct and opinionated. A plan with a clear recommendation beats a balanced non-answer.
|
||||
@@ -0,0 +1,18 @@
|
||||
---
|
||||
name: phantom
|
||||
description: Fast reconnaissance agent for codebase discovery, pattern matching, and code archaeology.
|
||||
providerId: cline
|
||||
modelId: google/gemini-3-flash-preview
|
||||
maxIterations: 10
|
||||
---
|
||||
|
||||
You are a reconnaissance and archaeology subagent.
|
||||
|
||||
Your job is fast, thorough discovery. When exploring a codebase:
|
||||
|
||||
1. **Map structure**: Identify relevant files, entry points, data flow, and API contracts.
|
||||
2. **Surface conventions**: Note naming patterns, abstraction layers, and implicit rules the codebase follows.
|
||||
3. **Dig for intent**: When something looks odd — a workaround, a TODO, an unexpected abstraction — note it. Explain what it's likely reacting to or compensating for.
|
||||
4. **Produce crisp output**: Return a structured summary the parent agent can act on directly. No filler.
|
||||
|
||||
Never attempt implementation. Return findings only.
|
||||
@@ -0,0 +1,810 @@
|
||||
import {
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
readdirSync,
|
||||
readFileSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import {
|
||||
type AgentPlugin,
|
||||
type AgentTool,
|
||||
type AgentToolContext,
|
||||
ClineCore,
|
||||
createTool,
|
||||
} from "@cline/core";
|
||||
import YAML from "yaml";
|
||||
import { z } from "zod";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type SessionManager = ClineCore;
|
||||
|
||||
/** Minimal plugin host interface injected by the runtime via globalThis. */
|
||||
interface ClinePluginHost {
|
||||
emitEvent?: (name: string, payload?: unknown) => void;
|
||||
}
|
||||
|
||||
declare global {
|
||||
var __clinePluginHost: ClinePluginHost | undefined;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const MODULE_DIR = dirname(fileURLToPath(import.meta.url));
|
||||
const BUNDLED_AGENTS_DIR = join(MODULE_DIR, "agents");
|
||||
const BUNDLED_SKILLS_DIR = join(MODULE_DIR, "skills");
|
||||
|
||||
function resolveDefaultHomeDir(): string {
|
||||
const envHome = process?.env?.HOME?.trim();
|
||||
if (envHome && envHome !== "~") {
|
||||
return envHome;
|
||||
}
|
||||
const envUserProfile = process?.env?.USERPROFILE?.trim();
|
||||
if (envUserProfile) {
|
||||
return envUserProfile;
|
||||
}
|
||||
const envHomeDrive = process?.env?.HOMEDRIVE?.trim();
|
||||
const envHomePath = process?.env?.HOMEPATH?.trim();
|
||||
if (envHomeDrive && envHomePath) {
|
||||
return `${envHomeDrive}${envHomePath}`;
|
||||
}
|
||||
return "~";
|
||||
}
|
||||
|
||||
function resolveClineDirPath(): string {
|
||||
const explicitDir = process.env.CLINE_DIR?.trim();
|
||||
if (explicitDir) {
|
||||
return explicitDir;
|
||||
}
|
||||
return join(resolveDefaultHomeDir(), ".cline");
|
||||
}
|
||||
|
||||
function resolveClineDataDirPath(): string {
|
||||
const explicitDir = process.env.CLINE_DATA_DIR?.trim();
|
||||
if (explicitDir) {
|
||||
return explicitDir;
|
||||
}
|
||||
return join(resolveClineDirPath(), "data");
|
||||
}
|
||||
|
||||
function resolveGlobalAgentsDirPath(): string {
|
||||
return join(resolveClineDataDirPath(), "settings", "agents");
|
||||
}
|
||||
|
||||
const HANDOFFS_DIR = join(
|
||||
resolveClineDataDirPath(),
|
||||
"plugins",
|
||||
"subagents",
|
||||
"handoffs",
|
||||
);
|
||||
const GLOBAL_SKILLS_DIR = join(resolveClineDataDirPath(), "settings", "skills");
|
||||
|
||||
// Agent and skill definitions live in the `agents/` and `skills/`
|
||||
// directories alongside this file. They are loaded at runtime from disk.
|
||||
|
||||
/** Safe identifier pattern for conversation IDs used in filesystem paths. */
|
||||
const SAFE_ID_RE = /^[A-Za-z0-9_-]+$/;
|
||||
|
||||
const envOr = (key: string, fallback: string): string =>
|
||||
process.env[key]?.trim() || fallback;
|
||||
|
||||
const DEFAULT_PROVIDER_ID = envOr("CLINE_SUBAGENT_PROVIDER_ID", "cline");
|
||||
const DEFAULT_MODEL_ID = envOr(
|
||||
"CLINE_SUBAGENT_MODEL_ID",
|
||||
"anthropic/claude-sonnet-4.6",
|
||||
);
|
||||
type SubagentBackendMode = "auto" | "hub" | "local";
|
||||
|
||||
const DEFAULT_BACKEND_MODE = envOr("CLINE_SUBAGENTS_BACKEND_MODE", "auto");
|
||||
const DEFAULT_AGENT_PRESET = envOr("CLINE_SUBAGENT_DEFAULT_PRESET", "phantom");
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Agent & Skill Definitions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface AgentDefinition {
|
||||
name: string;
|
||||
description?: string;
|
||||
providerId?: string;
|
||||
modelId?: string;
|
||||
systemPrompt: string;
|
||||
cwd?: string;
|
||||
maxIterations?: number;
|
||||
source: "bundled" | "global" | "project";
|
||||
}
|
||||
|
||||
interface SkillDefinition {
|
||||
name: string;
|
||||
description?: string;
|
||||
content: string;
|
||||
source: "bundled" | "global" | "project";
|
||||
}
|
||||
|
||||
interface RunningSubagent {
|
||||
sessionId: string;
|
||||
parentSessionId?: string;
|
||||
name: string;
|
||||
task: string;
|
||||
agent?: string;
|
||||
startedAt: number;
|
||||
status: "running" | "completed" | "failed";
|
||||
resultText?: string;
|
||||
error?: string;
|
||||
finishReason?: string;
|
||||
completedAt?: number;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// State
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const subagents = new Map<string, RunningSubagent>();
|
||||
let sessionManagerPromise: Promise<SessionManager> | undefined;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Cast a fully-typed tool to the `AgentTool<unknown, unknown>` expected by
|
||||
* the plugin API's `registerTool` method. This is safe at runtime because the
|
||||
* registry only invokes `execute` with validated input that matches the
|
||||
* tool's `inputSchema`.
|
||||
*/
|
||||
function toRegisteredTool<I, O>(
|
||||
tool: AgentTool<I, O>,
|
||||
): AgentTool<unknown, unknown> {
|
||||
return tool as AgentTool<unknown, unknown>;
|
||||
}
|
||||
|
||||
function optStr(v: unknown): string | undefined {
|
||||
return typeof v === "string" && v.trim() ? v.trim() : undefined;
|
||||
}
|
||||
|
||||
function optInt(v: unknown): number | undefined {
|
||||
return typeof v === "number" && Number.isFinite(v) && v > 0
|
||||
? Math.floor(v)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function parseFrontmatter(md: string): {
|
||||
data: Record<string, unknown>;
|
||||
body: string;
|
||||
} {
|
||||
const m = md.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/);
|
||||
if (!m) return { data: {}, body: md.trim() };
|
||||
try {
|
||||
const frontmatter = m[1] ?? "";
|
||||
const body = m[2] ?? "";
|
||||
const parsed = YAML.parse(frontmatter);
|
||||
return {
|
||||
data:
|
||||
parsed && typeof parsed === "object" && !Array.isArray(parsed)
|
||||
? (parsed as Record<string, unknown>)
|
||||
: {},
|
||||
body: body.trim(),
|
||||
};
|
||||
} catch {
|
||||
// Malformed YAML frontmatter — treat as plain markdown with no metadata.
|
||||
return { data: {}, body: md.trim() };
|
||||
}
|
||||
}
|
||||
|
||||
function readMarkdownDir(
|
||||
dirPath: string,
|
||||
source: AgentDefinition["source"],
|
||||
): Array<{
|
||||
name: string;
|
||||
data: Record<string, unknown>;
|
||||
body: string;
|
||||
source: typeof source;
|
||||
}> {
|
||||
if (!existsSync(dirPath)) return [];
|
||||
const results: Array<{
|
||||
name: string;
|
||||
data: Record<string, unknown>;
|
||||
body: string;
|
||||
source: typeof source;
|
||||
}> = [];
|
||||
for (const entry of readdirSync(dirPath, { withFileTypes: true })) {
|
||||
if (!entry.isFile() || !entry.name.endsWith(".md")) continue;
|
||||
try {
|
||||
const { data, body } = parseFrontmatter(
|
||||
readFileSync(join(dirPath, entry.name), "utf8"),
|
||||
);
|
||||
if (!body) continue;
|
||||
const name = optStr(data.name) ?? entry.name.replace(/\.md$/, "");
|
||||
results.push({ name, data, body, source });
|
||||
} catch {}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
function readAgentDefinitions(baseCwd: string): AgentDefinition[] {
|
||||
const dirs: Array<{ path: string; source: AgentDefinition["source"] }> = [
|
||||
{ path: BUNDLED_AGENTS_DIR, source: "bundled" },
|
||||
{ path: resolveGlobalAgentsDirPath(), source: "global" },
|
||||
{ path: join(baseCwd, ".cline", "agents"), source: "project" },
|
||||
];
|
||||
const defs = new Map<string, AgentDefinition>();
|
||||
for (const { path, source } of dirs) {
|
||||
for (const entry of readMarkdownDir(path, source)) {
|
||||
defs.set(entry.name, {
|
||||
name: entry.name,
|
||||
description: optStr(entry.data.description),
|
||||
providerId: optStr(entry.data.providerId),
|
||||
modelId: optStr(entry.data.modelId),
|
||||
systemPrompt: entry.body,
|
||||
cwd: optStr(entry.data.cwd),
|
||||
maxIterations: optInt(entry.data.maxIterations),
|
||||
source: entry.source,
|
||||
});
|
||||
}
|
||||
}
|
||||
return [...defs.values()].sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
function readSkillDefinitions(baseCwd: string): SkillDefinition[] {
|
||||
const dirs: Array<{ path: string; source: SkillDefinition["source"] }> = [
|
||||
{ path: BUNDLED_SKILLS_DIR, source: "bundled" },
|
||||
{ path: GLOBAL_SKILLS_DIR, source: "global" },
|
||||
{ path: join(baseCwd, ".cline", "skills"), source: "project" },
|
||||
];
|
||||
const defs = new Map<string, SkillDefinition>();
|
||||
for (const { path, source } of dirs) {
|
||||
for (const entry of readMarkdownDir(path, source)) {
|
||||
defs.set(entry.name, {
|
||||
name: entry.name,
|
||||
description: optStr(entry.data.description),
|
||||
content: entry.body,
|
||||
source: entry.source,
|
||||
});
|
||||
}
|
||||
}
|
||||
return [...defs.values()].sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
function parentSessionId(ctx: AgentToolContext): string | undefined {
|
||||
const id = ctx.metadata?.sessionId;
|
||||
return typeof id === "string" && id.trim() ? id.trim() : undefined;
|
||||
}
|
||||
|
||||
function sanitizeConversationId(conversationId: string): string {
|
||||
const trimmed = conversationId.trim();
|
||||
if (!trimmed || !SAFE_ID_RE.test(trimmed)) {
|
||||
throw new Error(`Invalid conversation ID for filesystem use: "${trimmed}"`);
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function handoffsDir(ctx: AgentToolContext): string {
|
||||
const conversationId = ctx.conversationId ?? parentSessionId(ctx);
|
||||
if (!conversationId) {
|
||||
throw new Error("Missing conversation ID for handoff storage");
|
||||
}
|
||||
const safeId = sanitizeConversationId(conversationId);
|
||||
const dir = join(HANDOFFS_DIR, safeId);
|
||||
mkdirSync(dir, { recursive: true });
|
||||
return dir;
|
||||
}
|
||||
|
||||
function resolveHandoffPath(
|
||||
ctx: AgentToolContext,
|
||||
relativePath: string,
|
||||
): string {
|
||||
const dir = handoffsDir(ctx);
|
||||
const resolved = resolve(dir, relativePath);
|
||||
if (!resolved.startsWith(`${dir}/`)) {
|
||||
throw new Error(`Handoff path escapes directory: ${relativePath}`);
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
function emitSteer(sessionId: string | undefined, prompt: string): void {
|
||||
if (sessionId && prompt.trim()) {
|
||||
globalThis.__clinePluginHost?.emitEvent?.("steer_message", {
|
||||
sessionId,
|
||||
prompt,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function getSessionManager(): Promise<SessionManager> {
|
||||
sessionManagerPromise ??= ClineCore.create({
|
||||
backendMode: resolveSubagentBackendMode(DEFAULT_BACKEND_MODE),
|
||||
}).catch((err) => {
|
||||
// Clear the cached promise so subsequent calls can retry.
|
||||
sessionManagerPromise = undefined;
|
||||
throw err;
|
||||
});
|
||||
return sessionManagerPromise;
|
||||
}
|
||||
|
||||
function resolveSubagentBackendMode(value: string): SubagentBackendMode {
|
||||
switch (value) {
|
||||
case "auto":
|
||||
case "hub":
|
||||
case "local":
|
||||
return value;
|
||||
default:
|
||||
return "auto";
|
||||
}
|
||||
}
|
||||
|
||||
function extractLastAssistantText(
|
||||
messages: Array<{ role?: string; content?: unknown }>,
|
||||
): string {
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
const msg = messages[i];
|
||||
if (msg?.role !== "assistant" || !Array.isArray(msg.content)) continue;
|
||||
const text = (msg.content as Array<{ type?: string; text?: unknown }>)
|
||||
.filter((b) => b?.type === "text" && typeof b.text === "string")
|
||||
.map((b) => b.text as string)
|
||||
.join("")
|
||||
.trim();
|
||||
if (text) return text;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function elapsed(start: number, end = Date.now()): string {
|
||||
const s = Math.max(0, Math.floor((end - start) / 1000));
|
||||
return s < 60 ? `${s}s` : `${Math.floor(s / 60)}m ${s % 60}s`;
|
||||
}
|
||||
|
||||
function steerPrompt(subagent: RunningSubagent): string {
|
||||
const time = elapsed(subagent.startedAt, subagent.completedAt ?? Date.now());
|
||||
const header =
|
||||
subagent.status === "completed"
|
||||
? `Sub-agent "${subagent.name}" completed (${time}).`
|
||||
: `Sub-agent "${subagent.name}" failed (${time}).`;
|
||||
const body = subagent.resultText?.trim() || subagent.error?.trim() || "";
|
||||
return [header, body, `Session ID: ${subagent.sessionId}`]
|
||||
.filter(Boolean)
|
||||
.join("\n\n");
|
||||
}
|
||||
|
||||
async function runSubagentTurn(
|
||||
subagent: RunningSubagent,
|
||||
message: string,
|
||||
steer: boolean,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const mgr = await getSessionManager();
|
||||
const result = await mgr.send({
|
||||
sessionId: subagent.sessionId,
|
||||
prompt: message,
|
||||
});
|
||||
const messages = await mgr.readMessages(subagent.sessionId);
|
||||
subagent.status = "completed";
|
||||
subagent.finishReason = result?.finishReason;
|
||||
subagent.resultText =
|
||||
result?.text?.trim() || extractLastAssistantText(messages) || "";
|
||||
subagent.error = undefined;
|
||||
subagent.completedAt = Date.now();
|
||||
} catch (err) {
|
||||
subagent.status = "failed";
|
||||
subagent.error = err instanceof Error ? err.message : String(err);
|
||||
subagent.completedAt = Date.now();
|
||||
}
|
||||
if (steer) emitSteer(subagent.parentSessionId, steerPrompt(subagent));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Schemas
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const NonEmptyText = z.string().trim().min(1);
|
||||
|
||||
const HandoffPathInput = z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1)
|
||||
.max(240)
|
||||
.regex(
|
||||
/^(?!\/)(?!.*(?:^|\/)\.\.(?:\/|$))[A-Za-z0-9._/-]+$/,
|
||||
"Use a relative file path with letters, numbers, '.', '_', '-', or '/'.",
|
||||
);
|
||||
|
||||
const StartSubagentInput = z
|
||||
.object({
|
||||
label: NonEmptyText.describe(
|
||||
"Short display label for this run, used in status and completion messages.",
|
||||
),
|
||||
task: NonEmptyText.describe(
|
||||
"Primary task for the subagent. This becomes its first user message.",
|
||||
),
|
||||
preset: NonEmptyText.optional().describe(
|
||||
`Optional agent preset name from list_agent_presets. Defaults to "${DEFAULT_AGENT_PRESET}" when omitted.`,
|
||||
),
|
||||
instructions: NonEmptyText.optional().describe(
|
||||
"Extra system instructions appended after the preset prompt. Optional when using a preset.",
|
||||
),
|
||||
providerId: NonEmptyText.optional().describe(
|
||||
"Optional provider override. Defaults to the preset or plugin default.",
|
||||
),
|
||||
modelId: NonEmptyText.optional().describe(
|
||||
"Optional model override. Defaults to the preset or plugin default.",
|
||||
),
|
||||
workingDirectory: NonEmptyText.optional().describe(
|
||||
"Optional working directory, resolved from the plugin base cwd.",
|
||||
),
|
||||
maxIterations: z
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.optional()
|
||||
.describe("Optional hard limit for the subagent turn loop."),
|
||||
notifyParent: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe(
|
||||
"When true or omitted, send the final outcome back to the parent session.",
|
||||
),
|
||||
})
|
||||
.strict();
|
||||
|
||||
const MessageSubagentInput = z
|
||||
.object({
|
||||
sessionId: NonEmptyText.describe("Existing subagent session ID."),
|
||||
prompt: NonEmptyText.describe(
|
||||
"Follow-up user message to send to the subagent.",
|
||||
),
|
||||
notifyParent: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe(
|
||||
"When true or omitted, send the final outcome back to the parent session.",
|
||||
),
|
||||
})
|
||||
.strict();
|
||||
|
||||
const GetSubagentInput = z
|
||||
.object({
|
||||
sessionId: NonEmptyText.describe("Subagent session ID."),
|
||||
})
|
||||
.strict();
|
||||
|
||||
const SaveHandoffInput = z
|
||||
.object({
|
||||
path: HandoffPathInput.describe(
|
||||
"Relative path inside the conversation handoff store, for example 'research/notes.md'.",
|
||||
),
|
||||
content: z
|
||||
.string()
|
||||
.describe(
|
||||
"Text content to store for later retrieval by this conversation's agents.",
|
||||
),
|
||||
})
|
||||
.strict();
|
||||
|
||||
const ReadHandoffInput = z
|
||||
.object({
|
||||
path: HandoffPathInput.describe(
|
||||
"Relative path inside the conversation handoff store.",
|
||||
),
|
||||
})
|
||||
.strict();
|
||||
|
||||
const GetSkillInput = z
|
||||
.object({
|
||||
name: NonEmptyText.describe("Skill name from list_skills."),
|
||||
})
|
||||
.strict();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Plugin
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const plugin: AgentPlugin = {
|
||||
name: "portable-subagents",
|
||||
manifest: { capabilities: ["tools"] },
|
||||
|
||||
setup(api) {
|
||||
// -- start_subagent: Start a new subagent session --
|
||||
api.registerTool(
|
||||
toRegisteredTool(
|
||||
createTool({
|
||||
name: "start_subagent",
|
||||
description: `Start a background subagent run and return its session ID immediately. Prefer a preset from list_agent_presets; when omitted, this tool uses the bundled "${DEFAULT_AGENT_PRESET}" preset automatically. Use get_subagent to poll, or keep notifyParent enabled to have the result pushed back into the parent session.`,
|
||||
inputSchema: StartSubagentInput,
|
||||
timeoutMs: 60_000,
|
||||
retryable: false,
|
||||
async execute(input, ctx) {
|
||||
const mgr = await getSessionManager();
|
||||
const baseCwd = envOr("CLINE_SUBAGENT_CWD", process.cwd());
|
||||
const defs = readAgentDefinitions(baseCwd);
|
||||
const presetName = input.preset ?? DEFAULT_AGENT_PRESET;
|
||||
const def = defs.find((d) => d.name === presetName);
|
||||
if (presetName && !def && !input.instructions?.trim()) {
|
||||
throw new Error(`Unknown agent preset: ${presetName}`);
|
||||
}
|
||||
|
||||
const cwd = resolve(
|
||||
baseCwd,
|
||||
input.workingDirectory ?? def?.cwd ?? ".",
|
||||
);
|
||||
const providerId =
|
||||
input.providerId ?? def?.providerId ?? DEFAULT_PROVIDER_ID;
|
||||
const modelId = input.modelId ?? def?.modelId ?? DEFAULT_MODEL_ID;
|
||||
const prompt = [
|
||||
def?.systemPrompt?.trim(),
|
||||
input.instructions?.trim(),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n\n");
|
||||
if (!prompt) {
|
||||
throw new Error(
|
||||
`Subagent "${input.label}" needs instructions. Provide "instructions" or use an available preset such as "${DEFAULT_AGENT_PRESET}".`,
|
||||
);
|
||||
}
|
||||
|
||||
const { sessionId } = await mgr.start({
|
||||
config: {
|
||||
providerId,
|
||||
modelId,
|
||||
cwd,
|
||||
workspaceRoot: cwd,
|
||||
enableTools: true,
|
||||
enableSpawnAgent: false,
|
||||
enableAgentTeams: false,
|
||||
pluginPaths: [],
|
||||
systemPrompt: prompt,
|
||||
maxIterations: input.maxIterations ?? def?.maxIterations,
|
||||
},
|
||||
interactive: false,
|
||||
});
|
||||
|
||||
const subagent: RunningSubagent = {
|
||||
sessionId,
|
||||
parentSessionId: parentSessionId(ctx),
|
||||
name: input.label,
|
||||
task: input.task,
|
||||
agent: input.preset,
|
||||
startedAt: Date.now(),
|
||||
status: "running",
|
||||
};
|
||||
subagents.set(sessionId, subagent);
|
||||
void runSubagentTurn(
|
||||
subagent,
|
||||
input.task,
|
||||
input.notifyParent !== false,
|
||||
);
|
||||
|
||||
return {
|
||||
status: "started",
|
||||
sessionId,
|
||||
label: subagent.name,
|
||||
preset: def?.name ?? input.preset,
|
||||
task: subagent.task,
|
||||
};
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
// -- list_agent_presets: Show available agent definitions --
|
||||
api.registerTool(
|
||||
toRegisteredTool(
|
||||
createTool({
|
||||
name: "list_agent_presets",
|
||||
description:
|
||||
"List the available subagent presets, including bundled, global, and project-level definitions.",
|
||||
inputSchema: z.object({}).strict(),
|
||||
async execute(_input, _ctx) {
|
||||
const baseCwd = envOr("CLINE_SUBAGENT_CWD", process.cwd());
|
||||
const agents = readAgentDefinitions(baseCwd).map((a) => ({
|
||||
name: a.name,
|
||||
description: a.description,
|
||||
providerId: a.providerId ?? DEFAULT_PROVIDER_ID,
|
||||
modelId: a.modelId ?? DEFAULT_MODEL_ID,
|
||||
source: a.source,
|
||||
}));
|
||||
return {
|
||||
agents,
|
||||
text: agents.length
|
||||
? agents
|
||||
.map(
|
||||
(a) =>
|
||||
`- ${a.name} [${a.source}] (${a.providerId}/${a.modelId})${a.description ? `: ${a.description}` : ""}`,
|
||||
)
|
||||
.join("\n")
|
||||
: "No agent definitions found.",
|
||||
};
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
// -- message_subagent: Send follow-up to an existing session --
|
||||
api.registerTool(
|
||||
toRegisteredTool(
|
||||
createTool({
|
||||
name: "message_subagent",
|
||||
description:
|
||||
"Send a follow-up message to an existing subagent session and return immediately.",
|
||||
inputSchema: MessageSubagentInput,
|
||||
timeoutMs: 60_000,
|
||||
retryable: false,
|
||||
async execute(input, ctx) {
|
||||
const mgr = await getSessionManager();
|
||||
const record = await mgr.get(input.sessionId);
|
||||
if (!record) {
|
||||
throw new Error(`Unknown session: ${input.sessionId}`);
|
||||
}
|
||||
|
||||
const subagent: RunningSubagent = subagents.get(
|
||||
input.sessionId,
|
||||
) ?? {
|
||||
sessionId: input.sessionId,
|
||||
parentSessionId: parentSessionId(ctx),
|
||||
name: input.sessionId,
|
||||
task: input.prompt,
|
||||
startedAt: Date.now(),
|
||||
status: "running",
|
||||
};
|
||||
subagent.parentSessionId = parentSessionId(ctx);
|
||||
subagent.task = input.prompt;
|
||||
subagent.status = "running";
|
||||
subagent.error = undefined;
|
||||
subagents.set(subagent.sessionId, subagent);
|
||||
|
||||
void runSubagentTurn(
|
||||
subagent,
|
||||
input.prompt,
|
||||
input.notifyParent !== false,
|
||||
);
|
||||
return {
|
||||
status: "started",
|
||||
sessionId: subagent.sessionId,
|
||||
label: subagent.name,
|
||||
task: subagent.task,
|
||||
};
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
// -- get_subagent: Check subagent result --
|
||||
api.registerTool(
|
||||
toRegisteredTool(
|
||||
createTool({
|
||||
name: "get_subagent",
|
||||
description:
|
||||
"Get the latest status, output, and error details for a subagent session.",
|
||||
inputSchema: GetSubagentInput,
|
||||
async execute(input, _ctx) {
|
||||
const subagent = subagents.get(input.sessionId);
|
||||
if (!subagent) {
|
||||
return {
|
||||
status: "unknown",
|
||||
sessionId: input.sessionId,
|
||||
text: `No tracked session: ${input.sessionId}`,
|
||||
};
|
||||
}
|
||||
return {
|
||||
status: subagent.status,
|
||||
sessionId: subagent.sessionId,
|
||||
label: subagent.name,
|
||||
task: subagent.task,
|
||||
finishReason: subagent.finishReason,
|
||||
error: subagent.error,
|
||||
text:
|
||||
subagent.resultText ??
|
||||
(subagent.status === "running" ? "Still running." : ""),
|
||||
};
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
// -- save_handoff: Persist a conversation-scoped handoff file --
|
||||
api.registerTool(
|
||||
toRegisteredTool(
|
||||
createTool({
|
||||
name: "save_handoff",
|
||||
description:
|
||||
"Save text into the conversation handoff store so other agents in this conversation can read it later.",
|
||||
inputSchema: SaveHandoffInput,
|
||||
async execute(input, ctx) {
|
||||
const filePath = resolveHandoffPath(ctx, input.path);
|
||||
mkdirSync(dirname(filePath), { recursive: true });
|
||||
writeFileSync(filePath, input.content, "utf8");
|
||||
return { path: filePath, handoffPath: input.path };
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
// -- read_handoff: Read a conversation-scoped handoff file --
|
||||
api.registerTool(
|
||||
toRegisteredTool(
|
||||
createTool({
|
||||
name: "read_handoff",
|
||||
description: "Read text from the conversation handoff store.",
|
||||
inputSchema: ReadHandoffInput,
|
||||
async execute(input, ctx) {
|
||||
const filePath = resolveHandoffPath(ctx, input.path);
|
||||
if (!existsSync(filePath)) {
|
||||
throw new Error(`Handoff not found: ${input.path}`);
|
||||
}
|
||||
return {
|
||||
path: filePath,
|
||||
handoffPath: input.path,
|
||||
content: readFileSync(filePath, "utf8"),
|
||||
};
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
// -- list_skills: Show available skill definitions --
|
||||
api.registerTool(
|
||||
toRegisteredTool(
|
||||
createTool({
|
||||
name: "list_skills",
|
||||
description:
|
||||
"List the available skill definitions from bundled, global, and project-level directories.",
|
||||
inputSchema: z.object({}).strict(),
|
||||
async execute(_input, _ctx) {
|
||||
const baseCwd = envOr("CLINE_SUBAGENT_CWD", process.cwd());
|
||||
const skills = readSkillDefinitions(baseCwd);
|
||||
return {
|
||||
skills: skills.map((s) => ({
|
||||
name: s.name,
|
||||
description: s.description,
|
||||
source: s.source,
|
||||
})),
|
||||
text: skills.length
|
||||
? skills
|
||||
.map(
|
||||
(s) =>
|
||||
`- ${s.name} [${s.source}]${s.description ? `: ${s.description}` : ""}`,
|
||||
)
|
||||
.join("\n")
|
||||
: "No skill definitions found.",
|
||||
};
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
// -- get_skill: Load a skill's instructions --
|
||||
api.registerTool(
|
||||
toRegisteredTool(
|
||||
createTool({
|
||||
name: "get_skill",
|
||||
description:
|
||||
"Get a skill by name, including the instructions that should be followed for that specialization.",
|
||||
inputSchema: GetSkillInput,
|
||||
async execute(input, _ctx) {
|
||||
const baseCwd = envOr("CLINE_SUBAGENT_CWD", process.cwd());
|
||||
const skills = readSkillDefinitions(baseCwd);
|
||||
const skill = skills.find((s) => s.name === input.name);
|
||||
if (!skill) {
|
||||
const available = skills.map((s) => s.name).join(", ");
|
||||
throw new Error(
|
||||
`Unknown skill: "${input.name}". Available: ${available || "none"}`,
|
||||
);
|
||||
}
|
||||
return {
|
||||
name: skill.name,
|
||||
description: skill.description,
|
||||
source: skill.source,
|
||||
instructions: skill.content,
|
||||
};
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export { plugin };
|
||||
export default plugin;
|
||||
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"name": "cline-sdk-portable-agents",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"description": "SDK-native background subagents plugin for the Cline SDK",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"typecheck": "tsc --noEmit",
|
||||
"clean": "rm -rf node_modules dist"
|
||||
},
|
||||
"exports": {
|
||||
".": "./index.ts"
|
||||
},
|
||||
"cline": {
|
||||
"plugins": [
|
||||
{
|
||||
"paths": [
|
||||
"./index.ts"
|
||||
],
|
||||
"capabilities": [
|
||||
"hooks",
|
||||
"tools"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@cline/core": "*"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@cline/core": {
|
||||
"optional": true
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"yaml": "^2.8.1",
|
||||
"zod": "^4.1.5"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
---
|
||||
name: api-design
|
||||
description: Design clean APIs — REST, RPC, or library interfaces with consistent naming, error handling, and versioning.
|
||||
---
|
||||
|
||||
# API Design Skill
|
||||
|
||||
When designing or reviewing an API (REST, RPC, or library), follow these principles:
|
||||
|
||||
## 1. Understand the Consumer
|
||||
|
||||
- Who calls this API? (Frontend, other services, CLI, third-party developers)
|
||||
- What are the most common operations?
|
||||
- What error conditions do consumers need to handle?
|
||||
- What's the expected request volume and latency budget?
|
||||
|
||||
## 2. Naming Conventions
|
||||
|
||||
- Use consistent, predictable names across all endpoints/methods.
|
||||
- Nouns for resources, verbs for actions: `GET /users`, `POST /users/:id/activate`.
|
||||
- For library APIs: verb-first for actions (`createUser`, `deleteSession`), noun-first for accessors (`getUserById`).
|
||||
- Avoid abbreviations unless universally understood (`id`, `url`, `api`).
|
||||
- Be specific: `getActiveUserCount()` not `getCount()`.
|
||||
|
||||
## 3. Input Design
|
||||
|
||||
- Accept the minimum required input. Optional fields should have sensible defaults.
|
||||
- Use typed schemas (Zod, JSON Schema) for validation at the boundary.
|
||||
- Reject invalid input early with clear error messages.
|
||||
- For REST: use path params for identity (`/users/:id`), query params for filtering (`?status=active`), body for creation/mutation.
|
||||
- For libraries: prefer options objects over long parameter lists.
|
||||
|
||||
## 4. Output Design
|
||||
|
||||
- Return consistent shapes. Every endpoint should return the same envelope structure.
|
||||
- Include enough context for the consumer to act without a follow-up call.
|
||||
- Paginate list endpoints. Always include `total`, `limit`, `offset` or cursor.
|
||||
- Use ISO 8601 for dates, consistent casing (camelCase or snake_case, not both).
|
||||
|
||||
## 5. Error Handling
|
||||
|
||||
- Use standard HTTP status codes (REST) or typed error codes (RPC/library).
|
||||
- Every error response must include: error code, human-readable message, and request ID.
|
||||
- Distinguish client errors (4xx / validation) from server errors (5xx / internal).
|
||||
- Never expose internal details (stack traces, SQL, file paths) in production errors.
|
||||
- Document every error code the consumer might receive.
|
||||
|
||||
## 6. Versioning & Evolution
|
||||
|
||||
- Version the API from day one (`/v1/`, header-based, or semver for libraries).
|
||||
- Additive changes (new fields, new endpoints) are non-breaking.
|
||||
- Removing or renaming fields is breaking — deprecate first, remove in next major version.
|
||||
- Document breaking changes in a changelog.
|
||||
|
||||
## 7. Documentation
|
||||
|
||||
For each endpoint or method, document:
|
||||
1. Purpose (one sentence).
|
||||
2. Input parameters with types and constraints.
|
||||
3. Output shape with example.
|
||||
4. Error codes and when they occur.
|
||||
5. Authentication/authorization requirements.
|
||||
6. Rate limits if applicable.
|
||||
@@ -0,0 +1,61 @@
|
||||
---
|
||||
name: code-review
|
||||
description: Structured code review — security, correctness, performance, and maintainability analysis with severity-ranked findings.
|
||||
---
|
||||
|
||||
# Code Review Skill
|
||||
|
||||
When reviewing code, follow this structured process:
|
||||
|
||||
## 1. Scope the Review
|
||||
|
||||
- Identify all changed files and their relationships.
|
||||
- Understand the intent: what problem does this change solve?
|
||||
- Note any files that *should* have changed but didn't.
|
||||
|
||||
## 2. Correctness Pass
|
||||
|
||||
- Trace data flow through every changed path.
|
||||
- Check edge cases: null/undefined, empty collections, boundary values.
|
||||
- Verify error handling: are errors caught, propagated, and surfaced correctly?
|
||||
- Look for off-by-one errors, race conditions, and state mutation bugs.
|
||||
- Confirm types match runtime expectations (especially `any`, casts, and assertions).
|
||||
|
||||
## 3. Security Pass
|
||||
|
||||
- Flag unvalidated user input reaching sensitive operations (SQL, shell, file paths, URLs).
|
||||
- Check authentication and authorization on every new endpoint or handler.
|
||||
- Look for secrets in code, logs, or error messages.
|
||||
- Verify CORS, CSP, and other security headers if applicable.
|
||||
- Check for timing attacks in comparison operations.
|
||||
|
||||
## 4. Performance Pass
|
||||
|
||||
- Identify N+1 queries, unbounded loops, and unnecessary allocations.
|
||||
- Check for missing indexes on new database queries.
|
||||
- Look for blocking operations on hot paths.
|
||||
- Verify pagination and limits on list operations.
|
||||
- Note any operations that scale poorly with input size.
|
||||
|
||||
## 5. Maintainability Pass
|
||||
|
||||
- Evaluate naming: do names communicate intent?
|
||||
- Check abstraction boundaries: is coupling introduced or reduced?
|
||||
- Look for duplicated logic that should be shared.
|
||||
- Verify tests cover the new behavior and edge cases.
|
||||
- Note missing documentation for public APIs.
|
||||
|
||||
## 6. Report Format
|
||||
|
||||
Organize findings by severity:
|
||||
|
||||
- **Critical**: Must fix before merge. Bugs, security issues, data loss risks.
|
||||
- **Major**: Should fix. Design problems, missing error handling, performance issues.
|
||||
- **Minor**: Worth noting. Style, naming, minor improvements.
|
||||
- **Positive**: Non-obvious good decisions worth calling out (keep brief).
|
||||
|
||||
For each finding, include:
|
||||
1. File and line reference
|
||||
2. What the issue is
|
||||
3. Why it matters
|
||||
4. Suggested fix (concrete, not vague)
|
||||
@@ -0,0 +1,62 @@
|
||||
---
|
||||
name: debugging
|
||||
description: Systematic debugging — reproduce, isolate, diagnose, and fix bugs with root-cause analysis.
|
||||
---
|
||||
|
||||
# Debugging Skill
|
||||
|
||||
When debugging an issue, follow this systematic process:
|
||||
|
||||
## 1. Understand the Bug
|
||||
|
||||
- Read the error message, stack trace, and any logs carefully.
|
||||
- Reproduce the issue. If you can't reproduce it, you can't verify a fix.
|
||||
- Identify the expected behavior vs. actual behavior.
|
||||
- Note the environment: OS, runtime version, configuration, input data.
|
||||
|
||||
## 2. Isolate
|
||||
|
||||
Narrow the scope using binary search:
|
||||
|
||||
- **Which file?** Trace the stack trace or data flow to the origin.
|
||||
- **Which function?** Add logging or breakpoints at entry/exit of suspect functions.
|
||||
- **Which line?** Check variable values before and after the suspect operation.
|
||||
- **Which input?** Find the minimal input that triggers the bug.
|
||||
|
||||
### Common Isolation Techniques
|
||||
- Comment out code blocks to find the trigger.
|
||||
- Add temporary `console.log` / `console.error` with labeled values.
|
||||
- Use a debugger to step through execution.
|
||||
- Write a minimal reproduction test case.
|
||||
|
||||
## 3. Diagnose
|
||||
|
||||
Once isolated, determine the root cause:
|
||||
|
||||
### Common Root Causes
|
||||
- **Type mismatch**: Runtime value doesn't match expected type (null, undefined, wrong shape).
|
||||
- **State mutation**: Shared state modified unexpectedly by another code path.
|
||||
- **Race condition**: Timing-dependent behavior in async or concurrent code.
|
||||
- **Off-by-one**: Loop bounds, array indexing, or string slicing errors.
|
||||
- **Missing error handling**: Unhandled promise rejection, uncaught exception, or swallowed error.
|
||||
- **Stale reference**: Closure capturing a variable that changes, or cached data that's outdated.
|
||||
- **Environment difference**: Works locally but fails in CI/production due to config, permissions, or versions.
|
||||
|
||||
Ask: "Why did this happen?" at least twice to get past symptoms to the root cause.
|
||||
|
||||
## 4. Fix
|
||||
|
||||
- Write a test that fails because of the bug (before fixing it).
|
||||
- Make the minimal change that fixes the root cause.
|
||||
- Verify the test now passes.
|
||||
- Check for the same pattern elsewhere in the codebase.
|
||||
- Run the full test suite to confirm no regressions.
|
||||
|
||||
## 5. Report
|
||||
|
||||
Document:
|
||||
- What the bug was (symptoms and root cause).
|
||||
- How it was reproduced.
|
||||
- What the fix was and why it's correct.
|
||||
- Whether the same pattern exists elsewhere.
|
||||
- What test was added to prevent regression.
|
||||
@@ -0,0 +1,83 @@
|
||||
---
|
||||
name: documentation
|
||||
description: Write clear technical documentation — READMEs, API docs, architecture guides, and inline comments.
|
||||
---
|
||||
|
||||
# Documentation Skill
|
||||
|
||||
When writing or improving documentation, follow these principles:
|
||||
|
||||
## 1. Know Your Audience
|
||||
|
||||
- **README**: New developers evaluating or onboarding to the project.
|
||||
- **API docs**: Developers integrating with the API.
|
||||
- **Architecture docs**: Team members understanding system design.
|
||||
- **Inline comments**: Future maintainers (including yourself in 6 months).
|
||||
|
||||
## 2. README Structure
|
||||
|
||||
A good README answers these questions in order:
|
||||
|
||||
1. **What is this?** One paragraph. What problem does it solve?
|
||||
2. **Quick start**: The fastest path from zero to working. Copy-pasteable commands.
|
||||
3. **Installation**: Prerequisites, install steps, configuration.
|
||||
4. **Usage**: Common use cases with code examples.
|
||||
5. **API reference**: If small enough; otherwise link to generated docs.
|
||||
6. **Configuration**: All options with defaults and descriptions.
|
||||
7. **Contributing**: How to set up dev environment, run tests, submit changes.
|
||||
8. **License**: One line.
|
||||
|
||||
## 3. API Documentation
|
||||
|
||||
For each endpoint, function, or method:
|
||||
|
||||
```
|
||||
### functionName(param1, param2, options?)
|
||||
|
||||
Brief description of what it does.
|
||||
|
||||
**Parameters:**
|
||||
- `param1` (string, required) — What this parameter controls.
|
||||
- `param2` (number, optional, default: 10) — What this parameter controls.
|
||||
- `options.verbose` (boolean, default: false) — Enable verbose output.
|
||||
|
||||
**Returns:** `Promise<Result>` — Description of the return value.
|
||||
|
||||
**Throws:**
|
||||
- `ValidationError` — When input is invalid.
|
||||
- `NotFoundError` — When the resource doesn't exist.
|
||||
|
||||
**Example:**
|
||||
```ts
|
||||
const result = await functionName("input", 5);
|
||||
```
|
||||
```
|
||||
|
||||
## 4. Architecture Documentation
|
||||
|
||||
- Start with a high-level diagram (Mermaid, ASCII, or image).
|
||||
- Describe each component's responsibility in one sentence.
|
||||
- Document data flow for the most important operations.
|
||||
- List key design decisions and their rationale.
|
||||
- Note known limitations and planned improvements.
|
||||
|
||||
## 5. Inline Comments
|
||||
|
||||
Write comments that explain **why**, not **what**:
|
||||
|
||||
- ✅ `// Retry 3 times because the upstream API has transient 503s during deploys`
|
||||
- ❌ `// Retry 3 times`
|
||||
- ✅ `// Sort descending so the most recent entry is first for the dashboard`
|
||||
- ❌ `// Sort the array`
|
||||
|
||||
Never comment obvious code. If code needs a comment to explain what it does, refactor the code to be self-explanatory first.
|
||||
|
||||
## 6. Quality Checklist
|
||||
|
||||
Before finalizing documentation:
|
||||
- [ ] All code examples compile and run.
|
||||
- [ ] No broken links.
|
||||
- [ ] Consistent formatting and terminology.
|
||||
- [ ] No outdated information from previous versions.
|
||||
- [ ] Spelling and grammar checked.
|
||||
- [ ] Table of contents for documents longer than 3 sections.
|
||||
@@ -0,0 +1,77 @@
|
||||
---
|
||||
name: migration
|
||||
description: Plan and execute data or schema migrations — database, config, and API migrations with rollback strategies.
|
||||
---
|
||||
|
||||
# Migration Skill
|
||||
|
||||
When planning or executing a migration (database schema, data transformation, config format, or API version), follow this process:
|
||||
|
||||
## 1. Assess Scope
|
||||
|
||||
- What is being migrated? (Schema, data, config, API contract)
|
||||
- How much data is affected? (Row count, file count, consumer count)
|
||||
- What is the downtime tolerance? (Zero-downtime, maintenance window, offline)
|
||||
- What systems depend on the current state?
|
||||
|
||||
## 2. Plan the Migration
|
||||
|
||||
### Strategy Selection
|
||||
|
||||
- **Expand-Contract** (preferred for zero-downtime):
|
||||
1. Expand: Add new columns/fields/endpoints alongside old ones.
|
||||
2. Migrate: Backfill data, update consumers to use new format.
|
||||
3. Contract: Remove old columns/fields/endpoints.
|
||||
|
||||
- **Blue-Green**: Run old and new versions in parallel, switch traffic.
|
||||
- **Big Bang**: Take the system offline, migrate, bring it back. Only for small datasets or when downtime is acceptable.
|
||||
|
||||
### Rollback Plan
|
||||
|
||||
Every migration must have a rollback plan before execution:
|
||||
- Can the migration be reversed with a down migration?
|
||||
- Is there a backup of the current state?
|
||||
- What is the point of no return (if any)?
|
||||
- How long does rollback take?
|
||||
|
||||
## 3. Write the Migration
|
||||
|
||||
### Database Migrations
|
||||
- One migration file per logical change.
|
||||
- Include both `up` and `down` functions.
|
||||
- Use transactions where the database supports them.
|
||||
- Never modify data and schema in the same migration.
|
||||
- Test with production-scale data volumes (not just empty tables).
|
||||
|
||||
### Data Migrations
|
||||
- Process in batches to avoid memory exhaustion and lock contention.
|
||||
- Log progress (processed X of Y records).
|
||||
- Handle partial failures: make migrations idempotent so they can be re-run.
|
||||
- Validate data after migration (row counts, checksums, spot checks).
|
||||
|
||||
### Config Migrations
|
||||
- Read old format, write new format, validate round-trip.
|
||||
- Preserve comments and ordering where possible.
|
||||
- Provide a CLI command or script users can run.
|
||||
|
||||
## 4. Test
|
||||
|
||||
- Run the migration on a copy of production data.
|
||||
- Verify the application works correctly after migration.
|
||||
- Run the rollback and verify the application works on the old state.
|
||||
- Test the migration under load if zero-downtime is required.
|
||||
|
||||
## 5. Execute
|
||||
|
||||
- Take a backup before starting.
|
||||
- Run the migration with monitoring (error rates, latency, disk usage).
|
||||
- Verify success criteria immediately after completion.
|
||||
- Keep the rollback plan ready for the agreed monitoring period.
|
||||
|
||||
## 6. Report
|
||||
|
||||
Document:
|
||||
- What was migrated and why.
|
||||
- Duration and any issues encountered.
|
||||
- Verification results.
|
||||
- Rollback status (available / expired / not needed).
|
||||
@@ -0,0 +1,58 @@
|
||||
---
|
||||
name: refactoring
|
||||
description: Safe, incremental refactoring — extract, rename, simplify, and restructure code without changing behavior.
|
||||
---
|
||||
|
||||
# Refactoring Skill
|
||||
|
||||
When refactoring code, follow this disciplined process:
|
||||
|
||||
## 1. Establish Safety Net
|
||||
|
||||
Before changing anything:
|
||||
- Confirm existing tests pass. If no tests exist, write characterization tests first.
|
||||
- Identify all callers and consumers of the code being refactored.
|
||||
- Document the current behavior as your contract — refactoring must not change it.
|
||||
|
||||
## 2. Plan the Refactoring
|
||||
|
||||
Choose the smallest transformation that makes progress:
|
||||
|
||||
### Common Refactorings
|
||||
- **Extract function**: Pull a block into a named function when it has a clear purpose.
|
||||
- **Inline function**: Remove a function that adds indirection without clarity.
|
||||
- **Rename**: Change names to communicate intent (variables, functions, types, files).
|
||||
- **Extract type/interface**: Pull inline types into named declarations.
|
||||
- **Simplify conditionals**: Replace nested if/else with early returns, guard clauses, or lookup tables.
|
||||
- **Remove dead code**: Delete unreachable code, unused imports, and commented-out blocks.
|
||||
- **Reduce parameters**: Group related parameters into an options object.
|
||||
- **Split module**: Break a large file into focused modules with clear responsibilities.
|
||||
|
||||
### Decision Criteria
|
||||
- Does this reduce cognitive load for the next reader?
|
||||
- Does this make the code easier to test?
|
||||
- Does this reduce the blast radius of future changes?
|
||||
- If none of the above: don't refactor it.
|
||||
|
||||
## 3. Execute Incrementally
|
||||
|
||||
- Make one refactoring at a time.
|
||||
- After each change, verify tests still pass.
|
||||
- Commit or checkpoint after each successful step.
|
||||
- If a step breaks something, revert it and try a smaller step.
|
||||
|
||||
## 4. Verify
|
||||
|
||||
After all changes:
|
||||
- Run the full test suite.
|
||||
- Check that all callers still compile and work correctly.
|
||||
- Verify no behavior has changed (same inputs → same outputs).
|
||||
- Review the diff: is the code genuinely simpler, or just different?
|
||||
|
||||
## 5. Report
|
||||
|
||||
Summarize:
|
||||
- What was refactored and why.
|
||||
- Which files changed.
|
||||
- Any behavior that looks different but is equivalent.
|
||||
- Anything left incomplete or worth refactoring next.
|
||||
@@ -0,0 +1,76 @@
|
||||
---
|
||||
name: test-generation
|
||||
description: Generate comprehensive test suites — unit, integration, and edge-case coverage with proper mocking strategies.
|
||||
---
|
||||
|
||||
# Test Generation Skill
|
||||
|
||||
When generating tests, follow this process:
|
||||
|
||||
## 1. Analyze the Target
|
||||
|
||||
- Read the source code thoroughly before writing any tests.
|
||||
- Identify the public API surface: exports, parameters, return types.
|
||||
- Map dependencies that need mocking or stubbing.
|
||||
- List the behavioral contracts: what must always be true?
|
||||
|
||||
## 2. Plan Test Cases
|
||||
|
||||
Organize tests into categories:
|
||||
|
||||
### Happy Path
|
||||
- Standard inputs produce expected outputs.
|
||||
- All documented use cases work correctly.
|
||||
|
||||
### Edge Cases
|
||||
- Empty inputs (null, undefined, empty string, empty array, 0).
|
||||
- Boundary values (min/max integers, very long strings, single-element arrays).
|
||||
- Unicode and special characters in string inputs.
|
||||
|
||||
### Error Cases
|
||||
- Invalid input types and shapes.
|
||||
- Missing required fields.
|
||||
- Network/IO failures (timeouts, connection refused, permission denied).
|
||||
- Concurrent access and race conditions where applicable.
|
||||
|
||||
### Integration Points
|
||||
- Verify correct interaction with dependencies.
|
||||
- Check that mocks match the real interface.
|
||||
- Test retry and fallback behavior.
|
||||
|
||||
## 3. Write Tests
|
||||
|
||||
Follow these conventions:
|
||||
|
||||
```
|
||||
describe("ModuleName", () => {
|
||||
describe("functionName", () => {
|
||||
it("should [expected behavior] when [condition]", () => {
|
||||
// Arrange — set up inputs and mocks
|
||||
// Act — call the function
|
||||
// Assert — verify the result
|
||||
});
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Mocking Strategy
|
||||
- Mock at the boundary (network, filesystem, database), not internal functions.
|
||||
- Use dependency injection where possible instead of module mocking.
|
||||
- Verify mock call counts and arguments, not just return values.
|
||||
- Reset mocks between tests to prevent state leakage.
|
||||
|
||||
### Assertions
|
||||
- Assert on specific values, not just truthiness.
|
||||
- Check error messages and types, not just that an error was thrown.
|
||||
- Use snapshot tests sparingly — only for stable, complex output.
|
||||
- Verify side effects (files written, events emitted, logs produced).
|
||||
|
||||
## 4. Quality Checks
|
||||
|
||||
Before finalizing:
|
||||
- Run the tests and confirm they pass.
|
||||
- Verify each test fails when the behavior it tests is broken.
|
||||
- Check that tests are independent and can run in any order.
|
||||
- Ensure test names describe the behavior, not the implementation.
|
||||
- Remove any redundant tests that don't add coverage.
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"include": ["index.ts"]
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
# TypeScript LSP Plugin
|
||||
|
||||
A plugin that gives the agent a `goto_definition` tool powered by the TypeScript Language Service API. Instead of grep or text search, it resolves symbols through imports, re-exports, type aliases, and declaration merging -- the same way your IDE does.
|
||||
|
||||
Code entrypoint: [index.ts](./index.ts)
|
||||
|
||||
## What it does
|
||||
|
||||
The agent gets a single tool: `goto_definition(file, line)`. It finds all identifiers on that line and resolves where they're actually defined. For example, given an import line like:
|
||||
|
||||
```ts
|
||||
import { disposeAll, initVcr } from "@cline/shared"
|
||||
```
|
||||
|
||||
It resolves both symbols through the workspace package alias to their source files:
|
||||
|
||||
```
|
||||
disposeAll -> packages/shared/src/dispose.ts:19
|
||||
initVcr -> packages/shared/src/vcr.ts:699
|
||||
```
|
||||
|
||||
## Why this matters
|
||||
|
||||
This is a good example of the kind of plugin that makes agents dramatically more effective at navigating large codebases. Text search can find symbol names but can't distinguish between definitions, references, re-exports, and shadowed variables. The TypeScript Language Service handles all of that.
|
||||
|
||||
The same pattern applies for enterprise use cases: you can build plugins that wrap internal APIs, deployment systems, feature flags, incident management, CI pipelines, or anything else your team works with. A plugin is just a TypeScript file -- no MCP server to host and maintain.
|
||||
|
||||
## Use it with the CLI
|
||||
|
||||
```bash
|
||||
cp examples/plugins/typescript-lsp-plugin/index.ts ~/.cline/plugins/typescript-lsp.ts
|
||||
cline -i "Find where createTool is defined"
|
||||
```
|
||||
|
||||
The plugin resolves `typescript` from the target project's own `node_modules` at runtime, so it uses the same TS version the project compiles with. No extra dependencies needed.
|
||||
|
||||
## Run the demo directly
|
||||
|
||||
```bash
|
||||
ANTHROPIC_API_KEY=sk-... bun run examples/plugins/typescript-lsp-plugin/index.ts
|
||||
```
|
||||
|
||||
## How it works
|
||||
|
||||
The plugin registers a single tool via `createTool()` in its `setup()` method:
|
||||
|
||||
```ts
|
||||
const plugin: AgentPlugin = {
|
||||
name: "typescript-lsp",
|
||||
manifest: {
|
||||
capabilities: ["tools"],
|
||||
},
|
||||
|
||||
setup(api) {
|
||||
api.registerTool(
|
||||
createTool({
|
||||
name: "goto_definition",
|
||||
description: "Find where TypeScript/JavaScript symbols on a given line are defined...",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
file: { type: "string", description: "Absolute path to the file." },
|
||||
line: { type: "integer", description: "Line number (1-based)." },
|
||||
},
|
||||
required: ["file", "line"],
|
||||
},
|
||||
async execute(input) {
|
||||
// 1. Walk up from the file to find tsconfig.json
|
||||
// 2. Create (or reuse cached) TypeScript Language Service
|
||||
// 3. Scan the AST for identifiers on the target line
|
||||
// 4. Resolve each identifier's definition via the Language Service
|
||||
// 5. Filter out self-references and return locations
|
||||
},
|
||||
}),
|
||||
);
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
Under the hood:
|
||||
|
||||
1. `findTsConfig()` walks up parent directories from the target file to find the nearest `tsconfig.json`
|
||||
2. `loadTypeScript()` uses `createRequire()` to resolve `typescript` from the project's own `node_modules`
|
||||
3. `createLanguageService()` sets up a full TypeScript Language Service with the project's compiler options
|
||||
4. The service is cached so subsequent calls in the same session reuse it
|
||||
5. `getIdentifierOffsetsOnLine()` scans the AST to find all identifiers on the requested line
|
||||
6. Each identifier is resolved via `service.getDefinitionAtPosition()`, which follows through imports, re-exports, type aliases, etc.
|
||||
|
||||
Then pass it to the SDK:
|
||||
|
||||
```ts
|
||||
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],
|
||||
},
|
||||
prompt: "Find where createTool is defined",
|
||||
interactive: false,
|
||||
});
|
||||
```
|
||||
@@ -0,0 +1,341 @@
|
||||
/**
|
||||
* TypeScript LSP Plugin
|
||||
*
|
||||
* Gives the agent a `goto_definition` tool powered by the TypeScript Language
|
||||
* Service API. It resolves through imports, re-exports, type aliases, etc. so
|
||||
* it's much more precise than grep or text search.
|
||||
*
|
||||
* The plugin resolves `typescript` from the target project's own node_modules
|
||||
* at runtime, so it has zero dependencies beyond Node builtins.
|
||||
*
|
||||
* CLI usage:
|
||||
* cp examples/plugins/typescript-lsp-plugin/index.ts ~/.cline/plugins/typescript-lsp.ts
|
||||
* cline -i "Find where createTool is defined"
|
||||
*
|
||||
* Direct demo usage:
|
||||
* ANTHROPIC_API_KEY=sk-... bun run examples/plugins/typescript-lsp-plugin/index.ts
|
||||
*/
|
||||
|
||||
import { existsSync } from "node:fs";
|
||||
import { createRequire } from "node:module";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { type AgentPlugin, ClineCore, createTool } from "@cline/core";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// TypeScript Language Service setup
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type LanguageServiceCache = {
|
||||
tsconfigPath: string;
|
||||
service: ReturnType<typeof createLanguageService>;
|
||||
ts: typeof import("typescript");
|
||||
};
|
||||
|
||||
let cache: LanguageServiceCache | undefined;
|
||||
|
||||
function findTsConfig(startDir: string): string | undefined {
|
||||
let dir = startDir;
|
||||
while (true) {
|
||||
const candidate = join(dir, "tsconfig.json");
|
||||
if (existsSync(candidate)) return candidate;
|
||||
const parent = dirname(dir);
|
||||
if (parent === dir) return undefined;
|
||||
dir = parent;
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve typescript from the target project's node_modules so we use the
|
||||
// same version the project is compiled with.
|
||||
function loadTypeScript(projectDir: string) {
|
||||
const req = createRequire(resolve(projectDir, "package.json"));
|
||||
const tsPath = req.resolve("typescript");
|
||||
return req(tsPath) as typeof import("typescript");
|
||||
}
|
||||
|
||||
function createLanguageService(
|
||||
ts: typeof import("typescript"),
|
||||
tsconfigPath: string,
|
||||
) {
|
||||
const projectDir = dirname(tsconfigPath);
|
||||
const configFile = ts.readConfigFile(tsconfigPath, ts.sys.readFile);
|
||||
|
||||
if (configFile.error) {
|
||||
throw new Error(
|
||||
"Failed to read tsconfig.json: " +
|
||||
ts.flattenDiagnosticMessageText(configFile.error.messageText, "\n"),
|
||||
);
|
||||
}
|
||||
|
||||
const parsed = ts.parseJsonConfigFileContent(
|
||||
configFile.config,
|
||||
ts.sys,
|
||||
projectDir,
|
||||
);
|
||||
|
||||
const host: import("typescript").LanguageServiceHost = {
|
||||
getScriptFileNames: () => parsed.fileNames,
|
||||
getScriptVersion: () => "1",
|
||||
getScriptSnapshot: (fileName) => {
|
||||
const content = ts.sys.readFile(fileName);
|
||||
if (content === undefined) return undefined;
|
||||
return ts.ScriptSnapshot.fromString(content);
|
||||
},
|
||||
getCurrentDirectory: () => projectDir,
|
||||
getCompilationSettings: () => parsed.options,
|
||||
getDefaultLibFileName: (options) => ts.getDefaultLibFilePath(options),
|
||||
fileExists: ts.sys.fileExists,
|
||||
readFile: ts.sys.readFile,
|
||||
readDirectory: ts.sys.readDirectory,
|
||||
getDirectories: ts.sys.getDirectories,
|
||||
};
|
||||
|
||||
return ts.createLanguageService(host, ts.createDocumentRegistry());
|
||||
}
|
||||
|
||||
function getOrCreateService(tsconfigPath: string) {
|
||||
if (cache && cache.tsconfigPath === tsconfigPath) {
|
||||
return cache;
|
||||
}
|
||||
|
||||
const projectDir = dirname(tsconfigPath);
|
||||
const ts = loadTypeScript(projectDir);
|
||||
const service = createLanguageService(ts, tsconfigPath);
|
||||
cache = { tsconfigPath, service, ts };
|
||||
return cache;
|
||||
}
|
||||
|
||||
function offsetToLineCol(
|
||||
sourceFile: import("typescript").SourceFile,
|
||||
ts: typeof import("typescript"),
|
||||
offset: number,
|
||||
) {
|
||||
const lc = ts.getLineAndCharacterOfPosition(sourceFile, offset);
|
||||
return { line: lc.line + 1, column: lc.character + 1 };
|
||||
}
|
||||
|
||||
function getIdentifiersOnLine(
|
||||
ts: typeof import("typescript"),
|
||||
sourceFile: import("typescript").SourceFile,
|
||||
targetLine: number,
|
||||
) {
|
||||
const identifiers: Array<{ offset: number; name: string }> = [];
|
||||
function visit(node: import("typescript").Node) {
|
||||
if (ts.isIdentifier(node)) {
|
||||
const lc = ts.getLineAndCharacterOfPosition(
|
||||
sourceFile,
|
||||
node.getStart(sourceFile),
|
||||
);
|
||||
if (lc.line + 1 === targetLine) {
|
||||
identifiers.push({
|
||||
offset: node.getStart(sourceFile),
|
||||
name: node.text,
|
||||
});
|
||||
}
|
||||
}
|
||||
ts.forEachChild(node, visit);
|
||||
}
|
||||
visit(sourceFile);
|
||||
return identifiers;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Plugin definition
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type GotoDefinitionInput = { file: string; line: number };
|
||||
|
||||
type DefinitionLocation = {
|
||||
file: string;
|
||||
line: number;
|
||||
column: number;
|
||||
kind: string;
|
||||
name: string;
|
||||
containerName?: string;
|
||||
};
|
||||
|
||||
const plugin: AgentPlugin = {
|
||||
name: "typescript-lsp",
|
||||
manifest: {
|
||||
capabilities: ["tools"],
|
||||
},
|
||||
|
||||
setup(api) {
|
||||
api.registerTool(
|
||||
createTool({
|
||||
name: "goto_definition",
|
||||
description:
|
||||
"Find where TypeScript/JavaScript symbols on a given line are defined. " +
|
||||
"Given a file path and line number, finds all identifiers on that line " +
|
||||
"and resolves their definitions. Much more precise than text search " +
|
||||
"-- resolves through imports, re-exports, type aliases, etc.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
file: {
|
||||
type: "string",
|
||||
description: "Absolute path to the file.",
|
||||
},
|
||||
line: {
|
||||
type: "integer",
|
||||
description: "Line number (1-based).",
|
||||
},
|
||||
},
|
||||
required: ["file", "line"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
timeoutMs: 30000,
|
||||
retryable: false,
|
||||
async execute(input: unknown) {
|
||||
const { file, line } = input as GotoDefinitionInput;
|
||||
const fileName = resolve(file);
|
||||
|
||||
if (!existsSync(fileName)) {
|
||||
throw new Error(`File does not exist: ${fileName}`);
|
||||
}
|
||||
|
||||
const tsconfigPath = findTsConfig(dirname(fileName));
|
||||
if (!tsconfigPath) {
|
||||
throw new Error(
|
||||
`No tsconfig.json found in any parent directory of ${fileName}`,
|
||||
);
|
||||
}
|
||||
|
||||
const { ts, service } = getOrCreateService(tsconfigPath);
|
||||
const program = service.getProgram();
|
||||
if (!program) throw new Error("Failed to create TypeScript program");
|
||||
|
||||
const sourceFile = program.getSourceFile(fileName);
|
||||
if (!sourceFile) {
|
||||
throw new Error(
|
||||
"File not found in TypeScript program. Make sure it is included by tsconfig.json: " +
|
||||
fileName,
|
||||
);
|
||||
}
|
||||
|
||||
const identifiers = getIdentifiersOnLine(ts, sourceFile, line);
|
||||
|
||||
if (identifiers.length === 0) {
|
||||
return {
|
||||
found: false,
|
||||
file,
|
||||
line,
|
||||
message: "No identifiers found on this line.",
|
||||
};
|
||||
}
|
||||
|
||||
const results: Array<{
|
||||
symbol: string;
|
||||
definitions: DefinitionLocation[];
|
||||
}> = [];
|
||||
|
||||
const seen = new Set<string>();
|
||||
|
||||
for (const { offset, name: symbolName } of identifiers) {
|
||||
if (seen.has(symbolName)) continue;
|
||||
seen.add(symbolName);
|
||||
|
||||
const definitions = service.getDefinitionAtPosition(
|
||||
fileName,
|
||||
offset,
|
||||
);
|
||||
if (!definitions || definitions.length === 0) continue;
|
||||
|
||||
const nonSelfDefs = definitions.filter((def) => {
|
||||
if (def.fileName !== fileName) return true;
|
||||
const defLine = offsetToLineCol(
|
||||
sourceFile,
|
||||
ts,
|
||||
def.textSpan.start,
|
||||
);
|
||||
return defLine.line !== line;
|
||||
});
|
||||
|
||||
if (nonSelfDefs.length === 0) continue;
|
||||
|
||||
results.push({
|
||||
symbol: symbolName,
|
||||
definitions: nonSelfDefs.map((def) => {
|
||||
const defSourceFile = program.getSourceFile(def.fileName);
|
||||
const loc = defSourceFile
|
||||
? offsetToLineCol(defSourceFile, ts, def.textSpan.start)
|
||||
: { line: 0, column: 0 };
|
||||
|
||||
return {
|
||||
file: def.fileName,
|
||||
line: loc.line,
|
||||
column: loc.column,
|
||||
kind: def.kind,
|
||||
name: def.name,
|
||||
containerName: def.containerName || undefined,
|
||||
};
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
if (results.length === 0) {
|
||||
return {
|
||||
found: false,
|
||||
file,
|
||||
line,
|
||||
message:
|
||||
"Identifiers found on this line but none resolved to external definitions.",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
found: true,
|
||||
query: { file, line },
|
||||
tsconfig: tsconfigPath,
|
||||
results,
|
||||
};
|
||||
},
|
||||
}),
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Standalone demo
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function runDemo(): Promise<void> {
|
||||
const sessionManager = await ClineCore.create({ backendMode: "local" });
|
||||
|
||||
try {
|
||||
const result = await sessionManager.start({
|
||||
config: {
|
||||
providerId: "anthropic",
|
||||
modelId: "claude-sonnet-4-6",
|
||||
apiKey: process.env.ANTHROPIC_API_KEY ?? "",
|
||||
cwd: process.cwd(),
|
||||
enableTools: true,
|
||||
enableSpawnAgent: false,
|
||||
enableAgentTeams: false,
|
||||
systemPrompt:
|
||||
"You are a helpful assistant. Use the goto_definition tool to navigate TypeScript code.",
|
||||
extensions: [plugin],
|
||||
extensionContext: {
|
||||
workspace: {
|
||||
rootPath: process.cwd(),
|
||||
cwd: process.cwd(),
|
||||
},
|
||||
},
|
||||
},
|
||||
prompt:
|
||||
"Use goto_definition to find where createTool is defined. " +
|
||||
"Start from packages/shared/src/tools/create.ts line 42.",
|
||||
interactive: false,
|
||||
});
|
||||
|
||||
console.log(`\n${result.result?.text ?? ""}`);
|
||||
} finally {
|
||||
await sessionManager.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
if (import.meta.main) {
|
||||
await runDemo();
|
||||
}
|
||||
|
||||
export { plugin, runDemo };
|
||||
export default plugin;
|
||||
@@ -0,0 +1,193 @@
|
||||
/**
|
||||
* Custom Plugin Example
|
||||
*
|
||||
* Shows how to author a reusable plugin module for the CLI and SDK hosts.
|
||||
*
|
||||
* Demonstrates:
|
||||
* - setup(api, ctx) — workspace-aware tool registration via
|
||||
* ctx.workspaceInfo
|
||||
* - hooks.beforeRun / beforeTool / afterTool / afterRun — lifecycle metrics
|
||||
*
|
||||
* CLI usage:
|
||||
* mkdir -p .cline/plugins
|
||||
* cp examples/plugins/weather-plugin.example.ts .cline/plugins/weather-metrics.ts
|
||||
* cline -i "What's the weather like in Tokyo and Paris?"
|
||||
*
|
||||
* Direct demo usage:
|
||||
* ANTHROPIC_API_KEY=sk-... bun run examples/plugins/weather-plugin.example.ts
|
||||
*/
|
||||
|
||||
import { type AgentPlugin, ClineCore, createTool } from "@cline/core";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Plugin-level state — populated from setup context and available to all hook
|
||||
// handlers and tool executors for the duration of the session.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
let sessionWorkspaceRoot: string | undefined;
|
||||
let sessionBranch: string | undefined;
|
||||
let sessionCommit: string | undefined;
|
||||
|
||||
const plugin: AgentPlugin = {
|
||||
name: "weather-and-metrics",
|
||||
manifest: {
|
||||
capabilities: ["tools", "hooks"],
|
||||
},
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// setup(api, ctx)
|
||||
//
|
||||
// Called once before the first run. The second argument `ctx` provides
|
||||
// workspace context sourced directly from the session config — never from
|
||||
// process.cwd() or import.meta.url, so it is correct even when --cwd was
|
||||
// passed to the CLI without calling process.chdir().
|
||||
//
|
||||
// ctx.workspaceInfo — structured workspace + git metadata: rootPath, hint,
|
||||
// latestGitCommitHash, latestGitBranchName,
|
||||
// associatedRemoteUrls
|
||||
//
|
||||
// Use setup() context for anything that affects tool registration itself —
|
||||
// e.g. building workspace-relative descriptions or defaulting file paths.
|
||||
// Use setup context for session-scoped plugin state.
|
||||
// -------------------------------------------------------------------------
|
||||
setup(api, ctx) {
|
||||
// Build a workspace-aware description so the model knows exactly where
|
||||
// the tool operates. rootPath covers the workspace location and the
|
||||
// remaining workspaceInfo fields add the git layer.
|
||||
const root = ctx.workspaceInfo?.rootPath ?? "(unknown)";
|
||||
const branch = ctx.workspaceInfo?.latestGitBranchName;
|
||||
const locationSuffix = branch
|
||||
? ` (workspace: ${root}, branch: ${branch})`
|
||||
: ` (workspace: ${root})`;
|
||||
|
||||
api.registerTool(
|
||||
createTool({
|
||||
name: "get_weather",
|
||||
description: `Get the current weather for a city${locationSuffix}`,
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
city: { type: "string", description: "The city name" },
|
||||
},
|
||||
required: ["city"],
|
||||
},
|
||||
execute: async (input: unknown) => {
|
||||
const { city } = input as { city: string };
|
||||
return {
|
||||
city,
|
||||
temperature: "72°F",
|
||||
condition: "sunny",
|
||||
humidity: "45%",
|
||||
};
|
||||
},
|
||||
}),
|
||||
);
|
||||
sessionWorkspaceRoot = ctx.workspaceInfo?.rootPath;
|
||||
sessionBranch = ctx.workspaceInfo?.latestGitBranchName;
|
||||
sessionCommit = ctx.workspaceInfo?.latestGitCommitHash?.slice(0, 7);
|
||||
const remotes = ctx.workspaceInfo?.associatedRemoteUrls ?? [];
|
||||
|
||||
console.log(`\n[metrics] session started`);
|
||||
if (sessionWorkspaceRoot) {
|
||||
console.log(`[metrics] workspace : ${sessionWorkspaceRoot}`);
|
||||
}
|
||||
if (sessionBranch) {
|
||||
console.log(
|
||||
`[metrics] branch : ${sessionBranch}${sessionCommit ? ` @ ${sessionCommit}` : ""}`,
|
||||
);
|
||||
}
|
||||
if (remotes.length > 0) {
|
||||
console.log(`[metrics] remotes : ${remotes.join(", ")}`);
|
||||
}
|
||||
},
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Lifecycle metrics hooks
|
||||
// -------------------------------------------------------------------------
|
||||
hooks: {
|
||||
beforeRun() {
|
||||
console.log("\n[metrics] run started");
|
||||
return undefined;
|
||||
},
|
||||
|
||||
beforeTool({ toolCall, input }) {
|
||||
console.log(`[metrics] -> ${toolCall.toolName}`, input);
|
||||
|
||||
if (toolCall.toolName === "run_commands") {
|
||||
const { commands } = input as { commands?: string[] };
|
||||
const isProtected =
|
||||
sessionBranch === "main" || sessionBranch === "master";
|
||||
const hasPush = commands?.some((c) =>
|
||||
c.trimStart().startsWith("git push"),
|
||||
);
|
||||
if (isProtected && hasPush) {
|
||||
console.error(
|
||||
`[metrics] blocked: git push on protected branch "${sessionBranch}"`,
|
||||
);
|
||||
return { stop: true, reason: "Blocked git push on protected branch" };
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
|
||||
afterTool({ toolCall }) {
|
||||
console.log(`[metrics] <- ${toolCall.toolName}`);
|
||||
return undefined;
|
||||
},
|
||||
|
||||
afterRun({ result }) {
|
||||
const { status, iterations, usage } = result;
|
||||
const loc = sessionWorkspaceRoot ? ` in ${sessionWorkspaceRoot}` : "";
|
||||
console.log(
|
||||
`[metrics] run done${loc} — ${iterations} iteration(s), status: ${status}`,
|
||||
);
|
||||
console.log(
|
||||
`[metrics] tokens — in: ${usage.inputTokens}, out: ${usage.outputTokens}, cost: ${usage.totalCost?.toFixed(6)}`,
|
||||
);
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
async function runDemo(): Promise<void> {
|
||||
const sessionManager = await ClineCore.create({ backendMode: "local" });
|
||||
|
||||
try {
|
||||
const result = await sessionManager.start({
|
||||
config: {
|
||||
providerId: "anthropic",
|
||||
modelId: "claude-sonnet-4-6",
|
||||
apiKey: process.env.ANTHROPIC_API_KEY ?? "",
|
||||
cwd: process.cwd(),
|
||||
enableTools: true,
|
||||
enableSpawnAgent: false,
|
||||
enableAgentTeams: false,
|
||||
systemPrompt: "You are a helpful assistant. Use tools when needed.",
|
||||
extensions: [plugin],
|
||||
// extensionContext.workspace is the authoritative source for
|
||||
// workspaceInfo that flows into setup(api, ctx). The CLI
|
||||
// and VS Code hosts populate this automatically from their runtime
|
||||
// state. When using the SDK directly, set it explicitly so plugins
|
||||
// always receive accurate workspace metadata.
|
||||
extensionContext: {
|
||||
workspace: {
|
||||
rootPath: process.cwd(),
|
||||
cwd: process.cwd(),
|
||||
},
|
||||
},
|
||||
},
|
||||
prompt: "What's the weather like in Tokyo and Paris?",
|
||||
interactive: false,
|
||||
});
|
||||
|
||||
console.log(`\n${result.result?.text ?? ""}`);
|
||||
} finally {
|
||||
await sessionManager.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
if (import.meta.main) {
|
||||
await runDemo();
|
||||
}
|
||||
|
||||
export { plugin, runDemo };
|
||||
export default plugin;
|
||||
Reference in New Issue
Block a user