/**
 * `tbd setup` - Configure tbd integration with editors and tools.
 *
 * Requires a git repository. All setup artifacts (.tbd/, .claude/) are placed
 * at the git root, adjacent to .git/. Installation is always project-local —
 * there is no global/user-level install.
 *
 * Options:
 * - `tbd setup --auto` - Non-interactive setup (for agents/scripts)
 * - `tbd setup --interactive` - Interactive setup with prompts (for humans)
 * - `tbd setup --from-beads` - Migrate from Beads to tbd
 *
 * See: tbd-design.md §6.4.2 Claude Code Integration
 */

import { Command } from 'commander';
import { readFile, mkdir, access, rm, rename, chmod, readdir } from 'node:fs/promises';
import { spawnSync } from 'node:child_process';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { writeFile } from 'atomically';

import { BaseCommand } from '../lib/base-command.js';
import { CLIError } from '../lib/errors.js';
import { loadSkillContent } from './prime.js';
import { insertAfterFrontmatter } from '../../utils/markdown-utils.js';
import { pathExists } from '../../utils/file-utils.js';
import { ensureGitignorePatterns } from '../../utils/gitignore-utils.js';
import { type DiagnosticResult, renderDiagnostics } from '../lib/diagnostics.js';
import { isValidPrefix, isRecommendedPrefix, getBeadsPrefix } from '../lib/prefix-detection.js';
import {
  initConfig,
  isInitialized,
  readConfig,
  readConfigWithMigration,
  findTbdRoot,
  writeConfig,
  markWelcomeSeen,
} from '../../file/config.js';
import { syncDocsWithDefaults } from '../../file/doc-sync.js';
import { PINNED_NPM_VERSION, VERSION } from '../lib/version.js';
import {
  TBD_DIR,
  TBD_DOCS_DIR,
  WORKTREE_DIR_NAME,
  DATA_SYNC_DIR_NAME,
  DEFAULT_SHORTCUT_PATHS,
  DEFAULT_GUIDELINES_PATHS,
  TBD_SHORTCUTS_SYSTEM,
  TBD_SHORTCUTS_STANDARD,
  TBD_GUIDELINES_DIR,
  TBD_TEMPLATES_DIR,
  resolveSharedTbdPaths,
} from '../../lib/paths.js';
import {
  getClaudePaths,
  getAgentsMdPath,
  getAgentSkillPaths,
  getCodexPaths,
  AGENTS_SKILL_DISPLAY,
  AGENT_INTEGRATION_FORMAT,
} from '../../lib/integration-paths.js';
import { initWorktree, isInGitRepo, findGitRoot, checkWorktreeHealth } from '../../file/git.js';
import { DocCache, generateShortcutDirectory } from '../../file/doc-cache.js';
import { withSharedDataSyncLock, writeCommonDirLayout } from '../../file/common-dir-layout.js';
import { withDataSyncContext } from '../lib/data-context.js';

/**
 * Get the shortcut and guidelines directory content for appending to installed skill files.
 * Always generates on-the-fly from installed shortcuts and guidelines.
 *
 * @param quiet - If true, suppress auto-sync output (default: false)
 */
async function getShortcutDirectory(quiet = false): Promise<string | null> {
  const cwd = process.cwd();

  // Try to find tbd root (may not be initialized)
  const tbdRoot = await findTbdRoot(cwd);
  if (!tbdRoot) {
    return null;
  }

  // Load shortcuts
  const shortcutCache = new DocCache(DEFAULT_SHORTCUT_PATHS, tbdRoot);
  await shortcutCache.load({ quiet });
  const shortcuts = shortcutCache.list();

  // Load guidelines
  const guidelinesCache = new DocCache(DEFAULT_GUIDELINES_PATHS, tbdRoot);
  await guidelinesCache.load({ quiet });
  const guidelines = guidelinesCache.list();

  // If no docs loaded, skip directory
  if (shortcuts.length === 0 && guidelines.length === 0) {
    return null;
  }

  return generateShortcutDirectory(shortcuts, guidelines);
}

/**
 * DO NOT EDIT marker inserted after the frontmatter of every generated SKILL.md.
 * Carries the shared `format=fNN` integration code so the forward-compatibility
 * guard can detect a skill written by a newer tbd (see {@link writeSkillFile}).
 * Formatted to match flowmark output.
 */
const SKILL_DO_NOT_EDIT_MARKER = `<!-- DO NOT EDIT: Generated by tbd setup (format=${AGENT_INTEGRATION_FORMAT}).\nRun 'tbd setup' to update.\n-->`;

/**
 * Build the full generated SKILL.md payload: the bundled skill content with the
 * shortcut/guideline directory appended and a DO NOT EDIT marker after the
 * frontmatter. This is the single source for every skill surface (the portable
 * .agents/skills install and the .claude/skills mirror) so they stay identical.
 *
 * @param quiet - If true, suppress auto-sync output.
 */
async function buildSkillPayload(quiet = false): Promise<string> {
  let skillContent = await loadSkillContent();
  const directory = await getShortcutDirectory(quiet);
  if (directory) {
    skillContent = skillContent.trimEnd() + '\n\n' + directory;
  }
  skillContent = insertAfterFrontmatter(skillContent, SKILL_DO_NOT_EDIT_MARKER);
  return skillContent.trimEnd() + '\n';
}

/**
 * Write a generated SKILL.md payload to a target path, creating parent dirs.
 *
 * Forward-compatibility guard: if an existing skill carries a newer
 * `format=fNN` stamp than this tbd understands, refuse to overwrite it and tell
 * the user to upgrade. Guarding each generated surface at its own write point
 * means an older tbd cannot partial-downgrade a newer committed skill,
 * regardless of the order in which surfaces are written.
 */
async function writeSkillFile(targetPath: string, payload: string): Promise<void> {
  let existing: string | null = null;
  try {
    existing = await readFile(targetPath, 'utf-8');
  } catch {
    // No existing file (first install) — nothing to guard against.
  }
  if (existing !== null) {
    assertNotNewerFormat(existing, targetPath);
  }
  await mkdir(dirname(targetPath), { recursive: true });
  await writeFile(targetPath, payload);
}

/**
 * AGENTS.md managed-block markers. `CODEX_BEGIN_MARKER` is the stable PREFIX of
 * the begin line — the metadata (`format=fNN surface=agents-md`) follows it on the
 * same line, e.g. `<!-- BEGIN TBD INTEGRATION format=f02 surface=agents-md -->`.
 * Cleanup/upgrade code matches on this prefix so it finds both legacy
 * (`<!-- BEGIN TBD INTEGRATION -->`) and current marked blocks.
 */
const CODEX_BEGIN_MARKER = '<!-- BEGIN TBD INTEGRATION';
const CODEX_END_MARKER = '<!-- END TBD INTEGRATION -->';

/** The full begin marker line for newly generated blocks. */
const CODEX_BEGIN_LINE = `${CODEX_BEGIN_MARKER} format=${AGENT_INTEGRATION_FORMAT} surface=agents-md -->`;

/** Numeric value of an `fNN` format string, for ordering comparisons. */
function formatToNumber(format: string): number {
  return Number.parseInt(format.replace(/^f/, ''), 10);
}

/**
 * Read the integration format (`fNN`) stamped in a generated artifact's begin
 * marker. Returns `f01` for a legacy marked block with no `format=` field, or
 * null when there is no tbd-managed block at all.
 */
function parseIntegrationFormat(content: string): string | null {
  const match = /format=f(\d+)/.exec(content);
  if (match?.[1]) {
    return `f${match[1]}`;
  }
  return content.includes(CODEX_BEGIN_MARKER) ? 'f01' : null;
}

/**
 * Forward-compatibility guard. If a generated artifact was written by a NEWER
 * tbd than this one understands, refuse to rewrite it and tell the user to
 * upgrade tbd — overwriting would downgrade a newer managed format. This is what
 * makes pinning safe on a team: an older tbd fails loudly instead of clobbering.
 */
function assertNotNewerFormat(content: string, artifact: string): void {
  const format = parseIntegrationFormat(content);
  if (format !== null && formatToNumber(format) > formatToNumber(AGENT_INTEGRATION_FORMAT)) {
    throw new CLIError(
      `${artifact} was generated by a newer tbd (integration format ${format}; ` +
        `this tbd supports up to ${AGENT_INTEGRATION_FORMAT}).\n` +
        `Upgrade tbd to manage it: npm install -g get-tbd@latest`,
    );
  }
}

/**
 * Get the tbd managed block for AGENTS.md.
 *
 * This is a COMPACT always-on bootstrap, not a copy of the full skill: it names
 * tbd, states the operating rule, and points to the CLI commands that provide
 * progressive disclosure (`tbd prime`, `tbd skill`, `tbd shortcut/guidelines
 * --list`). The full skill body lives in the SKILL.md surfaces, not here, so the
 * block stays well under the AGENTS.md prompt-budget guidance.
 */
function getCodexTbdSection(): string {
  // This text is kept in flowmark's canonical form (sentence-aware line breaks,
  // curly apostrophe, blank line before the END marker) so that setup output and
  // a formatter pass produce byte-identical results and the dogfooded AGENTS.md
  // never drifts.
  const body = `## tbd

This repository uses **tbd** for git-native issue tracking (beads), spec-driven
planning, and on-demand engineering guidelines.
As the agent, you operate tbd on the user’s behalf — translate their requests into tbd
actions rather than telling them to run commands.

- Run \`tbd prime\` to load current project state and the full tbd workflow.
- Run \`tbd skill\` for the complete reusable tbd skill instructions.
- Run \`tbd shortcut --list\` and \`tbd guidelines --list\` for on-demand resources.
- Track all work as beads: \`tbd create\`, \`tbd ready\`, \`tbd close\`, and \`tbd sync\`.
`;
  return `${CODEX_BEGIN_LINE}\n${body}\n${CODEX_END_MARKER}\n`;
}

interface SetupClaudeOptions {
  check?: boolean;
  remove?: boolean;
}

interface SetupCodexOptions {
  check?: boolean;
  remove?: boolean;
}

