JuliusBrussee/caveman · error

SKILL.md missing under ${resolved}

Error message

SKILL.md missing under ${resolved}

What it means

Thrown by importedSkillSource() in the caveman CLI when the import source is a DIRECTORY that does not contain a readable SKILL.md file (hasFile check fails). The directory itself resolved fine, but the required skill manifest file is absent, so the import aborts with exit code 2.

Source

Thrown at packages/cli/src/index.ts:1570

	stop_condition: { status: string; value?: string };
	prompt: { bytes: number; byte_budget: number; status: string };
	resources: { scripts: string[]; references: string[]; assets: string[]; status: string };
	transformations: { exact_duplicate_blocks_removed: number; decorative_separators_removed: number };
	conflicts: { status: "needs_review"; declared: string[] };
	benchmarks: { status: "required"; fixtures: string[] };
	evidence_status: "unevaluated";
	publication: { status: "blocked"; blockers: string[] };
};

function importedSkillSource(input: string): { root: string; file: string } {
	let resolved: string;
	try { resolved = realpathSync(resolve(process.cwd(), expandTilde(input))); } catch {
		throw new Error(`source does not exist: ${input}`);
	}
	const stat = lstatSync(resolved);
	const file = stat.isDirectory() ? join(resolved, SKILL_MD) : resolved;
	if (!stat.isDirectory() && basename(resolved) !== SKILL_MD) throw new Error("source file must be named SKILL.md");
	if (!hasFile(file)) throw new Error(`SKILL.md missing under ${resolved}`);
	return { root: stat.isDirectory() ? resolved : dirname(resolved), file };
}

function cavemannifyImportedSkill(bytes: Buffer): { body: string; removedDuplicates: number; removedSeparators: number } {
	if (bytes.length === 0 || bytes.length > 256 * 1024) throw new Error("SKILL.md must be 1..262144 bytes");
	if (bytes.includes(0)) throw new Error("SKILL.md contains NUL bytes");
	const text = bytes.toString("utf8");
	if (!Buffer.from(text, "utf8").equals(bytes)) throw new Error("SKILL.md must be valid UTF-8");
	if (/-----BEGIN [A-Z ]*PRIVATE KEY-----|\b(?:sk|ghp|github_pat|xox[baprs])[-_][A-Za-z0-9_-]{16,}/i.test(text)) {
		throw new Error("SKILL.md contains credential-shaped material");
	}
	const split = splitSkillMarkdown(bytes);
	if (!split) throw new Error("SKILL.md needs closed YAML frontmatter");
	const blocks = split.bodyText.trim().split(/\r?\n(?:[ \t]*\r?\n)+/);
	const seen = new Set<string>();
	const kept: string[] = [];
	let removedDuplicates = 0;
	let removedSeparators = 0;

View on GitHub (pinned to 5184b3d11a)

Solutions

  1. cd into the source and locate the folder that actually holds SKILL.md (find . -name SKILL.md), then import that directory
  2. Fix the filename to exactly SKILL.md if it differs in case or spelling
  3. Repair dangling symlinks and ensure the file is readable (chmod +r)

Example fix

# before
caveman skills import ./cloned-repo        # SKILL.md is at ./cloned-repo/skills/foo/SKILL.md

# after
caveman skills import ./cloned-repo/skills/foo
Defensive patterns

Strategy: validation

Validate before calling

import { existsSync, statSync } from "node:fs";
import { join } from "node:path";

const hasSkillMd = (dir: string) => {
  const f = join(dir, "SKILL.md");
  return existsSync(f) && statSync(f).isFile();
};

Try / catch

try {
  execFileSync("caveman", ["skills", "import", src]);
} catch (err) {
  if (/SKILL\.md missing under/.test(err.stderr?.toString() ?? "")) {
    // find the real skill dir: find src -name SKILL.md, then import that dir
  }
}

Prevention

When it happens

Trigger: `caveman skills import ./empty-dir`, a directory whose skill file is named skill.md/SKILL.MD instead of SKILL.md, a SKILL.md that is a dangling symlink, or one with no read permission (stat fails).

Common situations: Importing a cloned repo's root when the skill lives in a subdirectory (e.g. must import repo/skills/foo, not repo); case-mismatched filenames carried from case-insensitive filesystems; a partially synced/downloaded skill folder missing its main file.

Understand the failure class

Background: "File not found" and ENOENT errors: why libraries can't find a file that should exist — this error's family across 50 libraries.

Related errors


AI-assisted analysis of JuliusBrussee/caveman@5184b3d11a (2026-08-18). Data as JSON: /api/errors/ca93052b4022a3e8. Report an issue: GitHub.