Skip to content
Open
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
22 changes: 21 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -496,7 +496,7 @@ saved default:
zcode --model account:zai-individual-coding-plan/GLM-5.3-Flash --effort high --prompt 'Reply with exactly: ok'
```

`--model` requires a full provider/model ID and `--prompt` (or `-p`). It also
`--model` normally 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.

Expand All @@ -511,6 +511,26 @@ run's model and effort are recorded on that session.
For automation and long-running `-p` jobs, see [headless execution](./docs/HEADLESS.md)
for output completion, cancellation, background execution and validation limits.

For CI with a custom model endpoint, configure the invocation through environment
variables; no Desktop installation, browser login or GitHub App is required:

```bash
ZCODE_MODEL_BASE_URL=https://gateway.example.com/v1 \
ZCODE_API_KEY="$CI_MODEL_API_KEY" \
ZCODE_MODEL=organization/model-name \
ZCODE_API_TYPE=openai-chat-completions \
ZCODE_EFFORT=high \
zcode -p "$(cat .github/prompts/review.md)" --output-format stream-json
```

In this mode `ZCODE_MODEL` and `--model` are raw server model IDs, including IDs
containing `/`. Explicit `--model` and `--effort` override their environment
counterparts. The provider is temporary and does not replace saved settings.
`ZCODE_MODEL_BASE_URL` is the inference endpoint; the existing `ZCODE_BASE_URL`
still controls the ZCode account service. See the [CI setup and security
guidance](./docs/HEADLESS.md#environment-configuration-for-ci) before running this
against repository-controlled code.

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
7 changes: 7 additions & 0 deletions docs/CONFIGURATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -384,6 +384,13 @@ flow; ciphertext is not an API key.

## Custom provider

For unattended `--prompt` runs, `ZCODE_MODEL_BASE_URL`, `ZCODE_API_KEY`,
`ZCODE_MODEL`, `ZCODE_API_TYPE` and `ZCODE_EFFORT` provide a temporary custom
provider without writing personal settings. See [CI environment
configuration](HEADLESS.md#environment-configuration-for-ci) for precedence,
protocol mappings, GitHub Actions setup and security limits. `ZCODE_BASE_URL`
remains the account service URL, not the inference URL.

Use `provider.example.json` as a reference for a new provider file. Its enabled
model inherits the upstream catalog; its disabled reference models demonstrate
all smart-override and manual fields. Fill the empty API key, replace the
Expand Down
11 changes: 11 additions & 0 deletions docs/CONFIGURATION.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,17 @@ CLI 跟随当前 ZCode runtime 的 provider registry schema。Provider 和模型

## 配置文件

CI 等无人值守场景可在 `--prompt` / `-p` 运行时设置 `ZCODE_MODEL_BASE_URL`、
`ZCODE_API_KEY`、`ZCODE_MODEL`、`ZCODE_API_TYPE` 和 `ZCODE_EFFORT`,无需预先登录
或手写 provider 文件。自定义端点模式使用临时 provider,不覆盖个人配置;此时模型值是
服务端原始 ID(可包含 `/`),`--model` / `--effort` 优先于对应环境变量。
不配置自定义端点时,`ZCODE_MODEL` 与 `--model` 一样使用 `provider/model` 格式。
`ZCODE_BASE_URL` 仍为账号服务地址,模型地址必须使用 `ZCODE_MODEL_BASE_URL`。
协议默认 `openai-chat-completions`,也支持 `openai-responses` 和
`anthropic-messages`。这些变量仅作用于 headless prompt,不改变 TUI 或 app-server。
具体协议映射、GitHub Actions 示例和密钥隔离限制见
[CI 环境变量配置](HEADLESS.md#environment-configuration-for-ci)。

| 文件 | 用途 |
| --- | --- |
| `~/.zcode/cli/setting.json` | MCP、hooks、插件、权限、网络、存储和 CLI 显示设置 |
Expand Down
113 changes: 113 additions & 0 deletions docs/HEADLESS.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,119 @@
`zcode -p 'task'` runs without a TUI. It is a foreground command, not a daemon,
and headless mode is separate from the browser's `--browser-use=headless` setting.

## Environment configuration for CI

Headless `--prompt` / `-p` invocations accept the following environment variables.
They do not configure the TUI, login, doctor, plugin commands or app-server.

| Variable | Meaning |
| --- | --- |
| `ZCODE_MODEL_BASE_URL` | Custom inference API root, including its API prefix, e.g. `https://gateway.example.com/v1` |
| `ZCODE_API_KEY` | API key for that custom endpoint |
| `ZCODE_API_TYPE` | `openai-chat-completions` (default), `openai-responses`, or `anthropic-messages` |
| `ZCODE_MODEL` | Raw server model ID with a custom endpoint; otherwise a saved `provider/model` selection |
| `ZCODE_EFFORT` | Reasoning effort for this run; explicit `--effort` takes precedence |

