feat: scan project workspaces for skills

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Dotta
2026-03-16 19:09:33 -05:00
parent 52978e84ba
commit 56f7807732
9 changed files with 595 additions and 34 deletions

View File

@@ -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,

View File

@@ -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;

View File

@@ -12,6 +12,10 @@ export type {
CompanySkillUpdateStatus,
CompanySkillImportRequest,
CompanySkillImportResult,
CompanySkillProjectScanRequest,
CompanySkillProjectScanSkipped,
CompanySkillProjectScanConflict,
CompanySkillProjectScanResult,
CompanySkillCreateRequest,
CompanySkillFileDetail,
CompanySkillFileUpdateRequest,

View File

@@ -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<typeof companySkillImportSchema>;
export type CompanySkillProjectScan = z.infer<typeof companySkillProjectScanRequestSchema>;
export type CompanySkillCreate = z.infer<typeof companySkillCreateSchema>;
export type CompanySkillFileUpdate = z.infer<typeof companySkillFileUpdateSchema>;

View File

@@ -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";

View File

@@ -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<string>();
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"), "<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");
});
});

View File

@@ -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;

View File

@@ -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<CompanySkillFileInventoryEntry[]> {
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<string>(["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<string, unknown> | null;
},
): Promise<ImportedSkill> {
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<Array<{
skillDir: string;
inventoryMode: LocalSkillInventoryMode;
}>> {
const discovered = new Map<string, LocalSkillInventoryMode>();
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<ImportedSkill[]> {
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<CompanySkillProjectScanResult> {
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<string>();
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,

View File

@@ -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<CompanySkillProjectScanResult>(
`/companies/${encodeURIComponent(companyId)}/skills/scan-projects`,
payload,
),
installUpdate: (companyId: string, skillId: string) =>
api.post<CompanySkill>(
`/companies/${encodeURIComponent(companyId)}/skills/${encodeURIComponent(skillId)}/install-update`,