mirror of
https://github.com/cline/cline.git
synced 2026-09-19 02:05:44 +08:00
* Move the apps to the root dir * Update all references from sdk/apps/ to apps/ * Update dependencies * Install bun types * Fix types * Fix types * Fix linter * Ingore apps from vscode * Fix security warning * Fix windows install * Enable windows dev mode * Revert "Enable windows dev mode" This reverts commita46c99282e. * Revert "Ingore apps from vscode" This reverts commit47f7b265d2. * Revert "Fix windows install" This reverts commit1dabba1556. * update the repo root * fix root dir * fix path * fix other path * Fix unrelated changes * fix: address apps move follow-up blockers (#11228) * fix: update root app command paths * fix: include moved apps in root checks * fix: clean up moved app path references --------- Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
562 lines
15 KiB
TypeScript
562 lines
15 KiB
TypeScript
import { spawnSync } from "node:child_process";
|
|
import { appendFileSync, existsSync, unlinkSync } from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import { join, resolve } from "node:path";
|
|
import { type HookEventPayload, parseHookEventPayload } from "@cline/shared";
|
|
import { ensureHookLogDir } from "@cline/shared/storage";
|
|
import { nanoid } from "nanoid";
|
|
import { commanderToParsedArgs, createProgram } from "../commands/program";
|
|
import type { ParsedArgs } from "./types";
|
|
|
|
export function sanitizeSessionToken(value: string): string {
|
|
return value.replace(/[^a-zA-Z0-9._-]+/g, "_");
|
|
}
|
|
|
|
export function makeSubSessionId(
|
|
rootSessionId: string,
|
|
agentId: string,
|
|
): string {
|
|
const root = sanitizeSessionToken(rootSessionId);
|
|
const agent = sanitizeSessionToken(agentId);
|
|
const joined = `${root}__${agent}`;
|
|
return joined.length > 180 ? joined.slice(0, 180) : joined;
|
|
}
|
|
|
|
export function makeTeamTaskSubSessionId(
|
|
rootSessionId: string,
|
|
agentId: string,
|
|
): string {
|
|
const root = sanitizeSessionToken(rootSessionId);
|
|
const agent = sanitizeSessionToken(agentId);
|
|
const nonce = Math.random().toString(36).slice(2, 8);
|
|
return `${root}__teamtask__${agent}__${Date.now()}_${nonce}`;
|
|
}
|
|
|
|
export function nowIso(): string {
|
|
return new Date().toISOString();
|
|
}
|
|
|
|
export function randomSessionId(): string {
|
|
return `${Date.now()}_${nanoid(5)}_cli`;
|
|
}
|
|
|
|
export function resolveWorkspaceRoot(cwd: string): string {
|
|
const result = spawnSync("git", ["-C", cwd, "rev-parse", "--show-toplevel"], {
|
|
encoding: "utf8",
|
|
});
|
|
if (result.status === 0) {
|
|
const value = result.stdout.trim();
|
|
if (value) {
|
|
return value;
|
|
}
|
|
}
|
|
return cwd;
|
|
}
|
|
|
|
export function truncate(str: string, maxLen: number): string {
|
|
const oneLine = str.replace(/\n/g, " ").trim();
|
|
if (oneLine.length <= maxLen) {
|
|
return oneLine;
|
|
}
|
|
return `${oneLine.slice(0, maxLen - 3)}...`;
|
|
}
|
|
|
|
function formatStructuredCommand(cmd: unknown): string {
|
|
if (typeof cmd === "string") {
|
|
return cmd;
|
|
}
|
|
if (cmd && typeof cmd === "object" && "command" in cmd) {
|
|
const structured = cmd as { command: string; args?: unknown };
|
|
const args = Array.isArray(structured.args) ? structured.args : [];
|
|
if (args.length === 0) {
|
|
return structured.command;
|
|
}
|
|
return `${structured.command} ${args.join(" ")}`;
|
|
}
|
|
return String(cmd);
|
|
}
|
|
|
|
function summarizeRunCommandsInput(input: unknown): string {
|
|
if (typeof input === "string") {
|
|
return input;
|
|
}
|
|
|
|
if (Array.isArray(input)) {
|
|
return input.map(formatStructuredCommand).join("; ");
|
|
}
|
|
|
|
if (input && typeof input === "object") {
|
|
const obj = input as Record<string, unknown>;
|
|
if (obj.commands !== undefined) {
|
|
if (Array.isArray(obj.commands)) {
|
|
return obj.commands.map(formatStructuredCommand).join("; ");
|
|
}
|
|
return formatStructuredCommand(obj.commands);
|
|
}
|
|
if ("command" in obj) {
|
|
return formatStructuredCommand(obj);
|
|
}
|
|
}
|
|
|
|
return "";
|
|
}
|
|
|
|
function formatAskQuestionInput(input: Record<string, unknown>): string {
|
|
const question =
|
|
typeof input.question === "string" ? input.question.trim() : "";
|
|
const options = Array.isArray(input.options)
|
|
? input.options
|
|
.map((option) => String(option).trim())
|
|
.filter((option) => option.length > 0)
|
|
: [];
|
|
|
|
if (!question && options.length === 0) {
|
|
return "";
|
|
}
|
|
|
|
const lines = ["The agent is waiting for your input."];
|
|
if (question) {
|
|
lines.push(question);
|
|
}
|
|
for (const [index, option] of options.entries()) {
|
|
lines.push(`${index + 1}. ${option}`);
|
|
}
|
|
lines.push("> Reply with an option number or type your answer.");
|
|
return lines.join("\n");
|
|
}
|
|
|
|
export function formatToolInput(toolName: string, input: unknown): string {
|
|
if (!input) {
|
|
return "";
|
|
}
|
|
|
|
if (toolName === "run_commands") {
|
|
return truncate(summarizeRunCommandsInput(input), 120);
|
|
}
|
|
|
|
if (typeof input !== "object") {
|
|
return "";
|
|
}
|
|
|
|
const obj = input as Record<string, unknown>;
|
|
|
|
switch (toolName) {
|
|
case "ask_question":
|
|
return formatAskQuestionInput(obj);
|
|
case "read_files":
|
|
if (Array.isArray(obj.file_paths)) {
|
|
return truncate(obj.file_paths.join(", "), 120);
|
|
}
|
|
break;
|
|
case "search_codebase":
|
|
if (Array.isArray(obj.queries)) {
|
|
return truncate(obj.queries.join(", "), 120);
|
|
}
|
|
break;
|
|
case "fetch_web_content":
|
|
if (Array.isArray(obj.requests)) {
|
|
return truncate(
|
|
obj.requests
|
|
.map((r) => r.url)
|
|
.filter(Boolean)
|
|
.join(", "),
|
|
120,
|
|
);
|
|
}
|
|
break;
|
|
case "spawn_agent":
|
|
return truncate(String(obj.task ?? ""), 50);
|
|
case "skills":
|
|
return truncate(
|
|
`${String(obj.skill ?? "")}${obj.args ? ` ${String(obj.args)}` : ""}`,
|
|
70,
|
|
);
|
|
case "ask_followup_question":
|
|
return truncate(String(obj.question ?? ""), 120);
|
|
case "team_member": {
|
|
const action = String(obj.action ?? "");
|
|
if (action === "spawn") {
|
|
return truncate(
|
|
`spawn ${String(obj.agentId ?? "")}: ${String(obj.rolePrompt ?? "")}`,
|
|
70,
|
|
);
|
|
}
|
|
if (action === "shutdown") {
|
|
return truncate(`shutdown ${String(obj.agentId ?? "")}`, 70);
|
|
}
|
|
break;
|
|
}
|
|
case "team_spawn_teammate":
|
|
return truncate(
|
|
`${String(obj.agentId ?? "")}: ${String(obj.rolePrompt ?? "")}`,
|
|
70,
|
|
);
|
|
case "team_shutdown_teammate":
|
|
return truncate(`shutdown ${String(obj.agentId ?? "")}`, 70);
|
|
case "team_task": {
|
|
const action = String(obj.action ?? "");
|
|
if (action === "create") {
|
|
return truncate(`create ${String(obj.title ?? "")}`, 60);
|
|
}
|
|
if (action === "list") {
|
|
return truncate(
|
|
`list status=${String(obj.status ?? "any")} readyOnly=${String(obj.readyOnly ?? false)}`,
|
|
60,
|
|
);
|
|
}
|
|
if (action === "claim") {
|
|
return truncate(`claim ${String(obj.taskId ?? "")}`, 60);
|
|
}
|
|
if (action === "complete") {
|
|
return truncate(
|
|
`complete ${String(obj.taskId ?? "")}: ${String(obj.summary ?? "")}`,
|
|
70,
|
|
);
|
|
}
|
|
if (action === "block") {
|
|
return truncate(
|
|
`block ${String(obj.taskId ?? "")}: ${String(obj.reason ?? "")}`,
|
|
70,
|
|
);
|
|
}
|
|
break;
|
|
}
|
|
case "team_run_task":
|
|
return truncate(
|
|
`${String(obj.runMode ?? "sync")} ${String(obj.agentId ?? "")}: ${String(obj.task ?? "")}`,
|
|
70,
|
|
);
|
|
case "team_list_runs":
|
|
return truncate(
|
|
`status=${String(obj.status ?? "any")} agent=${String(obj.agentId ?? "any")}`,
|
|
60,
|
|
);
|
|
case "team_cancel_run":
|
|
return truncate(`cancel ${String(obj.runId ?? "")}`, 60);
|
|
case "team_await_run":
|
|
return truncate(String(obj.runId ?? ""), 60);
|
|
case "team_await_all_runs":
|
|
return "all runs";
|
|
case "team_message": {
|
|
const action = String(obj.action ?? "");
|
|
if (action === "send") {
|
|
return truncate(
|
|
`send ${String(obj.toAgentId ?? "")}: ${String(obj.subject ?? "")}`,
|
|
70,
|
|
);
|
|
}
|
|
if (action === "broadcast") {
|
|
return truncate(`broadcast ${String(obj.subject ?? "")}`, 70);
|
|
}
|
|
if (action === "read") {
|
|
return truncate(
|
|
`read unreadOnly=${String(obj.unreadOnly ?? true)} limit=${String(obj.limit ?? "default")}`,
|
|
70,
|
|
);
|
|
}
|
|
break;
|
|
}
|
|
case "team_send_message":
|
|
return truncate(
|
|
`${String(obj.toAgentId ?? "")}: ${String(obj.subject ?? "")}`,
|
|
70,
|
|
);
|
|
case "team_broadcast":
|
|
return truncate(String(obj.subject ?? ""), 70);
|
|
case "team_read_mailbox":
|
|
return truncate(
|
|
`read unreadOnly=${String(obj.unreadOnly ?? true)} limit=${String(obj.limit ?? "default")}`,
|
|
70,
|
|
);
|
|
case "team_create_outcome":
|
|
return truncate(String(obj.title ?? ""), 70);
|
|
case "team_attach_outcome_fragment":
|
|
return truncate(
|
|
`${String(obj.outcomeId ?? "")}/${String(obj.section ?? "")}`,
|
|
70,
|
|
);
|
|
case "team_review_outcome_fragment":
|
|
return truncate(
|
|
`${String(obj.fragmentId ?? "")}: ${String(obj.approved ?? "")}`,
|
|
70,
|
|
);
|
|
case "team_finalize_outcome":
|
|
return truncate(String(obj.outcomeId ?? ""), 70);
|
|
case "team_list_outcomes":
|
|
return "list";
|
|
}
|
|
|
|
return truncate(JSON.stringify(input), 60);
|
|
}
|
|
|
|
export function formatToolOutput(output: unknown): string {
|
|
if (output === null || output === undefined) {
|
|
return "";
|
|
}
|
|
|
|
if (typeof output === "string") {
|
|
return truncate(output, 100);
|
|
}
|
|
|
|
if (isTeamStatusBoard(output)) {
|
|
const pending = output.taskCounts.pending;
|
|
const inProgress = output.taskCounts.in_progress;
|
|
const blocked = output.taskCounts.blocked;
|
|
const completed = output.taskCounts.completed;
|
|
const outcomes = output.outcomeCounts;
|
|
return truncate(
|
|
`team=${output.teamName} members=${output.members.length} tasks(p:${pending}/ip:${inProgress}/b:${blocked}/c:${completed}) runs(active:${output.activeRuns}/queued:${output.queuedRuns}) outcomes(d:${outcomes.draft}/r:${outcomes.in_review}/f:${outcomes.finalized})`,
|
|
130,
|
|
);
|
|
}
|
|
|
|
if (Array.isArray(output)) {
|
|
const results = output
|
|
.map((item) => {
|
|
if (item && typeof item === "object" && "result" in item) {
|
|
const result = item.result;
|
|
const resultStr = Array.isArray(result)
|
|
? result
|
|
.map((part: unknown) =>
|
|
part &&
|
|
typeof part === "object" &&
|
|
"type" in part &&
|
|
(part as { type: string }).type === "text" &&
|
|
"text" in part
|
|
? String((part as { text: unknown }).text)
|
|
: (part as { type?: string })?.type === "image"
|
|
? "[image]"
|
|
: "",
|
|
)
|
|
.filter(Boolean)
|
|
.join(" ") || "Successfully read image"
|
|
: String(result ?? "");
|
|
return truncate(resultStr, 80);
|
|
}
|
|
return truncate(JSON.stringify(item), 80);
|
|
})
|
|
.filter((s) => s.length > 0);
|
|
|
|
if (results.length === 0) {
|
|
return "";
|
|
}
|
|
if (results.length === 1) {
|
|
return results[0];
|
|
}
|
|
return `${results[0]} (+${results.length - 1} more)`;
|
|
}
|
|
|
|
return truncate(JSON.stringify(output), 100);
|
|
}
|
|
|
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
return !!value && typeof value === "object";
|
|
}
|
|
|
|
function isTeamStatusBoard(value: unknown): value is {
|
|
teamName: string;
|
|
members: unknown[];
|
|
taskCounts: Record<
|
|
"pending" | "in_progress" | "blocked" | "completed",
|
|
number
|
|
>;
|
|
activeRuns: number;
|
|
queuedRuns: number;
|
|
outcomeCounts: Record<"draft" | "in_review" | "finalized", number>;
|
|
} {
|
|
if (!isRecord(value)) {
|
|
return false;
|
|
}
|
|
const teamName = value.teamName;
|
|
const members = value.members;
|
|
const taskCounts = value.taskCounts;
|
|
const outcomeCounts = value.outcomeCounts;
|
|
return (
|
|
typeof teamName === "string" &&
|
|
Array.isArray(members) &&
|
|
isRecord(taskCounts) &&
|
|
typeof taskCounts.pending === "number" &&
|
|
typeof taskCounts.in_progress === "number" &&
|
|
typeof taskCounts.blocked === "number" &&
|
|
typeof taskCounts.completed === "number" &&
|
|
typeof value.activeRuns === "number" &&
|
|
typeof value.queuedRuns === "number" &&
|
|
isRecord(outcomeCounts) &&
|
|
typeof outcomeCounts.draft === "number" &&
|
|
typeof outcomeCounts.in_review === "number" &&
|
|
typeof outcomeCounts.finalized === "number"
|
|
);
|
|
}
|
|
|
|
export function unlinkIfExists(filePath: string | null | undefined): void {
|
|
if (!filePath) {
|
|
return;
|
|
}
|
|
if (!existsSync(filePath)) {
|
|
return;
|
|
}
|
|
try {
|
|
unlinkSync(filePath);
|
|
} catch {
|
|
// Best-effort cleanup.
|
|
}
|
|
}
|
|
|
|
export function readStdinUtf8(): Promise<string> {
|
|
return new Promise((resolve, reject) => {
|
|
const chunks: Buffer[] = [];
|
|
process.stdin.on("data", (chunk: Buffer) =>
|
|
chunks.push(Buffer.from(chunk)),
|
|
);
|
|
process.stdin.on("end", () =>
|
|
resolve(Buffer.concat(chunks).toString("utf-8")),
|
|
);
|
|
process.stdin.on("error", reject);
|
|
});
|
|
}
|
|
|
|
export function writeHookJson(value: unknown): void {
|
|
try {
|
|
process.stdout.write(JSON.stringify(value));
|
|
} catch (error) {
|
|
if (
|
|
!(
|
|
error &&
|
|
typeof error === "object" &&
|
|
"code" in error &&
|
|
typeof (error as { code?: unknown }).code === "string" &&
|
|
(error as { code: string }).code === "EPIPE"
|
|
)
|
|
) {
|
|
throw error;
|
|
}
|
|
}
|
|
}
|
|
|
|
export async function appendHookAudit(event: HookEventPayload): Promise<void> {
|
|
const line = `${JSON.stringify({
|
|
ts: new Date().toISOString(),
|
|
...event,
|
|
})}\n`;
|
|
const envPath = process.env.CLINE_HOOKS_LOG_PATH?.trim() || undefined;
|
|
const logPath = envPath ?? join(ensureHookLogDir(), "hooks.jsonl");
|
|
ensureHookLogDir(logPath);
|
|
appendFileSync(logPath, line, "utf-8");
|
|
}
|
|
|
|
export async function isCliHookPayload(value: unknown): Promise<boolean> {
|
|
return parseHookEventPayload(value) !== undefined;
|
|
}
|
|
|
|
export async function parseCliHookPayload(
|
|
value: unknown,
|
|
): Promise<HookEventPayload | undefined> {
|
|
return parseHookEventPayload(value);
|
|
}
|
|
|
|
function isBooleanLikeAutoApproveValue(value: string | undefined): boolean {
|
|
if (!value) {
|
|
return false;
|
|
}
|
|
const normalized = value.trim().toLowerCase();
|
|
return normalized === "true" || normalized === "false";
|
|
}
|
|
|
|
export function normalizeCliArgs(args: string[]): string[] {
|
|
const normalized: string[] = [];
|
|
for (let index = 0; index < args.length; index += 1) {
|
|
const token = args[index];
|
|
if (token === "--autoapprove" || token === "--auto-approve") {
|
|
const nextToken = args[index + 1];
|
|
if (isBooleanLikeAutoApproveValue(nextToken)) {
|
|
normalized.push("--auto-approve", nextToken);
|
|
index += 1;
|
|
continue;
|
|
}
|
|
normalized.push("--auto-approve", "true");
|
|
continue;
|
|
}
|
|
if (token.startsWith("--autoapprove=")) {
|
|
normalized.push(token.replace(/^--autoapprove=/, "--auto-approve="));
|
|
continue;
|
|
}
|
|
if (token === "--thinking") {
|
|
const nextToken = args[index + 1];
|
|
if (nextToken !== undefined && !nextToken.startsWith("-")) {
|
|
normalized.push("--thinking", nextToken);
|
|
index += 1;
|
|
continue;
|
|
}
|
|
normalized.push("--thinking", "medium");
|
|
continue;
|
|
}
|
|
if (token === "--reasoning-effort") {
|
|
const nextToken = args[index + 1];
|
|
// Legacy spelling is normalized to --thinking and intentionally relies
|
|
// on commanderToParsedArgs for level validation, so invalid values behave
|
|
// the same as `--thinking <value>` and `--thinking=<value>`.
|
|
if (nextToken !== undefined && !nextToken.startsWith("-")) {
|
|
normalized.push("--thinking", nextToken);
|
|
index += 1;
|
|
continue;
|
|
}
|
|
normalized.push("--thinking", "medium");
|
|
continue;
|
|
}
|
|
if (token.startsWith("--reasoning-effort=")) {
|
|
normalized.push(token.replace(/^--reasoning-effort=/, "--thinking="));
|
|
continue;
|
|
}
|
|
normalized.push(token);
|
|
}
|
|
return normalized;
|
|
}
|
|
|
|
export const normalizeAutoApproveArgs = normalizeCliArgs;
|
|
|
|
export function parseArgs(args: string[]): ParsedArgs {
|
|
const program = createProgram();
|
|
try {
|
|
program.parse(normalizeCliArgs(args), { from: "user" });
|
|
} catch (_: unknown) {
|
|
// exitOverride throws CommanderError on --help / --version; commander
|
|
// handles output directly, and we treat the thrown error as a signal
|
|
// to exit gracefully in the caller.
|
|
}
|
|
return commanderToParsedArgs(program);
|
|
}
|
|
|
|
export function resolveSandboxDataDir(
|
|
cwd: string,
|
|
explicitDir?: string,
|
|
): string {
|
|
const envDir = process.env.CLINE_SANDBOX_DATA_DIR?.trim();
|
|
const baseDir =
|
|
explicitDir?.trim() || envDir || join(tmpdir(), "cline-sandbox");
|
|
return resolve(cwd, baseDir);
|
|
}
|
|
|
|
export function configureSandboxEnvironment(options: {
|
|
enabled: boolean;
|
|
cwd: string;
|
|
explicitDir?: string;
|
|
}): string | undefined {
|
|
if (!options.enabled) {
|
|
return undefined;
|
|
}
|
|
const dataDir = resolveSandboxDataDir(options.cwd, options.explicitDir);
|
|
process.env.CLINE_SANDBOX = "1";
|
|
process.env.CLINE_SANDBOX_DATA_DIR = dataDir;
|
|
process.env.CLINE_DATA_DIR = dataDir;
|
|
process.env.CLINE_DB_DATA_DIR = join(dataDir, "db");
|
|
process.env.CLINE_SESSION_DATA_DIR = join(dataDir, "sessions");
|
|
process.env.CLINE_TEAM_DATA_DIR = join(dataDir, "teams");
|
|
process.env.CLINE_PROVIDER_SETTINGS_PATH = join(
|
|
dataDir,
|
|
"settings",
|
|
"providers.json",
|
|
);
|
|
process.env.CLINE_HOOKS_LOG_PATH = join(dataDir, "logs", "hooks.jsonl");
|
|
return dataDir;
|
|
}
|