{"version":3,"file":"utils-CJymHkYm.mjs","names":[],"sources":["../src/cli/utils.ts"],"sourcesContent":["/**\n * Shared utilities for CLI commands: formatting helpers, path encoding,\n * slug generation, and chalk colour wrappers.\n */\n\nimport chalk from \"chalk\";\nimport { resolve, basename, join } from \"node:path\";\nimport { mkdirSync, existsSync, writeFileSync, readdirSync, statSync } from \"node:fs\";\nimport { homedir } from \"node:os\";\n\n// ---------------------------------------------------------------------------\n// Chalk colour helpers (thin wrappers so callers don't import chalk directly)\n// ---------------------------------------------------------------------------\n\nexport const ok = (msg: string) => chalk.green(msg);\nexport const warn = (msg: string) => chalk.yellow(msg);\nexport const err = (msg: string) => chalk.red(msg);\nexport const dim = (msg: string) => chalk.dim(msg);\nexport const bold = (msg: string) => chalk.bold(msg);\nexport const header = (msg: string) => chalk.bold.underline(msg);\n\n// ---------------------------------------------------------------------------\n// Path / slug helpers\n// ---------------------------------------------------------------------------\n\n/**\n * Convert any path string into a kebab-case slug.\n *   \"/Users/foo/my-project\"  →  \"my-project\"\n *   \"Some Cool Project\"       →  \"some-cool-project\"\n */\nexport function slugify(input: string): string {\n  return input\n    .toLowerCase()\n    .replace(/[^a-z0-9]+/g, \"-\") // non-alphanum runs → single hyphen\n    .replace(/^-+|-+$/g, \"\"); // strip leading/trailing hyphens\n}\n\n/**\n * Derive a default project slug from the last component of a path.\n *   \"/home/user/my-project\"  →  \"my-project\"\n */\nexport function slugFromPath(projectPath: string): string {\n  return slugify(basename(projectPath));\n}\n\n/**\n * Encode an absolute path into Claude Code's encoded-dir format.\n *\n * Claude Code's actual encoding rules (reverse-engineered from real data):\n *   - Every `/`, ` ` (space), `.` (dot), and `-` (literal hyphen) → single `-`\n *   - The result therefore starts with `-` (from the leading `/`)\n *\n * This is a lossy encoding — space, dot, hyphen, and path-separator all\n * collapse to the same token.  The decode is therefore ambiguous; prefer\n * {@link buildEncodedDirMap} from migrate.ts to get authoritative mappings.\n *\n * Examples:\n *   \"/Users/foo/my-project\"        →  \"-Users-foo-my-project\"\n *   \"/Users/foo/04 - Ablage\"       →  \"-Users-foo-04---Ablage\"\n *   \"/Users/foo/.ssh\"              →  \"-Users-foo--ssh\"\n *   \"/Users/foo/MDF-System.de\"     →  \"-Users-foo-MDF-System-de\"\n *\n * NOTE: For `project add`, prefer {@link findExistingEncodedDir} to look up\n * whether Claude Code has already created a directory for this path — that\n * avoids any mismatch between our encoding and Claude's.\n */\nexport function encodeDir(absolutePath: string): string {\n  // Every `/`, space, dot, and hyphen → single `-`\n  // The leading `/` produces the leading `-` that all encoded dirs start with.\n  return absolutePath.replace(/[\\/\\s.\\-]/g, \"-\");\n}\n\n/**\n * Look up an absolute path in ~/.claude/projects/ to find the encoded-dir\n * name that Claude Code actually uses for it.\n *\n * This is more reliable than {@link encodeDir} because it reads the real\n * filesystem rather than re-implementing Claude's encoding algorithm.\n *\n * Returns the encoded-dir string (e.g. \"-Users-foo-my-project\") if a match\n * is found in ~/.claude/projects/, or `null` if not present.\n */\nexport function findExistingEncodedDir(absolutePath: string): string | null {\n  const claudeProjectsDir = join(homedir(), \".claude\", \"projects\");\n  if (!existsSync(claudeProjectsDir)) return null;\n\n  // Build the expected encoded form to compare against directory names\n  const expected = encodeDir(absolutePath);\n\n  try {\n    const entries = readdirSync(claudeProjectsDir);\n    // Exact match (our encoding matches Claude's)\n    if (entries.includes(expected)) return expected;\n\n    // Fallback: scan all entries and compare the decoded path.\n    // Import decodeEncodedDir lazily to avoid circular dependency.\n    for (const entry of entries) {\n      const full = join(claudeProjectsDir, entry);\n      try {\n        if (!statSync(full).isDirectory()) continue;\n      } catch {\n        continue;\n      }\n      // Simple heuristic decode: `--` → `-`, single `-` → `/`\n      // (good enough for finding exact matches via the registry JSON)\n      if (entry === expected) return entry;\n    }\n  } catch {\n    // Unreadable directory — ignore\n  }\n\n  return null;\n}\n\n/**\n * Decode a Claude encoded-dir back to an approximate absolute path.\n *\n * NOTE: This decode is best-effort only — the encoding is lossy (space, dot,\n * literal hyphen, and path-separator all collapse to `-`).  Prefer reading\n * original_path from session-registry.json via buildEncodedDirMap() in\n * src/registry/migrate.ts for authoritative decoding.\n *\n *   \"-Users-foo-my-project\"  →  \"/Users/foo/my-project\"\n */\nexport function decodeDir(encodedDir: string): string {\n  if (!encodedDir) return \"/\";\n  // Try filesystem-walking decode first (handles spaces, dots, hyphens correctly)\n  const smart = smartDecodeDir(encodedDir);\n  if (smart) return smart;\n  // Fallback: treat every `-` as `/` (wrong for paths with spaces/dots/hyphens)\n  return encodedDir.replace(/-/g, \"/\");\n}\n\n/**\n * Decode a Claude encoded-dir by walking the actual filesystem.\n *\n * Because the encoding is lossy (/, space, dot, and hyphen all → `-`), the\n * only reliable way to reverse it is to check what actually exists on disk.\n *\n * Algorithm: starting from `/`, read directory entries at each level, encode\n * each candidate, and greedily match the longest one against the remaining\n * encoded string.  This correctly resolves e.g.:\n *\n *   \"-Users-foo-09---Job-Search\"  →  \"/Users/foo/09 - Job Search\"\n *   \"-Users-foo-87---DevonThink\"  →  \"/Users/foo/87 - DevonThink\"\n *   \"-Users-foo-MDF-System-de\"    →  \"/Users/foo/MDF-System.de\"\n *\n * Returns `null` if the path cannot be resolved against the filesystem.\n */\nexport function smartDecodeDir(encoded: string): string | null {\n  if (!encoded || !encoded.startsWith(\"-\")) return null;\n\n  // Strip the leading `-` (encodes the leading `/`)\n  let remaining = encoded.slice(1);\n  let current = \"/\";\n\n  while (remaining.length > 0) {\n    let entries: string[];\n    try {\n      entries = readdirSync(current);\n    } catch {\n      return null; // Can't read directory\n    }\n\n    // Encode each candidate entry and find matches against remaining string.\n    // Sort by encoded length descending so we prefer the longest (most specific) match.\n    // Use case-insensitive comparison because macOS (HFS+/APFS) is case-insensitive\n    // by default, and Claude Code may have encoded the dir with different casing\n    // than what currently exists on disk (e.g. directory was renamed TEKmidian → TEKMidian).\n    const candidates: { name: string; enc: string }[] = [];\n    const remainingLower = remaining.toLowerCase();\n    for (const name of entries) {\n      // Encode this entry the same way Claude Code does (without the leading /)\n      const enc = name.replace(/[\\s.\\-]/g, \"-\");\n      const encLower = enc.toLowerCase();\n      // Must match at start of remaining, followed by `-` separator or end of string\n      if (remainingLower === encLower || remainingLower.startsWith(encLower + \"-\")) {\n        candidates.push({ name, enc });\n      }\n    }\n\n    if (candidates.length === 0) return null; // No match found\n\n    // Prefer longest encoded match (most specific)\n    candidates.sort((a, b) => b.enc.length - a.enc.length);\n\n    // Try each candidate — pick the first one that is a real directory\n    // (or the last segment which may be a file)\n    let matched = false;\n    for (const { name, enc } of candidates) {\n      const nextPath = join(current, name);\n      // Use enc.length to consume the right number of chars (case-insensitive match)\n      const nextRemaining = remainingLower === enc.toLowerCase() ? \"\" : remaining.slice(enc.length + 1);\n\n      // If nothing left, this is the final segment — accept it\n      if (nextRemaining === \"\") {\n        return nextPath;\n      }\n\n      // Otherwise, verify this is a directory we can descend into\n      try {\n        if (statSync(nextPath).isDirectory()) {\n          current = nextPath;\n          remaining = nextRemaining;\n          matched = true;\n          break;\n        }\n      } catch {\n        continue;\n      }\n    }\n\n    if (!matched) return null;\n  }\n\n  return current;\n}\n\n/**\n * Resolve a raw CLI path argument to an absolute path.\n */\nexport function resolvePath(rawPath: string): string {\n  return resolve(rawPath);\n}\n\n// ---------------------------------------------------------------------------\n// Filesystem scaffolding\n// ---------------------------------------------------------------------------\n\nconst MEMORY_MD_SCAFFOLD = `# Memory\n\nProject-specific memory for PAI sessions.\nAdd persistent notes, reminders, and context here.\n`;\n\n/**\n * Ensure Notes/ and memory/ sub-directories exist under `projectRoot`.\n * Also creates a memory/MEMORY.md scaffold if it does not yet exist.\n */\nexport function scaffoldProjectDirs(projectRoot: string): void {\n  const notesDir = `${projectRoot}/Notes`;\n  const memoryDir = `${projectRoot}/memory`;\n  const memoryFile = `${memoryDir}/MEMORY.md`;\n\n  mkdirSync(notesDir, { recursive: true });\n  mkdirSync(memoryDir, { recursive: true });\n\n  if (!existsSync(memoryFile)) {\n    writeFileSync(memoryFile, MEMORY_MD_SCAFFOLD, \"utf8\");\n  }\n}\n\n// ---------------------------------------------------------------------------\n// Table rendering\n// ---------------------------------------------------------------------------\n\n/**\n * Pad a string to a minimum width (left-aligned).\n */\nexport function pad(str: string, width: number): string {\n  const plain = stripAnsi(str);\n  const extra = width - plain.length;\n  return str + (extra > 0 ? \" \".repeat(extra) : \"\");\n}\n\n/**\n * Strip ANSI escape sequences to measure visible string length.\n */\nfunction stripAnsi(str: string): string {\n  // eslint-disable-next-line no-control-regex\n  return str.replace(/\\x1B\\[[0-9;]*m/g, \"\");\n}\n\n/**\n * Render a simple columnar table.\n *\n * @param headers  Column header strings\n * @param rows     Array of row arrays (each cell is a string, may include chalk sequences)\n */\nexport function renderTable(headers: string[], rows: string[][]): string {\n  const allRows = [headers, ...rows];\n\n  // Compute column widths\n  const widths = headers.map((_, colIdx) =>\n    Math.max(...allRows.map((row) => stripAnsi(row[colIdx] ?? \"\").length))\n  );\n\n  const divider = dim(\"  \" + widths.map((w) => \"-\".repeat(w)).join(\"  \"));\n  const renderRow = (row: string[], isHeader = false) => {\n    const cells = widths.map((w, i) => {\n      const cell = row[i] ?? \"\";\n      // pad() already strips ANSI internally to compute visible length,\n      // so pass the target visible width directly — no fudge factor needed.\n      return isHeader ? pad(bold(cell), w) : pad(cell, w);\n    });\n    return \"  \" + cells.join(\"  \");\n  };\n\n  const lines: string[] = [];\n  lines.push(renderRow(headers, true));\n  lines.push(divider);\n  for (const row of rows) {\n    lines.push(renderRow(row));\n  }\n  return lines.join(\"\\n\");\n}\n\n// ---------------------------------------------------------------------------\n// Date helpers\n// ---------------------------------------------------------------------------\n\n/**\n * Shorten an absolute path for display: replace home dir with ~,\n * truncate from left if still longer than maxLen.\n */\nexport function shortenPath(absolutePath: string, maxLen = 40): string {\n  const home = homedir();\n  let p = absolutePath;\n  if (p.startsWith(home)) {\n    p = \"~\" + p.slice(home.length);\n  }\n  if (p.length <= maxLen) return p;\n  return \"...\" + p.slice(p.length - maxLen + 3);\n}\n\n/**\n * Format an epoch milliseconds timestamp as YYYY-MM-DD.\n */\nexport function fmtDate(epochMs: number | null | undefined): string {\n  if (epochMs == null) return dim(\"—\");\n  return new Date(epochMs).toISOString().slice(0, 10);\n}\n\n/**\n * Return the current epoch milliseconds.\n */\nexport function now(): number {\n  return Date.now();\n}\n"],"mappings":"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"}