diff --git a/packages/plugins/sdk/src/host-client-factory.ts b/packages/plugins/sdk/src/host-client-factory.ts index 13f0a5925de..d2ef73a7909 100644 --- a/packages/plugins/sdk/src/host-client-factory.ts +++ b/packages/plugins/sdk/src/host-client-factory.ts @@ -189,6 +189,13 @@ export interface HostServices { managedRun(params: WorkerToHostMethods["routines.managed.run"][0]): Promise; }; + /** Provides `skills.managed.*`. */ + skills: { + managedGet(params: WorkerToHostMethods["skills.managed.get"][0]): Promise; + managedReconcile(params: WorkerToHostMethods["skills.managed.reconcile"][0]): Promise; + managedReset(params: WorkerToHostMethods["skills.managed.reset"][0]): Promise; + }; + /** Provides issue read/write, relation, checkout, wakeup, summary, comment methods. */ issues: { list(params: WorkerToHostMethods["issues.list"][0]): Promise; @@ -367,6 +374,9 @@ const METHOD_CAPABILITY_MAP: Record { + return services.skills.managedGet(params); + }), + "skills.managed.reconcile": gated("skills.managed.reconcile", async (params) => { + return services.skills.managedReconcile(params); + }), + "skills.managed.reset": gated("skills.managed.reset", async (params) => { + return services.skills.managedReset(params); + }), + // Issues "issues.list": gated("issues.list", async (params) => { return services.issues.list(params); diff --git a/packages/plugins/sdk/src/index.ts b/packages/plugins/sdk/src/index.ts index 3d0bd07e19e..2753d72c93e 100644 --- a/packages/plugins/sdk/src/index.ts +++ b/packages/plugins/sdk/src/index.ts @@ -197,6 +197,7 @@ export type { PluginStateClient, PluginEntitiesClient, PluginProjectsClient, + PluginSkillsClient, PluginCompaniesClient, PluginIssuesClient, PluginIssueMutationActor, @@ -268,6 +269,10 @@ export type { PluginManagedProjectResolution, PluginManagedRoutineDeclaration, PluginManagedRoutineResolution, + PluginManagedSkillDeclaration, + PluginManagedSkillFileDeclaration, + PluginManagedSkillResolution, + CompanySkill, PluginManagedResourceKind, PluginManagedResourceRef, PluginUiSlotDeclaration, diff --git a/packages/plugins/sdk/src/protocol.ts b/packages/plugins/sdk/src/protocol.ts index 843f479ba8e..4d887e04a82 100644 --- a/packages/plugins/sdk/src/protocol.ts +++ b/packages/plugins/sdk/src/protocol.ts @@ -27,11 +27,13 @@ import type { IssueComment, IssueDocument, IssueDocumentSummary, + IssueAssigneeAdapterOverrides, IssueThreadInteraction, CreateIssueThreadInteraction, PluginManagedAgentResolution, PluginManagedProjectResolution, PluginManagedRoutineResolution, + PluginManagedSkillResolution, Routine, RoutineRun, Agent, @@ -821,6 +823,18 @@ export interface WorkerToHostMethods { }, result: RoutineRun, ]; + "skills.managed.get": [ + params: { skillKey: string; companyId: string }, + result: PluginManagedSkillResolution, + ]; + "skills.managed.reconcile": [ + params: { skillKey: string; companyId: string }, + result: PluginManagedSkillResolution, + ]; + "skills.managed.reset": [ + params: { skillKey: string; companyId: string }, + result: PluginManagedSkillResolution, + ]; // Issues "issues.list": [ @@ -852,12 +866,12 @@ export interface WorkerToHostMethods { title: string; description?: string; status?: string; - workMode?: string; priority?: string; assigneeAgentId?: string; assigneeUserId?: string | null; requestDepth?: number; billingCode?: string | null; + assigneeAdapterOverrides?: IssueAssigneeAdapterOverrides | null; surfaceVisibility?: string | null; originKind?: string | null; originId?: string | null; diff --git a/packages/plugins/sdk/src/testing.ts b/packages/plugins/sdk/src/testing.ts index fdc81f1c2a6..afef0f78ff8 100644 --- a/packages/plugins/sdk/src/testing.ts +++ b/packages/plugins/sdk/src/testing.ts @@ -7,6 +7,8 @@ import type { PluginIssueOriginKind, PluginManagedAgentResolution, PluginManagedRoutineResolution, + PluginManagedSkillResolution, + CompanySkill, Company, Project, Routine, @@ -997,8 +999,6 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness { updatedByUserId: null, lastTriggeredAt: null, lastEnqueuedAt: null, - latestRevisionId: null, - latestRevisionNumber: 1, createdAt: now, updatedAt: now, managedByPlugin: { @@ -1087,6 +1087,115 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness { }, }, }, + skills: { + managed: { + async get(skillKey, companyId) { + requireCapability(manifest, capabilitySet, "skills.managed"); + const declaration = manifest.skills?.find((skill) => skill.skillKey === skillKey); + if (!declaration) { + return { + pluginKey: manifest.id, + resourceKind: "skill", + resourceKey: skillKey, + companyId, + skillId: null, + skill: null, + status: "missing", + } satisfies PluginManagedSkillResolution; + } + const externalId = `${manifest.id}:skill:${skillKey}`; + const existingEntity = [...entities.values()].find((entity) => + entity.entityType === "managed_resource" + && entity.scopeKind === "company" + && entity.scopeId === companyId + && entity.externalId === externalId + ); + const existingSkill = existingEntity?.data?.skill as CompanySkill | undefined; + if (existingSkill && existingSkill.companyId === companyId) { + return { + pluginKey: manifest.id, + resourceKind: "skill", + resourceKey: skillKey, + companyId, + skillId: existingSkill.id, + skill: existingSkill, + status: "resolved", + } satisfies PluginManagedSkillResolution; + } + return { + pluginKey: manifest.id, + resourceKind: "skill", + resourceKey: skillKey, + companyId, + skillId: null, + skill: null, + status: "missing", + } satisfies PluginManagedSkillResolution; + }, + async reconcile(skillKey, companyId) { + const existing = await this.get(skillKey, companyId); + if (existing.skill) return existing; + const declaration = manifest.skills?.find((skill) => skill.skillKey === skillKey); + if (!declaration) return existing; + const now = new Date(); + const skill = { + id: randomUUID(), + companyId, + key: `plugin/${manifest.id.replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "")}/${skillKey}`, + slug: declaration.slug ?? skillKey, + name: declaration.displayName, + description: declaration.description ?? null, + markdown: declaration.markdown ?? `# ${declaration.displayName}\n`, + sourceType: "catalog", + sourceLocator: null, + sourceRef: null, + trustLevel: "markdown_only", + compatibility: "compatible", + fileInventory: [{ path: "SKILL.md", kind: "skill" }], + metadata: { + sourceKind: "catalog", + pluginManagedResource: { + pluginKey: manifest.id, + resourceKind: "skill", + resourceKey: skillKey, + }, + }, + createdAt: now, + updatedAt: now, + } satisfies CompanySkill; + const nowIso = now.toISOString(); + const record: PluginEntityRecord = { + id: randomUUID(), + entityType: "managed_resource", + scopeKind: "company", + scopeId: companyId, + externalId: `${manifest.id}:skill:${skillKey}`, + title: declaration.displayName, + status: null, + data: { resourceKind: "skill", resourceKey: skillKey, skillId: skill.id, skill }, + createdAt: nowIso, + updatedAt: nowIso, + }; + entities.set(record.id, record); + return { + pluginKey: manifest.id, + resourceKind: "skill", + resourceKey: skillKey, + companyId, + skillId: skill.id, + skill, + status: "created", + } satisfies PluginManagedSkillResolution; + }, + async reset(skillKey, companyId) { + const resolved = await this.reconcile(skillKey, companyId); + return { + ...resolved, + status: resolved.skill ? "reset" : resolved.status, + } satisfies PluginManagedSkillResolution; + }, + }, + }, companies: { async list(input) { requireCapability(manifest, capabilitySet, "companies.read"); @@ -1147,7 +1256,6 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness { title: input.title, description: input.description ?? null, status: input.status ?? "todo", - workMode: input.workMode ?? "standard", priority: input.priority ?? "medium", assigneeAgentId: input.assigneeAgentId ?? null, assigneeUserId: input.assigneeUserId ?? null, @@ -1164,7 +1272,7 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness { originRunId: input.originRunId ?? null, requestDepth: input.requestDepth ?? 0, billingCode: input.billingCode ?? null, - assigneeAdapterOverrides: null, + assigneeAdapterOverrides: input.assigneeAdapterOverrides ?? null, executionWorkspaceId: input.executionWorkspaceId ?? null, executionWorkspacePreference: input.executionWorkspacePreference ?? null, executionWorkspaceSettings: input.executionWorkspaceSettings ?? null, @@ -1261,12 +1369,9 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness { id: randomUUID(), companyId: parentIssue.companyId, issueId, - authorType: options?.authorAgentId ? "agent" : "system", authorAgentId: options?.authorAgentId ?? null, authorUserId: null, body, - presentation: null, - metadata: null, createdAt: now, updatedAt: now, }; diff --git a/packages/plugins/sdk/src/types.ts b/packages/plugins/sdk/src/types.ts index a7b7a1685c3..4df1c7688b8 100644 --- a/packages/plugins/sdk/src/types.ts +++ b/packages/plugins/sdk/src/types.ts @@ -22,6 +22,7 @@ import type { IssueDocument, IssueDocumentSummary, IssueRelationIssueSummary, + IssueAssigneeAdapterOverrides, IssueThreadInteraction, SuggestTasksInteraction, AskUserQuestionsInteraction, @@ -32,6 +33,8 @@ import type { PluginManagedAgentResolution, PluginManagedProjectResolution, PluginManagedRoutineResolution, + PluginManagedSkillResolution, + CompanySkill, Routine, RoutineRun, Agent, @@ -54,6 +57,10 @@ export type { PluginManagedProjectResolution, PluginManagedRoutineDeclaration, PluginManagedRoutineResolution, + PluginManagedSkillDeclaration, + PluginManagedSkillFileDeclaration, + PluginManagedSkillResolution, + CompanySkill, Routine, RoutineRun, PluginLocalFolderDeclaration, @@ -840,6 +847,19 @@ export interface PluginRoutinesClient { }; } +/** + * `ctx.skills` — resolve and reconcile plugin-managed company skills. + * + * Requires `skills.managed` capability. + */ +export interface PluginSkillsClient { + managed: { + get(skillKey: string, companyId: string): Promise; + reconcile(skillKey: string, companyId: string): Promise; + reset(skillKey: string, companyId: string): Promise; + }; +} + /** * `ctx.data` — register `getData` handlers that back `usePluginData()` in the * plugin's frontend components. @@ -1257,12 +1277,12 @@ export interface PluginIssuesClient { title: string; description?: string; status?: Issue["status"]; - workMode?: Issue["workMode"]; priority?: Issue["priority"]; assigneeAgentId?: string; assigneeUserId?: string | null; requestDepth?: number; billingCode?: string | null; + assigneeAdapterOverrides?: IssueAssigneeAdapterOverrides | null; surfaceVisibility?: IssueSurfaceVisibility; originKind?: PluginIssueOriginKind; originId?: string | null; @@ -1281,7 +1301,6 @@ export interface PluginIssuesClient { | "title" | "description" | "status" - | "workMode" | "priority" | "assigneeAgentId" | "assigneeUserId" @@ -1624,6 +1643,9 @@ export interface PluginContext { /** Resolve and reconcile plugin-managed routines. Requires `routines.managed`. */ routines: PluginRoutinesClient; + /** Resolve and reconcile plugin-managed company skills. Requires `skills.managed`. */ + skills: PluginSkillsClient; + /** Read company metadata. Requires `companies.read`. */ companies: PluginCompaniesClient; diff --git a/packages/plugins/sdk/src/worker-rpc-host.ts b/packages/plugins/sdk/src/worker-rpc-host.ts index ad578d609c6..9359fcf70f1 100644 --- a/packages/plugins/sdk/src/worker-rpc-host.ts +++ b/packages/plugins/sdk/src/worker-rpc-host.ts @@ -671,6 +671,20 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost }, }, + skills: { + managed: { + async get(skillKey: string, companyId: string) { + return callHost("skills.managed.get", { skillKey, companyId }); + }, + async reconcile(skillKey: string, companyId: string) { + return callHost("skills.managed.reconcile", { skillKey, companyId }); + }, + async reset(skillKey: string, companyId: string) { + return callHost("skills.managed.reset", { skillKey, companyId }); + }, + }, + }, + companies: { async list(input) { return callHost("companies.list", { @@ -714,12 +728,12 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost title: input.title, description: input.description, status: input.status, - workMode: input.workMode, priority: input.priority, assigneeAgentId: input.assigneeAgentId, assigneeUserId: input.assigneeUserId, requestDepth: input.requestDepth, billingCode: input.billingCode, + assigneeAdapterOverrides: input.assigneeAdapterOverrides, surfaceVisibility: input.surfaceVisibility, originKind: input.originKind, originId: input.originId, diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index 12cf3b0a538..129bb279cd4 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -667,6 +667,7 @@ export const PLUGIN_CAPABILITIES = [ "issue.documents.write", "projects.managed", "routines.managed", + "skills.managed", "agents.pause", "agents.resume", "agents.invoke", diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 4c06b7362d0..b1087d23965 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -551,10 +551,13 @@ export type { PluginManagedAgentDeclaration, PluginManagedProjectDeclaration, PluginManagedRoutineDeclaration, + PluginManagedSkillDeclaration, + PluginManagedSkillFileDeclaration, PluginLocalFolderDeclaration, PluginManagedAgentResolution, PluginManagedProjectResolution, PluginManagedRoutineResolution, + PluginManagedSkillResolution, PluginManagedResourceKind, PluginManagedResourceRef, PluginUiSlotDeclaration, diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index c0c0f1f44f9..bb60527a47b 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -348,10 +348,13 @@ export type { PluginManagedAgentDeclaration, PluginManagedProjectDeclaration, PluginManagedRoutineDeclaration, + PluginManagedSkillDeclaration, + PluginManagedSkillFileDeclaration, PluginLocalFolderDeclaration, PluginManagedAgentResolution, PluginManagedProjectResolution, PluginManagedRoutineResolution, + PluginManagedSkillResolution, PluginManagedResourceKind, PluginManagedResourceRef, PluginUiSlotDeclaration, diff --git a/packages/shared/src/types/plugin.ts b/packages/shared/src/types/plugin.ts index d5231aadff5..3bc776edde8 100644 --- a/packages/shared/src/types/plugin.ts +++ b/packages/shared/src/types/plugin.ts @@ -27,6 +27,7 @@ import type { IssueSurfaceVisibility, } from "../constants.js"; import type { Agent } from "./agent.js"; +import type { CompanySkill } from "./company-skill.js"; import type { Project } from "./project.js"; import type { Routine, RoutineTrigger, RoutineVariable } from "./routine.js"; @@ -164,6 +165,7 @@ export interface PluginManagedAgentDeclaration { instructions?: { entryFile?: string; content?: string; + files?: Record; assetPath?: string; }; } @@ -208,7 +210,33 @@ export interface PluginManagedProjectDeclaration { settings?: Record; } -export type PluginManagedResourceKind = "agent" | "project" | "routine"; +export interface PluginManagedSkillFileDeclaration { + /** Relative path inside the skill folder, for example `references/guide.md`. */ + path: string; + /** File contents written when the skill is installed or reset. */ + content: string; +} + +/** + * Declares a company skill that a plugin can install into each company's + * skills library and later resolve by stable key. + */ +export interface PluginManagedSkillDeclaration { + /** Stable identifier for this managed skill, unique within the plugin. */ + skillKey: string; + /** Suggested visible skill name. */ + displayName: string; + /** Suggested skill slug. Defaults to `skillKey`. */ + slug?: string; + /** Suggested skill description. */ + description?: string | null; + /** Full `SKILL.md` contents. Defaults to generated markdown from display metadata. */ + markdown?: string; + /** Additional files installed with the skill. */ + files?: PluginManagedSkillFileDeclaration[]; +} + +export type PluginManagedResourceKind = "agent" | "project" | "routine" | "skill"; export interface PluginManagedResourceRef { pluginKey?: string; @@ -258,6 +286,10 @@ export interface PluginManagedAgentResolution { agent: Agent | null; status: "missing" | "resolved" | "created" | "relinked" | "reset"; approvalId?: string | null; + defaultDrift?: { + entryFile: string; + changedFiles: string[]; + } | null; } export interface PluginManagedProjectResolution { @@ -281,6 +313,16 @@ export interface PluginManagedRoutineResolution { missingRefs?: PluginManagedResourceRef[]; } +export interface PluginManagedSkillResolution { + pluginKey: string; + resourceKind: "skill"; + resourceKey: string; + companyId: string; + skillId: string | null; + skill: CompanySkill | null; + status: "missing" | "resolved" | "created" | "relinked" | "reset"; +} + /** * Declares a UI extension slot the plugin fills with a React component. * @@ -496,6 +538,8 @@ export interface PaperclipPluginManifestV1 { projects?: PluginManagedProjectDeclaration[]; /** Suggested company-scoped routines this plugin can provision and resolve by stable key. */ routines?: PluginManagedRoutineDeclaration[]; + /** Suggested company skills this plugin can install and resolve by stable key. */ + skills?: PluginManagedSkillDeclaration[]; /** Trusted local folders this plugin can configure and access by stable key. */ localFolders?: PluginLocalFolderDeclaration[]; /** diff --git a/packages/shared/src/validators/index.ts b/packages/shared/src/validators/index.ts index f030762b481..4ef4fe2d2d1 100644 --- a/packages/shared/src/validators/index.ts +++ b/packages/shared/src/validators/index.ts @@ -374,6 +374,8 @@ export { pluginLauncherRenderDeclarationSchema, pluginLauncherDeclarationSchema, pluginDatabaseDeclarationSchema, + pluginManagedSkillFileDeclarationSchema, + pluginManagedSkillDeclarationSchema, pluginApiRouteDeclarationSchema, pluginManifestV1Schema, installPluginSchema, @@ -393,6 +395,8 @@ export { type PluginLauncherRenderDeclarationInput, type PluginLauncherDeclarationInput, type PluginDatabaseDeclarationInput, + type PluginManagedSkillFileDeclarationInput, + type PluginManagedSkillDeclarationInput, type PluginApiRouteDeclarationInput, type PluginManifestV1Input, type InstallPlugin, diff --git a/packages/shared/src/validators/plugin.test.ts b/packages/shared/src/validators/plugin.test.ts index 7de0c31687c..ccea0d6a3f6 100644 --- a/packages/shared/src/validators/plugin.test.ts +++ b/packages/shared/src/validators/plugin.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; import { PLUGIN_CAPABILITIES } from "../constants.js"; -import { pluginManagedRoutineDeclarationSchema, pluginUiSlotDeclarationSchema } from "./plugin.js"; +import { pluginManagedRoutineDeclarationSchema, pluginManifestV1Schema, pluginUiSlotDeclarationSchema } from "./plugin.js"; describe("plugin capability constants", () => { it("exposes each capability once", () => { @@ -30,6 +30,41 @@ describe("plugin managed routine validators", () => { }); }); +describe("plugin managed skill validators", () => { + const baseManifest = { + id: "paperclip.test-managed-skills", + apiVersion: 1, + version: "0.1.0", + displayName: "Managed Skills", + description: "Managed skills test plugin.", + author: "Paperclip", + categories: ["automation"], + entrypoints: { worker: "./dist/worker.js" }, + } as const; + + it("requires skills.managed when managed skills are declared", () => { + const parsed = pluginManifestV1Schema.safeParse({ + ...baseManifest, + capabilities: [], + skills: [{ skillKey: "wiki-maintainer", displayName: "Wiki Maintainer" }], + }); + + expect(parsed.success).toBe(false); + if (parsed.success) return; + expect(parsed.error.issues.some((issue) => issue.message.includes("skills.managed"))).toBe(true); + }); + + it("accepts managed skills with the skills.managed capability", () => { + const parsed = pluginManifestV1Schema.parse({ + ...baseManifest, + capabilities: ["skills.managed"], + skills: [{ skillKey: "wiki-maintainer", displayName: "Wiki Maintainer" }], + }); + + expect(parsed.skills?.[0]?.skillKey).toBe("wiki-maintainer"); + }); +}); + describe("plugin UI slot validators", () => { it("accepts route-scoped sidebar slots with a routePath", () => { const parsed = pluginUiSlotDeclarationSchema.parse({ diff --git a/packages/shared/src/validators/plugin.ts b/packages/shared/src/validators/plugin.ts index 336620a0acb..8d4016cd704 100644 --- a/packages/shared/src/validators/plugin.ts +++ b/packages/shared/src/validators/plugin.ts @@ -151,6 +151,7 @@ export const pluginManagedAgentDeclarationSchema = z.object({ instructions: z.object({ entryFile: z.string().min(1).max(200).optional(), content: z.string().max(200_000).optional(), + files: z.record(z.string().max(200_000)).optional(), assetPath: z.string().min(1).max(500).optional(), }).optional(), }); @@ -172,7 +173,7 @@ export type PluginManagedProjectDeclarationInput = z.infer; +export const pluginManagedSkillFileDeclarationSchema = z.object({ + path: pluginLocalFolderRelativePathSchema.refine( + (value) => value.toLowerCase() !== "skill.md", + { message: "managed skill files cannot replace SKILL.md; use markdown for the main skill file" }, + ), + content: z.string().max(200_000), +}); + +export type PluginManagedSkillFileDeclarationInput = z.infer; + +export const pluginManagedSkillDeclarationSchema = z.object({ + skillKey: z.string().min(1).max(100).regex(/^[a-z0-9][a-z0-9._:-]*$/, { + message: "skillKey must start with a lowercase alphanumeric and contain only lowercase letters, digits, dots, colons, underscores, or hyphens", + }), + displayName: z.string().min(1).max(100), + slug: z.string().min(1).max(100).regex(/^[a-z0-9][a-z0-9._:-]*$/, { + message: "slug must start with a lowercase alphanumeric and contain only lowercase letters, digits, dots, colons, underscores, or hyphens", + }).optional(), + description: z.string().max(2000).nullable().optional(), + markdown: z.string().max(200_000).optional(), + files: z.array(pluginManagedSkillFileDeclarationSchema).max(50).optional(), +}).superRefine((value, ctx) => { + const paths = (value.files ?? []).map((file) => file.path); + const duplicates = paths.filter((path, index) => paths.indexOf(path) !== index); + if (duplicates.length > 0) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `Duplicate managed skill file paths: ${[...new Set(duplicates)].join(", ")}`, + path: ["files"], + }); + } +}); + +export type PluginManagedSkillDeclarationInput = z.infer; + /** * Validates a {@link PluginUiSlotDeclaration} — a UI extension slot the plugin * fills with a React component. Includes `superRefine` checks for slot-specific @@ -589,6 +625,7 @@ export const pluginManifestV1Schema = z.object({ agents: z.array(pluginManagedAgentDeclarationSchema).optional(), projects: z.array(pluginManagedProjectDeclarationSchema).optional(), routines: z.array(pluginManagedRoutineDeclarationSchema).optional(), + skills: z.array(pluginManagedSkillDeclarationSchema).optional(), localFolders: z.array(pluginLocalFolderDeclarationSchema).optional(), launchers: z.array(pluginLauncherDeclarationSchema).optional(), ui: z.object({ @@ -678,6 +715,16 @@ export const pluginManifestV1Schema = z.object({ } } + if (manifest.skills && manifest.skills.length > 0) { + if (!manifest.capabilities.includes("skills.managed")) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Capability 'skills.managed' is required when managed skills are declared", + path: ["capabilities"], + }); + } + } + if (manifest.localFolders && manifest.localFolders.length > 0) { if (!manifest.capabilities.includes("local.folders")) { ctx.addIssue({ @@ -871,6 +918,18 @@ export const pluginManifestV1Schema = z.object({ } } + if (manifest.skills) { + const skillKeys = manifest.skills.map((skill) => skill.skillKey); + const duplicates = skillKeys.filter((key, i) => skillKeys.indexOf(key) !== i); + if (duplicates.length > 0) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `Duplicate managed skill keys: ${[...new Set(duplicates)].join(", ")}`, + path: ["skills"], + }); + } + } + // UI slot ids must be unique within the plugin (namespaced at runtime) if (manifest.ui) { if (manifest.ui.slots) { diff --git a/server/src/__tests__/plugin-managed-skills.test.ts b/server/src/__tests__/plugin-managed-skills.test.ts new file mode 100644 index 00000000000..9d5b7fe9130 --- /dev/null +++ b/server/src/__tests__/plugin-managed-skills.test.ts @@ -0,0 +1,207 @@ +import { randomUUID } from "node:crypto"; +import { eq } from "drizzle-orm"; +import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; +import { + activityLog, + companies, + companySkills, + createDb, + pluginManagedResources, + plugins, +} from "@paperclipai/db"; +import type { PaperclipPluginManifestV1 } from "@paperclipai/shared"; +import { + getEmbeddedPostgresTestSupport, + startEmbeddedPostgresTestDatabase, +} from "./helpers/embedded-postgres.js"; +import { buildHostServices } from "../services/plugin-host-services.js"; + +const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); +const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; + +function createEventBusStub() { + return { + forPlugin() { + return { + emit: async () => {}, + subscribe: () => {}, + }; + }, + } as any; +} + +function issuePrefix(id: string) { + return `T${id.replace(/-/g, "").slice(0, 6).toUpperCase()}`; +} + +function manifest(): PaperclipPluginManifestV1 { + return { + id: "paperclip.managed-skills-test", + apiVersion: 1, + version: "0.1.0", + displayName: "Managed Skills Test", + description: "Test plugin", + author: "Paperclip", + categories: ["automation"], + capabilities: ["skills.managed"], + entrypoints: { worker: "./dist/worker.js" }, + skills: [{ + skillKey: "wiki-maintainer", + displayName: "Wiki Maintainer Skill", + description: "Use LLM Wiki tools to maintain company knowledge.", + files: [{ + path: "references/wiki-style.md", + content: "# Wiki style\n\nKeep pages cited and terse.\n", + }], + }], + }; +} + +if (!embeddedPostgresSupport.supported) { + console.warn( + `Skipping embedded Postgres plugin-managed skill tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`, + ); +} + +describeEmbeddedPostgres("plugin-managed skills", () => { + let db!: ReturnType; + let tempDb: Awaited> | null = null; + + beforeAll(async () => { + tempDb = await startEmbeddedPostgresTestDatabase("paperclip-plugin-managed-skills-"); + db = createDb(tempDb.connectionString); + }, 20_000); + + afterEach(async () => { + await db.delete(activityLog); + await db.delete(pluginManagedResources); + await db.delete(companySkills); + await db.delete(plugins); + await db.delete(companies); + }); + + afterAll(async () => { + await tempDb?.cleanup(); + }); + + async function seedCompanyAndPlugin(pluginManifest = manifest()) { + const companyId = randomUUID(); + const pluginId = randomUUID(); + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix: issuePrefix(companyId), + }); + await db.insert(plugins).values({ + id: pluginId, + pluginKey: pluginManifest.id, + packageName: "@paperclipai/plugin-managed-skills-test", + version: pluginManifest.version, + apiVersion: pluginManifest.apiVersion, + categories: pluginManifest.categories, + manifestJson: pluginManifest, + status: "ready", + installOrder: 1, + }); + const services = buildHostServices(db, pluginId, pluginManifest.id, createEventBusStub(), undefined, { + manifest: pluginManifest, + }); + return { companyId, pluginId, pluginManifest, services }; + } + + it("installs and resolves managed company skills by stable resource key", async () => { + const { companyId, services } = await seedCompanyAndPlugin(); + + const created = await services.skills.managedReconcile({ + companyId, + skillKey: "wiki-maintainer", + }); + + expect(created.status).toBe("created"); + expect(created.skill).toMatchObject({ + name: "Wiki Maintainer Skill", + key: "plugin/paperclip-managed-skills-test/wiki-maintainer", + sourceType: "catalog", + fileInventory: expect.arrayContaining([ + expect.objectContaining({ path: "SKILL.md", kind: "skill" }), + expect.objectContaining({ path: "references/wiki-style.md", kind: "reference" }), + ]), + }); + + const resolved = await services.skills.managedGet({ + companyId, + skillKey: "wiki-maintainer", + }); + expect(resolved.status).toBe("resolved"); + expect(resolved.skillId).toBe(created.skillId); + + const [binding] = await db.select().from(pluginManagedResources); + expect(binding).toMatchObject({ + companyId, + resourceKind: "skill", + resourceKey: "wiki-maintainer", + resourceId: created.skillId, + }); + }); + + it("preserves operator edits during reconcile and restores manifest defaults on reset", async () => { + const { companyId, services } = await seedCompanyAndPlugin(); + const created = await services.skills.managedReconcile({ companyId, skillKey: "wiki-maintainer" }); + expect(created.skillId).toBeTruthy(); + + await db + .update(companySkills) + .set({ + name: "Custom Wiki Skill", + markdown: "# Custom instructions\n", + updatedAt: new Date(), + }) + .where(eq(companySkills.id, created.skillId!)); + + const reconciled = await services.skills.managedReconcile({ companyId, skillKey: "wiki-maintainer" }); + expect(reconciled.status).toBe("resolved"); + expect(reconciled.skill).toMatchObject({ + name: "Custom Wiki Skill", + markdown: "# Custom instructions\n", + }); + + const reset = await services.skills.managedReset({ companyId, skillKey: "wiki-maintainer" }); + expect(reset.status).toBe("reset"); + expect(reset.skill).toMatchObject({ + name: "Wiki Maintainer Skill", + description: "Use LLM Wiki tools to maintain company knowledge.", + }); + expect(reset.skill?.markdown).toContain("key: \"plugin/paperclip-managed-skills-test/wiki-maintainer\""); + }); + + it("adds the canonical managed key to manifest-provided markdown skills", async () => { + const pluginManifest = manifest(); + pluginManifest.skills = [ + ...(pluginManifest.skills ?? []), + { + skillKey: "markdown-skill", + displayName: "Markdown Skill", + markdown: [ + "---", + "name: markdown-skill", + "description: Markdown source without a key.", + "---", + "", + "# Markdown Skill", + "", + "Follow the managed markdown.", + ].join("\n"), + }, + ]; + const { companyId, services } = await seedCompanyAndPlugin(pluginManifest); + + const created = await services.skills.managedReconcile({ companyId, skillKey: "markdown-skill" }); + + expect(created.status).toBe("created"); + expect(created.skill).toMatchObject({ + key: "plugin/paperclip-managed-skills-test/markdown-skill", + name: "markdown-skill", + }); + expect(created.skill?.markdown).toContain("key: \"plugin/paperclip-managed-skills-test/markdown-skill\""); + }); +}); diff --git a/server/src/services/plugin-host-services.ts b/server/src/services/plugin-host-services.ts index e97ab8d0a8c..472210472f9 100644 --- a/server/src/services/plugin-host-services.ts +++ b/server/src/services/plugin-host-services.ts @@ -44,6 +44,7 @@ import { pluginStateStore } from "./plugin-state-store.js"; import { pluginDatabaseService } from "./plugin-database.js"; import { pluginManagedAgentService } from "./plugin-managed-agents.js"; import { pluginManagedRoutineService } from "./plugin-managed-routines.js"; +import { pluginManagedSkillService } from "./plugin-managed-skills.js"; import { assertConfiguredLocalFolder, assertWritableConfiguredLocalFolder, @@ -509,6 +510,11 @@ export function buildHostServices( manifest: options.manifest, pluginWorkerManager: options.pluginWorkerManager, }); + const managedSkills = pluginManagedSkillService(db, { + pluginId, + pluginKey, + manifest: options.manifest, + }); const heartbeat = heartbeatService(db, { pluginWorkerManager: options.pluginWorkerManager, }); @@ -1224,6 +1230,24 @@ export function buildHostServices( }, }, + skills: { + async managedGet(params) { + const companyId = ensureCompanyId(params.companyId); + await ensurePluginAvailableForCompany(companyId); + return managedSkills.get(params.skillKey, companyId); + }, + async managedReconcile(params) { + const companyId = ensureCompanyId(params.companyId); + await ensurePluginAvailableForCompany(companyId); + return managedSkills.reconcile(params.skillKey, companyId); + }, + async managedReset(params) { + const companyId = ensureCompanyId(params.companyId); + await ensurePluginAvailableForCompany(companyId); + return managedSkills.reset(params.skillKey, companyId); + }, + }, + issues: { async list(params) { const companyId = ensureCompanyId(params.companyId); diff --git a/server/src/services/plugin-managed-agents.ts b/server/src/services/plugin-managed-agents.ts index 84a55c3a318..ea95b59ce8c 100644 --- a/server/src/services/plugin-managed-agents.ts +++ b/server/src/services/plugin-managed-agents.ts @@ -126,6 +126,33 @@ function applyInstructionTemplateVariables( return next; } +function declaredInstructionFiles( + declaration: PluginManagedAgentDeclaration, + variables: Record, +) { + const instructionDeclaration = declaration.instructions; + if (!instructionDeclaration?.content && !instructionDeclaration?.files) return null; + + const entryFile = instructionDeclaration.entryFile ?? "AGENTS.md"; + const files = { ...(instructionDeclaration.files ?? {}) }; + if (instructionDeclaration.content !== undefined) { + files[entryFile] = instructionDeclaration.content; + } + if (files[entryFile] === undefined) { + files[entryFile] = ""; + } + + return { + entryFile, + files: Object.fromEntries( + Object.entries(files).map(([filePath, content]) => [ + filePath, + applyInstructionTemplateVariables(content, variables), + ]), + ), + }; +} + function rowIsManagedAgent( row: typeof agents.$inferSelect, pluginKey: string, @@ -299,19 +326,18 @@ export function pluginManagedAgentService( companyId: string, agent: Agent, declaration: PluginManagedAgentDeclaration, - options: { replaceExisting: boolean }, + materializeOptions: { replaceExisting: boolean }, ): Promise { - const instructionDeclaration = declaration.instructions; - if (!instructionDeclaration?.content) return agent; - - const entryFile = instructionDeclaration.entryFile ?? "AGENTS.md"; const variables = await optionsForInstructionVariables(companyId); + const declared = declaredInstructionFiles(declaration, variables); + if (!declared) return agent; + const materialized = await instructions.materializeManagedBundle( agent, - { [entryFile]: applyInstructionTemplateVariables(instructionDeclaration.content, variables) }, + declared.files, { - entryFile, - replaceExisting: options.replaceExisting, + entryFile: declared.entryFile, + replaceExisting: materializeOptions.replaceExisting, clearLegacyPromptTemplate: true, }, ); @@ -325,6 +351,33 @@ export function pluginManagedAgentService( return (updated as Agent | null) ?? { ...agent, adapterConfig: materialized.adapterConfig }; } + async function managedInstructionDefaultDrift( + companyId: string, + agent: Agent | null, + declaration: PluginManagedAgentDeclaration, + ): Promise { + if (!agent) return null; + const variables = await optionsForInstructionVariables(companyId); + const declared = declaredInstructionFiles(declaration, variables); + if (!declared) return null; + + let exported: Awaited>; + try { + exported = await instructions.exportFiles(agent); + } catch { + return { entryFile: declared.entryFile, changedFiles: [declared.entryFile] }; + } + + const paths = new Set([...Object.keys(declared.files), ...Object.keys(exported.files)]); + const changedFiles = [...paths] + .filter((filePath) => (exported.files[filePath] ?? null) !== (declared.files[filePath] ?? null)) + .sort((left, right) => left.localeCompare(right)); + if (exported.entryFile !== declared.entryFile && !changedFiles.includes(declared.entryFile)) { + changedFiles.unshift(declared.entryFile); + } + return changedFiles.length > 0 ? { entryFile: declared.entryFile, changedFiles } : null; + } + async function optionsForInstructionVariables(companyId: string) { return options.instructionTemplateVariables ? options.instructionTemplateVariables(companyId) : {}; } @@ -333,13 +386,13 @@ export function pluginManagedAgentService( return options.pluginKey; } - function resolution( + async function resolution( companyId: string, declaration: PluginManagedAgentDeclaration, agent: Agent | null, status: PluginManagedAgentResolution["status"], approvalId?: string | null, - ): PluginManagedAgentResolution { + ): Promise { return { pluginKey: options.pluginKey, resourceKind: "agent", @@ -349,6 +402,7 @@ export function pluginManagedAgentService( agent, status, approvalId: approvalId ?? null, + defaultDrift: await managedInstructionDefaultDrift(companyId, agent, declaration), }; } diff --git a/server/src/services/plugin-managed-skills.ts b/server/src/services/plugin-managed-skills.ts new file mode 100644 index 00000000000..8b673b0da70 --- /dev/null +++ b/server/src/services/plugin-managed-skills.ts @@ -0,0 +1,279 @@ +import { and, eq } from "drizzle-orm"; +import type { Db } from "@paperclipai/db"; +import { + pluginManagedResources, +} from "@paperclipai/db"; +import { normalizeAgentUrlKey } from "@paperclipai/shared"; +import type { + CompanySkill, + PaperclipPluginManifestV1, + PluginManagedSkillDeclaration, + PluginManagedSkillResolution, +} from "@paperclipai/shared"; +import { notFound } from "../errors.js"; +import { logActivity } from "./activity-log.js"; +import { companySkillService } from "./company-skills.js"; + +const MANAGED_SKILL_RESOURCE_KIND = "skill"; + +interface PluginManagedSkillServiceOptions { + pluginId: string; + pluginKey: string; + manifest?: PaperclipPluginManifestV1 | null; +} + +function pluginKeySlug(pluginKey: string) { + return normalizeAgentUrlKey(pluginKey) ?? "plugin"; +} + +function canonicalSkillKey(pluginKey: string, skillKey: string) { + return `plugin/${pluginKeySlug(pluginKey)}/${skillKey}`; +} + +function yamlString(value: string) { + return JSON.stringify(value); +} + +function buildDefaultMarkdown( + pluginKey: string, + declaration: PluginManagedSkillDeclaration, +) { + const description = declaration.description?.trim() || `${declaration.displayName} plugin skill.`; + return [ + "---", + `name: ${yamlString(declaration.displayName)}`, + `description: ${yamlString(description)}`, + `key: ${yamlString(canonicalSkillKey(pluginKey, declaration.skillKey))}`, + "---", + "", + `# ${declaration.displayName}`, + "", + description, + "", + ].join("\n"); +} + +function withManagedSkillKey(markdown: string, canonicalKey: string) { + const keyLine = `key: ${yamlString(canonicalKey)}`; + const normalized = markdown.replace(/\r\n/g, "\n"); + const frontmatter = /^---\n([\s\S]*?)\n---(\n?)/.exec(normalized); + if (!frontmatter) { + return [ + "---", + keyLine, + "---", + "", + normalized, + ].join("\n"); + } + + const currentBody = frontmatter[1] ?? ""; + const nextBody = /^key\s*:/m.test(currentBody) + ? currentBody.replace(/^key\s*:.*$/m, keyLine) + : [currentBody, keyLine].filter(Boolean).join("\n"); + return `---\n${nextBody}\n---${frontmatter[2] ?? ""}${normalized.slice(frontmatter[0].length)}`; +} + +function buildPackageFiles( + pluginKey: string, + declaration: PluginManagedSkillDeclaration, +) { + const root = declaration.skillKey; + const canonicalKey = canonicalSkillKey(pluginKey, declaration.skillKey); + const files: Record = { + [`${root}/SKILL.md`]: declaration.markdown?.trim() + ? withManagedSkillKey(declaration.markdown, canonicalKey) + : buildDefaultMarkdown(pluginKey, declaration), + }; + for (const file of declaration.files ?? []) { + files[`${root}/${file.path}`] = file.content; + } + return files; +} + +function buildSkillDefaults( + pluginKey: string, + declaration: PluginManagedSkillDeclaration, +) { + return { + skillKey: declaration.skillKey, + displayName: declaration.displayName, + slug: declaration.slug ?? declaration.skillKey, + description: declaration.description ?? null, + canonicalKey: canonicalSkillKey(pluginKey, declaration.skillKey), + files: [ + "SKILL.md", + ...(declaration.files ?? []).map((file) => file.path), + ], + }; +} + +function resolution( + pluginKey: string, + companyId: string, + declaration: PluginManagedSkillDeclaration, + skill: CompanySkill | null, + status: PluginManagedSkillResolution["status"], +): PluginManagedSkillResolution { + return { + pluginKey, + resourceKind: "skill", + resourceKey: declaration.skillKey, + companyId, + skillId: skill?.id ?? null, + skill, + status, + }; +} + +export function pluginManagedSkillService( + db: Db, + options: PluginManagedSkillServiceOptions, +) { + const skills = companySkillService(db); + + function declarationFor(skillKey: string) { + const declaration = options.manifest?.skills?.find((skill) => skill.skillKey === skillKey); + if (!declaration) { + throw notFound(`Managed skill declaration not found: ${skillKey}`); + } + return declaration; + } + + async function getBinding(companyId: string, skillKey: string) { + return db + .select() + .from(pluginManagedResources) + .where(and( + eq(pluginManagedResources.companyId, companyId), + eq(pluginManagedResources.pluginId, options.pluginId), + eq(pluginManagedResources.resourceKind, MANAGED_SKILL_RESOURCE_KIND), + eq(pluginManagedResources.resourceKey, skillKey), + )) + .then((rows) => rows[0] ?? null); + } + + async function upsertBinding( + companyId: string, + declaration: PluginManagedSkillDeclaration, + skillId: string, + ) { + const defaultsJson = buildSkillDefaults(options.pluginKey, declaration); + const existing = await getBinding(companyId, declaration.skillKey); + if (existing) { + return db + .update(pluginManagedResources) + .set({ + resourceId: skillId, + defaultsJson, + updatedAt: new Date(), + }) + .where(eq(pluginManagedResources.id, existing.id)) + .returning() + .then((rows) => rows[0]); + } + return db + .insert(pluginManagedResources) + .values({ + companyId, + pluginId: options.pluginId, + pluginKey: options.pluginKey, + resourceKind: MANAGED_SKILL_RESOURCE_KIND, + resourceKey: declaration.skillKey, + resourceId: skillId, + defaultsJson, + }) + .returning() + .then((rows) => rows[0]); + } + + async function getSkill(companyId: string, skillId: string) { + return skills.getById(companyId, skillId); + } + + async function importDeclaredSkill( + companyId: string, + declaration: PluginManagedSkillDeclaration, + mode: "reconcile" | "reset", + ) { + const beforeByKey = mode === "reconcile" + ? await skills.getByKey(companyId, canonicalSkillKey(options.pluginKey, declaration.skillKey)) + : null; + const results = await skills.importPackageFiles( + companyId, + buildPackageFiles(options.pluginKey, declaration), + { onConflict: "replace" }, + ); + const imported = results.find((result) => + result.skill.key === canonicalSkillKey(options.pluginKey, declaration.skillKey) + || result.originalSlug === (declaration.slug ?? declaration.skillKey) + || result.originalSlug === declaration.skillKey + )?.skill ?? results[0]?.skill ?? null; + if (!imported) { + throw notFound(`Managed skill was not imported: ${declaration.skillKey}`); + } + await upsertBinding(companyId, declaration, imported.id); + const status: PluginManagedSkillResolution["status"] = mode === "reset" + ? "reset" + : beforeByKey + ? "relinked" + : "created"; + return { skill: imported, status }; + } + + async function get(skillKey: string, companyId: string) { + const declaration = declarationFor(skillKey); + const binding = await getBinding(companyId, skillKey); + if (!binding) return resolution(options.pluginKey, companyId, declaration, null, "missing"); + const skill = await getSkill(companyId, binding.resourceId); + return resolution(options.pluginKey, companyId, declaration, skill, skill ? "resolved" : "missing"); + } + + async function reconcile(skillKey: string, companyId: string) { + const declaration = declarationFor(skillKey); + const current = await get(skillKey, companyId); + if (current.skill) { + await upsertBinding(companyId, declaration, current.skill.id); + return current; + } + const imported = await importDeclaredSkill(companyId, declaration, "reconcile"); + await logActivity(db, { + companyId, + actorType: "plugin", + actorId: options.pluginId, + action: "plugin.managed_skill.reconciled", + entityType: "company_skill", + entityId: imported.skill.id, + details: { + sourcePluginKey: options.pluginKey, + managedResourceKey: declaration.skillKey, + status: imported.status, + }, + }); + return resolution(options.pluginKey, companyId, declaration, imported.skill, imported.status); + } + + async function reset(skillKey: string, companyId: string) { + const declaration = declarationFor(skillKey); + const imported = await importDeclaredSkill(companyId, declaration, "reset"); + await logActivity(db, { + companyId, + actorType: "plugin", + actorId: options.pluginId, + action: "plugin.managed_skill.reset", + entityType: "company_skill", + entityId: imported.skill.id, + details: { + sourcePluginKey: options.pluginKey, + managedResourceKey: declaration.skillKey, + }, + }); + return resolution(options.pluginKey, companyId, declaration, imported.skill, "reset"); + } + + return { + get, + reconcile, + reset, + }; +}