import * as os from 'node:os';
import { asErrorMessage, emitFailure, printJson } from './shared-output.js';
import * as path from 'node:path';
import { Command } from 'commander';

import { COMMAND_REGISTRY } from '../core/completions/command-registry.js';

import {
  StoreError,
  doctorStores,
  listStores,
  prepareStoreSetup,
  prepareStoreCleanup,
  registerExistingStore,
  removeStore,
  resolveSetupGitEnabled,
  setupPreparedStore,
  unregisterStore,
  validateStoreId,
  type StoreCleanupResult,
  type StoreDiagnostic,
  type StoreDoctorResult,
  type StoreInfo,
  type StoreInspection,
  type StoreListResult,
  type StoreMutationResult,
  type SetupStoreInput,
} from '../core/store/index.js';
import { isInteractive } from '../utils/interactive.js';

interface StoreSetupOptions {
  path?: string;
  initGit?: boolean;
  json?: boolean;
  remote?: string;
}

interface StoreRegisterOptions {
  id?: string;
  yes?: boolean;
  json?: boolean;
}

interface StoreRemoveOptions {
  yes?: boolean;
  json?: boolean;
}

interface StoreJsonOptions {
  json?: boolean;
}

interface ResolvedStoreSetupInput extends SetupStoreInput {
  id: string;
}

interface StoreOutput {
  id: string;
  root: string;
  metadata_path?: string;
}

interface StoreMutationOutput {
  store: StoreOutput | null;
  registry: {
    path: string;
    registered: boolean;
    already_registered: boolean;
  } | null;
  git: {
    is_repository: boolean;
    initialized: boolean;
    committed: boolean;
  } | null;
  created_files: string[];
  status: StoreDiagnostic[];
}

interface StoreCleanupOutput {
  store: StoreOutput | null;
  registry: {
    path: string;
    removed: boolean;
  } | null;
  files: {
    deleted: boolean;
    deleted_path: string | null;
    left_on_disk: string | null;
  } | null;
  status: StoreDiagnostic[];
}

interface StoreListOutput {
  stores: StoreOutput[];
  status: StoreDiagnostic[];
}

type OpenSpecRootOutput = Omit<StoreInspection['openspecRoot'], 'diagnostics'> & {
  status: StoreDiagnostic[];
};

interface StoreDoctorStoreOutput extends StoreOutput {
  openspec_root: OpenSpecRootOutput;
  metadata: StoreInspection['metadata'];
  git: {
    is_repository: boolean | null;
    has_commits: boolean | null;
    has_uncommitted_changes: boolean | null;
    has_remote: boolean | null;
    origin_url: string | null;
  };
  status: StoreDiagnostic[];
}

interface StoreDoctorOutput {
  stores: StoreDoctorStoreOutput[];
  status: StoreDiagnostic[];
}

function toStoreOutput(store: StoreInfo): StoreOutput {
  return {
    id: store.id,
    root: store.root,
    ...(store.metadataPath ? { metadata_path: store.metadataPath } : {}),
  };
}

function toMutationOutput(result: StoreMutationResult): StoreMutationOutput {
  return {
    store: toStoreOutput(result.store),
    registry: {
      path: result.registryCommit.path,
      registered: result.registryCommit.registered,
      already_registered: result.registryCommit.alreadyRegistered,
    },
    git: {
      is_repository: result.git.isRepository,
      initialized: result.git.initialized,
      committed: result.git.committed,
    },
    created_files: result.createdArtifacts,
    status: result.diagnostics,
  };
}

function toCleanupOutput(result: StoreCleanupResult): StoreCleanupOutput {
  return {
    store: toStoreOutput(result.store),
    registry: {
      path: result.registryCommit.path,
      removed: result.registryCommit.removed,
    },
    files: {
      deleted: result.files.deleted,
      deleted_path: result.files.deletedPath ?? null,
      left_on_disk: result.files.leftOnDisk ?? null,
    },
    status: result.diagnostics,
  };
}

function toListOutput(result: StoreListResult): StoreListOutput {
  return {
    stores: result.stores.map(toStoreOutput),
    status: [],
  };
}

function toOpenSpecRootOutput(root: StoreInspection['openspecRoot']): OpenSpecRootOutput {
  return {
    present: root.present,
    config: root.config,
    specs: root.specs,
    changes: root.changes,
    archive: root.archive,
    healthy: root.healthy,
    status: root.diagnostics,
  };
}

function toDoctorStoreOutput(store: StoreInspection): StoreDoctorStoreOutput {
  return {
    ...toStoreOutput(store),
    openspec_root: toOpenSpecRootOutput(store.openspecRoot),
    metadata: store.metadata,
    git: {
      is_repository: store.git.isRepository,
      has_commits: store.git.hasCommits,
      has_uncommitted_changes: store.git.hasUncommittedChanges,
      has_remote: store.git.hasRemote,
      origin_url: store.git.originUrl,
    },
    status: store.diagnostics,
  };
}

function toDoctorOutput(result: StoreDoctorResult): StoreDoctorOutput {
  return {
    stores: result.stores.map(toDoctorStoreOutput),
    status: result.diagnostics,
  };
}

function formatPathForHuman(targetPath: string): string {
  const home = os.homedir();
  const normalizedHome = path.resolve(home);
  const normalizedTarget = path.resolve(targetPath);

  if (normalizedTarget === normalizedHome) return '~';
  if (normalizedTarget.startsWith(`${normalizedHome}${path.sep}`)) {
    return `~${path.sep}${path.relative(normalizedHome, normalizedTarget)}`;
  }

  return targetPath;
}

async function promptStoreId(): Promise<string> {
  const { input } = await import('@inquirer/prompts');

  return input({
    message: 'Store name',
    required: true,
    validate(value: string) {
      try {
        validateStoreId(value);
        return true;
      } catch (error) {
        return asErrorMessage(error);
      }
    },
  });
}

async function promptStorePath(id: string): Promise<string> {
  const { input } = await import('@inquirer/prompts');
  // Suggest a visible, user-owned location — never the managed XDG data dir.
  const defaultPath = ['~', 'openspec', id].join('/');

  return input({
    message: 'Where should this store live?',
    default: defaultPath,
    prefill: 'editable',
    required: true,
  });
}

async function resolveSetupInput(
  id: string | undefined,
  options: StoreSetupOptions
): Promise<ResolvedStoreSetupInput> {
  const interactive = !options.json && isInteractive();

  if (!id && !interactive) {
    throw new StoreError(
      'Pass a store name.',
      'store_setup_id_required',
      {
        target: 'store.id',
        fix: 'openspec store setup <id> --path ~/openspec/<id> --json',
      }
    );
  }

  if (options.path === undefined && !interactive) {
    throw new StoreError(
      'Pass --path with the folder where this store should live.',
      'store_setup_path_required',
      {
        target: 'store.root',
        fix: `openspec store setup ${id ?? '<id>'} --path ~/openspec/${id ?? '<id>'}`,
      }
    );
  }

  const resolvedId = id ? validateStoreId(id) : await promptStoreId();
  const promptedPath = options.path === undefined
    ? await promptStorePath(resolvedId)
    : undefined;

  return {
    id: resolvedId,
    path: options.path ?? promptedPath,
    ...(options.remote !== undefined ? { remote: options.remote } : {}),
  };
}

async function prepareSetupInput(
  input: ResolvedStoreSetupInput,
  _options: StoreSetupOptions
) {
  return prepareStoreSetup(input);
}

