← back to Kickbacks Ai Upstream

src/adapters/claude-cli/adapter.ts

259 lines

import { readFileSync, writeFileSync, existsSync, rmSync, mkdirSync }
  from "node:fs";
import { resolve, dirname, join } from "node:path";
import type { TargetAdapter, PreflightResult, OpResult, RestoreResult,
              PatchParams } from "../types";
import { sha256 } from "../../util/crypto";
import { resolveAsset } from "../../util/asset";
import { parseable, readTopLevel, upsertStatusLine, upsertSpinnerVerbs,
         removeSpinnerVerbs, removeTopLevel }
  from "./settingsEdit";

const ABSENT = " VIBE-ADS-ABSENT";
const SCRIPT_NAME = "vibe-ads-statusline.mjs";
const PREV_NAME = "cli-prev-statusline.json";
const FRESH_MS = 10 * 60 * 1000;
/** Hard exit deadline for the chained pre-existing statusLine command — a
 *  wedged user HUD must never hang CC's status line. */
const CHAIN_TIMEOUT_MS = 5000;

/** A chain-capturable statusLine: a command-type entry that is not our own
 *  script (re-applying over ourselves must never capture ourselves). */
function isForeignStatusLine(v: unknown): v is { type: string; command: string } {
  return typeof v === "object" && v !== null
    && (v as { type?: unknown }).type === "command"
    && typeof (v as { command?: unknown }).command === "string"
    && !(v as { command: string }).command.includes(SCRIPT_NAME);
}

/** Resolve the shipped asset in BOTH unbundled (co-located src) and
 *  esbuild-bundled (dist/adapters/claude-cli/) layouts — mirrors the
 *  webview adapter's resolveBlockAsset contract. */
export function resolveStatuslineAsset(baseDir: string): string {
  return resolveAsset(baseDir, "adapters/claude-cli", "statusline.asset.mjs");
}

export class ClaudeCliStatuslineAdapter implements TargetAdapter {
  readonly name = "claude-cli-statusline";
  private readonly settings: string;
  private readonly home: string;

  /** Whether to write the `spinnerVerbs` override. Gated on the terminal CLI
   *  honouring the key (CC >= 2.1.143). Defaults to true (fail-open) so the
   *  surface works before async version detection resolves; cliSync flips it
   *  off only when it positively detects an older CLI. See cliVersion.ts. */
  spinnerVerbsSupported = true;

  /** @param settingsPath absolute path to ~/.claude/settings.json. The home
   *  dir (for ~/.vibe-ads) is its grandparent (<home>/.claude/settings.json). */
  constructor(settingsPath: string) {
    this.settings = resolve(settingsPath);
    this.home = dirname(dirname(this.settings));
  }

  private backupPath(): string { return this.settings + ".vibe-ads-backup"; }
  private vibeDir(): string { return join(this.home, ".vibe-ads"); }
  private scriptPath(): string { return join(this.vibeDir(), SCRIPT_NAME); }
  private cachePath(): string { return join(this.vibeDir(), "cli-ad.json"); }
  private prevPath(): string { return join(this.vibeDir(), PREV_NAME); }

  /** The user's pre-install statusLine captured by applyPatch (chain-capture),
   *  or undefined when none was captured / the file is unreadable. */
  private readPrevStatusLine(): unknown {
    try {
      const v = JSON.parse(readFileSync(this.prevPath(), "utf8")).statusLine;
      return isForeignStatusLine(v) ? v : undefined;
    } catch { return undefined; }
  }

  /** restore()'s fallback capture source: the statusLine inside the
   *  first-apply snapshot. Used when the capture file is missing/corrupt
   *  (cleared ~/.vibe-ads, AV tooling, disk error) so the user's entry is
   *  still put back. Can be stale if they swapped HUDs while installed —
   *  stale beats deleted. */
  private savedStatusLine(saved: string): unknown {
    if (saved === ABSENT) return undefined;
    const v = readTopLevel(saved, "statusLine");
    return isForeignStatusLine(v) ? v : undefined;
  }

  version(): string | null { return "cli"; }

