@agentuity/cli 0.0.48 → 0.0.49

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 (256) hide show
  1. package/bin/cli.ts +26 -5
  2. package/dist/banner.d.ts +1 -1
  3. package/dist/banner.d.ts.map +1 -1
  4. package/dist/cli-logger.d.ts +27 -0
  5. package/dist/cli-logger.d.ts.map +1 -0
  6. package/dist/cli.d.ts.map +1 -1
  7. package/dist/cmd/auth/index.d.ts.map +1 -1
  8. package/dist/cmd/auth/login.d.ts.map +1 -1
  9. package/dist/cmd/auth/logout.d.ts.map +1 -1
  10. package/dist/cmd/auth/signup.d.ts.map +1 -1
  11. package/dist/cmd/auth/ssh/add.d.ts.map +1 -1
  12. package/dist/cmd/auth/ssh/delete.d.ts.map +1 -1
  13. package/dist/cmd/auth/ssh/index.d.ts +1 -2
  14. package/dist/cmd/auth/ssh/index.d.ts.map +1 -1
  15. package/dist/cmd/auth/ssh/list.d.ts.map +1 -1
  16. package/dist/cmd/auth/whoami.d.ts.map +1 -1
  17. package/dist/cmd/bundle/ast.d.ts.map +1 -1
  18. package/dist/cmd/bundle/index.d.ts.map +1 -1
  19. package/dist/cmd/bundle/plugin.d.ts.map +1 -1
  20. package/dist/cmd/capabilities/index.d.ts +4 -0
  21. package/dist/cmd/capabilities/index.d.ts.map +1 -0
  22. package/dist/cmd/capabilities/show.d.ts +20 -0
  23. package/dist/cmd/capabilities/show.d.ts.map +1 -0
  24. package/dist/cmd/cloud/deploy.d.ts.map +1 -1
  25. package/dist/cmd/cloud/deployment/index.d.ts.map +1 -1
  26. package/dist/cmd/cloud/deployment/list.d.ts.map +1 -1
  27. package/dist/cmd/cloud/deployment/remove.d.ts.map +1 -1
  28. package/dist/cmd/cloud/deployment/rollback.d.ts.map +1 -1
  29. package/dist/cmd/cloud/deployment/show.d.ts.map +1 -1
  30. package/dist/cmd/cloud/deployment/undeploy.d.ts.map +1 -1
  31. package/dist/cmd/cloud/deployment/utils.d.ts +4 -2
  32. package/dist/cmd/cloud/deployment/utils.d.ts.map +1 -1
  33. package/dist/cmd/cloud/domain.d.ts.map +1 -1
  34. package/dist/cmd/cloud/index.d.ts.map +1 -1
  35. package/dist/cmd/cloud/resource/add.d.ts.map +1 -1
  36. package/dist/cmd/cloud/resource/delete.d.ts.map +1 -1
  37. package/dist/cmd/cloud/resource/index.d.ts +1 -2
  38. package/dist/cmd/cloud/resource/index.d.ts.map +1 -1
  39. package/dist/cmd/cloud/resource/list.d.ts.map +1 -1
  40. package/dist/cmd/cloud/scp/download.d.ts.map +1 -1
  41. package/dist/cmd/cloud/scp/index.d.ts +1 -2
  42. package/dist/cmd/cloud/scp/index.d.ts.map +1 -1
  43. package/dist/cmd/cloud/scp/upload.d.ts.map +1 -1
  44. package/dist/cmd/cloud/ssh.d.ts.map +1 -1
  45. package/dist/cmd/dev/index.d.ts.map +1 -1
  46. package/dist/cmd/env/delete.d.ts.map +1 -1
  47. package/dist/cmd/env/get.d.ts.map +1 -1
  48. package/dist/cmd/env/import.d.ts.map +1 -1
  49. package/dist/cmd/env/index.d.ts.map +1 -1
  50. package/dist/cmd/env/list.d.ts.map +1 -1
  51. package/dist/cmd/env/pull.d.ts.map +1 -1
  52. package/dist/cmd/env/push.d.ts.map +1 -1
  53. package/dist/cmd/env/set.d.ts.map +1 -1
  54. package/dist/cmd/index.d.ts.map +1 -1
  55. package/dist/cmd/kv/create-namespace.d.ts +3 -0
  56. package/dist/cmd/kv/create-namespace.d.ts.map +1 -0
  57. package/dist/cmd/kv/delete-namespace.d.ts +3 -0
  58. package/dist/cmd/kv/delete-namespace.d.ts.map +1 -0
  59. package/dist/cmd/kv/delete.d.ts +3 -0
  60. package/dist/cmd/kv/delete.d.ts.map +1 -0
  61. package/dist/cmd/kv/get.d.ts +3 -0
  62. package/dist/cmd/kv/get.d.ts.map +1 -0
  63. package/dist/cmd/kv/index.d.ts +2 -0
  64. package/dist/cmd/kv/index.d.ts.map +1 -0
  65. package/dist/cmd/kv/keys.d.ts +3 -0
  66. package/dist/cmd/kv/keys.d.ts.map +1 -0
  67. package/dist/cmd/kv/list-namespaces.d.ts +3 -0
  68. package/dist/cmd/kv/list-namespaces.d.ts.map +1 -0
  69. package/dist/cmd/kv/repl.d.ts +3 -0
  70. package/dist/cmd/kv/repl.d.ts.map +1 -0
  71. package/dist/cmd/kv/search.d.ts +3 -0
  72. package/dist/cmd/kv/search.d.ts.map +1 -0
  73. package/dist/cmd/kv/set.d.ts +3 -0
  74. package/dist/cmd/kv/set.d.ts.map +1 -0
  75. package/dist/cmd/kv/stats.d.ts +3 -0
  76. package/dist/cmd/kv/stats.d.ts.map +1 -0
  77. package/dist/cmd/kv/util.d.ts +8 -0
  78. package/dist/cmd/kv/util.d.ts.map +1 -0
  79. package/dist/cmd/objectstore/delete-bucket.d.ts +3 -0
  80. package/dist/cmd/objectstore/delete-bucket.d.ts.map +1 -0
  81. package/dist/cmd/objectstore/delete.d.ts +3 -0
  82. package/dist/cmd/objectstore/delete.d.ts.map +1 -0
  83. package/dist/cmd/objectstore/get.d.ts +3 -0
  84. package/dist/cmd/objectstore/get.d.ts.map +1 -0
  85. package/dist/cmd/objectstore/index.d.ts +2 -0
  86. package/dist/cmd/objectstore/index.d.ts.map +1 -0
  87. package/dist/cmd/objectstore/list-buckets.d.ts +3 -0
  88. package/dist/cmd/objectstore/list-buckets.d.ts.map +1 -0
  89. package/dist/cmd/objectstore/list-keys.d.ts +3 -0
  90. package/dist/cmd/objectstore/list-keys.d.ts.map +1 -0
  91. package/dist/cmd/objectstore/put.d.ts +3 -0
  92. package/dist/cmd/objectstore/put.d.ts.map +1 -0
  93. package/dist/cmd/objectstore/repl.d.ts +3 -0
  94. package/dist/cmd/objectstore/repl.d.ts.map +1 -0
  95. package/dist/cmd/objectstore/url.d.ts +3 -0
  96. package/dist/cmd/objectstore/url.d.ts.map +1 -0
  97. package/dist/cmd/objectstore/util.d.ts +8 -0
  98. package/dist/cmd/objectstore/util.d.ts.map +1 -0
  99. package/dist/cmd/profile/create.d.ts.map +1 -1
  100. package/dist/cmd/profile/delete.d.ts.map +1 -1
  101. package/dist/cmd/profile/index.d.ts.map +1 -1
  102. package/dist/cmd/profile/list.d.ts +1 -2
  103. package/dist/cmd/profile/list.d.ts.map +1 -1
  104. package/dist/cmd/profile/show.d.ts.map +1 -1
  105. package/dist/cmd/profile/use.d.ts.map +1 -1
  106. package/dist/cmd/project/create.d.ts.map +1 -1
  107. package/dist/cmd/project/delete.d.ts.map +1 -1
  108. package/dist/cmd/project/index.d.ts.map +1 -1
  109. package/dist/cmd/project/list.d.ts.map +1 -1
  110. package/dist/cmd/project/show.d.ts.map +1 -1
  111. package/dist/cmd/project/template-flow.d.ts +1 -1
  112. package/dist/cmd/project/template-flow.d.ts.map +1 -1
  113. package/dist/cmd/prompt/index.d.ts +4 -0
  114. package/dist/cmd/prompt/index.d.ts.map +1 -0
  115. package/dist/cmd/prompt/llm.d.ts +3 -0
  116. package/dist/cmd/prompt/llm.d.ts.map +1 -0
  117. package/dist/cmd/repl/index.d.ts +3 -0
  118. package/dist/cmd/repl/index.d.ts.map +1 -0
  119. package/dist/cmd/schema/index.d.ts +4 -0
  120. package/dist/cmd/schema/index.d.ts.map +1 -0
  121. package/dist/cmd/schema/show.d.ts +3 -0
  122. package/dist/cmd/schema/show.d.ts.map +1 -0
  123. package/dist/cmd/secret/delete.d.ts.map +1 -1
  124. package/dist/cmd/secret/get.d.ts.map +1 -1
  125. package/dist/cmd/secret/import.d.ts.map +1 -1
  126. package/dist/cmd/secret/index.d.ts.map +1 -1
  127. package/dist/cmd/secret/list.d.ts.map +1 -1
  128. package/dist/cmd/secret/pull.d.ts.map +1 -1
  129. package/dist/cmd/secret/push.d.ts.map +1 -1
  130. package/dist/cmd/secret/set.d.ts.map +1 -1
  131. package/dist/cmd/version/index.d.ts.map +1 -1
  132. package/dist/config.d.ts +1 -1
  133. package/dist/config.d.ts.map +1 -1
  134. package/dist/errors.d.ts +83 -0
  135. package/dist/errors.d.ts.map +1 -0
  136. package/dist/explain.d.ts +47 -0
  137. package/dist/explain.d.ts.map +1 -0
  138. package/dist/index.d.ts +6 -0
  139. package/dist/index.d.ts.map +1 -1
  140. package/dist/json.d.ts +3 -0
  141. package/dist/json.d.ts.map +1 -0
  142. package/dist/output.d.ts +136 -0
  143. package/dist/output.d.ts.map +1 -0
  144. package/dist/repl.d.ts +124 -0
  145. package/dist/repl.d.ts.map +1 -0
  146. package/dist/schema-generator.d.ts +67 -0
  147. package/dist/schema-generator.d.ts.map +1 -0
  148. package/dist/tui.d.ts +11 -1
  149. package/dist/tui.d.ts.map +1 -1
  150. package/dist/types.d.ts +65 -6
  151. package/dist/types.d.ts.map +1 -1
  152. package/package.json +9 -4
  153. package/src/banner.ts +7 -7
  154. package/src/cli-logger.ts +80 -0
  155. package/src/cli.ts +186 -54
  156. package/src/cmd/auth/index.ts +1 -0
  157. package/src/cmd/auth/login.ts +7 -2
  158. package/src/cmd/auth/logout.ts +4 -0
  159. package/src/cmd/auth/signup.ts +7 -2
  160. package/src/cmd/auth/ssh/add.ts +20 -3
  161. package/src/cmd/auth/ssh/delete.ts +57 -4
  162. package/src/cmd/auth/ssh/index.ts +4 -3
  163. package/src/cmd/auth/ssh/list.ts +29 -12
  164. package/src/cmd/auth/whoami.ts +32 -21
  165. package/src/cmd/bundle/ast.ts +27 -5
  166. package/src/cmd/bundle/index.ts +20 -0
  167. package/src/cmd/bundle/plugin.ts +36 -12
  168. package/src/cmd/capabilities/index.ts +12 -0
  169. package/src/cmd/capabilities/show.ts +256 -0
  170. package/src/cmd/cloud/deploy.ts +54 -0
  171. package/src/cmd/cloud/deployment/index.ts +1 -0
  172. package/src/cmd/cloud/deployment/list.ts +50 -3
  173. package/src/cmd/cloud/deployment/remove.ts +26 -2
  174. package/src/cmd/cloud/deployment/rollback.ts +35 -4
  175. package/src/cmd/cloud/deployment/show.ts +37 -2
  176. package/src/cmd/cloud/deployment/undeploy.ts +12 -1
  177. package/src/cmd/cloud/deployment/utils.ts +5 -2
  178. package/src/cmd/cloud/domain.ts +3 -2
  179. package/src/cmd/cloud/index.ts +8 -1
  180. package/src/cmd/cloud/resource/add.ts +19 -0
  181. package/src/cmd/cloud/resource/delete.ts +24 -3
  182. package/src/cmd/cloud/resource/index.ts +4 -3
  183. package/src/cmd/cloud/resource/list.ts +36 -10
  184. package/src/cmd/cloud/scp/download.ts +27 -1
  185. package/src/cmd/cloud/scp/index.ts +4 -3
  186. package/src/cmd/cloud/scp/upload.ts +27 -1
  187. package/src/cmd/cloud/ssh.ts +12 -0
  188. package/src/cmd/dev/index.ts +11 -7
  189. package/src/cmd/dev/templates.ts +1 -1
  190. package/src/cmd/env/delete.ts +17 -0
  191. package/src/cmd/env/get.ts +17 -1
  192. package/src/cmd/env/import.ts +47 -3
  193. package/src/cmd/env/index.ts +1 -0
  194. package/src/cmd/env/list.ts +13 -1
  195. package/src/cmd/env/pull.ts +20 -0
  196. package/src/cmd/env/push.ts +33 -1
  197. package/src/cmd/env/set.ts +25 -1
  198. package/src/cmd/index.ts +9 -2
  199. package/src/cmd/kv/create-namespace.ts +45 -0
  200. package/src/cmd/kv/delete-namespace.ts +73 -0
  201. package/src/cmd/kv/delete.ts +51 -0
  202. package/src/cmd/kv/get.ts +65 -0
  203. package/src/cmd/kv/index.ts +31 -0
  204. package/src/cmd/kv/keys.ts +57 -0
  205. package/src/cmd/kv/list-namespaces.ts +43 -0
  206. package/src/cmd/kv/repl.ts +284 -0
  207. package/src/cmd/kv/search.ts +80 -0
  208. package/src/cmd/kv/set.ts +63 -0
  209. package/src/cmd/kv/stats.ts +96 -0
  210. package/src/cmd/kv/util.ts +32 -0
  211. package/src/cmd/objectstore/delete-bucket.ts +72 -0
  212. package/src/cmd/objectstore/delete.ts +59 -0
  213. package/src/cmd/objectstore/get.ts +64 -0
  214. package/src/cmd/objectstore/index.ts +27 -0
  215. package/src/cmd/objectstore/list-buckets.ts +45 -0
  216. package/src/cmd/objectstore/list-keys.ts +60 -0
  217. package/src/cmd/objectstore/put.ts +62 -0
  218. package/src/cmd/objectstore/repl.ts +235 -0
  219. package/src/cmd/objectstore/url.ts +59 -0
  220. package/src/cmd/objectstore/util.ts +28 -0
  221. package/src/cmd/profile/create.ts +28 -2
  222. package/src/cmd/profile/delete.ts +17 -2
  223. package/src/cmd/profile/index.ts +1 -0
  224. package/src/cmd/profile/list.ts +7 -3
  225. package/src/cmd/profile/show.ts +20 -5
  226. package/src/cmd/profile/use.ts +8 -0
  227. package/src/cmd/project/create.ts +31 -0
  228. package/src/cmd/project/delete.ts +24 -2
  229. package/src/cmd/project/index.ts +1 -0
  230. package/src/cmd/project/list.ts +23 -9
  231. package/src/cmd/project/show.ts +27 -8
  232. package/src/cmd/project/template-flow.ts +10 -6
  233. package/src/cmd/prompt/index.ts +12 -0
  234. package/src/cmd/prompt/llm.ts +368 -0
  235. package/src/cmd/repl/index.ts +477 -0
  236. package/src/cmd/schema/index.ts +12 -0
  237. package/src/cmd/schema/show.ts +27 -0
  238. package/src/cmd/secret/delete.ts +17 -0
  239. package/src/cmd/secret/get.ts +20 -1
  240. package/src/cmd/secret/import.ts +45 -2
  241. package/src/cmd/secret/index.ts +1 -0
  242. package/src/cmd/secret/list.ts +10 -1
  243. package/src/cmd/secret/pull.ts +20 -0
  244. package/src/cmd/secret/push.ts +33 -1
  245. package/src/cmd/secret/set.ts +20 -0
  246. package/src/cmd/version/index.ts +15 -2
  247. package/src/config.ts +17 -4
  248. package/src/errors.ts +222 -0
  249. package/src/explain.ts +126 -0
  250. package/src/index.ts +51 -0
  251. package/src/json.ts +28 -0
  252. package/src/output.ts +307 -0
  253. package/src/repl.ts +1517 -0
  254. package/src/schema-generator.ts +389 -0
  255. package/src/tui.ts +84 -13
  256. package/src/types.ts +62 -12