async function confirmSetup(
  prepared: Awaited<ReturnType<typeof prepareStoreSetup>>,
  initGit: boolean
): Promise<void> {
  const { confirm } = await import('@inquirer/prompts');

  console.log('');
  console.log('OpenSpec will create:');
  console.log('');
  console.log(`  Store: ${prepared.id}`);
  console.log(`  Location: ${formatPathForHuman(prepared.root)}`);
  console.log(`  Git: ${initGit ? 'initialized' : 'not initialized'}`);
  console.log('');

  const confirmed = await confirm({
    message: 'Create this store?',
    default: true,
  });

  if (!confirmed) {
    throw new StoreError(
      'Store setup cancelled.',
      'store_setup_cancelled',
      {
        target: 'store.root',
        fix: 'Rerun setup when you are ready.',
      }
    );
  }
}

async function confirmRemove(id: string, root: string, options: StoreRemoveOptions): Promise<void> {
  if (options.yes) return;

  if (options.json || !isInteractive()) {
    throw new StoreError(
      'Pass --yes to delete store files non-interactively.',
      'store_remove_confirmation_required',
      {
        target: 'store.root',
        fix: `openspec store remove ${id} --yes`,
      }
    );
  }

  const { confirm } = await import('@inquirer/prompts');
  const confirmed = await confirm({
    message: `Delete local store folder ${formatPathForHuman(root)}?`,
    default: false,
  });

  if (!confirmed) {
    throw new StoreError(
      'Store remove cancelled.',
      'store_remove_cancelled',
      {
        target: 'store.root',
        fix: 'Run "openspec store unregister <id>" if you only want to forget the local registration.',
      }
    );
  }
}

function isRegisterIdentityConfirmationError(error: unknown): boolean {
  return (
    error instanceof StoreError &&
    error.diagnostic.code === 'store_register_identity_confirmation_required'
  );
}

async function confirmRegisterConversion(error: unknown): Promise<void> {
  const { confirm } = await import('@inquirer/prompts');
  const confirmed = await confirm({
    message: asErrorMessage(error),
    default: false,
  });

  if (!confirmed) {
    throw new StoreError(
      'Store register cancelled.',
      'store_register_cancelled',
      {
        target: 'store.metadata',
        fix: 'Rerun register when you are ready to create store identity metadata.',
      }
    );
  }
}

function printMutationHuman(
  title: string,
  payload: StoreMutationOutput,
  remotes?: { canonical?: string; observed?: string }
): void {
  if (!payload.store || !payload.registry || !payload.git) {
    return;
  }

  console.log(`${title}: ${payload.store.id}`);
  console.log(`Location: ${formatPathForHuman(payload.store.root)}`);
  console.log('OpenSpec root: ready');
  console.log(`Registry: ${payload.registry.already_registered ? 'already registered' : 'registered'}`);
  for (const status of payload.status) {
    console.log(`${status.severity === 'error' ? 'Issue' : 'Note'}: ${status.message}`);
  }
  console.log('');
  console.log('Next: run normal OpenSpec commands against this store, for example:');
  console.log(`  openspec new change <change-id> --store ${payload.store.id}`);
  if (payload.git.is_repository) {
    const shareRemote = remotes?.canonical ?? remotes?.observed;
    console.log(
      shareRemote
        ? `Share it: teammates clone ${shareRemote} and run openspec store register <path>.`
        : 'Share this store by committing and pushing it like any Git repo.'
    );
  }
}

function printCleanupHuman(title: string, payload: StoreCleanupOutput): void {
  if (!payload.store || !payload.registry || !payload.files) {
    return;
  }

  console.log(`${title}: ${payload.store.id}`);

  if (payload.files.deleted_path) {
    console.log(`Deleted: ${formatPathForHuman(payload.files.deleted_path)}`);
  } else if (payload.files.left_on_disk) {
    console.log(`Files kept at: ${formatPathForHuman(payload.files.left_on_disk)}`);
  } else if (!payload.files.deleted) {
    console.log(`Files were already missing: ${formatPathForHuman(payload.store.root)}`);
  }

  for (const status of payload.status) {
    console.log(`${status.severity === 'error' ? 'Issue' : 'Note'}: ${status.message}`);
  }
}

function printListHuman(payload: StoreListOutput): void {
  if (payload.stores.length === 0) {
    console.log('No stores registered.');
    console.log('');
    console.log('Next:');
    console.log('  openspec store setup team-context --path ~/openspec/team-context');
    console.log('  openspec store register /path/to/store');
    return;
  }

  console.log(`OpenSpec stores (${payload.stores.length})`);
  console.log('');
  console.log(`${'ID'.padEnd(16)}Location`);
  for (const store of payload.stores) {
    console.log(`${store.id.padEnd(16)}${store.root}`);
  }
}

function formatMetadataHuman(store: StoreDoctorOutput['stores'][number]): string {
  if (store.metadata.valid) return 'ok';
  if (store.metadata.present === false) return 'missing';
  if (store.metadata.present === null) return 'unknown';
  return 'invalid';
}

function formatDoctorGitHuman(store: StoreDoctorOutput['stores'][number]): string {
  if (store.git.is_repository === null) return 'unknown';
  if (!store.git.is_repository) return 'not detected';

  const fact = (value: boolean | null, yes: string, no: string): string =>
    value === null ? 'unknown' : value ? yes : no;

  return `repository detected (commits: ${fact(store.git.has_commits, 'yes', 'none')}, uncommitted changes: ${fact(store.git.has_uncommitted_changes, 'yes', 'no')}, remote: ${fact(store.git.has_remote, 'yes', 'none')})`;
}

function formatOpenSpecRootHuman(store: StoreDoctorOutput['stores'][number]): string {
  if (store.openspec_root.healthy) return 'ok';
  if (store.openspec_root.present === false) return 'missing';
  if (store.openspec_root.present === null) return 'unknown';
  return 'incomplete';
}

function printDoctorHuman(payload: StoreDoctorOutput): void {
  if (payload.stores.length === 0) {
    console.log('No stores registered.');
    return;
  }

  console.log('Store doctor');
  for (const store of payload.stores) {
    console.log('');
    console.log(store.id);
    console.log(`  Location: ${store.root}`);
    console.log(`  OpenSpec root: ${formatOpenSpecRootHuman(store)}`);
    console.log(`  Metadata: ${formatMetadataHuman(store)}`);
    const remoteLine = store.metadata.remote ?? store.git.origin_url;
    if (remoteLine) {
      console.log(`  Remote: ${remoteLine}`);
    }
    console.log(`  Git: ${formatDoctorGitHuman(store)}`);

    if (store.status.length === 0) {
      console.log('  Issues: none');
      continue;
    }

    console.log('  Issues:');
    for (const status of store.status) {
      console.log(`    - ${status.message}`);
      if (status.fix) {
        console.log(`      Fix: ${status.fix}`);
      }
    }
  }
}

class StoreCommand {
  async setup(id: string | undefined, options: StoreSetupOptions = {}): Promise<void> {
    try {
      const setupInput = await resolveSetupInput(id, options);
      const prepared = await prepareSetupInput(setupInput, options);
      const initGit = resolveSetupGitEnabled(prepared, options.initGit);
      if (!options.json && isInteractive()) {
        await confirmSetup(prepared, initGit);
      }
      const result = await setupPreparedStore(prepared, { initGit });
      const payload = toMutationOutput(result);

      if (options.json) {
        printJson(payload);
        return;
      }

      printMutationHuman('Store ready', payload, result.remotes);
    } catch (error) {
      this.handleFailure(
        options.json,
        { store: null, registry: null, git: null, created_files: [], status: [] },
        error
      );
    }
  }

  async register(inputPath: string | undefined, options: StoreRegisterOptions = {}): Promise<void> {
    try {
      let result: StoreMutationResult;
      try {
        result = await registerExistingStore({
          path: inputPath,
          id: options.id,
          allowCreateIdentity: options.yes,
        });
      } catch (error) {
        if (!isRegisterIdentityConfirmationError(error) || options.json || !isInteractive()) {
          throw error;
        }

        await confirmRegisterConversion(error);
        result = await registerExistingStore({
          path: inputPath,
          id: options.id,
          allowCreateIdentity: true,
        });
      }

      const payload = toMutationOutput(result);

      if (options.json) {
        printJson(payload);
        return;
      }

      printMutationHuman('Store registered', payload, result.remotes);
    } catch (error) {
      this.handleFailure(
        options.json,
        { store: null, registry: null, git: null, created_files: [], status: [] },
        error
      );
    }
  }

