import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import * as fs from "node:fs";
import * as path from "node:path";
import * as os from "node:os";
import { parse as parseYaml } from "yaml";
import { parse as parseJsonc } from "jsonc-parser";
import {
  ALL_ADAPTERS,
  getMcpEntry,
  isArgentManagedEntry,
  resolveLocalCommandMode,
  addClaudePermission,
  removeClaudePermission,
  copyRulesAndAgents,
  findConfiguredAdapterScopes,
  getManagedContentTargets,
  injectCodexRules,
  removeCodexRules,
} from "../src/mcp-configs.js";

// ── homedir mock ──────────────────────────────────────────────────────────────
// Allows individual tests to redirect homedir() to a temp path so that
// global-scope operations don't write into the real home directory.
// The variable is read at call time, so TDZ is not a concern.

let homedirOverride: string | undefined;

vi.mock("node:os", async (importOriginal) => {
  const original = await importOriginal<typeof import("node:os")>();
  return {
    ...original,
    homedir: vi.fn(() => homedirOverride ?? original.homedir()),
  };
});

// Stub the tool-server CLI shell-out used by the Codex adapter's allowlist
// logic so tests don't need the bundled `tool-server.cjs` on disk.
vi.mock("node:child_process", async (importOriginal) => {
  const original = await importOriginal<typeof import("node:child_process")>();
  return {
    ...original,
    execFileSync: vi.fn(() =>
      JSON.stringify([{ id: "tool-a" }, { id: "tool-b" }, { id: "tool-c" }])
    ),
  };
});

// ── Helpers ───────────────────────────────────────────────────────────────────

let tmpDir: string;

function setupTmpDir(): string {
  const dir = fs.mkdtempSync(path.join(os.tmpdir(), "argent-test-"));
  return dir;
}

function readJsonFile(filePath: string): Record<string, unknown> {
  return JSON.parse(fs.readFileSync(filePath, "utf8"));
}

// JSONC-tolerant variant for tests that read back files the Zed adapter
// wrote — those preserve user comments and trailing commas, so strict
// JSON.parse rejects them.
function readJsoncFile(filePath: string): Record<string, unknown> {
  return parseJsonc(fs.readFileSync(filePath, "utf8"), [], {
    allowTrailingComma: true,
  }) as Record<string, unknown>;
}

function readYamlFile(filePath: string): Record<string, unknown> {
  return parseYaml(fs.readFileSync(filePath, "utf8")) as Record<string, unknown>;
}

beforeEach(() => {
  tmpDir = setupTmpDir();
});

afterEach(() => {
  fs.rmSync(tmpDir, { recursive: true, force: true });
  // Safety net for tests that set the override inside an it() body: a leaked
  // override points homedir() at an already-removed tmpdir, which silently
  // neutralizes the home-branch of every later detection/adapter test (and
  // conversely, tests passing only thanks to the leak).
  homedirOverride = undefined;
});

// ── isArgentManagedEntry ──────────────────────────────────────────────────────

describe("isArgentManagedEntry", () => {
  it("accepts every shape argent itself writes", () => {
    expect(isArgentManagedEntry({ command: "argent", args: ["mcp"] })).toBe(true);
    expect(
      isArgentManagedEntry({
        command: "node",
        args: ["node_modules/@swmansion/argent/dist/cli.js", "mcp"],
      })
    ).toBe(true);
    // Windows separators in a committed relative path still count as managed.
    expect(
      isArgentManagedEntry({
        command: "node",
        args: ["node_modules\\@swmansion\\argent\\dist\\cli.js", "mcp"],
      })
    ).toBe(true);
    // The fallback shapes getLocalArgentBinRelPath emits for hoisted
    // workspaces and pnpm store layouts are argent-authored too.
    expect(
      isArgentManagedEntry({
        command: "node",
        args: ["../../node_modules/@swmansion/argent/dist/cli.js", "mcp"],
      })
    ).toBe(true);
    expect(
      isArgentManagedEntry({
        command: "node",
        args: [
          "node_modules/.pnpm/@swmansion+argent@1.0.0/node_modules/@swmansion/argent/dist/cli.js",
          "mcp",
        ],
      })
    ).toBe(true);
    expect(isArgentManagedEntry({ command: "yarn", args: ["argent", "mcp"] })).toBe(true);
    // The shape argent itself wrote up to v0.9.x (env dropped in 0.10 by
    // #238): still argent-authored, still repairable.
    expect(
      isArgentManagedEntry({
        command: "argent",
        args: ["mcp"],
        env: { ARGENT_MCP_LOG: "/Users/someone/.argent/mcp-calls.log" },
      })
    ).toBe(true);
    expect(isArgentManagedEntry({ command: "npx", args: ["--no-install", "argent", "mcp"] })).toBe(
      true
    );
  });

  it("rejects customized and unrecognizable entries", () => {
    // A dev checkout: node with an absolute / out-of-tree path.
    expect(
      isArgentManagedEntry({ command: "node", args: ["/home/dev/argent/cli.js", "mcp"] })
    ).toBe(false);
    expect(isArgentManagedEntry({ command: "node", args: ["../elsewhere/cli.js", "mcp"] })).toBe(
      false
    );
    // Env vars mark a hand-tuned entry even on the stock command.
    expect(
      isArgentManagedEntry({ command: "argent", args: ["mcp"], env: { PATH: "/custom" } })
    ).toBe(false);
    // Extra or missing args.
    expect(isArgentManagedEntry({ command: "argent", args: ["mcp", "--flag"] })).toBe(false);
    expect(isArgentManagedEntry({ command: "argent", args: [] })).toBe(false);
    // The unreadable-entry sentinel and absent entries.
    expect(isArgentManagedEntry({ command: "", args: [] })).toBe(false);
    expect(isArgentManagedEntry(null)).toBe(false);
    expect(isArgentManagedEntry({ command: "docker", args: ["run", "argent", "mcp"] })).toBe(false);
  });
});

// ── claudeAdapter.remove on the user-global file ─────────────────────────────

describe("claudeAdapter.remove on ~/.claude.json", () => {
  it("drops only the argent key and never prunes unrelated empty structures", () => {
    homedirOverride = tmpDir;
    const claude = ALL_ADAPTERS.find((a) => a.name === "Claude Code")!;
    const globalPath = path.join(tmpDir, ".claude.json");
    fs.writeFileSync(
      globalPath,
      JSON.stringify({
        oauthAccount: { email: "user@example.com" },
        projects: { "/work/app": { allowedTools: [], history: [] } },
        mcpServers: { argent: { command: "argent", args: ["mcp"] } },
      })
    );

    expect(claude.remove(globalPath)).toBe(true);

    const after = JSON.parse(fs.readFileSync(globalPath, "utf8"));
    expect(after.mcpServers).toBeUndefined();
    // The user's empty scaffolding survives — pruneEmptyConfig must not run
    // against their primary Claude config.
    expect(after.projects["/work/app"]).toEqual({ allowedTools: [], history: [] });
    expect(after.oauthAccount).toEqual({ email: "user@example.com" });
    homedirOverride = undefined;
  });
});

// ── Allowlist scope on global-only clients ────────────────────────────────────

describe("global-only allowlists honor the cleanup scope", () => {
  it("Cursor's removeAllowlist no-ops for scope 'local'", () => {
    homedirOverride = tmpDir;
    const cursor = ALL_ADAPTERS.find((a) => a.name === "Cursor")!;
    const permPath = path.join(tmpDir, ".cursor", "permissions.json");
    fs.mkdirSync(path.dirname(permPath), { recursive: true });
    fs.writeFileSync(permPath, JSON.stringify({ mcpAllowlist: ["argent:*"] }));

    // A local-only uninstall retaining the global install must not strip the
    // machine-global file that install depends on.
    cursor.removeAllowlist!(tmpDir, "local");
    expect(JSON.parse(fs.readFileSync(permPath, "utf8")).mcpAllowlist).toEqual(["argent:*"]);

    cursor.removeAllowlist!(tmpDir, "global");
    expect(fs.existsSync(permPath)).toBe(false);
    homedirOverride = undefined;
  });
});

// ── getArgentEntry env passthrough ────────────────────────────────────────────

describe("getArgentEntry env passthrough", () => {
  it("surfaces env vars so classification can see hand-tuned entries", () => {
    const cursor = ALL_ADAPTERS.find((a) => a.name === "Cursor")!;
    const configPath = path.join(tmpDir, ".cursor", "mcp.json");
    fs.mkdirSync(path.dirname(configPath), { recursive: true });
    fs.writeFileSync(
      configPath,
      JSON.stringify({
        mcpServers: {
          argent: { command: "argent", args: ["mcp"], env: { PATH: "/custom/bin" } },
        },
      })
    );

    expect(cursor.getArgentEntry(configPath)).toEqual({
      command: "argent",
      args: ["mcp"],
      env: { PATH: "/custom/bin" },
    });
  });

  it("maps opencode's `environment` key onto env", () => {
    const opencode = ALL_ADAPTERS.find((a) => a.name === "opencode")!;
    const configPath = path.join(tmpDir, "opencode.json");
    fs.writeFileSync(
      configPath,
      JSON.stringify({
        mcp: {
          argent: {
            type: "local",
            command: ["argent", "mcp"],
            enabled: true,
            environment: { FOO: "bar" },
          },
        },
      })
    );

    expect(opencode.getArgentEntry(configPath)).toEqual({
      command: "argent",
      args: ["mcp"],
      env: { FOO: "bar" },
    });
  });
});

// ── getMcpEntry ───────────────────────────────────────────────────────────────

describe("getMcpEntry", () => {
  it("returns an entry with argent as command and no env vars", () => {
    const entry = getMcpEntry();
    expect(entry.command).toBe("argent");
    expect(entry.args).toEqual(["mcp"]);
    // Generated configs are committed and shared, so they must not bake a
    // per-user log path into ARGENT_MCP_LOG (issue #238). The MCP server
    // falls back to ${homedir()}/.argent/mcp-calls.log at runtime.
    expect(entry.env).toBeUndefined();
  });

  it("defaults to the global command when no mode is passed", () => {
    expect(getMcpEntry({ kind: "global" })).toEqual({ command: "argent", args: ["mcp"] });
  });

  it("local-node runs `node <relative bin path> mcp`", () => {
    expect(
      getMcpEntry({ kind: "local-node", binRelPath: "node_modules/@swmansion/argent/dist/cli.js" })
    ).toEqual({
      command: "node",
      args: ["node_modules/@swmansion/argent/dist/cli.js", "mcp"],
    });
  });

  it("local-pnp runs `yarn argent mcp`", () => {
    expect(getMcpEntry({ kind: "local-pnp" })).toEqual({
      command: "yarn",
      args: ["argent", "mcp"],
    });
  });

  it("local-npx runs `npx --no-install argent mcp` (never bare npx / -y)", () => {
    expect(getMcpEntry({ kind: "local-npx" })).toEqual({
      command: "npx",
      args: ["--no-install", "argent", "mcp"],
    });
  });

  it("local entries carry no env block", () => {
    expect(getMcpEntry({ kind: "local-node", binRelPath: "x" }).env).toBeUndefined();
    expect(getMcpEntry({ kind: "local-pnp" }).env).toBeUndefined();
  });
});

// ── resolveLocalCommandMode ─────────────────────────────────────────────────

describe("resolveLocalCommandMode", () => {
  function stageLocalArgent(root: string): void {
    const pkgDir = path.join(root, "node_modules", "@swmansion", "argent", "dist");
    fs.mkdirSync(pkgDir, { recursive: true });
    fs.writeFileSync(
      path.join(root, "node_modules", "@swmansion", "argent", "package.json"),
      JSON.stringify({
        name: "@swmansion/argent",
        version: "1.0.0",
        bin: { argent: "dist/cli.js" },
      })
    );
    fs.writeFileSync(path.join(pkgDir, "cli.js"), "");
  }

  it("returns local-pnp for a Yarn PnP project", () => {
    fs.writeFileSync(path.join(tmpDir, ".pnp.cjs"), "");
    expect(resolveLocalCommandMode(tmpDir)).toEqual({ kind: "local-pnp" });
  });

  it("returns local-node with the resolved bin path when installed", () => {
    stageLocalArgent(tmpDir);
    expect(resolveLocalCommandMode(tmpDir)).toEqual({
      kind: "local-node",
      binRelPath: "node_modules/@swmansion/argent/dist/cli.js",
    });
  });

  it("falls back to local-npx when the bin can't be resolved", () => {
    expect(resolveLocalCommandMode(tmpDir)).toEqual({ kind: "local-npx" });
  });

  it("opencode writes the local-node entry as a command array", () => {
    const adapter = ALL_ADAPTERS.find((a) => a.name === "opencode")!;
    const configPath = path.join(tmpDir, "opencode.json");
    adapter.write(
      configPath,
      getMcpEntry({ kind: "local-node", binRelPath: "node_modules/@swmansion/argent/dist/cli.js" })
    );
    const cfg = JSON.parse(fs.readFileSync(configPath, "utf8")) as {
      mcp: { argent: { command: string[]; type: string; enabled: boolean } };
    };
    expect(cfg.mcp.argent.command).toEqual([
      "node",
      "node_modules/@swmansion/argent/dist/cli.js",
      "mcp",
    ]);
    expect(cfg.mcp.argent.type).toBe("local");
    expect(cfg.mcp.argent.enabled).toBe(true);
  });
});

// ── Adapter registry ──────────────────────────────────────────────────────────

describe("ALL_ADAPTERS", () => {
  it("contains all ten adapters", () => {
    const names = ALL_ADAPTERS.map((a) => a.name);
    expect(names).toEqual([
      "Cursor",
      "Claude Code",
      "VS Code",
      "Windsurf",
      "Zed",
      "Gemini",
      "Codex",
      "Hermes",
      "opencode",
      "Kiro",
    ]);
  });
});

// ── Cursor adapter ────────────────────────────────────────────────────────────

describe("Cursor adapter", () => {
  const adapter = ALL_ADAPTERS.find((a) => a.name === "Cursor")!;

  it("writes { mcpServers: { argent: ... } } format", () => {
    const configPath = path.join(tmpDir, ".cursor", "mcp.json");
    const entry = getMcpEntry();

    adapter.write(configPath, entry);

    const config = readJsonFile(configPath);
    const servers = config.mcpServers as Record<string, unknown>;
    expect(servers).toHaveProperty("argent");
    const argent = servers.argent as Record<string, unknown>;
    expect(argent.command).toBe("argent");
    expect(argent).not.toHaveProperty("type");
  });

  it("removes argent entry and returns true", () => {
    const configPath = path.join(tmpDir, ".cursor", "mcp.json");
    adapter.write(configPath, getMcpEntry());

    const removed = adapter.remove(configPath);
    expect(removed).toBe(true);
    expect(fs.existsSync(configPath)).toBe(false);
  });

  it("returns false when removing from non-existent file", () => {
    expect(adapter.remove(path.join(tmpDir, "nope.json"))).toBe(false);
  });

  it("returns false when removing from file without argent entry", () => {
    const configPath = path.join(tmpDir, ".cursor", "mcp.json");
    fs.mkdirSync(path.dirname(configPath), { recursive: true });
    fs.writeFileSync(configPath, JSON.stringify({ mcpServers: {} }));

    expect(adapter.remove(configPath)).toBe(false);
  });

  it("preserves other servers when writing", () => {
    const configPath = path.join(tmpDir, ".cursor", "mcp.json");
    fs.mkdirSync(path.dirname(configPath), { recursive: true });
    fs.writeFileSync(configPath, JSON.stringify({ mcpServers: { other: { command: "other" } } }));

    adapter.write(configPath, getMcpEntry());

    const config = readJsonFile(configPath);
    const servers = config.mcpServers as Record<string, unknown>;
    expect(servers).toHaveProperty("other");
    expect(servers).toHaveProperty("argent");
  });

  it("projectPath returns correct path", () => {
    expect(adapter.projectPath("/foo")).toBe(path.join("/foo", ".cursor", "mcp.json"));
  });

  it("globalPath returns path in homedir", () => {
    expect(adapter.globalPath()).toBe(path.join(os.homedir(), ".cursor", "mcp.json"));
  });
});

