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:
authored and GitHub committed 2026-07-15 18:01:44 -05:00
1 parent 9af96461d5
commit 3ae2c30f2f
22 files changed
+2310 -26

No files matched your search

+336 -10
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,
};
}