diff --git a/server/src/__tests__/company-skills-service.test.ts b/server/src/__tests__/company-skills-service.test.ts index 88be5f595be..ae931cb9a2c 100644 --- a/server/src/__tests__/company-skills-service.test.ts +++ b/server/src/__tests__/company-skills-service.test.ts @@ -53,6 +53,26 @@ describeEmbeddedPostgres("companySkillService.list", () => { await tempDb?.cleanup(); }); + async function createCompany(companyId = randomUUID()) { + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }); + return companyId; + } + + async function writeSkillTree(skillDir: string, name: string) { + await fs.mkdir(path.join(skillDir, "references"), { recursive: true }); + await fs.mkdir(path.join(skillDir, "scripts"), { recursive: true }); + await fs.mkdir(path.join(skillDir, "assets"), { recursive: true }); + await fs.writeFile(path.join(skillDir, "SKILL.md"), `---\nname: ${name}\n---\n\n# ${name}\n`, "utf8"); + await fs.writeFile(path.join(skillDir, "references", "how-to-guide-template.md"), "# Guide Template\n", "utf8"); + await fs.writeFile(path.join(skillDir, "scripts", "bootstrap.sh"), "echo ok\n", "utf8"); + await fs.writeFile(path.join(skillDir, "assets", "logo.svg"), "\n", "utf8"); + } + it("lists skills without exposing markdown content", async () => { const companyId = randomUUID(); const skillId = randomUUID(); @@ -101,6 +121,55 @@ describeEmbeddedPostgres("companySkillService.list", () => { }); }); + it("indexes nested files when importing a direct local skill directory", async () => { + const companyId = await createCompany(); + const skillDir = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-direct-skill-")); + cleanupDirs.add(skillDir); + await writeSkillTree(skillDir, "Diataxis"); + + const result = await svc.importFromSource(companyId, skillDir); + const imported = result.imported[0]!; + const inventoryPaths = imported.fileInventory.map((entry) => entry.path); + + expect(inventoryPaths).toEqual([ + "assets/logo.svg", + "references/how-to-guide-template.md", + "scripts/bootstrap.sh", + "SKILL.md", + ]); + expect(imported.fileInventory).toEqual(expect.arrayContaining([ + { path: "references/how-to-guide-template.md", kind: "reference" }, + { path: "scripts/bootstrap.sh", kind: "script" }, + { path: "assets/logo.svg", kind: "asset" }, + ])); + + const reference = await svc.readFile(companyId, imported.id, "references/how-to-guide-template.md"); + expect(reference).toMatchObject({ + path: "references/how-to-guide-template.md", + content: "# Guide Template\n", + }); + }); + + it("keeps parent-directory local imports scoped to the requested skill", async () => { + const companyId = await createCompany(); + const rootDir = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-parent-skills-")); + cleanupDirs.add(rootDir); + await writeSkillTree(path.join(rootDir, "diataxis"), "Diataxis"); + await writeSkillTree(path.join(rootDir, "other-skill"), "Other Skill"); + + const result = await svc.importFromSource(companyId, `npx skills add ${rootDir} --skill diataxis`); + const imported = result.imported[0]!; + + expect(result.imported).toHaveLength(1); + expect(imported.slug).toBe("diataxis"); + expect(imported.fileInventory).toEqual([ + { path: "assets/logo.svg", kind: "asset" }, + { path: "references/how-to-guide-template.md", kind: "reference" }, + { path: "scripts/bootstrap.sh", kind: "script" }, + { path: "SKILL.md", kind: "skill" }, + ]); + }); + it("rejects skill inventory refresh for a missing company", async () => { await expect(svc.list(randomUUID())).rejects.toMatchObject({ status: 404, diff --git a/server/src/services/company-skills.ts b/server/src/services/company-skills.ts index 58bfb3ce949..9823c0bc2dd 100644 --- a/server/src/services/company-skills.ts +++ b/server/src/services/company-skills.ts @@ -1118,10 +1118,17 @@ async function readLocalSkillImports(companyId: string, sourcePath: string): Pro const imports: ImportedSkill[] = []; for (const skillPath of skillPaths) { const skillDir = path.posix.dirname(skillPath); + const skillInventoryRoot = skillDir === "." ? "" : skillDir; const inventory = allFiles - .filter((entry) => entry === skillPath || entry.startsWith(`${skillDir}/`)) + .filter((entry) => + entry === skillPath + || (skillInventoryRoot ? entry.startsWith(`${skillInventoryRoot}/`) : true)) .map((entry) => { - const relative = entry === skillPath ? "SKILL.md" : entry.slice(skillDir.length + 1); + const relative = entry === skillPath + ? "SKILL.md" + : skillInventoryRoot + ? entry.slice(skillInventoryRoot.length + 1) + : entry; return { path: normalizePortablePath(relative), kind: classifyInventoryKind(relative),