mirror of
https://github.com/cline/cline.git
synced 2026-09-19 02:05:44 +08:00
* Add Cline Desktop launch CTA to extension home banner and CLI startup notice Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com> * Show Cline Desktop CTA on all platforms Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com> * Drop hardcoded extension desktop banner in favor of remote banner Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com> --------- Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2019 lines
61 KiB
TypeScript
2019 lines
61 KiB
TypeScript
import { fstatSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import type {
|
|
CliMigrationNotice,
|
|
CliMigrationNoticeOptions,
|
|
} from "./kanban-migration/notice";
|
|
|
|
/** Real `fstatSync`: used when tests stub only stdin (fd 0); throwing for every fd breaks imports and session I/O. */
|
|
const fsActual = vi.hoisted(() => ({
|
|
realFstatSync: null as null | typeof import("node:fs").fstatSync,
|
|
}));
|
|
vi.mock("node:fs", async () => {
|
|
const actual = await vi.importActual<typeof import("node:fs")>("node:fs");
|
|
fsActual.realFstatSync = actual.fstatSync;
|
|
return { ...actual, fstatSync: vi.fn(actual.fstatSync) };
|
|
});
|
|
|
|
const originalArgv = [...process.argv];
|
|
const originalStdinIsTTY = process.stdin.isTTY;
|
|
const originalStdoutIsTTY = process.stdout.isTTY;
|
|
const originalGlobalSettingsPath = process.env.CLINE_GLOBAL_SETTINGS_PATH;
|
|
const mockState = vi.hoisted(() => ({
|
|
runAgentImports: 0,
|
|
runInteractiveImports: 0,
|
|
runAgentCalls: 0,
|
|
}));
|
|
const authMocks = vi.hoisted(() => ({
|
|
ensureOAuthProviderApiKey: vi.fn(),
|
|
getPersistedProviderApiKey: vi.fn(() => undefined),
|
|
isOAuthProvider: vi.fn(() => false),
|
|
normalizeProviderId: vi.fn((providerId?: string) => providerId ?? "cline"),
|
|
parseAuthCommandArgs: vi.fn(),
|
|
runAuthCommand: vi.fn(),
|
|
}));
|
|
const providerSettingsMocks = vi.hoisted(() => ({
|
|
getLastUsedProviderSettings: vi.fn<(options?: unknown) => unknown>(
|
|
() => undefined,
|
|
),
|
|
getProviderConfig: vi.fn<(providerId: string, options?: unknown) => unknown>(
|
|
() => undefined,
|
|
),
|
|
getProviderSettings: vi.fn<(providerId: string) => unknown>(() => undefined),
|
|
saveProviderSettings: vi.fn<(settings: unknown, options?: unknown) => void>(
|
|
() => {},
|
|
),
|
|
}));
|
|
const sessionMocks = vi.hoisted(() => ({
|
|
deleteSession: vi.fn(),
|
|
getSessionRow: vi.fn(),
|
|
listSessions: vi.fn(async () => []),
|
|
updateSession: vi.fn(),
|
|
}));
|
|
const llmMocks = vi.hoisted(() => ({
|
|
resolveProviderConfig: vi.fn(async (): Promise<unknown> => undefined),
|
|
}));
|
|
const promptMocks = vi.hoisted(() => ({
|
|
resolveSystemPrompt: vi.fn(async () => "system prompt"),
|
|
}));
|
|
const kanbanMocks = vi.hoisted(() => ({
|
|
launchKanban: vi.fn(),
|
|
}));
|
|
const dashboardMocks = vi.hoisted(() => ({
|
|
runDashboardCommand: vi.fn(),
|
|
}));
|
|
const connectMocks = vi.hoisted(() => ({
|
|
formatAdapterList: vi.fn(() => ""),
|
|
runCleanupConnectorInstance: vi.fn(async () => 0),
|
|
runConnectAdapter: vi.fn(async () => 0),
|
|
runRestartConnector: vi.fn(async () => 0),
|
|
runStopAllConnectors: vi.fn(async () => 0),
|
|
runStopConnector: vi.fn(async () => 0),
|
|
}));
|
|
const migrationNoticeMocks = vi.hoisted(() => ({
|
|
getClineCliMigrationNotice: vi.fn<
|
|
(
|
|
dataDir?: string,
|
|
env?: NodeJS.ProcessEnv,
|
|
options?: CliMigrationNoticeOptions,
|
|
) => CliMigrationNotice | undefined
|
|
>(() => undefined),
|
|
markClineCliMigrationNoticeShown: vi.fn(),
|
|
}));
|
|
const updateMocks = vi.hoisted(() => ({
|
|
autoUpdateOnStartup: vi.fn(),
|
|
checkForUpdates: vi.fn(async () => 0),
|
|
getPreferredKanbanInstaller: vi.fn(() => undefined),
|
|
}));
|
|
const runtimeMocks = vi.hoisted(() => ({
|
|
runAgent: vi.fn(async () => {
|
|
mockState.runAgentCalls += 1;
|
|
}),
|
|
runInteractive: vi.fn(),
|
|
}));
|
|
const worktreeMocks = vi.hoisted(() => ({
|
|
createTaskWorktree: vi.fn(),
|
|
}));
|
|
const historyMocks = vi.hoisted(() => ({
|
|
runHistoryList: vi.fn<() => Promise<number>>(async () => 0),
|
|
runHistoryDelete: vi.fn(async () => 0),
|
|
runHistoryExport: vi.fn(async () => 0),
|
|
runHistoryUpdate: vi.fn(async () => 0),
|
|
}));
|
|
const loggingMocks = vi.hoisted(() => ({
|
|
createCliLoggerAdapter: vi.fn(() => ({
|
|
core: {
|
|
debug: vi.fn(),
|
|
log: vi.fn(),
|
|
error: vi.fn(),
|
|
},
|
|
runtimeConfig: undefined,
|
|
})),
|
|
flushCliLoggerAdapters: vi.fn(),
|
|
}));
|
|
const hubRuntimeMocks = vi.hoisted(() => ({
|
|
ensureCliHubServer: vi.fn(async () => ({
|
|
url: "ws://127.0.0.1:25463",
|
|
authToken: "test-token",
|
|
})),
|
|
}));
|
|
const telemetryMocks = vi.hoisted(() => ({
|
|
captureCliExtensionActivated: vi.fn(),
|
|
identifyTelemetryAccount: vi.fn(),
|
|
getCliTelemetryService: vi.fn(),
|
|
disposeCliTelemetryService: vi.fn(async () => {}),
|
|
}));
|
|
const featureFlagMocks = vi.hoisted(() => ({
|
|
getBooleanFlagEnabled: vi.fn(() => false),
|
|
setCliFeatureFlagsAccountContext: vi.fn(),
|
|
refreshCliFeatureFlagsInBackground: vi.fn(),
|
|
}));
|
|
|
|
function forcePromptModeInput() {
|
|
Object.defineProperty(process.stdin, "isTTY", {
|
|
value: true,
|
|
configurable: true,
|
|
});
|
|
vi.mocked(fstatSync).mockImplementation((fd, ...rest) => {
|
|
if (fd === 0) {
|
|
throw new Error("stdin not piped");
|
|
}
|
|
const real = fsActual.realFstatSync;
|
|
if (!real) {
|
|
throw new Error("node:fs fstatSync mock not initialized");
|
|
}
|
|
return real(fd, ...rest) as
|
|
| import("node:fs").Stats
|
|
| import("node:fs").BigIntStats;
|
|
});
|
|
}
|
|
|
|
vi.mock("./runtime/run-agent", () => {
|
|
mockState.runAgentImports += 1;
|
|
return {
|
|
runAgent: runtimeMocks.runAgent,
|
|
};
|
|
});
|
|
vi.mock("./runtime/run-interactive", () => {
|
|
mockState.runInteractiveImports += 1;
|
|
return {
|
|
runInteractive: runtimeMocks.runInteractive,
|
|
};
|
|
});
|
|
vi.mock("./utils/session", () => sessionMocks);
|
|
vi.mock("./session/session", () => sessionMocks);
|
|
vi.mock("@cline/core", async () => {
|
|
// Keep dispatch tests independent of the full SDK runtime import graph.
|
|
// Only persisted-settings behavior needs its real implementation here.
|
|
const { readGlobalSettings } = await vi.importActual<
|
|
typeof import("../../../sdk/packages/core/src/services/global-settings")
|
|
>("../../../sdk/packages/core/src/services/global-settings");
|
|
return {
|
|
readGlobalSettings,
|
|
setSdkLogger: vi.fn(),
|
|
resolveProviderConfig: llmMocks.resolveProviderConfig,
|
|
createTeamName: vi.fn(() => "team-test"),
|
|
createUserInstructionConfigService: vi.fn(() => ({
|
|
start: vi.fn(async () => {}),
|
|
stop: vi.fn(),
|
|
})),
|
|
ProviderSettingsManager: class {
|
|
getLastUsedProviderSettings(options?: unknown) {
|
|
return providerSettingsMocks.getLastUsedProviderSettings(options);
|
|
}
|
|
getProviderSettings(providerId: string) {
|
|
return providerSettingsMocks.getProviderSettings(providerId);
|
|
}
|
|
getProviderConfig(providerId: string, options?: unknown) {
|
|
return providerSettingsMocks.getProviderConfig(providerId, options);
|
|
}
|
|
saveProviderSettings(settings: unknown, options?: unknown) {
|
|
providerSettingsMocks.saveProviderSettings(settings, options);
|
|
}
|
|
},
|
|
};
|
|
});
|
|
vi.mock("./utils/provider-auth", () => authMocks);
|
|
vi.mock("./utils/feature-flags", () => ({
|
|
getCliFeatureFlagsService: () => ({
|
|
getBooleanFlagEnabled: featureFlagMocks.getBooleanFlagEnabled,
|
|
}),
|
|
refreshCliFeatureFlagsInBackground:
|
|
featureFlagMocks.refreshCliFeatureFlagsInBackground,
|
|
setCliFeatureFlagsAccountContext:
|
|
featureFlagMocks.setCliFeatureFlagsAccountContext,
|
|
}));
|
|
vi.mock("./runtime/prompt", () => ({
|
|
resolveSystemPrompt: promptMocks.resolveSystemPrompt,
|
|
}));
|
|
vi.mock("./commands/kanban", () => kanbanMocks);
|
|
vi.mock("./commands/dashboard", () => dashboardMocks);
|
|
vi.mock("./commands/connect", () => connectMocks);
|
|
vi.mock("./kanban-migration/notice", () => migrationNoticeMocks);
|
|
vi.mock("./commands/update", () => updateMocks);
|
|
vi.mock("./commands/history", () => historyMocks);
|
|
vi.mock("./logging/adapter", () => loggingMocks);
|
|
vi.mock("./utils/hub-runtime", () => hubRuntimeMocks);
|
|
vi.mock("./utils/telemetry", () => telemetryMocks);
|
|
vi.mock("./utils/worktree", () => worktreeMocks);
|
|
|
|
describe("runCli lightweight command dispatch", () => {
|
|
let globalSettingsRoot: string | undefined;
|
|
|
|
beforeEach(() => {
|
|
process.exitCode = undefined;
|
|
// Startup now reads persisted general settings; point the resolver at a
|
|
// fresh temp file so the developer's real settings cannot leak in.
|
|
globalSettingsRoot = mkdtempSync(join(tmpdir(), "cline-cli-main-test-"));
|
|
process.env.CLINE_GLOBAL_SETTINGS_PATH = join(
|
|
globalSettingsRoot,
|
|
"global-settings.json",
|
|
);
|
|
mockState.runAgentImports = 0;
|
|
mockState.runInteractiveImports = 0;
|
|
mockState.runAgentCalls = 0;
|
|
historyMocks.runHistoryList.mockReset();
|
|
historyMocks.runHistoryList.mockResolvedValue(0);
|
|
historyMocks.runHistoryDelete.mockReset();
|
|
historyMocks.runHistoryDelete.mockResolvedValue(0);
|
|
historyMocks.runHistoryExport.mockReset();
|
|
historyMocks.runHistoryExport.mockResolvedValue(0);
|
|
historyMocks.runHistoryUpdate.mockReset();
|
|
historyMocks.runHistoryUpdate.mockResolvedValue(0);
|
|
sessionMocks.getSessionRow.mockReset();
|
|
sessionMocks.getSessionRow.mockResolvedValue({
|
|
sessionId: "sess_123",
|
|
});
|
|
runtimeMocks.runAgent.mockReset();
|
|
runtimeMocks.runAgent.mockImplementation(async () => {
|
|
mockState.runAgentCalls += 1;
|
|
});
|
|
runtimeMocks.runInteractive.mockReset();
|
|
worktreeMocks.createTaskWorktree.mockReset();
|
|
worktreeMocks.createTaskWorktree.mockResolvedValue({
|
|
success: true,
|
|
message: "Worktree created",
|
|
path: "/tmp/cline-worktree",
|
|
taskId: "task-1",
|
|
repoRoot: "/tmp/source",
|
|
});
|
|
hubRuntimeMocks.ensureCliHubServer.mockReset();
|
|
hubRuntimeMocks.ensureCliHubServer.mockResolvedValue({
|
|
url: "ws://127.0.0.1:25463",
|
|
authToken: "test-token",
|
|
});
|
|
llmMocks.resolveProviderConfig.mockReset();
|
|
llmMocks.resolveProviderConfig.mockResolvedValue(undefined);
|
|
authMocks.ensureOAuthProviderApiKey.mockReset();
|
|
authMocks.getPersistedProviderApiKey.mockReset();
|
|
authMocks.getPersistedProviderApiKey.mockReturnValue(undefined);
|
|
authMocks.isOAuthProvider.mockReset();
|
|
authMocks.isOAuthProvider.mockReturnValue(false);
|
|
authMocks.normalizeProviderId.mockReset();
|
|
authMocks.normalizeProviderId.mockImplementation(
|
|
(providerId?: string) => providerId ?? "cline",
|
|
);
|
|
authMocks.parseAuthCommandArgs.mockReset();
|
|
authMocks.runAuthCommand.mockReset();
|
|
providerSettingsMocks.getLastUsedProviderSettings.mockReset();
|
|
providerSettingsMocks.getLastUsedProviderSettings.mockReturnValue(
|
|
undefined,
|
|
);
|
|
providerSettingsMocks.getProviderSettings.mockReset();
|
|
providerSettingsMocks.getProviderSettings.mockReturnValue(undefined);
|
|
providerSettingsMocks.saveProviderSettings.mockReset();
|
|
featureFlagMocks.getBooleanFlagEnabled.mockReset();
|
|
featureFlagMocks.getBooleanFlagEnabled.mockReturnValue(false);
|
|
featureFlagMocks.setCliFeatureFlagsAccountContext.mockReset();
|
|
featureFlagMocks.refreshCliFeatureFlagsInBackground.mockReset();
|
|
kanbanMocks.launchKanban.mockReset();
|
|
kanbanMocks.launchKanban.mockResolvedValue(0);
|
|
dashboardMocks.runDashboardCommand.mockReset();
|
|
dashboardMocks.runDashboardCommand.mockResolvedValue(0);
|
|
connectMocks.formatAdapterList.mockReset();
|
|
connectMocks.formatAdapterList.mockReturnValue("");
|
|
connectMocks.runConnectAdapter.mockReset();
|
|
connectMocks.runConnectAdapter.mockResolvedValue(0);
|
|
connectMocks.runRestartConnector.mockReset();
|
|
connectMocks.runRestartConnector.mockResolvedValue(0);
|
|
connectMocks.runStopAllConnectors.mockReset();
|
|
connectMocks.runStopAllConnectors.mockResolvedValue(0);
|
|
connectMocks.runStopConnector.mockReset();
|
|
connectMocks.runStopConnector.mockResolvedValue(0);
|
|
migrationNoticeMocks.getClineCliMigrationNotice.mockReset();
|
|
migrationNoticeMocks.getClineCliMigrationNotice.mockReturnValue(undefined);
|
|
migrationNoticeMocks.markClineCliMigrationNoticeShown.mockReset();
|
|
updateMocks.autoUpdateOnStartup.mockReset();
|
|
updateMocks.checkForUpdates.mockReset();
|
|
updateMocks.checkForUpdates.mockResolvedValue(0);
|
|
updateMocks.getPreferredKanbanInstaller.mockReset();
|
|
updateMocks.getPreferredKanbanInstaller.mockReturnValue(undefined);
|
|
telemetryMocks.captureCliExtensionActivated.mockReset();
|
|
telemetryMocks.identifyTelemetryAccount.mockReset();
|
|
telemetryMocks.getCliTelemetryService.mockReset();
|
|
telemetryMocks.disposeCliTelemetryService.mockReset();
|
|
telemetryMocks.disposeCliTelemetryService.mockResolvedValue(undefined);
|
|
// CI: fd 0 is often a pipe with no EOF. If routing ever falls through to agent bootstrap,
|
|
// `main` can block forever in `for await (process.stdin)` (see `!process.stdin.isTTY && …`).
|
|
// Mark stdin as a TTY so that path is skipped in unit tests (real piped-input behavior is
|
|
// covered elsewhere). `forcePromptModeInput()` in agent tests still tightens fstat on fd 0.
|
|
Object.defineProperty(process.stdin, "isTTY", {
|
|
value: true,
|
|
configurable: true,
|
|
});
|
|
Object.defineProperty(process.stdout, "isTTY", {
|
|
value: true,
|
|
configurable: true,
|
|
});
|
|
});
|
|
|
|
afterEach(() => {
|
|
process.exitCode = undefined;
|
|
|
|
if (originalGlobalSettingsPath === undefined) {
|
|
delete process.env.CLINE_GLOBAL_SETTINGS_PATH;
|
|
} else {
|
|
process.env.CLINE_GLOBAL_SETTINGS_PATH = originalGlobalSettingsPath;
|
|
}
|
|
if (globalSettingsRoot) {
|
|
rmSync(globalSettingsRoot, { recursive: true, force: true });
|
|
globalSettingsRoot = undefined;
|
|
}
|
|
|
|
process.argv = [...originalArgv];
|
|
Object.defineProperty(process.stdin, "isTTY", {
|
|
value: originalStdinIsTTY,
|
|
configurable: true,
|
|
});
|
|
Object.defineProperty(process.stdout, "isTTY", {
|
|
value: originalStdoutIsTTY,
|
|
configurable: true,
|
|
});
|
|
vi.restoreAllMocks();
|
|
vi.resetModules();
|
|
});
|
|
|
|
it("does not load runtime modules for history json listing", async () => {
|
|
mockState.runAgentImports = 0;
|
|
mockState.runInteractiveImports = 0;
|
|
|
|
process.argv = ["bun", "src/index.ts", "history", "--json"];
|
|
|
|
const { runCli } = await import("./main");
|
|
|
|
await expect(runCli()).resolves.toBeUndefined();
|
|
expect(process.exitCode).toBe(0);
|
|
expect(historyMocks.runHistoryList).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
limit: 50,
|
|
outputMode: "json",
|
|
}),
|
|
);
|
|
const historyListCalls = historyMocks.runHistoryList.mock
|
|
.calls as unknown as Array<[Record<string, unknown>]>;
|
|
expect(historyListCalls[0]?.[0]).not.toHaveProperty("workspaceRoot");
|
|
expect(mockState.runAgentImports).toBe(0);
|
|
expect(mockState.runInteractiveImports).toBe(0);
|
|
}, 30_000);
|
|
|
|
it("routes connector restart arguments through the restart lifecycle", async () => {
|
|
process.argv = [
|
|
"bun",
|
|
"src/index.ts",
|
|
"connect",
|
|
"--restart",
|
|
"telegram",
|
|
"-k",
|
|
"token",
|
|
];
|
|
|
|
const { runCli } = await import("./main");
|
|
|
|
await expect(runCli()).resolves.toBeUndefined();
|
|
expect(process.exitCode).toBe(0);
|
|
expect(connectMocks.runRestartConnector).toHaveBeenCalledWith(
|
|
"telegram",
|
|
["-k", "token"],
|
|
expect.any(Object),
|
|
undefined,
|
|
);
|
|
expect(connectMocks.runConnectAdapter).not.toHaveBeenCalled();
|
|
expect(connectMocks.runStopConnector).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("routes a supervised cleanup to one connector instance", async () => {
|
|
connectMocks.runCleanupConnectorInstance.mockClear();
|
|
connectMocks.runConnectAdapter.mockClear();
|
|
process.argv = [
|
|
"bun",
|
|
"src/index.ts",
|
|
"connect",
|
|
"--cleanup-instance",
|
|
"cline-slack",
|
|
"slack",
|
|
];
|
|
|
|
const { runCli } = await import("./main");
|
|
|
|
await expect(runCli()).resolves.toBeUndefined();
|
|
expect(process.exitCode).toBe(0);
|
|
expect(connectMocks.runCleanupConnectorInstance).toHaveBeenCalledWith(
|
|
"slack",
|
|
"cline-slack",
|
|
expect.any(Object),
|
|
);
|
|
expect(connectMocks.runConnectAdapter).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("rejects combining cleanup with another connect mode", async () => {
|
|
connectMocks.runCleanupConnectorInstance.mockClear();
|
|
connectMocks.runStopConnector.mockClear();
|
|
process.argv = [
|
|
"bun",
|
|
"src/index.ts",
|
|
"connect",
|
|
"--cleanup-instance",
|
|
"cline-slack",
|
|
"--stop",
|
|
"slack",
|
|
];
|
|
|
|
const { runCli } = await import("./main");
|
|
|
|
await runCli();
|
|
expect(process.exitCode).toBe(1);
|
|
expect(connectMocks.runCleanupConnectorInstance).not.toHaveBeenCalled();
|
|
expect(connectMocks.runStopConnector).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("requires a channel for a supervised cleanup", async () => {
|
|
connectMocks.runCleanupConnectorInstance.mockClear();
|
|
process.argv = [
|
|
"bun",
|
|
"src/index.ts",
|
|
"connect",
|
|
"--cleanup-instance",
|
|
"x",
|
|
];
|
|
|
|
const { runCli } = await import("./main");
|
|
|
|
await runCli();
|
|
expect(process.exitCode).toBe(1);
|
|
expect(connectMocks.runCleanupConnectorInstance).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("routes a targeted connector restart to one instance", async () => {
|
|
process.argv = [
|
|
"bun",
|
|
"src/index.ts",
|
|
"connect",
|
|
"--restart-instance",
|
|
"cline_bot",
|
|
"telegram",
|
|
"-k",
|
|
"token",
|
|
];
|
|
|
|
const { runCli } = await import("./main");
|
|
|
|
await expect(runCli()).resolves.toBeUndefined();
|
|
expect(process.exitCode).toBe(0);
|
|
expect(connectMocks.runRestartConnector).toHaveBeenCalledWith(
|
|
"telegram",
|
|
["-k", "token"],
|
|
expect.any(Object),
|
|
"cline_bot",
|
|
);
|
|
});
|
|
|
|
it("does not load runtime modules for root update", async () => {
|
|
mockState.runAgentImports = 0;
|
|
mockState.runInteractiveImports = 0;
|
|
|
|
process.argv = ["bun", "src/index.ts", "--update", "--verbose"];
|
|
|
|
const { runCli } = await import("./main");
|
|
|
|
await expect(runCli()).resolves.toBeUndefined();
|
|
expect(process.exitCode).toBe(0);
|
|
expect(updateMocks.checkForUpdates).toHaveBeenCalledWith({
|
|
verbose: true,
|
|
});
|
|
expect(mockState.runAgentImports).toBe(0);
|
|
expect(mockState.runInteractiveImports).toBe(0);
|
|
});
|
|
|
|
it("does not load runtime modules for root kanban flag", async () => {
|
|
mockState.runAgentImports = 0;
|
|
mockState.runInteractiveImports = 0;
|
|
|
|
process.argv = ["bun", "src/index.ts", "--kanban"];
|
|
|
|
const { runCli } = await import("./main");
|
|
|
|
await expect(runCli()).resolves.toBeUndefined();
|
|
expect(process.exitCode).toBe(0);
|
|
expect(kanbanMocks.launchKanban).toHaveBeenCalledTimes(1);
|
|
expect(mockState.runAgentImports).toBe(0);
|
|
expect(mockState.runInteractiveImports).toBe(0);
|
|
});
|
|
|
|
it("rejects root kanban with a prompt", async () => {
|
|
mockState.runAgentImports = 0;
|
|
mockState.runInteractiveImports = 0;
|
|
|
|
process.argv = ["bun", "src/index.ts", "--kanban", "hello"];
|
|
|
|
const { runCli } = await import("./main");
|
|
|
|
await expect(runCli()).resolves.toBeUndefined();
|
|
expect(process.exitCode).toBe(1);
|
|
expect(kanbanMocks.launchKanban).not.toHaveBeenCalled();
|
|
expect(mockState.runAgentImports).toBe(0);
|
|
expect(mockState.runInteractiveImports).toBe(0);
|
|
});
|
|
|
|
it("exits gracefully for handled command errors", async () => {
|
|
mockState.runAgentImports = 0;
|
|
mockState.runInteractiveImports = 0;
|
|
|
|
process.argv = ["bun", "src/index.ts", "history", "delete"];
|
|
|
|
const { runCli } = await import("./main");
|
|
|
|
await expect(runCli()).resolves.toBeUndefined();
|
|
expect(process.exitCode).toBe(1);
|
|
expect(mockState.runAgentImports).toBe(0);
|
|
expect(mockState.runInteractiveImports).toBe(0);
|
|
});
|
|
|
|
it("does not load runtime modules for history export", async () => {
|
|
mockState.runAgentImports = 0;
|
|
mockState.runInteractiveImports = 0;
|
|
|
|
process.argv = ["bun", "src/index.ts", "history", "export", "sess_1"];
|
|
|
|
const { runCli } = await import("./main");
|
|
|
|
await expect(runCli()).resolves.toBeUndefined();
|
|
expect(process.exitCode).toBe(0);
|
|
expect(historyMocks.runHistoryExport).toHaveBeenCalledWith(
|
|
"sess_1",
|
|
undefined,
|
|
"text",
|
|
expect.any(Object),
|
|
);
|
|
expect(mockState.runAgentImports).toBe(0);
|
|
expect(mockState.runInteractiveImports).toBe(0);
|
|
});
|
|
|
|
it("does not load interactive runtime for single-prompt mode", async () => {
|
|
forcePromptModeInput();
|
|
process.argv = ["bun", "src/index.ts", "say hello"];
|
|
|
|
const { runCli } = await import("./main");
|
|
|
|
await expect(runCli()).resolves.toBeUndefined();
|
|
expect(runtimeMocks.runAgent).toHaveBeenCalledTimes(1);
|
|
expect(mockState.runAgentImports).toBe(1);
|
|
expect(mockState.runInteractiveImports).toBe(0);
|
|
});
|
|
|
|
it("rejects a single bare positional prompt token", async () => {
|
|
const consoleError = vi
|
|
.spyOn(console, "error")
|
|
.mockImplementation(() => undefined);
|
|
forcePromptModeInput();
|
|
process.argv = ["bun", "src/index.ts", "nonexistent-command"];
|
|
|
|
const { runCli } = await import("./main");
|
|
|
|
await expect(runCli()).resolves.toBeUndefined();
|
|
expect(process.exitCode).toBe(1);
|
|
expect(consoleError).toHaveBeenCalledWith(
|
|
expect.stringContaining(
|
|
"Unknown command or unquoted prompt: nonexistent-command",
|
|
),
|
|
);
|
|
expect(consoleError).toHaveBeenCalledWith(
|
|
expect.stringContaining('Use "cline --help"'),
|
|
);
|
|
expect(runtimeMocks.runAgent).not.toHaveBeenCalled();
|
|
expect(mockState.runAgentImports).toBe(0);
|
|
expect(mockState.runInteractiveImports).toBe(0);
|
|
});
|
|
|
|
it("rejects multiple bare positional prompt tokens", async () => {
|
|
const consoleError = vi
|
|
.spyOn(console, "error")
|
|
.mockImplementation(() => undefined);
|
|
forcePromptModeInput();
|
|
process.argv = ["bun", "src/index.ts", "hello", "world"];
|
|
|
|
const { runCli } = await import("./main");
|
|
|
|
await expect(runCli()).resolves.toBeUndefined();
|
|
expect(process.exitCode).toBe(1);
|
|
expect(consoleError).toHaveBeenCalledWith(
|
|
expect.stringContaining(
|
|
"Unknown command or unquoted prompt: hello world",
|
|
),
|
|
);
|
|
expect(runtimeMocks.runAgent).not.toHaveBeenCalled();
|
|
expect(mockState.runAgentImports).toBe(0);
|
|
expect(mockState.runInteractiveImports).toBe(0);
|
|
});
|
|
|
|
it("runs quoted positional prompt text", async () => {
|
|
forcePromptModeInput();
|
|
process.argv = ["bun", "src/index.ts", "hello world"];
|
|
|
|
const { runCli } = await import("./main");
|
|
|
|
await expect(runCli()).resolves.toBeUndefined();
|
|
expect(runtimeMocks.runAgent).toHaveBeenCalledTimes(1);
|
|
expect(runtimeMocks.runAgent).toHaveBeenCalledWith(
|
|
"hello world",
|
|
expect.any(Object),
|
|
expect.anything(),
|
|
);
|
|
});
|
|
|
|
it("rejects unknown root flags before loading runtime modules", async () => {
|
|
const consoleError = vi
|
|
.spyOn(console, "error")
|
|
.mockImplementation(() => undefined);
|
|
forcePromptModeInput();
|
|
process.argv = ["bun", "src/index.ts", "--made-up-flag"];
|
|
|
|
const { runCli } = await import("./main");
|
|
|
|
await expect(runCli()).resolves.toBeUndefined();
|
|
expect(process.exitCode).toBe(1);
|
|
expect(consoleError).toHaveBeenCalledWith(
|
|
expect.stringContaining("unknown option '--made-up-flag'"),
|
|
);
|
|
expect(runtimeMocks.runAgent).not.toHaveBeenCalled();
|
|
expect(mockState.runAgentImports).toBe(0);
|
|
expect(mockState.runInteractiveImports).toBe(0);
|
|
});
|
|
|
|
it("creates a worktree and runs prompt sessions from it", async () => {
|
|
forcePromptModeInput();
|
|
process.argv = ["bun", "src/index.ts", "--worktree", "say hello"];
|
|
|
|
const { runCli } = await import("./main");
|
|
|
|
await expect(runCli()).resolves.toBeUndefined();
|
|
expect(worktreeMocks.createTaskWorktree).toHaveBeenCalledWith({
|
|
cwd: process.cwd(),
|
|
});
|
|
expect(runtimeMocks.runAgent).toHaveBeenCalledWith(
|
|
"say hello",
|
|
expect.objectContaining({
|
|
cwd: "/tmp/cline-worktree",
|
|
workspaceRoot: "/tmp/cline-worktree",
|
|
}),
|
|
expect.anything(),
|
|
);
|
|
});
|
|
|
|
it("creates a worktree for default interactive mode", async () => {
|
|
process.argv = ["bun", "src/index.ts", "--worktree"];
|
|
|
|
const { runCli } = await import("./main");
|
|
|
|
await expect(runCli()).resolves.toBeUndefined();
|
|
expect(worktreeMocks.createTaskWorktree).toHaveBeenCalledWith({
|
|
cwd: process.cwd(),
|
|
});
|
|
expect(runtimeMocks.runAgent).not.toHaveBeenCalled();
|
|
expect(runtimeMocks.runInteractive).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
cwd: "/tmp/cline-worktree",
|
|
workspaceRoot: "/tmp/cline-worktree",
|
|
}),
|
|
expect.anything(),
|
|
undefined,
|
|
expect.objectContaining({
|
|
initialPrompt: undefined,
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("rejects interactive --worktree when no terminal is attached", async () => {
|
|
Object.defineProperty(process.stdin, "isTTY", {
|
|
value: false,
|
|
configurable: true,
|
|
});
|
|
Object.defineProperty(process.stdout, "isTTY", {
|
|
value: false,
|
|
configurable: true,
|
|
});
|
|
process.argv = ["bun", "src/index.ts", "--worktree"];
|
|
|
|
const { runCli } = await import("./main");
|
|
|
|
await expect(runCli()).resolves.toBeUndefined();
|
|
expect(process.exitCode).toBe(1);
|
|
expect(worktreeMocks.createTaskWorktree).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("allows --worktree with piped stdin", async () => {
|
|
Object.defineProperty(process.stdin, "isTTY", {
|
|
value: false,
|
|
configurable: true,
|
|
});
|
|
Object.defineProperty(process.stdout, "isTTY", {
|
|
value: false,
|
|
configurable: true,
|
|
});
|
|
vi.mocked(fstatSync).mockReturnValue({
|
|
isFIFO: () => true,
|
|
isFile: () => false,
|
|
} as unknown as ReturnType<typeof fstatSync>);
|
|
vi.spyOn(process.stdin, Symbol.asyncIterator).mockImplementation(
|
|
async function* (): AsyncGenerator<Buffer, undefined> {
|
|
yield Buffer.from("from pipe");
|
|
return undefined;
|
|
},
|
|
);
|
|
process.argv = ["bun", "src/index.ts", "--worktree"];
|
|
|
|
const { runCli } = await import("./main");
|
|
|
|
await expect(runCli()).resolves.toBeUndefined();
|
|
expect(worktreeMocks.createTaskWorktree).toHaveBeenCalledWith({
|
|
cwd: process.cwd(),
|
|
});
|
|
expect(runtimeMocks.runAgent).toHaveBeenCalledWith(
|
|
"from pipe",
|
|
expect.objectContaining({
|
|
cwd: "/tmp/cline-worktree",
|
|
workspaceRoot: "/tmp/cline-worktree",
|
|
}),
|
|
expect.anything(),
|
|
);
|
|
});
|
|
|
|
it("validates resumed sessions before creating a worktree", async () => {
|
|
sessionMocks.getSessionRow.mockResolvedValueOnce(undefined);
|
|
process.argv = ["bun", "src/index.ts", "--worktree", "--id", "missing"];
|
|
|
|
const { runCli } = await import("./main");
|
|
|
|
await expect(runCli()).resolves.toBeUndefined();
|
|
expect(process.exitCode).toBe(1);
|
|
expect(sessionMocks.getSessionRow).toHaveBeenCalledWith("missing");
|
|
expect(worktreeMocks.createTaskWorktree).not.toHaveBeenCalled();
|
|
expect(runtimeMocks.runInteractive).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("does not force chat view for default interactive mode", async () => {
|
|
process.argv = ["bun", "src/index.ts"];
|
|
|
|
const { runCli } = await import("./main");
|
|
|
|
await expect(runCli()).resolves.toBeUndefined();
|
|
expect(runtimeMocks.runInteractive).toHaveBeenCalledTimes(1);
|
|
expect(runtimeMocks.runInteractive).toHaveBeenCalledWith(
|
|
expect.any(Object),
|
|
expect.anything(),
|
|
undefined,
|
|
expect.objectContaining({
|
|
startupTarget: undefined,
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("passes the migration notice marker into interactive mode", async () => {
|
|
const notice = {
|
|
id: "cline-cli-cline-pass-intro",
|
|
title: "Try ClinePass",
|
|
body: "ClinePass body",
|
|
url: "https://app.cline.bot/dashboard/subscription?personal=true",
|
|
openLabel: "Open ClinePass",
|
|
};
|
|
migrationNoticeMocks.getClineCliMigrationNotice.mockReturnValue(notice);
|
|
process.argv = ["bun", "src/index.ts"];
|
|
|
|
const { runCli } = await import("./main");
|
|
|
|
await expect(runCli()).resolves.toBeUndefined();
|
|
expect(runtimeMocks.runInteractive).toHaveBeenCalledWith(
|
|
expect.any(Object),
|
|
expect.anything(),
|
|
undefined,
|
|
expect.objectContaining({
|
|
initialNotice: notice,
|
|
onInitialNoticeShown: expect.any(Function),
|
|
}),
|
|
);
|
|
expect(
|
|
migrationNoticeMocks.markClineCliMigrationNoticeShown,
|
|
).not.toHaveBeenCalled();
|
|
const options = runtimeMocks.runInteractive.mock.calls[0]?.[3];
|
|
await options?.onInitialNoticeShown?.(notice);
|
|
expect(
|
|
migrationNoticeMocks.markClineCliMigrationNoticeShown,
|
|
).toHaveBeenCalledWith(undefined, notice.id);
|
|
});
|
|
|
|
it("passes the active ClinePass provider into the migration notice gate", async () => {
|
|
providerSettingsMocks.getLastUsedProviderSettings.mockReturnValue({
|
|
provider: "cline-pass",
|
|
model: "cline-pass/test-model",
|
|
});
|
|
process.argv = ["bun", "src/index.ts"];
|
|
|
|
const { runCli } = await import("./main");
|
|
|
|
await expect(runCli()).resolves.toBeUndefined();
|
|
expect(
|
|
migrationNoticeMocks.getClineCliMigrationNotice,
|
|
).toHaveBeenCalledWith(undefined, process.env, {
|
|
activeProviderId: "cline-pass",
|
|
});
|
|
expect(runtimeMocks.runInteractive).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
providerId: "cline-pass",
|
|
}),
|
|
expect.anything(),
|
|
undefined,
|
|
expect.objectContaining({
|
|
initialNotice: undefined,
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("does not start OAuth before onboarding in interactive mode", async () => {
|
|
authMocks.isOAuthProvider.mockReturnValue(true);
|
|
authMocks.normalizeProviderId.mockReturnValue("cline");
|
|
authMocks.getPersistedProviderApiKey.mockReturnValue(undefined);
|
|
authMocks.ensureOAuthProviderApiKey.mockClear();
|
|
process.argv = ["bun", "src/index.ts", "-i"];
|
|
|
|
const { runCli } = await import("./main");
|
|
|
|
await expect(runCli()).resolves.toBeUndefined();
|
|
expect(authMocks.ensureOAuthProviderApiKey).not.toHaveBeenCalled();
|
|
expect(runtimeMocks.runInteractive).toHaveBeenCalledTimes(1);
|
|
expect(runtimeMocks.runInteractive).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
providerId: "cline",
|
|
apiKey: "",
|
|
}),
|
|
expect.anything(),
|
|
undefined,
|
|
expect.any(Object),
|
|
);
|
|
});
|
|
|
|
it("loads live catalog models for default interactive model selection", async () => {
|
|
llmMocks.resolveProviderConfig.mockResolvedValue({
|
|
knownModels: {
|
|
"live-only-model": {
|
|
id: "live-only-model",
|
|
name: "Live Only Model",
|
|
},
|
|
},
|
|
});
|
|
process.argv = ["bun", "src/index.ts"];
|
|
|
|
const { runCli } = await import("./main");
|
|
|
|
await expect(runCli()).resolves.toBeUndefined();
|
|
expect(llmMocks.resolveProviderConfig).toHaveBeenCalledWith(
|
|
"cline",
|
|
{
|
|
loadLatestOnInit: true,
|
|
loadPrivateOnAuth: true,
|
|
failOnError: false,
|
|
},
|
|
undefined,
|
|
);
|
|
expect(runtimeMocks.runInteractive).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
knownModels: expect.objectContaining({
|
|
"live-only-model": expect.objectContaining({
|
|
name: "Live Only Model",
|
|
}),
|
|
}),
|
|
}),
|
|
expect.anything(),
|
|
undefined,
|
|
expect.any(Object),
|
|
);
|
|
});
|
|
|
|
it("passes a positional prompt into TUI mode for startup submission", async () => {
|
|
process.argv = ["bun", "src/index.ts", "sup", "-i"];
|
|
|
|
const { runCli } = await import("./main");
|
|
|
|
await expect(runCli()).resolves.toBeUndefined();
|
|
expect(llmMocks.resolveProviderConfig).toHaveBeenCalledWith(
|
|
"cline",
|
|
{
|
|
loadLatestOnInit: true,
|
|
loadPrivateOnAuth: true,
|
|
failOnError: false,
|
|
},
|
|
undefined,
|
|
);
|
|
expect(runtimeMocks.runInteractive).toHaveBeenCalledTimes(1);
|
|
expect(runtimeMocks.runInteractive).toHaveBeenCalledWith(
|
|
expect.any(Object),
|
|
expect.anything(),
|
|
undefined,
|
|
expect.objectContaining({
|
|
initialPrompt: "sup",
|
|
startupTarget: undefined,
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("uses the bundled catalog path for single-prompt runs", async () => {
|
|
forcePromptModeInput();
|
|
process.argv = ["bun", "src/index.ts", "say hello"];
|
|
|
|
const { runCli } = await import("./main");
|
|
|
|
await expect(runCli()).resolves.toBeUndefined();
|
|
expect(llmMocks.resolveProviderConfig).toHaveBeenCalledWith(
|
|
"cline",
|
|
undefined,
|
|
undefined,
|
|
);
|
|
expect(runtimeMocks.runAgent).toHaveBeenCalledTimes(1);
|
|
expect(runtimeMocks.runInteractive).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("treats dash-prefixed positional text after -- as a prompt", async () => {
|
|
forcePromptModeInput();
|
|
process.argv = [
|
|
"bun",
|
|
"src/index.ts",
|
|
"--",
|
|
"- You are given a PyTorch state dictionary.",
|
|
];
|
|
|
|
const { runCli } = await import("./main");
|
|
|
|
await expect(runCli()).resolves.toBeUndefined();
|
|
expect(runtimeMocks.runAgent).toHaveBeenCalledTimes(1);
|
|
expect(runtimeMocks.runAgent).toHaveBeenCalledWith(
|
|
"- You are given a PyTorch state dictionary.",
|
|
expect.any(Object),
|
|
expect.anything(),
|
|
);
|
|
expect(runtimeMocks.runInteractive).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("applies --auto-approve as a runtime policy without changing the config default", async () => {
|
|
process.argv = ["bun", "src/index.ts", "--auto-approve", "false"];
|
|
|
|
const { runCli } = await import("./main");
|
|
|
|
await expect(runCli()).resolves.toBeUndefined();
|
|
expect(runtimeMocks.runInteractive).toHaveBeenCalledTimes(1);
|
|
expect(runtimeMocks.runInteractive).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
defaultToolAutoApprove: true,
|
|
toolPolicies: {
|
|
"*": { autoApprove: false },
|
|
},
|
|
}),
|
|
expect.anything(),
|
|
undefined,
|
|
expect.any(Object),
|
|
);
|
|
});
|
|
|
|
describe("persisted general settings at startup", () => {
|
|
function writePersistedSettings(settings: Record<string, unknown>) {
|
|
const path = process.env.CLINE_GLOBAL_SETTINGS_PATH;
|
|
if (!path) {
|
|
throw new Error("CLINE_GLOBAL_SETTINGS_PATH is not set");
|
|
}
|
|
writeFileSync(path, JSON.stringify(settings));
|
|
}
|
|
|
|
it("restores the persisted plan mode when no mode flag is provided", async () => {
|
|
writePersistedSettings({ planActMode: "plan" });
|
|
promptMocks.resolveSystemPrompt.mockClear();
|
|
process.argv = ["bun", "src/index.ts"];
|
|
|
|
const { runCli } = await import("./main");
|
|
|
|
await expect(runCli()).resolves.toBeUndefined();
|
|
expect(runtimeMocks.runInteractive).toHaveBeenCalledWith(
|
|
expect.objectContaining({ mode: "plan" }),
|
|
expect.anything(),
|
|
undefined,
|
|
expect.any(Object),
|
|
);
|
|
expect(promptMocks.resolveSystemPrompt).toHaveBeenCalledWith(
|
|
expect.objectContaining({ mode: "plan" }),
|
|
);
|
|
});
|
|
|
|
it("prefers an explicit --act flag over the persisted plan mode", async () => {
|
|
writePersistedSettings({ planActMode: "plan" });
|
|
promptMocks.resolveSystemPrompt.mockClear();
|
|
process.argv = ["bun", "src/index.ts", "--act"];
|
|
|
|
const { runCli } = await import("./main");
|
|
|
|
await expect(runCli()).resolves.toBeUndefined();
|
|
expect(runtimeMocks.runInteractive).toHaveBeenCalledWith(
|
|
expect.objectContaining({ mode: "act" }),
|
|
expect.anything(),
|
|
undefined,
|
|
expect.any(Object),
|
|
);
|
|
expect(promptMocks.resolveSystemPrompt).toHaveBeenCalledWith(
|
|
expect.objectContaining({ mode: "act" }),
|
|
);
|
|
});
|
|
|
|
it("restores the persisted auto-approve setting as a runtime policy", async () => {
|
|
writePersistedSettings({ toolAutoApprove: false });
|
|
process.argv = ["bun", "src/index.ts"];
|
|
|
|
const { runCli } = await import("./main");
|
|
|
|
await expect(runCli()).resolves.toBeUndefined();
|
|
expect(runtimeMocks.runInteractive).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
defaultToolAutoApprove: true,
|
|
toolPolicies: {
|
|
"*": { autoApprove: false },
|
|
},
|
|
}),
|
|
expect.anything(),
|
|
undefined,
|
|
expect.any(Object),
|
|
);
|
|
});
|
|
|
|
it("prefers an explicit --auto-approve flag over the persisted setting", async () => {
|
|
writePersistedSettings({ toolAutoApprove: false });
|
|
process.argv = ["bun", "src/index.ts", "--auto-approve", "true"];
|
|
|
|
const { runCli } = await import("./main");
|
|
|
|
await expect(runCli()).resolves.toBeUndefined();
|
|
expect(runtimeMocks.runInteractive).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
toolPolicies: {
|
|
"*": { autoApprove: true },
|
|
},
|
|
}),
|
|
expect.anything(),
|
|
undefined,
|
|
expect.any(Object),
|
|
);
|
|
});
|
|
|
|
it("restores disabled compaction across restarts", async () => {
|
|
writePersistedSettings({
|
|
compactionEnabled: false,
|
|
compactionStrategy: "basic",
|
|
});
|
|
process.argv = ["bun", "src/index.ts"];
|
|
|
|
const { runCli } = await import("./main");
|
|
|
|
await expect(runCli()).resolves.toBeUndefined();
|
|
expect(runtimeMocks.runInteractive).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
compaction: { enabled: false },
|
|
}),
|
|
expect.anything(),
|
|
undefined,
|
|
expect.any(Object),
|
|
);
|
|
});
|
|
|
|
it("restores the persisted compaction strategy across restarts", async () => {
|
|
writePersistedSettings({
|
|
compactionEnabled: true,
|
|
compactionStrategy: "basic",
|
|
});
|
|
process.argv = ["bun", "src/index.ts"];
|
|
|
|
const { runCli } = await import("./main");
|
|
|
|
await expect(runCli()).resolves.toBeUndefined();
|
|
expect(runtimeMocks.runInteractive).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
compaction: { enabled: true, strategy: "basic" },
|
|
}),
|
|
expect.anything(),
|
|
undefined,
|
|
expect.any(Object),
|
|
);
|
|
});
|
|
|
|
it("prefers an explicit --compaction flag over the persisted mode", async () => {
|
|
writePersistedSettings({ compactionEnabled: false });
|
|
process.argv = ["bun", "src/index.ts", "--compaction", "agentic"];
|
|
|
|
const { runCli } = await import("./main");
|
|
|
|
await expect(runCli()).resolves.toBeUndefined();
|
|
expect(runtimeMocks.runInteractive).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
compaction: { enabled: true, strategy: "agentic" },
|
|
}),
|
|
expect.anything(),
|
|
undefined,
|
|
expect.any(Object),
|
|
);
|
|
});
|
|
|
|
it("applies persisted settings to single-prompt runs as well", async () => {
|
|
writePersistedSettings({
|
|
compactionEnabled: true,
|
|
compactionStrategy: "basic",
|
|
planActMode: "plan",
|
|
});
|
|
forcePromptModeInput();
|
|
process.argv = ["bun", "src/index.ts", "say hello"];
|
|
|
|
const { runCli } = await import("./main");
|
|
|
|
await expect(runCli()).resolves.toBeUndefined();
|
|
expect(runtimeMocks.runAgent).toHaveBeenCalledWith(
|
|
"say hello",
|
|
expect.objectContaining({
|
|
compaction: { enabled: true, strategy: "basic" },
|
|
mode: "plan",
|
|
}),
|
|
expect.anything(),
|
|
);
|
|
});
|
|
});
|
|
|
|
it("forces chat view when resuming a session", async () => {
|
|
process.argv = ["bun", "src/index.ts", "--id", "sess_123"];
|
|
|
|
const { runCli } = await import("./main");
|
|
|
|
await expect(runCli()).resolves.toBeUndefined();
|
|
expect(runtimeMocks.runInteractive).toHaveBeenCalledTimes(1);
|
|
expect(runtimeMocks.runInteractive).toHaveBeenCalledWith(
|
|
expect.any(Object),
|
|
expect.anything(),
|
|
"sess_123",
|
|
expect.objectContaining({
|
|
startupTarget: "chat",
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("opens history inside the interactive TUI for the history picker", async () => {
|
|
migrationNoticeMocks.getClineCliMigrationNotice.mockReturnValue({
|
|
id: "cline-cli-cline-pass-intro",
|
|
title: "Try ClinePass",
|
|
body: "ClinePass body",
|
|
url: "https://app.cline.bot/dashboard/subscription?personal=true",
|
|
openLabel: "Open ClinePass",
|
|
});
|
|
process.argv = ["bun", "src/index.ts", "history"];
|
|
|
|
const { runCli } = await import("./main");
|
|
|
|
await expect(runCli()).resolves.toBeUndefined();
|
|
expect(historyMocks.runHistoryList).not.toHaveBeenCalled();
|
|
expect(
|
|
migrationNoticeMocks.getClineCliMigrationNotice,
|
|
).not.toHaveBeenCalled();
|
|
expect(runtimeMocks.runInteractive).toHaveBeenCalledTimes(1);
|
|
expect(runtimeMocks.runInteractive).toHaveBeenCalledWith(
|
|
expect.any(Object),
|
|
expect.anything(),
|
|
undefined,
|
|
expect.objectContaining({
|
|
initialPrompt: undefined,
|
|
startupTarget: "history",
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("does not pass non-Cline provider settings as Cline account options", async () => {
|
|
providerSettingsMocks.getLastUsedProviderSettings.mockReturnValue({
|
|
provider: "openrouter",
|
|
baseUrl: "https://openrouter.ai/api/v1",
|
|
model: "openai/gpt-5",
|
|
});
|
|
providerSettingsMocks.getProviderSettings.mockReturnValue({
|
|
provider: "openrouter",
|
|
baseUrl: "https://openrouter.ai/api/v1",
|
|
model: "openai/gpt-5",
|
|
});
|
|
authMocks.normalizeProviderId.mockImplementation(
|
|
(providerId?: string) => providerId ?? "openrouter",
|
|
);
|
|
process.argv = ["bun", "src/index.ts"];
|
|
|
|
const { runCli } = await import("./main");
|
|
|
|
await expect(runCli()).resolves.toBeUndefined();
|
|
expect(runtimeMocks.runInteractive).toHaveBeenCalledTimes(1);
|
|
expect(runtimeMocks.runInteractive).toHaveBeenCalledWith(
|
|
expect.any(Object),
|
|
expect.anything(),
|
|
undefined,
|
|
expect.objectContaining({
|
|
clineApiBaseUrl: undefined,
|
|
clineProviderSettings: undefined,
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("passes Cline provider settings as Cline account options", async () => {
|
|
const clineSettings = {
|
|
provider: "cline",
|
|
baseUrl: "https://api.example.test",
|
|
model: "anthropic/claude-sonnet-4.6",
|
|
};
|
|
providerSettingsMocks.getLastUsedProviderSettings.mockReturnValue(
|
|
clineSettings,
|
|
);
|
|
providerSettingsMocks.getProviderSettings.mockReturnValue(clineSettings);
|
|
authMocks.normalizeProviderId.mockImplementation(
|
|
(providerId?: string) => providerId ?? "cline",
|
|
);
|
|
process.argv = ["bun", "src/index.ts"];
|
|
|
|
const { runCli } = await import("./main");
|
|
|
|
await expect(runCli()).resolves.toBeUndefined();
|
|
expect(runtimeMocks.runInteractive).toHaveBeenCalledTimes(1);
|
|
expect(runtimeMocks.runInteractive).toHaveBeenCalledWith(
|
|
expect.any(Object),
|
|
expect.anything(),
|
|
undefined,
|
|
expect.objectContaining({
|
|
clineApiBaseUrl: "https://api.example.test",
|
|
clineProviderSettings: clineSettings,
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("seeds feature flag identity from persisted Cline account id before refreshing flags", async () => {
|
|
const clineSettings = {
|
|
provider: "cline",
|
|
model: "anthropic/claude-sonnet-4.6",
|
|
auth: {
|
|
accountId: "acct-startup",
|
|
accessToken: "workos:token",
|
|
refreshToken: "refresh-token",
|
|
},
|
|
};
|
|
providerSettingsMocks.getProviderSettings.mockReturnValue(clineSettings);
|
|
process.argv = ["bun", "src/index.ts"];
|
|
|
|
const { runCli } = await import("./main");
|
|
|
|
await expect(runCli()).resolves.toBeUndefined();
|
|
expect(
|
|
featureFlagMocks.setCliFeatureFlagsAccountContext,
|
|
).toHaveBeenCalledWith({ id: "acct-startup" });
|
|
// The account identity must be seeded before flags are refreshed/used so
|
|
// the background refresh resolves flags for the correct account.
|
|
expect(
|
|
featureFlagMocks.setCliFeatureFlagsAccountContext.mock
|
|
.invocationCallOrder[0],
|
|
).toBeLessThan(
|
|
featureFlagMocks.refreshCliFeatureFlagsInBackground.mock
|
|
.invocationCallOrder[0],
|
|
);
|
|
});
|
|
|
|
it("identifies saved Cline accountId for telemetry before runtime events", async () => {
|
|
// CLINE-2406: when persisted Cline auth includes an accountId, the
|
|
// runtime path must call identifyTelemetryAccount(accountContext) so
|
|
// subsequent task.* and workspace.* events carry user_id.
|
|
const clineSettings = {
|
|
provider: "cline",
|
|
model: "anthropic/claude-sonnet-4.6",
|
|
auth: { accountId: "usr-abc-123", refreshToken: "rt-token" },
|
|
};
|
|
providerSettingsMocks.getLastUsedProviderSettings.mockReturnValue(
|
|
clineSettings,
|
|
);
|
|
providerSettingsMocks.getProviderSettings.mockReturnValue(clineSettings);
|
|
authMocks.normalizeProviderId.mockImplementation(
|
|
(providerId?: string) => providerId ?? "cline",
|
|
);
|
|
process.argv = ["bun", "src/index.ts"];
|
|
|
|
const { runCli } = await import("./main");
|
|
|
|
await expect(runCli()).resolves.toBeUndefined();
|
|
expect(telemetryMocks.identifyTelemetryAccount).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
id: "usr-abc-123",
|
|
provider: "cline",
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("does not call identifyTelemetryAccount in runtime path when no saved Cline accountId", async () => {
|
|
// CLINE-2406: when no persisted accountId is found (anonymous/unauthenticated),
|
|
// identifyTelemetryAccount should not be called from the runtime path.
|
|
const clineSettings = {
|
|
provider: "cline",
|
|
model: "anthropic/claude-sonnet-4.6",
|
|
// no auth / no accountId
|
|
};
|
|
providerSettingsMocks.getLastUsedProviderSettings.mockReturnValue(
|
|
clineSettings,
|
|
);
|
|
providerSettingsMocks.getProviderSettings.mockReturnValue(clineSettings);
|
|
authMocks.normalizeProviderId.mockImplementation(
|
|
(providerId?: string) => providerId ?? "cline",
|
|
);
|
|
process.argv = ["bun", "src/index.ts"];
|
|
|
|
const { runCli } = await import("./main");
|
|
|
|
await expect(runCli()).resolves.toBeUndefined();
|
|
expect(telemetryMocks.identifyTelemetryAccount).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("does not call identifyTelemetryAccount from runtime path when provider is not cline", async () => {
|
|
// CLINE-2406: identity identification from saved settings only applies
|
|
// to Cline-provider sessions; other providers use different auth flows.
|
|
providerSettingsMocks.getLastUsedProviderSettings.mockReturnValue({
|
|
provider: "openrouter",
|
|
model: "openai/gpt-5",
|
|
});
|
|
providerSettingsMocks.getProviderSettings.mockReturnValue({
|
|
provider: "openrouter",
|
|
model: "openai/gpt-5",
|
|
});
|
|
authMocks.normalizeProviderId.mockImplementation(
|
|
(providerId?: string) => providerId ?? "openrouter",
|
|
);
|
|
process.argv = ["bun", "src/index.ts"];
|
|
|
|
const { runCli } = await import("./main");
|
|
|
|
await expect(runCli()).resolves.toBeUndefined();
|
|
expect(telemetryMocks.identifyTelemetryAccount).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("runs kanban before loading runtime modules", async () => {
|
|
process.argv = ["bun", "src/index.ts", "kanban"];
|
|
|
|
const { runCli } = await import("./main");
|
|
|
|
await expect(runCli()).resolves.toBeUndefined();
|
|
expect(kanbanMocks.launchKanban).toHaveBeenCalledTimes(1);
|
|
expect(mockState.runAgentImports).toBe(0);
|
|
expect(mockState.runInteractiveImports).toBe(0);
|
|
expect(process.exitCode).toBe(0);
|
|
});
|
|
|
|
it("runs dashboard before loading runtime modules", async () => {
|
|
process.argv = [
|
|
"bun",
|
|
"src/index.ts",
|
|
"dashboard",
|
|
"--config",
|
|
"/tmp/cline-config",
|
|
"--data-dir",
|
|
".cline-dashboard-data",
|
|
"--port",
|
|
"9090",
|
|
"--no-open",
|
|
];
|
|
|
|
const { runCli } = await import("./main");
|
|
|
|
await expect(runCli()).resolves.toBeUndefined();
|
|
expect(dashboardMocks.runDashboardCommand).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
configDir: "/tmp/cline-config",
|
|
dataDir: ".cline-dashboard-data",
|
|
port: "9090",
|
|
openBrowser: false,
|
|
io: expect.any(Object),
|
|
}),
|
|
);
|
|
expect(mockState.runAgentImports).toBe(0);
|
|
expect(mockState.runInteractiveImports).toBe(0);
|
|
expect(process.exitCode).toBe(0);
|
|
});
|
|
|
|
it("prints an install hint when kanban is missing", async () => {
|
|
const stderrWrite = vi
|
|
.spyOn(process.stderr, "write")
|
|
.mockImplementation(() => true);
|
|
kanbanMocks.launchKanban.mockImplementation(async () => {
|
|
process.stderr.write(
|
|
'kanban is not installed. Install it with "npm i -g kanban"\n',
|
|
);
|
|
return 1;
|
|
});
|
|
process.argv = ["bun", "src/index.ts", "kanban"];
|
|
|
|
const { runCli } = await import("./main");
|
|
|
|
await expect(runCli()).resolves.toBeUndefined();
|
|
expect(stderrWrite).toHaveBeenCalledWith(
|
|
expect.stringContaining(
|
|
'kanban is not installed. Install it with "npm i -g kanban"',
|
|
),
|
|
);
|
|
expect(process.exitCode).toBe(1);
|
|
});
|
|
|
|
it("skips hub prewarm for yolo runs", async () => {
|
|
forcePromptModeInput();
|
|
process.argv = ["bun", "src/index.ts", "--yolo", "say hello"];
|
|
|
|
const { runCli } = await import("./main");
|
|
|
|
await expect(runCli()).resolves.toBeUndefined();
|
|
expect(runtimeMocks.runAgent).toHaveBeenCalledTimes(1);
|
|
expect(hubRuntimeMocks.ensureCliHubServer).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("rejects yolo runs with a single bare prompt token", async () => {
|
|
const consoleError = vi
|
|
.spyOn(console, "error")
|
|
.mockImplementation(() => undefined);
|
|
forcePromptModeInput();
|
|
process.argv = ["bun", "src/index.ts", "--yolo", "hello"];
|
|
|
|
const { runCli } = await import("./main");
|
|
|
|
await expect(runCli()).resolves.toBeUndefined();
|
|
expect(process.exitCode).toBe(1);
|
|
expect(consoleError).toHaveBeenCalledWith(
|
|
expect.stringContaining("Unknown command or unquoted prompt: hello"),
|
|
);
|
|
expect(runtimeMocks.runAgent).not.toHaveBeenCalled();
|
|
expect(hubRuntimeMocks.ensureCliHubServer).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("rewrites /team prompts and enables teams in single-prompt mode", async () => {
|
|
runtimeMocks.runAgent.mockClear();
|
|
|
|
forcePromptModeInput();
|
|
process.argv = ["bun", "src/index.ts", "/team find the bug"];
|
|
|
|
const { runCli } = await import("./main");
|
|
|
|
await expect(runCli()).resolves.toBeUndefined();
|
|
expect(runtimeMocks.runAgent).toHaveBeenCalledTimes(1);
|
|
expect(runtimeMocks.runAgent).toHaveBeenCalledWith(
|
|
'<user_command slash="team">spawn a team of agents for the following task: find the bug</user_command>',
|
|
expect.objectContaining({
|
|
enableAgentTeams: true,
|
|
teamName: undefined,
|
|
}),
|
|
expect.anything(),
|
|
);
|
|
});
|
|
|
|
it("rejects /team without quoted task text", async () => {
|
|
mockState.runAgentCalls = 0;
|
|
runtimeMocks.runAgent.mockClear();
|
|
const consoleError = vi
|
|
.spyOn(console, "error")
|
|
.mockImplementation(() => undefined);
|
|
|
|
forcePromptModeInput();
|
|
process.argv = ["bun", "src/index.ts", "/team"];
|
|
|
|
const { runCli } = await import("./main");
|
|
|
|
await expect(runCli()).resolves.toBeUndefined();
|
|
expect(process.exitCode).toBe(1);
|
|
expect(mockState.runAgentCalls).toBe(0);
|
|
expect(consoleError).toHaveBeenCalledWith(
|
|
expect.stringContaining("Unknown command or unquoted prompt: /team"),
|
|
);
|
|
});
|
|
|
|
it("enables thinking when explicit thinking level is provided", async () => {
|
|
mockState.runAgentCalls = 0;
|
|
runtimeMocks.runAgent.mockClear();
|
|
|
|
forcePromptModeInput();
|
|
process.argv = ["bun", "src/index.ts", "--thinking", "high", "say hello"];
|
|
|
|
const { runCli } = await import("./main");
|
|
|
|
await expect(runCli()).resolves.toBeUndefined();
|
|
expect(mockState.runAgentCalls).toBe(1);
|
|
expect(runtimeMocks.runAgent).toHaveBeenCalledWith(
|
|
"say hello",
|
|
expect.objectContaining({
|
|
thinking: true,
|
|
reasoningEffort: "high",
|
|
}),
|
|
expect.anything(),
|
|
);
|
|
});
|
|
|
|
it("leaves thinking unset when --thinking is not provided", async () => {
|
|
mockState.runAgentCalls = 0;
|
|
runtimeMocks.runAgent.mockClear();
|
|
|
|
forcePromptModeInput();
|
|
process.argv = ["bun", "src/index.ts", "say hello"];
|
|
|
|
const { runCli } = await import("./main");
|
|
|
|
await expect(runCli()).resolves.toBeUndefined();
|
|
expect(mockState.runAgentCalls).toBe(1);
|
|
expect(runtimeMocks.runAgent).toHaveBeenCalledWith(
|
|
"say hello",
|
|
expect.objectContaining({
|
|
thinking: undefined,
|
|
reasoningEffort: undefined,
|
|
}),
|
|
expect.anything(),
|
|
);
|
|
});
|
|
|
|
it("disables thinking when --thinking none is explicitly provided", async () => {
|
|
mockState.runAgentCalls = 0;
|
|
runtimeMocks.runAgent.mockClear();
|
|
|
|
forcePromptModeInput();
|
|
process.argv = ["bun", "src/index.ts", "--thinking", "none", "say hello"];
|
|
|
|
const { runCli } = await import("./main");
|
|
|
|
await expect(runCli()).resolves.toBeUndefined();
|
|
expect(mockState.runAgentCalls).toBe(1);
|
|
expect(runtimeMocks.runAgent).toHaveBeenCalledWith(
|
|
"say hello",
|
|
expect.objectContaining({
|
|
thinking: false,
|
|
reasoningEffort: undefined,
|
|
}),
|
|
expect.anything(),
|
|
);
|
|
});
|
|
|
|
it("maps --thinking to medium effort", async () => {
|
|
mockState.runAgentCalls = 0;
|
|
runtimeMocks.runAgent.mockClear();
|
|
|
|
forcePromptModeInput();
|
|
process.argv = ["bun", "src/index.ts", "--thinking", "--", "say hello"];
|
|
|
|
const { runCli } = await import("./main");
|
|
|
|
await expect(runCli()).resolves.toBeUndefined();
|
|
expect(mockState.runAgentCalls).toBe(1);
|
|
expect(runtimeMocks.runAgent).toHaveBeenCalledWith(
|
|
"say hello",
|
|
expect.objectContaining({
|
|
compaction: {
|
|
enabled: true,
|
|
},
|
|
thinking: true,
|
|
reasoningEffort: "medium",
|
|
}),
|
|
expect.anything(),
|
|
);
|
|
});
|
|
|
|
it("uses persisted reasoning effort when --thinking is not provided", async () => {
|
|
mockState.runAgentCalls = 0;
|
|
runtimeMocks.runAgent.mockClear();
|
|
providerSettingsMocks.getProviderSettings.mockReturnValue({
|
|
provider: "cline",
|
|
model: "openai/gpt-5",
|
|
reasoning: { enabled: true, effort: "high" },
|
|
});
|
|
|
|
forcePromptModeInput();
|
|
process.argv = ["bun", "src/index.ts", "say hello"];
|
|
|
|
const { runCli } = await import("./main");
|
|
|
|
await expect(runCli()).resolves.toBeUndefined();
|
|
expect(mockState.runAgentCalls).toBe(1);
|
|
expect(runtimeMocks.runAgent).toHaveBeenCalledWith(
|
|
"say hello",
|
|
expect.objectContaining({
|
|
thinking: true,
|
|
reasoningEffort: "high",
|
|
}),
|
|
expect.anything(),
|
|
);
|
|
});
|
|
|
|
it("uses persisted disabled reasoning when --thinking is not provided", async () => {
|
|
mockState.runAgentCalls = 0;
|
|
runtimeMocks.runAgent.mockClear();
|
|
providerSettingsMocks.getProviderSettings.mockReturnValue({
|
|
provider: "cline",
|
|
model: "openai/gpt-5",
|
|
reasoning: { enabled: false },
|
|
});
|
|
|
|
forcePromptModeInput();
|
|
process.argv = ["bun", "src/index.ts", "say hello"];
|
|
|
|
const { runCli } = await import("./main");
|
|
|
|
await expect(runCli()).resolves.toBeUndefined();
|
|
expect(mockState.runAgentCalls).toBe(1);
|
|
expect(runtimeMocks.runAgent).toHaveBeenCalledWith(
|
|
"say hello",
|
|
expect.objectContaining({
|
|
thinking: false,
|
|
reasoningEffort: undefined,
|
|
}),
|
|
expect.anything(),
|
|
);
|
|
});
|
|
|
|
it("prefers explicit --thinking over persisted reasoning effort", async () => {
|
|
mockState.runAgentCalls = 0;
|
|
runtimeMocks.runAgent.mockClear();
|
|
providerSettingsMocks.getProviderSettings.mockReturnValue({
|
|
provider: "cline",
|
|
model: "openai/gpt-5",
|
|
reasoning: { enabled: true, effort: "high" },
|
|
});
|
|
|
|
forcePromptModeInput();
|
|
process.argv = ["bun", "src/index.ts", "--thinking", "low", "say hello"];
|
|
|
|
const { runCli } = await import("./main");
|
|
|
|
await expect(runCli()).resolves.toBeUndefined();
|
|
expect(mockState.runAgentCalls).toBe(1);
|
|
expect(runtimeMocks.runAgent).toHaveBeenCalledWith(
|
|
"say hello",
|
|
expect.objectContaining({
|
|
thinking: true,
|
|
reasoningEffort: "low",
|
|
}),
|
|
expect.anything(),
|
|
);
|
|
});
|
|
|
|
it("uses Core's agentic compaction default for prompt runs", async () => {
|
|
mockState.runAgentCalls = 0;
|
|
runtimeMocks.runAgent.mockClear();
|
|
|
|
forcePromptModeInput();
|
|
process.argv = ["bun", "src/index.ts", "say hello"];
|
|
|
|
const { runCli } = await import("./main");
|
|
|
|
await expect(runCli()).resolves.toBeUndefined();
|
|
expect(runtimeMocks.runAgent).toHaveBeenCalledWith(
|
|
"say hello",
|
|
expect.objectContaining({
|
|
compaction: {
|
|
enabled: true,
|
|
},
|
|
}),
|
|
expect.anything(),
|
|
);
|
|
});
|
|
|
|
it("supports basic truncation compaction for prompt runs", async () => {
|
|
mockState.runAgentCalls = 0;
|
|
runtimeMocks.runAgent.mockClear();
|
|
|
|
forcePromptModeInput();
|
|
process.argv = [
|
|
"bun",
|
|
"src/index.ts",
|
|
"--compaction",
|
|
"basic",
|
|
"say hello",
|
|
];
|
|
|
|
const { runCli } = await import("./main");
|
|
|
|
await expect(runCli()).resolves.toBeUndefined();
|
|
expect(runtimeMocks.runAgent).toHaveBeenCalledWith(
|
|
"say hello",
|
|
expect.objectContaining({
|
|
compaction: {
|
|
enabled: true,
|
|
strategy: "basic",
|
|
},
|
|
}),
|
|
expect.anything(),
|
|
);
|
|
});
|
|
|
|
it("supports agentic compaction for prompt runs", async () => {
|
|
mockState.runAgentCalls = 0;
|
|
runtimeMocks.runAgent.mockClear();
|
|
|
|
forcePromptModeInput();
|
|
process.argv = [
|
|
"bun",
|
|
"src/index.ts",
|
|
"--compaction",
|
|
"agentic",
|
|
"say hello",
|
|
];
|
|
|
|
const { runCli } = await import("./main");
|
|
|
|
await expect(runCli()).resolves.toBeUndefined();
|
|
expect(runtimeMocks.runAgent).toHaveBeenCalledWith(
|
|
"say hello",
|
|
expect.objectContaining({
|
|
compaction: {
|
|
enabled: true,
|
|
strategy: "agentic",
|
|
},
|
|
}),
|
|
expect.anything(),
|
|
);
|
|
});
|
|
|
|
it("rejects the removed llm compaction alias", async () => {
|
|
mockState.runAgentCalls = 0;
|
|
runtimeMocks.runAgent.mockClear();
|
|
|
|
forcePromptModeInput();
|
|
process.argv = ["bun", "src/index.ts", "--compaction", "llm", "hello"];
|
|
|
|
const { runCli } = await import("./main");
|
|
|
|
await expect(runCli()).resolves.toBeUndefined();
|
|
expect(process.exitCode).toBe(1);
|
|
expect(runtimeMocks.runAgent).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("rejects the removed truncation compaction alias", async () => {
|
|
mockState.runAgentCalls = 0;
|
|
runtimeMocks.runAgent.mockClear();
|
|
|
|
forcePromptModeInput();
|
|
process.argv = [
|
|
"bun",
|
|
"src/index.ts",
|
|
"--compaction",
|
|
"truncation",
|
|
"hello",
|
|
];
|
|
|
|
const { runCli } = await import("./main");
|
|
|
|
await expect(runCli()).resolves.toBeUndefined();
|
|
expect(process.exitCode).toBe(1);
|
|
expect(runtimeMocks.runAgent).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("supports disabling compaction for prompt runs", async () => {
|
|
mockState.runAgentCalls = 0;
|
|
runtimeMocks.runAgent.mockClear();
|
|
|
|
forcePromptModeInput();
|
|
process.argv = ["bun", "src/index.ts", "--compaction", "off", "say hello"];
|
|
|
|
const { runCli } = await import("./main");
|
|
|
|
await expect(runCli()).resolves.toBeUndefined();
|
|
expect(runtimeMocks.runAgent).toHaveBeenCalledWith(
|
|
"say hello",
|
|
expect.objectContaining({
|
|
compaction: {
|
|
enabled: false,
|
|
},
|
|
}),
|
|
expect.anything(),
|
|
);
|
|
});
|
|
|
|
it("rejects invalid compaction modes", async () => {
|
|
mockState.runAgentCalls = 0;
|
|
runtimeMocks.runAgent.mockClear();
|
|
|
|
forcePromptModeInput();
|
|
process.argv = [
|
|
"bun",
|
|
"src/index.ts",
|
|
"--compaction",
|
|
"aggressive",
|
|
"hello",
|
|
];
|
|
|
|
const { runCli } = await import("./main");
|
|
|
|
await expect(runCli()).resolves.toBeUndefined();
|
|
expect(process.exitCode).toBe(1);
|
|
expect(runtimeMocks.runAgent).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("does not fail fast for headless json mode with an OAuth provider", async () => {
|
|
mockState.runAgentCalls = 0;
|
|
runtimeMocks.runAgent.mockClear();
|
|
authMocks.isOAuthProvider.mockReturnValue(true);
|
|
authMocks.normalizeProviderId.mockReturnValue("cline");
|
|
authMocks.getPersistedProviderApiKey.mockReturnValue(undefined);
|
|
authMocks.ensureOAuthProviderApiKey.mockClear();
|
|
|
|
forcePromptModeInput();
|
|
process.argv = ["bun", "src/index.ts", "--json", "say hello"];
|
|
|
|
const { runCli } = await import("./main");
|
|
|
|
await expect(runCli()).resolves.toBeUndefined();
|
|
expect(mockState.runAgentCalls).toBe(1);
|
|
expect(authMocks.ensureOAuthProviderApiKey).not.toHaveBeenCalled();
|
|
expect(runtimeMocks.runAgent).toHaveBeenCalledWith(
|
|
"say hello",
|
|
expect.objectContaining({
|
|
outputMode: "json",
|
|
apiKey: "",
|
|
providerId: "cline",
|
|
}),
|
|
expect.anything(),
|
|
);
|
|
});
|
|
|
|
it("does not fail fast for headless json mode with a non-OAuth provider", async () => {
|
|
mockState.runAgentCalls = 0;
|
|
runtimeMocks.runAgent.mockClear();
|
|
authMocks.isOAuthProvider.mockReturnValue(false);
|
|
authMocks.normalizeProviderId.mockReturnValue("anthropic");
|
|
authMocks.getPersistedProviderApiKey.mockReturnValue(undefined);
|
|
authMocks.ensureOAuthProviderApiKey.mockClear();
|
|
|
|
forcePromptModeInput();
|
|
process.argv = ["bun", "src/index.ts", "--json", "say hello"];
|
|
|
|
const { runCli } = await import("./main");
|
|
|
|
await expect(runCli()).resolves.toBeUndefined();
|
|
expect(mockState.runAgentCalls).toBe(1);
|
|
expect(authMocks.ensureOAuthProviderApiKey).not.toHaveBeenCalled();
|
|
expect(runtimeMocks.runAgent).toHaveBeenCalledWith(
|
|
"say hello",
|
|
expect.objectContaining({
|
|
outputMode: "json",
|
|
apiKey: "",
|
|
providerId: "anthropic",
|
|
}),
|
|
expect.anything(),
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("stdinHasPipedInput", () => {
|
|
const originalIsTTY = process.stdin.isTTY;
|
|
|
|
afterEach(() => {
|
|
Object.defineProperty(process.stdin, "isTTY", {
|
|
value: originalIsTTY,
|
|
configurable: true,
|
|
});
|
|
vi.mocked(fstatSync).mockRestore();
|
|
});
|
|
|
|
it("returns false when stdin is a TTY", async () => {
|
|
Object.defineProperty(process.stdin, "isTTY", {
|
|
value: true,
|
|
configurable: true,
|
|
});
|
|
const { stdinHasPipedInput } = await import("./main");
|
|
expect(stdinHasPipedInput()).toBe(false);
|
|
});
|
|
|
|
it("returns true when stdin is a FIFO (pipe)", async () => {
|
|
Object.defineProperty(process.stdin, "isTTY", {
|
|
value: undefined,
|
|
configurable: true,
|
|
});
|
|
vi.mocked(fstatSync).mockReturnValue({
|
|
isFIFO: () => true,
|
|
isFile: () => false,
|
|
} as unknown as ReturnType<typeof fstatSync>);
|
|
const { stdinHasPipedInput } = await import("./main");
|
|
expect(stdinHasPipedInput()).toBe(true);
|
|
});
|
|
|
|
it("returns true when stdin is a file", async () => {
|
|
Object.defineProperty(process.stdin, "isTTY", {
|
|
value: undefined,
|
|
configurable: true,
|
|
});
|
|
vi.mocked(fstatSync).mockReturnValue({
|
|
isFIFO: () => false,
|
|
isFile: () => true,
|
|
} as unknown as ReturnType<typeof fstatSync>);
|
|
const { stdinHasPipedInput } = await import("./main");
|
|
expect(stdinHasPipedInput()).toBe(true);
|
|
});
|
|
|
|
it("returns false in headless/CI (not TTY, not pipe, not file)", async () => {
|
|
Object.defineProperty(process.stdin, "isTTY", {
|
|
value: undefined,
|
|
configurable: true,
|
|
});
|
|
vi.mocked(fstatSync).mockReturnValue({
|
|
isFIFO: () => false,
|
|
isFile: () => false,
|
|
} as unknown as ReturnType<typeof fstatSync>);
|
|
const { stdinHasPipedInput } = await import("./main");
|
|
expect(stdinHasPipedInput()).toBe(false);
|
|
});
|
|
|
|
it("returns false when fstatSync throws", async () => {
|
|
Object.defineProperty(process.stdin, "isTTY", {
|
|
value: undefined,
|
|
configurable: true,
|
|
});
|
|
vi.mocked(fstatSync).mockImplementation(() => {
|
|
throw new Error("EBADF");
|
|
});
|
|
const { stdinHasPipedInput } = await import("./main");
|
|
expect(stdinHasPipedInput()).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("resolveConfigDirArg", () => {
|
|
it("returns undefined when --config is not present", async () => {
|
|
const { resolveConfigDirArg } = await import("./main");
|
|
expect(resolveConfigDirArg([])).toBeUndefined();
|
|
expect(
|
|
resolveConfigDirArg(["auth", "--provider", "openai"]),
|
|
).toBeUndefined();
|
|
});
|
|
|
|
it("parses the space-separated form: --config <dir>", async () => {
|
|
const { resolveConfigDirArg } = await import("./main");
|
|
expect(resolveConfigDirArg(["--config", "./mycfg"])).toBe("./mycfg");
|
|
expect(
|
|
resolveConfigDirArg([
|
|
"auth",
|
|
"--config",
|
|
"./mycfg",
|
|
"--provider",
|
|
"openai",
|
|
]),
|
|
).toBe("./mycfg");
|
|
});
|
|
|
|
it("parses the equals form: --config=<dir>", async () => {
|
|
const { resolveConfigDirArg } = await import("./main");
|
|
expect(resolveConfigDirArg(["--config=./mycfg"])).toBe("./mycfg");
|
|
expect(
|
|
resolveConfigDirArg(["auth", "--config=./mycfg", "--provider", "openai"]),
|
|
).toBe("./mycfg");
|
|
});
|
|
|
|
it("trims surrounding whitespace from the value", async () => {
|
|
const { resolveConfigDirArg } = await import("./main");
|
|
expect(resolveConfigDirArg(["--config", " ./mycfg "])).toBe("./mycfg");
|
|
expect(resolveConfigDirArg(["--config= ./mycfg "])).toBe("./mycfg");
|
|
});
|
|
|
|
it("returns undefined for empty or whitespace-only values", async () => {
|
|
const { resolveConfigDirArg } = await import("./main");
|
|
expect(resolveConfigDirArg(["--config", ""])).toBeUndefined();
|
|
expect(resolveConfigDirArg(["--config", " "])).toBeUndefined();
|
|
expect(resolveConfigDirArg(["--config="])).toBeUndefined();
|
|
expect(resolveConfigDirArg(["--config= "])).toBeUndefined();
|
|
});
|
|
|
|
it("returns undefined when --config has no following value (space form)", async () => {
|
|
const { resolveConfigDirArg } = await import("./main");
|
|
expect(resolveConfigDirArg(["--config"])).toBeUndefined();
|
|
});
|
|
|
|
it("returns the first occurrence when --config appears multiple times", async () => {
|
|
const { resolveConfigDirArg } = await import("./main");
|
|
expect(
|
|
resolveConfigDirArg(["--config", "./first", "--config", "./second"]),
|
|
).toBe("./first");
|
|
expect(resolveConfigDirArg(["--config=./first", "--config=./second"])).toBe(
|
|
"./first",
|
|
);
|
|
});
|
|
|
|
it("does not match unrelated flags that share a prefix", async () => {
|
|
const { resolveConfigDirArg } = await import("./main");
|
|
// e.g. a hypothetical --configure flag must not be picked up.
|
|
expect(resolveConfigDirArg(["--configure", "./foo"])).toBeUndefined();
|
|
expect(resolveConfigDirArg(["--configure=./foo"])).toBeUndefined();
|
|
});
|
|
});
|