mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-26 18:31:34 +01:00
Build skills store creation flows
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
85902696c8
commit
2f93dc99a6
10 files changed
+960
-76
No files matched your search
@@ -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 {
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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 ? (
|
||||
|
||||
Reference in new issue
Block a user