/**
 * Script to ensure tbd CLI is installed and run tbd prime.
 * Installed to project .claude/scripts/tbd-session.sh.
 * Runs on SessionStart and PreCompact to ensure tbd is available and provide orientation.
 *
 * Usage:
 *   tbd-session.sh           # Ensure tbd + run tbd prime
 *   tbd-session.sh --brief   # Ensure tbd + run tbd prime --brief (for PreCompact)
 */
const TBD_SESSION_SCRIPT = `#!/bin/bash
# Ensure the tbd CLI is available and run \`tbd prime\`.
# Installed by: tbd setup --auto. Runs on SessionStart and PreCompact.
#
# Local-first, then a VERSION-PINNED zero-install fallback. Pinning is both a
# supply-chain control (an unpinned runner re-resolves to latest on every run
# and bypasses any cool-off) and a consistency control (every teammate and agent
# runs the same tbd version).

# Prefer common local bin locations.
export PATH="$HOME/.local/bin:$HOME/bin:/usr/local/bin:$PATH"

# Local-first: use tbd if it is already on PATH.
if command -v tbd &> /dev/null; then
    tbd prime "$@"
    exit $?
fi

# Pinned zero-install fallback. Never use an unpinned runner here.
if command -v npx &> /dev/null; then
    npx --yes get-tbd@${PINNED_NPM_VERSION} prime "$@"
    exit $?
fi

echo "[tbd] tbd CLI not found and npx is unavailable."
echo "[tbd] Install it with: npm install -g get-tbd@${PINNED_NPM_VERSION}"
exit 1
`;

/**
 * Claude Code session hooks configuration.
 * Always uses project-relative paths so hooks work in any environment
 * (local dev, Claude Code Cloud, etc.).
 */
const CLAUDE_SESSION_HOOKS = {
  hooks: {
    SessionStart: [
      {
        matcher: '',
        hooks: [{ type: 'command', command: 'bash .claude/scripts/tbd-session.sh' }],
      },
    ],
    PreCompact: [
      {
        matcher: '',
        hooks: [{ type: 'command', command: 'bash .claude/scripts/tbd-session.sh --brief' }],
      },
    ],
  },
};

/**
 * Claude Code project-local hooks configuration (installed to .claude/settings.json)
 * PostToolUse hook reminds about tbd sync after git push
 */
const CLAUDE_PROJECT_HOOKS = {
  hooks: {
    PostToolUse: [
      {
        matcher: 'Bash',
        hooks: [
          {
            type: 'command',
            command: '"$CLAUDE_PROJECT_DIR"/.claude/hooks/tbd-closing-reminder.sh',
          },
        ],
      },
    ],
  },
};

/**
 * Script to remind about close protocol after git push
 */
const TBD_CLOSE_PROTOCOL_SCRIPT = `#!/bin/bash
# Remind about close protocol after git push
# Installed by: tbd setup claude

input=$(cat)
command=$(echo "$input" | jq -r '.tool_input.command // empty')

# Check if this is a git push command and .tbd exists
if [[ "$command" == git\\ push* ]] || [[ "$command" == *"&& git push"* ]] || [[ "$command" == *"; git push"* ]]; then
  if [ -d ".tbd" ]; then
    tbd closing
  fi
fi

exit 0
`;

/**
 * SessionStart hook entry for the gh CLI ensure script.
 * Installed to project-local .claude/settings.json when use_gh_cli is true.
 */
const GH_CLI_HOOK_ENTRY = {
  matcher: '',
  hooks: [{ type: 'command', command: 'bash .claude/scripts/ensure-gh-cli.sh', timeout: 120 }],
};

/**
 * Command string used to identify the gh CLI hook entry in settings.json.
 */
const GH_CLI_HOOK_COMMAND_PATTERN = 'ensure-gh-cli';

/**
 * Load a bundled script from dist/docs/install/ (or dev fallback).
 * Used to read real .sh files that are copied into the npm package at build time.
 */
async function loadBundledScript(name: string): Promise<string> {
  const __filename = fileURLToPath(import.meta.url);
  const __dirname = dirname(__filename);
  // Flat bundle: dist/docs/install/<name> (tsdown produces flat dist/)
  const flatBundlePath = join(__dirname, 'docs', 'install', name);
  // Nested bundle: dist/cli/commands/../../docs/install/<name>
  const nestedBundlePath = join(__dirname, '..', 'docs', 'install', name);
  // Dev fallback: packages/tbd/docs/install/<name>
  const devPath = join(__dirname, '..', '..', '..', 'docs', 'install', name);
  for (const p of [flatBundlePath, nestedBundlePath, devPath]) {
    try {
      return await readFile(p, 'utf-8');
    } catch {
      continue;
    }
  }
  throw new Error(`Bundled script not found: ${name}`);
}

/**
 * Generate a new AGENTS.md file with tbd integration.
 */
function getCodexNewAgentsFile(): string {
  const tbdSection = getCodexTbdSection();
  return `# Project Instructions for AI Agents

This file provides instructions and context for AI coding agents working on this project.

${tbdSection}
## Build & Test

_Add your build and test commands here_

\`\`\`bash
# Example:
# npm install
# npm test
\`\`\`

## Architecture Overview

_Add a brief overview of your project architecture_

## Conventions & Patterns

_Add your project-specific conventions here_
`;
}

/**
 * Codex project-local script paths (relative to repo root). Codex hooks
 * reference ONLY these `.codex/` paths, never `.claude/`, so Codex setup stays
 * independent of Claude Code setup.
 */
const CODEX_SESSION_SCRIPT_REL = '.codex/tbd-session.sh';
const CODEX_CLOSING_REMINDER_REL = '.codex/tbd-closing-reminder.sh';
const CODEX_GH_CLI_SCRIPT_REL = '.codex/ensure-gh-cli.sh';

/**
 * Build the Codex hooks.json content. Codex uses the same lifecycle event
 * schema as Claude Code (command handlers), so tbd's hooks map almost 1:1:
 * SessionStart and PreCompact run `tbd prime`, PostToolUse reminds about sync
 * after `git push`, and (when enabled) a second SessionStart entry ensures gh.
 */
function getCodexHooksConfig(useGhCli: boolean): { hooks: Record<string, unknown[]> } {
  const sessionStart: unknown[] = [
    { matcher: '', hooks: [{ type: 'command', command: `bash ${CODEX_SESSION_SCRIPT_REL}` }] },
  ];
  if (useGhCli) {
    sessionStart.push({
      matcher: '',
      hooks: [{ type: 'command', command: `bash ${CODEX_GH_CLI_SCRIPT_REL}`, timeout: 120 }],
    });
  }
  return {
    hooks: {
      SessionStart: sessionStart,
      PreCompact: [
        {
          matcher: '',
          hooks: [{ type: 'command', command: `bash ${CODEX_SESSION_SCRIPT_REL} --brief` }],
        },
      ],
      PostToolUse: [
        {
          matcher: 'Bash',
          hooks: [{ type: 'command', command: `bash ${CODEX_CLOSING_REMINDER_REL}` }],
        },
      ],
    },
  };
}

/**
 * Legacy script patterns to clean up from .claude/scripts/
 * These were used in older versions of tbd before hooks moved to `tbd prime`
 */
const LEGACY_TBD_SCRIPTS = ['setup-tbd.sh', 'ensure-tbd-cli.sh', 'ensure-tbd.sh', 'tbd-setup.sh'];

/**
 * Patterns to identify legacy tbd hooks that should be removed.
 * These patterns match old-style commands that are no longer used.
 */
const LEGACY_TBD_HOOK_PATTERNS = [
  /\.claude\/scripts\/.*tbd/i, // Any tbd-related script in .claude/scripts/
  /tbd\s+setup\s+claude/i, // Old command: tbd setup claude
  /setup-tbd\.sh/i, // Old script name
  /ensure-tbd/i, // Old script names
];

class SetupClaudeHandler extends BaseCommand {
  private projectDir: string | undefined;

  setProjectDir(dir: string): void {
    this.projectDir = dir;
  }

  async run(options: SetupClaudeOptions): Promise<void> {
    const cwd = this.projectDir ?? process.cwd();
    const claudePaths = getClaudePaths(cwd);

    if (options.check) {
      await this.checkClaudeSetup(claudePaths.skill);
      return;
    }

    if (options.remove) {
      await this.removeClaudeSetup(claudePaths.skill);
      return;
    }

    await this.installClaudeSetup(claudePaths.skill);
  }

