Skip to content

Commit d5ebac9

Browse files
committed
feat: deepen Kimi support as a first-class CLI cat
1 parent 3273293 commit d5ebac9

78 files changed

Lines changed: 3351 additions & 207 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

cat-config.json

Lines changed: 279 additions & 45 deletions
Large diffs are not rendered by default.

cat-template.json

Lines changed: 279 additions & 45 deletions
Large diffs are not rendered by default.

packages/api/src/config/account-resolver.ts

Lines changed: 20 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import { readCredential } from './credentials.js';
1010

1111
// ── Types surviving from provider-profiles.types.ts (F136 Phase 4d) ──
1212

13-
export type BuiltinAccountClient = 'anthropic' | 'openai' | 'google' | 'dare' | 'opencode';
13+
export type BuiltinAccountClient = 'anthropic' | 'openai' | 'google' | 'kimi' | 'dare' | 'opencode';
1414
export type ProviderProfileKind = 'builtin' | 'api_key';
1515

1616
export interface RuntimeProviderProfile {
@@ -37,6 +37,7 @@ export function resolveBuiltinClientForProvider(provider: CatProvider): BuiltinA
3737
case 'anthropic':
3838
case 'openai':
3939
case 'google':
40+
case 'kimi':
4041
case 'dare':
4142
case 'opencode':
4243
return provider;
@@ -51,6 +52,7 @@ const LEGACY_BUILTIN_IDS: Record<BuiltinAccountClient, string> = {
5152
anthropic: 'claude',
5253
openai: 'codex',
5354
google: 'gemini',
55+
kimi: 'kimi',
5456
dare: 'dare',
5557
opencode: 'opencode',
5658
};
@@ -77,6 +79,7 @@ const PROTOCOL_ENV_KEY_MAP: Record<AccountProtocol, string> = {
7779
openai: 'OPENAI_API_KEY',
7880
'openai-responses': 'OPENAI_API_KEY',
7981
google: 'GOOGLE_API_KEY',
82+
kimi: 'MOONSHOT_API_KEY',
8083
};
8184

8285
function protocolToClient(protocol: AccountProtocol): BuiltinAccountClient {
@@ -96,6 +99,8 @@ const BUILTIN_ACCOUNT_MAP: Record<string, { client: BuiltinAccountClient; protoc
9699
builtin_openai: { client: 'openai', protocol: 'openai' },
97100
gemini: { client: 'google', protocol: 'google' },
98101
builtin_google: { client: 'google', protocol: 'google' },
102+
kimi: { client: 'kimi', protocol: 'kimi' },
103+
builtin_kimi: { client: 'kimi', protocol: 'kimi' },
99104
dare: { client: 'dare', protocol: 'openai' },
100105
builtin_dare: { client: 'dare', protocol: 'openai' },
101106
opencode: { client: 'opencode', protocol: 'anthropic' },
@@ -142,6 +147,16 @@ export function resolveForClient(
142147
if (preferredAccountRef) {
143148
const preferred = accounts[preferredAccountRef];
144149
if (preferred) return accountToRuntimeProfile(preferredAccountRef, preferred);
150+
const builtin = BUILTIN_ACCOUNT_MAP[preferredAccountRef];
151+
if (builtin) {
152+
return {
153+
id: preferredAccountRef,
154+
authType: 'oauth',
155+
kind: 'builtin',
156+
client: builtin.client,
157+
protocol: builtin.protocol,
158+
};
159+
}
145160
}
146161

147162
// Find accounts matching the protocol — return only if unambiguous (exactly one match)
@@ -156,21 +171,6 @@ export function resolveForClient(
156171
return accountToRuntimeProfile(matches[0][0], matches[0][1]);
157172
}
158173

159-
// Synthetic builtin fallback: only when no real accounts match the protocol
160-
// (e.g. fresh install before migration, or test env with no catalog)
161-
if (preferredAccountRef && matches.length === 0) {
162-
const builtin = BUILTIN_ACCOUNT_MAP[preferredAccountRef];
163-
if (builtin) {
164-
return {
165-
id: preferredAccountRef,
166-
authType: 'oauth',
167-
kind: 'builtin',
168-
client: builtin.client,
169-
protocol: builtin.protocol,
170-
};
171-
}
172-
}
173-
174174
// 0 matches = no account configured; >1 = ambiguous → fall through to legacy
175175
return null;
176176
}
@@ -180,7 +180,8 @@ function normalizeProtocol(clientOrProtocol: string): AccountProtocol {
180180
clientOrProtocol === 'anthropic' ||
181181
clientOrProtocol === 'openai' ||
182182
clientOrProtocol === 'openai-responses' ||
183-
clientOrProtocol === 'google'
183+
clientOrProtocol === 'google' ||
184+
clientOrProtocol === 'kimi'
184185
) {
185186
return clientOrProtocol;
186187
}
@@ -221,6 +222,8 @@ function expectedProtocolForProvider(provider: CatProvider): AccountProtocol | n
221222
return 'openai';
222223
case 'google':
223224
return 'google';
225+
case 'kimi':
226+
return 'kimi';
224227
case 'dare':
225228
return 'openai';
226229
case 'opencode':

packages/api/src/config/capabilities/capability-orchestrator.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,11 @@ import {
1818
readClaudeMcpConfig,
1919
readCodexMcpConfig,
2020
readGeminiMcpConfig,
21+
readKimiMcpConfig,
2122
writeClaudeMcpConfig,
2223
writeCodexMcpConfig,
2324
writeGeminiMcpConfig,
25+
writeKimiMcpConfig,
2426
} from './mcp-config-adapters.js';
2527

2628
// ────────── Constants ──────────
@@ -103,6 +105,7 @@ const PROVIDER_WRITERS = {
103105
anthropic: writeClaudeMcpConfig,
104106
openai: writeCodexMcpConfig,
105107
google: writeGeminiMcpConfig,
108+
kimi: writeKimiMcpConfig,
106109
} as const;
107110

108111
/** Check if a descriptor has a usable transport (stdio command, local resolver, or streamableHttp URL). */
@@ -331,20 +334,22 @@ export interface DiscoveryPaths {
331334
claudeConfig: string; // e.g. <projectRoot>/.mcp.json
332335
codexConfig: string; // e.g. <projectRoot>/.codex/config.toml
333336
geminiConfig: string; // e.g. <projectRoot>/.gemini/settings.json
337+
kimiConfig: string; // e.g. <projectRoot>/.kimi/mcp.json
334338
}
335339

336340
/**
337341
* Discover external MCP servers from all 3 CLI configs.
338342
* Merges by name; if same name appears in multiple, first wins.
339343
*/
340344
export async function discoverExternalMcpServers(paths: DiscoveryPaths): Promise<McpServerDescriptor[]> {
341-
const [claude, codex, gemini] = await Promise.all([
345+
const [claude, codex, gemini, kimi] = await Promise.all([
342346
readClaudeMcpConfig(paths.claudeConfig),
343347
readCodexMcpConfig(paths.codexConfig),
344348
readGeminiMcpConfig(paths.geminiConfig),
349+
readKimiMcpConfig(paths.kimiConfig),
345350
]);
346351
return deduplicateDiscoveredMcpServers(
347-
[...claude, ...codex, ...gemini]
352+
[...claude, ...codex, ...gemini, ...kimi]
348353
.filter((server) => hasUsableTransport(server))
349354
.map((server) => ({ ...server, source: 'external' as const })),
350355
);
@@ -548,10 +553,11 @@ export interface CliConfigPaths {
548553
anthropic: string; // e.g. <projectRoot>/.mcp.json
549554
openai: string; // e.g. <projectRoot>/.codex/config.toml
550555
google: string; // e.g. <projectRoot>/.gemini/settings.json
556+
kimi: string; // e.g. <projectRoot>/.kimi/mcp.json
551557
}
552558

553559
/** Providers that support streamableHttp transport (URL-based MCP). */
554-
const STREAMABLE_HTTP_PROVIDERS = new Set(['anthropic']);
560+
const STREAMABLE_HTTP_PROVIDERS = new Set(['anthropic', 'kimi']);
555561

556562
/**
557563
* Resolve effective MCP servers for a specific cat.

packages/api/src/config/capabilities/mcp-config-adapters.ts

Lines changed: 90 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
* Claude: .mcp.json — { mcpServers: { name: { command, args, env } } }
77
* Codex: .codex/config.toml — [mcp_servers.<name>] command/args/env/enabled
88
* Gemini: .gemini/settings.json — { mcpServers: { name: { command, args, env, cwd } } }
9+
* Kimi: .kimi/mcp.json — { mcpServers: { name: { url|command, args, env, headers } } }
910
*/
1011

1112
import { mkdir, readFile, writeFile } from 'node:fs/promises';
@@ -20,6 +21,13 @@ const GEMINI_CAT_CAFE_ENV_PLACEHOLDERS: Readonly<Record<string, string>> = {
2021
CAT_CAFE_USER_ID: '${CAT_CAFE_USER_ID}',
2122
CAT_CAFE_SIGNAL_USER: '${CAT_CAFE_SIGNAL_USER}',
2223
};
24+
const KIMI_CAT_CAFE_ENV_PLACEHOLDERS: Readonly<Record<string, string>> = {
25+
CAT_CAFE_API_URL: '${CAT_CAFE_API_URL}',
26+
CAT_CAFE_INVOCATION_ID: '${CAT_CAFE_INVOCATION_ID}',
27+
CAT_CAFE_CALLBACK_TOKEN: '${CAT_CAFE_CALLBACK_TOKEN}',
28+
CAT_CAFE_USER_ID: '${CAT_CAFE_USER_ID}',
29+
CAT_CAFE_SIGNAL_USER: '${CAT_CAFE_SIGNAL_USER}',
30+
};
2331

2432
function isCatCafeServer(name: string): boolean {
2533
return name === 'cat-cafe' || name.startsWith('cat-cafe-');
@@ -33,6 +41,14 @@ function ensureGeminiCatCafeEnv(name: string, env?: Record<string, string>): Rec
3341
};
3442
}
3543

44+
function ensureKimiCatCafeEnv(name: string, env?: Record<string, string>): Record<string, string> | undefined {
45+
if (!isCatCafeServer(name)) return env;
46+
return {
47+
...KIMI_CAT_CAFE_ENV_PLACEHOLDERS,
48+
...(env ?? {}),
49+
};
50+
}
51+
3652
// ────────── Readers ──────────
3753

3854
/** Read Claude .mcp.json → McpServerDescriptor[] */
@@ -87,6 +103,22 @@ export async function readGeminiMcpConfig(filePath: string): Promise<McpServerDe
87103
);
88104
}
89105

106+
/** Read Kimi .kimi/mcp.json → McpServerDescriptor[] */
107+
export async function readKimiMcpConfig(filePath: string): Promise<McpServerDescriptor[]> {
108+
const raw = await safeReadFile(filePath);
109+
if (!raw) return [];
110+
111+
const data = safeJsonParse(raw);
112+
if (!data) return [];
113+
114+
const servers = data.mcpServers;
115+
if (!servers || typeof servers !== 'object') return [];
116+
117+
return Object.entries(servers as Record<string, Record<string, unknown>>).map(([name, cfg]) =>
118+
toDescriptor(name, cfg, true),
119+
);
120+
}
121+
90122
// ────────── Writers ──────────
91123

92124
/** Write McpServerDescriptor[] → Claude .mcp.json (merge: preserves user's non-managed servers) */
@@ -219,9 +251,64 @@ export async function writeGeminiMcpConfig(filePath: string, servers: McpServerD
219251
await writeFile(filePath, `${JSON.stringify(existing, null, 2)}\n`, 'utf-8');
220252
}
221253

254+
/** Write McpServerDescriptor[] → Kimi .kimi/mcp.json (merge: preserves user's non-managed servers) */
255+
export async function writeKimiMcpConfig(filePath: string, servers: McpServerDescriptor[]): Promise<void> {
256+
const raw = await safeReadFile(filePath);
257+
let existing: Record<string, unknown> = {};
258+
if (raw) {
259+
const parsed = safeJsonParse(raw);
260+
if (parsed) existing = parsed;
261+
}
262+
263+
const existingMcp: Record<string, unknown> =
264+
existing.mcpServers && typeof existing.mcpServers === 'object'
265+
? { ...(existing.mcpServers as Record<string, unknown>) }
266+
: {};
267+
268+
for (const s of servers) {
269+
if (!s.enabled) {
270+
delete existingMcp[s.name];
271+
continue;
272+
}
273+
if (s.transport === 'streamableHttp') {
274+
if (!s.url?.trim()) {
275+
delete existingMcp[s.name];
276+
continue;
277+
}
278+
const entry: Record<string, unknown> = { url: s.url };
279+
if (s.headers && Object.keys(s.headers).length > 0) entry.headers = s.headers;
280+
existingMcp[s.name] = entry;
281+
continue;
282+
}
283+
if (!s.command || s.command.trim().length === 0) {
284+
delete existingMcp[s.name];
285+
continue;
286+
}
287+
const entry: Record<string, unknown> = { command: s.command, args: s.args };
288+
const env = ensureKimiCatCafeEnv(s.name, s.env);
289+
if (env && Object.keys(env).length > 0) entry.env = env;
290+
if (s.workingDir) entry.cwd = s.workingDir;
291+
existingMcp[s.name] = entry;
292+
}
293+
294+
for (const [name, value] of Object.entries(existingMcp)) {
295+
if (!isCatCafeServer(name)) continue;
296+
if (!value || typeof value !== 'object' || Array.isArray(value)) continue;
297+
const cfg = value as Record<string, unknown>;
298+
const currentEnv = toStringRecord(cfg.env);
299+
cfg.env = ensureKimiCatCafeEnv(name, currentEnv);
300+
existingMcp[name] = cfg;
301+
}
302+
303+
existing.mcpServers = existingMcp;
304+
await ensureDir(filePath);
305+
await writeFile(filePath, `${JSON.stringify(existing, null, 2)}\n`, 'utf-8');
306+
}
307+
222308
// ────────── Helpers ──────────
223309

224-
async function safeReadFile(filePath: string): Promise<string | null> {
310+
async function safeReadFile(filePath?: string): Promise<string | null> {
311+
if (!filePath) return null;
225312
try {
226313
return await readFile(filePath, 'utf-8');
227314
} catch {
@@ -254,7 +341,8 @@ function toStringRecord(val: unknown): Record<string, string> | undefined {
254341
}
255342

256343
function toDescriptor(name: string, cfg: Record<string, unknown>, enabled: boolean): McpServerDescriptor {
257-
const isHttp = cfg.type === 'streamableHttp' || cfg.type === 'http';
344+
const isHttp =
345+
cfg.type === 'streamableHttp' || cfg.type === 'http' || (typeof cfg.url === 'string' && cfg.url.length > 0);
258346
const desc: McpServerDescriptor = {
259347
name,
260348
command: typeof cfg.command === 'string' ? cfg.command : '',

packages/api/src/config/cat-catalog-store.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,8 @@ function providerToBootstrapClient(provider: unknown): BuiltinAccountClient | nu
6767
return 'openai';
6868
case 'google':
6969
return 'google';
70+
case 'kimi':
71+
return 'kimi';
7072
case 'dare':
7173
return 'dare';
7274
case 'opencode':

packages/api/src/config/cat-config-loader.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ const catVariantSchema = z.object({
6363
mentionPatterns: z.array(mentionPatternSchema).optional(), // F32-b: variant-level mentions
6464
accountRef: z.string().min(1).optional(), // F127: concrete account binding
6565
providerProfileId: z.string().min(1).optional(), // Legacy migration path
66-
provider: z.enum(['anthropic', 'openai', 'google', 'dare', 'antigravity', 'opencode', 'a2a']),
66+
provider: z.enum(['anthropic', 'openai', 'google', 'kimi', 'dare', 'antigravity', 'opencode', 'a2a']),
6767
defaultModel: z.string().min(1),
6868
mcpSupport: z.boolean(),
6969
cli: cliConfigSchema,

packages/api/src/config/env-registry.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ export type EnvCategory =
2424
| 'codex'
2525
| 'dare'
2626
| 'gemini'
27+
| 'kimi'
2728
| 'tts'
2829
| 'stt'
2930
| 'frontend'
@@ -64,6 +65,7 @@ export const ENV_CATEGORIES: Record<EnvCategory, string> = {
6465
codex: '缅因猫 (Codex)',
6566
dare: '狸花猫 (Dare)',
6667
gemini: '暹罗猫 (Gemini)',
68+
kimi: '金吉拉猫 (Kimi)',
6769
tts: '语音合成 (TTS)',
6870
stt: '语音识别 (STT)',
6971
frontend: '前端',
@@ -889,6 +891,24 @@ export const ENV_VARS: EnvDefinition[] = [
889891
sensitive: false,
890892
},
891893

894+
// --- kimi ---
895+
{
896+
name: 'MOONSHOT_API_KEY',
897+
defaultValue: '(未设置)',
898+
description: 'Kimi / Moonshot API Key(官方 kimi-cli API Key 模式用)',
899+
category: 'kimi',
900+
sensitive: true,
901+
hubVisible: false,
902+
},
903+
{
904+
name: 'KIMI_SHARE_DIR',
905+
defaultValue: '~/.kimi',
906+
description: '官方 kimi-cli 共享目录(session / mcp / logs)',
907+
category: 'kimi',
908+
sensitive: false,
909+
hubVisible: false,
910+
},
911+
892912
// --- tts ---
893913
{
894914
name: 'TTS_URL',

packages/api/src/config/governance/governance-bootstrap.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,20 +28,23 @@ const PROVIDER_FILES: Record<Provider, string> = {
2828
claude: 'CLAUDE.md',
2929
codex: 'AGENTS.md',
3030
gemini: 'GEMINI.md',
31+
kimi: 'KIMI.md',
3132
};
3233

3334
/** Provider skills directory mapping */
3435
const PROVIDER_SKILLS_DIRS: Record<Provider, string> = {
3536
claude: '.claude/skills',
3637
codex: '.codex/skills',
3738
gemini: '.gemini/skills',
39+
kimi: '.kimi/skills',
3840
};
3941

4042
/** Provider hooks directory mapping (F070 Phase 2) */
4143
const PROVIDER_HOOKS_DIRS: Record<Provider, string> = {
4244
claude: '.claude/hooks',
4345
codex: '.codex/hooks',
4446
gemini: '.gemini/hooks',
47+
kimi: '.kimi/hooks',
4548
};
4649

4750
export interface BootstrapOptions {
@@ -70,7 +73,7 @@ export class GovernanceBootstrapService {
7073
actions.push(action);
7174
}
7275

73-
// 2. Skills symlinks for all 3 providers
76+
// 2. Skills symlinks for all supported providers
7477
for (const [provider, skillsDir] of Object.entries(PROVIDER_SKILLS_DIRS) as [Provider, string][]) {
7578
const action = await this.symlinkSkills(targetProject, provider, skillsDir, opts.dryRun);
7679
actions.push(action);

0 commit comments

Comments
 (0)