diff --git a/server/src/services/company-portability.ts b/server/src/services/company-portability.ts index cdc220d0..44a409ee 100644 --- a/server/src/services/company-portability.ts +++ b/server/src/services/company-portability.ts @@ -1,6 +1,7 @@ import { promises as fs } from "node:fs"; -import { execFileSync } from "node:child_process"; +import { execFile } from "node:child_process"; import path from "node:path"; +import { promisify } from "node:util"; import type { Db } from "@paperclipai/db"; import type { CompanyPortabilityAgentManifestEntry, @@ -47,6 +48,8 @@ const DEFAULT_INCLUDE: CompanyPortabilityInclude = { }; const DEFAULT_COLLISION_STRATEGY: CompanyPortabilityCollisionStrategy = "rename"; +const execFileAsync = promisify(execFile); +let bundledSkillsCommitPromise: Promise | null = null; function isSensitiveEnvKey(key: string) { const normalized = key.trim().toLowerCase(); @@ -603,19 +606,22 @@ function buildMarkdown(frontmatter: Record, body: string) { return `${renderFrontmatter(frontmatter)}\n${cleanBody}\n`; } -function buildSkillSourceEntry(skill: CompanySkill) { +async function resolveBundledSkillsCommit() { + if (!bundledSkillsCommitPromise) { + bundledSkillsCommitPromise = execFileAsync("git", ["rev-parse", "HEAD"], { + cwd: process.cwd(), + encoding: "utf8", + }) + .then(({ stdout }) => stdout.trim() || null) + .catch(() => null); + } + return bundledSkillsCommitPromise; +} + +async function buildSkillSourceEntry(skill: CompanySkill) { const metadata = isPlainRecord(skill.metadata) ? skill.metadata : null; if (asString(metadata?.sourceKind) === "paperclip_bundled") { - let commit: string | null = null; - try { - const resolved = execFileSync("git", ["rev-parse", "HEAD"], { - cwd: process.cwd(), - encoding: "utf8", - }).trim(); - commit = resolved || null; - } catch { - commit = null; - } + const commit = await resolveBundledSkillsCommit(); return { kind: "github-dir", repo: "paperclipai/paperclip", @@ -658,8 +664,8 @@ function shouldReferenceSkillOnExport(skill: CompanySkill, expandReferencedSkill return skill.sourceType === "github" || skill.sourceType === "url"; } -function buildReferencedSkillMarkdown(skill: CompanySkill) { - const sourceEntry = buildSkillSourceEntry(skill); +async function buildReferencedSkillMarkdown(skill: CompanySkill) { + const sourceEntry = await buildSkillSourceEntry(skill); const frontmatter: Record = { name: skill.name, description: skill.description ?? null, @@ -672,8 +678,8 @@ function buildReferencedSkillMarkdown(skill: CompanySkill) { return buildMarkdown(frontmatter, ""); } -function withSkillSourceMetadata(skill: CompanySkill, markdown: string) { - const sourceEntry = buildSkillSourceEntry(skill); +async function withSkillSourceMetadata(skill: CompanySkill, markdown: string) { + const sourceEntry = await buildSkillSourceEntry(skill); if (!sourceEntry) return markdown; const parsed = parseFrontmatterMarkdown(markdown); const metadata = isPlainRecord(parsed.frontmatter.metadata) @@ -1635,7 +1641,7 @@ export function companyPortabilityService(db: Db) { for (const skill of companySkillRows) { if (shouldReferenceSkillOnExport(skill, Boolean(input.expandReferencedSkills))) { - files[`skills/${skill.slug}/SKILL.md`] = buildReferencedSkillMarkdown(skill); + files[`skills/${skill.slug}/SKILL.md`] = await buildReferencedSkillMarkdown(skill); continue; } @@ -1644,7 +1650,7 @@ export function companyPortabilityService(db: Db) { if (!fileDetail) continue; const filePath = `skills/${skill.slug}/${inventoryEntry.path}`; files[filePath] = inventoryEntry.path === "SKILL.md" - ? withSkillSourceMetadata(skill, fileDetail.content) + ? await withSkillSourceMetadata(skill, fileDetail.content) : fileDetail.content; } } diff --git a/server/src/services/company-skills.ts b/server/src/services/company-skills.ts index d0b6db69..90895d7a 100644 --- a/server/src/services/company-skills.ts +++ b/server/src/services/company-skills.ts @@ -70,7 +70,16 @@ function isPlainRecord(value: unknown): value is Record { } function normalizePortablePath(input: string) { - return input.replace(/\\/g, "/").replace(/^\.\/+/, "").replace(/^\/+/, ""); + const parts: string[] = []; + for (const segment of input.replace(/\\/g, "/").replace(/^\.\/+/, "").replace(/^\/+/, "").split("/")) { + if (!segment || segment === ".") continue; + if (segment === "..") { + if (parts.length > 0) parts.pop(); + continue; + } + parts.push(segment); + } + return parts.join("/"); } function normalizePackageFileMap(files: Record) { diff --git a/ui/src/pages/AgentDetail.tsx b/ui/src/pages/AgentDetail.tsx index 7dbc4dd4..d34eaead 100644 --- a/ui/src/pages/AgentDetail.tsx +++ b/ui/src/pages/AgentDetail.tsx @@ -1190,14 +1190,16 @@ function AgentSkillsTab({ useEffect(() => { if (!skillSnapshot) return; if (syncSkills.isPending) return; - if (arraysEqual(skillDraft, lastSavedSkills)) return; + if (arraysEqual(skillDraft, lastSavedSkillsRef.current)) return; const timeout = window.setTimeout(() => { - syncSkills.mutate(skillDraft); + if (!arraysEqual(skillDraft, lastSavedSkillsRef.current)) { + syncSkills.mutate(skillDraft); + } }, 250); return () => window.clearTimeout(timeout); - }, [lastSavedSkills, skillDraft, skillSnapshot, syncSkills.isPending, syncSkills.mutate]); + }, [skillDraft, skillSnapshot, syncSkills.isPending, syncSkills.mutate]); const companySkillBySlug = useMemo( () => new Map((companySkills ?? []).map((skill) => [skill.slug, skill])),