// ── Claude Code adapter ───────────────────────────────────────────────────────

describe("Claude Code adapter", () => {
  const adapter = ALL_ADAPTERS.find((a) => a.name === "Claude Code")!;

  it("writes { mcpServers: { argent: { type: 'stdio', ... } } }", () => {
    const configPath = path.join(tmpDir, ".mcp.json");
    adapter.write(configPath, getMcpEntry());

    const config = readJsonFile(configPath);
    const servers = config.mcpServers as Record<string, unknown>;
    const argent = servers.argent as Record<string, unknown>;
    expect(argent.type).toBe("stdio");
    expect(argent.command).toBe("argent");
  });

  it("removes argent entry", () => {
    const configPath = path.join(tmpDir, ".mcp.json");
    adapter.write(configPath, getMcpEntry());

    expect(adapter.remove(configPath)).toBe(true);
    expect(fs.existsSync(configPath)).toBe(false);
  });

  it("projectPath returns .mcp.json", () => {
    expect(adapter.projectPath("/foo")).toBe(path.join("/foo", ".mcp.json"));
  });

  it("globalPath returns ~/.claude.json", () => {
    expect(adapter.globalPath()).toBe(path.join(os.homedir(), ".claude.json"));
  });
});

// ── VS Code adapter ──────────────────────────────────────────────────────────

describe("VS Code adapter", () => {
  const adapter = ALL_ADAPTERS.find((a) => a.name === "VS Code")!;

  it("writes { servers: { argent: { type: 'stdio', ... } } }", () => {
    const configPath = path.join(tmpDir, ".vscode", "mcp.json");
    adapter.write(configPath, getMcpEntry());

    const config = readJsonFile(configPath);
    expect(config).toHaveProperty("servers");
    expect(config).not.toHaveProperty("mcpServers");
    const servers = config.servers as Record<string, unknown>;
    const argent = servers.argent as Record<string, unknown>;
    expect(argent.type).toBe("stdio");
  });

  it("removes from servers key", () => {
    const configPath = path.join(tmpDir, ".vscode", "mcp.json");
    adapter.write(configPath, getMcpEntry());

    expect(adapter.remove(configPath)).toBe(true);
    expect(fs.existsSync(configPath)).toBe(false);
  });

  it("globalPath returns null (project-only)", () => {
    expect(adapter.globalPath()).toBeNull();
  });
});

// ── Windsurf adapter ─────────────────────────────────────────────────────────

describe("Windsurf adapter", () => {
  const adapter = ALL_ADAPTERS.find((a) => a.name === "Windsurf")!;

  afterEach(() => {
    homedirOverride = undefined;
  });

  it("writes { mcpServers: { argent: ... } } without type", () => {
    const configPath = path.join(tmpDir, "mcp_config.json");
    adapter.write(configPath, getMcpEntry());

    const config = readJsonFile(configPath);
    const argent = (config.mcpServers as Record<string, unknown>).argent as Record<string, unknown>;
    expect(argent.command).toBe("argent");
    expect(argent).not.toHaveProperty("type");
  });

  it("projectPath returns null (global-only)", () => {
    expect(adapter.projectPath("/foo")).toBeNull();
  });

  it("globalPath returns ~/.codeium/windsurf/mcp_config.json", () => {
    expect(adapter.globalPath()).toBe(
      path.join(os.homedir(), ".codeium", "windsurf", "mcp_config.json")
    );
  });

  // The argent entry and its alwaysAllow toggle live in the same JSONC config,
  // and `init` runs write() (comment-preserving) before addAllowlist(). The old
  // readJson path parsed the commented file to {}, found no argent entry, and
  // silently skipped the toggle. editJsoncFile applies it while keeping comments
  // and foreign servers.
  it("addAllowlist/removeAllowlist round-trip on a commented config (JSONC-safe)", () => {
    homedirOverride = path.join(tmpDir, "home");
    const configPath = path.join(homedirOverride, ".codeium", "windsurf", "mcp_config.json");
    fs.mkdirSync(path.dirname(configPath), { recursive: true });
    fs.writeFileSync(
      configPath,
      `{\n  // my own MCP server, do not touch\n  "mcpServers": { "myserver": { "command": "my-bin", "args": ["x"] } }\n}\n`
    );

    adapter.write(configPath, getMcpEntry());
    adapter.addAllowlist!(tmpDir, "global");

    expect(fs.readFileSync(configPath, "utf8")).toContain("// my own MCP server, do not touch");
    const servers = readJsoncFile(configPath).mcpServers as Record<string, unknown>;
    expect(servers).toHaveProperty("myserver");
    expect((servers.argent as Record<string, unknown>).alwaysAllow).toEqual(["*"]);

    adapter.removeAllowlist!(tmpDir, "global");

    expect(fs.readFileSync(configPath, "utf8")).toContain("// my own MCP server, do not touch");
    const servers2 = readJsoncFile(configPath).mcpServers as Record<string, unknown>;
    expect(servers2).toHaveProperty("myserver");
    expect(servers2.argent as Record<string, unknown>).not.toHaveProperty("alwaysAllow");
  });
});

// ── Zed adapter ──────────────────────────────────────────────────────────────

describe("Zed adapter", () => {
  const adapter = ALL_ADAPTERS.find((a) => a.name === "Zed")!;

  it("writes { context_servers: { argent: { source: 'custom', ... } } }", () => {
    const configPath = path.join(tmpDir, ".zed", "settings.json");
    adapter.write(configPath, getMcpEntry());

    const config = readJsonFile(configPath);
    expect(config).toHaveProperty("context_servers");
    const servers = config.context_servers as Record<string, unknown>;
    const argent = servers.argent as Record<string, unknown>;
    expect(argent.source).toBe("custom");
    expect(argent.command).toBe("argent");
  });

  it("merges into existing settings.json", () => {
    const configPath = path.join(tmpDir, ".zed", "settings.json");
    fs.mkdirSync(path.dirname(configPath), { recursive: true });
    fs.writeFileSync(configPath, JSON.stringify({ theme: "dark", context_servers: {} }));

    adapter.write(configPath, getMcpEntry());

    const config = readJsonFile(configPath);
    expect(config.theme).toBe("dark");
    expect(config.context_servers as Record<string, unknown>).toHaveProperty("argent");
  });

  it("removes from context_servers", () => {
    const configPath = path.join(tmpDir, ".zed", "settings.json");
    adapter.write(configPath, getMcpEntry());

    expect(adapter.remove(configPath)).toBe(true);
    expect(fs.existsSync(configPath)).toBe(false);
  });

  it("globalPath returns ~/.config/zed/settings.json", () => {
    expect(adapter.globalPath()).toBe(path.join(os.homedir(), ".config", "zed", "settings.json"));
  });

  // Zed's settings.json is JSONC. The plain JSON.parse → mutate → JSON.stringify
  // round-trip used elsewhere strips every // and /* */ comment, plus trailing
  // commas. The Zed adapter goes through editJsoncFile so user-authored
  // formatting outside the touched key survives byte-for-byte.
  it("preserves user comments and trailing commas across write/remove", () => {
    const configPath = path.join(tmpDir, ".zed", "settings.json");
    fs.mkdirSync(path.dirname(configPath), { recursive: true });
    const original = `{
  // Theme
  "theme": "One Dark",
  /* fonts */
  "buffer_font_size": 14,
}
`;
    fs.writeFileSync(configPath, original);

    adapter.write(configPath, getMcpEntry());
    let after = fs.readFileSync(configPath, "utf8");
    expect(after).toContain("// Theme");
    expect(after).toContain("/* fonts */");
    expect(after).toContain('"buffer_font_size": 14');
    // The trailing comma after the last user key must still be present.
    expect(after).toMatch(/14,\s*\n/);
    // And the argent entry actually got written.
    expect(readJsoncFile(configPath).context_servers).toHaveProperty("argent");

    expect(adapter.remove(configPath)).toBe(true);
    after = fs.readFileSync(configPath, "utf8");
    // Comments stay after we leave again.
    expect(after).toContain("// Theme");
    expect(after).toContain("/* fonts */");
    // context_servers wrapper was empty after removing argent and got pruned.
    expect(readJsoncFile(configPath)).not.toHaveProperty("context_servers");
  });

  it("writes a freshly-created JSONC config with a trailing newline", () => {
    // No pre-existing file: editJsoncFile creates it. Like writeJson/TOML, a
    // freshly-created config must end with a newline (POSIX convention; avoids a
    // git "\\ No newline at end of file" and satisfies newline-requiring linters).
    const configPath = path.join(tmpDir, ".zed", "settings.json");
    adapter.write(configPath, getMcpEntry());
    const text = fs.readFileSync(configPath, "utf8");
    expect(text.endsWith("\n")).toBe(true);
    expect(readJsoncFile(configPath).context_servers).toHaveProperty("argent");
  });

  it("removes the file when only the argent key was present", () => {
    const configPath = path.join(tmpDir, ".zed", "settings.json");
    adapter.write(configPath, getMcpEntry());
    // No user keys, just our entry — remove() should clean up the file.
    expect(adapter.remove(configPath)).toBe(true);
    expect(fs.existsSync(configPath)).toBe(false);
  });

  it("addAllowlist/removeAllowlist round-trip preserves comments", () => {
    const configPath = path.join(tmpDir, ".zed", "settings.json");
    fs.mkdirSync(path.dirname(configPath), { recursive: true });
    fs.writeFileSync(
      configPath,
      `{
  // user note
  "theme": "Solarized"
}
`
    );

    adapter.addAllowlist!(tmpDir, "local");
    let after = fs.readFileSync(configPath, "utf8");
    expect(after).toContain("// user note");
    expect((readJsoncFile(configPath).agent as Record<string, unknown>).tool_permissions).toEqual({
      default: "allow",
    });

    adapter.removeAllowlist!(tmpDir, "local");
    after = fs.readFileSync(configPath, "utf8");
    expect(after).toContain("// user note");
    expect((readJsoncFile(configPath).agent as Record<string, unknown>).tool_permissions).toEqual({
      default: "confirm",
    });
  });
});

// ── Gemini adapter ────────────────────────────────────────────────────────────

describe("Gemini adapter", () => {
  const adapter = ALL_ADAPTERS.find((a) => a.name === "Gemini")!;

  it("writes { mcpServers: { argent: ... } } without type", () => {
    const configPath = path.join(tmpDir, "settings.json");
    adapter.write(configPath, getMcpEntry());

    const config = readJsonFile(configPath);
    const servers = config.mcpServers as Record<string, unknown>;
    expect(servers).toHaveProperty("argent");
    const argent = servers.argent as Record<string, unknown>;
    expect(argent.command).toBe("argent");
    expect(argent).not.toHaveProperty("type");
  });

  it("removes argent entry and returns true", () => {
    const configPath = path.join(tmpDir, "settings.json");
    adapter.write(configPath, getMcpEntry());

    const removed = adapter.remove(configPath);
    expect(removed).toBe(true);
    expect(fs.existsSync(configPath)).toBe(false);
  });

  it("returns false when removing from non-existent file", () => {
    expect(adapter.remove(path.join(tmpDir, "nope.json"))).toBe(false);
  });

  it("returns false when removing from file without argent entry", () => {
    const configPath = path.join(tmpDir, "settings.json");
    fs.writeFileSync(configPath, JSON.stringify({ mcpServers: {} }));
    expect(adapter.remove(configPath)).toBe(false);
  });

  it("projectPath returns .gemini/settings.json under project root", () => {
    expect(adapter.projectPath("/foo")).toBe(path.join("/foo", ".gemini", "settings.json"));
  });

  it("detect() returns true when local .gemini dir exists", () => {
    const localGemini = path.join(process.cwd(), ".gemini");
    const existed = fs.existsSync(localGemini);
    if (!existed) fs.mkdirSync(localGemini, { recursive: true });
    try {
      expect(adapter.detect()).toBe(true);
    } finally {
      if (!existed) fs.rmdirSync(localGemini);
    }
  });

  it("globalPath returns ~/.gemini/settings.json", () => {
    expect(adapter.globalPath()).toBe(path.join(os.homedir(), ".gemini", "settings.json"));
  });

  it("preserves existing settings when writing", () => {
    const configPath = path.join(tmpDir, "settings.json");
    fs.writeFileSync(
      configPath,
      JSON.stringify({
        mcpServers: { "other-tool": { command: "npx" } },
        security: { auth: "oauth" },
      })
    );

    adapter.write(configPath, getMcpEntry());

    const config = readJsonFile(configPath);
    const servers = config.mcpServers as Record<string, unknown>;
    expect(servers).toHaveProperty("other-tool");
    expect(servers).toHaveProperty("argent");
    expect(config.security).toBeDefined();
  });

  it("addAllowlist sets trust:true on the argent entry (local)", () => {
    const configPath = path.join(tmpDir, ".gemini", "settings.json");
    adapter.write(configPath, getMcpEntry());

    adapter.addAllowlist!(tmpDir, "local");

    const config = readJsonFile(configPath);
    const entry = (config.mcpServers as Record<string, unknown>).argent as Record<string, unknown>;
    expect(entry.trust).toBe(true);
  });

  it("addAllowlist sets trust:true on the argent entry (global)", () => {
    homedirOverride = path.join(tmpDir, "home");
    const configPath = path.join(homedirOverride, ".gemini", "settings.json");
    adapter.write(configPath, getMcpEntry());

    adapter.addAllowlist!(tmpDir, "global");

    const config = readJsonFile(configPath);
    const entry = (config.mcpServers as Record<string, unknown>).argent as Record<string, unknown>;
    expect(entry.trust).toBe(true);
  });

  it("removeAllowlist deletes trust from the argent entry", () => {
    const configPath = path.join(tmpDir, ".gemini", "settings.json");
    adapter.write(configPath, getMcpEntry());
    adapter.addAllowlist!(tmpDir, "local");

    adapter.removeAllowlist!(tmpDir, "local");

    const config = readJsonFile(configPath);
    const entry = (config.mcpServers as Record<string, unknown>).argent as Record<string, unknown>;
    expect(entry).not.toHaveProperty("trust");
  });

  it("removeAllowlist is a no-op when file does not exist", () => {
    expect(() => adapter.removeAllowlist!(tmpDir, "local")).not.toThrow();
  });

  // The argent entry and its trust flag live in the same JSONC settings.json,
  // and `init` runs write() (comment-preserving) before addAllowlist(). The old
  // readJson path parsed the commented file to {}, found no argent entry, and
  // silently skipped the toggle. editJsoncFile applies it while keeping comments
  // and foreign servers.
  it("addAllowlist/removeAllowlist round-trip on a commented config (JSONC-safe)", () => {
    const configPath = path.join(tmpDir, ".gemini", "settings.json");
    fs.mkdirSync(path.dirname(configPath), { recursive: true });
    fs.writeFileSync(
      configPath,
      `{\n  // my own MCP server, do not touch\n  "mcpServers": { "myserver": { "command": "my-bin", "args": ["x"] } }\n}\n`
    );

    adapter.write(configPath, getMcpEntry());
    adapter.addAllowlist!(tmpDir, "local");

    expect(fs.readFileSync(configPath, "utf8")).toContain("// my own MCP server, do not touch");
    const servers = readJsoncFile(configPath).mcpServers as Record<string, unknown>;
    expect(servers).toHaveProperty("myserver");
    expect((servers.argent as Record<string, unknown>).trust).toBe(true);

    adapter.removeAllowlist!(tmpDir, "local");

    expect(fs.readFileSync(configPath, "utf8")).toContain("// my own MCP server, do not touch");
    const servers2 = readJsoncFile(configPath).mcpServers as Record<string, unknown>;
    expect(servers2).toHaveProperty("myserver");
    expect(servers2.argent as Record<string, unknown>).not.toHaveProperty("trust");
  });
});

