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

{children}

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

{children}

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