  private async checkClaudeSetup(skillPath: string): Promise<void> {
    const cwd = this.projectDir ?? process.cwd();
    let sessionScriptInstalled = false;
    let sessionStartHook = false;
    let preCompactHook = false;
    let postToolUseHook = false;
    let hookScriptInstalled = false;
    let skillInstalled = false;

    // All hooks and scripts are project-local in .claude/
    const projectSettingsPath = join(cwd, '.claude', 'settings.json');
    const sessionScript = join(cwd, '.claude', 'scripts', 'tbd-session.sh');
    const hookScriptPath = join(cwd, '.claude', 'hooks', 'tbd-closing-reminder.sh');

    // Check for tbd-session.sh script
    try {
      await access(sessionScript);
      sessionScriptInstalled = true;
    } catch {
      // Script doesn't exist
    }

    // Check hooks in project settings
    try {
      await access(projectSettingsPath);
      const content = await readFile(projectSettingsPath, 'utf-8');
      const settings = JSON.parse(content) as Record<string, unknown>;
      const hooks = settings.hooks as Record<string, unknown> | undefined;

      if (hooks) {
        const sessionStart = hooks.SessionStart as { hooks?: { command?: string }[] }[];
        const preCompact = hooks.PreCompact as { hooks?: { command?: string }[] }[];
        const postToolUse = hooks.PostToolUse as { hooks?: { command?: string }[] }[];

        sessionStartHook =
          sessionStart?.some((h) =>
            h.hooks?.some(
              (hook) =>
                (hook.command?.includes('tbd prime') ?? false) ||
                (hook.command?.includes('tbd-session.sh') ?? false),
            ),
          ) ?? false;

        preCompactHook =
          preCompact?.some((h) =>
            h.hooks?.some(
              (hook) =>
                (hook.command?.includes('tbd prime') ?? false) ||
                (hook.command?.includes('tbd-session.sh') ?? false),
            ),
          ) ?? false;

        postToolUseHook =
          postToolUse?.some((h) =>
            h.hooks?.some((hook) => hook.command?.includes('tbd-closing-reminder')),
          ) ?? false;
      }
    } catch {
      // Project settings file doesn't exist
    }

    try {
      await access(hookScriptPath);
      hookScriptInstalled = true;
    } catch {
      // Hook script doesn't exist
    }

    const sessionHooksInstalled = sessionStartHook && preCompactHook && sessionScriptInstalled;
    const projectHooksInstalled = postToolUseHook && hookScriptInstalled;

    // Check skill file in project
    try {
      await access(skillPath);
      skillInstalled = true;
    } catch {
      // Skill file doesn't exist
    }

    const fullyInstalled = sessionHooksInstalled && projectHooksInstalled && skillInstalled;

    // Build diagnostic results for text output
    const diagnostics: DiagnosticResult[] = [];
    const settingsRelPath = '.claude/settings.json';

    // Session hooks diagnostic
    if (sessionHooksInstalled) {
      diagnostics.push({
        name: 'Session hooks',
        status: 'ok',
        message: 'SessionStart, PreCompact',
        path: settingsRelPath,
      });
    } else if (sessionStartHook || preCompactHook) {
      diagnostics.push({
        name: 'Session hooks',
        status: 'warn',
        message: 'partially configured',
        path: settingsRelPath,
        suggestion: 'Run: tbd setup --auto',
      });
    } else {
      diagnostics.push({
        name: 'Session hooks',
        status: 'warn',
        message: 'not configured',
        path: settingsRelPath,
        suggestion: 'Run: tbd setup --auto',
      });
    }

    // Project hooks diagnostic
    if (projectHooksInstalled) {
      diagnostics.push({
        name: 'Project hooks',
        status: 'ok',
        message: 'PostToolUse sync reminder',
        path: settingsRelPath,
      });
    } else if (postToolUseHook || hookScriptInstalled) {
      diagnostics.push({
        name: 'Project hooks',
        status: 'warn',
        message: 'partially configured',
        path: settingsRelPath,
        suggestion: 'Run: tbd setup --auto',
      });
    } else {
      diagnostics.push({
        name: 'Project hooks',
        status: 'warn',
        message: 'not configured',
        path: settingsRelPath,
        suggestion: 'Run: tbd setup --auto',
      });
    }

    // Skill file diagnostic
    const skillRelPath = '.claude/skills/tbd/SKILL.md';
    if (skillInstalled) {
      diagnostics.push({
        name: 'Skill file',
        status: 'ok',
        path: skillRelPath,
      });
    } else {
      diagnostics.push({
        name: 'Skill file',
        status: 'warn',
        message: 'not found',
        path: skillRelPath,
        suggestion: 'Run: tbd setup --auto',
      });
    }

    this.output.data(
      {
        installed: fullyInstalled,
        sessionHooks: {
          installed: sessionHooksInstalled,
          sessionStart: sessionStartHook,
          preCompact: preCompactHook,
          script: sessionScriptInstalled,
          path: projectSettingsPath,
        },
        projectHooks: {
          installed: projectHooksInstalled,
          postToolUse: postToolUseHook,
          hookScript: hookScriptInstalled,
          path: projectSettingsPath,
        },
        skill: { installed: skillInstalled, path: skillPath },
      },
      () => {
        const colors = this.output.getColors();
        renderDiagnostics(diagnostics, colors);
      },
    );
  }

  private async removeClaudeSetup(skillPath: string): Promise<void> {
    const cwd = this.projectDir ?? process.cwd();
    const claudePaths = getClaudePaths(cwd);
    let removedHooks = false;
    let removedScripts = false;
    let removedSkill = false;

    // Remove hooks from project .claude/settings.json
    try {
      await access(claudePaths.settings);
      const content = await readFile(claudePaths.settings, 'utf-8');
      const settings = JSON.parse(content) as Record<string, unknown>;

      if (settings.hooks) {
        const hooks = settings.hooks as Record<string, unknown>;

        // Remove all tbd hooks (SessionStart, PreCompact, PostToolUse)
        const filterTbdHooks = (arr: { hooks?: { command?: string }[] }[] | undefined) => {
          if (!arr) return undefined;
          return arr.filter(
            (h) =>
              !h.hooks?.some(
                (hook) =>
                  (hook.command?.includes('tbd-closing-reminder') ?? false) ||
                  (hook.command?.includes('tbd-session.sh') ?? false) ||
                  (hook.command?.includes('tbd prime') ?? false),
              ),
          );
        };

        for (const hookType of ['PostToolUse', 'SessionStart', 'PreCompact'] as const) {
          const filtered = filterTbdHooks(hooks[hookType] as { hooks?: { command?: string }[] }[]);
          if (filtered?.length === 0) delete hooks[hookType];
          else if (filtered) hooks[hookType] = filtered;
        }

        if (Object.keys(hooks).length === 0) {
          delete settings.hooks;
        }

        await writeFile(claudePaths.settings, JSON.stringify(settings, null, 2) + '\n');
        removedHooks = true;
      }
    } catch {
      // Project settings file doesn't exist
    }

    // Remove hook script
    try {
      await rm(claudePaths.closingReminder);
      removedHooks = true;
    } catch {
      // Hook script doesn't exist
    }

    // Remove tbd scripts from project
    try {
      await rm(claudePaths.sessionScript);
      removedScripts = true;
    } catch {
      // Script doesn't exist
    }

    // Remove skill file from project
    try {
      await rm(skillPath);
      removedSkill = true;
    } catch {
      // Skill file doesn't exist
    }

    // Report what was removed
    if (removedHooks || removedScripts) {
      this.output.success('Removed hooks and scripts');
    } else {
      this.output.info('No hooks to remove');
    }

    if (removedSkill) {
      this.output.success('Removed skill file');
    } else {
      this.output.info('No skill file to remove');
    }
  }

  private async installClaudeSetup(skillPath: string): Promise<void> {
    const cwd = this.projectDir ?? process.cwd();
    const claudePaths = getClaudePaths(cwd);

    if (
      this.checkDryRun('Would install Claude Code hooks and skill file', {
        settingsPath: claudePaths.settings,
        skillPath,
      })
    ) {
      return;
    }

    try {
      // Always install to project .claude/ directory (create if needed).
      // This avoids confusion between global vs project-level settings and
      // ensures hooks work in any environment (local dev, Claude Code Cloud, etc.).
      await mkdir(claudePaths.dir, { recursive: true });

      // Read existing project settings
      let settings: Record<string, unknown> = {};
      try {
        await access(claudePaths.settings);
        const content = await readFile(claudePaths.settings, 'utf-8');
        settings = JSON.parse(content) as Record<string, unknown>;
      } catch {
        // File doesn't exist, start fresh
      }

      // Merge session hooks (SessionStart, PreCompact) - append without overwriting
      const existingHooks = (settings.hooks as Record<string, unknown[]>) || {};
      const newHooks = CLAUDE_SESSION_HOOKS.hooks as Record<string, unknown[]>;
      const mergedHooks: Record<string, unknown[]> = { ...existingHooks };

      for (const [hookType, hookEntries] of Object.entries(newHooks)) {
        if (mergedHooks[hookType]) {
          // Filter out any existing tbd-session.sh hooks before adding new ones
          const filtered = (mergedHooks[hookType] as { hooks?: { command?: string }[] }[]).filter(
            (entry) => !entry.hooks?.some((h) => h.command?.includes('tbd-session.sh')),
          );
          mergedHooks[hookType] = [...filtered, ...hookEntries];
        } else {
          mergedHooks[hookType] = hookEntries;
        }
      }

      // Merge PostToolUse hooks
      const projectHooks = CLAUDE_PROJECT_HOOKS.hooks as Record<string, unknown[]>;
      for (const [hookType, hookEntries] of Object.entries(projectHooks)) {
        mergedHooks[hookType] ??= hookEntries;
      }

      settings.hooks = mergedHooks;

      // Manage gh CLI SessionStart hook based on use_gh_cli config setting
      const useGhCli = await this.getUseGhCliSetting();
      const finalHooks = settings.hooks as Record<string, unknown>;
      let sessionStartEntries = (finalHooks.SessionStart as Record<string, unknown>[]) || [];

      if (useGhCli) {
        // Add gh CLI hook if not already present
        const hasGhCliHook = sessionStartEntries.some((h: Record<string, unknown>) =>
          (h.hooks as { command?: string }[])?.some((hook) =>
            hook.command?.includes(GH_CLI_HOOK_COMMAND_PATTERN),
          ),
        );
        if (!hasGhCliHook) {
          sessionStartEntries = [...sessionStartEntries, GH_CLI_HOOK_ENTRY];
        }

        // Install the script file
        await mkdir(claudePaths.scriptsDir, { recursive: true });
        const ghScriptContent = await loadBundledScript('ensure-gh-cli.sh');
        await writeFile(claudePaths.ghCliScript, ghScriptContent);
        await chmod(claudePaths.ghCliScript, 0o755);
        this.output.success('Installed gh CLI setup script');
      } else {
        // Remove gh CLI hook entries
        sessionStartEntries = sessionStartEntries.filter(
          (h: Record<string, unknown>) =>
            !(h.hooks as { command?: string }[])?.some((hook) =>
              hook.command?.includes(GH_CLI_HOOK_COMMAND_PATTERN),
            ),
        );

        // Remove the script file
        try {
          await rm(claudePaths.ghCliScript);
          this.output.success('Removed gh CLI setup script');
        } catch {
          // Script doesn't exist, ignore
        }
      }

      if (sessionStartEntries.length > 0) {
        finalHooks.SessionStart = sessionStartEntries;
      } else {
        delete finalHooks.SessionStart;
      }

      // Write all hooks to project settings in a single write
      await writeFile(claudePaths.settings, JSON.stringify(settings, null, 2) + '\n');
      this.output.success('Installed hooks to .claude/settings.json');

      // Install tbd-session.sh script
      await mkdir(claudePaths.scriptsDir, { recursive: true });
      await writeFile(claudePaths.sessionScript, TBD_SESSION_SCRIPT);
      await chmod(claudePaths.sessionScript, 0o755);

      // Clean up legacy scripts in project
      const legacyScripts = ['ensure-tbd-cli.sh', 'setup-tbd.sh', 'ensure-tbd.sh'];
      for (const script of legacyScripts) {
        try {
          await rm(join(claudePaths.scriptsDir, script));
        } catch {
          // Script doesn't exist, ignore
        }
      }

      this.output.success('Installed tbd session script to .claude/scripts/');

      // Add .claude/.gitignore to ignore backup files
      // NOTE: Pattern re-addition is intentional - see comment in initializeTbd
      const claudeGitignorePath = join(claudePaths.dir, '.gitignore');
      const claudeGitignoreResult = await ensureGitignorePatterns(claudeGitignorePath, [
        '# Backup files',
        '*.bak',
      ]);
      if (claudeGitignoreResult.created) {
        this.output.success('Created .claude/.gitignore');
      } else if (claudeGitignoreResult.added.length > 0) {
        this.output.success('Updated .claude/.gitignore');
      }
      // else: file is up-to-date, no message needed

      // Install hook script
      await mkdir(claudePaths.hooksDir, { recursive: true });
      await writeFile(claudePaths.closingReminder, TBD_CLOSE_PROTOCOL_SCRIPT);
      await chmod(claudePaths.closingReminder, 0o755);
      this.output.success('Installed sync reminder hook script');

      // Install Claude Code skill mirror (identical payload to the portable skill)
      const skillPayload = await buildSkillPayload(this.ctx.quiet);
      await writeSkillFile(skillPath, skillPayload);
      this.output.success('Installed skill file');
      this.output.info(`  ${skillPath}`);

      this.output.info('');
      this.output.info('What was installed:');
      this.output.info('  - Session hooks: SessionStart and PreCompact run `tbd prime`');
      this.output.info('  - Session script: .claude/scripts/tbd-session.sh');
      this.output.info('  - Project hooks: PostToolUse reminds about `tbd sync` after git push');
      this.output.info('  - Project skill: .claude/skills/tbd/SKILL.md');
    } catch (error) {
      throw new CLIError(`Failed to install: ${(error as Error).message}`);
    }
  }

