Ctrl K
ComunidadGuías…

install.test.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
import { spawnSync } from 'node:child_process';
import {
  mkdtempSync,
  readdirSync,
  readFileSync,
  rmSync,
  writeFileSync,
  existsSync,
  mkdirSync,
  chmodSync,
  statSync,
  symlinkSync,
  renameSync,
} from 'node:fs';
import { tmpdir } from 'node:os';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';

import { afterAll, afterEach, beforeEach, describe, expect, it } from 'vitest';

// Tests for the whole installer system: the repo-root `install.sh` shim AND
// the `apps/plugin/install.sh` orchestrator it forwards to. Co-located with the
// root shim (the canonical entry point). Drives the installer through its
// headless (non-interactive) surface — the arrow-key TUI needs a real /dev/tty
// and is verified by the operator (see the rembric-tui-installer-e2e skill).
// Everything runs with REMBRIC_SRC pointed at the repo so the script reads
// artifacts from disk (cp), never the network.

const REPO_ROOT = dirname(fileURLToPath(import.meta.url));
const INSTALL_SH = join(REPO_ROOT, 'apps', 'plugin', 'install.sh');
const ROOT_SHIM = join(REPO_ROOT, 'install.sh');

// The installer reports each agent's "available" plugin version from
// .release-please-manifest.json per component. Derive expectations from the
// same source so a release-please bump doesn't break these tests.
const MANIFEST = JSON.parse(
  readFileSync(join(REPO_ROOT, '.release-please-manifest.json'), 'utf8'),
) as Record<string, string>;

const INSTALLER_SRC = readFileSync(INSTALL_SH, 'utf8');

// The regex requires the installer to keep its single-line `CLIENTS='…'` form.
const CLIENTS: string[] = (() => {
  const m = /^CLIENTS='([^']+)'$/m.exec(INSTALLER_SRC);
  if (!m) throw new Error('apps/plugin/install.sh has no single CLIENTS= definition');
  return m[1].split(' ');
})();

// Parsed from the installer's own definition so the expectations cannot drift
// from what the parser accepts. Same single-line requirement as CLIENTS.
const ACTIONS: string[] = (() => {
  const m = /^ACTIONS='([^']+)'$/m.exec(INSTALLER_SRC);
  if (!m) throw new Error('apps/plugin/install.sh has no single ACTIONS= definition');
  return m[1].split(' ');
})();

// The status table's ACTION column prints one of these when the state warrants
// no action at all. Everything else in that column must be an ACTIONS verb.
const NON_RECOMMENDATIONS = ['up to date', 'ahead', '-'];

// Core tools live in /usr/bin:/bin and no client binary does, so a case that
// needs a client to look absent points PATH here.
const CORE_PATH = '/usr/bin:/bin';

// A stub client binary first on PATH, so the real one never runs from the suite
// and installs into the developer's own configuration. The `RAN:` sentinel is
// what distinguishes "executed" from "merely printed".
function fakeClientBinDir(name: 'claude' | 'codex' | 'pi' | 'hermes'): string {
  const d = mkdtempSync(join(tmpdir(), 'rembric-fakeclient-'));
  writeFileSync(join(d, name), `#!/bin/sh\necho "RAN:${name} $*"\n`);
  chmodSync(join(d, name), 0o755);
  return d;
}

const PI_STUB_DIR = fakeClientBinDir('pi');
afterAll(() => rmSync(PI_STUB_DIR, { recursive: true, force: true }));

const PLUGIN_VERSION: Record<string, string> = Object.fromEntries(
  // All clients ship under the single unified `plugin` release-please component
  // (`apps/plugin`) — one shared version (unify-plugin-release-track).
  CLIENTS.map((c) => [c, MANIFEST['apps/plugin']]),
);

interface RunOpts {
  cwd?: string;
  home?: string;
  env?: Record<string, string>;
  path?: string;
  script?: string;
}

function run(args: string[], opts: RunOpts = {}): { code: number; out: string } {
  const env: Record<string, string> = {
    REMBRIC_SRC: REPO_ROOT,
    REMBRIC_NONINTERACTIVE: '1',
    REMBRIC_UPDATE_CHECK: 'off', // no GitHub network in tests unless a case opts in
    PATH: opts.path ?? process.env.PATH ?? '/usr/bin:/bin',
    HOME: opts.home ?? process.env.HOME ?? '/tmp',
    ...opts.env,
  };
  const res = spawnSync('/bin/sh', [opts.script ?? INSTALL_SH, ...args], {
    cwd: opts.cwd,
    env,
    encoding: 'utf8',
  });
  return { code: res.status ?? -1, out: `${res.stdout}${res.stderr}` };
}

let dir: string;
let home: string;

beforeEach(() => {
  dir = mkdtempSync(join(tmpdir(), 'rembric-cwd-'));
  home = mkdtempSync(join(tmpdir(), 'rembric-home-'));
});
afterEach(() => {
  rmSync(dir, { recursive: true, force: true });
  rmSync(home, { recursive: true, force: true });
});

describe('argument handling', () => {
  it('--help exits 0 and documents the full flag set', () => {
    const { code, out } = run(['--help']);
    expect(code).toBe(0);
    expect(out).toContain('rembric installer');
    for (const flag of [
      '--server',
      '--agent=',
      '--action=',
      '--status',
      '--json',
      '--token=',
      '--port=',
      '--up',
      '--ref=',
    ]) {
      expect(out).toContain(flag);
    }
    for (const c of CLIENTS) expect(out).toContain(c); // the --agent list derives from CLIENTS
  });

  it('no flags (headless) refuses and exits non-zero', () => {
    const { code, out } = run([]);
    expect(code).toBe(2);
    expect(out).toContain('Interactive:');
  });

  it('--agent without --action errors', () => {
    const { code, out } = run(['--agent=opencode']);
    expect(code).toBe(2);
    expect(out).toContain('--agent requires --action');
  });

  it('unknown agent errors', () => {
    const { code, out } = run(['--agent=bogus', '--action=install']);
    expect(code).toBe(2);
    expect(out).toContain('unknown agent');
  });

  it('an unrecognised --action is refused at parse time, before anything runs', () => {
    // --yes is on so the run WOULD execute the client CLI if it got that far.
    const opts = { home, path: `${PI_STUB_DIR}:${CORE_PATH}` };
    const { code, out } = run(['--agent=pi', '--action=bogus', '--yes'], opts);
    expect(code).toBe(2);
    expect(out).toContain('invalid --action=bogus');
    for (const a of ACTIONS) expect(out).toContain(a); // the error names what is accepted
    expect(out).not.toContain('RAN:pi');
    expect(out).not.toContain('Next');

    // Control: the same invocation with an accepted verb does all three.
    const ok = run(['--agent=pi', '--action=install', '--yes'], opts);
    expect(ok.code).toBe(0);
    expect(ok.out).toContain('RAN:pi');
    expect(ok.out).toContain('Next');
  });

  it('--server refuses an action it has no backend for', () => {
    // do_server treats every non-`update` action as install, so accepting
    // `uninstall` here would install under an "uninstall" heading.
    const { code, out } = run(['--server', '--action=uninstall'], { cwd: dir });
    expect(code).toBe(2);
    expect(out).toContain('--server accepts --action=install|update');
    expect(existsSync(join(dir, '.env'))).toBe(false);
  });
});

describe('preflight', () => {
  it('aborts listing missing core tools when PATH is empty (remote mode)', () => {
    // Empty PATH + no REMBRIC_SRC → curl + core tools missing. command -v is a
    // shell builtin so preflight still runs and reports.
    const { code, out } = run(['--server'], { path: '/nonexistent', env: { REMBRIC_SRC: '' } });
    expect(code).toBe(1);
    expect(out).toContain('missing required tool');
  });
});

describe('server install', () => {
  it('fresh dir: generates a 64-hex token, prepares files, never starts docker', () => {
    const { code, out } = run(['--server', '--action=install'], { cwd: dir });
    expect(code).toBe(0);
    expect(out).toContain('Generated admin token');
    expect(existsSync(join(dir, 'docker-compose.yml'))).toBe(true);
    const envText = readFileSync(join(dir, '.env'), 'utf8');
    const token = /^REMBRIC_ADMIN_TOKEN=([0-9a-f]+)$/m.exec(envText)?.[1];
    expect(token).toBeDefined();
    expect(token).toHaveLength(64);
    // Headless without --up must not bring the stack up.
    expect(out).not.toContain('Up.');
  });

  it('interrupted run (empty token in existing .env) gets filled on re-run', () => {
    writeFileSync(join(dir, '.env'), 'REMBRIC_ADMIN_TOKEN=\n# REMBRIC_VERSION=\n');
    const { code, out } = run(['--server', '--action=install'], { cwd: dir });
    expect(code).toBe(0);
    expect(out).toContain('is empty');
    const token = /^REMBRIC_ADMIN_TOKEN=([0-9a-f]{64})$/m.exec(
      readFileSync(join(dir, '.env'), 'utf8'),
    )?.[1];
    expect(token).toBeDefined();
  });

  it('configured .env is left untouched and its token shown', () => {
    writeFileSync(join(dir, '.env'), 'REMBRIC_ADMIN_TOKEN=existingtok123\n');
    const { code, out } = run(['--server', '--action=install'], { cwd: dir });
    expect(code).toBe(0);
    expect(out).toContain('already configured');
    expect(out).toContain('existingtok123');
    expect(readFileSync(join(dir, '.env'), 'utf8')).toContain('REMBRIC_ADMIN_TOKEN=existingtok123');
  });
});

