toml.ts 5.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154
  1. /**
  2. * Tiny TOML helpers — just enough to inject / replace / remove a
  3. * single dotted-key table block (`[mcp_servers.codegraph]`) inside an
  4. * existing `~/.codex/config.toml`. We deliberately do NOT try to be a
  5. * general TOML parser/serializer; that would mean pulling in a
  6. * dependency (~50KB) for ~6 lines of output.
  7. *
  8. * Strategy: treat the file as text. Find the `[mcp_servers.codegraph]`
  9. * header line, splice it (and the lines that follow it until the next
  10. * `[...]` header or EOF) in or out. Everything outside that block is
  11. * preserved verbatim, byte-for-byte.
  12. *
  13. * Limitations (acceptable for our narrow use):
  14. * - Only handles top-level table headers; not array-of-tables or
  15. * subtables nested inside `[mcp_servers]` itself (we always write
  16. * the full dotted key `[mcp_servers.codegraph]`).
  17. * - Doesn't validate sibling TOML — if the file is malformed
  18. * elsewhere, our injection won't fix it but won't make it worse.
  19. * - Quotes string values with double quotes; escapes `\` and `"`.
  20. */
  21. /**
  22. * Serialize a record into the body lines of a TOML table. Values
  23. * supported: string, string[]. Other types throw — the codex MCP
  24. * config only needs these two.
  25. */
  26. export function serializeTomlTableBody(values: Record<string, string | string[]>): string {
  27. const lines: string[] = [];
  28. for (const [key, value] of Object.entries(values)) {
  29. if (typeof value === 'string') {
  30. lines.push(`${key} = ${quoteString(value)}`);
  31. } else if (Array.isArray(value) && value.every((v) => typeof v === 'string')) {
  32. const parts = value.map(quoteString).join(', ');
  33. lines.push(`${key} = [${parts}]`);
  34. } else {
  35. throw new Error(`Unsupported TOML value type for key "${key}"`);
  36. }
  37. }
  38. return lines.join('\n');
  39. }
  40. function quoteString(s: string): string {
  41. // TOML basic strings: backslash and double-quote escapes; control
  42. // chars not expected in our payload (paths/args).
  43. return '"' + s.replace(/\\/g, '\\\\').replace(/"/g, '\\"') + '"';
  44. }
  45. /**
  46. * Build a full table block: header line + body. Suitable for direct
  47. * insertion into a TOML file.
  48. */
  49. export function buildTomlTable(header: string, values: Record<string, string | string[]>): string {
  50. return `[${header}]\n${serializeTomlTableBody(values)}`;
  51. }
  52. /**
  53. * Insert or replace a top-level dotted-key TOML table block in the
  54. * given file content. Preserves all other content verbatim.
  55. *
  56. * Returns `'inserted'` when the table was newly added, `'replaced'`
  57. * when an existing one was rewritten, `'unchanged'` when the
  58. * existing block already matches `block` byte-for-byte.
  59. */
  60. export function upsertTomlTable(
  61. fileContent: string,
  62. header: string,
  63. block: string,
  64. ): { content: string; action: 'inserted' | 'replaced' | 'unchanged' } {
  65. const headerLine = `[${header}]`;
  66. const headerIdx = findHeaderIndex(fileContent, headerLine);
  67. if (headerIdx === -1) {
  68. // Insert at end with separating blank line if there's existing content.
  69. const trimmed = fileContent.trimEnd();
  70. const sep = trimmed.length > 0 ? '\n\n' : '';
  71. return {
  72. content: trimmed + sep + block + '\n',
  73. action: 'inserted',
  74. };
  75. }
  76. // Find the end of this block: next `[...]` header (at line start) or EOF.
  77. const blockEnd = findNextTableHeader(fileContent, headerIdx + headerLine.length);
  78. const existingBlock = fileContent.substring(headerIdx, blockEnd).replace(/\n+$/, '');
  79. if (existingBlock === block) {
  80. return { content: fileContent, action: 'unchanged' };
  81. }
  82. const before = fileContent.substring(0, headerIdx);
  83. const after = fileContent.substring(blockEnd);
  84. // Trim trailing blank lines from `before` (we'll re-add one) and
  85. // leading blank lines from `after` so the file shape stays clean.
  86. const beforeClean = before.replace(/\n+$/, '');
  87. const afterClean = after.replace(/^\n+/, '');
  88. const sepBefore = beforeClean.length > 0 ? '\n\n' : '';
  89. const sepAfter = afterClean.length > 0 ? '\n\n' : '\n';
  90. return {
  91. content: beforeClean + sepBefore + block + sepAfter + afterClean,
  92. action: 'replaced',
  93. };
  94. }
  95. /**
  96. * Remove a top-level dotted-key TOML table block. Returns the
  97. * possibly-empty new content + an action flag.
  98. */
  99. export function removeTomlTable(
  100. fileContent: string,
  101. header: string,
  102. ): { content: string; action: 'removed' | 'not-found' } {
  103. const headerLine = `[${header}]`;
  104. const headerIdx = findHeaderIndex(fileContent, headerLine);
  105. if (headerIdx === -1) return { content: fileContent, action: 'not-found' };
  106. const blockEnd = findNextTableHeader(fileContent, headerIdx + headerLine.length);
  107. const before = fileContent.substring(0, headerIdx).replace(/\n+$/, '');
  108. const after = fileContent.substring(blockEnd).replace(/^\n+/, '');
  109. const joined = before + (before && after ? '\n\n' : '') + after;
  110. return { content: joined, action: 'removed' };
  111. }
  112. /**
  113. * Locate the byte index of a header line (`[foo.bar]`) when it
  114. * appears at the start of a line. Returns -1 if not found.
  115. */
  116. function findHeaderIndex(content: string, headerLine: string): number {
  117. // Search BOL or right after a newline.
  118. if (content.startsWith(headerLine)) return 0;
  119. const needle = '\n' + headerLine;
  120. const idx = content.indexOf(needle);
  121. return idx === -1 ? -1 : idx + 1;
  122. }
  123. /**
  124. * Find the byte index of the next top-level `[...]` table header
  125. * (excluding array-of-tables `[[...]]`) starting from `from`, or
  126. * return content length when none.
  127. */
  128. function findNextTableHeader(content: string, from: number): number {
  129. // Look for "\n[" but skip "\n[[" (array of tables).
  130. let i = from;
  131. while (i < content.length) {
  132. const nlIdx = content.indexOf('\n[', i);
  133. if (nlIdx === -1) return content.length;
  134. if (content[nlIdx + 2] === '[') {
  135. // [[...]] — keep searching past it.
  136. i = nlIdx + 2;
  137. continue;
  138. }
  139. return nlIdx + 1;
  140. }
  141. return content.length;
  142. }