package/src/repl.ts ADDED
@@ -0,0 +1,1517 @@
1
+ /**
2
+ * Reusable REPL (Read-Eval-Print Loop) component for building interactive CLI tools
3
+ */
4
+
5
+ import * as tui from './tui';
6
+ import { getDefaultConfigDir } from './config';
7
+ import { join } from 'node:path';
8
+ import { mkdir } from 'node:fs/promises';
9
+ import { z } from 'zod';
10
+ import { Table } from 'console-table-printer';
11
+ import { colorize } from 'json-colorizer';
12
+
13
+ /**
14
+ * Result of parsing a REPL command
15
+ */
16
+ export interface ParsedCommand {
17
+ /** The command name */
18
+ command: string;
19
+ /** Command arguments (positional) */
20
+ args: string[];
21
+ /** Command options (flags/named parameters) */
22
+ options: Record<string, string | boolean>;
23
+ }
24
+
25
+ /**
26
+ * Table column definition
27
+ */
28
+ export interface TableColumn {
29
+ /** Column name */
30
+ name: string;
31
+ /** Column alignment */
32
+ alignment?: 'left' | 'right' | 'center';
33
+ }
34
+
35
+ /**
36
+ * Context provided to command handlers
37
+ */
38
+ export interface ReplContext {
39
+ /** The parsed command */
40
+ parsed: ParsedCommand;
41
+ /** Raw input line */
42
+ raw: string;
43
+ /** Write output to the REPL */
44
+ write: (message: string) => void;
45
+ /** Write error output to the REPL */
46
+ error: (message: string) => void;
47
+ /** Write success output to the REPL */
48
+ success: (message: string) => void;
49
+ /** Write info output to the REPL */
50
+ info: (message: string) => void;
51
+ /** Write warning output to the REPL */
52
+ warning: (message: string) => void;
53
+ /** Write debug output to the REPL */
54
+ debug: (message: string) => void;
55
+ /** Update the progress/activity message */
56
+ setProgress: (message: string) => void;
57
+ /** Abort signal for cancelling long-running operations */
58
+ signal: AbortSignal;
59
+ /** Exit the REPL */
60
+ exit: () => void;
61
+ /** Render a table with columns and data */
62
+ table: (columns: TableColumn[], data: Record<string, unknown>[]) => void;
63
+ /** Render colorized JSON output */
64
+ json: (value: unknown) => void;
65
+ }
66
+
67
+ /**
68
+ * Command handler function - can return void, Promise<void>, or an async generator for streaming
69
+ */
70
+ export type CommandHandler = (
71
+ ctx: ReplContext
72
+ ) => void | Promise<void> | AsyncGenerator<string, void, unknown>;
73
+
74
+ /**
75
+ * Command schema for validation and autocomplete
76
+ */
77
+ export interface ReplCommandSchema {
78
+ /** Zod schema for arguments (positional) */
79
+ args?: z.ZodTuple<[z.ZodTypeAny, ...z.ZodTypeAny[]]> | z.ZodArray<z.ZodTypeAny>;
80
+ /** Zod schema for options (flags) */
81
+ options?: z.ZodObject<z.ZodRawShape>;
82
+ /** Argument names for display (e.g., ['message', 'count']) */
83
+ argNames?: string[];
84
+ }
85
+
86
+ /**
87
+ * Subcommand definition
88
+ */
89
+ export interface ReplSubcommand {
90
+ /** Subcommand name */
91
+ name: string;
92
+ /** Subcommand description */
93
+ description?: string;
94
+ /** Subcommand handler */
95
+ handler: CommandHandler;
96
+ /** Aliases for this subcommand */
97
+ aliases?: string[];
98
+ /** Schema for validation and autocomplete hints */
99
+ schema?: ReplCommandSchema;
100
+ }
101
+
102
+ /**
103
+ * Command definition for the REPL
104
+ */
105
+ export interface ReplCommand {
106
+ /** Command name */
107
+ name: string;
108
+ /** Command description (shown in help) */
109
+ description?: string;
110
+ /** Command handler (not used if subcommands provided) */
111
+ handler?: CommandHandler;
112
+ /** Aliases for this command */
113
+ aliases?: string[];
114
+ /** Schema for validation and autocomplete hints */
115
+ schema?: ReplCommandSchema;
116
+ /** Subcommands for this command group */
117
+ subcommands?: ReplSubcommand[];
118
+ }
119
+
120
+ /**
121
+ * REPL configuration
122
+ */
123
+ export interface ReplConfig {
124
+ /** REPL prompt (default: "> ") */
125
+ prompt?: string;
126
+ /** Welcome message shown on startup */
127
+ welcome?: string;
128
+ /** Exit message shown on exit */
129
+ exitMessage?: string;
130
+ /** Commands to register */
131
+ commands: ReplCommand[];
132
+ /** Show help command automatically (default: true) */
133
+ showHelp?: boolean;
134
+ /** Name for history file (saved as ~/.config/agentuity/history/<name>.txt) */
135
+ name?: string;
136
+ }
137
+
138
+ /**
139
+ * Parse a command line into command, args, and options
140
+ */
141
+ function parseCommandLine(line: string): ParsedCommand {
142
+ const tokens: string[] = [];
143
+ let current = '';
144
+ let inQuotes = false;
145
+ let quoteChar = '';
146
+ let braceDepth = 0;
147
+ let bracketDepth = 0;
148
+
149
+ // Tokenize the input, respecting quotes and JSON objects/arrays
150
+ for (let i = 0; i < line.length; i++) {
151
+ const char = line[i];
152
+
153
+ if ((char === '"' || char === "'") && !inQuotes) {
154
+ inQuotes = true;
155
+ quoteChar = char;
156
+ current += char;
157
+ } else if (char === quoteChar && inQuotes) {
158
+ inQuotes = false;
159
+ quoteChar = '';
160
+ current += char;
161
+ } else if (char === '{' && !inQuotes) {
162
+ braceDepth++;
163
+ current += char;
164
+ } else if (char === '}' && !inQuotes) {
165
+ braceDepth--;
166
+ current += char;
167
+ } else if (char === '[' && !inQuotes) {
168
+ bracketDepth++;
169
+ current += char;
170
+ } else if (char === ']' && !inQuotes) {
171
+ bracketDepth--;
172
+ current += char;
173
+ } else if (char === ' ' && !inQuotes && braceDepth === 0 && bracketDepth === 0) {
174
+ if (current) {
175
+ tokens.push(current);
176
+ current = '';
177
+ }
178
+ } else {
179
+ current += char;
180
+ }
181
+ }
182
+ if (current) {
183
+ tokens.push(current);
184
+ }
185
+
186
+ const command = tokens[0] || '';
187
+ const args: string[] = [];
188
+ const options: Record<string, string | boolean> = {};
189
+
190
+ // Parse remaining tokens into args and options
191
+ for (let i = 1; i < tokens.length; i++) {
192
+ const token = tokens[i];
193
+
194
+ if (token.startsWith('--')) {
195
+ // Long option: --name=value or --flag
196
+ const name = token.slice(2);
197
+ const eqIndex = name.indexOf('=');
198
+
199
+ if (eqIndex > 0) {
200
+ options[name.slice(0, eqIndex)] = name.slice(eqIndex + 1);
201
+ } else {
202
+ // Check if next token is a value
203
+ if (i + 1 < tokens.length && !tokens[i + 1].startsWith('-')) {
204
+ options[name] = tokens[i + 1];
205
+ i++;
206
+ } else {
207
+ options[name] = true;
208
+ }
209
+ }
210
+ } else if (token.startsWith('-') && token.length > 1) {
211
+ // Short option: -f or -n value
212
+ const name = token.slice(1);
213
+
214
+ // Check if next token is a value
215
+ if (i + 1 < tokens.length && !tokens[i + 1].startsWith('-')) {
216
+ options[name] = tokens[i + 1];
217
+ i++;
218
+ } else {
219
+ options[name] = true;
220
+ }
221
+ } else {
222
+ args.push(token);
223
+ }
224
+ }
225
+
226
+ return { command, args, options };
227
+ }
228
+
229
+ /**
230
+ * Load history from file
231
+ */
232
+ async function loadHistory(name: string): Promise<string[]> {
233
+ if (!name) return [];
234
+
235
+ try {
236
+ const historyDir = join(getDefaultConfigDir(), 'history');
237
+ const historyFile = join(historyDir, `${name}.txt`);
238
+
239
+ if (!(await Bun.file(historyFile).exists())) {
240
+ return [];
241
+ }
242
+
243
+ const content = await Bun.file(historyFile).text();
244
+ return content.split('\n').filter((line) => line.trim());
245
+ } catch (_err) {
246
+ return [];
247
+ }
248
+ }
249
+
250
+ /**
251
+ * Save history to file
252
+ */
253
+ async function saveHistory(name: string, history: string[]): Promise<void> {
254
+ if (!name) return;
255
+
256
+ try {
257
+ const historyDir = join(getDefaultConfigDir(), 'history');
258
+ await mkdir(historyDir, { recursive: true });
259
+
260
+ const historyFile = join(historyDir, `${name}.txt`);
261
+ await Bun.write(historyFile, history.join('\n'));
262
+ } catch (_err) {
263
+ // Silently fail - history is not critical
264
+ }
265
+ }
266
+
267
+ /**
268
+ * Create and run a REPL
269
+ */
270
+ export async function createRepl(config: ReplConfig): Promise<void> {
271
+ const prompt = config.prompt || '> ';
272
+ const showHelp = config.showHelp !== false;
273
+ const historyName = config.name || '';
274
+
275
+ // Load command history
276
+ const history = await loadHistory(historyName);
277
+ let historyIndex = history.length;
278
+
279
+ // Build command map with aliases
280
+ const commandMap = new Map<string, ReplCommand>();
281
+ for (const cmd of config.commands) {
282
+ commandMap.set(cmd.name.toLowerCase(), cmd);
283
+ if (cmd.aliases) {
284
+ for (const alias of cmd.aliases) {
285
+ commandMap.set(alias.toLowerCase(), cmd);
286
+ }
287
+ }
288
+ }
289
+
290
+ // Add built-in help command
291
+ if (showHelp) {
292
+ const helpCommand: ReplCommand = {
293
+ name: 'help',
294
+ description: 'Show available commands',
295
+ aliases: ['?'],
296
+ handler: (ctx) => {
297
+ ctx.info('Available commands:');
298
+ tui.newline();
299
+
300
+ for (const cmd of config.commands) {
301
+ const aliases = cmd.aliases ? ` (${cmd.aliases.join(', ')})` : '';
302
+ const desc = cmd.description || 'No description';
303
+ console.log(` ${tui.bold(cmd.name)}${tui.muted(aliases)}`);
304
+ console.log(` ${desc}`);
305
+ }
306
+
307
+ tui.newline();
308
+ console.log(` ${tui.bold('exit')} ${tui.muted('(quit, q)')}`);
309
+ console.log(` Exit the REPL`);
310
+ },
311
+ };
312
+ commandMap.set('help', helpCommand);
313
+ commandMap.set('?', helpCommand);
314
+ }
315
+
316
+ // Show welcome message
317
+ if (config.welcome) {
318
+ console.log(tui.bold(config.welcome));
319
+ tui.newline();
320
+ }
321
+
322
+ // REPL state
323
+ let running = true;
324
+ const ctrlCState = { lastTime: 0 };
325
+ let commandAbortController: AbortController | null = null;
326
+
327
+ const exitRepl = () => {
328
+ running = false;
329
+ };
330
+
331
+ // Build list of all command names for autocomplete
332
+ const commandNames = Array.from(commandMap.keys());
333
+
334
+ // Remove any existing SIGINT handlers to prevent default exit
335
+ process.removeAllListeners('SIGINT');
336
+
337
+ // Setup global SIGINT handler to prevent default exit
338
+ const globalSigintHandler = () => {
339
+ // If command is running, abort it
340
+ if (commandAbortController && !commandAbortController.signal.aborted) {
341
+ commandAbortController.abort();
342
+ // Don't exit - just abort the command
343
+ return;
344
+ }
345
+ // If not running command, ignore - let raw mode handler deal with it
346
+ };
347
+ process.on('SIGINT', globalSigintHandler);
348
+
349
+ // Main REPL loop
350
+ while (running) {
351
+ // Reset Ctrl+C timer when starting new command
352
+ ctrlCState.lastTime = 0;
353
+
354
+ // Read input with history support
355
+ process.stdout.write(prompt);
356
+
357
+ const input = await readLine(
358
+ history,
359
+ historyIndex,
360
+ prompt,
361
+ commandNames,
362
+ commandMap,
363
+ ctrlCState
364
+ );
365
+ if (input === null) {
366
+ // EOF (Ctrl+D)
367
+ break;
368
+ }
369
+
370
+ const { line: rawInput, newHistoryIndex } = input;
371
+ historyIndex = newHistoryIndex;
372
+
373
+ const line = rawInput.trim();
374
+ if (!line) {
375
+ continue;
376
+ }
377
+
378
+ // Parse command
379
+ const parsed = parseCommandLine(line);
380
+
381
+ // Check for exit commands
382
+ if (['exit', 'quit', 'q'].includes(parsed.command.toLowerCase())) {
383
+ break;
384
+ }
385
+
386
+ // Find and execute command
387
+ const cmd = commandMap.get(parsed.command.toLowerCase());
388
+ if (!cmd) {
389
+ tui.error(`Unknown command: ${parsed.command}`);
390
+ console.log(`Type ${tui.bold('help')} for available commands`);
391
+ continue;
392
+ }
393
+
394
+ // Check if command has subcommands
395
+ let actualHandler = cmd.handler;
396
+ let actualSchema = cmd.schema;
397
+
398
+ if (cmd.subcommands && cmd.subcommands.length > 0) {
399
+ // Parse subcommand from first arg
400
+ const subcommandName = parsed.args[0]?.toLowerCase();
401
+
402
+ if (!subcommandName) {
403
+ tui.error(`Missing subcommand for ${parsed.command}`);
404
+ console.log('Available subcommands:');
405
+ for (const sub of cmd.subcommands) {
406
+ const argHint = sub.schema?.argNames?.map((n) => `<${n}>`).join(' ') || '';
407
+ console.log(
408
+ ` ${tui.bold(sub.name)} ${argHint} ${tui.muted(sub.description || '')}`
409
+ );
410
+ }
411
+ continue;
412
+ }
413
+
414
+ const subcommand = cmd.subcommands.find(
415
+ (sub) =>
416
+ sub.name.toLowerCase() === subcommandName || sub.aliases?.includes(subcommandName)
417
+ );
418
+
419
+ if (!subcommand) {
420
+ tui.error(`Unknown subcommand: ${parsed.command} ${subcommandName}`);
421
+ console.log('Available subcommands:');
422
+ for (const sub of cmd.subcommands) {
423
+ const argHint = sub.schema?.argNames?.map((n) => `<${n}>`).join(' ') || '';
424
+ console.log(
425
+ ` ${tui.bold(sub.name)} ${argHint} ${tui.muted(sub.description || '')}`
426
+ );
427
+ }
428
+ continue;
429
+ }
430
+
431
+ // Use subcommand handler and schema, remove subcommand from args
432
+ actualHandler = subcommand.handler;
433
+ actualSchema = subcommand.schema;
434
+ parsed.args = parsed.args.slice(1);
435
+ }
436
+
437
+ if (!actualHandler) {
438
+ tui.error(`Command ${parsed.command} requires a subcommand`);
439
+ continue;
440
+ }
441
+
442
+ // Validate against schema if provided
443
+ if (actualSchema) {
444
+ try {
445
+ if (actualSchema.args) {
446
+ parsed.args = actualSchema.args.parse(parsed.args) as string[];
447
+ }
448
+
449
+ if (actualSchema.options) {
450
+ parsed.options = actualSchema.options.parse(parsed.options) as Record<
451
+ string,
452
+ string | boolean
453
+ >;
454
+ }
455
+ } catch (err) {
456
+ if (err instanceof z.ZodError) {
457
+ tui.error('Invalid arguments:');
458
+ for (const issue of err.issues) {
459
+ const path = issue.path.join('.');
460
+ console.log(
461
+ ` ${tui.colorError('•')} ${path ? `${path}: ` : ''}${issue.message}`
462
+ );
463
+ }
464
+ continue;
465
+ }
466
+ throw err;
467
+ }
468
+ }
469
+
470
+ // Create context with output buffering for paging
471
+ const outputBuffer: string[] = [];
472
+
473
+ const bufferWrite = (msg: string) => {
474
+ outputBuffer.push(...msg.split('\n'));
475
+ };
476
+
477
+ // Helper to format messages with icons and colors (without printing)
478
+ const formatMessage = (
479
+ type: 'success' | 'error' | 'info' | 'warning' | 'debug',
480
+ msg: string
481
+ ): string => {
482
+ const icons = {
483
+ success: '✓',
484
+ error: '✗',
485
+ warning: '⚠',
486
+ info: 'ℹ',
487
+ debug: '',
488
+ };
489
+
490
+ const colorFormatters = {
491
+ success: tui.colorSuccess,
492
+ error: tui.colorError,
493
+ warning: tui.colorWarning,
494
+ info: tui.colorInfo,
495
+ debug: tui.colorMuted,
496
+ };
497
+
498
+ const icon = icons[type];
499
+ const formatter = colorFormatters[type];
500
+
501
+ if (!icon) {
502
+ return formatter(msg); // debug messages have no icon
503
+ }
504
+
505
+ return `${formatter(icon + ' ' + msg)}`;
506
+ };
507
+
508
+ // Create abort controller for this command
509
+ const abortController = new AbortController();
510
+ commandAbortController = abortController;
511
+
512
+ const ctx: ReplContext = {
513
+ parsed,
514
+ raw: line,
515
+ write: bufferWrite,
516
+ error: (msg: string) => outputBuffer.push(formatMessage('error', msg)),
517
+ success: (msg: string) => outputBuffer.push(formatMessage('success', msg)),
518
+ info: (msg: string) => outputBuffer.push(formatMessage('info', msg)),
519
+ warning: (msg: string) => outputBuffer.push(formatMessage('warning', msg)),
520
+ debug: (msg: string) => outputBuffer.push(formatMessage('debug', msg)),
521
+ setProgress: (msg: string) => spinner.updateMessage(msg),
522
+ signal: abortController.signal,
523
+ exit: exitRepl,
524
+ table: (columns: TableColumn[], data: Record<string, unknown>[]) => {
525
+ const table = new Table({ columns });
526
+ for (const row of data) {
527
+ table.addRow(row);
528
+ }
529
+ // Capture table output to buffer instead of direct stdout
530
+ const tableOutput = table.render();
531
+ outputBuffer.push(...tableOutput.split('\n'));
532
+ },
533
+ json: (value: unknown) => {
534
+ // Use util.inspect for colorized output if colors are enabled
535
+ const stringValue = typeof value === 'string' ? value : JSON.stringify(value, null, 2);
536
+ const output = colorize(stringValue);
537
+ outputBuffer.push(output);
538
+ },
539
+ };
540
+
541
+ // Execute command handler with activity indicator
542
+ const spinner = new ActivityIndicator(parsed.command);
543
+ spinner.start();
544
+
545
+ try {
546
+ const result = actualHandler(ctx);
547
+
548
+ // Handle async generator (streaming)
549
+ if (result && typeof result === 'object' && Symbol.asyncIterator in result) {
550
+ for await (const chunk of result as AsyncGenerator<string, void, unknown>) {
551
+ if (abortController.signal.aborted) {
552
+ break;
553
+ }
554
+ outputBuffer.push(...chunk.split('\n'));
555
+ }
556
+ } else if (result && typeof result === 'object' && 'then' in result) {
557
+ // Handle promise
558
+ await result;
559
+ }
560
+
561
+ spinner.stop();
562
+ commandAbortController = null;
563
+
564
+ // Check if aborted
565
+ if (abortController.signal.aborted) {
566
+ tui.warning('Command aborted');
567
+ continue;
568
+ }
569
+
570
+ // Display output with paging
571
+ if (outputBuffer.length > 0) {
572
+ await displayWithPaging(outputBuffer);
573
+ }
574
+
575
+ // Add successful command to history
576
+ if (history[history.length - 1] !== line) {
577
+ history.push(line);
578
+ historyIndex = history.length;
579
+
580
+ // Save history asynchronously (don't await to avoid blocking)
581
+ saveHistory(historyName, history);
582
+ }
583
+ } catch (err) {
584
+ spinner.stop();
585
+ commandAbortController = null;
586
+
587
+ // Check if it was an abort
588
+ if (err instanceof Error && err.name === 'AbortError') {
589
+ tui.warning('Command aborted');
590
+ } else {
591
+ tui.error(`Command failed: ${err instanceof Error ? err.message : String(err)}`);
592
+ }
593
+ }
594
+ }
595
+
596
+ // Cleanup global handler
597
+ process.off('SIGINT', globalSigintHandler);
598
+
599
+ // Show exit message
600
+ if (config.exitMessage) {
601
+ tui.info(config.exitMessage);
602
+ }
603
+ }
604
+
605
+ /**
606
+ * Get all autocomplete matches based on current input
607
+ */
608
+ function getAutocompleteMatches(
609
+ buffer: string,
610
+ commands: string[],
611
+ commandMap?: Map<string, ReplCommand>
612
+ ): string[] {
613
+ if (!buffer.trim()) return [];
614
+
615
+ const tokens = buffer.trim().split(/\s+/);
616
+ const firstToken = tokens[0].toLowerCase();
617
+
618
+ // If we're typing the first word (no trailing space), suggest commands
619
+ if (tokens.length === 1 && buffer === buffer.trimEnd()) {
620
+ return commands.filter((cmd) => cmd.startsWith(firstToken) && cmd !== firstToken);
621
+ }
622
+
623
+ // If we have a command + space, suggest subcommands
624
+ if (tokens.length === 1 && buffer !== buffer.trimEnd() && commandMap) {
625
+ const cmd = commandMap.get(firstToken);
626
+ if (cmd?.subcommands) {
627
+ return cmd.subcommands.map((sub) => sub.name);
628
+ }
629
+ return [];
630
+ }
631
+
632
+ // If we're typing a subcommand, filter matches
633
+ if (tokens.length === 2 && buffer === buffer.trimEnd() && commandMap) {
634
+ const cmd = commandMap.get(firstToken);
635
+ if (cmd?.subcommands) {
636
+ const subToken = tokens[1].toLowerCase();
637
+ return cmd.subcommands
638
+ .filter((sub) => sub.name.startsWith(subToken) && sub.name !== subToken)
639
+ .map((sub) => sub.name);
640
+ }
641
+ return [];
642
+ }
643
+
644
+ return [];
645
+ }
646
+
647
+ /**
648
+ * Get autocomplete suggestion based on current input and cycle index
649
+ */
650
+ function getAutocompleteSuggestion(
651
+ buffer: string,
652
+ commands: string[],
653
+ commandMap: Map<string, ReplCommand>,
654
+ cycleIndex: number = 0
655
+ ): string {
656
+ const matches = getAutocompleteMatches(buffer, commands, commandMap);
657
+ if (matches.length === 0) return '';
658
+
659
+ const selectedMatch = matches[cycleIndex % matches.length];
660
+ const tokens = buffer.trim().split(/\s+/);
661
+ const firstToken = tokens[0].toLowerCase();
662
+
663
+ // Typing first word (command name)
664
+ if (tokens.length === 1 && buffer === buffer.trimEnd()) {
665
+ const cmd = commandMap.get(selectedMatch.toLowerCase());
666
+ let suggestion = selectedMatch.slice(firstToken.length);
667
+
668
+ // Add argument placeholders if schema exists
669
+ if (cmd?.schema?.argNames) {
670
+ suggestion += ' ' + cmd.schema.argNames.map((name) => `<${name}>`).join(' ');
671
+ }
672
+ // Add subcommand hint if this command has subcommands
673
+ else if (cmd?.subcommands && cmd.subcommands.length > 0) {
674
+ suggestion += ' <subcommand>';
675
+ }
676
+
677
+ return suggestion;
678
+ }
679
+
680
+ // After command + space, suggesting subcommands
681
+ if (tokens.length === 1 && buffer !== buffer.trimEnd()) {
682
+ return selectedMatch;
683
+ }
684
+
685
+ // Typing subcommand name
686
+ if (tokens.length === 2 && buffer === buffer.trimEnd()) {
687
+ const cmd = commandMap.get(firstToken);
688
+ const subToken = tokens[1];
689
+ const subcommand = cmd?.subcommands?.find((sub) => sub.name === selectedMatch);
690
+
691
+ let suggestion = selectedMatch.slice(subToken.length);
692
+
693
+ // Add argument placeholders for subcommand
694
+ if (subcommand?.schema?.argNames) {
695
+ suggestion += ' ' + subcommand.schema.argNames.map((name) => `<${name}>`).join(' ');
696
+ }
697
+
698
+ return suggestion;
699
+ }
700
+
701
+ return '';
702
+ }
703
+
704
+ /**
705
+ * Display output with paging if it's too long
706
+ */
707
+ async function displayWithPaging(lines: string[]): Promise<void> {
708
+ const terminalHeight = process.stdout.rows || 24;
709
+ const pageSize = terminalHeight - 2; // Leave room for prompt
710
+
711
+ if (lines.length <= pageSize) {
712
+ // Short output, just display it
713
+ for (const line of lines) {
714
+ console.log(`\x1b[2m│\x1b[0m ${line}`);
715
+ }
716
+ return;
717
+ }
718
+
719
+ // Long output, page it
720
+ let currentLine = 0;
721
+
722
+ while (currentLine < lines.length) {
723
+ // Clear screen and show current page
724
+ const endLine = Math.min(currentLine + pageSize, lines.length);
725
+
726
+ for (let i = currentLine; i < endLine; i++) {
727
+ console.log(`${tui.colorMuted('│')} ${lines[i]}`);
728
+ }
729
+
730
+ // Check if there's more
731
+ if (endLine < lines.length) {
732
+ const remaining = lines.length - endLine;
733
+ process.stdout.write(tui.bold(`-- More (${remaining} lines) -- [Space=next, q=quit]`));
734
+
735
+ // Wait for keypress
736
+ const key = await waitForKey();
737
+ process.stdout.write('\r\x1b[K'); // Clear the "More" line
738
+
739
+ if (key === 'q' || key === '\x03') {
740
+ // Quit
741
+ console.log(`${tui.colorMuted('│')} (output truncated)`);
742
+ break;
743
+ } else if (key === ' ' || key === '\r' || key === '\n') {
744
+ // Continue to next page
745
+ currentLine = endLine;
746
+ } else {
747
+ // Any other key, continue
748
+ currentLine = endLine;
749
+ }
750
+ } else {
751
+ break;
752
+ }
753
+ }
754
+ }
755
+
756
+ /**
757
+ * Wait for a single keypress
758
+ */
759
+ async function waitForKey(): Promise<string> {
760
+ return new Promise((resolve) => {
761
+ process.stdin.setRawMode(true);
762
+ process.stdin.resume();
763
+
764
+ const onData = (chunk: Buffer) => {
765
+ const key = chunk.toString();
766
+ process.stdin.setRawMode(false);
767
+ process.stdin.removeListener('data', onData);
768
+ process.stdin.pause();
769
+ resolve(key);
770
+ };
771
+
772
+ process.stdin.on('data', onData);
773
+ });
774
+ }
775
+
776
+ /**
777
+ * Activity indicator that shows a spinner while command is executing
778
+ */
779
+ class ActivityIndicator {
780
+ private frames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
781
+ private currentFrame = 0;
782
+ private intervalId: Timer | null = null;
783
+ private message: string;
784
+
785
+ constructor(message: string = 'Running') {
786
+ this.message = message;
787
+ }
788
+
789
+ start() {
790
+ // Hide cursor
791
+ process.stdout.write('\x1b[?25l');
792
+
793
+ // Show initial spinner on current line
794
+ this.draw();
795
+
796
+ // Update spinner every 80ms
797
+ this.intervalId = setInterval(() => {
798
+ this.currentFrame = (this.currentFrame + 1) % this.frames.length;
799
+ this.draw();
800
+ }, 80);
801
+ }
802
+
803
+ private draw() {
804
+ const frame = this.frames[this.currentFrame];
805
+ // Clear line, draw spinner, stay on same line
806
+ process.stdout.write('\r\x1b[K'); // Clear line from cursor
807
+ process.stdout.write(`${tui.muted(frame)} ${tui.muted(this.message)}...`);
808
+ }
809
+
810
+ stop() {
811
+ if (this.intervalId) {
812
+ clearInterval(this.intervalId);
813
+ this.intervalId = null;
814
+ }
815
+
816
+ // Clear the spinner line - cursor stays at start of line
817
+ process.stdout.write('\r\x1b[K'); // Clear current line, cursor at start
818
+
819
+ // Show cursor again
820
+ process.stdout.write('\x1b[?25h');
821
+ }
822
+
823
+ updateMessage(message: string) {
824
+ this.message = message;
825
+ }
826
+ }
827
+
828
+ /**
829
+ * Show command picker popup and return selected command
830
+ */
831
+ async function showCommandPicker(
832
+ commandMap: Map<string, ReplCommand>,
833
+ prompt: string
834
+ ): Promise<string | null> {
835
+ // Build list of commands
836
+ const commands = Array.from(commandMap.entries())
837
+ .filter(([name, cmd]) => name === cmd.name.toLowerCase())
838
+ .sort(([a], [b]) => a.localeCompare(b))
839
+ .map(([name, cmd]) => ({
840
+ name,
841
+ description: cmd.description || 'No description',
842
+ argHint: cmd.schema?.argNames?.map((n) => `<${n}>`).join(' ') || '',
843
+ }));
844
+
845
+ let selectedIndex = 0;
846
+ const menuHeight = commands.length + 2; // +2 for header and blank line
847
+
848
+ // Calculate max command text length for padding
849
+ const maxCmdLength = Math.max(
850
+ ...commands.map((cmd) => {
851
+ const cmdText = `${cmd.name}${cmd.argHint ? ' ' + cmd.argHint : ''}`;
852
+ return cmdText.length;
853
+ })
854
+ );
855
+
856
+ const drawPicker = () => {
857
+ // Save cursor position, move down to draw menu
858
+ process.stdout.write('\n'); // Move to next line
859
+
860
+ // Draw header
861
+ console.log(
862
+ tui.bold('Command Picker') + ' ' + tui.muted('(↑/↓ navigate, Enter select, Esc cancel)')
863
+ );
864
+
865
+ // Draw commands
866
+ for (let i = 0; i < commands.length; i++) {
867
+ const cmd = commands[i];
868
+ const isSelected = i === selectedIndex;
869
+ const prefix = isSelected ? '▶ ' : ' ';
870
+ const style = isSelected ? '\x1b[7m' : ''; // Reverse video for selected
871
+ const reset = isSelected ? '\x1b[0m' : '';
872
+
873
+ const cmdText = `${cmd.name}${cmd.argHint ? ' ' + cmd.argHint : ''}`;
874
+ const paddedCmdText = cmdText.padEnd(maxCmdLength);
875
+ const description = tui.muted(cmd.description);
876
+
877
+ console.log(`${prefix}${style}${tui.bold(paddedCmdText)}${reset} ${description}`);
878
+ }
879
+
880
+ // Move cursor back to prompt line
881
+ process.stdout.write(`\x1b[${menuHeight}A`); // Move up N lines
882
+ process.stdout.write(`\r${prompt}/`); // Redraw prompt with /
883
+ };
884
+
885
+ const clearPicker = () => {
886
+ // Move down to menu area
887
+ process.stdout.write(`\x1b[${menuHeight}B`); // Move down to after menu
888
+ // Clear all menu lines by moving up and clearing
889
+ for (let i = 0; i < menuHeight; i++) {
890
+ process.stdout.write('\x1b[A'); // Move up
891
+ process.stdout.write('\r\x1b[K'); // Clear line
892
+ }
893
+ // Back to prompt - clear the line completely
894
+ process.stdout.write('\r\x1b[K');
895
+ };
896
+
897
+ drawPicker();
898
+
899
+ return new Promise((resolve) => {
900
+ process.stdin.setRawMode(true);
901
+ process.stdin.resume();
902
+
903
+ const onData = (chunk: Buffer) => {
904
+ const bytes = Array.from(chunk);
905
+
906
+ // Escape or Ctrl+C - cancel
907
+ if (bytes[0] === 0x1b && bytes.length === 1) {
908
+ cleanup();
909
+ clearPicker();
910
+ resolve(null);
911
+ return;
912
+ }
913
+
914
+ if (bytes[0] === 0x03) {
915
+ cleanup();
916
+ clearPicker();
917
+ resolve(null);
918
+ return;
919
+ }
920
+
921
+ // Enter - select
922
+ if (bytes[0] === 0x0d || bytes[0] === 0x0a) {
923
+ const selected = commands[selectedIndex];
924
+ cleanup();
925
+ clearPicker();
926
+ resolve(selected.name + (selected.argHint ? ' ' : ''));
927
+ return;
928
+ }
929
+
930
+ // Arrow keys
931
+ if (bytes[0] === 0x1b && bytes[1] === 0x5b) {
932
+ // Up arrow
933
+ if (bytes[2] === 0x41) {
934
+ selectedIndex = selectedIndex > 0 ? selectedIndex - 1 : commands.length - 1;
935
+ drawPicker();
936
+ return;
937
+ }
938
+
939
+ // Down arrow
940
+ if (bytes[2] === 0x42) {
941
+ selectedIndex = (selectedIndex + 1) % commands.length;
942
+ drawPicker();
943
+ return;
944
+ }
945
+ }
946
+ };
947
+
948
+ const cleanup = () => {
949
+ process.stdin.setRawMode(false);
950
+ process.stdin.removeListener('data', onData);
951
+ process.stdin.pause();
952
+ };
953
+
954
+ process.stdin.on('data', onData);
955
+ });
956
+ }
957
+
958
+ /**
959
+ * Apply syntax highlighting to buffer
960
+ */
961
+ function applySyntaxHighlighting(buffer: string, commands: string[]): string {
962
+ if (!buffer.trim()) return buffer;
963
+
964
+ const tokens = buffer.split(/(\s+)/); // Split but keep whitespace
965
+ let result = '';
966
+ let isFirstToken = true;
967
+
968
+ for (let i = 0; i < tokens.length; i++) {
969
+ const token = tokens[i];
970
+
971
+ // Skip whitespace
972
+ if (/^\s+$/.test(token)) {
973
+ result += token;
974
+ continue;
975
+ }
976
+
977
+ // First token is the command
978
+ if (isFirstToken) {
979
+ const isValid = commands.includes(token.toLowerCase());
980
+ if (isValid) {
981
+ result += tui.colorSuccess(token); // Green for valid command
982
+ } else {
983
+ result += tui.colorError(token); // Red for invalid command
984
+ }
985
+ isFirstToken = false;
986
+ }
987
+ // Options (start with - or --)
988
+ else if (token.startsWith('-')) {
989
+ result += tui.colorInfo(token); // Cyan for options
990
+ }
991
+ // Regular arguments
992
+ else {
993
+ result += tui.colorMuted(token); // Gray for arguments
994
+ }
995
+ }
996
+
997
+ return result;
998
+ }
999
+
1000
+ /**
1001
+ * Read a line from stdin with arrow key history support and autocomplete
1002
+ */
1003
+ async function readLine(
1004
+ history: string[],
1005
+ startIndex: number,
1006
+ prompt: string = '',
1007
+ commands: string[] = [],
1008
+ commandMap?: Map<string, ReplCommand>,
1009
+ ctrlCState?: { lastTime: number }
1010
+ ): Promise<{ line: string; newHistoryIndex: number } | null> {
1011
+ return new Promise((resolve) => {
1012
+ process.stdin.setRawMode(true);
1013
+ process.stdin.resume();
1014
+
1015
+ // Enable vertical bar cursor
1016
+ process.stdout.write('\x1b[6 q');
1017
+
1018
+ let buffer = '';
1019
+ let cursorPos = 0;
1020
+ let historyIndex = startIndex;
1021
+ let searchMode = false;
1022
+ let searchQuery = '';
1023
+ let searchResultIndex = -1;
1024
+ let autocompleteCycleIndex = 0;
1025
+ let lastAutocompleteBuffer = '';
1026
+ const lines: string[] = [''];
1027
+ let currentLineIndex = 0;
1028
+
1029
+ const searchHistory = (query: string, startFrom: number): number => {
1030
+ for (let i = startFrom - 1; i >= 0; i--) {
1031
+ if (history[i].toLowerCase().includes(query.toLowerCase())) {
1032
+ return i;
1033
+ }
1034
+ }
1035
+ return -1;
1036
+ };
1037
+
1038
+ const redraw = (cmdMap?: Map<string, ReplCommand>) => {
1039
+ if (searchMode) {
1040
+ // Search mode display
1041
+ process.stdout.write('\r\x1b[K');
1042
+ const foundEntry = searchResultIndex >= 0 ? history[searchResultIndex] : '';
1043
+ const searchPrompt = `(reverse-i-search)\`${searchQuery}': `;
1044
+ process.stdout.write(searchPrompt + foundEntry);
1045
+ } else if (lines.length > 1) {
1046
+ // Multi-line mode - redraw all lines
1047
+ // Move to start of first line
1048
+ for (let i = 0; i < currentLineIndex; i++) {
1049
+ process.stdout.write('\x1b[A'); // Move up
1050
+ }
1051
+ process.stdout.write('\r');
1052
+
1053
+ // Redraw all lines
1054
+ for (let i = 0; i < lines.length; i++) {
1055
+ process.stdout.write('\x1b[K'); // Clear line
1056
+ const linePrompt = i === 0 ? prompt : '... ';
1057
+ process.stdout.write(linePrompt + lines[i]);
1058
+ if (i < lines.length - 1) {
1059
+ process.stdout.write('\n');
1060
+ }
1061
+ }
1062
+
1063
+ // Position cursor on current line at cursor position
1064
+ const linesToMove = lines.length - 1 - currentLineIndex;
1065
+ for (let i = 0; i < linesToMove; i++) {
1066
+ process.stdout.write('\x1b[A'); // Move up to current line
1067
+ }
1068
+ const linePrompt = currentLineIndex === 0 ? prompt : '... ';
1069
+ process.stdout.write('\r');
1070
+ process.stdout.write(linePrompt + lines[currentLineIndex].slice(0, cursorPos));
1071
+ } else {
1072
+ // Single-line mode (original behavior)
1073
+ process.stdout.write('\r\x1b[K');
1074
+ const suggestion = cmdMap
1075
+ ? getAutocompleteSuggestion(buffer, commands, cmdMap, autocompleteCycleIndex)
1076
+ : '';
1077
+ const matches = cmdMap ? getAutocompleteMatches(buffer, commands, cmdMap) : [];
1078
+
1079
+ // Apply syntax highlighting to buffer
1080
+ const highlightedBuffer = applySyntaxHighlighting(buffer, commands);
1081
+ process.stdout.write(prompt + highlightedBuffer);
1082
+
1083
+ // Show suggestion in dark gray (only when cursor is at end)
1084
+ const showSuggestion = suggestion && cursorPos === buffer.length;
1085
+ if (showSuggestion) {
1086
+ process.stdout.write(`\x1b[90m${suggestion}\x1b[0m`);
1087
+
1088
+ // Show match count if multiple matches
1089
+ if (matches.length > 1) {
1090
+ process.stdout.write(
1091
+ ` \x1b[90m[${autocompleteCycleIndex + 1}/${matches.length}]\x1b[0m`
1092
+ );
1093
+ }
1094
+ }
1095
+
1096
+ // Move cursor to correct position (accounting for prompt length and suggestion)
1097
+ const suggestionLength = showSuggestion ? suggestion.length : 0;
1098
+ const counterLength =
1099
+ showSuggestion && matches.length > 1
1100
+ ? ` [${autocompleteCycleIndex + 1}/${matches.length}]`.length
1101
+ : 0;
1102
+ const totalLength = buffer.length + suggestionLength + counterLength;
1103
+ const diff = totalLength - cursorPos;
1104
+ if (diff > 0) {
1105
+ process.stdout.write(`\x1b[${diff}D`);
1106
+ }
1107
+ }
1108
+ };
1109
+
1110
+ const onData = async (chunk: Buffer) => {
1111
+ const bytes = Array.from(chunk);
1112
+
1113
+ // Check for / key - show command picker
1114
+ if (bytes[0] === 0x2f && buffer.length === 0 && commandMap) {
1115
+ // '/' key at start of line
1116
+
1117
+ // Temporarily remove our listener to avoid conflicts
1118
+ process.stdin.removeListener('data', onData);
1119
+
1120
+ const selected = await showCommandPicker(commandMap, prompt);
1121
+
1122
+ // Re-attach our listener and restore state
1123
+ process.stdin.setRawMode(true);
1124
+ process.stdin.resume();
1125
+ process.stdin.on('data', onData);
1126
+
1127
+ if (selected) {
1128
+ buffer = selected;
1129
+ cursorPos = buffer.length;
1130
+ lines[currentLineIndex] = buffer;
1131
+ // Reset autocomplete state
1132
+ autocompleteCycleIndex = 0;
1133
+ lastAutocompleteBuffer = '';
1134
+ // Force full redraw after command picker
1135
+ redraw(commandMap);
1136
+ }
1137
+ return;
1138
+ }
1139
+
1140
+ // Check for Ctrl+C - double press to exit
1141
+ if (bytes[0] === 0x03 && ctrlCState) {
1142
+ const now = Date.now();
1143
+ const timeSinceLastCtrlC = now - ctrlCState.lastTime;
1144
+
1145
+ // If pressed within 2 seconds, exit
1146
+ if (timeSinceLastCtrlC < 2000 && ctrlCState.lastTime > 0) {
1147
+ cleanup();
1148
+ console.log(''); // Newline before exit
1149
+ process.exit(0);
1150
+ }
1151
+
1152
+ // First Ctrl+C - show message below, keep prompt in place
1153
+ ctrlCState.lastTime = now;
1154
+
1155
+ // Save cursor position
1156
+ const promptWithBuffer = prompt + buffer;
1157
+
1158
+ // Move to new line and show message
1159
+ process.stdout.write('\n');
1160
+ process.stdout.write(tui.muted('Press Ctrl+C again to exit'));
1161
+
1162
+ // Move back up to prompt line
1163
+ process.stdout.write('\x1b[A'); // Move up one line
1164
+ process.stdout.write(`\r`); // Go to start of line
1165
+ process.stdout.write(promptWithBuffer); // Redraw prompt
1166
+
1167
+ // Position cursor at correct location
1168
+ if (cursorPos < buffer.length) {
1169
+ const diff = buffer.length - cursorPos;
1170
+ process.stdout.write(`\x1b[${diff}D`);
1171
+ }
1172
+
1173
+ return;
1174
+ }
1175
+
1176
+ // Check for Ctrl+D (EOF)
1177
+ if (bytes[0] === 0x04 && buffer.length === 0) {
1178
+ cleanup();
1179
+ resolve(null);
1180
+ return;
1181
+ }
1182
+
1183
+ // Check for Ctrl+A (jump to start)
1184
+ if (bytes[0] === 0x01) {
1185
+ cursorPos = 0;
1186
+ redraw(commandMap);
1187
+ return;
1188
+ }
1189
+
1190
+ // Check for Ctrl+E (jump to end)
1191
+ if (bytes[0] === 0x05) {
1192
+ cursorPos = buffer.length;
1193
+ redraw(commandMap);
1194
+ return;
1195
+ }
1196
+
1197
+ // Check for Ctrl+K (delete to end of line)
1198
+ if (bytes[0] === 0x0b) {
1199
+ buffer = buffer.slice(0, cursorPos);
1200
+ redraw(commandMap);
1201
+ return;
1202
+ }
1203
+
1204
+ // Check for Ctrl+U (delete entire line)
1205
+ if (bytes[0] === 0x15) {
1206
+ buffer = '';
1207
+ cursorPos = 0;
1208
+ redraw(commandMap);
1209
+ return;
1210
+ }
1211
+
1212
+ // Check for Ctrl+W (delete word backward)
1213
+ if (bytes[0] === 0x17) {
1214
+ const beforeCursor = buffer.slice(0, cursorPos);
1215
+ const match = beforeCursor.match(/\s*\S+\s*$/);
1216
+ if (match) {
1217
+ const deleteCount = match[0].length;
1218
+ buffer = buffer.slice(0, cursorPos - deleteCount) + buffer.slice(cursorPos);
1219
+ cursorPos -= deleteCount;
1220
+ redraw(commandMap);
1221
+ }
1222
+ return;
1223
+ }
1224
+
1225
+ // Check for Ctrl+L (clear screen)
1226
+ if (bytes[0] === 0x0c) {
1227
+ process.stdout.write('\x1b[2J\x1b[H');
1228
+ redraw(commandMap);
1229
+ return;
1230
+ }
1231
+
1232
+ // Check for Ctrl+R (reverse search)
1233
+ if (bytes[0] === 0x12) {
1234
+ if (!searchMode) {
1235
+ // Enter search mode
1236
+ searchMode = true;
1237
+ searchQuery = '';
1238
+ searchResultIndex = searchHistory('', history.length);
1239
+ redraw(commandMap);
1240
+ } else {
1241
+ // Find next match
1242
+ if (searchResultIndex > 0) {
1243
+ searchResultIndex = searchHistory(searchQuery, searchResultIndex);
1244
+ redraw(commandMap);
1245
+ }
1246
+ }
1247
+ return;
1248
+ }
1249
+
1250
+ // Check for Tab (autocomplete - cycle only)
1251
+ if (bytes[0] === 0x09 && commandMap) {
1252
+ const matches = getAutocompleteMatches(buffer, commands, commandMap);
1253
+
1254
+ if (matches.length === 0) {
1255
+ // No matches, do nothing
1256
+ return;
1257
+ }
1258
+
1259
+ // Check if we're cycling through the same buffer
1260
+ if (buffer === lastAutocompleteBuffer && matches.length > 0) {
1261
+ // Continue cycling
1262
+ autocompleteCycleIndex = (autocompleteCycleIndex + 1) % matches.length;
1263
+ redraw(commandMap);
1264
+ } else {
1265
+ // Start new cycle - show first suggestion
1266
+ autocompleteCycleIndex = 0;
1267
+ lastAutocompleteBuffer = buffer;
1268
+ redraw(commandMap);
1269
+ }
1270
+ return;
1271
+ }
1272
+
1273
+ // Check for Shift+Enter (newline without submit)
1274
+ // Different terminals send different sequences:
1275
+ // - iTerm2/Terminal.app: ESC + Enter (0x1b, 0x0d or 0x1b, 0x0a)
1276
+ // - Some terminals: ESC[27;2;13~
1277
+ // - VSCode: ESC[13;2u
1278
+ if (
1279
+ (bytes[0] === 0x1b && bytes.length === 2 && (bytes[1] === 0x0d || bytes[1] === 0x0a)) || // ESC + Enter
1280
+ (bytes[0] === 0x1b &&
1281
+ bytes[1] === 0x5b &&
1282
+ bytes.includes(0x3b) &&
1283
+ bytes.includes(0x32)) || // ESC[...;2;...
1284
+ (bytes[0] === 0x1b &&
1285
+ bytes[1] === 0x5b &&
1286
+ bytes[2] === 0x31 &&
1287
+ bytes[3] === 0x33 &&
1288
+ bytes[4] === 0x3b &&
1289
+ bytes[5] === 0x32) // ESC[13;2u
1290
+ ) {
1291
+ // Shift+Enter detected - add newline
1292
+ lines.push('');
1293
+ currentLineIndex++;
1294
+ cursorPos = 0;
1295
+ buffer = lines[currentLineIndex];
1296
+ process.stdout.write('\n');
1297
+ process.stdout.write('... ');
1298
+ return;
1299
+ }
1300
+
1301
+ // Check for Enter
1302
+ if (bytes[0] === 0x0d || bytes[0] === 0x0a) {
1303
+ if (searchMode) {
1304
+ // Accept search result
1305
+ if (searchResultIndex >= 0) {
1306
+ buffer = history[searchResultIndex];
1307
+ }
1308
+ searchMode = false;
1309
+ process.stdout.write('\n');
1310
+ cleanup();
1311
+ resolve({ line: buffer, newHistoryIndex: history.length });
1312
+ return;
1313
+ }
1314
+
1315
+ // Check if line ends with backslash (continuation)
1316
+ if (buffer.endsWith('\\')) {
1317
+ // Remove backslash and continue to next line
1318
+ lines[currentLineIndex] = buffer.slice(0, -1);
1319
+ lines.push('');
1320
+ currentLineIndex++;
1321
+ cursorPos = 0;
1322
+ buffer = '';
1323
+ process.stdout.write('\n');
1324
+ process.stdout.write('... ');
1325
+ return;
1326
+ }
1327
+
1328
+ // Check for unclosed quotes or brackets
1329
+ const hasUnclosedQuote =
1330
+ (buffer.match(/"/g) || []).length % 2 !== 0 ||
1331
+ (buffer.match(/'/g) || []).length % 2 !== 0;
1332
+ const openBrackets = (buffer.match(/[{[(]/g) || []).length;
1333
+ const closeBrackets = (buffer.match(/[}\])]/g) || []).length;
1334
+
1335
+ if (hasUnclosedQuote || openBrackets > closeBrackets) {
1336
+ // Auto-continue to next line
1337
+ lines[currentLineIndex] = buffer;
1338
+ lines.push('');
1339
+ currentLineIndex++;
1340
+ cursorPos = 0;
1341
+ buffer = '';
1342
+ process.stdout.write('\n');
1343
+ process.stdout.write('... ');
1344
+ return;
1345
+ }
1346
+
1347
+ // Submit the command
1348
+ process.stdout.write('\n');
1349
+ const finalBuffer = lines.length > 1 ? lines.join('\n') : buffer;
1350
+ cleanup();
1351
+ resolve({ line: finalBuffer, newHistoryIndex: history.length });
1352
+ return;
1353
+ }
1354
+
1355
+ // Check for Esc key (cancel search mode)
1356
+ if (bytes[0] === 0x1b && bytes.length === 1) {
1357
+ if (searchMode) {
1358
+ searchMode = false;
1359
+ searchQuery = '';
1360
+ searchResultIndex = -1;
1361
+ redraw(commandMap);
1362
+ }
1363
+ return;
1364
+ }
1365
+
1366
+ // Check for escape sequences (arrow keys, delete, home, end)
1367
+ if (bytes[0] === 0x1b && bytes[1] === 0x5b) {
1368
+ // Up arrow
1369
+ if (bytes[2] === 0x41) {
1370
+ if (historyIndex > 0) {
1371
+ historyIndex--;
1372
+ buffer = history[historyIndex] || '';
1373
+ cursorPos = buffer.length;
1374
+ redraw(commandMap);
1375
+ }
1376
+ return;
1377
+ }
1378
+
1379
+ // Down arrow
1380
+ if (bytes[2] === 0x42) {
1381
+ if (historyIndex < history.length) {
1382
+ historyIndex++;
1383
+ buffer = history[historyIndex] || '';
1384
+ cursorPos = buffer.length;
1385
+ redraw(commandMap);
1386
+ }
1387
+ return;
1388
+ }
1389
+
1390
+ // Right arrow
1391
+ if (bytes[2] === 0x43) {
1392
+ // Check if we're at the end and have a suggestion to accept
1393
+ if (cursorPos === buffer.length && commandMap) {
1394
+ const suggestion = getAutocompleteSuggestion(
1395
+ buffer,
1396
+ commands,
1397
+ commandMap,
1398
+ autocompleteCycleIndex
1399
+ );
1400
+ if (suggestion) {
1401
+ // Accept the autocomplete suggestion (without argument placeholders)
1402
+ // Remove argument placeholders like <key> <value> from suggestion
1403
+ let completionOnly = suggestion.replace(/<[^>]+>/g, '').trimEnd();
1404
+
1405
+ // Add trailing space if there were argument placeholders
1406
+ if (suggestion.includes('<')) {
1407
+ completionOnly += ' ';
1408
+ }
1409
+
1410
+ buffer += completionOnly;
1411
+ cursorPos = buffer.length;
1412
+ lines[currentLineIndex] = buffer;
1413
+ autocompleteCycleIndex = 0;
1414
+ lastAutocompleteBuffer = '';
1415
+ redraw(commandMap);
1416
+ return;
1417
+ }
1418
+ }
1419
+
1420
+ // Normal cursor movement
1421
+ if (cursorPos < buffer.length) {
1422
+ cursorPos++;
1423
+ process.stdout.write('\x1b[C');
1424
+ }
1425
+ return;
1426
+ }
1427
+
1428
+ // Left arrow
1429
+ if (bytes[2] === 0x44) {
1430
+ if (cursorPos > 0) {
1431
+ cursorPos--;
1432
+ process.stdout.write('\x1b[D');
1433
+ }
1434
+ return;
1435
+ }
1436
+
1437
+ // Delete key (ESC[3~)
1438
+ if (bytes[2] === 0x33 && bytes.length > 3 && bytes[3] === 0x7e) {
1439
+ if (cursorPos < buffer.length) {
1440
+ buffer = buffer.slice(0, cursorPos) + buffer.slice(cursorPos + 1);
1441
+ redraw(commandMap);
1442
+ }
1443
+ return;
1444
+ }
1445
+
1446
+ // Home key (ESC[H or ESC[1~)
1447
+ if (bytes[2] === 0x48 || (bytes[2] === 0x31 && bytes[3] === 0x7e)) {
1448
+ cursorPos = 0;
1449
+ redraw(commandMap);
1450
+ return;
1451
+ }
1452
+
1453
+ // End key (ESC[F or ESC[4~)
1454
+ if (bytes[2] === 0x46 || (bytes[2] === 0x34 && bytes[3] === 0x7e)) {
1455
+ cursorPos = buffer.length;
1456
+ redraw(commandMap);
1457
+ return;
1458
+ }
1459
+ }
1460
+
1461
+ // Backspace
1462
+ if (bytes[0] === 0x7f || bytes[0] === 0x08) {
1463
+ if (searchMode) {
1464
+ // In search mode, delete from search query
1465
+ if (searchQuery.length > 0) {
1466
+ searchQuery = searchQuery.slice(0, -1);
1467
+ searchResultIndex = searchHistory(searchQuery, history.length);
1468
+ redraw(commandMap);
1469
+ }
1470
+ } else {
1471
+ if (cursorPos > 0) {
1472
+ buffer = buffer.slice(0, cursorPos - 1) + buffer.slice(cursorPos);
1473
+ cursorPos--;
1474
+ redraw(commandMap);
1475
+ }
1476
+ }
1477
+ return;
1478
+ }
1479
+
1480
+ // Regular character input
1481
+ if (searchMode) {
1482
+ // In search mode, add to search query
1483
+ const char = chunk.toString();
1484
+ if (char.match(/^[\x20-\x7E]$/)) {
1485
+ // Printable ASCII
1486
+ searchQuery += char;
1487
+ searchResultIndex = searchHistory(searchQuery, history.length);
1488
+ redraw(commandMap);
1489
+ }
1490
+ } else {
1491
+ const char = chunk.toString();
1492
+ buffer = buffer.slice(0, cursorPos) + char + buffer.slice(cursorPos);
1493
+ cursorPos += char.length;
1494
+
1495
+ // Update current line
1496
+ lines[currentLineIndex] = buffer;
1497
+
1498
+ // Reset autocomplete cycle on new input
1499
+ autocompleteCycleIndex = 0;
1500
+ lastAutocompleteBuffer = '';
1501
+
1502
+ // Always redraw to show autocomplete suggestion
1503
+ redraw(commandMap);
1504
+ }
1505
+ };
1506
+
1507
+ const cleanup = () => {
1508
+ // Restore default block cursor
1509
+ process.stdout.write('\x1b[0 q');
1510
+ process.stdin.setRawMode(false);
1511
+ process.stdin.removeListener('data', onData);
1512
+ process.stdin.pause();
1513
+ };
1514
+
1515
+ process.stdin.on('data', onData);
1516
+ });
1517
+ }