describe('server update', () => {
  it('with a configured .env: refetches and offers the gated bring-up', () => {
    writeFileSync(join(dir, '.env'), 'REMBRIC_ADMIN_TOKEN=tok\n');
    const { code, out } = run(['--server', '--action=update'], { cwd: dir });
    expect(code).toBe(0);
    expect(out).toContain('Refetched');
    expect(out).toContain('docker compose pull && docker compose up -d');
  });

  it('without a .env: refuses to bring up and points to install', () => {
    const { code, out } = run(['--server', '--action=update'], { cwd: dir });
    expect(code).toBe(0);
    expect(out).toContain('No ./.env');
    expect(out).toContain('install first');
  });
});

function nativeFixture(version = '0.0.1'): void {
  const plugins = join(home, '.hermes', 'plugins');
  mkdirSync(join(plugins, 'rembric'), { recursive: true });
  writeFileSync(join(plugins, 'rembric', 'plugin.yaml'), `name: rembric\nversion: ${version}\n`);
  writeFileSync(
    join(plugins, '.install-metadata.json'),
    JSON.stringify({
      rembric: {
        source: 'https://github.com/susomejias/rembric.git#apps/plugin/.hermes-plugin',
        pinned: false,
        revision: '2e8cca2dc3a2ed23ee193bf1cfeb68ea9b6b7590',
      },
    }),
  );
}

function nativeRun(action: string, yes = true, failure = false, absent = false) {
  const bin = join(home, 'bin');
  mkdirSync(bin, { recursive: true });
  if (!absent) {
    writeFileSync(
      join(bin, 'hermes'),
      `#!/bin/sh\necho "RAN:hermes $*"\nexit ${failure ? 7 : 0}\n`,
    );
    chmodSync(join(bin, 'hermes'), 0o755);
  }
  writeFileSync(
    join(bin, 'curl'),
    `#!/bin/sh\nfor arg do case "$arg" in https://raw.githubusercontent.com/susomejias/rembric/main/*) rel="\u0024{arg#https://raw.githubusercontent.com/susomejias/rembric/main/}" ;; esac; done\nwhile [ "$#" -gt 0 ]; do if [ "$1" = -o ]; then cp '${REPO_ROOT}/'"$rel" "$2"; exit; fi; shift; done\nexit 91\n`,
  );
  chmodSync(join(bin, 'curl'), 0o755);
  return run(['--agent=hermes', `--action=${action}`, ...(yes ? ['--yes'] : [])], {
    home,
    path: `${bin}:${absent ? CORE_PATH : process.env.PATH}`,
    env: { REMBRIC_SRC: '' },
  });
}

describe('hermes native distribution', () => {
  it('prints without consent and executes native install only with --yes', () => {
    const printed = nativeRun('install', false);
    expect(printed.code).toBe(0);
    expect(printed.out).toContain(
      'hermes plugins install susomejias/rembric/apps/plugin/.hermes-plugin --enable',
    );
    expect(printed.out).not.toContain('RAN:hermes');
    expect(existsSync(join(home, '.hermes'))).toBe(false);
    const executed = nativeRun('install');
    expect(executed.code).toBe(0);
    expect(executed.out).toContain(
      'RAN:hermes plugins install susomejias/rembric/apps/plugin/.hermes-plugin --enable',
    );
    expect(executed.out).not.toContain('--force');
    expect(executed.out).not.toContain('--yes-deps');
  });

  it.each(['update', 'uninstall'])(
    'executes the native %s verb for a source-tracked install',
    (action) => {
      nativeFixture();
      const result = nativeRun(action);
      expect(result.code).toBe(0);
      expect(result.out).toContain(
        `RAN:hermes plugins ${action === 'uninstall' ? 'remove' : 'update'} rembric`,
      );
    },
  );

  it('propagates native failure without repair or restart claims', () => {
    nativeFixture();
    const config = join(home, '.hermes', 'config.yaml');
    const incomplete =
      "mcp_servers:\n  rembric:\n    command: npx\n    args: ['-y', '@rembric/mcp-bridge@0.29.1']\n";
    writeFileSync(config, incomplete);
    const result = nativeRun('update', true, true);
    expect(readFileSync(config, 'utf8')).toBe(incomplete);
    expect(existsSync(`${config}.rembric-mcp-env.bak`)).toBe(false);
    expect(result.code).not.toBe(0);
    expect(result.out).toContain('RAN:hermes plugins update rembric');
    expect(result.out).not.toContain('gateway restart');
    expect(result.out).not.toContain('migrated');
  });

  it('does not execute with an absent binary even under --yes', () => {
    const inherited = join(home, 'inherited');
    mkdirSync(inherited);
    writeFileSync(join(inherited, 'hermes'), '#!/bin/sh\necho RAN:inherited-hermes\nexit 99\n');
    chmodSync(join(inherited, 'hermes'), 0o755);
    const previousPath = process.env.PATH;
    process.env.PATH = `${inherited}:${previousPath}`;
    try {
      const result = nativeRun('install', true, false, true);
      expect(result.code).toBe(0);
      expect(result.out).toContain(
        'hermes plugins install susomejias/rembric/apps/plugin/.hermes-plugin --enable',
      );
      expect(result.out).not.toContain('RAN:');
      expect(existsSync(join(home, '.hermes'))).toBe(false);
    } finally {
      process.env.PATH = previousPath;
    }
  });

  it('rejects copied and invalid metadata without touching unknown files', () => {
    nativeFixture();
    const plugins = join(home, '.hermes', 'plugins');
    const unknown = join(plugins, 'rembric', 'operator-file');
    writeFileSync(unknown, 'keep');
    for (const metadata of [
      '{}',
      '{',
      JSON.stringify({ rembric: { source: 'foreign', pinned: false, revision: 'a'.repeat(40) } }),
      JSON.stringify({
        rembric: {
          source: 'https://github.com/susomejias/rembric.git#apps/plugin/.hermes-plugin',
          pinned: true,
          revision: 'a'.repeat(40),
        },
      }),
    ]) {
      writeFileSync(join(plugins, '.install-metadata.json'), metadata);
      for (const action of ACTIONS) {
        const result = nativeRun(action);
        expect(result.code).not.toBe(0);
        expect(result.out).not.toContain('RAN:hermes');
        expect(result.out).toContain('backup');
        expect(readFileSync(unknown, 'utf8')).toBe('keep');
        expect(readFileSync(join(plugins, '.install-metadata.json'), 'utf8')).toBe(metadata);
      }
    }
  });

  it.each([
    {
      source: 'https://github.com/susomejias/rembric.git#apps/plugin/.hermes-plugin',
      revision: 'short',
      pinned: false,
    },
    {
      source: 'https://github.com/susomejias/rembric.git#apps/plugin/.hermes-plugin',
      revision: 'a'.repeat(40),
    },
    {
      source: 'https://github.com/susomejias/rembric.git#apps/plugin/.hermes-plugin',
      revision: 42,
      pinned: false,
    },
    null,
  ])('rejects incomplete metadata: %j', (record) => {
    nativeFixture();
    writeFileSync(
      join(home, '.hermes', 'plugins', '.install-metadata.json'),
      JSON.stringify({ rembric: record }),
    );
    const result = nativeRun('uninstall');
    expect(result.code).not.toBe(0);
    expect(result.out).not.toContain('RAN:hermes');
  });

  it.each([
    'linked-target',
    'linked-metadata',
    'missing-target',
    'missing-manifest',
    'non-object-metadata',
  ])('refuses %s without touching the existing installation', (state) => {
    nativeFixture();
    const plugins = join(home, '.hermes', 'plugins');
    const target = join(plugins, 'rembric');
    const metadata = join(plugins, '.install-metadata.json');
    if (state === 'linked-target' || state === 'missing-target') {
      renameSync(target, join(home, 'preserved'));
      if (state === 'linked-target') symlinkSync(join(home, 'preserved'), target);
    } else if (state === 'linked-metadata') {
      renameSync(metadata, join(home, 'metadata'));
      symlinkSync(join(home, 'metadata'), metadata);
    } else if (state === 'missing-manifest') {
      renameSync(join(target, 'plugin.yaml'), join(target, 'saved-manifest'));
    } else writeFileSync(metadata, '[]');
    const result = nativeRun('uninstall');
    expect(result.code).not.toBe(0);
    expect(result.out).not.toContain('RAN:hermes');
    expect(
      existsSync(
        state === 'linked-target' || state === 'missing-target'
          ? join(home, 'preserved', 'plugin.yaml')
          : target,
      ),
    ).toBe(true);
  });

  it('a repeated install of a managed source is a no-op, never a force reinstall', () => {
    nativeFixture();
    const result = nativeRun('install');
    expect(result.code).toBe(0);
    expect(result.out).toContain('already installed');
    expect(result.out).not.toContain('RAN:hermes');
  });

  it('already-absent uninstall is a successful no-op', () => {
    const result = nativeRun('uninstall');
    expect(result.code).toBe(0);
    expect(result.out).toContain('already absent');
    expect(result.out).not.toContain('RAN:hermes');
  });

  it.each([
    { args: [], env: { REMBRIC_SRC: REPO_ROOT } },
    { args: ['--ref=main'], env: { REMBRIC_SRC: '' } },
    { args: [], env: { REMBRIC_SRC: '', REMBRIC_REF: 'main' } },
    { args: [], env: { REMBRIC_SRC: '', PLUGIN_SRC: '/unused' } },
  ])(
    'rejects unsupported source overrides before mixed-operation mutation: %j',
    ({ args, env }) => {
      nativeRun('install', false);
      const result = run(['--server', '--agent=opencode,hermes', '--action=install', ...args], {
        home,
        cwd: dir,
        env,
      });
      expect(result.code).not.toBe(0);
      expect(result.out).toContain('Hermes supports only the default native source');
      expect(existsSync(join(dir, 'docker-compose.yml'))).toBe(false);
      expect(existsSync(join(home, '.config'))).toBe(false);
    },
  );

  it.each([
    { args: ['--ref=main'], env: {} },
    { args: [], env: { PLUGIN_SRC: '/unused' } },
    { args: [], env: { REMBRIC_SRC: REPO_ROOT } },
    { args: [], env: { REMBRIC_REF: 'main' } },
  ])('direct native adapter rejects unsupported input: %j', ({ args, env }) => {
    nativeRun('install', false);
    const result = run(args, {
      home,
      script: join(REPO_ROOT, 'apps/plugin/.hermes-plugin/install.sh'),
      path: `${home}/bin:${process.env.PATH}`,
      env: { REMBRIC_SRC: '', ...env },
    });
    expect(result.code).toBe(2);
    expect(result.out).toContain('default native source');
    expect(result.out).not.toContain('RAN:hermes');
    expect(existsSync(join(home, '.hermes'))).toBe(false);
  });

  it('reports update status unknown and skips native Hermes in update-all', () => {
    nativeFixture();
    const result = run(['--status', '--json'], { home, path: CORE_PATH });
    const hermes = JSON.parse(result.out).agents.find(
      (a: { agent: string }) => a.agent === 'hermes',
    );
    expect(hermes.present).toBe(false);
    expect(hermes.installed).toBe('0.0.1');
    expect(hermes.action).toBe('unknown');
    nativeRun('install', false);
    const detected = run(['--status', '--json'], { home, path: `${home}/bin:${process.env.PATH}` });
    expect(
      JSON.parse(detected.out).agents.find((a: { agent: string }) => a.agent === 'hermes').present,
    ).toBe(true);
    const updated = run(['--action=update', '--yes'], {
      home,
      path: `${home}/bin:${process.env.PATH}`,
    });
    expect(updated.code).toBe(0);
    expect(updated.out).toContain('hermes: version unknown');
    expect(updated.out).not.toContain('hermes (update)');
  });
});

