feat: scan project workspaces for skills
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
parent
52978e84ba
commit
56f7807732
9 changed files with 595 additions and 34 deletions
|
|
@ -132,6 +132,10 @@ export type {
|
||||||
CompanySkillUpdateStatus,
|
CompanySkillUpdateStatus,
|
||||||
CompanySkillImportRequest,
|
CompanySkillImportRequest,
|
||||||
CompanySkillImportResult,
|
CompanySkillImportResult,
|
||||||
|
CompanySkillProjectScanRequest,
|
||||||
|
CompanySkillProjectScanSkipped,
|
||||||
|
CompanySkillProjectScanConflict,
|
||||||
|
CompanySkillProjectScanResult,
|
||||||
CompanySkillCreateRequest,
|
CompanySkillCreateRequest,
|
||||||
CompanySkillFileDetail,
|
CompanySkillFileDetail,
|
||||||
CompanySkillFileUpdateRequest,
|
CompanySkillFileUpdateRequest,
|
||||||
|
|
@ -374,6 +378,10 @@ export {
|
||||||
companySkillDetailSchema,
|
companySkillDetailSchema,
|
||||||
companySkillUpdateStatusSchema,
|
companySkillUpdateStatusSchema,
|
||||||
companySkillImportSchema,
|
companySkillImportSchema,
|
||||||
|
companySkillProjectScanRequestSchema,
|
||||||
|
companySkillProjectScanSkippedSchema,
|
||||||
|
companySkillProjectScanConflictSchema,
|
||||||
|
companySkillProjectScanResultSchema,
|
||||||
companySkillCreateSchema,
|
companySkillCreateSchema,
|
||||||
companySkillFileDetailSchema,
|
companySkillFileDetailSchema,
|
||||||
companySkillFileUpdateSchema,
|
companySkillFileUpdateSchema,
|
||||||
|
|
|
||||||
|
|
@ -88,6 +88,45 @@ export interface CompanySkillImportResult {
|
||||||
warnings: string[];
|
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 {
|
export interface CompanySkillCreateRequest {
|
||||||
name: string;
|
name: string;
|
||||||
slug?: string | null;
|
slug?: string | null;
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,10 @@ export type {
|
||||||
CompanySkillUpdateStatus,
|
CompanySkillUpdateStatus,
|
||||||
CompanySkillImportRequest,
|
CompanySkillImportRequest,
|
||||||
CompanySkillImportResult,
|
CompanySkillImportResult,
|
||||||
|
CompanySkillProjectScanRequest,
|
||||||
|
CompanySkillProjectScanSkipped,
|
||||||
|
CompanySkillProjectScanConflict,
|
||||||
|
CompanySkillProjectScanResult,
|
||||||
CompanySkillCreateRequest,
|
CompanySkillCreateRequest,
|
||||||
CompanySkillFileDetail,
|
CompanySkillFileDetail,
|
||||||
CompanySkillFileUpdateRequest,
|
CompanySkillFileUpdateRequest,
|
||||||
|
|
|
||||||
|
|
@ -68,6 +68,45 @@ export const companySkillImportSchema = z.object({
|
||||||
source: z.string().min(1),
|
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({
|
export const companySkillCreateSchema = z.object({
|
||||||
name: z.string().min(1),
|
name: z.string().min(1),
|
||||||
slug: z.string().min(1).nullable().optional(),
|
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 CompanySkillImport = z.infer<typeof companySkillImportSchema>;
|
||||||
|
export type CompanySkillProjectScan = z.infer<typeof companySkillProjectScanRequestSchema>;
|
||||||
export type CompanySkillCreate = z.infer<typeof companySkillCreateSchema>;
|
export type CompanySkillCreate = z.infer<typeof companySkillCreateSchema>;
|
||||||
export type CompanySkillFileUpdate = z.infer<typeof companySkillFileUpdateSchema>;
|
export type CompanySkillFileUpdate = z.infer<typeof companySkillFileUpdateSchema>;
|
||||||
|
|
|
||||||
|
|
@ -23,10 +23,15 @@ export {
|
||||||
companySkillDetailSchema,
|
companySkillDetailSchema,
|
||||||
companySkillUpdateStatusSchema,
|
companySkillUpdateStatusSchema,
|
||||||
companySkillImportSchema,
|
companySkillImportSchema,
|
||||||
|
companySkillProjectScanRequestSchema,
|
||||||
|
companySkillProjectScanSkippedSchema,
|
||||||
|
companySkillProjectScanConflictSchema,
|
||||||
|
companySkillProjectScanResultSchema,
|
||||||
companySkillCreateSchema,
|
companySkillCreateSchema,
|
||||||
companySkillFileDetailSchema,
|
companySkillFileDetailSchema,
|
||||||
companySkillFileUpdateSchema,
|
companySkillFileUpdateSchema,
|
||||||
type CompanySkillImport,
|
type CompanySkillImport,
|
||||||
|
type CompanySkillProjectScan,
|
||||||
type CompanySkillCreate,
|
type CompanySkillCreate,
|
||||||
type CompanySkillFileUpdate,
|
type CompanySkillFileUpdate,
|
||||||
} from "./company-skill.js";
|
} from "./company-skill.js";
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,30 @@
|
||||||
import { describe, expect, it } from "vitest";
|
import os from "node:os";
|
||||||
import { parseSkillImportSourceInput } from "../services/company-skills.js";
|
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", () => {
|
describe("company skill import source parsing", () => {
|
||||||
it("parses a skills.sh command without executing shell input", () => {
|
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");
|
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,
|
companySkillCreateSchema,
|
||||||
companySkillFileUpdateSchema,
|
companySkillFileUpdateSchema,
|
||||||
companySkillImportSchema,
|
companySkillImportSchema,
|
||||||
|
companySkillProjectScanRequestSchema,
|
||||||
} from "@paperclipai/shared";
|
} from "@paperclipai/shared";
|
||||||
import { validate } from "../middleware/validate.js";
|
import { validate } from "../middleware/validate.js";
|
||||||
import { companySkillService, logActivity } from "../services/index.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) => {
|
router.post("/companies/:companyId/skills/:skillId/install-update", async (req, res) => {
|
||||||
const companyId = req.params.companyId as string;
|
const companyId = req.params.companyId as string;
|
||||||
const skillId = req.params.skillId as string;
|
const skillId = req.params.skillId as string;
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,10 @@ import type {
|
||||||
CompanySkillFileInventoryEntry,
|
CompanySkillFileInventoryEntry,
|
||||||
CompanySkillImportResult,
|
CompanySkillImportResult,
|
||||||
CompanySkillListItem,
|
CompanySkillListItem,
|
||||||
|
CompanySkillProjectScanConflict,
|
||||||
|
CompanySkillProjectScanRequest,
|
||||||
|
CompanySkillProjectScanResult,
|
||||||
|
CompanySkillProjectScanSkipped,
|
||||||
CompanySkillSourceBadge,
|
CompanySkillSourceBadge,
|
||||||
CompanySkillSourceType,
|
CompanySkillSourceType,
|
||||||
CompanySkillTrustLevel,
|
CompanySkillTrustLevel,
|
||||||
|
|
@ -27,6 +31,7 @@ import { findServerAdapter } from "../adapters/index.js";
|
||||||
import { resolvePaperclipInstanceRoot } from "../home-paths.js";
|
import { resolvePaperclipInstanceRoot } from "../home-paths.js";
|
||||||
import { notFound, unprocessable } from "../errors.js";
|
import { notFound, unprocessable } from "../errors.js";
|
||||||
import { agentService } from "./agents.js";
|
import { agentService } from "./agents.js";
|
||||||
|
import { projectService } from "./projects.js";
|
||||||
import { secretService } from "./secrets.js";
|
import { secretService } from "./secrets.js";
|
||||||
|
|
||||||
type CompanySkillRow = typeof companySkills.$inferSelect;
|
type CompanySkillRow = typeof companySkills.$inferSelect;
|
||||||
|
|
@ -61,8 +66,66 @@ type SkillSourceMeta = {
|
||||||
ref?: string;
|
ref?: string;
|
||||||
trackingRef?: string;
|
trackingRef?: string;
|
||||||
repoSkillDir?: 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 {
|
function asString(value: unknown): string | null {
|
||||||
if (typeof value !== "string") return null;
|
if (typeof value !== "string") return null;
|
||||||
const trimmed = value.trim();
|
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[]> {
|
async function readLocalSkillImports(companyId: string, sourcePath: string): Promise<ImportedSkill[]> {
|
||||||
const resolvedPath = path.resolve(sourcePath);
|
const resolvedPath = path.resolve(sourcePath);
|
||||||
const stat = await fs.stat(resolvedPath).catch(() => null);
|
const stat = await fs.stat(resolvedPath).catch(() => null);
|
||||||
|
|
@ -686,17 +866,6 @@ async function readLocalSkillImports(companyId: string, sourcePath: string): Pro
|
||||||
const imports: ImportedSkill[] = [];
|
const imports: ImportedSkill[] = [];
|
||||||
for (const skillPath of skillPaths) {
|
for (const skillPath of skillPaths) {
|
||||||
const skillDir = path.posix.dirname(skillPath);
|
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
|
const inventory = allFiles
|
||||||
.filter((entry) => entry === skillPath || entry.startsWith(`${skillDir}/`))
|
.filter((entry) => entry === skillPath || entry.startsWith(`${skillDir}/`))
|
||||||
.map((entry) => {
|
.map((entry) => {
|
||||||
|
|
@ -707,26 +876,10 @@ async function readLocalSkillImports(companyId: string, sourcePath: string): Pro
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
.sort((left, right) => left.path.localeCompare(right.path));
|
.sort((left, right) => left.path.localeCompare(right.path));
|
||||||
imports.push({
|
const imported = await readLocalSkillImportFromDirectory(companyId, path.join(root, skillDir));
|
||||||
key: deriveCanonicalSkillKey(companyId, {
|
imported.fileInventory = inventory;
|
||||||
slug,
|
imported.trustLevel = deriveTrustLevel(inventory);
|
||||||
sourceType: "local_path",
|
imports.push(imported);
|
||||||
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,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return imports;
|
return imports;
|
||||||
|
|
@ -942,6 +1095,12 @@ function normalizeSkillDirectory(skill: CompanySkill) {
|
||||||
return resolved;
|
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) {
|
function resolveManagedSkillsRoot(companyId: string) {
|
||||||
return path.resolve(resolvePaperclipInstanceRoot(), "skills", companyId);
|
return path.resolve(resolvePaperclipInstanceRoot(), "skills", companyId);
|
||||||
}
|
}
|
||||||
|
|
@ -1019,6 +1178,9 @@ function deriveSkillSourceInfo(skill: CompanySkill): {
|
||||||
|
|
||||||
if (skill.sourceType === "local_path") {
|
if (skill.sourceType === "local_path") {
|
||||||
const managedRoot = resolveManagedSkillsRoot(skill.companyId);
|
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)) {
|
if (localSkillDir && localSkillDir.startsWith(managedRoot)) {
|
||||||
return {
|
return {
|
||||||
editable: true,
|
editable: true,
|
||||||
|
|
@ -1031,7 +1193,10 @@ function deriveSkillSourceInfo(skill: CompanySkill): {
|
||||||
return {
|
return {
|
||||||
editable: true,
|
editable: true,
|
||||||
editableReason: null,
|
editableReason: null,
|
||||||
sourceLabel: skill.sourceLocator,
|
sourceLabel: isProjectScan
|
||||||
|
? [projectName, workspaceName].filter((value): value is string => Boolean(value)).join(" / ")
|
||||||
|
|| skill.sourceLocator
|
||||||
|
: skill.sourceLocator,
|
||||||
sourceBadge: "local",
|
sourceBadge: "local",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -1081,6 +1246,7 @@ function toCompanySkillListItem(skill: CompanySkill, attachedAgentCount: number)
|
||||||
|
|
||||||
export function companySkillService(db: Db) {
|
export function companySkillService(db: Db) {
|
||||||
const agents = agentService(db);
|
const agents = agentService(db);
|
||||||
|
const projects = projectService(db);
|
||||||
const secretsSvc = secretService(db);
|
const secretsSvc = secretService(db);
|
||||||
|
|
||||||
async function ensureBundledSkills(companyId: string) {
|
async function ensureBundledSkills(companyId: string) {
|
||||||
|
|
@ -1409,6 +1575,183 @@ export function companySkillService(db: Db) {
|
||||||
return imported[0] ?? null;
|
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(
|
async function materializeCatalogSkillFiles(
|
||||||
companyId: string,
|
companyId: string,
|
||||||
skill: ImportedSkill,
|
skill: ImportedSkill,
|
||||||
|
|
@ -1601,6 +1944,7 @@ export function companySkillService(db: Db) {
|
||||||
updateFile,
|
updateFile,
|
||||||
createLocalSkill,
|
createLocalSkill,
|
||||||
importFromSource,
|
importFromSource,
|
||||||
|
scanProjectWorkspaces,
|
||||||
importPackageFiles,
|
importPackageFiles,
|
||||||
installUpdate,
|
installUpdate,
|
||||||
listRuntimeSkillEntries,
|
listRuntimeSkillEntries,
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,8 @@ import type {
|
||||||
CompanySkillFileDetail,
|
CompanySkillFileDetail,
|
||||||
CompanySkillImportResult,
|
CompanySkillImportResult,
|
||||||
CompanySkillListItem,
|
CompanySkillListItem,
|
||||||
|
CompanySkillProjectScanRequest,
|
||||||
|
CompanySkillProjectScanResult,
|
||||||
CompanySkillUpdateStatus,
|
CompanySkillUpdateStatus,
|
||||||
} from "@paperclipai/shared";
|
} from "@paperclipai/shared";
|
||||||
import { api } from "./client";
|
import { api } from "./client";
|
||||||
|
|
@ -39,6 +41,11 @@ export const companySkillsApi = {
|
||||||
`/companies/${encodeURIComponent(companyId)}/skills/import`,
|
`/companies/${encodeURIComponent(companyId)}/skills/import`,
|
||||||
{ source },
|
{ source },
|
||||||
),
|
),
|
||||||
|
scanProjects: (companyId: string, payload: CompanySkillProjectScanRequest = {}) =>
|
||||||
|
api.post<CompanySkillProjectScanResult>(
|
||||||
|
`/companies/${encodeURIComponent(companyId)}/skills/scan-projects`,
|
||||||
|
payload,
|
||||||
|
),
|
||||||
installUpdate: (companyId: string, skillId: string) =>
|
installUpdate: (companyId: string, skillId: string) =>
|
||||||
api.post<CompanySkill>(
|
api.post<CompanySkill>(
|
||||||
`/companies/${encodeURIComponent(companyId)}/skills/${encodeURIComponent(skillId)}/install-update`,
|
`/companies/${encodeURIComponent(companyId)}/skills/${encodeURIComponent(skillId)}/install-update`,
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue