diff --git a/packages/agent-core/src/config/workspace-local.ts b/packages/agent-core/src/config/workspace-local.ts index 337ae76a0c..4289f5737d 100644 --- a/packages/agent-core/src/config/workspace-local.ts +++ b/packages/agent-core/src/config/workspace-local.ts @@ -12,6 +12,7 @@ const WorkspaceLocalTomlSchema = z.object({ workspace: z .object({ additional_dir: z.array(z.string()), + plugin_dir: z.array(z.string()).optional(), }) .optional(), }); @@ -22,6 +23,7 @@ export interface WorkspaceAdditionalDirsLoadResult { readonly projectRoot: string; readonly configPath: string; readonly additionalDirs: readonly string[]; + readonly pluginDirs: readonly string[]; readonly warning?: string; } @@ -41,14 +43,20 @@ export async function loadWorkspaceLocalConfig( const file = await readWorkspaceLocalToml(kaos, configPath); const additionalDirs = file?.parsed.workspace?.additional_dir; - if (additionalDirs === undefined) { - return { projectRoot, configPath, additionalDirs: [] }; + const pluginDirs = file?.parsed.workspace?.plugin_dir; + if (additionalDirs === undefined && pluginDirs === undefined) { + return { projectRoot, configPath, additionalDirs: [], pluginDirs: [] }; } return { projectRoot, configPath, - additionalDirs: await resolveAdditionalDirs(kaos, projectRoot, additionalDirs), + additionalDirs: additionalDirs !== undefined + ? await resolveAdditionalDirs(kaos, projectRoot, additionalDirs) + : [], + pluginDirs: pluginDirs !== undefined + ? await resolvePluginDirs(kaos, projectRoot, pluginDirs) + : [], }; } @@ -81,7 +89,7 @@ export async function appendWorkspaceAdditionalDir( const fileExistingDirs = resolveExistingAdditionalDirs(kaos, projectRoot, fileAdditionalDirs); if (hasSameAdditionalDir(kaos, fileExistingDirs, additionalDir)) { - return { projectRoot, configPath, additionalDirs: fileExistingDirs }; + return { projectRoot, configPath, additionalDirs: fileExistingDirs, pluginDirs: [] }; } const workspace = cloneRecord(file.raw['workspace']); @@ -91,7 +99,7 @@ export async function appendWorkspaceAdditionalDir( await kaos.mkdir(dirname(configPath), { parents: true, existOk: true }); await kaos.writeText(configPath, `${stringifyToml(file.raw)}\n`); - return { projectRoot, configPath, additionalDirs: [...fileExistingDirs, additionalDir] }; + return { projectRoot, configPath, additionalDirs: [...fileExistingDirs, additionalDir], pluginDirs: [] }; } export function normalizeAdditionalDirs(additionalDirs: readonly string[]): string[] { @@ -182,6 +190,9 @@ function describeWorkspaceLocalValidationError(error: z.ZodError): string { if (issue?.path[0] === 'workspace' && issue.path[1] === 'additional_dir') { return 'workspace.additional_dir must be an array of strings'; } + if (issue?.path[0] === 'workspace' && issue.path[1] === 'plugin_dir') { + return 'workspace.plugin_dir must be an array of strings'; + } if (issue?.path[0] === 'workspace') return 'workspace must be a table'; return `Invalid workspace local config: ${error.message}`; } @@ -202,6 +213,22 @@ async function resolveAdditionalDirs( return resolvedDirs; } +async function resolvePluginDirs( + kaos: Kaos, + projectRoot: string, + pluginDirs: readonly string[], +): Promise { + const resolvedDirs: string[] = []; + + for (const pluginDir of normalizeAdditionalDirs(pluginDirs)) { + const resolvedDir = resolvePath(kaos, projectRoot, pluginDir); + if (hasSameAdditionalDir(kaos, resolvedDirs, resolvedDir)) continue; + resolvedDirs.push(resolvedDir); + } + + return resolvedDirs; +} + function resolveExistingAdditionalDirs( kaos: Kaos, projectRoot: string, diff --git a/packages/agent-core/src/plugin/index.ts b/packages/agent-core/src/plugin/index.ts index 5722be5bda..656baa5c21 100644 --- a/packages/agent-core/src/plugin/index.ts +++ b/packages/agent-core/src/plugin/index.ts @@ -8,3 +8,4 @@ export type { PluginManagerOptions } from './manager'; export { resolveInstallSource } from './source'; export type { InstallSource, ResolvedSource } from './source'; export { downloadZip, extractZip } from './archive'; +export { scanProjectPluginDirs } from './project-plugins'; diff --git a/packages/agent-core/src/plugin/project-plugins.ts b/packages/agent-core/src/plugin/project-plugins.ts new file mode 100644 index 0000000000..b9e4b29ff2 --- /dev/null +++ b/packages/agent-core/src/plugin/project-plugins.ts @@ -0,0 +1,55 @@ +import type { Kaos } from '@moonshot-ai/kaos'; +import { join } from 'pathe'; + +import type { SkillRoot } from '../skill/types'; +import type { PluginManifest } from './types'; + +/** + * Scan project-level plugin directories for plugins. + * Each directory is expected to contain plugin subdirectories with + * `kimi-plugin.json` manifest files. + */ +export async function scanProjectPluginDirs( + kaos: Kaos, + pluginDirs: readonly string[], +): Promise { + const roots: SkillRoot[] = []; + + for (const pluginDir of pluginDirs) { + try { + for await (const entryPath of kaos.iterdir(pluginDir)) { + const manifestPath = join(entryPath, 'kimi-plugin.json'); + const manifest = await readProjectPluginManifest(kaos, manifestPath); + if (manifest === undefined || manifest.skills === undefined) continue; + for (const skillPath of manifest.skills) { + roots.push({ + path: skillPath, + source: 'extra', + plugin: { + id: manifest.name, + instructions: manifest.skillInstructions, + }, + }); + } + } + } catch { + // Skip directories that can't be read + } + } + + return roots; +} + +async function readProjectPluginManifest( + kaos: Kaos, + manifestPath: string, +): Promise { + try { + const text = await kaos.readText(manifestPath); + const parsed = JSON.parse(text) as PluginManifest; + if (typeof parsed.name !== 'string') return undefined; + return parsed; + } catch { + return undefined; + } +} diff --git a/packages/agent-core/src/rpc/core-impl.ts b/packages/agent-core/src/rpc/core-impl.ts index 2fdc740cbf..4621b2c3d4 100644 --- a/packages/agent-core/src/rpc/core-impl.ts +++ b/packages/agent-core/src/rpc/core-impl.ts @@ -3,7 +3,8 @@ import { homedir } from 'node:os'; import { ErrorCodes, KimiError } from '#/errors'; import { getRootLogger, log } from '#/logging/logger'; -import { PluginManager } from '#/plugin'; +import { PluginManager, scanProjectPluginDirs } from '#/plugin'; +import type { SkillRoot } from '../skill/types'; import { LocalFetchURLProvider } from '#/tools/providers/local-fetch-url'; import { MoonshotFetchURLProvider } from '#/tools/providers/moonshot-fetch-url'; import { MoonshotWebSearchProvider } from '#/tools/providers/moonshot-web-search'; @@ -302,6 +303,11 @@ export class KimiCore implements PromisableMethods { const pluginCommands = await this.plugins.enabledCommands(); const mcpConfig = this.mergePluginMcpConfig(withCallerMcp); + // Scan project-level plugin directories from workspace local config + const projectPluginSkillRoots = localWorkspaceDirs.pluginDirs.length > 0 + ? await scanProjectPluginDirs(persistenceKaos, localWorkspaceDirs.pluginDirs) + : []; + // Session ctor attaches its own log sink. If anything in the setup-after- // ctor block throws, `session.close()` releases the sink (and mcp). const runtime = await this.resolveRuntime(config); @@ -318,7 +324,7 @@ export class KimiCore implements PromisableMethods { background: sessionConfig.background, hooks: [...(config.hooks ?? []), ...this.plugins.enabledHooks()], permissionRules: config.permission?.rules, - skills: this.resolveSessionSkillConfig(config), + skills: this.resolveSessionSkillConfig(config, projectPluginSkillRoots), mcpConfig, experimentalFlags: this.experimentalFlags, imageLimits: this.imageLimits, @@ -439,9 +445,16 @@ export class KimiCore implements PromisableMethods { const pluginSessionStarts = this.plugins.enabledSessionStarts(); const pluginCommands = await this.plugins.enabledCommands(); const mcpConfig = this.mergePluginMcpConfig(withCallerMcp); + const runtime = await this.resolveRuntime(config); const parentKaos = parentKaosForRead; const persistenceKaos = overrides.persistenceKaos ?? parentKaos; + + // Scan project-level plugin directories from workspace local config + const projectPluginSkillRoots = localWorkspaceDirs.pluginDirs.length > 0 + ? await scanProjectPluginDirs(persistenceKaos, localWorkspaceDirs.pluginDirs) + : []; + const session = new Session({ kaos: parentKaos.withCwd(summary.workDir), persistenceKaos, @@ -455,7 +468,7 @@ export class KimiCore implements PromisableMethods { background: sessionConfig.background, hooks: [...(config.hooks ?? []), ...this.plugins.enabledHooks()], permissionRules: config.permission?.rules, - skills: this.resolveSessionSkillConfig(config), + skills: this.resolveSessionSkillConfig(config, projectPluginSkillRoots), mcpConfig, experimentalFlags: this.experimentalFlags, imageLimits: this.imageLimits, @@ -1010,14 +1023,20 @@ export class KimiCore implements PromisableMethods { return this.kaos; } - private resolveSessionSkillConfig(config: KimiConfig): SessionSkillConfig { + private resolveSessionSkillConfig( + config: KimiConfig, + extraProjectSkillRoots?: readonly SkillRoot[], + ): SessionSkillConfig { const explicitDirs = this.skillDirs.length > 0 ? this.skillDirs : undefined; + const pluginSkillRoots = this.plugins.pluginSkillRoots(); return { userHomeDir: this.userHomeDir, brandHomeDir: this.homeDir, explicitDirs, extraDirs: config.extraSkillDirs, - pluginSkillRoots: this.plugins.pluginSkillRoots(), + pluginSkillRoots: extraProjectSkillRoots !== undefined + ? [...pluginSkillRoots, ...extraProjectSkillRoots] + : pluginSkillRoots, mergeAllAvailableSkills: config.mergeAllAvailableSkills, }; } diff --git a/packages/agent-core/src/session/index.ts b/packages/agent-core/src/session/index.ts index 2442b900b1..c0622316b4 100644 --- a/packages/agent-core/src/session/index.ts +++ b/packages/agent-core/src/session/index.ts @@ -286,6 +286,7 @@ export class Session { projectRoot: workspace.projectRoot, configPath: workspace.configPath, additionalDirs: nextAdditionalDirs, + pluginDirs: [], persisted: false, }; } diff --git a/packages/agent-core/test/config/workspace-local.test.ts b/packages/agent-core/test/config/workspace-local.test.ts index 202b591364..979aacdb9d 100644 --- a/packages/agent-core/test/config/workspace-local.test.ts +++ b/packages/agent-core/test/config/workspace-local.test.ts @@ -48,6 +48,7 @@ describe('workspace local config', () => { projectRoot: root, configPath: join(root, '.kimi-code', 'local.toml'), additionalDirs: [], + pluginDirs: [], }); }); @@ -68,6 +69,7 @@ describe('workspace local config', () => { projectRoot: root, configPath: join(root, '.kimi-code', 'local.toml'), additionalDirs: [sharedDir, otherDir], + pluginDirs: [], }); });