describe('agent routing', () => {
  it('codex install prints marketplace commands, copies nothing', () => {
    const { code, out } = run(['--agent=codex', '--action=install'], { home });
    expect(code).toBe(0);
    expect(out).toContain('codex plugin marketplace add');
    expect(out).toContain('codex plugin add rembric@rembric');
  });

  it('claude uninstall prints the marketplace command and the conservative note', () => {
    const { out } = run(['--agent=claude', '--action=uninstall'], { home });
    expect(out).toContain('claude plugin uninstall rembric@rembric');
    expect(out).toContain('Left in place');
  });

  it('claude/codex update use their real upgrade commands, not re-install', () => {
    const claude = run(['--agent=claude', '--action=update'], { home });
    expect(claude.code).toBe(0);
    expect(claude.out).toContain('claude plugin update rembric@rembric');
    expect(claude.out).not.toContain('claude plugin install'); // update ≠ re-install
    expect(claude.out).not.toContain('marketplace add'); // marketplace already added

    const codex = run(['--agent=codex', '--action=update'], { home });
    expect(codex.code).toBe(0);
    expect(codex.out).toContain('codex plugin marketplace upgrade rembric');
    expect(codex.out).not.toContain('codex plugin install'); // no such subcommand in the Codex CLI
  });

  it('a comma-separated --agent list drives multiple agents in one run', () => {
    const { code, out } = run(['--agent=codex,claude', '--action=install'], { home });
    expect(code).toBe(0);
    expect(out).toContain('codex plugin add rembric@rembric');
    expect(out).toContain('claude plugin install rembric@rembric');
  });

  it('install surfaces the required post-install steps per agent', () => {
    const codex = run(['--agent=codex', '--action=install'], { home });
    expect(codex.out).not.toContain('plugin_hooks'); // flag removed upstream in codex-cli 0.142.3+
    expect(codex.out).toContain('/hooks');
    const hermes = nativeRun('install', false);
    expect(hermes.out).toContain(
      'hermes plugins install susomejias/rembric/apps/plugin/.hermes-plugin --enable',
    );
    expect(hermes.out).toContain('memory.provider');
    expect(hermes.out).not.toContain('hermes plugins enable rembric');
    expect(hermes.out).toContain('hermes gateway restart');
  });

  it('hermes update explains the automatic legacy migration and exact fallback before restart', () => {
    const { code, out } = nativeRun('update', false);
    expect(code).toBe(0);
    expect(out).toContain(`args: ['-y', '@rembric/mcp-bridge@${PLUGIN_VERSION.hermes}']`);
    expect(out).toContain('REMBRIC_SERVER_URL: ${REMBRIC_SERVER_URL}');
    expect(out).toContain('enabled: true');
    expect(out).toContain('repairs recognized Rembric MCP blocks');
    expect(out).toContain('writes a backup before changing config');
    expect(out).toContain('hermes gateway restart');
    expect(out).not.toContain('hermes plugins install rembric');
  });

  it('hermes update migrates only the documented legacy MCP block and preserves a backup', () => {
    const hermesHome = join(home, '.hermes');
    mkdirSync(hermesHome, { recursive: true });
    const config = join(hermesHome, 'config.yaml');
    const legacy = `mcp_servers:\n  rembric:\n    command: npx\n    args:\n      [\n        '-y',\n        'mcp-remote@latest',\n        '\${REMBRIC_SERVER_URL}/mcp/\${REMBRIC_PROJECT_SLUG}',\n        '--header',\n        'Authorization: Bearer \${REMBRIC_API_TOKEN}',\n        '--allow-http',\n      ]\n  other:\n    command: other-mcp\n`;
    writeFileSync(config, legacy);

    nativeFixture(PLUGIN_VERSION.hermes);
    const { code, out } = nativeRun('update');

    expect(code).toBe(0);
    expect(readFileSync(join(hermesHome, 'config.yaml.rembric-mcp-remote.bak'), 'utf8')).toBe(
      legacy,
    );
    expect(out).toContain('migrated mcp_servers.rembric');
    expect(readFileSync(config, 'utf8')).toBe(
      `mcp_servers:\n  rembric:\n    command: npx\n    args: ['-y', '@rembric/mcp-bridge@${PLUGIN_VERSION.hermes}']\n    env:\n      REMBRIC_SERVER_URL: \${REMBRIC_SERVER_URL}\n      REMBRIC_API_TOKEN: \${REMBRIC_API_TOKEN}\n      REMBRIC_PROJECT_SLUG: \${REMBRIC_PROJECT_SLUG}\n    enabled: true\n  other:\n    command: other-mcp\n`,
    );
  });

  it('hermes update repairs only the incomplete bridge block emitted by the prior updater', () => {
    const hermesHome = join(home, '.hermes');
    mkdirSync(hermesHome, { recursive: true });
    const config = join(hermesHome, 'config.yaml');
    const incomplete = `mcp_servers:\n  rembric:\n    command: npx\n    args: ['-y', '@rembric/mcp-bridge@0.29.1']\n`;
    writeFileSync(config, incomplete);
    nativeFixture(PLUGIN_VERSION.hermes);
    expect(nativeRun('update').code).toBe(0);

    expect(readFileSync(join(hermesHome, 'config.yaml.rembric-mcp-env.bak'), 'utf8')).toBe(
      incomplete,
    );
    expect(readFileSync(config, 'utf8')).toContain('REMBRIC_API_TOKEN: ${REMBRIC_API_TOKEN}');
    expect(readFileSync(config, 'utf8')).toContain('enabled: true');
  });

  it('hermes update preserves canonical and custom bridge blocks', () => {
    const hermesHome = join(home, '.hermes');
    mkdirSync(hermesHome, { recursive: true });
    const config = join(hermesHome, 'config.yaml');
    const custom = `mcp_servers:\n  rembric:\n    command: wrapper\n    args: ['rembric']\n`;
    writeFileSync(config, custom);
    nativeFixture(PLUGIN_VERSION.hermes);
    expect(nativeRun('update').code).toBe(0);

    expect(readFileSync(config, 'utf8')).toBe(custom);
    expect(existsSync(join(hermesHome, 'config.yaml.rembric-mcp-env.bak'))).toBe(false);
  });

  it('opencode/claude update notes drop the install-only wiring (just restart)', () => {
    const opencode = run(['--agent=opencode', '--action=update'], { home });
    expect(opencode.code).toBe(0);
    expect(opencode.out).toContain('restart opencode');
    expect(opencode.out).not.toContain('paste the printed MCP block'); // install-only

    const claude = run(['--agent=claude', '--action=update'], { home });
    expect(claude.code).toBe(0);
    expect(claude.out).toContain('restart Claude Code');
    expect(claude.out).not.toContain('prompts for the server URL'); // install-only
  });

  it('uninstall does not print post-install "Next" steps', () => {
    const { out } = run(['--agent=codex', '--action=uninstall'], { home });
    expect(out).not.toContain('Next');
  });

  it('opencode install then uninstall round-trips against a throwaway HOME', () => {
    const installed = join(home, '.config', 'opencode', 'plugins', 'rembric.ts');

    const ins = run(['--agent=opencode', '--action=install'], { home });
    expect(ins.code).toBe(0);
    expect(existsSync(installed)).toBe(true);
    // The version comment survives the install rewrite (used for detection).
    expect(readFileSync(installed, 'utf8')).toMatch(/@rembric-plugin-version\s+\d+\.\d+\.\d+/);

    const un = run(['--agent=opencode', '--action=uninstall'], { home });
    expect(un.code).toBe(0);
    expect(existsSync(installed)).toBe(false);
    expect(un.out).toContain('Left in place');
  });
});

