feat: scan project workspaces for skills
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -12,6 +12,10 @@ export type {
|
||||
CompanySkillUpdateStatus,
|
||||
CompanySkillImportRequest,
|
||||
CompanySkillImportResult,
|
||||
CompanySkillProjectScanRequest,
|
||||
CompanySkillProjectScanSkipped,
|
||||
CompanySkillProjectScanConflict,
|
||||
CompanySkillProjectScanResult,
|
||||
CompanySkillCreateRequest,
|
||||
CompanySkillFileDetail,
|
||||
CompanySkillFileUpdateRequest,
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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`,
|
||||
|
||||
Reference in New Issue
Block a user