  async unregister(id: string, options: StoreJsonOptions = {}): Promise<void> {
    try {
      const payload = toCleanupOutput(await unregisterStore({ id }));

      if (options.json) {
        printJson(payload);
        return;
      }

      printCleanupHuman('Unregistered store', payload);
    } catch (error) {
      this.handleFailure(
        options.json,
        { store: null, registry: null, files: null, status: [] },
        error
      );
    }
  }

  async remove(id: string, options: StoreRemoveOptions = {}): Promise<void> {
    try {
      const target = await prepareStoreCleanup({ id });
      await confirmRemove(target.id, target.root, options);
      const payload = toCleanupOutput(await removeStore(target));

      if (options.json) {
        printJson(payload);
        return;
      }

      printCleanupHuman('Removed store', payload);
    } catch (error) {
      this.handleFailure(
        options.json,
        { store: null, registry: null, files: null, status: [] },
        error
      );
    }
  }

  async list(options: StoreJsonOptions = {}): Promise<void> {
    try {
      const payload = toListOutput(await listStores());

      if (options.json) {
        printJson(payload);
        return;
      }

      printListHuman(payload);
    } catch (error) {
      this.handleFailure(options.json, { stores: [], status: [] }, error);
    }
  }

  async doctor(id: string | undefined, options: StoreJsonOptions = {}): Promise<void> {
    try {
      const payload = toDoctorOutput(await doctorStores(id));

      if (options.json) {
        printJson(payload);
        return;
      }

      printDoctorHuman(payload);
    } catch (error) {
      this.handleFailure(options.json, { stores: [], status: [] }, error);
    }
  }

  private handleFailure<T extends { status: StoreDiagnostic[] }>(
    json: boolean | undefined,
    payload: T,
    error: unknown
  ): void {
    emitFailure(json, payload, error, 'store_error');
  }
}

export function registerStoreCommand(program: Command): void {
  const storeCommand = new StoreCommand();
  // One source for the locked group one-liner: the completions registry
  // entry, which shell completion scripts also consume.
  const storeGroupDescription =
    COMMAND_REGISTRY.find((entry) => entry.name === 'store')?.description ??
    'Create and manage stores - standalone OpenSpec repos you register on this machine';
  const store = program.command('store').description(storeGroupDescription);

  store
    .command('setup [id]')
    .description('Create and register a local store')
    .option('--path <path>', 'Folder where the store should live (for example ~/openspec/<id>)')
    .option('--init-git', 'Initialize a Git repository with an initial commit (default)')
    .option('--no-init-git', 'Skip every Git action: no init, no initial commit')
    .option('--remote <url>', 'Canonical clone source recorded in store.yaml')
    .option('--json', 'Output as JSON')
    .action(async (id: string | undefined, options: StoreSetupOptions) => {
      await storeCommand.setup(id, options);
    });

  store
    .command('register [path]')
    .description('Register an existing local store')
    .option('--id <id>', 'Store id; defaults to metadata or folder name')
    .option('--yes', 'Confirm creating store identity metadata for a healthy OpenSpec root')
    .option('--json', 'Output as JSON')
    .action(async (inputPath: string | undefined, options: StoreRegisterOptions) => {
      await storeCommand.register(inputPath, options);
    });

  store
    .command('unregister <id>')
    .description('Forget a local store registration without deleting files')
    .option('--json', 'Output as JSON')
    .action(async (id: string, options: StoreJsonOptions) => {
      await storeCommand.unregister(id, options);
    });

  store
    .command('remove <id>')
    .description('Forget a local store registration and delete its local folder')
    .option('--yes', 'Confirm local store folder deletion')
    .option('--json', 'Output as JSON')
    .action(async (id: string, options: StoreRemoveOptions) => {
      await storeCommand.remove(id, options);
    });

  store
    .command('list')
    .alias('ls')
    .description('List locally registered stores')
    .option('--json', 'Output as JSON')
    .action(async (options: StoreJsonOptions) => {
      await storeCommand.list(options);
    });

  store
    .command('doctor [id]')
    .description('Check local store registration and metadata')
    .option('--json', 'Output as JSON')
    .action(async (id: string | undefined, options: StoreJsonOptions) => {
      await storeCommand.doctor(id, options);
    });

  const lifecycleRedirects = new Set(
    COMMAND_REGISTRY.filter(
      (entry) =>
        entry.flags.some((flag) => flag.name === 'store') ||
        (entry.subcommands ?? []).some((subcommand) =>
          subcommand.flags.some((flag) => flag.name === 'store')
        )
    ).map((entry) => entry.name)
  );
  const storeSubcommandsLine = store.commands
    .map((subcommand) => {
      const aliases = subcommand.aliases();
      return aliases.length > 0 ? `${subcommand.name()} (${aliases.join(', ')})` : subcommand.name();
    })
    .join(', ');
  // One group action owns missing AND unknown subcommands. Known
  // subcommands dispatch above; everything else — including a bare
  // `store --json` with no operand — lands here, so the handler owns the
  // entire message and exit path (same text for human and --json). The
  // permissive flags route unknown operands/options here instead of
  // letting Commander emit a raw error before the action runs. We detect
  // `--json` in the residual args rather than declaring a group option,
  // which would otherwise shadow each subcommand's own `--json` flag.
  store.allowExcessArguments(true);
  store.allowUnknownOption(true);
  store.action(() => {
    const operands = store.args;
    // Flag values are indistinguishable from operands without a full
    // parse, so the verbatim echo only applies to plain-operand input.
    const attempted = operands.filter((operand) => !operand.startsWith('-'));
    const hasFlagLikeToken = operands.some((operand) => operand.startsWith('-'));
    // The agent contract: --json failures emit one JSON document.
    if (operands.includes('--json')) {
      const message =
        attempted.length > 0
          ? `Unknown command '${attempted[0]}' for 'openspec store'. Store subcommands: ${storeSubcommandsLine}.`
          : `Missing subcommand for 'openspec store'. Store subcommands: ${storeSubcommandsLine}.`;
      printJson({
        status: [
          {
            severity: 'error',
            code: 'unknown_store_subcommand',
            message,
            fix: 'Run a store subcommand, or use the lifecycle command with --store <id>.',
          },
        ],
      });
      process.exitCode = 1;
      return;
    }
    let example = 'openspec new change <change-id> --store <id>';
    if (!hasFlagLikeToken && attempted.length > 0 && lifecycleRedirects.has(attempted[0])) {
      if (attempted[0] === 'new') {
        const changeId = attempted[1] === 'change' && attempted[2] ? attempted[2] : '<change-id>';
        example = `openspec new change ${changeId} --store <id>`;
      } else {
        example = `openspec ${attempted.join(' ')} --store <id>`;
      }
    }
    console.error(
      attempted.length > 0
        ? `Error: unknown command '${attempted[0]}' for 'openspec store'.`
        : "Error: missing subcommand for 'openspec store'."
    );
    console.error(
      `Store subcommands manage store registration: ${storeSubcommandsLine}.`
    );
    console.error(
      'To create or work on a change in a store, use the normal command with --store, for example:'
    );
    console.error(`  ${example}`);
    process.exitCode = 1;
  });
}


