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
1 change: 1 addition & 0 deletions apps/kimi-code/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@
},
"devDependencies": {
"@moonshot-ai/acp-adapter": "workspace:^",
"@moonshot-ai/acp-server": "workspace:^",
"@moonshot-ai/agent-core-v2": "workspace:^",
"@moonshot-ai/kap-server": "workspace:^",
"@moonshot-ai/kimi-code-oauth": "workspace:^",
Expand Down
5 changes: 5 additions & 0 deletions apps/kimi-code/src/cli/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@ import { CLI_COMMAND_NAME } from '#/constant/app';
import { registerMigrateCommand } from '#/migration/index';
import { Command, Option } from 'commander';

import { isAcpV2Enabled } from './experimental-v2';
import type { CLIOptions } from './options';
import { registerAcpCommand } from './sub/acp';
import { registerAcpV2Command } from './sub/acp-v2';
import { registerDoctorCommand } from './sub/doctor';
import { registerExportCommand } from './sub/export';
import { registerLoginCommand } from './sub/login';
Expand Down Expand Up @@ -89,6 +91,9 @@ export function createProgram(
registerExportCommand(program);
registerProviderCommand(program);
registerAcpCommand(program);
if (isAcpV2Enabled()) {
registerAcpV2Command(program);
}
registerServerCommand(program);
registerLoginCommand(program);
registerDoctorCommand(program);
Expand Down
10 changes: 10 additions & 0 deletions apps/kimi-code/src/cli/experimental-v2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,13 @@
*
* `kimi -p` (print mode) routes to the native agent-core-v2 runner through the
* same master switch.
*
* `kimi acp-v2` is gated behind its own per-feature env var
* `KIMI_CODE_EXPERIMENTAL_ACP_V2`, or the master switch.
*/

export const KIMI_V2_ENV = 'KIMI_CODE_EXPERIMENTAL_FLAG';
export const KIMI_ACP_V2_ENV = 'KIMI_CODE_EXPERIMENTAL_ACP_V2';

const TRUTHY_VALUES = new Set(['1', 'true', 'yes', 'on']);

Expand All @@ -26,3 +30,9 @@ export function isKimiV2Enabled(
): boolean {
return isTruthyEnv(KIMI_V2_ENV, env);
}

export function isAcpV2Enabled(
env: Readonly<Record<string, string | undefined>> = process.env,
): boolean {
return isTruthyEnv(KIMI_ACP_V2_ENV, env) || isKimiV2Enabled(env);
}
77 changes: 77 additions & 0 deletions apps/kimi-code/src/cli/sub/acp-v2.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
/**
* `kimi acp-v2` sub-command.
*
* Starts the Agent Client Protocol (ACP) server backed directly by the
* DI × Scope agent engine (`agent-core-v2`) over stdio, so ACP-compatible
* clients can drive a kimi-code session on the new engine. This is the v2
* counterpart to `kimi acp` (which runs the legacy `@moonshot-ai/acp-adapter`
* over the SDK harness).
*
* Wire-up mirrors `kimi acp` for the parts that are host-independent:
* - `--login` pivots into the shared device-code login flow (the entry point
* ACP clients hit via the first-class `AuthMethodTerminal` path, re-invoking
* the agent binary with the advertised `args:['--login']`).
* - `KIMI_CODE_HOME` (if set) is forwarded into `authMethods[0].env` so the
* login subprocess writes its token under the same data root the server
* reads from, and `process.argv[1]` is advertised as the legacy
* `_meta['terminal-auth'].command` fallback.
*
* `@moonshot-ai/acp-server` (and its `agent-core-v2` engine) is loaded via a
* lazy dynamic import so the default CLI / `kimi acp` module graph stays free
* of the experimental v2 engine — mirroring the `kimi server run` v2 routing
* in `#/cli/sub/server/run.ts`.
*/

import type { Command } from 'commander';

import { getVersion } from '#/cli/version';
import { KIMI_CODE_HOME_ENV } from '#/constant/app';
import { getDataDir } from '#/utils/paths';

import { runLoginFlow } from './login-flow';

export function registerAcpV2Command(parent: Command): void {
parent
.command('acp-v2')
.description(
'Run kimi-code as an Agent Client Protocol (ACP) server over stdio (experimental agent-core-v2 engine).',
)
.option(
'--login',
'Run the device-code login flow then exit (entry point for ACP terminal-auth).',
false,
)
.action(async (opts: { login?: boolean }) => {
if (opts.login === true) {
await runLoginFlow();
return;
}
// Forward `KIMI_CODE_HOME` (if set) into `authMethods[0].env` so the
// login subprocess clients spawn for terminal-auth writes its token
// under the same data root the ACP server reads from.
const sandboxHome = process.env[KIMI_CODE_HOME_ENV];
const terminalAuthEnv =
sandboxHome !== undefined && sandboxHome.length > 0
? { [KIMI_CODE_HOME_ENV]: sandboxHome }
: undefined;
// Legacy `_meta.terminal-auth` fallback for clients that don't yet
// honor the first-class `type:'terminal'`. `command` is the absolute
// path to this very binary so the client can spawn it for login.
const legacyCommand = process.argv[1];
try {
const { runAcpServer } = await import('@moonshot-ai/acp-server');
await runAcpServer({
homeDir: getDataDir(),
agentInfo: { name: 'Kimi Code CLI', version: getVersion() },
...(terminalAuthEnv ? { terminalAuthEnv } : {}),
...(legacyCommand !== undefined && legacyCommand.length > 0
? { terminalAuthLegacyCommand: legacyCommand }
: {}),
});
process.exit(0);
} catch (error) {
process.stderr.write(`acp-v2 server: fatal error: ${String(error)}\n`);
process.exit(1);
}
});
}
165 changes: 165 additions & 0 deletions apps/kimi-code/test/cli/acp-v2.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
/**
* `kimi acp-v2`
*
* Verifies that the ACP v2 sub-command is registered on the program and that
* the action wires `@moonshot-ai/acp-server`'s `runAcpServer` (the real server
* is stubbed so the test doesn't actually take over stdio). The module is
* loaded via a lazy dynamic import in the action, so the mock intercepts that
* import.
*/

import { Command } from 'commander';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

vi.mock('@moonshot-ai/acp-server', () => ({
runAcpServer: vi.fn(async () => undefined),
}));

import { runAcpServer } from '@moonshot-ai/acp-server';

import { registerAcpV2Command } from '#/cli/sub/acp-v2';
import { getDataDir } from '#/utils/paths';

class ExitCalled extends Error {
constructor(public code: number | string | null | undefined) {
super(`process.exit(${String(code)})`);
}
}

describe('kimi acp-v2', () => {
let exitSpy: ReturnType<typeof vi.spyOn>;
let stderrSpy: ReturnType<typeof vi.spyOn>;

beforeEach(() => {
vi.mocked(runAcpServer).mockClear();
exitSpy = vi.spyOn(process, 'exit').mockImplementation(((code?: number | string | null) => {
throw new ExitCalled(code);
}) as never);
stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
});

afterEach(() => {
exitSpy.mockRestore();
stderrSpy.mockRestore();
});

it('registers an `acp-v2` subcommand on the program', () => {
const program = new Command('kimi');
registerAcpV2Command(program);

const acpV2 = program.commands.find((c) => c.name() === 'acp-v2');
expect(acpV2).toBeDefined();
expect(acpV2?.description()).toMatch(/Agent Client Protocol/);
});

it('invokes runAcpServer with the v2 host options and exits 0 on success', async () => {
const program = new Command('kimi').exitOverride();
registerAcpV2Command(program);

await expect(program.parseAsync(['node', 'kimi', 'acp-v2'])).rejects.toThrow(ExitCalled);

expect(runAcpServer).toHaveBeenCalledTimes(1);
const optsArg = vi.mocked(runAcpServer).mock.calls[0]?.[0];
expect(optsArg).toEqual(
expect.objectContaining({
homeDir: getDataDir(),
agentInfo: { name: 'Kimi Code CLI', version: expect.any(String) },
}),
);
expect(exitSpy).toHaveBeenCalledWith(0);
});

it('forwards KIMI_CODE_HOME to terminalAuthEnv and homeDir when set', async () => {
const previous = process.env['KIMI_CODE_HOME'];
process.env['KIMI_CODE_HOME'] = '/tmp/kimi-debug';
try {
const program = new Command('kimi').exitOverride();
registerAcpV2Command(program);

await expect(program.parseAsync(['node', 'kimi', 'acp-v2'])).rejects.toThrow(ExitCalled);

const optsArg = vi.mocked(runAcpServer).mock.calls[0]?.[0];
expect(optsArg).toEqual(
expect.objectContaining({
homeDir: '/tmp/kimi-debug',
terminalAuthEnv: { KIMI_CODE_HOME: '/tmp/kimi-debug' },
}),
);
} finally {
if (previous === undefined) {
delete process.env['KIMI_CODE_HOME'];
} else {
process.env['KIMI_CODE_HOME'] = previous;
}
}
});

it('omits terminalAuthEnv when KIMI_CODE_HOME is unset', async () => {
const previous = process.env['KIMI_CODE_HOME'];
delete process.env['KIMI_CODE_HOME'];
try {
const program = new Command('kimi').exitOverride();
registerAcpV2Command(program);

await expect(program.parseAsync(['node', 'kimi', 'acp-v2'])).rejects.toThrow(ExitCalled);

const optsArg = vi.mocked(runAcpServer).mock.calls[0]?.[0] as {
terminalAuthEnv?: unknown;
};
expect(optsArg.terminalAuthEnv).toBeUndefined();
} finally {
if (previous === undefined) {
delete process.env['KIMI_CODE_HOME'];
} else {
process.env['KIMI_CODE_HOME'] = previous;
}
}
});

it('forwards process.argv[1] as terminalAuthLegacyCommand', async () => {
const program = new Command('kimi').exitOverride();
registerAcpV2Command(program);

await expect(program.parseAsync(['node', 'kimi', 'acp-v2'])).rejects.toThrow(ExitCalled);

const optsArg = vi.mocked(runAcpServer).mock.calls[0]?.[0] as {
terminalAuthLegacyCommand?: string;
};
expect(typeof optsArg.terminalAuthLegacyCommand).toBe('string');
expect((optsArg.terminalAuthLegacyCommand ?? '').length).toBeGreaterThan(0);
expect(optsArg.terminalAuthLegacyCommand).toBe(process.argv[1]);
});

it('exits without starting the ACP server when --login is passed', async () => {
// Stub the SDK harness so runLoginFlow doesn't hit a real OAuth endpoint:
// harness.auth.login resolves immediately and triggers exit 0.
const loginStub = vi.fn(async () => ({ providerName: 'kimi-code' }));
vi.doMock(import('@moonshot-ai/kimi-code-sdk'), async (importOriginal) => {
const actual = await importOriginal();
return {
...actual,
createKimiHarness: () =>
({
auth: { login: loginStub },
}) as unknown as ReturnType<typeof actual.createKimiHarness>,
};
});
vi.resetModules();
const { registerAcpV2Command: freshRegister } = await import('#/cli/sub/acp-v2');
try {
const program = new Command('kimi').exitOverride();
freshRegister(program);

await expect(program.parseAsync(['node', 'kimi', 'acp-v2', '--login'])).rejects.toThrow(
ExitCalled,
);

expect(loginStub).toHaveBeenCalledTimes(1);
expect(runAcpServer).not.toHaveBeenCalled();
expect(exitSpy).toHaveBeenCalledWith(0);
} finally {
vi.doUnmock('@moonshot-ai/kimi-code-sdk');
vi.resetModules();
}
});
});
22 changes: 22 additions & 0 deletions apps/kimi-code/test/cli/options.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -471,6 +471,28 @@ describe('CLI options parsing', () => {
'upgrade',
]);
});