// ── Codex adapter ────────────────────────────────────────────────────────────

describe("Codex adapter", () => {
  const adapter = ALL_ADAPTERS.find((a) => a.name === "Codex")!;

  it("writes [mcp_servers.argent] in TOML format", () => {
    const configPath = path.join(tmpDir, "config.toml");
    adapter.write(configPath, getMcpEntry());

    const content = fs.readFileSync(configPath, "utf8");
    expect(content).toContain("[mcp_servers.argent]");
    expect(content).toContain('command = "argent"');
  });

  it("removes argent entry and returns true", () => {
    const configPath = path.join(tmpDir, "config.toml");
    adapter.write(configPath, getMcpEntry());

    const removed = adapter.remove(configPath);
    expect(removed).toBe(true);
    expect(fs.existsSync(configPath)).toBe(false);
  });

  it("returns false when removing from non-existent file", () => {
    expect(adapter.remove(path.join(tmpDir, "nope.toml"))).toBe(false);
  });

  it("returns false when removing from file without argent entry", () => {
    const configPath = path.join(tmpDir, "config.toml");
    fs.writeFileSync(configPath, "[mcp_servers]\n");
    expect(adapter.remove(configPath)).toBe(false);
  });

  it("projectPath returns .codex/config.toml under project root", () => {
    expect(adapter.projectPath("/foo")).toBe(path.join("/foo", ".codex", "config.toml"));
  });

  it("detect() returns true when the local .codex dir holds user content", () => {
    // A bare/argent-only .codex dir is NOT detection evidence (see the
    // detection-evidence suite); real user content in it is. Both detect()
    // branches must be isolated: an EMPTY temp home pins the assertion to the
    // project-dir branch (a real ~/.codex would satisfy detect() on its own
    // and mask a broken cwd path), and the mocked cwd keeps the marker out of
    // the actual working directory.
    homedirOverride = path.join(tmpDir, "home");
    fs.mkdirSync(homedirOverride, { recursive: true });
    const proj = path.join(tmpDir, "proj");
    fs.mkdirSync(path.join(proj, ".codex"), { recursive: true });
    fs.writeFileSync(path.join(proj, ".codex", "history.jsonl"), "");
    const cwdSpy = vi.spyOn(process, "cwd").mockReturnValue(proj);
    try {
      expect(adapter.detect()).toBe(true);
    } finally {
      cwdSpy.mockRestore();
    }
  });

  it("globalPath returns ~/.codex/config.toml", () => {
    expect(adapter.globalPath()).toBe(path.join(os.homedir(), ".codex", "config.toml"));
  });

  it("preserves existing settings when writing", () => {
    const configPath = path.join(tmpDir, "config.toml");
    fs.writeFileSync(configPath, 'model = "o3"\n\n[mcp_servers.other]\ncommand = "npx"\n');

    adapter.write(configPath, getMcpEntry());

    const content = fs.readFileSync(configPath, "utf8");
    expect(content).toContain('model = "o3"');
    expect(content).toContain("[mcp_servers.other]");
    expect(content).toContain("[mcp_servers.argent]");
  });

  it("addAllowlist writes approval_mode per tool under mcp_servers.argent.tools (local)", () => {
    const configPath = path.join(tmpDir, ".codex", "config.toml");
    adapter.write(configPath, getMcpEntry());

    adapter.addAllowlist!(tmpDir, "local");

    const content = fs.readFileSync(configPath, "utf8");
    expect(content).toContain("[mcp_servers.argent.tools.tool-a]");
    expect(content).toContain("[mcp_servers.argent.tools.tool-b]");
    expect(content).toContain("[mcp_servers.argent.tools.tool-c]");
    expect(content).toContain('approval_mode = "approve"');
  });

  it("addAllowlist works with global scope", () => {
    homedirOverride = path.join(tmpDir, "home");
    const configPath = path.join(homedirOverride, ".codex", "config.toml");
    adapter.write(configPath, getMcpEntry());

    adapter.addAllowlist!(tmpDir, "global");

    const content = fs.readFileSync(configPath, "utf8");
    expect(content).toContain("[mcp_servers.argent.tools.tool-a]");
  });

  it("addAllowlist preserves the existing argent entry", () => {
    const configPath = path.join(tmpDir, ".codex", "config.toml");
    adapter.write(configPath, getMcpEntry());

    adapter.addAllowlist!(tmpDir, "local");

    const content = fs.readFileSync(configPath, "utf8");
    expect(content).toContain('command = "argent"');
  });

  it("removeAllowlist deletes tool entries added by addAllowlist", () => {
    const configPath = path.join(tmpDir, ".codex", "config.toml");
    adapter.write(configPath, getMcpEntry());
    adapter.addAllowlist!(tmpDir, "local");

    adapter.removeAllowlist!(tmpDir, "local");

    const content = fs.readFileSync(configPath, "utf8");
    expect(content).not.toContain("[mcp_servers.argent.tools.tool-a]");
    expect(content).not.toContain("[mcp_servers.argent.tools.tool-b]");
    expect(content).not.toContain("[mcp_servers.argent.tools.tool-c]");
    expect(content).toContain('command = "argent"');
  });

  it("removeAllowlist is a no-op when no tools section exists", () => {
    const configPath = path.join(tmpDir, ".codex", "config.toml");
    adapter.write(configPath, getMcpEntry());

    expect(() => adapter.removeAllowlist!(tmpDir, "local")).not.toThrow();
    const content = fs.readFileSync(configPath, "utf8");
    expect(content).toContain('command = "argent"');
  });
});

// ── Hermes adapter ──────────────────────────────────────────────────────────

describe("Hermes adapter", () => {
  const adapter = ALL_ADAPTERS.find((a) => a.name === "Hermes")!;

  it("writes YAML format with mcp_servers key", () => {
    const configPath = path.join(tmpDir, ".hermes", "config.yaml");
    adapter.write(configPath, getMcpEntry());

    const config = readYamlFile(configPath);
    const servers = config.mcp_servers as Record<string, unknown>;
    expect(servers).toHaveProperty("argent");
    const argent = servers.argent as Record<string, unknown>;
    expect(argent.command).toBe("argent");
    expect(argent.args).toEqual(["mcp"]);
    // No env field unless the entry explicitly carries env vars (issue #238).
    expect(argent).not.toHaveProperty("env");
  });

  it("removes argent entry and drops empty mcp_servers", () => {
    const configPath = path.join(tmpDir, ".hermes", "config.yaml");
    adapter.write(configPath, getMcpEntry());

    expect(adapter.remove(configPath)).toBe(true);
    const config = readYamlFile(configPath);
    expect(config).not.toHaveProperty("mcp_servers");
  });

  it("returns false when removing from non-existent file", () => {
    expect(adapter.remove(path.join(tmpDir, "nope.yaml"))).toBe(false);
  });

  it("returns false when removing from file without argent entry", () => {
    const configPath = path.join(tmpDir, ".hermes", "config.yaml");
    fs.mkdirSync(path.dirname(configPath), { recursive: true });
    fs.writeFileSync(configPath, "mcp_servers: {}\n");

    expect(adapter.remove(configPath)).toBe(false);
  });

  it("preserves other config and servers when writing", () => {
    const configPath = path.join(tmpDir, ".hermes", "config.yaml");
    fs.mkdirSync(path.dirname(configPath), { recursive: true });
    fs.writeFileSync(configPath, "model: test\nmcp_servers:\n  other:\n    command: other\n");

    adapter.write(configPath, getMcpEntry());

    const config = readYamlFile(configPath);
    expect(config.model).toBe("test");
    const servers = config.mcp_servers as Record<string, unknown>;
    expect(servers).toHaveProperty("other");
    expect(servers).toHaveProperty("argent");
  });

  it("projectPath returns null", () => {
    expect(adapter.projectPath("/foo")).toBeNull();
  });

  it("globalPath returns path in homedir", () => {
    expect(adapter.globalPath()).toBe(path.join(os.homedir(), ".hermes", "config.yaml"));
  });

  it("preserves comments on write", () => {
    const configPath = path.join(tmpDir, ".hermes", "config.yaml");
    fs.mkdirSync(path.dirname(configPath), { recursive: true });
    fs.writeFileSync(
      configPath,
      [
        "# top-of-file comment",
        "model:",
        "  default: claude-opus-4-6 # inline comment",
        "  provider: anthropic",
        "# section comment",
        "agent:",
        "  max_turns: 90",
        "",
      ].join("\n")
    );

    adapter.write(configPath, getMcpEntry());

    const after = fs.readFileSync(configPath, "utf8");
    expect(after).toContain("# top-of-file comment");
    expect(after).toContain("# inline comment");
    expect(after).toContain("# section comment");
    const config = readYamlFile(configPath);
    expect(config.mcp_servers as Record<string, unknown>).toHaveProperty("argent");
    expect((config.model as Record<string, unknown>).default).toBe("claude-opus-4-6");
  });

  it("preserves comments around other keys on remove", () => {
    const configPath = path.join(tmpDir, ".hermes", "config.yaml");
    fs.mkdirSync(path.dirname(configPath), { recursive: true });
    fs.writeFileSync(
      configPath,
      [
        "# header",
        "model:",
        "  default: claude-opus-4-6",
        "mcp_servers:",
        "  argent:",
        "    command: argent",
        "    args:",
        "      - mcp",
        "    env: {}",
        "  filesystem:",
        "    command: npx # keep me",
        "",
      ].join("\n")
    );

    expect(adapter.remove(configPath)).toBe(true);

    const after = fs.readFileSync(configPath, "utf8");
    expect(after).toContain("# header");
    expect(after).toContain("# keep me");
    const config = readYamlFile(configPath);
    expect(config.mcp_servers as Record<string, unknown>).toHaveProperty("filesystem");
    expect(config.mcp_servers as Record<string, unknown>).not.toHaveProperty("argent");
  });

  it("throws when config.yaml is malformed instead of silently overwriting", () => {
    const configPath = path.join(tmpDir, ".hermes", "config.yaml");
    fs.mkdirSync(path.dirname(configPath), { recursive: true });
    const bogus = "model:\n  default: [unbalanced\nagent: : :\n";
    fs.writeFileSync(configPath, bogus);

    expect(() => adapter.write(configPath, getMcpEntry())).toThrow(/Failed to parse YAML/);
    expect(fs.readFileSync(configPath, "utf8")).toBe(bogus);
  });

  it("throws when mcp_servers is a sequence instead of silently no-op", () => {
    const configPath = path.join(tmpDir, ".hermes", "config.yaml");
    fs.mkdirSync(path.dirname(configPath), { recursive: true });
    fs.writeFileSync(configPath, "mcp_servers:\n  - foo\n  - bar\n");

    expect(() => adapter.write(configPath, getMcpEntry())).toThrow(/not a YAML mapping/);
  });

  it("handles mcp_servers explicitly null", () => {
    const configPath = path.join(tmpDir, ".hermes", "config.yaml");
    fs.mkdirSync(path.dirname(configPath), { recursive: true });
    fs.writeFileSync(configPath, "model: test\nmcp_servers:\n");

    adapter.write(configPath, getMcpEntry());

    const config = readYamlFile(configPath);
    expect(config.model).toBe("test");
    expect(config.mcp_servers as Record<string, unknown>).toHaveProperty("argent");
  });

  it("write is idempotent", () => {
    const configPath = path.join(tmpDir, ".hermes", "config.yaml");
    adapter.write(configPath, getMcpEntry());
    const first = fs.readFileSync(configPath, "utf8");
    adapter.write(configPath, getMcpEntry());
    const second = fs.readFileSync(configPath, "utf8");
    expect(second).toBe(first);
  });

  it("creates ~/.hermes/config.yaml when neither file nor directory exists", () => {
    const configPath = path.join(tmpDir, ".hermes-fresh", "config.yaml");
    expect(fs.existsSync(path.dirname(configPath))).toBe(false);

    adapter.write(configPath, getMcpEntry());

    expect(fs.existsSync(configPath)).toBe(true);
    const config = readYamlFile(configPath);
    expect(config.mcp_servers as Record<string, unknown>).toHaveProperty("argent");
  });

  it("does not hard-wrap long user strings (lineWidth disabled)", () => {
    const configPath = path.join(tmpDir, ".hermes", "config.yaml");
    fs.mkdirSync(path.dirname(configPath), { recursive: true });
    const longLine =
      "You are a kawaii assistant! Use cute expressions and be super enthusiastic about everything! Every response should feel warm and adorable.";
    fs.writeFileSync(configPath, `agent:\n  personalities:\n    kawaii: "${longLine}"\n`);

    adapter.write(configPath, getMcpEntry());

    const after = fs.readFileSync(configPath, "utf8");
    expect(after).toContain(longLine);
    // The full long string must remain on a single content line — the yaml
    // library would wrap at column 80 by default.
    const kawaiiLine = after.split("\n").find((l) => l.includes("kawaii:"));
    expect(kawaiiLine).toBeDefined();
    expect(kawaiiLine!.length).toBeGreaterThan(80);
  });

  it("write+remove on a realistic seed is semantically lossless", () => {
    const configPath = path.join(tmpDir, ".hermes", "config.yaml");
    fs.mkdirSync(path.dirname(configPath), { recursive: true });
    const seed = [
      "# managed by hermes",
      "model:",
      "  default: claude-opus-4-6 # selected via hermes model",
      "  provider: anthropic",
      "providers: {}",
      "fallback_providers: []",
      "toolsets:",
      "  - hermes-cli",
      "agent:",
      "  max_turns: 90",
      "  personalities:",
      '    helpful: "You are a helpful assistant."',
      '    kawaii: "You are a kawaii assistant! Use cute expressions and be super enthusiastic."',
      "terminal:",
      "  backend: local",
      "",
    ].join("\n");
    fs.writeFileSync(configPath, seed);
    const before = readYamlFile(configPath);

    adapter.write(configPath, getMcpEntry());
    expect(adapter.remove(configPath)).toBe(true);

    const after = fs.readFileSync(configPath, "utf8");
    const afterParsed = readYamlFile(configPath);
    // Semantic check: parsed JS should equal the original parsed JS
    expect(JSON.stringify(afterParsed)).toBe(JSON.stringify(before));
    // Every comment line in the seed must still be in the output
    const seedComments = seed.split("\n").filter((l) => l.includes("#"));
    for (const c of seedComments) {
      const stripped = c.trim();
      expect(after).toContain(stripped);
    }
  });
});

