VCS extensions

Summary

Register a diff source without changing the Changes view or Thread format.

A VCS extension registers an adapter with registerVcsAdapter. The daemon selects the adapter and captures the diff. cueloop owns the Thread, comments, revision history, and UI.

You can install an npm extension with cueloop install npm:<package>. Its package.json declares a cueloop.daemon entry point. See Extensions for the package format. You can also load a local entry point from your user config as shown below.

An adapter has apiVersion: 1, a unique namespaced ID, and four required operations:

OperationReturns
detect(cwd)Checkout root, or null
captureWorkingDiff(root)Git-format patch and full contents for text files that can be curated
listChanges(root)Changed paths and statuses
listFiles(root)Project file paths

The optional captureChange(root, changeId) follows a logical change after a rewrite. It must fail when the change is ambiguous. captureWorkingDiff can return a source with a stable changeId and exact revisionId. Return an empty files array if exact old and new file contents are unavailable. The patch remains reviewable, but hunk curation is disabled.

Example: Sapling

Save this file as ~/.config/cueloop/sapling.ts:

import type { ExtensionAPI, VcsAdapter } from "@cueloop/extension-api";

async function runSapling(root: string, ...args: string[]): Promise<string> {
  const child = Bun.spawn(["sl", ...args], {
    cwd: root,
    stdout: "pipe",
    stderr: "pipe",
  });
  const output = await new Response(child.stdout).text();

  if ((await child.exited) !== 0) throw new Error(`sl ${args[0]} failed`);

  return output;
}

const sapling: VcsAdapter = {
  apiVersion: 1,
  id: "example.sapling",
  async detect(cwd) {
    try {
      return (await runSapling(cwd, "root")).trim();
    } catch {
      return null;
    }
  },
  async captureWorkingDiff(root) {
    return { patch: (await runSapling(root, "diff", "--git")).trim(), files: [] };
  },
  async listChanges(root) {
    const status = await runSapling(root, "status", "--root-relative", "--print0");

    return status
      .split("\0")
      .filter(Boolean)
      .flatMap((entry) => {
        const code = entry[0];

        if (code === "M") return [{ path: entry.slice(2), status: "modified" as const }];
        if (code === "A") return [{ path: entry.slice(2), status: "added" as const }];
        if (code === "R" || code === "!")
          return [{ path: entry.slice(2), status: "deleted" as const }];

        return [];
      });
  },
  async listFiles(root) {
    const status = await runSapling(root, "status", "--all", "--root-relative", "--print0");

    return status
      .split("\0")
      .filter((entry) => /^[CAM] /.test(entry))
      .map((entry) => entry.slice(2))
      .sort();
  },
};

export default (cueloop: ExtensionAPI) => {
  cueloop.registerVcsAdapter(sapling);
};

Add the extension to your user config:

[vcs]
provider = "example.sapling"
extensions = ["./sapling.ts"]

Relative extension paths resolve from the user config file. Repository config can select an installed adapter with provider, but cannot load executable extensions. A failed extension does not remove the built-in Git and JJ adapters.

This example keeps the patch reviewable and reports changed files. It returns no full file contents, so hunk curation is unavailable. Add exact old and new contents before enabling curation. Sapling's diff and status commands define the output used here.