feat(agent-core-v2): add plugin management and consumption plane

- parse kimi.plugin.json / .kimi-plugin/plugin.json manifests
  (skills, sessionStart, mcpServers, hooks, commands)
- install plugins from local paths, zip URLs, and GitHub refs via
  the manager, store, source, archive, and github-resolver modules
- load plugin slash commands from .md files with $ARGUMENTS expansion
- register IPluginService/PluginService (App scope) exposing management
  plus consumption planes: skill roots, session starts, MCP servers, hooks
- add session-start context injector and RPC prompt metadata
- cover manifest, manager, source, archive, github-resolver, commands,
  and session-start injection with tests
This commit is contained in:
haozhe.yang 2026-07-01 11:52:35 +08:00
parent 97874de529
commit cc3422c77a
22 changed files with 3225 additions and 6 deletions

View file

@ -0,0 +1,158 @@
/**
* `contextInjector` domain (L4) plugin session-start reminder injection.
*
* Production equivalent of v1's `agent/injection/plugin-session-start.ts`.
* Registers a turn-cadence `plugin_session_start` injection with
* `IAgentContextInjectorService` so enabled plugins' `sessionStart` skills are
* rendered into the main agent's context once per turn (deduped against
* replayed history by the context-injector). On `IPluginService.onDidReload`,
* force-appends a fresh reminder or a neutralizing reminder when no session
* start is resolvable but stale guidance may linger mirroring the `/reload`
* re-injection flow.
*/
import { Disposable } from '#/_base/di/lifecycle';
import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation';
import { InstantiationType } from '#/_base/di/extensions';
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
import { escapeXmlAttr } from '#/_base/utils/xml-escape';
import { IAgentContextMemoryService } from '#/contextMemory';
import { ILogService } from '#/log';
import { IPluginService } from '#/plugin';
import type { EnabledPluginSessionStart } from '#/plugin/types';
import { ISessionSkillCatalog } from '#/skill';
import type { SkillCatalog, SkillDefinition } from '#/skill/types';
import { IAgentSystemReminderService } from '#/systemReminder';
import { IAgentContextInjectorService } from './contextInjector';
const INJECTION_VARIANT = 'plugin_session_start';
export interface IPluginSessionStartInjectorService {
readonly _serviceBrand: undefined;
}
export const IPluginSessionStartInjectorService: ServiceIdentifier<IPluginSessionStartInjectorService> =
createDecorator<IPluginSessionStartInjectorService>('pluginSessionStartInjectorService');
export class PluginSessionStartInjectorService
extends Disposable
implements IPluginSessionStartInjectorService
{
declare readonly _serviceBrand: undefined;
constructor(
@IAgentContextInjectorService private readonly injector: IAgentContextInjectorService,
@IAgentSystemReminderService private readonly reminders: IAgentSystemReminderService,
@IAgentContextMemoryService private readonly context: IAgentContextMemoryService,
@IPluginService private readonly plugins: IPluginService,
@ISessionSkillCatalog private readonly skillCatalog: ISessionSkillCatalog,
@ILogService private readonly log: ILogService,
) {
super();
this._register(
this.injector.register(INJECTION_VARIANT, () => this.renderReminder(), { cadence: 'turn' }),
);
this._register(
this.plugins.onDidReload(() => {
void this.appendReminderOnReload();
}),
);
}
private async renderReminder(): Promise<string | undefined> {
const sessionStarts = await this.plugins.enabledSessionStarts();
if (sessionStarts.length === 0) return undefined;
await this.skillCatalog.ready;
return renderPluginSessionStartReminder({
sessionStarts,
catalog: this.skillCatalog.catalog,
log: this.log,
});
}
private async appendReminderOnReload(): Promise<void> {
const sessionStarts = await this.plugins.enabledSessionStarts();
await this.skillCatalog.ready;
const reminder = renderPluginSessionStartReminder({
sessionStarts,
catalog: this.skillCatalog.catalog,
log: this.log,
});
if (reminder !== undefined) {
this.reminders.appendSystemReminder(
`${reminder}\n\nThis supersedes any earlier plugin_session_start reminder in this session.`,
{ kind: 'injection', variant: INJECTION_VARIANT },
);
} else if (shouldNeutralizePluginSessionStart(this.context.get())) {
this.reminders.appendSystemReminder(
'There are currently no active plugin session starts. ' +
'This supersedes any earlier plugin_session_start reminder in this session.',
{ kind: 'injection', variant: INJECTION_VARIANT },
);
}
}
}
export interface RenderPluginSessionStartReminderInput {
readonly sessionStarts: readonly EnabledPluginSessionStart[];
readonly catalog: SkillCatalog | undefined;
readonly log?: { warn(message: string, payload?: unknown): void };
}
export function renderPluginSessionStartReminder(
input: RenderPluginSessionStartReminderInput,
): string | undefined {
const { sessionStarts, catalog, log } = input;
if (sessionStarts.length === 0) return undefined;
if (catalog === undefined) return undefined;
const blocks: string[] = [];
for (const sessionStart of sessionStarts) {
const skill = catalog.getPluginSkill(sessionStart.pluginId, sessionStart.skillName);
if (skill === undefined) {
log?.warn('plugin sessionStart skill not found', {
pluginId: sessionStart.pluginId,
skillName: sessionStart.skillName,
});
continue;
}
blocks.push(renderSessionStartBlock(sessionStart, skill, catalog.renderSkillPrompt(skill, '')));
}
return blocks.length > 0 ? blocks.join('\n') : undefined;
}
/**
* True when the context may still carry stale plugin guidance an earlier
* `<plugin_session_start>` reminder or a compaction summary that may have
* folded one in so a neutralizing reminder should replace it.
*/
export function shouldNeutralizePluginSessionStart(
history: readonly { readonly origin?: { readonly kind: string; readonly variant?: string } }[],
): boolean {
return history.some((message) => {
const kind = message.origin?.kind;
if (kind === 'injection') {
return message.origin?.variant === INJECTION_VARIANT;
}
return kind === 'compaction_summary';
});
}
function renderSessionStartBlock(
sessionStart: EnabledPluginSessionStart,
skill: SkillDefinition,
skillContent: string,
): string {
return (
`<plugin_session_start plugin="${escapeXmlAttr(sessionStart.pluginId)}" ` +
`skill="${escapeXmlAttr(skill.name)}">\n${skillContent}\n</plugin_session_start>`
);
}
registerScopedService(
LifecycleScope.Agent,
IPluginSessionStartInjectorService,
PluginSessionStartInjectorService,
InstantiationType.Delayed,
'contextInjector',
);

View file

@ -0,0 +1,148 @@
import { createWriteStream } from 'node:fs';
import { chmod, mkdir, readdir, stat } from 'node:fs/promises';
import path from 'node:path';
import { pipeline } from 'node:stream/promises';
import { type Entry, fromBuffer as yauzlFromBuffer } from 'yauzl';
export async function downloadZip(url: string, signal?: AbortSignal): Promise<Buffer> {
const controller = new AbortController();
const timeoutHandle = setTimeout(() => {
controller.abort();
}, 5 * 60 * 1000);
try {
const resp = await fetch(url, { signal: signal ?? controller.signal });
if (!resp.ok) {
throw new Error(`Failed to download zip: HTTP ${resp.status} ${resp.statusText}`);
}
return Buffer.from(await resp.arrayBuffer());
} finally {
clearTimeout(timeoutHandle);
}
}
export async function extractZip(buffer: Buffer, destDir: string): Promise<string> {
await mkdir(destDir, { recursive: true });
const destDirResolved = path.resolve(destDir);
let settled = false;
await new Promise<void>((resolve, reject) => {
yauzlFromBuffer(buffer, { lazyEntries: true }, (openErr, zipfile) => {
if (openErr !== null || zipfile === undefined) {
reject(new Error(`Failed to open zip: ${openErr?.message ?? 'unknown error'}`));
return;
}
const onEntry = (entry: Entry): void => {
const fileName = entry.fileName;
const destPath = path.resolve(destDir, fileName);
if (destPath !== destDirResolved && !destPath.startsWith(destDirResolved + path.sep)) {
if (!settled) {
settled = true;
reject(new Error(`Path traversal detected in zip entry: ${fileName}`));
}
zipfile.close();
return;
}
if (fileName.endsWith('/')) {
mkdir(destPath, { recursive: true })
.then(() => {
zipfile.readEntry();
})
.catch((error) => {
if (!settled) {
settled = true;
reject(error);
}
zipfile.close();
});
return;
}
zipfile.openReadStream(entry, (streamErr, stream) => {
if (streamErr !== null || stream === undefined) {
if (!settled) {
settled = true;
reject(
new Error(
`Failed to read ${fileName} from archive: ${streamErr?.message ?? 'unknown error'}`,
),
);
}
zipfile.close();
return;
}
mkdir(path.dirname(destPath), { recursive: true })
.then(() => pipeline(stream, createWriteStream(destPath)))
.then(() => restoreFilePermissions(destPath, entry))
.then(() => {
zipfile.readEntry();
})
.catch((error) => {
if (!settled) {
settled = true;
reject(error);
}
zipfile.close();
});
});
};
zipfile.on('entry', onEntry);
zipfile.on('end', () => {
if (!settled) {
settled = true;
resolve();
}
});
zipfile.on('error', (err: Error) => {
if (!settled) {
settled = true;
reject(err);
}
});
zipfile.readEntry();
});
});
return detectPluginRoot(destDir);
}
async function restoreFilePermissions(destPath: string, entry: Entry): Promise<void> {
const mode = entry.externalFileAttributes >>> 16;
if (mode === 0) return;
const permissions = mode & 0o777;
if (permissions === 0) return;
await chmod(destPath, permissions);
}
async function detectPluginRoot(dir: string): Promise<string> {
if (await hasManifest(dir)) return dir;
const entries = await readdir(dir, { withFileTypes: true });
const childDirs = entries.filter((entry) => entry.isDirectory());
const childDir = childDirs.length === 1 ? childDirs[0] : undefined;
if (childDir !== undefined) {
const child = path.join(dir, childDir.name);
if (await hasManifest(child)) return child;
}
return dir;
}
async function hasManifest(dir: string): Promise<boolean> {
const rootManifest = path.join(dir, 'kimi.plugin.json');
const dirManifest = path.join(dir, '.kimi-plugin', 'plugin.json');
return (await isFile(rootManifest)) || (await isFile(dirManifest));
}
async function isFile(p: string): Promise<boolean> {
try {
return (await stat(p)).isFile();
} catch {
return false;
}
}

View file

@ -0,0 +1,79 @@
import { readFile } from 'node:fs/promises';
import path from 'node:path';
import { parseFrontmatter } from '#/skill/parser';
import type { PluginCommandDef } from './types';
export function parseCommandText(input: {
readonly text: string;
readonly commandPath: string;
readonly pluginId: string;
readonly fallbackName?: string;
}): PluginCommandDef {
const { text, commandPath, pluginId } = input;
const parsed = parseFrontmatter(text);
const frontmatter = isRecord(parsed.data) ? parsed.data : {};
const baseName = input.fallbackName ?? path.basename(commandPath).replace(/\.md$/i, '');
const name = nonEmptyString(frontmatter['name']) ?? baseName;
const body = parsed.body.trim();
const description = nonEmptyString(frontmatter['description']) ?? descriptionFromBody(body);
return {
pluginId,
name,
description,
body,
path: path.resolve(commandPath),
};
}
export async function loadPluginCommand(input: {
readonly commandPath: string;
readonly pluginId: string;
readonly fallbackName?: string;
}): Promise<PluginCommandDef | undefined> {
try {
const text = await readFile(input.commandPath, 'utf8');
return parseCommandText({
text,
commandPath: input.commandPath,
pluginId: input.pluginId,
fallbackName: input.fallbackName,
});
} catch {
return undefined;
}
}
/**
* Expand `$ARGUMENTS` placeholders in a plugin command body with the typed args.
* If the body has no placeholder but args are present, append them so nothing
* is silently dropped.
*/
export function expandCommandArguments(body: string, args: string): string {
const replaced = body.replaceAll('$ARGUMENTS', args);
if (!body.includes('$ARGUMENTS') && args.length > 0) {
return `${replaced}\n\nARGUMENTS: ${args}`;
}
return replaced;
}
function nonEmptyString(value: unknown): string | undefined {
return typeof value === 'string' && value.trim() !== '' ? value.trim() : undefined;
}
function descriptionFromBody(body: string): string {
const firstLine = body
.split(/\r?\n/)
.map((line) => line.trim())
.find((line) => line.length > 0);
if (firstLine === undefined) return 'No description provided.';
return firstLine.length > 240 ? `${firstLine.slice(0, 239)}` : firstLine;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}

View file

