Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -460,6 +460,16 @@ not available on `PATH`.

## Configuration

Select a model for one headless prompt without changing the saved default:

```bash
zcode --model account:zai-individual-coding-plan/GLM-5.3-Flash --prompt 'Reply with exactly: ok'
```

`--model` requires a full provider/model ID and `--prompt` (or `-p`). It also
overrides a resumed session's model for that run. Unknown or unavailable models
fail before a prompt is sent, rather than falling back to the default.

ZCode reads MCP servers, hooks, plugins, permissions, network, storage and display
settings from `~/.zcode/cli/setting.json` (or
`%USERPROFILE%\.zcode\cli\setting.json` on Windows). Project settings use
Expand Down
1 change: 1 addition & 0 deletions scripts/check-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@ if (patchRuntimeLoginModelDefaults(runtimeSource) !== runtimeSource
&& runtimeSource.includes('"OAuth response is not valid JSON",{httpStatus:void 0}'))
|| !runtimeSource.includes('ZCODE_CLI_OAUTH_CALLBACK_STDIN==="1"')
|| !runtimeSource.includes("ZCODE_CLI_CREDENTIALS_FILE")
|| !runtimeSource.includes("ZCODE_CLI_PROMPT_MODEL")
|| !runtimeSource.includes(".loadSessionTranscript=async()=>await(await")
|| !runtimeSource.includes('"loadSessionContextMessages"')
|| !runtimeSource.includes(".readGoal=async()=>await(await")
Expand Down
15 changes: 15 additions & 0 deletions scripts/sync-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1377,6 +1377,15 @@ export function patchRuntimeStreamEofFinishGuard(runtime: string): string {
return patched;
}