// ── opencode adapter ────────────────────────────────────────────────────────

describe("opencode adapter", () => {
  const adapter = ALL_ADAPTERS.find((a) => a.name === "opencode")!;

  it("writes { mcp: { argent: { type: 'local', command: [...] } } }", () => {
    const configPath = path.join(tmpDir, "opencode.json");
    adapter.write(configPath, getMcpEntry());

    const config = readJsonFile(configPath);
    const servers = config.mcp as Record<string, unknown>;
    expect(servers).toHaveProperty("argent");
    const argent = servers.argent as Record<string, unknown>;
    expect(argent.type).toBe("local");
    expect(argent.command).toEqual(["argent", "mcp"]);
    expect(argent.enabled).toBe(true);
    // No environment field unless the entry explicitly carries env vars
    // (issue #238).
    expect(argent).not.toHaveProperty("environment");
  });

  it("removes argent entry and returns true", () => {
    const configPath = path.join(tmpDir, "opencode.json");
    adapter.write(configPath, getMcpEntry());

    expect(adapter.remove(configPath)).toBe(true);
    expect(fs.existsSync(configPath)).toBe(false);
  });

  it("returns false when removing from non-existent file", () => {
    expect(adapter.remove(path.join(tmpDir, "nope.json"))).toBe(false);
  });

  it("returns false when removing from file without argent entry", () => {
    const configPath = path.join(tmpDir, "opencode.json");
    fs.writeFileSync(configPath, JSON.stringify({ mcp: {} }));
    expect(adapter.remove(configPath)).toBe(false);
  });

  it("preserves other servers and unrelated settings when writing", () => {
    const configPath = path.join(tmpDir, "opencode.json");
    fs.writeFileSync(
      configPath,
      JSON.stringify({
        mcp: { "other-tool": { type: "local", command: ["npx", "other"] } },
        theme: "dark",
      })
    );

    adapter.write(configPath, getMcpEntry());

    const config = readJsonFile(configPath);
    const servers = config.mcp as Record<string, unknown>;
    expect(servers).toHaveProperty("other-tool");
    expect(servers).toHaveProperty("argent");
    expect(config.theme).toBe("dark");
  });

  it("projectPath returns opencode.json at project root", () => {
    expect(adapter.projectPath("/foo")).toBe(path.join("/foo", "opencode.json"));
  });

  it("globalPath returns ~/.config/opencode/opencode.json", () => {
    expect(adapter.globalPath()).toBe(
      path.join(os.homedir(), ".config", "opencode", "opencode.json")
    );
  });

  it("projectPath prefers existing opencode.jsonc over default opencode.json", () => {
    const jsoncPath = path.join(tmpDir, "opencode.jsonc");
    fs.writeFileSync(jsoncPath, "{}");
    expect(adapter.projectPath(tmpDir)).toBe(jsoncPath);
  });

  it("projectPath falls back to opencode.json when no candidate exists", () => {
    expect(adapter.projectPath(tmpDir)).toBe(path.join(tmpDir, "opencode.json"));
  });

  it("globalPath returns legacy config.json when it is the only candidate", () => {
    homedirOverride = path.join(tmpDir, "home");
    const opencodeDir = path.join(homedirOverride, ".config", "opencode");
    fs.mkdirSync(opencodeDir, { recursive: true });
    const configJsonPath = path.join(opencodeDir, "config.json");
    fs.writeFileSync(configJsonPath, "{}");
    expect(adapter.globalPath()).toBe(configJsonPath);
  });

  // Mirrors opencode's own globalConfigFile() precedence so the file argent
  // writes is the same one opencode treats as authoritative.
  it("globalPath prefers opencode.jsonc over opencode.json over config.json", () => {
    homedirOverride = path.join(tmpDir, "home");
    const opencodeDir = path.join(homedirOverride, ".config", "opencode");
    fs.mkdirSync(opencodeDir, { recursive: true });
    fs.writeFileSync(path.join(opencodeDir, "config.json"), "{}");
    fs.writeFileSync(path.join(opencodeDir, "opencode.json"), "{}");
    fs.writeFileSync(path.join(opencodeDir, "opencode.jsonc"), "{}");
    expect(adapter.globalPath()).toBe(path.join(opencodeDir, "opencode.jsonc"));
  });

  it("globalPath prefers opencode.json over config.json when .jsonc is absent", () => {
    homedirOverride = path.join(tmpDir, "home");
    const opencodeDir = path.join(homedirOverride, ".config", "opencode");
    fs.mkdirSync(opencodeDir, { recursive: true });
    fs.writeFileSync(path.join(opencodeDir, "config.json"), "{}");
    fs.writeFileSync(path.join(opencodeDir, "opencode.json"), "{}");
    expect(adapter.globalPath()).toBe(path.join(opencodeDir, "opencode.json"));
  });

  it("globalPath falls back to opencode.json when no candidate exists", () => {
    homedirOverride = path.join(tmpDir, "home");
    expect(adapter.globalPath()).toBe(
      path.join(homedirOverride, ".config", "opencode", "opencode.json")
    );
  });

  it("addAllowlist sets 'argent*' wildcard in tools (local)", () => {
    const configPath = path.join(tmpDir, "opencode.json");
    adapter.write(configPath, getMcpEntry());

    adapter.addAllowlist!(tmpDir, "local");

    const config = readJsonFile(configPath);
    const tools = config.tools as Record<string, unknown>;
    expect(tools["argent*"]).toBe(true);
  });

  it("addAllowlist sets 'argent*' wildcard in tools (global)", () => {
    homedirOverride = path.join(tmpDir, "home");
    const configPath = path.join(homedirOverride, ".config", "opencode", "opencode.json");
    adapter.write(configPath, getMcpEntry());

    adapter.addAllowlist!(tmpDir, "global");

    const config = readJsonFile(configPath);
    const tools = config.tools as Record<string, unknown>;
    expect(tools["argent*"]).toBe(true);
  });

  it("removeAllowlist deletes the 'argent*' entry", () => {
    const configPath = path.join(tmpDir, "opencode.json");
    adapter.write(configPath, getMcpEntry());
    adapter.addAllowlist!(tmpDir, "local");

    adapter.removeAllowlist!(tmpDir, "local");

    const config = readJsonFile(configPath);
    const tools = config.tools as Record<string, unknown> | undefined;
    expect(tools?.["argent*"]).toBeUndefined();
  });

  it("removeAllowlist is a no-op when file does not exist", () => {
    expect(() => adapter.removeAllowlist!(tmpDir, "local")).not.toThrow();
  });

  it("preserves other tools entries when adding allowlist", () => {
    const configPath = path.join(tmpDir, "opencode.json");
    fs.writeFileSync(
      configPath,
      JSON.stringify({ mcp: {}, tools: { "other-mcp*": true, "write": "ask" } })
    );

    adapter.addAllowlist!(tmpDir, "local");

    const config = readJsonFile(configPath);
    const tools = config.tools as Record<string, unknown>;
    expect(tools["other-mcp*"]).toBe(true);
    expect(tools.write).toBe("ask");
    expect(tools["argent*"]).toBe(true);
  });

  // opencode supports both opencode.json (strict JSON) and opencode.jsonc
  // (with comments + trailing commas). Going through editJsoncFile means
  // user-authored comments survive write/remove the same way they do for Zed.
  it("preserves user comments when writing into opencode.jsonc", () => {
    const configPath = path.join(tmpDir, "opencode.jsonc");
    const original = `{
  // top-of-file comment
  "theme": "opencode-dark",
  /* trailing block comment */
}
`;
    fs.writeFileSync(configPath, original);

    adapter.write(configPath, getMcpEntry());

    const after = fs.readFileSync(configPath, "utf8");
    expect(after).toContain("// top-of-file comment");
    expect(after).toContain("/* trailing block comment */");
    expect(after).toContain('"theme": "opencode-dark"');

    const parsed = readJsoncFile(configPath);
    const servers = parsed.mcp as Record<string, unknown>;
    expect(servers).toHaveProperty("argent");
    expect((servers.argent as Record<string, unknown>).type).toBe("local");
  });
});

// ── Kiro adapter ─────────────────────────────────────────────────────────────

describe("Kiro adapter", () => {
  const adapter = ALL_ADAPTERS.find((a) => a.name === "Kiro")!;

  afterEach(() => {
    homedirOverride = undefined;
  });

  it("writes { mcpServers: { argent: ... } } without type", () => {
    const configPath = path.join(tmpDir, ".kiro", "settings", "mcp.json");
    adapter.write(configPath, getMcpEntry());

    const config = readJsonFile(configPath);
    const servers = config.mcpServers as Record<string, unknown>;
    expect(servers).toHaveProperty("argent");
    const argent = servers.argent as Record<string, unknown>;
    expect(argent.command).toBe("argent");
    expect(argent.args).toEqual(["mcp"]);
    expect(argent).not.toHaveProperty("type");
  });

  it("removes argent entry and returns true", () => {
    const configPath = path.join(tmpDir, ".kiro", "settings", "mcp.json");
    adapter.write(configPath, getMcpEntry());

    expect(adapter.remove(configPath)).toBe(true);
    expect(fs.existsSync(configPath)).toBe(false);
  });

  it("returns false when removing from non-existent file", () => {
    expect(adapter.remove(path.join(tmpDir, "nope.json"))).toBe(false);
  });

  it("returns false when removing from file without argent entry", () => {
    const configPath = path.join(tmpDir, ".kiro", "settings", "mcp.json");
    fs.mkdirSync(path.dirname(configPath), { recursive: true });
    fs.writeFileSync(configPath, JSON.stringify({ mcpServers: {} }));
    expect(adapter.remove(configPath)).toBe(false);
  });

  it("preserves other servers when writing", () => {
    const configPath = path.join(tmpDir, ".kiro", "settings", "mcp.json");
    fs.mkdirSync(path.dirname(configPath), { recursive: true });
    fs.writeFileSync(configPath, JSON.stringify({ mcpServers: { other: { command: "other" } } }));

    adapter.write(configPath, getMcpEntry());

    const config = readJsonFile(configPath);
    const servers = config.mcpServers as Record<string, unknown>;
    expect(servers).toHaveProperty("other");
    expect(servers).toHaveProperty("argent");
  });

  it("projectPath returns .kiro/settings/mcp.json under project root", () => {
    expect(adapter.projectPath("/foo")).toBe(path.join("/foo", ".kiro", "settings", "mcp.json"));
  });

  it("globalPath returns ~/.kiro/settings/mcp.json", () => {
    expect(adapter.globalPath()).toBe(path.join(os.homedir(), ".kiro", "settings", "mcp.json"));
  });

  it("detect() returns true when local .kiro dir exists", () => {
    const localKiro = path.join(process.cwd(), ".kiro");
    const existed = fs.existsSync(localKiro);
    if (!existed) fs.mkdirSync(localKiro, { recursive: true });
    try {
      expect(adapter.detect()).toBe(true);
    } finally {
      if (!existed) fs.rmdirSync(localKiro);
    }
  });

  it("addAllowlist sets autoApprove: ['*'] on the argent entry (local)", () => {
    const configPath = path.join(tmpDir, ".kiro", "settings", "mcp.json");
    adapter.write(configPath, getMcpEntry());

    adapter.addAllowlist!(tmpDir, "local");

    const config = readJsonFile(configPath);
    const entry = (config.mcpServers as Record<string, unknown>).argent as Record<string, unknown>;
    expect(entry.autoApprove).toEqual(["*"]);
  });

  it("addAllowlist sets autoApprove: ['*'] on the argent entry (global)", () => {
    homedirOverride = path.join(tmpDir, "home");
    const configPath = path.join(homedirOverride, ".kiro", "settings", "mcp.json");
    adapter.write(configPath, getMcpEntry());

    adapter.addAllowlist!(tmpDir, "global");

    const config = readJsonFile(configPath);
    const entry = (config.mcpServers as Record<string, unknown>).argent as Record<string, unknown>;
    expect(entry.autoApprove).toEqual(["*"]);
  });

  it("removeAllowlist deletes autoApprove from the argent entry", () => {
    const configPath = path.join(tmpDir, ".kiro", "settings", "mcp.json");
    adapter.write(configPath, getMcpEntry());
    adapter.addAllowlist!(tmpDir, "local");

    adapter.removeAllowlist!(tmpDir, "local");

    const config = readJsonFile(configPath);
    const entry = (config.mcpServers as Record<string, unknown>).argent as Record<string, unknown>;
    expect(entry).not.toHaveProperty("autoApprove");
  });

  it("removeAllowlist is a no-op when file does not exist", () => {
    expect(() => adapter.removeAllowlist!(tmpDir, "local")).not.toThrow();
  });

  // The argent entry and its autoApprove list live in the same JSONC mcp.json,
  // and `init` runs write() (comment-preserving) before addAllowlist(). The old
  // readJson path parsed the commented file to {}, found no argent entry, and
  // silently skipped the toggle. editJsoncFile applies it while keeping comments
  // and foreign servers.
  it("addAllowlist/removeAllowlist round-trip on a commented config (JSONC-safe)", () => {
    const configPath = path.join(tmpDir, ".kiro", "settings", "mcp.json");
    fs.mkdirSync(path.dirname(configPath), { recursive: true });
    fs.writeFileSync(
      configPath,
      `{\n  // my own MCP server, do not touch\n  "mcpServers": { "myserver": { "command": "my-bin", "args": ["x"] } }\n}\n`
    );

    adapter.write(configPath, getMcpEntry());
    adapter.addAllowlist!(tmpDir, "local");

    expect(fs.readFileSync(configPath, "utf8")).toContain("// my own MCP server, do not touch");
    const servers = readJsoncFile(configPath).mcpServers as Record<string, unknown>;
    expect(servers).toHaveProperty("myserver");
    expect((servers.argent as Record<string, unknown>).autoApprove).toEqual(["*"]);

    adapter.removeAllowlist!(tmpDir, "local");

    expect(fs.readFileSync(configPath, "utf8")).toContain("// my own MCP server, do not touch");
    const servers2 = readJsoncFile(configPath).mcpServers as Record<string, unknown>;
    expect(servers2).toHaveProperty("myserver");
    expect(servers2.argent as Record<string, unknown>).not.toHaveProperty("autoApprove");
  });
});

// ── Claude permissions ────────────────────────────────────────────────────────

describe("addClaudePermission / removeClaudePermission", () => {
  it("adds mcp__argent to .claude/settings.json", () => {
    addClaudePermission(tmpDir, "local");

    const settingsPath = path.join(tmpDir, ".claude", "settings.json");
    const config = readJsonFile(settingsPath);
    const allow = (config.permissions as Record<string, unknown>).allow as string[];
    expect(allow).toContain("mcp__argent");
  });

  it("does not duplicate the permission", () => {
    addClaudePermission(tmpDir, "local");
    addClaudePermission(tmpDir, "local");

    const settingsPath = path.join(tmpDir, ".claude", "settings.json");
    const config = readJsonFile(settingsPath);
    const allow = (config.permissions as Record<string, unknown>).allow as string[];
    expect(allow.filter((r) => r === "mcp__argent")).toHaveLength(1);
  });

  it("removes the permission", () => {
    addClaudePermission(tmpDir, "local");
    removeClaudePermission(tmpDir, "local");

    const settingsPath = path.join(tmpDir, ".claude", "settings.json");
    expect(fs.existsSync(settingsPath)).toBe(false);
  });

  it("removeClaudePermission is a no-op when file does not exist", () => {
    expect(() => removeClaudePermission(tmpDir, "local")).not.toThrow();
  });
});

