{"record":{"id":"d79d519bf456ad5a","repo":"paperclipai/paperclip","slug":"paperclip-skills-must-be-directories","errorCode":null,"errorMessage":"Paperclip skills must be directories.","messagePattern":"Paperclip skills must be directories\\.","errorType":"exception","errorClass":"Error","httpStatus":null,"severity":"error","filePath":"packages/adapter-utils/src/server-utils.ts","lineNumber":3071,"sourceCode":"  const sourceRoot = path.resolve(source);\n  const targetRoot = path.resolve(target);\n  const relativeTarget = path.relative(sourceRoot, targetRoot);\n  const relativeSource = path.relative(targetRoot, sourceRoot);\n  if (\n    !relativeTarget ||\n    (!relativeTarget.startsWith(\"..\") && !path.isAbsolute(relativeTarget)) ||\n    !relativeSource ||\n    (!relativeSource.startsWith(\"..\") && !path.isAbsolute(relativeSource))\n  ) {\n    throw new Error(\"Refusing to materialize a skill into itself, an ancestor, or one of its descendants.\");\n  }\n\n  const rootStat = await fs.lstat(sourceRoot);\n  if (rootStat.isSymbolicLink()) {\n    throw new Error(\"Refusing to materialize a skill root that is itself a symlink.\");\n  }\n  if (!rootStat.isDirectory()) {\n    throw new Error(\"Paperclip skills must be directories.\");\n  }\n\n  const result: MaterializedPaperclipSkillCopyResult = {\n    copiedFiles: 0,\n    skippedSymlinks: [],\n  };\n\n  const lockDir = `${targetRoot}.lock`;\n  const releaseLock = await acquireMaterializeLock(lockDir);\n  const tempRoot = `${targetRoot}.tmp-${process.pid}-${randomUUID()}`;\n\n  async function copyEntry(sourcePath: string, targetPath: string, relativePath: string): Promise<void> {\n    const stat = await fs.lstat(sourcePath);\n    if (stat.isSymbolicLink()) {\n      result.skippedSymlinks.push(relativePath || \".\");\n      return;\n    }\n","sourceCodeStart":3053,"sourceCodeEnd":3089,"githubUrl":"https://github.com/paperclipai/paperclip/blob/67001ec6eb96ae601aa27bc91d9b2415d665334a/packages/adapter-utils/src/server-utils.ts#L3053-L3089","documentation":"Thrown by materializePaperclipSkillCopy when sourceRoot is neither a symlink nor a directory (fs.lstat reports a regular file or other non-directory type). Paperclip skills are structured as directories containing SKILL.md and resources, so a file source cannot be materialized.","triggerScenarios":"Calling materializePaperclipSkillCopy with source pointing at a regular file (e.g. /path/SKILL.md instead of /path/skill-dir), a broken path that lstat still resolves as a file, or a socket/FIFO device path.","commonSituations":"Off-by-one path resolution that lands on a file inside the skill dir instead of the dir itself; a packaging step that flattened a skill into a single file; a misconfigured catalog entry whose path points at a manifest file.","solutions":["Verify source resolves to a directory: assert fs.lstat(source).isDirectory() before calling.","Point source at the parent directory that contains SKILL.md, not at SKILL.md itself.","Re-package the skill as a directory if a build step collapsed it into a single file."],"exampleFix":"// before\nawait materializePaperclipSkillCopy(\"/skills/my-skill/SKILL.md\", target);\n// after\nawait materializePaperclipSkillCopy(\"/skills/my-skill\", target);","handlingStrategy":"validation","validationCode":"const stat = await fs.lstat(source);\nif (!stat.isDirectory()) {\n  throw new Error(`skill source must be a directory: ${source}`);\n}\nawait materializePaperclipSkillCopy(source, target);","typeGuard":null,"tryCatchPattern":null,"preventionTips":["Validate the source is a directory at config/catalog load time, not only at materialize time.","Point catalog entries at directories (containing SKILL.md), never at files.","Add a unit test that asserts the catalog paths pass fs.lstat().isDirectory()."],"tags":["filesystem","skill-materialization","validation","paperclip"],"backgroundTag":null,"analyzedSha":"67001ec6eb96ae601aa27bc91d9b2415d665334a","analyzedAt":"2026-08-12T12:05:45.408Z","schemaVersion":2},"datasetVersion":"2026-08-12T18:17:37.767Z"}