  /**
   * Read the use_gh_cli setting from config. Defaults to true if not set or if
   * tbd is not yet initialized (so fresh setup installs gh CLI by default).
   */
  private async getUseGhCliSetting(): Promise<boolean> {
    try {
      const tbdRoot = await findTbdRoot(process.cwd());
      if (!tbdRoot) return true;
      const config = await readConfig(tbdRoot);
      return config.settings.use_gh_cli ?? true;
    } catch {
      return true;
    }
  }
}

class SetupCodexHandler extends BaseCommand {
  private projectDir: string | undefined;

  setProjectDir(dir: string): void {
    this.projectDir = dir;
  }

  async run(options: SetupCodexOptions): Promise<void> {
    const cwd = this.projectDir ?? process.cwd();
    const agentsPath = join(cwd, 'AGENTS.md');

    if (options.check) {
      await this.checkCodexSetup(agentsPath);
      return;
    }

    if (options.remove) {
      await this.removeCodexSection(agentsPath);
      await this.removeCodexHooks(cwd);
      return;
    }

    await this.installCodexSection(agentsPath);
    await this.installCodexHooks(cwd);
  }

  /** Install only the AGENTS.md managed block (the `agents-md` surface). */
  async runAgentsMdOnly(): Promise<void> {
    const cwd = this.projectDir ?? process.cwd();
    await this.installCodexSection(join(cwd, 'AGENTS.md'));
  }

  /** Install only the Codex lifecycle hooks (the `codex` surface). */
  async runCodexHooksOnly(): Promise<void> {
    const cwd = this.projectDir ?? process.cwd();
    await this.installCodexHooks(cwd);
  }

  /**
   * Read the use_gh_cli setting; defaults to true (so fresh setup installs it).
   */
  private async getUseGhCliSetting(cwd: string): Promise<boolean> {
    try {
      const tbdRoot = await findTbdRoot(cwd);
      if (!tbdRoot) return true;
      const config = await readConfig(tbdRoot);
      return config.settings.use_gh_cli ?? true;
    } catch {
      return true;
    }
  }

  /**
   * Install Codex lifecycle hooks: writes .codex/ scripts and a .codex/hooks.json
   * (merged idempotently with any user hooks). Scripts reuse the same bodies as
   * the Claude install but live under .codex/ so Codex never references .claude/.
   */
  private async installCodexHooks(cwd: string): Promise<void> {
    if (this.checkDryRun('Would install Codex hooks', { path: './.codex/hooks.json' })) {
      return;
    }

    const codexPaths = getCodexPaths(cwd);
    await mkdir(codexPaths.dir, { recursive: true });

    const useGhCli = await this.getUseGhCliSetting(cwd);

    // Write the hook scripts (same content as the Claude install).
    await writeFile(join(cwd, CODEX_SESSION_SCRIPT_REL), TBD_SESSION_SCRIPT);
    await chmod(join(cwd, CODEX_SESSION_SCRIPT_REL), 0o755);
    await writeFile(join(cwd, CODEX_CLOSING_REMINDER_REL), TBD_CLOSE_PROTOCOL_SCRIPT);
    await chmod(join(cwd, CODEX_CLOSING_REMINDER_REL), 0o755);
    if (useGhCli) {
      const ghScriptContent = await loadBundledScript('ensure-gh-cli.sh');
      await writeFile(join(cwd, CODEX_GH_CLI_SCRIPT_REL), ghScriptContent);
      await chmod(join(cwd, CODEX_GH_CLI_SCRIPT_REL), 0o755);
    } else {
      await rm(join(cwd, CODEX_GH_CLI_SCRIPT_REL), { force: true });
    }

    // Merge our hooks into any existing .codex/hooks.json, removing prior
    // tbd-owned entries (identified by the .codex/ script paths) so repeated
    // runs stay idempotent and user-authored hooks are preserved.
    let existing: { hooks?: Record<string, unknown[]> } = {};
    try {
      existing = JSON.parse(await readFile(codexPaths.hooks, 'utf-8')) as {
        hooks?: Record<string, unknown[]>;
      };
    } catch {
      // No existing file or unparseable; start fresh.
    }

    const isTbdOwned = (entry: unknown): boolean => {
      const hooks = (entry as { hooks?: { command?: string }[] }).hooks ?? [];
      return hooks.some((h) => h.command?.includes('.codex/'));
    };

    const merged: Record<string, unknown[]> = { ...(existing.hooks ?? {}) };
    const tbdHooks = getCodexHooksConfig(useGhCli).hooks;
    for (const [event, entries] of Object.entries(tbdHooks)) {
      const userEntries = (merged[event] ?? []).filter((e) => !isTbdOwned(e));
      merged[event] = [...userEntries, ...entries];
    }

    await writeFile(
      codexPaths.hooks,
      JSON.stringify({ ...existing, hooks: merged }, null, 2) + '\n',
    );
    this.output.success('Installed Codex hooks (.codex/hooks.json)');
  }

  /**
   * Remove tbd-owned Codex hook entries and scripts, preserving user hooks.
   */
  private async removeCodexHooks(cwd: string): Promise<void> {
    const codexPaths = getCodexPaths(cwd);
    try {
      const existing = JSON.parse(await readFile(codexPaths.hooks, 'utf-8')) as {
        hooks?: Record<string, unknown[]>;
      };
      const hooks = existing.hooks ?? {};
      for (const event of Object.keys(hooks)) {
        const kept = (hooks[event] ?? []).filter((entry) => {
          const entryHooks = (entry as { hooks?: { command?: string }[] }).hooks ?? [];
          return !entryHooks.some((h) => h.command?.includes('.codex/'));
        });
        if (kept.length === 0) {
          delete hooks[event];
        } else {
          hooks[event] = kept;
        }
      }
      if (Object.keys(hooks).length === 0) {
        await rm(codexPaths.hooks, { force: true });
      } else {
        await writeFile(codexPaths.hooks, JSON.stringify({ ...existing, hooks }, null, 2) + '\n');
      }
    } catch {
      // No hooks file; nothing to remove.
    }
    for (const rel of [
      CODEX_SESSION_SCRIPT_REL,
      CODEX_CLOSING_REMINDER_REL,
      CODEX_GH_CLI_SCRIPT_REL,
    ]) {
      await rm(join(cwd, rel), { force: true });
    }
  }

  private async checkCodexSetup(agentsPath: string): Promise<void> {
    const agentsRelPath = './AGENTS.md';
    try {
      await access(agentsPath);
      const content = await readFile(agentsPath, 'utf-8');

      if (content.includes(CODEX_BEGIN_MARKER)) {
        const diagnostic: DiagnosticResult = {
          name: 'AGENTS.md',
          status: 'ok',
          message: 'tbd section found',
          path: agentsRelPath,
        };
        this.output.data({ installed: true, path: agentsPath, hastbdSection: true }, () => {
          const colors = this.output.getColors();
          renderDiagnostics([diagnostic], colors);
        });
      } else {
        const diagnostic: DiagnosticResult = {
          name: 'AGENTS.md',
          status: 'warn',
          message: 'exists but no tbd section',
          path: agentsRelPath,
          suggestion: 'Run: tbd setup --auto',
        };
        this.output.data({ installed: false, path: agentsPath, hastbdSection: false }, () => {
          const colors = this.output.getColors();
          renderDiagnostics([diagnostic], colors);
        });
      }
    } catch {
      const diagnostic: DiagnosticResult = {
        name: 'AGENTS.md',
        status: 'warn',
        message: 'not found',
        path: agentsRelPath,
        suggestion: 'Run: tbd setup --auto',
      };
      this.output.data({ installed: false, expectedPath: agentsPath }, () => {
        const colors = this.output.getColors();
        renderDiagnostics([diagnostic], colors);
      });
    }
  }