// ── copyRulesAndAgents ────────────────────────────────────────────────────────

describe("getManagedContentTargets", () => {
  afterEach(() => {
    homedirOverride = undefined;
  });

  it("derives local managed paths from adapter definitions", () => {
    const adapters = ALL_ADAPTERS.filter((a) =>
      ["Claude Code", "Cursor", "Gemini", "Codex"].includes(a.name)
    );

    const targets = getManagedContentTargets(adapters, tmpDir, "local");

    expect(targets.skillTargets.map((t) => t.label)).toEqual(
      expect.arrayContaining([".claude/skills", ".cursor/skills", ".agents/skills"])
    );
    expect(targets.ruleTargets.map((t) => t.label)).toEqual(
      expect.arrayContaining([".claude/rules", ".cursor/rules", ".gemini/rules"])
    );
    expect(targets.agentTargets.map((t) => t.label)).toEqual(
      expect.arrayContaining([".claude/agents", ".gemini/agents"])
    );
    expect(targets.codexConfigTargets.map((t) => t.label)).toEqual([".codex/config.toml"]);
    expect(targets.skillsLockTargets.map((t) => t.label)).toEqual(["skills-lock.json"]);
  });

  it("derives global managed paths from adapter definitions", () => {
    homedirOverride = path.join(tmpDir, "home");
    const adapters = ALL_ADAPTERS.filter((a) =>
      ["Claude Code", "Cursor", "Gemini", "Codex"].includes(a.name)
    );

    const targets = getManagedContentTargets(adapters, tmpDir, "global");

    expect(targets.skillTargets.map((t) => t.label)).toEqual(
      expect.arrayContaining(["~/.claude/skills", "~/.cursor/skills", "~/.agents/skills"])
    );
    expect(targets.ruleTargets.map((t) => t.label)).toEqual(
      expect.arrayContaining(["~/.claude/rules", "~/.cursor/rules", "~/.gemini/rules"])
    );
    expect(targets.agentTargets.map((t) => t.label)).toEqual(
      expect.arrayContaining(["~/.claude/agents", "~/.gemini/agents"])
    );
    expect(targets.codexConfigTargets.map((t) => t.label)).toEqual(["~/.codex/config.toml"]);
    expect(targets.skillsLockTargets.map((t) => t.label)).toEqual(["~/skills-lock.json"]);
  });

  it("routes opencode skills/agents under .opencode (local)", () => {
    const adapters = ALL_ADAPTERS.filter((a) => a.name === "opencode");
    const targets = getManagedContentTargets(adapters, tmpDir, "local");

    expect(targets.skillTargets.map((t) => t.label)).toEqual(
      expect.arrayContaining([".opencode/skills"])
    );
    expect(targets.agentTargets.map((t) => t.label)).toEqual([".opencode/agents"]);
    expect(targets.ruleTargets).toEqual([]);
  });

  it("routes opencode skills/agents under ~/.config/opencode (global)", () => {
    homedirOverride = path.join(tmpDir, "home");
    const adapters = ALL_ADAPTERS.filter((a) => a.name === "opencode");
    const targets = getManagedContentTargets(adapters, tmpDir, "global");

    expect(targets.skillTargets.map((t) => t.label)).toEqual(
      expect.arrayContaining(["~/.config/opencode/skills"])
    );
    expect(targets.agentTargets.map((t) => t.label)).toEqual(["~/.config/opencode/agents"]);
  });
});

describe("copyRulesAndAgents", () => {
  let rulesDir: string;
  let agentsDir: string;

  afterEach(() => {
    homedirOverride = undefined;
  });

  beforeEach(() => {
    rulesDir = path.join(tmpDir, "src-rules");
    agentsDir = path.join(tmpDir, "src-agents");
    fs.mkdirSync(rulesDir, { recursive: true });
    fs.mkdirSync(agentsDir, { recursive: true });
    fs.writeFileSync(path.join(rulesDir, "argent.md"), "# Rule");
    fs.writeFileSync(path.join(agentsDir, "environment-inspector.md"), "# Agent");
    // The bundled agents tree is not flat — it carries a references/
    // subdirectory — so the fixture mirrors that shape.
    fs.mkdirSync(path.join(agentsDir, "references"), { recursive: true });
    fs.writeFileSync(path.join(agentsDir, "references", "checklist.md"), "# Checklist");
  });

  it("copies rules to .claude/rules for Claude Code adapter (local)", () => {
    const claudeAdapter = ALL_ADAPTERS.find((a) => a.name === "Claude Code")!;
    const results = copyRulesAndAgents([claudeAdapter], tmpDir, "local", rulesDir, agentsDir);

    expect(results.some((r) => r.includes("rules"))).toBe(true);
    expect(fs.existsSync(path.join(tmpDir, ".claude", "rules", "argent.md"))).toBe(true);
  });

  it("copies agents to .claude/agents for Claude Code adapter", () => {
    const claudeAdapter = ALL_ADAPTERS.find((a) => a.name === "Claude Code")!;
    copyRulesAndAgents([claudeAdapter], tmpDir, "local", rulesDir, agentsDir);

    expect(fs.existsSync(path.join(tmpDir, ".claude", "agents", "environment-inspector.md"))).toBe(
      true
    );
  });

  it("names the target plainly when nothing redirects", () => {
    const claudeAdapter = ALL_ADAPTERS.find((a) => a.name === "Claude Code")!;

    const results = copyRulesAndAgents([claudeAdapter], tmpDir, "local", rulesDir, agentsDir);

    expect(results.every((r) => !r.includes(" -> "))).toBe(true);
  });

  it("copies rules to .cursor/rules for Cursor adapter (local)", () => {
    const cursorAdapter = ALL_ADAPTERS.find((a) => a.name === "Cursor")!;
    copyRulesAndAgents([cursorAdapter], tmpDir, "local", rulesDir, agentsDir);

    expect(fs.existsSync(path.join(tmpDir, ".cursor", "rules", "argent.md"))).toBe(true);
  });

  it("returns empty array for adapters without rules/agents targets", () => {
    const windsurfAdapter = ALL_ADAPTERS.find((a) => a.name === "Windsurf")!;
    const results = copyRulesAndAgents([windsurfAdapter], tmpDir, "local", rulesDir, agentsDir);
    expect(results).toHaveLength(0);
  });

  it("copies rules and agents to .gemini/ for Gemini adapter (local)", () => {
    const geminiAdapter = ALL_ADAPTERS.find((a) => a.name === "Gemini")!;
    const results = copyRulesAndAgents([geminiAdapter], tmpDir, "local", rulesDir, agentsDir);
    expect(results.some((r) => r.includes("rules"))).toBe(true);
    expect(results.some((r) => r.includes("agents"))).toBe(true);
    expect(fs.existsSync(path.join(tmpDir, ".gemini", "rules", "argent.md"))).toBe(true);
    expect(fs.existsSync(path.join(tmpDir, ".gemini", "agents", "environment-inspector.md"))).toBe(
      true
    );
  });

  it("copies rules and agents to ~/.gemini/ for Gemini adapter (global)", () => {
    const geminiAdapter = ALL_ADAPTERS.find((a) => a.name === "Gemini")!;
    homedirOverride = path.join(tmpDir, "home");
    const results = copyRulesAndAgents([geminiAdapter], tmpDir, "global", rulesDir, agentsDir);
    expect(results.some((r) => r.includes("rules"))).toBe(true);
    expect(results.some((r) => r.includes("agents"))).toBe(true);
    expect(fs.existsSync(path.join(homedirOverride, ".gemini", "rules", "argent.md"))).toBe(true);
    expect(
      fs.existsSync(path.join(homedirOverride, ".gemini", "agents", "environment-inspector.md"))
    ).toBe(true);
  });

  // Issue #701: a `.claude/agents -> ../.agents/agents` layout keeps one
  // canonical copy of the agent definitions and points each harness's path at
  // it. Before the fix the copy tried to replace the link with a directory and
  // failed, so argent-environment-inspector never landed even though
  // rules/argent.md kept referencing it.
  // Skipped on Windows, where creating symlinks needs admin rights.
  describe.skipIf(process.platform === "win32")("symlinked targets", () => {
    it("installs agents into a symlinked .claude/agents, including nested files", () => {
      const claudeAdapter = ALL_ADAPTERS.find((a) => a.name === "Claude Code")!;
      const canonical = path.join(tmpDir, ".agents", "agents");
      const link = path.join(tmpDir, ".claude", "agents");
      fs.mkdirSync(canonical, { recursive: true });
      fs.mkdirSync(path.join(tmpDir, ".claude"), { recursive: true });
      fs.symlinkSync(path.join("..", ".agents", "agents"), link);

      const results = copyRulesAndAgents([claudeAdapter], tmpDir, "local", rulesDir, agentsDir);

      expect(fs.existsSync(path.join(canonical, "environment-inspector.md"))).toBe(true);
      expect(fs.existsSync(path.join(canonical, "references", "checklist.md"))).toBe(true);
      expect(fs.lstatSync(link).isSymbolicLink()).toBe(true);
      expect(results.some((r) => r.includes("Could not copy agents"))).toBe(false);
    });

    it("names both paths when a target redirects through a symlink", () => {
      const claudeAdapter = ALL_ADAPTERS.find((a) => a.name === "Claude Code")!;
      const canonical = path.join(tmpDir, ".agents", "agents");
      const link = path.join(tmpDir, ".claude", "agents");
      fs.mkdirSync(canonical, { recursive: true });
      fs.mkdirSync(path.join(tmpDir, ".claude"), { recursive: true });
      fs.symlinkSync(path.join("..", ".agents", "agents"), link);

      const results = copyRulesAndAgents([claudeAdapter], tmpDir, "local", rulesDir, agentsDir);
      const agentsLine = results.find((r) => r.includes("Copied agents"))!;

      expect(agentsLine).toContain(`.claude/agents -> ${path.join(".agents", "agents")}`);
    });

    it("installs rules into a symlinked .claude/rules", () => {
      const claudeAdapter = ALL_ADAPTERS.find((a) => a.name === "Claude Code")!;
      const canonical = path.join(tmpDir, ".agents", "rules");
      fs.mkdirSync(canonical, { recursive: true });
      fs.mkdirSync(path.join(tmpDir, ".claude"), { recursive: true });
      fs.symlinkSync(path.join("..", ".agents", "rules"), path.join(tmpDir, ".claude", "rules"));

      copyRulesAndAgents([claudeAdapter], tmpDir, "local", rulesDir, agentsDir);

      expect(fs.existsSync(path.join(canonical, "argent.md"))).toBe(true);
    });
  });

  it("injects Codex rules into developer_instructions in config.toml (local)", () => {
    const codexAdapter = ALL_ADAPTERS.find((a) => a.name === "Codex")!;
    // Pre-create the config.toml so the adapter can find it
    const configPath = path.join(tmpDir, ".codex", "config.toml");
    fs.mkdirSync(path.dirname(configPath), { recursive: true });
    fs.writeFileSync(configPath, "");

    const results = copyRulesAndAgents([codexAdapter], tmpDir, "local", rulesDir, agentsDir);
    expect(results.some((r) => r.includes("developer_instructions"))).toBe(true);
    const content = fs.readFileSync(configPath, "utf8");
    expect(content).toContain("argent rules");
    expect(content).toContain("developer_instructions");
  });
});

// ── Codex developer_instructions injection ──────────────────────────────────

describe("injectCodexRules / removeCodexRules", () => {
  let rulesDir: string;

  beforeEach(() => {
    rulesDir = path.join(tmpDir, "src-rules");
    fs.mkdirSync(rulesDir, { recursive: true });
    fs.writeFileSync(
      path.join(rulesDir, "argent.md"),
      "---\ndescription: Test rule\nalwaysApply: true\n---\n\nUse argent tools for simulator control."
    );
  });

  it("injects rules content with frontmatter stripped", () => {
    const configPath = path.join(tmpDir, "config.toml");
    injectCodexRules(configPath, rulesDir);

    const content = fs.readFileSync(configPath, "utf8");
    expect(content).toContain("Use argent tools for simulator control.");
    expect(content).not.toContain("alwaysApply");
    expect(content).toContain("argent rules");
  });

  it("preserves existing developer_instructions", () => {
    const configPath = path.join(tmpDir, "config.toml");
    fs.writeFileSync(configPath, 'developer_instructions = "Always use TypeScript."\n');

    injectCodexRules(configPath, rulesDir);

    const content = fs.readFileSync(configPath, "utf8");
    expect(content).toContain("Always use TypeScript.");
    expect(content).toContain("Use argent tools for simulator control.");
  });

  it("replaces existing argent section on re-inject", () => {
    const configPath = path.join(tmpDir, "config.toml");
    injectCodexRules(configPath, rulesDir);

    // Update rule content and re-inject
    fs.writeFileSync(path.join(rulesDir, "argent.md"), "Updated rule content.");
    injectCodexRules(configPath, rulesDir);

    const content = fs.readFileSync(configPath, "utf8");
    expect(content).toContain("Updated rule content.");
    expect(content).not.toContain("Use argent tools for simulator control.");
    // Should only have one pair of markers
    expect(content.split("argent rules").length - 1).toBe(2); // start + end
  });

  it("removeCodexRules strips argent section", () => {
    const configPath = path.join(tmpDir, "config.toml");
    fs.writeFileSync(configPath, 'developer_instructions = "Always use TypeScript."\n');
    injectCodexRules(configPath, rulesDir);

    const removed = removeCodexRules(configPath);
    expect(removed).toBe(true);

    const content = fs.readFileSync(configPath, "utf8");
    expect(content).toContain("Always use TypeScript.");
    expect(content).not.toContain("argent rules");
  });

  it("removeCodexRules deletes field when only argent content remains", () => {
    const configPath = path.join(tmpDir, "config.toml");
    injectCodexRules(configPath, rulesDir);

    removeCodexRules(configPath);
    expect(fs.existsSync(configPath)).toBe(false);
  });

  it("removeCodexRules returns false when no argent section exists", () => {
    const configPath = path.join(tmpDir, "config.toml");
    fs.writeFileSync(configPath, 'model = "o3"\n');
    expect(removeCodexRules(configPath)).toBe(false);
  });

  it("removeCodexRules returns false for non-existent file", () => {
    expect(removeCodexRules(path.join(tmpDir, "nope.toml"))).toBe(false);
  });

  it("returns null when rulesDir does not exist", () => {
    const configPath = path.join(tmpDir, "config.toml");
    expect(injectCodexRules(configPath, path.join(tmpDir, "nonexistent"))).toBeNull();
  });
});

// ── hasArgentEntry / findConfiguredAdapterScopes ─────────────────────────────
// Regression for issue #195: `update` must not (re)create configs for
// editors the user opted out of during `init`, even when their config dir
// exists on disk. The signal is the argent MCP entry in the config file,
// not the editor dir's presence.

