Z ZPI 文档 v0.3.8
介绍页

源码

packages/desktop/src/main/custom-commands.ts

typescript712 行查看原文
import { createHash, randomUUID } from "node:crypto";
import { constants, type Stats } from "node:fs";
import fs from "node:fs/promises";
import { basename, dirname, isAbsolute, join, parse, resolve, sep } from "node:path";
import { builtinPlugins } from "@zpi-dev/builtin-plugins";
import type {
  CustomCommand,
  CustomCommandDelete,
  CustomCommandDiagnostic,
  CustomCommandInput,
  CustomCommandSetEnabled,
  CustomCommandsList,
  CustomCommandUpdate,
} from "../shared/custom-commands.ts";

interface MarkdownParts {
  frontmatter: string[];
  prompt: string;
}
export interface ParsedCustomCommandMarkdown {
  prompt: string;
  description?: string;
  argumentHint?: string;
  skills?: string[];
  disableNonInteractive?: boolean;
}
export interface CustomCommandLimits {
  maxFileBytes: number;
  maxCommands: number;
  maxEntries: number;
  maxDepth: number;
  maxTotalBytes: number;
}
export interface CustomCommandsStoreOptions {
  rootDirectory: string;
  stateFilePath: string;
  limits?: Partial<CustomCommandLimits>;
  /** Trusted main-process importer provenance; never supplied by the renderer. */
  importedLinkTargets?: () => Promise<ReadonlyMap<string, string>>;
}
const defaultLimits: CustomCommandLimits = {
  maxFileBytes: 256 * 1024,
  maxCommands: 1000,
  maxEntries: 10000,
  maxDepth: 8,
  maxTotalBytes: 16 * 1024 * 1024,
};
const maxStateBytes = 1024 * 1024;
const maxDisabledIds = 10000;
const managedKeys = new Set(["description", "argument-hint"]);