  private async removeCodexSection(agentsPath: string): Promise<void> {
    try {
      await access(agentsPath);
      const content = await readFile(agentsPath, 'utf-8');

      if (!content.includes(CODEX_BEGIN_MARKER)) {
        this.output.info('No tbd section found in AGENTS.md');
        return;
      }

      const newContent = this.removetbdSection(content);
      const trimmed = newContent.trim();

      if (trimmed === '' || trimmed === '# Project Instructions for AI Agents') {
        // File is empty or only has the default header, remove it
        await rm(agentsPath);
        this.output.success('Removed AGENTS.md (file was empty after removing tbd section)');
      } else {
        await writeFile(agentsPath, newContent);
        this.output.success('Removed tbd section from AGENTS.md');
      }
    } catch {
      this.output.info('AGENTS.md not found');
    }
  }

  private async installCodexSection(agentsPath: string): Promise<void> {
    if (this.checkDryRun('Would create/update AGENTS.md', { path: agentsPath })) {
      return;
    }

    try {
      let existingContent = '';
      try {
        await access(agentsPath);
        existingContent = await readFile(agentsPath, 'utf-8');
      } catch {
        // File doesn't exist
      }

      let newContent: string;

      const tbdSection = getCodexTbdSection();

      if (existingContent) {
        if (existingContent.includes(CODEX_BEGIN_MARKER)) {
          // Refuse to downgrade a block written by a newer tbd.
          assertNotNewerFormat(existingContent, 'AGENTS.md');
          // Update existing section
          newContent = this.updatetbdSection(existingContent, tbdSection);
          await writeFile(agentsPath, newContent);
          this.output.success('Updated existing tbd section in AGENTS.md');
        } else {
          // Append section to existing file
          newContent = existingContent + '\n\n' + tbdSection;
          await writeFile(agentsPath, newContent);
          this.output.success('Added tbd section to existing AGENTS.md');
        }
      } else {
        // Create new file
        const newAgentsFile = getCodexNewAgentsFile();
        await writeFile(agentsPath, newAgentsFile);
        this.output.success('Created new AGENTS.md with tbd integration');
      }

      this.output.info(`  File: ${agentsPath}`);
      this.output.info('');
      this.output.info('Codex and other AGENTS.md-compatible tools will automatically');
      this.output.info('read this file on session start.');
    } catch (error) {
      throw new CLIError(`Failed to update AGENTS.md: ${(error as Error).message}`);
    }
  }

  private updatetbdSection(content: string, tbdSection: string): string {
    const startIdx = content.indexOf(CODEX_BEGIN_MARKER);
    const endIdx = content.indexOf(CODEX_END_MARKER);

    if (startIdx === -1 || endIdx === -1 || startIdx > endIdx) {
      // Markers not found or invalid, append instead
      return content + '\n\n' + tbdSection;
    }

    // Find the end of the end marker line
    let endOfEndMarker = endIdx + CODEX_END_MARKER.length;
    const nextNewline = content.indexOf('\n', endOfEndMarker);
    if (nextNewline !== -1) {
      endOfEndMarker = nextNewline + 1;
    }

    return content.slice(0, startIdx) + tbdSection + content.slice(endOfEndMarker);
  }

  private removetbdSection(content: string): string {
    const startIdx = content.indexOf(CODEX_BEGIN_MARKER);
    const endIdx = content.indexOf(CODEX_END_MARKER);

    if (startIdx === -1 || endIdx === -1 || startIdx > endIdx) {
      return content;
    }

    // Find the end of the end marker line
    let endOfEndMarker = endIdx + CODEX_END_MARKER.length;
    const nextNewline = content.indexOf('\n', endOfEndMarker);
    if (nextNewline !== -1) {
      endOfEndMarker = nextNewline + 1;
    }

    // Also remove leading blank lines before the section
    let trimStart = startIdx;
    while (trimStart > 0 && (content[trimStart - 1] === '\n' || content[trimStart - 1] === '\r')) {
      trimStart--;
    }

    return content.slice(0, trimStart) + content.slice(endOfEndMarker);
  }
}

// ============================================================================
// Setup Default Handler (for --auto and --interactive modes)
// ============================================================================

interface SetupDefaultOptions {
  auto?: boolean;
  interactive?: boolean;
  fromBeads?: boolean;
  prefix?: string;
  force?: boolean;
  ghCli?: boolean; // Commander sets to false when --no-gh-cli is passed
}

/**
 * Default handler for `tbd setup` with --auto or --interactive flags.
 *
 * This implements the unified onboarding flow:
 * - `tbd setup --auto`: Non-interactive setup with smart defaults (for agents)
 * - `tbd setup --interactive`: Interactive setup with prompts (for humans)
 *
 * Decision tree:
 * 1. Not in git repo → Error (git init first)
 * 2. Resolve to git root → All paths relative to .git/ parent
 * 3. Has .tbd/ → Already initialized, check/update integrations
 * 4. Has .beads/ → Beads migration flow
 * 5. Fresh repo → Initialize + configure integrations
 */
class SetupDefaultHandler extends BaseCommand {
  private cmd: Command;

  constructor(command: Command) {
    super(command);
    this.cmd = command;
  }

  async run(options: SetupDefaultOptions): Promise<void> {
    const colors = this.output.getColors();
    const cwd = process.cwd();

    // Determine mode
    const isAutoMode = options.auto === true;
    // Note: options.interactive will be used when we add interactive prompts

    // Header
    console.log(colors.bold('tbd: Git-native issue tracking for AI agents and humans'));
    console.log('');

    // Check if in git repo and resolve to git root
    const inGitRepo = await isInGitRepo(cwd);
    if (!inGitRepo) {
      throw new CLIError('Not a git repository. Run `git init` first.');
    }

    // Resolve to git root so .tbd/ and .claude/ are always adjacent to .git/
    const gitRoot = await findGitRoot(cwd);
    if (!gitRoot) {
      throw new CLIError('Could not determine git repository root.');
    }

    // Use git root as the working directory for all setup operations
    const projectDir = gitRoot;

    // Check current state
    const hasTbd = await isInitialized(projectDir);
    const hasBeads = await pathExists(join(projectDir, '.beads'));

    // Validate --from-beads flag requires .beads/ directory
    if (options.fromBeads && !hasBeads) {
      throw new CLIError(
        'The --from-beads flag requires a .beads/ directory to migrate from.\n' +
          'For fresh setup, use: tbd setup --auto --prefix=<name>',
      );
    }

    console.log('Checking repository...');
    console.log(`  ${colors.success('✓')} Git repository detected`);

    if (hasTbd) {
      // Already initialized flow - check for migrations
      const { config, migrated, changes } = await readConfigWithMigration(projectDir);
      console.log(`  ${colors.success('✓')} tbd initialized (prefix: ${config.display.id_prefix})`);

      // Apply --no-gh-cli flag to config if specified
      let ghCliChanged = false;
      if (options.ghCli === false && config.settings.use_gh_cli !== false) {
        config.settings.use_gh_cli = false;
        ghCliChanged = true;
      }

      if (migrated) {
        // Initialize the shared common-dir layout under the data-sync lock.
        // prepareDataSyncContext also persists the migrated config to disk, so
        // we don't need a separate writeConfig() for the migration itself.
        await withDataSyncContext(projectDir, { lock: true }, async () => undefined);
        console.log(`  ${colors.success('✓')} Config migrated to latest format`);
        for (const change of changes) {
          console.log(`      ${colors.dim(change)}`);
        }
      }

      // Persist non-migration changes (e.g., --no-gh-cli) as a single explicit write.
      if (ghCliChanged) {
        await writeConfig(projectDir, config);
        console.log(`  ${colors.success('✓')} Disabled gh CLI auto-setup`);
      }

      console.log('');
      await this.handleAlreadyInitialized(projectDir, isAutoMode);
    } else if (hasBeads || options.fromBeads) {
      // Beads migration flow
      console.log(`  ${colors.dim('✗')} tbd not initialized`);
      console.log(`  ${colors.warn('!')} Beads detected (.beads/ directory found)`);
      console.log('');
      await this.handleBeadsMigration(projectDir, isAutoMode, options);
    } else {
      // Fresh setup flow
      console.log(`  ${colors.dim('✗')} tbd not initialized`);
      console.log('');
      await this.handleFreshSetup(projectDir, isAutoMode, options);
    }
  }

  private async handleAlreadyInitialized(projectDir: string, _isAutoMode: boolean): Promise<void> {
    const colors = this.output.getColors();

    // Ensure .tbd/.gitignore is up-to-date (may have new patterns from newer versions)
    const tbdGitignoreResult = await ensureGitignorePatterns(
      join(projectDir, TBD_DIR, '.gitignore'),
      [
        '# Synced documentation cache (regenerated by tbd sync --docs)',
        'docs/',
        '',
        '# Hidden worktree for tbd-sync branch',
        `${WORKTREE_DIR_NAME}/`,
        '',
        '# Data sync directory (only exists in worktree)',
        `${DATA_SYNC_DIR_NAME}/`,
        '',
        '# Local state',
        'state.yml',
        '',
        '# Migration backups (local only, not synced)',
        'backups/',
        '',
        '# Temporary files',
        '*.tmp',
        '*.temp',
        '',
        '# workspaces/ stores state (including outbox) committed to the working branch',
        '!workspaces/',
      ],
    );
    if (tbdGitignoreResult.created) {
      console.log(`  ${colors.success('✓')} Created .tbd/.gitignore`);
    } else if (tbdGitignoreResult.added.length > 0) {
      console.log(`  ${colors.success('✓')} Updated .tbd/.gitignore with new patterns`);
    }

    // Ensure .tbd/.gitattributes has merge protection for outbox ids.yml
    // Placed inside .tbd/ so all tbd settings are self-contained in one directory.
    // Git supports .gitattributes in subdirectories — patterns are relative to that directory.
    const gitattributesResult = await ensureGitignorePatterns(
      join(projectDir, TBD_DIR, '.gitattributes'),
      [
        '# Protect ID mappings from merge deletion (always keep all rows)',
        '**/mappings/ids.yml merge=union',
      ],
    );
    if (gitattributesResult.created) {
      console.log(`  ${colors.success('✓')} Created .tbd/.gitattributes (merge protection)`);
    } else if (gitattributesResult.added.length > 0) {
      console.log(`  ${colors.success('✓')} Updated .tbd/.gitattributes (merge protection)`);
    }

    console.log('Checking integrations...');

    // Use SetupAutoHandler to configure integrations
    const autoHandler = new SetupAutoHandler(this.cmd);
    await autoHandler.run(projectDir);

    console.log('');
    console.log(colors.success('All set!'));
  }