describe("hasArgentEntry — non-mutating per-adapter predicate", () => {
  // Pin homedir into tmpDir so global-only adapters (projectPath === null)
  // resolve globalPath() inside the sandbox. Without this, the rmSync below
  // would delete the developer's real Windsurf/Hermes config.
  beforeEach(() => {
    homedirOverride = tmpDir;
  });

  afterEach(() => {
    homedirOverride = undefined;
  });

  for (const adapter of ALL_ADAPTERS) {
    describe(adapter.name, () => {
      it("returns false when the config file does not exist", () => {
        const configPath =
          adapter.projectPath(tmpDir) ?? adapter.globalPath() ?? path.join(tmpDir, "nope");
        // Adapter-provided paths point into tmpDir for project / homedir for
        // global; the test's only invariant is "file doesn't exist yet".
        if (fs.existsSync(configPath)) fs.rmSync(configPath, { force: true });
        expect(adapter.hasArgentEntry(configPath)).toBe(false);
      });

      it("returns true after write(), false after remove()", () => {
        const configPath = adapter.projectPath(tmpDir);
        if (!configPath) return; // global-only adapter — covered by sibling test below
        adapter.write(configPath, getMcpEntry());
        expect(adapter.hasArgentEntry(configPath)).toBe(true);

        adapter.remove(configPath);
        expect(adapter.hasArgentEntry(configPath)).toBe(false);
      });
    });
  }
});

describe("findConfiguredAdapterScopes", () => {
  beforeEach(() => {
    homedirOverride = tmpDir;
  });

  afterEach(() => {
    homedirOverride = undefined;
  });

  it("returns empty when no adapter has argent configured", () => {
    const projectRoot = path.join(tmpDir, "project");
    fs.mkdirSync(projectRoot, { recursive: true });
    // Create the editor dirs without any argent entry to simulate the
    // issue #195 scenario: the user has gemini installed on their machine
    // but never opted argent into it.
    fs.mkdirSync(path.join(projectRoot, ".gemini"), { recursive: true });
    fs.mkdirSync(path.join(tmpDir, ".gemini"), { recursive: true });

    expect(findConfiguredAdapterScopes(ALL_ADAPTERS, projectRoot)).toEqual([]);
  });

  it("returns only adapters with an argent entry, not all detected editor dirs", () => {
    const projectRoot = path.join(tmpDir, "project");
    fs.mkdirSync(projectRoot, { recursive: true });

    // Gemini: editor present on disk but argent NOT configured.
    fs.mkdirSync(path.join(tmpDir, ".gemini"), { recursive: true });
    fs.writeFileSync(
      path.join(tmpDir, ".gemini", "settings.json"),
      JSON.stringify({ mcpServers: { other: { command: "other" } } })
    );

    // Cursor: argent configured globally (the only scope we should refresh).
    const cursor = ALL_ADAPTERS.find((a) => a.name === "Cursor")!;
    cursor.write(cursor.globalPath()!, getMcpEntry());

    const result = findConfiguredAdapterScopes(ALL_ADAPTERS, projectRoot);
    expect(result.map((r) => `${r.adapter.name}:${r.scope}`)).toEqual(["Cursor:global"]);
  });

  it("returns both scopes when argent is configured in project and global", () => {
    const projectRoot = path.join(tmpDir, "project");
    fs.mkdirSync(projectRoot, { recursive: true });

    const claude = ALL_ADAPTERS.find((a) => a.name === "Claude Code")!;
    claude.write(claude.projectPath(projectRoot)!, getMcpEntry());
    claude.write(claude.globalPath()!, getMcpEntry());

    const result = findConfiguredAdapterScopes(ALL_ADAPTERS, projectRoot);
    expect(result.map((r) => `${r.adapter.name}:${r.scope}`).sort()).toEqual([
      "Claude Code:global",
      "Claude Code:project",
    ]);
  });

  it("skips a malformed config instead of aborting detection", () => {
    const projectRoot = path.join(tmpDir, "project");
    fs.mkdirSync(projectRoot, { recursive: true });

    // Hermes' hasArgentEntry parses YAML and throws on broken input. A single
    // unparseable config must not take down detection for every other adapter
    // (regression: it previously propagated out of update()).
    const hermes = ALL_ADAPTERS.find((a) => a.name === "Hermes")!;
    const hermesPath = hermes.globalPath()!;
    fs.mkdirSync(path.dirname(hermesPath), { recursive: true });
    fs.writeFileSync(hermesPath, "mcp_servers: : not: valid: yaml\n  - broken");

    // A properly configured adapter alongside the broken one.
    const claude = ALL_ADAPTERS.find((a) => a.name === "Claude Code")!;
    claude.write(claude.globalPath()!, getMcpEntry());

    let result: ReturnType<typeof findConfiguredAdapterScopes> = [];
    expect(() => {
      result = findConfiguredAdapterScopes(ALL_ADAPTERS, projectRoot);
    }).not.toThrow();
    expect(result.map((r) => `${r.adapter.name}:${r.scope}`)).toContain("Claude Code:global");
    expect(result.map((r) => r.adapter.name)).not.toContain("Hermes");
  });

  it("detects a JSONC (commented) config that strict JSON.parse would have skipped", () => {
    // The behavior transition this fix is built around: hasArgentEntry reads
    // through readJsonc, so a comment-carrying config — which the old strict
    // readJson parsed to {} and `update` silently skipped — is now seen as
    // configured. A regression back to readJson would make this file parse to
    // {}, drop the argent entry, and quietly stop refreshing these users (and
    // stop reaching their allowlist). No other fixture here carries a comment,
    // so this is the one that pins it.
    const projectRoot = path.join(tmpDir, "project");
    fs.mkdirSync(projectRoot, { recursive: true });

    const cursor = ALL_ADAPTERS.find((a) => a.name === "Cursor")!;
    const configPath = cursor.globalPath()!;
    fs.mkdirSync(path.dirname(configPath), { recursive: true });
    // A line comment AND a trailing comma — both make strict JSON.parse throw.
    fs.writeFileSync(
      configPath,
      `{\n  // my Cursor MCP config, hand-edited\n  "mcpServers": {\n    "argent": { "command": "argent", "args": ["mcp"] },\n  }\n}`
    );

    // Guard: the fixture really is invalid strict JSON, so the strict path
    // would have returned {} and reported "not configured".
    expect(() => JSON.parse(fs.readFileSync(configPath, "utf8"))).toThrow();

    expect(cursor.hasArgentEntry(configPath)).toBe(true);
    const result = findConfiguredAdapterScopes(ALL_ADAPTERS, projectRoot);
    expect(result.map((r) => `${r.adapter.name}:${r.scope}`)).toContain("Cursor:global");
  });
});

// ── Portability: generated configs must not embed absolute home paths ───────
// Regression coverage for issue #238 — every adapter must produce a config
// that can be checked into git and shared with collaborators on different
// machines.

describe("generated configs are portable across machines (issue #238)", () => {
  function readTextFile(filePath: string): string {
    return fs.readFileSync(filePath, "utf8");
  }

  // Pin homedir to a fake user path so we can scan written configs for the
  // literal `/Users/...` segment without false positives from the test
  // runner's real homedir.
  const fakeHome = "/Users/pretend-user";

  beforeEach(() => {
    homedirOverride = fakeHome;
  });

  afterEach(() => {
    homedirOverride = undefined;
  });

  it("no adapter writes an absolute home path into the config", () => {
    for (const adapter of ALL_ADAPTERS) {
      // Use each adapter's own project path so we exercise its real write
      // logic (file extension, key layout) without faking paths.
      const configPath =
        adapter.projectPath(tmpDir) ??
        // Windsurf / Hermes are global-only; route the write into tmp.
        path.join(tmpDir, adapter.name.replace(/\s+/g, "-"), "config");

      adapter.write(configPath, getMcpEntry());
      const written = readTextFile(configPath);
      expect(written, `${adapter.name} embedded a home path: ${written}`).not.toContain(fakeHome);
      expect(written).not.toContain("ARGENT_MCP_LOG");
    }
  });

  it("each adapter still honors env vars when the entry carries them", () => {
    const customEntry = { ...getMcpEntry(), env: { FOO: "bar" } };

    for (const adapter of ALL_ADAPTERS) {
      const configPath =
        adapter.projectPath(tmpDir) ??
        path.join(tmpDir, adapter.name.replace(/\s+/g, "-"), "config");
      adapter.write(configPath, customEntry);
      const written = readTextFile(configPath);
      expect(written, `${adapter.name} dropped a supplied env var`).toContain("FOO");
      expect(written).toContain("bar");
    }
  });
});

// ── Foreign-config preservation ─────────────────────────────────────────────
// The installer's contract is "only touch argent". Two regressions broke it:
//  - uninstall recursively pruned every empty object/array in the tree, so a
//    foreign server's `args: []` / `env: {}` and any sibling user key holding an
//    empty value were silently deleted (Defect A).
//  - the VS Code adapter read .vscode/mcp.json with strict JSON, so a JSONC file
//    (comments / trailing commas) parsed to {} and was rewritten with only the
//    argent entry, destroying every pre-existing user server (Defect B).

