diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 650ab4df8a0..60f66917e9e 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -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, diff --git a/packages/shared/src/types/company-skill.ts b/packages/shared/src/types/company-skill.ts index 0a587f671e9..a2d3e6b9f20 100644 --- a/packages/shared/src/types/company-skill.ts +++ b/packages/shared/src/types/company-skill.ts @@ -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 { diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index 2e918085a4c..ffc265520c3 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -77,6 +77,7 @@ export type { CompanySkillCommentCreateRequest, CompanySkillCommentUpdateRequest, CompanySkillForkRequest, + CompanySkillUpdateRequest, CompanySkillUpdateStatus, CompanySkillAuditSeverity, CompanySkillAuditVerdict, diff --git a/packages/shared/src/validators/company-skill.ts b/packages/shared/src/validators/company-skill.ts index f0772a4b772..ff223dd60fa 100644 --- a/packages/shared/src/validators/company-skill.ts +++ b/packages/shared/src/validators/company-skill.ts @@ -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; export type CompanySkillCommentUpdate = z.infer; export type CompanySkillFork = z.infer; +export type CompanySkillUpdate = z.infer; export type CatalogSkillListQuery = z.infer; export type CompanySkillInstallCatalog = z.infer; export type CompanySkillInstallUpdate = z.infer; diff --git a/packages/shared/src/validators/index.ts b/packages/shared/src/validators/index.ts index c08a05d79c5..9b170e8114b 100644 --- a/packages/shared/src/validators/index.ts +++ b/packages/shared/src/validators/index.ts @@ -78,6 +78,7 @@ export { companySkillCommentCreateSchema, companySkillCommentUpdateSchema, companySkillForkSchema, + companySkillUpdateSchema, companySkillUpdateStatusSchema, companySkillAuditFindingSchema, companySkillAuditResultSchema, diff --git a/server/src/__tests__/company-skills-service.test.ts b/server/src/__tests__/company-skills-service.test.ts index 82c91cb4341..e151a23e13e 100644 --- a/server/src/__tests__/company-skills-service.test.ts +++ b/server/src/__tests__/company-skills-service.test.ts @@ -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(); diff --git a/server/src/routes/company-skills.ts b/server/src/routes/company-skills.ts index a2446c0cc9a..84ce347c7d7 100644 --- a/server/src/routes/company-skills.ts +++ b/server/src/routes/company-skills.ts @@ -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), diff --git a/server/src/services/company-skills.ts b/server/src/services/company-skills.ts index 258df90d1b6..28faf6d3324 100644 --- a/server/src/services/company-skills.ts +++ b/server/src/services/company-skills.ts @@ -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 { + async function updateSkill( + companyId: string, + skillId: string, + input: CompanySkillUpdateRequest = {}, + ): Promise { + await ensureSkillInventoryCurrent(companyId); + const skill = await getById(companyId, skillId); + if (!skill) throw notFound("Skill not found"); + + const sharingScope = Object.prototype.hasOwnProperty.call(input, "sharingScope") + ? normalizeMutableSharingScope(input.sharingScope) + : null; + const values: Partial = { + updatedAt: new Date(), + }; + + if (Object.prototype.hasOwnProperty.call(input, "description")) { + values.description = normalizeStoreText(input.description, 2000); + } + if (Object.prototype.hasOwnProperty.call(input, "iconUrl")) { + values.iconUrl = normalizeStoreText(input.iconUrl, 2000); + } + if (Object.prototype.hasOwnProperty.call(input, "color")) { + values.color = normalizeStoreText(input.color, 64); + } + if (Object.prototype.hasOwnProperty.call(input, "tagline")) { + values.tagline = normalizeStoreText(input.tagline, 120); + } + if (Object.prototype.hasOwnProperty.call(input, "authorName")) { + values.authorName = normalizeStoreText(input.authorName, 200); + } + if (Object.prototype.hasOwnProperty.call(input, "homepageUrl")) { + values.homepageUrl = normalizeStoreText(input.homepageUrl, 2000); + } + if (Object.prototype.hasOwnProperty.call(input, "categories")) { + values.categories = normalizeCategoryList(input.categories); + } + if (sharingScope) { + values.sharingScope = sharingScope; + values.publicShareToken = null; + } + + const row = await db + .update(companySkills) + .set(values) + .where(and(eq(companySkills.id, skillId), eq(companySkills.companyId, companyId))) + .returning() + .then((rows) => rows[0] ?? null); + if (!row) throw notFound("Skill not found"); + return toCompanySkill(row); + } + + async function createLocalSkill( + companyId: string, + input: CompanySkillCreateRequest, + actor: SkillActor | null = null, + ): Promise { const slug = normalizeSkillSlug(input.slug ?? input.name) ?? "skill"; + const key = `company/${companyId}/${slug}`; + const existing = await getByKey(companyId, key); + if (existing) { + throw conflict(`A company skill with slug "${slug}" already exists.`); + } + + const forkSource = input.forkedFromSkillId + ? await getById(companyId, input.forkedFromSkillId) + : null; + if (input.forkedFromSkillId && !forkSource) { + throw notFound("Fork source skill not found"); + } + const sharingScope = normalizeMutableSharingScope(input.sharingScope) ?? "company"; const managedRoot = resolveManagedSkillsRoot(companyId); const skillDir = path.resolve(managedRoot, slug); const skillFilePath = path.resolve(skillDir, "SKILL.md"); + await fs.rm(skillDir, { recursive: true, force: true }); await fs.mkdir(skillDir, { recursive: true }); - const markdown = (input.markdown?.trim().length - ? input.markdown - : [ + if (forkSource) { + for (const entry of forkSource.fileInventory) { + const detail = await readFile(companyId, forkSource.id, entry.path); + if (!detail) continue; + const targetPath = path.resolve(skillDir, detail.path); + await fs.mkdir(path.dirname(targetPath), { recursive: true }); + await fs.writeFile(targetPath, detail.content, "utf8"); + } + } + + const fallbackMarkdown = [ "---", `name: ${input.name}`, ...(input.description?.trim() ? [`description: ${input.description.trim()}`] : []), @@ -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 { 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, diff --git a/ui/src/api/companySkills.ts b/ui/src/api/companySkills.ts index 76d337ce68a..1e3aeb8f3db 100644 --- a/ui/src/api/companySkills.ts +++ b/ui/src/api/companySkills.ts @@ -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( + `/companies/${encodeURIComponent(companyId)}/skills/${encodeURIComponent(skillId)}`, + payload, + ), importFromSource: (companyId: string, source: string) => api.post( `/companies/${encodeURIComponent(companyId)}/skills/import`, diff --git a/ui/src/pages/CompanySkills.tsx b/ui/src/pages/CompanySkills.tsx index f4a1bd40c7a..1f5b3ea3ab5 100644 --- a/ui/src/pages/CompanySkills.tsx +++ b/ui/src/pages/CompanySkills.tsx @@ -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; + 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(initialDraft); + const categoryDraft = draft.categories.join(", "); + const steps = ["Basics", "Design", "Content", "Review"]; + + useEffect(() => { + setStep(0); + setDraft(initialDraft); + }, [initialDraft]); + + function patchDraft(patch: Partial) { + 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 ( -
-
- 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" - /> - 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" - /> -