mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-08-21 06:35:50 +00:00
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:
parent
97874de529
commit
cc3422c77a
22 changed files with 3225 additions and 6 deletions
158
packages/agent-core-v2/src/contextInjector/pluginSessionStart.ts
Normal file
158
packages/agent-core-v2/src/contextInjector/pluginSessionStart.ts
Normal 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',
|
||||
);
|
||||
148
packages/agent-core-v2/src/plugin/archive.ts
Normal file
148
packages/agent-core-v2/src/plugin/archive.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
79
packages/agent-core-v2/src/plugin/commands.ts
Normal file
79
packages/agent-core-v2/src/plugin/commands.ts
Normal 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);
|
||||
}
|
||||
100
packages/agent-core-v2/src/plugin/github-resolver.ts
Normal file
100
packages/agent-core-v2/src/plugin/github-resolver.ts
Normal 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('/');
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
557
packages/agent-core-v2/src/plugin/manager.ts
Normal file
557
packages/agent-core-v2/src/plugin/manager.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
487
packages/agent-core-v2/src/plugin/manifest.ts
Normal file
487
packages/agent-core-v2/src/plugin/manifest.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
67
packages/agent-core-v2/src/plugin/plugin.ts
Normal file
67
packages/agent-core-v2/src/plugin/plugin.ts
Normal 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');
|
||||
123
packages/agent-core-v2/src/plugin/pluginService.ts
Normal file
123
packages/agent-core-v2/src/plugin/pluginService.ts
Normal 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',
|
||||
);
|
||||
86
packages/agent-core-v2/src/plugin/source.ts
Normal file
86
packages/agent-core-v2/src/plugin/source.ts
Normal 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('/');
|
||||
}
|
||||
54
packages/agent-core-v2/src/plugin/store.ts
Normal file
54
packages/agent-core-v2/src/plugin/store.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
78
packages/agent-core-v2/src/rpc/prompt-metadata.ts
Normal file
78
packages/agent-core-v2/src/rpc/prompt-metadata.ts
Normal 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);
|
||||
}
|
||||
36
packages/agent-core-v2/test/plugin/archive.test.ts
Normal file
36
packages/agent-core-v2/test/plugin/archive.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
73
packages/agent-core-v2/test/plugin/commands.test.ts
Normal file
73
packages/agent-core-v2/test/plugin/commands.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
124
packages/agent-core-v2/test/plugin/github-resolver.test.ts
Normal file
124
packages/agent-core-v2/test/plugin/github-resolver.test.ts
Normal 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>/);
|
||||
});
|
||||
});
|
||||
521
packages/agent-core-v2/test/plugin/manager-consumption.test.ts
Normal file
521
packages/agent-core-v2/test/plugin/manager-consumption.test.ts
Normal 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 });
|
||||
});
|
||||
});
|
||||
200
packages/agent-core-v2/test/plugin/manager.test.ts
Normal file
200
packages/agent-core-v2/test/plugin/manager.test.ts
Normal 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 })]);
|
||||
});
|
||||
});
|
||||
65
packages/agent-core-v2/test/plugin/manifest.test.ts
Normal file
65
packages/agent-core-v2/test/plugin/manifest.test.ts
Normal 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")',
|
||||
]);
|
||||
});
|
||||
});
|
||||
171
packages/agent-core-v2/test/plugin/plugin-session-start.test.ts
Normal file
171
packages/agent-core-v2/test/plugin/plugin-session-start.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
41
packages/agent-core-v2/test/plugin/source.test.ts
Normal file
41
packages/agent-core-v2/test/plugin/source.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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 () => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue