Files
cline/apps/cli/src/utils/helpers.test.ts
T
Saoud RizwanandSaoud Rizwan e3ff875e09 fix(cli): persist /settings general toggles (mode, auto-approve, compaction) across restarts (#12614)
* feat(core): persist plan/act mode, tool auto-approve, and compaction mode in global settings

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>

* fix(cli): restore /settings general toggles across restarts

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>

* fix(core): make global settings updates cross-process safe

Targeted setters previously did unlocked read-modify-write cycles over the
shared global-settings.json, so concurrent hosts (two CLIs, or CLI + VS Code)
could silently discard each other's changes. Route all setters through a new
updateGlobalSettings(mutate) helper that re-reads the latest on-disk state
under a short-lived lock file (with stale-lock reclaim and a bounded wait)
and replaces the file atomically via temp-file rename so readers never see
torn writes.

* Revert "fix(core): make global settings updates cross-process safe"

This reverts commit 198c1c831b.

---------

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2026-07-28 08:56:55 -07:00

537 lines
17 KiB
TypeScript

import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import {
appendHookAudit,
configureSandboxEnvironment,
formatToolInput,
formatToolOutput,
isCliHookPayload,
normalizeAutoApproveArgs,
parseArgs,
} from "./helpers";
type EnvSnapshot = {
CLINE_DATA_DIR: string | undefined;
CLINE_DB_DATA_DIR: string | undefined;
CLINE_HOOKS_LOG_PATH: string | undefined;
CLINE_SESSION_ID: string | undefined;
CLINE_SESSION_DATA_DIR: string | undefined;
};
function captureEnv(): EnvSnapshot {
return {
CLINE_DATA_DIR: process.env.CLINE_DATA_DIR,
CLINE_DB_DATA_DIR: process.env.CLINE_DB_DATA_DIR,
CLINE_HOOKS_LOG_PATH: process.env.CLINE_HOOKS_LOG_PATH,
CLINE_SESSION_ID: process.env.CLINE_SESSION_ID,
CLINE_SESSION_DATA_DIR: process.env.CLINE_SESSION_DATA_DIR,
};
}
function restoreEnv(snapshot: EnvSnapshot): void {
process.env.CLINE_DATA_DIR = snapshot.CLINE_DATA_DIR;
process.env.CLINE_DB_DATA_DIR = snapshot.CLINE_DB_DATA_DIR;
process.env.CLINE_HOOKS_LOG_PATH = snapshot.CLINE_HOOKS_LOG_PATH;
process.env.CLINE_SESSION_ID = snapshot.CLINE_SESSION_ID;
process.env.CLINE_SESSION_DATA_DIR = snapshot.CLINE_SESSION_DATA_DIR;
}
describe("parseArgs", () => {
it("returns defaults when no arguments are supplied", () => {
const parsed = parseArgs([]);
expect(parsed).toEqual({
verbose: false,
interactive: false,
outputMode: "text",
mode: "act",
modeExplicitlySet: false,
sandbox: false,
acpMode: false,
thinking: false,
reasoningEffort: undefined,
defaultToolAutoApprove: true,
});
});
it("parses prompt, runtime flags, and global approval settings", () => {
const parsed = parseArgs([
"--verbose",
"--auto-approve",
"false",
"--cwd",
"/tmp/work",
"--team-name",
"dev-team",
"--provider",
"openai",
"--model",
"gpt-5",
"--key",
"abc123",
"--thinking",
"high",
"--plan",
"Audit",
"the",
"repo",
]);
expect(parsed.prompt).toBe("Audit the repo");
expect(parsed.verbose).toBe(true);
expect(parsed.defaultToolAutoApprove).toBe(false);
expect(parsed.autoApproveOverride).toBe(false);
expect(parsed.thinking).toBe(true);
expect(parsed.reasoningEffort).toBe("high");
expect(parsed.outputMode).toBe("text");
expect(parsed.mode).toBe("plan");
expect(parsed.cwd).toBe("/tmp/work");
expect(parsed.teamName).toBe("dev-team");
expect(parsed.provider).toBe("openai");
expect(parsed.model).toBe("gpt-5");
expect(parsed.key).toBe("abc123");
expect(parsed.sandbox).toBe(false);
});
it("parses provider via -P shorthand", () => {
const parsed = parseArgs(["-P", "cline"]);
expect(parsed.provider).toBe("cline");
});
it("enables sandbox automatically when --data-dir is set", () => {
const parsed = parseArgs(["--data-dir", "./.tmp-cline"]);
expect(parsed.sandbox).toBe(true);
expect(parsed.dataDir).toBe("./.tmp-cline");
});
it("does not enable sandbox when --data-dir is omitted", () => {
const parsed = parseArgs([]);
expect(parsed.sandbox).toBe(false);
expect(parsed.dataDir).toBeUndefined();
});
it("parses --auto-approve false as global approval-off", () => {
const parsed = parseArgs([
"--auto-approve",
"false",
"tell me about this repo",
]);
expect(parsed.defaultToolAutoApprove).toBe(false);
expect(parsed.autoApproveOverride).toBe(false);
expect(parsed.prompt).toBe("tell me about this repo");
});
it("treats bare --auto-approve as true", () => {
expect(
normalizeAutoApproveArgs(["--auto-approve", "Audit the repo"]),
).toEqual(["--auto-approve", "true", "Audit the repo"]);
const parsed = parseArgs(["--auto-approve", "Audit the repo"]);
expect(parsed.defaultToolAutoApprove).toBe(true);
expect(parsed.autoApproveOverride).toBe(true);
expect(parsed.prompt).toBe("Audit the repo");
});
it("records invalid --auto-approve values", () => {
const parsed = parseArgs(["--auto-approve=maybe"]);
expect(parsed.invalidAutoApprove).toBe("maybe");
});
it("accepts legacy --autoapprove as --auto-approve", () => {
const parsed = parseArgs(["--autoapprove", "false"]);
expect(parsed.defaultToolAutoApprove).toBe(false);
expect(parsed.autoApproveOverride).toBe(false);
});
it("supports json output flags and validates explicit output modes", () => {
const parsedJsonAlias = parseArgs(["--json", "hello"]);
expect(parsedJsonAlias.outputMode).toBe("json");
expect(parsedJsonAlias.prompt).toBe("hello");
});
it("parses act/plan mode flags", () => {
const parsedPlan = parseArgs(["--plan"]);
expect(parsedPlan.mode).toBe("plan");
const parsedAct = parseArgs(["-a"]);
expect(parsedAct.mode).toBe("act");
});
it("parses bare --thinking as medium before a flag", () => {
const parsed = parseArgs(["--thinking", "--plan", "Audit the repo"]);
expect(parsed.thinking).toBe(true);
expect(parsed.reasoningEffort).toBe("medium");
expect(parsed.mode).toBe("plan");
expect(parsed.prompt).toBe("Audit the repo");
});
it("parses --thinking with explicit level", () => {
const parsed = parseArgs(["--thinking", "high"]);
expect(parsed.thinking).toBe(true);
expect(parsed.reasoningEffort).toBe("high");
});
it("parses --thinking none as disabled", () => {
const parsed = parseArgs(["--thinking", "none"]);
expect(parsed.thinking).toBe(false);
expect(parsed.reasoningEffort).toBeUndefined();
});
it("parses and validates thinking level with equals notation", () => {
const parsedInvalid = parseArgs(["--thinking=ultra"]);
expect(parsedInvalid.reasoningEffort).toBeUndefined();
expect(parsedInvalid.invalidThinkingLevel).toBe("ultra");
});
it("parses and validates thinking level with space notation", () => {
const parsedInvalid = parseArgs(["--thinking", "ultra", "Audit"]);
expect(parsedInvalid.reasoningEffort).toBeUndefined();
expect(parsedInvalid.invalidThinkingLevel).toBe("ultra");
expect(parsedInvalid.prompt).toBe("Audit");
});
it("accepts legacy --reasoning-effort as --thinking", () => {
const parsed = parseArgs(["--reasoning-effort", "high"]);
expect(parsed.thinking).toBe(true);
expect(parsed.reasoningEffort).toBe("high");
});
it("validates legacy --reasoning-effort invalid levels", () => {
const parsedSpace = parseArgs(["--reasoning-effort", "ultra", "Audit"]);
expect(parsedSpace.reasoningEffort).toBeUndefined();
expect(parsedSpace.invalidThinkingLevel).toBe("ultra");
expect(parsedSpace.prompt).toBe("Audit");
const parsedEquals = parseArgs(["--reasoning-effort=ultra", "Audit"]);
expect(parsedEquals.reasoningEffort).toBeUndefined();
expect(parsedEquals.invalidThinkingLevel).toBe("ultra");
expect(parsedEquals.prompt).toBe("Audit");
});
it("parses --retries when valid", () => {
const parsed = parseArgs(["--retries", "5"]);
expect(parsed.retries).toBe(5);
expect(parsed.invalidRetries).toBeUndefined();
});
it("supports yolo as an auto-approval shortcut", () => {
const parsedYolo = parseArgs(["--yolo"]);
expect(parsedYolo.mode).toBe("yolo");
expect(parsedYolo.defaultToolAutoApprove).toBe(true);
expect(parsedYolo.autoApproveOverride).toBe(true);
});
it("marks explicit mode flags so persisted settings do not override them", () => {
expect(parseArgs([]).modeExplicitlySet).toBe(false);
expect(parseArgs(["Audit the repo"]).modeExplicitlySet).toBe(false);
expect(parseArgs(["--plan"]).modeExplicitlySet).toBe(true);
expect(parseArgs(["--act"]).modeExplicitlySet).toBe(true);
expect(parseArgs(["--yolo"]).modeExplicitlySet).toBe(true);
expect(parseArgs(["--zen", "do it"]).modeExplicitlySet).toBe(true);
});
it("parses --zen flag for background hub dispatch", () => {
const parsedLong = parseArgs(["--zen", "do it"]);
expect(parsedLong.mode).toBe("zen");
expect(parsedLong.prompt).toBe("do it");
const parsedShort = parseArgs(["-z", "do it"]);
expect(parsedShort.mode).toBe("zen");
});
it("parses timeout and validates invalid values", () => {
const parsed = parseArgs(["-t", "30"]);
expect(parsed.timeoutSeconds).toBe(30);
const invalid = parseArgs(["--timeout", "abc"]);
expect(invalid.invalidTimeoutSeconds).toBe("abc");
});
it("records invalid --retries values", () => {
const parsed = parseArgs(["--retries", "0"]);
expect(parsed.retries).toBeUndefined();
expect(parsed.invalidRetries).toBe("0");
});
});
describe("format helpers", () => {
it("truncates run_commands with commands array", () => {
const result = formatToolInput("run_commands", {
commands: [
"echo hello",
"npm run very-very-long-command-name-that-will-truncate",
],
});
expect(result).toContain("echo hello");
expect(result.length).toBeLessThanOrEqual(120);
});
it("truncates run_commands with commands as single string", () => {
const longCmd = `echo ${"x".repeat(200)}`;
const result = formatToolInput("run_commands", { commands: longCmd });
expect(result).toContain("echo");
expect(result.length).toBeLessThanOrEqual(120);
});
it("truncates run_commands with bare string input", () => {
const longCmd = `echo ${"x".repeat(200)}`;
const result = formatToolInput("run_commands", longCmd);
expect(result).toContain("echo");
expect(result.length).toBeLessThanOrEqual(120);
});
it("truncates run_commands with bare string array input", () => {
const result = formatToolInput("run_commands", [
"echo hello",
"echo world",
]);
expect(result).toContain("echo hello; echo world");
});
it("truncates run_commands with structured command input", () => {
const result = formatToolInput("run_commands", {
commands: [{ command: "git", args: ["status", "--short"] }],
});
expect(result).toContain("git status --short");
});
it("truncates run_commands with bare structured command", () => {
const result = formatToolInput("run_commands", {
command: "git",
args: ["log", "--oneline"],
});
expect(result).toContain("git log --oneline");
});
it("handles structured command with non-array args gracefully", () => {
const result = formatToolInput("run_commands", {
commands: [{ command: "git", args: "status" }],
});
expect(result).toBe("git");
});
it("formats known tool input payloads with truncation", () => {
expect(
formatToolInput("team_run_task", {
runMode: "sync",
agentId: "coder",
task: "implement feature with extensive acceptance criteria and checks",
}),
).toContain("sync coder:");
expect(
formatToolInput("team_member", {
action: "spawn",
agentId: "reviewer",
rolePrompt: "Review changes and call out risks",
}),
).toContain("spawn reviewer:");
expect(
formatToolInput("team_task", {
action: "complete",
taskId: "task_0012",
summary: "Done and verified",
}),
).toContain("complete task_0012:");
expect(
formatToolInput("team_message", {
action: "send",
toAgentId: "lead",
subject: "Status update",
}),
).toContain("send lead:");
});
it("formats ask_question as a readable prompt", () => {
expect(
formatToolInput("ask_question", {
question: "How can I best assist you today?",
options: [
"Help me understand or analyze code in a repository",
"Help me create or edit files",
"Help me run commands or tests",
],
}),
).toBe(
[
"The agent is waiting for your input.",
"How can I best assist you today?",
"1. Help me understand or analyze code in a repository",
"2. Help me create or edit files",
"3. Help me run commands or tests",
"> Reply with an option number or type your answer.",
].join("\n"),
);
});
it("summarizes structured tool outputs", () => {
expect(formatToolOutput("simple text output")).toBe("simple text output");
expect(
formatToolOutput([
{ result: "first" },
{ result: "second" },
{ result: "third" },
]),
).toBe("first (+2 more)");
expect(formatToolOutput(null)).toBe("");
});
});
describe("hook payload validation and audit logging", () => {
let tempDir = "";
afterEach(() => {
if (tempDir) {
rmSync(tempDir, { recursive: true, force: true });
tempDir = "";
}
});
it("validates hook payload structure", async () => {
expect(
await isCliHookPayload({
clineVersion: "",
hookName: "tool_call",
timestamp: new Date().toISOString(),
taskId: "conv_1",
workspaceRoots: [],
userId: "agent_1",
agent_id: "agent_1",
parent_agent_id: null,
iteration: 1,
tool_call: {
id: "call_1",
name: "read_files",
input: { file_paths: ["README.md"] },
},
}),
).toBe(true);
expect(await isCliHookPayload({ hookName: "tool_call" })).toBe(false);
expect(await isCliHookPayload(null)).toBe(false);
});
it("writes hook audits to global log", async () => {
tempDir = mkdtempSync(path.join(os.tmpdir(), "cli-helper-audit-"));
const expectedPath = path.join(tempDir, "logs", "hooks.jsonl");
const env = captureEnv();
process.env.CLINE_DATA_DIR = tempDir;
delete process.env.CLINE_HOOKS_LOG_PATH;
delete process.env.CLINE_SESSION_ID;
delete process.env.CLINE_SESSION_DATA_DIR;
await appendHookAudit({
clineVersion: "",
hookName: "tool_call",
timestamp: new Date().toISOString(),
taskId: "conv_1",
sessionContext: { rootSessionId: "session_from_context" },
workspaceRoots: [],
userId: "agent_1",
iteration: 1,
agent_id: "agent_1",
parent_agent_id: null,
tool_call: {
id: "call_1",
name: "read_files",
input: { file_paths: ["README.md"] },
},
});
restoreEnv(env);
expect(existsSync(expectedPath)).toBe(true);
const content = readFileSync(expectedPath, "utf8");
expect(content).toContain('"hookName":"tool_call"');
expect(content).toContain('"agent_id":"agent_1"');
});
it("writes hook audits to CLINE_HOOKS_LOG_PATH when set", async () => {
tempDir = mkdtempSync(path.join(os.tmpdir(), "cli-helper-env-audit-"));
const expectedPath = path.join(tempDir, "hooks", "from-env.jsonl");
const env = captureEnv();
process.env.CLINE_HOOKS_LOG_PATH = expectedPath;
delete process.env.CLINE_DATA_DIR;
delete process.env.CLINE_SESSION_ID;
delete process.env.CLINE_SESSION_DATA_DIR;
await appendHookAudit({
clineVersion: "",
hookName: "tool_result",
timestamp: new Date().toISOString(),
taskId: "conv_3",
workspaceRoots: [],
userId: "agent_3",
iteration: 1,
agent_id: "agent_3",
parent_agent_id: null,
tool_result: {
id: "call_3",
name: "read_files",
input: { file_paths: ["README.md"] },
output: "ok",
durationMs: 5,
startedAt: new Date("2026-01-01T00:00:00.000Z"),
endedAt: new Date("2026-01-01T00:00:00.005Z"),
},
});
restoreEnv(env);
expect(existsSync(expectedPath)).toBe(true);
const content = readFileSync(expectedPath, "utf8");
expect(content).toContain('"hookName":"tool_result"');
});
});
describe("sandbox environment", () => {
it("sets sandbox-specific storage paths", () => {
const root = mkdtempSync(path.join(os.tmpdir(), "cli-helper-sandbox-"));
const previous = {
CLINE_SANDBOX: process.env.CLINE_SANDBOX,
CLINE_SANDBOX_DATA_DIR: process.env.CLINE_SANDBOX_DATA_DIR,
CLINE_DATA_DIR: process.env.CLINE_DATA_DIR,
CLINE_DB_DATA_DIR: process.env.CLINE_DB_DATA_DIR,
CLINE_SESSION_DATA_DIR: process.env.CLINE_SESSION_DATA_DIR,
CLINE_TEAM_DATA_DIR: process.env.CLINE_TEAM_DATA_DIR,
CLINE_PROVIDER_SETTINGS_PATH: process.env.CLINE_PROVIDER_SETTINGS_PATH,
CLINE_HOOKS_LOG_PATH: process.env.CLINE_HOOKS_LOG_PATH,
};
try {
const resolved = configureSandboxEnvironment({
enabled: true,
cwd: root,
explicitDir: "./sandbox-state",
});
expect(resolved).toBe(path.join(root, "sandbox-state"));
expect(process.env.CLINE_SANDBOX).toBe("1");
expect(process.env.CLINE_SANDBOX_DATA_DIR).toBe(
path.join(root, "sandbox-state"),
);
expect(process.env.CLINE_DATA_DIR).toBe(path.join(root, "sandbox-state"));
expect(process.env.CLINE_DB_DATA_DIR).toBe(
path.join(root, "sandbox-state", "db"),
);
expect(process.env.CLINE_SESSION_DATA_DIR).toBe(
path.join(root, "sandbox-state", "sessions"),
);
expect(process.env.CLINE_TEAM_DATA_DIR).toBe(
path.join(root, "sandbox-state", "teams"),
);
expect(process.env.CLINE_PROVIDER_SETTINGS_PATH).toBe(
path.join(root, "sandbox-state", "settings", "providers.json"),
);
expect(process.env.CLINE_HOOKS_LOG_PATH).toBe(
path.join(root, "sandbox-state", "logs", "hooks.jsonl"),
);
} finally {
process.env.CLINE_SANDBOX = previous.CLINE_SANDBOX;
process.env.CLINE_SANDBOX_DATA_DIR = previous.CLINE_SANDBOX_DATA_DIR;
process.env.CLINE_DATA_DIR = previous.CLINE_DATA_DIR;
process.env.CLINE_DB_DATA_DIR = previous.CLINE_DB_DATA_DIR;
process.env.CLINE_SESSION_DATA_DIR = previous.CLINE_SESSION_DATA_DIR;
process.env.CLINE_TEAM_DATA_DIR = previous.CLINE_TEAM_DATA_DIR;
process.env.CLINE_PROVIDER_SETTINGS_PATH =
previous.CLINE_PROVIDER_SETTINGS_PATH;
process.env.CLINE_HOOKS_LOG_PATH = previous.CLINE_HOOKS_LOG_PATH;
rmSync(root, { recursive: true, force: true });
}
});
});