← back to Kickbacks Ai Upstream

src/adapters/codex-cli/adapter.ts

161 lines

import { readFileSync, writeFileSync, existsSync, rmSync, mkdirSync,
         copyFileSync, chmodSync } from "node:fs";
import { resolve, dirname, join, basename } from "node:path";
import type { TargetAdapter, PreflightResult, OpResult, RestoreResult,
              PatchParams } from "../types";
import { sha256 } from "../../util/crypto";
import { resolveAsset } from "../../util/asset";

const MARKER = "VIBE-ADS-CODEX-CLI";
const AD_FILE_NAME = "codex-cli-ad.txt";

/** Terminal esc()-analog: strip control chars (C0 + DEL + C1) — and ONLY
 *  those — before the wrappers printf/echo the ad text raw to a terminal.
 *  Emoji / pipes / unicode / URLs pass through untouched (permissive by
 *  design — see backend validate_creative_fields, same char class). */
function stripControlChars(s: string): string {
  return s.replace(/[\x00-\x1f\x7f-\x9f]/g, "");
}

/** Resolve the wrapper asset in BOTH unbundled (co-located src) and
 *  esbuild-bundled (dist/adapters/codex-cli/) layouts — mirrors the
 *  CLI status-line adapter's resolveStatuslineAsset contract. */
export function resolveWrapperAsset(baseDir: string, isWin: boolean): string {
  const name = isWin ? "wrapper.cmd.asset" : "wrapper.sh.asset";
  return resolveAsset(baseDir, "adapters/codex-cli", name);
}

/** Wraps the npm-installed `codex` CLI shim with a small script that prints
 *  a one-line ad banner above the real codex invocation. Reversible: the
 *  pristine shim is copied to `<stem>.vibe-ads-orig<.cmd>` alongside it
 *  (kept next to the shim so the wrapper's `call` can target it on Windows,
 *  which requires a .cmd extension).
 *
 *  Codex CLI has no documented hook surface (per row 05 of
 *  test-stack/codexcli-test-e2e/), so a PATH wrapper is the only injection
 *  path that doesn't require an open-source binary rewrite. The banner is
 *  a startup print, NOT a live spinner verb — replacing the TUI's "Working"
 *  status row would require a context-anchored binary patch which is
 *  intentionally out of scope here. */
export class CodexCliWrapperAdapter implements TargetAdapter {
  readonly name = "codex-cli-wrapper";
  private readonly shim: string;
  private readonly home: string;
  private readonly isWin: boolean;

  /** @param shimPath absolute path to the npm-generated codex shim
   *      (codex.cmd on Windows; the extensionless `codex` JS shebang on POSIX).
   *  @param home directory hosting `~/.vibe-ads/` (typically os.homedir()). */
  constructor(shimPath: string, home: string) {
    this.shim = resolve(shimPath);
    this.home = resolve(home);
    this.isWin = this.shim.toLowerCase().endsWith(".cmd");
  }

  private vibeDir(): string { return join(this.home, ".vibe-ads"); }
  private adFilePath(): string { return join(this.vibeDir(), AD_FILE_NAME); }
  /** Backup lives ALONGSIDE the shim, not under ~/.vibe-ads, because cmd.exe's
   *  `call` resolves relative to the wrapper and only honours .cmd/.bat
   *  extensions on Windows. Keep round-trip simple by mirroring the shim's
   *  basename + extension. */
  private backupPath(): string {
    const dir = dirname(this.shim);
    if (this.isWin) {
      const stem = basename(this.shim, ".cmd");
      return join(dir, stem + ".vibe-ads-orig.cmd");
    }
    return join(dir, basename(this.shim) + ".vibe-ads-orig");
  }

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

  isPatched(): boolean {
    try {
      return existsSync(this.shim)
        && readFileSync(this.shim, "utf8").includes(MARKER);
    } catch { return false; }
  }

  preflight(): PreflightResult {
    try {
      if (!existsSync(this.shim))
        return { ok: true, compatible: false, version: "cli",
                 reason: "shim not found" };
      const raw = readFileSync(this.shim, "utf8");
      if (raw.includes(MARKER))
        return { ok: true, compatible: true, version: "cli" };
      // Sanity-check: only wrap things that LOOK like an npm-generated
      // codex shim — both the Windows .cmd and the POSIX JS-shebang version
      // reference `@openai/codex` (or its Windows-path form). A substring
      // match on bare "codex" would false-positive on any unrelated
      // codex.cmd containing the word ("not-codex", "claude-codex", etc.).
      if (!/@openai[\/\\]codex|codex\.js/.test(raw))
        return { ok: true, compatible: false, version: "cli",
                 reason: "shim doesn't look like @openai/codex" };
      return { ok: true, compatible: true, version: "cli" };
    } catch (e) {
      return { ok: false, compatible: false, version: null, reason: String(e) };
    }
  }

  private renderWrapper(): string {
    const asset = resolveWrapperAsset(dirname(__filename), this.isWin);
    return readFileSync(asset, "utf8")
      .split("__VIBE_ADS_AD_PATH__").join(this.adFilePath())
      .split("__VIBE_ADS_BACKUP__").join(this.backupPath());
  }

  applyPatch(p: PatchParams): OpResult {
    try {
      if (!existsSync(this.shim))
        return { ok: false, reason: "shim not found" };
      // Always (re)write the ad-text file. Cheap, decoupled from the wrapper
      // so an ad change does NOT require a wrapper rewrite — the wrapper
      // re-reads this file on every codex invocation.
      mkdirSync(this.vibeDir(), { recursive: true });
      // Control chars are stripped here (the shell/batch wrappers print the
      // file raw and cannot sanitize); an adText that strips to empty falls
      // back to the default line rather than printing a blank banner.
      writeFileSync(this.adFilePath(),
        (stripControlChars(p.adText || "") || "Earning Kickback") + "\n",
        "utf8");
      // Idempotent: if the shim already carries our marker, no wrapper rewrite.
      const current = readFileSync(this.shim, "utf8");
      if (current.includes(MARKER)) return { ok: true };
      // First-time install: snapshot the pristine npm shim. The check guards
      // against ever overwriting an existing backup with our wrapper, which
      // would happen if a stale wrapper file somehow lost its MARKER.
      if (!existsSync(this.backupPath()))
        copyFileSync(this.shim, this.backupPath());
      const wrapper = this.renderWrapper();
      writeFileSync(this.shim, wrapper, "utf8");
      if (!this.isWin) {
        try { chmodSync(this.shim, 0o755); } catch { /* best-effort */ }
      }
      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 pristine = readFileSync(bak);
      writeFileSync(this.shim, pristine);
      if (sha256(readFileSync(this.shim)) !== sha256(pristine))
        return { ok: false, restored: false,
                 reason: "sha256 mismatch after restore" };
      rmSync(bak);
      if (existsSync(this.adFilePath())) {
        try { rmSync(this.adFilePath()); } catch { /* best-effort */ }
      }
      return { ok: true, restored: true };
    } catch (e) {
      return { ok: false, restored: false, reason: String(e) };
    }
  }
}