From 717684ad8f5f33fd3b6e8cf7a96aa7062d8ff29b Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Sun, 2 Aug 2026 10:55:36 -0500 Subject: [PATCH] Add project folder browsing to skill imports (#9930) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is the control plane teams use to manage AI agents and their reusable capabilities. > - Skills Manager lets operators discover and import skills from project workspaces. > - Automatic discovery only surfaces skills in conventional locations, so valid skills stored elsewhere in a project are invisible. > - Operators need a safe way to navigate project folders without exposing paths outside the selected workspace. > - This pull request adds company-scoped workspace folder browsing and selection to the project skill import flow. > - The benefit is that operators can find and import valid skill folders regardless of repository layout while preserving workspace boundaries. ## Linked Issues or Issue Description - **Subsystem affected:** Cross-cutting (`server/`, `ui/`, and `packages/shared`). - **Problem or motivation:** Project skill imports rely on conventional directory discovery, which prevents operators from selecting valid `SKILL.md` folders stored in atypical locations. - **Proposed solution:** Add a company-scoped browse endpoint and a folder browser in the import dialog. The server resolves real paths, rejects traversal outside the workspace, skips symlinks and high-noise directories, identifies skill directories/files, and caps listings at 250 entries. - **Alternatives considered:** Expanding the automatic scan to every directory would be slower and noisier, while accepting arbitrary filesystem paths would weaken project/workspace scoping. - **Roadmap alignment:** This extends the completed “Skills Manager, Skill Studio & Skills Store” capability in `ROADMAP.md` without duplicating planned core work. - **Additional context:** GitHub search found no duplicate or closely related public issues or pull requests. ## What Changed - Added shared browse request/result contracts and validation for project workspace navigation. - Added a company-scoped API route and service that safely lists local workspace folders and detects `SKILL.md` entries. - Added project workspace/folder navigation to the import dialog, including parent navigation, workspace switching, truncation feedback, and direct skill selection. - Added service and route regression tests for browsing, skill detection, company isolation, and traversal rejection. - Added shared response schemas and OpenAPI documentation for the browse endpoint. - Hardened explicit skill selections with realpath containment so symlinked directories cannot escape the project workspace. ## Verification - `pnpm exec vitest run server/src/__tests__/company-skills-service.test.ts server/src/__tests__/company-skills.test.ts` — 64 tests passed. - `pnpm --filter @paperclipai/shared typecheck` — passed. - `pnpm --filter @paperclipai/server typecheck` — passed. - `pnpm --filter @paperclipai/ui typecheck` — passed. - `pnpm exec vitest run server/src/__tests__/openapi-routes.test.ts` — 3 tests passed. - Focused post-review reruns: `company-skills-service.test.ts` — 45 tests passed; shared/server typechecks passed. - GitHub latest-head checks — all green after one transient e2e rerun; no pending or failing checks. - `pnpm check:token-gates` — all gates clean on the rebased head. ## Risks - Low-to-moderate risk: this adds a filesystem browsing surface. Realpath containment checks prevent workspace escape, symlinks are excluded, remote-managed workspaces are rejected, and directory listings are capped. - The browser intentionally hides `.git` and `node_modules`; skills inside those directories cannot be selected through this flow. > 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 - OpenAI Codex, exact model ID `gpt-5.5`, reasoning-enabled with terminal/tool use and code execution; runtime context-window size is not exposed. ## 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) - [ ] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details — the execution harness requires preserving the assigned branch name. - [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 — no user-facing docs changes are needed beyond this PR description because the flow is self-explanatory UI behavior. - [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 --- packages/shared/src/index.ts | 6 + packages/shared/src/types/company-skill.ts | 23 ++ packages/shared/src/types/index.ts | 3 + .../shared/src/validators/company-skill.ts | 24 ++ packages/shared/src/validators/index.ts | 4 + .../__tests__/company-skills-service.test.ts | 76 +++++- server/src/__tests__/company-skills.test.ts | 23 ++ server/src/routes/company-skills.ts | 12 + server/src/routes/openapi.ts | 14 ++ server/src/services/company-skills.ts | 105 ++++++++- ui/src/api/companySkills.ts | 7 + .../skills/ImportSkillsFromProjectDialog.tsx | 223 ++++++++++++++++++ 12 files changed, 511 insertions(+), 9 deletions(-) diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 7ea8c6ac51..3135cf1820 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -682,6 +682,9 @@ export type { CompanySkillImportRequest, CompanySkillImportResult, CompanySkillProjectScanRequest, + CompanySkillProjectBrowseRequest, + CompanySkillProjectBrowseEntry, + CompanySkillProjectBrowseResult, CompanySkillProjectScanCandidateStatus, CompanySkillProjectScanCandidate, CompanySkillProjectScanSkipped, @@ -2045,6 +2048,9 @@ export { companySkillAuditResultSchema, companySkillImportSchema, companySkillProjectScanRequestSchema, + companySkillProjectBrowseRequestSchema, + companySkillProjectBrowseEntrySchema, + companySkillProjectBrowseResultSchema, companySkillProjectScanSkippedSchema, companySkillProjectScanConflictSchema, companySkillProjectScanResultSchema, diff --git a/packages/shared/src/types/company-skill.ts b/packages/shared/src/types/company-skill.ts index 4d72d2e674..ad8bb1fb63 100644 --- a/packages/shared/src/types/company-skill.ts +++ b/packages/shared/src/types/company-skill.ts @@ -343,6 +343,29 @@ export interface CompanySkillProjectScanRequest { }>; } +export interface CompanySkillProjectBrowseRequest { + projectId: string; + workspaceId: string; + path?: string | null; +} + +export interface CompanySkillProjectBrowseEntry { + name: string; + path: string; + kind: "directory" | "file"; + isSkill: boolean; +} + +export interface CompanySkillProjectBrowseResult { + projectId: string; + workspaceId: string; + workspaceName: string; + path: string; + parentPath: string | null; + entries: CompanySkillProjectBrowseEntry[]; + truncated: boolean; +} + export type CompanySkillProjectScanCandidateStatus = "new" | "already_imported" | "conflict" | "skipped"; export interface CompanySkillProjectScanCandidate { diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index 59466c41a7..eab32cbc7c 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -168,6 +168,9 @@ export type { CompanySkillImportRequest, CompanySkillImportResult, CompanySkillProjectScanRequest, + CompanySkillProjectBrowseRequest, + CompanySkillProjectBrowseEntry, + CompanySkillProjectBrowseResult, CompanySkillProjectScanCandidateStatus, CompanySkillProjectScanCandidate, CompanySkillProjectScanSkipped, diff --git a/packages/shared/src/validators/company-skill.ts b/packages/shared/src/validators/company-skill.ts index bc9c7bd292..70bcafde27 100644 --- a/packages/shared/src/validators/company-skill.ts +++ b/packages/shared/src/validators/company-skill.ts @@ -286,6 +286,29 @@ export const companySkillProjectScanRequestSchema = z.object({ })).optional(), }); +export const companySkillProjectBrowseRequestSchema = z.object({ + projectId: z.string().uuid(), + workspaceId: z.string().uuid(), + path: z.string().nullable().optional(), +}); + +export const companySkillProjectBrowseEntrySchema = z.object({ + name: z.string().min(1), + path: z.string().min(1), + kind: z.enum(["directory", "file"]), + isSkill: z.boolean(), +}); + +export const companySkillProjectBrowseResultSchema = z.object({ + projectId: z.string().uuid(), + workspaceId: z.string().uuid(), + workspaceName: z.string().min(1), + path: z.string().min(1), + parentPath: z.string().nullable(), + entries: z.array(companySkillProjectBrowseEntrySchema), + truncated: z.boolean(), +}); + export const companySkillProjectScanCandidateSchema = z.object({ slug: z.string().min(1), name: z.string().min(1), @@ -569,6 +592,7 @@ export const companySkillInstallCatalogResultSchema = z.object({ export type CompanySkillImport = z.infer; export type CompanySkillListQuery = z.infer; export type CompanySkillProjectScan = z.infer; +export type CompanySkillProjectBrowse = z.infer; export type CompanySkillCreate = z.infer; export type CompanySkillFileUpdate = z.infer; export type CompanySkillFileDelete = z.infer; diff --git a/packages/shared/src/validators/index.ts b/packages/shared/src/validators/index.ts index ed562e0e45..da5d5ca7a9 100644 --- a/packages/shared/src/validators/index.ts +++ b/packages/shared/src/validators/index.ts @@ -185,6 +185,9 @@ export { companySkillAuditResultSchema, companySkillImportSchema, companySkillProjectScanRequestSchema, + companySkillProjectBrowseRequestSchema, + companySkillProjectBrowseEntrySchema, + companySkillProjectBrowseResultSchema, companySkillProjectScanSkippedSchema, companySkillProjectScanConflictSchema, companySkillProjectScanResultSchema, @@ -218,6 +221,7 @@ export { type CompanySkillImport, type CompanySkillListQuery, type CompanySkillProjectScan, + type CompanySkillProjectBrowse, type CompanySkillCreate, type CompanySkillFileUpdate, type CompanySkillTestInputCreate, diff --git a/server/src/__tests__/company-skills-service.test.ts b/server/src/__tests__/company-skills-service.test.ts index 4697b67f0c..c2c3541bfc 100644 --- a/server/src/__tests__/company-skills-service.test.ts +++ b/server/src/__tests__/company-skills-service.test.ts @@ -2058,6 +2058,66 @@ describeEmbeddedPostgres("companySkillService.list", () => { expect(versions).toHaveLength(2); }); + it("browses project folders and imports a selected non-standard skill", async () => { + const companyId = randomUUID(); + const projectId = randomUUID(); + const workspaceId = randomUUID(); + const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-skill-browse-")); + cleanupDirs.add(workspaceDir); + const skillDir = path.join(workspaceDir, "content", "teams", "editorial"); + await fs.mkdir(skillDir, { recursive: true }); + await fs.writeFile(path.join(skillDir, "SKILL.md"), "---\nname: Editorial\n---\n", "utf8"); + await fs.writeFile(path.join(workspaceDir, "content", "README.md"), "# Content\n", "utf8"); + await fs.writeFile(path.join(workspaceDir, "content", "skill.md"), "# Not a valid skill filename\n", "utf8"); + for (let entryIndex = 0; entryIndex < 251; entryIndex += 1) { + await fs.symlink(skillDir, path.join(workspaceDir, `ignored-${String(entryIndex).padStart(3, "0")}`)); + } + 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 root = await svc.browseProjectWorkspace(companyId, { projectId, workspaceId }); + expect(root.entries).toEqual([expect.objectContaining({ name: "content", kind: "directory", isSkill: false })]); + expect(root.truncated).toBe(false); + + const content = await svc.browseProjectWorkspace(companyId, { projectId, workspaceId, path: "content" }); + expect(content).toMatchObject({ path: "content", parentPath: "." }); + expect(content.entries).toEqual([ + expect.objectContaining({ name: "teams", kind: "directory", isSkill: false }), + expect.objectContaining({ name: "README.md", kind: "file", isSkill: false }), + expect.objectContaining({ name: "skill.md", kind: "file", isSkill: false }), + ]); + + const teams = await svc.browseProjectWorkspace(companyId, { projectId, workspaceId, path: "content/teams" }); + expect(teams.entries).toEqual([ + expect.objectContaining({ + name: "editorial", + path: "content/teams/editorial", + kind: "directory", + isSkill: true, + }), + ]); + + const imported = await svc.scanProjectWorkspaces(companyId, { + projectIds: [projectId], + mode: "import", + selection: [{ workspaceId, path: "content/teams/editorial" }], + }); + expect(imported.imported).toEqual([expect.objectContaining({ name: "Editorial" })]); + }); + it("previews project workspace skill candidates without importing them", async () => { const companyId = randomUUID(); const projectId = randomUUID(); @@ -2477,7 +2537,9 @@ describeEmbeddedPostgres("companySkillService.list", () => { 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.writeFile(path.join(outsideDir, "SKILL.md"), "---\nname: Outside Directory Skill\n---\n", "utf8"); await fs.symlink(outsideSkillFile, path.join(linkedSkillDir, "SKILL.md")); + await fs.symlink(outsideDir, path.join(workspaceDir, "linked-directory")); await db.insert(companies).values({ id: companyId, name: "Paperclip", @@ -2497,7 +2559,10 @@ describeEmbeddedPostgres("companySkillService.list", () => { const result = await svc.scanProjectWorkspaces(companyId, { mode: "import", workspaceIds: [workspaceId], - selection: [{ workspaceId, path: ".codex/skills/linked-skill" }], + selection: [ + { workspaceId, path: ".codex/skills/linked-skill" }, + { workspaceId, path: "linked-directory" }, + ], }); expect(result.imported).toEqual([]); @@ -2508,13 +2573,18 @@ describeEmbeddedPostgres("companySkillService.list", () => { reason: expect.stringContaining("symbolic link"), }), ]); - expect(result.skipped).toEqual([ + expect(result.skipped).toEqual(expect.arrayContaining([ expect.objectContaining({ workspaceId, path: linkedSkillDir, reason: expect.stringContaining("symbolic link"), }), - ]); + expect.objectContaining({ + workspaceId, + path: "linked-directory", + reason: expect.stringContaining("was not rediscovered"), + }), + ])); expect(result.candidates[0]?.reason).not.toContain(workspaceDir); expect(result.candidates[0]?.reason).not.toContain(outsideDir); expect(result.skipped[0]?.reason).not.toContain(workspaceDir); diff --git a/server/src/__tests__/company-skills.test.ts b/server/src/__tests__/company-skills.test.ts index d30fb48ce2..eaacabb060 100644 --- a/server/src/__tests__/company-skills.test.ts +++ b/server/src/__tests__/company-skills.test.ts @@ -366,6 +366,29 @@ describe("project workspace skill discovery", () => { expect(imported.description).toBe("First line second line"); }); + it("includes explicitly selected skills from non-standard folders", async () => { + const workspace = await makeTempDir("paperclip-skill-workspace-"); + await writeSkillDir(path.join(workspace, "content", "specialists", "editorial"), "Editorial"); + + const discovered = await discoverProjectWorkspaceSkillDirectories({ + projectId: "11111111-1111-1111-1111-111111111111", + projectName: "Repo", + workspaceId: "22222222-2222-2222-2222-222222222222", + workspaceName: "Main", + workspaceCwd: workspace, + }, ["content/specialists/editorial"]); + + expect(discovered).toEqual([ + { + skillDir: path.resolve(workspace, "content", "specialists", "editorial"), + directoryRoot: "content/specialists", + relativePath: "content/specialists/editorial", + inventoryMode: "full", + }, + ]); + }); + + }); describe("missing local skill reconciliation", () => { diff --git a/server/src/routes/company-skills.ts b/server/src/routes/company-skills.ts index 80966a15a8..1aaba318e3 100644 --- a/server/src/routes/company-skills.ts +++ b/server/src/routes/company-skills.ts @@ -12,6 +12,7 @@ import { companySkillInstallCatalogSchema, companySkillInstallUpdateSchema, companySkillListQuerySchema, + companySkillProjectBrowseRequestSchema, companySkillProjectScanRequestSchema, companySkillRenameSchema, companySkillResetSchema, @@ -1210,6 +1211,17 @@ export function companySkillRoutes(db: Db) { }, ); + router.post( + "/companies/:companyId/skills/browse-project", + validate(companySkillProjectBrowseRequestSchema), + async (req, res) => { + const companyId = req.params.companyId as string; + await assertCanMutateCompanySkills(req, companyId, "skills.import", { sourceType: "workspace" }); + const result = await svc.browseProjectWorkspace(companyId, req.body); + res.json(result); + }, + ); + router.post( "/companies/:companyId/skills/scan-projects", validate(companySkillProjectScanRequestSchema), diff --git a/server/src/routes/openapi.ts b/server/src/routes/openapi.ts index 91aea336a5..b8701b6724 100644 --- a/server/src/routes/openapi.ts +++ b/server/src/routes/openapi.ts @@ -119,6 +119,8 @@ import { companySkillFileDeleteSchema, companySkillFileUpdateSchema, companySkillImportSchema, + companySkillProjectBrowseRequestSchema, + companySkillProjectBrowseResultSchema, companySkillProjectScanRequestSchema, companySkillProjectScanResultSchema, companySkillRenameResultSchema, @@ -4726,6 +4728,18 @@ registry.registerPath({ responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized }, }); +registry.registerPath({ + method: "post", + path: "/api/companies/{companyId}/skills/browse-project", + tags: ["skills"], + summary: "Browse a project workspace for skills", + request: { + params: z.object({ companyId: z.string() }), + body: jsonBody(companySkillProjectBrowseRequestSchema), + }, + responses: { 200: r.ok(companySkillProjectBrowseResultSchema), 400: r.badRequest, 401: r.unauthorized }, +}); + registry.registerPath({ method: "post", path: "/api/companies/{companyId}/skills/scan-projects", diff --git a/server/src/services/company-skills.ts b/server/src/services/company-skills.ts index f5b03985c1..4776fadfa2 100644 --- a/server/src/services/company-skills.ts +++ b/server/src/services/company-skills.ts @@ -56,6 +56,8 @@ import type { CompanySkillListItem, CompanySkillLastEditor, CompanySkillOriginalSummary, + CompanySkillProjectBrowseRequest, + CompanySkillProjectBrowseResult, CompanySkillProjectScanConflict, CompanySkillProjectScanCandidate, CompanySkillProjectScanRequest, @@ -1407,7 +1409,10 @@ export async function readLocalSkillImportFromDirectory( }; } -export async function discoverProjectWorkspaceSkillDirectories(target: ProjectSkillScanTarget): Promise(); - const rootSkillPath = path.join(target.workspaceCwd, "SKILL.md"); + const workspaceRoot = await fs.realpath(path.resolve(target.workspaceCwd)).catch(() => null); + if (!workspaceRoot) return []; + const rootSkillPath = path.join(workspaceRoot, "SKILL.md"); if ((await statPath(rootSkillPath))?.isFile()) { - discovered.set(path.resolve(target.workspaceCwd), { + discovered.set(workspaceRoot, { directoryRoot: ".", relativePath: ".", inventoryMode: "project_root", }); } + for (const explicitPath of explicitPaths) { + const relativeSkillDir = explicitPath.toLowerCase().endsWith("/skill.md") + ? path.posix.dirname(explicitPath) + : explicitPath.toLowerCase() === "skill.md" + ? "." + : explicitPath; + const absoluteSkillDir = await fs.realpath(path.resolve(workspaceRoot, relativeSkillDir)).catch(() => null); + if (!absoluteSkillDir) continue; + const relativeToWorkspace = path.relative(workspaceRoot, absoluteSkillDir); + if ( + relativeToWorkspace === ".." + || relativeToWorkspace.startsWith(`..${path.sep}`) + || path.isAbsolute(relativeToWorkspace) + ) continue; + if (!(await statPath(path.join(absoluteSkillDir, "SKILL.md")))?.isFile()) continue; + discovered.set(absoluteSkillDir, { + directoryRoot: relativeSkillDir === "." ? "." : path.posix.dirname(relativeSkillDir), + relativePath: relativeSkillDir, + inventoryMode: relativeSkillDir === "." ? "project_root" : "full", + }); + } + for (const relativeRoot of PROJECT_SCAN_DIRECTORY_ROOTS) { - const absoluteRoot = path.join(target.workspaceCwd, relativeRoot); + const absoluteRoot = path.join(workspaceRoot, relativeRoot); const rootStat = await statPath(absoluteRoot); if (!rootStat?.isDirectory()) continue; @@ -1440,7 +1469,7 @@ export async function discoverProjectWorkspaceSkillDirectories(target: ProjectSk if (!(await statPath(path.join(absoluteSkillDir, "SKILL.md")))?.isFile()) continue; discovered.set(absoluteSkillDir, { directoryRoot: relativeRoot, - relativePath: normalizePortablePath(path.relative(target.workspaceCwd, absoluteSkillDir)), + relativePath: normalizePortablePath(path.relative(workspaceRoot, absoluteSkillDir)), inventoryMode: "full", }); } @@ -4782,6 +4811,66 @@ export function companySkillService(db: Db) { return persistAuditMetadata(reset, postAudit); } + async function browseProjectWorkspace( + companyId: string, + input: CompanySkillProjectBrowseRequest, + ): Promise { + const project = (await projects.listByIds(companyId, [input.projectId]))[0]; + if (!project) throw notFound("Project not found"); + const workspace = project.workspaces.find((entry) => entry.id === input.workspaceId); + if (!workspace) throw notFound("Project workspace not found"); + const workspaceCwd = asString(workspace.cwd); + if (!workspaceCwd || workspace.sourceType === "remote_managed") { + throw unprocessable("Project workspace is not available for local browsing."); + } + + const normalizedPath = input.path?.trim() ? normalizeProjectScanSelectionPath(input.path) : "."; + if (!normalizedPath) throw unprocessable("Project workspace path is invalid."); + const workspaceRoot = await fs.realpath(path.resolve(workspaceCwd)).catch(() => null); + if (!workspaceRoot) throw unprocessable("Project workspace is not available locally."); + const targetPath = await fs.realpath(path.resolve(workspaceRoot, normalizedPath)).catch(() => null); + if (!targetPath) throw notFound("Project workspace folder not found"); + const relativeTarget = path.relative(workspaceRoot, targetPath); + if (relativeTarget === ".." || relativeTarget.startsWith(`..${path.sep}`) || path.isAbsolute(relativeTarget)) { + throw forbidden("Project workspace path is outside the workspace."); + } + const targetStat = await fs.stat(targetPath); + if (!targetStat.isDirectory()) throw unprocessable("Project workspace path must be a folder."); + + const directoryEntries = await fs.readdir(targetPath, { withFileTypes: true }); + const visibleDirectoryEntries = directoryEntries.filter((entry) => ( + !entry.isSymbolicLink() + && entry.name !== ".git" + && entry.name !== "node_modules" + && (entry.isDirectory() || entry.isFile()) + )); + const entries: CompanySkillProjectBrowseResult["entries"] = []; + for (const entry of visibleDirectoryEntries.slice(0, 250)) { + const entryPath = normalizedPath === "." ? entry.name : `${normalizedPath}/${entry.name}`; + entries.push({ + name: entry.name, + path: entryPath, + kind: entry.isDirectory() ? "directory" : "file", + isSkill: entry.isDirectory() + ? Boolean((await statPath(path.join(targetPath, entry.name, "SKILL.md")))?.isFile()) + : entry.name === "SKILL.md", + }); + } + entries.sort((left, right) => { + if (left.kind !== right.kind) return left.kind === "directory" ? -1 : 1; + return left.name.localeCompare(right.name); + }); + return { + projectId: project.id, + workspaceId: workspace.id, + workspaceName: workspace.name, + path: normalizedPath, + parentPath: normalizedPath === "." ? null : path.posix.dirname(normalizedPath) || ".", + entries, + truncated: visibleDirectoryEntries.length > 250, + }; + } + async function scanProjectWorkspaces( companyId: string, input: CompanySkillProjectScanRequest = {}, @@ -4897,7 +4986,10 @@ export function companySkillService(db: Db) { for (const target of scanTargets) { scannedProjectIds.add(target.projectId); - const directories = await discoverProjectWorkspaceSkillDirectories(target); + const explicitPaths = Array.from(selectedPaths.values()) + .filter((selection) => selection.workspaceId === target.workspaceId) + .map((selection) => selection.path); + const directories = await discoverProjectWorkspaceSkillDirectories(target, explicitPaths); for (const directory of directories) { discovered += 1; @@ -6835,6 +6927,7 @@ export function companySkillService(db: Db) { pruneExpiredTestHarnessIssues, importFromSource, installFromCatalog, + browseProjectWorkspace, scanProjectWorkspaces, importPackageFiles, auditSkill, diff --git a/ui/src/api/companySkills.ts b/ui/src/api/companySkills.ts index 731a8f6da7..f80d0b8629 100644 --- a/ui/src/api/companySkills.ts +++ b/ui/src/api/companySkills.ts @@ -20,6 +20,8 @@ import type { CompanySkillInstallCatalogResult, CompanySkillListQuery, CompanySkillListItem, + CompanySkillProjectBrowseRequest, + CompanySkillProjectBrowseResult, CompanySkillProjectScanRequest, CompanySkillProjectScanResult, CompanySkillStarResult, @@ -215,6 +217,11 @@ export const companySkillsApi = { `/companies/${encodeURIComponent(companyId)}/skills/import`, { source }, ), + browseProject: (companyId: string, payload: CompanySkillProjectBrowseRequest) => + api.post( + `/companies/${encodeURIComponent(companyId)}/skills/browse-project`, + payload, + ), scanProjects: (companyId: string, payload: CompanySkillProjectScanRequest = {}) => api.post( `/companies/${encodeURIComponent(companyId)}/skills/scan-projects`, diff --git a/ui/src/pages/skills/ImportSkillsFromProjectDialog.tsx b/ui/src/pages/skills/ImportSkillsFromProjectDialog.tsx index 019a3a1a1a..f20b87625b 100644 --- a/ui/src/pages/skills/ImportSkillsFromProjectDialog.tsx +++ b/ui/src/pages/skills/ImportSkillsFromProjectDialog.tsx @@ -5,8 +5,12 @@ import { AlertTriangle, ArrowLeft, CheckCircle2, + ChevronRight, ExternalLink, + FileText, FileWarning, + Folder, + FolderOpen, FolderSearch, Layers, Link2, @@ -17,6 +21,7 @@ import { } from "lucide-react"; import type { CompanySkill, + CompanySkillProjectBrowseEntry, CompanySkillProjectScanCandidate, CompanySkillProjectScanResult, Project, @@ -287,6 +292,8 @@ export function ImportSkillsFromProjectDialog({ const [scanError, setScanError] = useState(null); const [selection, setSelection] = useState>(new Map()); const [importResult, setImportResult] = useState(null); + const [browseOpen, setBrowseOpen] = useState(false); + const [browseAddingKey, setBrowseAddingKey] = useState(null); const scanTokenRef = useRef(0); const projectsQuery = useQuery({ @@ -306,6 +313,8 @@ export function ImportSkillsFromProjectDialog({ setScanError(null); setSelection(new Map()); setImportResult(null); + setBrowseOpen(false); + setBrowseAddingKey(null); scanTokenRef.current += 1; }, [open]); @@ -421,6 +430,50 @@ export function ImportSkillsFromProjectDialog({ }); } + async function addBrowsedSkill(workspaceId: string, selectedPath: string) { + if (!selectedProject) return; + const skillPath = selectedPath.toLowerCase().endsWith("/skill.md") + ? selectedPath.slice(0, -"/SKILL.md".length) || "." + : selectedPath.toLowerCase() === "skill.md" + ? "." + : selectedPath; + const key = selectionKey(workspaceId, skillPath); + setBrowseAddingKey(key); + try { + const result = await companySkillsApi.scanProjects(companyId, { + projectIds: [selectedProject.id], + mode: "preview", + selection: [{ workspaceId, path: skillPath }], + }); + setScanResult(result); + const candidate = result.candidates.find((entry) => ( + entry.workspaceId === workspaceId && entry.relativePath === skillPath + )); + if (candidate && isSelectableCandidate(candidate)) { + setSelection((previous) => { + const next = new Map(previous); + next.set(key, { + workspaceId, + path: skillPath, + ...(candidate.status === "conflict" ? { slug: suggestedConflictSlug(candidate) } : {}), + }); + return next; + }); + setBrowseOpen(false); + } else { + toast.pushToast({ + tone: "warn", + title: candidate?.status === "already_imported" ? "Skill already imported" : "Skill could not be added", + body: candidate?.reason ?? "The selected folder does not contain a valid SKILL.md file.", + }); + } + } catch (error) { + toast.pushToast({ tone: "error", title: "Could not inspect skill", body: readableErrorMessage(error) }); + } finally { + setBrowseAddingKey(null); + } + } + function selectAll() { setSelection(selectAllSelection(candidates)); } @@ -501,6 +554,12 @@ export function ImportSkillsFromProjectDialog({ selection={selection} toggleCandidate={toggleCandidate} renameCandidate={renameCandidate} + project={selectedProject} + companyId={companyId} + browseOpen={browseOpen} + onBrowseOpenChange={setBrowseOpen} + onAddBrowsedSkill={addBrowsedSkill} + browseAddingKey={browseAddingKey} /> )} {step === "result" && importResult && } @@ -717,6 +776,131 @@ function ScanningStep({ projectName }: { projectName: string }) { ); } + +function ProjectSkillBrowser({ + companyId, + project, + onBack, + onAddSkill, + addingKey, +}: { + companyId: string; + project: Project; + onBack: () => void; + onAddSkill: (workspaceId: string, path: string) => void; + addingKey: string | null; +}) { + const workspaces = scannableWorkspaces(project); + const initialWorkspace = workspaces.find((workspace) => workspace.isPrimary) ?? workspaces[0] ?? null; + const [workspaceId, setWorkspaceId] = useState(initialWorkspace?.id ?? ""); + const [folderPath, setFolderPath] = useState("."); + const browseQuery = useQuery({ + queryKey: ["company-skills", "browse-project", companyId, project.id, workspaceId, folderPath], + queryFn: () => companySkillsApi.browseProject(companyId, { + projectId: project.id, + workspaceId, + path: folderPath, + }), + enabled: Boolean(workspaceId), + }); + const result = browseQuery.data; + + function changeWorkspace(nextWorkspaceId: string) { + setWorkspaceId(nextWorkspaceId); + setFolderPath("."); + } + + return ( +
+
+
+
+

Browse project folders

+

Open any folder and add directories or individual SKILL.md files.

+
+ +
+ {workspaces.length > 1 && ( +
+ {workspaces.map((workspace) => ( + + ))} +
+ )} +
+
+ + {result?.path ?? folderPath} +
+
+ {browseQuery.isLoading ? ( +
+ Loading folder… +
+ ) : browseQuery.error ? ( +
{readableErrorMessage(browseQuery.error)}
+ ) : result?.entries.length ? ( +
    + {result.entries.map((entry: CompanySkillProjectBrowseEntry) => { + const selectablePath = entry.kind === "file" + ? entry.path.toLowerCase() === "skill.md" ? "." : entry.path.slice(0, -"/SKILL.md".length) + : entry.path; + const key = selectionKey(workspaceId, selectablePath); + return ( +
  • + {entry.kind === "directory" ? ( + + ) : ( + + )} + + {entry.isSkill ? ( + + ) : entry.kind === "directory" ? ( + + ) : null} +
  • + ); + })} +
+ ) : ( +
This folder is empty.
+ )} + {result?.truncated && ( +

Showing the first 250 entries.

+ )} +
+
+ ); +} + interface SelectStepProps { scanError: unknown; onRetry: () => void; @@ -728,6 +912,12 @@ interface SelectStepProps { selection: Map; toggleCandidate: (candidate: CompanySkillProjectScanCandidate) => void; renameCandidate: (candidate: CompanySkillProjectScanCandidate, slug: string) => void; + project: Project | null; + companyId: string; + browseOpen: boolean; + onBrowseOpenChange: (open: boolean) => void; + onAddBrowsedSkill: (workspaceId: string, path: string) => void; + browseAddingKey: string | null; } function SelectStep({ @@ -741,6 +931,12 @@ function SelectStep({ selection, toggleCandidate, renameCandidate, + project, + companyId, + browseOpen, + onBrowseOpenChange, + onAddBrowsedSkill, + browseAddingKey, }: SelectStepProps) { if (scanError) { const grant = isGrantError(scanError); @@ -772,6 +968,18 @@ function SelectStep({ ); } + if (browseOpen && project) { + return ( + onBrowseOpenChange(false)} + onAddSkill={onAddBrowsedSkill} + addingKey={browseAddingKey} + /> + ); + } + if (totalCandidates === 0) { return (
+ {onImportFromPath && (

For skills in non-standard folders, use{" "} @@ -810,6 +1027,12 @@ function SelectStep({ return (

+
+

Choose discovered skills, or browse any workspace folder.

+ +