Add project folder browsing to skill imports (#9930)
## 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 <noreply@paperclip.ing>
This commit is contained in:
parent
0a09e4d975
commit
717684ad8f
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -168,6 +168,9 @@ export type {
|
|||
CompanySkillImportRequest,
|
||||
CompanySkillImportResult,
|
||||
CompanySkillProjectScanRequest,
|
||||
CompanySkillProjectBrowseRequest,
|
||||
CompanySkillProjectBrowseEntry,
|
||||
CompanySkillProjectBrowseResult,
|
||||
CompanySkillProjectScanCandidateStatus,
|
||||
CompanySkillProjectScanCandidate,
|
||||
CompanySkillProjectScanSkipped,
|
||||
|
|
|
|||
|
|
@ -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<typeof companySkillImportSchema>;
|
||||
export type CompanySkillListQuery = z.infer<typeof companySkillListQuerySchema>;
|
||||
export type CompanySkillProjectScan = z.infer<typeof companySkillProjectScanRequestSchema>;
|
||||
export type CompanySkillProjectBrowse = z.infer<typeof companySkillProjectBrowseRequestSchema>;
|
||||
export type CompanySkillCreate = z.infer<typeof companySkillCreateSchema>;
|
||||
export type CompanySkillFileUpdate = z.infer<typeof companySkillFileUpdateSchema>;
|
||||
export type CompanySkillFileDelete = z.infer<typeof companySkillFileDeleteSchema>;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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<Array<{
|
||||
export async function discoverProjectWorkspaceSkillDirectories(
|
||||
target: ProjectSkillScanTarget,
|
||||
explicitPaths: string[] = [],
|
||||
): Promise<Array<{
|
||||
skillDir: string;
|
||||
directoryRoot: string;
|
||||
relativePath: string;
|
||||
|
|
@ -1418,17 +1423,41 @@ export async function discoverProjectWorkspaceSkillDirectories(target: ProjectSk
|
|||
relativePath: string;
|
||||
inventoryMode: LocalSkillInventoryMode;
|
||||
}>();
|
||||
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<CompanySkillProjectBrowseResult> {
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -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<CompanySkillProjectBrowseResult>(
|
||||
`/companies/${encodeURIComponent(companyId)}/skills/browse-project`,
|
||||
payload,
|
||||
),
|
||||
scanProjects: (companyId: string, payload: CompanySkillProjectScanRequest = {}) =>
|
||||
api.post<CompanySkillProjectScanResult>(
|
||||
`/companies/${encodeURIComponent(companyId)}/skills/scan-projects`,
|
||||
|
|
|
|||
|
|
@ -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<unknown>(null);
|
||||
const [selection, setSelection] = useState<Map<string, SkillSelection>>(new Map());
|
||||
const [importResult, setImportResult] = useState<CompanySkillProjectScanResult | null>(null);
|
||||
const [browseOpen, setBrowseOpen] = useState(false);
|
||||
const [browseAddingKey, setBrowseAddingKey] = useState<string | null>(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 && <ResultStep 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 (
|
||||
<div className="flex min-h-0 flex-1 flex-col" data-testid="project-skill-browser">
|
||||
<div className="shrink-0 border-b border-border/60 px-5 py-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-sm font-medium">Browse project folders</p>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">Open any folder and add directories or individual SKILL.md files.</p>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={onBack}>
|
||||
<ArrowLeft className="mr-1 h-3.5 w-3.5" /> Discovered skills
|
||||
</Button>
|
||||
</div>
|
||||
{workspaces.length > 1 && (
|
||||
<div className="mt-3 flex flex-wrap gap-1.5" aria-label="Project workspace">
|
||||
{workspaces.map((workspace) => (
|
||||
<Button
|
||||
key={workspace.id}
|
||||
type="button"
|
||||
variant={workspace.id === workspaceId ? "secondary" : "ghost"}
|
||||
size="sm"
|
||||
onClick={() => changeWorkspace(workspace.id)}
|
||||
>
|
||||
{workspace.name}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2 border-b border-border/60 bg-muted/20 px-5 py-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => result?.parentPath && setFolderPath(result.parentPath)}
|
||||
disabled={!result?.parentPath}
|
||||
aria-label="Open parent folder"
|
||||
>
|
||||
<ArrowLeft className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<span className="min-w-0 truncate font-mono text-xs text-muted-foreground">{result?.path ?? folderPath}</span>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto">
|
||||
{browseQuery.isLoading ? (
|
||||
<div className="flex items-center justify-center gap-2 p-8 text-sm text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" /> Loading folder…
|
||||
</div>
|
||||
) : browseQuery.error ? (
|
||||
<div className="p-6 text-sm text-destructive">{readableErrorMessage(browseQuery.error)}</div>
|
||||
) : result?.entries.length ? (
|
||||
<ul className="divide-y divide-border/60">
|
||||
{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 (
|
||||
<li key={entry.path} className="flex items-center gap-3 px-5 py-2.5">
|
||||
{entry.kind === "directory" ? (
|
||||
<Folder className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
) : (
|
||||
<FileText className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className={cn("min-w-0 flex-1 truncate text-left text-sm", entry.kind === "directory" && "font-medium hover:underline")}
|
||||
onClick={() => entry.kind === "directory" && setFolderPath(entry.path)}
|
||||
disabled={entry.kind === "file"}
|
||||
>
|
||||
{entry.name}
|
||||
</button>
|
||||
{entry.isSkill ? (
|
||||
<Button size="sm" onClick={() => onAddSkill(workspaceId, entry.path)} disabled={addingKey === key}>
|
||||
{addingKey === key ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : "Add skill"}
|
||||
</Button>
|
||||
) : entry.kind === "directory" ? (
|
||||
<ChevronRight className="h-4 w-4 text-muted-foreground" />
|
||||
) : null}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
) : (
|
||||
<div className="p-8 text-center text-sm text-muted-foreground">This folder is empty.</div>
|
||||
)}
|
||||
{result?.truncated && (
|
||||
<p className="border-t border-border/60 px-5 py-2 text-xs text-muted-foreground">Showing the first 250 entries.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface SelectStepProps {
|
||||
scanError: unknown;
|
||||
onRetry: () => void;
|
||||
|
|
@ -728,6 +912,12 @@ interface SelectStepProps {
|
|||
selection: Map<string, SkillSelection>;
|
||||
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 (
|
||||
<ProjectSkillBrowser
|
||||
companyId={companyId}
|
||||
project={project}
|
||||
onBack={() => onBrowseOpenChange(false)}
|
||||
onAddSkill={onAddBrowsedSkill}
|
||||
addingKey={browseAddingKey}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (totalCandidates === 0) {
|
||||
return (
|
||||
<div
|
||||
|
|
@ -790,6 +998,15 @@ function SelectStep({
|
|||
HIGHLIGHTED_SCAN_FOLDERS.length}{" "}
|
||||
other agent-harness folders.
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="mt-4"
|
||||
onClick={() => onBrowseOpenChange(true)}
|
||||
data-testid="browse-project-folders-empty"
|
||||
>
|
||||
<FolderOpen className="mr-1.5 h-3.5 w-3.5" /> Browse project folders
|
||||
</Button>
|
||||
{onImportFromPath && (
|
||||
<p className="mt-3 text-sm text-muted-foreground">
|
||||
For skills in non-standard folders, use{" "}
|
||||
|
|
@ -810,6 +1027,12 @@ function SelectStep({
|
|||
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col" data-testid="candidate-list">
|
||||
<div className="flex shrink-0 items-center justify-between gap-3 border-b border-border/60 px-5 py-2.5">
|
||||
<p className="text-xs text-muted-foreground">Choose discovered skills, or browse any workspace folder.</p>
|
||||
<Button variant="outline" size="sm" onClick={() => onBrowseOpenChange(true)} data-testid="browse-project-folders">
|
||||
<FolderOpen className="mr-1.5 h-3.5 w-3.5" /> Browse folders
|
||||
</Button>
|
||||
</div>
|
||||
<div className="shrink-0 border-b border-border/60 px-5 py-2.5">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
|
||||
|
|
|
|||
Loading…
Reference in New Issue