mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-01 15:35:12 +01:00
feat(skills): import skills from projects (#9620)
## Thinking Path > - Paperclip is the open source control plane people use to manage AI-agent companies. > - Company skills make reusable agent behavior discoverable and editable from one place. > - Projects already contain skill directories, but operators had to import each skill path manually. > - Copying those skills would break the desired write-through workflow between Skill Studio and the source project. > - The server therefore needs a safe preview/select/import contract that only accepts rediscovered, workspace-contained candidates. > - The UI needs a guided project picker that explains reference semantics, handles conflicts, and remains usable on mobile. > - This pull request adds that end-to-end project skill import flow with authorization, tenant-scope, traversal, and symlink regression coverage. > - The benefit is faster bulk onboarding while keeping project files as the single source of truth. ## Linked Issues or Issue Description **Feature request** **Problem:** Importing several skills already stored in a Paperclip project requires operators to discover and submit each local path individually. This is slow, hides which well-known directories were searched, and makes conflict/already-imported states difficult to evaluate before mutation. **Proposed solution:** Add an “Import skills from project” flow that previews skills from well-known directories, lets operators selectively import eligible candidates, and stores local-path references so Skill Studio edits write through to the project files. **Alternatives considered:** Copying files into company-managed skill storage was rejected because it creates divergent copies. Trusting client-supplied paths was rejected because imports must be constrained to server-rediscovered, workspace-contained candidates. **Additional context:** GitHub duplicate search found no existing issue or PR for this exact workflow. Refs #3799 for related skill-import inventory behavior; this PR does not claim to close that issue. ## What Changed - Extend `scan-projects` with backward-compatible preview and selective-import modes, typed validation, candidate statuses, and OpenAPI coverage. - Discover project skills under `skills`, `.agents/skills`, `.claude/skills`, `.codex/skills`, `.cursor/skills`, `.opencode/skills`, and `.gemini/skills`. - Re-discover selections server-side, enforce company/project/workspace scope, and reject traversal or symlink escapes before creating `local_path` references. - Add the Skills-page menu entry and responsive project import dialog with project selection, grouped candidates, select all/deselect all, conflicts, empty/error/403 states, and import results. - Add route, service, and component regressions for preview authorization, cross-tenant selections, traversal/symlink safety, selection counts, grouping, and result semantics. ### Screenshots **Choose a project**  **Review discovered skills**  **Mobile selection footer**  **Import result**  ## Verification - `pnpm exec vitest run server/src/__tests__/company-skills-service.test.ts server/src/__tests__/company-skills-routes.test.ts ui/src/pages/skills/ImportSkillsFromProjectDialog.test.tsx` — 3 files, 81 tests passed. - `pnpm check:token-gates` — all token gates clean. - `pnpm --filter @paperclipai/shared typecheck` — passed. - `pnpm --filter @paperclipai/server typecheck` — passed. - `pnpm --filter @paperclipai/ui typecheck` — passed. - Security review passed after adding tenant-scope and unauthorized-preview regressions; UX re-review approved desktop/mobile surfaces; QA passed all seven acceptance areas including write-through editing, deduplication, conflicts, empty state, and permission denial. ## Risks - Files remain referenced in project workspaces, so moving or deleting a source directory can make an imported skill unavailable; the UI explicitly communicates the reference behavior. - New well-known directory scans may discover more candidates than older versions, but preview mode prevents mutation until the operator confirms a selection. - The endpoint remains backward compatible: omitting `mode` preserves the prior full-import behavior. - No schema migration or telemetry event changes. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used - Anthropic Claude Opus 4.8 with tool use/code execution assisted with the UI implementation and UX polish. OpenAI Codex CLI with tool use/code execution assisted with server implementation, security fixes, regression coverage, integration, and PR preparation; the runtime did not expose Codex's exact backing model ID or context-window size. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
22 files changed
+2310
-26
No files matched your search
@@ -253,6 +253,7 @@ async function renderDiscoveryGrid(props: Partial<ComponentProps<typeof Discover
|
||||
totalCount={0}
|
||||
onCreate={vi.fn()}
|
||||
onImport={vi.fn()}
|
||||
onImportFromProject={vi.fn()}
|
||||
onBrowseCatalog={vi.fn()}
|
||||
onScan={vi.fn()}
|
||||
scanPending={false}
|
||||
|
||||
@@ -85,6 +85,7 @@ import {
|
||||
type SkillCreateDraft,
|
||||
} from "../lib/skill-create";
|
||||
import { SkillCardIcon } from "../components/SkillCardIcon";
|
||||
import { ImportSkillsFromProjectDialog } from "./skills/ImportSkillsFromProjectDialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
@@ -105,6 +106,7 @@ import {
|
||||
FileText,
|
||||
Folder,
|
||||
FolderOpen,
|
||||
FolderSearch,
|
||||
GitFork,
|
||||
Github,
|
||||
Globe,
|
||||
@@ -880,6 +882,7 @@ export function DiscoveryGrid({
|
||||
totalCount,
|
||||
onCreate,
|
||||
onImport,
|
||||
onImportFromProject,
|
||||
onBrowseCatalog,
|
||||
onScan,
|
||||
scanPending,
|
||||
@@ -903,6 +906,7 @@ export function DiscoveryGrid({
|
||||
totalCount: number;
|
||||
onCreate: () => void;
|
||||
onImport: () => void;
|
||||
onImportFromProject: () => void;
|
||||
onBrowseCatalog: () => void;
|
||||
onScan: () => void;
|
||||
scanPending: boolean;
|
||||
@@ -1041,6 +1045,10 @@ export function DiscoveryGrid({
|
||||
<Globe className="mr-2 h-4 w-4" />
|
||||
Import from path or URL
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={onImportFromProject}>
|
||||
<FolderSearch className="mr-2 h-4 w-4" />
|
||||
Import skills from project
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
@@ -3541,6 +3549,7 @@ export function CompanySkills() {
|
||||
const [discoverySort, setDiscoverySort] = useState<DiscoverySort>("agents");
|
||||
const [createError, setCreateError] = useState<string | null>(null);
|
||||
const [importDialogOpen, setImportDialogOpen] = useState(false);
|
||||
const [importFromProjectOpen, setImportFromProjectOpen] = useState(false);
|
||||
const parsedRoute = useMemo(() => parseSkillRoute(routePath), [routePath]);
|
||||
const isStudioNew = routePath === "studio/new";
|
||||
const routeSkillToken = isStudioNew ? null : parsedRoute.skillToken;
|
||||
@@ -4431,6 +4440,18 @@ export function CompanySkills() {
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{selectedCompanyId ? (
|
||||
<ImportSkillsFromProjectDialog
|
||||
open={importFromProjectOpen}
|
||||
onOpenChange={setImportFromProjectOpen}
|
||||
companyId={selectedCompanyId}
|
||||
onImportFromPath={() => {
|
||||
setImportFromProjectOpen(false);
|
||||
setImportDialogOpen(true);
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{isStudioNew ? (
|
||||
<div className="min-h-(--sz-calc-30)">
|
||||
<div className="border-b border-border px-4 py-5">
|
||||
@@ -4482,6 +4503,7 @@ export function CompanySkills() {
|
||||
totalCount={discoveryCards.length}
|
||||
onCreate={() => navigate(skillStudioNewRoute())}
|
||||
onImport={() => setImportDialogOpen(true)}
|
||||
onImportFromProject={() => setImportFromProjectOpen(true)}
|
||||
onBrowseCatalog={() => setDiscoveryTab("catalog")}
|
||||
onScan={() => scanProjects.mutate()}
|
||||
scanPending={scanProjects.isPending}
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import type {
|
||||
CompanySkillProjectScanCandidate,
|
||||
Project,
|
||||
ProjectWorkspace,
|
||||
} from "@paperclipai/shared";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
defaultSelection,
|
||||
filterCandidates,
|
||||
groupCandidates,
|
||||
isScannableWorkspace,
|
||||
isSelectableCandidate,
|
||||
isValidSelectionSlug,
|
||||
scannableWorkspaces,
|
||||
selectAllSelection,
|
||||
selectionKey,
|
||||
suggestedConflictSlug,
|
||||
} from "./ImportSkillsFromProjectDialog";
|
||||
|
||||
const WS_A = "11111111-1111-1111-1111-111111111111";
|
||||
const WS_B = "22222222-2222-2222-2222-222222222222";
|
||||
|
||||
function candidate(
|
||||
overrides: Partial<CompanySkillProjectScanCandidate> &
|
||||
Pick<CompanySkillProjectScanCandidate, "slug" | "relativePath" | "status">,
|
||||
): CompanySkillProjectScanCandidate {
|
||||
return {
|
||||
name: overrides.slug,
|
||||
description: null,
|
||||
workspaceId: WS_A,
|
||||
workspaceName: "Workspace A",
|
||||
projectId: "33333333-3333-3333-3333-333333333333",
|
||||
projectName: "Project",
|
||||
directoryRoot: ".claude/skills",
|
||||
...overrides,
|
||||
} as CompanySkillProjectScanCandidate;
|
||||
}
|
||||
|
||||
/**
|
||||
* A mixed candidate set: two new skills, one selectable conflict, and two
|
||||
* disabled statuses.
|
||||
*/
|
||||
function mixedCandidates(): CompanySkillProjectScanCandidate[] {
|
||||
return [
|
||||
candidate({ slug: "alpha", relativePath: ".claude/skills/alpha", status: "new" }),
|
||||
candidate({ slug: "beta", relativePath: ".claude/skills/beta", status: "new" }),
|
||||
candidate({
|
||||
slug: "gamma",
|
||||
relativePath: ".claude/skills/gamma",
|
||||
status: "already_imported",
|
||||
existingSkillId: "44444444-4444-4444-4444-444444444444",
|
||||
}),
|
||||
candidate({
|
||||
slug: "delta",
|
||||
relativePath: ".claude/skills/delta",
|
||||
status: "conflict",
|
||||
reason: "Slug delta is already in use.",
|
||||
}),
|
||||
candidate({
|
||||
slug: "epsilon",
|
||||
relativePath: ".claude/skills/epsilon",
|
||||
status: "skipped",
|
||||
reason: "Could not parse SKILL.md.",
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
describe("ImportSkillsFromProjectDialog selection logic", () => {
|
||||
it("new and conflict candidates are selectable", () => {
|
||||
const candidates = mixedCandidates();
|
||||
const selectable = candidates.filter(isSelectableCandidate);
|
||||
expect(selectable.map((c) => c.slug)).toEqual(["alpha", "beta", "delta"]);
|
||||
});
|
||||
|
||||
it("default selection leaves every candidate unchecked", () => {
|
||||
expect(defaultSelection(mixedCandidates())).toEqual(new Map());
|
||||
});
|
||||
|
||||
it("select all checks new candidates and leaves conflicts unchecked", () => {
|
||||
const candidates = mixedCandidates();
|
||||
const selectAll = selectAllSelection(candidates);
|
||||
const defaultChecked = candidates.filter((candidate) => candidate.status === "new");
|
||||
expect(selectAll.size).toBe(defaultChecked.length);
|
||||
for (const c of defaultChecked) {
|
||||
expect(selectAll.has(selectionKey(c.workspaceId, c.relativePath))).toBe(true);
|
||||
}
|
||||
expect(selectAll.has(selectionKey(WS_A, ".claude/skills/delta"))).toBe(false);
|
||||
});
|
||||
|
||||
it("deselect all yields an empty selection (N = 0)", () => {
|
||||
const deselectAll = new Map<string, { workspaceId: string; path: string }>();
|
||||
expect(deselectAll.size).toBe(0);
|
||||
});
|
||||
|
||||
it("selection payload carries workspaceId + path for each checked new candidate", () => {
|
||||
const selection = selectAllSelection(mixedCandidates());
|
||||
const payload = Array.from(selection.values());
|
||||
expect(payload).toEqual([
|
||||
{ workspaceId: WS_A, path: ".claude/skills/alpha" },
|
||||
{ workspaceId: WS_A, path: ".claude/skills/beta" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("toggling a single new candidate removes only that row from N", () => {
|
||||
const selection = selectAllSelection(mixedCandidates());
|
||||
const key = selectionKey(WS_A, ".claude/skills/alpha");
|
||||
selection.delete(key);
|
||||
expect(selection.size).toBe(1);
|
||||
expect(selection.has(key)).toBe(false);
|
||||
expect(selection.has(selectionKey(WS_A, ".claude/skills/beta"))).toBe(true);
|
||||
});
|
||||
|
||||
it("groups folders beneath each workspace and sorts the primary workspace first", () => {
|
||||
const candidates = [
|
||||
candidate({ slug: "a", relativePath: ".claude/skills/a", status: "new" }),
|
||||
candidate({
|
||||
slug: "b",
|
||||
relativePath: "skills/b",
|
||||
directoryRoot: "skills",
|
||||
status: "new",
|
||||
}),
|
||||
candidate({
|
||||
slug: "c",
|
||||
relativePath: ".claude/skills/c",
|
||||
workspaceId: WS_B,
|
||||
workspaceName: "Workspace B",
|
||||
status: "new",
|
||||
}),
|
||||
];
|
||||
const groups = groupCandidates(candidates, [
|
||||
workspace({ id: WS_A, name: "Workspace A", isPrimary: false }),
|
||||
workspace({ id: WS_B, name: "Workspace B", isPrimary: true }),
|
||||
]);
|
||||
expect(groups).toHaveLength(2);
|
||||
expect(groups.map((group) => group.workspaceName)).toEqual(["Workspace B", "Workspace A"]);
|
||||
expect(groups[1]?.directories.map((directory) => directory.directoryRoot)).toEqual([
|
||||
".claude/skills",
|
||||
"skills",
|
||||
]);
|
||||
});
|
||||
|
||||
it("filters candidates by name, slug, path, workspace, and status", () => {
|
||||
const candidates = mixedCandidates();
|
||||
expect(filterCandidates(candidates, "DELTA").map((entry) => entry.slug)).toEqual(["delta"]);
|
||||
expect(filterCandidates(candidates, "already_imported").map((entry) => entry.slug)).toEqual(["gamma"]);
|
||||
expect(filterCandidates(candidates, ".claude/skills/epsilon").map((entry) => entry.slug)).toEqual(["epsilon"]);
|
||||
expect(filterCandidates(candidates, "workspace a")).toHaveLength(candidates.length);
|
||||
});
|
||||
|
||||
it("suggests and validates a URL-safe rename for conflicts", () => {
|
||||
const conflict = mixedCandidates().find((entry) => entry.status === "conflict")!;
|
||||
expect(suggestedConflictSlug(conflict)).toBe("delta-copy");
|
||||
expect(isValidSelectionSlug({ workspaceId: WS_A, path: conflict.relativePath, slug: "delta-copy" })).toBe(true);
|
||||
expect(isValidSelectionSlug({ workspaceId: WS_A, path: conflict.relativePath, slug: "Delta Copy" })).toBe(false);
|
||||
expect(isValidSelectionSlug({ workspaceId: WS_A, path: conflict.relativePath, slug: "" })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
function workspace(overrides: Partial<ProjectWorkspace>): ProjectWorkspace {
|
||||
return {
|
||||
id: WS_A,
|
||||
companyId: "c",
|
||||
projectId: "p",
|
||||
name: "ws",
|
||||
sourceType: "local_path",
|
||||
cwd: "/srv/project",
|
||||
repoUrl: null,
|
||||
repoRef: null,
|
||||
defaultRef: null,
|
||||
visibility: "default",
|
||||
setupCommand: null,
|
||||
cleanupCommand: null,
|
||||
remoteProvider: null,
|
||||
remoteWorkspaceRef: null,
|
||||
sharedWorkspaceKey: null,
|
||||
metadata: null,
|
||||
runtimeConfig: null,
|
||||
isPrimary: true,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
...overrides,
|
||||
} as ProjectWorkspace;
|
||||
}
|
||||
|
||||
describe("scannable workspace detection", () => {
|
||||
it("local/git/folder workspaces with a cwd are scannable", () => {
|
||||
expect(isScannableWorkspace(workspace({ sourceType: "local_path" }))).toBe(true);
|
||||
expect(isScannableWorkspace(workspace({ sourceType: "git_repo" }))).toBe(true);
|
||||
expect(isScannableWorkspace(workspace({ sourceType: "non_git_path" }))).toBe(true);
|
||||
});
|
||||
|
||||
it("remote-managed workspaces are never scannable", () => {
|
||||
expect(
|
||||
isScannableWorkspace(workspace({ sourceType: "remote_managed", cwd: null })),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("workspaces without a cwd are not scannable", () => {
|
||||
expect(isScannableWorkspace(workspace({ cwd: null }))).toBe(false);
|
||||
expect(isScannableWorkspace(workspace({ cwd: " " }))).toBe(false);
|
||||
});
|
||||
|
||||
it("a remote-only project has zero scannable workspaces", () => {
|
||||
const project = {
|
||||
workspaces: [
|
||||
workspace({ id: WS_A, sourceType: "remote_managed", cwd: null }),
|
||||
workspace({ id: WS_B, sourceType: "remote_managed", cwd: null }),
|
||||
],
|
||||
} as unknown as Project;
|
||||
expect(scannableWorkspaces(project)).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user