  private async handleBeadsMigration(
    cwd: string,
    isAutoMode: boolean,
    options: SetupDefaultOptions,
  ): Promise<void> {
    const colors = this.output.getColors();

    if (isAutoMode) {
      console.log(`  ${colors.warn('!')} Beads detected - auto-migrating`);
      console.log('');
    }

    // Get prefix from beads config or use provided --prefix
    const beadsPrefix = await getBeadsPrefix(cwd);
    const prefix = options.prefix ?? beadsPrefix;

    if (!prefix) {
      throw new CLIError(
        'Could not read prefix from beads config.\n' +
          'Please specify a prefix (2-8 letters recommended):\n' +
          '  tbd setup --auto --prefix=tbd',
      );
    }

    // Hard validation: always enforced
    if (!isValidPrefix(prefix)) {
      throw new CLIError(
        'Invalid prefix format.\n' +
          'Prefix must be 1-20 lowercase characters:\n' +
          '  - Must start with a letter (a-z)\n' +
          '  - Must end with alphanumeric (a-z, 0-9)\n' +
          '  - Middle characters can include dots (.) and underscores (_)\n' +
          '  - No dashes allowed (breaks ID syntax)\n\n' +
          'Please specify a valid prefix:\n' +
          '  tbd setup --from-beads --prefix=tbd',
      );
    }

    // Soft validation: only for user-provided prefixes (not from beads config)
    // If prefix came from beads config, we accept it to ease migration
    const prefixFromBeads = beadsPrefix && prefix === beadsPrefix;
    if (!prefixFromBeads && !isRecommendedPrefix(prefix) && !options.force) {
      throw new CLIError(
        `Prefix "${prefix}" is not recommended.\n` +
          'Recommended prefixes are 2-8 alphabetic characters (e.g., "tbd", "myp", "proj").\n\n' +
          'If you really want to use this prefix, add --force to override.\n\n' +
          'Example:\n' +
          `  tbd setup --from-beads --prefix=${prefix} --force`,
      );
    }

    // Initialize tbd first
    await this.initializeTbd(cwd, prefix);

    // Apply --no-gh-cli flag to newly created config
    if (options.ghCli === false) {
      const config = await readConfig(cwd);
      config.settings.use_gh_cli = false;
      await writeConfig(cwd, config);
      console.log(`  ${colors.success('✓')} Disabled gh CLI auto-setup`);
    }

    // Import beads issues from the JSONL file
    console.log('Importing from Beads...');
    const beadsDir = join(cwd, '.beads');
    const jsonlPath = join(beadsDir, 'issues.jsonl');

    try {
      await access(jsonlPath);
      // Import directly from the JSONL file (tbd is already initialized)
      const result = spawnSync('tbd', ['import', jsonlPath, '--verbose'], {
        cwd,
        stdio: 'inherit',
      });
      if (result.status !== 0) {
        console.log(colors.warn('Warning: Some issues may not have imported correctly'));
      }
    } catch {
      console.log(colors.dim('  No issues.jsonl found - skipping import'));
    }

    // Disable beads
    await this.disableBeads(cwd);

    console.log('');
    console.log('Configuring integrations...');

    // Configure integrations
    const autoHandler = new SetupAutoHandler(this.cmd);
    await autoHandler.run(cwd);

    console.log('');
    console.log(colors.success('Setup complete!'));

    this.showWhatsNext(colors);

    // Show dashboard after setup
    spawnSync('tbd', ['prime'], { stdio: 'inherit' });

    // Mark welcome as seen since the user got the full onboarding experience
    try {
      await markWelcomeSeen(cwd);
    } catch {
      // Non-critical: don't fail setup if state write fails
    }
  }

  private async handleFreshSetup(
    cwd: string,
    isAutoMode: boolean,
    options: SetupDefaultOptions,
  ): Promise<void> {
    const colors = this.output.getColors();

    // Require --prefix for fresh setup (no auto-detection)
    const prefix = options.prefix;

    if (!prefix) {
      throw new CLIError(
        '--prefix is required for tbd setup --auto\n\n' +
          'The --prefix flag specifies your project name for issue IDs.\n' +
          'Use a short 2-4 letter prefix so issue IDs stand out clearly.\n\n' +
          'Example:\n' +
          '  tbd setup --auto --prefix=tbd    # Issues: tbd-a1b2\n' +
          '  tbd setup --auto --prefix=myp    # Issues: myp-c3d4\n\n' +
          'Note: If migrating from beads, the prefix is automatically read from your beads config.',
      );
    }

    // Hard validation: always enforced
    if (!isValidPrefix(prefix)) {
      throw new CLIError(
        'Invalid prefix format.\n' +
          'Prefix must be 1-20 lowercase characters:\n' +
          '  - Must start with a letter (a-z)\n' +
          '  - Must end with alphanumeric (a-z, 0-9)\n' +
          '  - Middle characters can include dots (.) and underscores (_)\n' +
          '  - No dashes allowed (breaks ID syntax)\n\n' +
          'Example:\n' +
          '  tbd setup --auto --prefix=tbd',
      );
    }

    // Soft validation: recommended format (2-8 alphabetic)
    if (!isRecommendedPrefix(prefix) && !options.force) {
      throw new CLIError(
        `Prefix "${prefix}" is not recommended.\n` +
          'Recommended prefixes are 2-8 alphabetic characters (e.g., "tbd", "myp", "proj").\n\n' +
          'If you really want to use this prefix, add --force to override.\n\n' +
          'Example:\n' +
          `  tbd setup --auto --prefix=${prefix} --force`,
      );
    }

    console.log(`Initializing with prefix "${prefix}"...`);

    await this.initializeTbd(cwd, prefix);

    // Apply --no-gh-cli flag to newly created config
    if (options.ghCli === false) {
      const config = await readConfig(cwd);
      config.settings.use_gh_cli = false;
      await writeConfig(cwd, config);
      console.log(`  ${colors.success('✓')} Disabled gh CLI auto-setup`);
    }

    console.log('');
    console.log('Configuring integrations...');

    // Configure integrations
    const autoHandler = new SetupAutoHandler(this.cmd);
    await autoHandler.run(cwd);

    console.log('');
    console.log(colors.success('Setup complete!'));

    this.showWhatsNext(colors);

    // Show dashboard after setup
    spawnSync('tbd', ['prime'], { stdio: 'inherit' });

    // Mark welcome as seen since the user got the full onboarding experience
    try {
      await markWelcomeSeen(cwd);
    } catch {
      // Non-critical: don't fail setup if state write fails
    }
  }

  /**
   * Show "What's Next" guidance after setup completion.
   * Framed as what users can SAY to get help, not as CLI commands to run.
   */
  private showWhatsNext(colors: ReturnType<typeof this.output.getColors>): void {
    console.log('');
    console.log(colors.bold("WHAT'S NEXT"));
    console.log('');
    console.log('  Try saying things like:');
    console.log('    "There\'s a bug where ..."       → Creates and tracks a bug');
    console.log('    "Let\'s plan a new feature"      → Walks through a planning spec');
    console.log('    "Let\'s work on current issues"  → Shows ready issues to tackle');
    console.log('    "Commit this code"               → Reviews and commits properly');
    console.log('    "Review for best practices"      → Code review with guidelines');
    console.log('');
  }

