can1357/oh-my-pi · error

Managed skill "${name}" SKILL.md is a symlink; refusing to o

Error message

Managed skill "${name}" SKILL.md is a symlink; refusing to overwrite it.

What it means

openManagedSkillFileForUpdate opens SKILL.md with O_NOFOLLOW, which makes open() fail with ELOOP if the target is a symlink. The ELOOP is converted into this explicit error so an update can never write through a symlink to a path outside the managed directory.

Source

Thrown at packages/coding-agent/src/autolearn/managed-skills.ts:145

const UPDATE_FILE_OPEN_FLAGS = fsConstants.O_WRONLY | fsConstants.O_NOFOLLOW;

function assertManagedSkillFileSafeForUpdate(name: string, fileStat: Stats): void {
	if (!fileStat.isFile()) {
		throw new Error(`Managed skill "${name}" SKILL.md is not a regular file; refusing to overwrite it.`);
	}
	if (fileStat.nlink > 1) {
		throw new Error(
			`Managed skill "${name}" SKILL.md has ${fileStat.nlink} hard links; refusing to overwrite a file that may be user-authored elsewhere.`,
		);
	}
}

async function openManagedSkillFileForUpdate(name: string, file: string) {
	try {
		return await fs.open(file, UPDATE_FILE_OPEN_FLAGS);
	} catch (err) {
		if ((err as { code?: string }).code === "ELOOP") {
			throw new Error(`Managed skill "${name}" SKILL.md is a symlink; refusing to overwrite it.`);
		}
		throw err;
	}
}

/** Create or update a managed `SKILL.md`. Returns the resolved file path. */
export async function writeManagedSkill(input: WriteManagedSkillInput): Promise<{ path: string }> {
	const name = sanitizeSkillName(input.name);
	const description = sanitizeManagedDescription(input.description);
	const body = input.body.trim();
	// Reject empty content: an all-whitespace/control description sanitizes to ""
	// and the `requireDescription` discovery scan then silently drops the skill,
	// so the tool would report success for a skill that never appears.
	if (!description) {
		throw new Error(`Managed skill "${name}" needs a non-empty description.`);
	}
	if (!body) {
		throw new Error(`Managed skill "${name}" needs a non-empty body.`);

View on GitHub (pinned to 9690622007)

Solutions

  1. Replace the symlink with a real file: `rm <path> && cp <target-of-link> <path>`, then retry
  2. Stop symlinking individual SKILL.md files into managed-skills; place authored skills in ~/.omp/agent/skills instead
  3. Verify with `ls -la ~/.omp/agent/managed-skills/<name>/`

Example fix

// before (shell)
ln -s ~/my-skills/foo.md ~/.omp/agent/managed-skills/foo/SKILL.md
// after (shell)
rm ~/.omp/agent/managed-skills/foo/SKILL.md && cp ~/my-skills/foo.md ~/.omp/agent/managed-skills/foo/SKILL.md
Defensive patterns

Strategy: validation

Validate before calling

import { lstat } from "node:fs/promises";
const st = await lstat(skillMdPath).catch(() => null);
if (st?.isSymbolicLink()) throw new Error("SKILL.md must not be a symlink");

Type guard

function isNotSymlink(st: { isSymbolicLink(): boolean } | null): boolean {
  return st === null || !st.isSymbolicLink();
}

Try / catch

try {
  await writeManagedSkill({ name, action: "update", ... });
} catch (err) {
  if (String((err as Error).message).includes("is a symlink")) {
    // replace the symlink with a real file (rm + cp), then retry
  } else throw err;
}

Prevention

When it happens

Trigger: writeManagedSkill updates an existing skill whose <name>/SKILL.md is a symbolic link (fs.open returns errno ELOOP).

Common situations: A user or sync tool replaced a managed skill's SKILL.md with a symlink to their own file; dotfile managers stow-link individual files into ~/.omp.

Related errors


AI-assisted analysis of can1357/oh-my-pi@9690622007 (2026-08-31). Data as JSON: /api/errors/09a805a56644369e. Report an issue: GitHub.