describe('--action=update with no --agent (update-all)', () => {
  // #262 / memory.about's `update_all` command: before this feature, a bare
  // `--action=update` errored ("--agent requires --action" is backwards —
  // actually it fell through to the usage error because ARG_AGENTS was
  // empty). This section proves the fix: it updates only what has an update
  // available and never errors.

  function ageOpencodePlugin(version: string): void {
    const dir = join(home, '.config', 'opencode', 'plugins');
    mkdirSync(dir, { recursive: true });
    writeFileSync(
      join(dir, 'rembric.ts'),
      `// @rembric-plugin-version ${version}\nimport { readRembricSlug } from './lib/rembric-dotenv.mjs';\nexport const RembricPlugin = () => ({});\n`,
    );
  }

  it('updates only the installed-and-outdated agent, skips the rest, never errors', () => {
    ageOpencodePlugin('0.0.1'); // older than PLUGIN_VERSION.opencode from the manifest
    const { code, out } = run(['--action=update'], { home });
    expect(code).toBe(0);
    expect(out).toContain('Updating all plugins with an update available');
    // opencode was outdated → updated (its update note, not the install-only one).
    expect(out).toContain('restart opencode');
    expect(out).not.toContain('paste the printed MCP block');
    // claude/codex/hermes were never installed → skipped, not errored.
    expect(out).toContain('claude: not installed — skipped');
    expect(out).toContain('codex: not installed — skipped');
    expect(out).toContain('hermes: not installed — skipped');
    expect(out).toContain(`Done: 1 updated, ${CLIENTS.length - 1} skipped.`);
  });

  it('--agent=all --action=update is an explicit alias for the same behavior', () => {
    ageOpencodePlugin('0.0.1');
    const { code, out } = run(['--agent=all', '--action=update'], { home });
    expect(code).toBe(0);
    expect(out).toContain(`Done: 1 updated, ${CLIENTS.length - 1} skipped.`);
  });

  it('an up-to-date agent is skipped as "up to date", not re-updated', () => {
    ageOpencodePlugin(PLUGIN_VERSION.opencode); // matches the manifest exactly
    const { code, out } = run(['--action=update'], { home });
    expect(code).toBe(0);
    expect(out).toContain('opencode: up to date — skipped');
    expect(out).toContain(`Done: 0 updated, ${CLIENTS.length} skipped.`);
  });
});

describe('the client set has a single definition every surface agrees with', () => {
  it('is exactly the five supported clients, in a stable order', () => {
    // Non-vacuity control for every assertion below that derives from CLIENTS.
    expect(CLIENTS).toEqual(['claude', 'codex', 'hermes', 'opencode', 'pi']);
  });

  it('no second line in the installer enumerates the client set', () => {
    const enumerating = INSTALLER_SRC.split('\n').filter(
      (line) => CLIENTS.filter((c) => new RegExp(`\\b${c}\\b`).test(line)).length >= 4,
    );
    expect(enumerating).toEqual([`CLIENTS='${CLIENTS.join(' ')}'`]);
  });

  it('the interactive agent menu and its index mapping both derive from it', () => {
    // The arrow-key menu needs a real /dev/tty, so this is asserted at the source.
    expect(INSTALLER_SRC).toContain('arrow_menu "Which agent?" "all — update outdated" $CLIENTS');
    expect(INSTALLER_SRC).toContain('c=$(client_at "$MENU_INDEX")');
  });

  it('--status --json emits one entry per client, in the same order', () => {
    const { code, out } = run(['--status', '--json'], { home });
    expect(code).toBe(0);
    expect(JSON.parse(out).agents.map((a: { agent: string }) => a.agent)).toEqual(CLIENTS);
  });

  it('update-all accounts for every client in the set', () => {
    const { code, out } = run(['--action=update'], { home });
    expect(code).toBe(0);
    for (const c of CLIENTS) expect(out).toMatch(new RegExp(`^  ${c}: `, 'm'));
    expect(out).toContain(`Done: 0 updated, ${CLIENTS.length} skipped.`);
  });

  it.each(CLIENTS)('--agent=%s is routed to a backend and has post-install steps', (client) => {
    const { code, out } =
      client === 'hermes'
        ? nativeRun('install', false)
        : run([`--agent=${client}`, '--action=install'], {
            home,
            path: `${PI_STUB_DIR}:${process.env.PATH ?? '/usr/bin:/bin'}`,
          });
    expect(code).toBe(0);
    expect(out).not.toContain('unknown agent');
    expect(out).toContain(`${client} (install)`);
    // A client with no post_install_notes arm installs and says nothing about the
    // wiring it still needs.
    expect(out).toContain('Next');
  });

  it('every client has a presence adapter (an unmatched case would report present)', () => {
    // Every row must read absent here, and a client missing from client_present's
    // `case` falls through to an empty arm, which exits 0 — i.e. reports present.
    const { out } = run(['--status', '--json'], { home, path: '/usr/bin:/bin' });
    const agents = JSON.parse(out).agents as { agent: string; present: boolean }[];
    expect(agents.map((a) => a.present)).toEqual(CLIENTS.map(() => false));
  });
});

describe('the ACTION column recommends only actions --action accepts', () => {
  // The three surfaces derived from client_state print different things: the
  // table prints the recommended VERB, `--status --json` the detected STATE, and
  // update-all the verb it would take.

  function tableRows(out: string): { agent: string; action: string }[] {
    return out
      .split('\n')
      .map((line) => line.split(/\s{2,}/).filter(Boolean))
      .filter((cells) => cells.length === 5 && CLIENTS.includes(cells[0]))
      .map((cells) => ({ agent: cells[0], action: cells[4] }));
  }

  function writeOpencodePlugin(version: string): void {
    const d = join(home, '.config', 'opencode', 'plugins');
    mkdirSync(d, { recursive: true });
    writeFileSync(join(d, 'rembric.ts'), `// @rembric-plugin-version ${version}\n`);
  }

  it('no cell in any detectable state is outside the accepted verbs', () => {
    const withPi = { home, path: `${PI_STUB_DIR}:${CORE_PATH}` };
    // One scenario per state that reaches the column: not installed (install),
    // installed-and-old (update), installed-and-current (up to date), ahead of
    // the published version, and present-but-unreadable (the pi row).
    const scenarios = [
      () => undefined,
      () => writeOpencodePlugin('0.0.1'),
      () => writeOpencodePlugin(PLUGIN_VERSION.opencode),
      () => writeOpencodePlugin('99.0.0'),
    ];
    const seen = new Set<string>();
    for (const setup of scenarios) {
      setup();
      const rows = tableRows(run(['--status'], withPi).out);
      // Non-vacuity: an unparsed table would make every assertion below empty.
      expect(rows.map((r) => r.agent)).toEqual(CLIENTS);
      for (const row of rows) {
        expect([...ACTIONS, ...NON_RECOMMENDATIONS]).toContain(row.action);
        seen.add(row.action);
      }
    }
    // …and the scenarios really did exercise more than one outcome.
    expect(seen.size).toBeGreaterThan(2);
  });

  it('following the recommendation the table prints resolves to a real action', () => {
    const withPi = { home, path: `${PI_STUB_DIR}:${CORE_PATH}` };
    const pi = tableRows(run(['--status'], withPi).out).find((r) => r.agent === 'pi');
    expect(pi).toBeDefined();
    expect(ACTIONS).toContain(pi!.action);

    const followed = run(['--agent=pi', `--action=${pi!.action}`, '--yes'], withPi);
    expect(followed.code).toBe(0);
    expect(followed.out).toContain('RAN:pi');
    expect(followed.out).not.toMatch(/invalid --action|parameter not set|unsupported action/);
  });

  it.each(ACTIONS)(
    '--yes with --action=%s reaches a real command, never an unset one',
    (action) => {
      const { code, out } = run(['--agent=pi', `--action=${action}`, '--yes'], {
        home,
        path: `${PI_STUB_DIR}:${CORE_PATH}`,
      });
      expect(code).toBe(0);
      expect(out).not.toMatch(/parameter not set|unbound variable|unsupported action/);
      expect(out).toContain('RAN:pi');
    },
  );

  it("the CLI backend's action table has one arm per verb and fails closed", () => {
    // Unreachable from the CLI while the parser refuses unknown verbs, so it is
    // asserted at the source: an unmatched POSIX `case` exits 0, silently.
    const block = /client_cli_cmds\(\)[\s\S]*?\n {2}case "\$action" in\n([\s\S]*?)\n {2}esac/.exec(
      INSTALLER_SRC,
    );
    expect(block).not.toBeNull();
    const arms = [...block![1].matchAll(/^[ \t]*([a-z]+|\*)\)/gm)].map((m) => m[1]);
    expect(arms).toContain('*');
    expect(arms.filter((a) => a !== '*').sort()).toEqual([...ACTIONS].sort());
  });

  it('update-all names a verb the parser accepts when it declines to act', () => {
    const { code, out } = run(['--action=update', '--yes'], {
      home,
      path: `${PI_STUB_DIR}:${CORE_PATH}`,
    });
    expect(code).toBe(0);
    const hint = /pi: version unknown — skipped \(use --agent=pi --action=(\S+) to force\)/.exec(
      out,
    );
    expect(hint).not.toBeNull();
    expect(ACTIONS).toContain(hint![1]);
  });
});

