@brianli/kimaki 0.4.72-brianli.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (328) hide show
  1. package/bin.js +2 -0
  2. package/dist/ai-tool-to-genai.js +233 -0
  3. package/dist/ai-tool-to-genai.test.js +267 -0
  4. package/dist/ai-tool.js +6 -0
  5. package/dist/bin.js +87 -0
  6. package/dist/bot-token.js +121 -0
  7. package/dist/bot-token.test.js +134 -0
  8. package/dist/channel-management.js +101 -0
  9. package/dist/cli-parsing.test.js +89 -0
  10. package/dist/cli.js +2529 -0
  11. package/dist/commands/abort.js +82 -0
  12. package/dist/commands/action-buttons.js +257 -0
  13. package/dist/commands/add-project.js +114 -0
  14. package/dist/commands/agent.js +291 -0
  15. package/dist/commands/ask-question.js +223 -0
  16. package/dist/commands/compact.js +120 -0
  17. package/dist/commands/context-usage.js +140 -0
  18. package/dist/commands/create-new-project.js +118 -0
  19. package/dist/commands/diff.js +128 -0
  20. package/dist/commands/file-upload.js +275 -0
  21. package/dist/commands/fork.js +217 -0
  22. package/dist/commands/gemini-apikey.js +70 -0
  23. package/dist/commands/login.js +490 -0
  24. package/dist/commands/mention-mode.js +51 -0
  25. package/dist/commands/merge-worktree.js +124 -0
  26. package/dist/commands/model.js +694 -0
  27. package/dist/commands/permissions.js +163 -0
  28. package/dist/commands/queue.js +217 -0
  29. package/dist/commands/remove-project.js +115 -0
  30. package/dist/commands/restart-opencode-server.js +116 -0
  31. package/dist/commands/resume.js +159 -0
  32. package/dist/commands/run-command.js +79 -0
  33. package/dist/commands/session-id.js +78 -0
  34. package/dist/commands/session.js +192 -0
  35. package/dist/commands/share.js +80 -0
  36. package/dist/commands/types.js +2 -0
  37. package/dist/commands/undo-redo.js +159 -0
  38. package/dist/commands/unset-model.js +152 -0
  39. package/dist/commands/upgrade.js +42 -0
  40. package/dist/commands/user-command.js +148 -0
  41. package/dist/commands/verbosity.js +60 -0
  42. package/dist/commands/worktree-settings.js +50 -0
  43. package/dist/commands/worktree.js +299 -0
  44. package/dist/condense-memory.js +33 -0
  45. package/dist/config.js +110 -0
  46. package/dist/database.js +1050 -0
  47. package/dist/db.js +159 -0
  48. package/dist/db.test.js +49 -0
  49. package/dist/discord-api.js +28 -0
  50. package/dist/discord-auth.js +231 -0
  51. package/dist/discord-auth.test.js +80 -0
  52. package/dist/discord-bot.js +997 -0
  53. package/dist/discord-utils.js +560 -0
  54. package/dist/discord-utils.test.js +115 -0
  55. package/dist/errors.js +167 -0
  56. package/dist/escape-backticks.test.js +429 -0
  57. package/dist/format-tables.js +122 -0
  58. package/dist/format-tables.test.js +199 -0
  59. package/dist/forum-sync/config.js +79 -0
  60. package/dist/forum-sync/discord-operations.js +154 -0
  61. package/dist/forum-sync/index.js +5 -0
  62. package/dist/forum-sync/markdown.js +117 -0
  63. package/dist/forum-sync/sync-to-discord.js +417 -0
  64. package/dist/forum-sync/sync-to-files.js +190 -0
  65. package/dist/forum-sync/types.js +53 -0
  66. package/dist/forum-sync/watchers.js +307 -0
  67. package/dist/gateway-consumer.js +232 -0
  68. package/dist/gateway-consumer.test.js +18 -0
  69. package/dist/genai-worker-wrapper.js +111 -0
  70. package/dist/genai-worker.js +311 -0
  71. package/dist/genai.js +232 -0
  72. package/dist/generated/browser.js +17 -0
  73. package/dist/generated/client.js +35 -0
  74. package/dist/generated/commonInputTypes.js +10 -0
  75. package/dist/generated/enums.js +30 -0
  76. package/dist/generated/internal/class.js +41 -0
  77. package/dist/generated/internal/prismaNamespace.js +239 -0
  78. package/dist/generated/internal/prismaNamespaceBrowser.js +209 -0
  79. package/dist/generated/models/bot_api_keys.js +1 -0
  80. package/dist/generated/models/bot_tokens.js +1 -0
  81. package/dist/generated/models/channel_agents.js +1 -0
  82. package/dist/generated/models/channel_directories.js +1 -0
  83. package/dist/generated/models/channel_mention_mode.js +1 -0
  84. package/dist/generated/models/channel_models.js +1 -0
  85. package/dist/generated/models/channel_verbosity.js +1 -0
  86. package/dist/generated/models/channel_worktrees.js +1 -0
  87. package/dist/generated/models/forum_sync_configs.js +1 -0
  88. package/dist/generated/models/global_models.js +1 -0
  89. package/dist/generated/models/ipc_requests.js +1 -0
  90. package/dist/generated/models/part_messages.js +1 -0
  91. package/dist/generated/models/scheduled_tasks.js +1 -0
  92. package/dist/generated/models/session_agents.js +1 -0
  93. package/dist/generated/models/session_models.js +1 -0
  94. package/dist/generated/models/session_start_sources.js +1 -0
  95. package/dist/generated/models/thread_sessions.js +1 -0
  96. package/dist/generated/models/thread_worktrees.js +1 -0
  97. package/dist/generated/models.js +1 -0
  98. package/dist/heap-monitor.js +95 -0
  99. package/dist/hrana-server.js +416 -0
  100. package/dist/hrana-server.test.js +368 -0
  101. package/dist/image-utils.js +112 -0
  102. package/dist/interaction-handler.js +327 -0
  103. package/dist/ipc-polling.js +251 -0
  104. package/dist/kimaki-digital-twin.e2e.test.js +165 -0
  105. package/dist/limit-heading-depth.js +25 -0
  106. package/dist/limit-heading-depth.test.js +105 -0
  107. package/dist/logger.js +160 -0
  108. package/dist/markdown.js +342 -0
  109. package/dist/markdown.test.js +253 -0
  110. package/dist/message-formatting.js +433 -0
  111. package/dist/message-formatting.test.js +73 -0
  112. package/dist/openai-realtime.js +228 -0
  113. package/dist/opencode-plugin-loading.e2e.test.js +91 -0
  114. package/dist/opencode-plugin.js +536 -0
  115. package/dist/opencode-plugin.test.js +98 -0
  116. package/dist/opencode.js +409 -0
  117. package/dist/privacy-sanitizer.js +105 -0
  118. package/dist/runtime-mode.js +51 -0
  119. package/dist/runtime-mode.test.js +115 -0
  120. package/dist/sentry.js +127 -0
  121. package/dist/session-handler/state.js +151 -0
  122. package/dist/session-handler.js +1874 -0
  123. package/dist/session-search.js +100 -0
  124. package/dist/session-search.test.js +40 -0
  125. package/dist/startup-service.js +153 -0
  126. package/dist/system-message.js +499 -0
  127. package/dist/task-runner.js +282 -0
  128. package/dist/task-schedule.js +191 -0
  129. package/dist/task-schedule.test.js +71 -0
  130. package/dist/thinking-utils.js +35 -0
  131. package/dist/thread-message-queue.e2e.test.js +781 -0
  132. package/dist/tools.js +359 -0
  133. package/dist/unnest-code-blocks.js +136 -0
  134. package/dist/unnest-code-blocks.test.js +641 -0
  135. package/dist/upgrade.js +114 -0
  136. package/dist/utils.js +109 -0
  137. package/dist/voice-handler.js +606 -0
  138. package/dist/voice.js +304 -0
  139. package/dist/voice.test.js +187 -0
  140. package/dist/wait-session.js +94 -0
  141. package/dist/worker-types.js +4 -0
  142. package/dist/worktree-utils.js +727 -0
  143. package/dist/xml.js +92 -0
  144. package/dist/xml.test.js +32 -0
  145. package/package.json +82 -0
  146. package/schema.prisma +246 -0
  147. package/skills/batch/SKILL.md +87 -0
  148. package/skills/critique/SKILL.md +129 -0
  149. package/skills/errore/SKILL.md +589 -0
  150. package/skills/goke/.prettierrc +5 -0
  151. package/skills/goke/CHANGELOG.md +40 -0
  152. package/skills/goke/LICENSE +21 -0
  153. package/skills/goke/README.md +666 -0
  154. package/skills/goke/SKILL.md +458 -0
  155. package/skills/goke/package.json +43 -0
  156. package/skills/goke/src/__test__/coerce.test.ts +411 -0
  157. package/skills/goke/src/__test__/index.test.ts +1798 -0
  158. package/skills/goke/src/__test__/types.test-d.ts +111 -0
  159. package/skills/goke/src/coerce.ts +547 -0
  160. package/skills/goke/src/goke.ts +1362 -0
  161. package/skills/goke/src/index.ts +16 -0
  162. package/skills/goke/src/mri.ts +164 -0
  163. package/skills/goke/tsconfig.json +15 -0
  164. package/skills/jitter/EDITOR.md +219 -0
  165. package/skills/jitter/EXPORT-INTERNALS.md +309 -0
  166. package/skills/jitter/SKILL.md +158 -0
  167. package/skills/jitter/jitter-clipboard.json +1042 -0
  168. package/skills/jitter/package.json +14 -0
  169. package/skills/jitter/tsconfig.json +15 -0
  170. package/skills/jitter/utils/actions.ts +212 -0
  171. package/skills/jitter/utils/export.ts +114 -0
  172. package/skills/jitter/utils/index.ts +141 -0
  173. package/skills/jitter/utils/snapshot.ts +154 -0
  174. package/skills/jitter/utils/traverse.ts +246 -0
  175. package/skills/jitter/utils/types.ts +279 -0
  176. package/skills/jitter/utils/wait.ts +133 -0
  177. package/skills/playwriter/SKILL.md +31 -0
  178. package/skills/security-review/SKILL.md +208 -0
  179. package/skills/simplify/SKILL.md +58 -0
  180. package/skills/termcast/SKILL.md +945 -0
  181. package/skills/tuistory/SKILL.md +250 -0
  182. package/skills/zustand-centralized-state/SKILL.md +582 -0
  183. package/src/__snapshots__/compact-session-context-no-system.md +35 -0
  184. package/src/__snapshots__/compact-session-context.md +41 -0
  185. package/src/__snapshots__/first-session-no-info.md +17 -0
  186. package/src/__snapshots__/first-session-with-info.md +23 -0
  187. package/src/__snapshots__/session-1.md +17 -0
  188. package/src/__snapshots__/session-2.md +5871 -0
  189. package/src/__snapshots__/session-3.md +17 -0
  190. package/src/__snapshots__/session-with-tools.md +5871 -0
  191. package/src/ai-tool-to-genai.test.ts +296 -0
  192. package/src/ai-tool-to-genai.ts +282 -0
  193. package/src/ai-tool.ts +39 -0
  194. package/src/bin.ts +108 -0
  195. package/src/bot-token.test.ts +171 -0
  196. package/src/bot-token.ts +159 -0
  197. package/src/channel-management.ts +172 -0
  198. package/src/cli-parsing.test.ts +132 -0
  199. package/src/cli.ts +3605 -0
  200. package/src/commands/abort.ts +112 -0
  201. package/src/commands/action-buttons.ts +376 -0
  202. package/src/commands/add-project.ts +152 -0
  203. package/src/commands/agent.ts +404 -0
  204. package/src/commands/ask-question.ts +330 -0
  205. package/src/commands/compact.ts +157 -0
  206. package/src/commands/context-usage.ts +199 -0
  207. package/src/commands/create-new-project.ts +179 -0
  208. package/src/commands/diff.ts +165 -0
  209. package/src/commands/file-upload.ts +389 -0
  210. package/src/commands/fork.ts +320 -0
  211. package/src/commands/gemini-apikey.ts +104 -0
  212. package/src/commands/login.ts +634 -0
  213. package/src/commands/mention-mode.ts +77 -0
  214. package/src/commands/merge-worktree.ts +177 -0
  215. package/src/commands/model.ts +961 -0
  216. package/src/commands/permissions.ts +261 -0
  217. package/src/commands/queue.ts +296 -0
  218. package/src/commands/remove-project.ts +155 -0
  219. package/src/commands/restart-opencode-server.ts +162 -0
  220. package/src/commands/resume.ts +242 -0
  221. package/src/commands/run-command.ts +123 -0
  222. package/src/commands/session-id.ts +109 -0
  223. package/src/commands/session.ts +250 -0
  224. package/src/commands/share.ts +106 -0
  225. package/src/commands/types.ts +25 -0
  226. package/src/commands/undo-redo.ts +221 -0
  227. package/src/commands/unset-model.ts +189 -0
  228. package/src/commands/upgrade.ts +52 -0
  229. package/src/commands/user-command.ts +193 -0
  230. package/src/commands/verbosity.ts +88 -0
  231. package/src/commands/worktree-settings.ts +79 -0
  232. package/src/commands/worktree.ts +431 -0
  233. package/src/condense-memory.ts +36 -0
  234. package/src/config.ts +148 -0
  235. package/src/database.ts +1530 -0
  236. package/src/db.test.ts +60 -0
  237. package/src/db.ts +190 -0
  238. package/src/discord-api.ts +35 -0
  239. package/src/discord-bot.ts +1316 -0
  240. package/src/discord-utils.test.ts +132 -0
  241. package/src/discord-utils.ts +767 -0
  242. package/src/errors.ts +213 -0
  243. package/src/escape-backticks.test.ts +469 -0
  244. package/src/format-tables.test.ts +223 -0
  245. package/src/format-tables.ts +145 -0
  246. package/src/forum-sync/config.ts +92 -0
  247. package/src/forum-sync/discord-operations.ts +241 -0
  248. package/src/forum-sync/index.ts +9 -0
  249. package/src/forum-sync/markdown.ts +176 -0
  250. package/src/forum-sync/sync-to-discord.ts +595 -0
  251. package/src/forum-sync/sync-to-files.ts +294 -0
  252. package/src/forum-sync/types.ts +175 -0
  253. package/src/forum-sync/watchers.ts +454 -0
  254. package/src/genai-worker-wrapper.ts +164 -0
  255. package/src/genai-worker.ts +386 -0
  256. package/src/genai.ts +321 -0
  257. package/src/generated/browser.ts +109 -0
  258. package/src/generated/client.ts +131 -0
  259. package/src/generated/commonInputTypes.ts +512 -0
  260. package/src/generated/enums.ts +46 -0
  261. package/src/generated/internal/class.ts +362 -0
  262. package/src/generated/internal/prismaNamespace.ts +2251 -0
  263. package/src/generated/internal/prismaNamespaceBrowser.ts +308 -0
  264. package/src/generated/models/bot_api_keys.ts +1288 -0
  265. package/src/generated/models/bot_tokens.ts +1577 -0
  266. package/src/generated/models/channel_agents.ts +1256 -0
  267. package/src/generated/models/channel_directories.ts +2104 -0
  268. package/src/generated/models/channel_mention_mode.ts +1300 -0
  269. package/src/generated/models/channel_models.ts +1288 -0
  270. package/src/generated/models/channel_verbosity.ts +1224 -0
  271. package/src/generated/models/channel_worktrees.ts +1308 -0
  272. package/src/generated/models/forum_sync_configs.ts +1452 -0
  273. package/src/generated/models/global_models.ts +1288 -0
  274. package/src/generated/models/ipc_requests.ts +1485 -0
  275. package/src/generated/models/part_messages.ts +1302 -0
  276. package/src/generated/models/scheduled_tasks.ts +2320 -0
  277. package/src/generated/models/session_agents.ts +1086 -0
  278. package/src/generated/models/session_models.ts +1114 -0
  279. package/src/generated/models/session_start_sources.ts +1408 -0
  280. package/src/generated/models/thread_sessions.ts +1599 -0
  281. package/src/generated/models/thread_worktrees.ts +1352 -0
  282. package/src/generated/models.ts +29 -0
  283. package/src/heap-monitor.ts +121 -0
  284. package/src/hrana-server.test.ts +428 -0
  285. package/src/hrana-server.ts +547 -0
  286. package/src/image-utils.ts +149 -0
  287. package/src/interaction-handler.ts +461 -0
  288. package/src/ipc-polling.ts +325 -0
  289. package/src/kimaki-digital-twin.e2e.test.ts +201 -0
  290. package/src/limit-heading-depth.test.ts +116 -0
  291. package/src/limit-heading-depth.ts +26 -0
  292. package/src/logger.ts +203 -0
  293. package/src/markdown.test.ts +360 -0
  294. package/src/markdown.ts +410 -0
  295. package/src/message-formatting.test.ts +81 -0
  296. package/src/message-formatting.ts +549 -0
  297. package/src/openai-realtime.ts +362 -0
  298. package/src/opencode-plugin-loading.e2e.test.ts +112 -0
  299. package/src/opencode-plugin.test.ts +108 -0
  300. package/src/opencode-plugin.ts +652 -0
  301. package/src/opencode.ts +554 -0
  302. package/src/privacy-sanitizer.ts +142 -0
  303. package/src/schema.sql +158 -0
  304. package/src/sentry.ts +137 -0
  305. package/src/session-handler/state.ts +232 -0
  306. package/src/session-handler.ts +2668 -0
  307. package/src/session-search.test.ts +50 -0
  308. package/src/session-search.ts +148 -0
  309. package/src/startup-service.ts +200 -0
  310. package/src/system-message.ts +568 -0
  311. package/src/task-runner.ts +425 -0
  312. package/src/task-schedule.test.ts +84 -0
  313. package/src/task-schedule.ts +287 -0
  314. package/src/thinking-utils.ts +61 -0
  315. package/src/thread-message-queue.e2e.test.ts +997 -0
  316. package/src/tools.ts +432 -0
  317. package/src/unnest-code-blocks.test.ts +679 -0
  318. package/src/unnest-code-blocks.ts +168 -0
  319. package/src/upgrade.ts +127 -0
  320. package/src/utils.ts +145 -0
  321. package/src/voice-handler.ts +852 -0
  322. package/src/voice.test.ts +219 -0
  323. package/src/voice.ts +444 -0
  324. package/src/wait-session.ts +147 -0
  325. package/src/worker-types.ts +64 -0
  326. package/src/worktree-utils.ts +988 -0
  327. package/src/xml.test.ts +38 -0
  328. package/src/xml.ts +121 -0
