mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-07-10 17:59:24 +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>
228 lines
7.6 KiB
TypeScript
228 lines
7.6 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
|
|
|
import { resolveInstallSource } from '../../src/plugin/source';
|
|
|
|
describe('resolveInstallSource', () => {
|
|
it('recognizes https:// as zip-url', () => {
|
|
const result = resolveInstallSource('https://example.com/plugin.zip');
|
|
expect(result).toEqual({ kind: 'zip-url', path: 'https://example.com/plugin.zip' });
|
|
});
|
|
|
|
it('recognizes http:// as zip-url', () => {
|
|
const result = resolveInstallSource('http://example.com/plugin.zip');
|
|
expect(result).toEqual({ kind: 'zip-url', path: 'http://example.com/plugin.zip' });
|
|
});
|
|
|
|
it('recognizes absolute path as local-path', () => {
|
|
const result = resolveInstallSource('/home/user/plugin');
|
|
expect(result).toEqual({ kind: 'local-path', path: '/home/user/plugin' });
|
|
});
|
|
|
|
it('trims whitespace from local paths', () => {
|
|
const result = resolveInstallSource(' /home/user/plugin ');
|
|
expect(result).toEqual({ kind: 'local-path', path: '/home/user/plugin' });
|
|
});
|
|
|
|
it('throws for relative local paths', () => {
|
|
expect(() => resolveInstallSource('relative/path')).toThrow(/absolute path/i);
|
|
});
|
|
|
|
it('throws for empty string', () => {
|
|
expect(() => resolveInstallSource('')).toThrow(/absolute path/i);
|
|
});
|
|
|
|
describe('GitHub URL recognition', () => {
|
|
it('recognizes bare github URL', () => {
|
|
const result = resolveInstallSource('https://github.com/wbxl2000/superpowers');
|
|
expect(result).toEqual({
|
|
kind: 'github',
|
|
owner: 'wbxl2000',
|
|
repo: 'superpowers',
|
|
});
|
|
});
|
|
|
|
it('recognizes www.github.com as a synonym', () => {
|
|
const result = resolveInstallSource('https://www.github.com/wbxl2000/superpowers');
|
|
expect(result).toEqual({
|
|
kind: 'github',
|
|
owner: 'wbxl2000',
|
|
repo: 'superpowers',
|
|
});
|
|
});
|
|
|
|
it('strips trailing slash on bare URL', () => {
|
|
const result = resolveInstallSource('https://github.com/wbxl2000/superpowers/');
|
|
expect(result).toEqual({
|
|
kind: 'github',
|
|
owner: 'wbxl2000',
|
|
repo: 'superpowers',
|
|
});
|
|
});
|
|
|
|
it('recognizes /tree/<branch>', () => {
|
|
const result = resolveInstallSource('https://github.com/obra/superpowers/tree/main');
|
|
expect(result).toEqual({
|
|
kind: 'github',
|
|
owner: 'obra',
|
|
repo: 'superpowers',
|
|
ref: { kind: 'branch', value: 'main' },
|
|
});
|
|
});
|
|
|
|
it('recognizes /tree/<tag-like-value> as branch (cannot distinguish without API)', () => {
|
|
const result = resolveInstallSource('https://github.com/obra/superpowers/tree/v5.1.0');
|
|
expect(result).toEqual({
|
|
kind: 'github',
|
|
owner: 'obra',
|
|
repo: 'superpowers',
|
|
ref: { kind: 'branch', value: 'v5.1.0' },
|
|
});
|
|
});
|
|
|
|
it('recognizes /tree/<short-sha> as sha', () => {
|
|
const result = resolveInstallSource('https://github.com/obra/superpowers/tree/45b441d');
|
|
expect(result).toEqual({
|
|
kind: 'github',
|
|
owner: 'obra',
|
|
repo: 'superpowers',
|
|
ref: { kind: 'sha', value: '45b441d' },
|
|
});
|
|
});
|
|
|
|
it('recognizes /tree/<full-sha> as sha', () => {
|
|
const sha = '45b441d62b81b5f27d3bfd8700e04436cd4de5b3';
|
|
const result = resolveInstallSource(`https://github.com/obra/superpowers/tree/${sha}`);
|
|
expect(result).toEqual({
|
|
kind: 'github',
|
|
owner: 'obra',
|
|
repo: 'superpowers',
|
|
ref: { kind: 'sha', value: sha },
|
|
});
|
|
});
|
|
|
|
it('preserves slashes inside branch names under /tree/', () => {
|
|
const result = resolveInstallSource('https://github.com/owner/repo/tree/feat/foo-bar');
|
|
expect(result).toEqual({
|
|
kind: 'github',
|
|
owner: 'owner',
|
|
repo: 'repo',
|
|
ref: { kind: 'branch', value: 'feat/foo-bar' },
|
|
});
|
|
});
|
|
|
|
it('strips trailing slash on /tree/', () => {
|
|
const result = resolveInstallSource('https://github.com/owner/repo/tree/main/');
|
|
expect(result).toEqual({
|
|
kind: 'github',
|
|
owner: 'owner',
|
|
repo: 'repo',
|
|
ref: { kind: 'branch', value: 'main' },
|
|
});
|
|
});
|
|
|
|
it('drops query and fragment from /tree/<ref>', () => {
|
|
const result = resolveInstallSource('https://github.com/owner/repo/tree/main?x=1#y');
|
|
expect(result).toEqual({
|
|
kind: 'github',
|
|
owner: 'owner',
|
|
repo: 'repo',
|
|
ref: { kind: 'branch', value: 'main' },
|
|
});
|
|
});
|
|
|
|
it('accepts /releases/tag/<tag> as a tag-kind ref', () => {
|
|
const result = resolveInstallSource(
|
|
'https://github.com/obra/superpowers/releases/tag/v5.1.0',
|
|
);
|
|
expect(result).toEqual({
|
|
kind: 'github',
|
|
owner: 'obra',
|
|
repo: 'superpowers',
|
|
ref: { kind: 'tag', value: 'v5.1.0' },
|
|
});
|
|
});
|
|
|
|
it('accepts /commit/<sha> as a sha-kind ref', () => {
|
|
const result = resolveInstallSource(
|
|
'https://github.com/obra/superpowers/commit/45b441d62b81b5f27d3bfd8700e04436cd4de5b3',
|
|
);
|
|
expect(result).toEqual({
|
|
kind: 'github',
|
|
owner: 'obra',
|
|
repo: 'superpowers',
|
|
ref: {
|
|
kind: 'sha',
|
|
value: '45b441d62b81b5f27d3bfd8700e04436cd4de5b3',
|
|
},
|
|
});
|
|
});
|
|
|
|
it('does not recognize /archive/refs/tags/X.zip as github source (falls through to zip-url)', () => {
|
|
const url = 'https://github.com/obra/superpowers/archive/refs/tags/v5.1.0.zip';
|
|
const result = resolveInstallSource(url);
|
|
expect(result).toEqual({ kind: 'zip-url', path: url });
|
|
});
|
|
|
|
it('does not recognize /archive/refs/heads/main.zip as github source (falls through to zip-url)', () => {
|
|
const url = 'https://github.com/obra/superpowers/archive/refs/heads/main.zip';
|
|
const result = resolveInstallSource(url);
|
|
expect(result).toEqual({ kind: 'zip-url', path: url });
|
|
});
|
|
|
|
it('treats http:// (non-https) github URL as plain zip-url', () => {
|
|
const url = 'http://github.com/wbxl2000/superpowers';
|
|
const result = resolveInstallSource(url);
|
|
expect(result).toEqual({ kind: 'zip-url', path: url });
|
|
});
|
|
|
|
it('percent-decodes %23 in /releases/tag/ so storage is human-readable', () => {
|
|
// Git allows `#` in tag names. GitHub UI URLs encode it as %23.
|
|
const result = resolveInstallSource(
|
|
'https://github.com/owner/repo/releases/tag/release%231',
|
|
);
|
|
expect(result).toEqual({
|
|
kind: 'github',
|
|
owner: 'owner',
|
|
repo: 'repo',
|
|
ref: { kind: 'tag', value: 'release#1' },
|
|
});
|
|
});
|
|
|
|
it('percent-decodes /tree/<encoded> ref values', () => {
|
|
const result = resolveInstallSource(
|
|
'https://github.com/owner/repo/tree/feat%231',
|
|
);
|
|
expect(result).toEqual({
|
|
kind: 'github',
|
|
owner: 'owner',
|
|
repo: 'repo',
|
|
ref: { kind: 'branch', value: 'feat#1' },
|
|
});
|
|
});
|
|
|
|
it('preserves slashes when decoding multi-segment refs (e.g. feat/foo with %20 in middle)', () => {
|
|
const result = resolveInstallSource(
|
|
'https://github.com/owner/repo/tree/feat/has%20space',
|
|
);
|
|
expect(result).toEqual({
|
|
kind: 'github',
|
|
owner: 'owner',
|
|
repo: 'repo',
|
|
ref: { kind: 'branch', value: 'feat/has space' },
|
|
});
|
|
});
|
|
|
|
it('keeps malformed percent-encoding verbatim instead of crashing', () => {
|
|
// `%ZZ` is invalid; decodeURIComponent throws. Don't propagate.
|
|
const result = resolveInstallSource(
|
|
'https://github.com/owner/repo/tree/bad%ZZname',
|
|
);
|
|
expect(result).toEqual({
|
|
kind: 'github',
|
|
owner: 'owner',
|
|
repo: 'repo',
|
|
ref: { kind: 'branch', value: 'bad%ZZname' },
|
|
});
|
|
});
|
|
});
|
|
});
|