describe('pi (registry-CLI backend)', () => {
  const piAgent = (root: string): string => join(root, 'npm', 'node_modules', '@rembric', 'pi');

  function installedFixture(agentDir: string, version: string): void {
    const pkg = piAgent(agentDir);
    mkdirSync(pkg, { recursive: true });
    writeFileSync(
      join(pkg, 'package.json'),
      `${JSON.stringify({ name: '@rembric/pi', version }, null, 2)}\n`,
    );
  }

  function piRow(out: string): string {
    return out.split('\n').find((l) => /^ {2}pi\s/.test(l)) ?? '';
  }

  it('has no repo-side install or uninstall script', () => {
    const piPlugin = join(REPO_ROOT, 'apps', 'plugin', '.pi-plugin');
    expect(existsSync(join(piPlugin, 'install.sh'))).toBe(false);
    expect(existsSync(join(piPlugin, 'uninstall.sh'))).toBe(false);
  });

  it('install and update print the SAME unpinned command, even under --ref', () => {
    const spec = 'pi install npm:@rembric/pi';
    const ins = run(['--agent=pi', '--action=install'], { home, path: CORE_PATH });
    const upd = run(['--agent=pi', '--action=update'], { home, path: CORE_PATH });
    const pinned = run(['--agent=pi', '--action=install', '--ref=v9.9.9'], {
      home,
      path: CORE_PATH,
    });
    expect(ins.code).toBe(0);
    expect(upd.code).toBe(0);
    expect(pinned.code).toBe(0);
    for (const { out } of [ins, upd, pinned]) {
      expect(out).toContain(spec);
      // A version-pinned spec is skipped by `pi update`, freezing the operator
      // while reporting success.
      expect(out).not.toMatch(/@rembric\/pi@/);
    }
    // --ref names a git ref; this artifact comes from the registry.
    expect(pinned.out).not.toContain('9.9.9');
  });

  it("uninstall routes to the client's own removal verb and keeps credentials", () => {
    const { code, out } = run(['--agent=pi', '--action=uninstall'], { home, path: CORE_PATH });
    expect(code).toBe(0);
    expect(out).toContain('pi remove npm:@rembric/pi');
    expect(out).toContain('Left in place');
    expect(out).not.toContain('Next');
  });

  it('install prints the shell-environment step and offers no settings-file alternative', () => {
    const { out } = run(['--agent=pi', '--action=install'], { home, path: CORE_PATH });
    expect(out).toContain('REMBRIC_SERVER_URL');
    expect(out).toContain('REMBRIC_API_TOKEN');
    // Pi reads no environment from its settings file, so there is no
    // settings-file alternative to offer.
    expect(out).not.toMatch(/settings/i);
  });

  it('update prints only the restart, not the install-only credential step', () => {
    const { out } = run(['--agent=pi', '--action=update'], { home, path: CORE_PATH });
    expect(out).toContain('restart Pi');
    expect(out).not.toContain('REMBRIC_API_TOKEN');
  });

  it('--yes runs the registry command when the binary is present, and nothing when absent', () => {
    const present = run(['--agent=pi', '--action=install', '--yes'], {
      home,
      path: `${PI_STUB_DIR}:${CORE_PATH}`,
    });
    expect(present.code).toBe(0);
    expect(present.out).toContain('RAN:pi install npm:@rembric/pi');

    const noFlag = run(['--agent=pi', '--action=install'], {
      home,
      path: `${PI_STUB_DIR}:${CORE_PATH}`,
    });
    expect(noFlag.out).toContain('pi install npm:@rembric/pi'); // printed
    expect(noFlag.out).not.toContain('RAN:pi'); // never executed

    const absent = run(['--agent=pi', '--action=install', '--yes'], { home, path: CORE_PATH });
    expect(absent.out).toContain('pi install npm:@rembric/pi');
    expect(absent.out).not.toContain('RAN:pi');
  });

  describe('installed-version detection', () => {
    // A user-scope `pi install npm:<pkg>` leaves the manifest under
    // <agentDir>/npm/node_modules/; every other install vector leaves no version
    // on disk at all.
    it('reads the version from the deterministic location under PI_CODING_AGENT_DIR', () => {
      const agentDir = join(home, 'piagent');
      installedFixture(agentDir, '0.0.1');
      const { out } = run(['--status'], {
        home,
        path: `${PI_STUB_DIR}:${CORE_PATH}`,
        env: { PI_CODING_AGENT_DIR: agentDir },
      });
      expect(piRow(out)).toContain('0.0.1');
      expect(piRow(out)).toContain('update');

      installedFixture(agentDir, PLUGIN_VERSION.pi);
      const current = run(['--status'], {
        home,
        path: `${PI_STUB_DIR}:${CORE_PATH}`,
        env: { PI_CODING_AGENT_DIR: agentDir },
      });
      expect(piRow(current.out)).toContain('up to date');
    });

    it('defaults to ~/.pi/agent when PI_CODING_AGENT_DIR is unset', () => {
      installedFixture(join(home, '.pi', 'agent'), '0.0.2');
      const { out } = run(['--status'], { home, path: `${PI_STUB_DIR}:${CORE_PATH}` });
      expect(piRow(out)).toContain('0.0.2');
    });

    it('reads a prerelease version whole, not truncated to its release core', () => {
      // The digits-only extraction the other four adapters use returns empty or a
      // truncated `9.9.9` here, so vercmp would compare a version nobody has.
      const agentDir = join(home, 'piagent');
      installedFixture(agentDir, '9.9.9-rc.1');
      const { out } = run(['--status'], {
        home,
        path: `${PI_STUB_DIR}:${CORE_PATH}`,
        env: { PI_CODING_AGENT_DIR: agentDir },
      });
      expect(piRow(out)).toContain('9.9.9-rc.1');
    });

    it('renders unknown + the idempotent install verb when no version is on disk', () => {
      const { code, out } = run(['--status'], { home, path: `${PI_STUB_DIR}:${CORE_PATH}` });
      expect(code).toBe(0);
      expect(piRow(out)).toContain('unknown');
      const action = piRow(out)
        .trim()
        .split(/\s{2,}/)
        .at(-1);
      expect(action).toBe('install');
      expect(ACTIONS).toContain(action);
      // An unreadable version is neither determinate state, so the table must
      // claim neither.
      expect(piRow(out)).not.toContain('up to date');
      expect(piRow(out)).not.toContain('update');
    });

    it('--status --json carries a null version and an unknown action', () => {
      const { out } = run(['--status', '--json'], { home, path: `${PI_STUB_DIR}:${CORE_PATH}` });
      const pi = JSON.parse(out).agents.find((a: { agent: string }) => a.agent === 'pi');
      expect(pi.present).toBe(true);
      expect(pi.installed).toBeNull(); // a semver or null, never a marker string
      expect(pi.action).toBe('unknown');
    });
  });

  describe('update-all', () => {
    it('skips an unknown row with unknown as the reason, exits 0, and runs nothing', () => {
      const { code, out } = run(['--action=update', '--yes'], {
        home,
        path: `${PI_STUB_DIR}:${CORE_PATH}`,
      });
      expect(code).toBe(0);
      expect(out).toContain('pi: version unknown — skipped');
      // Unattended, so reinstalling on ignorance would act on every run.
      expect(out).not.toContain('RAN:pi');
    });

    it('control — the same command DOES update it when the version is readable and old', () => {
      const agentDir = join(home, 'piagent');
      installedFixture(agentDir, '0.0.1');
      const { code, out } = run(['--action=update', '--yes'], {
        home,
        path: `${PI_STUB_DIR}:${CORE_PATH}`,
        env: { PI_CODING_AGENT_DIR: agentDir },
      });
      expect(code).toBe(0);
      expect(out).toContain('RAN:pi install npm:@rembric/pi');
      expect(out).not.toContain('pi: version unknown');
    });
  });
});