Blank variables are treated as unset. Specifying an endpoint, key or API type
activates custom-provider mode and requires an endpoint, key and model. `--model`
may supply the model instead of `ZCODE_MODEL`. In custom-provider mode it is a
raw server ID, not a saved provider selection, and `/` is preserved verbatim.
Without custom-provider mode, `--model` and `ZCODE_MODEL` require `provider/model`.
Explicit CLI model/effort flags take precedence over environment variables, which
take precedence over saved or resumed selections. Reapply the custom endpoint
environment when resuming a session created with it.

`ZCODE_BASE_URL` retains its existing ZCode account/control-service meaning. It is
not an alias for `ZCODE_MODEL_BASE_URL`. A custom model endpoint must be HTTP(S)
without embedded credentials, query parameters or fragments. Use HTTPS for remote
services. The endpoint must implement the selected protocol, including streaming
and tool calling for agent tasks; a compatible-looking URL alone is insufficient.

Custom-provider mode creates a private temporary provider file (directory mode
0700 and file mode 0600 on POSIX), containing only the supplied provider. It uses
the internal provider ID `cli-env` and does not overwrite the personal provider
file, saved default model or account credentials. The launcher removes its
temporary directory after success, failure or handled cancellation. `SIGKILL`,
runner failure or power loss cannot guarantee cleanup; prefer disposable runners
and do not archive temporary/runtime directories containing secrets. Normal
runtime session data may still be persisted; this is not an ephemeral-session or
secret-isolation feature.

With a custom endpoint, omitted effort or `auto` sends no explicit reasoning
parameters. Explicit effort uses the selected protocol's mapping:

- Chat Completions: `reasoning_effort`.
- Responses: `reasoning.effort`.
- Anthropic Messages: adaptive `thinking` with `output_config.effort`;
`none` / `disabled` disables thinking instead.

The custom endpoint validates whether the model accepts that effort and mapping.
These are protocol presets, not automatic model capability discovery. Older
Anthropic models requiring token-budget thinking, gateway-specific mappings,
custom headers and model capability overrides should use the full
[provider configuration](PROVIDER_CONFIG.md) instead. Without custom-provider
mode, effort continues to use the saved provider's catalog and validation.
Invalid local configuration fails before starting the runtime. Provider errors
fail the job rather than falling back to an account or saved provider.

### GitHub Actions example

Create repository variables `AI_BASE_URL`, `AI_MODEL`, and optionally `AI_EFFORT`,
plus the secret `AI_API_KEY`. Add a trusted prompt at `.github/prompts/review.md`.
This minimal example runs only on manual dispatch, not arbitrary external PRs:

```yaml
name: ZCode task
on:
workflow_dispatch:
permissions:
contents: read
jobs:
task:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v6
with:
persist-credentials: false
- uses: actions/setup-node@v6
with:
node-version: '24'
- name: Install CLI
run: npm install -g zcode-app-cli
- name: Run trusted task
env:
ZCODE_MODEL_BASE_URL: ${{ vars.AI_BASE_URL }}
ZCODE_API_KEY: ${{ secrets.AI_API_KEY }}
ZCODE_MODEL: ${{ vars.AI_MODEL }}
ZCODE_EFFORT: ${{ vars.AI_EFFORT }}
ZCODE_API_TYPE: openai-chat-completions
shell: bash
run: |
set -euo pipefail
zcode -p "$(cat .github/prompts/review.md)" \
--mode build --disallowed-tools 'Bash Edit Write' \
--output-format stream-json > "$RUNNER_TEMP/zcode-result.jsonl"
jq -s -e 'any(.[]; .type == "result")' "$RUNNER_TEMP/zcode-result.jsonl" > /dev/null
```

Pin CLI versions and action commit SHAs according to your deployment policy.
This example restricts common shell/edit tools for an inspection task; it is not
an OS sandbox or a comprehensive allowlist. Approval-requiring operations cannot
rely on the TUI in CI. Use `yolo` only for trusted workloads on isolated runners.
Successful execution is not a quality gate by itself; parse the task's outcome
separately if findings should fail CI.

