-
Notifications
You must be signed in to change notification settings - Fork 907
feat(acp): implement Agent Communication Protocol adapter for IDE integration #368
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,6 @@ | ||
| --- | ||
| "@moonshot-ai/acp-adapter": minor | ||
| "@moonshot-ai/kimi-code": minor | ||
| --- | ||
|
|
||
| Add `@moonshot-ai/acp-adapter` and the `kimi acp` subcommand: kimi-code now speaks [Agent Client Protocol 0.23](https://agentclientprotocol.com/) over stdio so IDEs (Zed, JetBrains AI Chat, custom clients) can drive sessions directly — coverage matrix, Zed configuration and breaking pre-release notes are in [`docs/en/reference/kimi-acp.md`](https://www.kimi.com/code/docs/en/kimi-code-cli/reference/kimi-acp.html). |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,83 @@ | ||
| /** | ||
| * `kimi acp` sub-command. | ||
| * | ||
| * Starts the Agent Client Protocol (ACP) server over stdio so that | ||
| * ACP-compatible clients (editors, IDEs, custom front-ends) can drive | ||
| * a kimi-code session. | ||
| * | ||
| * Wire-up: | ||
| * - A {@link KimiHarness} is constructed with the kimi-code host identity | ||
| * and a dedicated `uiMode: 'acp'` so downstream telemetry can | ||
| * distinguish ACP sessions from the TUI. | ||
| * - {@link runAcpServer} owns the JSON-RPC stdio bridge and redirects | ||
| * rogue `console.*` traffic to stderr. | ||
| * - `--login` pivots into the device-code login flow instead of | ||
| * starting the server. This is the entry point ACP clients hit | ||
| * via the first-class `AuthMethodTerminal` path when they re-invoke | ||
| * the agent binary with the advertised `args:['--login']` appended. | ||
| * - On stream close or unhandled error the process exits with the | ||
| * appropriate code. | ||
| */ | ||
|
|
||
| import type { Command } from 'commander'; | ||
|
|
||
| import { runAcpServer } from '@moonshot-ai/acp-adapter'; | ||
| import { createKimiHarness } from '@moonshot-ai/kimi-code-sdk'; | ||
|
|
||
| import { KIMI_CODE_HOME_ENV } from '#/constant/app'; | ||
| import { createKimiCodeHostIdentity, getVersion } from '#/cli/version'; | ||
|
|
||
| import { runLoginFlow } from './login-flow'; | ||
|
|
||
| export function registerAcpCommand(parent: Command): void { | ||
| parent | ||
| .command('acp') | ||
| .description('Run kimi-code as an Agent Client Protocol (ACP) server over stdio.') | ||
| .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; | ||
| } | ||
| const identity = createKimiCodeHostIdentity(); | ||
| const harness = createKimiHarness({ | ||
| identity, | ||
| uiMode: 'acp', | ||
| }); | ||
| // Forward `KIMI_CODE_HOME` (if set) into `authMethods[0].env` so the | ||
| // `kimi login` subprocess clients spawn for terminal-auth writes its | ||
| // token under the same data root the ACP server reads from. Used for | ||
| // sandboxed test setups (Zed's `agent_servers.*.env.KIMI_CODE_HOME = | ||
| // /tmp/...`). Production runs leave the env unset and the field stays | ||
| // empty. | ||
| 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'` (Zed without the | ||
| // AcpBetaFeatureFlag, current JetBrains plugin, etc.). `command` is | ||
| // the absolute path to this very binary (`process.argv[1]`) so the | ||
| // client can spawn it with `args:['login']` for the top-level | ||
| // `kimi login` subcommand — matches kimi-cli `acp/server.py:77-96`. | ||
| const legacyCommand = process.argv[1]; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
For Windows npm installs, Useful? React with 👍 / 👎. |
||
| try { | ||
| await runAcpServer(harness, { | ||
| agentInfo: { name: 'Kimi Code CLI', version: getVersion() }, | ||
| ...(terminalAuthEnv ? { terminalAuthEnv } : {}), | ||
| ...(legacyCommand !== undefined && legacyCommand.length > 0 | ||
| ? { terminalAuthLegacyCommand: legacyCommand } | ||
| : {}), | ||
| }); | ||
| process.exit(0); | ||
| } catch (err) { | ||
| process.stderr.write(`acp server: fatal error: ${String(err)}\n`); | ||
| process.exit(1); | ||
| } | ||
| }); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,59 @@ | ||
| /** | ||
| * Shared device-code login flow used by both `kimi login` (top-level | ||
| * subcommand) and `kimi acp --login` (the first-class ACP terminal-auth | ||
| * entry point). Exiting the process is part of the contract — callers | ||
| * MUST treat the returned promise as `Promise<never>`. | ||
| */ | ||
|
|
||
| import { createKimiHarness } from '@moonshot-ai/kimi-code-sdk'; | ||
|
|
||
| import { createKimiCodeHostIdentity } from '#/cli/version'; | ||
| import { openUrl } from '#/utils/open-url'; | ||
|
|
||
| export async function runLoginFlow(): Promise<never> { | ||
| const identity = createKimiCodeHostIdentity(); | ||
| const harness = createKimiHarness({ | ||
| identity, | ||
| uiMode: 'cli', | ||
| }); | ||
| const controller = new AbortController(); | ||
| process.once('SIGINT', () => controller.abort()); | ||
| try { | ||
| const result = await harness.auth.login(undefined, { | ||
| signal: controller.signal, | ||
| onDeviceCode: (data) => { | ||
| const url = data.verificationUriComplete || data.verificationUri; | ||
| // Best-effort: try to open the user's default browser at the | ||
| // pre-baked URL (which already embeds the user code). Print the | ||
| // URL + code as a fallback for headless boxes / when openUrl | ||
| // silently fails (it `execFile`s `open`/`xdg-open`/`cmd start` | ||
| // with no error handling — see `utils/open-url.ts`). | ||
| openUrl(url); | ||
| process.stderr.write( | ||
| [ | ||
| '', | ||
| `Opening browser for Kimi device login: ${url}`, | ||
| `If the browser did not open, paste the URL above and enter code: ${data.userCode}`, | ||
| data.expiresIn !== null && data.expiresIn !== undefined | ||
| ? `Code expires in ${data.expiresIn}s.` | ||
| : undefined, | ||
| 'Waiting for authorization to complete...', | ||
| '', | ||
| ] | ||
| .filter((line): line is string => line !== undefined) | ||
| .join('\n'), | ||
| ); | ||
| }, | ||
| }); | ||
| process.stderr.write(`Logged in to ${result.providerName}.\n`); | ||
| process.exit(0); | ||
| } catch (err) { | ||
| if (controller.signal.aborted) { | ||
| process.stderr.write('Login cancelled.\n'); | ||
| } else { | ||
| const message = err instanceof Error ? err.message : String(err); | ||
| process.stderr.write(`Login failed: ${message}\n`); | ||
| } | ||
| process.exit(1); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,20 @@ | ||
| /** | ||
| * `kimi login` — drive the OAuth device-code flow non-interactively. | ||
| * The `authMethods.terminal-auth.args=['login']` (legacy `_meta` path) | ||
| * advertised by the ACP server points clients at this entry point. The | ||
| * first-class ACP `args=['--login']` path enters the same flow via | ||
| * `kimi acp --login`. | ||
| */ | ||
|
|
||
| import type { Command } from 'commander'; | ||
|
|
||
| import { runLoginFlow } from './login-flow'; | ||
|
|
||
| export function registerLoginCommand(parent: Command): void { | ||
| parent | ||
| .command('login') | ||
| .description('Authenticate with Kimi Code CLI via the device-code flow.') | ||
| .action(async () => { | ||
| await runLoginFlow(); | ||
| }); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,165 @@ | ||
| /** | ||
| * `kimi acp` | ||
| * | ||
| * Verifies that the ACP sub-command is registered on the program and | ||
| * that the action wires the harness into `@moonshot-ai/acp-adapter`'s | ||
| * `runAcpServer` (the real server is stubbed so the test doesn't | ||
| * actually take over stdio). | ||
| */ | ||
|
|
||
| import { Command } from 'commander'; | ||
| import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; | ||
|
|
||
| vi.mock('@moonshot-ai/acp-adapter', () => ({ | ||
| runAcpServer: vi.fn(async () => undefined), | ||
| })); | ||
|
|
||
| import { runAcpServer } from '@moonshot-ai/acp-adapter'; | ||
|
|
||
| import { registerAcpCommand } from '#/cli/sub/acp'; | ||
|
|
||
| class ExitCalled extends Error { | ||
| constructor(public code: number | string | null | undefined) { | ||
| super(`process.exit(${String(code)})`); | ||
| } | ||
| } | ||
|
|
||
| describe('kimi acp', () => { | ||
| 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` subcommand on the program', () => { | ||
| const program = new Command('kimi'); | ||
| registerAcpCommand(program); | ||
|
|
||
| const acp = program.commands.find((c) => c.name() === 'acp'); | ||
| expect(acp).toBeDefined(); | ||
| expect(acp?.description()).toMatch(/Agent Client Protocol/); | ||
| }); | ||
|
|
||
| it('invokes runAcpServer with a constructed harness and exits 0 on success', async () => { | ||
| const program = new Command('kimi').exitOverride(); | ||
| registerAcpCommand(program); | ||
|
|
||
| await expect(program.parseAsync(['node', 'kimi', 'acp'])).rejects.toThrow(ExitCalled); | ||
|
|
||
| expect(runAcpServer).toHaveBeenCalledTimes(1); | ||
| const harnessArg = vi.mocked(runAcpServer).mock.calls[0]?.[0]; | ||
| expect(harnessArg).toBeDefined(); | ||
| const optsArg = vi.mocked(runAcpServer).mock.calls[0]?.[1]; | ||
| expect(optsArg).toEqual( | ||
| expect.objectContaining({ | ||
| agentInfo: { name: 'Kimi Code CLI', version: expect.any(String) }, | ||
| }), | ||
| ); | ||
| expect(exitSpy).toHaveBeenCalledWith(0); | ||
| }); | ||
|
|
||
| it('forwards KIMI_CODE_HOME to terminalAuthEnv 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(); | ||
| registerAcpCommand(program); | ||
|
|
||
| await expect(program.parseAsync(['node', 'kimi', 'acp'])).rejects.toThrow(ExitCalled); | ||
|
|
||
| const optsArg = vi.mocked(runAcpServer).mock.calls[0]?.[1]; | ||
| expect(optsArg).toEqual( | ||
| expect.objectContaining({ | ||
| 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(); | ||
| registerAcpCommand(program); | ||
|
|
||
| await expect(program.parseAsync(['node', 'kimi', 'acp'])).rejects.toThrow(ExitCalled); | ||
|
|
||
| const optsArg = vi.mocked(runAcpServer).mock.calls[0]?.[1] as { | ||
| terminalAuthEnv?: unknown; | ||
| }; | ||
| expect(optsArg.terminalAuthEnv).toBeUndefined(); | ||
| } finally { | ||
| if (previous !== undefined) { | ||
| process.env['KIMI_CODE_HOME'] = previous; | ||
| } | ||
| } | ||
| }); | ||
|
|
||
| it('forwards process.argv[1] as terminalAuthLegacyCommand', async () => { | ||
| const program = new Command('kimi').exitOverride(); | ||
| registerAcpCommand(program); | ||
|
|
||
| await expect(program.parseAsync(['node', 'kimi', 'acp'])).rejects.toThrow(ExitCalled); | ||
|
|
||
| const optsArg = vi.mocked(runAcpServer).mock.calls[0]?.[1] as { | ||
| terminalAuthLegacyCommand?: string; | ||
| }; | ||
| // process.argv[1] points at the test runner entry — non-empty | ||
| // absolute-ish path, exactly what we want forwarded. | ||
| 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 harness module so runLoginFlow doesn't hit a real OAuth | ||
| // endpoint: harness.auth.login resolves immediately and triggers exit 0. | ||
| // `importOriginal` preserves the other named exports (`ErrorCodes`, etc.) | ||
| // that constant/app.ts depends on at module load. | ||
| 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 { registerAcpCommand: freshRegister } = await import('#/cli/sub/acp'); | ||
| try { | ||
| const program = new Command('kimi').exitOverride(); | ||
| freshRegister(program); | ||
|
|
||
| await expect(program.parseAsync(['node', 'kimi', 'acp', '--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(); | ||
| } | ||
| }); | ||
| }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Adding the ACP adapter as a devDependency is enough for the local build, but the non-native CLI bundle only force-bundles
@moonshot-ai/*packages (apps/kimi-code/tsdown.config.ts), so the adapter's runtime import of@agentclientprotocol/sdkcan remain external. Since@agentclientprotocol/sdkis not listed in this publishable package'sdependencies, users installing the npm CLI can hitERR_MODULE_NOT_FOUNDas soon askimi acploads; either bundle that package too or declare it as a runtime dependency.Useful? React with 👍 / 👎.