describe('opencode installer verifications', () => {
  const OPENCODE_INSTALL = join(REPO_ROOT, 'apps', 'plugin', '.opencode-plugin', 'install.sh');

  it('is idempotent: a second install produces byte-identical files', () => {
    const files = [
      join(home, '.config', 'opencode', 'plugins', 'rembric.ts'),
      join(home, '.config', 'rembric', 'bin', 'rembric-dotenv.mjs'),
      join(home, '.config', 'rembric', 'bin', 'rembric-plugin-core.mjs'),
    ];
    const first = run(['--agent=opencode', '--action=install'], { home });
    expect(first.code).toBe(0);
    const snapshot = files.map((f) => readFileSync(f, 'utf8'));
    expect(existsSync(join(home, '.config', 'opencode', 'opencode.json'))).toBe(false);
    expect(first.out).toContain('adds the MCP entry in memory');
    const second = run(['--agent=opencode', '--action=install'], { home });
    expect(second.code).toBe(0);
    expect(second.out).toContain('left untouched');
    expect(files.map((f) => readFileSync(f, 'utf8'))).toEqual(snapshot);
  });

  it('leaves an existing opencode.json byte-identical', () => {
    const cfgDir = join(home, '.config', 'opencode');
    mkdirSync(cfgDir, { recursive: true });
    const configPath = join(cfgDir, 'opencode.json');
    const oldConfig = JSON.stringify(
      {
        mcp: {
          rembric: {
            type: 'local',
            command: ['node', '/home/user/.config/rembric/bin/rembric-bridge.mjs'],
            environment: { REMBRIC_SERVER_URL: 'old-url', REMBRIC_API_TOKEN: 'old-token' },
          },
        },
        operatorSetting: 'leave-me-alone',
      },
      null,
      2,
    );
    writeFileSync(configPath, oldConfig);

    const result = run(['--agent=opencode', '--action=update'], { home });

    expect(result.code).toBe(0);
    expect(readFileSync(configPath, 'utf8')).toBe(oldConfig);
    expect(result.out).toContain('left untouched');
    expect(result.out).toContain('adds the MCP entry in memory');
  });

  it('forwards the local bridge source and keeps the public ref source wired', () => {
    const noNetwork = mkdtempSync(join(tmpdir(), 'rembric-no-network-'));
    writeFileSync(join(noNetwork, 'curl'), '#!/bin/sh\nexit 91\n');
    chmodSync(join(noNetwork, 'curl'), 0o755);
    const result = run(['--agent=opencode', '--action=install'], {
      home,
      path: `${noNetwork}:${CORE_PATH}`,
    });
    rmSync(noNetwork, { recursive: true, force: true });

    expect(result.code).toBe(0);
    expect(INSTALLER_SRC).toContain('MCP_BRIDGE_SRC="$REMBRIC_SRC/apps/plugin/mcp-bridge"');
    expect(INSTALLER_SRC).toContain('MCP_BRIDGE_SRC="$base/mcp-bridge"');
  });

  it('an unrelated "rembric" string elsewhere in opencode.json is NOT treated as configured', () => {
    const cfgDir = join(home, '.config', 'opencode');
    mkdirSync(cfgDir, { recursive: true });
    const cfg = JSON.stringify({ mcp: { 'rembric-foo': { type: 'local' } }, theme: 'rembric' });
    writeFileSync(join(cfgDir, 'opencode.json'), cfg);
    const { code, out } = run(['--agent=opencode', '--action=install'], { home });
    expect(code).toBe(0);
    expect(out).toContain('left untouched');
    expect(readFileSync(join(cfgDir, 'opencode.json'), 'utf8')).toBe(cfg); // untouched
  });

  it('a real mcp.rembric entry is detected as already configured', () => {
    const cfgDir = join(home, '.config', 'opencode');
    mkdirSync(cfgDir, { recursive: true });
    const cfg = JSON.stringify({ mcp: { rembric: { type: 'local', enabled: true } } });
    writeFileSync(join(cfgDir, 'opencode.json'), cfg);
    const { out } = run(['--agent=opencode', '--action=install'], { home });
    expect(out).toContain('left untouched');
    expect(readFileSync(join(cfgDir, 'opencode.json'), 'utf8')).toBe(cfg);
  });

  it('aborts loudly and removes the partial plugin when the import rewrite no-ops', () => {
    const drift = mkdtempSync(join(tmpdir(), 'rembric-drift-'));
    writeFileSync(
      join(drift, 'plugin.ts'),
      `// @rembric-plugin-version 0.0.0\nimport { readRembricSlug } from './lib/rembric-dotenv.mjs';\nexport const RembricPlugin = () => ({});\n`,
    );
    const { code, out } = run([], {
      home,
      script: OPENCODE_INSTALL,
      env: {
        PLUGIN_SRC: drift,
        BIN_SRC: join(REPO_ROOT, 'apps', 'plugin', 'bin'),
        MCP_BRIDGE_SRC: join(REPO_ROOT, 'apps', 'plugin', 'mcp-bridge'),
      },
    });
    rmSync(drift, { recursive: true, force: true });
    expect(code).toBe(1);
    expect(out).toContain('rewrite failed');
    expect(existsSync(join(home, '.config', 'opencode', 'plugins', 'rembric.ts'))).toBe(false);
  });

  it.each([
    { label: 'core', good: 'rembric-dotenv.mjs', drifted: 'rembric-plugin-core.mjs' },
    { label: 'dotenv', good: 'rembric-plugin-core.mjs', drifted: 'rembric-dotenv.mjs' },
  ])('a drifted $label import aborts even though the other rewrite succeeded', ({ drifted }) => {
    const drift = mkdtempSync(join(tmpdir(), 'rembric-drift-'));
    writeFileSync(
      join(drift, 'plugin.ts'),
      [
        '// @rembric-plugin-version 0.0.0',
        "import { readRembricSlug } from '../mcp-bridge/rembric-dotenv.mjs';",
        "import { createSessionProtocol } from '../bin/rembric-plugin-core.mjs';",
        'export const RembricPlugin = () => ({});',
        '',
      ]
        .join('\n')
        // Drift ONE import out of the sed pattern's reach; the other still rewrites.
        .replace(
          `../${drifted === 'rembric-dotenv.mjs' ? 'mcp-bridge' : 'bin'}/${drifted}`,
          `./lib/${drifted}`,
        ),
    );
    const { code, out } = run([], {
      home,
      script: OPENCODE_INSTALL,
      env: {
        PLUGIN_SRC: drift,
        BIN_SRC: join(REPO_ROOT, 'apps', 'plugin', 'bin'),
        MCP_BRIDGE_SRC: join(REPO_ROOT, 'apps', 'plugin', 'mcp-bridge'),
      },
    });
    rmSync(drift, { recursive: true, force: true });
    expect(code).toBe(1);
    expect(out).toContain('rewrite failed');
    expect(out).toContain(drifted);
    expect(existsSync(join(home, '.config', 'opencode', 'plugins', 'rembric.ts'))).toBe(false);
  });

  it('the installed plugin loads, resolving both shared modules from disk', async () => {
    const ins = run(['--agent=opencode', '--action=install'], { home });
    expect(ins.code).toBe(0);

    const installed = join(home, '.config', 'opencode', 'plugins', 'rembric.ts');
    const mod = (await import(installed)) as Record<string, unknown>;
    expect(typeof mod.RembricPlugin).toBe('function');
    expect(mod.default).toEqual({ id: 'rembric.lifecycle', setup: expect.any(Function) });
    expect(Object.keys(mod)).toEqual(['RembricPlugin', 'default']);
  });

  it('every file the install copies is removed by the uninstall', () => {
    const filesUnder = (root: string): string[] => {
      const walk = (rel: string): string[] =>
        readdirSync(join(root, rel), { withFileTypes: true }).flatMap((e) =>
          e.isDirectory() ? walk(join(rel, e.name)) : [join(rel, e.name)],
        );
      return walk('.').sort();
    };

    expect(run(['--agent=opencode', '--action=install'], { home }).code).toBe(0);
    const installedFiles = filesUnder(home);
    expect(installedFiles).toContain('.config/rembric/bin/rembric-plugin-core.mjs');

    expect(run(['--agent=opencode', '--action=uninstall'], { home }).code).toBe(0);
    expect(filesUnder(home)).toEqual([]);
  });
});

