Implement skills store API foundation

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-06-11 16:18:33 +00:00
1 parent 69a368ed51
commit db599edadf
23 files changed
+2145 -53

No files matched your search

+74 -14
View File
@@ -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<string, unknown>): {
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<string, unknown>;
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<string, unknown>;
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<string, PaperclipDesiredSkillEntry>();
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<string, unknown>,
desiredSkills: string[],
desiredSkills: Array<string | PaperclipDesiredSkillEntry>,
): Record<string, unknown> {
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<string, unknown>) }
: {};
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<string, PaperclipDesiredSkillEntry>();
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;
}
+3
View File
@@ -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[];
}
@@ -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");
@@ -694,6 +694,13 @@
"when": 1780534200000,
"tag": "0098_project_icon",
"breakpoints": true
},
{
"idx": 99,
"version": "7",
"when": 1781130000000,
"tag": "0099_skills_store_foundation",
"breakpoints": true
}
]
}
+97
View File
@@ -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<Array<Record<string, unknown>>>().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<CompanySkillSharingScope>().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<Record<string, unknown>>(),
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<CompanySkillVersionFileInventoryEntry[]>().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),
}),
);
+1 -1
View File
@@ -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";
+28
View File
@@ -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: {
+27
View File
@@ -334,11 +334,23 @@ export type {
CompanySkillTrustLevel,
CompanySkillCompatibility,
CompanySkillSourceBadge,
CompanySkillSharingScope,
CompanySkillListSort,
CompanySkillFileInventoryEntry,
CompanySkillVersionFileInventoryEntry,
CompanySkill,
CompanySkillListItem,
CompanySkillUsageAgent,
CompanySkillDetail,
CompanySkillListQuery,
CompanySkillCategoryCount,
CompanySkillVersion,
CompanySkillVersionCreateRequest,
CompanySkillStarResult,
CompanySkillComment,
CompanySkillCommentCreateRequest,
CompanySkillCommentUpdateRequest,
CompanySkillForkRequest,
CompanySkillUpdateStatus,
CompanySkillAuditSeverity,
CompanySkillAuditVerdict,
@@ -390,6 +402,7 @@ export type {
AgentSkillSyncMode,
AgentSkillState,
AgentSkillOrigin,
AgentDesiredSkillEntry,
AgentSkillEntry,
AgentSkillSnapshot,
AgentSkillSyncRequest,
@@ -918,6 +931,8 @@ export {
type ProbeEnvironmentConfig,
agentSkillStateSchema,
agentSkillSyncModeSchema,
agentDesiredSkillEntrySchema,
agentDesiredSkillSelectionSchema,
agentSkillEntrySchema,
agentSkillSnapshotSchema,
agentSkillSyncSchema,
@@ -1212,11 +1227,23 @@ export {
companySkillTrustLevelSchema,
companySkillCompatibilitySchema,
companySkillSourceBadgeSchema,
companySkillSharingScopeSchema,
companySkillListSortSchema,
companySkillFileInventoryEntrySchema,
companySkillVersionFileInventoryEntrySchema,
companySkillSchema,
companySkillListItemSchema,
companySkillUsageAgentSchema,
companySkillListQuerySchema,
companySkillCategoryCountSchema,
companySkillVersionSchema,
companySkillDetailSchema,
companySkillVersionCreateSchema,
companySkillStarResultSchema,
companySkillCommentSchema,
companySkillCommentCreateSchema,
companySkillCommentUpdateSchema,
companySkillForkSchema,
companySkillUpdateStatusSchema,
companySkillAuditFindingSchema,
companySkillAuditResultSchema,
+9 -1
View File
@@ -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<string | AgentDesiredSkillEntry>;
}
+108
View File
@@ -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<string, unknown> | null;
createdAt: Date;
updatedAt: Date;
@@ -43,6 +65,20 @@ export interface CompanySkillListItem {
trustLevel: CompanySkillTrustLevel;
compatibility: CompanySkillCompatibility;
fileInventory: CompanySkillFileInventoryEntry[];
iconUrl: string | null;
color: string | null;
tagline: string | null;
authorName: string | null;
homepageUrl: string | null;
categories: string[];
sharingScope: CompanySkillSharingScope;
publicShareToken: string | null;
forkedFromSkillId: string | null;
forkedFromCompanyId: string | null;
starCount: number;
installCount: number;
forkCount: number;
currentVersionId: string | null;
createdAt: Date;
updatedAt: Date;
attachedAgentCount: number;
@@ -69,6 +105,7 @@ export interface CompanySkillUsageAgent {
* agent runtimes while loading operator-facing skill metadata.
*/
actualState: string | null;
versionId: string | null;
}
export interface CompanySkillDetail extends CompanySkill {
@@ -79,6 +116,70 @@ export interface CompanySkillDetail extends CompanySkill {
sourceLabel: string | null;
sourceBadge: CompanySkillSourceBadge;
sourcePath: string | null;
currentVersion: CompanySkillVersion | null;
starredByCurrentActor: boolean;
}
export interface CompanySkillListQuery {
q?: string;
sort?: CompanySkillListSort;
categories?: string[];
scope?: CompanySkillSharingScope;
}
export interface CompanySkillCategoryCount {
slug: string;
count: number;
}
export interface CompanySkillVersion {
id: string;
companyId: string;
companySkillId: string;
revisionNumber: number;
label: string | null;
fileInventory: CompanySkillVersionFileInventoryEntry[];
authorAgentId: string | null;
authorUserId: string | null;
createdAt: Date;
}
export interface CompanySkillVersionCreateRequest {
label?: string | null;
}
export interface CompanySkillStarResult {
skillId: string;
starred: boolean;
starCount: number;
}
export interface CompanySkillComment {
id: string;
companyId: string;
companySkillId: string;
parentCommentId: string | null;
authorAgentId: string | null;
authorUserId: string | null;
body: string;
deletedAt: Date | null;
createdAt: Date;
updatedAt: Date;
}
export interface CompanySkillCommentCreateRequest {
body: string;
parentCommentId?: string | null;
}
export interface CompanySkillCommentUpdateRequest {
body: string;
}
export interface CompanySkillForkRequest {
name?: string | null;
slug?: string | null;
sharingScope?: CompanySkillSharingScope;
}
export interface CompanySkillUpdateStatus {
@@ -186,6 +287,13 @@ export interface CompanySkillCreateRequest {
slug?: string | null;
description?: string | null;
markdown?: string | null;
iconUrl?: string | null;
color?: string | null;
tagline?: string | null;
authorName?: string | null;
homepageUrl?: string | null;
categories?: string[];
sharingScope?: CompanySkillSharingScope;
}
export interface CompanySkillFileDetail {
+13
View File
@@ -60,11 +60,23 @@ export type {
CompanySkillTrustLevel,
CompanySkillCompatibility,
CompanySkillSourceBadge,
CompanySkillSharingScope,
CompanySkillListSort,
CompanySkillFileInventoryEntry,
CompanySkillVersionFileInventoryEntry,
CompanySkill,
CompanySkillListItem,
CompanySkillUsageAgent,
CompanySkillDetail,
CompanySkillListQuery,
CompanySkillCategoryCount,
CompanySkillVersion,
CompanySkillVersionCreateRequest,
CompanySkillStarResult,
CompanySkillComment,
CompanySkillCommentCreateRequest,
CompanySkillCommentUpdateRequest,
CompanySkillForkRequest,
CompanySkillUpdateStatus,
CompanySkillAuditSeverity,
CompanySkillAuditVerdict,
@@ -120,6 +132,7 @@ export type {
AgentSkillSyncMode,
AgentSkillState,
AgentSkillOrigin,
AgentDesiredSkillEntry,
AgentSkillEntry,
AgentSkillSnapshot,
AgentSkillSyncRequest,
@@ -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<typeof agentSkillSyncSchema>;
+2 -1
View File
@@ -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(),
@@ -4,12 +4,18 @@ export const companySkillSourceTypeSchema = z.enum(["local_path", "github", "url
export const companySkillTrustLevelSchema = z.enum(["markdown_only", "assets", "scripts_executables"]);
export const companySkillCompatibilitySchema = z.enum(["compatible", "unknown", "invalid"]);
export const companySkillSourceBadgeSchema = z.enum(["paperclip", "github", "local", "url", "catalog", "skills_sh"]);
export const companySkillSharingScopeSchema = z.enum(["private", "company", "public_link"]);
export const companySkillListSortSchema = z.enum(["alphabetical", "recent", "installs", "stars", "agents", "forks"]);
export const companySkillFileInventoryEntrySchema = z.object({
path: z.string().min(1),
kind: z.enum(["skill", "markdown", "reference", "script", "asset", "other"]),
});
export const companySkillVersionFileInventoryEntrySchema = companySkillFileInventoryEntrySchema.extend({
content: z.string(),
});
export const companySkillSchema = z.object({
id: z.string().uuid(),
companyId: z.string().uuid(),
@@ -24,6 +30,20 @@ export const companySkillSchema = z.object({
trustLevel: companySkillTrustLevelSchema,
compatibility: companySkillCompatibilitySchema,
fileInventory: z.array(companySkillFileInventoryEntrySchema).default([]),
iconUrl: z.string().nullable(),
color: z.string().nullable(),
tagline: z.string().nullable(),
authorName: z.string().nullable(),
homepageUrl: z.string().nullable(),
categories: z.array(z.string().min(1)).default([]),
sharingScope: companySkillSharingScopeSchema,
publicShareToken: z.string().nullable(),
forkedFromSkillId: z.string().uuid().nullable(),
forkedFromCompanyId: z.string().uuid().nullable(),
starCount: z.number().int().nonnegative(),
installCount: z.number().int().nonnegative(),
forkCount: z.number().int().nonnegative(),
currentVersionId: z.string().uuid().nullable(),
metadata: z.record(z.string(), z.unknown()).nullable(),
createdAt: z.coerce.date(),
updatedAt: z.coerce.date(),
@@ -50,6 +70,31 @@ export const companySkillUsageAgentSchema = z.object({
actualState: z.string().nullable().describe(
"Runtime adapter skill state when explicitly fetched; company skill detail reads return null without probing agent runtimes.",
),
versionId: z.string().uuid().nullable(),
});
export const companySkillListQuerySchema = z.object({
q: z.string().min(1).optional(),
sort: companySkillListSortSchema.optional(),
categories: z.array(z.string().min(1)).optional(),
scope: companySkillSharingScopeSchema.optional(),
});
export const companySkillCategoryCountSchema = z.object({
slug: z.string().min(1),
count: z.number().int().nonnegative(),
});
export const companySkillVersionSchema = z.object({
id: z.string().uuid(),
companyId: z.string().uuid(),
companySkillId: z.string().uuid(),
revisionNumber: z.number().int().positive(),
label: z.string().nullable(),
fileInventory: z.array(companySkillVersionFileInventoryEntrySchema).default([]),
authorAgentId: z.string().uuid().nullable(),
authorUserId: z.string().nullable(),
createdAt: z.coerce.date(),
});
export const companySkillDetailSchema = companySkillSchema.extend({
@@ -59,8 +104,48 @@ export const companySkillDetailSchema = companySkillSchema.extend({
editableReason: z.string().nullable(),
sourceLabel: z.string().nullable(),
sourceBadge: companySkillSourceBadgeSchema,
currentVersion: companySkillVersionSchema.nullable(),
starredByCurrentActor: z.boolean(),
});
export const companySkillVersionCreateSchema = z.object({
label: z.string().trim().min(1).nullable().optional(),
}).default({});
export const companySkillStarResultSchema = z.object({
skillId: z.string().uuid(),
starred: z.boolean(),
starCount: z.number().int().nonnegative(),
});
export const companySkillCommentSchema = z.object({
id: z.string().uuid(),
companyId: z.string().uuid(),
companySkillId: z.string().uuid(),
parentCommentId: z.string().uuid().nullable(),
authorAgentId: z.string().uuid().nullable(),
authorUserId: z.string().nullable(),
body: z.string(),
deletedAt: z.coerce.date().nullable(),
createdAt: z.coerce.date(),
updatedAt: z.coerce.date(),
});
export const companySkillCommentCreateSchema = z.object({
body: z.string().min(1),
parentCommentId: z.string().uuid().nullable().optional(),
});
export const companySkillCommentUpdateSchema = z.object({
body: z.string().min(1),
});
export const companySkillForkSchema = z.object({
name: z.string().min(1).nullable().optional(),
slug: z.string().min(1).nullable().optional(),
sharingScope: companySkillSharingScopeSchema.optional(),
}).default({});
export const companySkillUpdateStatusSchema = z.object({
supported: z.boolean(),
reason: z.string().nullable(),
@@ -156,6 +241,13 @@ export const companySkillCreateSchema = z.object({
slug: z.string().min(1).nullable().optional(),
description: z.string().nullable().optional(),
markdown: z.string().nullable().optional(),
iconUrl: z.string().nullable().optional(),
color: z.string().nullable().optional(),
tagline: z.string().max(120).nullable().optional(),
authorName: z.string().nullable().optional(),
homepageUrl: z.string().nullable().optional(),
categories: z.array(z.string().min(1)).optional(),
sharingScope: companySkillSharingScopeSchema.optional(),
});
export const companySkillFileDetailSchema = z.object({
@@ -247,9 +339,14 @@ export const companySkillInstallCatalogResultSchema = z.object({
});
export type CompanySkillImport = z.infer<typeof companySkillImportSchema>;
export type CompanySkillListQuery = z.infer<typeof companySkillListQuerySchema>;
export type CompanySkillProjectScan = z.infer<typeof companySkillProjectScanRequestSchema>;
export type CompanySkillCreate = z.infer<typeof companySkillCreateSchema>;
export type CompanySkillFileUpdate = z.infer<typeof companySkillFileUpdateSchema>;
export type CompanySkillVersionCreate = z.infer<typeof companySkillVersionCreateSchema>;
export type CompanySkillCommentCreate = z.infer<typeof companySkillCommentCreateSchema>;
export type CompanySkillCommentUpdate = z.infer<typeof companySkillCommentUpdateSchema>;
export type CompanySkillFork = z.infer<typeof companySkillForkSchema>;
export type CatalogSkillListQuery = z.infer<typeof catalogSkillListQuerySchema>;
export type CompanySkillInstallCatalog = z.infer<typeof companySkillInstallCatalogSchema>;
export type CompanySkillInstallUpdate = z.infer<typeof companySkillInstallUpdateSchema>;
+19
View File
@@ -61,11 +61,23 @@ export {
companySkillTrustLevelSchema,
companySkillCompatibilitySchema,
companySkillSourceBadgeSchema,
companySkillSharingScopeSchema,
companySkillListSortSchema,
companySkillFileInventoryEntrySchema,
companySkillVersionFileInventoryEntrySchema,
companySkillSchema,
companySkillListItemSchema,
companySkillUsageAgentSchema,
companySkillListQuerySchema,
companySkillCategoryCountSchema,
companySkillVersionSchema,
companySkillDetailSchema,
companySkillVersionCreateSchema,
companySkillStarResultSchema,
companySkillCommentSchema,
companySkillCommentCreateSchema,
companySkillCommentUpdateSchema,
companySkillForkSchema,
companySkillUpdateStatusSchema,
companySkillAuditFindingSchema,
companySkillAuditResultSchema,
@@ -89,9 +101,14 @@ export {
companySkillInstallUpdateSchema,
companySkillResetSchema,
type CompanySkillImport,
type CompanySkillListQuery,
type CompanySkillProjectScan,
type CompanySkillCreate,
type CompanySkillFileUpdate,
type CompanySkillVersionCreate,
type CompanySkillCommentCreate,
type CompanySkillCommentUpdate,
type CompanySkillFork,
type CatalogSkillListQuery,
type CompanySkillInstallCatalog,
type CompanySkillInstallUpdate,
@@ -121,6 +138,8 @@ export {
export {
agentSkillStateSchema,
agentSkillSyncModeSchema,
agentDesiredSkillEntrySchema,
agentDesiredSkillSelectionSchema,
agentSkillEntrySchema,
agentSkillSnapshotSchema,
agentSkillSyncSchema,
@@ -12,6 +12,19 @@ const mockAccessService = vi.hoisted(() => ({
}));
const mockCompanySkillService = vi.hoisted(() => ({
list: vi.fn(),
categoryCounts: vi.fn(),
detail: vi.fn(),
listVersions: vi.fn(),
getVersion: vi.fn(),
createVersion: vi.fn(),
starSkill: vi.fn(),
unstarSkill: vi.fn(),
forkSkill: vi.fn(),
listComments: vi.fn(),
createComment: vi.fn(),
updateComment: vi.fn(),
deleteComment: vi.fn(),
importFromSource: vi.fn(),
installFromCatalog: vi.fn(),
deleteSkill: vi.fn(),
@@ -102,6 +115,101 @@ describe("company skill mutation permissions", () => {
imported: [],
warnings: [],
});
mockCompanySkillService.list.mockResolvedValue([]);
mockCompanySkillService.categoryCounts.mockResolvedValue([]);
mockCompanySkillService.detail.mockResolvedValue(null);
mockCompanySkillService.listVersions.mockResolvedValue([]);
mockCompanySkillService.getVersion.mockResolvedValue(null);
mockCompanySkillService.createVersion.mockResolvedValue({
id: "version-1",
companyId: "company-1",
companySkillId: "skill-1",
revisionNumber: 1,
label: "v1",
fileInventory: [{ path: "SKILL.md", kind: "skill", content: "# Skill" }],
authorAgentId: null,
authorUserId: "board",
createdAt: new Date("2026-05-26T00:00:00.000Z"),
});
mockCompanySkillService.starSkill.mockResolvedValue({
skillId: "skill-1",
starred: true,
starCount: 1,
});
mockCompanySkillService.unstarSkill.mockResolvedValue({
skillId: "skill-1",
starred: false,
starCount: 0,
});
mockCompanySkillService.forkSkill.mockResolvedValue({
id: "skill-fork",
companyId: "company-1",
key: "company/company-1/review-fork",
slug: "review-fork",
name: "Review Fork",
description: null,
markdown: "# Review",
sourceType: "local_path",
sourceLocator: "/tmp/review-fork",
sourceRef: null,
trustLevel: "markdown_only",
compatibility: "compatible",
fileInventory: [{ path: "SKILL.md", kind: "skill" }],
iconUrl: null,
color: null,
tagline: null,
authorName: null,
homepageUrl: null,
categories: [],
sharingScope: "company",
publicShareToken: null,
forkedFromSkillId: "skill-1",
forkedFromCompanyId: "company-1",
starCount: 0,
installCount: 1,
forkCount: 0,
currentVersionId: null,
metadata: null,
createdAt: new Date("2026-05-26T00:00:00.000Z"),
updatedAt: new Date("2026-05-26T00:00:00.000Z"),
});
mockCompanySkillService.listComments.mockResolvedValue([]);
mockCompanySkillService.createComment.mockResolvedValue({
id: "comment-1",
companyId: "company-1",
companySkillId: "skill-1",
parentCommentId: null,
authorAgentId: null,
authorUserId: "board",
body: "Looks good",
deletedAt: null,
createdAt: new Date("2026-05-26T00:00:00.000Z"),
updatedAt: new Date("2026-05-26T00:00:00.000Z"),
});
mockCompanySkillService.updateComment.mockResolvedValue({
id: "comment-1",
companyId: "company-1",
companySkillId: "skill-1",
parentCommentId: null,
authorAgentId: null,
authorUserId: "board",
body: "Updated",
deletedAt: null,
createdAt: new Date("2026-05-26T00:00:00.000Z"),
updatedAt: new Date("2026-05-26T00:00:00.000Z"),
});
mockCompanySkillService.deleteComment.mockResolvedValue({
id: "comment-1",
companyId: "company-1",
companySkillId: "skill-1",
parentCommentId: null,
authorAgentId: null,
authorUserId: "board",
body: "Updated",
deletedAt: new Date("2026-05-26T00:01:00.000Z"),
createdAt: new Date("2026-05-26T00:00:00.000Z"),
updatedAt: new Date("2026-05-26T00:01:00.000Z"),
});
mockCompanySkillService.installFromCatalog.mockResolvedValue({
action: "created",
skill: {
@@ -484,6 +592,77 @@ describe("company skill mutation permissions", () => {
expect(mockCompanySkillService.installFromCatalog).not.toHaveBeenCalled();
});
it("passes store list filters and category count requests to the service", async () => {
const app = await createApp({ type: "board", source: "local_implicit" });
await request(app)
.get("/api/companies/company-1/skills?sort=stars&categories[]=memory&category=git&scope=company&q=review")
.expect(200);
expect(mockCompanySkillService.list).toHaveBeenCalledWith("company-1", {
q: "review",
sort: "stars",
categories: ["git", "memory"],
scope: "company",
});
await request(app).get("/api/companies/company-1/skills/categories").expect(200);
expect(mockCompanySkillService.categoryCounts).toHaveBeenCalledWith("company-1");
});
it("creates skill versions and logs the mutation", async () => {
const app = await createApp({ type: "board", source: "local_implicit", userId: "user-1" });
await request(app)
.post("/api/companies/company-1/skills/skill-1/versions")
.send({ label: "v1" })
.expect(201);
expect(mockCompanySkillService.createVersion).toHaveBeenCalledWith("company-1", "skill-1", { label: "v1" }, {
type: "user",
userId: "user-1",
});
expect(mockLogActivity).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({
action: "company.skill_version_created",
entityType: "company_skill_version",
entityId: "version-1",
}));
});
it("stars, forks, and comments on skills through company-scoped endpoints", async () => {
const app = await createApp({ type: "board", source: "local_implicit", userId: "user-1" });
await request(app).post("/api/companies/company-1/skills/skill-1/star").send({}).expect(200);
expect(mockCompanySkillService.starSkill).toHaveBeenCalledWith("company-1", "skill-1", {
type: "user",
userId: "user-1",
});
await request(app).post("/api/companies/company-1/skills/skill-1/fork").send({ slug: "review-fork" }).expect(201);
expect(mockCompanySkillService.forkSkill).toHaveBeenCalledWith("company-1", "skill-1", { slug: "review-fork" }, {
type: "user",
userId: "user-1",
});
await request(app).post("/api/companies/company-1/skills/skill-1/comments").send({ body: "Looks good" }).expect(201);
expect(mockCompanySkillService.createComment).toHaveBeenCalledWith("company-1", "skill-1", { body: "Looks good" }, {
type: "user",
userId: "user-1",
});
expect(mockLogActivity).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({
action: "company.skill_starred",
entityId: "skill-1",
}));
expect(mockLogActivity).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({
action: "company.skill_forked",
entityId: "skill-fork",
}));
expect(mockLogActivity).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({
action: "company.skill_comment_created",
entityId: "comment-1",
}));
});
it("allows agents with canCreateAgents to mutate company skills", async () => {
mockAgentService.getById.mockResolvedValue({
id: "agent-1",
@@ -143,6 +143,169 @@ describeEmbeddedPostgres("companySkillService.list", () => {
});
});
it("filters store list results by category and creates version snapshots", async () => {
const companyId = randomUUID();
const skillId = randomUUID();
const skillDir = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-versioned-skill-"));
cleanupDirs.add(skillDir);
await fs.writeFile(path.join(skillDir, "SKILL.md"), "---\nname: Versioned Skill\ncategories:\n - Memory\n---\n\n# Versioned Skill\n", "utf8");
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
await db.insert(companySkills).values({
id: skillId,
companyId,
key: `company/${companyId}/versioned-skill`,
slug: "versioned-skill",
name: "Versioned Skill",
description: "Tracks revisions.",
markdown: "# Versioned Skill",
sourceType: "local_path",
sourceLocator: skillDir,
trustLevel: "markdown_only",
compatibility: "compatible",
fileInventory: [{ path: "SKILL.md", kind: "skill" }],
categories: ["memory"],
tagline: "Tracks revisions",
});
const filtered = await svc.list(companyId, { categories: ["memory"], sort: "recent" });
expect(filtered.some((skill) => skill.id === skillId)).toBe(true);
expect(filtered.find((skill) => skill.id === skillId)).toMatchObject({
categories: ["memory"],
tagline: "Tracks revisions",
});
const version = await svc.createVersion(companyId, skillId, { label: "v1" }, { type: "user", userId: "board" });
expect(version).toMatchObject({
companySkillId: skillId,
revisionNumber: 1,
label: "v1",
authorUserId: "board",
});
expect(version.fileInventory).toEqual([
expect.objectContaining({
path: "SKILL.md",
kind: "skill",
content: expect.stringContaining("# Versioned Skill"),
}),
]);
await expect(svc.getVersion(companyId, skillId, version.id)).resolves.toMatchObject({ id: version.id });
});
it("tracks stars and skill comments with actor ownership", async () => {
const companyId = randomUUID();
const skillId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
await db.insert(companySkills).values({
id: skillId,
companyId,
key: `company/${companyId}/discussion-skill`,
slug: "discussion-skill",
name: "Discussion Skill",
description: null,
markdown: "# Discussion Skill",
sourceType: "local_path",
sourceLocator: null,
trustLevel: "markdown_only",
compatibility: "compatible",
fileInventory: [{ path: "SKILL.md", kind: "skill" }],
});
await expect(svc.starSkill(companyId, skillId, { type: "user", userId: "board" })).resolves.toMatchObject({
starred: true,
starCount: 1,
});
await expect(svc.starSkill(companyId, skillId, { type: "user", userId: "board" })).resolves.toMatchObject({
starred: true,
starCount: 1,
});
const comment = await svc.createComment(
companyId,
skillId,
{ body: "Looks useful." },
{ type: "user", userId: "board" },
);
expect(comment).toMatchObject({ body: "Looks useful.", authorUserId: "board" });
await expect(svc.updateComment(
companyId,
skillId,
comment.id,
{ body: "Looks very useful." },
{ type: "agent", agentId: randomUUID() },
)).rejects.toMatchObject({ status: 422 });
await expect(svc.deleteComment(companyId, skillId, comment.id, { type: "user", userId: "board" }))
.resolves.toMatchObject({ id: comment.id, deletedAt: expect.any(Date) });
await expect(svc.unstarSkill(companyId, skillId, { type: "user", userId: "board" })).resolves.toMatchObject({
starred: false,
starCount: 0,
});
});
it("validates version-aware desired skill selections", async () => {
const companyId = randomUUID();
const skillId = randomUUID();
const otherSkillId = randomUUID();
const skillDir = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-pinned-skill-"));
cleanupDirs.add(skillDir);
await fs.writeFile(path.join(skillDir, "SKILL.md"), "# Pinned Skill\n", "utf8");
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
await db.insert(companySkills).values([
{
id: skillId,
companyId,
key: `company/${companyId}/pinned-skill`,
slug: "pinned-skill",
name: "Pinned Skill",
description: null,
markdown: "# Pinned Skill",
sourceType: "local_path",
sourceLocator: skillDir,
trustLevel: "markdown_only",
compatibility: "compatible",
fileInventory: [{ path: "SKILL.md", kind: "skill" }],
},
{
id: otherSkillId,
companyId,
key: `company/${companyId}/other-skill`,
slug: "other-skill",
name: "Other Skill",
description: null,
markdown: "# Other Skill",
sourceType: "local_path",
sourceLocator: null,
trustLevel: "markdown_only",
compatibility: "compatible",
fileInventory: [{ path: "SKILL.md", kind: "skill" }],
},
]);
const version = await svc.createVersion(companyId, skillId, {}, { type: "user", userId: "board" });
await expect(svc.resolveRequestedSkillEntries(companyId, [
{ key: "pinned-skill", versionId: version.id },
])).resolves.toEqual([
{ key: `company/${companyId}/pinned-skill`, versionId: version.id },
]);
await expect(svc.resolveRequestedSkillEntries(companyId, [
{ key: "other-skill", versionId: version.id },
])).rejects.toMatchObject({ status: 422 });
});
it("preserves missing local-path skills that active agents still desire", async () => {
const companyId = randomUUID();
const skillId = randomUUID();
+50 -17
View File
@@ -16,6 +16,7 @@ import {
normalizeIssueIdentifier,
resetAgentSessionSchema,
testAdapterEnvironmentSchema,
type AgentDesiredSkillEntry,
type AgentSkillSnapshot,
type InstanceSchedulerHeartbeatAgent,
upsertAgentInstructionsFileSchema,
@@ -1282,18 +1283,39 @@ export function agentRoutes(
function buildUnsupportedSkillSnapshot(
adapterType: string,
desiredSkills: string[] = [],
desiredSkillEntries: AgentDesiredSkillEntry[] = [],
): AgentSkillSnapshot {
const desiredSkills = desiredSkillEntries.map((entry) => entry.key);
return {
adapterType,
supported: false,
mode: "unsupported",
desiredSkills,
desiredSkillEntries,
entries: [],
warnings: ["This adapter does not implement skill sync yet."],
};
}
function normalizeDesiredSkillSelections(
requestedDesiredSkills: Array<string | AgentDesiredSkillEntry> | undefined,
): AgentDesiredSkillEntry[] | undefined {
if (!requestedDesiredSkills) return undefined;
const out = new Map<string, AgentDesiredSkillEntry>();
for (const value of requestedDesiredSkills) {
const entry = typeof value === "string"
? { key: value.trim(), versionId: null }
: { key: value.key.trim(), versionId: value.versionId ?? null };
if (!entry.key || out.has(entry.key)) continue;
out.set(entry.key, entry);
}
return Array.from(out.values());
}
function skillVersionSelectionMap(entries: Array<{ key: string; versionId: string | null }>) {
return new Map(entries.map((entry) => [entry.key, entry.versionId] as const));
}
// Legacy hardcoded set — used as fallback when adapter module does not
// declare requiresMaterializedRuntimeSkills explicitly.
const LEGACY_MATERIALIZED_SKILLS_SET = new Set([
@@ -1319,9 +1341,11 @@ export function agentRoutes(
materializeMissing?: boolean;
} = {},
) {
const preference = readPaperclipSkillSyncPreference(config);
const runtimeSkillEntries = await companySkills.listRuntimeSkillEntries(companyId, {
materializeMissing: options.materializeMissing
?? shouldMaterializeRuntimeSkillsForAdapter(adapterType),
versionSelections: skillVersionSelectionMap(preference.desiredSkillEntries),
});
return {
...config,
@@ -1333,31 +1357,39 @@ export function agentRoutes(
companyId: string,
adapterType: string,
adapterConfig: Record<string, unknown>,
requestedDesiredSkills: string[] | undefined,
requestedDesiredSkills: AgentDesiredSkillEntry[] | undefined,
) {
if (!requestedDesiredSkills) {
return {
adapterConfig,
desiredSkills: null as string[] | null,
desiredSkillEntries: null as AgentDesiredSkillEntry[] | null,
runtimeSkillEntries: null as Awaited<ReturnType<typeof companySkills.listRuntimeSkillEntries>> | null,
};
}
const resolvedRequestedSkills = await companySkills.resolveRequestedSkillKeys(
const resolvedRequestedSkillEntries = await companySkills.resolveRequestedSkillEntries(
companyId,
requestedDesiredSkills,
);
const resolvedRequestedSkills = resolvedRequestedSkillEntries.map((entry) => entry.key);
const runtimeSkillEntries = await companySkills.listRuntimeSkillEntries(companyId, {
materializeMissing: shouldMaterializeRuntimeSkillsForAdapter(adapterType),
versionSelections: skillVersionSelectionMap(resolvedRequestedSkillEntries),
});
const requiredSkills = runtimeSkillEntries
.filter((entry) => entry.required)
.map((entry) => entry.key);
const desiredSkills = Array.from(new Set([...requiredSkills, ...resolvedRequestedSkills]));
const desiredSkillEntries = [
...requiredSkills.map((key) => ({ key, versionId: null })),
...resolvedRequestedSkillEntries,
].filter((entry, index, entries) => entries.findIndex((candidate) => candidate.key === entry.key) === index);
const desiredSkills = desiredSkillEntries.map((entry) => entry.key);
return {
adapterConfig: writePaperclipSkillSyncPreference(adapterConfig, desiredSkills),
adapterConfig: writePaperclipSkillSyncPreference(adapterConfig, desiredSkillEntries),
desiredSkills,
desiredSkillEntries,
runtimeSkillEntries,
};
}
@@ -1574,9 +1606,14 @@ export function agentRoutes(
);
const runtimeSkillEntries = await companySkills.listRuntimeSkillEntries(agent.companyId, {
materializeMissing: false,
versionSelections: skillVersionSelectionMap(preference.desiredSkillEntries),
});
const requiredSkills = runtimeSkillEntries.filter((entry) => entry.required).map((entry) => entry.key);
res.json(buildUnsupportedSkillSnapshot(agent.adapterType, Array.from(new Set([...requiredSkills, ...preference.desiredSkills]))));
const desiredSkillEntries = [
...requiredSkills.map((key) => ({ key, versionId: null })),
...preference.desiredSkillEntries,
].filter((entry, index, entries) => entries.findIndex((candidate) => candidate.key === entry.key) === index);
res.json(buildUnsupportedSkillSnapshot(agent.adapterType, desiredSkillEntries));
return;
}
@@ -1611,16 +1648,11 @@ export function agentRoutes(
}
await assertCanUpdateAgent(req, agent);
const requestedSkills = Array.from(
new Set(
(req.body.desiredSkills as string[])
.map((value) => value.trim())
.filter(Boolean),
),
);
const requestedSkills = normalizeDesiredSkillSelections(req.body.desiredSkills);
const {
adapterConfig: nextAdapterConfig,
desiredSkills,
desiredSkillEntries,
runtimeSkillEntries,
} = await resolveDesiredSkillAssignment(
agent.companyId,
@@ -1628,7 +1660,7 @@ export function agentRoutes(
agent.adapterConfig as Record<string, unknown>,
requestedSkills,
);
if (!desiredSkills || !runtimeSkillEntries) {
if (!desiredSkills || !desiredSkillEntries || !runtimeSkillEntries) {
throw unprocessable("Skill sync requires desiredSkills.");
}
const actor = getActorInfo(req);
@@ -1669,7 +1701,7 @@ export function agentRoutes(
adapterType: updated.adapterType,
config: runtimeSkillConfig,
})
: buildUnsupportedSkillSnapshot(updated.adapterType, desiredSkills);
: buildUnsupportedSkillSnapshot(updated.adapterType, desiredSkillEntries);
await logActivity(db, {
companyId: updated.companyId,
@@ -1683,6 +1715,7 @@ export function agentRoutes(
details: {
adapterType: updated.adapterType,
desiredSkills,
desiredSkillEntries,
mode: snapshot.mode,
supported: snapshot.supported,
entryCount: snapshot.entries.length,
@@ -2093,7 +2126,7 @@ export function agentRoutes(
companyId,
hireInput.adapterType,
requestedAdapterConfig,
Array.isArray(requestedDesiredSkills) ? requestedDesiredSkills : undefined,
normalizeDesiredSkillSelections(Array.isArray(requestedDesiredSkills) ? requestedDesiredSkills : undefined),
);
const normalizedAdapterConfig = await normalizeMediatedAdapterConfigForPersistence({
companyId,
@@ -2279,7 +2312,7 @@ export function agentRoutes(
companyId,
createInput.adapterType,
requestedAdapterConfig,
Array.isArray(requestedDesiredSkills) ? requestedDesiredSkills : undefined,
normalizeDesiredSkillSelections(Array.isArray(requestedDesiredSkills) ? requestedDesiredSkills : undefined),
);
const normalizedAdapterConfig = await normalizeMediatedAdapterConfigForPersistence({
companyId,
+231 -2
View File
@@ -2,13 +2,18 @@ import { Router, type Request } from "express";
import type { Db } from "@paperclipai/db";
import {
catalogSkillListQuerySchema,
companySkillCommentCreateSchema,
companySkillCommentUpdateSchema,
companySkillCreateSchema,
companySkillFileUpdateSchema,
companySkillForkSchema,
companySkillImportSchema,
companySkillInstallCatalogSchema,
companySkillInstallUpdateSchema,
companySkillListQuerySchema,
companySkillProjectScanRequestSchema,
companySkillResetSchema,
companySkillVersionCreateSchema,
} from "@paperclipai/shared";
import { trackSkillImported } from "@paperclipai/shared/telemetry";
import { validate } from "../middleware/validate.js";
@@ -63,6 +68,22 @@ export function companySkillRoutes(db: Db) {
return undefined;
}
function queryStringArray(value: unknown): string[] {
if (typeof value === "string") return [value];
if (Array.isArray(value)) return value.filter((entry): entry is string => typeof entry === "string");
return [];
}
function skillActor(req: Request) {
if (req.actor.type === "agent") {
return { type: "agent" as const, agentId: req.actor.agentId ?? null };
}
if (req.actor.type === "board") {
return { type: "user" as const, userId: req.actor.userId ?? "board" };
}
return { type: "system" as const };
}
async function assertCanMutateCompanySkills(req: Request, companyId: string) {
assertCompanyAccess(req, companyId);
@@ -118,15 +139,30 @@ export function companySkillRoutes(db: Db) {
router.get("/companies/:companyId/skills", async (req, res) => {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
const result = await svc.list(companyId);
const result = await svc.list(companyId, companySkillListQuerySchema.parse({
q: firstQueryString(req.query.q),
sort: firstQueryString(req.query.sort),
categories: [
...queryStringArray(req.query.category),
...queryStringArray(req.query.categories),
...queryStringArray(req.query["categories[]"]),
],
scope: firstQueryString(req.query.scope),
}));
res.json(result);
});
router.get("/companies/:companyId/skills/categories", async (req, res) => {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
res.json(await svc.categoryCounts(companyId));
});
router.get("/companies/:companyId/skills/:skillId", async (req, res) => {
const companyId = req.params.companyId as string;
const skillId = req.params.skillId as string;
assertCompanyAccess(req, companyId);
const result = await svc.detail(companyId, skillId);
const result = await svc.detail(companyId, skillId, skillActor(req));
if (!result) {
res.status(404).json({ error: "Skill not found" });
return;
@@ -134,6 +170,199 @@ export function companySkillRoutes(db: Db) {
res.json(result);
});
router.get("/companies/:companyId/skills/:skillId/versions", async (req, res) => {
const companyId = req.params.companyId as string;
const skillId = req.params.skillId as string;
assertCompanyAccess(req, companyId);
res.json(await svc.listVersions(companyId, skillId));
});
router.get("/companies/:companyId/skills/:skillId/versions/:versionId", async (req, res) => {
const companyId = req.params.companyId as string;
const skillId = req.params.skillId as string;
const versionId = req.params.versionId as string;
assertCompanyAccess(req, companyId);
const result = await svc.getVersion(companyId, skillId, versionId);
if (!result) {
res.status(404).json({ error: "Skill version not found" });
return;
}
res.json(result);
});
router.post(
"/companies/:companyId/skills/:skillId/versions",
validate(companySkillVersionCreateSchema),
async (req, res) => {
const companyId = req.params.companyId as string;
const skillId = req.params.skillId as string;
await assertCanMutateCompanySkills(req, companyId);
const result = await svc.createVersion(companyId, skillId, req.body, skillActor(req));
const actor = getActorInfo(req);
await logActivity(db, {
companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
action: "company.skill_version_created",
entityType: "company_skill_version",
entityId: result.id,
details: {
skillId,
revisionNumber: result.revisionNumber,
label: result.label,
},
});
res.status(201).json(result);
},
);
router.post("/companies/:companyId/skills/:skillId/star", async (req, res) => {
const companyId = req.params.companyId as string;
const skillId = req.params.skillId as string;
assertCompanyAccess(req, companyId);
const result = await svc.starSkill(companyId, skillId, skillActor(req));
const actor = getActorInfo(req);
await logActivity(db, {
companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
action: "company.skill_starred",
entityType: "company_skill",
entityId: skillId,
details: { starCount: result.starCount },
});
res.json(result);
});
router.delete("/companies/:companyId/skills/:skillId/star", async (req, res) => {
const companyId = req.params.companyId as string;
const skillId = req.params.skillId as string;
assertCompanyAccess(req, companyId);
const result = await svc.unstarSkill(companyId, skillId, skillActor(req));
const actor = getActorInfo(req);
await logActivity(db, {
companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
action: "company.skill_unstarred",
entityType: "company_skill",
entityId: skillId,
details: { starCount: result.starCount },
});
res.json(result);
});
router.post(
"/companies/:companyId/skills/:skillId/fork",
validate(companySkillForkSchema),
async (req, res) => {
const companyId = req.params.companyId as string;
const skillId = req.params.skillId as string;
await assertCanMutateCompanySkills(req, companyId);
const result = await svc.forkSkill(companyId, skillId, req.body, skillActor(req));
const actor = getActorInfo(req);
await logActivity(db, {
companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
action: "company.skill_forked",
entityType: "company_skill",
entityId: result.id,
details: {
sourceSkillId: skillId,
slug: result.slug,
name: result.name,
},
});
res.status(201).json(result);
},
);
router.get("/companies/:companyId/skills/:skillId/comments", async (req, res) => {
const companyId = req.params.companyId as string;
const skillId = req.params.skillId as string;
assertCompanyAccess(req, companyId);
res.json(await svc.listComments(companyId, skillId));
});
router.post(
"/companies/:companyId/skills/:skillId/comments",
validate(companySkillCommentCreateSchema),
async (req, res) => {
const companyId = req.params.companyId as string;
const skillId = req.params.skillId as string;
assertCompanyAccess(req, companyId);
const result = await svc.createComment(companyId, skillId, req.body, skillActor(req));
const actor = getActorInfo(req);
await logActivity(db, {
companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
action: "company.skill_comment_created",
entityType: "company_skill_comment",
entityId: result.id,
details: { skillId, parentCommentId: result.parentCommentId },
});
res.status(201).json(result);
},
);
router.patch(
"/companies/:companyId/skills/:skillId/comments/:commentId",
validate(companySkillCommentUpdateSchema),
async (req, res) => {
const companyId = req.params.companyId as string;
const skillId = req.params.skillId as string;
const commentId = req.params.commentId as string;
assertCompanyAccess(req, companyId);
const result = await svc.updateComment(companyId, skillId, commentId, req.body, skillActor(req));
const actor = getActorInfo(req);
await logActivity(db, {
companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
action: "company.skill_comment_updated",
entityType: "company_skill_comment",
entityId: result.id,
details: { skillId },
});
res.json(result);
},
);
router.delete("/companies/:companyId/skills/:skillId/comments/:commentId", async (req, res) => {
const companyId = req.params.companyId as string;
const skillId = req.params.skillId as string;
const commentId = req.params.commentId as string;
assertCompanyAccess(req, companyId);
const result = await svc.deleteComment(companyId, skillId, commentId, skillActor(req));
const actor = getActorInfo(req);
await logActivity(db, {
companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
action: "company.skill_comment_deleted",
entityType: "company_skill_comment",
entityId: result.id,
details: { skillId },
});
res.json(result);
});
router.get("/companies/:companyId/skills/:skillId/update-status", async (req, res) => {
const companyId = req.params.companyId as string;
const skillId = req.params.skillId as string;
File diff suppressed because it is too large. Load diff
+2 -1
View File
@@ -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<AgentKey[]>(agentPath(id, companyId, "/keys")),
skills: (id: string, companyId?: string) =>
api.get<AgentSkillSnapshot>(agentPath(id, companyId, "/skills")),
syncSkills: (id: string, desiredSkills: string[], companyId?: string) =>
syncSkills: (id: string, desiredSkills: Array<string | AgentDesiredSkillEntry>, companyId?: string) =>
api.post<AgentSkillSnapshot>(agentPath(id, companyId, "/skills/sync"), { desiredSkills }),
createKey: (id: string, name: string, companyId?: string) =>
api.post<AgentKeyCreated>(agentPath(id, companyId, "/keys"), { name }),
+65 -2
View File
@@ -3,16 +3,25 @@ import type {
CatalogSkillFileDetail,
CatalogSkillKind,
CompanySkill,
CompanySkillCategoryCount,
CompanySkillComment,
CompanySkillCommentCreateRequest,
CompanySkillCommentUpdateRequest,
CompanySkillCreateRequest,
CompanySkillDetail,
CompanySkillFileDetail,
CompanySkillForkRequest,
CompanySkillImportResult,
CompanySkillInstallCatalogRequest,
CompanySkillInstallCatalogResult,
CompanySkillListQuery,
CompanySkillListItem,
CompanySkillProjectScanRequest,
CompanySkillProjectScanResult,
CompanySkillStarResult,
CompanySkillUpdateStatus,
CompanySkillVersion,
CompanySkillVersionCreateRequest,
} from "@paperclipai/shared";
import { api } from "./client";
@@ -23,12 +32,66 @@ export interface CatalogListQuery {
}
export const companySkillsApi = {
list: (companyId: string) =>
api.get<CompanySkillListItem[]>(`/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<CompanySkillListItem[]>(`/companies/${encodeURIComponent(companyId)}/skills${search ? `?${search}` : ""}`);
},
categories: (companyId: string) =>
api.get<CompanySkillCategoryCount[]>(`/companies/${encodeURIComponent(companyId)}/skills/categories`),
detail: (companyId: string, skillId: string) =>
api.get<CompanySkillDetail>(
`/companies/${encodeURIComponent(companyId)}/skills/${encodeURIComponent(skillId)}`,
),
versions: (companyId: string, skillId: string) =>
api.get<CompanySkillVersion[]>(
`/companies/${encodeURIComponent(companyId)}/skills/${encodeURIComponent(skillId)}/versions`,
),
version: (companyId: string, skillId: string, versionId: string) =>
api.get<CompanySkillVersion>(
`/companies/${encodeURIComponent(companyId)}/skills/${encodeURIComponent(skillId)}/versions/${encodeURIComponent(versionId)}`,
),
createVersion: (companyId: string, skillId: string, payload: CompanySkillVersionCreateRequest = {}) =>
api.post<CompanySkillVersion>(
`/companies/${encodeURIComponent(companyId)}/skills/${encodeURIComponent(skillId)}/versions`,
payload,
),
star: (companyId: string, skillId: string) =>
api.post<CompanySkillStarResult>(
`/companies/${encodeURIComponent(companyId)}/skills/${encodeURIComponent(skillId)}/star`,
{},
),
unstar: (companyId: string, skillId: string) =>
api.delete<CompanySkillStarResult>(
`/companies/${encodeURIComponent(companyId)}/skills/${encodeURIComponent(skillId)}/star`,
),
fork: (companyId: string, skillId: string, payload: CompanySkillForkRequest = {}) =>
api.post<CompanySkill>(
`/companies/${encodeURIComponent(companyId)}/skills/${encodeURIComponent(skillId)}/fork`,
payload,
),
comments: (companyId: string, skillId: string) =>
api.get<CompanySkillComment[]>(
`/companies/${encodeURIComponent(companyId)}/skills/${encodeURIComponent(skillId)}/comments`,
),
createComment: (companyId: string, skillId: string, payload: CompanySkillCommentCreateRequest) =>
api.post<CompanySkillComment>(
`/companies/${encodeURIComponent(companyId)}/skills/${encodeURIComponent(skillId)}/comments`,
payload,
),
updateComment: (companyId: string, skillId: string, commentId: string, payload: CompanySkillCommentUpdateRequest) =>
api.patch<CompanySkillComment>(
`/companies/${encodeURIComponent(companyId)}/skills/${encodeURIComponent(skillId)}/comments/${encodeURIComponent(commentId)}`,
payload,
),
deleteComment: (companyId: string, skillId: string, commentId: string) =>
api.delete<CompanySkillComment>(
`/companies/${encodeURIComponent(companyId)}/skills/${encodeURIComponent(skillId)}/comments/${encodeURIComponent(commentId)}`,
),
updateStatus: (companyId: string, skillId: string) =>
api.get<CompanySkillUpdateStatus>(
`/companies/${encodeURIComponent(companyId)}/skills/${encodeURIComponent(skillId)}/update-status`,
@@ -424,6 +424,23 @@ function AcpxLocalTranscriptStory() {
const SKILLS_COMPANY_ID = "company-storybook";
const defaultStoreSkillFields = {
iconUrl: null,
color: null,
tagline: null,
authorName: null,
homepageUrl: null,
categories: [],
sharingScope: "company" as const,
publicShareToken: null,
forkedFromSkillId: null,
forkedFromCompanyId: null,
starCount: 0,
installCount: 1,
forkCount: 0,
currentVersionId: null,
};
const acpxSkillsCompanyLibrary: CompanySkillListItem[] = [
{
id: "skill-paperclip",
@@ -439,6 +456,7 @@ const acpxSkillsCompanyLibrary: CompanySkillListItem[] = [
trustLevel: "scripts_executables",
compatibility: "compatible",
fileInventory: [{ path: "SKILL.md", kind: "skill" }],
...defaultStoreSkillFields,
createdAt: new Date("2026-04-12T09:00:00.000Z"),
updatedAt: new Date("2026-04-22T15:30:00.000Z"),
attachedAgentCount: 4,
@@ -466,6 +484,7 @@ const acpxSkillsCompanyLibrary: CompanySkillListItem[] = [
trustLevel: "markdown_only",
compatibility: "compatible",
fileInventory: [{ path: "SKILL.md", kind: "skill" }],
...defaultStoreSkillFields,
createdAt: new Date("2026-04-15T10:00:00.000Z"),
updatedAt: new Date("2026-04-25T12:00:00.000Z"),
attachedAgentCount: 2,
@@ -493,6 +512,7 @@ const acpxSkillsCompanyLibrary: CompanySkillListItem[] = [
trustLevel: "assets",
compatibility: "compatible",
fileInventory: [{ path: "SKILL.md", kind: "skill" }],
...defaultStoreSkillFields,
createdAt: new Date("2026-04-18T11:00:00.000Z"),
updatedAt: new Date("2026-04-26T09:30:00.000Z"),
attachedAgentCount: 1,