mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-08-21 06:35:50 +00:00
* feat(plugin): install plugins from a GitHub repository URL Allow `/plugins install <github-url>` (and marketplace `source` entries) to take a GitHub repo URL directly. A new `github` source kind joins the existing `local-path` and `zip-url` kinds. Recognized URL forms (parsing in source.ts): - `https://github.com/<o>/<r>` — bare; resolves to latest release tag, falling back to default branch HEAD. - `https://github.com/<o>/<r>/tree/<ref>` — branch / tag / SHA; value passed to codeload in its short form so the backend resolves either. - `https://github.com/<o>/<r>/releases/tag/<tag>` — explicit tag, uses refs/tags/<tag> to avoid same-named-branch ambiguity. - `https://github.com/<o>/<r>/commit/<sha>` — explicit commit SHA. The resolver deliberately avoids `api.github.com`: its 60/hour anonymous quota is shared with every other tool on the egress IP (browser, gh CLI, IDE integrations) and a first-time install failing because some other tool ate the budget is unacceptable UX. Instead we: - GET `github.com/<o>/<r>/releases/latest` with manual redirect and parse the `Location` header (302 → tag URL; 404 → no own release). - Fall back to `codeload.github.com/<o>/<r>/zip/HEAD` for repos with no releases (or for forks that inherit upstream tags but have no own release page, which redirect to bare `/releases`). - Only treat the explicit 404 from `/releases/latest` as "no release" — 5xx, 403, 429, and any other non-2xx status surface a hard error rather than silently installing the default branch, so the user knows when transient GitHub issues changed the install path. UI changes in the TUI: - `/plugins install` now shows a live Braille spinner while resolving and downloading, then flips to a final status that distinguishes Installed (fresh) vs Updated (same repo identity, new version) vs Migrated (source changed, e.g. CDN zip-url → GitHub). - `/plugins list`, the `/plugins` overview, and `/plugins info` show the install provenance inline. `zip-url` installs now display the URL host (e.g. `via code.kimi.com`, `via 127.0.0.1:port`) instead of the opaque `zip-url` literal. GitHub installs show `github <owner>/<repo>@<ref>`. - Three-tier trust badge driven by the marketplace context recorded at install time: `official` (green) for `tier: official`, `curated` (blue) for `tier: curated`, `third-party` (muted) for anything not installed through the marketplace selector. CLI `/plugins install <url>` always records as third-party; the marketplace selector passes the tier through. A re-install replaces the marketplace context: switching to a third-party source clears the badge, which matches the underlying trust change. `installed.json` gains optional `github` and `marketplace` fields (back-compatible). PluginSummary surfaces `source`, `originalSource`, `github`, and `marketplace` so the TUI can label installs without an extra round trip to PluginInfo. The SDK's `session.installPlugin(source)` gains an optional `{ marketplace }` second argument so the marketplace selector can forward `{ id, tier }` through RPC; the CLI install path omits it. Tests: 112 plugin-suite tests (URL parser, resolver, store round-trip, manager integration). The manager integration tests assert codeload URLs shape (short form for `/tree/<ref>`, explicit `refs/tags/` for `/releases/tag/`) and verify marketplace context is persisted across reloads and cleared on a third-party re-install. * chore(changeset): plugin install from GitHub * docs(plugins): document GitHub install URLs and trust badges * fix(plugin): preserve URL-encoded characters in GitHub ref names Git permits ref characters that have special meaning in URLs — most notably `#`, which is a valid tag character (e.g. `release#1`) but the URL fragment delimiter. The resolver decoded the tag from GitHub's `/releases/latest` 302 redirect Location header and then interpolated the raw value into the codeload URL. The literal `#1` became a fragment and the HTTP request reached the server as `…/refs/tags/release` — a wrong or truncated ref, leading to install failure for a release whose URL was otherwise valid. Two symmetric changes: - The codeload URL builder now splits the ref on `/` (so multi-segment refs like `feat/foo` keep their path separators) and percent-encodes each segment. - The GitHub URL parser now percent-decodes each segment from the URL's pathname when extracting `/tree/<ref>`, `/releases/tag/<tag>`, and `/commit/<sha>`. Storage and display see the human-readable Git ref name; the resolver re-encodes on the way out. Malformed `%xx` sequences in user-typed URLs are tolerated: we keep the raw segment so the caller surfaces a normal "ref not found" error downstream instead of crashing during parse. * fix: restrict plugin trust badges * chore: remove fetch when show plugin list --------- Co-authored-by: qer <Anna_Knapprfr@mail.com>
151 lines
5.5 KiB
TypeScript
151 lines
5.5 KiB
TypeScript
import { mkdir, mkdtemp, writeFile } from 'node:fs/promises';
|
|
import { tmpdir } from 'node:os';
|
|
import path from 'node:path';
|
|
|
|
import { describe, expect, it } from 'vitest';
|
|
|
|
import { KimiCore } from '../../src/rpc/core-impl';
|
|
|
|
describe('KimiCore plugin RPCs', () => {
|
|
it('install → list → setEnabled → remove round trip', async () => {
|
|
const home = await mkdtemp(path.join(tmpdir(), 'kimi-home-'));
|
|
const pluginRoot = await mkdtemp(path.join(tmpdir(), 'plugin-'));
|
|
await writeFile(
|
|
path.join(pluginRoot, 'kimi.plugin.json'),
|
|
JSON.stringify({ name: 'demo', version: '1.0.0' }),
|
|
'utf8',
|
|
);
|
|
|
|
const core = new KimiCore(async () => ({}) as never, { homeDir: home });
|
|
await new Promise((r) => setImmediate(r));
|
|
|
|
const installed = await core.installPlugin({ source: pluginRoot });
|
|
expect(installed.id).toBe('demo');
|
|
expect(installed.version).toBe('1.0.0');
|
|
|
|
const list = await core.listPlugins({});
|
|
expect(list).toHaveLength(1);
|
|
|
|
await core.setPluginEnabled({ id: 'demo', enabled: false });
|
|
const after = await core.listPlugins({});
|
|
expect(after[0]?.enabled).toBe(false);
|
|
|
|
await core.removePlugin({ id: 'demo' });
|
|
await expect(core.listPlugins({})).resolves.toEqual([]);
|
|
});
|
|
|
|
it('installPlugin ignores forged marketplace context from public RPC callers', async () => {
|
|
const home = await mkdtemp(path.join(tmpdir(), 'kimi-home-'));
|
|
const pluginRoot = await mkdtemp(path.join(tmpdir(), 'plugin-'));
|
|
await writeFile(
|
|
path.join(pluginRoot, 'kimi.plugin.json'),
|
|
JSON.stringify({ name: 'demo', version: '1.0.0' }),
|
|
'utf8',
|
|
);
|
|
|
|
const core = new KimiCore(async () => ({}) as never, { homeDir: home });
|
|
await new Promise((r) => setImmediate(r));
|
|
|
|
const installed = await core.installPlugin({
|
|
source: pluginRoot,
|
|
marketplace: { id: 'demo', tier: 'official' },
|
|
} as never);
|
|
|
|
expect((installed as { marketplace?: unknown }).marketplace).toBeUndefined();
|
|
});
|
|
|
|
it('setPluginMcpServerEnabled toggles plugin MCP state', async () => {
|
|
const home = await mkdtemp(path.join(tmpdir(), 'kimi-home-'));
|
|
const pluginRoot = await mkdtemp(path.join(tmpdir(), 'plugin-'));
|
|
await writeFile(
|
|
path.join(pluginRoot, 'kimi.plugin.json'),
|
|
JSON.stringify({
|
|
name: 'demo',
|
|
mcpServers: {
|
|
finance: { command: 'finance-mcp' },
|
|
},
|
|
}),
|
|
'utf8',
|
|
);
|
|
|
|
const core = new KimiCore(async () => ({}) as never, { homeDir: home });
|
|
await new Promise((r) => setImmediate(r));
|
|
|
|
await core.installPlugin({ source: pluginRoot });
|
|
await core.setPluginMcpServerEnabled({ id: 'demo', server: 'finance', enabled: true });
|
|
|
|
await expect(core.getPluginInfo({ id: 'demo' })).resolves.toEqual(
|
|
expect.objectContaining({
|
|
mcpServers: expect.arrayContaining([
|
|
expect.objectContaining({ name: 'finance', enabled: true }),
|
|
]),
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('throws PLUGIN_LOAD_FAILED on every RPC when installed.json is corrupt', async () => {
|
|
const home = await mkdtemp(path.join(tmpdir(), 'kimi-home-'));
|
|
await mkdir(path.join(home, 'plugins'), { recursive: true });
|
|
await writeFile(path.join(home, 'plugins', 'installed.json'), '{ not json', 'utf8');
|
|
|
|
const core = new KimiCore(async () => ({}) as never, { homeDir: home });
|
|
|
|
// Driving an awaiting RPC first ensures the load promise has settled
|
|
// and captured pluginsLoadError before the read RPCs run.
|
|
await expect(core.installPlugin({ source: '/tmp/nonexistent' })).rejects.toThrow(/load/i);
|
|
await expect(core.listPlugins({})).rejects.toThrow(/load/i);
|
|
await expect(core.getPluginInfo({ id: 'demo' })).rejects.toThrow(/load/i);
|
|
await expect(core.setPluginEnabled({ id: 'demo', enabled: false })).rejects.toThrow(/load/i);
|
|
await expect(
|
|
core.setPluginMcpServerEnabled({ id: 'demo', server: 'finance', enabled: true }),
|
|
).rejects.toThrow(/load/i);
|
|
await expect(core.removePlugin({ id: 'demo' })).rejects.toThrow(/load/i);
|
|
|
|
// installed.json must NOT have been overwritten by the failed install.
|
|
const { readFile } = await import('node:fs/promises');
|
|
const onDisk = await readFile(path.join(home, 'plugins', 'installed.json'), 'utf8');
|
|
expect(onDisk).toBe('{ not json');
|
|
|
|
// Fixing the file and calling reload clears the error state.
|
|
await writeFile(
|
|
path.join(home, 'plugins', 'installed.json'),
|
|
JSON.stringify({ version: 1, plugins: [] }),
|
|
'utf8',
|
|
);
|
|
await core.reloadPlugins({});
|
|
await expect(core.listPlugins({})).resolves.toEqual([]);
|
|
});
|
|
|
|
it('listPlugins waits for initial plugin load', async () => {
|
|
const home = await mkdtemp(path.join(tmpdir(), 'kimi-home-'));
|
|
const pluginRoot = await mkdtemp(path.join(tmpdir(), 'plugin-'));
|
|
await writeFile(
|
|
path.join(pluginRoot, 'kimi.plugin.json'),
|
|
JSON.stringify({ name: 'demo' }),
|
|
'utf8',
|
|
);
|
|
await mkdir(path.join(home, 'plugins'), { recursive: true });
|
|
await writeFile(
|
|
path.join(home, 'plugins', 'installed.json'),
|
|
JSON.stringify({
|
|
version: 1,
|
|
plugins: [
|
|
{
|
|
id: 'demo',
|
|
root: pluginRoot,
|
|
source: 'local-path',
|
|
enabled: true,
|
|
installedAt: '2026-05-25T09:00:00Z',
|
|
},
|
|
],
|
|
}),
|
|
'utf8',
|
|
);
|
|
|
|
const core = new KimiCore(async () => ({}) as never, { homeDir: home });
|
|
|
|
await expect(core.listPlugins({})).resolves.toContainEqual(
|
|
expect.objectContaining({ id: 'demo' }),
|
|
);
|
|
});
|
|
});
|