paperclipai/paperclip · error · Error

Paperclip skills must be directories.

Error message

Paperclip skills must be directories.

What it means

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.

Source

Thrown at packages/adapter-utils/src/server-utils.ts:3071

  const sourceRoot = path.resolve(source);
  const targetRoot = path.resolve(target);
  const relativeTarget = path.relative(sourceRoot, targetRoot);
  const relativeSource = path.relative(targetRoot, sourceRoot);
  if (
    !relativeTarget ||
    (!relativeTarget.startsWith("..") && !path.isAbsolute(relativeTarget)) ||
    !relativeSource ||
    (!relativeSource.startsWith("..") && !path.isAbsolute(relativeSource))
  ) {
    throw new Error("Refusing to materialize a skill into itself, an ancestor, or one of its descendants.");
  }

  const rootStat = await fs.lstat(sourceRoot);
  if (rootStat.isSymbolicLink()) {
    throw new Error("Refusing to materialize a skill root that is itself a symlink.");
  }
  if (!rootStat.isDirectory()) {
    throw new Error("Paperclip skills must be directories.");
  }

  const result: MaterializedPaperclipSkillCopyResult = {
    copiedFiles: 0,
    skippedSymlinks: [],
  };

  const lockDir = `${targetRoot}.lock`;
  const releaseLock = await acquireMaterializeLock(lockDir);
  const tempRoot = `${targetRoot}.tmp-${process.pid}-${randomUUID()}`;

  async function copyEntry(sourcePath: string, targetPath: string, relativePath: string): Promise<void> {
    const stat = await fs.lstat(sourcePath);
    if (stat.isSymbolicLink()) {
      result.skippedSymlinks.push(relativePath || ".");
      return;
    }

View on GitHub (pinned to 67001ec6eb)

Solutions

  1. Verify source resolves to a directory: assert fs.lstat(source).isDirectory() before calling.
  2. Point source at the parent directory that contains SKILL.md, not at SKILL.md itself.
  3. Re-package the skill as a directory if a build step collapsed it into a single file.

Example fix

// before
await materializePaperclipSkillCopy("/skills/my-skill/SKILL.md", target);
// after
await materializePaperclipSkillCopy("/skills/my-skill", target);
Defensive patterns

Strategy: validation

Validate before calling

const stat = await fs.lstat(source);
if (!stat.isDirectory()) {
  throw new Error(`skill source must be a directory: ${source}`);
}
await materializePaperclipSkillCopy(source, target);

Prevention

When it happens

Trigger: 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.

Common situations: 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.

Related errors


AI-assisted analysis of paperclipai/paperclip@67001ec6eb (2026-08-12). Data as JSON: /api/errors/d79d519bf456ad5a. Report an issue: GitHub.