Pass keys only to the invocation/step that needs them, not a whole job containing
untrusted install/build scripts. Even step-scoped keys and private files can be
read by tools or repository-controlled code executed by the agent. Environment
configuration does not isolate credentials. Do not expose secrets to untrusted
forks or use `pull_request_target` to execute untrusted PR code. Review prompt
injection risks, project hooks, plugins and MCP configuration. Run GitHub writes
(comments/PR creation) separately with narrowly scoped GitHub credentials; those
credentials are distinct from the model API key. A GitHub App is not required
for this CLI-based integration.

## Long-running jobs

- The prompt lifecycle keeps the Node event loop alive until prompt work and
Expand Down
24 changes: 14 additions & 10 deletions src/launcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import { requestAppServer } from "./app-server-client.ts";
import { runPluginCommand } from "./plugin-cli.ts";
import { missingCodingPlanKey } from "./prompt-preflight.ts";
import { extractPromptOverrides } from "./prompt-model.ts";
import { preparePromptEnvironment, resolvePromptEnvironment, type PromptEnvironment } from "./prompt-environment.ts";
import {
capabilitiesFromExtractionMetadata,
type RuntimeCliOptionType
Expand Down Expand Up @@ -488,13 +489,11 @@ export async function main(args: string[]): Promise<number> {
return 0;
}

let promptModel: string | undefined;
let promptEffort: string | undefined;
let promptSelection: PromptEnvironment;
try {
const parsed = extractPromptOverrides(args, readRuntimeCliOptionTypes());
const parsed = extractPromptOverrides(args, readRuntimeCliOptionTypes(), Boolean(process.env.ZCODE_MODEL_BASE_URL?.trim()));
args = parsed.args;
promptModel = parsed.model;
promptEffort = parsed.effort;
promptSelection = resolvePromptEnvironment(parsed);
} catch (error) {
console.error(`Error: ${error instanceof Error ? error.message : String(error)}`);
return 1;
Expand All @@ -520,7 +519,7 @@ export async function main(args: string[]): Promise<number> {
}

const node = resolveNodeExecutable();
if (providerMigrationNeeded()) {
if (!promptSelection.provider && providerMigrationNeeded()) {
try {
await new Promise<void>((resolve, reject) => {
const child = spawnChild(node, [runtimePath, "--version"], {
Expand Down Expand Up @@ -633,25 +632,30 @@ export async function main(args: string[]): Promise<number> {
}
}

let preparedPrompt: Awaited<ReturnType<typeof preparePromptEnvironment>> | undefined;
try {
const diagnostic = await readConfiguredModelAccess() ? undefined : await promptPreflight(login.args, process.env, promptModel);
preparedPrompt = await preparePromptEnvironment(promptSelection);
const promptEnv = { ...process.env, ...preparedPrompt.env };
const diagnostic = await readConfiguredModelAccess(promptEnv) ? undefined : await promptPreflight(login.args, promptEnv, promptSelection.model);
if (diagnostic) {
console.error(diagnostic);
return 1;
}
const runtimeArgs = withDefaultBrowserUse(login.args);
const code = await runRuntime(node, runtimeArgs, {
...firstRunSetupEnv(setupPending, runtimeArgs),
...(promptModel ? { ZCODE_CLI_PROMPT_MODEL: promptModel } : {}),
...(promptEffort ? { ZCODE_CLI_PROMPT_EFFORT: promptEffort } : {})
...preparedPrompt.env
});
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.");
console.log("\n --model <provider/model> Select a model for this --prompt run; with ZCODE_MODEL_BASE_URL, use a raw server model ID.");
console.log(" --effort <level> Select the model reasoning effort for this --prompt run; auto keeps the default.");
console.log("\n Headless environment: ZCODE_MODEL, ZCODE_EFFORT; custom endpoint: ZCODE_MODEL_BASE_URL, ZCODE_API_KEY, ZCODE_API_TYPE.");
}
return code;
} catch (error) {
console.error(`Error: ${error instanceof Error ? error.message : String(error)}`);
return 1;
} finally {
await preparedPrompt?.dispose();
}
}
104 changes: 104 additions & 0 deletions src/prompt-environment.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { PromptOverrides } from "./prompt-model.ts";

const environmentProviderId = "cli-env";
const apiTypes = ["openai-chat-completions", "openai-responses", "anthropic-messages"] as const;
type ApiType = typeof apiTypes[number];

export interface PromptEnvironment {
model?: string;
effort?: string;
provider?: { baseUrl: string; apiKey: string; modelId: string; apiType: ApiType };
}

export function resolvePromptEnvironment(
overrides: PromptOverrides, env: NodeJS.ProcessEnv = process.env
): PromptEnvironment {
if (!overrides.headless) return { model: overrides.model, effort: overrides.effort };
const model = overrides.model ?? (env.ZCODE_MODEL?.trim() || undefined);
const effort = overrides.effort ?? (env.ZCODE_EFFORT?.trim() || undefined);
if (effort && (!/^[a-z][a-z0-9_-]*$/iu.test(effort))) {
throw new Error("ZCODE_EFFORT must be a single reasoning effort level.");
}
const baseUrl = env.ZCODE_MODEL_BASE_URL?.trim();
const apiKey = env.ZCODE_API_KEY?.trim();
const apiType = env.ZCODE_API_TYPE?.trim() || "openai-chat-completions";
const customProvider = Boolean(baseUrl || apiKey || env.ZCODE_API_TYPE?.trim());
if (!customProvider) {
if (model && !/^[^/\s]+\/\S+$/u.test(model)) {
throw new Error("ZCODE_MODEL must be a provider/model ID unless ZCODE_MODEL_BASE_URL is configured.");
}
return { model, effort };
}
if (!baseUrl || !apiKey || !model) {
throw new Error("Custom model access requires ZCODE_MODEL_BASE_URL, ZCODE_API_KEY and ZCODE_MODEL (or --model).");
}
let url: URL;
try { url = new URL(baseUrl); } catch {
throw new Error("ZCODE_MODEL_BASE_URL must be an absolute HTTP(S) URL.");
}
if (!["https:", "http:"].includes(url.protocol) || url.username || url.password || url.search || url.hash) {
throw new Error("ZCODE_MODEL_BASE_URL must be an HTTP(S) URL without credentials, query or fragment.");
}
if (!apiTypes.includes(apiType as ApiType)) {
throw new Error(`ZCODE_API_TYPE must be one of: ${apiTypes.join(", ")}.`);
}
if (/\s/u.test(model) || model.startsWith("-")) throw new Error("Custom model ID must not contain whitespace or start with '-'.");
if (/[\r\n]/u.test(apiKey)) throw new Error("ZCODE_API_KEY must not contain line breaks.");
return {
model: `${environmentProviderId}/${model}`, effort,
provider: { baseUrl, apiKey, modelId: model, apiType: apiType as ApiType }
};
}

export async function preparePromptEnvironment(selection: PromptEnvironment): Promise<{
env: NodeJS.ProcessEnv;
dispose(): Promise<void>;
}> {
const env: NodeJS.ProcessEnv = {
...(selection.model ? { ZCODE_CLI_PROMPT_MODEL: selection.model } : {}),
...(selection.effort ? { ZCODE_CLI_PROMPT_EFFORT: selection.effort } : {})
};
if (!selection.provider) return { env, dispose: async () => {} };
const { baseUrl, apiKey, modelId, apiType } = selection.provider;
const effort = selection.effort?.toLowerCase();
const explicitEffort = effort && effort !== "auto" ? effort : undefined;
const reasoningMap = !explicitEffort ? "{}" : apiType === "openai-chat-completions"
? '{"reasoning_effort": reasoningLevel}'
: apiType === "openai-responses" ? '{"reasoning": {"effort": reasoningLevel}}'
: 'reasoningLevel == "disabled" || reasoningLevel == "none" ? {"thinking": {"type": "disabled"}} : {"thinking": {"type": "adaptive"}, "output_config": {"effort": reasoningLevel}}';
const providerConfig = {
schemaVersion: 1,
config: {
providerConfigRules: { providerRules: [{
providerId: environmentProviderId, providerName: "Environment model", enabled: true,
config: {
group: "standard-personal", access: { type: "api-key", apiKey },
api: { type: apiType, baseUrl }, personalModelIds: [modelId]
}
}] },
modelConfigRules: {
providerModelRules: [{ providerId: environmentProviderId, modelId, config: {
enabled: true, optionSpecs: { reasoningLevel: {
values: [explicitEffort ?? "default"], map: reasoningMap
} }
} }],
manualProviderModelRules: []
},
defaultModelSelection: { providerId: environmentProviderId, modelId }
}
};
const directory = await mkdtemp(join(tmpdir(), "zcode-prompt-provider-"));
const dispose = async () => { await rm(directory, { recursive: true, force: true }); };
try {
const path = join(directory, "provider_config.json");
await writeFile(path, JSON.stringify(providerConfig), { mode: 0o600 });
env.ZCODE_PERSONAL_PROVIDER_CONFIG_FILE = path;
return { env, dispose };
} catch (error) {
await dispose();
throw error;
}
}
Loading
Loading