From db599edadf673d90bb4d58970e3c6c3de69cc154 Mon Sep 17 00:00:00 2001 From: Dotta Date: Thu, 11 Jun 2026 03:26:20 +0000 Subject: [PATCH] Implement skills store API foundation Co-Authored-By: Paperclip --- packages/adapter-utils/src/server-utils.ts | 88 +- packages/adapter-utils/src/types.ts | 3 + .../0099_skills_store_foundation.sql | 121 +++ packages/db/src/migrations/meta/_journal.json | 7 + packages/db/src/schema/company_skills.ts | 97 ++ packages/db/src/schema/index.ts | 2 +- packages/plugins/sdk/src/testing.ts | 28 + packages/shared/src/index.ts | 27 + packages/shared/src/types/adapter-skills.ts | 10 +- packages/shared/src/types/company-skill.ts | 108 +++ packages/shared/src/types/index.ts | 13 + .../shared/src/validators/adapter-skills.ts | 15 +- packages/shared/src/validators/agent.ts | 3 +- .../shared/src/validators/company-skill.ts | 97 ++ packages/shared/src/validators/index.ts | 19 + .../__tests__/company-skills-routes.test.ts | 179 ++++ .../__tests__/company-skills-service.test.ts | 163 ++++ server/src/routes/agents.ts | 67 +- server/src/routes/company-skills.ts | 233 ++++- server/src/services/company-skills.ts | 828 +++++++++++++++++- ui/src/api/agents.ts | 3 +- ui/src/api/companySkills.ts | 67 +- ui/storybook/stories/acpx-local.stories.tsx | 20 + 23 files changed, 2145 insertions(+), 53 deletions(-) create mode 100644 packages/db/src/migrations/0099_skills_store_foundation.sql diff --git a/packages/adapter-utils/src/server-utils.ts b/packages/adapter-utils/src/server-utils.ts index 44eb5bbd14a..392e607337d 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 3d87f7a284c..2aac3d8c304 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 00000000000..1c541a79c98 --- /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/meta/_journal.json b/packages/db/src/migrations/meta/_journal.json index 96c9209ead3..10c87e20775 100644 --- a/packages/db/src/migrations/meta/_journal.json +++ b/packages/db/src/migrations/meta/_journal.json @@ -694,6 +694,13 @@ "when": 1780534200000, "tag": "0098_project_icon", "breakpoints": true + }, + { + "idx": 99, + "version": "7", + "when": 1781130000000, + "tag": "0099_skills_store_foundation", + "breakpoints": true } ] } diff --git a/packages/db/src/schema/company_skills.ts b/packages/db/src/schema/company_skills.ts index aff17c8eaf3..abb1d2b6b9d 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 9d78a33cbd2..2dfc4f7d866 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 b3189bf7ecc..aaacebbcb86 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 0a1d3f79253..650ab4df8a0 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -334,11 +334,23 @@ export type { CompanySkillTrustLevel, CompanySkillCompatibility, CompanySkillSourceBadge, + CompanySkillSharingScope, + CompanySkillListSort, CompanySkillFileInventoryEntry, + CompanySkillVersionFileInventoryEntry, CompanySkill, CompanySkillListItem, CompanySkillUsageAgent, CompanySkillDetail, + CompanySkillListQuery, + CompanySkillCategoryCount, + CompanySkillVersion, + CompanySkillVersionCreateRequest, + CompanySkillStarResult, + CompanySkillComment, + CompanySkillCommentCreateRequest, + CompanySkillCommentUpdateRequest, + CompanySkillForkRequest, CompanySkillUpdateStatus, CompanySkillAuditSeverity, CompanySkillAuditVerdict, @@ -390,6 +402,7 @@ export type { AgentSkillSyncMode, AgentSkillState, AgentSkillOrigin, + AgentDesiredSkillEntry, AgentSkillEntry, AgentSkillSnapshot, AgentSkillSyncRequest, @@ -918,6 +931,8 @@ export { type ProbeEnvironmentConfig, agentSkillStateSchema, agentSkillSyncModeSchema, + agentDesiredSkillEntrySchema, + agentDesiredSkillSelectionSchema, agentSkillEntrySchema, agentSkillSnapshotSchema, agentSkillSyncSchema, @@ -1212,11 +1227,23 @@ export { companySkillTrustLevelSchema, companySkillCompatibilitySchema, companySkillSourceBadgeSchema, + companySkillSharingScopeSchema, + companySkillListSortSchema, companySkillFileInventoryEntrySchema, + companySkillVersionFileInventoryEntrySchema, companySkillSchema, companySkillListItemSchema, companySkillUsageAgentSchema, + companySkillListQuerySchema, + companySkillCategoryCountSchema, + companySkillVersionSchema, companySkillDetailSchema, + companySkillVersionCreateSchema, + companySkillStarResultSchema, + companySkillCommentSchema, + companySkillCommentCreateSchema, + companySkillCommentUpdateSchema, + companySkillForkSchema, companySkillUpdateStatusSchema, companySkillAuditFindingSchema, companySkillAuditResultSchema, diff --git a/packages/shared/src/types/adapter-skills.ts b/packages/shared/src/types/adapter-skills.ts index 2699bef0a34..36d6d4fda1a 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 356e3d421db..0a587f671e9 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,70 @@ 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 CompanySkillUpdateStatus { @@ -186,6 +287,13 @@ 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; } export interface CompanySkillFileDetail { diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index 5fc8b5f8dff..2e918085a4c 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -60,11 +60,23 @@ export type { CompanySkillTrustLevel, CompanySkillCompatibility, CompanySkillSourceBadge, + CompanySkillSharingScope, + CompanySkillListSort, CompanySkillFileInventoryEntry, + CompanySkillVersionFileInventoryEntry, CompanySkill, CompanySkillListItem, CompanySkillUsageAgent, CompanySkillDetail, + CompanySkillListQuery, + CompanySkillCategoryCount, + CompanySkillVersion, + CompanySkillVersionCreateRequest, + CompanySkillStarResult, + CompanySkillComment, + CompanySkillCommentCreateRequest, + CompanySkillCommentUpdateRequest, + CompanySkillForkRequest, CompanySkillUpdateStatus, CompanySkillAuditSeverity, CompanySkillAuditVerdict, @@ -120,6 +132,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 84fa0f72cc2..95c25c2585e 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 4aa64c2aa09..e7eff192ced 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.ts b/packages/shared/src/validators/company-skill.ts index a0bbb7325c7..f0772a4b772 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,48 @@ 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 companySkillUpdateStatusSchema = z.object({ supported: z.boolean(), reason: z.string().nullable(), @@ -156,6 +241,13 @@ 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(), }); export const companySkillFileDetailSchema = z.object({ @@ -247,9 +339,14 @@ 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 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 683950de77d..c08a05d79c5 100644 --- a/packages/shared/src/validators/index.ts +++ b/packages/shared/src/validators/index.ts @@ -61,11 +61,23 @@ export { companySkillTrustLevelSchema, companySkillCompatibilitySchema, companySkillSourceBadgeSchema, + companySkillSharingScopeSchema, + companySkillListSortSchema, companySkillFileInventoryEntrySchema, + companySkillVersionFileInventoryEntrySchema, companySkillSchema, companySkillListItemSchema, companySkillUsageAgentSchema, + companySkillListQuerySchema, + companySkillCategoryCountSchema, + companySkillVersionSchema, companySkillDetailSchema, + companySkillVersionCreateSchema, + companySkillStarResultSchema, + companySkillCommentSchema, + companySkillCommentCreateSchema, + companySkillCommentUpdateSchema, + companySkillForkSchema, companySkillUpdateStatusSchema, companySkillAuditFindingSchema, companySkillAuditResultSchema, @@ -89,9 +101,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 +138,8 @@ export { export { agentSkillStateSchema, agentSkillSyncModeSchema, + agentDesiredSkillEntrySchema, + agentDesiredSkillSelectionSchema, agentSkillEntrySchema, agentSkillSnapshotSchema, agentSkillSyncSchema, diff --git a/server/src/__tests__/company-skills-routes.test.ts b/server/src/__tests__/company-skills-routes.test.ts index 1545ef637e4..9e1cc5bf0de 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,77 @@ 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("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 88be5f595be..82c91cb4341 100644 --- a/server/src/__tests__/company-skills-service.test.ts +++ b/server/src/__tests__/company-skills-service.test.ts @@ -143,6 +143,169 @@ 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, + }); + 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.unstarSkill(companyId, skillId, { type: "user", userId: "board" })).resolves.toMatchObject({ + starred: false, + starCount: 0, + }); + }); + + 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, [ + { 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(); diff --git a/server/src/routes/agents.ts b/server/src/routes/agents.ts index 3182c2bf0aa..10d29ab5fd9 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, @@ -1282,18 +1283,39 @@ 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()); + } + + function skillVersionSelectionMap(entries: Array<{ key: string; versionId: string | null }>) { + return new Map(entries.map((entry) => [entry.key, entry.versionId] as const)); + } + // Legacy hardcoded set — used as fallback when adapter module does not // declare requiresMaterializedRuntimeSkills explicitly. const LEGACY_MATERIALIZED_SKILLS_SET = new Set([ @@ -1319,9 +1341,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 +1357,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 +1606,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 +1648,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 +1660,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 +1701,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 +1715,7 @@ export function agentRoutes( details: { adapterType: updated.adapterType, desiredSkills, + desiredSkillEntries, mode: snapshot.mode, supported: snapshot.supported, entryCount: snapshot.entries.length, @@ -2093,7 +2126,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 +2312,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 1fb82298adb..a2446c0cc9a 100644 --- a/server/src/routes/company-skills.ts +++ b/server/src/routes/company-skills.ts @@ -2,13 +2,18 @@ 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, + companySkillVersionCreateSchema, } from "@paperclipai/shared"; import { trackSkillImported } from "@paperclipai/shared/telemetry"; import { validate } from "../middleware/validate.js"; @@ -63,6 +68,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 ?? "board" }; + } + return { type: "system" as const }; + } + async function assertCanMutateCompanySkills(req: Request, companyId: string) { assertCompanyAccess(req, companyId); @@ -118,15 +139,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 +170,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; diff --git a/server/src/services/company-skills.ts b/server/src/services/company-skills.ts index 58bfb3ce949..258df90d1b6 100644 --- a/server/src/services/company-skills.ts +++ b/server/src/services/company-skills.ts @@ -2,36 +2,47 @@ 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, 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, CompanySkillUpdateStatus, CompanySkillUpdateHoldReason, CompanySkillUsageAgent, + CompanySkillVersion, + CompanySkillVersionCreateRequest, + CompanySkillVersionFileInventoryEntry, } from "@paperclipai/shared"; import { normalizeAgentUrlKey } from "@paperclipai/shared"; import { resolvePaperclipInstanceRoot } from "../home-paths.js"; @@ -53,6 +64,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 +80,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 +112,20 @@ type CompanySkillListRow = Pick< | "trustLevel" | "compatibility" | "fileInventory" + | "iconUrl" + | "color" + | "tagline" + | "authorName" + | "homepageUrl" + | "categories" + | "sharingScope" + | "publicShareToken" + | "forkedFromSkillId" + | "forkedFromCompanyId" + | "starCount" + | "installCount" + | "forkCount" + | "currentVersionId" | "metadata" | "createdAt" | "updatedAt" @@ -219,6 +260,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 +290,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, @@ -1306,6 +1368,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 +1404,66 @@ 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 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 +1473,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 +1637,85 @@ 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) { + await assertVersionMatchesSkill(db, companyId, match.skill.id, selection.versionId); + if (!resolved.has(match.skill.key)) { + resolved.set(match.skill.key, { key: match.skill.key, versionId: selection.versionId }); + } + 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 +1728,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 +2128,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 +2168,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 +2314,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 +2330,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 +2353,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 +2401,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 +2444,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 +2532,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 +2546,325 @@ 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 [{ nextRevision }] = await db + .select({ + nextRevision: sql`coalesce(max(${companySkillVersions.revisionNumber}), 0) + 1`, + }) + .from(companySkillVersions) + .where(and(eq(companySkillVersions.companyId, companyId), eq(companySkillVersions.companySkillId, skillId))); + const versionRow = await db + .insert(companySkillVersions) + .values({ + companyId, + companySkillId: skillId, + revisionNumber: Number(nextRevision ?? 1), + label: input.label?.trim() || null, + fileInventory: serializeVersionFileInventory(await collectVersionFileInventory(companyId, skill)), + authorAgentId: actor?.type === "agent" ? actor.agentId ?? null : null, + authorUserId: actor?.type === "user" ? actor.userId ?? null : null, + }) + .returning() + .then((rows) => rows[0] ?? null); + if (!versionRow) throw notFound("Failed to persist skill version"); + await db + .update(companySkills) + .set({ currentVersionId: versionRow.id, updatedAt: new Date() }) + .where(and(eq(companySkills.id, skillId), eq(companySkills.companyId, companyId))); + 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))) + .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), + )) + .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), + )) + .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(eq(companySkillComments.id, commentId)) + .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), + )) + .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(eq(companySkillComments.id, commentId)) + .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(eq(companySkills.id, forked.id)); + 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))); + 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 { @@ -2376,7 +3066,23 @@ export function companySkillService(db: Db) { metadata: { sourceKind: "managed_local" }, }]); - return imported[0]!; + const created = imported[0]!; + const row = await db + .update(companySkills) + .set({ + iconUrl: normalizeStoreText(input.iconUrl, 2000) ?? created.iconUrl, + color: normalizeStoreText(input.color, 64) ?? created.color, + tagline: normalizeStoreText(input.tagline, 120) ?? created.tagline, + authorName: normalizeStoreText(input.authorName, 200) ?? created.authorName, + homepageUrl: normalizeStoreText(input.homepageUrl, 2000) ?? created.homepageUrl, + categories: input.categories ? normalizeCategoryList(input.categories) : created.categories, + sharingScope: input.sharingScope ?? created.sharingScope, + updatedAt: new Date(), + }) + .where(and(eq(companySkills.id, created.id), eq(companySkills.companyId, companyId))) + .returning() + .then((rows) => rows[0] ?? null); + return row ? toCompanySkill(row) : created; } async function updateFile(companyId: string, skillId: string, relativePath: string, content: string): Promise { @@ -2404,6 +3110,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)); @@ -3008,6 +3715,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 +3823,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 +3844,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 +3914,33 @@ export function companySkillService(db: Db) { return skillDir; } + 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); + await fs.rm(skillDir, { recursive: true, force: true }); + await fs.mkdir(skillDir, { recursive: true }); + + let wroteSkillFile = false; + for (const entry of version.fileInventory) { + const normalizedPath = normalizePortablePath(entry.path); + if (!normalizedPath) continue; + const targetPath = path.resolve(skillDir, normalizedPath); + if (targetPath !== skillDir && !targetPath.startsWith(`${skillDir}${path.sep}`)) { + throw unprocessable(`Skill version file path is invalid: ${entry.path}`); + } + 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 +3951,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 +4000,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, @@ -3384,6 +4161,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 +4176,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,7 +4293,22 @@ 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, updateFile, diff --git a/ui/src/api/agents.ts b/ui/src/api/agents.ts index 3dc1fae75a1..e9271831b7e 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 996fe9aaa7b..76d337ce68a 100644 --- a/ui/src/api/companySkills.ts +++ b/ui/src/api/companySkills.ts @@ -3,16 +3,25 @@ import type { CatalogSkillFileDetail, CatalogSkillKind, CompanySkill, + CompanySkillCategoryCount, + CompanySkillComment, + CompanySkillCommentCreateRequest, + CompanySkillCommentUpdateRequest, CompanySkillCreateRequest, CompanySkillDetail, CompanySkillFileDetail, + CompanySkillForkRequest, CompanySkillImportResult, CompanySkillInstallCatalogRequest, CompanySkillInstallCatalogResult, + CompanySkillListQuery, CompanySkillListItem, CompanySkillProjectScanRequest, CompanySkillProjectScanResult, + CompanySkillStarResult, CompanySkillUpdateStatus, + CompanySkillVersion, + CompanySkillVersionCreateRequest, } from "@paperclipai/shared"; import { api } from "./client"; @@ -23,12 +32,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`, diff --git a/ui/storybook/stories/acpx-local.stories.tsx b/ui/storybook/stories/acpx-local.stories.tsx index 740b24ccabb..7baf5d9e633 100644 --- a/ui/storybook/stories/acpx-local.stories.tsx +++ b/ui/storybook/stories/acpx-local.stories.tsx @@ -424,6 +424,23 @@ function AcpxLocalTranscriptStory() { const SKILLS_COMPANY_ID = "company-storybook"; +const defaultStoreSkillFields = { + iconUrl: null, + color: null, + tagline: null, + authorName: null, + homepageUrl: null, + categories: [], + sharingScope: "company" as const, + publicShareToken: null, + forkedFromSkillId: null, + forkedFromCompanyId: null, + starCount: 0, + installCount: 1, + forkCount: 0, + currentVersionId: null, +}; + const acpxSkillsCompanyLibrary: CompanySkillListItem[] = [ { id: "skill-paperclip", @@ -439,6 +456,7 @@ const acpxSkillsCompanyLibrary: CompanySkillListItem[] = [ trustLevel: "scripts_executables", compatibility: "compatible", fileInventory: [{ path: "SKILL.md", kind: "skill" }], + ...defaultStoreSkillFields, createdAt: new Date("2026-04-12T09:00:00.000Z"), updatedAt: new Date("2026-04-22T15:30:00.000Z"), attachedAgentCount: 4, @@ -466,6 +484,7 @@ const acpxSkillsCompanyLibrary: CompanySkillListItem[] = [ trustLevel: "markdown_only", compatibility: "compatible", fileInventory: [{ path: "SKILL.md", kind: "skill" }], + ...defaultStoreSkillFields, createdAt: new Date("2026-04-15T10:00:00.000Z"), updatedAt: new Date("2026-04-25T12:00:00.000Z"), attachedAgentCount: 2, @@ -493,6 +512,7 @@ const acpxSkillsCompanyLibrary: CompanySkillListItem[] = [ trustLevel: "assets", compatibility: "compatible", fileInventory: [{ path: "SKILL.md", kind: "skill" }], + ...defaultStoreSkillFields, createdAt: new Date("2026-04-18T11:00:00.000Z"), updatedAt: new Date("2026-04-26T09:30:00.000Z"), attachedAgentCount: 1,