@ -0,0 +1,100 @@
import type { GithubRef } from './source';
import type { PluginGithubRef } from './types';
export interface GithubSourceInput {
readonly kind: 'github';
readonly owner: string;
readonly repo: string;
readonly ref?: GithubRef;
}
export interface GithubSourceResolution {
readonly tarballUrl: string;
readonly displayVersion: string;
readonly ref: PluginGithubRef;
}
/**
* Resolve a `github` source descriptor to a downloadable zip URL.
*
* Hot path is the bare-URL case (no explicit ref). We deliberately avoid
* `api.github.com` because its anonymous quota is shared with the user's
* browser, gh CLI, IDE integrations, etc.
*/
export async function resolveGithubSource(
input: GithubSourceInput,
): Promise<GithubSourceResolution> {
const { owner, repo } = input;
if (input.ref !== undefined) {
return {
tarballUrl: codeloadUrl(owner, repo, input.ref),
displayVersion: input.ref.value,
ref: { kind: input.ref.kind, value: input.ref.value },
};
}
const latestTag = await tryResolveLatestReleaseTag(owner, repo);
if (latestTag !== undefined) {
return {
tarballUrl: codeloadUrl(owner, repo, { kind: 'tag', value: latestTag }),
displayVersion: latestTag,
ref: { kind: 'tag', value: latestTag },
};
}
const headProbe = await fetch(`https://codeload.github.com/${owner}/${repo}/zip/HEAD`, {
method: 'HEAD',
});
if (headProbe.status === 404) {
throw new Error(`Repository \`${owner}/${repo}\` not found or not accessible.`);
}
if (!headProbe.ok) {
throw new Error(
`Could not access \`${owner}/${repo}\`: HTTP ${headProbe.status} ${headProbe.statusText}.`,
);
}
return {
tarballUrl: `https://codeload.github.com/${owner}/${repo}/zip/HEAD`,
displayVersion: 'HEAD',
ref: { kind: 'branch', value: 'HEAD' },
};
}
async function tryResolveLatestReleaseTag(owner: string, repo: string): Promise<string | undefined> {
const url = `https://github.com/${owner}/${repo}/releases/latest`;
const resp = await fetch(url, { redirect: 'manual' });
if (resp.status === 404) return undefined;
if (resp.status !== 301 && resp.status !== 302) {
throw new Error(
`Could not look up latest release of \`${owner}/${repo}\`: ` +
`HTTP ${resp.status} ${resp.statusText} (${url}). ` +
`Pin a specific ref with \`/tree/<branch|tag|sha>\` to bypass release lookup.`,
);
}
const location = resp.headers.get('location');
if (location === null) return undefined;
const match = /\/releases\/tag\/([^/?#]+)/.exec(location);
if (match === null) return undefined;
try {
return decodeURIComponent(match[1]!);
} catch {
return match[1];
}
}
function codeloadUrl(owner: string, repo: string, ref: GithubRef): string {
const base = `https://codeload.github.com/${owner}/${repo}/zip`;
const encoded = encodeCodeloadRefPath(ref.value);
if (ref.kind === 'sha') return `${base}/${encoded}`;
if (ref.kind === 'tag') return `${base}/refs/tags/${encoded}`;
return `${base}/${encoded}`;
}
function encodeCodeloadRefPath(value: string): string {
return value.split('/').map(encodeURIComponent).join('/');
}

View file

@ -1 +1,10 @@
export * from './types';
export * from './commands';
export * from './manifest';
export * from './store';
export * from './source';
export * from './github-resolver';
export * from './archive';
export * from './manager';
export * from './plugin';
export * from './pluginService';

View file

@ -0,0 +1,557 @@
import { cp, mkdir, mkdtemp, readdir, realpath, rename, rm, stat } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import type { HookDef } from '#/externalHooks/types';
import type { McpServerConfig } from '#/mcp/config-schema';
import type { SkillRoot } from '#/skill/types';
import { downloadZip, extractZip } from './archive';
import { loadPluginCommand } from './commands';
import { resolveGithubSource } from './github-resolver';
import { resolveInstallSource } from './source';
import { parseManifest, type ParsedManifestResult } from './manifest';
import { readInstalled, writeInstalled, type InstalledRecord } from './store';
import {
normalizePluginId,
type EnabledPluginSessionStart,
type PluginCapabilityState,
type PluginCommandDef,
type PluginGithubMetadata,
type PluginInfo,
type PluginMcpServerInfo,
type PluginRecord,
type PluginSource,
type PluginSummary,
type PluginUpdateStatus,
type ReloadSummary,
} from './types';
export interface PluginManagerOptions {
readonly kimiHomeDir: string;
}
export class PluginManager {
private readonly kimiHomeDir: string;
private records = new Map<string, PluginRecord>();
constructor(options: PluginManagerOptions) {
this.kimiHomeDir = options.kimiHomeDir;
}
async load(): Promise<void> {
const file = await readInstalled(this.kimiHomeDir);
const next = new Map<string, PluginRecord>();
for (const entry of file.plugins) {
next.set(entry.id, await this.materialize(entry));
}
this.records = next;
}
list(): readonly PluginRecord[] {
return [...this.records.values()].toSorted((a, b) => a.id.localeCompare(b.id));
}
get(id: string): PluginRecord | undefined {
return this.records.get(normalizePluginId(id));
}
async install(source: string): Promise<PluginRecord> {
const resolved = resolveInstallSource(source);
let sourceRoot: string;
let originalSource: string;
let sourceType: PluginSource;
let parsed: ParsedManifestResult;
let zipTmpDir: string | undefined;
let github: PluginGithubMetadata | undefined;
if (resolved.kind === 'local-path') {
sourceRoot = await normalizeInstallRoot(resolved.path);
originalSource = resolved.path;
sourceType = 'local-path';
parsed = await parseManifest(sourceRoot);
} else {
originalSource = source.trim();
sourceType = resolved.kind === 'github' ? 'github' : 'zip-url';
const zipUrl =
resolved.kind === 'github'
? await (async () => {
const resolution = await resolveGithubSource(resolved);
github = {
owner: resolved.owner,
repo: resolved.repo,
ref: resolution.ref,
};
return resolution.tarballUrl;
})()
: resolved.path;
const buffer = await downloadZip(zipUrl);
zipTmpDir = await mkdtemp(path.join(tmpdir(), 'kimi-plugin-zip-'));
sourceRoot = await extractZip(buffer, zipTmpDir);
parsed = await parseManifest(sourceRoot);
}
try {
if (parsed.manifest === undefined) {
const msg = parsed.diagnostics.find((d) => d.severity === 'error')?.message ?? 'no manifest';
throw new Error(`Cannot install plugin at ${sourceRoot}: ${msg}`);
}
const id = normalizePluginId(parsed.manifest.name);
const normalizedRoot = await copyPluginToManagedRoot(this.kimiHomeDir, id, sourceRoot);
const managedParsed = await parseManifest(normalizedRoot);
const existing = this.records.get(id);
const now = new Date().toISOString();
const record = await recordFrom({
id,
root: normalizedRoot,
enabled: existing?.enabled ?? true,
installedAt: existing?.installedAt ?? now,
updatedAt: now,
originalSource,
source: sourceType,
capabilities: existing?.capabilities,
github,
parsed: managedParsed,
});
this.records.set(id, record);
await this.persist();
return record;
} finally {
if (zipTmpDir !== undefined) {
await rm(zipTmpDir, { recursive: true, force: true });
}
}
}
async setEnabled(id: string, enabled: boolean): Promise<void> {
const key = normalizePluginId(id);
const current = this.records.get(key);
if (current === undefined) throw new Error(`Plugin "${id}" is not installed`);
if (current.enabled === enabled) return;
this.records.set(key, { ...current, enabled, updatedAt: new Date().toISOString() });
await this.persist();
}
async setMcpServerEnabled(id: string, server: string, enabled: boolean): Promise<void> {
const key = normalizePluginId(id);
const current = this.records.get(key);
if (current === undefined) throw new Error(`Plugin "${id}" is not installed`);
if (current.manifest?.mcpServers?.[server] === undefined) {
throw new Error(`Plugin "${id}" does not declare MCP server "${server}"`);
}
const currentMcpServers = current.capabilities?.mcpServers ?? {};
const nextCapabilities: PluginCapabilityState = {
...current.capabilities,
mcpServers: {
...currentMcpServers,
[server]: { enabled },
},
};
this.records.set(key, {
...current,
capabilities: nextCapabilities,
updatedAt: new Date().toISOString(),
});
await this.persist();
}
async remove(id: string): Promise<void> {
const key = normalizePluginId(id);
if (!this.records.delete(key)) {
throw new Error(`Plugin "${id}" is not installed`);
}
await this.persist();
}
async checkUpdates(): Promise<readonly PluginUpdateStatus[]> {
const out: PluginUpdateStatus[] = [];
for (const record of this.records.values()) {
if (record.source !== 'github' || record.github === undefined) continue;
const latest = await resolveGithubSource({
kind: 'github',
owner: record.github.owner,
repo: record.github.repo,
});
const current = record.github.ref;
const updateAvailable =
current === undefined ||
current.kind !== latest.ref.kind ||
current.value !== latest.ref.value;
out.push({
id: record.id,
source: record.source,
current,
latest: latest.ref,
displayVersion: latest.displayVersion,
updateAvailable,
});
}
return out.toSorted((a, b) => a.id.localeCompare(b.id));
}
async reload(): Promise<ReloadSummary> {
const prevIds = new Set(this.records.keys());
const file = await readInstalled(this.kimiHomeDir);
const next = new Map<string, PluginRecord>();
const errors: Array<{ id: string; message: string }> = [];
for (const entry of file.plugins) {
try {
next.set(entry.id, await this.materialize(entry));
} catch (error) {
errors.push({ id: entry.id, message: (error as Error).message });
}
}
const added: string[] = [];
for (const id of next.keys()) if (!prevIds.has(id)) added.push(id);
const removed: string[] = [];
for (const id of prevIds) if (!next.has(id)) removed.push(id);
this.records = next;
return { added, removed, errors };
}
enabledHooks(): readonly HookDef[] {
const out: HookDef[] = [];
for (const record of this.records.values()) {
if (!record.enabled || record.state !== 'ok' || record.manifest === undefined) continue;
for (const hook of record.manifest.hooks ?? []) {
out.push({
...hook,
cwd: record.root,
env: {
KIMI_CODE_HOME: this.kimiHomeDir,
KIMI_PLUGIN_ROOT: record.root,
},
});
}
}
return out;
}
async enabledCommands(): Promise<readonly PluginCommandDef[]> {
const out: PluginCommandDef[] = [];
for (const record of this.records.values()) {
if (!record.enabled || record.state !== 'ok' || record.manifest === undefined) continue;
for (const entry of record.manifest.commands ?? []) {
const def = await loadPluginCommand({
commandPath: entry.path,
pluginId: record.id,
fallbackName: entry.name,
});
if (def !== undefined) out.push(def);
}
}
return out;
}
pluginSkillRoots(): readonly SkillRoot[] {
const roots: SkillRoot[] = [];
for (const record of this.records.values()) {
if (!record.enabled || record.state !== 'ok' || record.manifest === undefined) continue;
for (const dir of record.manifest.skills ?? []) {
roots.push({
path: dir,
source: 'extra',
plugin: { id: record.id, instructions: record.skillInstructions },
});
}
}
return roots;
}
enabledSessionStarts(): readonly EnabledPluginSessionStart[] {
const out: EnabledPluginSessionStart[] = [];
for (const record of this.records.values()) {
if (!record.enabled || record.state !== 'ok') continue;
const skill = record.manifest?.sessionStart?.skill;
if (skill === undefined) continue;
out.push({ pluginId: record.id, skillName: skill });
}
return out;
}
enabledMcpServers(): Record<string, McpServerConfig> {
const out: Record<string, McpServerConfig> = {};
for (const record of this.records.values()) {
if (!record.enabled || record.state !== 'ok' || record.manifest === undefined) continue;
for (const [name, config] of Object.entries(record.manifest.mcpServers ?? {})) {
if (!isMcpServerEnabled(record, name, config)) continue;
out[pluginMcpRuntimeName(record.id, name)] = withPluginMcpRuntime(
withMcpServerEnabled(config, true),
record.root,
this.kimiHomeDir,
);
}
}
return out;
}
summaries(): readonly PluginSummary[] {
return this.list().map((record) => recordToSummary(record));
}
info(id: string): PluginInfo | undefined {
const record = this.get(id);
return record === undefined ? undefined : recordToInfo(record);
}
private async persist(): Promise<void> {
const installed: InstalledRecord[] = [...this.records.values()].map((record) => ({
id: record.id,
root: record.root,
source: record.source,
enabled: record.enabled,
installedAt: record.installedAt,
updatedAt: record.updatedAt,
originalSource: record.originalSource,
capabilities: record.capabilities,
github: record.github,
}));
await writeInstalled(this.kimiHomeDir, { version: 1, plugins: installed });
}
private async materialize(entry: InstalledRecord): Promise<PluginRecord> {
const parsed = await parseManifest(entry.root);
return recordFrom({
id: entry.id,
root: entry.root,
enabled: entry.enabled,
installedAt: entry.installedAt,
updatedAt: entry.updatedAt,
originalSource: entry.originalSource,
capabilities: entry.capabilities,
github: entry.github,
source: entry.source,
parsed,
});
}
}
async function normalizeInstallRoot(rootPath: string): Promise<string> {
const trimmed = rootPath.trim();
if (!path.isAbsolute(trimmed)) {
throw new Error(`Plugin root must be an absolute path (got "${rootPath}")`);
}
let resolved: string;
try {
resolved = await realpath(trimmed);
} catch (error) {
throw new Error(`Plugin root does not exist: ${trimmed}`, { cause: error });
}
if (!(await stat(resolved)).isDirectory()) {
throw new Error(`Plugin root is not a directory: ${trimmed}`);
}
return resolved;
}
async function copyPluginToManagedRoot(
kimiHomeDir: string,
id: string,
sourceRoot: string,
): Promise<string> {
const managedRoot = path.join(kimiHomeDir, 'plugins', 'managed', id);
const managedDir = path.dirname(managedRoot);
await mkdir(managedDir, { recursive: true });
const stagingRoot = await mkdtemp(path.join(managedDir, `${id}-`));
try {
await cp(sourceRoot, stagingRoot, { recursive: true });
await rm(managedRoot, { recursive: true, force: true });
await rename(stagingRoot, managedRoot);
} catch (error) {
await rm(stagingRoot, { recursive: true, force: true });
throw error;
}
return realpath(managedRoot);
}
async function recordFrom(input: {
id: string;
root: string;
enabled: boolean;
installedAt: string;
updatedAt?: string;
originalSource?: string;
capabilities?: PluginCapabilityState;
github?: PluginGithubMetadata;
source?: PluginSource;
parsed: ParsedManifestResult;
}): Promise<PluginRecord> {
const { parsed } = input;
const hasError = parsed.diagnostics.some((d) => d.severity === 'error');
return {
id: input.id,
root: input.root,
source: input.source ?? 'local-path',
enabled: input.enabled,
state: hasError || parsed.manifest === undefined ? 'error' : 'ok',
installedAt: input.installedAt,
updatedAt: input.updatedAt,
originalSource: input.originalSource,
capabilities: input.capabilities,
github: input.github,
skillCount: await countDiscoveredPluginSkills(parsed.manifest),
manifest: parsed.manifest,
manifestKind: parsed.manifestKind,
manifestPath: parsed.manifestPath,
shadowedManifestPath: parsed.shadowedManifestPath,
diagnostics: parsed.diagnostics,
skillInstructions: parsed.manifest?.skillInstructions,
};
}
function recordToSummary(record: PluginRecord): PluginSummary {
return {
id: record.id,
displayName: record.manifest?.interface?.displayName ?? record.id,
version: record.manifest?.version,
enabled: record.enabled,
state: record.state,
skillCount: record.skillCount,
mcpServerCount: Object.keys(record.manifest?.mcpServers ?? {}).length,
enabledMcpServerCount: pluginMcpServersInfo(record).filter((server) => server.enabled).length,
hookCount: record.manifest?.hooks?.length ?? 0,
commandCount: record.manifest?.commands?.length ?? 0,
hasErrors: record.diagnostics.some((d) => d.severity === 'error'),
source: record.source,
originalSource: record.originalSource,
github: record.github,
};
}
function recordToInfo(record: PluginRecord): PluginInfo {
return {
...recordToSummary(record),
root: record.root,
installedAt: record.installedAt,
updatedAt: record.updatedAt,
manifestKind: record.manifestKind,
manifestPath: record.manifestPath,
manifest: record.manifest,
mcpServers: pluginMcpServersInfo(record),
shadowedManifestPath: record.shadowedManifestPath,
diagnostics: record.diagnostics,
};
}
function isMcpServerEnabled(record: PluginRecord, name: string, config: McpServerConfig): boolean {
return record.capabilities?.mcpServers?.[name]?.enabled ?? config.enabled !== false;
}
function pluginMcpServersInfo(record: PluginRecord): readonly PluginMcpServerInfo[] {
return Object.entries(record.manifest?.mcpServers ?? {})
.map(([name, config]) => pluginMcpServerInfo(record, name, config))
.toSorted((a, b) => a.name.localeCompare(b.name));
}
function pluginMcpServerInfo(
record: PluginRecord,
name: string,
config: McpServerConfig,
): PluginMcpServerInfo {
if (config.transport === 'http' || config.transport === 'sse') {
return {
name,
runtimeName: pluginMcpRuntimeName(record.id, name),
enabled: isMcpServerEnabled(record, name, config),
transport: config.transport,
url: config.url,
headerKeys: config.headers === undefined ? undefined : Object.keys(config.headers).toSorted(),
};
}
return {
name,
runtimeName: pluginMcpRuntimeName(record.id, name),
enabled: isMcpServerEnabled(record, name, config),
transport: 'stdio',
command: config.command,
args: config.args,
cwd: config.cwd,
envKeys: config.env === undefined ? undefined : Object.keys(config.env).toSorted(),
};
}
function pluginMcpRuntimeName(pluginId: string, serverName: string): string {
return `plugin-${pluginId}:${serverName}`;
}
// Hidden Kimi CLI subcommand that re-enters as a Node interpreter. Used as a
// fallback when an MCP server declares `"command": "node"` but the user is
// running a single-binary Kimi build that doesn't have `node` on PATH.
const KIMI_NODE_FALLBACK_SUBCOMMAND = '__plugin_run_node';
function withMcpServerEnabled(config: McpServerConfig, enabled: boolean): McpServerConfig {
return { ...config, enabled };
}
function withPluginMcpRuntime(
config: McpServerConfig,
pluginRoot: string,
kimiHomeDir: string,
): McpServerConfig {
if (config.transport === 'http' || config.transport === 'sse') return config;
const env = {
...config.env,
KIMI_CODE_HOME: kimiHomeDir,
KIMI_PLUGIN_ROOT: pluginRoot,
};
if (config.command === 'node' && isKimiNativeBinary()) {
return {
...config,
command: process.execPath,
args: [KIMI_NODE_FALLBACK_SUBCOMMAND, ...(config.args ?? [])],
cwd: config.cwd ?? pluginRoot,
env,
};
}
return { ...config, cwd: config.cwd ?? pluginRoot, env };
}
function isKimiNativeBinary(): boolean {
return !path.basename(process.execPath).toLowerCase().startsWith('node');
}
async function countDiscoveredPluginSkills(
manifest: PluginRecord['manifest'],
): Promise<number> {
const roots = manifest?.skills ?? [];
if (roots.length === 0) return 0;
let count = 0;
for (const root of roots) {
count += await countSkillBundles(root);
}
return count;
}
async function countSkillBundles(root: string): Promise<number> {
let entries;
try {
entries = await readdir(root, { withFileTypes: true });
} catch {
return 0;
}
let count = 0;
for (const entry of entries) {
if (entry.isDirectory()) {
if (await isFile(path.join(root, entry.name, 'SKILL.md'))) count++;
} else if (
entry.isFile() &&
entry.name.endsWith('.md') &&
entry.name !== 'SKILL.md'
) {
count++;
}
}
return count;
}
async function isFile(p: string): Promise<boolean> {
try {
return (await stat(p)).isFile();
} catch {
return false;
}
}

View file

@ -0,0 +1,487 @@
import { readdir, readFile, realpath, stat } from 'node:fs/promises';
import path from 'node:path';
import { HookDefSchema, type HookDefConfig } from '#/externalHooks/configSection';
import { McpServerConfigSchema, type McpServerConfig } from '#/mcp/config-schema';
import {
PLUGIN_NAME_REGEX,
type PluginCommandEntry,
type PluginDiagnostic,
type PluginInterface,
type PluginManifest,
type PluginManifestKind,
} from './types';
const KIMI_PLUGIN_ROOT_PATH = 'kimi.plugin.json';
const KIMI_PLUGIN_DIR_PATH = '.kimi-plugin/plugin.json';
// Fields that look like third-party runtime extensions (Claude / Codex / old
// Kimi CLI). We do not run them; emit an info diagnostic so plugin authors and
// users can see why a field is silently ignored.
const UNSUPPORTED_RUNTIME_FIELDS = [
'tools',
'apps',
'inject',
'configFile',
'config_file',
'bootstrap',
] as const;
export interface ParsedManifestResult {
readonly manifest?: PluginManifest;
readonly manifestKind?: PluginManifestKind;
readonly manifestPath?: string;
readonly shadowedManifestPath?: string;
readonly diagnostics: readonly PluginDiagnostic[];
}
export async function parseManifest(pluginRoot: string): Promise<ParsedManifestResult> {
const rootJsonPath = path.join(pluginRoot, KIMI_PLUGIN_ROOT_PATH);
const dirJsonPath = path.join(pluginRoot, KIMI_PLUGIN_DIR_PATH);
const rootJsonExists = await isFile(rootJsonPath);
const dirJsonExists = await isFile(dirJsonPath);
if (!rootJsonExists && !dirJsonExists) {
return {
diagnostics: [
{
severity: 'error',
message: `No manifest at ${KIMI_PLUGIN_ROOT_PATH} or ${KIMI_PLUGIN_DIR_PATH}`,
},
],
};
}
const manifestPath = rootJsonExists ? rootJsonPath : dirJsonPath;
const manifestKind: PluginManifestKind = rootJsonExists ? 'kimi-plugin-root' : 'kimi-plugin-dir';
const shadowedManifestPath = rootJsonExists && dirJsonExists ? dirJsonPath : undefined;
let raw: unknown;
try {
raw = JSON.parse(await readFile(manifestPath, 'utf8'));
} catch (error) {
return {
manifestKind,
manifestPath,
shadowedManifestPath,
diagnostics: [
{
severity: 'error',
message: `Failed to parse ${path.relative(pluginRoot, manifestPath)}: ${(error as Error).message}`,
},
],
};
}
if (!isObject(raw)) {
return {
manifestKind,
manifestPath,
shadowedManifestPath,
diagnostics: [{ severity: 'error', message: 'manifest must be a JSON object' }],
};
}
const diagnostics: PluginDiagnostic[] = [];
const name = typeof raw['name'] === 'string' ? raw['name'].trim() : '';
if (name.length === 0) {
diagnostics.push({ severity: 'error', message: '"name" is required' });
return { manifestKind, manifestPath, shadowedManifestPath, diagnostics };
}
if (!PLUGIN_NAME_REGEX.test(name)) {
diagnostics.push({
severity: 'error',
message: `"name" must match ${PLUGIN_NAME_REGEX} (got "${name}")`,
});
return { manifestKind, manifestPath, shadowedManifestPath, diagnostics };
}
let skills = await resolveSkillsField(pluginRoot, raw['skills'], diagnostics);
if (raw['skills'] === undefined) {
const rootSkillMd = path.join(pluginRoot, 'SKILL.md');
if (await isFile(rootSkillMd)) {
skills = [pluginRoot];
}
}
const skillInstructions =
typeof raw['skillInstructions'] === 'string' ? raw['skillInstructions'] : undefined;
recordUnsupportedRuntimeFields(raw, diagnostics);
const manifest: PluginManifest = {
name,
version: stringField(raw, 'version'),
description: stringField(raw, 'description'),
keywords: stringArrayField(raw, 'keywords'),
homepage: stringField(raw, 'homepage'),
license: stringField(raw, 'license'),
author: readAuthor(raw['author']),
skills,
sessionStart: readSessionStart(raw['sessionStart'], diagnostics),
mcpServers: await readMcpServers(pluginRoot, raw['mcpServers'], diagnostics),
hooks: readHooks(raw['hooks'], diagnostics),
commands: await readCommands(pluginRoot, raw['commands'], diagnostics),
interface: readInterface(raw['interface']),
skillInstructions,
};
return { manifest, manifestKind, manifestPath, shadowedManifestPath, diagnostics };
}
function recordUnsupportedRuntimeFields(
raw: Record<string, unknown>,
diagnostics: PluginDiagnostic[],
): void {
for (const field of UNSUPPORTED_RUNTIME_FIELDS) {
if (raw[field] === undefined) continue;
diagnostics.push({
severity: 'info',
message: `"${field}" is present but not supported by Kimi plugins`,
});
}
}
async function resolveSkillsField(
pluginRoot: string,
raw: unknown,
diagnostics: PluginDiagnostic[],
): Promise<readonly string[]> {
if (raw === undefined) return [];
const entries: string[] = [];
if (typeof raw === 'string') {
entries.push(raw);
} else if (Array.isArray(raw) && raw.every((entry) => typeof entry === 'string')) {
entries.push(...raw);
} else {
diagnostics.push({ severity: 'error', message: '"skills" must be a string or string[]' });
return [];
}
const resolved: string[] = [];
for (const entry of entries) {
if (!entry.startsWith('./')) {
diagnostics.push({
severity: 'error',
message: `"skills" path must start with "./" (got "${entry}")`,
});
continue;
}
const absolute = path.resolve(pluginRoot, entry);
let real: string;
try {
real = await realpath(absolute);
} catch {
real = absolute;
}
const rootReal = await realpath(pluginRoot).catch(() => pluginRoot);
if (!isWithin(real, rootReal)) {
diagnostics.push({
severity: 'error',
message: `"skills" path resolves outside the plugin (${entry})`,
});
continue;
}
if (!(await isDir(real))) {
diagnostics.push({
severity: 'warn',
message: `"skills" path is not a directory (${entry})`,
});
continue;
}
resolved.push(real);
}
return resolved;
}
async function resolvePluginPathField(input: {
readonly pluginRoot: string;
readonly field: string;
readonly value: string;
readonly diagnostics: PluginDiagnostic[];
}): Promise<string | undefined> {
if (!input.value.startsWith('./')) {
input.diagnostics.push({
severity: 'warn',
message: `"${input.field}" path must start with "./" (got "${input.value}")`,
});
return undefined;
}
const absolute = path.resolve(input.pluginRoot, input.value);
let real: string;
try {
real = await realpath(absolute);
} catch {
real = absolute;
}
const rootReal = await realpath(input.pluginRoot).catch(() => input.pluginRoot);
if (!isWithin(real, rootReal)) {
input.diagnostics.push({
severity: 'warn',
message: `"${input.field}" path resolves outside the plugin (${input.value})`,
});
return undefined;
}
return real;
}
function readSessionStart(
raw: unknown,
diagnostics: PluginDiagnostic[],
): PluginManifest['sessionStart'] {
if (raw === undefined) return undefined;
if (!isObject(raw)) {
diagnostics.push({ severity: 'warn', message: '"sessionStart" must be an object' });
return undefined;
}
const skill = typeof raw['skill'] === 'string' ? raw['skill'].trim() : '';
if (skill.length === 0) {
diagnostics.push({
severity: 'warn',
message: '"sessionStart.skill" is required when sessionStart is present',
});
return undefined;
}
return { skill };
}
async function readMcpServers(
pluginRoot: string,
raw: unknown,
diagnostics: PluginDiagnostic[],
): Promise<PluginManifest['mcpServers']> {
if (raw === undefined) return undefined;
if (!isObject(raw)) {
diagnostics.push({ severity: 'warn', message: '"mcpServers" must be an object' });
return undefined;
}
const out: Record<string, McpServerConfig> = {};
for (const [name, value] of Object.entries(raw)) {
const trimmedName = name.trim();
if (trimmedName.length === 0) {
diagnostics.push({
severity: 'warn',
message: '"mcpServers" entries must have a non-empty name',
});
continue;
}
const parsed = McpServerConfigSchema.safeParse(value);
if (!parsed.success) {
diagnostics.push({
severity: 'warn',
message: `Invalid MCP server "${trimmedName}": ${parsed.error.message}`,
});
continue;
}
const normalized = await normalizePluginMcpServer({
pluginRoot,
name: trimmedName,
config: parsed.data,
diagnostics,
});
if (normalized !== undefined) out[trimmedName] = normalized;
}
return Object.keys(out).length === 0 ? undefined : out;
}
function readHooks(
raw: unknown,
diagnostics: PluginDiagnostic[],
): readonly HookDefConfig[] | undefined {
if (raw === undefined) return undefined;
if (!Array.isArray(raw)) {
diagnostics.push({ severity: 'warn', message: '"hooks" must be an array' });
return undefined;
}
const out: HookDefConfig[] = [];
raw.forEach((entry, i) => {
const parsed = HookDefSchema.safeParse(entry);
if (!parsed.success) {
diagnostics.push({
severity: 'warn',
message: `Invalid hook at index ${i}: ${parsed.error.message}`,
});
} else {
out.push(parsed.data);
}
});
return out.length === 0 ? undefined : out;
}
async function readCommands(
pluginRoot: string,
raw: unknown,
diagnostics: PluginDiagnostic[],
): Promise<readonly PluginCommandEntry[] | undefined> {
if (raw === undefined) return undefined;
const entries: string[] = [];
if (typeof raw === 'string') {
entries.push(raw);
} else if (Array.isArray(raw) && raw.every((entry) => typeof entry === 'string')) {
entries.push(...raw);
} else {
diagnostics.push({ severity: 'warn', message: '"commands" must be a string or string[]' });
return undefined;
}
const files: PluginCommandEntry[] = [];
for (const entry of entries) {
const resolved = await resolvePluginPathField({
pluginRoot,
field: 'commands',
value: entry,
diagnostics,
});
if (resolved === undefined) continue;
if (await isDir(resolved)) {
files.push(...(await listMarkdownFilesRecursive(resolved)));
} else if ((await isFile(resolved)) && resolved.endsWith('.md')) {
files.push({ path: resolved, name: commandNameFromFile(resolved, path.dirname(resolved)) });
} else {
diagnostics.push({
severity: 'warn',
message: `"commands" entry must be a directory or .md file (${entry})`,
});
}
}
return files.length === 0 ? undefined : files.toSorted((a, b) => a.name.localeCompare(b.name));
}
async function listMarkdownFilesRecursive(root: string): Promise<readonly PluginCommandEntry[]> {
const out: PluginCommandEntry[] = [];
await walkMarkdown(root, root, out);
return out;
}
async function walkMarkdown(
root: string,
dir: string,
out: PluginCommandEntry[],
): Promise<void> {
let entries;
try {
entries = await readdir(dir, { withFileTypes: true });
} catch {
return;
}
for (const entry of entries) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) {
await walkMarkdown(root, full, out);
} else if (entry.isFile() && entry.name.endsWith('.md')) {
out.push({ path: full, name: commandNameFromFile(full, root) });
}
}
}
function commandNameFromFile(file: string, root: string): string {
const relative = path.relative(root, file).replace(/\.md$/i, '');
return relative.split(path.sep).join('/');
}
async function normalizePluginMcpServer(input: {
readonly pluginRoot: string;
readonly name: string;
readonly config: McpServerConfig;
readonly diagnostics: PluginDiagnostic[];
}): Promise<McpServerConfig | undefined> {
const { config } = input;
if (config.transport === 'http' || config.transport === 'sse') return config;
let command = config.command;
if (command.startsWith('./')) {
const resolvedCommand = await resolvePluginPathField({
pluginRoot: input.pluginRoot,
field: `mcpServers.${input.name}.command`,
value: command,
diagnostics: input.diagnostics,
});
if (resolvedCommand === undefined) return undefined;
command = resolvedCommand;
} else if (command.includes('/') || path.isAbsolute(command)) {
input.diagnostics.push({
severity: 'warn',
message: `"mcpServers.${input.name}.command" must be a PATH command or start with "./"`,
});
return undefined;
}
let cwd = config.cwd;
if (cwd !== undefined) {
const resolvedCwd = await resolvePluginPathField({
pluginRoot: input.pluginRoot,
field: `mcpServers.${input.name}.cwd`,
value: cwd,
diagnostics: input.diagnostics,
});
if (resolvedCwd === undefined) return undefined;
cwd = resolvedCwd;
}
return { ...config, command, cwd };
}
function readAuthor(raw: unknown): PluginManifest['author'] {
if (typeof raw === 'string') return { name: raw };
if (!isObject(raw)) return undefined;
const name = stringField(raw, 'name');
const email = stringField(raw, 'email');
if (name === undefined && email === undefined) return undefined;
return { name, email };
}
function readInterface(raw: unknown): PluginInterface | undefined {
if (!isObject(raw)) return undefined;
const out: PluginInterface = {
displayName: stringField(raw, 'displayName'),
shortDescription: stringField(raw, 'shortDescription'),
longDescription: stringField(raw, 'longDescription'),
developerName: stringField(raw, 'developerName'),
websiteURL: stringField(raw, 'websiteURL'),
};
const hasAny = Object.values(out).some((value) => value !== undefined);
return hasAny ? out : undefined;
}
function stringField(raw: Record<string, unknown>, key: string): string | undefined {
const value = raw[key];
if (typeof value !== 'string') return undefined;
const trimmed = value.trim();
return trimmed.length === 0 ? undefined : trimmed;
}
function stringArrayField(raw: Record<string, unknown>, key: string): readonly string[] | undefined {
const value = raw[key];
if (!Array.isArray(value) || !value.every((entry) => typeof entry === 'string')) {
return undefined;
}
return value as readonly string[];
}
function isObject(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function isWithin(child: string, parent: string): boolean {
const relative = path.relative(parent, child);
return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative));
}
async function isFile(p: string): Promise<boolean> {
try {
return (await stat(p)).isFile();
} catch {
return false;
}
}
async function isDir(p: string): Promise<boolean> {
try {
return (await stat(p)).isDirectory();
} catch {
return false;
}
}