  private async initializeTbd(cwd: string, prefix: string): Promise<void> {
    const colors = this.output.getColors();

    // 1. Create .tbd/ directory with config.yml
    const config = await initConfig(cwd, VERSION, prefix);
    console.log(`  ${colors.success('✓')} Created .tbd/config.yml`);

    // 2. Create/update .tbd/.gitignore (idempotent)
    // NOTE: Pattern re-addition is intentional - these are tool-managed files
    // that are regenerated from the npm package on every setup. If a user removes
    // a pattern, we re-add it because tracking these directories in git would
    // cause noise on every tbd upgrade.
    const tbdGitignoreResult = await ensureGitignorePatterns(join(cwd, TBD_DIR, '.gitignore'), [
      '# Synced documentation cache (regenerated by tbd sync --docs)',
      'docs/',
      '',
      '# Hidden worktree for tbd-sync branch',
      `${WORKTREE_DIR_NAME}/`,
      '',
      '# Data sync directory (only exists in worktree)',
      `${DATA_SYNC_DIR_NAME}/`,
      '',
      '# Local state',
      'state.yml',
      '',
      '# Migration backups (local only, not synced)',
      'backups/',
      '',
      '# Temporary files',
      '*.tmp',
      '*.temp',
      '',
      '# workspaces/ stores state (including outbox) committed to the working branch',
      '!workspaces/',
    ]);
    if (tbdGitignoreResult.created) {
      console.log(`  ${colors.success('✓')} Created .tbd/.gitignore`);
    } else if (tbdGitignoreResult.added.length > 0) {
      console.log(`  ${colors.success('✓')} Updated .tbd/.gitignore`);
    }
    // else: file is up-to-date, no message needed

    // 2b. Create/update .tbd/.gitattributes with merge strategies for tbd files.
    // Placed inside .tbd/ so all tbd settings are self-contained in one directory.
    // Git supports .gitattributes in subdirectories — patterns are relative to that directory.
    // The outbox ids.yml must use "merge=union" so git never drops rows during merge.
    // Without this, AI agents resolving merge conflicts can delete the mapping file
    // (main has no outbox, so the merge considers "no file" as the correct version),
    // causing all tbd commands to crash with "No short ID mapping found".
    // See: https://github.com/jlevy/tbd/issues/99
    const gitattributesResult = await ensureGitignorePatterns(
      join(cwd, TBD_DIR, '.gitattributes'),
      [
        '# Protect ID mappings from merge deletion (always keep all rows)',
        '**/mappings/ids.yml merge=union',
      ],
    );
    if (gitattributesResult.created) {
      console.log(`  ${colors.success('✓')} Created .tbd/.gitattributes (merge protection)`);
    } else if (gitattributesResult.added.length > 0) {
      console.log(`  ${colors.success('✓')} Updated .tbd/.gitattributes (merge protection)`);
    }

    // 3. Initialize worktree for sync branch
    try {
      // Serialize fresh-setup worktree creation + layout write under the shared
      // lock so concurrent agents from sibling worktrees cannot race init.
      await withSharedDataSyncLock(cwd, async () => {
        await initWorktree(cwd);
        const sharedPaths = await resolveSharedTbdPaths(cwd);
        await writeCommonDirLayout(sharedPaths, config);
      });

      // Verify worktree health after creation (prevents silent failures)
      const health = await checkWorktreeHealth(cwd);
      if (health.valid) {
        console.log(`  ${colors.success('✓')} Initialized sync branch`);
      } else {
        console.log(
          `  ${colors.warn('!')} Sync branch created but verification failed (status: ${health.status})`,
        );
        console.log(`      Run 'tbd doctor' to diagnose`);
      }
    } catch {
      // Non-fatal - sync will work, just not optimally
      console.log(`  ${colors.dim('○')} Sync branch will be created on first sync`);
    }
  }

  private async disableBeads(cwd: string): Promise<void> {
    const colors = this.output.getColors();

    // Move .beads to .beads-disabled
    const beadsDir = join(cwd, '.beads');
    const disabledDir = join(cwd, '.beads-disabled');

    try {
      await rename(beadsDir, disabledDir);
      console.log(`  ${colors.success('✓')} Disabled beads (moved to .beads-disabled/)`);
    } catch {
      console.log(`  ${colors.dim('○')} Could not move .beads directory`);
    }
  }
}

// ============================================================================
// Auto Setup Command
// ============================================================================

interface AutoSetupResult {
  name: string;
  detected: boolean;
  installed: boolean;
  alreadyInstalled: boolean;
  error?: string;
}

/**
 * Agent integration surfaces that `tbd setup` can install, in install/report
 * order. Each is selectable via `--surfaces=<comma-list>`; with the flag omitted,
 * all are installed. Adding an agent is one entry here plus a case in
 * `installSurface` — there is no per-agent flag to add.
 */
const SETUP_SURFACE_IDS = ['portable', 'agents-md', 'claude', 'codex'] as const;
type SurfaceId = (typeof SETUP_SURFACE_IDS)[number];
const SURFACE_DISPLAY_NAME: Record<SurfaceId, string> = {
  portable: 'Portable Agent Skill',
  'agents-md': 'AGENTS.md',
  claude: 'Claude Code',
  codex: 'Codex hooks',
};

class SetupAutoHandler extends BaseCommand {
  private cmd: Command;

  constructor(command: Command) {
    super(command);
    this.cmd = command;
  }

  /**
   * Clean up legacy scripts from project .claude/scripts/ directory.
   * This runs during any setup, regardless of whether Claude Code is detected,
   * since we want to clean up old project-level scripts that are no longer needed.
   */
  private async cleanupLegacyProjectScripts(cwd: string): Promise<string[]> {
    const scriptsDir = join(cwd, '.claude', 'scripts');
    const scriptsRemoved: string[] = [];

    try {
      await access(scriptsDir);
      const entries = await readdir(scriptsDir, { withFileTypes: true });

      for (const entry of entries) {
        if (entry.isFile()) {
          const filename = entry.name;
          // Check against known legacy script names
          if (LEGACY_TBD_SCRIPTS.includes(filename)) {
            // In dry-run mode, report what would be removed but don't touch disk.
            if (this.ctx.dryRun) {
              scriptsRemoved.push(filename);
              continue;
            }
            try {
              await rm(join(scriptsDir, filename));
              scriptsRemoved.push(filename);
            } catch {
              // Ignore removal errors
            }
          }
        }
      }
    } catch {
      // Scripts directory doesn't exist, nothing to clean
    }

    return scriptsRemoved;
  }

  /**
   * Filter out hook entries that match legacy tbd patterns from project settings.
   */
  private filterLegacyHooks(
    hookList: { hooks?: { command?: string }[] }[],
  ): { hooks?: { command?: string }[] }[] {
    return hookList.filter((entry) => {
      // Check if any hook command matches legacy patterns
      const hasLegacyCommand = entry.hooks?.some((hook) => {
        if (!hook.command) return false;
        return LEGACY_TBD_HOOK_PATTERNS.some((pattern) => pattern.test(hook.command!));
      });
      // Keep entries that DON'T have legacy commands
      return !hasLegacyCommand;
    });
  }

  /**
   * Clean up legacy hooks from project .claude/settings.json.
   * This runs during any setup, regardless of whether Claude Code is detected.
   */
  private async cleanupLegacyProjectHooks(cwd: string): Promise<number> {
    const projectSettingsPath = join(cwd, '.claude', 'settings.json');
    let hooksRemoved = 0;

    try {
      await access(projectSettingsPath);
      const content = await readFile(projectSettingsPath, 'utf-8');
      const settings = JSON.parse(content) as Record<string, unknown>;

      if (settings.hooks) {
        const hooks = settings.hooks as Record<string, unknown>;
        let modified = false;

        for (const hookType of ['SessionStart', 'PreCompact', 'PostToolUse']) {
          if (hooks[hookType]) {
            const hookList = hooks[hookType] as { hooks?: { command?: string }[] }[];
            const filtered = this.filterLegacyHooks(hookList);
            if (filtered.length !== hookList.length) {
              hooksRemoved += hookList.length - filtered.length;
              hooks[hookType] = filtered.length > 0 ? filtered : undefined;
              if (!hooks[hookType]) delete hooks[hookType];
              modified = true;
            }
          }
        }

        // In dry-run mode, return the count of hooks that would be removed but
        // never write the mutated settings back to disk.
        if (modified && !this.ctx.dryRun) {
          if (Object.keys(hooks).length === 0) {
            delete settings.hooks;
          }
          await writeFile(projectSettingsPath, JSON.stringify(settings, null, 2) + '\n');
        }
      }
    } catch {
      // Project settings file doesn't exist, nothing to clean
    }

    return hooksRemoved;
  }

  async run(projectDir?: string): Promise<void> {
    const colors = this.output.getColors();
    const cwd = projectDir ?? process.cwd();
    const results: AutoSetupResult[] = [];

    // Clean up legacy project-level scripts and hooks FIRST,
    // regardless of whether any coding agent is detected.
    // This ensures old tbd scripts are removed even if user switches tools.
    const scriptsRemoved = await this.cleanupLegacyProjectScripts(cwd);
    const hooksRemoved = await this.cleanupLegacyProjectHooks(cwd);
    if (scriptsRemoved.length > 0 || hooksRemoved > 0) {
      const parts = [];
      if (scriptsRemoved.length > 0) parts.push(`${scriptsRemoved.length} script(s)`);
      if (hooksRemoved > 0) parts.push(`${hooksRemoved} hook(s)`);
      // Past tense only when we actually wrote; otherwise make clear it's hypothetical.
      if (this.ctx.dryRun) {
        this.output.dryRun(`Would clean up legacy ${parts.join(' and ')}`);
      } else {
        console.log(colors.dim(`Cleaned up legacy ${parts.join(' and ')}`));
      }
    }

    // Sync docs using DocSync
    await this.syncDocs(cwd);

    // Install the selected surfaces. With no --surfaces flag, all are installed;
    // there is no detection gating — project-local integration files are cheap
    // and harmless, and installing them all is what makes the skill portable.
    const selected = this.resolveSurfaces();
    const skippedSurfaces: string[] = [];
    for (const id of SETUP_SURFACE_IDS) {
      if (!selected.has(id)) {
        skippedSurfaces.push(SURFACE_DISPLAY_NAME[id]);
        continue;
      }
      results.push(await this.installSurface(id, cwd));
    }

    // Report results
    const installed = results.filter((r) => r.installed && !r.alreadyInstalled);
    const alreadyInstalled = results.filter((r) => r.alreadyInstalled);
    const failed = results.filter((r) => r.error);

    if (installed.length > 0) {
      console.log(colors.bold('Configured integrations:'));
      for (const r of installed) {
        console.log(`  ${colors.success('✓')} ${r.name}`);
      }
    }

    if (alreadyInstalled.length > 0) {
      console.log(colors.dim('Already configured:'));
      for (const r of alreadyInstalled) {
        console.log(`  ${colors.dim('✓')} ${r.name}`);
      }
    }

    if (skippedSurfaces.length > 0) {
      console.log(colors.dim('Skipped (not in --surfaces):'));
      for (const name of skippedSurfaces) {
        console.log(`  ${colors.dim('-')} ${name}`);
      }
    }

    if (failed.length > 0) {
      for (const r of failed) {
        console.log(colors.warn(`  ! ${r.name}: ${r.error}`));
      }
    }
  }