it('registers acp-v2 when the experimental flag is enabled', () => {
const original = process.env['KIMI_CODE_EXPERIMENTAL_ACP_V2'];
process.env['KIMI_CODE_EXPERIMENTAL_ACP_V2'] = '1';
try {
const program = createProgram(
'0.0.0',
() => {},
() => {},
);
const commandNames: string[] = program.commands
.filter((command) => !command.name().startsWith('__'))
.map((command) => command.name());
expect(commandNames).toContain('acp-v2');
} finally {
if (original === undefined) {
delete process.env['KIMI_CODE_EXPERIMENTAL_ACP_V2'];
} else {
process.env['KIMI_CODE_EXPERIMENTAL_ACP_V2'] = original;
}
}
});
});

describe('rejected flags', () => {
Expand Down
2 changes: 2 additions & 0 deletions flake.nix
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@
# -------------------------------------------------------------------
workspacePaths = [
./packages/acp-adapter
./packages/acp-server
./packages/agent-core
./packages/agent-core-v2
./packages/server
Expand All @@ -89,6 +90,7 @@

workspaceNames = [
"@moonshot-ai/acp-adapter"
"@moonshot-ai/acp-server"
"@moonshot-ai/agent-core"
"@moonshot-ai/agent-core-v2"
"@moonshot-ai/server"
Expand Down
26 changes: 26 additions & 0 deletions packages/acp-server/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
{
"name": "@moonshot-ai/acp-server",
"version": "0.0.0",
"private": true,
"description": "Agent Client Protocol (ACP) host backed directly by the DI × Scope agent engine (agent-core-v2)",
"license": "MIT",
"author": "Moonshot AI",
"type": "module",
"exports": {
".": {
"types": "./src/index.ts",
"default": "./src/index.ts"
}
},
"scripts": {
"build": "tsdown",
"typecheck": "tsc -p tsconfig.json --noEmit",
"test": "vitest run",
"clean": "rm -rf dist"
},
"dependencies": {
"@agentclientprotocol/sdk": "^0.23.0",
"@moonshot-ai/agent-core-v2": "workspace:^",
"@moonshot-ai/protocol": "workspace:^"
}
}
Loading
Loading