cli.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382
  1. import { basename } from "node:path"
  2. import { render, Instance } from "ink"
  3. import React from "react"
  4. import { createStore } from "jotai"
  5. import { createExtensionService, ExtensionService } from "./services/extension.js"
  6. import { App } from "./ui/App.js"
  7. import { logs } from "./services/logs.js"
  8. import { extensionServiceAtom } from "./state/atoms/service.js"
  9. import { initializeServiceEffectAtom } from "./state/atoms/effects.js"
  10. import { loadConfigAtom, mappedExtensionStateAtom, providersAtom } from "./state/atoms/config.js"
  11. import { ciExitReasonAtom } from "./state/atoms/ci.js"
  12. import { requestRouterModelsAtom } from "./state/atoms/actions.js"
  13. import { loadHistoryAtom } from "./state/atoms/history.js"
  14. import { getTelemetryService, getIdentityManager } from "./services/telemetry/index.js"
  15. import { notificationsAtom, notificationsErrorAtom, notificationsLoadingAtom } from "./state/atoms/notifications.js"
  16. import { fetchKilocodeNotifications } from "./utils/notifications.js"
  17. import { finishParallelMode } from "./parallel/parallel.js"
  18. import { isGitWorktree } from "./utils/git.js"
  19. export interface CLIOptions {
  20. mode?: string
  21. workspace?: string
  22. ci?: boolean
  23. json?: boolean
  24. prompt?: string
  25. timeout?: number
  26. parallel?: boolean
  27. worktreeBranch?: string | undefined
  28. }
  29. /**
  30. * Main application class that orchestrates the CLI lifecycle
  31. */
  32. export class CLI {
  33. private service: ExtensionService | null = null
  34. private store: ReturnType<typeof createStore> | null = null
  35. private ui: Instance | null = null
  36. private options: CLIOptions
  37. private isInitialized = false
  38. constructor(options: CLIOptions = {}) {
  39. this.options = options
  40. }
  41. /**
  42. * Initialize the application
  43. * - Creates ExtensionService
  44. * - Sets up Jotai store
  45. * - Initializes service through effects
  46. */
  47. async initialize(): Promise<void> {
  48. if (this.isInitialized) {
  49. logs.warn("Application already initialized", "CLI")
  50. return
  51. }
  52. try {
  53. logs.info("Initializing Kilo Code CLI...", "CLI")
  54. // Set terminal title - use process.cwd() in parallel mode to show original directory
  55. const titleWorkspace = this.options.parallel ? process.cwd() : this.options.workspace || process.cwd()
  56. const folderName = `${basename(titleWorkspace)}${(await isGitWorktree(this.options.workspace || "")) ? " (git worktree)" : ""}`
  57. process.stdout.write(`\x1b]0;Kilo Code - ${folderName}\x07`)
  58. // Create Jotai store
  59. this.store = createStore()
  60. logs.debug("Jotai store created", "CLI")
  61. // Initialize telemetry service first to get identity
  62. const config = await this.store.set(loadConfigAtom, this.options.mode)
  63. logs.debug("CLI configuration loaded", "CLI", { mode: this.options.mode })
  64. const telemetryService = getTelemetryService()
  65. await telemetryService.initialize(config, {
  66. workspace: this.options.workspace || process.cwd(),
  67. mode: this.options.mode || "code",
  68. ciMode: this.options.ci || false,
  69. })
  70. logs.debug("Telemetry service initialized", "CLI")
  71. // Get identity from Identity Manager
  72. const identityManager = getIdentityManager()
  73. const identity = identityManager.getIdentity()
  74. // Create ExtensionService with identity
  75. const serviceOptions: Parameters<typeof createExtensionService>[0] = {
  76. workspace: this.options.workspace || process.cwd(),
  77. mode: this.options.mode || "code",
  78. }
  79. if (identity) {
  80. serviceOptions.identity = {
  81. machineId: identity.machineId,
  82. sessionId: identity.sessionId,
  83. cliUserId: identity.cliUserId,
  84. }
  85. }
  86. this.service = createExtensionService(serviceOptions)
  87. logs.debug("ExtensionService created with identity", "CLI", {
  88. hasIdentity: !!identity,
  89. })
  90. // Set service in store
  91. this.store.set(extensionServiceAtom, this.service)
  92. logs.debug("ExtensionService set in store", "CLI")
  93. // Track extension initialization
  94. telemetryService.trackExtensionInitialized(false) // Will be updated after actual initialization
  95. // Initialize service through effect atom
  96. // This sets up all event listeners and activates the extension
  97. await this.store.set(initializeServiceEffectAtom, this.store)
  98. logs.info("ExtensionService initialized through effects", "CLI")
  99. // Track successful extension initialization
  100. telemetryService.trackExtensionInitialized(true)
  101. // Load command history
  102. await this.store.set(loadHistoryAtom)
  103. logs.debug("Command history loaded", "CLI")
  104. // Inject CLI configuration into ExtensionHost
  105. await this.injectConfigurationToExtension()
  106. logs.debug("CLI configuration injected into extension", "CLI")
  107. const extensionHost = this.service.getExtensionHost()
  108. extensionHost.sendWebviewMessage({
  109. type: "yoloMode",
  110. bool: Boolean(this.options.ci),
  111. })
  112. // Request router models after configuration is injected
  113. void this.requestRouterModels()
  114. if (!this.options.ci && !this.options.prompt) {
  115. // Fetch Kilocode notifications if provider is kilocode
  116. void this.fetchNotifications()
  117. }
  118. this.isInitialized = true
  119. logs.info("Kilo Code CLI initialized successfully", "CLI")
  120. } catch (error) {
  121. logs.error("Failed to initialize CLI", "CLI", { error })
  122. throw error
  123. }
  124. }
  125. /**
  126. * Start the application
  127. * - Initializes if not already done
  128. * - Renders the UI
  129. * - Waits for exit
  130. */
  131. async start(): Promise<void> {
  132. // Initialize if not already done
  133. if (!this.isInitialized) {
  134. await this.initialize()
  135. }
  136. if (!this.store) {
  137. throw new Error("Store not initialized")
  138. }
  139. // Render UI with store
  140. // Disable stdin for Ink when in CI mode or when stdin is piped (not a TTY)
  141. // This prevents the "Raw mode is not supported" error
  142. const shouldDisableStdin = this.options.ci || !process.stdin.isTTY
  143. this.ui = render(
  144. React.createElement(App, {
  145. store: this.store,
  146. options: {
  147. mode: this.options.mode || "code",
  148. workspace: this.options.workspace || process.cwd(),
  149. ci: this.options.ci || false,
  150. json: this.options.json || false,
  151. prompt: this.options.prompt || "",
  152. ...(this.options.timeout !== undefined && { timeout: this.options.timeout }),
  153. parallel: this.options.parallel || false,
  154. worktreeBranch: this.options.worktreeBranch || undefined,
  155. },
  156. onExit: () => this.dispose(),
  157. }),
  158. shouldDisableStdin
  159. ? {
  160. stdout: process.stdout,
  161. stderr: process.stderr,
  162. }
  163. : undefined,
  164. )
  165. // Wait for UI to exit
  166. await this.ui.waitUntilExit()
  167. }
  168. private isDisposing = false
  169. /**
  170. * Dispose the application and clean up resources
  171. * - Unmounts UI
  172. * - Disposes service
  173. * - Cleans up store
  174. */
  175. async dispose(): Promise<void> {
  176. if (this.isDisposing) {
  177. logs.info("Already disposing, ignoring duplicate dispose call", "CLI")
  178. return
  179. }
  180. this.isDisposing = true
  181. // Determine exit code based on CI mode and exit reason
  182. let exitCode = 0
  183. let beforeExit = () => {}
  184. try {
  185. logs.info("Disposing Kilo Code CLI...", "CLI")
  186. if (this.options.ci && this.store) {
  187. // Check exit reason from CI atoms
  188. const exitReason = this.store.get(ciExitReasonAtom)
  189. // Set exit code based on the actual exit reason
  190. if (exitReason === "timeout") {
  191. exitCode = 124
  192. logs.warn("Exiting with timeout code", "CLI")
  193. // Track CI mode timeout
  194. getTelemetryService().trackCIModeTimeout()
  195. } else if (exitReason === "completion_result" || exitReason === "command_finished") {
  196. exitCode = 0
  197. logs.info("Exiting with success code", "CLI", { reason: exitReason })
  198. } else {
  199. // No exit reason set - this shouldn't happen in normal flow
  200. exitCode = 1
  201. logs.info("Exiting with default failure code", "CLI")
  202. }
  203. }
  204. // In parallel mode, we need to do manual git worktree cleanup
  205. if (this.options.parallel) {
  206. beforeExit = await finishParallelMode(this, this.options.workspace!, this.options.worktreeBranch!)
  207. }
  208. // Shutdown telemetry service before exiting
  209. const telemetryService = getTelemetryService()
  210. await telemetryService.shutdown()
  211. logs.debug("Telemetry service shut down", "CLI")
  212. // Unmount UI
  213. if (this.ui) {
  214. await this.ui.unmount()
  215. this.ui = null
  216. }
  217. // Dispose service
  218. if (this.service) {
  219. await this.service.dispose()
  220. this.service = null
  221. }
  222. // Clear store reference
  223. this.store = null
  224. this.isInitialized = false
  225. logs.info("Kilo Code CLI disposed", "CLI")
  226. } catch (error) {
  227. logs.error("Error disposing CLI", "CLI", { error })
  228. exitCode = 1
  229. } finally {
  230. beforeExit()
  231. // Exit process with appropriate code
  232. process.exit(exitCode)
  233. }
  234. }
  235. /**
  236. * Inject CLI configuration into the extension host
  237. */
  238. private async injectConfigurationToExtension(): Promise<void> {
  239. if (!this.service || !this.store) {
  240. logs.warn("Cannot inject configuration: service or store not available", "CLI")
  241. return
  242. }
  243. try {
  244. // Get the mapped extension state from config atoms
  245. const mappedState = this.store.get(mappedExtensionStateAtom)
  246. logs.debug("Mapped config state for injection", "CLI", {
  247. mode: mappedState.mode,
  248. telemetry: mappedState.telemetrySetting,
  249. provider: mappedState.currentApiConfigName,
  250. })
  251. // Get the extension host from the service
  252. const extensionHost = this.service.getExtensionHost()
  253. // Inject the configuration (await to ensure mode/telemetry messages are sent)
  254. await extensionHost.injectConfiguration(mappedState)
  255. logs.info("Configuration injected into extension host", "CLI")
  256. } catch (error) {
  257. logs.error("Failed to inject configuration into extension host", "CLI", { error })
  258. }
  259. }
  260. /**
  261. * Request router models from the extension
  262. */
  263. private async requestRouterModels(): Promise<void> {
  264. if (!this.service || !this.store) {
  265. logs.warn("Cannot request router models: service or store not available", "CLI")
  266. return
  267. }
  268. try {
  269. await this.store.set(requestRouterModelsAtom)
  270. logs.debug("Router models requested", "CLI")
  271. } catch (error) {
  272. logs.error("Failed to request router models", "CLI", { error })
  273. }
  274. }
  275. /**
  276. * Fetch notifications from Kilocode backend if provider is kilocode
  277. */
  278. private async fetchNotifications(): Promise<void> {
  279. if (!this.store) {
  280. logs.warn("Cannot fetch notifications: store not available", "CLI")
  281. return
  282. }
  283. try {
  284. const providers = this.store.get(providersAtom)
  285. const provider = providers.find(({ provider }) => provider === "kilocode")
  286. if (!provider) {
  287. logs.debug("No provider configured, skipping notification fetch", "CLI")
  288. return
  289. }
  290. this.store.set(notificationsLoadingAtom, true)
  291. const notifications = await fetchKilocodeNotifications(provider)
  292. this.store.set(notificationsAtom, notifications)
  293. } catch (error) {
  294. const err = error instanceof Error ? error : new Error(String(error))
  295. this.store.set(notificationsErrorAtom, err)
  296. logs.error("Failed to fetch notifications", "CLI", { error })
  297. } finally {
  298. this.store.set(notificationsLoadingAtom, false)
  299. }
  300. }
  301. /**
  302. * Get the ExtensionService instance
  303. */
  304. getService(): ExtensionService | null {
  305. return this.service
  306. }
  307. /**
  308. * Get the Jotai store instance
  309. */
  310. getStore(): ReturnType<typeof createStore> | null {
  311. return this.store
  312. }
  313. /**
  314. * Check if the application is initialized
  315. */
  316. isReady(): boolean {
  317. return this.isInitialized
  318. }
  319. }