View file

@ -0,0 +1,67 @@
import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation';
import type { Event } from '#/_base/event';
import type { HookDef } from '#/externalHooks/types';
import type { McpServerConfig } from '#/mcp/config-schema';
import type { SkillRoot } from '#/skill/types';
import type {
EnabledPluginSessionStart,
PluginCommandDef,
PluginInfo,
PluginSummary,
PluginUpdateStatus,
ReloadSummary,
} from './types';
export interface InstallPluginInput {
readonly source: string;
}
export interface SetPluginEnabledInput {
readonly id: string;
readonly enabled: boolean;
}
export interface SetPluginMcpServerEnabledInput {
readonly id: string;
readonly server: string;
readonly enabled: boolean;
}
export interface RemovePluginInput {
readonly id: string;
}
export interface GetPluginInfoInput {
readonly id: string;
}
export interface IPluginService {
readonly _serviceBrand: undefined;
listPlugins(): Promise<readonly PluginSummary[]>;
installPlugin(input: InstallPluginInput): Promise<PluginSummary>;
setPluginEnabled(input: SetPluginEnabledInput): Promise<void>;
setPluginMcpServerEnabled(input: SetPluginMcpServerEnabledInput): Promise<void>;
removePlugin(input: RemovePluginInput): Promise<void>;
reloadPlugins(): Promise<ReloadSummary>;
getPluginInfo(input: GetPluginInfoInput): Promise<PluginInfo | undefined>;
listPluginCommands(): Promise<readonly PluginCommandDef[]>;
checkUpdates(): Promise<readonly PluginUpdateStatus[]>;
// --- consumption plane (loaded from enabled, error-free plugins) ---------
/** Skill roots contributed by enabled plugins (fed into skill discovery). */
pluginSkillRoots(): Promise<readonly SkillRoot[]>;
/** Session-start reminders declared by enabled plugins. */
enabledSessionStarts(): Promise<readonly EnabledPluginSessionStart[]>;
/** MCP servers contributed by enabled plugins, keyed by runtime name. */
enabledMcpServers(): Promise<Record<string, McpServerConfig>>;
/** Hooks contributed by enabled plugins (cwd + env already resolved). */
enabledHooks(): Promise<readonly HookDef[]>;
/** Fires after a successful `reloadPlugins()` with the reload summary. */
readonly onDidReload: Event<ReloadSummary>;
}
export const IPluginService: ServiceIdentifier<IPluginService> =
createDecorator<IPluginService>('pluginService');

