From 56f7807732781a2fcba6ca4b9e6a7e05fdac2670 Mon Sep 17 00:00:00 2001 From: Dotta Date: Mon, 16 Mar 2026 19:09:33 -0500 Subject: [PATCH] feat: scan project workspaces for skills Co-Authored-By: Paperclip --- packages/shared/src/index.ts | 8 + packages/shared/src/types/company-skill.ts | 39 ++ packages/shared/src/types/index.ts | 4 + .../shared/src/validators/company-skill.ts | 40 ++ packages/shared/src/validators/index.ts | 5 + server/src/__tests__/company-skills.test.ts | 84 +++- server/src/routes/company-skills.ts | 34 ++ server/src/services/company-skills.ts | 408 ++++++++++++++++-- ui/src/api/companySkills.ts | 7 + 9 files changed, 595 insertions(+), 34 deletions(-) diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 6bd12791..a96fe864 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -132,6 +132,10 @@ export type { CompanySkillUpdateStatus, CompanySkillImportRequest, CompanySkillImportResult, + CompanySkillProjectScanRequest, + CompanySkillProjectScanSkipped, + CompanySkillProjectScanConflict, + CompanySkillProjectScanResult, CompanySkillCreateRequest, CompanySkillFileDetail, CompanySkillFileUpdateRequest, @@ -374,6 +378,10 @@ export { companySkillDetailSchema, companySkillUpdateStatusSchema, companySkillImportSchema, + companySkillProjectScanRequestSchema, + companySkillProjectScanSkippedSchema, + companySkillProjectScanConflictSchema, + companySkillProjectScanResultSchema, companySkillCreateSchema, companySkillFileDetailSchema, companySkillFileUpdateSchema, diff --git a/packages/shared/src/types/company-skill.ts b/packages/shared/src/types/company-skill.ts index 7f0698fd..917a73ef 100644 --- a/packages/shared/src/types/company-skill.ts +++ b/packages/shared/src/types/company-skill.ts @@ -88,6 +88,45 @@ export interface CompanySkillImportResult { warnings: string[]; } +export interface CompanySkillProjectScanRequest { + projectIds?: string[]; + workspaceIds?: string[]; +} + +export interface CompanySkillProjectScanSkipped { + projectId: string; + projectName: string; + workspaceId: string | null; + workspaceName: string | null; + path: string | null; + reason: string; +} + +export interface CompanySkillProjectScanConflict { + slug: string; + key: string; + projectId: string; + projectName: string; + workspaceId: string; + workspaceName: string; + path: string; + existingSkillId: string; + existingSkillKey: string; + existingSourceLocator: string | null; + reason: string; +} + +export interface CompanySkillProjectScanResult { + scannedProjects: number; + scannedWorkspaces: number; + discovered: number; + imported: CompanySkill[]; + updated: CompanySkill[]; + skipped: CompanySkillProjectScanSkipped[]; + conflicts: CompanySkillProjectScanConflict[]; + warnings: string[]; +} + export interface CompanySkillCreateRequest { name: string; slug?: string | null; diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index 0472fee6..66061829 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -12,6 +12,10 @@ export type { CompanySkillUpdateStatus, CompanySkillImportRequest, CompanySkillImportResult, + CompanySkillProjectScanRequest, + CompanySkillProjectScanSkipped, + CompanySkillProjectScanConflict, + CompanySkillProjectScanResult, CompanySkillCreateRequest, CompanySkillFileDetail, CompanySkillFileUpdateRequest, diff --git a/packages/shared/src/validators/company-skill.ts b/packages/shared/src/validators/company-skill.ts index 5d9586a2..15bd4e2a 100644 --- a/packages/shared/src/validators/company-skill.ts +++ b/packages/shared/src/validators/company-skill.ts @@ -68,6 +68,45 @@ export const companySkillImportSchema = z.object({ source: z.string().min(1), }); +export const companySkillProjectScanRequestSchema = z.object({ + projectIds: z.array(z.string().uuid()).optional(), + workspaceIds: z.array(z.string().uuid()).optional(), +}); + +export const companySkillProjectScanSkippedSchema = z.object({ + projectId: z.string().uuid(), + projectName: z.string().min(1), + workspaceId: z.string().uuid().nullable(), + workspaceName: z.string().nullable(), + path: z.string().nullable(), + reason: z.string().min(1), +}); + +export const companySkillProjectScanConflictSchema = z.object({ + slug: z.string().min(1), + key: z.string().min(1), + projectId: z.string().uuid(), + projectName: z.string().min(1), + workspaceId: z.string().uuid(), + workspaceName: z.string().min(1), + path: z.string().min(1), + existingSkillId: z.string().uuid(), + existingSkillKey: z.string().min(1), + existingSourceLocator: z.string().nullable(), + reason: z.string().min(1), +}); + +export const companySkillProjectScanResultSchema = z.object({ + scannedProjects: z.number().int().nonnegative(), + scannedWorkspaces: z.number().int().nonnegative(), + discovered: z.number().int().nonnegative(), + imported: z.array(companySkillSchema), + updated: z.array(companySkillSchema), + skipped: z.array(companySkillProjectScanSkippedSchema), + conflicts: z.array(companySkillProjectScanConflictSchema), + warnings: z.array(z.string()), +}); + export const companySkillCreateSchema = z.object({ name: z.string().min(1), slug: z.string().min(1).nullable().optional(), @@ -91,5 +130,6 @@ export const companySkillFileUpdateSchema = z.object({ }); export type CompanySkillImport = z.infer; +export type CompanySkillProjectScan = z.infer; export type CompanySkillCreate = z.infer; export type CompanySkillFileUpdate = z.infer; diff --git a/packages/shared/src/validators/index.ts b/packages/shared/src/validators/index.ts index 8009204b..c650f4f5 100644 --- a/packages/shared/src/validators/index.ts +++ b/packages/shared/src/validators/index.ts @@ -23,10 +23,15 @@ export { companySkillDetailSchema, companySkillUpdateStatusSchema, companySkillImportSchema, + companySkillProjectScanRequestSchema, + companySkillProjectScanSkippedSchema, + companySkillProjectScanConflictSchema, + companySkillProjectScanResultSchema, companySkillCreateSchema, companySkillFileDetailSchema, companySkillFileUpdateSchema, type CompanySkillImport, + type CompanySkillProjectScan, type CompanySkillCreate, type CompanySkillFileUpdate, } from "./company-skill.js"; diff --git a/server/src/__tests__/company-skills.test.ts b/server/src/__tests__/company-skills.test.ts index d092abd9..fda0ca87 100644 --- a/server/src/__tests__/company-skills.test.ts +++ b/server/src/__tests__/company-skills.test.ts @@ -1,5 +1,30 @@ -import { describe, expect, it } from "vitest"; -import { parseSkillImportSourceInput } from "../services/company-skills.js"; +import os from "node:os"; +import path from "node:path"; +import { promises as fs } from "node:fs"; +import { afterEach, describe, expect, it } from "vitest"; +import { + discoverProjectWorkspaceSkillDirectories, + parseSkillImportSourceInput, + readLocalSkillImportFromDirectory, +} from "../services/company-skills.js"; + +const cleanupDirs = new Set(); + +afterEach(async () => { + await Promise.all(Array.from(cleanupDirs, (dir) => fs.rm(dir, { recursive: true, force: true }))); + cleanupDirs.clear(); +}); + +async function makeTempDir(prefix: string) { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), prefix)); + cleanupDirs.add(dir); + return dir; +} + +async function writeSkillDir(skillDir: string, name: string) { + await fs.mkdir(skillDir, { recursive: true }); + await fs.writeFile(path.join(skillDir, "SKILL.md"), `---\nname: ${name}\n---\n\n# ${name}\n`, "utf8"); +} describe("company skill import source parsing", () => { it("parses a skills.sh command without executing shell input", () => { @@ -28,3 +53,58 @@ describe("company skill import source parsing", () => { expect(parsed.requestedSkillSlug).toBe("remotion-best-practices"); }); }); + +describe("project workspace skill discovery", () => { + it("finds bounded skill roots under supported workspace paths", async () => { + const workspace = await makeTempDir("paperclip-skill-workspace-"); + await writeSkillDir(workspace, "Workspace Root"); + await writeSkillDir(path.join(workspace, "skills", "find-skills"), "Find Skills"); + await writeSkillDir(path.join(workspace, ".agents", "skills", "release"), "Release"); + await writeSkillDir(path.join(workspace, "skills", ".system", "paperclip"), "Paperclip"); + await fs.writeFile(path.join(workspace, "README.md"), "# ignore\n", "utf8"); + + const discovered = await discoverProjectWorkspaceSkillDirectories({ + projectId: "11111111-1111-1111-1111-111111111111", + projectName: "Repo", + workspaceId: "22222222-2222-2222-2222-222222222222", + workspaceName: "Main", + workspaceCwd: workspace, + }); + + expect(discovered).toEqual([ + { skillDir: path.resolve(workspace), inventoryMode: "project_root" }, + { skillDir: path.resolve(workspace, ".agents", "skills", "release"), inventoryMode: "full" }, + { skillDir: path.resolve(workspace, "skills", ".system", "paperclip"), inventoryMode: "full" }, + { skillDir: path.resolve(workspace, "skills", "find-skills"), inventoryMode: "full" }, + ]); + }); + + it("limits root SKILL.md imports to skill-related support folders", async () => { + const workspace = await makeTempDir("paperclip-root-skill-"); + await writeSkillDir(workspace, "Workspace Skill"); + await fs.mkdir(path.join(workspace, "references"), { recursive: true }); + await fs.mkdir(path.join(workspace, "scripts"), { recursive: true }); + await fs.mkdir(path.join(workspace, "assets"), { recursive: true }); + await fs.mkdir(path.join(workspace, "src"), { recursive: true }); + await fs.writeFile(path.join(workspace, "references", "checklist.md"), "# Checklist\n", "utf8"); + await fs.writeFile(path.join(workspace, "scripts", "run.sh"), "echo ok\n", "utf8"); + await fs.writeFile(path.join(workspace, "assets", "logo.svg"), "\n", "utf8"); + await fs.writeFile(path.join(workspace, "README.md"), "# Repo\n", "utf8"); + await fs.writeFile(path.join(workspace, "src", "index.ts"), "export {};\n", "utf8"); + + const imported = await readLocalSkillImportFromDirectory( + "33333333-3333-4333-8333-333333333333", + workspace, + { inventoryMode: "project_root", metadata: { sourceKind: "project_scan" } }, + ); + + expect(new Set(imported.fileInventory.map((entry) => entry.path))).toEqual(new Set([ + "assets/logo.svg", + "references/checklist.md", + "scripts/run.sh", + "SKILL.md", + ])); + expect(imported.fileInventory.map((entry) => entry.kind)).toContain("script"); + expect(imported.metadata?.sourceKind).toBe("project_scan"); + }); +}); diff --git a/server/src/routes/company-skills.ts b/server/src/routes/company-skills.ts index 1fee1a06..5d9d1b94 100644 --- a/server/src/routes/company-skills.ts +++ b/server/src/routes/company-skills.ts @@ -4,6 +4,7 @@ import { companySkillCreateSchema, companySkillFileUpdateSchema, companySkillImportSchema, + companySkillProjectScanRequestSchema, } from "@paperclipai/shared"; import { validate } from "../middleware/validate.js"; import { companySkillService, logActivity } from "../services/index.js"; @@ -150,6 +151,39 @@ export function companySkillRoutes(db: Db) { }, ); + router.post( + "/companies/:companyId/skills/scan-projects", + validate(companySkillProjectScanRequestSchema), + async (req, res) => { + const companyId = req.params.companyId as string; + assertCompanyAccess(req, companyId); + const result = await svc.scanProjectWorkspaces(companyId, req.body); + + const actor = getActorInfo(req); + await logActivity(db, { + companyId, + actorType: actor.actorType, + actorId: actor.actorId, + agentId: actor.agentId, + runId: actor.runId, + action: "company.skills_scanned", + entityType: "company", + entityId: companyId, + details: { + scannedProjects: result.scannedProjects, + scannedWorkspaces: result.scannedWorkspaces, + discovered: result.discovered, + importedCount: result.imported.length, + updatedCount: result.updated.length, + conflictCount: result.conflicts.length, + warningCount: result.warnings.length, + }, + }); + + res.json(result); + }, + ); + router.post("/companies/:companyId/skills/:skillId/install-update", async (req, res) => { const companyId = req.params.companyId as string; const skillId = req.params.skillId as string; diff --git a/server/src/services/company-skills.ts b/server/src/services/company-skills.ts index 01234db6..a899fc41 100644 --- a/server/src/services/company-skills.ts +++ b/server/src/services/company-skills.ts @@ -16,6 +16,10 @@ import type { CompanySkillFileInventoryEntry, CompanySkillImportResult, CompanySkillListItem, + CompanySkillProjectScanConflict, + CompanySkillProjectScanRequest, + CompanySkillProjectScanResult, + CompanySkillProjectScanSkipped, CompanySkillSourceBadge, CompanySkillSourceType, CompanySkillTrustLevel, @@ -27,6 +31,7 @@ import { findServerAdapter } from "../adapters/index.js"; import { resolvePaperclipInstanceRoot } from "../home-paths.js"; import { notFound, unprocessable } from "../errors.js"; import { agentService } from "./agents.js"; +import { projectService } from "./projects.js"; import { secretService } from "./secrets.js"; type CompanySkillRow = typeof companySkills.$inferSelect; @@ -61,8 +66,66 @@ type SkillSourceMeta = { ref?: string; trackingRef?: string; repoSkillDir?: string; + projectId?: string; + projectName?: string; + workspaceId?: string; + workspaceName?: string; + workspaceCwd?: string; }; +export type LocalSkillInventoryMode = "full" | "project_root"; + +export type ProjectSkillScanTarget = { + projectId: string; + projectName: string; + workspaceId: string; + workspaceName: string; + workspaceCwd: string; +}; + +const PROJECT_SCAN_DIRECTORY_ROOTS = [ + "skills", + "skills/.curated", + "skills/.experimental", + "skills/.system", + ".agents/skills", + ".agent/skills", + ".augment/skills", + ".claude/skills", + ".codebuddy/skills", + ".commandcode/skills", + ".continue/skills", + ".cortex/skills", + ".crush/skills", + ".factory/skills", + ".goose/skills", + ".junie/skills", + ".iflow/skills", + ".kilocode/skills", + ".kiro/skills", + ".kode/skills", + ".mcpjam/skills", + ".vibe/skills", + ".mux/skills", + ".openhands/skills", + ".pi/skills", + ".qoder/skills", + ".qwen/skills", + ".roo/skills", + ".trae/skills", + ".windsurf/skills", + ".zencoder/skills", + ".neovate/skills", + ".pochi/skills", + ".adal/skills", +] as const; + +const PROJECT_ROOT_SKILL_SUBDIRECTORIES = [ + "references", + "scripts", + "assets", +] as const; + function asString(value: unknown): string | null { if (typeof value !== "string") return null; const trimmed = value.trim(); @@ -631,6 +694,123 @@ async function walkLocalFiles(root: string, current: string, out: string[]) { } } +async function statPath(targetPath: string) { + return fs.stat(targetPath).catch(() => null); +} + +async function collectLocalSkillInventory( + skillDir: string, + mode: LocalSkillInventoryMode = "full", +): Promise { + const skillFilePath = path.join(skillDir, "SKILL.md"); + const skillFileStat = await statPath(skillFilePath); + if (!skillFileStat?.isFile()) { + throw unprocessable(`No SKILL.md file was found in ${skillDir}.`); + } + + const allFiles = new Set(["SKILL.md"]); + if (mode === "full") { + const discoveredFiles: string[] = []; + await walkLocalFiles(skillDir, skillDir, discoveredFiles); + for (const relativePath of discoveredFiles) { + allFiles.add(relativePath); + } + } else { + for (const relativeDir of PROJECT_ROOT_SKILL_SUBDIRECTORIES) { + const absoluteDir = path.join(skillDir, relativeDir); + const dirStat = await statPath(absoluteDir); + if (!dirStat?.isDirectory()) continue; + const discoveredFiles: string[] = []; + await walkLocalFiles(skillDir, absoluteDir, discoveredFiles); + for (const relativePath of discoveredFiles) { + allFiles.add(relativePath); + } + } + } + + return Array.from(allFiles) + .map((relativePath) => ({ + path: normalizePortablePath(relativePath), + kind: classifyInventoryKind(relativePath), + })) + .sort((left, right) => left.path.localeCompare(right.path)); +} + +export async function readLocalSkillImportFromDirectory( + companyId: string, + skillDir: string, + options?: { + inventoryMode?: LocalSkillInventoryMode; + metadata?: Record | null; + }, +): Promise { + const resolvedSkillDir = path.resolve(skillDir); + const skillFilePath = path.join(resolvedSkillDir, "SKILL.md"); + const markdown = await fs.readFile(skillFilePath, "utf8"); + const parsed = parseFrontmatterMarkdown(markdown); + const slug = deriveImportedSkillSlug(parsed.frontmatter, path.basename(resolvedSkillDir)); + const skillKey = readCanonicalSkillKey( + parsed.frontmatter, + isPlainRecord(parsed.frontmatter.metadata) ? parsed.frontmatter.metadata : null, + ); + const metadata = { + ...(skillKey ? { skillKey } : {}), + sourceKind: "local_path", + ...(options?.metadata ?? {}), + }; + const inventory = await collectLocalSkillInventory(resolvedSkillDir, options?.inventoryMode ?? "full"); + + return { + key: deriveCanonicalSkillKey(companyId, { + slug, + sourceType: "local_path", + sourceLocator: resolvedSkillDir, + metadata, + }), + slug, + name: asString(parsed.frontmatter.name) ?? slug, + description: asString(parsed.frontmatter.description), + markdown, + packageDir: resolvedSkillDir, + sourceType: "local_path", + sourceLocator: resolvedSkillDir, + sourceRef: null, + trustLevel: deriveTrustLevel(inventory), + compatibility: "compatible", + fileInventory: inventory, + metadata, + }; +} + +export async function discoverProjectWorkspaceSkillDirectories(target: ProjectSkillScanTarget): Promise> { + const discovered = new Map(); + const rootSkillPath = path.join(target.workspaceCwd, "SKILL.md"); + if ((await statPath(rootSkillPath))?.isFile()) { + discovered.set(path.resolve(target.workspaceCwd), "project_root"); + } + + for (const relativeRoot of PROJECT_SCAN_DIRECTORY_ROOTS) { + const absoluteRoot = path.join(target.workspaceCwd, relativeRoot); + const rootStat = await statPath(absoluteRoot); + if (!rootStat?.isDirectory()) continue; + + const entries = await fs.readdir(absoluteRoot, { withFileTypes: true }).catch(() => []); + for (const entry of entries) { + if (!entry.isDirectory()) continue; + const absoluteSkillDir = path.resolve(absoluteRoot, entry.name); + if (!(await statPath(path.join(absoluteSkillDir, "SKILL.md")))?.isFile()) continue; + discovered.set(absoluteSkillDir, "full"); + } + } + + return Array.from(discovered.entries()) + .map(([skillDir, inventoryMode]) => ({ skillDir, inventoryMode })) + .sort((left, right) => left.skillDir.localeCompare(right.skillDir)); +} + async function readLocalSkillImports(companyId: string, sourcePath: string): Promise { const resolvedPath = path.resolve(sourcePath); const stat = await fs.stat(resolvedPath).catch(() => null); @@ -686,17 +866,6 @@ async function readLocalSkillImports(companyId: string, sourcePath: string): Pro const imports: ImportedSkill[] = []; for (const skillPath of skillPaths) { const skillDir = path.posix.dirname(skillPath); - const markdown = await fs.readFile(path.join(root, skillPath), "utf8"); - const parsed = parseFrontmatterMarkdown(markdown); - const slug = deriveImportedSkillSlug(parsed.frontmatter, path.posix.basename(skillDir)); - const skillKey = readCanonicalSkillKey( - parsed.frontmatter, - isPlainRecord(parsed.frontmatter.metadata) ? parsed.frontmatter.metadata : null, - ); - const metadata = { - ...(skillKey ? { skillKey } : {}), - sourceKind: "local_path", - }; const inventory = allFiles .filter((entry) => entry === skillPath || entry.startsWith(`${skillDir}/`)) .map((entry) => { @@ -707,26 +876,10 @@ async function readLocalSkillImports(companyId: string, sourcePath: string): Pro }; }) .sort((left, right) => left.path.localeCompare(right.path)); - imports.push({ - key: deriveCanonicalSkillKey(companyId, { - slug, - sourceType: "local_path", - sourceLocator: path.join(root, skillDir), - metadata, - }), - slug, - name: asString(parsed.frontmatter.name) ?? slug, - description: asString(parsed.frontmatter.description), - markdown, - packageDir: path.join(root, skillDir), - sourceType: "local_path", - sourceLocator: path.join(root, skillDir), - sourceRef: null, - trustLevel: deriveTrustLevel(inventory), - compatibility: "compatible", - fileInventory: inventory, - metadata, - }); + const imported = await readLocalSkillImportFromDirectory(companyId, path.join(root, skillDir)); + imported.fileInventory = inventory; + imported.trustLevel = deriveTrustLevel(inventory); + imports.push(imported); } return imports; @@ -942,6 +1095,12 @@ function normalizeSkillDirectory(skill: CompanySkill) { return resolved; } +function normalizeSourceLocatorDirectory(sourceLocator: string | null) { + if (!sourceLocator) return null; + const resolved = path.resolve(sourceLocator); + return path.basename(resolved).toLowerCase() === "skill.md" ? path.dirname(resolved) : resolved; +} + function resolveManagedSkillsRoot(companyId: string) { return path.resolve(resolvePaperclipInstanceRoot(), "skills", companyId); } @@ -1019,6 +1178,9 @@ function deriveSkillSourceInfo(skill: CompanySkill): { if (skill.sourceType === "local_path") { const managedRoot = resolveManagedSkillsRoot(skill.companyId); + const projectName = asString(metadata.projectName); + const workspaceName = asString(metadata.workspaceName); + const isProjectScan = metadata.sourceKind === "project_scan"; if (localSkillDir && localSkillDir.startsWith(managedRoot)) { return { editable: true, @@ -1031,7 +1193,10 @@ function deriveSkillSourceInfo(skill: CompanySkill): { return { editable: true, editableReason: null, - sourceLabel: skill.sourceLocator, + sourceLabel: isProjectScan + ? [projectName, workspaceName].filter((value): value is string => Boolean(value)).join(" / ") + || skill.sourceLocator + : skill.sourceLocator, sourceBadge: "local", }; } @@ -1081,6 +1246,7 @@ function toCompanySkillListItem(skill: CompanySkill, attachedAgentCount: number) export function companySkillService(db: Db) { const agents = agentService(db); + const projects = projectService(db); const secretsSvc = secretService(db); async function ensureBundledSkills(companyId: string) { @@ -1409,6 +1575,183 @@ export function companySkillService(db: Db) { return imported[0] ?? null; } + async function scanProjectWorkspaces( + companyId: string, + input: CompanySkillProjectScanRequest = {}, + ): Promise { + await ensureBundledSkills(companyId); + const projectRows = input.projectIds?.length + ? await projects.listByIds(companyId, input.projectIds) + : await projects.list(companyId); + const workspaceFilter = new Set(input.workspaceIds ?? []); + const skipped: CompanySkillProjectScanSkipped[] = []; + const conflicts: CompanySkillProjectScanConflict[] = []; + const warnings: string[] = []; + const imported: CompanySkill[] = []; + const updated: CompanySkill[] = []; + const availableSkills = await listFull(companyId); + const acceptedSkills = [...availableSkills]; + const acceptedByKey = new Map(acceptedSkills.map((skill) => [skill.key, skill])); + const scanTargets: ProjectSkillScanTarget[] = []; + const scannedProjectIds = new Set(); + let discovered = 0; + + const trackWarning = (message: string) => { + warnings.push(message); + return message; + }; + const upsertAcceptedSkill = (skill: CompanySkill) => { + const nextIndex = acceptedSkills.findIndex((entry) => entry.id === skill.id || entry.key === skill.key); + if (nextIndex >= 0) acceptedSkills[nextIndex] = skill; + else acceptedSkills.push(skill); + acceptedByKey.set(skill.key, skill); + }; + + for (const project of projectRows) { + for (const workspace of project.workspaces) { + if (workspaceFilter.size > 0 && !workspaceFilter.has(workspace.id)) continue; + const workspaceCwd = asString(workspace.cwd); + if (!workspaceCwd) { + skipped.push({ + projectId: project.id, + projectName: project.name, + workspaceId: workspace.id, + workspaceName: workspace.name, + path: null, + reason: trackWarning(`Skipped ${project.name} / ${workspace.name}: no local workspace path is configured.`), + }); + continue; + } + + const workspaceStat = await statPath(workspaceCwd); + if (!workspaceStat?.isDirectory()) { + skipped.push({ + projectId: project.id, + projectName: project.name, + workspaceId: workspace.id, + workspaceName: workspace.name, + path: workspaceCwd, + reason: trackWarning(`Skipped ${project.name} / ${workspace.name}: local workspace path is not available at ${workspaceCwd}.`), + }); + continue; + } + + scanTargets.push({ + projectId: project.id, + projectName: project.name, + workspaceId: workspace.id, + workspaceName: workspace.name, + workspaceCwd, + }); + } + } + + for (const target of scanTargets) { + scannedProjectIds.add(target.projectId); + const directories = await discoverProjectWorkspaceSkillDirectories(target); + + for (const directory of directories) { + discovered += 1; + + let nextSkill: ImportedSkill; + try { + nextSkill = await readLocalSkillImportFromDirectory(companyId, directory.skillDir, { + inventoryMode: directory.inventoryMode, + metadata: { + sourceKind: "project_scan", + projectId: target.projectId, + projectName: target.projectName, + workspaceId: target.workspaceId, + workspaceName: target.workspaceName, + workspaceCwd: target.workspaceCwd, + }, + }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + skipped.push({ + projectId: target.projectId, + projectName: target.projectName, + workspaceId: target.workspaceId, + workspaceName: target.workspaceName, + path: directory.skillDir, + reason: trackWarning(`Skipped ${directory.skillDir}: ${message}`), + }); + continue; + } + + const normalizedSourceDir = normalizeSourceLocatorDirectory(nextSkill.sourceLocator); + const existingByKey = acceptedByKey.get(nextSkill.key) ?? null; + if (existingByKey) { + const existingSourceDir = normalizeSkillDirectory(existingByKey); + if ( + existingByKey.sourceType !== "local_path" + || !existingSourceDir + || !normalizedSourceDir + || existingSourceDir !== normalizedSourceDir + ) { + conflicts.push({ + slug: nextSkill.slug, + key: nextSkill.key, + projectId: target.projectId, + projectName: target.projectName, + workspaceId: target.workspaceId, + workspaceName: target.workspaceName, + path: directory.skillDir, + existingSkillId: existingByKey.id, + existingSkillKey: existingByKey.key, + existingSourceLocator: existingByKey.sourceLocator, + reason: `Skill key ${nextSkill.key} already points at ${existingByKey.sourceLocator ?? "another source"}.`, + }); + continue; + } + + const persisted = (await upsertImportedSkills(companyId, [nextSkill]))[0]; + if (!persisted) continue; + updated.push(persisted); + upsertAcceptedSkill(persisted); + continue; + } + + const slugConflict = acceptedSkills.find((skill) => { + if (skill.slug !== nextSkill.slug) return false; + return normalizeSkillDirectory(skill) !== normalizedSourceDir; + }); + if (slugConflict) { + conflicts.push({ + slug: nextSkill.slug, + key: nextSkill.key, + projectId: target.projectId, + projectName: target.projectName, + workspaceId: target.workspaceId, + workspaceName: target.workspaceName, + path: directory.skillDir, + existingSkillId: slugConflict.id, + existingSkillKey: slugConflict.key, + existingSourceLocator: slugConflict.sourceLocator, + reason: `Slug ${nextSkill.slug} is already in use by ${slugConflict.sourceLocator ?? slugConflict.key}.`, + }); + continue; + } + + const persisted = (await upsertImportedSkills(companyId, [nextSkill]))[0]; + if (!persisted) continue; + imported.push(persisted); + upsertAcceptedSkill(persisted); + } + } + + return { + scannedProjects: scannedProjectIds.size, + scannedWorkspaces: scanTargets.length, + discovered, + imported, + updated, + skipped, + conflicts, + warnings, + }; + } + async function materializeCatalogSkillFiles( companyId: string, skill: ImportedSkill, @@ -1601,6 +1944,7 @@ export function companySkillService(db: Db) { updateFile, createLocalSkill, importFromSource, + scanProjectWorkspaces, importPackageFiles, installUpdate, listRuntimeSkillEntries, diff --git a/ui/src/api/companySkills.ts b/ui/src/api/companySkills.ts index 417dbe8f..adbc2117 100644 --- a/ui/src/api/companySkills.ts +++ b/ui/src/api/companySkills.ts @@ -5,6 +5,8 @@ import type { CompanySkillFileDetail, CompanySkillImportResult, CompanySkillListItem, + CompanySkillProjectScanRequest, + CompanySkillProjectScanResult, CompanySkillUpdateStatus, } from "@paperclipai/shared"; import { api } from "./client"; @@ -39,6 +41,11 @@ export const companySkillsApi = { `/companies/${encodeURIComponent(companyId)}/skills/import`, { source }, ), + scanProjects: (companyId: string, payload: CompanySkillProjectScanRequest = {}) => + api.post( + `/companies/${encodeURIComponent(companyId)}/skills/scan-projects`, + payload, + ), installUpdate: (companyId: string, skillId: string) => api.post( `/companies/${encodeURIComponent(companyId)}/skills/${encodeURIComponent(skillId)}/install-update`,