describe("installer preserves foreign MCP config", () => {
  it("remove keeps an unrelated server's empty env/args and a sibling user key (Cursor)", () => {
    const adapter = ALL_ADAPTERS.find((a) => a.name === "Cursor")!;
    const dir = fs.mkdtempSync(path.join(os.tmpdir(), "argent-fc-"));
    const configPath = path.join(dir, ".cursor", "mcp.json");
    fs.mkdirSync(path.dirname(configPath), { recursive: true });
    fs.writeFileSync(
      configPath,
      JSON.stringify(
        {
          mcpServers: {
            argent: { command: "argent", args: ["mcp"] },
            other: { command: "other-bin", args: [], env: {} },
          },
          userSettings: { theme: "dark", overrides: {} },
        },
        null,
        2
      )
    );
    expect(adapter.remove(configPath)).toBe(true);
    const after = JSON.parse(fs.readFileSync(configPath, "utf8"));
    expect(after.mcpServers.other).toEqual({ command: "other-bin", args: [], env: {} });
    expect(after.userSettings).toEqual({ theme: "dark", overrides: {} });
    fs.rmSync(dir, { recursive: true, force: true });
  });

  it("VS Code write preserves a pre-existing server in a JSONC (commented) file", () => {
    const adapter = ALL_ADAPTERS.find((a) => a.name === "VS Code")!;
    const dir = fs.mkdtempSync(path.join(os.tmpdir(), "argent-fc-"));
    const configPath = path.join(dir, ".vscode", "mcp.json");
    fs.mkdirSync(path.dirname(configPath), { recursive: true });
    fs.writeFileSync(
      configPath,
      `{\n  // my own MCP server, do not touch\n  "servers": { "myserver": { "type": "stdio", "command": "my-bin", "args": ["x"] } }\n}`
    );
    adapter.write(configPath, getMcpEntry());
    const after = parseJsonc(fs.readFileSync(configPath, "utf8"), [], {
      allowTrailingComma: true,
    }) as Record<string, any>;
    expect(after.servers).toHaveProperty("argent");
    expect(after.servers).toHaveProperty("myserver");
    fs.rmSync(dir, { recursive: true, force: true });
  });

  // Same Defect B, but for the { mcpServers } adapters: Cursor and Kiro are
  // comment-tolerant VS Code forks, and Claude / Windsurf / Gemini configs are
  // strict JSON that JSONC is a superset of. All five now write through
  // editJsoncFile, so a hand-authored comment and a pre-existing foreign server
  // survive an argent `write` — the old readJson → writeJson path reduced the
  // commented file to {} and rewrote it with only the argent entry.
  for (const name of ["Cursor", "Claude Code", "Windsurf", "Gemini", "Kiro"]) {
    it(`${name} write preserves a comment and a foreign server in a JSONC file`, () => {
      const adapter = ALL_ADAPTERS.find((a) => a.name === name)!;
      const dir = fs.mkdtempSync(path.join(os.tmpdir(), "argent-fc-"));
      const configPath = path.join(dir, "mcp.json");
      fs.writeFileSync(
        configPath,
        `{\n  // my own MCP server, do not touch\n  "mcpServers": { "myserver": { "command": "my-bin", "args": ["x"] } }\n}`
      );
      adapter.write(configPath, getMcpEntry());
      const raw = fs.readFileSync(configPath, "utf8");
      expect(raw).toContain("// my own MCP server, do not touch");
      const after = parseJsonc(raw, [], { allowTrailingComma: true }) as Record<string, any>;
      expect(after.mcpServers).toHaveProperty("argent");
      expect(after.mcpServers).toHaveProperty("myserver");
      fs.rmSync(dir, { recursive: true, force: true });
    });
  }

  // Defect B's REMOVE side: the write-preservation tests above only prove
  // `write` keeps comments/foreign servers. Uninstall (`remove`) must ALSO keep
  // them — a full argent install→uninstall round-trip on a hand-commented JSONC
  // file must leave the user's comment and foreign server byte-intact and the
  // file on disk (it still has a foreign server), with only argent gone.
  it("VS Code remove preserves a comment and a foreign server (uninstall round-trip)", () => {
    const adapter = ALL_ADAPTERS.find((a) => a.name === "VS Code")!;
    const dir = fs.mkdtempSync(path.join(os.tmpdir(), "argent-fc-"));
    const configPath = path.join(dir, ".vscode", "mcp.json");
    fs.mkdirSync(path.dirname(configPath), { recursive: true });
    fs.writeFileSync(
      configPath,
      `{\n  // my own MCP server, do not touch\n  "servers": { "myserver": { "type": "stdio", "command": "my-bin", "args": ["x"] } }\n}`
    );
    adapter.write(configPath, getMcpEntry());
    expect(adapter.remove(configPath)).toBe(true);
    const raw = fs.readFileSync(configPath, "utf8");
    expect(raw).toContain("// my own MCP server, do not touch");
    const after = parseJsonc(raw, [], { allowTrailingComma: true }) as Record<string, any>;
    expect(after.servers).not.toHaveProperty("argent");
    expect(after.servers).toHaveProperty("myserver");
    expect(fs.existsSync(configPath)).toBe(true);
    fs.rmSync(dir, { recursive: true, force: true });
  });

  for (const name of ["Cursor", "Claude Code", "Windsurf", "Gemini", "Kiro"]) {
    it(`${name} remove preserves a comment and a foreign server (uninstall round-trip)`, () => {
      const adapter = ALL_ADAPTERS.find((a) => a.name === name)!;
      const dir = fs.mkdtempSync(path.join(os.tmpdir(), "argent-fc-"));
      const configPath = path.join(dir, "mcp.json");
      fs.writeFileSync(
        configPath,
        `{\n  // my own MCP server, do not touch\n  "mcpServers": { "myserver": { "command": "my-bin", "args": ["x"] } }\n}`
      );
      adapter.write(configPath, getMcpEntry());
      expect(adapter.remove(configPath)).toBe(true);
      const raw = fs.readFileSync(configPath, "utf8");
      expect(raw).toContain("// my own MCP server, do not touch");
      const after = parseJsonc(raw, [], { allowTrailingComma: true }) as Record<string, any>;
      expect(after.mcpServers).not.toHaveProperty("argent");
      expect(after.mcpServers).toHaveProperty("myserver");
      fs.rmSync(dir, { recursive: true, force: true });
    });
  }

  it("Codex remove keeps an unrelated server's empty args and a sibling empty table (TOML)", () => {
    // Same "only touch argent" contract as the JSON adapters above, but for the
    // TOML-backed Codex config: writeTomlOrRemove used to deep-prune the whole
    // tree (pruneEmptyConfig), silently stripping a foreign server's `args = []`
    // and any sibling empty table alongside deleting the argent entry.
    const adapter = ALL_ADAPTERS.find((a) => a.name === "Codex")!;
    const dir = fs.mkdtempSync(path.join(os.tmpdir(), "argent-fc-"));
    const configPath = path.join(dir, "config.toml");
    fs.writeFileSync(
      configPath,
      'model = "o3"\n\n' +
        "[other_section]\n\n" +
        "[mcp_servers.other]\n" +
        'command = "other-bin"\n' +
        "args = []\n\n" +
        "[mcp_servers.argent]\n" +
        'command = "argent"\n' +
        'args = ["mcp"]\n'
    );
    expect(adapter.remove(configPath)).toBe(true);
    const after = fs.readFileSync(configPath, "utf8");
    expect(after).not.toContain("mcp_servers.argent");
    expect(after).toContain('model = "o3"');
    expect(after).toContain("[other_section]");
    expect(after).toContain("[mcp_servers.other]");
    expect(after).toContain("args = []");
    fs.rmSync(dir, { recursive: true, force: true });
  });

  it("Codex remove deletes the file when argent was the only content (TOML)", () => {
    const adapter = ALL_ADAPTERS.find((a) => a.name === "Codex")!;
    const dir = fs.mkdtempSync(path.join(os.tmpdir(), "argent-fc-"));
    const configPath = path.join(dir, "config.toml");
    fs.writeFileSync(configPath, '[mcp_servers.argent]\ncommand = "argent"\nargs = ["mcp"]\n');
    expect(adapter.remove(configPath)).toBe(true);
    expect(fs.existsSync(configPath)).toBe(false);
    fs.rmSync(dir, { recursive: true, force: true });
  });

  // ── Allowlist writers (the last readJson → writeJson clobber path) ──────────
  // The allowlist helpers were the one surface left on the readJson → writeJson
  // path this PR migrated everywhere else. Cursor's allowlist lives in a
  // *separate* ~/.cursor/permissions.json (a JSONC file that can hold the user's
  // own unrelated rules), and Claude's in .claude/settings.json — both are now
  // routed through readJsonc / editJsoncFile so an install (add) no longer runs
  // a commented/multi-rule file through `catch { return {} }` and rewrites it
  // with only the argent entry, and an uninstall (remove) round-trips cleanly.
  afterEach(() => {
    homedirOverride = undefined;
  });

  it("Cursor addAllowlist preserves a comment and foreign rules in permissions.json", () => {
    const cursor = ALL_ADAPTERS.find((a) => a.name === "Cursor")!;
    homedirOverride = fs.mkdtempSync(path.join(os.tmpdir(), "argent-fc-home-"));
    const permPath = path.join(homedirOverride, ".cursor", "permissions.json");
    fs.mkdirSync(path.dirname(permPath), { recursive: true });
    fs.writeFileSync(
      permPath,
      `{\n  // user's own Cursor rules, do not touch\n  "fileAllowlist": ["src/**"],\n  "mcpAllowlist": ["other:*"]\n}\n`
    );
    cursor.addAllowlist!(tmpDir, "global");
    const raw = fs.readFileSync(permPath, "utf8");
    const after = readJsoncFile(permPath);
    expect(after.mcpAllowlist).toContain("argent:*"); // argent added
    expect(after.mcpAllowlist).toContain("other:*"); // foreign rule survives
    expect(after.fileAllowlist).toEqual(["src/**"]); // foreign key survives
    expect(raw).toContain("do not touch"); // comment survives
    fs.rmSync(homedirOverride, { recursive: true, force: true });
  });

  it("Cursor addAllowlist/removeAllowlist round-trip preserves foreign rules and keeps the file", () => {
    const cursor = ALL_ADAPTERS.find((a) => a.name === "Cursor")!;
    homedirOverride = fs.mkdtempSync(path.join(os.tmpdir(), "argent-fc-home-"));
    const permPath = path.join(homedirOverride, ".cursor", "permissions.json");
    fs.mkdirSync(path.dirname(permPath), { recursive: true });
    fs.writeFileSync(permPath, `{\n  // keep me\n  "mcpAllowlist": ["other:*"]\n}\n`);
    cursor.addAllowlist!(tmpDir, "global");
    cursor.removeAllowlist!(tmpDir, "global");
    expect(fs.existsSync(permPath)).toBe(true); // foreign rule ⇒ file kept
    const raw = fs.readFileSync(permPath, "utf8");
    const after = readJsoncFile(permPath);
    expect(after.mcpAllowlist).toEqual(["other:*"]); // argent gone, foreign kept
    expect(raw).toContain("keep me"); // comment survives
    fs.rmSync(homedirOverride, { recursive: true, force: true });
  });

  it("Cursor removeAllowlist deletes the file when argent was the only rule", () => {
    const cursor = ALL_ADAPTERS.find((a) => a.name === "Cursor")!;
    homedirOverride = fs.mkdtempSync(path.join(os.tmpdir(), "argent-fc-home-"));
    const permPath = path.join(homedirOverride, ".cursor", "permissions.json");
    cursor.addAllowlist!(tmpDir, "global"); // fresh create: { mcpAllowlist: ["argent:*"] }
    cursor.removeAllowlist!(tmpDir, "global");
    expect(fs.existsSync(permPath)).toBe(false);
    fs.rmSync(homedirOverride, { recursive: true, force: true });
  });

  it("addClaudePermission preserves a comment and foreign permissions in settings.json", () => {
    const settingsPath = path.join(tmpDir, ".claude", "settings.json");
    fs.mkdirSync(path.dirname(settingsPath), { recursive: true });
    fs.writeFileSync(
      settingsPath,
      `{\n  // user's own settings, do not touch\n  "model": "opus",\n  "permissions": { "allow": ["Bash(ls:*)"] }\n}\n`
    );
    addClaudePermission(tmpDir, "local");
    const raw = fs.readFileSync(settingsPath, "utf8");
    const after = readJsoncFile(settingsPath);
    const allow = (after.permissions as Record<string, unknown>).allow as string[];
    expect(allow).toContain("mcp__argent"); // argent added
    expect(allow).toContain("Bash(ls:*)"); // foreign permission survives
    expect(after.model).toBe("opus"); // foreign key survives
    expect(raw).toContain("do not touch"); // comment survives
  });

  it("addClaudePermission/removeClaudePermission round-trip preserves foreign permissions", () => {
    const settingsPath = path.join(tmpDir, ".claude", "settings.json");
    fs.mkdirSync(path.dirname(settingsPath), { recursive: true });
    fs.writeFileSync(
      settingsPath,
      `{\n  // keep me\n  "permissions": { "allow": ["Bash(ls:*)"] }\n}\n`
    );
    addClaudePermission(tmpDir, "local");
    removeClaudePermission(tmpDir, "local");
    expect(fs.existsSync(settingsPath)).toBe(true); // foreign permission ⇒ file kept
    const raw = fs.readFileSync(settingsPath, "utf8");
    const after = readJsoncFile(settingsPath);
    const allow = (after.permissions as Record<string, unknown>).allow as string[];
    expect(allow).toEqual(["Bash(ls:*)"]); // argent gone, foreign kept
    expect(raw).toContain("keep me"); // comment survives
  });

  // ── Behaviors correct but previously unpinned (review coverage notes) ────────

  it("write leaves an existing file without a trailing newline untouched", () => {
    const adapter = ALL_ADAPTERS.find((a) => a.name === "VS Code")!;
    const configPath = path.join(tmpDir, ".vscode", "mcp.json");
    fs.mkdirSync(path.dirname(configPath), { recursive: true });
    // Deliberately no trailing newline after the closing brace.
    fs.writeFileSync(
      configPath,
      `{\n  "servers": { "myserver": { "type": "stdio", "command": "my-bin" } }\n}`
    );
    adapter.write(configPath, getMcpEntry());
    const raw = fs.readFileSync(configPath, "utf8");
    // editJsoncFile only appends a newline on fresh create — an existing file's
    // own EOL style is preserved, so this one stays newline-free.
    expect(raw.endsWith("\n")).toBe(false);
    const after = parseJsonc(raw, [], { allowTrailingComma: true }) as Record<string, any>;
    expect(after.servers).toHaveProperty("argent");
    expect(after.servers).toHaveProperty("myserver");
  });

  it("re-write replaces a stale env key rather than merging it", () => {
    const adapter = ALL_ADAPTERS.find((a) => a.name === "Cursor")!;
    const configPath = path.join(tmpDir, ".cursor", "mcp.json");
    // A pre-#238 entry that still carried env (the update upgrade path).
    adapter.write(configPath, {
      command: "argent",
      args: ["mcp"],
      env: { ARGENT_MCP_LOG: "/tmp/x.log" },
    });
    adapter.write(configPath, getMcpEntry()); // env-less canonical entry
    const after = readJsoncFile(configPath);
    const argent = (after.mcpServers as Record<string, unknown>).argent as Record<string, unknown>;
    expect(argent).not.toHaveProperty("env"); // replaced, not merged
  });

  it("remove keeps the file and an empty top-level sibling when argent was the only server", () => {
    const adapter = ALL_ADAPTERS.find((a) => a.name === "Cursor")!;
    const configPath = path.join(tmpDir, ".cursor", "mcp.json");
    fs.mkdirSync(path.dirname(configPath), { recursive: true });
    fs.writeFileSync(
      configPath,
      `{\n  "mcpServers": { "argent": { "command": "argent", "args": ["mcp"] } },\n  "userThing": {}\n}\n`
    );
    expect(adapter.remove(configPath)).toBe(true);
    expect(fs.existsSync(configPath)).toBe(true); // kept: userThing is a foreign key
    const after = readJsoncFile(configPath);
    expect(after).not.toHaveProperty("mcpServers"); // emptied container pruned
    expect(after.userThing).toEqual({}); // empty top-level sibling survives
  });

  it("addClaudePermission writes a trailing newline when the existing settings file was empty", () => {
    // Routing addClaudePermission through editJsoncFile must not regress the
    // trailing-newline normalization writeJson gave for free: an empty /
    // whitespace-only existing file has no real content to preserve, so it is
    // synthesized fresh and gets a final newline (no git "No newline at end of
    // file"). A file with real content still keeps its own EOL (test above).
    const settingsPath = path.join(tmpDir, ".claude", "settings.json");
    fs.mkdirSync(path.dirname(settingsPath), { recursive: true });
    fs.writeFileSync(settingsPath, "   \n"); // whitespace-only existing file
    addClaudePermission(tmpDir, "local");
    const raw = fs.readFileSync(settingsPath, "utf8");
    expect(raw.endsWith("\n")).toBe(true);
    const after = readJsoncFile(settingsPath);
    expect((after.permissions as Record<string, unknown>).allow).toContain("mcp__argent");
  });

  it("removeClaudePermission keeps a permissions.deny sibling when argent was the sole allow rule", () => {
    // The nested-ancestor partial-prune boundary: allow empties and is pruned,
    // but permissions must be KEPT because a foreign `deny` remains (allow+deny
    // is a common real Claude config). An over-prune here would silently delete
    // the user's deny rules.
    const settingsPath = path.join(tmpDir, ".claude", "settings.json");
    fs.mkdirSync(path.dirname(settingsPath), { recursive: true });
    fs.writeFileSync(
      settingsPath,
      `{\n  "permissions": { "allow": ["mcp__argent"], "deny": ["Bash(rm:*)"] }\n}\n`
    );
    removeClaudePermission(tmpDir, "local");
    expect(fs.existsSync(settingsPath)).toBe(true); // deny keeps the file
    const after = readJsoncFile(settingsPath);
    const permissions = after.permissions as Record<string, unknown>;
    expect(permissions).not.toHaveProperty("allow"); // emptied allow pruned
    expect(permissions.deny).toEqual(["Bash(rm:*)"]); // sibling survives, permissions kept
  });

  it("addClaudePermission preserves a UTF-8 BOM and foreign keys", () => {
    const settingsPath = path.join(tmpDir, ".claude", "settings.json");
    fs.mkdirSync(path.dirname(settingsPath), { recursive: true });
    fs.writeFileSync(settingsPath, "﻿" + `{\n  "model": "opus"\n}\n`);
    addClaudePermission(tmpDir, "local");
    const raw = fs.readFileSync(settingsPath, "utf8");
    expect(raw.charCodeAt(0)).toBe(0xfeff); // BOM preserved byte-correct
    const after = readJsoncFile(settingsPath);
    expect(after.model).toBe("opus");
    expect((after.permissions as Record<string, unknown>).allow).toContain("mcp__argent");
  });

  it("Cursor removeAllowlist prunes an emptied mcpAllowlist but keeps the file for a foreign key", () => {
    const cursor = ALL_ADAPTERS.find((a) => a.name === "Cursor")!;
    homedirOverride = fs.mkdtempSync(path.join(os.tmpdir(), "argent-fc-home-"));
    const permPath = path.join(homedirOverride, ".cursor", "permissions.json");
    fs.mkdirSync(path.dirname(permPath), { recursive: true });
    fs.writeFileSync(
      permPath,
      `{\n  "mcpAllowlist": ["argent:*"],\n  "fileAllowlist": ["src/**"]\n}\n`
    );
    cursor.removeAllowlist!(tmpDir, "global");
    expect(fs.existsSync(permPath)).toBe(true); // fileAllowlist keeps the file
    const after = readJsoncFile(permPath);
    expect(after).not.toHaveProperty("mcpAllowlist"); // emptied array pruned
    expect(after.fileAllowlist).toEqual(["src/**"]); // foreign key survives
    fs.rmSync(homedirOverride, { recursive: true, force: true });
  });
});

// ── Installed-editor detection evidence ──────────────────────────────────────
// A bare .cursor/.codex dir created by argent's own writes must not read as
// "the editor is installed" on later runs (self-fulfilling detection), while
// anything the user/editor itself put there still must.