View file

@ -0,0 +1,123 @@
import { Disposable } from '#/_base/di/lifecycle';
import { InstantiationType } from '#/_base/di/extensions';
import { Emitter, type Event } from '#/_base/event';
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
import { IBootstrapService } from '#/bootstrap';
import type { HookDef } from '#/externalHooks/types';
import type { McpServerConfig } from '#/mcp/config-schema';
import type { SkillRoot } from '#/skill/types';
import { PluginManager } from './manager';
import {
type GetPluginInfoInput,
type InstallPluginInput,
IPluginService,
type RemovePluginInput,
type SetPluginEnabledInput,
type SetPluginMcpServerEnabledInput,
} from './plugin';
import type {
EnabledPluginSessionStart,
PluginCommandDef,
PluginInfo,
PluginSummary,
PluginUpdateStatus,
ReloadSummary,
} from './types';
export class PluginService extends Disposable implements IPluginService {
declare readonly _serviceBrand: undefined;
private readonly manager: PluginManager;
private loaded = false;
private readonly onDidReloadEmitter = this._register(new Emitter<ReloadSummary>());
readonly onDidReload: Event<ReloadSummary> = this.onDidReloadEmitter.event;
constructor(@IBootstrapService bootstrap: IBootstrapService) {
super();
this.manager = new PluginManager({ kimiHomeDir: bootstrap.homeDir });
}
async listPlugins(): Promise<readonly PluginSummary[]> {
await this.ensureLoaded();
return this.manager.summaries();
}
async installPlugin(input: InstallPluginInput): Promise<PluginSummary> {
await this.ensureLoaded();
const record = await this.manager.install(input.source);
return this.manager.info(record.id) as PluginSummary;
}
async setPluginEnabled(input: SetPluginEnabledInput): Promise<void> {
await this.ensureLoaded();
await this.manager.setEnabled(input.id, input.enabled);
}
async setPluginMcpServerEnabled(input: SetPluginMcpServerEnabledInput): Promise<void> {
await this.ensureLoaded();
await this.manager.setMcpServerEnabled(input.id, input.server, input.enabled);
}
async removePlugin(input: RemovePluginInput): Promise<void> {
await this.ensureLoaded();
await this.manager.remove(input.id);
}
async reloadPlugins(): Promise<ReloadSummary> {
const summary = await this.manager.reload();
this.loaded = true;
this.onDidReloadEmitter.fire(summary);
return summary;
}
async getPluginInfo(input: GetPluginInfoInput): Promise<PluginInfo | undefined> {
await this.ensureLoaded();
return this.manager.info(input.id);
}
async listPluginCommands(): Promise<readonly PluginCommandDef[]> {
await this.ensureLoaded();
return this.manager.enabledCommands();
}
async checkUpdates(): Promise<readonly PluginUpdateStatus[]> {
await this.ensureLoaded();
return this.manager.checkUpdates();
}
async pluginSkillRoots(): Promise<readonly SkillRoot[]> {
await this.ensureLoaded();
return this.manager.pluginSkillRoots();
}
async enabledSessionStarts(): Promise<readonly EnabledPluginSessionStart[]> {
await this.ensureLoaded();
return this.manager.enabledSessionStarts();
}
async enabledMcpServers(): Promise<Record<string, McpServerConfig>> {
await this.ensureLoaded();
return this.manager.enabledMcpServers();
}
async enabledHooks(): Promise<readonly HookDef[]> {
await this.ensureLoaded();
return this.manager.enabledHooks();
}
private async ensureLoaded(): Promise<void> {
if (this.loaded) return;
await this.manager.load();
this.loaded = true;
}
}
registerScopedService(
LifecycleScope.App,
IPluginService,
PluginService,
InstantiationType.Delayed,
'plugin',
);

View file