export function patchRuntimePromptModel(runtime: string): string {
if (runtime.includes("ZCODE_CLI_PROMPT_MODEL")) return runtime;
const anchor = /if\([A-Za-z_$][\w$]*=([A-Za-z_$][\w$]*)\.traceId,[A-Za-z_$][\w$]*\.memoryBench/u.exec(runtime);
if (!anchor) throw new Error("ZCode runtime is incompatible with the prompt model patch.");
return runtime.slice(0, anchor.index)
+ `if(process.env.ZCODE_CLI_PROMPT_MODEL)await ${anchor[1]}.setModel(process.env.ZCODE_CLI_PROMPT_MODEL,{transient:true});`
+ runtime.slice(anchor.index);
}

export function patchRuntimeCliCredentials(runtime: string): string {
const marker = 'ZCODE_CLI_CREDENTIALS_FILE';
if (runtime.includes(marker)) return runtime;
Expand Down Expand Up @@ -1663,6 +1672,12 @@ export const runtimePatchPlan: readonly RuntimePatchDefinition[] = [
apply: patchRuntimeCliCredentials,
verify: (runtime) => runtime.includes("ZCODE_CLI_CREDENTIALS_FILE")
},
{
id: "prompt-model",
requirement: "required",
apply: patchRuntimePromptModel,
verify: (runtime) => runtime.includes("ZCODE_CLI_PROMPT_MODEL")
},
{
id: "desktop-oauth",
requirement: "required",
Expand Down
27 changes: 23 additions & 4 deletions src/launcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import {
import { requestAppServer } from "./app-server-client.ts";
import { runPluginCommand } from "./plugin-cli.ts";
import { missingCodingPlanKey } from "./prompt-preflight.ts";
import { extractPromptModel } from "./prompt-model.ts";
import {
capabilitiesFromExtractionMetadata,
type RuntimeCliOptionType
Expand Down Expand Up @@ -240,13 +241,13 @@ function inspectRuntimeInvocation(
}

export async function promptPreflight(
args: string[], env: NodeJS.ProcessEnv = process.env
args: string[], env: NodeJS.ProcessEnv = process.env, model?: string
): Promise<string | undefined> {
const invocation = inspectRuntimeInvocation(args, readRuntimeCliOptionTypes());
if (!invocation.agentInvocation || invocation.invalid || invocation.passthrough || invocation.resume) {
return undefined;
}
return missingCodingPlanKey({ env, workingDirectory: invocation.workingDirectory });
return missingCodingPlanKey({ env, workingDirectory: invocation.workingDirectory, model });
}

export function withDefaultBrowserUse(
Expand Down Expand Up @@ -288,6 +289,7 @@ function runtimeEnvironment(extra: NodeJS.ProcessEnv = {}, inspectConfiguration
const env: NodeJS.ProcessEnv = { ...process.env };
delete env.ZCODE_CLI_OAUTH_CALLBACK_STDIN;
delete env.ZCODE_CLI_MIGRATE_CONFIG;
delete env.ZCODE_CLI_PROMPT_MODEL;
const distributionVersion = readDistributionVersion();
const appVersion = readJsonVersion(extractionMetadataPath, "appVersion");
const inherited: NodeJS.ProcessEnv = {
Expand Down Expand Up @@ -476,6 +478,16 @@ export async function main(args: string[]): Promise<number> {
return 0;
}

let promptModel: string | undefined;
try {
const parsed = extractPromptModel(args, readRuntimeCliOptionTypes());
args = parsed.args;
promptModel = parsed.model;
} catch (error) {
console.error(`Error: ${error instanceof Error ? error.message : String(error)}`);
return 1;
}

let setupPending = false;
try {
// doctor 必须能检查损坏的配置,不能先被普通启动校验或 provider 迁移拦住。
Expand Down Expand Up @@ -601,13 +613,20 @@ export async function main(args: string[]): Promise<number> {
}

try {
const diagnostic = await readConfiguredModelAccess() ? undefined : await promptPreflight(login.args);
const diagnostic = await readConfiguredModelAccess() ? undefined : await promptPreflight(login.args, process.env, promptModel);
if (diagnostic) {
console.error(diagnostic);
return 1;
}
const runtimeArgs = withDefaultBrowserUse(login.args);
return await runRuntime(node, runtimeArgs, firstRunSetupEnv(setupPending, runtimeArgs));
const code = await runRuntime(node, runtimeArgs, {
...firstRunSetupEnv(setupPending, runtimeArgs),
...(promptModel ? { ZCODE_CLI_PROMPT_MODEL: promptModel } : {})
});
if (code === 0 && (args.includes("--help") || args.includes("-h"))) {
console.log("\n --model <provider/model> Select a model for this --prompt run without changing the saved default.");
}
return code;
} catch (error) {
console.error(`Error: ${error instanceof Error ? error.message : String(error)}`);
return 1;
Expand Down
20 changes: 20 additions & 0 deletions src/prompt-model.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { parseArgs } from "node:util";
import type { RuntimeCliOptionType } from "./runtime-capabilities.ts";

export function extractPromptModel(
args: string[], optionTypes: Readonly<Record<string, RuntimeCliOptionType>>
): { args: string[]; model?: string } {
const options = Object.fromEntries(Object.entries(optionTypes).map(([name, type]) => [name, { type }]));
const parsed = parseArgs({ args, allowPositionals: true, strict: false, tokens: true,
options: { ...options, prompt: { type: "string", short: "p" }, model: { type: "string" } } });
const tokens = parsed.tokens.filter(token => token.kind === "option" && token.name === "model");
if (tokens.length === 0) return { args };
if (tokens.length !== 1) throw new Error("Specify --model only once.");
if (typeof parsed.values.prompt !== "string") throw new Error("--model requires --prompt or -p.");
const model = String(parsed.values.model).trim();
if (!/^[^/\s]+\/\S+$/u.test(model)) throw new Error("--model must be a provider/model ID.");
const token = tokens[0]!;
const removed = new Set([token.index]);
if (token.kind === "option" && !token.inlineValue) removed.add(token.index + 1);
return { args: args.filter((_, index) => !removed.has(index)), model };
}
41 changes: 41 additions & 0 deletions test/prompt-model.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { expect, test } from "bun:test";
import { extractPromptModel } from "../src/prompt-model.ts";
import { patchRuntimePromptModel } from "../scripts/sync-runtime.ts";

const model = "account:zai-start-plan/GLM-5.3-Flash";
const options = { prompt: "string", resume: "string" } as const;

test("extracts a per-prompt model while preserving prompt text and resume arguments", () => {
expect(extractPromptModel(["--model", model, "-p", "ok", "--resume", "session"], options))
.toEqual({ args: ["-p", "ok", "--resume", "session"], model });
expect(extractPromptModel([`--model=${model}`, "--prompt=ok"], options))
.toEqual({ args: ["--prompt=ok"], model });
expect(extractPromptModel(["--prompt", "--model"], options))
.toEqual({ args: ["--prompt", "--model"] });
expect(() => extractPromptModel(["--model", "glm", "-p", "ok"], options)).toThrow(/provider\/model/);
expect(() => extractPromptModel(["--model", model], options)).toThrow(/requires --prompt/);
expect(() => extractPromptModel(["--model", model, "--model", model, "-p", "ok"], options)).toThrow(/only once/);
});

test("switches the restored app transiently before submitting a prompt and propagates invalid models", async () => {
const source = 'async function run(app,options){let trace;if(trace=app.traceId,options.memoryBench)throw Error("unused");return app.submitPrompt("ok")}';
const patched = patchRuntimePromptModel(source);
expect(patchRuntimePromptModel(patched)).toBe(patched);
expect(() => patchRuntimePromptModel("unsupported")).toThrow(/incompatible/);
const load = (env: Record<string, string>) => new Function("process", `${patched};return run`)({ env });
const events: unknown[] = [];
const app = {
traceId: "fixture", setModel: async (...args: unknown[]) => { events.push(args); },
submitPrompt: async () => { events.push("prompt"); return "ok"; }
};
expect(await load({ ZCODE_CLI_PROMPT_MODEL: model })(app, {})).toBe("ok");
expect(events).toEqual([[model, { transient: true }], "prompt"]);
events.length = 0;
await load({})(app, {});
expect(events).toEqual(["prompt"]);
events.length = 0;
await expect(load({ ZCODE_CLI_PROMPT_MODEL: model })({ ...app,
setModel: async () => { throw new Error("Unknown model"); }
}, {})).rejects.toThrow("Unknown model");
expect(events).toEqual([]);
});
6 changes: 6 additions & 0 deletions test/prompt-preflight.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,12 @@ describe("prompt credential preflight (offline)", () => {
}
});

test("checks an explicit prompt model instead of the saved default", async () => {
const f = await fixture();
expect(await promptPreflight(["--cwd", f.home, "-p", "hello"], f.env,
"account:zai-individual-coding-plan/GLM-5.3-Flash")).toBeUndefined();
});

test("covers headless prompt forms while preserving help, login and resume", async () => {
const f = await fixture();
for (const args of [["--prompt", "hello"], ["--prompt=hello"], ["--print", "hello"], ["-p", "hello"], ["--target", "hello"]]) {
Expand Down
Loading