  preflight(): PreflightResult {
    try {
      if (!existsSync(this.settings))
        return { ok: true, compatible: true, version: "cli" };
      const src = readFileSync(this.settings, "utf8");
      if (!parseable(src))
        return { ok: true, compatible: false, version: "cli",
                 reason: "settings.json not parseable" };
      return { ok: true, compatible: true, version: "cli" };
    } catch (e) {
      return { ok: false, compatible: false, version: null, reason: String(e) };
    }
  }

  private renderScript(): string {
    const tplPath = resolveStatuslineAsset(dirname(__filename));
    const tpl = readFileSync(tplPath, "utf8");
    return tpl
      .split("__VIBE_ADS_CLI_AD_PATH__").join(JSON.stringify(this.cachePath()))
      .split("__VIBE_ADS_CLI_PREV_PATH__").join(JSON.stringify(this.prevPath()))
      .split("__VIBE_ADS_FRESH_MS__").join(String(FRESH_MS))
      .split("__VIBE_ADS_SCRIPT_NAME__").join(JSON.stringify(SCRIPT_NAME))
      .split("__VIBE_ADS_CHAIN_TIMEOUT_MS__").join(String(CHAIN_TIMEOUT_MS));
  }

  private statusLineValue(): string {
    const cmd = `node ${JSON.stringify(this.scriptPath())}`;
    return JSON.stringify({ type: "command", command: cmd, padding: 0 });
  }

  /** The spinnerVerbs override value: replace CC's stock verb dictionary with
   *  the single ad line so the thinking-shimmer verb shows the ad. */
  private spinnerVerbsValue(adText: string): string {
    return JSON.stringify({ mode: "replace", verbs: [adText] });
  }

  // The CLI adapter writes TWO surfaces into ~/.claude/settings.json:
  //   1. `statusLine` — an OSC 8 clickable hyperlink rendered at the bottom
  //      of the terminal on every status-line refresh (the click surface).
  //   2. `spinnerVerbs` — the ad text in the thinking-shimmer verb slot,
  //      replacing CC's stock "Discombobulating…"/"Baking…" pool (a
  //      brand-impression surface; the terminal verb is not clickable).
  // spinnerVerbs is gated on `spinnerVerbsSupported` (CC >= 2.1.143; older
  // CLIs silently ignore the key). History: an earlier adapter dropped
  // spinnerVerbs because, when the SAME settings.json was read by the VS
  // Code webview, the plain-text verb masked block.desync failures (rich
  // anchor missing but a plain-text ad still showed → broken click telemetry
  // looked fine). That risk is unchanged but accepted: the desync detector
  // (desyncDetector.ts) is timestamp-based and fires + auto-reloads
  // regardless of the spinner verb, and the webview overlay is the dominant
  // surface there. CC reads spinnerVerbs at boot, so the verb only rotates
  // on the next CC session; the statusLine ad updates live.

  applyPatch(p: PatchParams): OpResult {
    try {
      const existed = existsSync(this.settings);
      const pristine = existed
        ? readFileSync(this.settings, "utf8") : null;
      if (pristine !== null && !parseable(pristine))
        return { ok: false, reason: "settings.json not parseable" };

      mkdirSync(dirname(this.settings), { recursive: true });

      if (!existsSync(this.backupPath()))
        writeFileSync(this.backupPath(),
          pristine === null ? ABSENT : pristine, "utf8");
      mkdirSync(this.vibeDir(), { recursive: true });

      // Chain-capture: when settings.json carries a statusLine that is not
      // ours (e.g. a user HUD like claude-hud), persist it so (a) the
      // statusline script renders it on the lines BELOW the ad instead of
      // replacing it, and (b) restore() puts the entry back rather than
      // dropping the key. Idempotent across the 60s cliSync re-apply: once
      // the slot holds OUR command the capture is left untouched.
      const prevSl = pristine !== null
        ? readTopLevel(pristine, "statusLine") : undefined;
      if (isForeignStatusLine(prevSl)) {
        const json = JSON.stringify({ statusLine: prevSl });
        if (!existsSync(this.prevPath())
            || readFileSync(this.prevPath(), "utf8") !== json)
          writeFileSync(this.prevPath(), json, "utf8");
      }
      // NEVER auto-delete the capture. While installed the live slot holds
      // OUR command, so a vanished statusLine key means the user deleted the
      // AD entry (or settings.json was transiently absent mid-rewrite by CC
      // or a dotfile-sync tool) — neither is "the user deleted THEIR
      // statusLine". The capture is the only copy of their HUD; restore()
      // owns its cleanup and puts the entry back.
      const script = this.renderScript();
      // Idempotent: cliSync re-applies every 60s — skip the write when the
      // on-disk script is already byte-identical (no per-tick disk churn).
      if (!existsSync(this.scriptPath())
          || readFileSync(this.scriptPath(), "utf8") !== script)
        writeFileSync(this.scriptPath(), script, "utf8");

      const base = pristine ?? "{\n}\n";
      let next = upsertStatusLine(base, this.statusLineValue());
      // Gate the spinnerVerbs surface on CLI support. When supported, write
      // the ad as the replacement verb; otherwise REMOVE any spinnerVerbs
      // entry so an unsupported CLI keeps a clean settings.json and any
      // stale entry from a prior session heals on activation.
      next = this.spinnerVerbsSupported
        ? upsertSpinnerVerbs(next, this.spinnerVerbsValue(p.adText))
        : removeSpinnerVerbs(next);
      if (!existed || next !== pristine)
        writeFileSync(this.settings, next, "utf8");
      return { ok: true };
    } catch (e) {
      return { ok: false, reason: String(e) };
    }
  }

