transform-extensions.ts 8.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294
  1. #!/usr/bin/env bun
  2. /**
  3. * Transform extension files (Zed, etc.) with Kilo branding
  4. *
  5. * This script handles extension configuration files by transforming
  6. * OpenCode references to Kilo.
  7. */
  8. import { $ } from "bun"
  9. import { info, success, warn, debug } from "../utils/logger"
  10. import { defaultConfig } from "../utils/config"
  11. import { oursHasKilocodeChanges } from "../utils/git"
  12. export interface ExtensionTransformResult {
  13. file: string
  14. action: "transformed" | "skipped" | "failed" | "flagged"
  15. replacements: number
  16. dryRun: boolean
  17. }
  18. export interface ExtensionTransformOptions {
  19. dryRun?: boolean
  20. verbose?: boolean
  21. }
  22. interface ExtensionReplacement {
  23. pattern: RegExp
  24. replacement: string
  25. description: string
  26. fileTypes?: string[]
  27. }
  28. // Extension-specific replacements
  29. const EXTENSION_REPLACEMENTS: ExtensionReplacement[] = [
  30. // TOML files (Zed extension)
  31. {
  32. pattern: /name\s*=\s*"opencode"/g,
  33. replacement: 'name = "kilo"',
  34. description: "Extension name",
  35. fileTypes: [".toml"],
  36. },
  37. {
  38. pattern: /id\s*=\s*"opencode"/g,
  39. replacement: 'id = "kilo"',
  40. description: "Extension ID",
  41. fileTypes: [".toml"],
  42. },
  43. {
  44. pattern: /description\s*=\s*"OpenCode[^"]*"/g,
  45. replacement: 'description = "Kilo - AI coding assistant"',
  46. description: "Extension description",
  47. fileTypes: [".toml"],
  48. },
  49. // GitHub/Repository references
  50. {
  51. pattern: /repository\s*=\s*"[^"]*anomalyco\/opencode[^"]*"/g,
  52. replacement: 'repository = "https://github.com/Kilo-Org/kilocode"',
  53. description: "Repository URL",
  54. fileTypes: [".toml"],
  55. },
  56. {
  57. pattern: /github\.com\/anomalyco\/opencode/g,
  58. replacement: "github.com/Kilo-Org/kilocode",
  59. description: "GitHub URL",
  60. },
  61. {
  62. pattern: /anomalyco\/opencode/g,
  63. replacement: "Kilo-Org/kilocode",
  64. description: "GitHub repo",
  65. },
  66. // Binary/command references
  67. {
  68. pattern: /command\s*=\s*"opencode"/g,
  69. replacement: 'command = "kilo"',
  70. description: "Command name",
  71. fileTypes: [".toml"],
  72. },
  73. // Generic OpenCode -> Kilo in strings
  74. {
  75. pattern: /"OpenCode"/g,
  76. replacement: '"Kilo"',
  77. description: "Product name",
  78. },
  79. // Environment variables
  80. {
  81. pattern: /_EXTENSION_OPENCODE_/g,
  82. replacement: "_EXTENSION_KILO_",
  83. description: "Extension env var",
  84. },
  85. {
  86. pattern: /OpenCode\s+language\s+server/gi,
  87. replacement: "Kilo language server",
  88. description: "Language server name",
  89. },
  90. ]
  91. /**
  92. * Check if file is an extension file
  93. */
  94. export function isExtensionFile(file: string): boolean {
  95. const patterns = defaultConfig.extensionFiles
  96. return patterns.some((pattern) => {
  97. const regex = new RegExp("^" + pattern.replace(/\./g, "\\.").replace(/\*\*/g, ".*").replace(/\*/g, "[^/]*") + "$")
  98. return regex.test(file)
  99. })
  100. }
  101. /**
  102. * Get file extension
  103. */
  104. function getExtension(file: string): string {
  105. const match = file.match(/\.[^.]+$/)
  106. return match ? match[0] : ""
  107. }
  108. /**
  109. * Apply extension transforms to content
  110. */
  111. export function applyExtensionTransforms(
  112. content: string,
  113. file: string,
  114. verbose = false,
  115. ): { result: string; replacements: number } {
  116. const ext = getExtension(file)
  117. let result = content
  118. let total = 0
  119. for (const { pattern, replacement, description, fileTypes } of EXTENSION_REPLACEMENTS) {
  120. // Skip if this replacement is for specific file types and doesn't match
  121. if (fileTypes && !fileTypes.includes(ext)) {
  122. continue
  123. }
  124. pattern.lastIndex = 0
  125. if (pattern.test(result)) {
  126. pattern.lastIndex = 0
  127. const before = result
  128. result = result.replace(pattern, replacement)
  129. if (before !== result) {
  130. total++
  131. if (verbose) debug(` ${description}`)
  132. }
  133. }
  134. }
  135. return { result, replacements: total }
  136. }
  137. /**
  138. * Transform an extension file
  139. */
  140. export async function transformExtensionFile(
  141. file: string,
  142. options: ExtensionTransformOptions = {},
  143. ): Promise<ExtensionTransformResult> {
  144. if (options.dryRun) {
  145. info(`[DRY-RUN] Would transform extension: ${file}`)
  146. return { file, action: "transformed", replacements: 0, dryRun: true }
  147. }
  148. // If our version has kilocode_change markers, flag for manual resolution
  149. if (await oursHasKilocodeChanges(file)) {
  150. warn(`${file} has kilocode_change markers — skipping auto-transform, needs manual resolution`)
  151. return { file, action: "flagged", replacements: 0, dryRun: false }
  152. }
  153. try {
  154. // Take upstream's version first
  155. await $`git checkout --theirs ${file}`.quiet().nothrow()
  156. await $`git add ${file}`.quiet().nothrow()
  157. // Read content
  158. const content = await Bun.file(file).text()
  159. // Apply transforms
  160. const { result, replacements } = applyExtensionTransforms(content, file, options.verbose)
  161. // Write back if changed
  162. if (replacements > 0) {
  163. await Bun.write(file, result)
  164. await $`git add ${file}`.quiet().nothrow()
  165. }
  166. success(`Transformed extension ${file}: ${replacements} replacements`)
  167. return { file, action: "transformed", replacements, dryRun: false }
  168. } catch (err) {
  169. warn(`Failed to transform extension ${file}: ${err}`)
  170. return { file, action: "failed", replacements: 0, dryRun: false }
  171. }
  172. }
  173. /**
  174. * Transform conflicted extension files
  175. */
  176. export async function transformConflictedExtensions(
  177. files: string[],
  178. options: ExtensionTransformOptions = {},
  179. ): Promise<ExtensionTransformResult[]> {
  180. const results: ExtensionTransformResult[] = []
  181. for (const file of files) {
  182. if (!isExtensionFile(file)) {
  183. debug(`Skipping ${file} - not an extension file`)
  184. results.push({ file, action: "skipped", replacements: 0, dryRun: options.dryRun ?? false })
  185. continue
  186. }
  187. const result = await transformExtensionFile(file, options)
  188. results.push(result)
  189. }
  190. return results
  191. }
  192. /**
  193. * Transform all extension files (pre-merge, on opencode branch)
  194. */
  195. export async function transformAllExtensions(
  196. options: ExtensionTransformOptions = {},
  197. ): Promise<ExtensionTransformResult[]> {
  198. const { Glob } = await import("bun")
  199. const results: ExtensionTransformResult[] = []
  200. const patterns = defaultConfig.extensionFiles
  201. for (const pattern of patterns) {
  202. const glob = new Glob(pattern)
  203. for await (const path of glob.scan({ absolute: false })) {
  204. const file = Bun.file(path)
  205. if (!(await file.exists())) continue
  206. // Skip non-text files
  207. if (!path.endsWith(".toml") && !path.endsWith(".json") && !path.endsWith(".ts") && !path.endsWith(".js")) {
  208. continue
  209. }
  210. try {
  211. const content = await file.text()
  212. const { result, replacements } = applyExtensionTransforms(content, path, options.verbose)
  213. if (replacements > 0 && !options.dryRun) {
  214. await Bun.write(path, result)
  215. success(`Transformed extension ${path}: ${replacements} replacements`)
  216. } else if (options.dryRun && replacements > 0) {
  217. info(`[DRY-RUN] Would transform extension ${path}: ${replacements} replacements`)
  218. }
  219. results.push({ file: path, action: "transformed", replacements, dryRun: options.dryRun ?? false })
  220. } catch (err) {
  221. warn(`Failed to transform extension ${path}: ${err}`)
  222. results.push({ file: path, action: "failed", replacements: 0, dryRun: options.dryRun ?? false })
  223. }
  224. }
  225. }
  226. return results
  227. }
  228. // CLI entry point
  229. if (import.meta.main) {
  230. const args = process.argv.slice(2)
  231. const dryRun = args.includes("--dry-run")
  232. const verbose = args.includes("--verbose")
  233. const files = args.filter((a) => !a.startsWith("--"))
  234. if (files.length === 0) {
  235. info("Usage: transform-extensions.ts [--dry-run] [--verbose] <file1> <file2> ...")
  236. process.exit(1)
  237. }
  238. if (dryRun) {
  239. info("Running in dry-run mode")
  240. }
  241. const results = await transformConflictedExtensions(files, { dryRun, verbose })
  242. const transformed = results.filter((r) => r.action === "transformed")
  243. const total = results.reduce((sum, r) => sum + r.replacements, 0)
  244. console.log()
  245. success(`Transformed ${transformed.length} extension files with ${total} replacements`)
  246. if (dryRun) {
  247. info("Run without --dry-run to apply changes")
  248. }
  249. }