Skip to content

Commit a200a29

Browse files
authored
feat(kimi-code): add /connect command with bundled model catalog (#30)
* feat: add /connect command with models.dev catalog support Add a /connect slash command that configures a provider and model from a models.dev-style catalog. Users no longer need to hand-write model metadata (context window, output limit, capabilities). Architecture (3 layers): - kosong: pure data layer — Catalog schema, inferWireType, catalogModelToCapability - node-sdk: IO + config write — fetchCatalog, applyCatalogProvider, catalogModelToAlias - app: TUI flow — /connect command, provider/model selection, credential input, config persistence UI improvements in this PR: - ChoicePickerComponent: add searchable (fuzzy filter + search bar) - ModelSelectorComponent: add searchable (same) - Extract reusable paging.ts for list pagination Changesets included for kosong, kimi-code-sdk, and kimi-code. * feat: bundle pruned models.dev catalog for offline /connect When the network is unavailable, /connect now falls back to a built-in snapshot of the models.dev catalog. - `scripts/update-catalog.mjs`: fetches models.dev/api.json, strips unnecessary fields, and writes `src/built-in-catalog.ts` with the JSON string as a TS constant. - `loadBuiltInCatalog(text?)` in node-sdk: parses the JSON string safely; returns undefined on any failure. - `handleConnectCommand`: on fetch failure, shows an informative offline message and tries the built-in snapshot. - The snapshot file is a placeholder (`undefined`) in source control; `update-catalog.mjs` populates it before release builds so the actual catalog is inlined into the bundle by rolldown. * refactor(tui): share search and pagination across list pickers ChoicePicker and ModelSelector each carried their own copy of the cursor + fuzzy-search + pagination state machine. Extract it into a reusable SearchableList so both pickers share one implementation; behavior is unchanged. * fix(tui): filter unsupported catalog providers * docs(tui): clarify /connect stale-alias cleanup depends on removeProvider * fix(kimi-code): inject built-in catalog at release time * feat(tui): hint at /login and /connect when /model has no models Replace the bare "No models configured." error with a notice that points users to /login for Kimi and /connect for other providers. * fix(tui): tighten /connect error reporting for edge cases Two silent-failure cases in /connect could leave users without any feedback to act on: - Reject `--url` when its value is missing (e.g. `/connect --url` or `/connect --url=`). Previously the argument parser silently fell back to the default catalog, so a malformed flag still appeared to succeed but with the wrong source. - Show an explicit error when the resolved catalog yields no providers with supported wire types. Previously the picker resolved with no selection and the command returned without any UI feedback. * chore(tui): restore slash invalid intent type * fix(tui): support /logout for /connect-configured providers After /connect writes a non-managed provider (e.g. openai), /logout fell through to "Nothing to logout." because the handler only matched the managed default and isOpenPlatformId branches, leaving users no in-app way to drop the API key and model aliases they just configured. Collapse the OpenPlatform branch into a generic "provider is present in config" check so any non-managed provider in config — OpenPlatform OAuth targets and /connect catalog providers — goes through the same removeProvider path. * fix(tui): reject --url values that are not http(s) URLs `resolveConnectCatalogRequest` previously matched any non-space token after `--url` as the URL, so `/connect --url --refresh` parsed `--refresh` as the value and bypassed the missing-value error path. Bare non-URL tokens (`/connect --url not-a-url`) and non-http(s) schemes were also silently accepted. Constrain the captured value to `https?://...` so flag-like and non-URL tokens fall through to the existing `URL_FLAG_PRESENT_RE` check and surface a clear error. * ci(native): scope built-in catalog generation to signed macOS jobs The catalog-generation step ran whenever `inputs.sign-macos` was true, including Linux and Windows targets that take the local-profile build path and never consume the generated catalog. A transient models.dev outage would therefore fail unrelated artifact builds. Match the condition to the macOS signed release-profile build that actually consumes the bundled catalog. * fix(ci): embed built-in catalog in non-macOS native artifacts The earlier narrowing to `runner.os == 'macOS' && inputs.sign-macos` relied on a misread of build.mjs: its `profile === 'release'` guard only auto-fetches the catalog as a dev fallback. Whether the binary actually embeds the catalog is decided by tsdown's define at bundle time, which reads KIMI_CODE_BUILT_IN_CATALOG_FILE regardless of profile. Linux and Windows release artifacts therefore lost their bundled catalog and silently regressed offline /connect on those targets. Restore generation for all OS jobs when sign-macos is true. * chore(tui): mention /connect in welcome panel hints Align the welcome panel with the /model picker so the empty-state copy points users to both /login and /connect.
1 parent e9e4a48 commit a200a29

34 files changed

Lines changed: 1801 additions & 69 deletions
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
"@moonshot-ai/kosong": minor
3+
"@moonshot-ai/kimi-code-sdk": minor
4+
"@moonshot-ai/kimi-code": minor
5+
---
6+
7+
Add a `/connect` command that configures a provider and model from a model catalog. By default it reads from a pruned catalog snapshot bundled with the CLI, so the command works offline and is not gated by models.dev availability. Model metadata (context window, output limit, and capabilities) is filled in automatically, so models no longer need to be written by hand in config. Pass `--refresh` to fetch the latest catalog from models.dev (falling back to the bundled snapshot on failure), or `--url` to point at a custom catalog endpoint that uses the same format. When connecting an Anthropic-compatible provider whose catalog base URL already includes a version segment, the request path no longer duplicates that segment, so connections that previously failed with a not-found error now succeed.
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@moonshot-ai/kimi-code": minor
3+
---
4+
5+
The `/connect` provider and model pickers now support type-to-search filtering, and long selection lists are paginated instead of rendering every entry at once. The model picker also paginates when many models are configured.
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@moonshot-ai/kimi-code": patch
3+
---
4+
5+
Show a hint pointing to /login (Kimi) and /connect (other providers) when /model is opened with no configured models, and surface the same hint on the welcome panel when no model is set.

.github/workflows/_native-build.yml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,14 @@ jobs:
7777
certificate-p12: ${{ secrets.APPLE_CERTIFICATE_P12 }}
7878
certificate-password: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
7979

80+
- name: Generate built-in catalog (release artifacts)
81+
if: inputs.sign-macos
82+
shell: bash
83+
run: |
84+
CATALOG_FILE="$RUNNER_TEMP/kimi-code-built-in-catalog.json"
85+
node apps/kimi-code/scripts/update-catalog.mjs --out "$CATALOG_FILE"
86+
echo "KIMI_CODE_BUILT_IN_CATALOG_FILE=$CATALOG_FILE" >> "$GITHUB_ENV"
87+
8088
- name: Build native executable (release profile, macOS signed)
8189
if: runner.os == 'macOS' && inputs.sign-macos
8290
run: pnpm --filter @moonshot-ai/kimi-code run build:native:release

.github/workflows/release.yml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,13 @@ jobs:
4141
- name: Install dependencies
4242
run: pnpm install --frozen-lockfile
4343

44+
- name: Generate Kimi Code built-in catalog
45+
shell: bash
46+
run: |
47+
CATALOG_FILE="$RUNNER_TEMP/kimi-code-built-in-catalog.json"
48+
node apps/kimi-code/scripts/update-catalog.mjs --out "$CATALOG_FILE"
49+
echo "KIMI_CODE_BUILT_IN_CATALOG_FILE=$CATALOG_FILE" >> "$GITHUB_ENV"
50+
4451
- name: Build packages
4552
run: pnpm build
4653

apps/kimi-code/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@
4444
},
4545
"scripts": {
4646
"build": "tsdown",
47+
"catalog:update": "node scripts/update-catalog.mjs --out dist/built-in-catalog.json",
4748
"smoke": "node scripts/smoke.mjs",
4849
"build:native:js": "node scripts/native/01-bundle.mjs",
4950
"build:native:sea": "node scripts/native/build.mjs --profile=local",
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import { readFileSync } from 'node:fs';
2+
3+
export const BUILT_IN_CATALOG_ENV = 'KIMI_CODE_BUILT_IN_CATALOG_FILE';
4+
export const BUILT_IN_CATALOG_DEFINE = '__KIMI_CODE_BUILT_IN_CATALOG__';
5+
6+
export function builtInCatalogDefine(env = process.env) {
7+
const file = env[BUILT_IN_CATALOG_ENV];
8+
if (file === undefined || file.length === 0) return 'undefined';
9+
return JSON.stringify(readFileSync(file, 'utf-8'));
10+
}

apps/kimi-code/scripts/native/build.mjs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,14 @@
1+
import { resolve } from 'node:path';
12
import { parseArgs } from 'node:util';
23

34
import { runBundleStep } from './01-bundle.mjs';
45
import { runInjectStep } from './03-inject.mjs';
56
import { runSeaBlobStep } from './02-sea-blob.mjs';
67
import { runSignStep } from './04-sign.mjs';
78
import { runVerifyStep } from './05-verify.mjs';
9+
import { run } from './exec.mjs';
10+
import { appRoot, nativeIntermediatesDir } from './paths.mjs';
11+
import { BUILT_IN_CATALOG_ENV } from '../built-in-catalog.mjs';
812

913
const { values } = parseArgs({
1014
options: {
@@ -31,6 +35,12 @@ function ensureNodeVersion() {
3135
ensureNodeVersion();
3236
console.log(`==> Native build (profile=${profile})`);
3337

38+
if (profile === 'release' && process.env[BUILT_IN_CATALOG_ENV] === undefined) {
39+
const catalogPath = resolve(nativeIntermediatesDir(), 'built-in-catalog.json');
40+
await run(process.execPath, [resolve(appRoot, 'scripts/update-catalog.mjs'), '--out', catalogPath]);
41+
process.env[BUILT_IN_CATALOG_ENV] = catalogPath;
42+
}
43+
3444
await runBundleStep();
3545
await runSeaBlobStep();
3646
await runInjectStep();
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
#!/usr/bin/env node
2+
/**
3+
* Fetches models.dev/api.json, strips fields not needed by kimi-code, and
4+
* writes the result as raw JSON for release builds to inline.
5+
*
6+
* This script intentionally does not write into src/. The source tree keeps a
7+
* placeholder so the generated catalog is not committed.
8+
*/
9+
10+
import { mkdirSync, writeFileSync } from "node:fs";
11+
import { dirname, resolve } from "node:path";
12+
13+
const scriptDir = import.meta.dirname;
14+
const outFile = resolveOutputFile(process.argv.slice(2));
15+
const modelsUrl = process.env.MODELS_DEV_URL || "https://models.dev/api.json";
16+
17+
const KEEP_PROVIDER = new Set(["id", "name", "api", "env", "npm", "type", "models"]);
18+
const KEEP_MODEL = new Set(["id", "name", "family", "limit", "tool_call", "reasoning", "modalities"]);
19+
20+
function resolveOutputFile(args) {
21+
const index = args.indexOf("--out");
22+
if (index !== -1) {
23+
const value = args[index + 1];
24+
if (value === undefined || value.length === 0) {
25+
throw new Error("Missing value for --out");
26+
}
27+
return resolve(process.cwd(), value);
28+
}
29+
return resolve(scriptDir, "../dist/built-in-catalog.json");
30+
}
31+
32+
function stripModel(model) {
33+
if (typeof model !== "object" || model === null) return undefined;
34+
const result = {};
35+
for (const key of Object.keys(model)) {
36+
if (KEEP_MODEL.has(key)) result[key] = model[key];
37+
}
38+
return result;
39+
}
40+
41+
function stripProvider(provider) {
42+
if (typeof provider !== "object" || provider === null) return undefined;
43+
const result = {};
44+
for (const key of Object.keys(provider)) {
45+
if (!KEEP_PROVIDER.has(key)) continue;
46+
const value = provider[key];
47+
if (key === "models") {
48+
const stripped = {};
49+
for (const [mId, m] of Object.entries(value)) {
50+
const s = stripModel(m);
51+
if (s !== undefined) stripped[mId] = s;
52+
}
53+
if (Object.keys(stripped).length > 0) result[key] = stripped;
54+
} else {
55+
result[key] = value;
56+
}
57+
}
58+
return result;
59+
}
60+
61+
async function fetchCatalog(url) {
62+
const res = await fetch(url, { headers: { Accept: "application/json" } });
63+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
64+
const raw = await res.json();
65+
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
66+
throw new Error("invalid payload shape");
67+
}
68+
const stripped = {};
69+
for (const [k, v] of Object.entries(raw)) {
70+
const p = stripProvider(v);
71+
if (p !== undefined && Object.keys(p).length > 0) stripped[k] = p;
72+
}
73+
return JSON.stringify(stripped);
74+
}
75+
76+
async function main() {
77+
console.log(`Fetching ${modelsUrl} ...`);
78+
const json = await fetchCatalog(modelsUrl);
79+
mkdirSync(dirname(outFile), { recursive: true });
80+
writeFileSync(outFile, json, "utf-8");
81+
console.log(`Wrote ${outFile} (${(json.length / 1024).toFixed(0)} KB JSON)`);
82+
}
83+
84+
main().catch((error) => {
85+
console.error(error.message);
86+
process.exit(1);
87+
});
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
// Filled by tsdown define in release builds. Source stays empty so the
2+
// generated models.dev snapshot is not committed.
3+
declare const __KIMI_CODE_BUILT_IN_CATALOG__: string | undefined;
4+
5+
export const BUILT_IN_CATALOG_JSON: string | undefined =
6+
typeof __KIMI_CODE_BUILT_IN_CATALOG__ === 'string'
7+
? __KIMI_CODE_BUILT_IN_CATALOG__
8+
: undefined;

0 commit comments

Comments
 (0)