@ -0,0 +1,86 @@
import path from 'node:path';
export interface GithubRef {
readonly kind: 'branch' | 'tag' | 'sha';
readonly value: string;
}
export type ResolvedSource =
| { kind: 'local-path'; path: string }
| { kind: 'zip-url'; path: string }
| { kind: 'github'; owner: string; repo: string; ref?: GithubRef };
export type InstallSource = ResolvedSource;
const SHA_RE = /^[0-9a-f]{7,40}$/;
export function resolveInstallSource(source: string): ResolvedSource {
const trimmed = source.trim();
const github = parseGithubUrl(trimmed);
if (github !== undefined) return github;
if (trimmed.startsWith('http://') || trimmed.startsWith('https://')) {
return { kind: 'zip-url', path: trimmed };
}
if (!path.isAbsolute(trimmed)) {
throw new Error(`Plugin root must be an absolute path (got "${source}")`);
}
return { kind: 'local-path', path: trimmed };
}
function parseGithubUrl(raw: string): ResolvedSource | undefined {
let url: URL;
try {
url = new URL(raw);
} catch {
return undefined;
}
if (url.protocol !== 'https:') return undefined;
if (url.hostname !== 'github.com' && url.hostname !== 'www.github.com') return undefined;
const segments = url.pathname.split('/').filter((s) => s.length > 0);
const owner = segments[0];
const repoRaw = segments[1];
if (owner === undefined || repoRaw === undefined) return undefined;
const repo = repoRaw.endsWith('.git') ? repoRaw.slice(0, -4) : repoRaw;
const rest = segments.slice(2);
if (rest.length === 0) {
return { kind: 'github', owner, repo };
}
const head = rest[0];
const second = rest[1];
if (head === 'tree' && rest.length >= 2) {
const refValue = decodeRefSegments(rest.slice(1));
const kind: GithubRef['kind'] = SHA_RE.test(refValue) ? 'sha' : 'branch';
return { kind: 'github', owner, repo, ref: { kind, value: refValue } };
}
if (head === 'releases' && second === 'tag' && rest.length >= 3) {
const tag = decodeRefSegments(rest.slice(2));
return { kind: 'github', owner, repo, ref: { kind: 'tag', value: tag } };
}
if (head === 'commit' && rest.length >= 2) {
const sha = decodeRefSegments(rest.slice(1));
return { kind: 'github', owner, repo, ref: { kind: 'sha', value: sha } };
}
return undefined;
}
function decodeRefSegments(segments: readonly string[]): string {
return segments
.map((segment) => {
try {
return decodeURIComponent(segment);
} catch {
return segment;
}
})
.join('/');
}

View file

@ -0,0 +1,54 @@
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
import path from 'node:path';
import type { PluginCapabilityState, PluginGithubMetadata, PluginSource } from './types';
const INSTALLED_REL = path.join('plugins', 'installed.json');
export interface InstalledRecord {
readonly id: string;
readonly root: string;
readonly source: PluginSource;
readonly enabled: boolean;
readonly installedAt: string;
readonly updatedAt?: string;
readonly originalSource?: string;
readonly capabilities?: PluginCapabilityState;
readonly github?: PluginGithubMetadata;
}
export interface InstalledFile {
readonly version: 1;
readonly plugins: readonly InstalledRecord[];
}
const EMPTY: InstalledFile = { version: 1, plugins: [] };
export async function readInstalled(kimiHomeDir: string): Promise<InstalledFile> {
const filePath = path.join(kimiHomeDir, INSTALLED_REL);
let text: string;
try {
text = await readFile(filePath, 'utf8');
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return EMPTY;
throw error;
}
try {
const parsed = JSON.parse(text) as InstalledFile;
if (typeof parsed !== 'object' || parsed === null || !Array.isArray(parsed.plugins)) {
throw new Error('installed.json is not a valid InstalledFile object');
}
return parsed;
} catch (error) {
throw new Error(`Failed to parse ${filePath}: ${(error as Error).message}`, { cause: error });
}
}
export async function writeInstalled(kimiHomeDir: string, data: InstalledFile): Promise<void> {
const dir = path.join(kimiHomeDir, 'plugins');
await mkdir(dir, { recursive: true });
const final = path.join(dir, 'installed.json');
const tmp = `${final}.tmp`;
await writeFile(tmp, JSON.stringify(data, null, 2), 'utf8');
await rename(tmp, final);
}

View file

@ -1,3 +1,4 @@
import type { HookDefConfig } from '#/externalHooks/configSection';
import type { McpServerConfig } from '#/mcp/config-schema';
export type PluginDiagnosticSeverity = 'error' | 'warn' | 'info';
@ -35,6 +36,8 @@ export interface PluginManifest {
readonly skills?: readonly string[]; // resolved absolute paths
readonly sessionStart?: PluginSessionStart;
readonly mcpServers?: Readonly<Record<string, McpServerConfig>>;
readonly hooks?: readonly HookDefConfig[];
readonly commands?: readonly PluginCommandEntry[];
readonly interface?: PluginInterface;
readonly skillInstructions?: string;
}
@ -60,6 +63,27 @@ export interface PluginMcpServerInfo {
readonly headerKeys?: readonly string[];
}
export interface PluginCommandDef {
readonly pluginId: string;
readonly name: string;
readonly description: string;
readonly body: string;
readonly path: string;
}
/**
* A resolved command file plus its namespace-preserving name.
*
* `name` is the path of the file relative to the declared `commands` entry
* (without the `.md` extension, using `/` separators), so a file at
* `commands/frontend/component.md` yields the name `frontend/component`.
* Frontmatter `name` in the file itself takes precedence over this at load time.
*/
export interface PluginCommandEntry {
readonly path: string;
readonly name: string;
}
export type PluginManifestKind = 'kimi-plugin-root' | 'kimi-plugin-dir';
export type PluginSource = 'local-path' | 'zip-url' | 'github';
export type PluginState = 'ok' | 'error';
@ -105,6 +129,8 @@ export interface PluginSummary {
readonly skillCount: number;
readonly mcpServerCount: number;
readonly enabledMcpServerCount: number;
readonly hookCount: number;
readonly commandCount: number;
readonly hasErrors: boolean;
readonly source: PluginSource;
readonly originalSource?: string;
@ -134,6 +160,15 @@ export interface ReloadSummary {
readonly errors: ReadonlyArray<{ readonly id: string; readonly message: string }>;
}
export interface PluginUpdateStatus {
readonly id: string;
readonly source: PluginSource;
readonly current?: PluginGithubRef;
readonly latest: PluginGithubRef;
readonly displayVersion: string;
readonly updateAvailable: boolean;
}
export const PLUGIN_NAME_REGEX = /^[a-z0-9][a-z0-9_-]{0,63}$/;
export function normalizePluginId(name: string): string {

View file

@ -0,0 +1,78 @@
import type { ContentPart } from '@moonshot-ai/kosong';
import type {
ActivatePluginCommandPayload,
ActivateSkillPayload,
PromptPayload,
} from './core-api';
const MAX_TITLE_LENGTH = 200;
const MAX_LAST_PROMPT_LENGTH = 4000;
export function titleFromPromptMetadataText(text: string): string {
return text.slice(0, MAX_TITLE_LENGTH);
}
export function promptMetadataTextFromPayload(payload: PromptPayload): string | undefined {
const parts: string[] = [];
for (const part of payload.input) {
const text = promptPartText(part);
if (text !== undefined) parts.push(text);
}
return sanitizeAndTruncatePromptText(parts.join('\n'), MAX_LAST_PROMPT_LENGTH);
}
export function promptMetadataTextFromSkill(payload: ActivateSkillPayload): string | undefined {
const args = payload.args?.trim();
return sanitizeAndTruncatePromptText(
args === undefined || args.length === 0 ? `/${payload.name}` : `/${payload.name} ${args}`,
MAX_LAST_PROMPT_LENGTH,
);
}
export function promptMetadataTextFromPluginCommand(
payload: ActivatePluginCommandPayload,
): string | undefined {
const args = payload.args?.trim();
const command = `/${payload.pluginId}:${payload.commandName}`;
return sanitizeAndTruncatePromptText(
args === undefined || args.length === 0 ? command : `${command} ${args}`,
MAX_LAST_PROMPT_LENGTH,
);
}
function promptPartText(part: ContentPart): string | undefined {
switch (part.type) {
case 'text':
return part.text;
case 'image_url':
return '[image]';
case 'audio_url':
return '[audio]';
case 'video_url':
return '[video]';
case 'think':
return undefined;
}
}
function sanitizeAndTruncatePromptText(text: string, maxLength: number): string | undefined {
const sanitized = text
.replaceAll(
/-----BEGIN [^-]*PRIVATE KEY-----[\s\S]*?-----END [^-]*PRIVATE KEY-----/gi,
'[redacted]',
)
.replaceAll(/\b(authorization)\s*:\s*bearer\s+\S+/gi, '$1: Bearer [redacted]')
.replaceAll(
/\b(api[_-]?key|token|secret|password|passwd|pwd)\b\s*[:=]\s*(?:"[^"]*"|'[^']*'|\S+)/gi,
'$1=[redacted]',
)
.replaceAll(/\bsk-[A-Za-z0-9_-]{12,}\b/g, '[redacted]')
.replaceAll(/\b[A-Za-z0-9][A-Za-z0-9+/=_-]{39,}\b/g, '[redacted]')
.replaceAll(/\p{Cc}+/gu, ' ')
.replaceAll(/\s+/g, ' ')
.trim();
if (sanitized.length === 0) return undefined;
return sanitized.slice(0, maxLength);
}

View file

@ -0,0 +1,36 @@
import { execFileSync } from 'node:child_process';
import { readFile } from 'node:fs/promises';
import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { extractZip } from '../../src/plugin/archive';
describe('plugin archive extraction', () => {
let dir: string;
beforeEach(async () => {
dir = await mkdtemp(join(tmpdir(), 'plugin-archive-test-'));
});
afterEach(async () => {
await rm(dir, { recursive: true, force: true });
});
it('extracts a zip and detects a nested plugin root', async () => {
const source = join(dir, 'source');
const nested = join(source, 'plugin');
await mkdir(nested, { recursive: true });
await writeFile(join(nested, 'kimi.plugin.json'), JSON.stringify({ name: 'zip-demo' }), 'utf8');
const zipPath = join(dir, 'plugin.zip');
execFileSync('zip', ['-qr', zipPath, '.'], { cwd: source });
const outDir = join(dir, 'out');
const detectedRoot = await extractZip(await readFile(zipPath), outDir);
expect(detectedRoot).toBe(join(outDir, 'plugin'));
await expect(readFile(join(detectedRoot, 'kimi.plugin.json'), 'utf8')).resolves.toContain('zip-demo');
});
});

View file

@ -0,0 +1,73 @@
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import {
expandCommandArguments,
loadPluginCommand,
parseCommandText,
} from '../../src/plugin/commands';
describe('plugin command parser', () => {
let dir: string;
beforeEach(async () => {
dir = await mkdtemp(join(tmpdir(), 'plugin-command-test-'));
});
afterEach(async () => {
await rm(dir, { recursive: true, force: true });
});
it('parses frontmatter name and description', () => {
const commandPath = join(dir, 'deploy.md');
const result = parseCommandText({
text: '---\nname: deploy\ndescription: Deploy the app\n---\n\nRun deploy.',
commandPath,
pluginId: 'demo',
});
expect(result).toEqual({
pluginId: 'demo',
name: 'deploy',
description: 'Deploy the app',
body: 'Run deploy.',
path: commandPath,
});
});
it('falls back to the file name and first body line', () => {
const commandPath = join(dir, 'frontend/component.md');
const result = parseCommandText({
text: 'Build the component\n\nMore detail.',
commandPath,
pluginId: 'demo',
fallbackName: 'frontend/component',
});
expect(result.name).toBe('frontend/component');
expect(result.description).toBe('Build the component');
expect(result.body).toBe('Build the component\n\nMore detail.');
});
it('loads a command file and returns undefined for missing files', async () => {
const commandPath = join(dir, 'deploy.md');
await writeFile(commandPath, '---\ndescription: Deploy\n---\n\nBody', 'utf8');
await expect(loadPluginCommand({ commandPath, pluginId: 'demo' })).resolves.toMatchObject({
pluginId: 'demo',
name: 'deploy',
description: 'Deploy',
body: 'Body',
});
await expect(loadPluginCommand({ commandPath: join(dir, 'missing.md'), pluginId: 'demo' })).resolves.toBeUndefined();
});
it('expands $ARGUMENTS and appends args when no placeholder exists', () => {
expect(expandCommandArguments('deploy $ARGUMENTS now', 'prod')).toBe('deploy prod now');
expect(expandCommandArguments('deploy now', 'prod')).toBe('deploy now\n\nARGUMENTS: prod');
expect(expandCommandArguments('deploy now', '')).toBe('deploy now');
});
});

View file

@ -0,0 +1,124 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { resolveGithubSource } from '../../src/plugin/github-resolver';
describe('resolveGithubSource', () => {
afterEach(() => {
vi.unstubAllGlobals();
});
it('resolves explicit refs without network and encodes ref paths', async () => {
const fetchMock = vi.fn();
vi.stubGlobal('fetch', fetchMock);
await expect(
resolveGithubSource({ kind: 'github', owner: 'owner', repo: 'repo', ref: { kind: 'tag', value: 'release#1' } }),
).resolves.toEqual({
tarballUrl: 'https://codeload.github.com/owner/repo/zip/refs/tags/release%231',
displayVersion: 'release#1',
ref: { kind: 'tag', value: 'release#1' },
});
expect(fetchMock).not.toHaveBeenCalled();
});
it('uses latest release redirect for bare github urls', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
status: 302,
headers: new Headers({ location: 'https://github.com/owner/repo/releases/tag/v1.2.3' }),
}),
);
await expect(resolveGithubSource({ kind: 'github', owner: 'owner', repo: 'repo' })).resolves.toEqual({
tarballUrl: 'https://codeload.github.com/owner/repo/zip/refs/tags/v1.2.3',
displayVersion: 'v1.2.3',
ref: { kind: 'tag', value: 'v1.2.3' },
});
});
it('falls back to HEAD when there is no latest release', async () => {
vi.stubGlobal(
'fetch',
vi
.fn()
.mockResolvedValueOnce({ status: 404, ok: false, headers: new Headers() })
.mockResolvedValueOnce({ status: 200, ok: true, headers: new Headers() }),
);
await expect(resolveGithubSource({ kind: 'github', owner: 'owner', repo: 'repo' })).resolves.toEqual({
tarballUrl: 'https://codeload.github.com/owner/repo/zip/HEAD',
displayVersion: 'HEAD',
ref: { kind: 'branch', value: 'HEAD' },
});
});
it('branch-kind ref carrying a tag value (e.g. /tree/v5.1.0) still resolves via short form', async () => {
const fetchMock = vi.fn();
vi.stubGlobal('fetch', fetchMock);
const result = await resolveGithubSource({
kind: 'github',
owner: 'obra',
repo: 'superpowers',
ref: { kind: 'branch', value: 'v5.1.0' },
});
// Parser cannot distinguish branch from tag in `/tree/<ref>`, but codeload's
// short form resolves either — so no `/refs/heads/` 404.
expect(result.tarballUrl).toBe('https://codeload.github.com/obra/superpowers/zip/v5.1.0');
expect(fetchMock).not.toHaveBeenCalled();
});
it('bare URL: 302 with /releases/tag/X resolves to that tag', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
status: 302,
headers: new Headers({
location: 'https://github.com/obra/superpowers/releases/tag/v5.1.0',
}),
}),
);
const result = await resolveGithubSource({ kind: 'github', owner: 'obra', repo: 'superpowers' });
expect(result.tarballUrl).toBe(
'https://codeload.github.com/obra/superpowers/zip/refs/tags/v5.1.0',
);
expect(result.ref).toEqual({ kind: 'tag', value: 'v5.1.0' });
expect(result.displayVersion).toBe('v5.1.0');
});
it('does not call api.github.com on bare URL (API bypass)', async () => {
const calls: string[] = [];
vi.stubGlobal(
'fetch',
vi.fn(async (input: Parameters<typeof fetch>[0]) => {
const url =
typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
calls.push(url);
if (url.includes('github.com') && url.includes('/releases/latest')) {
return new Response(null, {
status: 302,
headers: { location: 'https://github.com/obra/superpowers/releases/tag/v5.1.0' },
});
}
throw new Error(`unexpected url: ${url}`);
}) as typeof fetch,
);
await resolveGithubSource({ kind: 'github', owner: 'obra', repo: 'superpowers' });
expect(calls.every((u) => !u.startsWith('https://api.github.com'))).toBe(true);
});
it('release-lookup error message hints at the /tree/<ref> escape hatch', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({ status: 502, statusText: 'Bad Gateway', headers: new Headers() }),
);
await expect(
resolveGithubSource({ kind: 'github', owner: 'obra', repo: 'superpowers' }),
).rejects.toThrow(/\/tree\/<branch\|tag\|sha>/);
});
});

