feat(cli): use saved provider settings for schedules (#10667)

Co-authored-by: Cline Agent <cline-agent@users.noreply.github.com>
This commit is contained in:
Bee
2026-08-06 17:34:40 -07:00
committed by GitHub
parent 574b8eb45e
commit c18d9478ba
6 changed files with 261 additions and 28 deletions
+4 -2
View File
@@ -221,13 +221,15 @@ In chat surfaces, connector slash commands include `/help`, `/start`, `/new`, `/
Schedule agents on cron-like intervals or external events.
If `--provider` and `--model` are omitted, schedules use the last configured
provider and model. If only `--provider` is given, the schedule uses that
provider's saved model.
```sh
cline schedule create "Daily code review" \
--cron "0 9 * * MON-FRI" \
--prompt "Review PRs opened yesterday and summarize issues." \
--workspace /path/to/repo \
--provider cline \
--model openai/gpt-5.3-codex \
--timeout 3600 \
--tags automation,review
+177 -4
View File
@@ -6,11 +6,29 @@ import { createScheduleCommand } from "./schedule";
const mockSendHubCommand = vi.hoisted(() => vi.fn());
const mockEnsureCliHubServer = vi.hoisted(() => vi.fn());
vi.mock("@cline/core", () => ({
sendHubCommand: mockSendHubCommand,
const mockProviderSettings = vi.hoisted(() => ({
lastUsed: undefined as { provider?: string; model?: string } | undefined,
providers: {} as Record<string, { provider?: string; model?: string }>,
}));
vi.mock("@cline/core", async () => {
const actual =
await vi.importActual<typeof import("@cline/core")>("@cline/core");
return {
...actual,
sendHubCommand: mockSendHubCommand,
ProviderSettingsManager: class {
getLastUsedProviderSettings() {
return mockProviderSettings.lastUsed;
}
getProviderSettings(providerId: string) {
return mockProviderSettings.providers[providerId];
}
},
};
});
vi.mock("../utils/hub-runtime", () => ({
ensureCliHubServer: mockEnsureCliHubServer,
parseHubEndpointOverride: (rawAddress: string | undefined) => {
@@ -47,6 +65,8 @@ async function runScheduleCommand(
describe("runScheduleCommand list output", () => {
afterEach(() => {
vi.clearAllMocks();
mockProviderSettings.lastUsed = undefined;
mockProviderSettings.providers = {};
});
it('prints "No schedules found." for empty non-json list output', async () => {
@@ -121,9 +141,158 @@ describe("runScheduleCommand list output", () => {
});
});
describe("runScheduleCommand create delivery metadata", () => {
describe("runScheduleCommand create", () => {
afterEach(() => {
vi.clearAllMocks();
mockProviderSettings.lastUsed = undefined;
mockProviderSettings.providers = {};
});
it("uses the last used provider and model when both flags are omitted", async () => {
mockProviderSettings.lastUsed = {
provider: "anthropic",
model: "claude-sonnet-4-6",
};
mockEnsureCliHubServer.mockResolvedValue({
url: "ws://127.0.0.1:25463/hub",
authToken: "test-token",
});
mockSendHubCommand.mockResolvedValue({
ok: true,
payload: { schedule: { scheduleId: "sched_123" } },
});
const output: string[] = [];
const errors: string[] = [];
const code = await runScheduleCommand(
[
"create",
"Health check",
"--cron",
"0 */6 * * *",
"--prompt",
"Run tests",
"--workspace",
"/tmp/workspace",
"--address",
"127.0.0.1:25463",
],
{
writeln: (text?: string) => {
output.push(text ?? "");
},
writeErr: (text: string) => {
errors.push(text);
},
},
);
expect(code).toBe(0);
expect(errors).toEqual([]);
expect(mockSendHubCommand).toHaveBeenCalledWith(
{ host: "127.0.0.1", port: 25463, pathname: "/hub" },
expect.objectContaining({
clientId: "cline-schedule",
command: "schedule.create",
payload: expect.objectContaining({
provider: "anthropic",
model: "claude-sonnet-4-6",
}),
}),
);
});
it("uses an explicit provider with that provider's configured model", async () => {
mockProviderSettings.lastUsed = {
provider: "cline",
model: "openai/gpt-5.3-codex",
};
mockProviderSettings.providers.anthropic = {
provider: "anthropic",
model: "claude-sonnet-4-6",
};
mockEnsureCliHubServer.mockResolvedValue({
url: "ws://127.0.0.1:25463/hub",
authToken: "test-token",
});
mockSendHubCommand.mockResolvedValue({
ok: true,
payload: { schedule: { scheduleId: "sched_123" } },
});
const errors: string[] = [];
const code = await runScheduleCommand(
[
"create",
"Health check",
"--cron",
"0 */6 * * *",
"--prompt",
"Run tests",
"--workspace",
"/tmp/workspace",
"--provider",
"anthropic",
"--address",
"127.0.0.1:25463",
],
{
writeln: () => {},
writeErr: (text: string) => {
errors.push(text);
},
},
);
expect(code).toBe(0);
expect(errors).toEqual([]);
expect(mockSendHubCommand).toHaveBeenCalledWith(
{ host: "127.0.0.1", port: 25463, pathname: "/hub" },
expect.objectContaining({
command: "schedule.create",
payload: expect.objectContaining({
provider: "anthropic",
model: "claude-sonnet-4-6",
}),
}),
);
});
it("fails when an explicit provider has no configured model and no model flag", async () => {
mockEnsureCliHubServer.mockResolvedValue({
url: "ws://127.0.0.1:25463/hub",
authToken: "test-token",
});
const errors: string[] = [];
const code = await runScheduleCommand(
[
"create",
"Health check",
"--cron",
"0 */6 * * *",
"--prompt",
"Run tests",
"--workspace",
"/tmp/workspace",
"--provider",
"anthropic",
"--address",
"127.0.0.1:25463",
],
{
writeln: () => {},
writeErr: (text: string) => {
errors.push(text);
},
},
);
expect(code).toBe(1);
expect(errors).toEqual([
'No model is configured for provider "anthropic". Pass --model or save a model for that provider before creating the schedule.',
]);
expect(mockSendHubCommand).not.toHaveBeenCalled();
});
it("maps --delivery-bot to delivery.userName", async () => {
@@ -192,6 +361,8 @@ describe("runScheduleCommand create delivery metadata", () => {
describe("runScheduleCommand import", () => {
afterEach(() => {
vi.clearAllMocks();
mockProviderSettings.lastUsed = undefined;
mockProviderSettings.providers = {};
});
it("preserves exported modelSelection providerId/modelId values", async () => {
@@ -257,6 +428,8 @@ describe("runScheduleCommand import", () => {
describe("runScheduleCommand export", () => {
afterEach(() => {
vi.clearAllMocks();
mockProviderSettings.lastUsed = undefined;
mockProviderSettings.providers = {};
});
it("writes JSON content to the --to file path", async () => {
+9 -5
View File
@@ -1,4 +1,3 @@
import { CLINE_DEFAULT_MODEL_ID } from "@cline/shared";
import type { Command } from "commander";
import { ensureSchedulerHub } from "./client";
import {
@@ -19,6 +18,7 @@ import {
registerScheduleImportCommand,
registerScheduleUpdateCommand,
} from "./import-export";
import { resolveScheduleModelSelection } from "./model-selection";
import type { CommandIo, ScheduleActionWrapper } from "./types";
export function registerScheduleCommands(
@@ -66,8 +66,8 @@ export function registerScheduleCommands(
.option("--max-parallel <n>", "Max parallel executions", "1")
.option("--metadata-json <json>", "Metadata as JSON object")
.option("--mode <act|plan|yolo>", "Execution mode", "yolo")
.option("--model <model>", "Model to use", CLINE_DEFAULT_MODEL_ID)
.option("--provider <id>", "Provider ID", "cline")
.option("--model <model>", "Model to use")
.option("--provider <id>", "Provider ID")
.option("--system-prompt <text>", "System prompt override")
.option("--tags <list>", "Comma-separated tags")
.option("--timeout <seconds>", "Timeout in seconds");
@@ -92,12 +92,16 @@ export function registerScheduleCommands(
parseJsonObjectFlag(opts.metadataJson),
opts,
);
const modelSelection = resolveScheduleModelSelection({
provider: opts.provider,
model: opts.model,
});
const created = await client.createSchedule({
name,
cronPattern: opts.cron,
prompt: opts.prompt,
provider: opts.provider,
model: opts.model,
provider: modelSelection.provider,
model: modelSelection.model,
mode: parseMode(opts.mode) ?? "yolo",
workspaceRoot: opts.workspace,
cwd: opts.cwd,
+15 -14
View File
@@ -1,6 +1,5 @@
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { dirname, isAbsolute, resolve } from "node:path";
import { CLINE_DEFAULT_MODEL_ID } from "@cline/shared";
import type { Command } from "commander";
import { ensureSchedulerHub } from "./client";
import {
@@ -18,8 +17,13 @@ import {
resolveAddress,
toPositiveInt,
} from "./common";
import { resolveScheduleModelSelection } from "./model-selection";
import type { CommandIo, ScheduleActionWrapper } from "./types";
function stringValue(value: unknown): string | undefined {
return typeof value === "string" ? value : undefined;
}
function resolveImportedModelSelection(parsed: Record<string, unknown>): {
provider: string;
model: string;
@@ -30,19 +34,16 @@ function resolveImportedModelSelection(parsed: Record<string, unknown>): {
!Array.isArray(parsed.modelSelection)
? (parsed.modelSelection as Record<string, unknown>)
: undefined;
const provider = String(
modelSelection?.providerId ??
parsed.providerId ??
parsed.provider ??
"cline",
).trim();
const model = String(
modelSelection?.modelId ??
parsed.modelId ??
parsed.model ??
CLINE_DEFAULT_MODEL_ID,
).trim();
return { provider, model };
return resolveScheduleModelSelection({
provider:
stringValue(modelSelection?.providerId) ??
stringValue(parsed.providerId) ??
stringValue(parsed.provider),
model:
stringValue(modelSelection?.modelId) ??
stringValue(parsed.modelId) ??
stringValue(parsed.model),
});
}
export function registerScheduleExportCommand(
@@ -0,0 +1,52 @@
import { type ProviderSettings, ProviderSettingsManager } from "@cline/core";
import { CLINE_DEFAULT_MODEL_ID } from "@cline/shared";
export const DEFAULT_SCHEDULE_PROVIDER = "cline";
interface ProviderSettingsReader {
getLastUsedProviderSettings(): ProviderSettings | undefined;
getProviderSettings(providerId: string): ProviderSettings | undefined;
}
function trimToUndefined(value: string | undefined): string | undefined {
const trimmed = value?.trim();
return trimmed ? trimmed : undefined;
}
export function resolveScheduleModelSelection(
options: {
provider?: string;
model?: string;
},
providerSettingsManager?: ProviderSettingsReader,
): { provider: string; model: string } {
const explicitProvider = trimToUndefined(options.provider);
const explicitModel = trimToUndefined(options.model);
if (explicitProvider && explicitModel) {
return { provider: explicitProvider, model: explicitModel };
}
const manager = providerSettingsManager ?? new ProviderSettingsManager();
const lastUsedSettings = manager.getLastUsedProviderSettings();
const provider =
explicitProvider ??
trimToUndefined(lastUsedSettings?.provider) ??
DEFAULT_SCHEDULE_PROVIDER;
const selectedProviderSettings = explicitProvider
? manager.getProviderSettings(provider)
: lastUsedSettings;
const model =
explicitModel ??
trimToUndefined(selectedProviderSettings?.model) ??
(provider === DEFAULT_SCHEDULE_PROVIDER
? CLINE_DEFAULT_MODEL_ID
: undefined);
if (!model) {
throw new Error(
`No model is configured for provider "${provider}". Pass --model or save a model for that provider before creating the schedule.`,
);
}
return { provider, model };
}
+4 -3
View File
@@ -1,10 +1,10 @@
import * as p from "@clack/prompts";
import { CLINE_DEFAULT_MODEL_ID } from "@cline/shared";
import {
ensureSchedulerHub,
type HubScheduleClient,
} from "../../commands/schedule/client";
import { resolveAddress } from "../../commands/schedule/common";
import { resolveScheduleModelSelection } from "../../commands/schedule/model-selection";
import { CRON_PRESETS } from "./cron-presets";
function isCancel(value: unknown): value is symbol {
@@ -211,12 +211,13 @@ async function actionCreate(client: HubScheduleClient): Promise<void> {
}
}
const modelSelection = resolveScheduleModelSelection({ provider, model });
const created = (await client.createSchedule({
name: (name as string).trim(),
cronPattern,
prompt: (prompt as string).trim(),
provider: provider ?? "cline",
model: model ?? CLINE_DEFAULT_MODEL_ID,
provider: modelSelection.provider,
model: modelSelection.model,
mode: mode as "act" | "plan" | "yolo",
workspaceRoot: (workspace as string).trim(),
systemPrompt,