// caveman — opencode plugin
//
// Provides dynamic caveman mode tracking for opencode:
// - Writes the mode flag on each session start (via the `event` dispatcher)
// - Parses user messages for /caveman commands and natural-language toggles
// - Injects per-turn reinforcement into the system prompt
//
// Bun ESM module; loads the existing security-hardened helpers from
// caveman-config.js via createRequire so the symlink-safe flag-write code
// lives in one place.
//
// Layout once installed:
//   ~/.config/opencode/plugins/caveman/
//   ├── package.json
//   ├── plugin.js              ← this file
//   └── caveman-config.cjs     ← copied sibling of src/hooks/caveman-config.js
//
// The always-on caveman ruleset is provided separately via
// ~/.config/opencode/AGENTS.md (Tier-3 base). This plugin handles dynamic
// state only: flag writes, slash-command parsing, natural-language
// activation, and per-turn reinforcement.
//
// Hook mapping (opencode >= 1.15.x):
//   - event (event.type === 'session.created'): session-init flag write,
//     re-fires per session rather than once per plugin-process load
//   - chat.message: intercept user prompts for mode changes
//   - experimental.chat.system.transform: inject reinforcement per-turn
//
// Note: opencode does NOT support 'session.created' or 'tui.prompt.append'
// as named plugin-hook keys. 'session.created' is an event *type* dispatched
// through the single `event` handler; the old direct-key handlers were
// silently ignored. See:
// https://github.com/JuliusBrussee/caveman/issues/418
// https://github.com/JuliusBrussee/caveman/issues/421

import { createRequire } from 'node:module';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import { existsSync, unlinkSync, readFileSync } from 'node:fs';
import os from 'node:os';
import path from 'node:path';

const here = dirname(fileURLToPath(import.meta.url));

