Build the Skills Store (#7990)
## 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 <noreply@paperclip.ing> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
@@ -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;
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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,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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<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 CompanySkillUpdate = z.infer<typeof companySkillUpdateSchema>;
|
||||
export type CatalogSkillListQuery = z.infer<typeof catalogSkillListQuerySchema>;
|
||||
export type CompanySkillInstallCatalog = z.infer<typeof companySkillInstallCatalogSchema>;
|
||||
export type CompanySkillInstallUpdate = z.infer<typeof companySkillInstallUpdateSchema>;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 },
|
||||
],
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<typeof heartbeatService>,
|
||||
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<typeof createDb>;
|
||||
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
|
||||
let oldPaperclipHome: string | undefined;
|
||||
let paperclipHome: string | null = null;
|
||||
const capturedRuns: Array<{ agentId: string; skills: PaperclipSkillEntry[] }> = [];
|
||||
const cleanupDirs = new Set<string>();
|
||||
|
||||
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());
|
||||
});
|
||||
});
|
||||
+47
-17
@@ -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<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());
|
||||
}
|
||||
|
||||
// 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<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 +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<string, unknown>,
|
||||
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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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<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`,
|
||||
@@ -47,6 +111,11 @@ export const companySkillsApi = {
|
||||
`/companies/${encodeURIComponent(companyId)}/skills`,
|
||||
payload,
|
||||
),
|
||||
update: (companyId: string, skillId: string, payload: CompanySkillUpdateRequest) =>
|
||||
api.patch<CompanySkill>(
|
||||
`/companies/${encodeURIComponent(companyId)}/skills/${encodeURIComponent(skillId)}`,
|
||||
payload,
|
||||
),
|
||||
importFromSource: (companyId: string, source: string) =>
|
||||
api.post<CompanySkillImportResult>(
|
||||
`/companies/${encodeURIComponent(companyId)}/skills/import`,
|
||||
|
||||
@@ -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<string | null>(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 ? (
|
||||
|
||||
@@ -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" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,192 @@
|
||||
import type { CompanySkill, CompanySkillDetail, CompanySkillListItem } from "@paperclipai/shared";
|
||||
|
||||
export type CompanySkillRouteSubject = Pick<CompanySkill | CompanySkillDetail | CompanySkillListItem, "id" | "key" | "slug">;
|
||||
|
||||
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];
|
||||
}
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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 }) => (
|
||||
<a href={to} {...props}>{children}</a>
|
||||
),
|
||||
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 }) => (
|
||||
<button type={type} {...props}>{children}</button>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/dialog", () => ({
|
||||
Dialog: ({ open, children }: { open?: boolean; children: ReactNode }) => (open ? <div>{children}</div> : null),
|
||||
DialogContent: ({ children }: { children: ReactNode }) => <div role="dialog">{children}</div>,
|
||||
DialogDescription: ({ children }: { children: ReactNode }) => <p>{children}</p>,
|
||||
DialogFooter: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
DialogHeader: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
DialogTitle: ({ children }: { children: ReactNode }) => <h2>{children}</h2>,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/dropdown-menu", () => ({
|
||||
DropdownMenu: ({ children }: { children: ReactNode }) => <>{children}</>,
|
||||
DropdownMenuContent: () => null,
|
||||
DropdownMenuItem: ({ children, onSelect }: { children: ReactNode; onSelect?: () => void }) => (
|
||||
<button type="button" onClick={onSelect}>{children}</button>
|
||||
),
|
||||
DropdownMenuLabel: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
DropdownMenuRadioGroup: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
DropdownMenuRadioItem: ({ children, onSelect }: { children: ReactNode; onSelect?: () => void }) => (
|
||||
<button type="button" onClick={onSelect}>{children}</button>
|
||||
),
|
||||
DropdownMenuSeparator: () => <hr />,
|
||||
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 }) => <span>{children}</span>,
|
||||
TooltipTrigger: ({ children }: { children: ReactNode }) => <>{children}</>,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/tabs", () => ({
|
||||
Tabs: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
TabsList: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
TabsTrigger: ({ children }: { children: ReactNode }) => <button type="button">{children}</button>,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/checkbox", () => ({
|
||||
Checkbox: (props: ComponentProps<"input">) => <input type="checkbox" {...props} />,
|
||||
}));
|
||||
|
||||
vi.mock("../components/MarkdownBody", () => ({
|
||||
MarkdownBody: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
}));
|
||||
|
||||
vi.mock("../components/MarkdownEditor", () => ({
|
||||
MarkdownEditor: ({ value }: { value: string }) => <textarea readOnly value={value} />,
|
||||
}));
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
let root: Root | null = null;
|
||||
let container: HTMLDivElement | null = null;
|
||||
|
||||
async function act(callback: () => void | Promise<void>) {
|
||||
let result: void | Promise<void> = undefined;
|
||||
flushSync(() => {
|
||||
result = callback();
|
||||
});
|
||||
await result;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
root?.unmount();
|
||||
root = null;
|
||||
container?.remove();
|
||||
container = null;
|
||||
});
|
||||
|
||||
function makeVersion(revisionNumber: number, content: string): CompanySkillVersion {
|
||||
return {
|
||||
id: `version-${revisionNumber}`,
|
||||
companyId: "company-1",
|
||||
companySkillId: "skill-1",
|
||||
revisionNumber,
|
||||
label: null,
|
||||
fileInventory: [
|
||||
{
|
||||
path: "SKILL.md",
|
||||
kind: "skill",
|
||||
content,
|
||||
},
|
||||
],
|
||||
authorAgentId: null,
|
||||
authorUserId: null,
|
||||
createdAt: new Date(`2026-01-0${revisionNumber}T00:00:00Z`),
|
||||
};
|
||||
}
|
||||
|
||||
function makeDetail(currentVersion: CompanySkillVersion): CompanySkillDetail {
|
||||
return {
|
||||
id: "skill-1",
|
||||
companyId: "company-1",
|
||||
key: "demo-skill",
|
||||
slug: "demo-skill",
|
||||
name: "Demo Skill",
|
||||
description: "A demo skill.",
|
||||
markdown: "# Demo Skill",
|
||||
sourceType: "local_path",
|
||||
sourceLocator: null,
|
||||
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: "private",
|
||||
publicShareToken: null,
|
||||
forkedFromSkillId: null,
|
||||
forkedFromCompanyId: null,
|
||||
starCount: 0,
|
||||
installCount: 0,
|
||||
forkCount: 0,
|
||||
currentVersionId: currentVersion.id,
|
||||
metadata: null,
|
||||
createdAt: new Date("2026-01-01T00:00:00Z"),
|
||||
updatedAt: new Date("2026-01-02T00:00:00Z"),
|
||||
attachedAgentCount: 0,
|
||||
usedByAgents: [],
|
||||
editable: true,
|
||||
editableReason: null,
|
||||
sourceLabel: "Local",
|
||||
sourceBadge: "local",
|
||||
sourcePath: null,
|
||||
currentVersion,
|
||||
starredByCurrentActor: false,
|
||||
};
|
||||
}
|
||||
|
||||
async function renderSkillDetail(versions: CompanySkillVersion[]) {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
|
||||
await act(async () => {
|
||||
root?.render(
|
||||
<SkillDetailPage
|
||||
detail={makeDetail(versions[0]!)}
|
||||
loading={false}
|
||||
activeTab="versions"
|
||||
onTabChange={vi.fn()}
|
||||
selectedPath="SKILL.md"
|
||||
file={null}
|
||||
fileLoading={false}
|
||||
viewMode="preview"
|
||||
editMode={false}
|
||||
draft=""
|
||||
setViewMode={vi.fn()}
|
||||
setEditMode={vi.fn()}
|
||||
setDraft={vi.fn()}
|
||||
onSave={vi.fn()}
|
||||
savePending={false}
|
||||
versions={versions}
|
||||
versionsLoading={false}
|
||||
attachAgents={[]}
|
||||
onSubmitAttach={vi.fn()}
|
||||
attachPending={false}
|
||||
expandedDirs={new Set()}
|
||||
onToggleDir={vi.fn()}
|
||||
onSelectPath={vi.fn()}
|
||||
updateStatus={null}
|
||||
updateStatusLoading={false}
|
||||
onCheckUpdates={vi.fn()}
|
||||
checkUpdatesPending={false}
|
||||
onInstallUpdate={vi.fn()}
|
||||
installUpdatePending={false}
|
||||
onToggleStar={vi.fn()}
|
||||
starPending={false}
|
||||
onFork={vi.fn()}
|
||||
onUpdateSharingScope={vi.fn()}
|
||||
updateSharingPending={false}
|
||||
onDelete={vi.fn()}
|
||||
deletePending={false}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
return container;
|
||||
}
|
||||
|
||||
function buttonsNamed(node: ParentNode, name: string) {
|
||||
return Array.from(node.querySelectorAll("button")).filter((button) => button.textContent?.trim() === name);
|
||||
}
|
||||
|
||||
async function click(button: HTMLButtonElement) {
|
||||
await act(async () => {
|
||||
button.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
}
|
||||
|
||||
describe("getSkillVersionDiffSelection", () => {
|
||||
it("selects the previous saved revision or the initial baseline for row diffs", () => {
|
||||
const v1 = makeVersion(1, "first");
|
||||
const v2 = makeVersion(2, "second");
|
||||
|
||||
expect(getSkillVersionDiffSelection([v2, v1], v2.id)).toEqual({
|
||||
leftVersionId: v1.id,
|
||||
rightVersionId: v2.id,
|
||||
});
|
||||
expect(getSkillVersionDiffSelection([v2, v1], v1.id)).toEqual({
|
||||
leftVersionId: null,
|
||||
rightVersionId: v1.id,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("SkillDetailPage versions tab", () => {
|
||||
it("opens per-row version diffs for newest and oldest revisions", async () => {
|
||||
const v1 = makeVersion(1, "# Demo Skill\n\nFirst line");
|
||||
const v2 = makeVersion(2, "# Demo Skill\n\nSecond line");
|
||||
const node = await renderSkillDetail([v2, v1]);
|
||||
const viewDiffButtons = buttonsNamed(node, "View diff") as HTMLButtonElement[];
|
||||
|
||||
expect(viewDiffButtons).toHaveLength(2);
|
||||
|
||||
await click(viewDiffButtons[0]!);
|
||||
let dialog = node.querySelector('[role="dialog"]') as HTMLElement;
|
||||
let selects = Array.from(dialog.querySelectorAll("select"));
|
||||
|
||||
expect(dialog.textContent).toContain("Diff");
|
||||
expect(selects[0]?.value).toBe(v1.id);
|
||||
expect(selects[1]?.value).toBe(v2.id);
|
||||
expect(dialog.textContent).toContain("Second line");
|
||||
|
||||
await click(viewDiffButtons[1]!);
|
||||
dialog = node.querySelector('[role="dialog"]') as HTMLElement;
|
||||
selects = Array.from(dialog.querySelectorAll("select"));
|
||||
|
||||
expect(selects[0]?.value).toBe("");
|
||||
expect(selects[1]?.value).toBe(v1.id);
|
||||
expect(dialog.textContent).toContain("Initial");
|
||||
expect(dialog.textContent).toContain("First line");
|
||||
expect(dialog.textContent).not.toContain("Both sides are the same version");
|
||||
});
|
||||
});
|
||||
+2486
-405
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
import { useState } from "react";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import type {
|
||||
CompanySkillDetail,
|
||||
CompanySkillFileDetail,
|
||||
CompanySkillVersion,
|
||||
} from "@paperclipai/shared";
|
||||
import { SkillDetailPage } from "@/pages/CompanySkills";
|
||||
|
||||
type DetailTab = "overview" | "files" | "versions" | "agents";
|
||||
|
||||
const NOW = new Date("2026-06-01T12:00:00Z");
|
||||
|
||||
const MOCK_DETAIL: CompanySkillDetail = {
|
||||
id: "skill-1",
|
||||
companyId: "company-1",
|
||||
key: "paperclipai/paperclip/deep-research",
|
||||
slug: "deep-research",
|
||||
name: "deep-research",
|
||||
description:
|
||||
"Fan-out web searches, fetch sources, adversarially verify claims, and synthesize a cited report. Best for multi-source, fact-checked research where one search angle will not find everything.",
|
||||
markdown: "# deep-research\n\nResearch harness.",
|
||||
sourceType: "github",
|
||||
sourceLocator: "github.com/paperclipai/skills",
|
||||
sourceRef: "a1b2c3d4e5f6",
|
||||
trustLevel: "scripts_executables",
|
||||
compatibility: "compatible",
|
||||
fileInventory: [
|
||||
{ path: "SKILL.md", kind: "skill" },
|
||||
{ path: "references/methodology.md", kind: "reference" },
|
||||
{ path: "scripts/fetch.ts", kind: "script" },
|
||||
],
|
||||
iconUrl: null,
|
||||
color: "#6366f1",
|
||||
tagline: "Multi-source research with citation-grade synthesis.",
|
||||
authorName: "Astra",
|
||||
homepageUrl: null,
|
||||
categories: ["research", "writing"],
|
||||
sharingScope: "company",
|
||||
publicShareToken: null,
|
||||
forkedFromSkillId: null,
|
||||
forkedFromCompanyId: null,
|
||||
starCount: 211,
|
||||
installCount: 96,
|
||||
forkCount: 17,
|
||||
currentVersionId: "v-2",
|
||||
metadata: null,
|
||||
createdAt: NOW,
|
||||
updatedAt: NOW,
|
||||
attachedAgentCount: 4,
|
||||
usedByAgents: [
|
||||
{ id: "a-1", name: "Astra", urlKey: "astra", adapterType: "process", desired: true, actualState: null, versionId: null },
|
||||
{ id: "a-2", name: "Scout", urlKey: "scout", adapterType: "http", desired: true, actualState: null, versionId: null },
|
||||
{ id: "a-3", name: "Quill", urlKey: "quill", adapterType: "process", desired: true, actualState: null, versionId: null },
|
||||
{ id: "a-4", name: "Marlow", urlKey: "marlow", adapterType: "process", desired: true, actualState: null, versionId: null },
|
||||
],
|
||||
editable: true,
|
||||
editableReason: null,
|
||||
sourceLabel: "GitHub",
|
||||
sourceBadge: "github",
|
||||
sourcePath: "github.com/paperclipai/skills/tree/main/deep-research",
|
||||
currentVersion: {
|
||||
id: "v-2",
|
||||
companyId: "company-1",
|
||||
companySkillId: "skill-1",
|
||||
revisionNumber: 2,
|
||||
label: "tighten verifier",
|
||||
fileInventory: [],
|
||||
authorAgentId: "a-1",
|
||||
authorUserId: null,
|
||||
createdAt: NOW,
|
||||
},
|
||||
starredByCurrentActor: true,
|
||||
};
|
||||
|
||||
const MOCK_VERSIONS: CompanySkillVersion[] = [
|
||||
{
|
||||
id: "v-2",
|
||||
companyId: "company-1",
|
||||
companySkillId: "skill-1",
|
||||
revisionNumber: 2,
|
||||
label: "tighten verifier",
|
||||
fileInventory: [{ path: "SKILL.md", kind: "skill", content: "# v2" }],
|
||||
authorAgentId: "a-1",
|
||||
authorUserId: null,
|
||||
createdAt: NOW,
|
||||
},
|
||||
{
|
||||
id: "v-1",
|
||||
companyId: "company-1",
|
||||
companySkillId: "skill-1",
|
||||
revisionNumber: 1,
|
||||
label: null,
|
||||
fileInventory: [{ path: "SKILL.md", kind: "skill", content: "# v1" }],
|
||||
authorAgentId: "a-1",
|
||||
authorUserId: null,
|
||||
createdAt: NOW,
|
||||
},
|
||||
];
|
||||
|
||||
const MOCK_FILE: CompanySkillFileDetail = {
|
||||
skillId: "skill-1",
|
||||
path: "SKILL.md",
|
||||
kind: "skill",
|
||||
content: "---\nname: deep-research\n---\n\n# deep-research\n\nFan out, verify, synthesize.",
|
||||
language: "markdown",
|
||||
markdown: true,
|
||||
editable: true,
|
||||
};
|
||||
|
||||
// Agents available to attach, including a paused one to exercise the badge.
|
||||
const MOCK_ATTACH_AGENTS = [
|
||||
{ id: "a-1", name: "Astra", adapterType: "process", supportsSkills: true, required: false, icon: "telescope", paused: false },
|
||||
{ id: "a-2", name: "Scout", adapterType: "http", supportsSkills: true, required: false, icon: "compass", paused: false },
|
||||
{ id: "a-3", name: "Quill", adapterType: "process", supportsSkills: true, required: false, icon: "feather", paused: true },
|
||||
{ id: "a-4", name: "Marlow", adapterType: "process", supportsSkills: true, required: false, icon: "bot", paused: false },
|
||||
{ id: "a-5", name: "Pixel", adapterType: "process", supportsSkills: true, required: false, icon: "palette", paused: false },
|
||||
{ id: "a-6", name: "Forge", adapterType: "process", supportsSkills: false, required: false, icon: "hammer", paused: false },
|
||||
];
|
||||
|
||||
function SkillDetailHarness({ initialTab = "overview" as DetailTab }: { initialTab?: DetailTab }) {
|
||||
const [activeTab, setActiveTab] = useState<DetailTab>(initialTab);
|
||||
const [viewMode, setViewMode] = useState<"preview" | "code">("preview");
|
||||
const [editMode, setEditMode] = useState(false);
|
||||
const [draft, setDraft] = useState(MOCK_FILE.content);
|
||||
|
||||
return (
|
||||
<SkillDetailPage
|
||||
detail={MOCK_DETAIL}
|
||||
catalogSource={{
|
||||
type: "github",
|
||||
hostname: "github.com",
|
||||
owner: "mvanhorn",
|
||||
repo: "last30days-skill",
|
||||
ref: "v3.3.0",
|
||||
commit: "daca71f89eb71d0d56d01a43ed7627aa919dba4f",
|
||||
path: "skills/last30days",
|
||||
url: "https://github.com/mvanhorn/last30days-skill/tree/v3.3.0/skills/last30days",
|
||||
}}
|
||||
loading={false}
|
||||
activeTab={activeTab}
|
||||
onTabChange={setActiveTab}
|
||||
selectedPath="SKILL.md"
|
||||
file={MOCK_FILE}
|
||||
fileLoading={false}
|
||||
viewMode={viewMode}
|
||||
editMode={editMode}
|
||||
draft={draft}
|
||||
setViewMode={setViewMode}
|
||||
setEditMode={setEditMode}
|
||||
setDraft={setDraft}
|
||||
onSave={() => {}}
|
||||
savePending={false}
|
||||
versions={MOCK_VERSIONS}
|
||||
versionsLoading={false}
|
||||
attachAgents={MOCK_ATTACH_AGENTS}
|
||||
onSubmitAttach={() => {}}
|
||||
attachPending={false}
|
||||
expandedDirs={new Set<string>()}
|
||||
onToggleDir={() => {}}
|
||||
onSelectPath={() => {}}
|
||||
updateStatus={{
|
||||
supported: true,
|
||||
reason: null,
|
||||
trackingRef: "main",
|
||||
currentRef: "a1b2c3d4e5f6",
|
||||
latestRef: "a1b2c3d4e5f6",
|
||||
hasUpdate: false,
|
||||
installedHash: null,
|
||||
originHash: null,
|
||||
userModifiedAt: null,
|
||||
updateHoldReason: null,
|
||||
auditVerdict: null,
|
||||
auditCodes: [],
|
||||
}}
|
||||
updateStatusLoading={false}
|
||||
onCheckUpdates={() => {}}
|
||||
checkUpdatesPending={false}
|
||||
onInstallUpdate={() => {}}
|
||||
installUpdatePending={false}
|
||||
onToggleStar={() => {}}
|
||||
starPending={false}
|
||||
onFork={() => {}}
|
||||
onUpdateSharingScope={() => {}}
|
||||
updateSharingPending={false}
|
||||
onDelete={() => {}}
|
||||
deletePending={false}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const meta: Meta<typeof SkillDetailHarness> = {
|
||||
title: "Skills Store/Skill detail",
|
||||
component: SkillDetailHarness,
|
||||
parameters: { layout: "fullscreen" },
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof SkillDetailHarness>;
|
||||
|
||||
export const Overview: Story = { args: { initialTab: "overview" } };
|
||||
export const AgentsTab: Story = { args: { initialTab: "agents" } };
|
||||
export const FilesTab: Story = { args: { initialTab: "files" } };
|
||||
export const VersionsTab: Story = { args: { initialTab: "versions" } };
|
||||
@@ -0,0 +1,322 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { DiscoveryGrid, type DiscoveryCard, type DiscoveryCategory } from "@/pages/CompanySkills";
|
||||
|
||||
type DiscoveryTab = "all" | "installed" | "catalog" | "bundled";
|
||||
type DiscoverySort = "agents" | "stars" | "forks" | "recent" | "alphabetical";
|
||||
|
||||
const MOCK_CARDS: DiscoveryCard[] = [
|
||||
{
|
||||
key: "paperclipai/paperclip/paperclip-dev",
|
||||
skillId: "s-dev",
|
||||
catalogRef: null,
|
||||
name: "paperclip-dev",
|
||||
slug: "paperclip-dev",
|
||||
author: "Paperclip",
|
||||
version: "v2.4.1",
|
||||
description: "Develop and operate a local Paperclip instance.",
|
||||
categories: ["devops", "coding"],
|
||||
iconUrl: null,
|
||||
color: null,
|
||||
starCount: 48,
|
||||
agentCount: 12,
|
||||
forkCount: 3,
|
||||
installed: true,
|
||||
required: false,
|
||||
forkedFrom: false,
|
||||
updatedAt: Date.now() - 2 * 86_400_000,
|
||||
sourceBadge: "github",
|
||||
},
|
||||
{
|
||||
key: "here.now/agent-browser",
|
||||
skillId: "s-browser",
|
||||
catalogRef: null,
|
||||
name: "agent-browser",
|
||||
slug: "agent-browser",
|
||||
author: "here.now",
|
||||
version: "v0.8.2",
|
||||
description: "Browser automation CLI for AI agents.",
|
||||
categories: ["research", "browsers"],
|
||||
iconUrl: null,
|
||||
color: null,
|
||||
starCount: 81,
|
||||
agentCount: 9,
|
||||
forkCount: 11,
|
||||
installed: false,
|
||||
required: false,
|
||||
forkedFrom: false,
|
||||
updatedAt: Date.now() - 5 * 86_400_000,
|
||||
sourceBadge: "skills_sh",
|
||||
},
|
||||
{
|
||||
key: "paperclipai/paperclip/verify",
|
||||
skillId: "s-verify",
|
||||
catalogRef: null,
|
||||
name: "verify",
|
||||
slug: "verify",
|
||||
author: "Paperclip",
|
||||
version: "v1.0.3",
|
||||
description: "Verify a code change works by running the app.",
|
||||
categories: ["testing"],
|
||||
iconUrl: null,
|
||||
color: null,
|
||||
starCount: 22,
|
||||
agentCount: 7,
|
||||
forkCount: 1,
|
||||
installed: true,
|
||||
required: false,
|
||||
forkedFrom: false,
|
||||
updatedAt: Date.now() - 9 * 86_400_000,
|
||||
sourceBadge: "local",
|
||||
},
|
||||
{
|
||||
key: "you/hue-prosumer",
|
||||
skillId: "s-hue",
|
||||
catalogRef: null,
|
||||
name: "hue-prosumer",
|
||||
slug: "hue-prosumer",
|
||||
author: "you",
|
||||
version: "v0.1.0",
|
||||
description: "Generate design language skills, prosumer remix.",
|
||||
categories: ["design"],
|
||||
iconUrl: null,
|
||||
color: null,
|
||||
starCount: 4,
|
||||
agentCount: 2,
|
||||
forkCount: 0,
|
||||
installed: true,
|
||||
required: false,
|
||||
forkedFrom: true,
|
||||
updatedAt: Date.now() - 1 * 86_400_000,
|
||||
sourceBadge: "paperclip",
|
||||
},
|
||||
{
|
||||
key: "anthropic/claude-api",
|
||||
skillId: null,
|
||||
catalogRef: "c-claude-api",
|
||||
name: "claude-api",
|
||||
slug: "claude-api",
|
||||
author: "Anthropic",
|
||||
version: "v1.6.0",
|
||||
description: "Build, debug and optimize Claude API apps.",
|
||||
categories: ["coding", "ai"],
|
||||
iconUrl: null,
|
||||
color: null,
|
||||
starCount: 0,
|
||||
agentCount: 0,
|
||||
forkCount: 0,
|
||||
installed: false,
|
||||
required: false,
|
||||
forkedFrom: false,
|
||||
updatedAt: 0,
|
||||
sourceBadge: "url",
|
||||
},
|
||||
{
|
||||
key: "paperclipai/paperclip/security-review",
|
||||
skillId: "s-sec",
|
||||
catalogRef: null,
|
||||
name: "security-review",
|
||||
slug: "security-review",
|
||||
author: "Paperclip",
|
||||
version: "v1.1.0",
|
||||
description: "Security review of pending changes on a branch.",
|
||||
categories: ["security"],
|
||||
iconUrl: null,
|
||||
color: null,
|
||||
starCount: 29,
|
||||
agentCount: 5,
|
||||
forkCount: 2,
|
||||
installed: true,
|
||||
required: false,
|
||||
forkedFrom: false,
|
||||
updatedAt: Date.now() - 3 * 86_400_000,
|
||||
sourceBadge: "github",
|
||||
},
|
||||
{
|
||||
key: "yuki/commit-perfect",
|
||||
skillId: null,
|
||||
catalogRef: "c-commit",
|
||||
name: "commit-perfect",
|
||||
slug: "commit-perfect",
|
||||
author: "Yuki",
|
||||
version: "v3.0.1",
|
||||
description: "Polish commits and PR titles before review.",
|
||||
categories: ["git", "workflow"],
|
||||
iconUrl: null,
|
||||
color: null,
|
||||
starCount: 0,
|
||||
agentCount: 0,
|
||||
forkCount: 0,
|
||||
installed: false,
|
||||
required: false,
|
||||
forkedFrom: false,
|
||||
updatedAt: 0,
|
||||
sourceBadge: "skills_sh",
|
||||
},
|
||||
{
|
||||
key: "astra/deep-research",
|
||||
skillId: "s-research",
|
||||
catalogRef: null,
|
||||
name: "deep-research",
|
||||
slug: "deep-research",
|
||||
author: "Astra",
|
||||
version: "v2.0.0",
|
||||
description: "Multi-source research with citation-grade synthesis.",
|
||||
categories: ["research"],
|
||||
iconUrl: null,
|
||||
color: null,
|
||||
starCount: 211,
|
||||
agentCount: 31,
|
||||
forkCount: 17,
|
||||
installed: true,
|
||||
required: false,
|
||||
forkedFrom: false,
|
||||
updatedAt: Date.now() - 4 * 86_400_000,
|
||||
sourceBadge: "local",
|
||||
},
|
||||
{
|
||||
key: "paperclipai/paperclip/paperclip",
|
||||
skillId: "s-core",
|
||||
catalogRef: "c-core",
|
||||
name: "paperclip",
|
||||
slug: "paperclip",
|
||||
author: "Paperclip",
|
||||
version: "core",
|
||||
description: "Control plane API for tasks, routines, and coordination.",
|
||||
categories: ["core"],
|
||||
iconUrl: null,
|
||||
color: null,
|
||||
starCount: 0,
|
||||
agentCount: 12,
|
||||
forkCount: 0,
|
||||
installed: true,
|
||||
required: true,
|
||||
forkedFrom: false,
|
||||
updatedAt: Date.now() - 30 * 86_400_000,
|
||||
sourceBadge: "paperclip",
|
||||
},
|
||||
{
|
||||
key: "paperclipai/paperclip/diagnose-why-work-stopped",
|
||||
skillId: "s-diag",
|
||||
catalogRef: "c-diag",
|
||||
name: "diagnose-why-work-stopped",
|
||||
slug: "diagnose-why-work-stopped",
|
||||
author: "Paperclip",
|
||||
version: "core",
|
||||
description: "Forensics for stalled or looping work trees.",
|
||||
categories: ["workflow"],
|
||||
iconUrl: null,
|
||||
color: null,
|
||||
starCount: 0,
|
||||
agentCount: 8,
|
||||
forkCount: 0,
|
||||
installed: true,
|
||||
required: true,
|
||||
forkedFrom: false,
|
||||
updatedAt: Date.now() - 28 * 86_400_000,
|
||||
sourceBadge: "url",
|
||||
},
|
||||
];
|
||||
|
||||
const DISCOVERY_TABS: DiscoveryTab[] = ["all", "installed", "catalog", "bundled"];
|
||||
|
||||
function cardsForTab(cards: DiscoveryCard[], tab: DiscoveryTab): DiscoveryCard[] {
|
||||
if (tab === "installed") return cards.filter((c) => c.installed);
|
||||
if (tab === "catalog") return cards.filter((c) => c.catalogRef != null);
|
||||
if (tab === "bundled") return cards.filter((c) => c.required);
|
||||
return cards;
|
||||
}
|
||||
|
||||
function DiscoveryGridHarness({
|
||||
initialTab = "all",
|
||||
cards = MOCK_CARDS,
|
||||
}: {
|
||||
initialTab?: DiscoveryTab;
|
||||
cards?: DiscoveryCard[];
|
||||
}) {
|
||||
const [tab, setTab] = useState<DiscoveryTab>(initialTab);
|
||||
const [sort, setSort] = useState<DiscoverySort>("agents");
|
||||
const [category, setCategory] = useState<string | null>(null);
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
const tabCards = useMemo(() => cardsForTab(cards, tab), [cards, tab]);
|
||||
const categories = useMemo<DiscoveryCategory[]>(() => {
|
||||
const counts = new Map<string, number>();
|
||||
for (const card of tabCards) for (const c of card.categories) counts.set(c, (counts.get(c) ?? 0) + 1);
|
||||
return Array.from(counts.entries())
|
||||
.map(([slug, count]) => ({ slug, count }))
|
||||
.sort((a, b) => b.count - a.count || a.slug.localeCompare(b.slug));
|
||||
}, [tabCards]);
|
||||
const visible = useMemo(() => {
|
||||
const q = search.trim().toLowerCase();
|
||||
const filtered = tabCards.filter((card) => {
|
||||
if (category && !card.categories.includes(category)) return false;
|
||||
if (!q) return true;
|
||||
return `${card.name} ${card.author} ${card.categories.join(" ")}`.toLowerCase().includes(q);
|
||||
});
|
||||
const demote = tab !== "bundled";
|
||||
return [...filtered].sort((a, b) => {
|
||||
if (demote && a.required !== b.required) return a.required ? 1 : -1;
|
||||
if (sort === "stars") return b.starCount - a.starCount;
|
||||
if (sort === "forks") return b.forkCount - a.forkCount;
|
||||
if (sort === "recent") return b.updatedAt - a.updatedAt;
|
||||
if (sort === "alphabetical") return a.name.localeCompare(b.name);
|
||||
return b.agentCount - a.agentCount;
|
||||
});
|
||||
}, [tabCards, category, search, sort, tab]);
|
||||
|
||||
const tabCounts = useMemo(
|
||||
() => ({
|
||||
all: cards.length,
|
||||
installed: cards.filter((c) => c.installed).length,
|
||||
catalog: cards.filter((c) => c.catalogRef != null).length,
|
||||
bundled: cards.filter((c) => c.required).length,
|
||||
}),
|
||||
[cards],
|
||||
) as Record<DiscoveryTab, number>;
|
||||
|
||||
return (
|
||||
<DiscoveryGrid
|
||||
tab={tab}
|
||||
tabCounts={tabCounts}
|
||||
onTabChange={(next) => {
|
||||
setTab(next);
|
||||
setCategory(null);
|
||||
}}
|
||||
categories={categories}
|
||||
categoryTotal={tabCards.length}
|
||||
activeCategory={category}
|
||||
onCategoryChange={setCategory}
|
||||
search={search}
|
||||
onSearchChange={setSearch}
|
||||
sort={sort}
|
||||
onSortChange={setSort}
|
||||
cards={visible}
|
||||
onOpenCard={() => {}}
|
||||
loading={false}
|
||||
error={null}
|
||||
totalCount={cards.length}
|
||||
onCreate={() => {}}
|
||||
onImport={() => {}}
|
||||
onBrowseCatalog={() => setTab("catalog")}
|
||||
onScan={() => {}}
|
||||
scanPending={false}
|
||||
scanStatus={null}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const meta: Meta<typeof DiscoveryGridHarness> = {
|
||||
title: "Skills Store/Discovery grid",
|
||||
component: DiscoveryGridHarness,
|
||||
parameters: { layout: "fullscreen" },
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof DiscoveryGridHarness>;
|
||||
|
||||
export const AllSkills: Story = { args: { initialTab: "all" } };
|
||||
export const InstalledTab: Story = { args: { initialTab: "installed" } };
|
||||
export const BundledRequiredTab: Story = { args: { initialTab: "bundled" } };
|
||||
export const EmptyLibrary: Story = { args: { initialTab: "all", cards: [] } };
|
||||
Reference in New Issue
Block a user