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
1 change: 1 addition & 0 deletions .changelog/next/fixed-issue-4139.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- Kimi Code CLI headless runs no longer fail at startup — PortOS was passing `--print` and `--afk`, neither of which exists in the `kimi` binary (it exits with `unknown option` before running anything). Non-interactive mode is implicit in `--prompt`, and kimi refuses approval flags (`--yolo`/`-y`/`--auto`) alongside `--prompt`, so the headless argv now carries only `--model` (when pinned) plus the prompt. Migration `269-kimi-drop-nonexistent-flags` strips the dead flags from installs that already stored them. (#4139)
2 changes: 1 addition & 1 deletion data.reference/providers.json
Original file line number Diff line number Diff line change
Expand Up @@ -323,7 +323,7 @@
"name": "Kimi Code CLI",
"type": "cli",
"command": "kimi",
"args": ["--print"],
"args": [],
"models": ["kimi-configured-default"],
"defaultModel": "kimi-configured-default",
"lightModel": "kimi-configured-default",
Expand Down
10 changes: 8 additions & 2 deletions scripts/migrations/201-kimi-providers.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,14 @@
* process-provider entries: `kimi-cli` (headless one-shot via `kimi --print`) and
* `kimi-tui` (interactive PTY). The plain HTTP API entry already exists separately
* as `nvidia-kimi`. The CLI/TUI argv conventions live in server/lib/kimi.js (kimi
* reads its prompt as the `--prompt <value>` argv, not raw stdin; `--print` implies
* `--afk` so headless runs auto-approve).
* reads its prompt as the `--prompt <value>` argv, not raw stdin).
*
* NOTE (issue #4139): the frozen `KIMI_CLI` def below seeds `args: ["--print"]`,
* which a live `kimi` v0.32.0 rejects outright — the flag does not exist. The def
* is left as-is because it is the historical record of what this migration
* installed; migration 269 strips the token from any install that received it
* (including one seeding it here for the first time, since migrations run in
* numeric order), and the data.reference seed now ships empty args.
*
* `setup-data.js` merges *missing* provider entries from data.reference, but only
* when an install re-runs setup. This migration delivers the providers on a plain
Expand Down
75 changes: 75 additions & 0 deletions scripts/migrations/269-kimi-drop-nonexistent-flags.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
/**
* Strip the non-existent `--print` / `--afk` flags from the Kimi Code providers'
* stored args so headless runs stop dying at argv parsing (issue #4139).
*
* Migration 201 seeded `kimi-cli` with `args: ["--print"]`, documented from
* MoonshotAI docs without a live binary to check against. A live `kimi` v0.32.0
* rejects it outright:
*
* $ kimi --print -p "hello"
* error: unknown option '--print'
* (Did you mean --prompt?)
*
* Non-interactive mode is implicit in supplying `-p`/`--prompt`, so the headless
* argv needs no mode flag at all. `--afk` is equally unrecognized on both the CLI
* and TUI paths. `server/lib/kimi.js` no longer injects either, but a deployed
* install already carries the bad token in `data/providers.json` — `setup-data.js`
* merges only *missing* provider entries and never updates existing ones — so
* without this migration every stored `kimi-cli` stays broken.
*
* Unlike the conservative "rewrite only an exactly-matching old default" rule used
* by 121-codex-tui-bypass-sandbox, this migration removes the two tokens wherever
* they appear in a Kimi provider's args, including a hand-curated list. That is
* safe because neither flag exists in the binary at all: keeping one is never a
* valid user preference, it is a guaranteed startup failure. Every other arg the
* user added is preserved in order, so the rest of their customization survives.
*/

import { readFile, writeFile } from 'fs/promises';
import { join } from 'path';

const PROVIDERS_REL_PATH = 'data/providers.json';

const TARGET_IDS = ['kimi-cli', 'kimi-tui'];
const DEAD_FLAGS = new Set(['--print', '--afk']);

export default {
async up({ rootDir }) {
const providersPath = join(rootDir, PROVIDERS_REL_PATH);
const raw = await readFile(providersPath, 'utf-8').catch((err) => {
if (err.code === 'ENOENT') return null;
throw err;
});
if (raw == null) {
console.log(`📄 ${PROVIDERS_REL_PATH} not present — skipping (fresh install seeds Kimi from data.reference without the dead flags)`);
return;
}

let config;
try {
config = JSON.parse(raw);
} catch (err) {
console.log(`⚠️ ${PROVIDERS_REL_PATH}: invalid JSON, skipping (${err.message})`);
return;
}

let changed = 0;
for (const id of TARGET_IDS) {
const provider = config?.providers?.[id];
if (!provider || !Array.isArray(provider.args)) continue;
const kept = provider.args.filter((arg) => !DEAD_FLAGS.has(arg));
if (kept.length === provider.args.length) continue;
const dropped = provider.args.filter((arg) => DEAD_FLAGS.has(arg));
provider.args = kept;
changed++;
console.log(`📝 ${PROVIDERS_REL_PATH}: ${id} dropped ${dropped.join(' ')} (not real kimi flags)`);
}

if (changed === 0) {
console.log(`✅ ${PROVIDERS_REL_PATH}: Kimi providers carry no dead flags — no change`);
return;
}

await writeFile(providersPath, `${JSON.stringify(config, null, 2)}\n`);
},
};
142 changes: 142 additions & 0 deletions scripts/migrations/269-kimi-drop-nonexistent-flags.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
/**
* Test for migration 269 — strip the non-existent `--print` / `--afk` flags from
* the stored Kimi Code providers (issue #4139). Picked up by
* server/vitest.config.js's `../scripts/**\/*.test.js` glob.
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { mkdtempSync, rmSync, writeFileSync, readFileSync, mkdirSync, existsSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';

import migration from './269-kimi-drop-nonexistent-flags.js';

const writeJson = (path, value) => writeFileSync(path, JSON.stringify(value, null, 2) + '\n');
const readJson = (path) => JSON.parse(readFileSync(path, 'utf-8'));

const kimiCli = (overrides = {}) => ({
id: 'kimi-cli',
name: 'Kimi Code CLI',
type: 'cli',
command: 'kimi',
args: ['--print'],
enabled: false,
...overrides,
});

const kimiTui = (overrides = {}) => ({
id: 'kimi-tui',
name: 'Kimi Code TUI',
type: 'tui',
command: 'kimi',
args: ['--yolo'],
enabled: false,
...overrides,
});

describe('migration 269 — drop non-existent kimi flags', () => {
let rootDir;
let providersPath;

beforeEach(() => {
rootDir = mkdtempSync(join(tmpdir(), 'migration-269-'));
mkdirSync(join(rootDir, 'data'), { recursive: true });
providersPath = join(rootDir, 'data/providers.json');
});

afterEach(() => {
rmSync(rootDir, { recursive: true, force: true });
});

it('empties the seeded `--print` args on kimi-cli', async () => {
writeJson(providersPath, { providers: { 'kimi-cli': kimiCli() } });

await migration.up({ rootDir });

const after = readJson(providersPath).providers['kimi-cli'];
expect(after.args).toEqual([]);
// unrelated fields preserved
expect(after.command).toBe('kimi');
expect(after.enabled).toBe(false);
});

it('strips the dead flags out of a curated list and keeps the rest in order', async () => {
writeJson(providersPath, {
providers: {
'kimi-cli': kimiCli({ args: ['--print', '--model', 'kimi-k2', '--afk'] }),
},
});

await migration.up({ rootDir });

expect(readJson(providersPath).providers['kimi-cli'].args).toEqual(['--model', 'kimi-k2']);
});

it('strips a user-pinned `--afk` from kimi-tui but leaves --yolo', async () => {
writeJson(providersPath, { providers: { 'kimi-tui': kimiTui({ args: ['--yolo', '--afk'] }) } });

await migration.up({ rootDir });

expect(readJson(providersPath).providers['kimi-tui'].args).toEqual(['--yolo']);
});

it('is a no-op when the Kimi providers are already clean', async () => {
writeJson(providersPath, {
providers: { 'kimi-cli': kimiCli({ args: [] }), 'kimi-tui': kimiTui() },
});
const before = readFileSync(providersPath, 'utf-8');

await migration.up({ rootDir });

expect(readFileSync(providersPath, 'utf-8')).toBe(before);
});

it('does not touch other providers that legitimately use --print', async () => {
writeJson(providersPath, {
providers: {
'kimi-cli': kimiCli(),
'claude-code': { id: 'claude-code', type: 'cli', command: 'claude', args: ['--print'] },
'antigravity': { id: 'antigravity', type: 'cli', command: 'agy', args: ['--print', '--dangerously-skip-permissions'] },
},
});

await migration.up({ rootDir });

const out = readJson(providersPath).providers;
expect(out['kimi-cli'].args).toEqual([]);
expect(out['claude-code'].args).toEqual(['--print']);
expect(out['antigravity'].args).toEqual(['--print', '--dangerously-skip-permissions']);
});

it('is a no-op when no kimi provider is present', async () => {
writeJson(providersPath, { providers: { 'codex': { id: 'codex', args: [] } } });
const before = readFileSync(providersPath, 'utf-8');

await migration.up({ rootDir });

expect(readFileSync(providersPath, 'utf-8')).toBe(before);
});

it('tolerates a kimi provider with a non-array args field', async () => {
writeJson(providersPath, { providers: { 'kimi-cli': kimiCli({ args: null }) } });
const before = readFileSync(providersPath, 'utf-8');

await migration.up({ rootDir });

expect(readFileSync(providersPath, 'utf-8')).toBe(before);
});

it('is a no-op when data/providers.json does not exist (fresh install)', async () => {
await migration.up({ rootDir });

expect(existsSync(providersPath)).toBe(false);
});

it('does not modify the file on invalid JSON (logs a warning and skips)', async () => {
writeFileSync(providersPath, '{ not valid json');
const before = readFileSync(providersPath, 'utf-8');

await migration.up({ rootDir });

expect(readFileSync(providersPath, 'utf-8')).toBe(before);
});
});
2 changes: 1 addition & 1 deletion server/lib/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,7 @@ The barrel `server/lib/index.js` is a machine-checkable enumeration of every pub
| `cursor.js` | Cursor Agent (`cursor-agent`) provider helpers — the `CURSOR_COMMAND` binary constant, the `isCursorCommand` predicate, and the `ensureCursorHeadlessArgs` (`--print --force`) / `ensureCursorTuiArgs` (`--force`) argv builders. `--force` is load-bearing beyond approvals: it also clears cursor's workspace-trust gate, which otherwise EXITS a headless run before any work happens. The prompt rides raw stdin (like claude/codex, unlike grok/kimi), and cursor needs no configured-default sentinel — its `auto` router is a real model id passed straight to `--model`. |
| `grok.js` | xAI Grok Build (`grok`) provider helpers — id/endpoint constants (`GROK_API_ID`/`GROK_CLI_ID`/`GROK_TUI_ID`/`GROK_API_ENDPOINT`), `isGrokCommand`/`isGrokCliProvider`/`isGrokTuiProvider` predicates, `ensureGrokHeadlessArgs`/`ensureGrokTuiArgs` argv builders (grok reads its prompt from `--prompt-file /dev/stdin`, not raw stdin; model selection uses the `GROK_CONFIGURED_DEFAULT` sentinel in `providerModels.js` so PortOS omits `--model` like Antigravity), and `prepareGrokPromptFile` (Windows temp-file delivery fallback). |
| `grokVideoClip.js` | Clip lengths grok's `image_to_video` actually delivers — `GROK_VIDEO_DURATIONS` (`[6, 10]`, measured in #3022: a 2s/3s request returns the same 6.04s clip), `GROK_VIDEO_DEFAULT_DURATION`, `resolveGrokDuration()` (validate an already-grok-shaped request, else default), and `nearestGrokDuration()` (round a length authored against another backend's continuous contract UP to the shortest clip that covers it — for translating a CD/commission `targetDurationSeconds`). Dependency-free so `validation.js` / `routes/videoGen.js` can derive their schemas from the same list the services gate on. |
| `kimi.js` | Moonshot AI Kimi Code (`kimi`) provider helpers — id constants (`KIMI_CLI_ID`/`KIMI_TUI_ID`), `isKimiCommand`/`isKimiCliProvider`/`isKimiTuiProvider` predicates, `ensureKimiHeadlessArgs` (`--print`, implies `--afk`) / `ensureKimiTuiArgs` (`--yolo`) argv builders (model selection uses the `KIMI_CONFIGURED_DEFAULT` sentinel in `providerModels.js` so PortOS omits `--model` like Grok/Antigravity), and `prepareKimiPrompt` (delivers the prompt as the `--prompt <value>` argv, not stdin). |
| `kimi.js` | Moonshot AI Kimi Code (`kimi`) provider helpers — id constants (`KIMI_CLI_ID`/`KIMI_TUI_ID`), `isKimiCommand`/`isKimiCliProvider`/`isKimiTuiProvider` predicates, `ensureKimiHeadlessArgs` (model flag only — non-interactive mode is implicit in `--prompt`, and kimi has no `--print`/`--afk` and refuses approval flags alongside `--prompt`) / `ensureKimiTuiArgs` (`--yolo`) argv builders (model selection uses the `KIMI_CONFIGURED_DEFAULT` sentinel in `providerModels.js` so PortOS omits `--model` like Grok/Antigravity), and `prepareKimiPrompt` (delivers the prompt as the `--prompt <value>` argv, not stdin). |
| `runners.js` | Image-runner family constants (`RUNNER_FAMILIES`, `loraCompatKey`), the separate `VIDEO_LORA_FAMILIES` + `videoLoraFamily` used by the video LoRA picker, and `isMiniMaxH3Runtime` / `MINIMAX_H3_RUNTIMES` — the predicate every gate asserting a MiniMax H3 model fact (24 fps, joint A/V, CFG-distilled, the 17n+5 grid) must use so the MLX and CUDA runtimes cannot drift apart. Mirrored to `client/src/lib/runnerFamilies.js`. |
| `codexAssistantExtract.js` | Strip Codex CLI banner + echoed metadata from session transcript. |
| `codexCliOutput.js` | Network/system error patterns for `agentErrorAnalysis.js`. |
Expand Down
2 changes: 1 addition & 1 deletion server/lib/aiToolkit/defaults/providers.sample.json
Original file line number Diff line number Diff line change
Expand Up @@ -338,7 +338,7 @@
"name": "Kimi Code CLI",
"type": "cli",
"command": "kimi",
"args": ["--print"],
"args": [],
"models": ["kimi-configured-default"],
"defaultModel": "kimi-configured-default",
"lightModel": "kimi-configured-default",
Expand Down
5 changes: 3 additions & 2 deletions server/lib/cliProviderArgs.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
* - Antigravity: `agy --print <prompt>` (argv value, not stdin; + `--model`)
* - Gemini CLI: legacy prompt piped to stdin (+ `-m <model>`)
* - Grok Build: `grok --prompt-file /dev/stdin` (+ `--model <id>`, see grok.js)
* - Kimi Code: `kimi --print --prompt <value>` (argv value, not stdin; see kimi.js)
* - Kimi Code: `kimi --prompt <value>` (argv value, not stdin; see kimi.js)
* - Cursor: `cursor-agent --print --force` (prompt on stdin; see cursor.js)
* - Claude Code: `-p -` (+ `--model <id>`)
*/
Expand Down Expand Up @@ -66,7 +66,8 @@ export function buildCliArgs(provider) {
* - Antigravity (`agy`): the prompt is spliced in as the VALUE of --print
* (agy does NOT read stdin) → `useStdin: false`.
* - Kimi (`kimi`): the prompt is spliced in as the VALUE of --prompt
* (kimi does NOT read stdin in --print mode) → `useStdin: false`.
* (kimi does NOT read stdin; supplying --prompt is also what selects
* non-interactive mode) → `useStdin: false`.
* - Grok on Windows: the `/dev/stdin` prompt-file is rewritten to a temp file
* → `useStdin: false` with a real `cleanup`.
* - Every other provider (Claude Code `-p -`, Codex `exec -`, OpenCode `run`,
Expand Down
23 changes: 12 additions & 11 deletions server/lib/cliProviderArgs.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -162,33 +162,34 @@ describe('cliProviderArgs', () => {
});

describe('buildCliArgs — Kimi Code CLI', () => {
it('builds a headless --print invocation without --model for the sentinel (seeded args)', () => {
const args = buildCliArgs({ id: 'kimi-cli', command: 'kimi', args: ['--print'], defaultModel: 'kimi-configured-default' });
expect(args).toEqual(['--print']);
it('builds an empty headless argv for the sentinel (seeded args) — no mode flag exists (#4139)', () => {
const args = buildCliArgs({ id: 'kimi-cli', command: 'kimi', args: [], defaultModel: 'kimi-configured-default' });
expect(args).toEqual([]);
expect(args).not.toContain('--model');
expect(args).not.toContain('kimi-configured-default');
});

it('adds --print when the saved args omit it', () => {
const args = buildCliArgs({ id: 'kimi-cli', command: 'kimi', args: [], defaultModel: 'kimi-configured-default' });
expect(args).toEqual(['--print']);
it('never injects --print or --afk — kimi rejects both outright (#4139)', () => {
const args = buildCliArgs({ id: 'kimi-cli', command: 'kimi', args: [], defaultModel: 'kimi-k2' });
expect(args).not.toContain('--print');
expect(args).not.toContain('--afk');
});

it('injects --model when a concrete model id is set (path/exe tolerant)', () => {
const args = buildCliArgs({ id: 'my-kimi', command: '/opt/homebrew/bin/kimi', args: ['--print'], defaultModel: 'kimi-k2' });
expect(args).toEqual(['--print', '--model', 'kimi-k2']);
const args = buildCliArgs({ id: 'my-kimi', command: '/opt/homebrew/bin/kimi', args: [], defaultModel: 'kimi-k2' });
expect(args).toEqual(['--model', 'kimi-k2']);
});

it('respects a user-baked --model and does not duplicate it', () => {
const args = buildCliArgs({ id: 'kimi-cli', command: 'kimi', args: ['--print', '--model', 'mine'], defaultModel: 'kimi-configured-default' });
const args = buildCliArgs({ id: 'kimi-cli', command: 'kimi', args: ['--model', 'mine'], defaultModel: 'kimi-configured-default' });
expect(args.filter((a) => a === '--model')).toHaveLength(1);
expect(args).toContain('mine');
});

it('delivers the prompt as the --prompt argv value (useStdin false)', () => {
const built = buildCliArgs({ id: 'kimi-cli', command: 'kimi', args: ['--print'], defaultModel: 'kimi-configured-default' });
const built = buildCliArgs({ id: 'kimi-cli', command: 'kimi', args: [], defaultModel: 'kimi-configured-default' });
const { args, useStdin } = prepareCliPrompt('kimi', built, 'write a haiku');
expect(args).toEqual(['--print', '--prompt', 'write a haiku']);
expect(args).toEqual(['--prompt', 'write a haiku']);
expect(useStdin).toBe(false);
});
});
Expand Down
Loading