Build skills store creation flows

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-06-11 16:18:33 +00:00
1 parent 85902696c8
commit 2f93dc99a6
10 files changed
+960 -76

No files matched your search

+2
View File
@@ -351,6 +351,7 @@ export type {
CompanySkillCommentCreateRequest,
CompanySkillCommentUpdateRequest,
CompanySkillForkRequest,
CompanySkillUpdateRequest,
CompanySkillUpdateStatus,
CompanySkillAuditSeverity,
CompanySkillAuditVerdict,
@@ -1244,6 +1245,7 @@ export {
companySkillCommentCreateSchema,
companySkillCommentUpdateSchema,
companySkillForkSchema,
companySkillUpdateSchema,
companySkillUpdateStatusSchema,
companySkillAuditFindingSchema,
companySkillAuditResultSchema,
@@ -182,6 +182,17 @@ export interface CompanySkillForkRequest {
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 {
supported: boolean;
reason: string | null;
@@ -294,6 +305,7 @@ export interface CompanySkillCreateRequest {
homepageUrl?: string | null;
categories?: string[];
sharingScope?: CompanySkillSharingScope;
forkedFromSkillId?: string | null;
}
export interface CompanySkillFileDetail {
+1
View File
@@ -77,6 +77,7 @@ export type {
CompanySkillCommentCreateRequest,
CompanySkillCommentUpdateRequest,
CompanySkillForkRequest,
CompanySkillUpdateRequest,
CompanySkillUpdateStatus,
CompanySkillAuditSeverity,
CompanySkillAuditVerdict,
@@ -146,6 +146,17 @@ export const companySkillForkSchema = z.object({
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(),
@@ -248,6 +259,7 @@ export const companySkillCreateSchema = z.object({
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({
@@ -347,6 +359,7 @@ export type CompanySkillVersionCreate = z.infer<typeof companySkillVersionCreate
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>;
+1
View File
@@ -78,6 +78,7 @@ export {
companySkillCommentCreateSchema,
companySkillCommentUpdateSchema,
companySkillForkSchema,
companySkillUpdateSchema,
companySkillUpdateStatusSchema,
companySkillAuditFindingSchema,
companySkillAuditResultSchema,
@@ -251,6 +251,118 @@ describeEmbeddedPostgres("companySkillService.list", () => {
});
});
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",
}),
});
});
it("validates version-aware desired skill selections", async () => {
const companyId = randomUUID();
const skillId = randomUUID();
@@ -442,6 +554,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();
+31 -1
View File
@@ -13,6 +13,7 @@ import {
companySkillListQuerySchema,
companySkillProjectScanRequestSchema,
companySkillResetSchema,
companySkillUpdateSchema,
companySkillVersionCreateSchema,
} from "@paperclipai/shared";
import { trackSkillImported } from "@paperclipai/shared/telemetry";
@@ -394,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, {
@@ -416,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),
+149 -18
View File
@@ -37,6 +37,7 @@ import type {
CompanySkillSourceBadge,
CompanySkillSourceType,
CompanySkillTrustLevel,
CompanySkillUpdateRequest,
CompanySkillUpdateStatus,
CompanySkillUpdateHoldReason,
CompanySkillUsageAgent,
@@ -209,6 +210,20 @@ function assertImportedSkillSourceAllowed(skill: ImportedSkill) {
}
}
function assertImportedSkillKeyAllowed(skill: ImportedSkill) {
if (!skill.key.startsWith("paperclipai/paperclip/")) return;
const metadata = isPlainRecord(skill.metadata) ? skill.metadata : null;
const sourceKind = asString(metadata?.sourceKind);
if (sourceKind === "paperclip_bundled") return;
throw unprocessable(
`Reserved Paperclip skill key "${skill.key}" cannot be imported from unbundled sources.`,
{
skillKey: skill.key,
sourceKind: sourceKind ?? skill.sourceType,
},
);
}
type SkillSourceMeta = {
skillKey?: string;
sourceKind?: string;
@@ -1426,6 +1441,15 @@ function normalizeSharingScope(value: unknown): CompanySkillSharingScope {
return value === "private" || value === "public_link" || value === "company" ? value : "company";
}
function normalizeMutableSharingScope(value: unknown): CompanySkillSharingScope | null {
if (value === undefined || value === null) return null;
if (value === "private" || value === "company") return value;
if (value === "public_link") {
throw unprocessable("Public skill sharing is not available in this version.");
}
throw unprocessable("Invalid skill sharing scope.");
}
function normalizeCategorySlug(value: unknown) {
if (typeof value !== "string") return null;
return normalizeSkillSlug(value);
@@ -3026,17 +3050,95 @@ export function companySkillService(db: Db) {
};
}
async function createLocalSkill(companyId: string, input: CompanySkillCreateRequest): Promise<CompanySkill> {
async function updateSkill(
companyId: string,
skillId: string,
input: CompanySkillUpdateRequest = {},
): Promise<CompanySkill> {
await ensureSkillInventoryCurrent(companyId);
const skill = await getById(companyId, skillId);
if (!skill) throw notFound("Skill not found");
const sharingScope = Object.prototype.hasOwnProperty.call(input, "sharingScope")
? normalizeMutableSharingScope(input.sharingScope)
: null;
const values: Partial<typeof companySkills.$inferInsert> = {
updatedAt: new Date(),
};
if (Object.prototype.hasOwnProperty.call(input, "description")) {
values.description = normalizeStoreText(input.description, 2000);
}
if (Object.prototype.hasOwnProperty.call(input, "iconUrl")) {
values.iconUrl = normalizeStoreText(input.iconUrl, 2000);
}
if (Object.prototype.hasOwnProperty.call(input, "color")) {
values.color = normalizeStoreText(input.color, 64);
}
if (Object.prototype.hasOwnProperty.call(input, "tagline")) {
values.tagline = normalizeStoreText(input.tagline, 120);
}
if (Object.prototype.hasOwnProperty.call(input, "authorName")) {
values.authorName = normalizeStoreText(input.authorName, 200);
}
if (Object.prototype.hasOwnProperty.call(input, "homepageUrl")) {
values.homepageUrl = normalizeStoreText(input.homepageUrl, 2000);
}
if (Object.prototype.hasOwnProperty.call(input, "categories")) {
values.categories = normalizeCategoryList(input.categories);
}
if (sharingScope) {
values.sharingScope = sharingScope;
values.publicShareToken = null;
}
const row = await db
.update(companySkills)
.set(values)
.where(and(eq(companySkills.id, skillId), eq(companySkills.companyId, companyId)))
.returning()
.then((rows) => rows[0] ?? null);
if (!row) throw notFound("Skill not found");
return toCompanySkill(row);
}
async function createLocalSkill(
companyId: string,
input: CompanySkillCreateRequest,
actor: SkillActor | null = null,
): Promise<CompanySkill> {
const slug = normalizeSkillSlug(input.slug ?? input.name) ?? "skill";
const key = `company/${companyId}/${slug}`;
const existing = await getByKey(companyId, key);
if (existing) {
throw conflict(`A company skill with slug "${slug}" already exists.`);
}
const forkSource = input.forkedFromSkillId
? await getById(companyId, input.forkedFromSkillId)
: null;
if (input.forkedFromSkillId && !forkSource) {
throw notFound("Fork source skill not found");
}
const sharingScope = normalizeMutableSharingScope(input.sharingScope) ?? "company";
const managedRoot = resolveManagedSkillsRoot(companyId);
const skillDir = path.resolve(managedRoot, slug);
const skillFilePath = path.resolve(skillDir, "SKILL.md");
await fs.rm(skillDir, { recursive: true, force: true });
await fs.mkdir(skillDir, { recursive: true });
const markdown = (input.markdown?.trim().length
? input.markdown
: [
if (forkSource) {
for (const entry of forkSource.fileInventory) {
const detail = await readFile(companyId, forkSource.id, entry.path);
if (!detail) continue;
const targetPath = path.resolve(skillDir, detail.path);
await fs.mkdir(path.dirname(targetPath), { recursive: true });
await fs.writeFile(targetPath, detail.content, "utf8");
}
}
const fallbackMarkdown = [
"---",
`name: ${input.name}`,
...(input.description?.trim() ? [`description: ${input.description.trim()}`] : []),
@@ -3046,42 +3148,69 @@ export function companySkillService(db: Db) {
"",
input.description?.trim() ? input.description.trim() : "Describe what this skill does.",
"",
].join("\n"));
].join("\n");
const markdown = input.markdown?.trim().length
? input.markdown
: forkSource?.markdown ?? fallbackMarkdown;
await fs.writeFile(skillFilePath, markdown, "utf8");
const inventory = forkSource
? await collectLocalSkillInventory(skillDir)
: [{ path: "SKILL.md", kind: "skill" as const }];
const parsed = parseFrontmatterMarkdown(markdown);
const metadata = {
sourceKind: "managed_local",
...(forkSource ? {
forkedFromSkillId: forkSource.id,
forkedFromCompanyId: forkSource.companyId,
forkedByAgentId: actor?.type === "agent" ? actor.agentId ?? null : null,
forkedByUserId: actor?.type === "user" ? actor.userId ?? null : null,
} : {}),
};
const imported = await upsertImportedSkills(companyId, [{
key: `company/${companyId}/${slug}`,
key,
slug,
name: asString(parsed.frontmatter.name) ?? input.name,
description: asString(parsed.frontmatter.description) ?? input.description?.trim() ?? null,
description: asString(parsed.frontmatter.description) ?? input.description?.trim() ?? forkSource?.description ?? null,
markdown,
sourceType: "local_path",
sourceLocator: skillDir,
sourceRef: null,
trustLevel: "markdown_only",
compatibility: "compatible",
fileInventory: [{ path: "SKILL.md", kind: "skill" }],
metadata: { sourceKind: "managed_local" },
trustLevel: deriveTrustLevel(inventory),
compatibility: forkSource?.compatibility ?? "compatible",
fileInventory: inventory,
metadata,
}]);
const created = imported[0]!;
const row = await db
.update(companySkills)
.set({
iconUrl: normalizeStoreText(input.iconUrl, 2000) ?? created.iconUrl,
color: normalizeStoreText(input.color, 64) ?? created.color,
tagline: normalizeStoreText(input.tagline, 120) ?? created.tagline,
authorName: normalizeStoreText(input.authorName, 200) ?? created.authorName,
homepageUrl: normalizeStoreText(input.homepageUrl, 2000) ?? created.homepageUrl,
categories: input.categories ? normalizeCategoryList(input.categories) : created.categories,
sharingScope: input.sharingScope ?? created.sharingScope,
iconUrl: normalizeStoreText(input.iconUrl, 2000) ?? forkSource?.iconUrl ?? created.iconUrl,
color: normalizeStoreText(input.color, 64) ?? forkSource?.color ?? created.color,
tagline: normalizeStoreText(input.tagline, 120) ?? forkSource?.tagline ?? created.tagline,
authorName: normalizeStoreText(input.authorName, 200) ?? forkSource?.authorName ?? created.authorName,
homepageUrl: normalizeStoreText(input.homepageUrl, 2000) ?? forkSource?.homepageUrl ?? created.homepageUrl,
categories: input.categories ? normalizeCategoryList(input.categories) : forkSource?.categories ?? created.categories,
sharingScope,
forkedFromSkillId: forkSource?.id ?? null,
forkedFromCompanyId: forkSource?.companyId ?? null,
updatedAt: new Date(),
})
.where(and(eq(companySkills.id, created.id), eq(companySkills.companyId, companyId)))
.returning()
.then((rows) => rows[0] ?? null);
if (forkSource) {
await db
.update(companySkills)
.set({
forkCount: sql`${companySkills.forkCount} + 1`,
installCount: sql`${companySkills.installCount} + 1`,
updatedAt: new Date(),
})
.where(and(eq(companySkills.id, forkSource.id), eq(companySkills.companyId, companyId)));
}
return row ? toCompanySkill(row) : created;
}
@@ -4139,6 +4268,7 @@ export function companySkillService(db: Db) {
async function upsertImportedSkills(companyId: string, imported: ImportedSkill[]): Promise<CompanySkill[]> {
const out: CompanySkill[] = [];
for (const skill of imported) {
assertImportedSkillKeyAllowed(skill);
assertImportedSkillSourceAllowed(skill);
const existing = await getByKey(companyId, skill.key);
const existingMeta = existing ? getSkillMeta(existing) : {};
@@ -4311,6 +4441,7 @@ export function companySkillService(db: Db) {
forkSkill,
updateStatus,
readFile,
updateSkill,
updateFile,
createLocalSkill,
deleteSkill,
+6
View File
@@ -19,6 +19,7 @@ import type {
CompanySkillProjectScanRequest,
CompanySkillProjectScanResult,
CompanySkillStarResult,
CompanySkillUpdateRequest,
CompanySkillUpdateStatus,
CompanySkillVersion,
CompanySkillVersionCreateRequest,
@@ -110,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`,
+578 -57
View File
@@ -14,6 +14,7 @@ import type {
CompanySkillFileInventoryEntry,
CompanySkillListItem,
CompanySkillProjectScanResult,
CompanySkillSharingScope,
CompanySkillSourceBadge,
CompanySkillTrustLevel,
CompanySkillUpdateStatus,
@@ -633,6 +634,48 @@ function uniqueCategories(values: (string | null | undefined)[]): string[] {
return out;
}
function normalizeSkillDraftSlug(value: string) {
return value
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 80);
}
function splitCategoryDraft(value: string) {
return value
.split(",")
.map((entry) => normalizeSkillDraftSlug(entry))
.filter(Boolean);
}
function defaultSkillMarkdown(name: string, tagline: string) {
const title = name.trim() || "New Skill";
const summary = tagline.trim() || "Describe when agents should use this skill.";
return [
"---",
`name: ${title}`,
`description: ${summary}`,
"---",
"",
`# ${title}`,
"",
summary,
"",
"## When To Use",
"",
"- Use this skill when the task needs its specialized workflow.",
"",
"## Workflow",
"",
"1. Inspect the task context.",
"2. Apply the workflow carefully.",
"3. Report what changed and how it was verified.",
"",
].join("\n");
}
// Merge installed company skills and the install catalog into one card model.
// Installed skills win on dedup (they carry the richer social-proof metadata);
// catalog-only skills fill in the rest of the discoverable surface.
@@ -1122,51 +1165,301 @@ export function DiscoveryGrid({
);
}
function NewSkillForm({
type SkillCreateDraft = {
name: string;
slug: string;
tagline: string;
description: string;
color: string;
categories: string[];
markdown: string;
sharingScope: Exclude<CompanySkillSharingScope, "public_link">;
forkedFromSkillId: string | null;
forkedFromName: string | null;
};
function buildBlankSkillDraft(): SkillCreateDraft {
return {
name: "",
slug: "",
tagline: "",
description: "",
color: DISCOVERY_ACCENTS[0]!,
categories: [],
markdown: defaultSkillMarkdown("", ""),
sharingScope: "company",
forkedFromSkillId: null,
forkedFromName: null,
};
}
function buildForkSkillDraft(skill: CompanySkillDetail): SkillCreateDraft {
const name = `${skill.name} Fork`;
const slug = normalizeSkillDraftSlug(`${skill.slug}-fork`);
return {
name,
slug,
tagline: skill.tagline ?? "",
description: skill.description ?? "",
color: skill.color ?? skillAccentColor(skill.key, null),
categories: skill.categories,
markdown: skill.markdown.replace(/^name:\s*.*$/m, `name: ${name}`),
sharingScope: "company",
forkedFromSkillId: skill.id,
forkedFromName: skill.name,
};
}
function NewSkillWizard({
initialDraft,
onCreate,
isPending,
error,
onCancel,
}: {
initialDraft: SkillCreateDraft;
onCreate: (payload: CompanySkillCreateRequest) => void;
isPending: boolean;
error: string | null;
onCancel: () => void;
}) {
const [name, setName] = useState("");
const [slug, setSlug] = useState("");
const [description, setDescription] = useState("");
const [step, setStep] = useState(0);
const [draft, setDraft] = useState<SkillCreateDraft>(initialDraft);
const categoryDraft = draft.categories.join(", ");
const steps = ["Basics", "Design", "Content", "Review"];
useEffect(() => {
setStep(0);
setDraft(initialDraft);
}, [initialDraft]);
function patchDraft(patch: Partial<SkillCreateDraft>) {
setDraft((current) => ({ ...current, ...patch }));
}
const nameValid = draft.name.trim().length > 0;
const effectiveSlug = draft.slug.trim() || normalizeSkillDraftSlug(draft.name);
const effectiveMarkdown = draft.markdown.trim().length > 0
? draft.markdown
: defaultSkillMarkdown(draft.name, draft.tagline);
function submit() {
onCreate({
name: draft.name.trim(),
slug: effectiveSlug || null,
description: draft.description.trim() || draft.tagline.trim() || null,
markdown: effectiveMarkdown,
color: draft.color,
tagline: draft.tagline.trim() || null,
categories: draft.categories,
sharingScope: draft.sharingScope,
forkedFromSkillId: draft.forkedFromSkillId,
});
}
return (
<div className="border-b border-border px-4 py-4">
<div className="space-y-3">
<Input
value={name}
onChange={(event) => setName(event.target.value)}
placeholder="Skill name"
className="h-9 rounded-none border-0 border-b border-border px-0 shadow-none focus-visible:ring-0"
/>
<Input
value={slug}
onChange={(event) => setSlug(event.target.value)}
placeholder="optional-shortname"
className="h-9 rounded-none border-0 border-b border-border px-0 shadow-none focus-visible:ring-0"
/>
<Textarea
value={description}
onChange={(event) => setDescription(event.target.value)}
placeholder="Short description"
className="min-h-20 rounded-none border-0 border-b border-border px-0 shadow-none focus-visible:ring-0"
/>
<div className="flex items-center justify-end gap-2">
<Button variant="ghost" size="sm" onClick={onCancel} disabled={isPending}>
Cancel
</Button>
<Button
size="sm"
onClick={() => onCreate({ name, slug: slug || null, description: description || null })}
disabled={isPending || name.trim().length === 0}
<div className="space-y-4">
<div className="flex items-center gap-2 border-b border-border pb-3">
{steps.map((label, index) => (
<button
key={label}
type="button"
onClick={() => setStep(index)}
className={cn(
"rounded-md px-2 py-1 text-xs",
step === index ? "bg-accent text-foreground" : "text-muted-foreground hover:text-foreground",
)}
>
{isPending ? "Creating..." : "Create skill"}
{label}
</button>
))}
</div>
{draft.forkedFromName ? (
<div className="flex items-center gap-2 rounded-md border border-border bg-muted/30 px-3 py-2 text-xs text-muted-foreground">
<GitFork className="h-3.5 w-3.5" />
Forking {draft.forkedFromName}
</div>
) : null}
{step === 0 ? (
<div className="space-y-3">
<Input
value={draft.name}
onChange={(event) => {
const nextName = event.target.value;
patchDraft({
name: nextName,
slug: draft.slug ? draft.slug : normalizeSkillDraftSlug(nextName),
markdown: draft.markdown === defaultSkillMarkdown(draft.name, draft.tagline)
? defaultSkillMarkdown(nextName, draft.tagline)
: draft.markdown,
});
}}
placeholder="Skill name"
className="h-9"
/>
<Input
value={draft.slug}
onChange={(event) => patchDraft({ slug: normalizeSkillDraftSlug(event.target.value) })}
placeholder="skill-shortname"
className="h-9 font-mono"
/>
<Textarea
value={draft.tagline}
onChange={(event) => {
const nextTagline = event.target.value;
patchDraft({
tagline: nextTagline,
description: draft.description ? draft.description : nextTagline,
markdown: draft.markdown === defaultSkillMarkdown(draft.name, draft.tagline)
? defaultSkillMarkdown(draft.name, nextTagline)
: draft.markdown,
});
}}
placeholder="One-line promise for the skill"
className="min-h-20"
/>
</div>
) : step === 1 ? (
<div className="space-y-4">
<div className="flex items-center gap-3">
<SkillCardIcon
size={48}
card={{
key: effectiveSlug || draft.name || "new-skill",
skillId: null,
catalogRef: null,
name: draft.name || "New Skill",
slug: effectiveSlug || "skill",
author: "you",
version: null,
description: draft.tagline,
categories: draft.categories,
iconUrl: null,
color: draft.color,
starCount: 0,
agentCount: 0,
forkCount: 0,
installed: false,
required: false,
forkedFrom: Boolean(draft.forkedFromSkillId),
updatedAt: Date.now(),
}}
/>
<div className="min-w-0">
<div className="truncate text-sm font-medium">{draft.name || "New Skill"}</div>
<div className="truncate text-xs text-muted-foreground">{draft.tagline || "No tagline yet."}</div>
</div>
</div>
<div>
<label className="mb-2 block text-xs font-medium uppercase tracking-wide text-muted-foreground">Color</label>
<div className="flex flex-wrap gap-2">
{DISCOVERY_ACCENTS.map((color) => (
<button
key={color}
type="button"
onClick={() => patchDraft({ color })}
className={cn(
"h-7 w-7 rounded-md border",
draft.color === color ? "border-foreground" : "border-border",
)}
style={{ backgroundColor: color }}
aria-label={`Use ${color}`}
/>
))}
<Input
value={draft.color}
onChange={(event) => patchDraft({ color: event.target.value })}
className="h-7 w-28 font-mono text-xs"
/>
</div>
</div>
<div>
<label className="mb-2 block text-xs font-medium uppercase tracking-wide text-muted-foreground">Categories</label>
<Input
value={categoryDraft}
onChange={(event) => patchDraft({ categories: splitCategoryDraft(event.target.value) })}
placeholder="engineering, review, memory"
className="h-9"
/>
</div>
</div>
) : step === 2 ? (
<div className="space-y-2">
<Textarea
value={draft.markdown}
onChange={(event) => patchDraft({ markdown: event.target.value })}
className="min-h-[22rem] font-mono text-xs"
/>
</div>
) : (
<div className="space-y-4 text-sm">
<div className="grid grid-cols-[7rem_minmax(0,1fr)] gap-y-2">
<span className="text-muted-foreground">Name</span>
<span>{draft.name || "Untitled"}</span>
<span className="text-muted-foreground">Slug</span>
<span className="font-mono">{effectiveSlug || "skill"}</span>
<span className="text-muted-foreground">Scope</span>
<span>{draft.sharingScope === "private" ? "Private" : "Company"}</span>
<span className="text-muted-foreground">Categories</span>
<span>{draft.categories.length ? draft.categories.join(", ") : "none"}</span>
</div>
<div className="space-y-2">
<label className="block text-xs font-medium uppercase tracking-wide text-muted-foreground">Sharing</label>
<div className="grid gap-2 sm:grid-cols-3">
{(["company", "private"] as const).map((scope) => (
<button
key={scope}
type="button"
onClick={() => patchDraft({ sharingScope: scope })}
className={cn(
"rounded-md border px-3 py-2 text-left text-sm",
draft.sharingScope === scope ? "border-foreground bg-accent/50" : "border-border",
)}
>
<span className="block font-medium">{scope === "company" ? "Company" : "Private"}</span>
<span className="mt-1 block text-xs text-muted-foreground">
{scope === "company" ? "Visible inside this company." : "Only visible in your library."}
</span>
</button>
))}
<button
type="button"
disabled
className="rounded-md border border-dashed border-border px-3 py-2 text-left text-sm text-muted-foreground"
>
<span className="block font-medium">Public link</span>
<span className="mt-1 block text-xs">Coming later.</span>
</button>
</div>
</div>
</div>
)}
{error ? (
<div className="rounded-md border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive">
{error}
</div>
) : null}
<div className="flex items-center justify-between gap-2 border-t border-border pt-3">
<Button variant="ghost" size="sm" onClick={onCancel} disabled={isPending}>
Cancel
</Button>
<div className="flex items-center gap-2">
<Button variant="ghost" size="sm" onClick={() => setStep((value) => Math.max(0, value - 1))} disabled={isPending || step === 0}>
Back
</Button>
{step < steps.length - 1 ? (
<Button size="sm" onClick={() => setStep((value) => Math.min(steps.length - 1, value + 1))} disabled={!nameValid}>
Next
</Button>
) : (
<Button size="sm" onClick={submit} disabled={isPending || !nameValid}>
{isPending ? "Creating..." : draft.forkedFromSkillId ? "Create fork" : "Create skill"}
</Button>
)}
</div>
</div>
</div>
@@ -2154,6 +2447,74 @@ function SkillVersionDiffDialog({
);
}
function SkillCommentRow({
comment,
onUpdate,
updatePending,
onDelete,
deletePending,
}: {
comment: CompanySkillComment;
onUpdate: (body: string) => void;
updatePending: boolean;
onDelete: () => void;
deletePending: boolean;
}) {
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(comment.body);
const deleted = Boolean(comment.deletedAt);
useEffect(() => {
if (!editing) setDraft(comment.body);
}, [comment.body, editing]);
return (
<article className="border-b border-border py-3 last:border-b-0">
<div className="mb-2 flex flex-wrap items-center justify-between gap-2">
<div className="text-xs text-muted-foreground">
{comment.authorAgentId ? "Agent" : comment.authorUserId ? "Board" : "System"} · {relativeTime(comment.createdAt)}
</div>
{!deleted ? (
<div className="flex items-center gap-1">
<Button variant="ghost" size="sm" onClick={() => setEditing((value) => !value)} disabled={updatePending || deletePending}>
<Pencil className="mr-1 h-3 w-3" />
{editing ? "Cancel" : "Edit"}
</Button>
<Button variant="ghost" size="sm" onClick={onDelete} disabled={deletePending || updatePending}>
<Trash2 className="mr-1 h-3 w-3" />
Delete
</Button>
</div>
) : null}
</div>
{deleted ? (
<MarkdownBody className="prose-sm" softBreaks>_Deleted comment_</MarkdownBody>
) : editing ? (
<div className="space-y-2">
<Textarea value={draft} onChange={(event) => setDraft(event.target.value)} className="min-h-24" />
<div className="flex justify-end gap-2">
<Button variant="ghost" size="sm" onClick={() => setEditing(false)} disabled={updatePending}>
Cancel
</Button>
<Button
size="sm"
onClick={() => {
onUpdate(draft.trim());
setEditing(false);
}}
disabled={updatePending || draft.trim().length === 0}
>
{updatePending ? "Saving..." : "Save"}
</Button>
</div>
</div>
) : (
<MarkdownBody className="prose-sm" softBreaks>{comment.body}</MarkdownBody>
)}
</article>
);
}
function SkillDetailPage({
detail,
loading,
@@ -2178,6 +2539,10 @@ function SkillDetailPage({
setCommentDraft,
onCreateComment,
createCommentPending,
onUpdateComment,
updateCommentPending,
onDeleteComment,
deleteCommentPending,
relatedSkills,
attachAgents,
attachPopoverOpen,
@@ -2193,6 +2558,11 @@ function SkillDetailPage({
checkUpdatesPending,
onInstallUpdate,
installUpdatePending,
onToggleStar,
starPending,
onFork,
onUpdateSharingScope,
updateSharingPending,
onDelete,
deletePending,
}: {
@@ -2219,6 +2589,10 @@ function SkillDetailPage({
setCommentDraft: (value: string) => void;
onCreateComment: () => void;
createCommentPending: boolean;
onUpdateComment: (commentId: string, body: string) => void;
updateCommentPending: boolean;
onDeleteComment: (commentId: string) => void;
deleteCommentPending: boolean;
relatedSkills: CompanySkillListItem[];
attachAgents: Array<{ id: string; name: string; adapterType: string; supportsSkills: boolean; required: boolean }>;
attachPopoverOpen: boolean;
@@ -2234,6 +2608,11 @@ function SkillDetailPage({
checkUpdatesPending: boolean;
onInstallUpdate: () => void;
installUpdatePending: boolean;
onToggleStar: () => void;
starPending: boolean;
onFork: () => void;
onUpdateSharingScope: (scope: Exclude<CompanySkillSharingScope, "public_link">) => void;
updateSharingPending: boolean;
onDelete: () => void;
deletePending: boolean;
}) {
@@ -2452,12 +2831,14 @@ function SkillDetailPage({
<div className="py-6 text-sm text-muted-foreground">No comments yet.</div>
) : (
comments.map((comment) => (
<article key={comment.id} className="border-b border-border py-3 last:border-b-0">
<div className="mb-1 text-xs text-muted-foreground">
{comment.authorAgentId ? "Agent" : comment.authorUserId ? "Board" : "System"} · {relativeTime(comment.createdAt)}
</div>
<MarkdownBody className="prose-sm" softBreaks>{comment.deletedAt ? "_Deleted comment_" : comment.body}</MarkdownBody>
</article>
<SkillCommentRow
key={comment.id}
comment={comment}
updatePending={updateCommentPending}
deletePending={deleteCommentPending}
onUpdate={(body) => onUpdateComment(comment.id, body)}
onDelete={() => onDeleteComment(comment.id)}
/>
))
)}
</div>
@@ -2568,6 +2949,16 @@ function SkillDetailPage({
</div>
</div>
<div className="flex flex-wrap items-center justify-end gap-2">
<Button variant="ghost" size="sm" onClick={onToggleStar} disabled={starPending}>
<Star className={cn("mr-1.5 h-3.5 w-3.5", detail.starredByCurrentActor && "fill-current text-yellow-400")} />
{detail.starredByCurrentActor ? "Starred" : "Star"}
<span className="ml-1 text-xs text-muted-foreground">{detail.starCount}</span>
</Button>
<Button variant="ghost" size="sm" onClick={onFork}>
<GitFork className="mr-1.5 h-3.5 w-3.5" />
Fork
<span className="ml-1 text-xs text-muted-foreground">{detail.forkCount}</span>
</Button>
{detail.sourceType === "github" ? (
<>
<Button variant="ghost" size="sm" onClick={onCheckUpdates} disabled={checkUpdatesPending || updateStatusLoading}>
@@ -2610,6 +3001,43 @@ function SkillDetailPage({
</main>
<aside className="min-w-0 space-y-6 border-t border-border pt-4 xl:border-l xl:border-t-0 xl:pl-5 xl:pt-0">
<section>
<div className="mb-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">Sharing</div>
<div className="space-y-2">
{(["company", "private"] as const).map((scope) => (
<button
key={scope}
type="button"
onClick={() => onUpdateSharingScope(scope)}
disabled={updateSharingPending || detail.sharingScope === scope}
className={cn(
"flex w-full items-start gap-2 rounded-md border px-3 py-2 text-left text-sm transition-colors",
detail.sharingScope === scope ? "border-foreground/50 bg-accent/40" : "border-border hover:bg-accent/30",
)}
>
{scope === "company" ? <Users className="mt-0.5 h-3.5 w-3.5" /> : <Lock className="mt-0.5 h-3.5 w-3.5" />}
<span className="min-w-0">
<span className="block font-medium">{scope === "company" ? "Company" : "Private"}</span>
<span className="mt-0.5 block text-xs text-muted-foreground">
{scope === "company" ? "Visible inside this company." : "Only visible in your library."}
</span>
</span>
</button>
))}
<button
type="button"
disabled
className="flex w-full items-start gap-2 rounded-md border border-dashed border-border px-3 py-2 text-left text-sm text-muted-foreground"
>
<Globe className="mt-0.5 h-3.5 w-3.5" />
<span>
<span className="block font-medium">Public link</span>
<span className="mt-0.5 block text-xs">Coming later.</span>
</span>
</button>
</div>
</section>
<section>
<div className="mb-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">Attach to agent</div>
<div className="space-y-2">
@@ -2984,7 +3412,6 @@ export function CompanySkills() {
const adapterCaps = useAdapterCapabilities();
const [skillFilter, setSkillFilter] = useState("");
const [source, setSource] = useState("");
const [createOpen, setCreateOpen] = useState(false);
const [emptySourceHelpOpen, setEmptySourceHelpOpen] = useState(false);
const [expandedSkillId, setExpandedSkillId] = useState<string | null>(null);
const [expandedDirs, setExpandedDirs] = useState<Record<string, Set<string>>>({});
@@ -3017,6 +3444,8 @@ export function CompanySkills() {
const [discoverySearch, setDiscoverySearch] = useState("");
const [discoverySort, setDiscoverySort] = useState<DiscoverySort>("agents");
const [createDialogOpen, setCreateDialogOpen] = useState(false);
const [createDraft, setCreateDraft] = useState<SkillCreateDraft>(() => buildBlankSkillDraft());
const [createError, setCreateError] = useState<string | null>(null);
const [importDialogOpen, setImportDialogOpen] = useState(false);
const parsedRoute = useMemo(() => parseSkillRoute(routePath), [routePath]);
const routeSkillId = parsedRoute.skillId;
@@ -3098,6 +3527,12 @@ export function CompanySkills() {
setCatalogSelectedPath(path);
}
function openCreateWizard(initialDraft: SkillCreateDraft = buildBlankSkillDraft()) {
setCreateDraft(initialDraft);
setCreateError(null);
setCreateDialogOpen(true);
}
useEffect(() => {
setBreadcrumbs([
{ label: "Skills", href: "/skills" },
@@ -3240,18 +3675,22 @@ export function CompanySkills() {
onSuccess: async (skill) => {
await queryClient.invalidateQueries({ queryKey: queryKeys.companySkills.list(selectedCompanyId!) });
navigate(skillRoute(skill.id));
setCreateOpen(false);
setCreateDialogOpen(false);
setCreateError(null);
setCreateDraft(buildBlankSkillDraft());
pushToast({
tone: "success",
title: "Skill created",
title: skill.forkedFromSkillId ? "Skill fork created" : "Skill created",
body: `${skill.name} is now editable in the Paperclip workspace.`,
});
},
onError: (error) => {
const message = error instanceof Error ? error.message : "Failed to create skill.";
setCreateError(message);
pushToast({
tone: "error",
title: "Skill creation failed",
body: error instanceof Error ? error.message : "Failed to create skill.",
body: message,
});
},
});
@@ -3343,6 +3782,83 @@ export function CompanySkills() {
},
});
const updateComment = useMutation({
mutationFn: (input: { commentId: string; body: string }) =>
companySkillsApi.updateComment(selectedCompanyId!, selectedSkillId!, input.commentId, { body: input.body }),
onSuccess: async () => {
await queryClient.invalidateQueries({
queryKey: queryKeys.companySkills.comments(selectedCompanyId!, selectedSkillId!),
});
pushToast({ tone: "success", title: "Comment updated" });
},
onError: (error) => {
pushToast({
tone: "error",
title: "Comment update failed",
body: error instanceof Error ? error.message : "Failed to update comment.",
});
},
});
const deleteComment = useMutation({
mutationFn: (commentId: string) => companySkillsApi.deleteComment(selectedCompanyId!, selectedSkillId!, commentId),
onSuccess: async () => {
await queryClient.invalidateQueries({
queryKey: queryKeys.companySkills.comments(selectedCompanyId!, selectedSkillId!),
});
pushToast({ tone: "success", title: "Comment deleted" });
},
onError: (error) => {
pushToast({
tone: "error",
title: "Comment delete failed",
body: error instanceof Error ? error.message : "Failed to delete comment.",
});
},
});
const toggleStar = useMutation({
mutationFn: () => {
if (!activeDetail) throw new Error("Select a skill first.");
return activeDetail.starredByCurrentActor
? companySkillsApi.unstar(selectedCompanyId!, activeDetail.id)
: companySkillsApi.star(selectedCompanyId!, activeDetail.id);
},
onSuccess: async () => {
if (!activeDetail) return;
await Promise.all([
queryClient.invalidateQueries({ queryKey: queryKeys.companySkills.list(selectedCompanyId!) }),
queryClient.invalidateQueries({ queryKey: queryKeys.companySkills.detail(selectedCompanyId!, activeDetail.id) }),
]);
},
onError: (error) => {
pushToast({
tone: "error",
title: "Star failed",
body: error instanceof Error ? error.message : "Failed to update star.",
});
},
});
const updateSkillSettings = useMutation({
mutationFn: (payload: { skillId: string; sharingScope: Exclude<CompanySkillSharingScope, "public_link"> }) =>
companySkillsApi.update(selectedCompanyId!, payload.skillId, { sharingScope: payload.sharingScope }),
onSuccess: async (skill) => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: queryKeys.companySkills.list(selectedCompanyId!) }),
queryClient.invalidateQueries({ queryKey: queryKeys.companySkills.detail(selectedCompanyId!, skill.id) }),
]);
pushToast({ tone: "success", title: "Sharing updated", body: skill.sharingScope === "private" ? "Private" : "Company" });
},
onError: (error) => {
pushToast({
tone: "error",
title: "Sharing update failed",
body: error instanceof Error ? error.message : "Failed to update sharing scope.",
});
},
});
const installUpdate = useMutation({
mutationFn: () => companySkillsApi.installUpdate(selectedCompanyId!, selectedSkillId!),
onSuccess: async (skill) => {
@@ -3783,16 +4299,20 @@ export function CompanySkills() {
/>
<Dialog open={createDialogOpen} onOpenChange={setCreateDialogOpen}>
<DialogContent className="sm:max-w-md">
<DialogContent className="sm:max-w-3xl">
<DialogHeader>
<DialogTitle>Create a new skill</DialogTitle>
<DialogTitle>{createDraft.forkedFromSkillId ? "Fork skill" : "Create a new skill"}</DialogTitle>
<DialogDescription>
Start a blank skill in the Paperclip workspace. You can add files, categories, and metadata afterwards.
{createDraft.forkedFromSkillId
? "Review the fork metadata and create an editable company copy."
: "Create an editable company skill in the Paperclip workspace."}
</DialogDescription>
</DialogHeader>
<NewSkillForm
<NewSkillWizard
initialDraft={createDraft}
onCreate={(payload) => createSkill.mutate(payload)}
isPending={createSkill.isPending}
error={createError}
onCancel={() => setCreateDialogOpen(false)}
/>
</DialogContent>
@@ -3864,7 +4384,7 @@ export function CompanySkills() {
loading={skillsQuery.isLoading || catalogListQuery.isLoading}
error={skillsQuery.error?.message ?? catalogListQuery.error?.message ?? null}
totalCount={discoveryCards.length}
onCreate={() => setCreateDialogOpen(true)}
onCreate={() => openCreateWizard()}
onImport={() => setImportDialogOpen(true)}
onBrowseCatalog={() => setDiscoveryTab("catalog")}
onScan={() => scanProjects.mutate()}
@@ -3896,6 +4416,10 @@ export function CompanySkills() {
setCommentDraft={setCommentDraft}
onCreateComment={() => createComment.mutate()}
createCommentPending={createComment.isPending}
onUpdateComment={(commentId, body) => updateComment.mutate({ commentId, body })}
updateCommentPending={updateComment.isPending}
onDeleteComment={(commentId) => deleteComment.mutate(commentId)}
deleteCommentPending={deleteComment.isPending}
relatedSkills={relatedSkills}
attachAgents={eligibleAgentsForAttach}
attachPopoverOpen={attachPopoverOpen}
@@ -3923,6 +4447,11 @@ export function CompanySkills() {
checkUpdatesPending={updateStatusQuery.isFetching}
onInstallUpdate={() => installUpdate.mutate()}
installUpdatePending={installUpdate.isPending}
onToggleStar={() => toggleStar.mutate()}
starPending={toggleStar.isPending}
onFork={() => activeDetail && openCreateWizard(buildForkSkillDraft(activeDetail))}
onUpdateSharingScope={(sharingScope) => activeDetail && updateSkillSettings.mutate({ skillId: activeDetail.id, sharingScope })}
updateSharingPending={updateSkillSettings.isPending}
onDelete={openDeleteDialog}
deletePending={deleteSkill.isPending}
/>
@@ -3978,7 +4507,7 @@ export function CompanySkills() {
<DropdownMenuItem
onSelect={() => {
setViewParam("installed");
setCreateOpen(true);
openCreateWizard();
}}
>
<Pencil className="mr-2 h-4 w-4" />
@@ -4025,14 +4554,6 @@ export function CompanySkills() {
)}
</div>
{createOpen && (
<NewSkillForm
onCreate={(payload) => createSkill.mutate(payload)}
isPending={createSkill.isPending}
onCancel={() => setCreateOpen(false)}
/>
)}
{skillsQuery.isLoading ? (
<PageSkeleton variant="list" />
) : skillsQuery.error ? (