describe('--yes runs the marketplace command (stubbed client binary)', () => {
  it('--yes executes the claude update command when the claude binary is present', () => {
    const bin = fakeClientBinDir('claude');
    const { code, out } = run(['--agent=claude', '--action=update', '--yes'], {
      home,
      path: `${bin}:${CORE_PATH}`,
    });
    rmSync(bin, { recursive: true, force: true });
    expect(code).toBe(0);
    expect(out).toContain('claude plugin update rembric@rembric'); // still printed
    expect(out).toContain('RAN:claude plugin update rembric@rembric'); // and executed
  });

  it('-y is an alias for --yes', () => {
    const bin = fakeClientBinDir('claude');
    const { code, out } = run(['--agent=claude', '--action=update', '-y'], {
      home,
      path: `${bin}:${CORE_PATH}`,
    });
    rmSync(bin, { recursive: true, force: true });
    expect(code).toBe(0);
    expect(out).toContain('RAN:claude plugin update rembric@rembric');
  });

  it('--yes executes the codex upgrade command when the codex binary is present', () => {
    const bin = fakeClientBinDir('codex');
    const { code, out } = run(['--agent=codex', '--action=update', '--yes'], {
      home,
      path: `${bin}:${CORE_PATH}`,
    });
    rmSync(bin, { recursive: true, force: true });
    expect(code).toBe(0);
    expect(out).toContain('RAN:codex plugin marketplace upgrade rembric');
  });

  it('without --yes a headless run only prints, never executes', () => {
    const bin = fakeClientBinDir('claude');
    const { code, out } = run(['--agent=claude', '--action=update'], {
      home,
      path: `${bin}:${CORE_PATH}`,
    });
    rmSync(bin, { recursive: true, force: true });
    expect(code).toBe(0);
    expect(out).toContain('claude plugin update rembric@rembric'); // printed
    expect(out).not.toContain('RAN:claude'); // not executed
  });

  it('--yes with an absent client binary prints but executes nothing', () => {
    const { code, out } = run(['--agent=codex', '--action=update', '--yes'], {
      home,
      path: CORE_PATH, // no codex on PATH
    });
    expect(code).toBe(0);
    expect(out).toContain('codex plugin marketplace upgrade rembric'); // printed
    expect(out).not.toContain('RAN:codex'); // nothing ran
  });

  it('--help documents the --yes / -y flag', () => {
    const { out } = run(['--help']);
    expect(out).toContain('--yes');
    expect(out).toContain('-y');
  });
});

describe('root install.sh shim', () => {
  it('--help is identical to the plugin installer (pure forwarder)', () => {
    const root = run(['--help'], { script: ROOT_SHIM });
    const plugin = run(['--help']);
    expect(root.code).toBe(0);
    expect(root.out).toBe(plugin.out);
  });

  it('forwards flags: server install through the shim generates a token', () => {
    const { code, out } = run(['--server', '--action=install'], { script: ROOT_SHIM, cwd: dir });
    expect(code).toBe(0);
    expect(out).toContain('Generated admin token');
    const token = /^REMBRIC_ADMIN_TOKEN=([0-9a-f]{64})$/m.exec(
      readFileSync(join(dir, '.env'), 'utf8'),
    )?.[1];
    expect(token).toBeDefined();
  });
});

describe('agent CLI flags', () => {
  it('--status --json emits { server, agents } with valid shapes', () => {
    const { code, out } = run(['--status', '--json'], { home });
    expect(code).toBe(0);
    const data = JSON.parse(out);
    // server block: docker-observable state + image tag; latest_release is null
    // here (REMBRIC_UPDATE_CHECK=off → no GitHub call).
    expect(typeof data.server.state).toBe('string'); // running|exited|absent|unknown|…
    expect(data.server).toHaveProperty('version');
    expect(data.server.latest_release).toBeNull();
    // agents block: one object per agent.
    expect(data.agents.map((d) => d.agent)).toEqual(CLIENTS);
    for (const d of data.agents) {
      expect(typeof d.present).toBe('boolean');
      expect(d.available).toBe(PLUGIN_VERSION[d.agent]); // from .release-please-manifest.json at the ref
      expect(d.installed).toBeNull(); // clean HOME → nothing installed
      expect(typeof d.action).toBe('string');
    }
  });

  it('--status surfaces the latest server release from the releases endpoint', () => {
    const releases = join(dir, 'releases.json');
    writeFileSync(
      releases,
      JSON.stringify([
        { tag_name: 'opencode-plugin-v0.10.0' }, // ignored (not server-v)
        { tag_name: 'server-v0.29.1' },
        { tag_name: 'server-v0.30.0' }, // highest server release
      ]),
    );
    const { code, out } = run(['--status', '--json'], {
      home,
      env: { REMBRIC_UPDATE_CHECK: 'on', REMBRIC_RELEASES_URL: `file://${releases}` },
    });
    expect(code).toBe(0);
    expect(JSON.parse(out).server.latest_release).toBe('0.30.0');
  });

  it('--status (text) prints the SERVER line and the aligned agent table', () => {
    const { code, out } = run(['--status'], { home });
    expect(code).toBe(0);
    expect(out).toContain('SERVER');
    expect(out).toContain('AGENT');
    expect(out).toContain('PLUGIN'); // clarifies the rows are about the plugin, not the agent
    expect(out).toContain('claude');
    expect(out).toContain(PLUGIN_VERSION.claude);
  });

  it('--token sets the admin token verbatim on server install', () => {
    const { code } = run(['--server', '--action=install', '--token=tok_ABC_1234567890'], {
      cwd: dir,
    });
    expect(code).toBe(0);
    expect(readFileSync(join(dir, '.env'), 'utf8')).toMatch(
      /^REMBRIC_ADMIN_TOKEN=tok_ABC_1234567890$/m,
    );
  });

  it('--token shorter than 16 chars is refused (server would reject it)', () => {
    const { code, out } = run(['--server', '--action=install', '--token=short'], { cwd: dir });
    expect(code).not.toBe(0);
    expect(out).toContain('too short');
  });

  it('--port writes REMBRIC_PORT on server install', () => {
    const { code } = run(['--server', '--action=install', '--port=9001'], { cwd: dir });
    expect(code).toBe(0);
    expect(readFileSync(join(dir, '.env'), 'utf8')).toMatch(/^REMBRIC_PORT=9001$/m);
  });

  it('--port=abc is rejected at parse time, before any .env write', () => {
    const { code, out } = run(['--server', '--action=install', '--port=abc'], { cwd: dir });
    expect(code).toBe(2);
    expect(out).toContain('invalid --port=abc');
    expect(existsSync(join(dir, '.env'))).toBe(false);
  });

  it('--port out of range (70000) is rejected', () => {
    const { code, out } = run(['--server', '--action=install', '--port=70000'], { cwd: dir });
    expect(code).toBe(2);
    expect(out).toContain('invalid --port=70000');
    expect(existsSync(join(dir, '.env'))).toBe(false);
  });
});

