diff --git a/packages/adapter-utils/src/mcp-isolation.integration.test.ts b/packages/adapter-utils/src/mcp-isolation.integration.test.ts index c74f985843..23b07c093d 100644 --- a/packages/adapter-utils/src/mcp-isolation.integration.test.ts +++ b/packages/adapter-utils/src/mcp-isolation.integration.test.ts @@ -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); diff --git a/packages/db/src/client.test.ts b/packages/db/src/client.test.ts index 2e9621ddb7..2308deb6cf 100644 --- a/packages/db/src/client.test.ts +++ b/packages/db/src/client.test.ts @@ -114,7 +114,7 @@ describeEmbeddedPostgres("resetPostgresDatabase", () => { } finally { await verifySql.end(); } - }); + }, 30_000); }); describeEmbeddedPostgres("applyPendingMigrations", () => { diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 587c429582..caefc73521 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -563,6 +563,8 @@ export type { CompanySkillImportRequest, CompanySkillImportResult, CompanySkillProjectScanRequest, + CompanySkillProjectScanCandidateStatus, + CompanySkillProjectScanCandidate, CompanySkillProjectScanSkipped, CompanySkillProjectScanConflict, CompanySkillProjectScanResult, diff --git a/packages/shared/src/types/company-skill.ts b/packages/shared/src/types/company-skill.ts index c255c1f67f..29ea6e3ddc 100644 --- a/packages/shared/src/types/company-skill.ts +++ b/packages/shared/src/types/company-skill.ts @@ -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[]; } diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index d7aab5b78b..8506983117 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -127,6 +127,8 @@ export type { CompanySkillImportRequest, CompanySkillImportResult, CompanySkillProjectScanRequest, + CompanySkillProjectScanCandidateStatus, + CompanySkillProjectScanCandidate, CompanySkillProjectScanSkipped, CompanySkillProjectScanConflict, CompanySkillProjectScanResult, diff --git a/packages/shared/src/validators/company-skill.ts b/packages/shared/src/validators/company-skill.ts index acf7cb4664..f458efaf3c 100644 --- a/packages/shared/src/validators/company-skill.ts +++ b/packages/shared/src/validators/company-skill.ts @@ -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()), }); diff --git a/server/src/__tests__/company-skills-routes.test.ts b/server/src/__tests__/company-skills-routes.test.ts index e141e649d2..4246b8c149 100644 --- a/server/src/__tests__/company-skills-routes.test.ts +++ b/server/src/__tests__/company-skills-routes.test.ts @@ -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", diff --git a/server/src/__tests__/company-skills-service.test.ts b/server/src/__tests__/company-skills-service.test.ts index a7e68a8abc..2d6035146a 100644 --- a/server/src/__tests__/company-skills-service.test.ts +++ b/server/src/__tests__/company-skills-service.test.ts @@ -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([]); + }); }); diff --git a/server/src/__tests__/company-skills.test.ts b/server/src/__tests__/company-skills.test.ts index 555df881c3..d30fb48ce2 100644 --- a/server/src/__tests__/company-skills.test.ts +++ b/server/src/__tests__/company-skills.test.ts @@ -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 }); diff --git a/server/src/__tests__/heartbeat-responsible-user-invariant.test.ts b/server/src/__tests__/heartbeat-responsible-user-invariant.test.ts index c7648b162a..32a1ed3a88 100644 --- a/server/src/__tests__/heartbeat-responsible-user-invariant.test.ts +++ b/server/src/__tests__/heartbeat-responsible-user-invariant.test.ts @@ -114,7 +114,7 @@ describeEmbeddedPostgres("heartbeat responsible-user invariant", () => { afterAll(async () => { await tempDb?.cleanup(); - }); + }, 60_000); async function seedCompany() { const companyId = randomUUID(); diff --git a/server/src/__tests__/heartbeat-workspace-branch-containment.test.ts b/server/src/__tests__/heartbeat-workspace-branch-containment.test.ts index 26d730255a..c9e9418d21 100644 --- a/server/src/__tests__/heartbeat-workspace-branch-containment.test.ts +++ b/server/src/__tests__/heartbeat-workspace-branch-containment.test.ts @@ -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(); diff --git a/server/src/__tests__/openapi-routes.test.ts b/server/src/__tests__/openapi-routes.test.ts index 743d5234fd..290b6ed6a1 100644 --- a/server/src/__tests__/openapi-routes.test.ts +++ b/server/src/__tests__/openapi-routes.test.ts @@ -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: { diff --git a/server/src/__tests__/plugin-install-autobuild.test.ts b/server/src/__tests__/plugin-install-autobuild.test.ts index 732ddff3d8..158bef7aaa 100644 --- a/server/src/__tests__/plugin-install-autobuild.test.ts +++ b/server/src/__tests__/plugin-install-autobuild.test.ts @@ -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"); diff --git a/server/src/__tests__/productivity-review-service.test.ts b/server/src/__tests__/productivity-review-service.test.ts index 0c6ed8b413..d58d2aac1b 100644 --- a/server/src/__tests__/productivity-review-service.test.ts +++ b/server/src/__tests__/productivity-review-service.test.ts @@ -48,7 +48,7 @@ describeEmbeddedPostgres("productivity review service", () => { afterAll(async () => { await tempDb?.cleanup(); - }); + }, 30_000); async function seedAssignedIssue(opts?: { status?: "todo" | "in_progress"; diff --git a/server/src/routes/company-skills.ts b/server/src/routes/company-skills.ts index 7fe9e62721..065f7b0464 100644 --- a/server/src/routes/company-skills.ts +++ b/server/src/routes/company-skills.ts @@ -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, diff --git a/server/src/routes/openapi.ts b/server/src/routes/openapi.ts index 75988a639c..774d53fa89 100644 --- a/server/src/routes/openapi.ts +++ b/server/src/routes/openapi.ts @@ -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({ diff --git a/server/src/services/company-skills.ts b/server/src/services/company-skills.ts index 4ea9d88554..da6049b256 100644 --- a/server/src/services/company-skills.ts +++ b/server/src/services/company-skills.ts @@ -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 { + 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 | null; + workspaceRoot?: string; }, ): Promise { 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> { - const discovered = new Map(); + const discovered = new Map(); const rootSkillPath = path.join(target.workspaceCwd, "SKILL.md"); if ((await statPath(rootSkillPath))?.isFile()) { - discovered.set(path.resolve(target.workspaceCwd), "project_root"); + 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 { 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 { 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(); + const selectedPaths = new Map(); + const invalidSelections: Array<{ workspaceId: string; path: string; slug?: string }> = []; + const rediscoveredSelections = new Set(); const scannedProjectIds = new Set(); 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, }; } diff --git a/ui/src/pages/CompanySkills.test.tsx b/ui/src/pages/CompanySkills.test.tsx index c22c37ee84..0ef77bd7f5 100644 --- a/ui/src/pages/CompanySkills.test.tsx +++ b/ui/src/pages/CompanySkills.test.tsx @@ -253,6 +253,7 @@ async function renderDiscoveryGrid(props: Partial void; onImport: () => void; + onImportFromProject: () => void; onBrowseCatalog: () => void; onScan: () => void; scanPending: boolean; @@ -1041,6 +1045,10 @@ export function DiscoveryGrid({ Import from path or URL + + + Import skills from project + @@ -3541,6 +3549,7 @@ export function CompanySkills() { const [discoverySort, setDiscoverySort] = useState("agents"); const [createError, setCreateError] = useState(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() { + {selectedCompanyId ? ( + { + setImportFromProjectOpen(false); + setImportDialogOpen(true); + }} + /> + ) : null} + {isStudioNew ? (
@@ -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} diff --git a/ui/src/pages/skills/ImportSkillsFromProjectDialog.test.tsx b/ui/src/pages/skills/ImportSkillsFromProjectDialog.test.tsx new file mode 100644 index 0000000000..2c3123d5f7 --- /dev/null +++ b/ui/src/pages/skills/ImportSkillsFromProjectDialog.test.tsx @@ -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 & + Pick, +): 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(); + 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 { + 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); + }); +}); diff --git a/ui/src/pages/skills/ImportSkillsFromProjectDialog.tsx b/ui/src/pages/skills/ImportSkillsFromProjectDialog.tsx new file mode 100644 index 0000000000..019a3a1a1a --- /dev/null +++ b/ui/src/pages/skills/ImportSkillsFromProjectDialog.tsx @@ -0,0 +1,1048 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { + AlertCircle, + AlertTriangle, + ArrowLeft, + CheckCircle2, + ExternalLink, + FileWarning, + FolderSearch, + Layers, + Link2, + Loader2, + Search, + ShieldAlert, + X, +} from "lucide-react"; +import type { + CompanySkill, + CompanySkillProjectScanCandidate, + CompanySkillProjectScanResult, + Project, + ProjectWorkspace, +} from "@paperclipai/shared"; +import { normalizeAgentUrlKey } from "@paperclipai/shared"; +import { Link } from "@/lib/router"; +import { ApiError } from "../../api/client"; +import { companySkillsApi } from "../../api/companySkills"; +import { projectsApi } from "../../api/projects"; +import { useToastActions } from "../../context/ToastContext"; +import { queryKeys } from "../../lib/queryKeys"; +import { skillStudioRoute } from "../../lib/company-skill-routes"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Badge } from "@/components/ui/badge"; +import { EmptyState } from "../../components/EmptyState"; +import { cn } from "../../lib/utils"; + +type Step = "pick" | "scanning" | "select" | "result"; +export type SkillSelection = { workspaceId: string; path: string; slug?: string }; + +interface ImportSkillsFromProjectDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + companyId: string; + /** Opens the legacy "Import from path or URL" dialog (empty-state fallback). */ + onImportFromPath?: () => void; +} + +/** + * A representative slice of the well-known folders the server scans, shown + * during the scanning step so users learn where to place skills. The full list + * lives in PROJECT_SCAN_DIRECTORY_ROOTS server-side (~34 entries); we surface + * the common ones plus a count of the rest. + */ +const HIGHLIGHTED_SCAN_FOLDERS = [ + "./skills", + ".agents/skills", + ".claude/skills", + ".codex/skills", + ".cursor/skills", +]; +const APPROX_TOTAL_SCAN_FOLDERS = 34; + +export function selectionKey(workspaceId: string, path: string): string { + return `${workspaceId} ${path}`; +} + +export function isScannableWorkspace(workspace: ProjectWorkspace): boolean { + if (workspace.sourceType === "remote_managed") return false; + return typeof workspace.cwd === "string" && workspace.cwd.trim().length > 0; +} + +export function scannableWorkspaces(project: Project): ProjectWorkspace[] { + return project.workspaces.filter(isScannableWorkspace); +} + +function workspaceKindLabel(sourceType: ProjectWorkspace["sourceType"]): string { + switch (sourceType) { + case "git_repo": + return "git"; + case "local_path": + return "local"; + case "non_git_path": + return "folder"; + case "remote_managed": + return "remote"; + default: + return sourceType; + } +} + +function summarizeWorkspaceKinds(workspaces: ProjectWorkspace[]): string { + const kinds = Array.from(new Set(workspaces.map((ws) => workspaceKindLabel(ws.sourceType)))); + return kinds.join(", "); +} + +/** + * New skills and conflicts can be checked. Conflicts remain unchecked by + * default and require an alternate slug before import. + */ +export function isSelectableCandidate(candidate: CompanySkillProjectScanCandidate): boolean { + return candidate.status === "new" || candidate.status === "conflict"; +} + +export function filterCandidates( + candidates: CompanySkillProjectScanCandidate[], + filter: string, +): CompanySkillProjectScanCandidate[] { + const query = filter.trim().toLowerCase(); + if (!query) return candidates; + return candidates.filter((candidate) => [ + candidate.name, + candidate.slug, + candidate.description, + candidate.relativePath, + candidate.workspaceName, + candidate.directoryRoot, + candidate.status, + ].some((value) => value?.toLowerCase().includes(query))); +} + +export interface CandidateDirectoryGroup { + key: string; + directoryRoot: string; + candidates: CompanySkillProjectScanCandidate[]; +} + +export interface CandidateWorkspaceGroup { + key: string; + workspaceId: string; + workspaceName: string; + isPrimary: boolean; + directories: CandidateDirectoryGroup[]; +} + +export function groupCandidates( + candidates: CompanySkillProjectScanCandidate[], + workspaces: ProjectWorkspace[] = [], +): CandidateWorkspaceGroup[] { + const workspaceMetadata = new Map(workspaces.map((workspace) => [workspace.id, workspace])); + const groups = new Map(); + for (const candidate of candidates) { + let group = groups.get(candidate.workspaceId); + if (!group) { + group = { + key: candidate.workspaceId, + workspaceId: candidate.workspaceId, + workspaceName: candidate.workspaceName, + isPrimary: workspaceMetadata.get(candidate.workspaceId)?.isPrimary ?? false, + directories: [], + }; + groups.set(candidate.workspaceId, group); + } + let directory = group.directories.find((entry) => entry.directoryRoot === candidate.directoryRoot); + if (!directory) { + directory = { + key: `${candidate.workspaceId} ${candidate.directoryRoot}`, + directoryRoot: candidate.directoryRoot, + candidates: [], + }; + group.directories.push(directory); + } + directory.candidates.push(candidate); + } + return Array.from(groups.values()) + .map((group) => ({ + ...group, + directories: group.directories.sort((a, b) => a.directoryRoot.localeCompare(b.directoryRoot)), + })) + .sort((a, b) => { + if (a.isPrimary !== b.isPrimary) return a.isPrimary ? -1 : 1; + return a.workspaceName.localeCompare(b.workspaceName); + }); +} + +export function defaultSelection( + _candidates: CompanySkillProjectScanCandidate[], +): Map { + return new Map(); +} + +export function selectAllSelection( + candidates: CompanySkillProjectScanCandidate[], +): Map { + const next = new Map(); + for (const candidate of candidates) { + if (candidate.status !== "new") continue; + next.set(selectionKey(candidate.workspaceId, candidate.relativePath), { + workspaceId: candidate.workspaceId, + path: candidate.relativePath, + }); + } + return next; +} + +export function suggestedConflictSlug(candidate: CompanySkillProjectScanCandidate): string { + return `${candidate.slug}-copy`; +} + +export function isValidSelectionSlug(selection: SkillSelection): boolean { + if (selection.slug === undefined) return true; + const trimmed = selection.slug.trim(); + return Boolean(trimmed) && normalizeAgentUrlKey(trimmed) === trimmed; +} + +function readableErrorMessage(error: unknown): string { + if (error instanceof ApiError) { + return error.message || `Request failed: ${error.status}`; + } + if (error instanceof Error) return error.message; + return "Unexpected error"; +} + +export function isGrantError(error: unknown): boolean { + if (!(error instanceof ApiError)) return false; + return error.status === 403; +} + +function CandidateStatusBadge({ + status, +}: { + status: CompanySkillProjectScanCandidate["status"]; +}) { + switch (status) { + case "already_imported": + return ( + + Imported + + ); + case "conflict": + return ( + + Conflict + + ); + case "skipped": + return ( + + Skipped + + ); + case "new": + default: + return ( + + New + + ); + } +} + +export function ImportSkillsFromProjectDialog({ + open, + onOpenChange, + companyId, + onImportFromPath, +}: ImportSkillsFromProjectDialogProps) { + const queryClient = useQueryClient(); + const toast = useToastActions(); + + const [step, setStep] = useState("pick"); + const [projectFilter, setProjectFilter] = useState(""); + const [candidateFilter, setCandidateFilter] = useState(""); + const [selectedProject, setSelectedProject] = useState(null); + const [scanResult, setScanResult] = useState(null); + const [scanError, setScanError] = useState(null); + const [selection, setSelection] = useState>(new Map()); + const [importResult, setImportResult] = useState(null); + const scanTokenRef = useRef(0); + + const projectsQuery = useQuery({ + queryKey: queryKeys.projects.list(companyId), + queryFn: () => projectsApi.list(companyId), + enabled: open, + }); + + // Reset all local state on each open transition. + useEffect(() => { + if (!open) return; + setStep("pick"); + setProjectFilter(""); + setCandidateFilter(""); + setSelectedProject(null); + setScanResult(null); + setScanError(null); + setSelection(new Map()); + setImportResult(null); + scanTokenRef.current += 1; + }, [open]); + + const projects = projectsQuery.data ?? []; + const filteredProjects = useMemo(() => { + const query = projectFilter.trim().toLowerCase(); + const sorted = [...projects].sort((a, b) => a.name.localeCompare(b.name)); + if (!query) return sorted; + return sorted.filter((project) => project.name.toLowerCase().includes(query)); + }, [projects, projectFilter]); + + function startScan(project: Project) { + setSelectedProject(project); + setScanResult(null); + setScanError(null); + setSelection(new Map()); + setStep("scanning"); + const token = ++scanTokenRef.current; + companySkillsApi + .scanProjects(companyId, { projectIds: [project.id], mode: "preview" }) + .then((result) => { + if (token !== scanTokenRef.current) return; + setScanResult(result); + setSelection(defaultSelection(result.candidates)); + setStep("select"); + }) + .catch((error) => { + if (token !== scanTokenRef.current) return; + setScanError(error); + setStep("select"); + }); + } + + const importMutation = useMutation({ + mutationFn: () => { + if (!selectedProject) throw new Error("No project selected."); + const selectionInput = Array.from(selection.values()); + return companySkillsApi.scanProjects(companyId, { + projectIds: [selectedProject.id], + mode: "import", + selection: selectionInput, + }); + }, + onSuccess: async (result) => { + setImportResult(result); + setStep("result"); + await queryClient.invalidateQueries({ + queryKey: queryKeys.companySkills.list(companyId), + }); + const importedCount = result.imported.length; + toast.pushToast({ + tone: importedCount > 0 ? "success" : "warn", + title: importedCount > 0 ? "Skills imported" : "Nothing imported", + body: + importedCount > 0 + ? `${importedCount} skill${importedCount === 1 ? "" : "s"} imported as references from ${selectedProject?.name ?? "the project"}.` + : "No skills were imported.", + }); + }, + onError: (error) => { + toast.pushToast({ + tone: "error", + title: "Import failed", + body: readableErrorMessage(error), + }); + }, + }); + + const candidates = scanResult?.candidates ?? []; + const selectableCandidates = useMemo( + () => candidates.filter(isSelectableCandidate), + [candidates], + ); + const filteredCandidates = useMemo( + () => filterCandidates(candidates, candidateFilter), + [candidateFilter, candidates], + ); + const groups = useMemo( + () => groupCandidates(filteredCandidates, selectedProject?.workspaces ?? []), + [filteredCandidates, selectedProject], + ); + const selectedCount = selection.size; + const hasInvalidSelection = Array.from(selection.values()).some( + (selected) => !isValidSelectionSlug(selected), + ); + + function toggleCandidate(candidate: CompanySkillProjectScanCandidate) { + if (!isSelectableCandidate(candidate)) return; + setSelection((prev) => { + const next = new Map(prev); + const key = selectionKey(candidate.workspaceId, candidate.relativePath); + if (next.has(key)) { + next.delete(key); + } else { + next.set(key, { + workspaceId: candidate.workspaceId, + path: candidate.relativePath, + ...(candidate.status === "conflict" ? { slug: suggestedConflictSlug(candidate) } : {}), + }); + } + return next; + }); + } + + function renameCandidate(candidate: CompanySkillProjectScanCandidate, slug: string) { + setSelection((prev) => { + const next = new Map(prev); + const key = selectionKey(candidate.workspaceId, candidate.relativePath); + const selected = next.get(key); + if (!selected) return prev; + next.set(key, { ...selected, slug }); + return next; + }); + } + + function selectAll() { + setSelection(selectAllSelection(candidates)); + } + + function deselectAll() { + setSelection(new Map()); + } + + function handleClose() { + if (importMutation.isPending) return; + onOpenChange(false); + } + + function backToPick() { + scanTokenRef.current += 1; + setSelectedProject(null); + setScanResult(null); + setScanError(null); + setCandidateFilter(""); + setSelection(new Map()); + setStep("pick"); + } + + return ( + { + if (next) onOpenChange(true); + else handleClose(); + }} + > + +
+
+ + Import skills from project + + + Pick a project, scan its workspaces for skills, and import them as references. + +
+ +
+ +
+ {step === "pick" && ( + + )} + {step === "scanning" && } + {step === "select" && ( + selectedProject && startScan(selectedProject)} + onImportFromPath={onImportFromPath} + groups={groups} + totalCandidates={candidates.length} + filter={candidateFilter} + onFilterChange={setCandidateFilter} + selection={selection} + toggleCandidate={toggleCandidate} + renameCandidate={renameCandidate} + /> + )} + {step === "result" && importResult && } +
+ +
+ {step === "select" && !scanError && candidates.length > 0 ? ( +
+ + + Files stay in the project — Studio edits save directly to them. + +
+ ) : ( +
+ )} +
+ {step === "select" && ( + <> + {!scanError && candidates.length > 0 && ( +
+ + +
+ )} + + {!scanError && candidates.length > 0 && ( + + )} + + )} + {step === "pick" && ( + + )} + {step === "result" && ( + + )} +
+
+
+
+ ); +} + +interface PickProjectStepProps { + loading: boolean; + error: unknown; + projects: Project[]; + totalProjects: number; + filter: string; + onFilterChange: (value: string) => void; + onPick: (project: Project) => void; +} + +function PickProjectStep({ + loading, + error, + projects, + totalProjects, + filter, + onFilterChange, + onPick, +}: PickProjectStepProps) { + return ( +
+
+
+ + onFilterChange(event.target.value)} + placeholder="Filter projects" + className="pl-7 text-xs" + aria-label="Filter projects" + data-testid="project-filter" + /> +
+
+
+ {loading ? ( +
Loading projects…
+ ) : error ? ( +
+ +
{readableErrorMessage(error)}
+
+ ) : totalProjects === 0 ? ( + + ) : projects.length === 0 ? ( + + ) : ( +
    + {projects.map((project) => { + const scannable = scannableWorkspaces(project); + const disabled = scannable.length === 0; + const kinds = summarizeWorkspaceKinds(project.workspaces); + return ( +
  • + +
  • + ); + })} +
+ )} +
+
+ ); +} + +function ScanningStep({ projectName }: { projectName: string }) { + return ( +
+
+ + +
+
+

Scanning {projectName || "project"} for skills…

+

+ Looking in well-known skill folders across each workspace. +

+
+
+ {HIGHLIGHTED_SCAN_FOLDERS.map((folder) => ( + + {folder} + + ))} + + +{APPROX_TOTAL_SCAN_FOLDERS - HIGHLIGHTED_SCAN_FOLDERS.length} more + +
+
+ ); +} + +interface SelectStepProps { + scanError: unknown; + onRetry: () => void; + onImportFromPath?: () => void; + groups: CandidateWorkspaceGroup[]; + totalCandidates: number; + filter: string; + onFilterChange: (value: string) => void; + selection: Map; + toggleCandidate: (candidate: CompanySkillProjectScanCandidate) => void; + renameCandidate: (candidate: CompanySkillProjectScanCandidate, slug: string) => void; +} + +function SelectStep({ + scanError, + onRetry, + onImportFromPath, + groups, + totalCandidates, + filter, + onFilterChange, + selection, + toggleCandidate, + renameCandidate, +}: SelectStepProps) { + if (scanError) { + const grant = isGrantError(scanError); + return ( +
+
+
+ {grant ? ( + + ) : ( + + )} +
+

+ {grant ? "You can't import skills here" : "Scan failed"} +

+

+ {grant + ? "Your account doesn't have permission to add skills to this company. Ask an owner to grant the skills permission, then try again." + : readableErrorMessage(scanError)} +

+ {!grant && ( + + )} +
+
+ ); + } + + if (totalCandidates === 0) { + return ( +
+
+
+ +
+

No skills found

+

+ None of the well-known skill folders in this project's workspaces contain a{" "} + SKILL.md. We searched{" "} + {HIGHLIGHTED_SCAN_FOLDERS.join(", ")} and {APPROX_TOTAL_SCAN_FOLDERS - + HIGHLIGHTED_SCAN_FOLDERS.length}{" "} + other agent-harness folders. +

+ {onImportFromPath && ( +

+ For skills in non-standard folders, use{" "} + + . +

+ )} +
+
+ ); + } + + return ( +
+
+
+ + onFilterChange(event.target.value)} + placeholder="Search discovered skills…" + className="h-8 pl-8 text-xs" + aria-label="Search discovered skills" + /> +
+
+
+ {groups.length === 0 ? ( +
+ No skills match “{filter.trim()}”. +
+ ) : ( + groups.map((group, groupIndex) => ( +
+ {groupIndex > 0 && !group.isPrimary && groups[groupIndex - 1]?.isPrimary && ( +
+ Other Workspaces +
+ )} +
+ {group.workspaceName} +
+ {group.directories.map((directory) => ( +
+
+ {directory.directoryRoot} +
+
    + {directory.candidates.map((candidate) => { + const selectable = isSelectableCandidate(candidate); + const key = selectionKey(candidate.workspaceId, candidate.relativePath); + const isSelected = selection.has(key); + const selectedValue = selection.get(key); + return ( +
  • toggleCandidate(candidate)} + data-testid={`candidate-${candidate.relativePath}`} + data-status={candidate.status} + data-selected={isSelected ? "true" : "false"} + > +
    event.stopPropagation()}> + toggleCandidate(candidate)} + disabled={!selectable} + aria-label={`Select ${candidate.name}`} + /> +
    +
    +
    + + {candidate.name} + + +
    + {candidate.description && ( +

    + {candidate.description} +

    + )} +

    + {candidate.relativePath} +

    + {candidate.reason && ( +

    + {candidate.reason} +

    + )} + {candidate.status === "conflict" && isSelected && ( +
    event.stopPropagation()} + > + + + renameCandidate(candidate, event.target.value) + } + className="h-7 max-w-xs font-mono text-xs" + aria-label={`Rename ${candidate.name}`} + aria-invalid={ + selectedValue + ? !isValidSelectionSlug(selectedValue) + : undefined + } + /> + {selectedValue && !isValidSelectionSlug(selectedValue) && ( + + Use a lowercase URL-safe slug. + + )} +
    + )} +
    +
  • + ); + })} +
+
+ ))} +
+ )) + )} +
+
+ ); +} + +interface ResultStepProps { + result: CompanySkillProjectScanResult; +} + +function ResultStep({ result }: ResultStepProps) { + const importedSkills: CompanySkill[] = useMemo( + () => [...result.imported, ...result.updated], + [result], + ); + + return ( +
+
+
+ +
+ No files were copied. These skills + reference the files in the project workspace — editing them in Skill Studio saves + directly back to those files. +
+
+
+ + ✓ {result.imported.length} imported + + {result.updated.length > 0 && ↻ {result.updated.length} updated} + {result.skipped.length > 0 && ⊘ {result.skipped.length} skipped} +
+
+
+ {importedSkills.length > 0 && ( +
+
+ Imported · {importedSkills.length} +
+
    + {importedSkills.map((skill) => ( +
  • +
    +
    {skill.name}
    + {skill.description && ( +
    + {skill.description} +
    + )} +
    + + Open + +
  • + ))} +
+
+ )} + {result.skipped.length > 0 && ( +
+
+ Skipped · {result.skipped.length} +
+
    + {result.skipped.map((row, index) => ( +
  • + +
    + {row.path ?? "—"} + {row.reason && ( + {row.reason} + )} +
    +
  • + ))} +
+
+ )} + {result.warnings.length > 0 && ( +
+
+ Warnings · {result.warnings.length} +
+
    + {result.warnings.map((warning, index) => ( +
  • + + {warning} +
  • + ))} +
+
+ )} +
+
+ ); +} diff --git a/ui/storybook/stories/skills-store-discovery.stories.tsx b/ui/storybook/stories/skills-store-discovery.stories.tsx index 32bab79b66..1c92aeacb7 100644 --- a/ui/storybook/stories/skills-store-discovery.stories.tsx +++ b/ui/storybook/stories/skills-store-discovery.stories.tsx @@ -308,6 +308,7 @@ function DiscoveryGridHarness({ totalCount={cards.length} onCreate={() => {}} onImport={() => {}} + onImportFromProject={() => {}} onBrowseCatalog={() => setTab("catalog")} onScan={() => {}} scanPending={false}