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
- 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.
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
- 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().
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
- Refusing to materialize a skill into itself, an ancestor, or
- Refusing to materialize a skill root that is itself a symlin
- Export output path ${root} exists and is not a directory.
- Export output directory ${root} already contains files. Re-r
- Worktree config already exists at ${paths.configPath} or ins
AI-assisted analysis of paperclipai/paperclip@67001ec6eb (2026-08-12).
Data as JSON: /api/errors/d79d519bf456ad5a.
Report an issue: GitHub.