View file

@ -0,0 +1,521 @@
import { execFileSync } from 'node:child_process';
import { createServer } from 'node:http';
import { mkdir, mkdtemp, readFile, realpath, rm, stat, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { PluginManager } from '../../src/plugin/manager';
async function makeKimiHome(): Promise<string> {
return mkdtemp(path.join(tmpdir(), 'kimi-home-'));
}
async function managedPluginRoot(home: string, id: string): Promise<string> {
return realpath(path.join(home, 'plugins', 'managed', id));
}
async function makePlugin(
name: string,
options: {
skills?: boolean;
skillNames?: readonly string[];
version?: string;
sessionStartSkill?: string;
mcpServers?: Record<string, unknown>;
hooks?: readonly unknown[];
commands?: Record<string, string>;
} = {},
): Promise<string> {
const root = await mkdtemp(path.join(tmpdir(), `plugin-${name}-`));
const manifest: Record<string, unknown> = { name };
if (options.version !== undefined) {
manifest['version'] = options.version;
}
const skillNames = options.skillNames ?? (options.skills === true ? ['demo-skill'] : []);
if (skillNames.length > 0) {
manifest['skills'] = './skills/';
await mkdir(path.join(root, 'skills'), { recursive: true });
for (const skillName of skillNames) {
await mkdir(path.join(root, 'skills', skillName), { recursive: true });
await writeFile(
path.join(root, 'skills', skillName, 'SKILL.md'),
`---\nname: ${skillName}\ndescription: A demo\n---\nbody`,
'utf8',
);
}
}
if (options.sessionStartSkill !== undefined) {
manifest['sessionStart'] = { skill: options.sessionStartSkill };
}
if (options.mcpServers !== undefined) {
manifest['mcpServers'] = options.mcpServers;
}
if (options.hooks !== undefined) {
manifest['hooks'] = options.hooks;
}
if (options.commands !== undefined) {
manifest['commands'] = ['./commands'];
await mkdir(path.join(root, 'commands'), { recursive: true });
for (const [file, body] of Object.entries(options.commands)) {
const filePath = path.join(root, 'commands', file);
await mkdir(path.dirname(filePath), { recursive: true });
await writeFile(filePath, body, 'utf8');
}
}
await writeFile(path.join(root, 'kimi.plugin.json'), JSON.stringify(manifest), 'utf8');
return realpath(root);
}
async function zipDir(sourceRoot: string): Promise<Buffer> {
const zipPath = path.join(tmpdir(), `plugin-${Date.now()}-${Math.random().toString(36).slice(2)}.zip`);
execFileSync('zip', ['-qr', zipPath, '.'], { cwd: sourceRoot });
const buffer = await readFile(zipPath);
await rm(zipPath, { force: true });
return buffer;
}
async function serveOnce(buffer: Buffer): Promise<string> {
const server = createServer((_, res) => {
res.writeHead(200, { 'Content-Type': 'application/zip' });
res.end(buffer);
server.close();
});
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
const address = server.address();
if (address === null || typeof address === 'string') throw new Error('bad server address');
return `http://127.0.0.1:${address.port}/plugin.zip`;
}
interface MockGithubFetchOptions {
releaseTag?: string;
tarball: Buffer;
onReleaseLookup?: () => void;
}
function mockGithubFetch(options: MockGithubFetchOptions): void {
vi.stubGlobal(
'fetch',
vi.fn(async (input: Parameters<typeof fetch>[0], init?: RequestInit) => {
const url =
typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
if (/^https:\/\/github\.com\/[^/]+\/[^/]+\/releases\/latest$/.test(url)) {
options.onReleaseLookup?.();
if (options.releaseTag === undefined) {
return new Response(null, { status: 404 });
}
const tagUrl = url.replace(/\/releases\/latest$/, `/releases/tag/${options.releaseTag}`);
return new Response(null, { status: 302, headers: { location: tagUrl } });
}
if (url.startsWith('https://codeload.github.com/')) {
if (init?.method === 'HEAD') return new Response(null, { status: 200 });
return new Response(options.tarball, { status: 200 });
}
throw new Error(`mockGithubFetch: unexpected url ${url}`);
}) as typeof fetch,
);
}
describe('PluginManager consumption plane', () => {
afterEach(() => {
vi.unstubAllGlobals();
});
it('pluginSkillRoots() returns only enabled plugins skills paths', async () => {
const home = await makeKimiHome();
const a = await makePlugin('a', { skills: true });
const b = await makePlugin('b', { skills: true });
const manager = new PluginManager({ kimiHomeDir: home });
await manager.load();
await manager.install(a);
await manager.install(b);
await manager.setEnabled('b', false);
const managedA = await managedPluginRoot(home, 'a');
const managedB = await managedPluginRoot(home, 'b');
expect(manager.pluginSkillRoots()).toContainEqual({
path: path.join(managedA, 'skills'),
source: 'extra',
plugin: { id: 'a', instructions: undefined },
});
expect(manager.pluginSkillRoots()).not.toContainEqual({
path: path.join(managedB, 'skills'),
source: 'extra',
plugin: { id: 'b', instructions: undefined },
});
});
it('pluginSkillRoots() excludes plugins in error state', async () => {
const home = await makeKimiHome();
const root = await makePlugin('demo');
const manager = new PluginManager({ kimiHomeDir: home });
await manager.load();
await manager.install(root);
await writeFile(
path.join(await managedPluginRoot(home, 'demo'), 'kimi.plugin.json'),
'{ not json',
'utf8',
);
await manager.reload();
expect(manager.get('demo')?.state).toBe('error');
expect(manager.pluginSkillRoots()).toEqual([]);
});
it('summaries count discovered skills inside plugin skill roots', async () => {
const home = await makeKimiHome();
const root = await makePlugin('superpowers', {
skillNames: ['brainstorming', 'systematic-debugging', 'writing-plans'],
});
const manager = new PluginManager({ kimiHomeDir: home });
await manager.load();
await manager.install(root);
expect(manager.summaries()).toContainEqual(
expect.objectContaining({ id: 'superpowers', skillCount: 3 }),
);
expect(manager.info('superpowers')?.skillCount).toBe(3);
});
it('enabledSessionStarts() returns only enabled plugin sessionStart declarations', async () => {
const home = await makeKimiHome();
const root = await makePlugin('demo', { skills: true, sessionStartSkill: 'demo-skill' });
const manager = new PluginManager({ kimiHomeDir: home });
await manager.load();
await manager.install(root);
expect(manager.enabledSessionStarts()).toEqual([{ pluginId: 'demo', skillName: 'demo-skill' }]);
await manager.setEnabled('demo', false);
expect(manager.enabledSessionStarts()).toEqual([]);
});
it('setMcpServerEnabled() persists explicit MCP server state with cwd + env + runtime name', async () => {
const home = await makeKimiHome();
const root = await makePlugin('demo', {
mcpServers: {
finance: { command: 'finance-mcp' },
docs: { url: 'https://example.com/mcp' },
events: { transport: 'sse', url: 'https://example.com/sse' },
},
});
const manager = new PluginManager({ kimiHomeDir: home });
await manager.load();
await manager.install(root);
const managedRoot = await managedPluginRoot(home, 'demo');
expect(manager.info('demo')?.mcpServers).toContainEqual(
expect.objectContaining({
name: 'finance',
runtimeName: 'plugin-demo:finance',
enabled: true,
command: 'finance-mcp',
}),
);
expect(manager.info('demo')?.mcpServers).toContainEqual(
expect.objectContaining({
name: 'events',
runtimeName: 'plugin-demo:events',
transport: 'sse',
url: 'https://example.com/sse',
}),
);
expect(manager.summaries()[0]).toEqual(
expect.objectContaining({ mcpServerCount: 3, enabledMcpServerCount: 3 }),
);
expect(manager.enabledMcpServers()).toEqual(
expect.objectContaining({
'plugin-demo:finance': expect.objectContaining({
command: 'finance-mcp',
cwd: managedRoot,
env: expect.objectContaining({ KIMI_CODE_HOME: home, KIMI_PLUGIN_ROOT: managedRoot }),
}),
'plugin-demo:docs': expect.objectContaining({ url: 'https://example.com/mcp' }),
'plugin-demo:events': expect.objectContaining({
transport: 'sse',
url: 'https://example.com/sse',
}),
}),
);
await manager.setMcpServerEnabled('demo', 'finance', false);
expect(manager.enabledMcpServers()).not.toHaveProperty('plugin-demo:finance');
expect(manager.summaries()[0]).toEqual(
expect.objectContaining({ mcpServerCount: 3, enabledMcpServerCount: 2 }),
);
const reloaded = new PluginManager({ kimiHomeDir: home });
await reloaded.load();
expect(reloaded.info('demo')?.mcpServers).toContainEqual(
expect.objectContaining({ name: 'finance', enabled: false }),
);
});
it('merges manifest MCP enabled defaults with explicit user state', async () => {
const home = await makeKimiHome();
const root = await makePlugin('demo', {
mcpServers: { finance: { command: 'finance-mcp', enabled: false } },
});
const manager = new PluginManager({ kimiHomeDir: home });
await manager.load();
await manager.install(root);
expect(manager.info('demo')?.mcpServers).toContainEqual(
expect.objectContaining({ name: 'finance', enabled: false }),
);
expect(manager.summaries()[0]).toEqual(
expect.objectContaining({ mcpServerCount: 1, enabledMcpServerCount: 0 }),
);
expect(manager.enabledMcpServers()).toEqual({});
await manager.setMcpServerEnabled('demo', 'finance', true);
expect(manager.enabledMcpServers()).toEqual(
expect.objectContaining({
'plugin-demo:finance': expect.objectContaining({ command: 'finance-mcp', enabled: true }),
}),
);
const reloaded = new PluginManager({ kimiHomeDir: home });
await reloaded.load();
expect(reloaded.info('demo')?.mcpServers).toContainEqual(
expect.objectContaining({ name: 'finance', enabled: true }),
);
expect(reloaded.enabledMcpServers()).toHaveProperty('plugin-demo:finance');
});
it('uses unambiguous runtime names for plugin MCP servers', async () => {
const home = await makeKimiHome();
const first = await makePlugin('a-b', { mcpServers: { c: { command: 'first-mcp' } } });
const second = await makePlugin('a', { mcpServers: { 'b-c': { command: 'second-mcp' } } });
const manager = new PluginManager({ kimiHomeDir: home });
await manager.load();
await manager.install(first);
await manager.install(second);
expect(manager.info('a-b')?.mcpServers).toContainEqual(
expect.objectContaining({ name: 'c', runtimeName: 'plugin-a-b:c' }),
);
expect(manager.info('a')?.mcpServers).toContainEqual(
expect.objectContaining({ name: 'b-c', runtimeName: 'plugin-a:b-c' }),
);
const servers = manager.enabledMcpServers();
expect(servers).toEqual(
expect.objectContaining({
'plugin-a-b:c': expect.objectContaining({ command: 'first-mcp' }),
'plugin-a:b-c': expect.objectContaining({ command: 'second-mcp' }),
}),
);
expect(Object.keys(servers)).toHaveLength(2);
});
it('enabledMcpServers() excludes disabled plugins', async () => {
const home = await makeKimiHome();
const root = await makePlugin('demo', { mcpServers: { finance: { command: 'finance-mcp' } } });
const manager = new PluginManager({ kimiHomeDir: home });
await manager.load();
await manager.install(root);
await manager.setMcpServerEnabled('demo', 'finance', true);
await manager.setEnabled('demo', false);
expect(manager.enabledMcpServers()).toEqual({});
});
it('setMcpServerEnabled() rejects unknown MCP servers', async () => {
const home = await makeKimiHome();
const root = await makePlugin('demo');
const manager = new PluginManager({ kimiHomeDir: home });
await manager.load();
await manager.install(root);
await expect(manager.setMcpServerEnabled('demo', 'missing', true)).rejects.toThrow(
/does not declare MCP server/i,
);
});
it('reload() picks up edits to the managed plugin copy', async () => {
const home = await makeKimiHome();
const root = await makePlugin('demo');
const manager = new PluginManager({ kimiHomeDir: home });
await manager.load();
await manager.install(root);
const managedRoot = await managedPluginRoot(home, 'demo');
await writeFile(
path.join(managedRoot, 'kimi.plugin.json'),
JSON.stringify({ name: 'demo', version: '2.0.0' }),
'utf8',
);
const summary = await manager.reload();
expect(summary.errors).toEqual([]);
expect(manager.get('demo')?.manifest?.version).toBe('2.0.0');
});
it('remove() clears the entry but does not delete the source directory', async () => {
const home = await makeKimiHome();
const root = await makePlugin('demo', { skills: true });
const manager = new PluginManager({ kimiHomeDir: home });
await manager.load();
await manager.install(root);
await manager.remove('demo');
expect(manager.get('demo')).toBeUndefined();
expect((await stat(root)).isDirectory()).toBe(true);
});
it('enabledHooks() returns hooks from enabled plugins with cwd and env injected', async () => {
const home = await makeKimiHome();
const root = await makePlugin('demo', {
hooks: [{ event: 'PreToolUse', command: './hooks/guard.sh', timeout: 10 }],
});
const manager = new PluginManager({ kimiHomeDir: home });
await manager.load();
await manager.install(root);
const installedRoot = await managedPluginRoot(home, 'demo');
expect(manager.enabledHooks()).toEqual([
{
event: 'PreToolUse',
command: './hooks/guard.sh',
timeout: 10,
cwd: installedRoot,
env: { KIMI_CODE_HOME: home, KIMI_PLUGIN_ROOT: installedRoot },
},
]);
});
it('enabledHooks() excludes disabled plugins', async () => {
const home = await makeKimiHome();
const root = await makePlugin('demo', { hooks: [{ event: 'PreToolUse', command: './x.sh' }] });
const manager = new PluginManager({ kimiHomeDir: home });
await manager.load();
await manager.install(root);
await manager.setEnabled('demo', false);
expect(manager.enabledHooks()).toEqual([]);
});
it('install() from /tree/<tag-shaped-ref> downloads via short form, not refs/heads/', async () => {
const home = await makeKimiHome();
const sourceRoot = await mkdtemp(path.join(tmpdir(), 'plugin-gh-tag-'));
await writeFile(
path.join(sourceRoot, 'kimi.plugin.json'),
JSON.stringify({ name: 'pin-tag-demo', version: '5.1.0' }),
'utf8',
);
const zipBuffer = await zipDir(sourceRoot);
let codeloadPath = '';
vi.stubGlobal(
'fetch',
vi.fn(async (input: Parameters<typeof fetch>[0]) => {
const url =
typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
if (url.startsWith('https://codeload.github.com/')) {
codeloadPath = new URL(url).pathname;
return new Response(zipBuffer, { status: 200 });
}
throw new Error(`unexpected url ${url}`);
}) as typeof fetch,
);
const manager = new PluginManager({ kimiHomeDir: home });
await manager.load();
const record = await manager.install('https://github.com/obra/superpowers/tree/v5.1.0');
expect(codeloadPath).toBe('/obra/superpowers/zip/v5.1.0');
expect(record.github?.ref).toEqual({ kind: 'branch', value: 'v5.1.0' });
await rm(sourceRoot, { recursive: true, force: true });
});
it('install() from /releases/tag/<tag> resolves precisely via refs/tags/', async () => {
const home = await makeKimiHome();
const sourceRoot = await mkdtemp(path.join(tmpdir(), 'plugin-gh-release-'));
await writeFile(
path.join(sourceRoot, 'kimi.plugin.json'),
JSON.stringify({ name: 'pin-tag-demo', version: '5.1.0' }),
'utf8',
);
const zipBuffer = await zipDir(sourceRoot);
let codeloadPath = '';
vi.stubGlobal(
'fetch',
vi.fn(async (input: Parameters<typeof fetch>[0]) => {
const url =
typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
if (url.startsWith('https://codeload.github.com/')) {
codeloadPath = new URL(url).pathname;
return new Response(zipBuffer, { status: 200 });
}
throw new Error(`unexpected url ${url}`);
}) as typeof fetch,
);
const manager = new PluginManager({ kimiHomeDir: home });
await manager.load();
const record = await manager.install('https://github.com/obra/superpowers/releases/tag/v5.1.0');
expect(codeloadPath).toBe('/obra/superpowers/zip/refs/tags/v5.1.0');
expect(record.github?.ref).toEqual({ kind: 'tag', value: 'v5.1.0' });
await rm(sourceRoot, { recursive: true, force: true });
});
it('install() from github /tree/<branch> bypasses the GitHub API', async () => {
const home = await makeKimiHome();
const sourceRoot = await mkdtemp(path.join(tmpdir(), 'plugin-gh-branch-'));
await writeFile(
path.join(sourceRoot, 'kimi.plugin.json'),
JSON.stringify({ name: 'gh-demo', version: '5.1.0' }),
'utf8',
);
const zipBuffer = await zipDir(sourceRoot);
let releaseLookups = 0;
mockGithubFetch({ tarball: zipBuffer, onReleaseLookup: () => releaseLookups++ });
const manager = new PluginManager({ kimiHomeDir: home });
await manager.load();
const record = await manager.install('https://github.com/wbxl2000/superpowers/tree/main');
expect(releaseLookups).toBe(0);
expect(record.source).toBe('github');
expect(record.github?.ref).toEqual({ kind: 'branch', value: 'main' });
await rm(sourceRoot, { recursive: true, force: true });
});
it('install() ignores forged marketplace context from legacy callers', async () => {
const home = await makeKimiHome();
const root = await makePlugin('rando', { version: '1.0.0' });
const manager = new PluginManager({ kimiHomeDir: home });
await manager.load();
const record = await (manager.install as (source: string, options?: unknown) => Promise<unknown>)(
root,
{ marketplace: { id: 'rando', tier: 'official' } },
);
expect((record as { marketplace?: unknown }).marketplace).toBeUndefined();
});
it('install() from github URL overwrites an existing zip-url install (CDN migration)', async () => {
const home = await makeKimiHome();
const cdnSource = await mkdtemp(path.join(tmpdir(), 'plugin-cdn-'));
await writeFile(
path.join(cdnSource, 'kimi.plugin.json'),
JSON.stringify({ name: 'superpowers', version: '5.0.0' }),
'utf8',
);
const cdnUrl = await serveOnce(await zipDir(cdnSource));
const manager = new PluginManager({ kimiHomeDir: home });
await manager.load();
const first = await manager.install(cdnUrl);
expect(first.source).toBe('zip-url');
await manager.setEnabled('superpowers', false);
const ghSource = await mkdtemp(path.join(tmpdir(), 'plugin-gh-migrate-'));
await writeFile(
path.join(ghSource, 'kimi.plugin.json'),
JSON.stringify({ name: 'superpowers', version: '5.1.0' }),
'utf8',
);
mockGithubFetch({ releaseTag: 'v5.1.0', tarball: await zipDir(ghSource) });
const updated = await manager.install('https://github.com/wbxl2000/superpowers');
expect(updated.source).toBe('github');
expect(updated.manifest?.version).toBe('5.1.0');
expect(updated.enabled).toBe(false);
expect(updated.installedAt).toBe(first.installedAt);
expect(updated.originalSource).toBe('https://github.com/wbxl2000/superpowers');
expect(updated.github?.ref).toEqual({ kind: 'tag', value: 'v5.1.0' });
expect(manager.list()).toHaveLength(1);
await rm(cdnSource, { recursive: true, force: true });
await rm(ghSource, { recursive: true, force: true });
});
});

View file

@ -0,0 +1,200 @@
import { execFileSync } from 'node:child_process';
import { createServer } from 'node:http';
import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { PluginManager } from '../../src/plugin/manager';
describe('PluginManager', () => {
let home: string;
let root: string;
beforeEach(async () => {
home = await mkdtemp(join(tmpdir(), 'plugin-manager-home-'));
root = await mkdtemp(join(tmpdir(), 'plugin-manager-root-'));
await mkdir(join(home, 'plugins'), { recursive: true });
await mkdir(join(root, 'commands'), { recursive: true });
await writeFile(join(root, 'commands', 'deploy.md'), '---\ndescription: Deploy\n---\n\nBody', 'utf8');
await writeFile(
join(root, 'kimi.plugin.json'),
JSON.stringify({
name: 'demo',
commands: ['./commands'],
hooks: [{ event: 'Stop', command: 'echo stop' }],
}),
'utf8',
);
await writeFile(
join(home, 'plugins', 'installed.json'),
JSON.stringify({
version: 1,
plugins: [
{
id: 'demo',
root,
source: 'local-path',
enabled: true,
installedAt: '2026-01-01T00:00:00.000Z',
},
],
}),
'utf8',
);
});
afterEach(async () => {
vi.unstubAllGlobals();
await rm(home, { recursive: true, force: true });
await rm(root, { recursive: true, force: true });
});
it('loads installed plugins and exposes summaries, hooks, and commands', async () => {
const manager = new PluginManager({ kimiHomeDir: home });
await manager.load();
expect(manager.summaries()).toEqual([
expect.objectContaining({
id: 'demo',
state: 'ok',
commandCount: 1,
hookCount: 1,
}),
]);
expect(manager.enabledHooks()).toEqual([
{
event: 'Stop',
command: 'echo stop',
cwd: root,
env: { KIMI_CODE_HOME: home, KIMI_PLUGIN_ROOT: root },
},
]);
await expect(manager.enabledCommands()).resolves.toEqual([
expect.objectContaining({ pluginId: 'demo', name: 'deploy', description: 'Deploy' }),
]);
});
it('installs a local-path plugin into the managed root', async () => {
const sourceRoot = await mkdtemp(join(tmpdir(), 'plugin-install-source-'));
try {
await writeFile(join(sourceRoot, 'kimi.plugin.json'), JSON.stringify({ name: 'other' }), 'utf8');
const manager = new PluginManager({ kimiHomeDir: home });
const record = await manager.install(sourceRoot);
expect(record.id).toBe('other');
expect(record.root).toContain(join(home, 'plugins', 'managed', 'other'));
expect(manager.get('other')?.manifest?.name).toBe('other');
} finally {
await rm(sourceRoot, { recursive: true, force: true });
}
});
it('installs a zip-url plugin', async () => {
const sourceRoot = await mkdtemp(join(tmpdir(), 'plugin-zip-source-'));
const zipPath = join(tmpdir(), `plugin-${Date.now()}.zip`);
const server = createServer((_req, res) => {
void readFile(zipPath).then((data) => res.end(data));
});
try {
await writeFile(join(sourceRoot, 'kimi.plugin.json'), JSON.stringify({ name: 'zip-plugin' }), 'utf8');
execFileSync('zip', ['-qr', zipPath, '.'], { cwd: sourceRoot });
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
const address = server.address();
if (address === null || typeof address === 'string') throw new Error('bad server address');
const manager = new PluginManager({ kimiHomeDir: home });
const record = await manager.install(`http://127.0.0.1:${address.port}/plugin.zip`);
expect(record.id).toBe('zip-plugin');
expect(record.source).toBe('zip-url');
expect(manager.get('zip-plugin')?.manifest?.name).toBe('zip-plugin');
} finally {
await new Promise<void>((resolve, reject) => server.close((err) => (err === undefined ? resolve() : reject(err))));
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`);
try {
await writeFile(join(sourceRoot, 'kimi.plugin.json'), JSON.stringify({ name: 'github-plugin' }), 'utf8');
execFileSync('zip', ['-qr', zipPath, '.'], { cwd: sourceRoot });
const zip = await readFile(zipPath);
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(zip)));
const manager = new PluginManager({ kimiHomeDir: home });
const record = await manager.install('https://github.com/owner/repo/tree/v1');
expect(record.id).toBe('github-plugin');
expect(record.source).toBe('github');
expect(record.github).toEqual({
owner: 'owner',
repo: 'repo',
ref: { kind: 'branch', value: 'v1' },
});
expect(manager.get('github-plugin')?.manifest?.name).toBe('github-plugin');
} finally {
await rm(sourceRoot, { recursive: true, force: true });
await rm(zipPath, { force: true });
}
});
it('checks github plugin updates against latest release', async () => {
await writeFile(
join(home, 'plugins', 'installed.json'),
JSON.stringify({
version: 1,
plugins: [
{
id: 'demo',
root,
source: 'github',
enabled: true,
installedAt: '2026-01-01T00:00:00.000Z',
github: { owner: 'owner', repo: 'repo', ref: { kind: 'branch', value: 'v1' } },
},
],
}),
'utf8',
);
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
status: 302,
ok: false,
headers: new Headers({ location: 'https://github.com/owner/repo/releases/tag/v2' }),
}),
);
const manager = new PluginManager({ kimiHomeDir: home });
await manager.load();
await expect(manager.checkUpdates()).resolves.toEqual([
{
id: 'demo',
source: 'github',
current: { kind: 'branch', value: 'v1' },
latest: { kind: 'tag', value: 'v2' },
displayVersion: 'v2',
updateAvailable: true,
},
]);
});
it('persists enabled state changes', async () => {
const manager = new PluginManager({ kimiHomeDir: home });
await manager.load();
await manager.setEnabled('demo', false);
expect(manager.get('demo')?.enabled).toBe(false);
const stored = JSON.parse(await readFile(join(home, 'plugins', 'installed.json'), 'utf8')) as {
plugins: Array<{ id: string; enabled: boolean }>;
};
expect(stored.plugins).toEqual([expect.objectContaining({ id: 'demo', enabled: false })]);
});
});

View file

@ -0,0 +1,65 @@
import { mkdtemp, mkdir, realpath, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { parseManifest } from '../../src/plugin/manifest';
describe('plugin manifest parser', () => {
let dir: string;
beforeEach(async () => {
dir = await mkdtemp(join(tmpdir(), 'plugin-manifest-test-'));
});
afterEach(async () => {
await rm(dir, { recursive: true, force: true });
});
it('reads recursive command entries and valid hooks', async () => {
await mkdir(join(dir, 'commands', 'frontend'), { recursive: true });
await writeFile(join(dir, 'commands', 'frontend', 'component.md'), '# Component', 'utf8');
await writeFile(join(dir, 'commands', 'deploy.md'), '# Deploy', 'utf8');
await writeFile(
join(dir, 'kimi.plugin.json'),
JSON.stringify({
name: 'demo',
commands: ['./commands'],
hooks: [{ event: 'Stop', command: 'echo stop' }],
}),
'utf8',
);
const result = await parseManifest(dir);
const root = await realpath(dir);
expect(result.manifest?.commands).toEqual([
{ path: join(root, 'commands', 'deploy.md'), name: 'deploy' },
{ path: join(root, 'commands', 'frontend', 'component.md'), name: 'frontend/component' },
]);
expect(result.manifest?.hooks).toEqual([{ event: 'Stop', command: 'echo stop' }]);
expect(result.diagnostics).toEqual([]);
});
it('warns on invalid hooks and command paths', async () => {
await writeFile(
join(dir, 'kimi.plugin.json'),
JSON.stringify({
name: 'demo',
commands: ['../outside.md'],
hooks: [{ event: 'Nope', command: 'echo nope' }],
}),
'utf8',
);
const result = await parseManifest(dir);
expect(result.manifest?.commands).toBeUndefined();
expect(result.manifest?.hooks).toBeUndefined();
expect(result.diagnostics.map((d) => d.message)).toEqual([
expect.stringContaining('Invalid hook at index 0'),
'"commands" path must start with "./" (got "../outside.md")',
]);
});
});

View file

@ -0,0 +1,171 @@
import { afterEach, describe, expect, it } from 'vitest';
import { SyncDescriptor } from '#/_base/di/descriptors';
import { Emitter } from '#/_base/event';
import {
IPluginSessionStartInjectorService,
PluginSessionStartInjectorService,
} from '#/contextInjector/pluginSessionStart';
import { IPluginService } from '#/plugin';
import type { EnabledPluginSessionStart, ReloadSummary } from '#/plugin/types';
import { InMemorySkillCatalog } from '#/skill/registry';
import type { SkillDefinition } from '#/skill/types';
import { agentService, appService, createTestAgent, skillServices, type TestAgentContext } from '../harness';
function pluginSkill(): SkillDefinition {
return {
name: 'demo-skill',
description: 'A plugin skill',
path: '/plugins/demo/skills/demo-skill/SKILL.md',
dir: '/plugins/demo/skills/demo-skill',
content: 'Do the demo thing.',
metadata: {},
source: 'extra',
plugin: { id: 'demo', instructions: 'Always be helpful.' },
};
}
interface PluginServiceStubOptions {
readonly sessionStarts: readonly EnabledPluginSessionStart[];
readonly reloadEmitter?: Emitter<ReloadSummary>;
}
function pluginServiceStub(options: PluginServiceStubOptions): IPluginService {
const reloadEmitter = options.reloadEmitter;
return {
_serviceBrand: undefined,
onDidReload: reloadEmitter !== undefined ? reloadEmitter.event : () => ({ dispose: () => {} }),
listPlugins: async () => [],
installPlugin: async () => ({ id: '' }) as never,
setPluginEnabled: async () => {},
setPluginMcpServerEnabled: async () => {},
removePlugin: async () => {},
reloadPlugins: async (): Promise<ReloadSummary> => ({ added: [], removed: [], errors: [] }),
getPluginInfo: async () => undefined,
listPluginCommands: async () => [],
checkUpdates: async () => [],
pluginSkillRoots: async () => [],
enabledSessionStarts: async () => options.sessionStarts,
enabledMcpServers: async () => ({}),
enabledHooks: async () => [],
};
}
function findPluginSessionStartMessages(ctx: TestAgentContext) {
return ctx.contextData().history.filter(
(message) =>
message.origin?.kind === 'injection' && message.origin.variant === 'plugin_session_start',
);
}
function messageText(message: { readonly content: readonly { readonly type: string; readonly text?: string }[] }): string {
return message.content.map((part) => (part.type === 'text' ? (part.text ?? '') : '')).join('');
}
describe('PluginSessionStartInjectorService (production wiring)', () => {
let ctx: TestAgentContext | undefined;
afterEach(async () => {
if (ctx !== undefined) await ctx.dispose();
ctx = undefined;
});
it('injects the plugin session-start reminder through the real service during a turn', async () => {
const catalog = new InMemorySkillCatalog();
catalog.register(pluginSkill());
ctx = createTestAgent(
{ autoConfigure: true },
appService(
IPluginService,
pluginServiceStub({ sessionStarts: [{ pluginId: 'demo', skillName: 'demo-skill' }] }),
),
skillServices(catalog),
agentService(
IPluginSessionStartInjectorService,
new SyncDescriptor(PluginSessionStartInjectorService),
),
);
// Force-instantiate the real injector (production does this from createMain).
ctx.get(IPluginSessionStartInjectorService);
ctx.mockNextResponse({ type: 'text', text: 'done' });
await ctx.rpc.prompt({ input: [{ type: 'text', text: 'hello' }] });
await ctx.untilTurnEnd();
const injected = findPluginSessionStartMessages(ctx).at(-1);
expect(injected).toBeDefined();
const text = injected === undefined ? '' : messageText(injected);
expect(text).toContain('<plugin_session_start plugin="demo" skill="demo-skill">');
expect(text).toContain('Do the demo thing.');
expect(text).toContain('Always be helpful.');
});
it('does not inject when no plugin session starts are enabled', async () => {
const catalog = new InMemorySkillCatalog();
catalog.register(pluginSkill());
ctx = createTestAgent(
{ autoConfigure: true },
appService(IPluginService, pluginServiceStub({ sessionStarts: [] })),
skillServices(catalog),
agentService(
IPluginSessionStartInjectorService,
new SyncDescriptor(PluginSessionStartInjectorService),
),
);
ctx.get(IPluginSessionStartInjectorService);
ctx.mockNextResponse({ type: 'text', text: 'done' });
await ctx.rpc.prompt({ input: [{ type: 'text', text: 'hello' }] });
await ctx.untilTurnEnd();
expect(findPluginSessionStartMessages(ctx)).toHaveLength(0);
});
it('re-appends a fresh reminder when plugins are reloaded', async () => {
const catalog = new InMemorySkillCatalog();
catalog.register(pluginSkill());
const reloadEmitter = new Emitter<ReloadSummary>();
ctx = createTestAgent(
{ autoConfigure: true },
appService(
IPluginService,
pluginServiceStub({
sessionStarts: [{ pluginId: 'demo', skillName: 'demo-skill' }],
reloadEmitter,
}),
),
skillServices(catalog),
agentService(
IPluginSessionStartInjectorService,
new SyncDescriptor(PluginSessionStartInjectorService),
),
);
ctx.get(IPluginSessionStartInjectorService);
ctx.mockNextResponse({ type: 'text', text: 'done' });
await ctx.rpc.prompt({ input: [{ type: 'text', text: 'hello' }] });
await ctx.untilTurnEnd();
expect(findPluginSessionStartMessages(ctx)).toHaveLength(1);
// Simulate `pluginService.reloadPlugins()` firing.
reloadEmitter.fire({ added: ['demo'], removed: [], errors: [] });
// appendReminderOnReload is async (awaits skillCatalog.ready); let it settle.
await Promise.resolve();
await Promise.resolve();
const messages = findPluginSessionStartMessages(ctx);
expect(messages.length).toBeGreaterThanOrEqual(2);
const latest = messageText(messages.at(-1)!);
expect(latest).toContain('<plugin_session_start plugin="demo" skill="demo-skill">');
expect(latest).toContain('supersedes any earlier plugin_session_start reminder');
reloadEmitter.dispose();
});
});

View file

@ -0,0 +1,41 @@
import { describe, expect, it } from 'vitest';
import { resolveInstallSource } from '../../src/plugin/source';
describe('resolveInstallSource', () => {
it('resolves absolute local paths', () => {
expect(resolveInstallSource('/tmp/plugin')).toEqual({ kind: 'local-path', path: '/tmp/plugin' });
});
it('resolves zip urls', () => {
expect(resolveInstallSource('https://example.com/plugin.zip')).toEqual({
kind: 'zip-url',
path: 'https://example.com/plugin.zip',
});
});
it('resolves github tree, release tag, and commit urls', () => {
expect(resolveInstallSource('https://github.com/owner/repo/tree/release%231')).toEqual({
kind: 'github',
owner: 'owner',
repo: 'repo',
ref: { kind: 'branch', value: 'release#1' },
});
expect(resolveInstallSource('https://github.com/owner/repo/releases/tag/v1.2.3')).toEqual({
kind: 'github',
owner: 'owner',
repo: 'repo',
ref: { kind: 'tag', value: 'v1.2.3' },
});
expect(resolveInstallSource('https://github.com/owner/repo/commit/abc1234')).toEqual({
kind: 'github',
owner: 'owner',
repo: 'repo',
ref: { kind: 'sha', value: 'abc1234' },
});
});
it('rejects relative paths', () => {
expect(() => resolveInstallSource('./plugin')).toThrow('absolute path');
});
});

View file

@ -74,7 +74,7 @@ function sessionStartRuntime(input: {
});
ctx.configure();
if (input.history !== undefined) {
ctx.context.spliceHistory(0, 0, input.history);
ctx.context.splice(0, 0, input.history);
}
return { ctx, warnings };
}
@ -84,10 +84,17 @@ async function injectDynamic(ctx: ReturnType<typeof testAgent>): Promise<void> {
}
function lastReminder(ctx: ReturnType<typeof testAgent>): string {
const last = ctx.context.getHistory().findLast((message) => message.role === 'user');
const last = ctx.context.get().findLast((message) => message.role === 'user');
return last?.content.map((part) => (part.type === 'text' ? part.text : '')).join('') ?? '';
}
function pluginSessionStartMessages(ctx: ReturnType<typeof testAgent>) {
return ctx.context.get().filter(
(message) =>
message.origin?.kind === 'injection' && message.origin.variant === 'plugin_session_start',
);
}
describe('plugin session-start dynamic injection', () => {
it('injects one <plugin_session_start> block per declared sessionStart on first call', async () => {
const { ctx } = sessionStartRuntime({
@ -109,7 +116,7 @@ describe('plugin session-start dynamic injection', () => {
expect(text).toContain('TodoList');
expect(text).toContain('body of skill');
expect(text).toContain('</plugin_session_start>');
expect(ctx.context.getHistory().at(-1)?.origin).toEqual({
expect(ctx.context.get().at(-1)?.origin).toEqual({
kind: 'injection',
variant: 'plugin_session_start',
});
@ -139,7 +146,7 @@ describe('plugin session-start dynamic injection', () => {
await injectDynamic(ctx);
await injectDynamic(ctx);
expect(ctx.context.getHistory()).toHaveLength(1);
expect(pluginSessionStartMessages(ctx)).toHaveLength(1);
});
it('does not re-inject when a replayed history already contains plugin sessionStart', async () => {
@ -158,7 +165,7 @@ describe('plugin session-start dynamic injection', () => {
await injectDynamic(ctx);
expect(ctx.context.getHistory()).toHaveLength(1);
expect(pluginSessionStartMessages(ctx)).toHaveLength(1);
});
it('skips a sessionStart whose skill is not registered and warns', async () => {
@ -188,7 +195,7 @@ describe('plugin session-start dynamic injection', () => {
await injectDynamic(ctx);
expect(ctx.context.getHistory()).toEqual([]);
expect(ctx.context.get()).toEqual([]);
});
it('resolves sessionStart skills by plugin identity when names collide', async () => {