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:
Dotta 2026-08-02 10:55:36 -05:00 committed by GitHub
parent 0a09e4d975
commit 717684ad8f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 511 additions and 9 deletions

View File

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

View File

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

View File

@ -168,6 +168,9 @@ export type {
CompanySkillImportRequest,
CompanySkillImportResult,
CompanySkillProjectScanRequest,
CompanySkillProjectBrowseRequest,
CompanySkillProjectBrowseEntry,
CompanySkillProjectBrowseResult,
CompanySkillProjectScanCandidateStatus,
CompanySkillProjectScanCandidate,
CompanySkillProjectScanSkipped,

View File

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

View File

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

View File

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

View File

@ -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", () => {

View File

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

View File

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

View File

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

View File

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

View File

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