describe("detection evidence", () => {
  const cursor = ALL_ADAPTERS.find((a) => a.name === "Cursor")!;
  const codex = ALL_ADAPTERS.find((a) => a.name === "Codex")!;
  const claude = ALL_ADAPTERS.find((a) => a.name === "Claude Code")!;
  const vscode = ALL_ADAPTERS.find((a) => a.name === "VS Code")!;
  const windsurf = ALL_ADAPTERS.find((a) => a.name === "Windsurf")!;
  const zed = ALL_ADAPTERS.find((a) => a.name === "Zed")!;
  const gemini = ALL_ADAPTERS.find((a) => a.name === "Gemini")!;
  const hermes = ALL_ADAPTERS.find((a) => a.name === "Hermes")!;
  const kiro = ALL_ADAPTERS.find((a) => a.name === "Kiro")!;
  let home: string;
  let proj: string;
  let cwdSpy: ReturnType<typeof vi.spyOn>;

  beforeEach(() => {
    home = path.join(tmpDir, "home");
    proj = path.join(tmpDir, "proj");
    fs.mkdirSync(home, { recursive: true });
    fs.mkdirSync(proj, { recursive: true });
    homedirOverride = home;
    cwdSpy = vi.spyOn(process, "cwd").mockReturnValue(proj);
  });

  afterEach(() => {
    homedirOverride = undefined;
    cwdSpy.mockRestore();
  });

  it("does not detect Cursor when no .cursor dir exists", () => {
    expect(cursor.detect()).toBe(false);
  });

  it("does not detect Cursor from an argent-only ~/.cursor (mcp.json with only the argent entry)", () => {
    cursor.write(path.join(home, ".cursor", "mcp.json"), getMcpEntry());
    expect(cursor.detect()).toBe(false);
  });

  it("does not detect Cursor from argent-copied rules/agents/skills only", () => {
    fs.mkdirSync(path.join(home, ".cursor", "rules"), { recursive: true });
    fs.writeFileSync(path.join(home, ".cursor", "rules", "argent.md"), "rules");
    fs.mkdirSync(path.join(home, ".cursor", "skills", "argent-device-interact"), {
      recursive: true,
    });
    expect(cursor.detect()).toBe(false);
  });

  it("detects Cursor from a real install artifact (argv.json)", () => {
    fs.mkdirSync(path.join(home, ".cursor"), { recursive: true });
    fs.writeFileSync(path.join(home, ".cursor", "argv.json"), "{}");
    expect(cursor.detect()).toBe(true);
  });

  it("does not detect Cursor when argent's permissions.json allowlist is the only extra file", () => {
    cursor.write(path.join(home, ".cursor", "mcp.json"), getMcpEntry());
    fs.writeFileSync(
      path.join(home, ".cursor", "permissions.json"),
      JSON.stringify({ mcpAllowlist: ["argent:*"] })
    );
    expect(cursor.detect()).toBe(false);
  });

  it("detects Cursor when permissions.json holds the user's own rules", () => {
    fs.mkdirSync(path.join(home, ".cursor"), { recursive: true });
    fs.writeFileSync(
      path.join(home, ".cursor", "permissions.json"),
      JSON.stringify({ mcpAllowlist: ["argent:*", "other-server:*"] })
    );
    expect(cursor.detect()).toBe(true);
  });

  it("detects Cursor when mcp.json is unparseable (conservative: user evidence)", () => {
    const configPath = path.join(home, ".cursor", "mcp.json");
    fs.mkdirSync(path.dirname(configPath), { recursive: true });
    fs.writeFileSync(configPath, "<<<<<<< HEAD not json at all");
    expect(cursor.detect()).toBe(true);
  });

  it("detects Cursor from an empty .cursor dir (conservative: argent never leaves one)", () => {
    fs.mkdirSync(path.join(home, ".cursor"), { recursive: true });
    expect(cursor.detect()).toBe(true);
  });

  it("detects Cursor from a user rules file that merely starts with 'argent'", () => {
    fs.mkdirSync(path.join(home, ".cursor", "rules"), { recursive: true });
    fs.writeFileSync(path.join(home, ".cursor", "rules", "argent-notes.md"), "my notes");
    expect(cursor.detect()).toBe(true);
  });

  it("detects Cursor when mcp.json has a non-argent server", () => {
    const configPath = path.join(home, ".cursor", "mcp.json");
    fs.mkdirSync(path.dirname(configPath), { recursive: true });
    fs.writeFileSync(
      configPath,
      JSON.stringify({ mcpServers: { argent: { command: "argent" }, other: { command: "x" } } })
    );
    expect(cursor.detect()).toBe(true);
  });

  it("detects Cursor from the user's own project rules", () => {
    fs.mkdirSync(path.join(proj, ".cursor", "rules"), { recursive: true });
    fs.writeFileSync(path.join(proj, ".cursor", "rules", "my-style.mdc"), "be nice");
    expect(cursor.detect()).toBe(true);
  });

  it("does not detect Codex from an argent-only ~/.codex (config.toml entry + managed rules)", () => {
    const configPath = path.join(home, ".codex", "config.toml");
    codex.write(configPath, getMcpEntry());
    const rulesDir = path.join(tmpDir, "detect-rules");
    fs.mkdirSync(rulesDir, { recursive: true });
    fs.writeFileSync(path.join(rulesDir, "argent.md"), "Use argent tools.");
    injectCodexRules(configPath, rulesDir);
    expect(codex.detect()).toBe(false);
  });

  it("detects Codex from a real install artifact (auth.json)", () => {
    fs.mkdirSync(path.join(home, ".codex"), { recursive: true });
    fs.writeFileSync(path.join(home, ".codex", "auth.json"), "{}");
    expect(codex.detect()).toBe(true);
  });

  it("detects Codex when config.toml carries user settings", () => {
    const configPath = path.join(home, ".codex", "config.toml");
    fs.mkdirSync(path.dirname(configPath), { recursive: true });
    fs.writeFileSync(configPath, 'model = "o4"\n');
    expect(codex.detect()).toBe(true);
  });

  it("detects Codex when config.toml is unparseable (conservative: user evidence)", () => {
    const configPath = path.join(home, ".codex", "config.toml");
    fs.mkdirSync(path.dirname(configPath), { recursive: true });
    fs.writeFileSync(configPath, "model = = broken toml [[");
    expect(codex.detect()).toBe(true);
  });

  it("detects Codex when developer_instructions holds the user's own text", () => {
    const configPath = path.join(home, ".codex", "config.toml");
    fs.mkdirSync(path.dirname(configPath), { recursive: true });
    fs.writeFileSync(
      configPath,
      'developer_instructions = "Always answer in Polish."\n\n[mcp_servers.argent]\ncommand = "argent"\nargs = ["mcp"]\n'
    );
    expect(codex.detect()).toBe(true);
  });

  // ── Claude Code ──────────────────────────────────────────────────────────

  it("does not detect Claude Code from argent-only artifacts (.mcp.json + .claude permissions/rules/skills)", () => {
    claude.write(path.join(proj, ".mcp.json"), getMcpEntry());
    addClaudePermission(proj, "local");
    fs.mkdirSync(path.join(proj, ".claude", "rules"), { recursive: true });
    fs.writeFileSync(path.join(proj, ".claude", "rules", "argent.md"), "rules");
    fs.mkdirSync(path.join(home, ".claude", "skills", "argent-device-interact"), {
      recursive: true,
    });
    expect(claude.detect()).toBe(false);
  });

  it("does not detect Claude Code from an argent-only ~/.claude.json", () => {
    claude.write(path.join(home, ".claude.json"), getMcpEntry());
    expect(claude.detect()).toBe(false);
  });

  it("detects Claude Code from a real ~/.claude.json (OAuth/projects state)", () => {
    fs.writeFileSync(
      path.join(home, ".claude.json"),
      JSON.stringify({
        oauthAccount: { email: "user@example.com" },
        mcpServers: { argent: { command: "argent", args: ["mcp"] } },
      })
    );
    expect(claude.detect()).toBe(true);
  });

  it("detects Claude Code when .claude/settings.json holds the user's own permissions", () => {
    fs.mkdirSync(path.join(proj, ".claude"), { recursive: true });
    fs.writeFileSync(
      path.join(proj, ".claude", "settings.json"),
      JSON.stringify({ permissions: { allow: ["Bash(npm:*)"] } })
    );
    expect(claude.detect()).toBe(true);
  });

  it("detects Claude Code when .mcp.json has a foreign server", () => {
    fs.writeFileSync(
      path.join(proj, ".mcp.json"),
      JSON.stringify({ mcpServers: { argent: { command: "argent" }, other: { command: "x" } } })
    );
    expect(claude.detect()).toBe(true);
  });

  it("detects Claude Code from user files in .claude (settings.local.json)", () => {
    fs.mkdirSync(path.join(proj, ".claude"), { recursive: true });
    fs.writeFileSync(path.join(proj, ".claude", "settings.local.json"), "{}");
    expect(claude.detect()).toBe(true);
  });

  // ── VS Code ──────────────────────────────────────────────────────────────

  it("does not detect VS Code from an argent-only .vscode/mcp.json", () => {
    vscode.write(path.join(proj, ".vscode", "mcp.json"), getMcpEntry());
    expect(vscode.detect()).toBe(false);
  });

  it("detects VS Code from the user's own workspace files", () => {
    fs.mkdirSync(path.join(proj, ".vscode"), { recursive: true });
    fs.writeFileSync(path.join(proj, ".vscode", "settings.json"), "{}");
    expect(vscode.detect()).toBe(true);
  });

  it("detects VS Code from a bare ~/.vscode (argent never writes there)", () => {
    fs.mkdirSync(path.join(home, ".vscode"), { recursive: true });
    expect(vscode.detect()).toBe(true);
  });

  it("detects VS Code when .vscode/mcp.json has a foreign server", () => {
    fs.mkdirSync(path.join(proj, ".vscode"), { recursive: true });
    fs.writeFileSync(
      path.join(proj, ".vscode", "mcp.json"),
      JSON.stringify({ servers: { argent: { command: "argent" }, other: { command: "x" } } })
    );
    expect(vscode.detect()).toBe(true);
  });

  // ── Windsurf ─────────────────────────────────────────────────────────────

  it("does not detect Windsurf from an argent-only mcp_config.json (incl. alwaysAllow)", () => {
    windsurf.write(path.join(home, ".codeium", "windsurf", "mcp_config.json"), getMcpEntry());
    windsurf.addAllowlist!(proj, "global");
    expect(windsurf.detect()).toBe(false);
  });

  it("detects Windsurf when its dir holds anything else", () => {
    const dir = path.join(home, ".codeium", "windsurf");
    fs.mkdirSync(dir, { recursive: true });
    fs.writeFileSync(path.join(dir, "memories.json"), "{}");
    expect(windsurf.detect()).toBe(true);
  });

  it("detects Windsurf when mcp_config.json has a foreign server", () => {
    const dir = path.join(home, ".codeium", "windsurf");
    fs.mkdirSync(dir, { recursive: true });
    fs.writeFileSync(
      path.join(dir, "mcp_config.json"),
      JSON.stringify({ mcpServers: { argent: { command: "argent" }, other: { command: "x" } } })
    );
    expect(windsurf.detect()).toBe(true);
  });

  // ── Zed ──────────────────────────────────────────────────────────────────

  it("does not detect Zed from an argent-only settings.json (entry + allowlist default)", () => {
    zed.write(path.join(home, ".config", "zed", "settings.json"), getMcpEntry());
    zed.addAllowlist!(proj, "global");
    expect(zed.detect()).toBe(false);
  });

  it("detects Zed when settings.json carries user settings", () => {
    const settingsPath = path.join(home, ".config", "zed", "settings.json");
    fs.mkdirSync(path.dirname(settingsPath), { recursive: true });
    fs.writeFileSync(
      settingsPath,
      JSON.stringify({
        theme: "One Dark",
        context_servers: { argent: { command: "argent", args: ["mcp"] } },
      })
    );
    expect(zed.detect()).toBe(true);
  });

  // ── Gemini ───────────────────────────────────────────────────────────────

  it("does not detect Gemini from argent-only artifacts (settings.json + trust + rules/agents)", () => {
    gemini.write(path.join(home, ".gemini", "settings.json"), getMcpEntry());
    gemini.addAllowlist!(proj, "global");
    fs.mkdirSync(path.join(home, ".gemini", "rules"), { recursive: true });
    fs.writeFileSync(path.join(home, ".gemini", "rules", "argent.md"), "rules");
    expect(gemini.detect()).toBe(false);
  });

  it("detects Gemini from the user's own files in .gemini", () => {
    fs.mkdirSync(path.join(home, ".gemini"), { recursive: true });
    fs.writeFileSync(path.join(home, ".gemini", "GEMINI.md"), "context");
    expect(gemini.detect()).toBe(true);
  });

  it("detects Gemini when settings.json carries user settings", () => {
    const settingsPath = path.join(home, ".gemini", "settings.json");
    fs.mkdirSync(path.dirname(settingsPath), { recursive: true });
    fs.writeFileSync(
      settingsPath,
      JSON.stringify({
        selectedAuthType: "oauth-personal",
        mcpServers: { argent: { command: "argent", args: ["mcp"] } },
      })
    );
    expect(gemini.detect()).toBe(true);
  });

  // ── Hermes ───────────────────────────────────────────────────────────────

  it("does not detect Hermes from an argent-only config.yaml", () => {
    hermes.write(path.join(home, ".hermes", "config.yaml"), getMcpEntry());
    expect(hermes.detect()).toBe(false);
  });

  it("detects Hermes when config.yaml carries user settings", () => {
    const configPath = path.join(home, ".hermes", "config.yaml");
    fs.mkdirSync(path.dirname(configPath), { recursive: true });
    fs.writeFileSync(configPath, "model: gpt-5\nmcp_servers:\n  argent:\n    command: argent\n");
    expect(hermes.detect()).toBe(true);
  });

  it("detects Hermes when its dir holds anything else", () => {
    fs.mkdirSync(path.join(home, ".hermes"), { recursive: true });
    fs.writeFileSync(path.join(home, ".hermes", "history.db"), "");
    expect(hermes.detect()).toBe(true);
  });

  // ── Kiro ─────────────────────────────────────────────────────────────────

  it("does not detect Kiro from an argent-only settings/mcp.json (incl. autoApprove)", () => {
    kiro.write(path.join(proj, ".kiro", "settings", "mcp.json"), getMcpEntry());
    kiro.addAllowlist!(proj, "local");
    expect(kiro.detect()).toBe(false);
  });

  it("detects Kiro from the user's own content (.kiro/steering)", () => {
    fs.mkdirSync(path.join(proj, ".kiro", "steering"), { recursive: true });
    fs.writeFileSync(path.join(proj, ".kiro", "steering", "product.md"), "docs");
    expect(kiro.detect()).toBe(true);
  });

  it("detects Kiro when settings/mcp.json has a foreign server", () => {
    const configPath = path.join(proj, ".kiro", "settings", "mcp.json");
    fs.mkdirSync(path.dirname(configPath), { recursive: true });
    fs.writeFileSync(
      configPath,
      JSON.stringify({ mcpServers: { argent: { command: "argent" }, other: { command: "x" } } })
    );
    expect(kiro.detect()).toBe(true);
  });

  // ── Loop closure across ALL adapters ─────────────────────────────────────

  it("a full init (entry + allowlist for every adapter, both scopes) leaves no adapter detected", () => {
    // The self-fulfilling detection bug, end to end: everything argent itself
    // writes must not count as evidence for ANY adapter — an asymmetric
    // subset re-detects the unfixed editors on the very next run. opencode is
    // excluded because it probes for the binary (mocked in this file), not a
    // directory.
    for (const adapter of ALL_ADAPTERS) {
      if (adapter.name === "opencode") continue;
      const projectPath = adapter.projectPath(proj);
      if (projectPath) {
        adapter.write(projectPath, getMcpEntry());
        adapter.addAllowlist?.(proj, "local");
      }
      const globalPath = adapter.globalPath();
      if (globalPath) {
        adapter.write(globalPath, getMcpEntry());
        adapter.addAllowlist?.(proj, "global");
      }
    }
    addClaudePermission(proj, "local");
    addClaudePermission(proj, "global");

    for (const adapter of ALL_ADAPTERS) {
      if (adapter.name === "opencode") continue;
      expect(adapter.detect(), `${adapter.name} must not self-detect`).toBe(false);
    }
  });
});