function fail(code: string, message: string): never {
  throw new Error(`${code}: ${message}`);
}
function isMissing(error: unknown): boolean {
  return (error as NodeJS.ErrnoException)?.code === "ENOENT";
}
function keyLine(line: string): RegExpExecArray | null {
  return /^([A-Za-z0-9_-]+)\s*:\s*(.*)$/.exec(line);
}
function splitMarkdown(content: string): MarkdownParts {
  const lines = content.replace(/^\uFEFF/, "").split(/\r?\n/);
  if (lines[0]?.trim() !== "---") return { frontmatter: [], prompt: content.trim() };
  const end = lines.findIndex((line, index) => index > 0 && line.trim() === "---");
  if (end < 0) fail("invalid_frontmatter", "Opening frontmatter fence has no closing fence");
  return {
    frontmatter: lines.slice(1, end),
    prompt: lines
      .slice(end + 1)
      .join("\n")
      .trim(),
  };
}
function scalar(value: string): string {
  value = value.trim();
  if (value.startsWith('"') && value.endsWith('"')) {
    try {
      const parsed: unknown = JSON.parse(value);
      if (typeof parsed === "string") return parsed;
    } catch {
      // YAML's unrecognized escapes remain visible rather than losing user content.
    }
  }
  if (value.startsWith("'") && value.endsWith("'")) return value.slice(1, -1).replaceAll("''", "'");
  return value;
}
function metadataValue(lines: string[], wanted: string): string | undefined {
  const index = lines.findIndex((line) => keyLine(line)?.[1].toLowerCase() === wanted);
  if (index < 0) return undefined;
  const initial = keyLine(lines[index])?.[2] ?? "";
  const block = /^[|>][-+]?\d?$/.test(initial);
  const continuation: string[] = [];
  for (let i = index + 1; i < lines.length; i++) {
    if (/^[^\s#]/.test(lines[i])) break;
    if (/^\s+\S/.test(lines[i]) && (block || !/^\s*#/.test(lines[i]))) continuation.push(lines[i].trim());
  }
  const value = block
    ? continuation.join(initial.startsWith("|") ? "\n" : " ")
    : [scalar(initial), ...continuation].filter(Boolean).join(" ");
  return value.trim() || undefined;
}
function flatMetadataValue(lines: string[], wanted: string): string | undefined {
  const index = lines.findIndex((line) => keyLine(line)?.[1].toLowerCase() === wanted);
  if (index < 0) return undefined;
  const value = keyLine(lines[index])?.[2] ?? "";
  if (wanted === "skills") {
    const next = lines.slice(index + 1).find((line) => line.trim() && !/^\s*#/.test(line));
    if (next && /^\s*-(?:\s|$)/.test(next))
      fail("invalid_frontmatter", "Block skills lists are unsupported; use skills: [a, b]");
  }
  return value;
}
function skillNames(value: string | undefined): string[] | undefined {
  if (value === undefined) return undefined;
  if (value.length > 8192) fail("invalid_frontmatter", "Command skills metadata exceeds the length limit");
  let names = scalar(value).trim();
  if (names.startsWith("[") || names.endsWith("]")) {
    if (!names.startsWith("[") || !names.endsWith("]"))
      fail("invalid_frontmatter", "Command skills list has unmatched brackets");
    names = names.slice(1, -1);
  }
  const skills = names
    .split(",")
    .map((name) => scalar(name.trim()).trim())
    .filter(Boolean);
  if (
    skills.length > 64 ||
    skills.some(
      (name) =>
        name.length > 128 ||
        [...name].some((character) => character.charCodeAt(0) < 32 || character.charCodeAt(0) === 127),
    )
  )
    fail(
      "invalid_frontmatter",
      "Command skills must contain at most 64 names of 128 characters without controls",
    );
  return [...new Set(skills)];
}
/** Small, non-executing Markdown frontmatter reader; this is not a general YAML evaluator. */
export function parseCustomCommandMarkdown(content: string): ParsedCustomCommandMarkdown {
  const { frontmatter, prompt } = splitMarkdown(content);
  const skills = skillNames(flatMetadataValue(frontmatter, "skills"));
  const nonInteractive = flatMetadataValue(frontmatter, "disable-noninteractive");
  return {
    prompt,
    description: metadataValue(frontmatter, "description"),
    argumentHint: metadataValue(frontmatter, "argument-hint"),
    ...(skills === undefined ? {} : { skills }),
    ...(nonInteractive === undefined
      ? {}
      : { disableNonInteractive: ["true", "yes"].includes(scalar(nonInteractive).trim().toLowerCase()) }),
  };
}
const reservedCommandNames = new Set([
  "compact",
  "side",
  "btw",
  ...builtinPlugins.flatMap((plugin) => (plugin.commands ?? []).map((command) => command.name.toLowerCase())),
]);
export function validateCustomCommandName(name: string): string {
  if (typeof name !== "string") fail("invalid_input", "Command name must be text");
  const normalized = name.replace(/^\//, "");
  if (!/^[A-Za-z0-9_-]{1,50}$/.test(normalized))
    fail("invalid_input", "Use 1–50 letters, numbers, underscores, or hyphens for a command name");
  if (reservedCommandNames.has(normalized.toLowerCase()))
    fail("invalid_input", "Command name is reserved for a built-in command");
  return normalized;
}
export function isCustomCommandId(id: unknown): id is string {
  return (
    typeof id === "string" &&
    Boolean(id) &&
    id.length <= 1024 &&
    !isAbsolute(id) &&
    !/[\\:]/.test(id) &&
    ![...id].some((character) => character.charCodeAt(0) < 32 || character.charCodeAt(0) === 127) &&
    /\.md$/i.test(id) &&
    id.split("/").every((part) => part && !part.startsWith(".") && !part.endsWith(".") && !part.endsWith(" "))
  );
}
function validateId(id: string): string {
  if (!isCustomCommandId(id)) fail("invalid_input", "Invalid relative command path");
  return id;
}
function commandName(id: string): string {
  return `/${id.replace(/\.md$/i, "")}`;
}
function linkFingerprint(id: string, target: string): string {
  return fingerprint(`registered-command-link\0${id}\0${target}`);
}
function fingerprint(content: string): string {
  return createHash("sha256").update(content).digest("hex");
}
function renderMarkdown(input: Omit<CustomCommandInput, "name">, previous = ""): string {
  if (!input || typeof input.prompt !== "string") fail("invalid_input", "Command prompt must be text");
  for (const value of [input.description, input.argumentHint]) {
    if (value !== undefined && (typeof value !== "string" || value.length > 10000))
      fail("invalid_input", "Command metadata must be text of at most 10000 characters");
  }
  const preserved: string[] = [];
  let replaced = false;
  for (const line of splitMarkdown(previous).frontmatter) {
    const key = keyLine(line)?.[1].toLowerCase();
    if (key) replaced = managedKeys.has(key);
    else if (/^[^\s#]/.test(line)) replaced = false;
    // Comments and blank lines are independent of the managed value being replaced.
    if (!replaced || /^(?:\s*#|\s*$)/.test(line)) preserved.push(line);
  }
  if (input.description?.trim()) preserved.push(`description: ${JSON.stringify(input.description.trim())}`);
  if (input.argumentHint?.trim())
    preserved.push(`argument-hint: ${JSON.stringify(input.argumentHint.trim())}`);
  if (
    !preserved.length &&
    input.prompt
      .replace(/^\uFEFF/, "")
      .split(/\r?\n/, 1)[0]
      ?.trim() === "---"
  )
    return `---\n---\n\n${input.prompt}`;
  return preserved.length ? `---\n${preserved.join("\n")}\n---\n\n${input.prompt}` : input.prompt;
}
function diagnostic(error: unknown, path?: string): CustomCommandDiagnostic {
  const message = error instanceof Error ? error.message : String(error);
  return { code: /^([a-z_]+):/.exec(message)?.[1] ?? "read_error", message, ...(path ? { path } : {}) };
}
/** Checks every existing path component, including ancestors above the configured root. */
async function safeDirectory(path: string, create = false): Promise<boolean> {
  const absolute = resolve(path);
  let current = parse(absolute).root;
  for (const component of absolute.slice(current.length).split(sep).filter(Boolean)) {
    current = join(current, component);
    let stat: Stats;
    try {
      stat = await fs.lstat(current);
    } catch (error) {
      if (!isMissing(error)) throw error;
      if (!create) return false;
      try {
        await fs.mkdir(current, { mode: 0o700 });
      } catch (mkdirError) {
        if ((mkdirError as NodeJS.ErrnoException).code !== "EEXIST") throw mkdirError;
      }
      stat = await fs.lstat(current);
    }
    if (stat.isSymbolicLink() || !stat.isDirectory())
      fail("unsafe_path", "Command path contains a symlink or non-directory ancestor");
  }
  return true;
}
async function readRegularFile(path: string, maxBytes: number): Promise<string> {
  if (!(await safeDirectory(dirname(path)))) fail("not_found", "Command directory no longer exists");
  const before = await fs.lstat(path);
  if (before.isSymbolicLink() || !before.isFile() || before.nlink > 1)
    fail("unsafe_path", "Command storage only accepts regular, unlinked files");
  const handle = await fs.open(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
  try {
    const stat = await handle.stat();
    if (!stat.isFile() || stat.nlink > 1 || stat.dev !== before.dev || stat.ino !== before.ino)
      fail("unsafe_path", "Command file changed identity while being opened");
    if (stat.size > maxBytes) fail("file_limit", "Command file exceeds the byte limit");
    let buffer = Buffer.alloc(Math.min(stat.size + 1, maxBytes + 1));
    let length = 0;
    while (length < buffer.length) {
      const result = await handle.read(buffer, length, buffer.length - length, length);
      if (!result.bytesRead) break;
      length += result.bytesRead;
      if (length === buffer.length && length <= maxBytes) {
        const expanded = Buffer.alloc(Math.min(buffer.length * 2, maxBytes + 1));
        buffer.copy(expanded);
        buffer = expanded;
      }
    }
    if (length > maxBytes) fail("file_limit", "Command file exceeds the byte limit");
    const after = await handle.stat();
    if (after.size !== stat.size || after.mtimeMs !== stat.mtimeMs || after.ctimeMs !== stat.ctimeMs)
      fail("conflict", "Command file changed while being read; reload it");
    return new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(buffer.subarray(0, length));
  } finally {
    await handle.close();
  }
}
interface ReadCommand {
  content: string;
  command: CustomCommand;
  diagnostic?: CustomCommandDiagnostic;
}

export class CustomCommandsStore {
  readonly rootDirectory: string;
  readonly stateFilePath: string;
  private readonly limits: CustomCommandLimits;
  private readonly importedLinkTargets?: CustomCommandsStoreOptions["importedLinkTargets"];
  private pending: Promise<unknown> = Promise.resolve();

  constructor(options: CustomCommandsStoreOptions) {
    if (!isAbsolute(options.rootDirectory) || !isAbsolute(options.stateFilePath))
      fail("invalid_input", "Command storage requires explicit absolute paths");
    this.rootDirectory = resolve(options.rootDirectory);
    this.stateFilePath = resolve(options.stateFilePath);
    this.limits = { ...defaultLimits, ...options.limits };
    for (const key of Object.keys(defaultLimits) as (keyof CustomCommandLimits)[]) {
      if (
        !Number.isSafeInteger(this.limits[key]) ||
        this.limits[key] < 1 ||
        this.limits[key] > defaultLimits[key]
      )
        fail("invalid_input", "Command storage limits must be positive and within the default safety caps");
    }
    if (this.stateFilePath === this.rootDirectory || /\.md$/i.test(this.stateFilePath))
      fail("invalid_input", "Enabled state must use a separate non-Markdown file");
    this.importedLinkTargets = options.importedLinkTargets;
  }
  private serialize<T>(operation: () => Promise<T>): Promise<T> {
    const result = this.pending.then(operation);
    this.pending = result.catch(() => undefined);
    return result;
  }
  private path(id: string): string {
    return join(this.rootDirectory, ...validateId(id).split("/"));
  }
  private async readCommand(
    id: string,
    disabled: ReadonlySet<string>,
    maxBytes = this.limits.maxFileBytes,
    onRead?: (bytes: number) => void,
    allowUnreadable = false,
  ): Promise<ReadCommand> {
    const filePath = this.path(id);
    if (!(await safeDirectory(dirname(filePath)))) fail("not_found", "Command directory no longer exists");
    let importedSourcePath: string | undefined;
    let linkTarget: string | undefined;
    const stat = await fs.lstat(filePath);
    if (stat.isSymbolicLink()) {
      const approved = (await this.importedLinkTargets?.())?.get(id);
      const target = await fs.readlink(filePath);
      linkTarget = target;
      if (!approved || !isAbsolute(approved) || resolve(dirname(filePath), target) !== resolve(approved))
        fail("unsafe_path", "Command symlink was not registered by the explicit importer");
      importedSourcePath = resolve(approved);
      if (!/\.md$/i.test(importedSourcePath)) fail("unsafe_path", "Imported command source is not Markdown");
    }
    const metadata = {
      id,
      name: commandName(id),
      filePath,
      ...(importedSourcePath ? { importedSourcePath } : {}),
      enabled: !disabled.has(id),
    };
    try {
      const content = await readRegularFile(importedSourcePath ?? filePath, maxBytes);
      onRead?.(Buffer.byteLength(content));
      return {
        content,
        command: { ...metadata, ...parseCustomCommandMarkdown(content), revision: fingerprint(content) },
      };
    } catch (error) {
      if (!allowUnreadable || !importedSourcePath || linkTarget === undefined) throw error;
      return {
        content: "",
        command: {
          ...metadata,
          enabled: false,
          prompt: "",
          unreadable: true,
          revision: linkFingerprint(id, linkTarget),
        },
        diagnostic: diagnostic(error, filePath),
      };
    }
  }
  private async assertNameAvailable(id: string): Promise<void> {
    const target = this.path(id);
    if (!(await safeDirectory(dirname(target)))) return;
    const name = basename(target).toLowerCase();
    let count = 0;
    const entries = await fs.opendir(dirname(target));
    for await (const entry of entries) {
      if (++count > this.limits.maxEntries)
        fail("scan_limit", "Cannot safely check command-name conflicts in this directory");
      if (entry.name.toLowerCase() === name)
        fail("conflict", "Command name already exists (names are case-insensitive)");
    }
  }

  private async readState(): Promise<Set<string>> {
    let raw: string;
    if (!(await safeDirectory(dirname(this.stateFilePath)))) return new Set();
    try {
      raw = await readRegularFile(this.stateFilePath, maxStateBytes);
    } catch (error) {
      if (isMissing(error)) return new Set();
      throw error;
    }
    let value: unknown;
    try {
      value = JSON.parse(raw);
    } catch {
      fail("invalid_state", "Command enabled state is not valid JSON; the original has been kept");
    }
    if (
      !value ||
      typeof value !== "object" ||
      !("version" in value) ||
      value.version !== 1 ||
      !("disabled" in value) ||
      !Array.isArray(value.disabled) ||
      value.disabled.length > maxDisabledIds ||
      value.disabled.some((id: unknown) => typeof id !== "string")
    )
      fail("invalid_state", "Command enabled state has an unsupported format; the original has been kept");
    for (const id of value.disabled) validateId(id);
    return new Set(value.disabled);
  }
  private async writeState(disabled: Set<string>): Promise<void> {
    if (disabled.size > maxDisabledIds) fail("state_limit", "Too many command enable overrides");
    await safeDirectory(dirname(this.stateFilePath), true);
    const value = `${JSON.stringify({ version: 1, disabled: [...disabled].sort() }, null, 2)}\n`;
    if (Buffer.byteLength(value) > maxStateBytes) fail("state_limit", "Command enabled state is too large");
    await this.publish(this.stateFilePath, value, true, async () => {
      await this.readState();
    });
  }
  private async publish(
    target: string,
    content: string,
    replace: boolean,
    beforePublish: () => Promise<void>,
  ): Promise<void> {
    await safeDirectory(dirname(target), true);
    const temporary = join(dirname(target), `.command-${randomUUID()}.tmp`);
    const handle = await fs.open(
      temporary,
      constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW,
      0o600,
    );
    const identity = await handle.stat();
    const sameIdentity = (stat: Stats) =>
      stat.isFile() && !stat.isSymbolicLink() && stat.dev === identity.dev && stat.ino === identity.ino;
    const cleanup = async () => {
      try {
        if (!(await safeDirectory(dirname(temporary)))) return;
        if (sameIdentity(await fs.lstat(temporary))) await fs.unlink(temporary);
      } catch {
        // A failed cleanup must not mask the original error or delete a substituted file.
      }
    };
    try {
      try {
        await handle.writeFile(content, "utf8");
        await handle.sync();
      } finally {
        await handle.close();
      }
      if (!(await safeDirectory(dirname(temporary)))) fail("not_found", "Staging directory no longer exists");
      if (!sameIdentity(await fs.lstat(temporary))) fail("unsafe_path", "Staged command changed identity");
      if ((await readRegularFile(temporary, Buffer.byteLength(content))) !== content)
        fail("conflict", "Staged command was modified before publication");
      await beforePublish();
      if (!(await safeDirectory(dirname(target)))) fail("not_found", "Command directory no longer exists");
      if (!sameIdentity(await fs.lstat(temporary))) fail("unsafe_path", "Staged command changed identity");
      if (replace) await fs.rename(temporary, target);
      else {
        try {
          await fs.link(temporary, target);
        } catch (error) {
          if ((error as NodeJS.ErrnoException).code === "EEXIST") fail("conflict", "Command already exists");
          throw error;
        }
      }
    } finally {
      await cleanup();
    }
  }
  private assertRevision(command: CustomCommand, expectedRevision: string): void {
    if (typeof expectedRevision !== "string" || !/^[a-f0-9]{64}$/.test(expectedRevision))
      fail("invalid_input", "A command revision is required");
    if (command.revision !== expectedRevision)
      fail("conflict", "Command changed on disk; reload it before saving or deleting");
  }
  private assertSize(content: string): void {
    if (Buffer.byteLength(content) > this.limits.maxFileBytes)
      fail("file_limit", "Command exceeds the byte limit");
  }
  list(): Promise<CustomCommandsList> {
    return this.serialize(async () => {
      const commands: CustomCommand[] = [];
      const diagnostics: CustomCommandDiagnostic[] = [];
      let disabled = new Set<string>();
      let stateInvalid = false;
      try {
        disabled = await this.readState();
      } catch (error) {
        stateInvalid = true;
        diagnostics.push(diagnostic(error, this.stateFilePath));
      }
      let entriesSeen = 0;
      let bytesRead = 0;
      let exhausted = false;
      let entryLimitReached = false;
      const visit = async (directory: string, prefix: string, depth: number): Promise<void> => {
        if (!(await safeDirectory(directory))) return;
        const entries: string[] = [];
        const iterator = await fs.opendir(directory);
        for await (const entry of iterator) {
          entriesSeen++;
          if (entriesSeen > this.limits.maxEntries) {
            exhausted = true;
            entryLimitReached = true;
            diagnostics.push({
              code: "scan_limit",
              message: "Command scan entry limit reached",
              path: directory,
            });
            break;
          }
          if (!entry.name.startsWith(".")) entries.push(entry.name);
        }
        for (const entry of entries.sort()) {
          if (exhausted) break;
          if (commands.length >= this.limits.maxCommands || bytesRead >= this.limits.maxTotalBytes) {
            exhausted = true;
            diagnostics.push({
              code: "scan_limit",
              message: "Command scan size limit reached",
              path: directory,
            });
            break;
          }
          const path = join(directory, entry);
          try {
            await safeDirectory(directory);
            const stat = await fs.lstat(path);
            const id = prefix ? `${prefix}/${entry}` : entry;
            if (stat.isDirectory()) {
              if (depth >= this.limits.maxDepth) {
                diagnostics.push({
                  code: "scan_limit",
                  message: "Command directory depth limit reached",
                  path,
                });
              } else if (!exhausted) await visit(path, id, depth + 1);
            } else if (/\.md$/i.test(entry)) {
              const read = await this.readCommand(
                id,
                disabled,
                Math.min(this.limits.maxFileBytes, this.limits.maxTotalBytes - bytesRead),
                (bytes) => {
                  bytesRead += bytes;
                },
                true,
              );
              if (read.diagnostic) diagnostics.push(read.diagnostic);
              if (bytesRead > this.limits.maxTotalBytes) {
                exhausted = true;
                diagnostics.push({ code: "scan_limit", message: "Command scan byte limit reached", path });
                break;
              }
              if (stateInvalid) read.command.enabled = false;
              commands.push(read.command);
            } else if (stat.isSymbolicLink()) {
              diagnostics.push({
                code: "unsafe_path",
                message: "Skipped symlink directory or non-command",
                path,
              });
            }
          } catch (error) {
            diagnostics.push(diagnostic(error, path));
          }
        }
      };
      try {
        await visit(this.rootDirectory, "", 0);
      } catch (error) {
        diagnostics.push(diagnostic(error, this.rootDirectory));
      }
      if (entryLimitReached) commands.length = 0;
      commands.sort((a, b) =>
        a.name < b.name ? -1 : a.name > b.name ? 1 : a.id < b.id ? -1 : a.id > b.id ? 1 : 0,
      );
      const selected = new Map<string, CustomCommand>();
      for (const command of commands) {
        const key = command.name.toLowerCase();
        if (selected.has(key)) {
          diagnostics.push({
            code: "duplicate_name",
            message: `Skipped duplicate command ${command.name}; names are case-insensitive`,
            path: command.filePath,
          });
        } else {
          selected.set(key, command);
          if (
            !/^\/[A-Za-z0-9_-]+(?:\/[A-Za-z0-9_-]+)*$/.test(command.name) ||
            reservedCommandNames.has(command.name.slice(1).toLowerCase())
          )
            diagnostics.push({
              code: "unsupported_name",
              message: `Command ${command.name} can be edited but its name is not supported by slash invocation`,
              path: command.filePath,
            });
        }
      }
      return { commands: [...selected.values()], diagnostics, directoryPath: this.rootDirectory };
    });
  }
  create(input: CustomCommandInput): Promise<CustomCommand> {
    return this.serialize(async () => {
      const id = `${validateCustomCommandName(input?.name)}.md`;
      const target = this.path(id);
      const content = renderMarkdown(input);
      this.assertSize(content);
      const disabled = await this.readState();
      await safeDirectory(this.rootDirectory, true);
      await this.assertNameAvailable(id);
      if (disabled.delete(id)) await this.writeState(disabled);
      await this.publish(target, content, false, async () => {
        await this.assertNameAvailable(id);
      });
      return (await this.readCommand(id, disabled)).command;
    });
  }
  update(input: CustomCommandUpdate): Promise<CustomCommand> {
    return this.serialize(async () => {
      const disabled = await this.readState();
      const existing = await this.readCommand(input.id, disabled);
      this.assertRevision(existing.command, input.expectedRevision);
      const name = input.name;
      const id =
        name === undefined || name === existing.command.name || `/${name}` === existing.command.name
          ? input.id
          : `${validateCustomCommandName(name)}.md`;
      const source = existing.command.importedSourcePath;
      if (source) {
        if (input.confirmLinkedSource !== true || input.expectedSourcePath !== source)
          fail(
            "confirmation_required",
            "Saving this linked command modifies its source; confirm the exact source path",
          );
        if (id !== input.id)
          fail("invalid_input", "A linked command cannot be renamed while editing its source");
      } else if (input.expectedSourcePath !== undefined) {
        fail("conflict", "Command is no longer linked to the expected source; reload it");
      }
      const target = source ?? this.path(id);
      const content = renderMarkdown(input, existing.content);
      this.assertSize(content);
      if (id !== input.id) {
        await this.assertNameAvailable(id);
        // Keep the old override through failures; a later create explicitly resets its own ID.
        const changed = disabled.has(input.id) ? !disabled.has(id) : disabled.has(id);
        if (disabled.has(input.id)) disabled.add(id);
        else disabled.delete(id);
        if (changed) await this.writeState(disabled);
      }
      await this.publish(target, content, id === input.id, async () => {
        if (id !== input.id) await this.assertNameAvailable(id);
        const current = (await this.readCommand(input.id, disabled)).command;
        this.assertRevision(current, input.expectedRevision);
        if (current.importedSourcePath !== source)
          fail("conflict", "Command link target changed; reload it before saving");
      });
      if (id !== input.id) {
        try {
          this.assertRevision((await this.readCommand(input.id, disabled)).command, input.expectedRevision);
          await safeDirectory(dirname(existing.command.filePath));
          await fs.unlink(existing.command.filePath);
        } catch (error) {
          if (await safeDirectory(dirname(target)).catch(() => false))
            await fs.unlink(target).catch(() => undefined);
          throw error;
        }
      }
      return (await this.readCommand(id, disabled)).command;
    });
  }
  delete(input: CustomCommandDelete): Promise<void> {
    return this.serialize(async () => {
      const path = this.path(input.id);
      if (!(await safeDirectory(dirname(path)))) fail("not_found", "Command directory no longer exists");
      const stat = await fs.lstat(path);
      if (stat.isSymbolicLink()) {
        const target = await fs.readlink(path);
        const approved = (await this.importedLinkTargets?.())?.get(input.id);
        if (!approved || !isAbsolute(approved) || resolve(dirname(path), target) !== resolve(approved))
          fail("unsafe_path", "Command symlink was not registered by the explicit importer");
        if (input.expectedRevision === linkFingerprint(input.id, target)) {
          await safeDirectory(dirname(path));
          if ((await fs.readlink(path)) !== target) fail("conflict", "Command link changed before deletion");
          await fs.unlink(path);
          return;
        }
      }
      const existing = await this.readCommand(input.id, new Set());
      this.assertRevision(existing.command, input.expectedRevision);
      await safeDirectory(dirname(existing.command.filePath));
      await fs.unlink(existing.command.filePath);
    });
  }
  setEnabled(input: CustomCommandSetEnabled): Promise<CustomCommand> {
    return this.serialize(async () => {
      if (typeof input?.enabled !== "boolean") fail("invalid_input", "Enabled must be a boolean");
      const disabled = await this.readState();
      const { command } = await this.readCommand(input.id, disabled);
      if (input.enabled) disabled.delete(input.id);
      else disabled.add(input.id);
      await this.writeState(disabled);
      return { ...command, enabled: input.enabled };
    });
  }
}