@@ -0,0 +1,1362 @@
1
+ /**
2
+ * Goke — a cac-inspired CLI framework.
3
+ *
4
+ * This file contains the entire core framework:
5
+ * - Option: CLI option parsing (flags, required/optional values)
6
+ * - Command / GlobalCommand: command definition, help/version output
7
+ * - Goke: main CLI class with parsing, matching, and execution
8
+ * - GokeOutputStream / GokeConsole / GokeOptions: injectable I/O
9
+ * - createConsole: factory for console-like objects from output streams
10
+ * - Utility functions: string helpers, bracket parsing, dot-prop access
11
+ */
12
+
13
+ import { EventEmitter } from 'events'
14
+ import pc from 'picocolors'
15
+ import mri from "./mri.js"
16
+ import { GokeError, coerceBySchema, extractJsonSchema, extractSchemaMetadata, isStandardSchema } from "./coerce.js"
17
+ import type { StandardJSONSchemaV1 } from "./coerce.js"
18
+
19
+ // ─── Node.js platform constants ───
20
+
21
+ const processArgs = process.argv
22
+ const platformInfo = `${process.platform}-${process.arch} node-${process.version}`
23
+
24
+ // ─── Utility functions ───
25
+
26
+ const removeBrackets = (v: string) => v.replace(/[<[].+/, '').trim()
27
+
28
+ const findAllBrackets = (v: string) => {
29
+ const ANGLED_BRACKET_RE_GLOBAL = /<([^>]+)>/g
30
+ const SQUARE_BRACKET_RE_GLOBAL = /\[([^\]]+)\]/g
31
+
32
+ const res: CommandArg[] = []
33
+
34
+ const parse = (match: string[]) => {
35
+ let variadic = false
36
+ let value = match[1]
37
+ if (value.startsWith('...')) {
38
+ value = value.slice(3)
39
+ variadic = true
40
+ }
41
+ return {
42
+ required: match[0].startsWith('<'),
43
+ value,
44
+ variadic
45
+ }
46
+ }
47
+
48
+ let angledMatch
49
+ while ((angledMatch = ANGLED_BRACKET_RE_GLOBAL.exec(v))) {
50
+ res.push(parse(angledMatch))
51
+ }
52
+
53
+ let squareMatch
54
+ while ((squareMatch = SQUARE_BRACKET_RE_GLOBAL.exec(v))) {
55
+ res.push(parse(squareMatch))
56
+ }
57
+
58
+ return res
59
+ }
60
+
61
+ interface MriOptionsConfig {
62
+ alias: { [k: string]: string[] }
63
+ boolean: string[]
64
+ }
65
+
66
+ const getMriOptions = (options: Option[]) => {
67
+ const result: MriOptionsConfig = { alias: {}, boolean: [] }
68
+
69
+ for (const option of options) {
70
+ // We do not set default values in mri options
71
+ // Since its type (typeof) will be used to cast parsed arguments.
72
+ // Which mean `--foo foo` will be parsed as `{foo: true}` if we have `{default:{foo: true}}`
73
+
74
+ // Set alias
75
+ if (option.names.length > 1) {
76
+ result.alias[option.names[0]] = option.names.slice(1)
77
+ }
78
+ // Set boolean
79
+ if (option.isBoolean) {
80
+ result.boolean.push(option.names[0])
81
+ }
82
+ }
83
+
84
+ return result
85
+ }
86
+
87
+ const maxVisibleLength = (arr: string[]) => {
88
+ return arr.reduce((max, value) => {
89
+ return Math.max(max, visibleLength(value))
90
+ }, 0)
91
+ }
92
+
93
+ const ANSI_RE = /\x1B\[[0-9;]*m/g
94
+
95
+ const visibleLength = (value: string) => value.replace(ANSI_RE, '').length
96
+
97
+ const commandGreen = (value: string) => pc.bold(pc.greenBright(value))
98
+
99
+ const optionBlue = (value: string) => pc.bold(pc.blueBright(value))
100
+
101
+ const padRight = (str: string, length: number) => {
102
+ return visibleLength(str) >= length ? str : `${str}${' '.repeat(length - visibleLength(str))}`
103
+ }
104
+
105
+ const wrapLine = (line: string, width: number) => {
106
+ if (width <= 0 || visibleLength(line) <= width) {
107
+ return [line]
108
+ }
109
+
110
+ const words = line.trim().split(/\s+/)
111
+ const wrapped: string[] = []
112
+ let current = ''
113
+
114
+ for (const word of words) {
115
+ const next = current ? `${current} ${word}` : word
116
+ if (visibleLength(next) <= width) {
117
+ current = next
118
+ continue
119
+ }
120
+
121
+ if (current) {
122
+ wrapped.push(current)
123
+ }
124
+
125
+ if (visibleLength(word) <= width) {
126
+ current = word
127
+ continue
128
+ }
129
+
130
+ let remaining = word
131
+ while (visibleLength(remaining) > width) {
132
+ wrapped.push(remaining.slice(0, width))
133
+ remaining = remaining.slice(width)
134
+ }
135
+ current = remaining
136
+ }
137
+
138
+ if (current) {
139
+ wrapped.push(current)
140
+ }
141
+
142
+ return wrapped
143
+ }
144
+
145
+ const wrapDescription = (text: string, width: number) => {
146
+ const maxWidth = Math.max(20, width)
147
+ return text
148
+ .split('\n')
149
+ .flatMap((line) => {
150
+ if (line.trim() === '') {
151
+ return ['']
152
+ }
153
+ return wrapLine(line, maxWidth)
154
+ })
155
+ }
156
+
157
+ const formatWrappedDescription = (text: string, width: number, indent: number) => {
158
+ const lines = wrapDescription(text, width)
159
+ .map((line) => (line ? pc.dim(line) : line))
160
+ if (lines.length === 0) {
161
+ return ''
162
+ }
163
+ return [
164
+ lines[0],
165
+ ...lines.slice(1).map((line) => `${' '.repeat(indent)}${line}`),
166
+ ].join('\n')
167
+ }
168
+
169
+ const optionDescriptionText = (option: Option) => {
170
+ const defaultText = option.default === undefined
171
+ ? ''
172
+ : ` ${pc.cyan(`(default: ${String(option.default)})`)}`
173
+ return `${option.description}${defaultText}`.trim()
174
+ }
175
+
176
+ const camelcase = (input: string) => {
177
+ return input.replace(/([a-z])-([a-z])/g, (_, p1, p2) => {
178
+ return p1 + p2.toUpperCase()
179
+ })
180
+ }
181
+
182
+ const setDotProp = (
183
+ obj: { [k: string]: any },
184
+ keys: string[],
185
+ val: any
186
+ ) => {
187
+ let i = 0
188
+ let length = keys.length
189
+ let t = obj
190
+ let x
191
+ for (; i < length; ++i) {
192
+ x = t[keys[i]]
193
+ t = t[keys[i]] =
194
+ i === length - 1
195
+ ? val
196
+ : x != null
197
+ ? x
198
+ : !!~keys[i + 1].indexOf('.') || !(+keys[i + 1] > -1)
199
+ ? {}
200
+ : []
201
+ }
202
+ }
203
+
204
+ const getFileName = (input: string) => {
205
+ const m = /([^\\\/]+)$/.exec(input)
206
+ return m ? m[1] : ''
207
+ }
208
+
209
+ const camelcaseOptionName = (name: string) => {
210
+ // Camelcase the option name
211
+ // Don't camelcase anything after the dot `.`
212
+ return name
213
+ .split('.')
214
+ .map((v, i) => {
215
+ return i === 0 ? camelcase(v) : v
216
+ })
217
+ .join('.')
218
+ }
219
+
220
+ // ─── Option ───
221
+
222
+ class Option {
223
+ /** Option name */
224
+ name: string
225
+ /** Option name and aliases */
226
+ names: string[]
227
+ isBoolean?: boolean
228
+ // `required` will be a boolean for options with brackets
229
+ required?: boolean
230
+ /** Description text for help output */
231
+ description: string
232
+ /** Default value for this option */
233
+ default?: unknown
234
+ /** Standard JSON Schema V1 schema for type coercion and inference */
235
+ schema?: StandardJSONSchemaV1
236
+ /** Whether this option is deprecated (hidden from help output) */
237
+ deprecated?: boolean
238
+
239
+ /**
240
+ * Create an option.
241
+ * @param rawName - The raw option string, e.g. '--port <port>', '-v, --verbose'
242
+ * @param descriptionOrSchema - Either a description string or a StandardJSONSchemaV1 schema.
243
+ * When a schema is provided, description and default are extracted from the JSON Schema.
244
+ */
245
+ constructor(
246
+ public rawName: string,
247
+ descriptionOrSchema?: string | StandardJSONSchemaV1,
248
+ ) {
249
+ if (typeof descriptionOrSchema === 'string') {
250
+ this.description = descriptionOrSchema
251
+ } else if (descriptionOrSchema && isStandardSchema(descriptionOrSchema)) {
252
+ this.schema = descriptionOrSchema
253
+ const meta = extractSchemaMetadata(descriptionOrSchema)
254
+ this.description = meta.description ?? ''
255
+ if (meta.default !== undefined) {
256
+ this.default = meta.default
257
+ }
258
+ if (meta.deprecated) {
259
+ this.deprecated = true
260
+ }
261
+ } else {
262
+ this.description = ''
263
+ }
264
+
265
+ // You may use cli.option('--env.* [value]', 'desc') to denote a dot-nested option
266
+ rawName = rawName.replace(/\.\*/g, '')
267
+
268
+ this.names = removeBrackets(rawName)
269
+ .split(',')
270
+ .map((v: string) => {
271
+ let name = v.trim().replace(/^-{1,2}/, '')
272
+ return camelcaseOptionName(name)
273
+ })
274
+ .sort((a, b) => (a.length > b.length ? 1 : -1)) // Sort names
275
+
276
+ // Use the longest name (last one) as actual option name
277
+ this.name = this.names[this.names.length - 1]
278
+
279
+ if (rawName.includes('<')) {
280
+ this.required = true
281
+ } else if (rawName.includes('[')) {
282
+ this.required = false
283
+ } else {
284
+ // No arg needed, it's boolean flag
285
+ this.isBoolean = true
286
+ }
287
+ }
288
+ }
289
+
290
+ // ─── Command ───
291
+
292
+ // Type-level helpers for inferring option names and types
293
+
294
+ /**
295
+ * Converts a kebab-case string to camelCase at the type level.
296
+ * "--foo-bar <val>" → name "foo-bar" → camelCase "fooBar"
297
+ */
298
+ type CamelCase<S extends string> =
299
+ S extends `${infer L}-${infer R}`
300
+ ? `${L}${CamelCase<Capitalize<R>>}`
301
+ : S
302
+
303
+ /**
304
+ * Extracts the long option name from a raw option string.
305
+ * "-p, --port <port>" → "port"
306
+ * "--foo-bar <val>" → "fooBar"
307
+ * "--verbose" → "verbose"
308
+ */
309
+ type ExtractOptionName<S extends string> =
310
+ // Match: --name <value> or --name [value] or --name
311
+ S extends `${string}--${infer Name} <${string}>` ? CamelCase<Name> :
312
+ S extends `${string}--${infer Name} [${string}]` ? CamelCase<Name> :
313
+ S extends `${string}--${infer Name}` ? CamelCase<Name> :
314
+ string
315
+
316
+ /**
317
+ * Determines if an option takes a required value (<...>) vs optional ([...]) vs boolean flag.
318
+ */
319
+ type IsOptionalOption<S extends string> =
320
+ S extends `${string}<${string}>` ? false :
321
+ true
322
+
323
+ /**
324
+ * Infer the output type from a StandardTypedV1-compatible schema.
325
+ */
326
+ type InferSchemaOutput<S> =
327
+ S extends { readonly "~standard": { readonly types?: { readonly output: infer O } } } ? O : unknown
328
+
329
+ /**
330
+ * Build the option type entry for a single .option() call.
331
+ * Required options (<...>) produce a required key.
332
+ * Optional options ([...]) and boolean flags produce an optional key.
333
+ */
334
+ type OptionEntry<RawName extends string, Schema> =
335
+ IsOptionalOption<RawName> extends true
336
+ ? { [K in ExtractOptionName<RawName>]?: InferSchemaOutput<Schema> }
337
+ : { [K in ExtractOptionName<RawName>]: InferSchemaOutput<Schema> }
338
+
339
+ interface CommandArg {
340
+ required: boolean
341
+ value: string
342
+ variadic: boolean
343
+ }
344
+
345
+ interface HelpSection {
346
+ title?: string
347
+ body: string
348
+ }
349
+
350
+ interface CommandConfig {
351
+ allowUnknownOptions?: boolean
352
+ ignoreOptionDefaultValue?: boolean
353
+ }
354
+
355
+ type HelpCallback = (sections: HelpSection[]) => void | HelpSection[]
356
+
357
+ type CommandExample = ((bin: string) => string) | string
358
+
359
+ class Command {
360
+ options: Option[]
361
+ aliasNames: string[]
362
+ /* Parsed command name */
363
+ name: string
364
+ args: CommandArg[]
365
+ commandAction?: (...args: any[]) => any
366
+ usageText?: string
367
+ versionNumber?: string
368
+ examples: CommandExample[]
369
+ helpCallback?: HelpCallback
370
+ globalCommand?: GlobalCommand
371
+
372
+ constructor(
373
+ public rawName: string,
374
+ public description: string,
375
+ public config: CommandConfig = {},
376
+ public cli: Goke
377
+ ) {
378
+ this.options = []
379
+ this.aliasNames = []
380
+ this.name = removeBrackets(rawName)
381
+ this.args = findAllBrackets(rawName)
382
+ this.examples = []
383
+ }
384
+
385
+ usage(text: string) {
386
+ this.usageText = text
387
+ return this
388
+ }
389
+
390
+ allowUnknownOptions() {
391
+ this.config.allowUnknownOptions = true
392
+ return this
393
+ }
394
+
395
+ ignoreOptionDefaultValue() {
396
+ this.config.ignoreOptionDefaultValue = true
397
+ return this
398
+ }
399
+
400
+ version(version: string, customFlags = '-v, --version') {
401
+ this.versionNumber = version
402
+ this.option(customFlags, 'Display version number')
403
+ return this
404
+ }
405
+
406
+ example(example: CommandExample) {
407
+ this.examples.push(example)
408
+ return this
409
+ }
410
+
411
+ /**
412
+ * Add an option for this command.
413
+ *
414
+ * The second argument is either a description string or a StandardJSONSchemaV1
415
+ * schema. When a schema is provided, description and default are extracted from
416
+ * the JSON Schema automatically.
417
+ *
418
+ * @example
419
+ * ```ts
420
+ * // With Zod schema (description + default extracted from schema):
421
+ * cmd.option('--port <port>', z.number().describe('Port number'))
422
+ *
423
+ * // Without schema (plain description, values are raw strings/booleans):
424
+ * cmd.option('--verbose', 'Verbose output')
425
+ * ```
426
+ */
427
+ option<
428
+ RawName extends string,
429
+ S extends StandardJSONSchemaV1
430
+ >(rawName: RawName, schema: S): Command & { __opts: OptionEntry<RawName, S> }
431
+ option(rawName: string, descriptionOrSchema?: string | StandardJSONSchemaV1): this
432
+ option(rawName: string, descriptionOrSchema?: string | StandardJSONSchemaV1): any {
433
+ const option = new Option(rawName, descriptionOrSchema)
434
+ this.options.push(option)
435
+ return this
436
+ }
437
+
438
+ alias(name: string) {
439
+ this.aliasNames.push(name)
440
+ return this
441
+ }
442
+
443
+ action(callback: (...args: any[]) => any) {
444
+ this.commandAction = callback
445
+ return this
446
+ }
447
+
448
+ isMatched(args: string[]): { matched: boolean; consumedArgs: number } {
449
+ const nameParts = this.name.split(' ').filter(Boolean)
450
+
451
+ if (nameParts.length === 0) {
452
+ return { matched: false, consumedArgs: 0 }
453
+ }
454
+
455
+ if (args.length < nameParts.length) {
456
+ return { matched: false, consumedArgs: 0 }
457
+ }
458
+
459
+ for (let i = 0; i < nameParts.length; i++) {
460
+ if (nameParts[i] !== args[i]) {
461
+ if (i === 0 && this.aliasNames.includes(args[i])) {
462
+ continue
463
+ }
464
+ return { matched: false, consumedArgs: 0 }
465
+ }
466
+ }
467
+
468
+ return { matched: true, consumedArgs: nameParts.length }
469
+ }
470
+
471
+ get isDefaultCommand() {
472
+ return this.name === '' || this.aliasNames.includes('!')
473
+ }
474
+
475
+ get isGlobalCommand(): boolean {
476
+ return this instanceof GlobalCommand
477
+ }
478
+
479
+ /**
480
+ * Check if an option is registered in this command
481
+ * @param name Option name
482
+ */
483
+ hasOption(name: string) {
484
+ name = name.split('.')[0]
485
+ return this.options.find((option) => {
486
+ return option.names.includes(name)
487
+ })
488
+ }
489
+
490
+ outputHelp() {
491
+ const { name, commands } = this.cli
492
+ const {
493
+ versionNumber,
494
+ options: globalOptions,
495
+ helpCallback,
496
+ } = this.cli.globalCommand
497
+
498
+ let sections: HelpSection[] = [
499
+ {
500
+ body: pc.bold(pc.cyan(`${name}${versionNumber ? `/${versionNumber}` : ''}`)),
501
+ },
502
+ ]
503
+
504
+ sections.push({
505
+ title: 'Usage',
506
+ body: ` ${pc.green('$')} ${pc.bold(name)} ${this.usageText || this.rawName || '[options]'}`,
507
+ })
508
+
509
+ const showCommands =
510
+ (this.isGlobalCommand || this.isDefaultCommand) && commands.length > 0
511
+ const terminalWidth = Math.max(this.cli.columns, 40)
512
+
513
+ if (showCommands) {
514
+ const commandRows = commands.map((command) => {
515
+ const displayName = command.rawName.trim() === '' ? name : command.rawName
516
+ // Hide deprecated options from subcommand help output
517
+ const displayOptions = command.isDefaultCommand ? [] : command.options.filter((o) => !o.deprecated)
518
+ return {
519
+ command,
520
+ displayName,
521
+ displayOptions,
522
+ }
523
+ })
524
+
525
+ const longestCommandNameLength = maxVisibleLength(
526
+ commandRows.map((row) => row.displayName)
527
+ )
528
+ const longestCommandOptions = commandRows
529
+ .flatMap((row) => row.displayOptions.map((option) => option.rawName))
530
+ const longestCommandOptionNameLength = maxVisibleLength(longestCommandOptions)
531
+ const commandDescriptionColumn = 2 + longestCommandNameLength + 2
532
+ const optionDescriptionColumn = 4 + longestCommandOptionNameLength + 2
533
+ const sharedDescriptionColumn = Math.max(commandDescriptionColumn, optionDescriptionColumn)
534
+ const descriptionWidth = terminalWidth - sharedDescriptionColumn
535
+
536
+ sections.push({
537
+ title: 'Commands',
538
+ body: commandRows
539
+ .map(({ command, displayName, displayOptions }) => {
540
+ const commandDescription = formatWrappedDescription(
541
+ command.description,
542
+ descriptionWidth,
543
+ sharedDescriptionColumn,
544
+ )
545
+ const commandPrefix = ` ${pc.bold(commandGreen(displayName))}`
546
+ const commandPadding = ' '.repeat(
547
+ Math.max(2, sharedDescriptionColumn - (2 + visibleLength(displayName)))
548
+ )
549
+ const headerLine = commandDescription
550
+ ? `${commandPrefix}${commandPadding}${commandDescription}`
551
+ : commandPrefix
552
+
553
+ if (displayOptions.length === 0) {
554
+ return headerLine
555
+ }
556
+
557
+ const optionLines = displayOptions
558
+ .map((option) => {
559
+ const optionDescription = formatWrappedDescription(
560
+ optionDescriptionText(option),
561
+ descriptionWidth,
562
+ sharedDescriptionColumn,
563
+ )
564
+ const optionPrefix = ` ${optionBlue(option.rawName)}`
565
+ const optionPadding = ' '.repeat(
566
+ Math.max(2, sharedDescriptionColumn - (4 + visibleLength(option.rawName)))
567
+ )
568
+ return optionDescription
569
+ ? `${optionPrefix}${optionPadding}${optionDescription}`
570
+ : optionPrefix
571
+ })
572
+ .join('\n')
573
+
574
+ return `${headerLine}\n\n${optionLines}`
575
+ })
576
+ .join('\n\n\n'),
577
+ })
578
+ }
579
+
580
+ const defaultCommandOptions = this.isGlobalCommand
581
+ ? commands
582
+ .filter((command) => command.isDefaultCommand)
583
+ .flatMap((command) => command.options)
584
+ : []
585
+
586
+ const mergedGlobalAndDefaultOptions = [...globalOptions]
587
+ const mergedOptionNames = new Set(globalOptions.map((option) => option.name))
588
+ for (const option of defaultCommandOptions) {
589
+ if (!mergedOptionNames.has(option.name)) {
590
+ mergedGlobalAndDefaultOptions.push(option)
591
+ mergedOptionNames.add(option.name)
592
+ }
593
+ }
594
+
595
+ const mergedCommandAndGlobalOptions = [...this.options]
596
+ const mergedCommandOptionNames = new Set(this.options.map((option) => option.name))
597
+ for (const option of globalOptions || []) {
598
+ if (!mergedCommandOptionNames.has(option.name)) {
599
+ mergedCommandAndGlobalOptions.push(option)
600
+ mergedCommandOptionNames.add(option.name)
601
+ }
602
+ }
603
+
604
+ let options = this.isGlobalCommand
605
+ ? mergedGlobalAndDefaultOptions
606
+ : mergedCommandAndGlobalOptions
607
+ if (!this.isGlobalCommand && !this.isDefaultCommand) {
608
+ options = options.filter((option) => option.name !== 'version')
609
+ }
610
+ // Hide deprecated options from help output
611
+ options = options.filter((option) => !option.deprecated)
612
+ if (options.length > 0) {
613
+ const longestOptionNameLength = maxVisibleLength(
614
+ options.map((option) => option.rawName)
615
+ )
616
+ const descriptionColumn = 2 + longestOptionNameLength + 2
617
+ const descriptionWidth = terminalWidth - descriptionColumn
618
+ sections.push({
619
+ title: 'Options',
620
+ body: options
621
+ .map((option) => {
622
+ const optionLabel = padRight(option.rawName, longestOptionNameLength)
623
+ const description = formatWrappedDescription(
624
+ optionDescriptionText(option),
625
+ descriptionWidth,
626
+ descriptionColumn,
627
+ )
628
+ return description
629
+ ? ` ${optionBlue(optionLabel)} ${description}`
630
+ : ` ${optionBlue(optionLabel)}`
631
+ })
632
+ .join('\n'),
633
+ })
634
+ }
635
+
636
+ // Show full description for specific commands (not global/default)
637
+ if (!this.isGlobalCommand && !this.isDefaultCommand && this.description) {
638
+ const descriptionLines = wrapDescription(this.description, terminalWidth - 2)
639
+ sections.push({
640
+ title: 'Description',
641
+ body: descriptionLines
642
+ .map((line) => (line ? ` ${pc.dim(line)}` : ''))
643
+ .join('\n'),
644
+ })
645
+ }
646
+
647
+ if (this.examples.length > 0) {
648
+ sections.push({
649
+ title: 'Examples',
650
+ body: this.examples
651
+ .map((example) => {
652
+ if (typeof example === 'function') {
653
+ return example(name)
654
+ }
655
+ return example
656
+ })
657
+ .join('\n'),
658
+ })
659
+ }
660
+
661
+ if (helpCallback) {
662
+ sections = helpCallback(sections) || sections
663
+ }
664
+
665
+ this.cli.console.log(
666
+ sections
667
+ .map((section) => {
668
+ return section.title
669
+ ? `${pc.bold(pc.blue(section.title))}:\n${section.body}`
670
+ : section.body
671
+ })
672
+ .join('\n\n\n')
673
+ )
674
+ }
675
+
676
+ outputVersion() {
677
+ const { name } = this.cli
678
+ const { versionNumber } = this.cli.globalCommand
679
+ if (versionNumber) {
680
+ this.cli.console.log(`${name}/${versionNumber} ${platformInfo}`)
681
+ }
682
+ }
683
+
684
+ checkRequiredArgs() {
685
+ const minimalArgsCount = this.args.filter((arg) => arg.required).length
686
+
687
+ if (this.cli.args.length < minimalArgsCount) {
688
+ throw new GokeError(
689
+ `missing required args for command \`${this.rawName}\``
690
+ )
691
+ }
692
+ }
693
+
694
+ /**
695
+ * Check if the parsed options contain any unknown options
696
+ *
697
+ * Exit and output error when true
698
+ */
699
+ checkUnknownOptions() {
700
+ const { options, globalCommand } = this.cli
701
+
702
+ if (!this.config.allowUnknownOptions) {
703
+ for (const name of Object.keys(options)) {
704
+ if (
705
+ name !== '--' &&
706
+ !this.hasOption(name) &&
707
+ !globalCommand.hasOption(name)
708
+ ) {
709
+ throw new GokeError(
710
+ `Unknown option \`${name.length > 1 ? `--${name}` : `-${name}`}\``
711
+ )
712
+ }
713
+ }
714
+ }
715
+ }
716
+
717
+ /**
718
+ * Check if the required string-type options exist
719
+ */
720
+ checkOptionValue() {
721
+ const { options: parsedOptions, globalCommand } = this.cli
722
+ const options = [...globalCommand.options, ...this.options]
723
+ for (const option of options) {
724
+ // Resolve the full dot-path to get the actual value.
725
+ // For "config.port", traverse parsedOptions.config.port instead of just parsedOptions.config.
726
+ const keys = option.name.split('.')
727
+ let value: unknown = parsedOptions
728
+ for (const key of keys) {
729
+ if (value != null && typeof value === 'object') {
730
+ value = (value as Record<string, unknown>)[key]
731
+ } else {
732
+ value = undefined
733
+ break
734
+ }
735
+ }
736
+ // Check required option value
737
+ if (option.required) {
738
+ if (value === true || value === false) {
739
+ throw new GokeError(`option \`${option.rawName}\` value is missing`)
740
+ }
741
+ }
742
+ }
743
+ }
744
+ }
745
+
746
+ class GlobalCommand extends Command {
747
+ constructor(cli: Goke) {
748
+ super('@@global@@', '', {}, cli)
749
+ }
750
+ }
751
+
752
+ // ─── I/O interfaces ───
753
+
754
+ /**
755
+ * Output stream interface, modeled after Node's process.stdout / process.stderr.
756
+ * Requires only a `write` method that accepts a string.
757
+ */
758
+ interface GokeOutputStream {
759
+ write(data: string): void
760
+ }
761
+
762
+ /**
763
+ * Console-like object returned by `createConsole`.
764
+ * Provides `log` and `error` methods that route output through
765
+ * the configured GokeOutputStream instances.
766
+ */
767
+ interface GokeConsole {
768
+ log(...args: unknown[]): void
769
+ error(...args: unknown[]): void
770
+ }
771
+
772
+ /**
773
+ * Options for configuring a Goke CLI instance.
774
+ */
775
+ interface GokeOptions {
776
+ /** Custom stdout stream. Defaults to process.stdout */
777
+ stdout?: GokeOutputStream
778
+ /** Custom stderr stream. Defaults to process.stderr */
779
+ stderr?: GokeOutputStream
780
+ /** Custom argv array. Defaults to process.argv */
781
+ argv?: string[]
782
+ /** Terminal width used to wrap help output. Defaults to process.stdout.columns, or Infinity when unavailable */
783
+ columns?: number
784
+ /**
785
+ * Custom exit function called on CLI errors (unknown option, missing value, etc.).
786
+ * Defaults to process.exit. Set to a no-op or throw to prevent exit in tests.
787
+ */
788
+ exit?: (code: number) => void
789
+ }
790
+
791
+ /**
792
+ * Creates a console-like object that writes to the given output streams.
793
+ *
794
+ * Joins arguments with a space and appends a newline, then writes to the
795
+ * provided stream. Does not support format specifiers like `%d` — only
796
+ * simple string concatenation via `String()` conversion.
797
+ */
798
+ function createConsole(stdout: GokeOutputStream, stderr: GokeOutputStream): GokeConsole {
799
+ return {
800
+ log(...args: unknown[]) {
801
+ stdout.write(args.map(String).join(' ') + '\n')
802
+ },
803
+ error(...args: unknown[]) {
804
+ stderr.write(args.map(String).join(' ') + '\n')
805
+ },
806
+ }
807
+ }
808
+
809
+ // ─── Error formatting ───
810
+
811
+ /**
812
+ * Format an error for CLI output.
813
+ * Prints a red "error:" prefix with the message, followed by a dimmed stack trace.
814
+ */
815
+ function formatCliError(err: Error): string {
816
+ const lines: string[] = []
817
+ lines.push(`${pc.red(pc.bold('error:'))} ${err.message}`)
818
+ if (err.stack) {
819
+ // Extract just the stack frames (skip the first line which is the message)
820
+ const stackLines = err.stack.split('\n').slice(1)
821
+ if (stackLines.length > 0) {
822
+ lines.push('')
823
+ lines.push(pc.red(pc.dim(stackLines.join('\n'))))
824
+ }
825
+ }
826
+ return lines.join('\n')
827
+ }
828
+
829
+ // ─── Goke (main CLI class) ───
830
+
831
+ interface ParsedArgv {
832
+ args: ReadonlyArray<string>
833
+ options: {
834
+ [k: string]: any
835
+ }
836
+ }
837
+
838
+ class Goke extends EventEmitter {
839
+ /** The program name to display in help and version message */
840
+ name: string
841
+ commands: Command[]
842
+ globalCommand: GlobalCommand
843
+ matchedCommand?: Command
844
+ matchedCommandName?: string
845
+ /**
846
+ * Raw CLI arguments
847
+ */
848
+ rawArgs: string[]
849
+ /**
850
+ * Parsed CLI arguments
851
+ */
852
+ args: ParsedArgv['args']
853
+ /**
854
+ * Parsed CLI options, camelCased
855
+ */
856
+ options: ParsedArgv['options']
857
+
858
+ showHelpOnExit?: boolean
859
+ showVersionOnExit?: boolean
860
+
861
+ /** Output stream for normal output (help, version, etc.) */
862
+ readonly stdout: GokeOutputStream
863
+ /** Output stream for error output */
864
+ readonly stderr: GokeOutputStream
865
+ /** Console-like object that routes through stdout/stderr */
866
+ readonly console: GokeConsole
867
+ /** Terminal width used to wrap help output text */
868
+ readonly columns: number
869
+ /** Exit function called on CLI errors. Defaults to process.exit */
870
+ readonly exit: (code: number) => void
871
+
872
+ #defaultArgv: string[]
873
+
874
+ /**
875
+ * @param name The program name to display in help and version message
876
+ * @param options Configuration for stdout, stderr, and argv
877
+ */
878
+ constructor(name = '', options?: GokeOptions) {
879
+ super()
880
+ this.name = name
881
+ this.commands = []
882
+ this.rawArgs = []
883
+ this.args = []
884
+ this.options = {}
885
+ this.stdout = options?.stdout ?? process.stdout
886
+ this.stderr = options?.stderr ?? process.stderr
887
+ this.console = createConsole(this.stdout, this.stderr)
888
+ this.columns = options?.columns ?? process.stdout.columns ?? Number.POSITIVE_INFINITY
889
+ this.exit = options?.exit ?? ((code: number) => process.exit(code))
890
+ this.#defaultArgv = options?.argv ?? processArgs
891
+ this.globalCommand = new GlobalCommand(this)
892
+ this.globalCommand.usage('<command> [options]')
893
+ }
894
+
895
+ /**
896
+ * Add a global usage text.
897
+ *
898
+ * This is not used by sub-commands.
899
+ */
900
+ usage(text: string) {
901
+ this.globalCommand.usage(text)
902
+ return this
903
+ }
904
+
905
+ /**
906
+ * Add a sub-command
907
+ */
908
+ command(rawName: string, description?: string, config?: CommandConfig) {
909
+ const command = new Command(rawName, description || '', config, this)
910
+ command.globalCommand = this.globalCommand
911
+ this.commands.push(command)
912
+ return command
913
+ }
914
+
915
+ /**
916
+ * Add a global CLI option.
917
+ *
918
+ * Which is also applied to sub-commands.
919
+ */
920
+ option(rawName: string, descriptionOrSchema?: string | StandardJSONSchemaV1) {
921
+ this.globalCommand.option(rawName, descriptionOrSchema as any)
922
+ return this
923
+ }
924
+
925
+ /**
926
+ * Show help message when `-h, --help` flags appear.
927
+ *
928
+ */
929
+ help(callback?: HelpCallback) {
930
+ this.globalCommand.option('-h, --help', 'Display this message')
931
+ this.globalCommand.helpCallback = callback
932
+ this.showHelpOnExit = true
933
+ return this
934
+ }
935
+
936
+ /**
937
+ * Show version number when `-v, --version` flags appear.
938
+ *
939
+ */
940
+ version(version: string, customFlags = '-v, --version') {
941
+ this.globalCommand.version(version, customFlags)
942
+ this.showVersionOnExit = true
943
+ return this
944
+ }
945
+
946
+ /**
947
+ * Add a global example.
948
+ *
949
+ * This example added here will not be used by sub-commands.
950
+ */
951
+ example(example: CommandExample) {
952
+ this.globalCommand.example(example)
953
+ return this
954
+ }
955
+
956
+ /**
957
+ * Output the corresponding help message
958
+ * When a sub-command is matched, output the help message for the command
959
+ * Otherwise output the global one.
960
+ *
961
+ */
962
+ outputHelp() {
963
+ if (this.matchedCommand) {
964
+ this.matchedCommand.outputHelp()
965
+ } else {
966
+ this.globalCommand.outputHelp()
967
+ }
968
+ }
969
+
970
+ /**
971
+ * Output help for commands matching a prefix.
972
+ * Used when user types "mcp nonexistent" and we have "mcp login", "mcp status", etc.
973
+ */
974
+ outputHelpForPrefix(prefix: string, matchingCommands: Command[], fromHelpFlag = false) {
975
+ const { versionNumber } = this.globalCommand
976
+
977
+ this.console.log(`${this.name}${versionNumber ? `/${versionNumber}` : ''}`)
978
+ this.console.log()
979
+ if (!fromHelpFlag) {
980
+ this.console.log(
981
+ `Unknown command: ${this.args.join(' ')}`
982
+ )
983
+ this.console.log()
984
+ }
985
+ this.console.log(`Available "${prefix}" commands:`)
986
+ this.console.log()
987
+
988
+ const longestName = Math.max(...matchingCommands.map((c) => c.rawName.length))
989
+ for (const cmd of matchingCommands) {
990
+ const firstLine = cmd.description.split('\n')[0].trim()
991
+ this.console.log(` ${cmd.rawName.padEnd(longestName)} ${firstLine}`)
992
+ }
993
+
994
+ this.console.log()
995
+ this.console.log(`Run "${this.name} <command> --help" for more information.`)
996
+ }
997
+
998
+ /**
999
+ * Output the version number.
1000
+ *
1001
+ */
1002
+ outputVersion() {
1003
+ this.globalCommand.outputVersion()
1004
+ }
1005
+
1006
+ private setParsedInfo(
1007
+ { args, options }: ParsedArgv,
1008
+ matchedCommand?: Command,
1009
+ matchedCommandName?: string
1010
+ ) {
1011
+ this.args = args
1012
+ this.options = options
1013
+ if (matchedCommand) {
1014
+ this.matchedCommand = matchedCommand
1015
+ }
1016
+ if (matchedCommandName) {
1017
+ this.matchedCommandName = matchedCommandName
1018
+ }
1019
+ return this
1020
+ }
1021
+
1022
+ unsetMatchedCommand() {
1023
+ this.matchedCommand = undefined
1024
+ this.matchedCommandName = undefined
1025
+ }
1026
+
1027
+ /**
1028
+ * Handle a CLI error by formatting it and writing to stderr.
1029
+ * For GokeError / coercion errors, also includes a help hint.
1030
+ */
1031
+ private handleCliError(err: Error): void {
1032
+ this.console.error(formatCliError(err))
1033
+
1034
+ // Add help hint when help is enabled
1035
+ if (this.showHelpOnExit) {
1036
+ const cmdName = this.matchedCommandName
1037
+ ? `${this.name} ${this.matchedCommandName} --help`
1038
+ : `${this.name} --help`
1039
+ this.console.error(`\nRun "${cmdName}" for usage information.`)
1040
+ }
1041
+ }
1042
+
1043
+ /**
1044
+ * Parse argv
1045
+ */
1046
+ parse(
1047
+ argv = this.#defaultArgv,
1048
+ {
1049
+ /** Whether to run the action for matched command */
1050
+ run = true,
1051
+ } = {}
1052
+ ): ParsedArgv {
1053
+ this.rawArgs = argv
1054
+ if (!this.name) {
1055
+ this.name = argv[1] ? getFileName(argv[1]) : 'cli'
1056
+ }
1057
+
1058
+ let shouldParse = true
1059
+
1060
+ // Sort by name length (longest first) so "mcp login" matches before "mcp"
1061
+ const sortedCommands = [...this.commands].sort((a, b) => {
1062
+ const aLength = a.name.split(' ').filter(Boolean).length
1063
+ const bLength = b.name.split(' ').filter(Boolean).length
1064
+ return bLength - aLength
1065
+ })
1066
+
1067
+ // Search sub-commands — mri() can throw coercion errors, catch them
1068
+ try {
1069
+ for (const command of sortedCommands) {
1070
+ const parsed = this.mri(argv.slice(2), command)
1071
+
1072
+ const result = command.isMatched(parsed.args as string[])
1073
+ if (result.matched) {
1074
+ shouldParse = false
1075
+ const matchedCommandName = parsed.args.slice(0, result.consumedArgs).join(' ')
1076
+ const parsedInfo = {
1077
+ ...parsed,
1078
+ args: parsed.args.slice(result.consumedArgs),
1079
+ }
1080
+ this.setParsedInfo(parsedInfo, command, matchedCommandName)
1081
+ this.emit(`command:${matchedCommandName}`, command)
1082
+ break // Stop after first match (greedy matching)
1083
+ }
1084
+ }
1085
+
1086
+ if (shouldParse) {
1087
+ // Search the default command
1088
+ for (const command of this.commands) {
1089
+ if (command.name === '') {
1090
+ // Check if any argument is a prefix of an existing command
1091
+ // If so, don't match the default command (user probably mistyped a subcommand)
1092
+ const parsed = this.mri(argv.slice(2), command)
1093
+ const firstArg = parsed.args[0]
1094
+ if (firstArg) {
1095
+ const isPrefixOfCommand = this.commands.some((cmd) => {
1096
+ if (cmd.name === '') return false
1097
+ const cmdParts = cmd.name.split(' ')
1098
+ return cmdParts[0] === firstArg
1099
+ })
1100
+ if (isPrefixOfCommand) {
1101
+ // Don't match default command - let it fall through to "unknown command"
1102
+ continue
1103
+ }
1104
+ }
1105
+ shouldParse = false
1106
+ this.setParsedInfo(parsed, command)
1107
+ this.emit(`command:!`, command)
1108
+ }
1109
+ }
1110
+ }
1111
+
1112
+ if (shouldParse) {
1113
+ const parsed = this.mri(argv.slice(2))
1114
+ this.setParsedInfo(parsed)
1115
+ }
1116
+ } catch (err) {
1117
+ if (err instanceof GokeError) {
1118
+ this.handleCliError(err)
1119
+ this.exit(1)
1120
+ }
1121
+ throw err
1122
+ }
1123
+
1124
+ if (this.options.help && this.showHelpOnExit) {
1125
+ if (!this.matchedCommand && this.args[0]) {
1126
+ const firstArg = this.args[0]
1127
+ const matchingCommands = this.commands.filter((cmd) => {
1128
+ if (cmd.name === '') return false
1129
+ const cmdParts = cmd.name.split(' ')
1130
+ return cmdParts[0] === firstArg
1131
+ })
1132
+
1133
+ if (matchingCommands.length > 0) {
1134
+ this.outputHelpForPrefix(firstArg, matchingCommands, true)
1135
+ } else {
1136
+ this.outputHelp()
1137
+ }
1138
+ } else {
1139
+ this.outputHelp()
1140
+ }
1141
+ run = false
1142
+ this.unsetMatchedCommand()
1143
+ }
1144
+
1145
+ if (this.options.version && this.showVersionOnExit && this.matchedCommandName == null) {
1146
+ this.outputVersion()
1147
+ run = false
1148
+ this.unsetMatchedCommand()
1149
+ }
1150
+
1151
+ const parsedArgv = { args: this.args, options: this.options }
1152
+
1153
+ if (run) {
1154
+ this.runMatchedCommand()
1155
+ }
1156
+
1157
+ if (!this.matchedCommand && this.args[0] && !(this.options.help && this.showHelpOnExit)) {
1158
+ this.emit('command:*')
1159
+
1160
+ // If the first arg is a prefix of existing commands but no command matched,
1161
+ // show help automatically (user likely mistyped a subcommand)
1162
+ if (this.showHelpOnExit) {
1163
+ const firstArg = this.args[0]
1164
+ const matchingCommands = this.commands.filter((cmd) => {
1165
+ if (cmd.name === '') return false
1166
+ const cmdParts = cmd.name.split(' ')
1167
+ return cmdParts[0] === firstArg
1168
+ })
1169
+ if (matchingCommands.length > 0) {
1170
+ // Show help for commands starting with this prefix
1171
+ this.outputHelpForPrefix(firstArg, matchingCommands)
1172
+ } else {
1173
+ // Unknown command with no matching prefix: show root help
1174
+ this.outputHelp()
1175
+ }
1176
+ }
1177
+ }
1178
+
1179
+ if (
1180
+ !this.matchedCommand &&
1181
+ this.args.length === 0 &&
1182
+ this.showHelpOnExit &&
1183
+ !(this.options.help && this.showHelpOnExit)
1184
+ ) {
1185
+ this.outputHelp()
1186
+ }
1187
+
1188
+ return parsedArgv
1189
+ }
1190
+
1191
+ private mri(
1192
+ argv: string[],
1193
+ /** Matched command */ command?: Command
1194
+ ): ParsedArgv {
1195
+ // All added options
1196
+ const cliOptions = [
1197
+ ...this.globalCommand.options,
1198
+ ...(command ? command.options : []),
1199
+ ]
1200
+ const mriOptions = getMriOptions(cliOptions)
1201
+
1202
+ // Extract everything after `--` since mri doesn't support it
1203
+ let argsAfterDoubleDashes: string[] = []
1204
+ const doubleDashesIndex = argv.indexOf('--')
1205
+ if (doubleDashesIndex > -1) {
1206
+ argsAfterDoubleDashes = argv.slice(doubleDashesIndex + 1)
1207
+ argv = argv.slice(0, doubleDashesIndex)
1208
+ }
1209
+
1210
+ let parsed = mri(argv, mriOptions)
1211
+ parsed = Object.keys(parsed).reduce(
1212
+ (res, name) => {
1213
+ return {
1214
+ ...res,
1215
+ [camelcaseOptionName(name)]: parsed[name],
1216
+ }
1217
+ },
1218
+ { _: [] }
1219
+ )
1220
+
1221
+ const args = parsed._
1222
+
1223
+ const options: { [k: string]: any } = {
1224
+ '--': argsAfterDoubleDashes,
1225
+ }
1226
+
1227
+ // Set option default value
1228
+ const ignoreDefault =
1229
+ command && command.config.ignoreOptionDefaultValue
1230
+ ? command.config.ignoreOptionDefaultValue
1231
+ : this.globalCommand.config.ignoreOptionDefaultValue
1232
+
1233
+ // Build a map of option name → JSON Schema for schema-backed options
1234
+ const schemaMap = new Map<string, { jsonSchema: Record<string, unknown>; optionName: string }>()
1235
+
1236
+ for (const cliOption of cliOptions) {
1237
+ if (!ignoreDefault && cliOption.default !== undefined) {
1238
+ for (const name of cliOption.names) {
1239
+ // Use setDotProp so dot-nested defaults (e.g. "config.port") produce
1240
+ // nested objects ({ config: { port: ... } }) instead of flat keys.
1241
+ const keys = name.split('.')
1242
+ setDotProp(options, keys, cliOption.default)
1243
+ }
1244
+ }
1245
+
1246
+ // Extract JSON Schema from StandardJSONSchemaV1-compatible schema
1247
+ if (cliOption.schema) {
1248
+ const jsonSchema = extractJsonSchema(cliOption.schema)
1249
+ if (jsonSchema) {
1250
+ schemaMap.set(cliOption.name, { jsonSchema, optionName: cliOption.name })
1251
+ // Also register aliases so we can look up by any name
1252
+ for (const alias of cliOption.names) {
1253
+ schemaMap.set(alias, { jsonSchema, optionName: cliOption.name })
1254
+ }
1255
+ }
1256
+ }
1257
+ }
1258
+
1259
+ // Build sets of option names for sentinel detection.
1260
+ //
1261
+ // When mri returns `true` for value-taking options, it means "flag present, no value given".
1262
+ // For required options (<...>), the sentinel is preserved so checkOptionValue() throws.
1263
+ // For optional options ([...]) with a schema, we replace `true` with `undefined`.
1264
+ const requiredValueOptions = new Set<string>()
1265
+ const optionalValueOptions = new Set<string>()
1266
+ for (const cliOption of cliOptions) {
1267
+ if (cliOption.required === true) {
1268
+ for (const name of cliOption.names) {
1269
+ requiredValueOptions.add(name)
1270
+ }
1271
+ } else if (cliOption.required === false) {
1272
+ for (const name of cliOption.names) {
1273
+ optionalValueOptions.add(name)
1274
+ }
1275
+ }
1276
+ }
1277
+
1278
+ // Set option values (support dot-nested property name)
1279
+ // Apply schema-based coercion for options with schemas
1280
+ for (const key of Object.keys(parsed)) {
1281
+ if (key !== '_') {
1282
+ const keys = key.split('.')
1283
+ let value = parsed[key]
1284
+
1285
+ // Apply schema coercion if this option has a schema.
1286
+ // When value is boolean `true` and the option takes a value, it's mri's sentinel
1287
+ // for "flag present, no value given":
1288
+ // - Required options (<...>): preserve `true` so checkOptionValue() throws
1289
+ // - Optional options ([...]) with schema: replace with `undefined` (no typed value)
1290
+ // - Optional options ([...]) without schema: preserve `true` (original goke behavior)
1291
+ const schemaInfo = schemaMap.get(key)
1292
+ if (schemaInfo && value !== undefined) {
1293
+ if (value === true && requiredValueOptions.has(key)) {
1294
+ // Keep sentinel for checkOptionValue() to detect
1295
+ } else if (value === true && optionalValueOptions.has(key)) {
1296
+ // Optional value not given — schema expects a typed value, so return undefined
1297
+ value = undefined
1298
+ } else {
1299
+ value = coerceBySchema(value, schemaInfo.jsonSchema, schemaInfo.optionName)
1300
+ }
1301
+ }
1302
+
1303
+ setDotProp(options, keys, value)
1304
+ }
1305
+ }
1306
+
1307
+ return {
1308
+ args,
1309
+ options,
1310
+ }
1311
+ }
1312
+
1313
+ runMatchedCommand() {
1314
+ const { args, options, matchedCommand: command } = this
1315
+
1316
+ if (!command || !command.commandAction) return
1317
+
1318
+ try {
1319
+ command.checkUnknownOptions()
1320
+ command.checkOptionValue()
1321
+ command.checkRequiredArgs()
1322
+ } catch (err) {
1323
+ if (err instanceof GokeError) {
1324
+ this.handleCliError(err)
1325
+ this.exit(1)
1326
+ }
1327
+ throw err
1328
+ }
1329
+
1330
+ const actionArgs: any[] = []
1331
+ command.args.forEach((arg, index) => {
1332
+ if (arg.variadic) {
1333
+ actionArgs.push(args.slice(index))
1334
+ } else {
1335
+ actionArgs.push(args[index])
1336
+ }
1337
+ })
1338
+ actionArgs.push(options)
1339
+
1340
+ const result = command.commandAction.apply(this, actionArgs)
1341
+
1342
+ // If the action returns a promise, catch async errors
1343
+ if (result && typeof result === 'object' && typeof result.catch === 'function') {
1344
+ result.catch((err: unknown) => {
1345
+ if (err instanceof Error) {
1346
+ this.handleCliError(err)
1347
+ } else {
1348
+ this.console.error(`${pc.red(pc.bold('error:'))} ${String(err)}`)
1349
+ }
1350
+ this.exit(1)
1351
+ })
1352
+ }
1353
+
1354
+ return result
1355
+ }
1356
+ }
1357
+
1358
+ // ─── Exports ───
1359
+
1360
+ export type { GokeOutputStream, GokeConsole, GokeOptions }
1361
+ export { createConsole, Command }
1362
+ export default Goke