feat(skills): import skills from projects (#9620)
## Thinking Path > - Paperclip is the open source control plane people use to manage AI-agent companies. > - Company skills make reusable agent behavior discoverable and editable from one place. > - Projects already contain skill directories, but operators had to import each skill path manually. > - Copying those skills would break the desired write-through workflow between Skill Studio and the source project. > - The server therefore needs a safe preview/select/import contract that only accepts rediscovered, workspace-contained candidates. > - The UI needs a guided project picker that explains reference semantics, handles conflicts, and remains usable on mobile. > - This pull request adds that end-to-end project skill import flow with authorization, tenant-scope, traversal, and symlink regression coverage. > - The benefit is faster bulk onboarding while keeping project files as the single source of truth. ## Linked Issues or Issue Description **Feature request** **Problem:** Importing several skills already stored in a Paperclip project requires operators to discover and submit each local path individually. This is slow, hides which well-known directories were searched, and makes conflict/already-imported states difficult to evaluate before mutation. **Proposed solution:** Add an “Import skills from project” flow that previews skills from well-known directories, lets operators selectively import eligible candidates, and stores local-path references so Skill Studio edits write through to the project files. **Alternatives considered:** Copying files into company-managed skill storage was rejected because it creates divergent copies. Trusting client-supplied paths was rejected because imports must be constrained to server-rediscovered, workspace-contained candidates. **Additional context:** GitHub duplicate search found no existing issue or PR for this exact workflow. Refs #3799 for related skill-import inventory behavior; this PR does not claim to close that issue. ## What Changed - Extend `scan-projects` with backward-compatible preview and selective-import modes, typed validation, candidate statuses, and OpenAPI coverage. - Discover project skills under `skills`, `.agents/skills`, `.claude/skills`, `.codex/skills`, `.cursor/skills`, `.opencode/skills`, and `.gemini/skills`. - Re-discover selections server-side, enforce company/project/workspace scope, and reject traversal or symlink escapes before creating `local_path` references. - Add the Skills-page menu entry and responsive project import dialog with project selection, grouped candidates, select all/deselect all, conflicts, empty/error/403 states, and import results. - Add route, service, and component regressions for preview authorization, cross-tenant selections, traversal/symlink safety, selection counts, grouping, and result semantics. ### Screenshots **Choose a project**  **Review discovered skills**  **Mobile selection footer**  **Import result**  ## Verification - `pnpm exec vitest run server/src/__tests__/company-skills-service.test.ts server/src/__tests__/company-skills-routes.test.ts ui/src/pages/skills/ImportSkillsFromProjectDialog.test.tsx` — 3 files, 81 tests passed. - `pnpm check:token-gates` — all token gates clean. - `pnpm --filter @paperclipai/shared typecheck` — passed. - `pnpm --filter @paperclipai/server typecheck` — passed. - `pnpm --filter @paperclipai/ui typecheck` — passed. - Security review passed after adding tenant-scope and unauthorized-preview regressions; UX re-review approved desktop/mobile surfaces; QA passed all seven acceptance areas including write-through editing, deduplication, conflicts, empty state, and permission denial. ## Risks - Files remain referenced in project workspaces, so moving or deleting a source directory can make an imported skill unavailable; the UI explicitly communicates the reference behavior. - New well-known directory scans may discover more candidates than older versions, but preview mode prevents mutation until the operator confirms a selection. - The endpoint remains backward compatible: omitting `mode` preserves the prior full-import behavior. - No schema migration or telemetry event changes. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used - Anthropic Claude Opus 4.8 with tool use/code execution assisted with the UI implementation and UX polish. OpenAI Codex CLI with tool use/code execution assisted with server implementation, security fixes, regression coverage, integration, and PR preparation; the runtime did not expose Codex's exact backing model ID or context-window size. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
9af96461d5
commit
3ae2c30f2f
|
|
@ -127,7 +127,9 @@ describe("same-machine MCP isolation", () => {
|
|||
it("keeps concurrent Claude CLI MCP configs strict and disjoint", async () => {
|
||||
const version = await commandVersion("claude");
|
||||
if (!version) return;
|
||||
expect(version).toBe("2.1.207 (Claude Code)");
|
||||
const claudeVersionMatch = version.match(/^2\.1\.(\d+) \(Claude Code\)$/);
|
||||
expect(claudeVersionMatch).not.toBeNull();
|
||||
expect(Number(claudeVersionMatch?.[1])).toBeGreaterThanOrEqual(207);
|
||||
|
||||
const root = await createMcpIsolationRoot("paperclip-claude-mcp-isolation-");
|
||||
cleanupRoots.push(root);
|
||||
|
|
|
|||
|
|
@ -114,7 +114,7 @@ describeEmbeddedPostgres("resetPostgresDatabase", () => {
|
|||
} finally {
|
||||
await verifySql.end();
|
||||
}
|
||||
});
|
||||
}, 30_000);
|
||||
});
|
||||
|
||||
describeEmbeddedPostgres("applyPendingMigrations", () => {
|
||||
|
|
|
|||
|
|
@ -563,6 +563,8 @@ export type {
|
|||
CompanySkillImportRequest,
|
||||
CompanySkillImportResult,
|
||||
CompanySkillProjectScanRequest,
|
||||
CompanySkillProjectScanCandidateStatus,
|
||||
CompanySkillProjectScanCandidate,
|
||||
CompanySkillProjectScanSkipped,
|
||||
CompanySkillProjectScanConflict,
|
||||
CompanySkillProjectScanResult,
|
||||
|
|
|
|||
|
|
@ -313,11 +313,34 @@ export interface CompanySkillImportResult {
|
|||
export interface CompanySkillProjectScanRequest {
|
||||
projectIds?: string[];
|
||||
workspaceIds?: string[];
|
||||
mode?: "import" | "preview";
|
||||
selection?: Array<{
|
||||
workspaceId: string;
|
||||
path: string;
|
||||
slug?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export type CompanySkillProjectScanCandidateStatus = "new" | "already_imported" | "conflict" | "skipped";
|
||||
|
||||
export interface CompanySkillProjectScanCandidate {
|
||||
slug: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
workspaceId: string;
|
||||
workspaceName: string;
|
||||
projectId: string;
|
||||
projectName: string;
|
||||
directoryRoot: string;
|
||||
relativePath: string;
|
||||
status: CompanySkillProjectScanCandidateStatus;
|
||||
existingSkillId?: string;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export interface CompanySkillProjectScanSkipped {
|
||||
projectId: string;
|
||||
projectName: string;
|
||||
projectId: string | null;
|
||||
projectName: string | null;
|
||||
workspaceId: string | null;
|
||||
workspaceName: string | null;
|
||||
path: string | null;
|
||||
|
|
@ -346,6 +369,7 @@ export interface CompanySkillProjectScanResult {
|
|||
updated: CompanySkill[];
|
||||
skipped: CompanySkillProjectScanSkipped[];
|
||||
conflicts: CompanySkillProjectScanConflict[];
|
||||
candidates: CompanySkillProjectScanCandidate[];
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -127,6 +127,8 @@ export type {
|
|||
CompanySkillImportRequest,
|
||||
CompanySkillImportResult,
|
||||
CompanySkillProjectScanRequest,
|
||||
CompanySkillProjectScanCandidateStatus,
|
||||
CompanySkillProjectScanCandidate,
|
||||
CompanySkillProjectScanSkipped,
|
||||
CompanySkillProjectScanConflict,
|
||||
CompanySkillProjectScanResult,
|
||||
|
|
|
|||
|
|
@ -261,11 +261,32 @@ export const companySkillImportSchema = z.object({
|
|||
export const companySkillProjectScanRequestSchema = z.object({
|
||||
projectIds: z.array(z.string().uuid()).optional(),
|
||||
workspaceIds: z.array(z.string().uuid()).optional(),
|
||||
mode: z.enum(["import", "preview"]).optional(),
|
||||
selection: z.array(z.object({
|
||||
workspaceId: z.string().uuid(),
|
||||
path: z.string().min(1),
|
||||
slug: z.string().min(1).optional(),
|
||||
})).optional(),
|
||||
});
|
||||
|
||||
export const companySkillProjectScanCandidateSchema = z.object({
|
||||
slug: z.string().min(1),
|
||||
name: z.string().min(1),
|
||||
description: z.string().nullable(),
|
||||
workspaceId: z.string().uuid(),
|
||||
workspaceName: z.string().min(1),
|
||||
projectId: z.string().uuid(),
|
||||
projectName: z.string().min(1),
|
||||
directoryRoot: z.string().min(1),
|
||||
relativePath: z.string().min(1),
|
||||
status: z.enum(["new", "already_imported", "conflict", "skipped"]),
|
||||
existingSkillId: z.string().uuid().optional(),
|
||||
reason: z.string().min(1).optional(),
|
||||
});
|
||||
|
||||
export const companySkillProjectScanSkippedSchema = z.object({
|
||||
projectId: z.string().uuid(),
|
||||
projectName: z.string().min(1),
|
||||
projectId: z.string().uuid().nullable(),
|
||||
projectName: z.string().min(1).nullable(),
|
||||
workspaceId: z.string().uuid().nullable(),
|
||||
workspaceName: z.string().nullable(),
|
||||
path: z.string().nullable(),
|
||||
|
|
@ -294,6 +315,7 @@ export const companySkillProjectScanResultSchema = z.object({
|
|||
updated: z.array(companySkillSchema),
|
||||
skipped: z.array(companySkillProjectScanSkippedSchema),
|
||||
conflicts: z.array(companySkillProjectScanConflictSchema),
|
||||
candidates: z.array(companySkillProjectScanCandidateSchema),
|
||||
warnings: z.array(z.string()),
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -233,6 +233,17 @@ describe("company skill mutation permissions", () => {
|
|||
imported: [],
|
||||
warnings: [],
|
||||
});
|
||||
mockCompanySkillService.scanProjectWorkspaces.mockResolvedValue({
|
||||
scannedProjects: 0,
|
||||
scannedWorkspaces: 0,
|
||||
discovered: 0,
|
||||
imported: [],
|
||||
updated: [],
|
||||
skipped: [],
|
||||
conflicts: [],
|
||||
candidates: [],
|
||||
warnings: [],
|
||||
});
|
||||
mockCatalogService.listCatalogSkillsOrEmpty.mockReturnValue([]);
|
||||
mockCompanySkillService.list.mockResolvedValue([]);
|
||||
mockCompanySkillService.categoryCounts.mockResolvedValue([]);
|
||||
|
|
@ -706,6 +717,93 @@ describe("company skill mutation permissions", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("forwards preview and selective scan-projects requests through the existing skill mutation gate", async () => {
|
||||
const workspaceId = "11111111-1111-4111-8111-111111111111";
|
||||
mockCompanySkillService.scanProjectWorkspaces.mockResolvedValue({
|
||||
scannedProjects: 1,
|
||||
scannedWorkspaces: 1,
|
||||
discovered: 1,
|
||||
imported: [],
|
||||
updated: [],
|
||||
skipped: [],
|
||||
conflicts: [],
|
||||
candidates: [{
|
||||
slug: "review",
|
||||
name: "Review",
|
||||
description: null,
|
||||
workspaceId,
|
||||
workspaceName: "Primary",
|
||||
projectId: "22222222-2222-4222-8222-222222222222",
|
||||
projectName: "Paperclip",
|
||||
directoryRoot: ".codex/skills",
|
||||
relativePath: ".codex/skills/review",
|
||||
status: "new",
|
||||
}],
|
||||
warnings: [],
|
||||
});
|
||||
const app = await createApp({
|
||||
type: "board",
|
||||
userId: "local-board",
|
||||
companyIds: ["company-1"],
|
||||
source: "local_implicit",
|
||||
isInstanceAdmin: false,
|
||||
});
|
||||
|
||||
const preview = await request(app)
|
||||
.post("/api/companies/company-1/skills/scan-projects")
|
||||
.send({ mode: "preview", workspaceIds: [workspaceId] });
|
||||
expect(preview.status, JSON.stringify(preview.body)).toBe(200);
|
||||
expect(preview.body.candidates).toHaveLength(1);
|
||||
expect(mockCompanySkillService.scanProjectWorkspaces).toHaveBeenCalledWith("company-1", {
|
||||
mode: "preview",
|
||||
workspaceIds: [workspaceId],
|
||||
});
|
||||
|
||||
const selective = await request(app)
|
||||
.post("/api/companies/company-1/skills/scan-projects")
|
||||
.send({
|
||||
mode: "import",
|
||||
workspaceIds: [workspaceId],
|
||||
selection: [{ workspaceId, path: ".codex/skills/review", slug: "review-project" }],
|
||||
});
|
||||
expect(selective.status, JSON.stringify(selective.body)).toBe(200);
|
||||
expect(mockCompanySkillService.scanProjectWorkspaces).toHaveBeenLastCalledWith("company-1", {
|
||||
mode: "import",
|
||||
workspaceIds: [workspaceId],
|
||||
selection: [{ workspaceId, path: ".codex/skills/review", slug: "review-project" }],
|
||||
});
|
||||
expect(mockAccessService.decide).toHaveBeenCalledWith(expect.objectContaining({
|
||||
action: "skill_config:update",
|
||||
resource: { type: "company", companyId: "company-1" },
|
||||
}));
|
||||
expect(mockLogActivity).toHaveBeenLastCalledWith(expect.anything(), expect.objectContaining({
|
||||
action: "company.skills_scanned",
|
||||
details: expect.objectContaining({ mode: "import", candidateCount: 1 }),
|
||||
}));
|
||||
});
|
||||
|
||||
it("blocks unauthorized preview scan-projects requests before candidate data is returned", async () => {
|
||||
const workspaceId = "11111111-1111-4111-8111-111111111111";
|
||||
mockAccessService.decide.mockResolvedValue(denySkillChangeDecision(
|
||||
"deny_actor_restricted",
|
||||
"Actor is restricted from changing skill configuration.",
|
||||
));
|
||||
|
||||
const res = await request(await createApp({
|
||||
type: "board",
|
||||
userId: "board-user",
|
||||
companyIds: ["company-1"],
|
||||
source: "session",
|
||||
isInstanceAdmin: false,
|
||||
}))
|
||||
.post("/api/companies/company-1/skills/scan-projects")
|
||||
.send({ mode: "preview", workspaceIds: [workspaceId] });
|
||||
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(403);
|
||||
expect(res.body.error).toBe("Actor is restricted from changing skill configuration.");
|
||||
expect(mockCompanySkillService.scanProjectWorkspaces).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("allows board users with skills:create to create, import, install, update, delete, audit, and reset company skills", async () => {
|
||||
const app = await createApp({
|
||||
type: "board",
|
||||
|
|
|
|||
|
|
@ -4,7 +4,16 @@ import path from "node:path";
|
|||
import { promises as fs } from "node:fs";
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { agents, authUsers, companies, companySkillVersions, companySkills, createDb } from "@paperclipai/db";
|
||||
import {
|
||||
agents,
|
||||
authUsers,
|
||||
companies,
|
||||
companySkillVersions,
|
||||
companySkills,
|
||||
createDb,
|
||||
projects,
|
||||
projectWorkspaces,
|
||||
} from "@paperclipai/db";
|
||||
import {
|
||||
getEmbeddedPostgresTestSupport,
|
||||
startEmbeddedPostgresTestDatabase,
|
||||
|
|
@ -52,6 +61,8 @@ describeEmbeddedPostgres("companySkillService.list", () => {
|
|||
afterEach(async () => {
|
||||
await db.delete(agents);
|
||||
await db.delete(companySkills);
|
||||
await db.delete(projectWorkspaces);
|
||||
await db.delete(projects);
|
||||
await db.delete(companies);
|
||||
await db.delete(authUsers);
|
||||
await Promise.all(Array.from(cleanupDirs, (dir) => fs.rm(dir, { recursive: true, force: true })));
|
||||
|
|
@ -1849,4 +1860,471 @@ describeEmbeddedPostgres("companySkillService.list", () => {
|
|||
versions = await svc.listVersions(companyId, skill.id);
|
||||
expect(versions).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("previews project workspace skill candidates without importing them", async () => {
|
||||
const companyId = randomUUID();
|
||||
const projectId = randomUUID();
|
||||
const workspaceId = randomUUID();
|
||||
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-skill-preview-"));
|
||||
cleanupDirs.add(workspaceDir);
|
||||
const codexSkillDir = path.join(workspaceDir, ".codex", "skills", "preview-codex");
|
||||
const cursorSkillDir = path.join(workspaceDir, ".cursor", "skills", "preview-cursor");
|
||||
await fs.mkdir(codexSkillDir, { recursive: true });
|
||||
await fs.mkdir(cursorSkillDir, { recursive: true });
|
||||
await fs.writeFile(path.join(codexSkillDir, "SKILL.md"), "---\nname: Preview Codex\ndescription: Codex candidate\n---\n", "utf8");
|
||||
await fs.writeFile(path.join(cursorSkillDir, "SKILL.md"), "---\nname: Preview Cursor\n---\n", "utf8");
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
name: "Paperclip",
|
||||
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
});
|
||||
await db.insert(projects).values({ id: projectId, companyId, name: "Skills Project" });
|
||||
await db.insert(projectWorkspaces).values({
|
||||
id: workspaceId,
|
||||
companyId,
|
||||
projectId,
|
||||
name: "Primary",
|
||||
cwd: workspaceDir,
|
||||
isPrimary: true,
|
||||
});
|
||||
|
||||
const result = await svc.scanProjectWorkspaces(companyId, { mode: "preview", workspaceIds: [workspaceId] });
|
||||
|
||||
expect(result).toMatchObject({
|
||||
scannedProjects: 1,
|
||||
scannedWorkspaces: 1,
|
||||
discovered: 2,
|
||||
imported: [],
|
||||
updated: [],
|
||||
conflicts: [],
|
||||
});
|
||||
expect(result.candidates).toEqual([
|
||||
expect.objectContaining({
|
||||
name: "Preview Codex",
|
||||
description: "Codex candidate",
|
||||
workspaceId,
|
||||
directoryRoot: ".codex/skills",
|
||||
relativePath: ".codex/skills/preview-codex",
|
||||
status: "new",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
name: "Preview Cursor",
|
||||
workspaceId,
|
||||
directoryRoot: ".cursor/skills",
|
||||
relativePath: ".cursor/skills/preview-cursor",
|
||||
status: "new",
|
||||
}),
|
||||
]);
|
||||
const persisted = await db.select().from(companySkills).where(eq(companySkills.companyId, companyId));
|
||||
expect(persisted.filter((skill) => skill.metadata?.sourceKind === "project_scan")).toEqual([]);
|
||||
});
|
||||
|
||||
it("reports a project skill as already installed when the source path matches", async () => {
|
||||
const companyId = randomUUID();
|
||||
const projectId = randomUUID();
|
||||
const workspaceId = randomUUID();
|
||||
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-skill-same-path-"));
|
||||
cleanupDirs.add(workspaceDir);
|
||||
const skillDir = path.join(workspaceDir, ".codex", "skills", "same-path");
|
||||
await fs.mkdir(skillDir, { recursive: true });
|
||||
await fs.writeFile(path.join(skillDir, "SKILL.md"), "---\nname: Same Path\n---\n", "utf8");
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
name: "Paperclip",
|
||||
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
});
|
||||
await db.insert(projects).values({ id: projectId, companyId, name: "Skills Project" });
|
||||
await db.insert(projectWorkspaces).values({
|
||||
id: workspaceId,
|
||||
companyId,
|
||||
projectId,
|
||||
name: "Primary",
|
||||
cwd: workspaceDir,
|
||||
isPrimary: true,
|
||||
});
|
||||
|
||||
const imported = await svc.scanProjectWorkspaces(companyId, {
|
||||
mode: "import",
|
||||
workspaceIds: [workspaceId],
|
||||
selection: [{ workspaceId, path: ".codex/skills/same-path" }],
|
||||
});
|
||||
expect(imported.imported).toHaveLength(1);
|
||||
|
||||
const preview = await svc.scanProjectWorkspaces(companyId, {
|
||||
mode: "preview",
|
||||
workspaceIds: [workspaceId],
|
||||
});
|
||||
expect(preview.conflicts).toEqual([]);
|
||||
expect(preview.candidates).toEqual([
|
||||
expect.objectContaining({
|
||||
relativePath: ".codex/skills/same-path",
|
||||
status: "already_imported",
|
||||
existingSkillId: imported.imported[0]!.id,
|
||||
reason: "This skill is already installed from the same path.",
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("reports project skills that duplicate built-in slugs as already available", async () => {
|
||||
const companyId = randomUUID();
|
||||
const projectId = randomUUID();
|
||||
const workspaceId = randomUUID();
|
||||
const bundledSkillId = randomUUID();
|
||||
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-skill-built-in-"));
|
||||
const bundledSkillDir = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-skill-bundled-source-"));
|
||||
cleanupDirs.add(workspaceDir);
|
||||
cleanupDirs.add(bundledSkillDir);
|
||||
const skillDir = path.join(workspaceDir, ".claude", "skills", "built-in-review");
|
||||
await fs.mkdir(skillDir, { recursive: true });
|
||||
await fs.writeFile(path.join(skillDir, "SKILL.md"), "---\nname: Built In Review\n---\n", "utf8");
|
||||
await fs.writeFile(path.join(bundledSkillDir, "SKILL.md"), "---\nname: Built In Review\n---\n", "utf8");
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
name: "Paperclip",
|
||||
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
});
|
||||
await db.insert(companySkills).values({
|
||||
id: bundledSkillId,
|
||||
companyId,
|
||||
key: "paperclipai/paperclip/built-in-review",
|
||||
slug: "built-in-review",
|
||||
name: "Built In Review",
|
||||
markdown: "---\nname: Built In Review\n---\n",
|
||||
sourceType: "local_path",
|
||||
sourceLocator: bundledSkillDir,
|
||||
trustLevel: "markdown_only",
|
||||
compatibility: "compatible",
|
||||
fileInventory: [{ path: "SKILL.md", kind: "skill" }],
|
||||
metadata: { sourceKind: "paperclip_bundled" },
|
||||
});
|
||||
await db.insert(projects).values({ id: projectId, companyId, name: "Skills Project" });
|
||||
await db.insert(projectWorkspaces).values({
|
||||
id: workspaceId,
|
||||
companyId,
|
||||
projectId,
|
||||
name: "Primary",
|
||||
cwd: workspaceDir,
|
||||
isPrimary: true,
|
||||
});
|
||||
|
||||
const preview = await svc.scanProjectWorkspaces(companyId, {
|
||||
mode: "preview",
|
||||
workspaceIds: [workspaceId],
|
||||
});
|
||||
|
||||
expect(preview.conflicts).toEqual([]);
|
||||
expect(preview.candidates).toEqual([
|
||||
expect.objectContaining({
|
||||
slug: "built-in-review",
|
||||
status: "already_imported",
|
||||
existingSkillId: bundledSkillId,
|
||||
reason: "This skill is already available as a built-in.",
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("imports a conflicting project skill under a selected replacement slug", async () => {
|
||||
const companyId = randomUUID();
|
||||
const projectId = randomUUID();
|
||||
const workspaceId = randomUUID();
|
||||
const existingSkillId = randomUUID();
|
||||
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-skill-rename-"));
|
||||
const existingSkillDir = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-skill-existing-"));
|
||||
cleanupDirs.add(workspaceDir);
|
||||
cleanupDirs.add(existingSkillDir);
|
||||
const skillDir = path.join(workspaceDir, ".cursor", "skills", "shared-skill");
|
||||
await fs.mkdir(skillDir, { recursive: true });
|
||||
await fs.writeFile(path.join(skillDir, "SKILL.md"), "---\nname: Shared Skill\n---\n", "utf8");
|
||||
await fs.writeFile(path.join(existingSkillDir, "SKILL.md"), "---\nname: Shared Skill\n---\n", "utf8");
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
name: "Paperclip",
|
||||
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
});
|
||||
await db.insert(companySkills).values({
|
||||
id: existingSkillId,
|
||||
companyId,
|
||||
key: "local/existing/shared-skill",
|
||||
slug: "shared-skill",
|
||||
name: "Shared Skill",
|
||||
markdown: "---\nname: Shared Skill\n---\n",
|
||||
sourceType: "local_path",
|
||||
sourceLocator: existingSkillDir,
|
||||
trustLevel: "markdown_only",
|
||||
compatibility: "compatible",
|
||||
fileInventory: [{ path: "SKILL.md", kind: "skill" }],
|
||||
metadata: { sourceKind: "local_path" },
|
||||
});
|
||||
await db.insert(projects).values({ id: projectId, companyId, name: "Skills Project" });
|
||||
await db.insert(projectWorkspaces).values({
|
||||
id: workspaceId,
|
||||
companyId,
|
||||
projectId,
|
||||
name: "Primary",
|
||||
cwd: workspaceDir,
|
||||
isPrimary: true,
|
||||
});
|
||||
|
||||
const preview = await svc.scanProjectWorkspaces(companyId, {
|
||||
mode: "preview",
|
||||
workspaceIds: [workspaceId],
|
||||
});
|
||||
expect(preview.candidates).toEqual([
|
||||
expect.objectContaining({ slug: "shared-skill", status: "conflict", existingSkillId }),
|
||||
]);
|
||||
|
||||
const result = await svc.scanProjectWorkspaces(companyId, {
|
||||
mode: "import",
|
||||
workspaceIds: [workspaceId],
|
||||
selection: [{
|
||||
workspaceId,
|
||||
path: ".cursor/skills/shared-skill",
|
||||
slug: "shared-skill-project",
|
||||
}],
|
||||
});
|
||||
|
||||
expect(result.conflicts).toEqual([]);
|
||||
expect(result.imported).toEqual([
|
||||
expect.objectContaining({
|
||||
slug: "shared-skill-project",
|
||||
key: expect.stringMatching(/^local\/[a-f0-9]+\/shared-skill-project$/),
|
||||
sourceLocator: skillDir,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("imports only selections rediscovered inside project workspaces", async () => {
|
||||
const companyId = randomUUID();
|
||||
const projectId = randomUUID();
|
||||
const workspaceId = randomUUID();
|
||||
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-skill-selective-"));
|
||||
cleanupDirs.add(workspaceDir);
|
||||
const selectedSkillDir = path.join(workspaceDir, ".gemini", "skills", "selected-skill");
|
||||
const ignoredSkillDir = path.join(workspaceDir, ".opencode", "skills", "ignored-skill");
|
||||
const ignoredLinkedSkillDir = path.join(workspaceDir, ".claude", "skills", "ignored-link");
|
||||
const outsideSkillFile = path.join(
|
||||
await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-skill-selective-outside-")),
|
||||
"SKILL.md",
|
||||
);
|
||||
cleanupDirs.add(path.dirname(outsideSkillFile));
|
||||
await fs.mkdir(selectedSkillDir, { recursive: true });
|
||||
await fs.mkdir(ignoredSkillDir, { recursive: true });
|
||||
await fs.mkdir(ignoredLinkedSkillDir, { recursive: true });
|
||||
await fs.writeFile(path.join(selectedSkillDir, "SKILL.md"), "---\nname: Selected Skill\n---\n", "utf8");
|
||||
await fs.writeFile(path.join(ignoredSkillDir, "SKILL.md"), "---\nname: Ignored Skill\n---\n", "utf8");
|
||||
await fs.writeFile(outsideSkillFile, "---\nname: Ignored Linked Skill\n---\n", "utf8");
|
||||
await fs.symlink(outsideSkillFile, path.join(ignoredLinkedSkillDir, "SKILL.md"));
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
name: "Paperclip",
|
||||
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
});
|
||||
await db.insert(projects).values({ id: projectId, companyId, name: "Skills Project" });
|
||||
await db.insert(projectWorkspaces).values({
|
||||
id: workspaceId,
|
||||
companyId,
|
||||
projectId,
|
||||
name: "Primary",
|
||||
cwd: workspaceDir,
|
||||
isPrimary: true,
|
||||
});
|
||||
|
||||
const result = await svc.scanProjectWorkspaces(companyId, {
|
||||
mode: "import",
|
||||
workspaceIds: [workspaceId],
|
||||
selection: [
|
||||
{ workspaceId, path: ".gemini/skills/selected-skill" },
|
||||
{ workspaceId, path: "../../outside-workspace" },
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.imported).toHaveLength(1);
|
||||
expect(result.imported[0]).toMatchObject({
|
||||
name: "Selected Skill",
|
||||
sourceType: "local_path",
|
||||
sourceLocator: selectedSkillDir,
|
||||
metadata: expect.objectContaining({ sourceKind: "project_scan", workspaceId, projectId }),
|
||||
});
|
||||
expect(result.candidates).toEqual([
|
||||
expect.objectContaining({ relativePath: ".gemini/skills/selected-skill", status: "new" }),
|
||||
]);
|
||||
expect(result.warnings.join("\n")).not.toContain("symbolic link");
|
||||
expect(result.skipped).not.toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ reason: expect.stringContaining("symbolic link") }),
|
||||
]),
|
||||
);
|
||||
expect(result.skipped).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
workspaceId,
|
||||
path: "../../outside-workspace",
|
||||
reason: expect.stringContaining("was not rediscovered"),
|
||||
}),
|
||||
]));
|
||||
const persisted = await db.select().from(companySkills).where(eq(companySkills.companyId, companyId));
|
||||
const projectScanSkills = persisted.filter((skill) => skill.metadata?.sourceKind === "project_scan");
|
||||
expect(projectScanSkills).toHaveLength(1);
|
||||
expect(projectScanSkills[0]?.sourceLocator).toBe(selectedSkillDir);
|
||||
});
|
||||
|
||||
it("treats out-of-scope workspace selections as unmatched without leaking workspace metadata", async () => {
|
||||
const companyId = randomUUID();
|
||||
const projectId = randomUUID();
|
||||
const workspaceId = randomUUID();
|
||||
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-skill-scope-"));
|
||||
const otherCompanyId = randomUUID();
|
||||
const otherProjectId = randomUUID();
|
||||
const otherWorkspaceId = randomUUID();
|
||||
const otherWorkspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-skill-scope-other-"));
|
||||
cleanupDirs.add(workspaceDir);
|
||||
cleanupDirs.add(otherWorkspaceDir);
|
||||
|
||||
const selectedSkillDir = path.join(workspaceDir, ".gemini", "skills", "selected-skill");
|
||||
const otherCompanySkillDir = path.join(otherWorkspaceDir, ".codex", "skills", "foreign-skill");
|
||||
await fs.mkdir(selectedSkillDir, { recursive: true });
|
||||
await fs.mkdir(otherCompanySkillDir, { recursive: true });
|
||||
await fs.writeFile(path.join(selectedSkillDir, "SKILL.md"), "---\nname: Selected Skill\n---\n", "utf8");
|
||||
await fs.writeFile(path.join(otherCompanySkillDir, "SKILL.md"), "---\nname: Foreign Skill\n---\n", "utf8");
|
||||
|
||||
await db.insert(companies).values([
|
||||
{
|
||||
id: companyId,
|
||||
name: "Paperclip",
|
||||
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
},
|
||||
{
|
||||
id: otherCompanyId,
|
||||
name: "Other Company",
|
||||
issuePrefix: `T${otherCompanyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
},
|
||||
]);
|
||||
await db.insert(projects).values([
|
||||
{ id: projectId, companyId, name: "Skills Project" },
|
||||
{ id: otherProjectId, companyId: otherCompanyId, name: "Other Project" },
|
||||
]);
|
||||
await db.insert(projectWorkspaces).values([
|
||||
{
|
||||
id: workspaceId,
|
||||
companyId,
|
||||
projectId,
|
||||
name: "Primary",
|
||||
cwd: workspaceDir,
|
||||
isPrimary: true,
|
||||
},
|
||||
{
|
||||
id: otherWorkspaceId,
|
||||
companyId: otherCompanyId,
|
||||
projectId: otherProjectId,
|
||||
name: "Other Primary",
|
||||
cwd: otherWorkspaceDir,
|
||||
isPrimary: true,
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await svc.scanProjectWorkspaces(companyId, {
|
||||
mode: "import",
|
||||
projectIds: [projectId],
|
||||
workspaceIds: [workspaceId, otherWorkspaceId],
|
||||
selection: [
|
||||
{ workspaceId, path: ".gemini/skills/selected-skill" },
|
||||
{ workspaceId: otherWorkspaceId, path: ".codex/skills/foreign-skill" },
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.scannedProjects).toBe(1);
|
||||
expect(result.scannedWorkspaces).toBe(1);
|
||||
expect(result.discovered).toBe(1);
|
||||
expect(result.imported).toHaveLength(1);
|
||||
expect(result.imported[0]).toMatchObject({
|
||||
name: "Selected Skill",
|
||||
sourceType: "local_path",
|
||||
sourceLocator: selectedSkillDir,
|
||||
metadata: expect.objectContaining({ sourceKind: "project_scan", workspaceId, projectId }),
|
||||
});
|
||||
expect(result.candidates).toEqual([
|
||||
expect.objectContaining({
|
||||
workspaceId,
|
||||
projectId,
|
||||
relativePath: ".gemini/skills/selected-skill",
|
||||
status: "new",
|
||||
}),
|
||||
]);
|
||||
expect(result.skipped).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
projectId: null,
|
||||
projectName: null,
|
||||
workspaceId: otherWorkspaceId,
|
||||
workspaceName: null,
|
||||
path: ".codex/skills/foreign-skill",
|
||||
reason: expect.stringContaining("was not rediscovered"),
|
||||
}),
|
||||
]));
|
||||
});
|
||||
|
||||
it("skips a selected project skill whose SKILL.md is a symlink outside the workspace", async () => {
|
||||
const companyId = randomUUID();
|
||||
const projectId = randomUUID();
|
||||
const workspaceId = randomUUID();
|
||||
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-skill-symlink-"));
|
||||
const outsideDir = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-skill-outside-"));
|
||||
cleanupDirs.add(workspaceDir);
|
||||
cleanupDirs.add(outsideDir);
|
||||
const linkedSkillDir = path.join(workspaceDir, ".codex", "skills", "linked-skill");
|
||||
const outsideSkillFile = path.join(outsideDir, "outside-skill.md");
|
||||
await fs.mkdir(linkedSkillDir, { recursive: true });
|
||||
await fs.writeFile(outsideSkillFile, "---\nname: Outside Skill\n---\n", "utf8");
|
||||
await fs.symlink(outsideSkillFile, path.join(linkedSkillDir, "SKILL.md"));
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
name: "Paperclip",
|
||||
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
});
|
||||
await db.insert(projects).values({ id: projectId, companyId, name: "Skills Project" });
|
||||
await db.insert(projectWorkspaces).values({
|
||||
id: workspaceId,
|
||||
companyId,
|
||||
projectId,
|
||||
name: "Primary",
|
||||
cwd: workspaceDir,
|
||||
isPrimary: true,
|
||||
});
|
||||
|
||||
const result = await svc.scanProjectWorkspaces(companyId, {
|
||||
mode: "import",
|
||||
workspaceIds: [workspaceId],
|
||||
selection: [{ workspaceId, path: ".codex/skills/linked-skill" }],
|
||||
});
|
||||
|
||||
expect(result.imported).toEqual([]);
|
||||
expect(result.candidates).toEqual([
|
||||
expect.objectContaining({
|
||||
relativePath: ".codex/skills/linked-skill",
|
||||
status: "skipped",
|
||||
reason: expect.stringContaining("symbolic link"),
|
||||
}),
|
||||
]);
|
||||
expect(result.skipped).toEqual([
|
||||
expect.objectContaining({
|
||||
workspaceId,
|
||||
path: linkedSkillDir,
|
||||
reason: expect.stringContaining("symbolic link"),
|
||||
}),
|
||||
]);
|
||||
expect(result.candidates[0]?.reason).not.toContain(workspaceDir);
|
||||
expect(result.candidates[0]?.reason).not.toContain(outsideDir);
|
||||
expect(result.skipped[0]?.reason).not.toContain(workspaceDir);
|
||||
expect(result.skipped[0]?.reason).not.toContain(outsideDir);
|
||||
expect(result.warnings.join("\n")).not.toContain(workspaceDir);
|
||||
expect(result.warnings.join("\n")).not.toContain(outsideDir);
|
||||
const persisted = await db.select().from(companySkills).where(eq(companySkills.companyId, companyId));
|
||||
expect(persisted.filter((skill) => skill.metadata?.sourceKind === "project_scan")).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -141,10 +141,25 @@ describe("project workspace skill discovery", () => {
|
|||
});
|
||||
|
||||
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" },
|
||||
{ skillDir: path.resolve(workspace), directoryRoot: ".", relativePath: ".", inventoryMode: "project_root" },
|
||||
{
|
||||
skillDir: path.resolve(workspace, ".agents", "skills", "release"),
|
||||
directoryRoot: ".agents/skills",
|
||||
relativePath: ".agents/skills/release",
|
||||
inventoryMode: "full",
|
||||
},
|
||||
{
|
||||
skillDir: path.resolve(workspace, "skills", ".system", "paperclip"),
|
||||
directoryRoot: "skills/.system",
|
||||
relativePath: "skills/.system/paperclip",
|
||||
inventoryMode: "full",
|
||||
},
|
||||
{
|
||||
skillDir: path.resolve(workspace, "skills", "find-skills"),
|
||||
directoryRoot: "skills",
|
||||
relativePath: "skills/find-skills",
|
||||
inventoryMode: "full",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
|
|
@ -177,6 +192,22 @@ describe("project workspace skill discovery", () => {
|
|||
expect(imported.metadata?.sourceKind).toBe("project_scan");
|
||||
});
|
||||
|
||||
it("rejects symlinks reachable from a project-scanned skill", async () => {
|
||||
const workspace = await makeTempDir("paperclip-linked-skill-file-");
|
||||
const skillDir = path.join(workspace, ".codex", "skills", "linked-file");
|
||||
const outsideFile = path.join(await makeTempDir("paperclip-linked-skill-outside-"), "outside.md");
|
||||
await writeSkillDir(skillDir, "Linked File");
|
||||
await fs.mkdir(path.join(skillDir, "references"), { recursive: true });
|
||||
await fs.writeFile(outsideFile, "outside workspace\n", "utf8");
|
||||
await fs.symlink(outsideFile, path.join(skillDir, "references", "outside.md"));
|
||||
|
||||
await expect(readLocalSkillImportFromDirectory(
|
||||
"33333333-3333-4333-8333-333333333333",
|
||||
skillDir,
|
||||
{ inventoryMode: "full", workspaceRoot: workspace },
|
||||
)).rejects.toThrow(/symbolic link/);
|
||||
});
|
||||
|
||||
it("parses inline object array items in skill frontmatter metadata", async () => {
|
||||
const workspace = await makeTempDir("paperclip-inline-skill-yaml-");
|
||||
await fs.mkdir(workspace, { recursive: true });
|
||||
|
|
|
|||
|
|
@ -114,7 +114,7 @@ describeEmbeddedPostgres("heartbeat responsible-user invariant", () => {
|
|||
|
||||
afterAll(async () => {
|
||||
await tempDb?.cleanup();
|
||||
});
|
||||
}, 60_000);
|
||||
|
||||
async function seedCompany() {
|
||||
const companyId = randomUUID();
|
||||
|
|
|
|||
|
|
@ -917,7 +917,7 @@ describeEmbeddedPostgres("heartbeat workspace branch containment", () => {
|
|||
afterAll(async () => {
|
||||
await db.$client.end();
|
||||
await tempDb?.cleanup();
|
||||
});
|
||||
}, 60_000);
|
||||
|
||||
it("blocks projectless isolated git-worktree issues before dispatch", async () => {
|
||||
const companyId = randomUUID();
|
||||
|
|
|
|||
|
|
@ -165,6 +165,16 @@ describe("openapi routes", () => {
|
|||
},
|
||||
required: ["name"],
|
||||
});
|
||||
expect(JSON.stringify(res.body.paths["/api/companies"].post.responses)).not.toContain("candidates");
|
||||
expect(res.body.paths["/api/companies/{companyId}/skills/scan-projects"].post.responses["200"].content[
|
||||
"application/json"
|
||||
].schema).toMatchObject({
|
||||
type: "object",
|
||||
properties: {
|
||||
candidates: { type: "array" },
|
||||
},
|
||||
required: expect.arrayContaining(["candidates"]),
|
||||
});
|
||||
expect(res.body.paths["/api/agents/{id}/keys"].post.requestBody.content["application/json"].schema).toMatchObject({
|
||||
type: "object",
|
||||
properties: {
|
||||
|
|
|
|||
|
|
@ -299,7 +299,7 @@ describeEmbeddedPostgres("plugin install auto-build route", () => {
|
|||
|
||||
afterAll(async () => {
|
||||
await tempDb?.cleanup();
|
||||
});
|
||||
}, 30_000);
|
||||
|
||||
it("auto-builds bundled local plugins during POST /api/plugins/install when dist is missing", async () => {
|
||||
const fixture = await createBundledPluginFixture("success");
|
||||
|
|
|
|||
|
|
@ -48,7 +48,7 @@ describeEmbeddedPostgres("productivity review service", () => {
|
|||
|
||||
afterAll(async () => {
|
||||
await tempDb?.cleanup();
|
||||
});
|
||||
}, 30_000);
|
||||
|
||||
async function seedAssignedIssue(opts?: {
|
||||
status?: "todo" | "in_progress";
|
||||
|
|
|
|||
|
|
@ -1145,9 +1145,11 @@ export function companySkillRoutes(db: Db) {
|
|||
entityType: "company",
|
||||
entityId: companyId,
|
||||
details: {
|
||||
mode: req.body.mode ?? "import",
|
||||
scannedProjects: result.scannedProjects,
|
||||
scannedWorkspaces: result.scannedWorkspaces,
|
||||
discovered: result.discovered,
|
||||
candidateCount: result.candidates.length,
|
||||
importedCount: result.imported.length,
|
||||
updatedCount: result.updated.length,
|
||||
conflictCount: result.conflicts.length,
|
||||
|
|
|
|||
|
|
@ -95,6 +95,7 @@ import {
|
|||
companySkillFileUpdateSchema,
|
||||
companySkillImportSchema,
|
||||
companySkillProjectScanRequestSchema,
|
||||
companySkillProjectScanResultSchema,
|
||||
companySkillTestInputCreateSchema,
|
||||
companySkillTestInputUpdateSchema,
|
||||
companySkillTestRunCreateSchema,
|
||||
|
|
@ -3958,7 +3959,7 @@ registry.registerPath({
|
|||
params: z.object({ companyId: z.string() }),
|
||||
body: jsonBody(companySkillProjectScanRequestSchema),
|
||||
},
|
||||
responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized },
|
||||
responses: { 200: r.ok(companySkillProjectScanResultSchema), 400: r.badRequest, 401: r.unauthorized },
|
||||
});
|
||||
|
||||
registry.registerPath({
|
||||
|
|
|
|||
|
|
@ -57,6 +57,7 @@ import type {
|
|||
CompanySkillLastEditor,
|
||||
CompanySkillOriginalSummary,
|
||||
CompanySkillProjectScanConflict,
|
||||
CompanySkillProjectScanCandidate,
|
||||
CompanySkillProjectScanRequest,
|
||||
CompanySkillProjectScanResult,
|
||||
CompanySkillProjectScanSkipped,
|
||||
|
|
@ -415,13 +416,16 @@ const PROJECT_SCAN_DIRECTORY_ROOTS = [
|
|||
".agent/skills",
|
||||
".augment/skills",
|
||||
".claude/skills",
|
||||
".codex/skills",
|
||||
".codebuddy/skills",
|
||||
".commandcode/skills",
|
||||
".continue/skills",
|
||||
".cursor/skills",
|
||||
".cortex/skills",
|
||||
".crush/skills",
|
||||
".factory/skills",
|
||||
".goose/skills",
|
||||
".gemini/skills",
|
||||
".junie/skills",
|
||||
".iflow/skills",
|
||||
".kilocode/skills",
|
||||
|
|
@ -431,6 +435,7 @@ const PROJECT_SCAN_DIRECTORY_ROOTS = [
|
|||
".vibe/skills",
|
||||
".mux/skills",
|
||||
".openhands/skills",
|
||||
".opencode/skills",
|
||||
".pi/skills",
|
||||
".qoder/skills",
|
||||
".qwen/skills",
|
||||
|
|
@ -1034,6 +1039,93 @@ async function statPath(targetPath: string) {
|
|||
return fs.stat(targetPath).catch(() => null);
|
||||
}
|
||||
|
||||
function pathIsContained(rootPath: string, candidatePath: string) {
|
||||
const relativePath = path.relative(rootPath, candidatePath);
|
||||
return relativePath === ""
|
||||
|| (!path.isAbsolute(relativePath) && relativePath !== ".." && !relativePath.startsWith(`..${path.sep}`));
|
||||
}
|
||||
|
||||
async function assertNoSymlinksInLocalTree(currentPath: string): Promise<void> {
|
||||
const entries = await fs.readdir(currentPath, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
if (entry.name === ".git" || entry.name === "node_modules") continue;
|
||||
const absolutePath = path.join(currentPath, entry.name);
|
||||
if (entry.isSymbolicLink()) {
|
||||
throw unprocessable(`Project skill candidate contains a symbolic link at ${absolutePath}.`);
|
||||
}
|
||||
if (entry.isDirectory()) {
|
||||
await assertNoSymlinksInLocalTree(absolutePath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function validateProjectSkillImportPath(
|
||||
skillDir: string,
|
||||
workspaceRoot: string,
|
||||
inventoryMode: LocalSkillInventoryMode,
|
||||
) {
|
||||
const resolvedWorkspaceRoot = path.resolve(workspaceRoot);
|
||||
const resolvedSkillDir = path.resolve(skillDir);
|
||||
if (!pathIsContained(resolvedWorkspaceRoot, resolvedSkillDir)) {
|
||||
throw unprocessable(`Project skill candidate ${resolvedSkillDir} is outside workspace root ${resolvedWorkspaceRoot}.`);
|
||||
}
|
||||
|
||||
const canonicalWorkspaceRoot = await fs.realpath(resolvedWorkspaceRoot);
|
||||
let currentPath = resolvedWorkspaceRoot;
|
||||
const relativeSkillDir = path.relative(resolvedWorkspaceRoot, resolvedSkillDir);
|
||||
for (const segment of relativeSkillDir.split(path.sep).filter(Boolean)) {
|
||||
currentPath = path.join(currentPath, segment);
|
||||
const segmentStat = await fs.lstat(currentPath);
|
||||
if (segmentStat.isSymbolicLink()) {
|
||||
throw unprocessable(`Project skill candidate contains a symbolic link at ${currentPath}.`);
|
||||
}
|
||||
}
|
||||
|
||||
const canonicalSkillDir = await fs.realpath(resolvedSkillDir);
|
||||
if (!pathIsContained(canonicalWorkspaceRoot, canonicalSkillDir)) {
|
||||
throw unprocessable(`Project skill candidate ${resolvedSkillDir} resolves outside workspace root ${resolvedWorkspaceRoot}.`);
|
||||
}
|
||||
|
||||
const skillFilePath = path.join(resolvedSkillDir, "SKILL.md");
|
||||
const skillFileStat = await fs.lstat(skillFilePath);
|
||||
if (skillFileStat.isSymbolicLink()) {
|
||||
throw unprocessable(`Project skill candidate contains a symbolic link at ${skillFilePath}.`);
|
||||
}
|
||||
if (!skillFileStat.isFile()) {
|
||||
throw unprocessable(`No SKILL.md file was found in ${resolvedSkillDir}.`);
|
||||
}
|
||||
const canonicalSkillFilePath = await fs.realpath(skillFilePath);
|
||||
if (!pathIsContained(canonicalWorkspaceRoot, canonicalSkillFilePath)) {
|
||||
throw unprocessable(`Project skill file ${skillFilePath} resolves outside workspace root ${resolvedWorkspaceRoot}.`);
|
||||
}
|
||||
|
||||
if (inventoryMode === "full") {
|
||||
await assertNoSymlinksInLocalTree(resolvedSkillDir);
|
||||
return;
|
||||
}
|
||||
for (const relativeDir of PROJECT_ROOT_SKILL_SUBDIRECTORIES) {
|
||||
const absoluteDir = path.join(resolvedSkillDir, relativeDir);
|
||||
const dirStat = await fs.lstat(absoluteDir).catch(() => null);
|
||||
if (!dirStat) continue;
|
||||
if (dirStat.isSymbolicLink()) {
|
||||
throw unprocessable(`Project skill candidate contains a symbolic link at ${absoluteDir}.`);
|
||||
}
|
||||
if (dirStat.isDirectory()) {
|
||||
await assertNoSymlinksInLocalTree(absoluteDir);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function projectSkillImportFailureReason(error: unknown) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
if (message.includes("symbolic link")) {
|
||||
return "Skipped because symbolic links can point outside the project workspace. Replace the link with a real file or directory to import this skill.";
|
||||
}
|
||||
if (message.includes("outside workspace root")) return "Project skill candidate resolves outside the workspace.";
|
||||
if (message.includes("No SKILL.md file")) return "Project skill candidate does not contain a readable SKILL.md file.";
|
||||
return "Project skill candidate could not be read.";
|
||||
}
|
||||
|
||||
async function collectLocalSkillInventory(
|
||||
skillDir: string,
|
||||
mode: LocalSkillInventoryMode = "full",
|
||||
|
|
@ -1190,9 +1282,14 @@ export async function readLocalSkillImportFromDirectory(
|
|||
options?: {
|
||||
inventoryMode?: LocalSkillInventoryMode;
|
||||
metadata?: Record<string, unknown> | null;
|
||||
workspaceRoot?: string;
|
||||
},
|
||||
): Promise<ImportedSkill> {
|
||||
const resolvedSkillDir = path.resolve(skillDir);
|
||||
const inventoryMode = options?.inventoryMode ?? "full";
|
||||
if (options?.workspaceRoot) {
|
||||
await validateProjectSkillImportPath(resolvedSkillDir, options.workspaceRoot, inventoryMode);
|
||||
}
|
||||
const skillFilePath = path.join(resolvedSkillDir, "SKILL.md");
|
||||
const markdown = await fs.readFile(skillFilePath, "utf8");
|
||||
const parsed = parseFrontmatterMarkdown(markdown);
|
||||
|
|
@ -1205,7 +1302,7 @@ export async function readLocalSkillImportFromDirectory(
|
|||
sourceKind: "local_path",
|
||||
...(options?.metadata ?? {}),
|
||||
};
|
||||
const inventory = await collectLocalSkillInventory(resolvedSkillDir, options?.inventoryMode ?? "full");
|
||||
const inventory = await collectLocalSkillInventory(resolvedSkillDir, inventoryMode);
|
||||
|
||||
return {
|
||||
key: deriveCanonicalSkillKey(companyId, {
|
||||
|
|
@ -1231,12 +1328,22 @@ export async function readLocalSkillImportFromDirectory(
|
|||
|
||||
export async function discoverProjectWorkspaceSkillDirectories(target: ProjectSkillScanTarget): Promise<Array<{
|
||||
skillDir: string;
|
||||
directoryRoot: string;
|
||||
relativePath: string;
|
||||
inventoryMode: LocalSkillInventoryMode;
|
||||
}>> {
|
||||
const discovered = new Map<string, LocalSkillInventoryMode>();
|
||||
const discovered = new Map<string, {
|
||||
directoryRoot: string;
|
||||
relativePath: string;
|
||||
inventoryMode: LocalSkillInventoryMode;
|
||||
}>();
|
||||
const rootSkillPath = path.join(target.workspaceCwd, "SKILL.md");
|
||||
if ((await statPath(rootSkillPath))?.isFile()) {
|
||||
discovered.set(path.resolve(target.workspaceCwd), "project_root");
|
||||
discovered.set(path.resolve(target.workspaceCwd), {
|
||||
directoryRoot: ".",
|
||||
relativePath: ".",
|
||||
inventoryMode: "project_root",
|
||||
});
|
||||
}
|
||||
|
||||
for (const relativeRoot of PROJECT_SCAN_DIRECTORY_ROOTS) {
|
||||
|
|
@ -1246,18 +1353,36 @@ export async function discoverProjectWorkspaceSkillDirectories(target: ProjectSk
|
|||
|
||||
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);
|
||||
const entryStat = entry.isSymbolicLink() ? await statPath(absoluteSkillDir) : null;
|
||||
if (!entry.isDirectory() && !entryStat?.isDirectory()) continue;
|
||||
if (!(await statPath(path.join(absoluteSkillDir, "SKILL.md")))?.isFile()) continue;
|
||||
discovered.set(absoluteSkillDir, "full");
|
||||
discovered.set(absoluteSkillDir, {
|
||||
directoryRoot: relativeRoot,
|
||||
relativePath: normalizePortablePath(path.relative(target.workspaceCwd, absoluteSkillDir)),
|
||||
inventoryMode: "full",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(discovered.entries())
|
||||
.map(([skillDir, inventoryMode]) => ({ skillDir, inventoryMode }))
|
||||
.map(([skillDir, details]) => ({ skillDir, ...details }))
|
||||
.sort((left, right) => left.skillDir.localeCompare(right.skillDir));
|
||||
}
|
||||
|
||||
function normalizeProjectScanSelectionPath(value: string): string | null {
|
||||
const trimmed = value.trim().replace(/\\/g, "/");
|
||||
if (trimmed === ".") return ".";
|
||||
if (!trimmed || trimmed.startsWith("/") || /^[A-Za-z]:\//.test(trimmed)) return null;
|
||||
const segments = trimmed.split("/").filter(Boolean);
|
||||
if (segments.length === 0 || segments.some((segment) => segment === "." || segment === "..")) return null;
|
||||
return segments.join("/");
|
||||
}
|
||||
|
||||
function projectScanSelectionKey(workspaceId: string, relativePath: string) {
|
||||
return `${workspaceId}\u0000${relativePath}`;
|
||||
}
|
||||
|
||||
async function readLocalSkillImports(companyId: string, sourcePath: string): Promise<ImportedSkill[]> {
|
||||
const resolvedPath = path.resolve(sourcePath);
|
||||
const stat = await fs.stat(resolvedPath).catch(() => null);
|
||||
|
|
@ -4251,12 +4376,15 @@ export function companySkillService(db: Db) {
|
|||
input: CompanySkillProjectScanRequest = {},
|
||||
): Promise<CompanySkillProjectScanResult> {
|
||||
await ensureSkillInventoryCurrent(companyId);
|
||||
const mode = input.mode ?? "import";
|
||||
const selectiveImport = mode === "import" && input.selection !== undefined;
|
||||
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 candidates: CompanySkillProjectScanCandidate[] = [];
|
||||
const warnings: string[] = [];
|
||||
const imported: CompanySkill[] = [];
|
||||
const updated: CompanySkill[] = [];
|
||||
|
|
@ -4264,9 +4392,41 @@ export function companySkillService(db: Db) {
|
|||
const acceptedSkills = [...availableSkills];
|
||||
const acceptedByKey = new Map(acceptedSkills.map((skill) => [skill.key, skill]));
|
||||
const scanTargets: ProjectSkillScanTarget[] = [];
|
||||
const workspaceContexts = new Map<string, {
|
||||
projectId: string;
|
||||
projectName: string;
|
||||
workspaceId: string;
|
||||
workspaceName: string;
|
||||
}>();
|
||||
const selectedPaths = new Map<string, { workspaceId: string; path: string; slug?: string }>();
|
||||
const invalidSelections: Array<{ workspaceId: string; path: string; slug?: string }> = [];
|
||||
const rediscoveredSelections = new Set<string>();
|
||||
const scannedProjectIds = new Set<string>();
|
||||
let discovered = 0;
|
||||
|
||||
for (const selection of input.selection ?? []) {
|
||||
const normalizedPath = normalizeProjectScanSelectionPath(selection.path);
|
||||
if (!normalizedPath) {
|
||||
invalidSelections.push(selection);
|
||||
continue;
|
||||
}
|
||||
const renamedSlug = selection.slug === undefined
|
||||
? undefined
|
||||
: normalizeSkillSlug(selection.slug);
|
||||
if (selection.slug !== undefined && !renamedSlug) {
|
||||
invalidSelections.push(selection);
|
||||
continue;
|
||||
}
|
||||
selectedPaths.set(projectScanSelectionKey(selection.workspaceId, normalizedPath), {
|
||||
workspaceId: selection.workspaceId,
|
||||
path: normalizedPath,
|
||||
...(renamedSlug ? { slug: renamedSlug } : {}),
|
||||
});
|
||||
}
|
||||
const selectedWorkspaceIds = new Set(
|
||||
Array.from(selectedPaths.values()).map((selection) => selection.workspaceId),
|
||||
);
|
||||
|
||||
const trackWarning = (message: string) => {
|
||||
warnings.push(message);
|
||||
return message;
|
||||
|
|
@ -4280,7 +4440,14 @@ export function companySkillService(db: Db) {
|
|||
|
||||
for (const project of projectRows) {
|
||||
for (const workspace of project.workspaces) {
|
||||
workspaceContexts.set(workspace.id, {
|
||||
projectId: project.id,
|
||||
projectName: project.name,
|
||||
workspaceId: workspace.id,
|
||||
workspaceName: workspace.name,
|
||||
});
|
||||
if (workspaceFilter.size > 0 && !workspaceFilter.has(workspace.id)) continue;
|
||||
if (selectiveImport && !selectedWorkspaceIds.has(workspace.id)) continue;
|
||||
const workspaceCwd = asString(workspace.cwd);
|
||||
if (!workspaceCwd) {
|
||||
skipped.push({
|
||||
|
|
@ -4323,6 +4490,11 @@ export function companySkillService(db: Db) {
|
|||
|
||||
for (const directory of directories) {
|
||||
discovered += 1;
|
||||
const selectionKey = projectScanSelectionKey(target.workspaceId, directory.relativePath);
|
||||
const selected = !selectiveImport || selectedPaths.has(selectionKey);
|
||||
const selectedRename = selectedPaths.get(selectionKey)?.slug;
|
||||
if (selectedPaths.has(selectionKey)) rediscoveredSelections.add(selectionKey);
|
||||
if (selectiveImport && !selected) continue;
|
||||
|
||||
let nextSkill: ImportedSkill;
|
||||
try {
|
||||
|
|
@ -4336,21 +4508,93 @@ export function companySkillService(db: Db) {
|
|||
workspaceName: target.workspaceName,
|
||||
workspaceCwd: target.workspaceCwd,
|
||||
},
|
||||
workspaceRoot: target.workspaceCwd,
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
const message = projectSkillImportFailureReason(error);
|
||||
candidates.push({
|
||||
slug: path.basename(directory.skillDir),
|
||||
name: path.basename(directory.skillDir),
|
||||
description: null,
|
||||
workspaceId: target.workspaceId,
|
||||
workspaceName: target.workspaceName,
|
||||
projectId: target.projectId,
|
||||
projectName: target.projectName,
|
||||
directoryRoot: directory.directoryRoot,
|
||||
relativePath: directory.relativePath,
|
||||
status: "skipped",
|
||||
reason: message,
|
||||
});
|
||||
skipped.push({
|
||||
projectId: target.projectId,
|
||||
projectName: target.projectName,
|
||||
workspaceId: target.workspaceId,
|
||||
workspaceName: target.workspaceName,
|
||||
path: directory.skillDir,
|
||||
reason: trackWarning(`Skipped ${directory.skillDir}: ${message}`),
|
||||
reason: trackWarning(
|
||||
`Skipped ${target.projectName} / ${target.workspaceName} / ${directory.relativePath}: ${message}`,
|
||||
),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const normalizedSourceDir = normalizeSourceLocatorDirectory(nextSkill.sourceLocator);
|
||||
const existingBySource = normalizedSourceDir
|
||||
? acceptedSkills.find((skill) => normalizeSkillDirectory(skill) === normalizedSourceDir) ?? null
|
||||
: null;
|
||||
if (existingBySource) {
|
||||
candidates.push({
|
||||
slug: nextSkill.slug,
|
||||
name: nextSkill.name,
|
||||
description: nextSkill.description,
|
||||
workspaceId: target.workspaceId,
|
||||
workspaceName: target.workspaceName,
|
||||
projectId: target.projectId,
|
||||
projectName: target.projectName,
|
||||
directoryRoot: directory.directoryRoot,
|
||||
relativePath: directory.relativePath,
|
||||
status: "already_imported",
|
||||
existingSkillId: existingBySource.id,
|
||||
reason: "This skill is already installed from the same path.",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const existingBundledBySlug = acceptedSkills.find((skill) => (
|
||||
skill.slug === nextSkill.slug
|
||||
&& (isPaperclipBundledSkillKey(skill.key) || asString(skill.metadata?.sourceKind) === "paperclip_bundled")
|
||||
)) ?? null;
|
||||
if (existingBundledBySlug) {
|
||||
candidates.push({
|
||||
slug: nextSkill.slug,
|
||||
name: nextSkill.name,
|
||||
description: nextSkill.description,
|
||||
workspaceId: target.workspaceId,
|
||||
workspaceName: target.workspaceName,
|
||||
projectId: target.projectId,
|
||||
projectName: target.projectName,
|
||||
directoryRoot: directory.directoryRoot,
|
||||
relativePath: directory.relativePath,
|
||||
status: "already_imported",
|
||||
existingSkillId: existingBundledBySlug.id,
|
||||
reason: "This skill is already available as a built-in.",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (selectedRename) {
|
||||
const renamedKey = `local/${hashSkillValue(path.resolve(nextSkill.sourceLocator ?? directory.skillDir))}/${selectedRename}`;
|
||||
nextSkill = {
|
||||
...nextSkill,
|
||||
key: renamedKey,
|
||||
slug: selectedRename,
|
||||
metadata: {
|
||||
...(isPlainRecord(nextSkill.metadata) ? nextSkill.metadata : {}),
|
||||
skillKey: renamedKey,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const existingByKey = acceptedByKey.get(nextSkill.key) ?? null;
|
||||
if (existingByKey) {
|
||||
const existingSourceDir = normalizeSkillDirectory(existingByKey);
|
||||
|
|
@ -4360,6 +4604,7 @@ export function companySkillService(db: Db) {
|
|||
|| !normalizedSourceDir
|
||||
|| existingSourceDir !== normalizedSourceDir
|
||||
) {
|
||||
const reason = `Skill key ${nextSkill.key} already points at ${existingByKey.sourceLocator ?? "another source"}.`;
|
||||
conflicts.push({
|
||||
slug: nextSkill.slug,
|
||||
key: nextSkill.key,
|
||||
|
|
@ -4371,11 +4616,40 @@ export function companySkillService(db: Db) {
|
|||
existingSkillId: existingByKey.id,
|
||||
existingSkillKey: existingByKey.key,
|
||||
existingSourceLocator: existingByKey.sourceLocator,
|
||||
reason: `Skill key ${nextSkill.key} already points at ${existingByKey.sourceLocator ?? "another source"}.`,
|
||||
reason,
|
||||
});
|
||||
candidates.push({
|
||||
slug: nextSkill.slug,
|
||||
name: nextSkill.name,
|
||||
description: nextSkill.description,
|
||||
workspaceId: target.workspaceId,
|
||||
workspaceName: target.workspaceName,
|
||||
projectId: target.projectId,
|
||||
projectName: target.projectName,
|
||||
directoryRoot: directory.directoryRoot,
|
||||
relativePath: directory.relativePath,
|
||||
status: "conflict",
|
||||
existingSkillId: existingByKey.id,
|
||||
reason,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
candidates.push({
|
||||
slug: nextSkill.slug,
|
||||
name: nextSkill.name,
|
||||
description: nextSkill.description,
|
||||
workspaceId: target.workspaceId,
|
||||
workspaceName: target.workspaceName,
|
||||
projectId: target.projectId,
|
||||
projectName: target.projectName,
|
||||
directoryRoot: directory.directoryRoot,
|
||||
relativePath: directory.relativePath,
|
||||
status: "already_imported",
|
||||
existingSkillId: existingByKey.id,
|
||||
...(selectiveImport && !selected ? { reason: "Not selected for import." } : {}),
|
||||
});
|
||||
if (mode === "preview" || !selected) continue;
|
||||
const persisted = (await upsertImportedSkills(companyId, [nextSkill]))[0];
|
||||
if (!persisted) continue;
|
||||
updated.push(persisted);
|
||||
|
|
@ -4388,6 +4662,7 @@ export function companySkillService(db: Db) {
|
|||
return normalizeSkillDirectory(skill) !== normalizedSourceDir;
|
||||
});
|
||||
if (slugConflict) {
|
||||
const reason = `Slug ${nextSkill.slug} is already in use by ${slugConflict.sourceLocator ?? slugConflict.key}.`;
|
||||
conflicts.push({
|
||||
slug: nextSkill.slug,
|
||||
key: nextSkill.key,
|
||||
|
|
@ -4399,11 +4674,39 @@ export function companySkillService(db: Db) {
|
|||
existingSkillId: slugConflict.id,
|
||||
existingSkillKey: slugConflict.key,
|
||||
existingSourceLocator: slugConflict.sourceLocator,
|
||||
reason: `Slug ${nextSkill.slug} is already in use by ${slugConflict.sourceLocator ?? slugConflict.key}.`,
|
||||
reason,
|
||||
});
|
||||
candidates.push({
|
||||
slug: nextSkill.slug,
|
||||
name: nextSkill.name,
|
||||
description: nextSkill.description,
|
||||
workspaceId: target.workspaceId,
|
||||
workspaceName: target.workspaceName,
|
||||
projectId: target.projectId,
|
||||
projectName: target.projectName,
|
||||
directoryRoot: directory.directoryRoot,
|
||||
relativePath: directory.relativePath,
|
||||
status: "conflict",
|
||||
existingSkillId: slugConflict.id,
|
||||
reason,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
candidates.push({
|
||||
slug: nextSkill.slug,
|
||||
name: nextSkill.name,
|
||||
description: nextSkill.description,
|
||||
workspaceId: target.workspaceId,
|
||||
workspaceName: target.workspaceName,
|
||||
projectId: target.projectId,
|
||||
projectName: target.projectName,
|
||||
directoryRoot: directory.directoryRoot,
|
||||
relativePath: directory.relativePath,
|
||||
status: selected ? "new" : "skipped",
|
||||
...(!selected ? { reason: "Not selected for import." } : {}),
|
||||
});
|
||||
if (mode === "preview" || !selected) continue;
|
||||
const persisted = (await upsertImportedSkills(companyId, [nextSkill]))[0];
|
||||
if (!persisted) continue;
|
||||
imported.push(persisted);
|
||||
|
|
@ -4411,6 +4714,28 @@ export function companySkillService(db: Db) {
|
|||
}
|
||||
}
|
||||
|
||||
if (selectiveImport) {
|
||||
const unmatchedSelections = [
|
||||
...invalidSelections,
|
||||
...Array.from(selectedPaths.entries())
|
||||
.filter(([key]) => !rediscoveredSelections.has(key))
|
||||
.map(([, selection]) => selection),
|
||||
];
|
||||
for (const selection of unmatchedSelections) {
|
||||
const context = workspaceContexts.get(selection.workspaceId) ?? null;
|
||||
skipped.push({
|
||||
projectId: context?.projectId ?? null,
|
||||
projectName: context?.projectName ?? null,
|
||||
workspaceId: selection.workspaceId,
|
||||
workspaceName: context?.workspaceName ?? null,
|
||||
path: selection.path,
|
||||
reason: trackWarning(
|
||||
`Skipped selection ${selection.workspaceId}:${selection.path}: the path was not rediscovered in the project workspace scan.`,
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
scannedProjects: scannedProjectIds.size,
|
||||
scannedWorkspaces: scanTargets.length,
|
||||
|
|
@ -4419,6 +4744,7 @@ export function companySkillService(db: Db) {
|
|||
updated,
|
||||
skipped,
|
||||
conflicts,
|
||||
candidates,
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -253,6 +253,7 @@ async function renderDiscoveryGrid(props: Partial<ComponentProps<typeof Discover
|
|||
totalCount={0}
|
||||
onCreate={vi.fn()}
|
||||
onImport={vi.fn()}
|
||||
onImportFromProject={vi.fn()}
|
||||
onBrowseCatalog={vi.fn()}
|
||||
onScan={vi.fn()}
|
||||
scanPending={false}
|
||||
|
|
|
|||
|
|
@ -85,6 +85,7 @@ import {
|
|||
type SkillCreateDraft,
|
||||
} from "../lib/skill-create";
|
||||
import { SkillCardIcon } from "../components/SkillCardIcon";
|
||||
import { ImportSkillsFromProjectDialog } from "./skills/ImportSkillsFromProjectDialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
|
|
@ -105,6 +106,7 @@ import {
|
|||
FileText,
|
||||
Folder,
|
||||
FolderOpen,
|
||||
FolderSearch,
|
||||
GitFork,
|
||||
Github,
|
||||
Globe,
|
||||
|
|
@ -880,6 +882,7 @@ export function DiscoveryGrid({
|
|||
totalCount,
|
||||
onCreate,
|
||||
onImport,
|
||||
onImportFromProject,
|
||||
onBrowseCatalog,
|
||||
onScan,
|
||||
scanPending,
|
||||
|
|
@ -903,6 +906,7 @@ export function DiscoveryGrid({
|
|||
totalCount: number;
|
||||
onCreate: () => void;
|
||||
onImport: () => void;
|
||||
onImportFromProject: () => void;
|
||||
onBrowseCatalog: () => void;
|
||||
onScan: () => void;
|
||||
scanPending: boolean;
|
||||
|
|
@ -1041,6 +1045,10 @@ export function DiscoveryGrid({
|
|||
<Globe className="mr-2 h-4 w-4" />
|
||||
Import from path or URL
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={onImportFromProject}>
|
||||
<FolderSearch className="mr-2 h-4 w-4" />
|
||||
Import skills from project
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
|
@ -3541,6 +3549,7 @@ export function CompanySkills() {
|
|||
const [discoverySort, setDiscoverySort] = useState<DiscoverySort>("agents");
|
||||
const [createError, setCreateError] = useState<string | null>(null);
|
||||
const [importDialogOpen, setImportDialogOpen] = useState(false);
|
||||
const [importFromProjectOpen, setImportFromProjectOpen] = useState(false);
|
||||
const parsedRoute = useMemo(() => parseSkillRoute(routePath), [routePath]);
|
||||
const isStudioNew = routePath === "studio/new";
|
||||
const routeSkillToken = isStudioNew ? null : parsedRoute.skillToken;
|
||||
|
|
@ -4431,6 +4440,18 @@ export function CompanySkills() {
|
|||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{selectedCompanyId ? (
|
||||
<ImportSkillsFromProjectDialog
|
||||
open={importFromProjectOpen}
|
||||
onOpenChange={setImportFromProjectOpen}
|
||||
companyId={selectedCompanyId}
|
||||
onImportFromPath={() => {
|
||||
setImportFromProjectOpen(false);
|
||||
setImportDialogOpen(true);
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{isStudioNew ? (
|
||||
<div className="min-h-(--sz-calc-30)">
|
||||
<div className="border-b border-border px-4 py-5">
|
||||
|
|
@ -4482,6 +4503,7 @@ export function CompanySkills() {
|
|||
totalCount={discoveryCards.length}
|
||||
onCreate={() => navigate(skillStudioNewRoute())}
|
||||
onImport={() => setImportDialogOpen(true)}
|
||||
onImportFromProject={() => setImportFromProjectOpen(true)}
|
||||
onBrowseCatalog={() => setDiscoveryTab("catalog")}
|
||||
onScan={() => scanProjects.mutate()}
|
||||
scanPending={scanProjects.isPending}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,214 @@
|
|||
// @vitest-environment jsdom
|
||||
|
||||
import type {
|
||||
CompanySkillProjectScanCandidate,
|
||||
Project,
|
||||
ProjectWorkspace,
|
||||
} from "@paperclipai/shared";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
defaultSelection,
|
||||
filterCandidates,
|
||||
groupCandidates,
|
||||
isScannableWorkspace,
|
||||
isSelectableCandidate,
|
||||
isValidSelectionSlug,
|
||||
scannableWorkspaces,
|
||||
selectAllSelection,
|
||||
selectionKey,
|
||||
suggestedConflictSlug,
|
||||
} from "./ImportSkillsFromProjectDialog";
|
||||
|
||||
const WS_A = "11111111-1111-1111-1111-111111111111";
|
||||
const WS_B = "22222222-2222-2222-2222-222222222222";
|
||||
|
||||
function candidate(
|
||||
overrides: Partial<CompanySkillProjectScanCandidate> &
|
||||
Pick<CompanySkillProjectScanCandidate, "slug" | "relativePath" | "status">,
|
||||
): CompanySkillProjectScanCandidate {
|
||||
return {
|
||||
name: overrides.slug,
|
||||
description: null,
|
||||
workspaceId: WS_A,
|
||||
workspaceName: "Workspace A",
|
||||
projectId: "33333333-3333-3333-3333-333333333333",
|
||||
projectName: "Project",
|
||||
directoryRoot: ".claude/skills",
|
||||
...overrides,
|
||||
} as CompanySkillProjectScanCandidate;
|
||||
}
|
||||
|
||||
/**
|
||||
* A mixed candidate set: two new skills, one selectable conflict, and two
|
||||
* disabled statuses.
|
||||
*/
|
||||
function mixedCandidates(): CompanySkillProjectScanCandidate[] {
|
||||
return [
|
||||
candidate({ slug: "alpha", relativePath: ".claude/skills/alpha", status: "new" }),
|
||||
candidate({ slug: "beta", relativePath: ".claude/skills/beta", status: "new" }),
|
||||
candidate({
|
||||
slug: "gamma",
|
||||
relativePath: ".claude/skills/gamma",
|
||||
status: "already_imported",
|
||||
existingSkillId: "44444444-4444-4444-4444-444444444444",
|
||||
}),
|
||||
candidate({
|
||||
slug: "delta",
|
||||
relativePath: ".claude/skills/delta",
|
||||
status: "conflict",
|
||||
reason: "Slug delta is already in use.",
|
||||
}),
|
||||
candidate({
|
||||
slug: "epsilon",
|
||||
relativePath: ".claude/skills/epsilon",
|
||||
status: "skipped",
|
||||
reason: "Could not parse SKILL.md.",
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
describe("ImportSkillsFromProjectDialog selection logic", () => {
|
||||
it("new and conflict candidates are selectable", () => {
|
||||
const candidates = mixedCandidates();
|
||||
const selectable = candidates.filter(isSelectableCandidate);
|
||||
expect(selectable.map((c) => c.slug)).toEqual(["alpha", "beta", "delta"]);
|
||||
});
|
||||
|
||||
it("default selection leaves every candidate unchecked", () => {
|
||||
expect(defaultSelection(mixedCandidates())).toEqual(new Map());
|
||||
});
|
||||
|
||||
it("select all checks new candidates and leaves conflicts unchecked", () => {
|
||||
const candidates = mixedCandidates();
|
||||
const selectAll = selectAllSelection(candidates);
|
||||
const defaultChecked = candidates.filter((candidate) => candidate.status === "new");
|
||||
expect(selectAll.size).toBe(defaultChecked.length);
|
||||
for (const c of defaultChecked) {
|
||||
expect(selectAll.has(selectionKey(c.workspaceId, c.relativePath))).toBe(true);
|
||||
}
|
||||
expect(selectAll.has(selectionKey(WS_A, ".claude/skills/delta"))).toBe(false);
|
||||
});
|
||||
|
||||
it("deselect all yields an empty selection (N = 0)", () => {
|
||||
const deselectAll = new Map<string, { workspaceId: string; path: string }>();
|
||||
expect(deselectAll.size).toBe(0);
|
||||
});
|
||||
|
||||
it("selection payload carries workspaceId + path for each checked new candidate", () => {
|
||||
const selection = selectAllSelection(mixedCandidates());
|
||||
const payload = Array.from(selection.values());
|
||||
expect(payload).toEqual([
|
||||
{ workspaceId: WS_A, path: ".claude/skills/alpha" },
|
||||
{ workspaceId: WS_A, path: ".claude/skills/beta" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("toggling a single new candidate removes only that row from N", () => {
|
||||
const selection = selectAllSelection(mixedCandidates());
|
||||
const key = selectionKey(WS_A, ".claude/skills/alpha");
|
||||
selection.delete(key);
|
||||
expect(selection.size).toBe(1);
|
||||
expect(selection.has(key)).toBe(false);
|
||||
expect(selection.has(selectionKey(WS_A, ".claude/skills/beta"))).toBe(true);
|
||||
});
|
||||
|
||||
it("groups folders beneath each workspace and sorts the primary workspace first", () => {
|
||||
const candidates = [
|
||||
candidate({ slug: "a", relativePath: ".claude/skills/a", status: "new" }),
|
||||
candidate({
|
||||
slug: "b",
|
||||
relativePath: "skills/b",
|
||||
directoryRoot: "skills",
|
||||
status: "new",
|
||||
}),
|
||||
candidate({
|
||||
slug: "c",
|
||||
relativePath: ".claude/skills/c",
|
||||
workspaceId: WS_B,
|
||||
workspaceName: "Workspace B",
|
||||
status: "new",
|
||||
}),
|
||||
];
|
||||
const groups = groupCandidates(candidates, [
|
||||
workspace({ id: WS_A, name: "Workspace A", isPrimary: false }),
|
||||
workspace({ id: WS_B, name: "Workspace B", isPrimary: true }),
|
||||
]);
|
||||
expect(groups).toHaveLength(2);
|
||||
expect(groups.map((group) => group.workspaceName)).toEqual(["Workspace B", "Workspace A"]);
|
||||
expect(groups[1]?.directories.map((directory) => directory.directoryRoot)).toEqual([
|
||||
".claude/skills",
|
||||
"skills",
|
||||
]);
|
||||
});
|
||||
|
||||
it("filters candidates by name, slug, path, workspace, and status", () => {
|
||||
const candidates = mixedCandidates();
|
||||
expect(filterCandidates(candidates, "DELTA").map((entry) => entry.slug)).toEqual(["delta"]);
|
||||
expect(filterCandidates(candidates, "already_imported").map((entry) => entry.slug)).toEqual(["gamma"]);
|
||||
expect(filterCandidates(candidates, ".claude/skills/epsilon").map((entry) => entry.slug)).toEqual(["epsilon"]);
|
||||
expect(filterCandidates(candidates, "workspace a")).toHaveLength(candidates.length);
|
||||
});
|
||||
|
||||
it("suggests and validates a URL-safe rename for conflicts", () => {
|
||||
const conflict = mixedCandidates().find((entry) => entry.status === "conflict")!;
|
||||
expect(suggestedConflictSlug(conflict)).toBe("delta-copy");
|
||||
expect(isValidSelectionSlug({ workspaceId: WS_A, path: conflict.relativePath, slug: "delta-copy" })).toBe(true);
|
||||
expect(isValidSelectionSlug({ workspaceId: WS_A, path: conflict.relativePath, slug: "Delta Copy" })).toBe(false);
|
||||
expect(isValidSelectionSlug({ workspaceId: WS_A, path: conflict.relativePath, slug: "" })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
function workspace(overrides: Partial<ProjectWorkspace>): ProjectWorkspace {
|
||||
return {
|
||||
id: WS_A,
|
||||
companyId: "c",
|
||||
projectId: "p",
|
||||
name: "ws",
|
||||
sourceType: "local_path",
|
||||
cwd: "/srv/project",
|
||||
repoUrl: null,
|
||||
repoRef: null,
|
||||
defaultRef: null,
|
||||
visibility: "default",
|
||||
setupCommand: null,
|
||||
cleanupCommand: null,
|
||||
remoteProvider: null,
|
||||
remoteWorkspaceRef: null,
|
||||
sharedWorkspaceKey: null,
|
||||
metadata: null,
|
||||
runtimeConfig: null,
|
||||
isPrimary: true,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
...overrides,
|
||||
} as ProjectWorkspace;
|
||||
}
|
||||
|
||||
describe("scannable workspace detection", () => {
|
||||
it("local/git/folder workspaces with a cwd are scannable", () => {
|
||||
expect(isScannableWorkspace(workspace({ sourceType: "local_path" }))).toBe(true);
|
||||
expect(isScannableWorkspace(workspace({ sourceType: "git_repo" }))).toBe(true);
|
||||
expect(isScannableWorkspace(workspace({ sourceType: "non_git_path" }))).toBe(true);
|
||||
});
|
||||
|
||||
it("remote-managed workspaces are never scannable", () => {
|
||||
expect(
|
||||
isScannableWorkspace(workspace({ sourceType: "remote_managed", cwd: null })),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("workspaces without a cwd are not scannable", () => {
|
||||
expect(isScannableWorkspace(workspace({ cwd: null }))).toBe(false);
|
||||
expect(isScannableWorkspace(workspace({ cwd: " " }))).toBe(false);
|
||||
});
|
||||
|
||||
it("a remote-only project has zero scannable workspaces", () => {
|
||||
const project = {
|
||||
workspaces: [
|
||||
workspace({ id: WS_A, sourceType: "remote_managed", cwd: null }),
|
||||
workspace({ id: WS_B, sourceType: "remote_managed", cwd: null }),
|
||||
],
|
||||
} as unknown as Project;
|
||||
expect(scannableWorkspaces(project)).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -308,6 +308,7 @@ function DiscoveryGridHarness({
|
|||
totalCount={cards.length}
|
||||
onCreate={() => {}}
|
||||
onImport={() => {}}
|
||||
onImportFromProject={() => {}}
|
||||
onBrowseCatalog={() => setTab("catalog")}
|
||||
onScan={() => {}}
|
||||
scanPending={false}
|
||||
|
|
|
|||
Loading…
Reference in New Issue