diff --git a/packages/adapter-utils/src/server-utils.ts b/packages/adapter-utils/src/server-utils.ts index 44eb5bbd..392e6073 100644 --- a/packages/adapter-utils/src/server-utils.ts +++ b/packages/adapter-utils/src/server-utils.ts @@ -133,12 +133,19 @@ export interface PaperclipSkillEntry { key: string; runtimeName: string; source: string; + versionId?: string | null; + currentVersionId?: string | null; sourceStatus?: "available" | "missing"; missingDetail?: string | null; required?: boolean; requiredReason?: string | null; } +export interface PaperclipDesiredSkillEntry { + key: string; + versionId: string | null; +} + export interface InstalledSkillTarget { targetPath: string | null; kind: "symlink" | "directory" | "file"; @@ -1446,6 +1453,8 @@ export function buildRuntimeMountedSkillSnapshot( entries.push({ key: available.key, runtimeName: available.runtimeName, + versionId: available.versionId ?? null, + currentVersionId: available.currentVersionId ?? null, desired, managed: true, state: "missing", @@ -1463,6 +1472,8 @@ export function buildRuntimeMountedSkillSnapshot( entries.push({ key: available.key, runtimeName: available.runtimeName, + versionId: available.versionId ?? null, + currentVersionId: available.currentVersionId ?? null, desired, managed: true, state: configured ? "configured" : "available", @@ -1528,6 +1539,10 @@ export function buildRuntimeMountedSkillSnapshot( supported, mode, desiredSkills, + desiredSkillEntries: desiredSkills.map((key) => ({ + key, + versionId: availableByKey.get(key)?.versionId ?? null, + })), entries, warnings, }; @@ -1560,6 +1575,8 @@ export function buildPersistentSkillSnapshot( entries.push({ key: available.key, runtimeName: available.runtimeName, + versionId: available.versionId ?? null, + currentVersionId: available.currentVersionId ?? null, desired, managed: true, state: "missing", @@ -1595,6 +1612,8 @@ export function buildPersistentSkillSnapshot( entries.push({ key: available.key, runtimeName: available.runtimeName, + versionId: available.versionId ?? null, + currentVersionId: available.currentVersionId ?? null, desired, managed, state, @@ -1650,6 +1669,10 @@ export function buildPersistentSkillSnapshot( supported: true, mode: "persistent", desiredSkills, + desiredSkillEntries: desiredSkills.map((key) => ({ + key, + versionId: availableByKey.get(key)?.versionId ?? null, + })), entries, warnings, }; @@ -1668,6 +1691,14 @@ function normalizeConfiguredPaperclipRuntimeSkills(value: unknown): PaperclipSki key, runtimeName, source, + versionId: + typeof entry.versionId === "string" && entry.versionId.trim().length > 0 + ? entry.versionId.trim() + : null, + currentVersionId: + typeof entry.currentVersionId === "string" && entry.currentVersionId.trim().length > 0 + ? entry.currentVersionId.trim() + : null, sourceStatus: entry.sourceStatus === "missing" ? "missing" : "available", missingDetail: typeof entry.missingDetail === "string" && entry.missingDetail.trim().length > 0 @@ -1714,22 +1745,41 @@ export async function readPaperclipSkillMarkdown( export function readPaperclipSkillSyncPreference(config: Record): { explicit: boolean; desiredSkills: string[]; + desiredSkillEntries: PaperclipDesiredSkillEntry[]; } { const raw = config.paperclipSkillSync; if (typeof raw !== "object" || raw === null || Array.isArray(raw)) { - return { explicit: false, desiredSkills: [] }; + return { explicit: false, desiredSkills: [], desiredSkillEntries: [] }; } const syncConfig = raw as Record; const desiredValues = syncConfig.desiredSkills; const desired = Array.isArray(desiredValues) - ? desiredValues - .filter((value): value is string => typeof value === "string") - .map((value) => value.trim()) - .filter(Boolean) + ? desiredValues.flatMap((value): PaperclipDesiredSkillEntry[] => { + if (typeof value === "string") { + const key = value.trim(); + return key ? [{ key, versionId: null }] : []; + } + if (typeof value === "object" && value !== null && !Array.isArray(value)) { + const record = value as Record; + const key = typeof record.key === "string" ? record.key.trim() : ""; + if (!key) return []; + const versionId = typeof record.versionId === "string" && record.versionId.trim() + ? record.versionId.trim() + : null; + return [{ key, versionId }]; + } + return []; + }) : []; + const byKey = new Map(); + for (const entry of desired) { + if (!byKey.has(entry.key)) byKey.set(entry.key, entry); + } + const desiredSkillEntries = Array.from(byKey.values()); return { explicit: Object.prototype.hasOwnProperty.call(raw, "desiredSkills"), - desiredSkills: Array.from(new Set(desired)), + desiredSkills: desiredSkillEntries.map((entry) => entry.key), + desiredSkillEntries, }; } @@ -1775,7 +1825,7 @@ export function resolvePaperclipDesiredSkillNames( export function writePaperclipSkillSyncPreference( config: Record, - desiredSkills: string[], + desiredSkills: Array, ): Record { const next = { ...config }; const raw = next.paperclipSkillSync; @@ -1783,13 +1833,23 @@ export function writePaperclipSkillSyncPreference( typeof raw === "object" && raw !== null && !Array.isArray(raw) ? { ...(raw as Record) } : {}; - current.desiredSkills = Array.from( - new Set( - desiredSkills - .map((value) => value.trim()) - .filter(Boolean), - ), - ); + const entries = desiredSkills.flatMap((value): PaperclipDesiredSkillEntry[] => { + if (typeof value === "string") { + const key = value.trim(); + return key ? [{ key, versionId: null }] : []; + } + const key = value.key.trim(); + if (!key) return []; + return [{ key, versionId: value.versionId ?? null }]; + }); + const byKey = new Map(); + for (const entry of entries) { + if (!byKey.has(entry.key)) byKey.set(entry.key, entry); + } + const normalized = Array.from(byKey.values()); + current.desiredSkills = normalized.some((entry) => entry.versionId) + ? normalized + : normalized.map((entry) => entry.key); next.paperclipSkillSync = current; return next; } diff --git a/packages/adapter-utils/src/types.ts b/packages/adapter-utils/src/types.ts index 3d87f7a2..2aac3d8c 100644 --- a/packages/adapter-utils/src/types.ts +++ b/packages/adapter-utils/src/types.ts @@ -193,6 +193,8 @@ export type AdapterSkillOrigin = export interface AdapterSkillEntry { key: string; runtimeName: string | null; + versionId?: string | null; + currentVersionId?: string | null; desired: boolean; managed: boolean; required?: boolean; @@ -212,6 +214,7 @@ export interface AdapterSkillSnapshot { supported: boolean; mode: AdapterSkillSyncMode; desiredSkills: string[]; + desiredSkillEntries?: Array<{ key: string; versionId: string | null }>; entries: AdapterSkillEntry[]; warnings: string[]; } diff --git a/packages/db/src/migrations/0099_skills_store_foundation.sql b/packages/db/src/migrations/0099_skills_store_foundation.sql new file mode 100644 index 00000000..1c541a79 --- /dev/null +++ b/packages/db/src/migrations/0099_skills_store_foundation.sql @@ -0,0 +1,121 @@ +ALTER TABLE "company_skills" ADD COLUMN IF NOT EXISTS "icon_url" text;--> statement-breakpoint +ALTER TABLE "company_skills" ADD COLUMN IF NOT EXISTS "color" text;--> statement-breakpoint +ALTER TABLE "company_skills" ADD COLUMN IF NOT EXISTS "tagline" text;--> statement-breakpoint +ALTER TABLE "company_skills" ADD COLUMN IF NOT EXISTS "author_name" text;--> statement-breakpoint +ALTER TABLE "company_skills" ADD COLUMN IF NOT EXISTS "homepage_url" text;--> statement-breakpoint +ALTER TABLE "company_skills" ADD COLUMN IF NOT EXISTS "categories" text[] NOT NULL DEFAULT '{}';--> statement-breakpoint +ALTER TABLE "company_skills" ADD COLUMN IF NOT EXISTS "sharing_scope" text NOT NULL DEFAULT 'company';--> statement-breakpoint +ALTER TABLE "company_skills" ADD COLUMN IF NOT EXISTS "public_share_token" text;--> statement-breakpoint +ALTER TABLE "company_skills" ADD COLUMN IF NOT EXISTS "forked_from_skill_id" uuid;--> statement-breakpoint +ALTER TABLE "company_skills" ADD COLUMN IF NOT EXISTS "forked_from_company_id" uuid;--> statement-breakpoint +ALTER TABLE "company_skills" ADD COLUMN IF NOT EXISTS "star_count" integer NOT NULL DEFAULT 0;--> statement-breakpoint +ALTER TABLE "company_skills" ADD COLUMN IF NOT EXISTS "install_count" integer NOT NULL DEFAULT 0;--> statement-breakpoint +ALTER TABLE "company_skills" ADD COLUMN IF NOT EXISTS "fork_count" integer NOT NULL DEFAULT 0;--> statement-breakpoint +ALTER TABLE "company_skills" ADD COLUMN IF NOT EXISTS "current_version_id" uuid;--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "company_skill_versions" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "company_id" uuid NOT NULL, + "company_skill_id" uuid NOT NULL, + "revision_number" integer NOT NULL, + "label" text, + "file_inventory" jsonb DEFAULT '[]'::jsonb NOT NULL, + "author_agent_id" uuid, + "author_user_id" text, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +);--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "company_skill_stars" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "company_id" uuid NOT NULL, + "company_skill_id" uuid NOT NULL, + "agent_id" uuid, + "user_id" text, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +);--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "company_skill_comments" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "company_id" uuid NOT NULL, + "company_skill_id" uuid NOT NULL, + "parent_comment_id" uuid, + "author_agent_id" uuid, + "author_user_id" text, + "body" text NOT NULL, + "deleted_at" timestamp with time zone, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +);--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "company_skills" ADD CONSTRAINT "company_skills_forked_from_skill_id_company_skills_id_fk" FOREIGN KEY ("forked_from_skill_id") REFERENCES "public"."company_skills"("id") ON DELETE set null ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$;--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "company_skills" ADD CONSTRAINT "company_skills_forked_from_company_id_companies_id_fk" FOREIGN KEY ("forked_from_company_id") REFERENCES "public"."companies"("id") ON DELETE set null ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$;--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "company_skill_versions" ADD CONSTRAINT "company_skill_versions_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$;--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "company_skill_versions" ADD CONSTRAINT "company_skill_versions_company_skill_id_company_skills_id_fk" FOREIGN KEY ("company_skill_id") REFERENCES "public"."company_skills"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$;--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "company_skill_versions" ADD CONSTRAINT "company_skill_versions_author_agent_id_agents_id_fk" FOREIGN KEY ("author_agent_id") REFERENCES "public"."agents"("id") ON DELETE set null ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$;--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "company_skills" ADD CONSTRAINT "company_skills_current_version_id_company_skill_versions_id_fk" FOREIGN KEY ("current_version_id") REFERENCES "public"."company_skill_versions"("id") ON DELETE set null ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$;--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "company_skill_stars" ADD CONSTRAINT "company_skill_stars_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$;--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "company_skill_stars" ADD CONSTRAINT "company_skill_stars_company_skill_id_company_skills_id_fk" FOREIGN KEY ("company_skill_id") REFERENCES "public"."company_skills"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$;--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "company_skill_stars" ADD CONSTRAINT "company_skill_stars_agent_id_agents_id_fk" FOREIGN KEY ("agent_id") REFERENCES "public"."agents"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$;--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "company_skill_comments" ADD CONSTRAINT "company_skill_comments_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$;--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "company_skill_comments" ADD CONSTRAINT "company_skill_comments_company_skill_id_company_skills_id_fk" FOREIGN KEY ("company_skill_id") REFERENCES "public"."company_skills"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$;--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "company_skill_comments" ADD CONSTRAINT "company_skill_comments_parent_comment_id_company_skill_comments_id_fk" FOREIGN KEY ("parent_comment_id") REFERENCES "public"."company_skill_comments"("id") ON DELETE set null ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$;--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "company_skill_comments" ADD CONSTRAINT "company_skill_comments_author_agent_id_agents_id_fk" FOREIGN KEY ("author_agent_id") REFERENCES "public"."agents"("id") ON DELETE set null ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$;--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "company_skills_company_categories_idx" ON "company_skills" USING gin ("categories");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "company_skills_company_sharing_scope_idx" ON "company_skills" USING btree ("company_id","sharing_scope");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "company_skills_company_current_version_idx" ON "company_skills" USING btree ("company_id","current_version_id");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "company_skills_company_forked_from_idx" ON "company_skills" USING btree ("company_id","forked_from_skill_id");--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "company_skill_versions_skill_revision_idx" ON "company_skill_versions" USING btree ("company_skill_id","revision_number");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "company_skill_versions_company_skill_created_idx" ON "company_skill_versions" USING btree ("company_id","company_skill_id","created_at");--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "company_skill_stars_skill_agent_idx" ON "company_skill_stars" USING btree ("company_skill_id","agent_id");--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "company_skill_stars_skill_user_idx" ON "company_skill_stars" USING btree ("company_skill_id","user_id");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "company_skill_stars_company_skill_created_idx" ON "company_skill_stars" USING btree ("company_id","company_skill_id","created_at");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "company_skill_comments_company_skill_created_idx" ON "company_skill_comments" USING btree ("company_id","company_skill_id","created_at");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "company_skill_comments_parent_idx" ON "company_skill_comments" USING btree ("parent_comment_id"); diff --git a/packages/db/src/migrations/0100_skill_install_count_backfill.sql b/packages/db/src/migrations/0100_skill_install_count_backfill.sql new file mode 100644 index 00000000..a464c922 --- /dev/null +++ b/packages/db/src/migrations/0100_skill_install_count_backfill.sql @@ -0,0 +1,3 @@ +UPDATE "company_skills" SET "install_count" = 0 WHERE "install_count" IS NULL;--> statement-breakpoint +ALTER TABLE "company_skills" ALTER COLUMN "install_count" SET DEFAULT 0;--> statement-breakpoint +ALTER TABLE "company_skills" ALTER COLUMN "install_count" SET NOT NULL; diff --git a/packages/db/src/migrations/meta/_journal.json b/packages/db/src/migrations/meta/_journal.json index 96c9209e..f5e72cf4 100644 --- a/packages/db/src/migrations/meta/_journal.json +++ b/packages/db/src/migrations/meta/_journal.json @@ -694,6 +694,20 @@ "when": 1780534200000, "tag": "0098_project_icon", "breakpoints": true + }, + { + "idx": 99, + "version": "7", + "when": 1781130000000, + "tag": "0099_skills_store_foundation", + "breakpoints": true + }, + { + "idx": 100, + "version": "7", + "when": 1781480000000, + "tag": "0100_skill_install_count_backfill", + "breakpoints": true } ] } diff --git a/packages/db/src/schema/company_skills.ts b/packages/db/src/schema/company_skills.ts index aff17c8e..abb1d2b6 100644 --- a/packages/db/src/schema/company_skills.ts +++ b/packages/db/src/schema/company_skills.ts @@ -1,12 +1,16 @@ import { + type AnyPgColumn, pgTable, uuid, text, timestamp, jsonb, index, + integer, uniqueIndex, } from "drizzle-orm/pg-core"; +import type { CompanySkillFileInventoryEntry, CompanySkillSharingScope } from "@paperclipai/shared"; +import { agents } from "./agents.js"; import { companies } from "./companies.js"; export const companySkills = pgTable( @@ -25,6 +29,20 @@ export const companySkills = pgTable( trustLevel: text("trust_level").notNull().default("markdown_only"), compatibility: text("compatibility").notNull().default("compatible"), fileInventory: jsonb("file_inventory").$type>>().notNull().default([]), + iconUrl: text("icon_url"), + color: text("color"), + tagline: text("tagline"), + authorName: text("author_name"), + homepageUrl: text("homepage_url"), + categories: text("categories").array().notNull().default([]), + sharingScope: text("sharing_scope").$type().notNull().default("company"), + publicShareToken: text("public_share_token"), + forkedFromSkillId: uuid("forked_from_skill_id").references((): AnyPgColumn => companySkills.id, { onDelete: "set null" }), + forkedFromCompanyId: uuid("forked_from_company_id").references(() => companies.id, { onDelete: "set null" }), + starCount: integer("star_count").notNull().default(0), + installCount: integer("install_count").notNull().default(0), + forkCount: integer("fork_count").notNull().default(0), + currentVersionId: uuid("current_version_id").references((): AnyPgColumn => companySkillVersions.id, { onDelete: "set null" }), metadata: jsonb("metadata").$type>(), createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), @@ -32,5 +50,84 @@ export const companySkills = pgTable( (table) => ({ companyKeyUniqueIdx: uniqueIndex("company_skills_company_key_idx").on(table.companyId, table.key), companyNameIdx: index("company_skills_company_name_idx").on(table.companyId, table.name), + companyCategoriesIdx: index("company_skills_company_categories_idx").using("gin", table.categories), + companySharingScopeIdx: index("company_skills_company_sharing_scope_idx").on(table.companyId, table.sharingScope), + companyCurrentVersionIdx: index("company_skills_company_current_version_idx").on(table.companyId, table.currentVersionId), + companyForkedFromIdx: index("company_skills_company_forked_from_idx").on(table.companyId, table.forkedFromSkillId), + }), +); + +export type CompanySkillVersionFileInventoryEntry = CompanySkillFileInventoryEntry & { + content: string; +}; + +export const companySkillVersions = pgTable( + "company_skill_versions", + { + id: uuid("id").primaryKey().defaultRandom(), + companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }), + companySkillId: uuid("company_skill_id").notNull().references(() => companySkills.id, { onDelete: "cascade" }), + revisionNumber: integer("revision_number").notNull(), + label: text("label"), + fileInventory: jsonb("file_inventory").$type().notNull().default([]), + authorAgentId: uuid("author_agent_id").references(() => agents.id, { onDelete: "set null" }), + authorUserId: text("author_user_id"), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + }, + (table) => ({ + companySkillRevisionUniqueIdx: uniqueIndex("company_skill_versions_skill_revision_idx").on( + table.companySkillId, + table.revisionNumber, + ), + companySkillCreatedIdx: index("company_skill_versions_company_skill_created_idx").on( + table.companyId, + table.companySkillId, + table.createdAt, + ), + }), +); + +export const companySkillStars = pgTable( + "company_skill_stars", + { + id: uuid("id").primaryKey().defaultRandom(), + companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }), + companySkillId: uuid("company_skill_id").notNull().references(() => companySkills.id, { onDelete: "cascade" }), + agentId: uuid("agent_id").references(() => agents.id, { onDelete: "cascade" }), + userId: text("user_id"), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + }, + (table) => ({ + companySkillAgentUniqueIdx: uniqueIndex("company_skill_stars_skill_agent_idx").on(table.companySkillId, table.agentId), + companySkillUserUniqueIdx: uniqueIndex("company_skill_stars_skill_user_idx").on(table.companySkillId, table.userId), + companySkillCreatedIdx: index("company_skill_stars_company_skill_created_idx").on( + table.companyId, + table.companySkillId, + table.createdAt, + ), + }), +); + +export const companySkillComments = pgTable( + "company_skill_comments", + { + id: uuid("id").primaryKey().defaultRandom(), + companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }), + companySkillId: uuid("company_skill_id").notNull().references(() => companySkills.id, { onDelete: "cascade" }), + parentCommentId: uuid("parent_comment_id").references((): AnyPgColumn => companySkillComments.id, { onDelete: "set null" }), + authorAgentId: uuid("author_agent_id").references(() => agents.id, { onDelete: "set null" }), + authorUserId: text("author_user_id"), + body: text("body").notNull(), + deletedAt: timestamp("deleted_at", { withTimezone: true }), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), + }, + (table) => ({ + companySkillCreatedIdx: index("company_skill_comments_company_skill_created_idx").on( + table.companyId, + table.companySkillId, + table.createdAt, + ), + parentIdx: index("company_skill_comments_parent_idx").on(table.parentCommentId), }), ); diff --git a/packages/db/src/schema/index.ts b/packages/db/src/schema/index.ts index 9d78a33c..2dfc4f7d 100644 --- a/packages/db/src/schema/index.ts +++ b/packages/db/src/schema/index.ts @@ -72,7 +72,7 @@ export { companySecrets } from "./company_secrets.js"; export { companySecretVersions } from "./company_secret_versions.js"; export { companySecretBindings } from "./company_secret_bindings.js"; export { secretAccessEvents } from "./secret_access_events.js"; -export { companySkills } from "./company_skills.js"; +export { companySkills, companySkillVersions, companySkillStars, companySkillComments } from "./company_skills.js"; export { plugins } from "./plugins.js"; export { pluginConfig } from "./plugin_config.js"; export { pluginCompanySettings } from "./plugin_company_settings.js"; diff --git a/packages/plugins/sdk/src/testing.ts b/packages/plugins/sdk/src/testing.ts index b3189bf7..aaacebbc 100644 --- a/packages/plugins/sdk/src/testing.ts +++ b/packages/plugins/sdk/src/testing.ts @@ -1347,6 +1347,20 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness { trustLevel: "markdown_only", compatibility: "compatible", fileInventory: [{ path: "SKILL.md", kind: "skill" }], + iconUrl: null, + color: null, + tagline: declaration.description?.slice(0, 120) ?? null, + authorName: null, + homepageUrl: null, + categories: [], + sharingScope: "company", + publicShareToken: null, + forkedFromSkillId: null, + forkedFromCompanyId: null, + starCount: 0, + installCount: 1, + forkCount: 0, + currentVersionId: null, metadata: { sourceKind: "catalog", pluginManagedResource: { @@ -1403,6 +1417,20 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness { trustLevel: "markdown_only", compatibility: "compatible", fileInventory: [{ path: "SKILL.md", kind: "skill" }], + iconUrl: null, + color: null, + tagline: declaration.description?.slice(0, 120) ?? null, + authorName: null, + homepageUrl: null, + categories: [], + sharingScope: "company", + publicShareToken: null, + forkedFromSkillId: null, + forkedFromCompanyId: null, + starCount: existing.skill?.starCount ?? 0, + installCount: existing.skill?.installCount ?? 1, + forkCount: existing.skill?.forkCount ?? 0, + currentVersionId: existing.skill?.currentVersionId ?? null, metadata: { sourceKind: "catalog", pluginManagedResource: { diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 0a1d3f79..60f66917 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -334,11 +334,24 @@ export type { CompanySkillTrustLevel, CompanySkillCompatibility, CompanySkillSourceBadge, + CompanySkillSharingScope, + CompanySkillListSort, CompanySkillFileInventoryEntry, + CompanySkillVersionFileInventoryEntry, CompanySkill, CompanySkillListItem, CompanySkillUsageAgent, CompanySkillDetail, + CompanySkillListQuery, + CompanySkillCategoryCount, + CompanySkillVersion, + CompanySkillVersionCreateRequest, + CompanySkillStarResult, + CompanySkillComment, + CompanySkillCommentCreateRequest, + CompanySkillCommentUpdateRequest, + CompanySkillForkRequest, + CompanySkillUpdateRequest, CompanySkillUpdateStatus, CompanySkillAuditSeverity, CompanySkillAuditVerdict, @@ -390,6 +403,7 @@ export type { AgentSkillSyncMode, AgentSkillState, AgentSkillOrigin, + AgentDesiredSkillEntry, AgentSkillEntry, AgentSkillSnapshot, AgentSkillSyncRequest, @@ -918,6 +932,8 @@ export { type ProbeEnvironmentConfig, agentSkillStateSchema, agentSkillSyncModeSchema, + agentDesiredSkillEntrySchema, + agentDesiredSkillSelectionSchema, agentSkillEntrySchema, agentSkillSnapshotSchema, agentSkillSyncSchema, @@ -1212,11 +1228,24 @@ export { companySkillTrustLevelSchema, companySkillCompatibilitySchema, companySkillSourceBadgeSchema, + companySkillSharingScopeSchema, + companySkillListSortSchema, companySkillFileInventoryEntrySchema, + companySkillVersionFileInventoryEntrySchema, companySkillSchema, companySkillListItemSchema, companySkillUsageAgentSchema, + companySkillListQuerySchema, + companySkillCategoryCountSchema, + companySkillVersionSchema, companySkillDetailSchema, + companySkillVersionCreateSchema, + companySkillStarResultSchema, + companySkillCommentSchema, + companySkillCommentCreateSchema, + companySkillCommentUpdateSchema, + companySkillForkSchema, + companySkillUpdateSchema, companySkillUpdateStatusSchema, companySkillAuditFindingSchema, companySkillAuditResultSchema, diff --git a/packages/shared/src/types/adapter-skills.ts b/packages/shared/src/types/adapter-skills.ts index 2699bef0..36d6d4fd 100644 --- a/packages/shared/src/types/adapter-skills.ts +++ b/packages/shared/src/types/adapter-skills.ts @@ -14,9 +14,16 @@ export type AgentSkillOrigin = | "user_installed" | "external_unknown"; +export interface AgentDesiredSkillEntry { + key: string; + versionId: string | null; +} + export interface AgentSkillEntry { key: string; runtimeName: string | null; + versionId?: string | null; + currentVersionId?: string | null; desired: boolean; managed: boolean; required?: boolean; @@ -36,10 +43,11 @@ export interface AgentSkillSnapshot { supported: boolean; mode: AgentSkillSyncMode; desiredSkills: string[]; + desiredSkillEntries?: AgentDesiredSkillEntry[]; entries: AgentSkillEntry[]; warnings: string[]; } export interface AgentSkillSyncRequest { - desiredSkills: string[]; + desiredSkills: Array; } diff --git a/packages/shared/src/types/company-skill.ts b/packages/shared/src/types/company-skill.ts index 356e3d42..a2d3e6b9 100644 --- a/packages/shared/src/types/company-skill.ts +++ b/packages/shared/src/types/company-skill.ts @@ -6,11 +6,19 @@ export type CompanySkillCompatibility = "compatible" | "unknown" | "invalid"; export type CompanySkillSourceBadge = "paperclip" | "github" | "local" | "url" | "catalog" | "skills_sh"; +export type CompanySkillSharingScope = "private" | "company" | "public_link"; + +export type CompanySkillListSort = "alphabetical" | "recent" | "installs" | "stars" | "agents" | "forks"; + export interface CompanySkillFileInventoryEntry { path: string; kind: "skill" | "markdown" | "reference" | "script" | "asset" | "other"; } +export interface CompanySkillVersionFileInventoryEntry extends CompanySkillFileInventoryEntry { + content: string; +} + export interface CompanySkill { id: string; companyId: string; @@ -25,6 +33,20 @@ export interface CompanySkill { trustLevel: CompanySkillTrustLevel; compatibility: CompanySkillCompatibility; fileInventory: CompanySkillFileInventoryEntry[]; + iconUrl: string | null; + color: string | null; + tagline: string | null; + authorName: string | null; + homepageUrl: string | null; + categories: string[]; + sharingScope: CompanySkillSharingScope; + publicShareToken: string | null; + forkedFromSkillId: string | null; + forkedFromCompanyId: string | null; + starCount: number; + installCount: number; + forkCount: number; + currentVersionId: string | null; metadata: Record | null; createdAt: Date; updatedAt: Date; @@ -43,6 +65,20 @@ export interface CompanySkillListItem { trustLevel: CompanySkillTrustLevel; compatibility: CompanySkillCompatibility; fileInventory: CompanySkillFileInventoryEntry[]; + iconUrl: string | null; + color: string | null; + tagline: string | null; + authorName: string | null; + homepageUrl: string | null; + categories: string[]; + sharingScope: CompanySkillSharingScope; + publicShareToken: string | null; + forkedFromSkillId: string | null; + forkedFromCompanyId: string | null; + starCount: number; + installCount: number; + forkCount: number; + currentVersionId: string | null; createdAt: Date; updatedAt: Date; attachedAgentCount: number; @@ -69,6 +105,7 @@ export interface CompanySkillUsageAgent { * agent runtimes while loading operator-facing skill metadata. */ actualState: string | null; + versionId: string | null; } export interface CompanySkillDetail extends CompanySkill { @@ -79,6 +116,81 @@ export interface CompanySkillDetail extends CompanySkill { sourceLabel: string | null; sourceBadge: CompanySkillSourceBadge; sourcePath: string | null; + currentVersion: CompanySkillVersion | null; + starredByCurrentActor: boolean; +} + +export interface CompanySkillListQuery { + q?: string; + sort?: CompanySkillListSort; + categories?: string[]; + scope?: CompanySkillSharingScope; +} + +export interface CompanySkillCategoryCount { + slug: string; + count: number; +} + +export interface CompanySkillVersion { + id: string; + companyId: string; + companySkillId: string; + revisionNumber: number; + label: string | null; + fileInventory: CompanySkillVersionFileInventoryEntry[]; + authorAgentId: string | null; + authorUserId: string | null; + createdAt: Date; +} + +export interface CompanySkillVersionCreateRequest { + label?: string | null; +} + +export interface CompanySkillStarResult { + skillId: string; + starred: boolean; + starCount: number; +} + +export interface CompanySkillComment { + id: string; + companyId: string; + companySkillId: string; + parentCommentId: string | null; + authorAgentId: string | null; + authorUserId: string | null; + body: string; + deletedAt: Date | null; + createdAt: Date; + updatedAt: Date; +} + +export interface CompanySkillCommentCreateRequest { + body: string; + parentCommentId?: string | null; +} + +export interface CompanySkillCommentUpdateRequest { + body: string; +} + +export interface CompanySkillForkRequest { + name?: string | null; + slug?: string | null; + sharingScope?: CompanySkillSharingScope; +} + +export interface CompanySkillUpdateRequest { + description?: string | null; + iconUrl?: string | null; + color?: string | null; + tagline?: string | null; + authorName?: string | null; + homepageUrl?: string | null; + categories?: string[]; + sharingScope?: CompanySkillSharingScope; } export interface CompanySkillUpdateStatus { @@ -186,6 +298,14 @@ export interface CompanySkillCreateRequest { slug?: string | null; description?: string | null; markdown?: string | null; + iconUrl?: string | null; + color?: string | null; + tagline?: string | null; + authorName?: string | null; + homepageUrl?: string | null; + categories?: string[]; + sharingScope?: CompanySkillSharingScope; + forkedFromSkillId?: string | null; } export interface CompanySkillFileDetail { diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index 5fc8b5f8..ffc26552 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -60,11 +60,24 @@ export type { CompanySkillTrustLevel, CompanySkillCompatibility, CompanySkillSourceBadge, + CompanySkillSharingScope, + CompanySkillListSort, CompanySkillFileInventoryEntry, + CompanySkillVersionFileInventoryEntry, CompanySkill, CompanySkillListItem, CompanySkillUsageAgent, CompanySkillDetail, + CompanySkillListQuery, + CompanySkillCategoryCount, + CompanySkillVersion, + CompanySkillVersionCreateRequest, + CompanySkillStarResult, + CompanySkillComment, + CompanySkillCommentCreateRequest, + CompanySkillCommentUpdateRequest, + CompanySkillForkRequest, + CompanySkillUpdateRequest, CompanySkillUpdateStatus, CompanySkillAuditSeverity, CompanySkillAuditVerdict, @@ -120,6 +133,7 @@ export type { AgentSkillSyncMode, AgentSkillState, AgentSkillOrigin, + AgentDesiredSkillEntry, AgentSkillEntry, AgentSkillSnapshot, AgentSkillSyncRequest, diff --git a/packages/shared/src/validators/adapter-skills.ts b/packages/shared/src/validators/adapter-skills.ts index 84fa0f72..95c25c25 100644 --- a/packages/shared/src/validators/adapter-skills.ts +++ b/packages/shared/src/validators/adapter-skills.ts @@ -22,9 +22,21 @@ export const agentSkillSyncModeSchema = z.enum([ "ephemeral", ]); +export const agentDesiredSkillEntrySchema = z.object({ + key: z.string().min(1), + versionId: z.string().uuid().nullable(), +}); + +export const agentDesiredSkillSelectionSchema = z.union([ + z.string().min(1), + agentDesiredSkillEntrySchema, +]); + export const agentSkillEntrySchema = z.object({ key: z.string().min(1), runtimeName: z.string().min(1).nullable(), + versionId: z.string().uuid().nullable().optional(), + currentVersionId: z.string().uuid().nullable().optional(), desired: z.boolean(), managed: z.boolean(), required: z.boolean().optional(), @@ -44,12 +56,13 @@ export const agentSkillSnapshotSchema = z.object({ supported: z.boolean(), mode: agentSkillSyncModeSchema, desiredSkills: z.array(z.string().min(1)), + desiredSkillEntries: z.array(agentDesiredSkillEntrySchema).optional(), entries: z.array(agentSkillEntrySchema), warnings: z.array(z.string()), }); export const agentSkillSyncSchema = z.object({ - desiredSkills: z.array(z.string().min(1)), + desiredSkills: z.array(agentDesiredSkillSelectionSchema), }); export type AgentSkillSync = z.infer; diff --git a/packages/shared/src/validators/agent.ts b/packages/shared/src/validators/agent.ts index 4aa64c2a..e7eff192 100644 --- a/packages/shared/src/validators/agent.ts +++ b/packages/shared/src/validators/agent.ts @@ -8,6 +8,7 @@ import { import { agentAdapterTypeSchema } from "../adapter-type.js"; import { envConfigSchema } from "./secret.js"; import { trustAuthorizationPolicySchema, trustPresetSchema } from "./trust-policy.js"; +import { agentDesiredSkillSelectionSchema } from "./adapter-skills.js"; export const agentPermissionsSchema = z.object({ canCreateAgents: z.boolean().optional().default(false), @@ -73,7 +74,7 @@ export const createAgentSchema = z.object({ icon: z.enum(AGENT_ICON_NAMES).optional().nullable(), reportsTo: z.string().uuid().optional().nullable(), capabilities: z.string().optional().nullable(), - desiredSkills: z.array(z.string().min(1)).optional(), + desiredSkills: z.array(agentDesiredSkillSelectionSchema).optional(), adapterType: agentAdapterTypeSchema, adapterConfig: adapterConfigSchema.optional().default({}), instructionsBundle: createAgentInstructionsBundleSchema.optional(), diff --git a/packages/shared/src/validators/company-skill.test.ts b/packages/shared/src/validators/company-skill.test.ts index 20965e63..ec28e5f9 100644 --- a/packages/shared/src/validators/company-skill.test.ts +++ b/packages/shared/src/validators/company-skill.test.ts @@ -54,6 +54,20 @@ const companySkill = { trustLevel: "markdown_only", compatibility: "compatible", fileInventory: [{ path: "SKILL.md", kind: "skill" }], + iconUrl: null, + color: null, + tagline: null, + authorName: null, + homepageUrl: null, + categories: [], + sharingScope: "private", + publicShareToken: null, + forkedFromSkillId: null, + forkedFromCompanyId: null, + starCount: 0, + installCount: 1, + forkCount: 0, + currentVersionId: null, metadata: { sourceKind: "catalog", catalogId: catalogSkill.id, diff --git a/packages/shared/src/validators/company-skill.ts b/packages/shared/src/validators/company-skill.ts index a0bbb732..ff223dd6 100644 --- a/packages/shared/src/validators/company-skill.ts +++ b/packages/shared/src/validators/company-skill.ts @@ -4,12 +4,18 @@ export const companySkillSourceTypeSchema = z.enum(["local_path", "github", "url export const companySkillTrustLevelSchema = z.enum(["markdown_only", "assets", "scripts_executables"]); export const companySkillCompatibilitySchema = z.enum(["compatible", "unknown", "invalid"]); export const companySkillSourceBadgeSchema = z.enum(["paperclip", "github", "local", "url", "catalog", "skills_sh"]); +export const companySkillSharingScopeSchema = z.enum(["private", "company", "public_link"]); +export const companySkillListSortSchema = z.enum(["alphabetical", "recent", "installs", "stars", "agents", "forks"]); export const companySkillFileInventoryEntrySchema = z.object({ path: z.string().min(1), kind: z.enum(["skill", "markdown", "reference", "script", "asset", "other"]), }); +export const companySkillVersionFileInventoryEntrySchema = companySkillFileInventoryEntrySchema.extend({ + content: z.string(), +}); + export const companySkillSchema = z.object({ id: z.string().uuid(), companyId: z.string().uuid(), @@ -24,6 +30,20 @@ export const companySkillSchema = z.object({ trustLevel: companySkillTrustLevelSchema, compatibility: companySkillCompatibilitySchema, fileInventory: z.array(companySkillFileInventoryEntrySchema).default([]), + iconUrl: z.string().nullable(), + color: z.string().nullable(), + tagline: z.string().nullable(), + authorName: z.string().nullable(), + homepageUrl: z.string().nullable(), + categories: z.array(z.string().min(1)).default([]), + sharingScope: companySkillSharingScopeSchema, + publicShareToken: z.string().nullable(), + forkedFromSkillId: z.string().uuid().nullable(), + forkedFromCompanyId: z.string().uuid().nullable(), + starCount: z.number().int().nonnegative(), + installCount: z.number().int().nonnegative(), + forkCount: z.number().int().nonnegative(), + currentVersionId: z.string().uuid().nullable(), metadata: z.record(z.string(), z.unknown()).nullable(), createdAt: z.coerce.date(), updatedAt: z.coerce.date(), @@ -50,6 +70,31 @@ export const companySkillUsageAgentSchema = z.object({ actualState: z.string().nullable().describe( "Runtime adapter skill state when explicitly fetched; company skill detail reads return null without probing agent runtimes.", ), + versionId: z.string().uuid().nullable(), +}); + +export const companySkillListQuerySchema = z.object({ + q: z.string().min(1).optional(), + sort: companySkillListSortSchema.optional(), + categories: z.array(z.string().min(1)).optional(), + scope: companySkillSharingScopeSchema.optional(), +}); + +export const companySkillCategoryCountSchema = z.object({ + slug: z.string().min(1), + count: z.number().int().nonnegative(), +}); + +export const companySkillVersionSchema = z.object({ + id: z.string().uuid(), + companyId: z.string().uuid(), + companySkillId: z.string().uuid(), + revisionNumber: z.number().int().positive(), + label: z.string().nullable(), + fileInventory: z.array(companySkillVersionFileInventoryEntrySchema).default([]), + authorAgentId: z.string().uuid().nullable(), + authorUserId: z.string().nullable(), + createdAt: z.coerce.date(), }); export const companySkillDetailSchema = companySkillSchema.extend({ @@ -59,8 +104,59 @@ export const companySkillDetailSchema = companySkillSchema.extend({ editableReason: z.string().nullable(), sourceLabel: z.string().nullable(), sourceBadge: companySkillSourceBadgeSchema, + currentVersion: companySkillVersionSchema.nullable(), + starredByCurrentActor: z.boolean(), }); +export const companySkillVersionCreateSchema = z.object({ + label: z.string().trim().min(1).nullable().optional(), +}).default({}); + +export const companySkillStarResultSchema = z.object({ + skillId: z.string().uuid(), + starred: z.boolean(), + starCount: z.number().int().nonnegative(), +}); + +export const companySkillCommentSchema = z.object({ + id: z.string().uuid(), + companyId: z.string().uuid(), + companySkillId: z.string().uuid(), + parentCommentId: z.string().uuid().nullable(), + authorAgentId: z.string().uuid().nullable(), + authorUserId: z.string().nullable(), + body: z.string(), + deletedAt: z.coerce.date().nullable(), + createdAt: z.coerce.date(), + updatedAt: z.coerce.date(), +}); + +export const companySkillCommentCreateSchema = z.object({ + body: z.string().min(1), + parentCommentId: z.string().uuid().nullable().optional(), +}); + +export const companySkillCommentUpdateSchema = z.object({ + body: z.string().min(1), +}); + +export const companySkillForkSchema = z.object({ + name: z.string().min(1).nullable().optional(), + slug: z.string().min(1).nullable().optional(), + sharingScope: companySkillSharingScopeSchema.optional(), +}).default({}); + +export const companySkillUpdateSchema = z.object({ + description: z.string().nullable().optional(), + iconUrl: z.string().nullable().optional(), + color: z.string().nullable().optional(), + tagline: z.string().max(120).nullable().optional(), + authorName: z.string().nullable().optional(), + homepageUrl: z.string().nullable().optional(), + categories: z.array(z.string().min(1)).optional(), + sharingScope: companySkillSharingScopeSchema.optional(), +}).default({}); + export const companySkillUpdateStatusSchema = z.object({ supported: z.boolean(), reason: z.string().nullable(), @@ -156,6 +252,14 @@ export const companySkillCreateSchema = z.object({ slug: z.string().min(1).nullable().optional(), description: z.string().nullable().optional(), markdown: z.string().nullable().optional(), + iconUrl: z.string().nullable().optional(), + color: z.string().nullable().optional(), + tagline: z.string().max(120).nullable().optional(), + authorName: z.string().nullable().optional(), + homepageUrl: z.string().nullable().optional(), + categories: z.array(z.string().min(1)).optional(), + sharingScope: companySkillSharingScopeSchema.optional(), + forkedFromSkillId: z.string().uuid().nullable().optional(), }); export const companySkillFileDetailSchema = z.object({ @@ -247,9 +351,15 @@ export const companySkillInstallCatalogResultSchema = z.object({ }); export type CompanySkillImport = z.infer; +export type CompanySkillListQuery = z.infer; export type CompanySkillProjectScan = z.infer; export type CompanySkillCreate = z.infer; export type CompanySkillFileUpdate = z.infer; +export type CompanySkillVersionCreate = z.infer; +export type CompanySkillCommentCreate = z.infer; +export type CompanySkillCommentUpdate = z.infer; +export type CompanySkillFork = z.infer; +export type CompanySkillUpdate = z.infer; export type CatalogSkillListQuery = z.infer; export type CompanySkillInstallCatalog = z.infer; export type CompanySkillInstallUpdate = z.infer; diff --git a/packages/shared/src/validators/index.ts b/packages/shared/src/validators/index.ts index 683950de..9b170e81 100644 --- a/packages/shared/src/validators/index.ts +++ b/packages/shared/src/validators/index.ts @@ -61,11 +61,24 @@ export { companySkillTrustLevelSchema, companySkillCompatibilitySchema, companySkillSourceBadgeSchema, + companySkillSharingScopeSchema, + companySkillListSortSchema, companySkillFileInventoryEntrySchema, + companySkillVersionFileInventoryEntrySchema, companySkillSchema, companySkillListItemSchema, companySkillUsageAgentSchema, + companySkillListQuerySchema, + companySkillCategoryCountSchema, + companySkillVersionSchema, companySkillDetailSchema, + companySkillVersionCreateSchema, + companySkillStarResultSchema, + companySkillCommentSchema, + companySkillCommentCreateSchema, + companySkillCommentUpdateSchema, + companySkillForkSchema, + companySkillUpdateSchema, companySkillUpdateStatusSchema, companySkillAuditFindingSchema, companySkillAuditResultSchema, @@ -89,9 +102,14 @@ export { companySkillInstallUpdateSchema, companySkillResetSchema, type CompanySkillImport, + type CompanySkillListQuery, type CompanySkillProjectScan, type CompanySkillCreate, type CompanySkillFileUpdate, + type CompanySkillVersionCreate, + type CompanySkillCommentCreate, + type CompanySkillCommentUpdate, + type CompanySkillFork, type CatalogSkillListQuery, type CompanySkillInstallCatalog, type CompanySkillInstallUpdate, @@ -121,6 +139,8 @@ export { export { agentSkillStateSchema, agentSkillSyncModeSchema, + agentDesiredSkillEntrySchema, + agentDesiredSkillSelectionSchema, agentSkillEntrySchema, agentSkillSnapshotSchema, agentSkillSyncSchema, diff --git a/server/src/__tests__/agent-skills-routes.test.ts b/server/src/__tests__/agent-skills-routes.test.ts index 07c40f7c..1ba32715 100644 --- a/server/src/__tests__/agent-skills-routes.test.ts +++ b/server/src/__tests__/agent-skills-routes.test.ts @@ -44,6 +44,7 @@ const mockAgentInstructionsService = vi.hoisted(() => ({ const mockCompanySkillService = vi.hoisted(() => ({ listRuntimeSkillEntries: vi.fn(), + resolveRequestedSkillEntries: vi.fn(), resolveRequestedSkillKeys: vi.fn(), })); @@ -258,6 +259,13 @@ describe.sequential("agent skill routes", () => { : value, ), ); + mockCompanySkillService.resolveRequestedSkillEntries.mockImplementation( + async (_companyId: string, requested: Array<{ key: string; versionId?: string | null }>) => + requested.map((entry) => ({ + key: entry.key === "paperclip" ? "paperclipai/paperclip/paperclip" : entry.key, + versionId: entry.versionId ?? null, + })), + ); mockAdapter.listSkills.mockResolvedValue({ adapterType: "claude_local", supported: true, @@ -338,9 +346,10 @@ describe.sequential("agent skill routes", () => { ); expect(res.status, JSON.stringify(res.body)).toBe(200); - expect(mockCompanySkillService.listRuntimeSkillEntries).toHaveBeenCalledWith("company-1", { + expect(mockCompanySkillService.listRuntimeSkillEntries).toHaveBeenCalledWith("company-1", expect.objectContaining({ materializeMissing: false, - }); + versionSelections: expect.any(Map), + })); expect(mockAdapter.listSkills).toHaveBeenCalledWith( expect.objectContaining({ adapterType: "claude_local", @@ -369,9 +378,10 @@ describe.sequential("agent skill routes", () => { ); expect(res.status, JSON.stringify(res.body)).toBe(200); - expect(mockCompanySkillService.listRuntimeSkillEntries).toHaveBeenCalledWith("company-1", { + expect(mockCompanySkillService.listRuntimeSkillEntries).toHaveBeenCalledWith("company-1", expect.objectContaining({ materializeMissing: false, - }); + versionSelections: expect.any(Map), + })); }); it("passes ACPX Claude config through the agent skill listing route", async () => { @@ -398,9 +408,10 @@ describe.sequential("agent skill routes", () => { ); expect(res.status, JSON.stringify(res.body)).toBe(200); - expect(mockCompanySkillService.listRuntimeSkillEntries).toHaveBeenCalledWith("company-1", { + expect(mockCompanySkillService.listRuntimeSkillEntries).toHaveBeenCalledWith("company-1", expect.objectContaining({ materializeMissing: false, - }); + versionSelections: expect.any(Map), + })); expect(mockAdapter.listSkills).toHaveBeenCalledWith( expect.objectContaining({ adapterType: "acpx_local", @@ -485,9 +496,10 @@ describe.sequential("agent skill routes", () => { ); expect(res.status, JSON.stringify(res.body)).toBe(200); - expect(mockCompanySkillService.listRuntimeSkillEntries).toHaveBeenCalledWith("company-1", { + expect(mockCompanySkillService.listRuntimeSkillEntries).toHaveBeenCalledWith("company-1", expect.objectContaining({ materializeMissing: false, - }); + versionSelections: expect.any(Map), + })); }); it("skips runtime materialization when syncing Claude skills", async () => { diff --git a/server/src/__tests__/company-skills-routes.test.ts b/server/src/__tests__/company-skills-routes.test.ts index 1545ef63..acd567a0 100644 --- a/server/src/__tests__/company-skills-routes.test.ts +++ b/server/src/__tests__/company-skills-routes.test.ts @@ -12,6 +12,19 @@ const mockAccessService = vi.hoisted(() => ({ })); const mockCompanySkillService = vi.hoisted(() => ({ + list: vi.fn(), + categoryCounts: vi.fn(), + detail: vi.fn(), + listVersions: vi.fn(), + getVersion: vi.fn(), + createVersion: vi.fn(), + starSkill: vi.fn(), + unstarSkill: vi.fn(), + forkSkill: vi.fn(), + listComments: vi.fn(), + createComment: vi.fn(), + updateComment: vi.fn(), + deleteComment: vi.fn(), importFromSource: vi.fn(), installFromCatalog: vi.fn(), deleteSkill: vi.fn(), @@ -102,6 +115,101 @@ describe("company skill mutation permissions", () => { imported: [], warnings: [], }); + mockCompanySkillService.list.mockResolvedValue([]); + mockCompanySkillService.categoryCounts.mockResolvedValue([]); + mockCompanySkillService.detail.mockResolvedValue(null); + mockCompanySkillService.listVersions.mockResolvedValue([]); + mockCompanySkillService.getVersion.mockResolvedValue(null); + mockCompanySkillService.createVersion.mockResolvedValue({ + id: "version-1", + companyId: "company-1", + companySkillId: "skill-1", + revisionNumber: 1, + label: "v1", + fileInventory: [{ path: "SKILL.md", kind: "skill", content: "# Skill" }], + authorAgentId: null, + authorUserId: "board", + createdAt: new Date("2026-05-26T00:00:00.000Z"), + }); + mockCompanySkillService.starSkill.mockResolvedValue({ + skillId: "skill-1", + starred: true, + starCount: 1, + }); + mockCompanySkillService.unstarSkill.mockResolvedValue({ + skillId: "skill-1", + starred: false, + starCount: 0, + }); + mockCompanySkillService.forkSkill.mockResolvedValue({ + id: "skill-fork", + companyId: "company-1", + key: "company/company-1/review-fork", + slug: "review-fork", + name: "Review Fork", + description: null, + markdown: "# Review", + sourceType: "local_path", + sourceLocator: "/tmp/review-fork", + sourceRef: null, + trustLevel: "markdown_only", + compatibility: "compatible", + fileInventory: [{ path: "SKILL.md", kind: "skill" }], + iconUrl: null, + color: null, + tagline: null, + authorName: null, + homepageUrl: null, + categories: [], + sharingScope: "company", + publicShareToken: null, + forkedFromSkillId: "skill-1", + forkedFromCompanyId: "company-1", + starCount: 0, + installCount: 1, + forkCount: 0, + currentVersionId: null, + metadata: null, + createdAt: new Date("2026-05-26T00:00:00.000Z"), + updatedAt: new Date("2026-05-26T00:00:00.000Z"), + }); + mockCompanySkillService.listComments.mockResolvedValue([]); + mockCompanySkillService.createComment.mockResolvedValue({ + id: "comment-1", + companyId: "company-1", + companySkillId: "skill-1", + parentCommentId: null, + authorAgentId: null, + authorUserId: "board", + body: "Looks good", + deletedAt: null, + createdAt: new Date("2026-05-26T00:00:00.000Z"), + updatedAt: new Date("2026-05-26T00:00:00.000Z"), + }); + mockCompanySkillService.updateComment.mockResolvedValue({ + id: "comment-1", + companyId: "company-1", + companySkillId: "skill-1", + parentCommentId: null, + authorAgentId: null, + authorUserId: "board", + body: "Updated", + deletedAt: null, + createdAt: new Date("2026-05-26T00:00:00.000Z"), + updatedAt: new Date("2026-05-26T00:00:00.000Z"), + }); + mockCompanySkillService.deleteComment.mockResolvedValue({ + id: "comment-1", + companyId: "company-1", + companySkillId: "skill-1", + parentCommentId: null, + authorAgentId: null, + authorUserId: "board", + body: "Updated", + deletedAt: new Date("2026-05-26T00:01:00.000Z"), + createdAt: new Date("2026-05-26T00:00:00.000Z"), + updatedAt: new Date("2026-05-26T00:01:00.000Z"), + }); mockCompanySkillService.installFromCatalog.mockResolvedValue({ action: "created", skill: { @@ -484,6 +592,88 @@ describe("company skill mutation permissions", () => { expect(mockCompanySkillService.installFromCatalog).not.toHaveBeenCalled(); }); + it("passes store list filters and category count requests to the service", async () => { + const app = await createApp({ type: "board", source: "local_implicit" }); + + await request(app) + .get("/api/companies/company-1/skills?sort=stars&categories[]=memory&category=git&scope=company&q=review") + .expect(200); + expect(mockCompanySkillService.list).toHaveBeenCalledWith("company-1", { + q: "review", + sort: "stars", + categories: ["git", "memory"], + scope: "company", + }); + + await request(app).get("/api/companies/company-1/skills/categories").expect(200); + expect(mockCompanySkillService.categoryCounts).toHaveBeenCalledWith("company-1"); + }); + + it("creates skill versions and logs the mutation", async () => { + const app = await createApp({ type: "board", source: "local_implicit", userId: "user-1" }); + + await request(app) + .post("/api/companies/company-1/skills/skill-1/versions") + .send({ label: "v1" }) + .expect(201); + + expect(mockCompanySkillService.createVersion).toHaveBeenCalledWith("company-1", "skill-1", { label: "v1" }, { + type: "user", + userId: "user-1", + }); + expect(mockLogActivity).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ + action: "company.skill_version_created", + entityType: "company_skill_version", + entityId: "version-1", + })); + }); + + it("stars, forks, and comments on skills through company-scoped endpoints", async () => { + const app = await createApp({ type: "board", source: "local_implicit", userId: "user-1" }); + + await request(app).post("/api/companies/company-1/skills/skill-1/star").send({}).expect(200); + expect(mockCompanySkillService.starSkill).toHaveBeenCalledWith("company-1", "skill-1", { + type: "user", + userId: "user-1", + }); + + await request(app).post("/api/companies/company-1/skills/skill-1/fork").send({ slug: "review-fork" }).expect(201); + expect(mockCompanySkillService.forkSkill).toHaveBeenCalledWith("company-1", "skill-1", { slug: "review-fork" }, { + type: "user", + userId: "user-1", + }); + + await request(app).post("/api/companies/company-1/skills/skill-1/comments").send({ body: "Looks good" }).expect(201); + expect(mockCompanySkillService.createComment).toHaveBeenCalledWith("company-1", "skill-1", { body: "Looks good" }, { + type: "user", + userId: "user-1", + }); + + expect(mockLogActivity).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ + action: "company.skill_starred", + entityId: "skill-1", + })); + expect(mockLogActivity).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ + action: "company.skill_forked", + entityId: "skill-fork", + })); + expect(mockLogActivity).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ + action: "company.skill_comment_created", + entityId: "comment-1", + })); + }); + + it("does not synthesize a shared board user id for board actors without user ids", async () => { + const app = await createApp({ type: "board", source: "local_implicit" }); + + await request(app).post("/api/companies/company-1/skills/skill-1/star").send({}).expect(200); + + expect(mockCompanySkillService.starSkill).toHaveBeenCalledWith("company-1", "skill-1", { + type: "user", + userId: null, + }); + }); + it("allows agents with canCreateAgents to mutate company skills", async () => { mockAgentService.getById.mockResolvedValue({ id: "agent-1", diff --git a/server/src/__tests__/company-skills-service.test.ts b/server/src/__tests__/company-skills-service.test.ts index 88be5f59..52d098e2 100644 --- a/server/src/__tests__/company-skills-service.test.ts +++ b/server/src/__tests__/company-skills-service.test.ts @@ -2,7 +2,7 @@ import { randomUUID } from "node:crypto"; import os from "node:os"; import path from "node:path"; import { promises as fs } from "node:fs"; -import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest"; import { agents, companies, companySkills, createDb } from "@paperclipai/db"; import { getEmbeddedPostgresTestSupport, @@ -143,6 +143,339 @@ describeEmbeddedPostgres("companySkillService.list", () => { }); }); + it("filters store list results by category and creates version snapshots", async () => { + const companyId = randomUUID(); + const skillId = randomUUID(); + const skillDir = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-versioned-skill-")); + cleanupDirs.add(skillDir); + await fs.writeFile(path.join(skillDir, "SKILL.md"), "---\nname: Versioned Skill\ncategories:\n - Memory\n---\n\n# Versioned Skill\n", "utf8"); + + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }); + await db.insert(companySkills).values({ + id: skillId, + companyId, + key: `company/${companyId}/versioned-skill`, + slug: "versioned-skill", + name: "Versioned Skill", + description: "Tracks revisions.", + markdown: "# Versioned Skill", + sourceType: "local_path", + sourceLocator: skillDir, + trustLevel: "markdown_only", + compatibility: "compatible", + fileInventory: [{ path: "SKILL.md", kind: "skill" }], + categories: ["memory"], + tagline: "Tracks revisions", + }); + + const filtered = await svc.list(companyId, { categories: ["memory"], sort: "recent" }); + expect(filtered.some((skill) => skill.id === skillId)).toBe(true); + expect(filtered.find((skill) => skill.id === skillId)).toMatchObject({ + categories: ["memory"], + tagline: "Tracks revisions", + }); + + const version = await svc.createVersion(companyId, skillId, { label: "v1" }, { type: "user", userId: "board" }); + expect(version).toMatchObject({ + companySkillId: skillId, + revisionNumber: 1, + label: "v1", + authorUserId: "board", + }); + expect(version.fileInventory).toEqual([ + expect.objectContaining({ + path: "SKILL.md", + kind: "skill", + content: expect.stringContaining("# Versioned Skill"), + }), + ]); + await expect(svc.getVersion(companyId, skillId, version.id)).resolves.toMatchObject({ id: version.id }); + }); + + it("tracks stars and skill comments with actor ownership", async () => { + const companyId = randomUUID(); + const skillId = randomUUID(); + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }); + await db.insert(companySkills).values({ + id: skillId, + companyId, + key: `company/${companyId}/discussion-skill`, + slug: "discussion-skill", + name: "Discussion Skill", + description: null, + markdown: "# Discussion Skill", + sourceType: "local_path", + sourceLocator: null, + trustLevel: "markdown_only", + compatibility: "compatible", + fileInventory: [{ path: "SKILL.md", kind: "skill" }], + }); + + await expect(svc.starSkill(companyId, skillId, { type: "user", userId: "board" })).resolves.toMatchObject({ + starred: true, + starCount: 1, + }); + await expect(svc.starSkill(companyId, skillId, { type: "user", userId: "board" })).resolves.toMatchObject({ + starred: true, + starCount: 1, + }); + await expect(svc.starSkill(companyId, skillId, { type: "user", userId: null })).rejects.toMatchObject({ + status: 422, + }); + const comment = await svc.createComment( + companyId, + skillId, + { body: "Looks useful." }, + { type: "user", userId: "board" }, + ); + expect(comment).toMatchObject({ body: "Looks useful.", authorUserId: "board" }); + await expect(svc.updateComment( + companyId, + skillId, + comment.id, + { body: "Looks very useful." }, + { type: "agent", agentId: randomUUID() }, + )).rejects.toMatchObject({ status: 422 }); + await expect(svc.deleteComment(companyId, skillId, comment.id, { type: "user", userId: "board" })) + .resolves.toMatchObject({ id: comment.id, deletedAt: expect.any(Date) }); + await expect(svc.listComments(companyId, skillId)).resolves.toEqual([]); + await expect(svc.updateComment( + companyId, + skillId, + comment.id, + { body: "Resurrected." }, + { type: "user", userId: "board" }, + )).rejects.toMatchObject({ status: 404 }); + await expect(svc.deleteComment(companyId, skillId, comment.id, { type: "user", userId: "board" })) + .rejects.toMatchObject({ status: 404 }); + await expect(svc.createComment( + companyId, + skillId, + { body: "Reply after delete.", parentCommentId: comment.id }, + { type: "user", userId: "board" }, + )).rejects.toMatchObject({ status: 404 }); + await expect(svc.unstarSkill(companyId, skillId, { type: "user", userId: "board" })).resolves.toMatchObject({ + starred: false, + starCount: 0, + }); + }); + + it("updates private/company sharing scope and rejects public link publishing", async () => { + const companyId = randomUUID(); + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }); + + const skill = await svc.createLocalSkill(companyId, { + name: "Sharing Skill", + tagline: "A scoped skill", + sharingScope: "company", + }); + + await expect(svc.updateSkill(companyId, skill.id, { sharingScope: "private" })).resolves.toMatchObject({ + id: skill.id, + sharingScope: "private", + publicShareToken: null, + }); + await expect(svc.updateSkill(companyId, skill.id, { sharingScope: "public_link" })).rejects.toMatchObject({ + status: 422, + message: "Public skill sharing is not available in this version.", + }); + await expect(svc.createLocalSkill(companyId, { + name: "Public Skill", + sharingScope: "public_link", + })).rejects.toMatchObject({ + status: 422, + message: "Public skill sharing is not available in this version.", + }); + }); + + it("creates a fork from the creation flow with copied files and lineage", async () => { + const companyId = randomUUID(); + const sourceSkillId = randomUUID(); + const sourceSkillDir = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-source-fork-skill-")); + cleanupDirs.add(sourceSkillDir); + await fs.mkdir(path.join(sourceSkillDir, "references"), { recursive: true }); + await fs.writeFile( + path.join(sourceSkillDir, "SKILL.md"), + "---\nname: Source Skill\ndescription: Source description\n---\n\n# Source Skill\n", + "utf8", + ); + await fs.writeFile(path.join(sourceSkillDir, "references", "guide.md"), "# Guide\n\nOriginal notes.\n", "utf8"); + + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }); + await db.insert(companySkills).values({ + id: sourceSkillId, + companyId, + key: `company/${companyId}/source-skill`, + slug: "source-skill", + name: "Source Skill", + description: "Source description", + markdown: "---\nname: Source Skill\ndescription: Source description\n---\n\n# Source Skill\n", + sourceType: "local_path", + sourceLocator: sourceSkillDir, + trustLevel: "markdown_only", + compatibility: "compatible", + fileInventory: [ + { path: "SKILL.md", kind: "skill" }, + { path: "references/guide.md", kind: "reference" }, + ], + color: "#0ea5e9", + categories: ["engineering"], + sharingScope: "company", + metadata: { sourceKind: "managed_local" }, + }); + + const forked = await svc.createLocalSkill(companyId, { + name: "Source Skill Fork", + slug: "source-skill-fork", + markdown: "---\nname: Source Skill Fork\ndescription: Fork description\n---\n\n# Forked Skill\n", + tagline: "Forked for the team", + color: "#ef4444", + categories: ["review"], + sharingScope: "private", + forkedFromSkillId: sourceSkillId, + }, { type: "user", userId: "board" }); + + expect(forked).toMatchObject({ + name: "Source Skill Fork", + slug: "source-skill-fork", + sharingScope: "private", + forkedFromSkillId: sourceSkillId, + forkedFromCompanyId: companyId, + color: "#ef4444", + tagline: "Forked for the team", + categories: ["review"], + }); + expect(forked.fileInventory.map((entry) => entry.path).sort()).toEqual(["SKILL.md", "references/guide.md"]); + await expect(svc.readFile(companyId, forked.id, "references/guide.md")).resolves.toMatchObject({ + content: expect.stringContaining("Original notes."), + }); + await expect(svc.getById(companyId, sourceSkillId)).resolves.toMatchObject({ + forkCount: 1, + installCount: 1, + }); + await expect(svc.getById(companyId, forked.id)).resolves.toMatchObject({ + metadata: expect.objectContaining({ + forkedFromSkillId: sourceSkillId, + forkedFromCompanyId: companyId, + forkedByUserId: "board", + }), + }); + const versions = await svc.listVersions(companyId, forked.id); + expect(versions).toHaveLength(1); + expect(versions[0]).toMatchObject({ + revisionNumber: 1, + label: "Initial version", + authorUserId: "board", + }); + + const dedicatedFork = await svc.forkSkill( + companyId, + sourceSkillId, + { name: "Dedicated Fork", slug: "dedicated-fork", sharingScope: "private" }, + { type: "user", userId: "board" }, + ); + expect(dedicatedFork).toMatchObject({ + name: "Source Skill", + slug: "dedicated-fork", + sharingScope: "private", + forkedFromSkillId: sourceSkillId, + forkedFromCompanyId: companyId, + currentVersionId: expect.any(String), + }); + const dedicatedVersions = await svc.listVersions(companyId, dedicatedFork.id); + expect(dedicatedVersions).toHaveLength(1); + expect(dedicatedVersions[0]).toMatchObject({ + revisionNumber: 1, + label: "Initial version", + authorUserId: "board", + }); + }); + + it("validates version-aware desired skill selections", async () => { + const companyId = randomUUID(); + const skillId = randomUUID(); + const otherSkillId = randomUUID(); + const skillDir = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-pinned-skill-")); + cleanupDirs.add(skillDir); + await fs.writeFile(path.join(skillDir, "SKILL.md"), "# Pinned Skill\n", "utf8"); + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }); + await db.insert(companySkills).values([ + { + id: skillId, + companyId, + key: `company/${companyId}/pinned-skill`, + slug: "pinned-skill", + name: "Pinned Skill", + description: null, + markdown: "# Pinned Skill", + sourceType: "local_path", + sourceLocator: skillDir, + trustLevel: "markdown_only", + compatibility: "compatible", + fileInventory: [{ path: "SKILL.md", kind: "skill" }], + }, + { + id: otherSkillId, + companyId, + key: `company/${companyId}/other-skill`, + slug: "other-skill", + name: "Other Skill", + description: null, + markdown: "# Other Skill", + sourceType: "local_path", + sourceLocator: null, + trustLevel: "markdown_only", + compatibility: "compatible", + fileInventory: [{ path: "SKILL.md", kind: "skill" }], + }, + ]); + const version = await svc.createVersion(companyId, skillId, {}, { type: "user", userId: "board" }); + + await expect(svc.resolveRequestedSkillEntries(companyId, [ + "pinned-skill", + ])).resolves.toEqual([ + { key: `company/${companyId}/pinned-skill`, versionId: null }, + ]); + await expect(svc.resolveRequestedSkillEntries(companyId, [ + { key: "pinned-skill", versionId: null }, + ])).resolves.toEqual([ + { key: `company/${companyId}/pinned-skill`, versionId: null }, + ]); + await expect(svc.resolveRequestedSkillEntries(companyId, [ + { key: "pinned-skill", versionId: version.id }, + ])).resolves.toEqual([ + { key: `company/${companyId}/pinned-skill`, versionId: version.id }, + ]); + await expect(svc.resolveRequestedSkillEntries(companyId, [ + { key: "other-skill", versionId: version.id }, + ])).rejects.toMatchObject({ status: 422 }); + }); + it("preserves missing local-path skills that active agents still desire", async () => { const companyId = randomUUID(); const skillId = randomUUID(); @@ -279,6 +612,61 @@ describeEmbeddedPostgres("companySkillService.list", () => { expect(rows.some((row) => row.companyId === companyId && row.slug === "evil")).toBe(false); }); + it("rejects unbundled package imports that claim reserved Paperclip skill keys", async () => { + const companyId = randomUUID(); + const skillId = randomUUID(); + const bundledSkillDir = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-bundled-skill-")); + cleanupDirs.add(bundledSkillDir); + await fs.writeFile(path.join(bundledSkillDir, "SKILL.md"), "---\nname: Paperclip\n---\n\n# Official Paperclip\n", "utf8"); + + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }); + await db.insert(companySkills).values({ + id: skillId, + companyId, + key: "paperclipai/paperclip/paperclip", + slug: "paperclip", + name: "Paperclip", + description: "Official coordination skill.", + markdown: "---\nname: Paperclip\n---\n\n# Official Paperclip\n", + sourceType: "local_path", + sourceLocator: bundledSkillDir, + trustLevel: "markdown_only", + compatibility: "compatible", + fileInventory: [{ path: "SKILL.md", kind: "skill" }], + metadata: { sourceKind: "paperclip_bundled" }, + }); + + await expect(svc.importPackageFiles(companyId, { + "skills/trojan/SKILL.md": [ + "---", + "name: Trojan Paperclip", + "metadata:", + " skillKey: paperclipai/paperclip/paperclip", + "---", + "", + "# Trojan Paperclip", + "", + ].join("\n"), + })).rejects.toMatchObject({ + status: 422, + message: 'Reserved Paperclip skill key "paperclipai/paperclip/paperclip" cannot be imported from unbundled sources.', + }); + + const stored = await svc.getById(companyId, skillId); + expect(stored).toMatchObject({ + id: skillId, + key: "paperclipai/paperclip/paperclip", + metadata: { sourceKind: "paperclip_bundled" }, + }); + expect(stored?.name).not.toBe("Trojan Paperclip"); + expect(stored?.markdown).not.toContain("Trojan Paperclip"); + }); + it("clears the missing-source marker when a local-path skill source returns", async () => { const companyId = randomUUID(); const skillId = randomUUID(); @@ -429,4 +817,216 @@ describeEmbeddedPostgres("companySkillService.list", () => { "# Runtime Coach\n\nRecovered from DB.\n", ); }); + + it("falls back to stored markdown when reading SKILL.md from a missing local source", async () => { + const companyId = randomUUID(); + const skillId = randomUUID(); + const skillKey = `company/${companyId}/missing-reader`; + const missingSkillDir = path.join(await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-missing-read-skill-")), "gone"); + cleanupDirs.add(path.dirname(missingSkillDir)); + + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }); + await db.insert(companySkills).values({ + id: skillId, + companyId, + key: skillKey, + slug: "missing-reader", + name: "Missing Reader", + description: null, + markdown: "# Missing Reader\n\nRecovered from DB.\n", + sourceType: "local_path", + sourceLocator: missingSkillDir, + trustLevel: "markdown_only", + compatibility: "compatible", + fileInventory: [ + { path: "SKILL.md", kind: "skill" }, + { path: "references/guide.md", kind: "reference" }, + ], + metadata: { sourceKind: "local_path" }, + }); + await db.insert(agents).values({ + id: randomUUID(), + companyId, + name: "Reader", + role: "engineer", + status: "active", + adapterType: "codex_local", + adapterConfig: { + paperclipSkillSync: { + desiredSkills: [skillKey], + }, + }, + }); + + await expect(svc.readFile(companyId, skillId, "SKILL.md")).resolves.toMatchObject({ + path: "SKILL.md", + content: "# Missing Reader\n\nRecovered from DB.\n", + }); + await expect(svc.readFile(companyId, skillId, "references/guide.md")).rejects.toMatchObject({ + status: 404, + }); + }); + + it("reads root-level SKILL.md for github skills with a '.' repoSkillDir", async () => { + const companyId = randomUUID(); + const skillId = randomUUID(); + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }); + await db.insert(companySkills).values({ + id: skillId, + companyId, + key: `company/${companyId}/root-skill`, + slug: "root-skill", + name: "Root Skill", + description: null, + markdown: "# Root Skill (stored)\n", + sourceType: "github", + sourceLocator: "https://github.com/acme/root-skill", + sourceRef: "main", + trustLevel: "markdown_only", + compatibility: "compatible", + fileInventory: [{ path: "SKILL.md", kind: "skill" }], + metadata: { owner: "acme", repo: "root-skill", ref: "main", repoSkillDir: "." }, + }); + + const requestedUrls: string[] = []; + vi.stubGlobal("fetch", async (url: string | URL) => { + requestedUrls.push(String(url)); + return new Response("# Root Skill (remote)\n", { status: 200 }); + }); + try { + await expect(svc.readFile(companyId, skillId, "SKILL.md")).resolves.toMatchObject({ + content: "# Root Skill (remote)\n", + }); + expect(requestedUrls).toEqual([ + "https://raw.githubusercontent.com/acme/root-skill/main/SKILL.md", + ]); + + vi.stubGlobal("fetch", async () => { + throw new Error("network down"); + }); + await expect(svc.readFile(companyId, skillId, "SKILL.md")).resolves.toMatchObject({ + content: "# Root Skill (stored)\n", + }); + } finally { + vi.unstubAllGlobals(); + } + }); + + it("falls back to slug paths for github skills only when repoSkillDir is absent", async () => { + const companyId = randomUUID(); + const rootSkillId = randomUUID(); + const slugSkillId = randomUUID(); + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }); + await db.insert(companySkills).values([ + { + id: rootSkillId, + companyId, + key: `company/${companyId}/empty-root-skill`, + slug: "empty-root-skill", + name: "Empty Root Skill", + description: null, + markdown: "# Empty Root Skill\n", + sourceType: "github", + sourceLocator: "https://github.com/acme/skills", + sourceRef: "main", + trustLevel: "markdown_only", + compatibility: "compatible", + fileInventory: [{ path: "SKILL.md", kind: "skill" }], + metadata: { owner: "acme", repo: "skills", ref: "main", repoSkillDir: "" }, + }, + { + id: slugSkillId, + companyId, + key: `company/${companyId}/slug-skill`, + slug: "slug-skill", + name: "Slug Skill", + description: null, + markdown: "# Slug Skill\n", + sourceType: "github", + sourceLocator: "https://github.com/acme/skills", + sourceRef: "main", + trustLevel: "markdown_only", + compatibility: "compatible", + fileInventory: [{ path: "SKILL.md", kind: "skill" }], + metadata: { owner: "acme", repo: "skills", ref: "main" }, + }, + ]); + + const requestedUrls: string[] = []; + vi.stubGlobal("fetch", async (url: string | URL) => { + requestedUrls.push(String(url)); + return new Response("# Remote Skill\n", { status: 200 }); + }); + try { + await expect(svc.readFile(companyId, rootSkillId, "SKILL.md")).resolves.toMatchObject({ + content: "# Remote Skill\n", + }); + await expect(svc.readFile(companyId, slugSkillId, "SKILL.md")).resolves.toMatchObject({ + content: "# Remote Skill\n", + }); + expect(requestedUrls).toEqual([ + "https://raw.githubusercontent.com/acme/skills/main/SKILL.md", + "https://raw.githubusercontent.com/acme/skills/main/slug-skill/SKILL.md", + ]); + } finally { + vi.unstubAllGlobals(); + } + }); + + it("seeds an initial version on create and snapshots a version on each changed save", async () => { + const companyId = randomUUID(); + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }); + + const skill = await svc.createLocalSkill( + companyId, + { name: "Versioned Editor", description: "Edits with history" }, + { type: "user", userId: "board" }, + ); + expect(skill.currentVersionId).not.toBeNull(); + let versions = await svc.listVersions(companyId, skill.id); + expect(versions).toHaveLength(1); + expect(versions[0]).toMatchObject({ + revisionNumber: 1, + label: "Initial version", + authorUserId: "board", + }); + expect(skill.currentVersionId).toBe(versions[0]!.id); + + const editedMarkdown = "---\nname: Versioned Editor\n---\n\n# Versioned Editor\n\nEdited body.\n"; + await expect(svc.updateFile(companyId, skill.id, "SKILL.md", editedMarkdown, { type: "user", userId: "board" })) + .resolves.toMatchObject({ path: "SKILL.md", content: editedMarkdown }); + versions = await svc.listVersions(companyId, skill.id); + expect(versions).toHaveLength(2); + expect(versions[0]).toMatchObject({ revisionNumber: 2, authorUserId: "board" }); + expect(versions[0]!.fileInventory).toEqual([ + expect.objectContaining({ path: "SKILL.md", content: editedMarkdown }), + ]); + await expect(svc.getById(companyId, skill.id)).resolves.toMatchObject({ + currentVersionId: versions[0]!.id, + }); + + await svc.updateFile(companyId, skill.id, "SKILL.md", editedMarkdown, { type: "user", userId: "board" }); + versions = await svc.listVersions(companyId, skill.id); + expect(versions).toHaveLength(2); + }); }); diff --git a/server/src/__tests__/heartbeat-project-env.test.ts b/server/src/__tests__/heartbeat-project-env.test.ts index 1f62aa76..2fde8594 100644 --- a/server/src/__tests__/heartbeat-project-env.test.ts +++ b/server/src/__tests__/heartbeat-project-env.test.ts @@ -333,4 +333,29 @@ describe("applyRunScopedMentionedSkillKeys", () => { }, }); }); + + it("preserves existing version pins when adding mentioned skills", () => { + const originalConfig = { + command: "codex", + paperclipSkillSync: { + desiredSkills: [ + { key: "company/company-1/release-changelog", versionId: "version-1" }, + ], + }, + }; + + const updatedConfig = applyRunScopedMentionedSkillKeys(originalConfig, [ + "company/company-1/security-review", + ]); + + expect(updatedConfig).toEqual({ + command: "codex", + paperclipSkillSync: { + desiredSkills: [ + { key: "company/company-1/release-changelog", versionId: "version-1" }, + { key: "company/company-1/security-review", versionId: null }, + ], + }, + }); + }); }); diff --git a/server/src/__tests__/heartbeat-runtime-skills.test.ts b/server/src/__tests__/heartbeat-runtime-skills.test.ts new file mode 100644 index 00000000..e5a54928 --- /dev/null +++ b/server/src/__tests__/heartbeat-runtime-skills.test.ts @@ -0,0 +1,243 @@ +import { randomUUID } from "node:crypto"; +import os from "node:os"; +import path from "node:path"; +import { promises as fs } from "node:fs"; +import { eq, sql } from "drizzle-orm"; +import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; +import { agents, companies, companySkills, createDb } from "@paperclipai/db"; +import type { PaperclipSkillEntry } from "@paperclipai/adapter-utils/server-utils"; +import { + getEmbeddedPostgresTestSupport, + startEmbeddedPostgresTestDatabase, +} from "./helpers/embedded-postgres.js"; +import { companySkillService } from "../services/company-skills.ts"; +import { heartbeatService } from "../services/heartbeat.ts"; +import { registerServerAdapter, unregisterServerAdapter } from "../adapters/index.ts"; + +const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); +const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; +const TEST_ADAPTER_TYPE = "runtime_skill_capture"; + +if (!embeddedPostgresSupport.supported) { + console.warn( + `Skipping embedded Postgres heartbeat runtime skill tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`, + ); +} + +async function waitForRunToFinish( + heartbeat: ReturnType, + runId: string, + timeoutMs = 5_000, +) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const run = await heartbeat.getRun(runId); + if (run && !["queued", "running"].includes(run.status)) return run; + await new Promise((resolve) => setTimeout(resolve, 50)); + } + return await heartbeat.getRun(runId); +} + +describeEmbeddedPostgres("heartbeat runtime skill version pins", () => { + let db!: ReturnType; + let tempDb: Awaited> | null = null; + let oldPaperclipHome: string | undefined; + let paperclipHome: string | null = null; + const capturedRuns: Array<{ agentId: string; skills: PaperclipSkillEntry[] }> = []; + const cleanupDirs = new Set(); + + beforeAll(async () => { + tempDb = await startEmbeddedPostgresTestDatabase("heartbeat-runtime-skills-"); + db = createDb(tempDb.connectionString); + oldPaperclipHome = process.env.PAPERCLIP_HOME; + paperclipHome = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-runtime-skills-home-")); + process.env.PAPERCLIP_HOME = paperclipHome; + registerServerAdapter({ + type: TEST_ADAPTER_TYPE, + execute: async (ctx) => { + capturedRuns.push({ + agentId: ctx.agent.id, + skills: (ctx.config.paperclipRuntimeSkills ?? []) as PaperclipSkillEntry[], + }); + return { + exitCode: 0, + signal: null, + timedOut: false, + label: "Captured runtime skills", + }; + }, + testEnvironment: async () => ({ + adapterType: TEST_ADAPTER_TYPE, + status: "pass", + checks: [], + testedAt: new Date().toISOString(), + }), + }); + }, 20_000); + + afterEach(async () => { + capturedRuns.length = 0; + await db.execute(sql.raw(` + TRUNCATE TABLE + "activity_log", + "environment_leases", + "environments", + "heartbeat_run_events", + "heartbeat_runs", + "agent_wakeup_requests", + "agent_runtime_state", + "company_skill_versions", + "company_skills", + "agents", + "companies" + RESTART IDENTITY CASCADE + `)); + await Promise.all(Array.from(cleanupDirs, (dir) => fs.rm(dir, { recursive: true, force: true }))); + cleanupDirs.clear(); + }); + + afterAll(async () => { + unregisterServerAdapter(TEST_ADAPTER_TYPE); + if (oldPaperclipHome === undefined) delete process.env.PAPERCLIP_HOME; + else process.env.PAPERCLIP_HOME = oldPaperclipHome; + if (paperclipHome) { + await fs.rm(paperclipHome, { recursive: true, force: true }); + } + await tempDb?.cleanup(); + }); + + it("materializes different pinned skill versions for different agents at runtime", async () => { + const companyId = randomUUID(); + const skillId = randomUUID(); + const firstAgentId = randomUUID(); + const secondAgentId = randomUUID(); + const issuePrefix = `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`; + const skillKey = `company/${companyId}/runtime-coach`; + const skillDir = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-versioned-runtime-skill-")); + cleanupDirs.add(skillDir); + + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix, + requireBoardApprovalForNewAgents: false, + }); + await fs.writeFile(path.join(skillDir, "SKILL.md"), "# Runtime Coach\n\nVersion one.\n", "utf8"); + await db.insert(companySkills).values({ + id: skillId, + companyId, + key: skillKey, + slug: "runtime-coach", + name: "Runtime Coach", + description: null, + markdown: "# Runtime Coach\n\nVersion one.\n", + sourceType: "local_path", + sourceLocator: skillDir, + trustLevel: "markdown_only", + compatibility: "compatible", + fileInventory: [{ path: "SKILL.md", kind: "skill" }], + metadata: { sourceKind: "local_path" }, + }); + + const skills = companySkillService(db); + const versionOne = await skills.createVersion( + companyId, + skillId, + { label: "v1" }, + { type: "user", userId: "board" }, + ); + await fs.writeFile(path.join(skillDir, "SKILL.md"), "# Runtime Coach\n\nVersion two.\n", "utf8"); + await db + .update(companySkills) + .set({ markdown: "# Runtime Coach\n\nVersion two.\n", updatedAt: new Date() }) + .where(eq(companySkills.id, skillId)); + const versionTwo = await skills.createVersion( + companyId, + skillId, + { label: "v2" }, + { type: "user", userId: "board" }, + ); + + await db.insert(agents).values([ + { + id: firstAgentId, + companyId, + name: "Pinned V1", + role: "engineer", + status: "idle", + adapterType: TEST_ADAPTER_TYPE, + adapterConfig: { + paperclipSkillSync: { + desiredSkills: [{ key: skillKey, versionId: versionOne.id }], + }, + }, + runtimeConfig: {}, + permissions: {}, + }, + { + id: secondAgentId, + companyId, + name: "Pinned V2", + role: "engineer", + status: "idle", + adapterType: TEST_ADAPTER_TYPE, + adapterConfig: { + paperclipSkillSync: { + desiredSkills: [{ key: skillKey, versionId: versionTwo.id }], + }, + }, + runtimeConfig: {}, + permissions: {}, + }, + ]); + + const heartbeat = heartbeatService(db); + const firstRun = await heartbeat.invoke(firstAgentId, "on_demand", {}, "manual"); + expect(firstRun).not.toBeNull(); + expect((await waitForRunToFinish(heartbeat, firstRun!.id))?.status).toBe("succeeded"); + + const secondRun = await heartbeat.invoke(secondAgentId, "on_demand", {}, "manual"); + expect(secondRun).not.toBeNull(); + expect((await waitForRunToFinish(heartbeat, secondRun!.id))?.status).toBe("succeeded"); + + const firstSkill = capturedRuns.find((run) => run.agentId === firstAgentId)?.skills + .find((entry) => entry.key === skillKey); + const secondSkill = capturedRuns.find((run) => run.agentId === secondAgentId)?.skills + .find((entry) => entry.key === skillKey); + + expect(firstSkill).toMatchObject({ + key: skillKey, + versionId: versionOne.id, + currentVersionId: versionTwo.id, + sourceStatus: "available", + }); + expect(secondSkill).toMatchObject({ + key: skillKey, + versionId: versionTwo.id, + currentVersionId: versionTwo.id, + sourceStatus: "available", + }); + await expect(fs.readFile(path.join(firstSkill!.source, "SKILL.md"), "utf8")) + .resolves.toContain("Version one."); + await expect(fs.readFile(path.join(secondSkill!.source, "SKILL.md"), "utf8")) + .resolves.toContain("Version two."); + + const firstSkillFile = path.join(firstSkill!.source, "SKILL.md"); + const oldMtime = new Date("2024-01-01T00:00:00.000Z"); + await fs.utimes(firstSkillFile, oldMtime, oldMtime); + + const repeatRun = await heartbeat.invoke(firstAgentId, "on_demand", {}, "manual"); + expect(repeatRun).not.toBeNull(); + expect((await waitForRunToFinish(heartbeat, repeatRun!.id))?.status).toBe("succeeded"); + const repeatedSkill = capturedRuns + .filter((run) => run.agentId === firstAgentId) + .at(-1) + ?.skills.find((entry) => entry.key === skillKey); + expect(repeatedSkill).toMatchObject({ + source: firstSkill!.source, + versionId: versionOne.id, + sourceStatus: "available", + }); + expect((await fs.stat(firstSkillFile)).mtime.toISOString()).toBe(oldMtime.toISOString()); + }); +}); diff --git a/server/src/routes/agents.ts b/server/src/routes/agents.ts index 3182c2bf..fb52036d 100644 --- a/server/src/routes/agents.ts +++ b/server/src/routes/agents.ts @@ -16,6 +16,7 @@ import { normalizeIssueIdentifier, resetAgentSessionSchema, testAdapterEnvironmentSchema, + type AgentDesiredSkillEntry, type AgentSkillSnapshot, type InstanceSchedulerHeartbeatAgent, upsertAgentInstructionsFileSchema, @@ -64,6 +65,7 @@ import type { AdapterEnvironmentCheck, AdapterEnvironmentTestResult, } from "@paperclipai/adapter-utils"; +import { skillVersionSelectionMap } from "../services/runtime-skill-selections.js"; import { secretService } from "../services/secrets.js"; import { detectAdapterModel, @@ -1282,18 +1284,35 @@ export function agentRoutes( function buildUnsupportedSkillSnapshot( adapterType: string, - desiredSkills: string[] = [], + desiredSkillEntries: AgentDesiredSkillEntry[] = [], ): AgentSkillSnapshot { + const desiredSkills = desiredSkillEntries.map((entry) => entry.key); return { adapterType, supported: false, mode: "unsupported", desiredSkills, + desiredSkillEntries, entries: [], warnings: ["This adapter does not implement skill sync yet."], }; } + function normalizeDesiredSkillSelections( + requestedDesiredSkills: Array | undefined, + ): AgentDesiredSkillEntry[] | undefined { + if (!requestedDesiredSkills) return undefined; + const out = new Map(); + for (const value of requestedDesiredSkills) { + const entry = typeof value === "string" + ? { key: value.trim(), versionId: null } + : { key: value.key.trim(), versionId: value.versionId ?? null }; + if (!entry.key || out.has(entry.key)) continue; + out.set(entry.key, entry); + } + return Array.from(out.values()); + } + // Legacy hardcoded set — used as fallback when adapter module does not // declare requiresMaterializedRuntimeSkills explicitly. const LEGACY_MATERIALIZED_SKILLS_SET = new Set([ @@ -1319,9 +1338,11 @@ export function agentRoutes( materializeMissing?: boolean; } = {}, ) { + const preference = readPaperclipSkillSyncPreference(config); const runtimeSkillEntries = await companySkills.listRuntimeSkillEntries(companyId, { materializeMissing: options.materializeMissing ?? shouldMaterializeRuntimeSkillsForAdapter(adapterType), + versionSelections: skillVersionSelectionMap(preference.desiredSkillEntries), }); return { ...config, @@ -1333,31 +1354,39 @@ export function agentRoutes( companyId: string, adapterType: string, adapterConfig: Record, - requestedDesiredSkills: string[] | undefined, + requestedDesiredSkills: AgentDesiredSkillEntry[] | undefined, ) { if (!requestedDesiredSkills) { return { adapterConfig, desiredSkills: null as string[] | null, + desiredSkillEntries: null as AgentDesiredSkillEntry[] | null, runtimeSkillEntries: null as Awaited> | null, }; } - const resolvedRequestedSkills = await companySkills.resolveRequestedSkillKeys( + const resolvedRequestedSkillEntries = await companySkills.resolveRequestedSkillEntries( companyId, requestedDesiredSkills, ); + const resolvedRequestedSkills = resolvedRequestedSkillEntries.map((entry) => entry.key); const runtimeSkillEntries = await companySkills.listRuntimeSkillEntries(companyId, { materializeMissing: shouldMaterializeRuntimeSkillsForAdapter(adapterType), + versionSelections: skillVersionSelectionMap(resolvedRequestedSkillEntries), }); const requiredSkills = runtimeSkillEntries .filter((entry) => entry.required) .map((entry) => entry.key); - const desiredSkills = Array.from(new Set([...requiredSkills, ...resolvedRequestedSkills])); + const desiredSkillEntries = [ + ...requiredSkills.map((key) => ({ key, versionId: null })), + ...resolvedRequestedSkillEntries, + ].filter((entry, index, entries) => entries.findIndex((candidate) => candidate.key === entry.key) === index); + const desiredSkills = desiredSkillEntries.map((entry) => entry.key); return { - adapterConfig: writePaperclipSkillSyncPreference(adapterConfig, desiredSkills), + adapterConfig: writePaperclipSkillSyncPreference(adapterConfig, desiredSkillEntries), desiredSkills, + desiredSkillEntries, runtimeSkillEntries, }; } @@ -1574,9 +1603,14 @@ export function agentRoutes( ); const runtimeSkillEntries = await companySkills.listRuntimeSkillEntries(agent.companyId, { materializeMissing: false, + versionSelections: skillVersionSelectionMap(preference.desiredSkillEntries), }); const requiredSkills = runtimeSkillEntries.filter((entry) => entry.required).map((entry) => entry.key); - res.json(buildUnsupportedSkillSnapshot(agent.adapterType, Array.from(new Set([...requiredSkills, ...preference.desiredSkills])))); + const desiredSkillEntries = [ + ...requiredSkills.map((key) => ({ key, versionId: null })), + ...preference.desiredSkillEntries, + ].filter((entry, index, entries) => entries.findIndex((candidate) => candidate.key === entry.key) === index); + res.json(buildUnsupportedSkillSnapshot(agent.adapterType, desiredSkillEntries)); return; } @@ -1611,16 +1645,11 @@ export function agentRoutes( } await assertCanUpdateAgent(req, agent); - const requestedSkills = Array.from( - new Set( - (req.body.desiredSkills as string[]) - .map((value) => value.trim()) - .filter(Boolean), - ), - ); + const requestedSkills = normalizeDesiredSkillSelections(req.body.desiredSkills); const { adapterConfig: nextAdapterConfig, desiredSkills, + desiredSkillEntries, runtimeSkillEntries, } = await resolveDesiredSkillAssignment( agent.companyId, @@ -1628,7 +1657,7 @@ export function agentRoutes( agent.adapterConfig as Record, requestedSkills, ); - if (!desiredSkills || !runtimeSkillEntries) { + if (!desiredSkills || !desiredSkillEntries || !runtimeSkillEntries) { throw unprocessable("Skill sync requires desiredSkills."); } const actor = getActorInfo(req); @@ -1669,7 +1698,7 @@ export function agentRoutes( adapterType: updated.adapterType, config: runtimeSkillConfig, }) - : buildUnsupportedSkillSnapshot(updated.adapterType, desiredSkills); + : buildUnsupportedSkillSnapshot(updated.adapterType, desiredSkillEntries); await logActivity(db, { companyId: updated.companyId, @@ -1683,6 +1712,7 @@ export function agentRoutes( details: { adapterType: updated.adapterType, desiredSkills, + desiredSkillEntries, mode: snapshot.mode, supported: snapshot.supported, entryCount: snapshot.entries.length, @@ -2093,7 +2123,7 @@ export function agentRoutes( companyId, hireInput.adapterType, requestedAdapterConfig, - Array.isArray(requestedDesiredSkills) ? requestedDesiredSkills : undefined, + normalizeDesiredSkillSelections(Array.isArray(requestedDesiredSkills) ? requestedDesiredSkills : undefined), ); const normalizedAdapterConfig = await normalizeMediatedAdapterConfigForPersistence({ companyId, @@ -2279,7 +2309,7 @@ export function agentRoutes( companyId, createInput.adapterType, requestedAdapterConfig, - Array.isArray(requestedDesiredSkills) ? requestedDesiredSkills : undefined, + normalizeDesiredSkillSelections(Array.isArray(requestedDesiredSkills) ? requestedDesiredSkills : undefined), ); const normalizedAdapterConfig = await normalizeMediatedAdapterConfigForPersistence({ companyId, diff --git a/server/src/routes/company-skills.ts b/server/src/routes/company-skills.ts index 1fb82298..c6bec7a6 100644 --- a/server/src/routes/company-skills.ts +++ b/server/src/routes/company-skills.ts @@ -2,13 +2,19 @@ import { Router, type Request } from "express"; import type { Db } from "@paperclipai/db"; import { catalogSkillListQuerySchema, + companySkillCommentCreateSchema, + companySkillCommentUpdateSchema, companySkillCreateSchema, companySkillFileUpdateSchema, + companySkillForkSchema, companySkillImportSchema, companySkillInstallCatalogSchema, companySkillInstallUpdateSchema, + companySkillListQuerySchema, companySkillProjectScanRequestSchema, companySkillResetSchema, + companySkillUpdateSchema, + companySkillVersionCreateSchema, } from "@paperclipai/shared"; import { trackSkillImported } from "@paperclipai/shared/telemetry"; import { validate } from "../middleware/validate.js"; @@ -63,6 +69,22 @@ export function companySkillRoutes(db: Db) { return undefined; } + function queryStringArray(value: unknown): string[] { + if (typeof value === "string") return [value]; + if (Array.isArray(value)) return value.filter((entry): entry is string => typeof entry === "string"); + return []; + } + + function skillActor(req: Request) { + if (req.actor.type === "agent") { + return { type: "agent" as const, agentId: req.actor.agentId ?? null }; + } + if (req.actor.type === "board") { + return { type: "user" as const, userId: req.actor.userId ?? null }; + } + return { type: "system" as const }; + } + async function assertCanMutateCompanySkills(req: Request, companyId: string) { assertCompanyAccess(req, companyId); @@ -118,15 +140,30 @@ export function companySkillRoutes(db: Db) { router.get("/companies/:companyId/skills", async (req, res) => { const companyId = req.params.companyId as string; assertCompanyAccess(req, companyId); - const result = await svc.list(companyId); + const result = await svc.list(companyId, companySkillListQuerySchema.parse({ + q: firstQueryString(req.query.q), + sort: firstQueryString(req.query.sort), + categories: [ + ...queryStringArray(req.query.category), + ...queryStringArray(req.query.categories), + ...queryStringArray(req.query["categories[]"]), + ], + scope: firstQueryString(req.query.scope), + })); res.json(result); }); + router.get("/companies/:companyId/skills/categories", async (req, res) => { + const companyId = req.params.companyId as string; + assertCompanyAccess(req, companyId); + res.json(await svc.categoryCounts(companyId)); + }); + router.get("/companies/:companyId/skills/:skillId", async (req, res) => { const companyId = req.params.companyId as string; const skillId = req.params.skillId as string; assertCompanyAccess(req, companyId); - const result = await svc.detail(companyId, skillId); + const result = await svc.detail(companyId, skillId, skillActor(req)); if (!result) { res.status(404).json({ error: "Skill not found" }); return; @@ -134,6 +171,199 @@ export function companySkillRoutes(db: Db) { res.json(result); }); + router.get("/companies/:companyId/skills/:skillId/versions", async (req, res) => { + const companyId = req.params.companyId as string; + const skillId = req.params.skillId as string; + assertCompanyAccess(req, companyId); + res.json(await svc.listVersions(companyId, skillId)); + }); + + router.get("/companies/:companyId/skills/:skillId/versions/:versionId", async (req, res) => { + const companyId = req.params.companyId as string; + const skillId = req.params.skillId as string; + const versionId = req.params.versionId as string; + assertCompanyAccess(req, companyId); + const result = await svc.getVersion(companyId, skillId, versionId); + if (!result) { + res.status(404).json({ error: "Skill version not found" }); + return; + } + res.json(result); + }); + + router.post( + "/companies/:companyId/skills/:skillId/versions", + validate(companySkillVersionCreateSchema), + async (req, res) => { + const companyId = req.params.companyId as string; + const skillId = req.params.skillId as string; + await assertCanMutateCompanySkills(req, companyId); + const result = await svc.createVersion(companyId, skillId, req.body, skillActor(req)); + const actor = getActorInfo(req); + await logActivity(db, { + companyId, + actorType: actor.actorType, + actorId: actor.actorId, + agentId: actor.agentId, + runId: actor.runId, + action: "company.skill_version_created", + entityType: "company_skill_version", + entityId: result.id, + details: { + skillId, + revisionNumber: result.revisionNumber, + label: result.label, + }, + }); + res.status(201).json(result); + }, + ); + + router.post("/companies/:companyId/skills/:skillId/star", async (req, res) => { + const companyId = req.params.companyId as string; + const skillId = req.params.skillId as string; + assertCompanyAccess(req, companyId); + const result = await svc.starSkill(companyId, skillId, skillActor(req)); + const actor = getActorInfo(req); + await logActivity(db, { + companyId, + actorType: actor.actorType, + actorId: actor.actorId, + agentId: actor.agentId, + runId: actor.runId, + action: "company.skill_starred", + entityType: "company_skill", + entityId: skillId, + details: { starCount: result.starCount }, + }); + res.json(result); + }); + + router.delete("/companies/:companyId/skills/:skillId/star", async (req, res) => { + const companyId = req.params.companyId as string; + const skillId = req.params.skillId as string; + assertCompanyAccess(req, companyId); + const result = await svc.unstarSkill(companyId, skillId, skillActor(req)); + const actor = getActorInfo(req); + await logActivity(db, { + companyId, + actorType: actor.actorType, + actorId: actor.actorId, + agentId: actor.agentId, + runId: actor.runId, + action: "company.skill_unstarred", + entityType: "company_skill", + entityId: skillId, + details: { starCount: result.starCount }, + }); + res.json(result); + }); + + router.post( + "/companies/:companyId/skills/:skillId/fork", + validate(companySkillForkSchema), + async (req, res) => { + const companyId = req.params.companyId as string; + const skillId = req.params.skillId as string; + await assertCanMutateCompanySkills(req, companyId); + const result = await svc.forkSkill(companyId, skillId, req.body, skillActor(req)); + const actor = getActorInfo(req); + await logActivity(db, { + companyId, + actorType: actor.actorType, + actorId: actor.actorId, + agentId: actor.agentId, + runId: actor.runId, + action: "company.skill_forked", + entityType: "company_skill", + entityId: result.id, + details: { + sourceSkillId: skillId, + slug: result.slug, + name: result.name, + }, + }); + res.status(201).json(result); + }, + ); + + router.get("/companies/:companyId/skills/:skillId/comments", async (req, res) => { + const companyId = req.params.companyId as string; + const skillId = req.params.skillId as string; + assertCompanyAccess(req, companyId); + res.json(await svc.listComments(companyId, skillId)); + }); + + router.post( + "/companies/:companyId/skills/:skillId/comments", + validate(companySkillCommentCreateSchema), + async (req, res) => { + const companyId = req.params.companyId as string; + const skillId = req.params.skillId as string; + assertCompanyAccess(req, companyId); + const result = await svc.createComment(companyId, skillId, req.body, skillActor(req)); + const actor = getActorInfo(req); + await logActivity(db, { + companyId, + actorType: actor.actorType, + actorId: actor.actorId, + agentId: actor.agentId, + runId: actor.runId, + action: "company.skill_comment_created", + entityType: "company_skill_comment", + entityId: result.id, + details: { skillId, parentCommentId: result.parentCommentId }, + }); + res.status(201).json(result); + }, + ); + + router.patch( + "/companies/:companyId/skills/:skillId/comments/:commentId", + validate(companySkillCommentUpdateSchema), + async (req, res) => { + const companyId = req.params.companyId as string; + const skillId = req.params.skillId as string; + const commentId = req.params.commentId as string; + assertCompanyAccess(req, companyId); + const result = await svc.updateComment(companyId, skillId, commentId, req.body, skillActor(req)); + const actor = getActorInfo(req); + await logActivity(db, { + companyId, + actorType: actor.actorType, + actorId: actor.actorId, + agentId: actor.agentId, + runId: actor.runId, + action: "company.skill_comment_updated", + entityType: "company_skill_comment", + entityId: result.id, + details: { skillId }, + }); + res.json(result); + }, + ); + + router.delete("/companies/:companyId/skills/:skillId/comments/:commentId", async (req, res) => { + const companyId = req.params.companyId as string; + const skillId = req.params.skillId as string; + const commentId = req.params.commentId as string; + assertCompanyAccess(req, companyId); + const result = await svc.deleteComment(companyId, skillId, commentId, skillActor(req)); + const actor = getActorInfo(req); + await logActivity(db, { + companyId, + actorType: actor.actorType, + actorId: actor.actorId, + agentId: actor.agentId, + runId: actor.runId, + action: "company.skill_comment_deleted", + entityType: "company_skill_comment", + entityId: result.id, + details: { skillId }, + }); + res.json(result); + }); + router.get("/companies/:companyId/skills/:skillId/update-status", async (req, res) => { const companyId = req.params.companyId as string; const skillId = req.params.skillId as string; @@ -165,7 +395,7 @@ export function companySkillRoutes(db: Db) { async (req, res) => { const companyId = req.params.companyId as string; await assertCanMutateCompanySkills(req, companyId); - const result = await svc.createLocalSkill(companyId, req.body); + const result = await svc.createLocalSkill(companyId, req.body, skillActor(req)); const actor = getActorInfo(req); await logActivity(db, { @@ -187,6 +417,35 @@ export function companySkillRoutes(db: Db) { }, ); + router.patch( + "/companies/:companyId/skills/:skillId", + validate(companySkillUpdateSchema), + async (req, res) => { + const companyId = req.params.companyId as string; + const skillId = req.params.skillId as string; + await assertCanMutateCompanySkills(req, companyId); + const result = await svc.updateSkill(companyId, skillId, req.body); + + const actor = getActorInfo(req); + await logActivity(db, { + companyId, + actorType: actor.actorType, + actorId: actor.actorId, + agentId: actor.agentId, + runId: actor.runId, + action: "company.skill_updated", + entityType: "company_skill", + entityId: result.id, + details: { + slug: result.slug, + sharingScope: result.sharingScope, + }, + }); + + res.json(result); + }, + ); + router.patch( "/companies/:companyId/skills/:skillId/files", validate(companySkillFileUpdateSchema), @@ -199,6 +458,7 @@ export function companySkillRoutes(db: Db) { skillId, String(req.body.path ?? ""), String(req.body.content ?? ""), + skillActor(req), ); const actor = getActorInfo(req); diff --git a/server/src/routes/openapi.ts b/server/src/routes/openapi.ts index 81b90061..ab1cc85b 100644 --- a/server/src/routes/openapi.ts +++ b/server/src/routes/openapi.ts @@ -4268,7 +4268,19 @@ for (const route of [ ["get", "/api/skills/catalog/{catalogId}", "Get a catalog skill"], ["get", "/api/skills/catalog/{catalogId}/files", "List catalog skill files"], ["post", "/api/companies/{companyId}/skills/install-catalog", "Install a catalog skill"], + ["get", "/api/companies/{companyId}/skills/categories", "List company skill categories"], ["post", "/api/companies/{companyId}/skills/{skillId}/audit", "Audit a company skill"], + ["patch", "/api/companies/{companyId}/skills/{skillId}", "Update a company skill"], + ["get", "/api/companies/{companyId}/skills/{skillId}/versions", "List skill versions"], + ["post", "/api/companies/{companyId}/skills/{skillId}/versions", "Create a skill version"], + ["get", "/api/companies/{companyId}/skills/{skillId}/versions/{versionId}", "Get a skill version"], + ["post", "/api/companies/{companyId}/skills/{skillId}/star", "Star a company skill"], + ["delete", "/api/companies/{companyId}/skills/{skillId}/star", "Unstar a company skill"], + ["post", "/api/companies/{companyId}/skills/{skillId}/fork", "Fork a company skill"], + ["get", "/api/companies/{companyId}/skills/{skillId}/comments", "List skill comments"], + ["post", "/api/companies/{companyId}/skills/{skillId}/comments", "Create a skill comment"], + ["patch", "/api/companies/{companyId}/skills/{skillId}/comments/{commentId}", "Update a skill comment"], + ["delete", "/api/companies/{companyId}/skills/{skillId}/comments/{commentId}", "Delete a skill comment"], ["post", "/api/companies/{companyId}/skills/{skillId}/reset", "Reset a company skill"], ] as const) { registerCurrentRoute({ diff --git a/server/src/services/company-portability.ts b/server/src/services/company-portability.ts index e6960634..1024a871 100644 --- a/server/src/services/company-portability.ts +++ b/server/src/services/company-portability.ts @@ -2770,13 +2770,19 @@ function buildManifestFromPackageFiles( const trackingRef = asString(primarySource?.trackingRef); const sourceHostname = asString(primarySource?.hostname) || "github.com"; const [owner, repoName] = (repo ?? "").split("/"); + const canonicalKey = readSkillKey(frontmatter); + const normalizedSourceKind = owner === "paperclipai" + && repoName === "paperclip" + && canonicalKey?.startsWith("paperclipai/paperclip/") + ? "paperclip_bundled" + : "github"; sourceType = "github"; sourceLocator = asString(primarySource?.url) ?? (repo ? `https://${sourceHostname}/${repo}${repoPath ? `/tree/${trackingRef ?? commit ?? "main"}/${repoPath}` : ""}` : null); sourceRef = commit; normalizedMetadata = owner && repoName ? { - sourceKind: "github", + sourceKind: normalizedSourceKind, ...(sourceHostname !== "github.com" ? { hostname: sourceHostname } : {}), owner, repo: repoName, diff --git a/server/src/services/company-skills.ts b/server/src/services/company-skills.ts index 58bfb3ce..17c39f50 100644 --- a/server/src/services/company-skills.ts +++ b/server/src/services/company-skills.ts @@ -2,36 +2,48 @@ import { createHash, randomUUID } from "node:crypto"; import { promises as fs } from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; -import { and, asc, eq } from "drizzle-orm"; +import { and, asc, desc, eq, isNull, sql } from "drizzle-orm"; import type { Db } from "@paperclipai/db"; -import { companies, companySkills } from "@paperclipai/db"; +import { companies, companySkillComments, companySkillStars, companySkillVersions, companySkills } from "@paperclipai/db"; import { readPaperclipSkillSyncPreference } from "@paperclipai/adapter-utils/server-utils"; -import type { PaperclipSkillEntry } from "@paperclipai/adapter-utils/server-utils"; +import type { PaperclipDesiredSkillEntry, PaperclipSkillEntry } from "@paperclipai/adapter-utils/server-utils"; import type { + AgentDesiredSkillEntry, CatalogSkill, CompanySkill, CompanySkillAuditFinding, CompanySkillAuditResult, CompanySkillAuditVerdict, + CompanySkillCategoryCount, + CompanySkillComment, + CompanySkillCommentCreateRequest, + CompanySkillCommentUpdateRequest, CompanySkillCreateRequest, CompanySkillCompatibility, CompanySkillDetail, CompanySkillFileDetail, CompanySkillFileInventoryEntry, + CompanySkillForkRequest, CompanySkillImportResult, CompanySkillInstallCatalogRequest, CompanySkillInstallCatalogResult, + CompanySkillListQuery, CompanySkillListItem, CompanySkillProjectScanConflict, CompanySkillProjectScanRequest, CompanySkillProjectScanResult, CompanySkillProjectScanSkipped, + CompanySkillSharingScope, CompanySkillSourceBadge, CompanySkillSourceType, CompanySkillTrustLevel, + CompanySkillUpdateRequest, CompanySkillUpdateStatus, CompanySkillUpdateHoldReason, CompanySkillUsageAgent, + CompanySkillVersion, + CompanySkillVersionCreateRequest, + CompanySkillVersionFileInventoryEntry, } from "@paperclipai/shared"; import { normalizeAgentUrlKey } from "@paperclipai/shared"; import { resolvePaperclipInstanceRoot } from "../home-paths.js"; @@ -53,6 +65,8 @@ import { } from "./catalog-provenance.js"; type CompanySkillRow = typeof companySkills.$inferSelect; +type CompanySkillVersionRow = typeof companySkillVersions.$inferSelect; +type CompanySkillCommentRow = typeof companySkillComments.$inferSelect; type CompanySkillListDbRow = Pick< CompanySkillRow, | "id" @@ -67,6 +81,20 @@ type CompanySkillListDbRow = Pick< | "trustLevel" | "compatibility" | "fileInventory" + | "iconUrl" + | "color" + | "tagline" + | "authorName" + | "homepageUrl" + | "categories" + | "sharingScope" + | "publicShareToken" + | "forkedFromSkillId" + | "forkedFromCompanyId" + | "starCount" + | "installCount" + | "forkCount" + | "currentVersionId" | "metadata" | "createdAt" | "updatedAt" @@ -85,6 +113,20 @@ type CompanySkillListRow = Pick< | "trustLevel" | "compatibility" | "fileInventory" + | "iconUrl" + | "color" + | "tagline" + | "authorName" + | "homepageUrl" + | "categories" + | "sharingScope" + | "publicShareToken" + | "forkedFromSkillId" + | "forkedFromCompanyId" + | "starCount" + | "installCount" + | "forkCount" + | "currentVersionId" | "metadata" | "createdAt" | "updatedAt" @@ -168,6 +210,20 @@ function assertImportedSkillSourceAllowed(skill: ImportedSkill) { } } +function assertImportedSkillKeyAllowed(skill: ImportedSkill) { + if (!skill.key.startsWith("paperclipai/paperclip/")) return; + const metadata = isPlainRecord(skill.metadata) ? skill.metadata : null; + const sourceKind = asString(metadata?.sourceKind); + if (sourceKind === "paperclip_bundled") return; + throw unprocessable( + `Reserved Paperclip skill key "${skill.key}" cannot be imported from unbundled sources.`, + { + skillKey: skill.key, + sourceKind: sourceKind ?? skill.sourceType, + }, + ); +} + type SkillSourceMeta = { skillKey?: string; sourceKind?: string; @@ -219,6 +275,13 @@ export type ProjectSkillScanTarget = { type RuntimeSkillEntryOptions = { materializeMissing?: boolean; + versionSelections?: Map; +}; + +type SkillActor = { + type: "agent" | "user" | "system"; + agentId?: string | null; + userId?: string | null; }; type RuntimeSkillSourceResolution = @@ -242,6 +305,20 @@ function selectCompanySkillColumns() { trustLevel: companySkills.trustLevel, compatibility: companySkills.compatibility, fileInventory: companySkills.fileInventory, + iconUrl: companySkills.iconUrl, + color: companySkills.color, + tagline: companySkills.tagline, + authorName: companySkills.authorName, + homepageUrl: companySkills.homepageUrl, + categories: companySkills.categories, + sharingScope: companySkills.sharingScope, + publicShareToken: companySkills.publicShareToken, + forkedFromSkillId: companySkills.forkedFromSkillId, + forkedFromCompanyId: companySkills.forkedFromCompanyId, + starCount: companySkills.starCount, + installCount: companySkills.installCount, + forkCount: companySkills.forkCount, + currentVersionId: companySkills.currentVersionId, metadata: companySkills.metadata, createdAt: companySkills.createdAt, updatedAt: companySkills.updatedAt, @@ -833,13 +910,18 @@ function deriveImportedSkillSource( : null); const [owner, repoName] = (repo ?? "").split("/"); if (repo && owner && repoName) { + const sourceKind = owner === "paperclipai" + && repoName === "paperclip" + && canonicalKey?.startsWith("paperclipai/paperclip/") + ? "paperclip_bundled" + : "github"; return { sourceType: "github", sourceLocator: url, sourceRef: commit, metadata: { ...(canonicalKey ? { skillKey: canonicalKey } : {}), - sourceKind: "github", + sourceKind, ...(sourceHostname !== "github.com" ? { hostname: sourceHostname } : {}), owner, repo: repoName, @@ -1306,6 +1388,20 @@ function toCompanySkill(row: CompanySkillRow): CompanySkill { }]; }) : [], + iconUrl: row.iconUrl ?? null, + color: row.color ?? null, + tagline: row.tagline ?? null, + authorName: row.authorName ?? null, + homepageUrl: row.homepageUrl ?? null, + categories: normalizeCategoryList(row.categories), + sharingScope: normalizeSharingScope(row.sharingScope), + publicShareToken: row.publicShareToken ?? null, + forkedFromSkillId: row.forkedFromSkillId ?? null, + forkedFromCompanyId: row.forkedFromCompanyId ?? null, + starCount: Math.max(0, row.starCount ?? 0), + installCount: Math.max(0, row.installCount ?? 0), + forkCount: Math.max(0, row.forkCount ?? 0), + currentVersionId: row.currentVersionId ?? null, metadata: isPlainRecord(row.metadata) ? row.metadata : null, }; } @@ -1328,10 +1424,75 @@ function toCompanySkillListRow(row: CompanySkillListDbRow): CompanySkillListRow }]; }) : [], + iconUrl: row.iconUrl ?? null, + color: row.color ?? null, + tagline: row.tagline ?? null, + authorName: row.authorName ?? null, + homepageUrl: row.homepageUrl ?? null, + categories: normalizeCategoryList(row.categories), + sharingScope: normalizeSharingScope(row.sharingScope), + publicShareToken: row.publicShareToken ?? null, + forkedFromSkillId: row.forkedFromSkillId ?? null, + forkedFromCompanyId: row.forkedFromCompanyId ?? null, + starCount: Math.max(0, row.starCount ?? 0), + installCount: Math.max(0, row.installCount ?? 0), + forkCount: Math.max(0, row.forkCount ?? 0), + currentVersionId: row.currentVersionId ?? null, metadata: isPlainRecord(row.metadata) ? row.metadata : null, }; } +function normalizeSharingScope(value: unknown): CompanySkillSharingScope { + return value === "private" || value === "public_link" || value === "company" ? value : "company"; +} + +function normalizeMutableSharingScope(value: unknown): CompanySkillSharingScope | null { + if (value === undefined || value === null) return null; + if (value === "private" || value === "company") return value; + if (value === "public_link") { + throw unprocessable("Public skill sharing is not available in this version."); + } + throw unprocessable("Invalid skill sharing scope."); +} + +function normalizeCategorySlug(value: unknown) { + if (typeof value !== "string") return null; + return normalizeSkillSlug(value); +} + +function normalizeCategoryList(values: unknown): string[] { + if (!Array.isArray(values)) return []; + return Array.from(new Set(values.map(normalizeCategorySlug).filter((value): value is string => Boolean(value)))); +} + +function normalizeStoreText(value: unknown, maxLength = 500) { + const text = asString(value); + return text ? text.slice(0, maxLength) : null; +} + +function readStringList(value: unknown): string[] { + return Array.isArray(value) + ? value.map(asString).filter((entry): entry is string => Boolean(entry)) + : []; +} + +function readSkillStoreMetadata(frontmatter: Record, metadata: Record | null) { + const categories = normalizeCategoryList([ + ...readStringList(frontmatter.categories), + ...readStringList(frontmatter.tags), + ...readStringList(metadata?.categories), + ...readStringList(metadata?.tags), + ]); + return { + iconUrl: normalizeStoreText(frontmatter.iconUrl ?? frontmatter.icon ?? metadata?.iconUrl ?? metadata?.icon, 2000), + color: normalizeStoreText(frontmatter.color ?? metadata?.color, 64), + tagline: normalizeStoreText(frontmatter.tagline ?? frontmatter.summary ?? metadata?.tagline, 120), + authorName: normalizeStoreText(frontmatter.author ?? frontmatter.authorName ?? metadata?.authorName, 200), + homepageUrl: normalizeStoreText(frontmatter.homepage ?? frontmatter.homepageUrl ?? metadata?.homepageUrl, 2000), + categories, + }; +} + function serializeFileInventory( fileInventory: CompanySkillFileInventoryEntry[], ): Array> { @@ -1341,6 +1502,45 @@ function serializeFileInventory( })); } +function serializeVersionFileInventory( + fileInventory: CompanySkillVersionFileInventoryEntry[], +): CompanySkillVersionFileInventoryEntry[] { + return fileInventory.map((entry) => ({ + path: entry.path, + kind: entry.kind, + content: entry.content, + })); +} + +function toCompanySkillVersion(row: CompanySkillVersionRow): CompanySkillVersion { + return { + ...row, + label: row.label ?? null, + fileInventory: Array.isArray(row.fileInventory) + ? row.fileInventory.flatMap((entry) => { + if (!isPlainRecord(entry)) return []; + return [{ + path: String(entry.path ?? ""), + kind: (String(entry.kind ?? "other") as CompanySkillFileInventoryEntry["kind"]), + content: String(entry.content ?? ""), + }]; + }) + : [], + authorAgentId: row.authorAgentId ?? null, + authorUserId: row.authorUserId ?? null, + }; +} + +function toCompanySkillComment(row: CompanySkillCommentRow): CompanySkillComment { + return { + ...row, + parentCommentId: row.parentCommentId ?? null, + authorAgentId: row.authorAgentId ?? null, + authorUserId: row.authorUserId ?? null, + deletedAt: row.deletedAt ?? null, + }; +} + function getSkillMeta(skill: Pick): SkillSourceMeta { return isPlainRecord(skill.metadata) ? skill.metadata as SkillSourceMeta : {}; } @@ -1466,6 +1666,91 @@ function resolveRequestedSkillKeysOrThrow( return Array.from(resolved); } +function normalizeRequestedDesiredSkillSelection(value: string | AgentDesiredSkillEntry): PaperclipDesiredSkillEntry { + if (typeof value === "string") { + return { key: value.trim(), versionId: null }; + } + return { + key: value.key.trim(), + versionId: value.versionId ?? null, + }; +} + +async function assertVersionMatchesSkill( + db: Db, + companyId: string, + skillId: string, + versionId: string | null, +) { + if (!versionId) return; + const row = await db + .select({ id: companySkillVersions.id }) + .from(companySkillVersions) + .where(and( + eq(companySkillVersions.companyId, companyId), + eq(companySkillVersions.companySkillId, skillId), + eq(companySkillVersions.id, versionId), + )) + .then((rows) => rows[0] ?? null); + if (!row) { + throw unprocessable("Selected skill version does not belong to the requested company skill.", { + versionId, + skillId, + }); + } +} + +async function resolveRequestedSkillEntriesOrThrow( + db: Db, + companyId: string, + skills: CompanySkill[], + requestedSelections: Array, +) { + const missing = new Set(); + const ambiguous = new Set(); + const resolved = new Map(); + + for (const rawSelection of requestedSelections) { + const selection = normalizeRequestedDesiredSkillSelection(rawSelection); + if (!selection.key) continue; + + const match = resolveSkillReference(skills, selection.key); + if (match.skill) { + const skill = skills.find((candidate) => candidate.id === match.skill?.id); + if (!skill) { + missing.add(selection.key); + continue; + } + const selectedVersionId = selection.versionId ?? null; + await assertVersionMatchesSkill(db, companyId, skill.id, selectedVersionId); + if (!resolved.has(skill.key)) { + resolved.set(skill.key, { key: skill.key, versionId: selectedVersionId }); + } + continue; + } + + if (match.ambiguous) { + ambiguous.add(selection.key); + continue; + } + + missing.add(selection.key); + } + + if (ambiguous.size > 0 || missing.size > 0) { + const problems: string[] = []; + if (ambiguous.size > 0) { + problems.push(`ambiguous references: ${Array.from(ambiguous).sort().join(", ")}`); + } + if (missing.size > 0) { + problems.push(`unknown references: ${Array.from(missing).sort().join(", ")}`); + } + throw unprocessable(`Invalid company skill selection (${problems.join("; ")}).`); + } + + return Array.from(resolved.values()); +} + function resolveDesiredSkillKeys( skills: SkillReferenceTarget[], config: Record, @@ -1478,6 +1763,20 @@ function resolveDesiredSkillKeys( )); } +function resolveDesiredSkillEntries( + skills: SkillReferenceTarget[], + config: Record, +) { + const preference = readPaperclipSkillSyncPreference(config); + const out = new Map(); + for (const entry of preference.desiredSkillEntries) { + const key = resolveSkillReference(skills, entry.key).skill?.key ?? normalizeSkillKey(entry.key); + if (!key || out.has(key)) continue; + out.set(key, { key, versionId: entry.versionId ?? null }); + } + return Array.from(out.values()); +} + function normalizeSkillDirectory(skill: SkillSourceInfoTarget) { if ((skill.sourceType !== "local_path" && skill.sourceType !== "catalog") || !skill.sourceLocator) return null; const resolved = path.resolve(skill.sourceLocator); @@ -1864,12 +2163,20 @@ function deriveSkillSourceInfo(skill: SkillSourceInfoTarget): { }; } -function enrichSkill(skill: CompanySkill, attachedAgentCount: number, usedByAgents: CompanySkillUsageAgent[] = []) { +function enrichSkill( + skill: CompanySkill, + attachedAgentCount: number, + usedByAgents: CompanySkillUsageAgent[] = [], + currentVersion: CompanySkillVersion | null = null, + starredByCurrentActor = false, +) { const source = deriveSkillSourceInfo(skill); return { ...skill, attachedAgentCount, usedByAgents, + currentVersion, + starredByCurrentActor, ...source, }; } @@ -1896,6 +2203,20 @@ function toCompanySkillListItem(skill: CompanySkillListRow, attachedAgentCount: trustLevel: skill.trustLevel, compatibility: skill.compatibility, fileInventory: skill.fileInventory, + iconUrl: skill.iconUrl, + color: skill.color, + tagline: skill.tagline, + authorName: skill.authorName, + homepageUrl: skill.homepageUrl, + categories: skill.categories, + sharingScope: skill.sharingScope, + publicShareToken: skill.publicShareToken, + forkedFromSkillId: skill.forkedFromSkillId, + forkedFromCompanyId: skill.forkedFromCompanyId, + starCount: skill.starCount, + installCount: skill.installCount, + forkCount: skill.forkCount, + currentVersionId: skill.currentVersionId, createdAt: skill.createdAt, updatedAt: skill.updatedAt, attachedAgentCount, @@ -2028,7 +2349,7 @@ export function companySkillService(db: Db) { } } - async function list(companyId: string): Promise { + async function list(companyId: string, query: CompanySkillListQuery = {}): Promise { await ensureSkillInventoryCurrent(companyId); const rows = await db .select({ @@ -2044,6 +2365,20 @@ export function companySkillService(db: Db) { trustLevel: companySkills.trustLevel, compatibility: companySkills.compatibility, fileInventory: companySkills.fileInventory, + iconUrl: companySkills.iconUrl, + color: companySkills.color, + tagline: companySkills.tagline, + authorName: companySkills.authorName, + homepageUrl: companySkills.homepageUrl, + categories: companySkills.categories, + sharingScope: companySkills.sharingScope, + publicShareToken: companySkills.publicShareToken, + forkedFromSkillId: companySkills.forkedFromSkillId, + forkedFromCompanyId: companySkills.forkedFromCompanyId, + starCount: companySkills.starCount, + installCount: companySkills.installCount, + forkCount: companySkills.forkCount, + currentVersionId: companySkills.currentVersionId, metadata: companySkills.metadata, createdAt: companySkills.createdAt, updatedAt: companySkills.updatedAt, @@ -2053,13 +2388,42 @@ export function companySkillService(db: Db) { .orderBy(asc(companySkills.name), asc(companySkills.key)) .then((entries) => entries.map((entry) => toCompanySkillListRow(entry as CompanySkillListDbRow))); const agentRows = await agents.list(companyId); - return rows.map((skill) => { + const q = query.q?.trim().toLowerCase() ?? ""; + const categories = new Set((query.categories ?? []).map(normalizeCategorySlug).filter((value): value is string => Boolean(value))); + const filtered = rows.filter((skill) => { + if (query.scope && skill.sharingScope !== query.scope) return false; + if (categories.size > 0 && !skill.categories.some((category) => categories.has(category))) return false; + if (q) { + const haystack = [ + skill.name, + skill.slug, + skill.key, + skill.description, + skill.tagline, + skill.authorName, + ...skill.categories, + ].filter(Boolean).join("\n").toLowerCase(); + if (!haystack.includes(q)) return false; + } + return true; + }); + const items = filtered.map((skill) => { const attachedAgentCount = agentRows.filter((agent) => { const desiredSkills = resolveDesiredSkillKeys(rows, agent.adapterConfig as Record); return desiredSkills.includes(skill.key); }).length; return toCompanySkillListItem(skill, attachedAgentCount); }); + const sort = query.sort ?? "alphabetical"; + items.sort((left, right) => { + if (sort === "recent") return right.updatedAt.getTime() - left.updatedAt.getTime() || left.name.localeCompare(right.name); + if (sort === "installs") return right.installCount - left.installCount || left.name.localeCompare(right.name); + if (sort === "stars") return right.starCount - left.starCount || left.name.localeCompare(right.name); + if (sort === "agents") return right.attachedAgentCount - left.attachedAgentCount || left.name.localeCompare(right.name); + if (sort === "forks") return right.forkCount - left.forkCount || left.name.localeCompare(right.name); + return left.name.localeCompare(right.name) || left.key.localeCompare(right.key); + }); + return items; } async function listFull(companyId: string): Promise { @@ -2072,6 +2436,19 @@ export function companySkillService(db: Db) { return rows.map((row) => toCompanySkill(row)); } + async function categoryCounts(companyId: string): Promise { + const rows = await listFull(companyId); + const counts = new Map(); + for (const skill of rows) { + for (const category of skill.categories) { + counts.set(category, (counts.get(category) ?? 0) + 1); + } + } + return Array.from(counts.entries()) + .map(([slug, count]) => ({ slug, count })) + .sort((left, right) => right.count - left.count || left.slug.localeCompare(right.slug)); + } + async function listReferenceTargets(companyId: string): Promise { const rows = await db .select({ @@ -2102,6 +2479,43 @@ export function companySkillService(db: Db) { return row ? toCompanySkill(row) : null; } + async function getVersion(companyId: string, skillId: string, versionId: string): Promise { + const row = await db + .select() + .from(companySkillVersions) + .where(and( + eq(companySkillVersions.companyId, companyId), + eq(companySkillVersions.companySkillId, skillId), + eq(companySkillVersions.id, versionId), + )) + .then((rows) => rows[0] ?? null); + return row ? toCompanySkillVersion(row) : null; + } + + async function getCurrentVersion(skill: CompanySkill): Promise { + return skill.currentVersionId ? getVersion(skill.companyId, skill.id, skill.currentVersionId) : null; + } + + async function isStarredByActor(companyId: string, skillId: string, actor: SkillActor | null | undefined) { + if (!actor || actor.type === "system") return false; + const clause = actor.type === "agent" && actor.agentId + ? eq(companySkillStars.agentId, actor.agentId) + : actor.type === "user" && actor.userId + ? eq(companySkillStars.userId, actor.userId) + : null; + if (!clause) return false; + const row = await db + .select({ id: companySkillStars.id }) + .from(companySkillStars) + .where(and( + eq(companySkillStars.companyId, companyId), + eq(companySkillStars.companySkillId, skillId), + clause, + )) + .then((rows) => rows[0] ?? null); + return Boolean(row); + } + async function updateSkillMetadata( skill: CompanySkill, metadataPatch: Record, @@ -2153,12 +2567,13 @@ export function companySkillService(db: Db) { async function usage(companyId: string, key: string): Promise { const skills = await listReferenceTargets(companyId); const agentRows = await agents.list(companyId); - const desiredAgents = agentRows.filter((agent) => { - const desiredSkills = resolveDesiredSkillKeys(skills, agent.adapterConfig as Record); - return desiredSkills.includes(key); + const desiredAgents = agentRows.flatMap((agent) => { + const desiredEntries = resolveDesiredSkillEntries(skills, agent.adapterConfig as Record); + const desiredEntry = desiredEntries.find((entry) => entry.key === key); + return desiredEntry ? [{ agent, desiredEntry }] : []; }); - return desiredAgents.map((agent) => ({ + return desiredAgents.map(({ agent, desiredEntry }) => ({ id: agent.id, name: agent.name, urlKey: agent.urlKey, @@ -2166,15 +2581,355 @@ export function companySkillService(db: Db) { desired: true, // Runtime adapter state is intentionally omitted from this bounded metadata read. actualState: null, + versionId: desiredEntry.versionId ?? null, })); } - async function detail(companyId: string, id: string): Promise { + async function detail(companyId: string, id: string, actor?: SkillActor | null): Promise { await ensureSkillInventoryCurrent(companyId); const skill = await getById(companyId, id); if (!skill) return null; const usedByAgents = await usage(companyId, skill.key); - return enrichSkill(skill, usedByAgents.length, usedByAgents); + return enrichSkill( + skill, + usedByAgents.length, + usedByAgents, + await getCurrentVersion(skill), + await isStarredByActor(companyId, id, actor), + ); + } + + async function collectVersionFileInventory( + companyId: string, + skill: CompanySkill, + ): Promise { + const out: CompanySkillVersionFileInventoryEntry[] = []; + for (const entry of skill.fileInventory) { + const detail = await readFile(companyId, skill.id, entry.path); + if (!detail) continue; + out.push({ + path: detail.path, + kind: detail.kind, + content: detail.content, + }); + } + return out; + } + + async function listVersions(companyId: string, skillId: string): Promise { + const skill = await getById(companyId, skillId); + if (!skill) throw notFound("Skill not found"); + const rows = await db + .select() + .from(companySkillVersions) + .where(and(eq(companySkillVersions.companyId, companyId), eq(companySkillVersions.companySkillId, skillId))) + .orderBy(desc(companySkillVersions.revisionNumber)); + return rows.map((row) => toCompanySkillVersion(row)); + } + + async function createVersion( + companyId: string, + skillId: string, + input: CompanySkillVersionCreateRequest = {}, + actor: SkillActor | null = null, + ): Promise { + await ensureSkillInventoryCurrent(companyId); + const skill = await getById(companyId, skillId); + if (!skill) throw notFound("Skill not found"); + const fileInventory = serializeVersionFileInventory(await collectVersionFileInventory(companyId, skill)); + const versionRow = await db.transaction(async (tx) => { + await tx.execute(sql` + select ${companySkills.id} + from ${companySkills} + where ${companySkills.id} = ${skillId} + and ${companySkills.companyId} = ${companyId} + for update + `); + const [{ nextRevision }] = await tx + .select({ + nextRevision: sql`coalesce(max(${companySkillVersions.revisionNumber}), 0) + 1`, + }) + .from(companySkillVersions) + .where(and(eq(companySkillVersions.companyId, companyId), eq(companySkillVersions.companySkillId, skillId))); + const row = await tx + .insert(companySkillVersions) + .values({ + companyId, + companySkillId: skillId, + revisionNumber: Number(nextRevision ?? 1), + label: input.label?.trim() || null, + fileInventory, + authorAgentId: actor?.type === "agent" ? actor.agentId ?? null : null, + authorUserId: actor?.type === "user" ? actor.userId ?? null : null, + }) + .returning() + .then((rows) => rows[0] ?? null); + if (!row) return null; + await tx + .update(companySkills) + .set({ currentVersionId: row.id, updatedAt: new Date() }) + .where(and(eq(companySkills.id, skillId), eq(companySkills.companyId, companyId))); + return row; + }); + if (!versionRow) throw notFound("Failed to persist skill version"); + return toCompanySkillVersion(versionRow); + } + + async function refreshStarCount(companyId: string, skillId: string) { + const [{ value }] = await db + .select({ value: sql`count(*)::int` }) + .from(companySkillStars) + .where(and(eq(companySkillStars.companyId, companyId), eq(companySkillStars.companySkillId, skillId))); + const starCount = Number(value ?? 0); + await db + .update(companySkills) + .set({ starCount, updatedAt: new Date() }) + .where(and(eq(companySkills.id, skillId), eq(companySkills.companyId, companyId))); + return starCount; + } + + function actorStarClause(actor: SkillActor) { + if (actor.type === "agent" && actor.agentId) return eq(companySkillStars.agentId, actor.agentId); + if (actor.type === "user" && actor.userId) return eq(companySkillStars.userId, actor.userId); + throw unprocessable("Skill stars require an agent or board user actor."); + } + + async function starSkill(companyId: string, skillId: string, actor: SkillActor) { + const skill = await getById(companyId, skillId); + if (!skill) throw notFound("Skill not found"); + const existing = await db + .select({ id: companySkillStars.id }) + .from(companySkillStars) + .where(and(eq(companySkillStars.companyId, companyId), eq(companySkillStars.companySkillId, skillId), actorStarClause(actor))) + .then((rows) => rows[0] ?? null); + if (!existing) { + await db.insert(companySkillStars).values({ + companyId, + companySkillId: skillId, + agentId: actor.type === "agent" ? actor.agentId ?? null : null, + userId: actor.type === "user" ? actor.userId ?? null : null, + }); + } + return { skillId, starred: true, starCount: await refreshStarCount(companyId, skillId) }; + } + + async function unstarSkill(companyId: string, skillId: string, actor: SkillActor) { + const skill = await getById(companyId, skillId); + if (!skill) throw notFound("Skill not found"); + await db + .delete(companySkillStars) + .where(and(eq(companySkillStars.companyId, companyId), eq(companySkillStars.companySkillId, skillId), actorStarClause(actor))); + return { skillId, starred: false, starCount: await refreshStarCount(companyId, skillId) }; + } + + async function listComments(companyId: string, skillId: string): Promise { + const skill = await getById(companyId, skillId); + if (!skill) throw notFound("Skill not found"); + const rows = await db + .select() + .from(companySkillComments) + .where(and( + eq(companySkillComments.companyId, companyId), + eq(companySkillComments.companySkillId, skillId), + isNull(companySkillComments.deletedAt), + )) + .orderBy(asc(companySkillComments.createdAt)); + return rows.map((row) => toCompanySkillComment(row)); + } + + async function createComment( + companyId: string, + skillId: string, + input: CompanySkillCommentCreateRequest, + actor: SkillActor, + ): Promise { + const skill = await getById(companyId, skillId); + if (!skill) throw notFound("Skill not found"); + if (input.parentCommentId) { + const parent = await db + .select({ id: companySkillComments.id }) + .from(companySkillComments) + .where(and( + eq(companySkillComments.companyId, companyId), + eq(companySkillComments.companySkillId, skillId), + eq(companySkillComments.id, input.parentCommentId), + isNull(companySkillComments.deletedAt), + )) + .then((rows) => rows[0] ?? null); + if (!parent) throw notFound("Parent comment not found"); + } + const row = await db + .insert(companySkillComments) + .values({ + companyId, + companySkillId: skillId, + parentCommentId: input.parentCommentId ?? null, + authorAgentId: actor.type === "agent" ? actor.agentId ?? null : null, + authorUserId: actor.type === "user" ? actor.userId ?? null : null, + body: input.body, + }) + .returning() + .then((rows) => rows[0] ?? null); + if (!row) throw notFound("Failed to persist skill comment"); + return toCompanySkillComment(row); + } + + function assertCanMutateComment(comment: CompanySkillComment, actor: SkillActor) { + if (actor.type === "user") return; + if (actor.type === "agent" && actor.agentId && comment.authorAgentId === actor.agentId) return; + throw unprocessable("Only the comment author or a board user can modify this skill comment."); + } + + async function updateComment( + companyId: string, + skillId: string, + commentId: string, + input: CompanySkillCommentUpdateRequest, + actor: SkillActor, + ): Promise { + const existing = await db + .select() + .from(companySkillComments) + .where(and( + eq(companySkillComments.companyId, companyId), + eq(companySkillComments.companySkillId, skillId), + eq(companySkillComments.id, commentId), + isNull(companySkillComments.deletedAt), + )) + .then((rows) => rows[0] ?? null); + if (!existing) throw notFound("Skill comment not found"); + const comment = toCompanySkillComment(existing); + assertCanMutateComment(comment, actor); + const row = await db + .update(companySkillComments) + .set({ body: input.body, updatedAt: new Date() }) + .where(and( + eq(companySkillComments.companyId, companyId), + eq(companySkillComments.companySkillId, skillId), + eq(companySkillComments.id, commentId), + isNull(companySkillComments.deletedAt), + )) + .returning() + .then((rows) => rows[0] ?? null); + if (!row) throw notFound("Skill comment not found"); + return toCompanySkillComment(row); + } + + async function deleteComment( + companyId: string, + skillId: string, + commentId: string, + actor: SkillActor, + ): Promise { + const existing = await db + .select() + .from(companySkillComments) + .where(and( + eq(companySkillComments.companyId, companyId), + eq(companySkillComments.companySkillId, skillId), + eq(companySkillComments.id, commentId), + isNull(companySkillComments.deletedAt), + )) + .then((rows) => rows[0] ?? null); + if (!existing) throw notFound("Skill comment not found"); + const comment = toCompanySkillComment(existing); + assertCanMutateComment(comment, actor); + const row = await db + .update(companySkillComments) + .set({ deletedAt: new Date(), updatedAt: new Date() }) + .where(and( + eq(companySkillComments.companyId, companyId), + eq(companySkillComments.companySkillId, skillId), + eq(companySkillComments.id, commentId), + isNull(companySkillComments.deletedAt), + )) + .returning() + .then((rows) => rows[0] ?? null); + if (!row) throw notFound("Skill comment not found"); + return toCompanySkillComment(row); + } + + async function forkSkill( + companyId: string, + skillId: string, + input: CompanySkillForkRequest = {}, + actor: SkillActor | null = null, + ): Promise { + await ensureSkillInventoryCurrent(companyId); + const source = await getById(companyId, skillId); + if (!source) throw notFound("Skill not found"); + const existing = await listFull(companyId); + const usedSlugs = new Set(existing.map((skill) => normalizeSkillSlug(skill.slug) ?? skill.slug)); + const forkSlug = uniqueSkillSlug(normalizeSkillSlug(input.slug ?? `${source.slug}-fork`) ?? `${source.slug}-fork`, usedSlugs); + const forkName = input.name?.trim() || `${source.name} Fork`; + const managedRoot = resolveManagedSkillsRoot(companyId); + const forkDir = path.resolve(managedRoot, forkSlug); + await fs.rm(forkDir, { recursive: true, force: true }); + await fs.mkdir(forkDir, { recursive: true }); + for (const entry of source.fileInventory) { + const detail = await readFile(companyId, source.id, entry.path); + if (!detail) continue; + const targetPath = path.resolve(forkDir, detail.path); + await fs.mkdir(path.dirname(targetPath), { recursive: true }); + await fs.writeFile(targetPath, detail.content, "utf8"); + } + const skillFilePath = path.join(forkDir, "SKILL.md"); + const markdown = await fs.readFile(skillFilePath, "utf8").catch(() => source.markdown); + if (!(await statPath(skillFilePath))?.isFile()) { + await fs.writeFile(skillFilePath, markdown, "utf8"); + } + const inventory = await collectLocalSkillInventory(forkDir); + const parsed = parseFrontmatterMarkdown(markdown); + const metadata = { + sourceKind: "managed_local", + forkedFromSkillId: source.id, + forkedFromCompanyId: source.companyId, + forkedByAgentId: actor?.type === "agent" ? actor.agentId ?? null : null, + forkedByUserId: actor?.type === "user" ? actor.userId ?? null : null, + }; + const imported = await upsertImportedSkills(companyId, [{ + key: `company/${companyId}/${forkSlug}`, + slug: forkSlug, + name: asString(parsed.frontmatter.name) ?? forkName, + description: asString(parsed.frontmatter.description) ?? source.description, + markdown, + sourceType: "local_path", + sourceLocator: forkDir, + sourceRef: null, + trustLevel: deriveTrustLevel(inventory), + compatibility: source.compatibility, + fileInventory: inventory, + metadata, + }]); + const forked = imported[0]!; + await db + .update(companySkills) + .set({ + iconUrl: source.iconUrl, + color: source.color, + tagline: source.tagline, + authorName: source.authorName, + homepageUrl: source.homepageUrl, + categories: source.categories, + sharingScope: input.sharingScope ?? "company", + forkedFromSkillId: source.id, + forkedFromCompanyId: source.companyId, + updatedAt: new Date(), + }) + .where(and(eq(companySkills.id, forked.id), eq(companySkills.companyId, companyId))); + await db + .update(companySkills) + .set({ + forkCount: sql`${companySkills.forkCount} + 1`, + installCount: sql`${companySkills.installCount} + 1`, + updatedAt: new Date(), + }) + .where(and(eq(companySkills.id, source.id), eq(companySkills.companyId, companyId))); + await createVersion(companyId, forked.id, { label: "Initial version" }, actor); + return getById(companyId, forked.id).then((skill) => { + if (!skill) throw notFound("Forked skill not found"); + return skill; + }); } async function updateStatus(companyId: string, skillId: string): Promise { @@ -2297,12 +3052,15 @@ export function companySkillService(db: Db) { if (skill.sourceType === "local_path" || skill.sourceType === "catalog") { const absolutePath = resolveLocalSkillFilePath(skill, normalizedPath); - if (absolutePath) { - content = await fs.readFile(absolutePath, "utf8"); + const diskContent = absolutePath + ? await fs.readFile(absolutePath, "utf8").catch(() => null) + : null; + if (diskContent !== null) { + content = diskContent; } else if (normalizedPath === "SKILL.md") { content = skill.markdown; } else { - throw notFound("Skill file not found"); + throw notFound("Skill file is unavailable: the skill source directory is missing."); } } else if (skill.sourceType === "github" || skill.sourceType === "skills_sh") { const metadata = getSkillMeta(skill); @@ -2310,12 +3068,25 @@ export function companySkillService(db: Db) { const repo = asString(metadata.repo); const hostname = asString(metadata.hostname) || "github.com"; const ref = skill.sourceRef ?? asString(metadata.ref) ?? "main"; - const repoSkillDir = normalizeGitHubSkillDirectory(asString(metadata.repoSkillDir), skill.slug); + const rawRepoSkillDir = typeof metadata.repoSkillDir === "string" ? metadata.repoSkillDir.trim() : null; + // An explicit "."/"" repoSkillDir means SKILL.md lives at the repo root; + // only fall back to the slug subdirectory when metadata is absent. + const repoSkillDir = typeof metadata.repoSkillDir === "string" + ? normalizeGitHubSkillDirectory(rawRepoSkillDir, "") + : normalizeGitHubSkillDirectory(rawRepoSkillDir, skill.slug); if (!owner || !repo) { throw unprocessable("Skill source metadata is incomplete."); } const repoPath = normalizePortablePath(path.posix.join(repoSkillDir, normalizedPath)); - content = await fetchText(resolveRawGitHubUrl(hostname, owner, repo, ref, repoPath)); + try { + content = await fetchText(resolveRawGitHubUrl(hostname, owner, repo, ref, repoPath)); + } catch (error) { + if (normalizedPath === "SKILL.md" && skill.markdown) { + content = skill.markdown; + } else { + throw error; + } + } } else if (skill.sourceType === "url") { if (normalizedPath !== "SKILL.md") { throw notFound("This skill source only exposes SKILL.md"); @@ -2336,17 +3107,95 @@ export function companySkillService(db: Db) { }; } - async function createLocalSkill(companyId: string, input: CompanySkillCreateRequest): Promise { + async function updateSkill( + companyId: string, + skillId: string, + input: CompanySkillUpdateRequest = {}, + ): Promise { + await ensureSkillInventoryCurrent(companyId); + const skill = await getById(companyId, skillId); + if (!skill) throw notFound("Skill not found"); + + const sharingScope = Object.prototype.hasOwnProperty.call(input, "sharingScope") + ? normalizeMutableSharingScope(input.sharingScope) + : null; + const values: Partial = { + updatedAt: new Date(), + }; + + if (Object.prototype.hasOwnProperty.call(input, "description")) { + values.description = normalizeStoreText(input.description, 2000); + } + if (Object.prototype.hasOwnProperty.call(input, "iconUrl")) { + values.iconUrl = normalizeStoreText(input.iconUrl, 2000); + } + if (Object.prototype.hasOwnProperty.call(input, "color")) { + values.color = normalizeStoreText(input.color, 64); + } + if (Object.prototype.hasOwnProperty.call(input, "tagline")) { + values.tagline = normalizeStoreText(input.tagline, 120); + } + if (Object.prototype.hasOwnProperty.call(input, "authorName")) { + values.authorName = normalizeStoreText(input.authorName, 200); + } + if (Object.prototype.hasOwnProperty.call(input, "homepageUrl")) { + values.homepageUrl = normalizeStoreText(input.homepageUrl, 2000); + } + if (Object.prototype.hasOwnProperty.call(input, "categories")) { + values.categories = normalizeCategoryList(input.categories); + } + if (sharingScope) { + values.sharingScope = sharingScope; + values.publicShareToken = null; + } + + const row = await db + .update(companySkills) + .set(values) + .where(and(eq(companySkills.id, skillId), eq(companySkills.companyId, companyId))) + .returning() + .then((rows) => rows[0] ?? null); + if (!row) throw notFound("Skill not found"); + return toCompanySkill(row); + } + + async function createLocalSkill( + companyId: string, + input: CompanySkillCreateRequest, + actor: SkillActor | null = null, + ): Promise { const slug = normalizeSkillSlug(input.slug ?? input.name) ?? "skill"; + const key = `company/${companyId}/${slug}`; + const existing = await getByKey(companyId, key); + if (existing) { + throw conflict(`A company skill with slug "${slug}" already exists.`); + } + + const forkSource = input.forkedFromSkillId + ? await getById(companyId, input.forkedFromSkillId) + : null; + if (input.forkedFromSkillId && !forkSource) { + throw notFound("Fork source skill not found"); + } + const sharingScope = normalizeMutableSharingScope(input.sharingScope) ?? "company"; const managedRoot = resolveManagedSkillsRoot(companyId); const skillDir = path.resolve(managedRoot, slug); const skillFilePath = path.resolve(skillDir, "SKILL.md"); + await fs.rm(skillDir, { recursive: true, force: true }); await fs.mkdir(skillDir, { recursive: true }); - const markdown = (input.markdown?.trim().length - ? input.markdown - : [ + if (forkSource) { + for (const entry of forkSource.fileInventory) { + const detail = await readFile(companyId, forkSource.id, entry.path); + if (!detail) continue; + const targetPath = path.resolve(skillDir, detail.path); + await fs.mkdir(path.dirname(targetPath), { recursive: true }); + await fs.writeFile(targetPath, detail.content, "utf8"); + } + } + + const fallbackMarkdown = [ "---", `name: ${input.name}`, ...(input.description?.trim() ? [`description: ${input.description.trim()}`] : []), @@ -2356,30 +3205,80 @@ export function companySkillService(db: Db) { "", input.description?.trim() ? input.description.trim() : "Describe what this skill does.", "", - ].join("\n")); + ].join("\n"); + const markdown = input.markdown?.trim().length + ? input.markdown + : forkSource?.markdown ?? fallbackMarkdown; await fs.writeFile(skillFilePath, markdown, "utf8"); + const inventory = forkSource + ? await collectLocalSkillInventory(skillDir) + : [{ path: "SKILL.md", kind: "skill" as const }]; const parsed = parseFrontmatterMarkdown(markdown); + const metadata = { + sourceKind: "managed_local", + ...(forkSource ? { + forkedFromSkillId: forkSource.id, + forkedFromCompanyId: forkSource.companyId, + forkedByAgentId: actor?.type === "agent" ? actor.agentId ?? null : null, + forkedByUserId: actor?.type === "user" ? actor.userId ?? null : null, + } : {}), + }; const imported = await upsertImportedSkills(companyId, [{ - key: `company/${companyId}/${slug}`, + key, slug, name: asString(parsed.frontmatter.name) ?? input.name, - description: asString(parsed.frontmatter.description) ?? input.description?.trim() ?? null, + description: asString(parsed.frontmatter.description) ?? input.description?.trim() ?? forkSource?.description ?? null, markdown, sourceType: "local_path", sourceLocator: skillDir, sourceRef: null, - trustLevel: "markdown_only", - compatibility: "compatible", - fileInventory: [{ path: "SKILL.md", kind: "skill" }], - metadata: { sourceKind: "managed_local" }, + trustLevel: deriveTrustLevel(inventory), + compatibility: forkSource?.compatibility ?? "compatible", + fileInventory: inventory, + metadata, }]); - return imported[0]!; + const created = imported[0]!; + const row = await db + .update(companySkills) + .set({ + iconUrl: normalizeStoreText(input.iconUrl, 2000) ?? forkSource?.iconUrl ?? created.iconUrl, + color: normalizeStoreText(input.color, 64) ?? forkSource?.color ?? created.color, + tagline: normalizeStoreText(input.tagline, 120) ?? forkSource?.tagline ?? created.tagline, + authorName: normalizeStoreText(input.authorName, 200) ?? forkSource?.authorName ?? created.authorName, + homepageUrl: normalizeStoreText(input.homepageUrl, 2000) ?? forkSource?.homepageUrl ?? created.homepageUrl, + categories: input.categories ? normalizeCategoryList(input.categories) : forkSource?.categories ?? created.categories, + sharingScope, + forkedFromSkillId: forkSource?.id ?? null, + forkedFromCompanyId: forkSource?.companyId ?? null, + updatedAt: new Date(), + }) + .where(and(eq(companySkills.id, created.id), eq(companySkills.companyId, companyId))) + .returning() + .then((rows) => rows[0] ?? null); + if (forkSource) { + await db + .update(companySkills) + .set({ + forkCount: sql`${companySkills.forkCount} + 1`, + installCount: sql`${companySkills.installCount} + 1`, + updatedAt: new Date(), + }) + .where(and(eq(companySkills.id, forkSource.id), eq(companySkills.companyId, companyId))); + } + await createVersion(companyId, created.id, { label: "Initial version" }, actor); + return (await getById(companyId, created.id)) ?? (row ? toCompanySkill(row) : created); } - async function updateFile(companyId: string, skillId: string, relativePath: string, content: string): Promise { + async function updateFile( + companyId: string, + skillId: string, + relativePath: string, + content: string, + actor: SkillActor | null = null, + ): Promise { await ensureSkillInventoryCurrent(companyId); const skill = await getById(companyId, skillId); if (!skill) throw notFound("Skill not found"); @@ -2393,6 +3292,7 @@ export function companySkillService(db: Db) { const absolutePath = resolveLocalSkillFilePath(skill, normalizedPath); if (!absolutePath) throw notFound("Skill file not found"); + const previousContent = await fs.readFile(absolutePath, "utf8").catch(() => null); await fs.mkdir(path.dirname(absolutePath), { recursive: true }); await fs.writeFile(absolutePath, content, "utf8"); @@ -2404,6 +3304,7 @@ export function companySkillService(db: Db) { name: asString(parsed.frontmatter.name) ?? skill.name, description: asString(parsed.frontmatter.description) ?? skill.description, markdown: content, + ...readSkillStoreMetadata(parsed.frontmatter, skill.metadata), updatedAt: new Date(), }) .where(eq(companySkills.id, skill.id)); @@ -2414,6 +3315,10 @@ export function companySkillService(db: Db) { .where(eq(companySkills.id, skill.id)); } + if (previousContent !== content) { + await createVersion(companyId, skillId, {}, actor); + } + const detail = await readFile(companyId, skillId, normalizedPath); if (!detail) throw notFound("Skill file not found"); return detail; @@ -3008,6 +3913,20 @@ export function companySkillService(db: Db) { trustLevel: catalogSkill.trustLevel, compatibility: catalogSkill.compatibility, fileInventory: catalogSkill.files.map((entry) => ({ path: entry.path, kind: entry.kind })), + iconUrl: null, + color: null, + tagline: null, + authorName: null, + homepageUrl: null, + categories: normalizeCategoryList([catalogSkill.category, ...catalogSkill.tags]), + sharingScope: "company", + publicShareToken: null, + forkedFromSkillId: null, + forkedFromCompanyId: null, + starCount: 0, + installCount: 0, + forkCount: 0, + currentVersionId: null, metadata: { sourceKind: "catalog", catalogId: catalogSkill.id, @@ -3102,6 +4021,11 @@ export function companySkillService(db: Db) { } const markdown = await fs.readFile(path.join(originSnapshotLocator, catalogSkill.entrypoint), "utf8"); const metadata = buildCatalogSkillMetadata(catalogSkill, existingByKey, originSnapshotLocator); + const parsed = parseFrontmatterMarkdown(markdown); + const storeMetadata = readSkillStoreMetadata(parsed.frontmatter, { + ...metadata, + categories: [catalogSkill.category, ...catalogSkill.tags], + }); const values = { companyId, key: catalogSkill.key, @@ -3118,6 +4042,14 @@ export function companySkillService(db: Db) { path: entry.path, kind: entry.kind, }))), + iconUrl: storeMetadata.iconUrl ?? existingByKey?.iconUrl ?? null, + color: storeMetadata.color ?? existingByKey?.color ?? null, + tagline: storeMetadata.tagline ?? existingByKey?.tagline ?? catalogSkill.description.slice(0, 120), + authorName: storeMetadata.authorName ?? existingByKey?.authorName ?? "Paperclip", + homepageUrl: storeMetadata.homepageUrl ?? existingByKey?.homepageUrl ?? catalogSkill.source?.url ?? null, + categories: storeMetadata.categories.length > 0 ? storeMetadata.categories : normalizeCategoryList([catalogSkill.category, ...catalogSkill.tags]), + sharingScope: existingByKey?.sharingScope ?? "company", + installCount: existingByKey?.installCount ?? 1, metadata, updatedAt: new Date(), }; @@ -3180,6 +4112,94 @@ export function companySkillService(db: Db) { return skillDir; } + function resolveVersionSnapshotPath(skillDir: string, relativePath: string) { + const normalizedPath = normalizePortablePath(relativePath); + if (!normalizedPath) return null; + const targetPath = path.resolve(skillDir, normalizedPath); + if (targetPath !== skillDir && !targetPath.startsWith(`${skillDir}${path.sep}`)) { + throw unprocessable(`Skill version file path is invalid: ${relativePath}`); + } + return { normalizedPath, targetPath }; + } + + async function listMaterializedFiles(root: string): Promise { + async function walk(dir: string, base: string): Promise { + const entries = await fs.readdir(dir, { withFileTypes: true }); + const out: string[] = []; + for (const entry of entries) { + const relativePath = base ? path.posix.join(base, entry.name) : entry.name; + const absolutePath = path.join(dir, entry.name); + if (entry.isDirectory()) { + out.push(...await walk(absolutePath, relativePath)); + } else if (entry.isFile()) { + out.push(normalizePortablePath(relativePath)); + } else { + out.push(normalizePortablePath(relativePath)); + } + } + return out; + } + + try { + return await walk(root, ""); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return null; + throw error; + } + } + + async function materializedVersionSnapshotMatches(skillDir: string, version: CompanySkillVersion) { + const expected = new Map(); + let sawSkillFile = false; + for (const entry of version.fileInventory) { + const resolved = resolveVersionSnapshotPath(skillDir, entry.path); + if (!resolved) continue; + expected.set(resolved.normalizedPath, entry.content); + if (resolved.normalizedPath === "SKILL.md") sawSkillFile = true; + } + if (!sawSkillFile) { + throw unprocessable("Company skill version could not be materialized because its SKILL.md snapshot is missing."); + } + + const existingFiles = await listMaterializedFiles(skillDir); + if (!existingFiles || existingFiles.length !== expected.size) return false; + for (const relativePath of existingFiles) { + if (!expected.has(relativePath)) return false; + } + for (const [relativePath, content] of expected.entries()) { + const existingContent = await fs.readFile(path.resolve(skillDir, relativePath), "utf8").catch(() => null); + if (existingContent !== content) return false; + } + return true; + } + + async function materializeVersionSnapshot(companyId: string, skill: CompanySkill, version: CompanySkillVersion) { + const runtimeRoot = path.resolve(resolveManagedSkillsRoot(companyId), "__versions__"); + const skillDir = path.resolve(runtimeRoot, skill.id, version.id); + if (await materializedVersionSnapshotMatches(skillDir, version)) { + return skillDir; + } + await fs.rm(skillDir, { recursive: true, force: true }); + await fs.mkdir(skillDir, { recursive: true }); + + let wroteSkillFile = false; + for (const entry of version.fileInventory) { + const resolved = resolveVersionSnapshotPath(skillDir, entry.path); + if (!resolved) continue; + const { normalizedPath, targetPath } = resolved; + await fs.mkdir(path.dirname(targetPath), { recursive: true }); + await fs.writeFile(targetPath, entry.content, "utf8"); + if (normalizedPath === "SKILL.md") wroteSkillFile = true; + } + + if (!wroteSkillFile) { + await fs.rm(skillDir, { recursive: true, force: true }); + throw unprocessable("Company skill version could not be materialized because its SKILL.md snapshot is missing."); + } + + return skillDir; + } + function resolveRuntimeSkillMaterializedPath(companyId: string, skill: Pick) { const runtimeRoot = path.resolve(resolveManagedSkillsRoot(companyId), "__runtime__"); return path.resolve(runtimeRoot, buildSkillRuntimeName(skill.key, skill.slug)); @@ -3190,6 +4210,20 @@ export function companySkillService(db: Db) { skill: CompanySkill, options: RuntimeSkillEntryOptions, ): Promise { + const selectedVersionId = options.versionSelections?.get(skill.key) ?? null; + if (selectedVersionId) { + const version = await getVersion(companyId, skill.id, selectedVersionId); + if (!version) { + return { + status: "missing", + source: path.resolve(resolveManagedSkillsRoot(companyId), "__versions__", skill.id, selectedVersionId), + detail: "The selected skill version no longer exists.", + }; + } + const versionSource = await materializeVersionSnapshot(companyId, skill, version).catch(() => null); + return versionSource ? { status: "available", source: versionSource } : null; + } + const source = await resolveExistingSkillDirectory(normalizeSkillDirectory(skill)); if (source) return { status: "available", source }; @@ -3225,6 +4259,8 @@ export function companySkillService(db: Db) { key: skill.key, runtimeName: buildSkillRuntimeName(skill.key, skill.slug), source: sourceResolution.source, + versionId: options.versionSelections?.get(skill.key) ?? null, + currentVersionId: skill.currentVersionId, sourceStatus: sourceResolution.status, missingDetail: sourceResolution.status === "missing" ? sourceResolution.detail : null, required, @@ -3362,6 +4398,7 @@ export function companySkillService(db: Db) { async function upsertImportedSkills(companyId: string, imported: ImportedSkill[]): Promise { const out: CompanySkill[] = []; for (const skill of imported) { + assertImportedSkillKeyAllowed(skill); assertImportedSkillSourceAllowed(skill); const existing = await getByKey(companyId, skill.key); const existingMeta = existing ? getSkillMeta(existing) : {}; @@ -3384,6 +4421,8 @@ export function companySkillService(db: Db) { ...(skill.metadata ?? {}), skillKey: skill.key, }; + const parsed = parseFrontmatterMarkdown(skill.markdown); + const storeMetadata = readSkillStoreMetadata(parsed.frontmatter, metadata); const values = { companyId, key: skill.key, @@ -3397,6 +4436,14 @@ export function companySkillService(db: Db) { trustLevel: skill.trustLevel, compatibility: skill.compatibility, fileInventory: serializeFileInventory(skill.fileInventory), + iconUrl: storeMetadata.iconUrl ?? existing?.iconUrl ?? null, + color: storeMetadata.color ?? existing?.color ?? null, + tagline: storeMetadata.tagline ?? existing?.tagline ?? null, + authorName: storeMetadata.authorName ?? existing?.authorName ?? null, + homepageUrl: storeMetadata.homepageUrl ?? existing?.homepageUrl ?? null, + categories: storeMetadata.categories.length > 0 ? storeMetadata.categories : existing?.categories ?? [], + sharingScope: existing?.sharingScope ?? "company", + installCount: existing?.installCount ?? 1, metadata, updatedAt: new Date(), }; @@ -3506,9 +4553,25 @@ export function companySkillService(db: Db) { const skills = await listFull(companyId); return resolveRequestedSkillKeysOrThrow(skills, requestedReferences); }, + resolveRequestedSkillEntries: async (companyId: string, requestedSelections: Array) => { + const skills = await listFull(companyId); + return resolveRequestedSkillEntriesOrThrow(db, companyId, skills, requestedSelections); + }, + categoryCounts, detail, + listVersions, + getVersion, + createVersion, + starSkill, + unstarSkill, + listComments, + createComment, + updateComment, + deleteComment, + forkSkill, updateStatus, readFile, + updateSkill, updateFile, createLocalSkill, deleteSkill, diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index deec2ab6..24a38df7 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -188,6 +188,7 @@ import { extractSkillMentionIds, isUuidLike } from "@paperclipai/shared"; import { environmentService } from "./environments.js"; import { parseExecutionPolicyBootstrapEnv } from "./execution-policy-bootstrap.js"; import { environmentRuntimeService } from "./environment-runtime.js"; +import { skillVersionSelectionMap } from "./runtime-skill-selections.js"; import { environmentRunOrchestrator } from "./environment-run-orchestrator.js"; import { isUnsafeSessionWorkspaceCwd } from "./session-workspace-cwd.js"; import { @@ -555,7 +556,7 @@ export function applyRunScopedMentionedSkillKeys( const existingPreference = readPaperclipSkillSyncPreference(config); return writePaperclipSkillSyncPreference(config, [ - ...existingPreference.desiredSkills, + ...existingPreference.desiredSkillEntries, ...normalizedSkillKeys, ]); } @@ -8264,7 +8265,10 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) resolvedConfig, runScopedMentionedSkillKeys, ); - const runtimeSkillEntries = await companySkills.listRuntimeSkillEntries(agent.companyId); + const runtimeSkillPreference = readPaperclipSkillSyncPreference(effectiveResolvedConfig); + const runtimeSkillEntries = await companySkills.listRuntimeSkillEntries(agent.companyId, { + versionSelections: skillVersionSelectionMap(runtimeSkillPreference.desiredSkillEntries), + }); let runtimeConfig = { ...effectiveResolvedConfig, paperclipRuntimeSkills: runtimeSkillEntries, diff --git a/server/src/services/runtime-skill-selections.ts b/server/src/services/runtime-skill-selections.ts new file mode 100644 index 00000000..658a71ba --- /dev/null +++ b/server/src/services/runtime-skill-selections.ts @@ -0,0 +1,3 @@ +export function skillVersionSelectionMap(entries: Array<{ key: string; versionId: string | null }>) { + return new Map(entries.map((entry) => [entry.key, entry.versionId] as const)); +} diff --git a/ui/src/api/agents.ts b/ui/src/api/agents.ts index 3dc1fae7..e9271831 100644 --- a/ui/src/api/agents.ts +++ b/ui/src/api/agents.ts @@ -1,5 +1,6 @@ import type { Agent, + AgentDesiredSkillEntry, AgentPermissions, AgentDetail, AgentInstructionsBundle, @@ -174,7 +175,7 @@ export const agentsApi = { listKeys: (id: string, companyId?: string) => api.get(agentPath(id, companyId, "/keys")), skills: (id: string, companyId?: string) => api.get(agentPath(id, companyId, "/skills")), - syncSkills: (id: string, desiredSkills: string[], companyId?: string) => + syncSkills: (id: string, desiredSkills: Array, companyId?: string) => api.post(agentPath(id, companyId, "/skills/sync"), { desiredSkills }), createKey: (id: string, name: string, companyId?: string) => api.post(agentPath(id, companyId, "/keys"), { name }), diff --git a/ui/src/api/companySkills.ts b/ui/src/api/companySkills.ts index 996fe9aa..1e3aeb8f 100644 --- a/ui/src/api/companySkills.ts +++ b/ui/src/api/companySkills.ts @@ -3,16 +3,26 @@ import type { CatalogSkillFileDetail, CatalogSkillKind, CompanySkill, + CompanySkillCategoryCount, + CompanySkillComment, + CompanySkillCommentCreateRequest, + CompanySkillCommentUpdateRequest, CompanySkillCreateRequest, CompanySkillDetail, CompanySkillFileDetail, + CompanySkillForkRequest, CompanySkillImportResult, CompanySkillInstallCatalogRequest, CompanySkillInstallCatalogResult, + CompanySkillListQuery, CompanySkillListItem, CompanySkillProjectScanRequest, CompanySkillProjectScanResult, + CompanySkillStarResult, + CompanySkillUpdateRequest, CompanySkillUpdateStatus, + CompanySkillVersion, + CompanySkillVersionCreateRequest, } from "@paperclipai/shared"; import { api } from "./client"; @@ -23,12 +33,66 @@ export interface CatalogListQuery { } export const companySkillsApi = { - list: (companyId: string) => - api.get(`/companies/${encodeURIComponent(companyId)}/skills`), + list: (companyId: string, query: CompanySkillListQuery = {}) => { + const params = new URLSearchParams(); + if (query.q) params.set("q", query.q); + if (query.sort) params.set("sort", query.sort); + if (query.scope) params.set("scope", query.scope); + for (const category of query.categories ?? []) params.append("categories[]", category); + const search = params.toString(); + return api.get(`/companies/${encodeURIComponent(companyId)}/skills${search ? `?${search}` : ""}`); + }, + categories: (companyId: string) => + api.get(`/companies/${encodeURIComponent(companyId)}/skills/categories`), detail: (companyId: string, skillId: string) => api.get( `/companies/${encodeURIComponent(companyId)}/skills/${encodeURIComponent(skillId)}`, ), + versions: (companyId: string, skillId: string) => + api.get( + `/companies/${encodeURIComponent(companyId)}/skills/${encodeURIComponent(skillId)}/versions`, + ), + version: (companyId: string, skillId: string, versionId: string) => + api.get( + `/companies/${encodeURIComponent(companyId)}/skills/${encodeURIComponent(skillId)}/versions/${encodeURIComponent(versionId)}`, + ), + createVersion: (companyId: string, skillId: string, payload: CompanySkillVersionCreateRequest = {}) => + api.post( + `/companies/${encodeURIComponent(companyId)}/skills/${encodeURIComponent(skillId)}/versions`, + payload, + ), + star: (companyId: string, skillId: string) => + api.post( + `/companies/${encodeURIComponent(companyId)}/skills/${encodeURIComponent(skillId)}/star`, + {}, + ), + unstar: (companyId: string, skillId: string) => + api.delete( + `/companies/${encodeURIComponent(companyId)}/skills/${encodeURIComponent(skillId)}/star`, + ), + fork: (companyId: string, skillId: string, payload: CompanySkillForkRequest = {}) => + api.post( + `/companies/${encodeURIComponent(companyId)}/skills/${encodeURIComponent(skillId)}/fork`, + payload, + ), + comments: (companyId: string, skillId: string) => + api.get( + `/companies/${encodeURIComponent(companyId)}/skills/${encodeURIComponent(skillId)}/comments`, + ), + createComment: (companyId: string, skillId: string, payload: CompanySkillCommentCreateRequest) => + api.post( + `/companies/${encodeURIComponent(companyId)}/skills/${encodeURIComponent(skillId)}/comments`, + payload, + ), + updateComment: (companyId: string, skillId: string, commentId: string, payload: CompanySkillCommentUpdateRequest) => + api.patch( + `/companies/${encodeURIComponent(companyId)}/skills/${encodeURIComponent(skillId)}/comments/${encodeURIComponent(commentId)}`, + payload, + ), + deleteComment: (companyId: string, skillId: string, commentId: string) => + api.delete( + `/companies/${encodeURIComponent(companyId)}/skills/${encodeURIComponent(skillId)}/comments/${encodeURIComponent(commentId)}`, + ), updateStatus: (companyId: string, skillId: string) => api.get( `/companies/${encodeURIComponent(companyId)}/skills/${encodeURIComponent(skillId)}/update-status`, @@ -47,6 +111,11 @@ export const companySkillsApi = { `/companies/${encodeURIComponent(companyId)}/skills`, payload, ), + update: (companyId: string, skillId: string, payload: CompanySkillUpdateRequest) => + api.patch( + `/companies/${encodeURIComponent(companyId)}/skills/${encodeURIComponent(skillId)}`, + payload, + ), importFromSource: (companyId: string, source: string) => api.post( `/companies/${encodeURIComponent(companyId)}/skills/import`, diff --git a/ui/src/components/Layout.tsx b/ui/src/components/Layout.tsx index f6679d11..18fe7cc3 100644 --- a/ui/src/components/Layout.tsx +++ b/ui/src/components/Layout.tsx @@ -78,6 +78,9 @@ export function Layout() { const location = useLocation(); const navigationType = useNavigationType(); const isCompanySettingsRoute = location.pathname.includes("/company/settings"); + // The Skills Store renders its own secondary (category) sidebar, so the main + // app nav collapses to its rail throughout the /skills section (PAP-10879). + const isSkillsRoute = /(^|\/)skills(\/|$)/.test(location.pathname); const onboardingTriggered = useRef(false); const lastMainScrollTop = useRef(0); const previousPathname = useRef(null); @@ -148,10 +151,11 @@ export function Layout() { // is active, but does NOT mutate the persisted preference. Clearing the force // on cleanup restores the user's expanded/collapsed choice when navigating // off the takeover route (PAP-10694). + const forceRailCollapsed = hasSecondarySidebar || isSkillsRoute; useLayoutEffect(() => { - setForceCollapsed(hasSecondarySidebar); + setForceCollapsed(forceRailCollapsed); return () => setForceCollapsed(false); - }, [hasSecondarySidebar, setForceCollapsed]); + }, [forceRailCollapsed, setForceCollapsed]); useEffect(() => { if (companiesLoading || onboardingTriggered.current) return; @@ -536,7 +540,12 @@ export function Layout() { tabIndex={-1} className={cn( "flex-1 p-4 outline-none md:p-6", - isMobile ? "overflow-visible pb-[calc(5rem+env(safe-area-inset-bottom))]" : "overflow-auto", + // Reserve the scrollbar gutter on desktop so pages whose height + // changes (e.g. switching skill-detail tabs) don't widen/shift + // when the vertical scrollbar appears or disappears (PAP-10907). + isMobile + ? "overflow-visible pb-[calc(5rem+env(safe-area-inset-bottom))]" + : "overflow-auto [scrollbar-gutter:stable]", )} > {hasUnknownCompanyPrefix ? ( diff --git a/ui/src/lib/company-skill-routes.test.ts b/ui/src/lib/company-skill-routes.test.ts new file mode 100644 index 00000000..b9040615 --- /dev/null +++ b/ui/src/lib/company-skill-routes.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from "vitest"; +import { + canonicalSkillRouteToken, + parseSkillRoute, + resolveSkillRouteToken, + skillRoute, + type CompanySkillRouteSubject, +} from "./company-skill-routes"; + +function skill(id: string, slug: string, key = slug): CompanySkillRouteSubject { + return { id, slug, key }; +} + +const deepResearch = skill("11111111-1111-4111-8111-111111111111", "deep-research", "paperclip/deep-research"); +const scopedDeepResearch = skill("22222222-2222-4222-8222-222222222222", "deep-research", "community/deep-research"); +const browser = skill("33333333-3333-4333-8333-333333333333", "browser", "paperclip/browser"); + +describe("company skill routes", () => { + it("builds unique slug detail and file routes", () => { + expect(canonicalSkillRouteToken(browser, [deepResearch, scopedDeepResearch, browser])).toBe("browser"); + expect(skillRoute(browser, [deepResearch, scopedDeepResearch, browser])).toBe("/skills/browser"); + expect(skillRoute(browser, [browser], "references/setup guide.md")).toBe( + "/skills/browser/files/references/setup%20guide.md", + ); + }); + + it("uses a unique key to disambiguate colliding slugs", () => { + const skills = [deepResearch, scopedDeepResearch, browser]; + + expect(canonicalSkillRouteToken(deepResearch, skills)).toBe("paperclip/deep-research"); + expect(canonicalSkillRouteToken(scopedDeepResearch, skills)).toBe("community/deep-research"); + expect(skillRoute(deepResearch, skills)).toBe("/skills/paperclip/deep-research"); + }); + + it("falls back to a slug plus short id when the key is not route-safe", () => { + const unsafe = skill("44444444-4444-4444-8444-444444444444", "deep-research", "paperclip/files/deep-research"); + const skills = [deepResearch, unsafe]; + + expect(canonicalSkillRouteToken(unsafe, skills)).toBe("deep-research-44444444"); + expect(skillRoute(unsafe, skills)).toBe("/skills/deep-research-44444444"); + }); + + it("resolves legacy UUID URLs and asks callers to redirect to the canonical token", () => { + const resolution = resolveSkillRouteToken(browser.id, [browser]); + + expect(resolution.skill?.id).toBe(browser.id); + expect(resolution.canonicalToken).toBe("browser"); + expect(resolution.shouldRedirect).toBe(true); + }); + + it("resolves canonical key URLs for colliding slugs", () => { + const skills = [deepResearch, scopedDeepResearch]; + const resolution = resolveSkillRouteToken("community/deep-research", skills); + + expect(resolution.skill?.id).toBe(scopedDeepResearch.id); + expect(resolution.shouldRedirect).toBe(false); + expect(resolution.ambiguous).toBe(false); + }); + + it("leaves ambiguous bare slug URLs unresolved", () => { + const resolution = resolveSkillRouteToken("deep-research", [deepResearch, scopedDeepResearch]); + + expect(resolution.skill).toBeNull(); + expect(resolution.ambiguous).toBe(true); + }); + + it("parses token paths and encoded file paths", () => { + expect(parseSkillRoute("paperclip/deep-research/files/references/setup%20guide.md")).toEqual({ + skillToken: "paperclip/deep-research", + filePath: "references/setup guide.md", + }); + expect(parseSkillRoute(undefined)).toEqual({ skillToken: null, filePath: "SKILL.md" }); + }); +}); diff --git a/ui/src/lib/company-skill-routes.ts b/ui/src/lib/company-skill-routes.ts new file mode 100644 index 00000000..f5fc9404 --- /dev/null +++ b/ui/src/lib/company-skill-routes.ts @@ -0,0 +1,192 @@ +import type { CompanySkill, CompanySkillDetail, CompanySkillListItem } from "@paperclipai/shared"; + +export type CompanySkillRouteSubject = Pick; + +export type ParsedCompanySkillRoute = { + skillToken: string | null; + filePath: string; +}; + +export type CompanySkillRouteResolution = { + skill: CompanySkillRouteSubject | null; + canonicalToken: string | null; + shouldRedirect: boolean; + ambiguous: boolean; +}; + +const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + +function decodeRouteSegment(segment: string) { + try { + return decodeURIComponent(segment); + } catch { + return segment; + } +} + +function encodeRoutePath(value: string) { + return value.split("/").map((segment) => encodeURIComponent(segment)).join("/"); +} + +export function encodeSkillFilePath(filePath: string) { + return encodeRoutePath(filePath); +} + +export function decodeSkillFilePath(filePath: string | undefined) { + if (!filePath) return "SKILL.md"; + return filePath + .split("/") + .filter(Boolean) + .map(decodeRouteSegment) + .join("/"); +} + +function decodeSkillRouteToken(tokenPath: string | undefined) { + if (!tokenPath) return null; + const token = tokenPath + .split("/") + .filter(Boolean) + .map(decodeRouteSegment) + .join("/"); + return token.length > 0 ? token : null; +} + +export function parseSkillRoute(routePath: string | undefined): ParsedCompanySkillRoute { + const segments = (routePath ?? "").split("/").filter(Boolean); + if (segments.length === 0) { + return { skillToken: null, filePath: "SKILL.md" }; + } + + const filesIndex = segments.indexOf("files"); + const tokenSegments = filesIndex >= 0 ? segments.slice(0, filesIndex) : segments; + const skillToken = decodeSkillRouteToken(tokenSegments.join("/")); + if (!skillToken) { + return { skillToken: null, filePath: "SKILL.md" }; + } + + return { + skillToken, + filePath: filesIndex >= 0 ? decodeSkillFilePath(segments.slice(filesIndex + 1).join("/")) : "SKILL.md", + }; +} + +function shortSkillId(skill: CompanySkillRouteSubject) { + return skill.id.replace(/-/g, "").slice(0, 8); +} + +function slugShortIdToken(skill: CompanySkillRouteSubject) { + const slug = skill.slug.trim(); + return `${slug || "skill"}-${shortSkillId(skill)}`; +} + +function hasExactlyOneMatch(skills: CompanySkillRouteSubject[], predicate: (skill: CompanySkillRouteSubject) => boolean) { + return skills.filter(predicate).length === 1; +} + +function isSafeKeyToken(skill: CompanySkillRouteSubject, skills: CompanySkillRouteSubject[]) { + const key = skill.key.trim(); + if (!key || key.split("/").includes("files")) return false; + return !skills.some((candidate) => + candidate.id !== skill.id + && ( + candidate.key === key + || candidate.slug === key + || slugShortIdToken(candidate) === key + ) + ); +} + +export function canonicalSkillRouteToken( + skill: CompanySkillRouteSubject, + skills: CompanySkillRouteSubject[] = [], +) { + const slug = skill.slug.trim(); + if (slug && hasExactlyOneMatch(skills.length > 0 ? skills : [skill], (candidate) => candidate.slug === slug)) { + return slug; + } + + if (isSafeKeyToken(skill, skills)) { + return skill.key.trim(); + } + + return slugShortIdToken(skill); +} + +function uniqueMatch( + skills: CompanySkillRouteSubject[], + predicate: (skill: CompanySkillRouteSubject) => boolean, +) { + const matches = skills.filter(predicate); + if (matches.length !== 1) return { skill: null, ambiguous: matches.length > 1 }; + return { skill: matches[0], ambiguous: false }; +} + +export function resolveSkillRouteToken( + token: string | null, + skills: CompanySkillRouteSubject[], +): CompanySkillRouteResolution { + if (!token) { + return { skill: null, canonicalToken: null, shouldRedirect: false, ambiguous: false }; + } + + const legacyId = UUID_PATTERN.test(token) ? skills.find((skill) => skill.id === token) ?? null : null; + if (legacyId) { + return { + skill: legacyId, + canonicalToken: canonicalSkillRouteToken(legacyId, skills), + shouldRedirect: true, + ambiguous: false, + }; + } + + const canonical = uniqueMatch(skills, (skill) => canonicalSkillRouteToken(skill, skills) === token); + if (canonical.skill) { + return { skill: canonical.skill, canonicalToken: token, shouldRedirect: false, ambiguous: false }; + } + + const bySlug = uniqueMatch(skills, (skill) => skill.slug === token); + if (bySlug.skill) { + const canonicalToken = canonicalSkillRouteToken(bySlug.skill, skills); + return { + skill: bySlug.skill, + canonicalToken, + shouldRedirect: canonicalToken !== token, + ambiguous: false, + }; + } + if (bySlug.ambiguous) { + return { skill: null, canonicalToken: null, shouldRedirect: false, ambiguous: true }; + } + + const byKey = uniqueMatch(skills, (skill) => skill.key === token); + if (byKey.skill) { + const canonicalToken = canonicalSkillRouteToken(byKey.skill, skills); + return { + skill: byKey.skill, + canonicalToken, + shouldRedirect: canonicalToken !== token, + ambiguous: false, + }; + } + + return { skill: null, canonicalToken: null, shouldRedirect: false, ambiguous: byKey.ambiguous }; +} + +export function skillRoute( + skill: CompanySkillRouteSubject | string, + skillsOrFilePath: CompanySkillRouteSubject[] | string | null = [], + filePath?: string | null, +) { + const skills = Array.isArray(skillsOrFilePath) ? skillsOrFilePath : []; + const effectiveFilePath = Array.isArray(skillsOrFilePath) ? filePath : skillsOrFilePath; + const token = typeof skill === "string" ? skill : canonicalSkillRouteToken(skill, skills); + const basePath = `/skills/${encodeRoutePath(token)}`; + return effectiveFilePath ? `${basePath}/files/${encodeSkillFilePath(effectiveFilePath)}` : basePath; +} + +export function withRouteSkill( + skills: CompanySkillRouteSubject[], + skill: CompanySkillRouteSubject, +) { + return skills.some((candidate) => candidate.id === skill.id) ? skills : [...skills, skill]; +} diff --git a/ui/src/lib/queryKeys.ts b/ui/src/lib/queryKeys.ts index 30a8e837..790b8813 100644 --- a/ui/src/lib/queryKeys.ts +++ b/ui/src/lib/queryKeys.ts @@ -7,6 +7,8 @@ export const queryKeys = { companySkills: { list: (companyId: string) => ["company-skills", companyId] as const, detail: (companyId: string, skillId: string) => ["company-skills", companyId, skillId] as const, + versions: (companyId: string, skillId: string) => ["company-skills", companyId, skillId, "versions"] as const, + comments: (companyId: string, skillId: string) => ["company-skills", companyId, skillId, "comments"] as const, updateStatus: (companyId: string, skillId: string) => ["company-skills", companyId, skillId, "update-status"] as const, file: (companyId: string, skillId: string, relativePath: string) => diff --git a/ui/src/pages/CompanySkills.test.tsx b/ui/src/pages/CompanySkills.test.tsx new file mode 100644 index 00000000..df199276 --- /dev/null +++ b/ui/src/pages/CompanySkills.test.tsx @@ -0,0 +1,276 @@ +// @vitest-environment jsdom + +import type { ComponentProps, ReactNode } from "react"; +import { flushSync } from "react-dom"; +import { createRoot, type Root } from "react-dom/client"; +import type { CompanySkillDetail, CompanySkillVersion } from "@paperclipai/shared"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { SkillDetailPage, getSkillVersionDiffSelection } from "./CompanySkills"; + +vi.mock("@/lib/router", () => ({ + Link: ({ children, to, ...props }: { children: ReactNode; to: string }) => ( + {children} + ), + useNavigate: () => vi.fn(), + useParams: () => ({}), + useSearchParams: () => [new URLSearchParams(), vi.fn()], +})); + +vi.mock("@/components/ui/button", () => ({ + Button: ({ + children, + type = "button", + variant: _variant, + size: _size, + asChild: _asChild, + ...props + }: ComponentProps<"button"> & { asChild?: boolean; variant?: string; size?: string }) => ( + + ), +})); + +vi.mock("@/components/ui/dialog", () => ({ + Dialog: ({ open, children }: { open?: boolean; children: ReactNode }) => (open ?
{children}
: null), + DialogContent: ({ children }: { children: ReactNode }) =>
{children}
, + DialogDescription: ({ children }: { children: ReactNode }) =>

{children}

, + DialogFooter: ({ children }: { children: ReactNode }) =>
{children}
, + DialogHeader: ({ children }: { children: ReactNode }) =>
{children}
, + DialogTitle: ({ children }: { children: ReactNode }) =>

{children}

, +})); + +vi.mock("@/components/ui/dropdown-menu", () => ({ + DropdownMenu: ({ children }: { children: ReactNode }) => <>{children}, + DropdownMenuContent: () => null, + DropdownMenuItem: ({ children, onSelect }: { children: ReactNode; onSelect?: () => void }) => ( + + ), + DropdownMenuLabel: ({ children }: { children: ReactNode }) =>
{children}
, + DropdownMenuRadioGroup: ({ children }: { children: ReactNode }) =>
{children}
, + DropdownMenuRadioItem: ({ children, onSelect }: { children: ReactNode; onSelect?: () => void }) => ( + + ), + DropdownMenuSeparator: () =>
, + DropdownMenuTrigger: ({ children }: { children: ReactNode }) => <>{children}, +})); + +vi.mock("@/components/ui/popover", () => ({ + Popover: ({ children }: { children: ReactNode }) => <>{children}, + PopoverContent: () => null, + PopoverTrigger: ({ children }: { children: ReactNode }) => <>{children}, +})); + +vi.mock("@/components/ui/tooltip", () => ({ + Tooltip: ({ children }: { children: ReactNode }) => <>{children}, + TooltipContent: ({ children }: { children: ReactNode }) => {children}, + TooltipTrigger: ({ children }: { children: ReactNode }) => <>{children}, +})); + +vi.mock("@/components/ui/tabs", () => ({ + Tabs: ({ children }: { children: ReactNode }) =>
{children}
, + TabsList: ({ children }: { children: ReactNode }) =>
{children}
, + TabsTrigger: ({ children }: { children: ReactNode }) => , +})); + +vi.mock("@/components/ui/checkbox", () => ({ + Checkbox: (props: ComponentProps<"input">) => , +})); + +vi.mock("../components/MarkdownBody", () => ({ + MarkdownBody: ({ children }: { children: ReactNode }) =>
{children}
, +})); + +vi.mock("../components/MarkdownEditor", () => ({ + MarkdownEditor: ({ value }: { value: string }) =>