  /**
   * Sync docs using syncDocsWithDefaults.
   * Uses the shared function that merges bundled defaults, prunes stale entries,
   * syncs files, and updates config.
   */
  private async syncDocs(cwd: string): Promise<void> {
    const colors = this.output.getColors();

    // Ensure docs directories exist
    await mkdir(join(cwd, TBD_SHORTCUTS_SYSTEM), { recursive: true });
    await mkdir(join(cwd, TBD_SHORTCUTS_STANDARD), { recursive: true });
    await mkdir(join(cwd, TBD_GUIDELINES_DIR), { recursive: true });
    await mkdir(join(cwd, TBD_TEMPLATES_DIR), { recursive: true });

    // Use shared sync function that handles:
    // - Merging bundled defaults with user config
    // - Pruning stale internal entries
    // - Syncing files to .tbd/docs/
    // - Writing config if changed
    // - Updating last_doc_sync_at in state
    const result = await syncDocsWithDefaults(cwd);

    // Report sync results
    if (result.configChanged) {
      console.log(colors.dim('Updated docs_cache config'));
    }

    const total = result.added.length + result.updated.length;
    if (total > 0) {
      console.log(colors.dim(`Synced ${total} doc(s) to ${TBD_DOCS_DIR}/`));
    }
    if (result.removed.length > 0) {
      console.log(colors.dim(`Removed ${result.removed.length} outdated doc(s)`));
    }
    if (result.pruned.length > 0) {
      console.log(colors.dim(`Pruned ${result.pruned.length} stale config entry/entries`));
    }
    if (result.errors.length > 0) {
      for (const { path, error } of result.errors) {
        console.log(colors.warn(`Warning: ${path}: ${error}`));
      }
    }
  }

  /**
   * Resolve the set of surfaces to install from `--surfaces=<comma-list>`. With
   * the flag omitted, every surface is installed. `all` is an alias for the full
   * set; an unknown surface ID is a hard error.
   */
  private resolveSurfaces(): Set<SurfaceId> {
    const opts: { surfaces?: string } = this.cmd.optsWithGlobals();
    if (opts.surfaces === undefined) {
      return new Set(SETUP_SURFACE_IDS);
    }
    const valid = new Set<string>(SETUP_SURFACE_IDS);
    const requested = opts.surfaces
      .split(',')
      .map((s) => s.trim())
      .filter((s) => s.length > 0);
    const selected = new Set<SurfaceId>();
    for (const name of requested) {
      if (name === 'all') {
        for (const id of SETUP_SURFACE_IDS) selected.add(id);
        continue;
      }
      if (valid.has(name)) {
        selected.add(name as SurfaceId);
        continue;
      }
      throw new CLIError(
        `Unknown surface "${name}". Valid surfaces: ${SETUP_SURFACE_IDS.join(', ')}, all.`,
      );
    }
    return selected;
  }

  /** Dispatch the install for a single surface by ID. */
  private async installSurface(id: SurfaceId, cwd: string): Promise<AutoSetupResult> {
    switch (id) {
      case 'portable':
        return this.installPortableSurface(cwd);
      case 'agents-md':
        return this.installAgentsMdSurface(cwd);
      case 'claude':
        return this.installClaudeSurface(cwd);
      case 'codex':
        return this.installCodexSurface(cwd);
      default: {
        // Exhaustiveness guard: adding a SurfaceId without a case here is a
        // compile-time error, not a silent `undefined` return.
        const unhandled: never = id;
        throw new Error(`Unhandled setup surface: ${String(unhandled)}`);
      }
    }
  }

  /**
   * Write the canonical portable Agent Skill to .agents/skills/tbd/SKILL.md.
   * Read natively by Codex, Gemini CLI, Cursor, and other Agent Skills clients.
   */
  private async installPortableSurface(cwd: string): Promise<AutoSetupResult> {
    const result: AutoSetupResult = {
      name: SURFACE_DISPLAY_NAME.portable,
      detected: true,
      installed: false,
      alreadyInstalled: false,
    };
    if (this.checkDryRun('Would install portable Agent Skill', { path: AGENTS_SKILL_DISPLAY })) {
      return result;
    }
    try {
      const { portable } = getAgentSkillPaths(cwd);
      const payload = await buildSkillPayload(this.ctx.quiet);
      await writeSkillFile(portable, payload);
      result.installed = true;
    } catch (error) {
      // The format guard is a hard stop — surface it instead of swallowing.
      if (error instanceof CLIError) {
        throw error;
      }
      result.error = (error as Error).message;
    }
    return result;
  }

  /** Install the Claude Code surface: skill mirror + .claude/settings.json hooks. */
  private async installClaudeSurface(cwd: string): Promise<AutoSetupResult> {
    const result: AutoSetupResult = {
      name: SURFACE_DISPLAY_NAME.claude,
      detected: true,
      installed: false,
      alreadyInstalled: false,
    };

    const claudePaths = getClaudePaths(cwd);
    try {
      if (await pathExists(claudePaths.settings)) {
        const content = await readFile(claudePaths.settings, 'utf-8');
        const settings = JSON.parse(content) as Record<string, unknown>;
        const hooks = settings.hooks as Record<string, unknown> | undefined;
        if (hooks) {
          const sessionStart = hooks.SessionStart as { hooks?: { command?: string }[] }[];
          const hasTbdHook = sessionStart?.some((h) =>
            h.hooks?.some(
              (hook) =>
                (hook.command?.includes('tbd prime') ?? false) ||
                (hook.command?.includes('tbd-session.sh') ?? false),
            ),
          );
          if (hasTbdHook && (await pathExists(claudePaths.skill))) {
            result.alreadyInstalled = true;
            // Still run the handler to refresh the skill file content.
          }
        }
      }

      const handler = new SetupClaudeHandler(this.cmd);
      handler.setProjectDir(cwd);
      await handler.run({});
      result.installed = true;
    } catch (error) {
      // The format guard is a hard stop — surface it instead of swallowing.
      if (error instanceof CLIError) {
        throw error;
      }
      result.error = (error as Error).message;
    }

    return result;
  }

  /** Install the AGENTS.md surface: the tbd managed block only (no Codex hooks). */
  private async installAgentsMdSurface(cwd: string): Promise<AutoSetupResult> {
    const result: AutoSetupResult = {
      name: SURFACE_DISPLAY_NAME['agents-md'],
      detected: true,
      installed: false,
      alreadyInstalled: false,
    };

    try {
      const agentsPath = getAgentsMdPath(cwd);
      if (await pathExists(agentsPath)) {
        const content = await readFile(agentsPath, 'utf-8');
        if (content.includes('BEGIN TBD INTEGRATION')) {
          result.alreadyInstalled = true;
          // Still run the handler to refresh the managed block.
        }
      }

      const handler = new SetupCodexHandler(this.cmd);
      handler.setProjectDir(cwd);
      await handler.runAgentsMdOnly();
      result.installed = true;
    } catch (error) {
      // The format guard is a hard stop — surface it instead of swallowing.
      if (error instanceof CLIError) {
        throw error;
      }
      result.error = (error as Error).message;
    }

    return result;
  }

  /** Install the Codex surface: .codex/hooks.json + .codex/ lifecycle scripts. */
  private async installCodexSurface(cwd: string): Promise<AutoSetupResult> {
    const result: AutoSetupResult = {
      name: SURFACE_DISPLAY_NAME.codex,
      detected: true,
      installed: false,
      alreadyInstalled: false,
    };

    try {
      if (await pathExists(getCodexPaths(cwd).hooks)) {
        result.alreadyInstalled = true;
        // Still run the handler to refresh the hooks and scripts.
      }

      const handler = new SetupCodexHandler(this.cmd);
      handler.setProjectDir(cwd);
      await handler.runCodexHooksOnly();
      result.installed = true;
    } catch (error) {
      if (error instanceof CLIError) {
        throw error;
      }
      result.error = (error as Error).message;
    }

    return result;
  }
}

// Main setup command
export const setupCommand = new Command('setup')
  .description('Configure tbd integration with editors and tools')
  .option('--auto', 'Non-interactive mode with smart defaults (for agents/scripts)')
  .option('--interactive', 'Interactive mode with prompts (for humans)')
  .option('--from-beads', 'Migrate from Beads to tbd')
  .option('--prefix <name>', 'Project prefix for issue IDs (required for fresh setup)')
  .option('--force', 'Allow non-recommended prefix format (not 2-8 alphabetic)')
  .option('--no-gh-cli', 'Disable automatic GitHub CLI installation hook')
  .option(
    '--surfaces <list>',
    'Comma-separated agent surfaces to install: portable, agents-md, claude, codex (or "all"). Default: all',
  )
  .action(async (options: SetupDefaultOptions, command) => {
    // If --auto or --interactive flag is set, run the default handler
    if (options.auto || options.interactive) {
      const handler = new SetupDefaultHandler(command);
      await handler.run(options);
      return;
    }

    // If --from-beads is set without --auto/--interactive, treat as --auto
    if (options.fromBeads) {
      const handler = new SetupDefaultHandler(command);
      await handler.run({ ...options, auto: true });
      return;
    }

    // No flags provided - show help
    console.log('Usage: tbd setup [options]');
    console.log('');
    console.log('Initialize tbd and configure agent integrations.');
    console.log('Must be run inside a git repository. Installs .tbd/ and .claude/');
    console.log('at the git root (adjacent to .git/).');
    console.log('');
    console.log('Modes (one required):');
    console.log(
      '  --auto              Non-interactive mode with smart defaults (for agents/scripts)',
    );
    console.log('  --interactive       Interactive mode with prompts (for humans)');
    console.log('  --from-beads        Migrate from Beads to tbd (implies --auto)');
    console.log('');
    console.log('Options:');
    console.log('  --prefix <name>     Project prefix for issue IDs (2-8 alphabetic recommended)');
    console.log('  --force             Allow non-recommended prefix format');
    console.log('  --no-gh-cli         Disable automatic GitHub CLI installation hook');
    console.log(
      '  --surfaces <list>   Agent surfaces to install: portable,agents-md,claude,codex,all (default: all)',
    );
    console.log('');
    console.log('Examples:');
    console.log('  tbd setup --auto --prefix=tbd   # Full automatic setup with prefix');
    console.log('  tbd setup --from-beads          # Migrate from Beads (uses beads prefix)');
    console.log('  tbd setup --interactive         # Interactive setup with prompts');
    console.log('');
    console.log('For surgical initialization without integrations, see: tbd init --help');
  });