  restore(): RestoreResult {
    try {
      const bak = this.backupPath();
      if (!existsSync(bak))
        return { ok: true, restored: false, reason: "no backup present" };
      const saved = readFileSync(bak, "utf8");
      // KEY-SCOPED restore — never a whole-file rollback. The backup is a
      // point-in-time snapshot from FIRST apply; the user may have edited
      // settings.json since (hooks, permissions, model config), and any
      // restore trigger (offline blip → killswitch fail-safe, sign-out,
      // deactivate) would silently destroy those edits. Instead remove ONLY
      // the keys we own from the CURRENT file; everything else survives
      // byte-for-byte (settingsEdit raw-text edits). The snapshot is kept
      // solely as the ABSENT sentinel: when the file didn't exist before us
      // and nothing but our keys was ever added, delete the shell we created.
      if (existsSync(this.settings)) {
        const cur = readFileSync(this.settings, "utf8");
        if (!parseable(cur))
          // User-edited into unparseable JSONC — we can't edit it safely, and
          // overwriting with the stale snapshot would destroy their edits.
          // Leave everything (incl. the backup) so a later restore can finish.
          return { ok: false, restored: false,
                   reason: "settings.json not parseable" };
        // Put the user's pre-install statusLine back when chain-capture
        // saved one (falling back to the first-apply snapshot when the
        // capture file is gone); otherwise remove the key we own
        // (pre-capture behavior: the slot was empty before us).
        // Re-serialized compactly — raw-text formatting of the original
        // entry is not preserved, its value is. ONLY when the slot is still
        // ours or absent: a foreign entry means the user hand-installed a
        // NEW statusLine after capture, and their newer edit beats the
        // stale capture — touch nothing.
        const prevSl = this.readPrevStatusLine()
          ?? this.savedStatusLine(saved);
        const curSl = readTopLevel(cur, "statusLine");
        let next = cur;
        if (!isForeignStatusLine(curSl)) {
          next = prevSl !== undefined
            ? upsertStatusLine(cur, JSON.stringify(prevSl))
            : removeTopLevel(cur, "statusLine");
        }
        next = removeTopLevel(next, "spinnerVerbs");
        // The shell we created is `{}` plus whitespace; anything else left
        // (user keys, even bare comments) means the file is now theirs.
        const emptyShell = /^[\s{}]*$/.test(next);
        if (saved === ABSENT && emptyShell) {
          rmSync(this.settings);
        } else if (next !== cur) {
          writeFileSync(this.settings, next, "utf8");
          if (sha256(readFileSync(this.settings))
              !== sha256(Buffer.from(next, "utf8")))
            return { ok: false, restored: false,
                     reason: "sha256 mismatch after restore" };
        }
      }
      if (existsSync(this.scriptPath())) rmSync(this.scriptPath());
      if (existsSync(this.cachePath())) rmSync(this.cachePath());
      if (existsSync(this.prevPath())) rmSync(this.prevPath());
      rmSync(bak);
      return { ok: true, restored: true };
    } catch (e) {
      return { ok: false, restored: false, reason: String(e) };
    }
  }
}