describe('server bring-up (--up) with a stubbed docker', () => {
  // A fake `docker` on PATH lets us exercise the `up` path (and its failure
  // modes) headlessly — no daemon. It answers the three subcommands bring_up
  // uses: `compose version` (deps check), `compose pull`, `compose up`.
  // A fake `curl` answers the post-up /healthz poll ('healthy' → {ok:true},
  // 'down' → connection refused rc 7), and a no-op `sleep` collapses the
  // ~30s poll ceiling so the down case stays fast.
  function fakeDockerDir(
    composeUp: 'ok' | 'conflict',
    healthz: 'healthy' | 'down' = 'healthy',
    chown: 'ok' | 'fail' = 'ok',
    statUid?: string,
  ): string {
    const d = mkdtempSync(join(tmpdir(), 'rembric-fakebin-'));
    const up =
      composeUp === 'conflict'
        ? `echo 'Error response from daemon: Conflict. The container name "/rembric" is already in use by container "abc123".' >&2; exit 1`
        : `echo '[+] Running 2/2'; exit 0`;
    writeFileSync(
      join(d, 'docker'),
      `#!/bin/sh
case "$1 $2" in
  "compose version") echo "Docker Compose version v2.31.0"; exit 0 ;;
  "compose pull") echo FAKEPULL; exit 0 ;;
  "compose up") ${up} ;;
  *) exit 0 ;;
esac
`,
    );
    chmodSync(join(d, 'docker'), 0o755);
    const curl =
      healthz === 'healthy'
        ? `#!/bin/sh\nprintf '{"ok":true,"version":"9.9.9"}'\nexit 0\n`
        : `#!/bin/sh\nexit 7\n`;
    writeFileSync(join(d, 'curl'), curl);
    chmodSync(join(d, 'curl'), 0o755);
    writeFileSync(join(d, 'sleep'), '#!/bin/sh\nexit 0\n');
    chmodSync(join(d, 'sleep'), 0o755);
    // Stubbed so the test controls the chown-vs-chmod-fallback branch
    // independently of whether the test runner itself happens to be root.
    writeFileSync(join(d, 'chown'), chown === 'ok' ? '#!/bin/sh\nexit 0\n' : '#!/bin/sh\nexit 1\n');
    chmodSync(join(d, 'chown'), 0o755);
    // When provided, stub `stat` to report this owning uid — lets a test
    // simulate an already-10001-owned ./data without needing root to
    // actually chown it (CI runs non-root). Ignores args so both the GNU
    // `stat -c '%u'` and BSD `stat -f '%u'` forms return it.
    if (statUid !== undefined) {
      writeFileSync(join(d, 'stat'), `#!/bin/sh\necho ${statUid}\nexit 0\n`);
      chmodSync(join(d, 'stat'), 0o755);
    }
    return d;
  }

  it('success: brings the stack up and prints the dashboard URL + token', () => {
    const bin = fakeDockerDir('ok');
    const { code, out } = run(['--server', '--action=install', '--up'], {
      cwd: dir,
      path: `${bin}:${process.env.PATH}`,
    });
    rmSync(bin, { recursive: true, force: true });
    expect(code).toBe(0);
    expect(out).toContain('Up.');
    expect(out).toContain('127.0.0.1:8787/dashboard');
    expect(out).toMatch(/Log in with admin token: [0-9a-f]{64}/);
  });

  it('healthy /healthz: success banner reports the server version from the response', () => {
    const bin = fakeDockerDir('ok', 'healthy');
    const { code, out } = run(['--server', '--action=install', '--up'], {
      cwd: dir,
      path: `${bin}:${process.env.PATH}`,
    });
    rmSync(bin, { recursive: true, force: true });
    expect(code).toBe(0);
    expect(out).toContain('Up.');
    expect(out).toContain('9.9.9'); // version parsed from the stubbed healthz JSON
    expect(out).toContain('127.0.0.1:8787/dashboard');
  });

  it('unreachable /healthz: withholds the success banner and hints at docker compose logs', () => {
    const bin = fakeDockerDir('ok', 'down');
    const { code, out } = run(['--server', '--action=install', '--up'], {
      cwd: dir,
      path: `${bin}:${process.env.PATH}`,
    });
    rmSync(bin, { recursive: true, force: true });
    expect(code).toBe(0); // bounded failure, never a set -e abort
    expect(out).not.toContain('Up.');
    expect(out).not.toContain('/dashboard');
    expect(out).not.toContain('Log in with admin token');
    expect(out).toContain('docker compose logs');
  });

  it('--up honours --port (dashboard URL) and --token (login line)', () => {
    const bin = fakeDockerDir('ok');
    const { code, out } = run(
      ['--server', '--action=install', '--up', '--port=8799', '--token=tok_xyz_1234567890'],
      { cwd: dir, path: `${bin}:${process.env.PATH}` },
    );
    rmSync(bin, { recursive: true, force: true });
    expect(code).toBe(0);
    expect(out).toContain('127.0.0.1:8799/dashboard');
    expect(out).toContain('Log in with admin token: tok_xyz_1234567890');
  });

  it('REMBRIC_NO_PULL skips `docker compose pull` but still brings the stack up', () => {
    const bin = fakeDockerDir('ok');
    const { code, out } = run(['--server', '--action=install', '--up'], {
      cwd: dir,
      path: `${bin}:${process.env.PATH}`,
      env: { REMBRIC_NO_PULL: '1' },
    });
    rmSync(bin, { recursive: true, force: true });
    expect(code).toBe(0);
    expect(out).toContain('REMBRIC_NO_PULL'); // the "pull skipped" notice
    expect(out).not.toContain('FAKEPULL'); // pull was NOT invoked
    expect(out).toContain('Up.');
  });

  it('creates ./data and chowns it to uid 10001 when chown succeeds (no chmod fallback noise)', () => {
    const bin = fakeDockerDir('ok', 'healthy', 'ok');
    const { code, out } = run(['--server', '--action=install', '--up'], {
      cwd: dir,
      path: `${bin}:${process.env.PATH}`,
    });
    rmSync(bin, { recursive: true, force: true });
    expect(code).toBe(0);
    expect(existsSync(join(dir, 'data'))).toBe(true);
    expect(out).not.toContain('Could not chown');
  });

  it('falls back to a world-writable ./data and warns when a FRESH directory is not owned by uid 10001 and chown is not possible', () => {
    const bin = fakeDockerDir('ok', 'healthy', 'fail');
    const { code, out } = run(['--server', '--action=install', '--up'], {
      cwd: dir,
      path: `${bin}:${process.env.PATH}`,
    });
    rmSync(bin, { recursive: true, force: true });
    expect(code).toBe(0);
    const dataDir = join(dir, 'data');
    expect(existsSync(dataDir)).toBe(true);
    expect(statSync(dataDir).mode & 0o777).toBe(0o777);
    expect(out).toContain('Could not chown ./data to the container');
    expect(out).toContain('sudo chown -R 10001:10001 ./data');
  });

  it('does NOT relax an existing ./data already owned by uid 10001, even when chown is not possible', () => {
    // Regression guard: chown fails on every non-root invocation regardless
    // of whether ./data is already fine (e.g. fixed by a prior manual `sudo
    // chown`, or working transparently under Docker Desktop) — that failure
    // alone must never cause a re-run to downgrade an already-correct,
    // already-working install to world-writable. `stat` is stubbed to report
    // uid 10001 (we can't really chown to 10001 without root, and CI runs
    // non-root), so this exercises the installer's decision, not the syscall.
    const dataDir = join(dir, 'data');
    mkdirSync(dataDir, { mode: 0o750 });
    const bin = fakeDockerDir('ok', 'healthy', 'fail', '10001');
    const { code, out } = run(['--server', '--action=install', '--up'], {
      cwd: dir,
      path: `${bin}:${process.env.PATH}`,
    });
    rmSync(bin, { recursive: true, force: true });
    expect(code).toBe(0);
    expect(statSync(dataDir).mode & 0o777).toBe(0o750); // unchanged, NOT widened to 0777
    expect(out).not.toContain('Could not chown');
    expect(out).not.toContain('world-writable');
  });

  it('container-name conflict: friendly message, never clobbers, no raw daemon dump', () => {
    const bin = fakeDockerDir('conflict');
    const { code, out } = run(['--server', '--action=install', '--up'], {
      cwd: dir,
      path: `${bin}:${process.env.PATH}`,
    });
    rmSync(bin, { recursive: true, force: true });
    expect(code).toBe(0); // handled gracefully, not a hard crash / set -e abort
    expect(out).toContain('already running');
    expect(out).toContain('docker rm -f rembric');
    expect(out).not.toContain('Error response from daemon');
  });
});

describe('server status reporting (stubbed docker)', () => {
  // A fake `docker` answering the calls server_state/server_image_version make:
  // `docker ps` (daemon reachable), `docker container inspect …{{.State.Status}}`,
  // `docker inspect …{{.Config.Image}}`.
  function fakeServerDocker(scenario: 'running-old' | 'absent' | 'daemon-down'): string {
    const d = mkdtempSync(join(tmpdir(), 'rembric-fakesrv-'));
    let body: string;
    if (scenario === 'running-old') {
      body = `case "$1" in
  ps) exit 0 ;;
  container) echo running ;;
  inspect) echo "ghcr.io/susomejias/rembric:0.20.0" ;;
  *) exit 0 ;;
esac`;
    } else if (scenario === 'absent') {
      body = `case "$1" in
  ps) exit 0 ;;
  container) exit 1 ;;
  inspect) exit 1 ;;
  *) exit 0 ;;
esac`;
    } else {
      body = 'exit 1'; // daemon unreachable: every docker call fails
    }
    writeFileSync(join(d, 'docker'), `#!/bin/sh\n${body}\n`);
    chmodSync(join(d, 'docker'), 0o755);
    return d;
  }

  it('running container older than the latest release → update hint + versions', () => {
    const bin = fakeServerDocker('running-old');
    const releases = join(dir, 'rel.json');
    writeFileSync(releases, JSON.stringify([{ tag_name: 'server-v0.30.0' }]));
    const { code, out } = run(['--status'], {
      home,
      path: `${bin}:${process.env.PATH}`,
      env: { REMBRIC_UPDATE_CHECK: 'on', REMBRIC_RELEASES_URL: `file://${releases}` },
    });
    rmSync(bin, { recursive: true, force: true });
    expect(code).toBe(0);
    expect(out).toContain('0.20.0'); // running image tag
    expect(out).toContain('0.30.0'); // latest release
    expect(out).toContain('update available');
  });

  it('no rembric container → state absent (--json)', () => {
    const bin = fakeServerDocker('absent');
    const { code, out } = run(['--status', '--json'], { home, path: `${bin}:${process.env.PATH}` });
    rmSync(bin, { recursive: true, force: true });
    expect(code).toBe(0);
    const s = JSON.parse(out).server;
    expect(s.state).toBe('absent');
    expect(s.version).toBeNull();
  });

  it('docker daemon unreachable → state unknown, never a crash (--json)', () => {
    const bin = fakeServerDocker('daemon-down');
    const { code, out } = run(['--status', '--json'], { home, path: `${bin}:${process.env.PATH}` });
    rmSync(bin, { recursive: true, force: true });
    expect(code).toBe(0);
    expect(JSON.parse(out).server.state).toBe('unknown');
  });
});

describe('output degradation', () => {
  it('emits no ANSI escapes when stdout is not a terminal', () => {
    const { out } = run(['--help']);
    // eslint-disable-next-line no-control-regex
    expect(out).not.toMatch(/\[/);
  });

  it('shows the plain wordmark (not the block banner) without a terminal', () => {
    mkdirSync(join(dir, 'sub'), { recursive: true });
    const { out } = run(['--server', '--action=update'], { cwd: dir });
    expect(out).toContain('rembric installer');
    expect(out).not.toContain('██');
  });
});