Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/tidy-gitlab-plugins.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

Install plugins from GitLab.com and self-managed GitLab repository URLs with `/plugins install <url>`.
Original file line number Diff line number Diff line change
Expand Up @@ -720,7 +720,9 @@ export class PluginsPanelComponent extends Container implements Focusable {

private renderCustom(lines: string[], width: number): void {
const colors = currentTheme.palette;
lines.push(mutedHintLine(' Install from a GitHub URL (or zip URL / local path):', colors));
lines.push(
mutedHintLine(' Install from a GitHub or GitLab URL (or zip URL / local path):', colors),
);
lines.push('');
lines.push(...renderUrlInputBox(this.customInput, this.focused, width, colors));
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -482,7 +482,7 @@ describe('plugins selector dialogs', () => {
it('installs from a URL typed on the Custom tab', () => {
const { panel, onSelect } = makePanel({ initialTab: 'custom' });
const out = strip(renderRaw(panel));
expect(out).toContain('Install from a GitHub URL');
expect(out).toContain('Install from a GitHub or GitLab URL');
expect(out).toContain('╭');

for (const ch of 'https://github.com/owner/repo') {
Expand Down
16 changes: 13 additions & 3 deletions docs/en/customization/plugins.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ You can also use slash commands directly:
| --- | --- |
| `/plugins` | Open the interactive plugin manager |
| `/plugins list` | List installed plugins |
| `/plugins install <path-or-url>` | Install from a local directory, zip URL, or GitHub repository URL |
| `/plugins install <path-or-url>` | Install from a local directory, zip URL, or GitHub/GitLab repository URL |
| `/plugins marketplace [source]` | Browse the official marketplace, or pass a custom marketplace JSON path or URL |
| `/plugins info <id>` | View plugin details and diagnostics |
| `/plugins enable <id>` | Enable a plugin |
Expand All @@ -46,6 +46,17 @@ Use `/plugins install <url>` to install directly from a GitHub repository. Four

Network requests only go through `github.com` redirects and `codeload.github.com` downloads; `api.github.com` is not called.

### Installing from GitLab

Use `/plugins install <url>` to install from GitLab.com or a self-managed GitLab instance served from the root of its origin. Four URL forms are supported:

- `https://gitlab.example.com/<namespace>/<project>`: Install the latest release; falls back to the default branch if no release exists
- `https://gitlab.example.com/<namespace>/<project>/-/tree/<ref>`: Install a specific branch, tag, or short commit SHA
- `https://gitlab.example.com/<namespace>/<project>/-/releases/<tag>`: Pin to a specific tag
- `https://gitlab.example.com/<namespace>/<project>/-/commit/<sha>`: Pin to a specific commit

GitLab.com and self-managed hostnames containing `gitlab` are detected automatically. For a self-managed instance with a different hostname, use its HTTPS clone URL ending in `.git`, such as `https://code.example.com/<namespace>/<project>.git`. Self-managed instances configured with a relative URL root, such as `https://code.example.com/gitlab`, are not currently supported. GitLab downloads use the selected instance's `/api/v4` release and repository archive endpoints.

### Notes

- Plugin changes apply after `/reload` or in new sessions. After installing, enabling/disabling, or removing a plugin, run `/reload` or `/new`; the current session will not update.
Expand All @@ -55,7 +66,7 @@ Network requests only go through `github.com` redirects and `codeload.github.com

### Custom marketplace JSON

Pass a custom marketplace JSON path or URL to `/plugins marketplace <source>`, or set [`KIMI_CODE_PLUGIN_MARKETPLACE_URL`](../configuration/env-vars.md) to override the default catalog. Each entry in the `plugins` array needs an `id` and a `source` (local path, zip URL, or GitHub URL):
Pass a custom marketplace JSON path or URL to `/plugins marketplace <source>`, or set [`KIMI_CODE_PLUGIN_MARKETPLACE_URL`](../configuration/env-vars.md) to override the default catalog. Each entry in the `plugins` array needs an `id` and a `source` (local path, zip URL, or GitHub/GitLab URL):

```json
{
Expand Down Expand Up @@ -316,4 +327,3 @@ Plugins have a limited loading scope. The following operations do not occur duri
- All paths must remain within the plugin root directory after symbolic link resolution
- MCP servers of enabled plugins start after `/reload` or in new sessions and can be disabled at any time from `/plugins`
- Broken manifests or unsafe paths appear in `/plugins info <id>` diagnostics and do not affect other sessions

16 changes: 13 additions & 3 deletions docs/zh/customization/plugins.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ Plugins 把可复用的 Kimi Code CLI 能力打包成可安装单元——可以
| --- | --- |
| `/plugins` | 打开交互式 plugin 管理器 |
| `/plugins list` | 列出已安装 plugins |
| `/plugins install <path-or-url>` | 从本地目录、zip URL 或 GitHub 仓库 URL 安装 |
| `/plugins install <path-or-url>` | 从本地目录、zip URL 或 GitHub/GitLab 仓库 URL 安装 |
| `/plugins marketplace [source]` | 浏览官方 marketplace,或传入自定义 marketplace JSON 的路径或 URL |
| `/plugins info <id>` | 查看 plugin 详情和 diagnostics |
| `/plugins enable <id>` | 启用 plugin |
Expand All @@ -46,6 +46,17 @@ Plugins 把可复用的 Kimi Code CLI 能力打包成可安装单元——可以

网络请求只走 `github.com` 重定向和 `codeload.github.com` 下载,不调用 `api.github.com`。

### 从 GitLab 安装

通过 `/plugins install <url>` 可以从 GitLab.com 或部署在站点根路径的自托管 GitLab 实例安装,支持四种 URL 形式:

- `https://gitlab.example.com/<namespace>/<project>`:安装最新 release;无 release 时回落到默认分支
- `https://gitlab.example.com/<namespace>/<project>/-/tree/<ref>`:安装指定分支、tag 或短 commit SHA
- `https://gitlab.example.com/<namespace>/<project>/-/releases/<tag>`:钉死具体 tag
- `https://gitlab.example.com/<namespace>/<project>/-/commit/<sha>`:钉死具体 commit

GitLab.com 和主机名中含 `gitlab` 的自托管实例会被自动识别。若自托管实例使用其他主机名,请使用以 `.git` 结尾的 HTTPS 克隆 URL,例如 `https://code.example.com/<namespace>/<project>.git`。暂不支持配置了相对 URL 根路径(例如 `https://code.example.com/gitlab`)的自托管实例。GitLab 下载只调用所选实例的 `/api/v4` release 与仓库归档端点。

### 注意事项

- Plugin 变更需要通过 `/reload` 或新会话生效。安装、启用/禁用、移除后,运行 `/reload` 或 `/new`;当前会话不会更新。
Expand All @@ -55,7 +66,7 @@ Plugins 把可复用的 Kimi Code CLI 能力打包成可安装单元——可以

### 自定义 marketplace JSON

浏览自定义目录时,把 JSON 路径或 URL 传给 `/plugins marketplace <source>`;或通过 [`KIMI_CODE_PLUGIN_MARKETPLACE_URL`](../configuration/env-vars.md) 覆盖默认 marketplace。`plugins` 数组中每个条目需要 `id` 和 `source`(本地路径、zip URL 或 GitHub URL):
浏览自定义目录时,把 JSON 路径或 URL 传给 `/plugins marketplace <source>`;或通过 [`KIMI_CODE_PLUGIN_MARKETPLACE_URL`](../configuration/env-vars.md) 覆盖默认 marketplace。`plugins` 数组中每个条目需要 `id` 和 `source`(本地路径、zip URL 或 GitHub/GitLab URL):

```json
{
Expand Down Expand Up @@ -316,4 +327,3 @@ Plugin 的加载范围有限,以下操作不会在安装或会话启动时发
- 所有路径在解析符号链接后仍必须位于 plugin 根目录内
- 已启用 plugin 的 MCP servers 会在 `/reload` 后或新会话中启动,且可随时从 `/plugins` 禁用
- 损坏的 manifest 或不安全路径会显示在 `/plugins info <id>` 的 diagnostics 中,不影响其他会话

70 changes: 70 additions & 0 deletions packages/agent-core-v2/src/app/plugin/gitlab-resolver.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
/**
* `plugin` domain (L3) — resolves GitLab plugin sources through instance APIs.
*
* Selects the latest release or requested ref and produces repository archive
* URLs for GitLab.com and self-managed GitLab instances.
*/

import type { GitlabRef } from './source';

export interface GitlabSourceInput {
readonly kind: 'gitlab';
readonly baseUrl: string;
readonly projectPath: string;
readonly ref?: GitlabRef;
}

export interface GitlabSourceResolution {
readonly tarballUrl: string;
}

export async function resolveGitlabSource(
input: GitlabSourceInput,
): Promise<GitlabSourceResolution> {
if (input.ref !== undefined) {
return { tarballUrl: archiveUrl(input, input.ref) };
}

const latestTag = await tryResolveLatestReleaseTag(input);
return {
tarballUrl:
latestTag === undefined
? archiveUrl(input)
: archiveUrl(input, { kind: 'tag', value: latestTag }),
};
}

async function tryResolveLatestReleaseTag(
input: GitlabSourceInput,
): Promise<string | undefined> {
const projectId = encodeURIComponent(input.projectPath);
const url = `${input.baseUrl}/api/v4/projects/${projectId}/releases/permalink/latest`;
const resp = await fetch(url, { signal: AbortSignal.timeout(10_000) });
if (resp.status === 404) return undefined;
if (!resp.ok) {
throw new Error(
`Could not look up latest release of \`${input.projectPath}\` on ${input.baseUrl}: ` +
`HTTP ${resp.status} ${resp.statusText}.`,
);
}

const release = (await resp.json()) as { tag_name?: unknown };
if (typeof release.tag_name !== 'string' || release.tag_name.length === 0) {
throw new Error(
`Could not determine the latest release tag of \`${input.projectPath}\` on ${input.baseUrl}.`,
);
}
return release.tag_name;
}

function archiveUrl(input: GitlabSourceInput, ref?: GitlabRef): string {
const projectId = encodeURIComponent(input.projectPath);
const url = new URL(
`${input.baseUrl}/api/v4/projects/${projectId}/repository/archive.zip`,
);
if (ref !== undefined) {
url.searchParams.set('sha', ref.value);
if (ref.kind === 'tag') url.searchParams.set('ref_type', 'tags');
}
return url.toString();
}
5 changes: 4 additions & 1 deletion packages/agent-core-v2/src/app/plugin/manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import type { SkillRoot } from '#/app/skillCatalog/types';
import { downloadZip, extractZip } from './archive';
import { loadPluginCommand } from './commands';
import { resolveGithubCommitSha, resolveGithubSource } from './github-resolver';
import { resolveGitlabSource } from './gitlab-resolver';
import { resolveInstallSource } from './source';
import { parseManifest, type ParsedManifestResult } from './manifest';
import { readInstalled, writeInstalled, type InstalledRecord } from './store';
Expand Down Expand Up @@ -114,7 +115,9 @@ export class PluginManager {
}
return resolution.tarballUrl;
})()
: resolved.path;
: resolved.kind === 'gitlab'
? (await resolveGitlabSource(resolved)).tarballUrl
: resolved.path;
const buffer = await downloadZip(zipUrl);
zipTmpDir = await mkdtemp(path.join(tmpdir(), 'kimi-plugin-zip-'));
sourceRoot = await extractZip(buffer, zipTmpDir);
Expand Down
67 changes: 66 additions & 1 deletion packages/agent-core-v2/src/app/plugin/source.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,13 @@ export interface GithubRef {
readonly value: string;
}

export type GitlabRef = GithubRef;

export type ResolvedSource =
| { kind: 'local-path'; path: string }
| { kind: 'zip-url'; path: string }
| { kind: 'github'; owner: string; repo: string; ref?: GithubRef };
| { kind: 'github'; owner: string; repo: string; ref?: GithubRef }
| { kind: 'gitlab'; baseUrl: string; projectPath: string; ref?: GitlabRef };

export type InstallSource = ResolvedSource;

Expand All @@ -20,6 +23,9 @@ export function resolveInstallSource(source: string): ResolvedSource {
const github = parseGithubUrl(trimmed);
if (github !== undefined) return github;

const gitlab = parseGitlabUrl(trimmed);
if (gitlab !== undefined) return gitlab;

if (trimmed.startsWith('http://') || trimmed.startsWith('https://')) {
return { kind: 'zip-url', path: trimmed };
}
Expand All @@ -29,6 +35,65 @@ export function resolveInstallSource(source: string): ResolvedSource {
return { kind: 'local-path', path: trimmed };
}

function parseGitlabUrl(raw: string): ResolvedSource | undefined {
let url: URL;
try {
url = new URL(raw);
} catch {
return undefined;
}
if (url.protocol !== 'https:') return undefined;

const segments = url.pathname.split('/').filter((segment) => segment.length > 0);
const separatorIndex = segments.indexOf('-');
const projectSegments = separatorIndex === -1 ? segments : segments.slice(0, separatorIndex);
const rest = separatorIndex === -1 ? [] : segments.slice(separatorIndex + 1);
const repoRaw = projectSegments.at(-1);
if (projectSegments.length < 2 || repoRaw === undefined) return undefined;

const hasGitSuffix = repoRaw.endsWith('.git');
const isGitlabHost = url.hostname.toLowerCase().includes('gitlab');
if (!isGitlabHost && separatorIndex === -1 && !hasGitSuffix) return undefined;
if (separatorIndex === -1 && (segments[0] === 'api' || repoRaw.endsWith('.zip'))) {
return undefined;
}

const repo = hasGitSuffix ? repoRaw.slice(0, -4) : repoRaw;
const projectPath = decodeRefSegments([...projectSegments.slice(0, -1), repo]);
const base = { kind: 'gitlab' as const, baseUrl: url.origin, projectPath };
Comment on lines +62 to +63

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve self-managed GitLab path prefixes

When a self-managed instance is served below a relative URL, such as https://code.example.com/gitlab, its documented HTTPS clone URL https://code.example.com/gitlab/team/sample.git is parsed with baseUrl reduced to the origin and gitlab folded into projectPath. The resolver consequently requests /api/v4/projects/gitlab%2Fteam%2Fsample instead of the instance endpoint /gitlab/api/v4/projects/team%2Fsample, so these supported self-managed installs fail. Preserve or otherwise resolve the instance path prefix; the legacy parser has the same issue.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for catching this. Supporting arbitrary relative URL roots requires distinguishing the instance path prefix from the project namespace, which cannot be determined unambiguously from the clone URL alone. To keep this PR small and its support scope clear, cdfbfab narrows the English and Chinese docs to self-managed GitLab instances served from the origin root and explicitly notes that relative URL roots are not currently supported. Full relative-root discovery/support can be handled separately.


if (rest.length === 0) return base;

const head = rest[0];
if (head === 'tree' && rest.length >= 2) {
const value = decodeRefSegments(rest.slice(1));
const kind: GitlabRef['kind'] = SHA_RE.test(value) ? 'sha' : 'branch';
return { ...base, ref: { kind, value } };
}

if (head === 'releases') {
if (rest.length === 3 && rest[1] === 'permalink' && rest[2] === 'latest') {
return base;
}
if (rest.length === 2) {
return {
...base,
ref: { kind: 'tag', value: decodeRefSegments(rest.slice(1)) },
};
}
return undefined;
}

if (head === 'commit' && rest.length >= 2) {
return {
...base,
ref: { kind: 'sha', value: decodeRefSegments(rest.slice(1)) },
};
}

return undefined;
}

function parseGithubUrl(raw: string): ResolvedSource | undefined {
let url: URL;
try {
Expand Down
68 changes: 68 additions & 0 deletions packages/agent-core-v2/test/app/plugin/gitlab-resolver.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
/**
* Scenario: GitLab plugin source resolution through instance API endpoints.
* Responsibilities: build archive URLs for explicit refs, select the latest
* release, and fall back to the default branch when no release exists.
* Wiring: real resolver with only the external `fetch` boundary stubbed.
* Run: pnpm --filter @moonshot-ai/agent-core-v2 exec vitest run test/app/plugin/gitlab-resolver.test.ts
*/

import { afterEach, describe, expect, it, vi } from 'vitest';

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3 Badge Add the required test scenario header

The applicable agent-core-review/test guidance requires every new v2 test file to start with a header describing the scenario, responsibilities, real versus stubbed wiring, and the command to run it. This new file starts directly with imports, so add that header for consistency with the v2 test contract.

Useful? React with 👍 / 👎.


import { resolveGitlabSource } from '#/app/plugin/gitlab-resolver';

describe('resolveGitlabSource', () => {
afterEach(() => {
vi.unstubAllGlobals();
});

it('builds an archive URL directly for an explicit ref', async () => {
const fetch = vi.fn();
vi.stubGlobal('fetch', fetch);

await expect(
resolveGitlabSource({
kind: 'gitlab',
baseUrl: 'https://gitlab.example.com',
projectPath: 'team/plugins/sample',
ref: { kind: 'tag', value: 'release#1' },
}),
).resolves.toEqual({
tarballUrl:
'https://gitlab.example.com/api/v4/projects/team%2Fplugins%2Fsample/repository/archive.zip?sha=release%231&ref_type=tags',
});
expect(fetch).not.toHaveBeenCalled();
});

it('uses the latest release for a bare repository URL', async () => {
const fetch = vi

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3 Badge Split release and fallback resolver scenarios

This test verifies two distinct behaviors—selecting a latest release and falling back after a 404—using ordered mock responses in one it, contrary to the applicable one-behavior-per-test rule. Split them so each failure identifies the broken contract directly and neither scenario depends on the other consuming the first mocked response.

Useful? React with 👍 / 👎.

.fn()
.mockResolvedValue(new Response(JSON.stringify({ tag_name: 'v1.2.3' }), { status: 200 }));
vi.stubGlobal('fetch', fetch);

await expect(
resolveGitlabSource({
kind: 'gitlab',
baseUrl: 'https://gitlab.example.com',
projectPath: 'team/sample',
}),
).resolves.toEqual({
tarballUrl:
'https://gitlab.example.com/api/v4/projects/team%2Fsample/repository/archive.zip?sha=v1.2.3&ref_type=tags',
});
});

it('falls back to the default branch when the project has no release', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(null, { status: 404 })));

await expect(
resolveGitlabSource({
kind: 'gitlab',
baseUrl: 'https://gitlab.example.com',
projectPath: 'team/sample',
}),
).resolves.toEqual({
tarballUrl:
'https://gitlab.example.com/api/v4/projects/team%2Fsample/repository/archive.zip',
});
});
});
31 changes: 31 additions & 0 deletions packages/agent-core-v2/test/app/plugin/manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,37 @@ describe('PluginManager', () => {
}
});

it('installs a plugin from a GitLab release URL', async () => {
const sourceRoot = await mkdtemp(join(tmpdir(), 'plugin-gitlab-source-'));
const zipPath = join(tmpdir(), `plugin-gitlab-${Date.now()}.zip`);
try {
await writeFile(
join(sourceRoot, 'kimi.plugin.json'),
JSON.stringify({ name: 'gitlab-plugin' }),
'utf8',
);
execFileSync('zip', ['-qr', zipPath, '.'], { cwd: sourceRoot });
const zip = await readFile(zipPath);
const fetchMock = vi.fn(async () => new Response(zip));
vi.stubGlobal('fetch', fetchMock as typeof fetch);
const manager = new PluginManager({ kimiHomeDir: home });
const source = 'https://gitlab.example.com/team/sample/-/releases/v1.2.3';

const record = await manager.install(source);

expect(record.id).toBe('gitlab-plugin');
expect(record.source).toBe('zip-url');
expect(record.originalSource).toBe(source);
expect(fetchMock).toHaveBeenCalledWith(
'https://gitlab.example.com/api/v4/projects/team%2Fsample/repository/archive.zip?sha=v1.2.3&ref_type=tags',
expect.objectContaining({ signal: expect.any(AbortSignal) }),
);
} finally {
await rm(sourceRoot, { recursive: true, force: true });
await rm(zipPath, { force: true });
}
});

it('installs a github plugin through codeload', async () => {
const sourceRoot = await mkdtemp(join(tmpdir(), 'plugin-github-source-'));
const zipPath = join(tmpdir(), `plugin-github-${Date.now()}.zip`);
Expand Down
Loading