// When installed: caveman-config.cjs sits next to plugin.js (copied by
// bin/install.js, renamed to .cjs because this directory's package.json
// declares "type": "module" — bare .js would be loaded as ESM). When loaded
// from the source tree (tests, dev): fall back to the canonical
// src/hooks/caveman-config.js, which lives in a directory whose own
// package.json pins "type": "commonjs". One source of truth either way.
//
// Loaded by evaluating the file as CommonJS by hand, NOT via the module
// loader: opencode runs plugins inside a compiled Bun binary where
// require() of on-disk files is rejected ("require() async module is
// unsupported") and await import() of a CJS file yields an empty namespace —
// both silently break the plugin (#418 follow-up). createRequire() still
// resolves node BUILT-INS fine in the compiled binary, which is all
// caveman-config needs (fs/path/os).
function loadConfig() {
  const installed = join(here, 'caveman-config.cjs');
  const dev = join(here, '..', '..', 'hooks', 'caveman-config.js');
  const target = existsSync(installed) ? installed : dev;
  const code = readFileSync(target, 'utf8').replace(/^#![^\n]*\n/, '');
  const mod = { exports: {} };
  new Function('module', 'exports', 'require', '__dirname', '__filename', code)(
    mod, mod.exports, createRequire(import.meta.url), dirname(target), target
  );
  return mod.exports;
}
const config = loadConfig();

const { getDefaultMode, safeWriteFlag, readFlag, VALID_MODES } = config;

// Modes handled by independent skills — not selectable via /caveman <arg>.
const INDEPENDENT_MODES = new Set(['commit', 'review', 'compress']);

// opencode resolves its config dir from $XDG_CONFIG_HOME, else ~/.config/opencode
// on every platform — including Windows, where it uses %USERPROFILE%\.config\opencode
// (NOT %APPDATA%). os.homedir() is %USERPROFILE% on win32, so the default branch
// is already correct cross-platform.
function opencodeConfigDir() {
  if (process.env.XDG_CONFIG_HOME) {
    return path.join(process.env.XDG_CONFIG_HOME, 'opencode');
  }
  return path.join(os.homedir(), '.config', 'opencode');
}

const flagPath = path.join(opencodeConfigDir(), '.caveman-active');

function reinforcementLine(mode) {
  return 'CAVEMAN MODE ACTIVE (' + mode + '). ' +
    'Drop articles/filler/pleasantries/hedging. Fragments OK. ' +
    'Code/commits/security: write normal.';
}

// Parse a prompt for slash-command activation or natural-language toggles.
// Returns the new mode to write, the literal string 'off' to deactivate, or
// null when the prompt doesn't change state. Mirrors caveman-mode-tracker.js.
function parseModeChange(promptRaw) {
  let prompt = (promptRaw || '').trim();
  // opencode's non-interactive `run` path delivers the message wrapped in
  // literal quote characters ("/caveman ultra"\n) — unwrap symmetric quotes
  // so the slash-command branch still matches.
  const wrapped = /^(["'`])([\s\S]*)\1$/.exec(prompt);
  if (wrapped) prompt = wrapped[2].trim();
  prompt = prompt.toLowerCase();
  if (!prompt) return null;

  // Natural-language deactivation — checked before activation so "stop talking
  // like caveman" doesn't trip the activation regex.
  if (/\b(stop|disable|deactivate|turn off)\b.*\bcaveman\b/i.test(prompt) ||
      /\bcaveman\b.*\b(stop|disable|deactivate|turn off)\b/i.test(prompt) ||
      /\bnormal mode\b/i.test(prompt)) {
    return 'off';
  }

  // Expanded /caveman command template. opencode replaces a typed
  // "/caveman <level>" with the command file's body ("Activate caveman
  // mode: $ARGUMENTS ...") before chat.message fires, so the literal
  // slash-command branch below never sees it — recover the level argument
  // from the template's first line instead. Must run before the generic
  // NL-activation match, which would swallow it and drop the level.
  const tpl = /^activate caveman mode:[ \t]*(\S*)/.exec(prompt);
  if (tpl) {
    const arg = tpl[1] || '';
    if (arg === 'off' || arg === 'stop' || arg === 'disable') return 'off';
    if (arg === 'wenyan-full') return 'wenyan';
    if (VALID_MODES.includes(arg) && !INDEPENDENT_MODES.has(arg)) return arg;
    return getDefaultMode();
  }

  // Natural-language activation
  if (/\b(activate|enable|turn on|start|talk like)\b.*\bcaveman\b/i.test(prompt) ||
      /\bcaveman\b.*\b(mode|activate|enable|turn on|start)\b/i.test(prompt)) {
    const mode = getDefaultMode();
    return mode === 'off' ? null : mode;
  }

  // Slash-command parsing — opencode also expands command files, but if the
  // user types the literal slash command we still want to flip the flag.
  if (prompt.startsWith('/caveman')) {
    const parts = prompt.split(/\s+/);
    const cmd = parts[0];
    const arg = parts[1] || '';

    if (cmd === '/caveman-commit')   return 'commit';
    if (cmd === '/caveman-review')   return 'review';
    if (cmd === '/caveman-compress') return 'compress';

    if (cmd === '/caveman') {
      if (!arg)                                     return getDefaultMode();
      if (arg === 'off' || arg === 'stop' || arg === 'disable') return 'off';
      if (arg === 'wenyan-full')                    return 'wenyan';
      if (VALID_MODES.includes(arg) && !INDEPENDENT_MODES.has(arg)) return arg;
      // Unknown arg — leave flag alone. No silent overwrite.
      return null;
    }
  }

  return null;
}

function applyModeChange(mode) {
  if (!mode) return;
  if (mode === 'off') {
    try { if (existsSync(flagPath)) unlinkSync(flagPath); } catch (e) {}
    return;
  }
  safeWriteFlag(flagPath, mode);
}

// Session-start logic — extracted so the `event` dispatcher (opencode >= 1.15)
// drives one shared implementation. Re-fires on every `session.created` event,
// so a new session in a long-lived plugin process re-asserts the flag.
function handleSessionCreated() {
  const mode = getDefaultMode();
  if (mode === 'off') {
    try { if (existsSync(flagPath)) unlinkSync(flagPath); } catch (e) {}
    return;
  }
  safeWriteFlag(flagPath, mode);
}

export const CavemanPlugin = async (_ctx) => {
  // Assert the flag at plugin load as well: in one-shot `opencode run` the
  // first session.created publishes before plugin event dispatch is wired,
  // so the event handler alone misses it. The factory-time write covers that
  // race; the event handler re-asserts on every later session in long-lived
  // TUI processes.
  handleSessionCreated();

  return {
  // opencode dispatches session/lifecycle events through a single `event`
  // handler keyed on event.type; the older direct top-level
  // 'session.created' key is silently ignored. Routing session-init through
  // here means the flag is rewritten on every new session, not just once when
  // the plugin module loads. See https://opencode.ai/docs/plugins#events.
  event: async ({ event } = {}) => {
    if (event && event.type === 'session.created') handleSessionCreated();
  },

  // Intercept user messages to detect /caveman commands and natural-language
  // mode toggles. opencode fires chat.message with (input, output) where
  // output.parts is the array of message parts; text parts carry .text.
  // Return value is ignored — state changes happen via the flag file.
  'chat.message': async (_input, output) => {
    if (!output || !output.parts) return;
    for (const part of output.parts) {
      if (part && part.type === 'text' && part.text) {
        const change = parseModeChange(part.text);
        if (change) applyModeChange(change);
      }
    }
  },

  // Inject the reinforcement line into the system prompt when caveman is
  // active. opencode calls this before every LLM request and expects the hook
  // to mutate output.system (a string[]); the return value is discarded.
  'experimental.chat.system.transform': async (_input, output) => {
    if (!output || !Array.isArray(output.system)) return;
    const active = readFlag(flagPath);
    if (active && !INDEPENDENT_MODES.has(active)) {
      output.system.push(reinforcementLine(active));
    }
  },
  };
};

export default CavemanPlugin;


// End-to-end: real fresh install against an isolated $CLAUDE_CONFIG_DIR.
//
// Unlike e2e.dryrun.test.mjs (which only verifies the planned output), this
// suite actually writes hooks, merges settings.json, and asserts the on-disk
// state. It catches regressions in the install pipeline that a dry-run can't
// see — missing hook files, malformed settings entries, broken statusline
// wiring, idempotency bugs, JSONC-tolerance regressions (#249-class).
//
// Limitations:
//   - The Claude Code provider only triggers when `claude` is on PATH. Tests
//     that depend on it skip cleanly when missing (most CI runners and dev
//     boxes won't have it). Tests that don't need `claude` (idempotence,
//     JSONC tolerance) always run.
//   - The installer's uninstall path also calls `claude plugin uninstall` and
//     `gemini extensions uninstall` against whatever binary is on PATH. We
//     strip those out of PATH in the uninstall test so the user's real
//     plugin/extension state is never touched.
//   - The plugin install step makes a network call (clones the marketplace).
//     We tolerate failure there — only the hook/settings assertions matter.
//     Since #392/#393, default install wires standalone hooks ONLY when the
//     plugin install fails (to avoid double-firing), so these tests pass
//     --with-hooks to force the standalone wiring path deterministically.
//   - Each fresh-install case spawns a real `claude plugin install` (~300MB
//     of git clone). Run the test runner with `--test-concurrency=1` to
//     avoid OOM on memory-constrained CI runners.

import { test } from 'node:test';
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { createRequire } from 'node:module';

const HERE = path.dirname(fileURLToPath(import.meta.url));
const REPO_ROOT = path.resolve(HERE, '..', '..');
const INSTALLER = path.join(REPO_ROOT, 'bin', 'install.js');
const requireCjs = createRequire(import.meta.url);
const SETTINGS = requireCjs(path.join(REPO_ROOT, 'bin', 'lib', 'settings.js'));

function freshTmpDir() {
  return fs.mkdtempSync(path.join(os.tmpdir(), 'caveman-freshinstall-'));
}

function pathWithout(binNames) {
  // Walk every PATH entry; drop any that contains one of the named binaries.
  // Cross-platform: works on macOS/Linux (`:` sep) and Windows (`;` sep).
  const sep = process.platform === 'win32' ? ';' : ':';
  const exts = process.platform === 'win32' ? ['.exe', '.cmd', '.bat', ''] : [''];
  const want = new Set(binNames);
  return (process.env.PATH || '')
    .split(sep)
    .filter(dir => {
      if (!dir) return false;
      for (const b of want) {
        for (const ext of exts) {
          try { if (fs.existsSync(path.join(dir, b + ext))) return false; } catch (_) {}
        }
      }
      return true;
    })
    .join(sep);
}

function runInstaller(args, configDir, extraEnv = {}) {
  return spawnSync('node', [INSTALLER, ...args, '--config-dir', configDir, '--non-interactive', '--no-mcp-shrink'], {
    env: { ...process.env, CLAUDE_CONFIG_DIR: configDir, NO_COLOR: '1', ...extraEnv },
    encoding: 'utf8',
  });
}

function hasClaudeCli() {
  // We can't import bin/install.js's hasCmd directly (CJS, not exported), but
  // a plain `command -v` / `where` shell-out is equivalent for this purpose.
  if (process.platform === 'win32') {
    return spawnSync('where', ['claude'], { stdio: 'ignore' }).status === 0;
  }
  return spawnSync('sh', ['-c', 'command -v claude'], { stdio: 'ignore' }).status === 0;
}

const STATUSLINE_FILE = process.platform === 'win32'
  ? 'caveman-statusline.ps1'
  : 'caveman-statusline.sh';

function getStatuslineCommand(settings) {
  if (!settings.statusLine) return '';
  return typeof settings.statusLine === 'string'
    ? settings.statusLine
    : (settings.statusLine.command || '');
}

function cavemanHookCommands(settings, event, marker) {
  return (settings.hooks?.[event] || [])
    .flatMap(e => (Array.isArray(e?.hooks) ? e.hooks : []))
    .filter(h => h && typeof h.command === 'string' && h.command.includes(marker));
}

// ── Test: fresh install populates expected files ───────────────────────────
test('fresh install populates hooks dir and settings.json (skipped without `claude` CLI)', { skip: !hasClaudeCli() && 'claude CLI not on PATH; the claude provider is the only path that wires hooks' }, () => {
  const dir = freshTmpDir();
  try {
    const r = runInstaller(['--only', 'claude', '--with-hooks'], dir);
    // The plugin install step may fail (network, auth); --with-hooks forces
    // the standalone hook wiring regardless. We only require the hooks-side state.
    assert.notEqual(r.status, 2, `installer aborted on argv parse: ${r.stderr}`);

    const hooks = path.join(dir, 'hooks');
    assert.ok(fs.existsSync(path.join(hooks, 'caveman-activate.js')),     'caveman-activate.js missing');
    assert.ok(fs.existsSync(path.join(hooks, 'caveman-mode-tracker.js')), 'caveman-mode-tracker.js missing');
    assert.ok(fs.existsSync(path.join(hooks, 'caveman-config.js')),       'caveman-config.js missing');
    assert.ok(fs.existsSync(path.join(hooks, 'package.json')),            'hooks/package.json (CJS marker) missing');
    assert.ok(fs.existsSync(path.join(hooks, STATUSLINE_FILE)),           `${STATUSLINE_FILE} missing`);

    // Settings merged correctly.
    const settingsPath = path.join(dir, 'settings.json');
    assert.ok(fs.existsSync(settingsPath), 'settings.json missing');
    const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));

    assert.ok(SETTINGS.hasCavemanHook(settings, 'SessionStart', 'caveman-activate'),
      'SessionStart hook missing or wrong marker');
    assert.ok(SETTINGS.hasCavemanHook(settings, 'UserPromptSubmit', 'caveman-mode-tracker'),
      'UserPromptSubmit hook missing or wrong marker');
    assert.ok(settings.statusLine, 'statusLine not set');
    assert.match(getStatuslineCommand(settings), /caveman-statusline/,
      'statusLine command does not reference caveman');
  } finally {
    fs.rmSync(dir, { recursive: true, force: true });
  }
});

// ── Test: idempotent install (run twice, no duplication) ───────────────────
test('idempotent install does not duplicate hook entries (skipped without `claude` CLI)', { skip: !hasClaudeCli() && 'claude CLI not on PATH' }, () => {
  const dir = freshTmpDir();
  try {
    const r1 = runInstaller(['--only', 'claude', '--with-hooks'], dir);
    assert.notEqual(r1.status, 2, `first install argv error: ${r1.stderr}`);
    const r2 = runInstaller(['--only', 'claude', '--with-hooks'], dir);
    assert.notEqual(r2.status, 2, `second install argv error: ${r2.stderr}`);

    const settings = JSON.parse(fs.readFileSync(path.join(dir, 'settings.json'), 'utf8'));

    const sessStart = cavemanHookCommands(settings, 'SessionStart', 'caveman-activate');
    assert.equal(sessStart.length, 1, `expected 1 SessionStart caveman hook, got ${sessStart.length}`);

    const ups = cavemanHookCommands(settings, 'UserPromptSubmit', 'caveman-mode-tracker');
    assert.equal(ups.length, 1, `expected 1 UserPromptSubmit caveman hook, got ${ups.length}`);
  } finally {
    fs.rmSync(dir, { recursive: true, force: true });
  }
});

// ── Test: uninstall removes hooks, preserves unrelated entries ─────────────
test('uninstall strips caveman hooks but preserves user-authored ones (skipped without `claude` CLI)', { skip: !hasClaudeCli() && 'claude CLI not on PATH; uninstall test depends on a prior real install' }, () => {
  const dir = freshTmpDir();
  try {
    // Seed user's existing settings so we can verify they survive.
    fs.writeFileSync(path.join(dir, 'settings.json'), JSON.stringify({
      model: 'opus',
      hooks: {
        SessionStart: [{ hooks: [{ type: 'command', command: 'echo user-owned-hook' }] }],
      },
    }, null, 2));

    const r1 = runInstaller(['--only', 'claude', '--with-hooks'], dir);
    assert.notEqual(r1.status, 2, `install argv error: ${r1.stderr}`);

    // Strip claude/gemini from PATH for uninstall so we don't touch the user's
    // real plugin/extension state — only file/settings cleanup runs.
    const cleanPath = pathWithout(['claude', 'gemini']);
    const r2 = runInstaller(['--uninstall'], dir, { PATH: cleanPath });
    assert.notEqual(r2.status, 2, `uninstall argv error: ${r2.stderr}`);

    // Hook scripts deleted.
    const hooks = path.join(dir, 'hooks');
    if (fs.existsSync(hooks)) {
      for (const f of ['caveman-activate.js', 'caveman-mode-tracker.js', 'caveman-config.js', STATUSLINE_FILE]) {
        assert.equal(fs.existsSync(path.join(hooks, f)), false, `${f} should be removed`);
      }
    }

    // Settings cleaned up.
    const settings = JSON.parse(fs.readFileSync(path.join(dir, 'settings.json'), 'utf8'));
    // No remaining caveman-marked hooks anywhere.
    for (const ev of Object.keys(settings.hooks || {})) {
      const arr = settings.hooks[ev] || [];
      for (const e of arr) {
        for (const h of (e.hooks || [])) {
          assert.doesNotMatch(h.command || '', /caveman/, `${ev} still has caveman hook: ${h.command}`);
        }
      }
    }
    // User's pre-existing hook preserved.
    const preservedUser = cavemanHookCommands(settings, 'SessionStart', 'user-owned-hook').length > 0;
    assert.ok(preservedUser, 'user-authored SessionStart hook was wiped during uninstall');

    // Statusline pointing at caveman should be removed.
    assert.doesNotMatch(getStatuslineCommand(settings), /caveman-statusline/,
      'caveman statusline survived uninstall');
  } finally {
    fs.rmSync(dir, { recursive: true, force: true });
  }
});

// ── Test: settings.json with JSONC comments doesn't crash (#249) ───────────
// Regression guard: the installer used to crash here because JSON.parse can't
// eat // or /* */. bin/lib/settings.js now strips them before merging.
test('install tolerates JSONC settings.json (comments + trailing commas)', { skip: !hasClaudeCli() && 'claude CLI not on PATH' }, () => {
  const dir = freshTmpDir();
  try {
    fs.writeFileSync(path.join(dir, 'settings.json'),
      `// user wrote this by hand
{
  /* keep it simple */
  "model": "opus",
  "hooks": {},
}
`);

    const r = runInstaller(['--only', 'claude', '--with-hooks'], dir);
    assert.notEqual(r.status, 2, `installer aborted on argv parse: ${r.stderr}`);

    // After install, settings.json must be strict-JSON parseable.
    const raw = fs.readFileSync(path.join(dir, 'settings.json'), 'utf8');
    let parsed;
    assert.doesNotThrow(() => { parsed = JSON.parse(raw); }, 'settings.json must round-trip as strict JSON');

    // User's `model` key must survive the merge.
    assert.equal(parsed.model, 'opus', 'user-authored model setting was dropped');

    // Caveman hooks must be wired.
    assert.ok(SETTINGS.hasCavemanHook(parsed, 'SessionStart', 'caveman-activate'));
    assert.ok(SETTINGS.hasCavemanHook(parsed, 'UserPromptSubmit', 'caveman-mode-tracker'));
  } finally {
    fs.rmSync(dir, { recursive: true, force: true });
  }
});

// ── Tests: OpenClaw workspace install (always run, no real OpenClaw needed)
// installOpenclaw writes plain files into a workspace dir we point at via
// OPENCLAW_WORKSPACE — no network, no external CLI, no plugin install. Safe
// to run on every CI box.

const SKILL_BODY_SRC = path.join(REPO_ROOT, 'skills', 'caveman', 'SKILL.md');

test('openclaw install writes skill folder + SOUL.md bootstrap', () => {
  const dir = freshTmpDir();
  const ws = path.join(dir, 'ws');
  fs.mkdirSync(ws);
  try {
    const r = spawnSync('node', [INSTALLER, '--only', 'openclaw', '--non-interactive', '--no-mcp-shrink', '--config-dir', dir], {
      env: { ...process.env, OPENCLAW_WORKSPACE: ws, NO_COLOR: '1' },
      encoding: 'utf8',
    });
    assert.notEqual(r.status, 2, `installer aborted on argv parse: ${r.stderr}`);

    // 1. Skill body written with merged frontmatter.
    const skillFile = path.join(ws, 'skills', 'caveman', 'SKILL.md');
    assert.ok(fs.existsSync(skillFile), 'skill SKILL.md missing');
    const skillRaw = fs.readFileSync(skillFile, 'utf8');
    assert.match(skillRaw, /^---\n/, 'skill missing frontmatter');
    assert.match(skillRaw, /\nversion:\s*\d+\.\d+\.\d+/, 'skill missing version frontmatter');
    assert.match(skillRaw, /\nalways:\s*true/, 'skill missing always: true frontmatter');

    // Body after the merged frontmatter must match the source body.
    const helper = requireCjs(path.join(REPO_ROOT, 'bin', 'lib', 'openclaw.js'));
    const srcRaw = fs.readFileSync(SKILL_BODY_SRC, 'utf8');
    const srcBody = helper.splitFrontmatter(srcRaw).body;
    const installedBody = helper.splitFrontmatter(skillRaw).body;
    assert.equal(installedBody, srcBody, 'installed skill body diverged from source');

    // 2. SOUL.md has marker block.
    const soul = path.join(ws, 'SOUL.md');
    assert.ok(fs.existsSync(soul), 'SOUL.md missing');
    const soulRaw = fs.readFileSync(soul, 'utf8');
    assert.match(soulRaw, /<!-- caveman-begin -->/, 'SOUL.md missing begin marker');
    assert.match(soulRaw, /<!-- caveman-end -->/, 'SOUL.md missing end marker');
    assert.match(soulRaw, /Respond terse like smart caveman/, 'SOUL.md missing sentinel');
  } finally {
    fs.rmSync(dir, { recursive: true, force: true });
  }
});

test('openclaw install is idempotent: skill frontmatter not double-prepended, SOUL.md has one marker block', () => {
  const dir = freshTmpDir();
  const ws = path.join(dir, 'ws');
  fs.mkdirSync(ws);
  try {
    const env = { ...process.env, OPENCLAW_WORKSPACE: ws, NO_COLOR: '1' };
    const args = ['--only', 'openclaw', '--non-interactive', '--no-mcp-shrink', '--config-dir', dir];
    spawnSync('node', [INSTALLER, ...args], { env, encoding: 'utf8' });
    spawnSync('node', [INSTALLER, ...args], { env, encoding: 'utf8' });

    const skillRaw = fs.readFileSync(path.join(ws, 'skills', 'caveman', 'SKILL.md'), 'utf8');
    // version key should appear exactly once (idempotent merge).
    const versionMatches = skillRaw.match(/^version:/gm) || [];
    assert.equal(versionMatches.length, 1, `expected 1 version key after re-run, got ${versionMatches.length}`);
    const alwaysMatches = skillRaw.match(/^always:/gm) || [];
    assert.equal(alwaysMatches.length, 1, `expected 1 always key after re-run, got ${alwaysMatches.length}`);

    const soulRaw = fs.readFileSync(path.join(ws, 'SOUL.md'), 'utf8');
    const beginMatches = soulRaw.match(/<!-- caveman-begin -->/g) || [];
    assert.equal(beginMatches.length, 1, `expected 1 marker block after re-run, got ${beginMatches.length}`);
  } finally {
    fs.rmSync(dir, { recursive: true, force: true });
  }
});

test('openclaw install preserves user content in SOUL.md (append, not overwrite)', () => {
  const dir = freshTmpDir();
  const ws = path.join(dir, 'ws');
  fs.mkdirSync(ws);
  const userContent = '# my workspace\n\nfoo bar baz\n';
  fs.writeFileSync(path.join(ws, 'SOUL.md'), userContent);
  try {
    spawnSync('node', [INSTALLER, '--only', 'openclaw', '--non-interactive', '--no-mcp-shrink', '--config-dir', dir], {
      env: { ...process.env, OPENCLAW_WORKSPACE: ws, NO_COLOR: '1' },
      encoding: 'utf8',
    });
    const soulRaw = fs.readFileSync(path.join(ws, 'SOUL.md'), 'utf8');
    assert.match(soulRaw, /# my workspace/, 'user heading wiped during install');
    assert.match(soulRaw, /foo bar baz/, 'user content wiped during install');
    assert.match(soulRaw, /<!-- caveman-begin -->/, 'caveman block not appended');
  } finally {
    fs.rmSync(dir, { recursive: true, force: true });
  }
});

test('openclaw uninstall removes skill folder + strips SOUL.md block, preserving user content', () => {
  const dir = freshTmpDir();
  const ws = path.join(dir, 'ws');
  fs.mkdirSync(ws);
  const userContent = '# my workspace\n\nfoo bar baz\n';
  fs.writeFileSync(path.join(ws, 'SOUL.md'), userContent);
  try {
    const env = { ...process.env, OPENCLAW_WORKSPACE: ws, NO_COLOR: '1' };
    spawnSync('node', [INSTALLER, '--only', 'openclaw', '--non-interactive', '--no-mcp-shrink', '--config-dir', dir], { env, encoding: 'utf8' });

    // Strip claude/gemini from PATH so uninstall doesn't touch real plugins.
    const cleanPath = pathWithout(['claude', 'gemini']);
    const r = spawnSync('node', [INSTALLER, '--uninstall', '--non-interactive', '--no-mcp-shrink', '--config-dir', dir], {
      env: { ...env, PATH: cleanPath },
      encoding: 'utf8',
    });
    assert.notEqual(r.status, 2, `uninstall argv error: ${r.stderr}`);

    assert.equal(fs.existsSync(path.join(ws, 'skills', 'caveman')), false, 'skill folder should be removed');
    const soulAfter = fs.readFileSync(path.join(ws, 'SOUL.md'), 'utf8');
    assert.doesNotMatch(soulAfter, /<!-- caveman-begin -->/, 'caveman block survived uninstall');
    assert.doesNotMatch(soulAfter, /<!-- caveman-end -->/, 'caveman end marker survived uninstall');
    assert.match(soulAfter, /# my workspace/, 'user heading wiped during uninstall');
    assert.match(soulAfter, /foo bar baz/, 'user content wiped during uninstall');
  } finally {
    fs.rmSync(dir, { recursive: true, force: true });
  }
});

test('caveman-init.js --only openclaw routes through the same helper', () => {
  const dir = freshTmpDir();
  const ws = path.join(dir, 'ws');
  fs.mkdirSync(ws);
  try {
    const initScript = path.join(REPO_ROOT, 'src', 'tools', 'caveman-init.js');
    const r = spawnSync('node', [initScript, dir, '--only', 'openclaw'], {
      env: { ...process.env, OPENCLAW_WORKSPACE: ws, NO_COLOR: '1' },
      encoding: 'utf8',
    });
    assert.equal(r.status, 0, `caveman-init failed: ${r.stderr || r.stdout}`);
    assert.ok(fs.existsSync(path.join(ws, 'skills', 'caveman', 'SKILL.md')), 'skill missing via init route');
    assert.ok(fs.existsSync(path.join(ws, 'SOUL.md')), 'SOUL.md missing via init route');
    const soulRaw = fs.readFileSync(path.join(ws, 'SOUL.md'), 'utf8');
    assert.match(soulRaw, /Respond terse like smart caveman/, 'sentinel missing via init route');
  } finally {
    fs.rmSync(dir, { recursive: true, force: true });
  }
});

// ── Test: idempotent re-add at the lib level (always runs, no claude needed)
// This guards the addCommandHook idempotency promise without spawning a real
// install — even on machines with no `claude` CLI we want this assertion.
test('lib settings.addCommandHook is idempotent across two synthetic install passes', () => {
  const dir = freshTmpDir();
  const settingsPath = path.join(dir, 'settings.json');
  try {
    const settings = SETTINGS.readSettings(settingsPath);
    SETTINGS.addCommandHook(settings, 'SessionStart', {
      command: '"/usr/bin/node" "/abs/hooks/caveman-activate.js"',
      marker: 'caveman-activate',
    });
    SETTINGS.addCommandHook(settings, 'SessionStart', {
      command: '"/usr/bin/node" "/different/hooks/caveman-activate.js"',
      marker: 'caveman-activate',
    });
    SETTINGS.validateHookFields(settings);
    SETTINGS.writeSettings(settingsPath, settings);

    const round = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
    assert.equal(round.hooks.SessionStart.length, 1, 'addCommandHook duplicated entry');
  } finally {
    fs.rmSync(dir, { recursive: true, force: true });
  }
});

// ── Test: --force migrates a mixed legacy AGENTS.md instead of wiping it (#594)
// The old code replaced the whole file with the fenced block whenever the
// legacy un-fenced sentinel was present — and the installer's own hint told
// users with mixed files to run exactly that. User content must survive.
test('opencode: --force on legacy AGENTS.md preserves user content and takes a backup', () => {
  const dir = freshTmpDir();
  const xdg = path.join(dir, 'xdg');
  const ocDir = path.join(xdg, 'opencode');
  fs.mkdirSync(ocDir, { recursive: true });
  const agentsMd = path.join(ocDir, 'AGENTS.md');
  const legacyBody = fs.readFileSync(
    path.join(REPO_ROOT, 'src', 'rules', 'caveman-activate.md'), 'utf8').trimEnd() + '\n';
  const userRules = '# My precious user rules\n\nAlways use tabs.\n';
  fs.writeFileSync(agentsMd, userRules + '\n' + legacyBody);
  try {
    const r = spawnSync('node', [INSTALLER, '--only', 'opencode', '--force', '--non-interactive', '--no-mcp-shrink', '--config-dir', path.join(dir, 'claude')], {
      env: { ...process.env, XDG_CONFIG_HOME: xdg, NO_COLOR: '1' },
      encoding: 'utf8',
    });
    assert.notEqual(r.status, 2, `installer argv error: ${r.stderr}`);

    const after = fs.readFileSync(agentsMd, 'utf8');
    assert.match(after, /My precious user rules/, 'user heading wiped by --force migration');
    assert.match(after, /Always use tabs\./, 'user rule wiped by --force migration');
    assert.match(after, /<!-- caveman-begin -->/, 'fenced block missing after migration');
    assert.match(after, /<!-- caveman-end -->/, 'fence end missing after migration');
    // Legacy un-fenced copy must be gone: sentinel appears only inside the fence.
    const beforeFence = after.slice(0, after.indexOf('<!-- caveman-begin -->'));
    assert.doesNotMatch(beforeFence, /Respond terse like smart caveman/,
      'legacy un-fenced block still present above the fence');
    assert.ok(fs.existsSync(agentsMd + '.bak'), 'backup missing after --force migration');
    assert.match(fs.readFileSync(agentsMd + '.bak', 'utf8'), /My precious user rules/,
      'backup does not contain the original content');
  } finally {
    fs.rmSync(dir, { recursive: true, force: true });
  }
});

// ── Tests: SOUL.md marker damage tolerance (#596) ──────────────────────────
// A stray/truncated marker used to chain into data loss: append added a
// second block, then strip cut from the FIRST begin to the FIRST end —
// spanning all user content in between. These drive the helper directly.
test('openclaw: truncated begin marker does not eat user content (issue #596 chain)', () => {
  const helper = requireCjs(path.join(REPO_ROOT, 'bin', 'lib', 'openclaw.js'));
  const dir = freshTmpDir();
  const soul = path.join(dir, 'SOUL.md');
  try {
    // Begin marker with no end (interrupted write), then user content.
    fs.writeFileSync(soul, helper.MARK_BEGIN + '\n\nUSER IMPORTANT CONTENT\n');
    const snippet = helper.loadBootstrapSnippet(REPO_ROOT);

    const a = helper.appendBootstrapToSoul(soul, snippet);
    assert.equal(a.changed, true);
    const afterAppend = fs.readFileSync(soul, 'utf8');
    assert.match(afterAppend, /USER IMPORTANT CONTENT/, 'user content lost during repair-append');
    assert.equal(afterAppend.split(helper.MARK_BEGIN).length - 1, 1, 'repair must leave exactly one begin marker');

    const s = helper.stripBootstrapFromSoul(soul);
    assert.equal(s.changed, true);
    assert.equal(s.removed, undefined, 'file with user content must not be deleted');
    const afterStrip = fs.readFileSync(soul, 'utf8');
    assert.match(afterStrip, /USER IMPORTANT CONTENT/, 'user content deleted by strip — the #596 data loss');
    assert.doesNotMatch(afterStrip, /caveman-begin/, 'marker survived strip');
  } finally {
    fs.rmSync(dir, { recursive: true, force: true });
  }
});

test('openclaw: strip removes multiple blocks pairwise, keeping user content between them', () => {
  const helper = requireCjs(path.join(REPO_ROOT, 'bin', 'lib', 'openclaw.js'));
  const dir = freshTmpDir();
  const soul = path.join(dir, 'SOUL.md');
  try {
    const block = helper.MARK_BEGIN + '\nrules v1\n' + helper.MARK_END;
    fs.writeFileSync(soul, block + '\n\nUSER KEEP ME\n\n' + block + '\n');
    const s = helper.stripBootstrapFromSoul(soul);
    assert.equal(s.changed, true);
    const after = fs.readFileSync(soul, 'utf8');
    assert.match(after, /USER KEEP ME/, 'user content between blocks deleted');
    assert.doesNotMatch(after, /caveman-(begin|end)/, 'markers survived');
    assert.doesNotMatch(after, /rules v1/, 'block bodies survived');
  } finally {
    fs.rmSync(dir, { recursive: true, force: true });
  }
});

test('openclaw: orphan end marker stripped without touching content', () => {
  const helper = requireCjs(path.join(REPO_ROOT, 'bin', 'lib', 'openclaw.js'));
  const dir = freshTmpDir();
  const soul = path.join(dir, 'SOUL.md');
  try {
    fs.writeFileSync(soul, 'before\n' + helper.MARK_END + '\nafter\n');
    const s = helper.stripBootstrapFromSoul(soul);
    assert.equal(s.changed, true);
    const after = fs.readFileSync(soul, 'utf8');
    assert.match(after, /before/);
    assert.match(after, /after/);
    assert.doesNotMatch(after, /caveman-end/);
  } finally {
    fs.rmSync(dir, { recursive: true, force: true });
  }
});

test('openclaw: append on a well-formed block stays a no-op', () => {
  const helper = requireCjs(path.join(REPO_ROOT, 'bin', 'lib', 'openclaw.js'));
  const dir = freshTmpDir();
  const soul = path.join(dir, 'SOUL.md');
  try {
    const snippet = helper.loadBootstrapSnippet(REPO_ROOT);
    helper.appendBootstrapToSoul(soul, snippet);
    const first = fs.readFileSync(soul, 'utf8');
    const again = helper.appendBootstrapToSoul(soul, snippet);
    assert.equal(again.changed, false);
    assert.equal(fs.readFileSync(soul, 'utf8'), first, 'no-op append must not modify the file');
  } finally {
    fs.rmSync(dir, { recursive: true, force: true });
  }
});

// ── Test: missing `claude` CLI must be a FAILURE, not silent success (#592)
// spawnSync reports ENOENT as { status: null, error }; the old
// `(r.status || 0) === 0` coerced that to success, so the installer printed
// "installed: claude", skipped the standalone-hook fallback, and left the
// machine with nothing installed. Always runs: an empty PATH guarantees the
// claude lookup fails even on machines that do have the CLI.
test('missing claude CLI: reports failure and falls back to standalone hook wiring', () => {
  const dir = freshTmpDir();
  const emptyBin = path.join(dir, 'empty-bin');
  fs.mkdirSync(emptyBin);
  const configDir = path.join(dir, 'claude-config');
  try {
    // process.execPath instead of 'node': the stripped PATH must not break
    // the test's own ability to launch the installer.
    const r = spawnSync(process.execPath, [
      INSTALLER, '--only', 'claude', '--skip-skills',
      '--config-dir', configDir, '--non-interactive', '--no-mcp-shrink',
    ], {
      env: { ...process.env, PATH: emptyBin, CLAUDE_CONFIG_DIR: configDir, NO_COLOR: '1' },
      encoding: 'utf8',
    });
    const out = (r.stdout || '') + (r.stderr || '');
    assert.match(out, /plugin install did not succeed; falling back to standalone wiring/,
      `fallback to standalone hooks did not trigger:\n${out}`);
    assert.match(out, /claude plugin install failed/, 'claude was not reported as failed');
    assert.ok(!/• claude\n/.test(out), 'claude must not be listed as installed');
    assert.ok(fs.existsSync(path.join(configDir, 'hooks', 'caveman-activate.js')),
      'standalone hooks were not written');
    const settings = JSON.parse(fs.readFileSync(path.join(configDir, 'settings.json'), 'utf8'));
    assert.ok(settings.hooks && settings.hooks.SessionStart, 'SessionStart hook not wired');
  } finally {
    fs.rmSync(dir, { recursive: true, force: true });
  }
});