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**

![Choose a
project](https://raw.githubusercontent.com/cryppadotta/paperclip-prs/refs/heads/pr-assets/import-skills-from-project/01-pick-project.png)

**Review discovered skills**

![Review discovered
skills](https://raw.githubusercontent.com/cryppadotta/paperclip-prs/refs/heads/pr-assets/import-skills-from-project/03-select.png)

**Mobile selection footer**

![Mobile selection
footer](https://raw.githubusercontent.com/cryppadotta/paperclip-prs/refs/heads/pr-assets/import-skills-from-project/select-390.png)

**Import result**

![Import
result](https://raw.githubusercontent.com/cryppadotta/paperclip-prs/refs/heads/pr-assets/import-skills-from-project/06-result.png)

## 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:
Dotta 2026-07-15 18:01:44 -05:00 committed by GitHub
parent 9af96461d5
commit 3ae2c30f2f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
22 changed files with 2310 additions and 26 deletions

View File

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

View File

@ -114,7 +114,7 @@ describeEmbeddedPostgres("resetPostgresDatabase", () => {
} finally {
await verifySql.end();
}
});
}, 30_000);
});
describeEmbeddedPostgres("applyPendingMigrations", () => {

View File

@ -563,6 +563,8 @@ export type {
CompanySkillImportRequest,
CompanySkillImportResult,
CompanySkillProjectScanRequest,
CompanySkillProjectScanCandidateStatus,
CompanySkillProjectScanCandidate,
CompanySkillProjectScanSkipped,
CompanySkillProjectScanConflict,
CompanySkillProjectScanResult,

View File

@ -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[];
}

View File

@ -127,6 +127,8 @@ export type {
CompanySkillImportRequest,
CompanySkillImportResult,
CompanySkillProjectScanRequest,
CompanySkillProjectScanCandidateStatus,
CompanySkillProjectScanCandidate,
CompanySkillProjectScanSkipped,
CompanySkillProjectScanConflict,
CompanySkillProjectScanResult,

View File

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

View File

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

View File

@ -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([]);
});
});

View File

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

View File

@ -114,7 +114,7 @@ describeEmbeddedPostgres("heartbeat responsible-user invariant", () => {
afterAll(async () => {
await tempDb?.cleanup();
});
}, 60_000);
async function seedCompany() {
const companyId = randomUUID();

View File

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

View File

@ -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: {

View File

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

View File

@ -48,7 +48,7 @@ describeEmbeddedPostgres("productivity review service", () => {
afterAll(async () => {
await tempDb?.cleanup();
});
}, 30_000);
async function seedAssignedIssue(opts?: {
status?: "todo" | "in_progress";

View File

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

View File

@ -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({

View File

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

View File

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

View File

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

View File

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

View File

@ -308,6 +308,7 @@ function DiscoveryGridHarness({
totalCount={cards.length}
onCreate={() => {}}
onImport={() => {}}
onImportFromProject={() => {}}
onBrowseCatalog={() => setTab("catalog")}
onScan={() => {}}
scanPending={false}