devvami 1.4.1 → 1.5.0

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 (96) hide show
  1. package/README.md +7 -0
  2. package/oclif.manifest.json +41 -1
  3. package/package.json +2 -1
  4. package/src/commands/auth/login.js +20 -16
  5. package/src/commands/changelog.js +12 -12
  6. package/src/commands/costs/get.js +14 -24
  7. package/src/commands/costs/trend.js +13 -24
  8. package/src/commands/create/repo.js +72 -54
  9. package/src/commands/docs/list.js +29 -25
  10. package/src/commands/docs/projects.js +58 -24
  11. package/src/commands/docs/read.js +56 -39
  12. package/src/commands/docs/search.js +37 -25
  13. package/src/commands/doctor.js +37 -35
  14. package/src/commands/dotfiles/add.js +51 -39
  15. package/src/commands/dotfiles/setup.js +62 -33
  16. package/src/commands/dotfiles/status.js +18 -18
  17. package/src/commands/dotfiles/sync.js +62 -46
  18. package/src/commands/init.js +143 -132
  19. package/src/commands/logs/index.js +10 -16
  20. package/src/commands/open.js +12 -12
  21. package/src/commands/pipeline/logs.js +8 -11
  22. package/src/commands/pipeline/rerun.js +21 -16
  23. package/src/commands/pipeline/status.js +28 -24
  24. package/src/commands/pr/create.js +40 -27
  25. package/src/commands/pr/detail.js +9 -7
  26. package/src/commands/pr/review.js +18 -19
  27. package/src/commands/pr/status.js +27 -21
  28. package/src/commands/prompts/browse.js +15 -15
  29. package/src/commands/prompts/download.js +15 -16
  30. package/src/commands/prompts/install-speckit.js +11 -12
  31. package/src/commands/prompts/list.js +12 -12
  32. package/src/commands/prompts/run.js +16 -19
  33. package/src/commands/repo/list.js +57 -41
  34. package/src/commands/search.js +20 -18
  35. package/src/commands/security/setup.js +38 -34
  36. package/src/commands/sync-config-ai/index.js +143 -0
  37. package/src/commands/tasks/assigned.js +43 -33
  38. package/src/commands/tasks/list.js +43 -33
  39. package/src/commands/tasks/today.js +32 -30
  40. package/src/commands/upgrade.js +18 -17
  41. package/src/commands/vuln/detail.js +8 -8
  42. package/src/commands/vuln/scan.js +95 -21
  43. package/src/commands/vuln/search.js +23 -18
  44. package/src/commands/welcome.js +2 -2
  45. package/src/commands/whoami.js +19 -23
  46. package/src/formatters/ai-config.js +127 -0
  47. package/src/formatters/charts.js +6 -23
  48. package/src/formatters/cost.js +1 -7
  49. package/src/formatters/dotfiles.js +48 -19
  50. package/src/formatters/markdown.js +11 -6
  51. package/src/formatters/openapi.js +7 -9
  52. package/src/formatters/prompts.js +69 -78
  53. package/src/formatters/security.js +2 -2
  54. package/src/formatters/status.js +1 -1
  55. package/src/formatters/table.js +1 -3
  56. package/src/formatters/vuln.js +33 -20
  57. package/src/help.js +162 -164
  58. package/src/hooks/init.js +1 -3
  59. package/src/hooks/postrun.js +5 -7
  60. package/src/index.js +1 -1
  61. package/src/services/ai-config-store.js +318 -0
  62. package/src/services/ai-env-deployer.js +444 -0
  63. package/src/services/ai-env-scanner.js +242 -0
  64. package/src/services/audit-detector.js +2 -2
  65. package/src/services/audit-runner.js +40 -31
  66. package/src/services/auth.js +9 -9
  67. package/src/services/awesome-copilot.js +7 -4
  68. package/src/services/aws-costs.js +22 -22
  69. package/src/services/clickup.js +26 -26
  70. package/src/services/cloudwatch-logs.js +5 -9
  71. package/src/services/config.js +13 -13
  72. package/src/services/docs.js +19 -20
  73. package/src/services/dotfiles.js +149 -51
  74. package/src/services/github.js +22 -24
  75. package/src/services/nvd.js +21 -31
  76. package/src/services/platform.js +2 -2
  77. package/src/services/prompts.js +23 -35
  78. package/src/services/security.js +135 -61
  79. package/src/services/shell.js +4 -4
  80. package/src/services/skills-sh.js +3 -9
  81. package/src/services/speckit.js +4 -7
  82. package/src/services/version-check.js +10 -10
  83. package/src/types.js +85 -0
  84. package/src/utils/aws-vault.js +18 -41
  85. package/src/utils/banner.js +5 -7
  86. package/src/utils/errors.js +42 -46
  87. package/src/utils/frontmatter.js +4 -4
  88. package/src/utils/gradient.js +18 -16
  89. package/src/utils/open-browser.js +3 -3
  90. package/src/utils/tui/form.js +1006 -0
  91. package/src/utils/tui/modal.js +15 -14
  92. package/src/utils/tui/navigable-table.js +25 -17
  93. package/src/utils/tui/tab-tui.js +800 -0
  94. package/src/utils/typewriter.js +3 -3
  95. package/src/utils/welcome.js +18 -21
  96. package/src/validators/repo-name.js +2 -2
@@ -0,0 +1,1006 @@
1
+ /**
2
+ * @module form
3
+ * Inline form component for the dvmi sync-config-ai TUI.
4
+ * All rendering functions are pure (no terminal side effects).
5
+ * The parent tab-tui.js is responsible for writing rendered lines to the screen.
6
+ */
7
+
8
+ import chalk from 'chalk'
9
+
10
+ // ──────────────────────────────────────────────────────────────────────────────
11
+ // Typedefs
12
+ // ──────────────────────────────────────────────────────────────────────────────
13
+
14
+ /**
15
+ * @typedef {Object} TextField
16
+ * @property {'text'} type
17
+ * @property {string} label
18
+ * @property {string} value
19
+ * @property {number} cursor - Cursor position (0 = before first char)
20
+ * @property {boolean} required
21
+ * @property {string} placeholder
22
+ * @property {string} [key] - Optional override key for extractValues output
23
+ */
24
+
25
+ /**
26
+ * @typedef {Object} SelectorField
27
+ * @property {'selector'} type
28
+ * @property {string} label
29
+ * @property {string[]} options
30
+ * @property {number} selectedIndex
31
+ * @property {boolean} required
32
+ * @property {string} [key]
33
+ */
34
+
35
+ /**
36
+ * @typedef {{ id: string, label: string }} MultiSelectOption
37
+ */
38
+
39
+ /**
40
+ * @typedef {Object} MultiSelectField
41
+ * @property {'multiselect'} type
42
+ * @property {string} label
43
+ * @property {MultiSelectOption[]} options
44
+ * @property {Set<string>} selected
45
+ * @property {number} focusedOptionIndex
46
+ * @property {boolean} required
47
+ * @property {string} [key]
48
+ */
49
+
50
+ /**
51
+ * @typedef {Object} MiniEditorField
52
+ * @property {'editor'} type
53
+ * @property {string} label
54
+ * @property {string[]} lines
55
+ * @property {number} cursorLine
56
+ * @property {number} cursorCol
57
+ * @property {boolean} required
58
+ * @property {string} [key]
59
+ */
60
+
61
+ /**
62
+ * @typedef {TextField|SelectorField|MultiSelectField|MiniEditorField} Field
63
+ */
64
+
65
+ /**
66
+ * @typedef {Object} FormState
67
+ * @property {Field[]} fields
68
+ * @property {number} focusedFieldIndex
69
+ * @property {string} title
70
+ * @property {'editing'|'submitted'|'cancelled'} status
71
+ * @property {string|null} errorMessage
72
+ */
73
+
74
+ /**
75
+ * @typedef {Object} SubmitResult
76
+ * @property {true} submitted
77
+ * @property {object} values
78
+ */
79
+
80
+ /**
81
+ * @typedef {Object} CancelResult
82
+ * @property {true} cancelled
83
+ */
84
+
85
+ // ──────────────────────────────────────────────────────────────────────────────
86
+ // Internal helpers
87
+ // ──────────────────────────────────────────────────────────────────────────────
88
+
89
+ /**
90
+ * Convert a field label to a plain object key (lowercase, spaces → underscores).
91
+ * If the field has a `key` property, use that instead.
92
+ * @param {Field} field
93
+ * @returns {string}
94
+ */
95
+ function fieldKey(field) {
96
+ if (field.key) return field.key
97
+ return field.label.toLowerCase().replace(/\s+/g, '_')
98
+ }
99
+
100
+ /**
101
+ * Render the text cursor inside a string value at the given position.
102
+ * Inserts a `|` character at the cursor index.
103
+ * @param {string} value
104
+ * @param {number} cursor
105
+ * @returns {string}
106
+ */
107
+ function renderCursor(value, cursor) {
108
+ return value.slice(0, cursor) + chalk.inverse('|') + value.slice(cursor)
109
+ }
110
+
111
+ // ──────────────────────────────────────────────────────────────────────────────
112
+ // buildFieldLine
113
+ // ──────────────────────────────────────────────────────────────────────────────
114
+
115
+ /**
116
+ * Render a single form field as a terminal line.
117
+ *
118
+ * - TextField: ` [label]: [value with cursor shown as |]`
119
+ * - SelectorField: ` [label]: < option >`
120
+ * - MultiSelectField: ` [label]: [N/total checked]`
121
+ * - MiniEditorField: ` [label]: [N lines]`
122
+ *
123
+ * When focused, the line is prefixed with a bold `> ` indicator instead of ` `.
124
+ *
125
+ * @param {Field} field
126
+ * @param {boolean} focused
127
+ * @returns {string}
128
+ */
129
+ export function buildFieldLine(field, focused) {
130
+ const prefix = focused ? chalk.bold('> ') : ' '
131
+
132
+ if (field.type === 'text') {
133
+ const display = focused
134
+ ? renderCursor(field.value, field.cursor)
135
+ : field.value || chalk.dim(field.placeholder || '')
136
+ return `${prefix}${chalk.bold(field.label)}: ${display}`
137
+ }
138
+
139
+ if (field.type === 'selector') {
140
+ const option = field.options[field.selectedIndex] ?? ''
141
+ const arrows = focused ? `${chalk.bold('< ')}${chalk.cyan(option)}${chalk.bold(' >')}` : `< ${option} >`
142
+ return `${prefix}${chalk.bold(field.label)}: ${arrows}`
143
+ }
144
+
145
+ if (field.type === 'multiselect') {
146
+ const count = field.selected.size
147
+ const total = field.options.length
148
+ const summary = focused ? chalk.cyan(`${count}/${total} selected`) : `${count}/${total} selected`
149
+ return `${prefix}${chalk.bold(field.label)}: ${summary}`
150
+ }
151
+
152
+ if (field.type === 'editor') {
153
+ const lineCount = field.lines.length
154
+ const summary = focused
155
+ ? chalk.cyan(`${lineCount} line${lineCount === 1 ? '' : 's'}`)
156
+ : `${lineCount} line${lineCount === 1 ? '' : 's'}`
157
+ return `${prefix}${chalk.bold(field.label)}: ${summary}`
158
+ }
159
+
160
+ return `${prefix}${chalk.bold(/** @type {any} */ (field).label)}: —`
161
+ }
162
+
163
+ // ──────────────────────────────────────────────────────────────────────────────
164
+ // buildMultiSelectLines
165
+ // ──────────────────────────────────────────────────────────────────────────────
166
+
167
+ /**
168
+ * Render expanded MultiSelectField options as multiple lines (shown when focused).
169
+ * Each option shows `[x]` when selected and `[ ]` when not.
170
+ * The option under the cursor is highlighted with chalk.bold.
171
+ *
172
+ * @param {MultiSelectField} field
173
+ * @param {boolean} focused
174
+ * @param {number} maxLines - Maximum number of lines to return
175
+ * @returns {string[]}
176
+ */
177
+ export function buildMultiSelectLines(field, focused, maxLines) {
178
+ const lines = []
179
+ for (let i = 0; i < field.options.length; i++) {
180
+ const opt = field.options[i]
181
+ const checked = field.selected.has(opt.id) ? chalk.green('[x]') : '[ ]'
182
+ const label = opt.label
183
+ const isCursor = focused && i === field.focusedOptionIndex
184
+ const line = isCursor ? chalk.bold(` ${checked} ${label}`) : ` ${checked} ${label}`
185
+ lines.push(line)
186
+ if (lines.length >= maxLines) break
187
+ }
188
+ return lines
189
+ }
190
+
191
+ // ──────────────────────────────────────────────────────────────────────────────
192
+ // buildMiniEditorLines
193
+ // ──────────────────────────────────────────────────────────────────────────────
194
+
195
+ /**
196
+ * Render MiniEditorField content with line numbers.
197
+ * When focused, inserts `|` at the cursor column on the active line.
198
+ * Returns up to `maxLines` lines.
199
+ *
200
+ * @param {MiniEditorField} field
201
+ * @param {boolean} focused
202
+ * @param {number} maxLines - Maximum number of lines to return
203
+ * @returns {string[]}
204
+ */
205
+ export function buildMiniEditorLines(field, focused, maxLines) {
206
+ const lines = []
207
+ const numWidth = String(field.lines.length).length
208
+
209
+ for (let i = 0; i < field.lines.length; i++) {
210
+ const lineNum = String(i + 1).padStart(numWidth)
211
+ const rawLine = field.lines[i]
212
+ let content
213
+ if (focused && i === field.cursorLine) {
214
+ content = renderCursor(rawLine, field.cursorCol)
215
+ } else {
216
+ content = rawLine
217
+ }
218
+ lines.push(` ${chalk.dim(lineNum + ' │')} ${content}`)
219
+ if (lines.length >= maxLines) break
220
+ }
221
+ return lines
222
+ }
223
+
224
+ // ──────────────────────────────────────────────────────────────────────────────
225
+ // buildFormScreen
226
+ // ──────────────────────────────────────────────────────────────────────────────
227
+
228
+ /**
229
+ * Render all form fields into an array of terminal lines.
230
+ *
231
+ * For the currently focused field:
232
+ * - MultiSelectField: renders expanded options below the field header line
233
+ * - MiniEditorField: renders editor content lines below the field header line
234
+ * - Other types: renders just the single header line
235
+ *
236
+ * Returns an array of lines (no ANSI clear/home — the parent handles that).
237
+ * Includes the form title at the top, an error message if set, all fields,
238
+ * and a footer hint line at the bottom.
239
+ *
240
+ * @param {FormState} formState
241
+ * @param {number} viewportHeight - Available content lines
242
+ * @param {number} termCols - Terminal width
243
+ * @returns {string[]}
244
+ */
245
+ export function buildFormScreen(formState, viewportHeight, termCols) {
246
+ const lines = []
247
+
248
+ // ── Title ──────────────────────────────────────────────────────────────────
249
+ lines.push('')
250
+ lines.push(` ${chalk.bold.cyan(formState.title)}`)
251
+ lines.push(` ${chalk.dim('─'.repeat(Math.min(termCols - 4, 60)))}`)
252
+
253
+ // ── Error message ─────────────────────────────────────────────────────────
254
+ if (formState.errorMessage) {
255
+ lines.push(` ${chalk.red('✖ ' + formState.errorMessage)}`)
256
+ }
257
+
258
+ lines.push('')
259
+
260
+ // ── Fields ────────────────────────────────────────────────────────────────
261
+ const FOOTER_RESERVE = 2
262
+ const availableForFields = viewportHeight - lines.length - FOOTER_RESERVE
263
+
264
+ for (let i = 0; i < formState.fields.length; i++) {
265
+ const field = formState.fields[i]
266
+ const isFocused = i === formState.focusedFieldIndex
267
+
268
+ // Header line
269
+ lines.push(buildFieldLine(field, isFocused))
270
+
271
+ // Expanded inline content for focused multiselect / editor
272
+ if (isFocused) {
273
+ const remaining = availableForFields - lines.length
274
+ if (field.type === 'multiselect' && remaining > 0) {
275
+ const expanded = buildMultiSelectLines(field, true, remaining)
276
+ lines.push(...expanded)
277
+ } else if (field.type === 'editor' && remaining > 0) {
278
+ const expanded = buildMiniEditorLines(field, true, remaining)
279
+ lines.push(...expanded)
280
+ }
281
+ }
282
+ }
283
+
284
+ // ── Footer hint ───────────────────────────────────────────────────────────
285
+ lines.push('')
286
+ lines.push(chalk.dim(' Tab next field Shift+Tab prev Ctrl+S save Esc cancel'))
287
+
288
+ return lines
289
+ }
290
+
291
+ // ──────────────────────────────────────────────────────────────────────────────
292
+ // extractValues
293
+ // ──────────────────────────────────────────────────────────────────────────────
294
+
295
+ /**
296
+ * Extract form field values into a plain object.
297
+ *
298
+ * - TextField → string value
299
+ * - SelectorField → selected option string
300
+ * - MultiSelectField → array of selected ids
301
+ * - MiniEditorField → lines joined with `\n`
302
+ *
303
+ * The key for each field is `field.key` if set, otherwise the label lowercased
304
+ * with spaces replaced by underscores.
305
+ *
306
+ * @param {FormState} formState
307
+ * @returns {object}
308
+ */
309
+ export function extractValues(formState) {
310
+ /** @type {Record<string, unknown>} */
311
+ const result = {}
312
+
313
+ for (const field of formState.fields) {
314
+ const key = fieldKey(field)
315
+
316
+ if (field.type === 'text') {
317
+ result[key] = field.value
318
+ } else if (field.type === 'selector') {
319
+ result[key] = field.options[field.selectedIndex] ?? ''
320
+ } else if (field.type === 'multiselect') {
321
+ result[key] = Array.from(field.selected)
322
+ } else if (field.type === 'editor') {
323
+ result[key] = field.lines.join('\n')
324
+ }
325
+ }
326
+
327
+ return result
328
+ }
329
+
330
+ // ──────────────────────────────────────────────────────────────────────────────
331
+ // Validation helper
332
+ // ──────────────────────────────────────────────────────────────────────────────
333
+
334
+ /**
335
+ * Check that all required fields have a non-empty value.
336
+ * Returns the label of the first invalid field, or null if all are valid.
337
+ * @param {FormState} formState
338
+ * @returns {string|null}
339
+ */
340
+ function validateForm(formState) {
341
+ for (const field of formState.fields) {
342
+ if (!field.required) continue
343
+
344
+ if (field.type === 'text' && field.value.trim() === '') {
345
+ return field.label
346
+ }
347
+ if (field.type === 'selector' && field.options.length === 0) {
348
+ return field.label
349
+ }
350
+ if (field.type === 'multiselect' && field.selected.size === 0) {
351
+ return field.label
352
+ }
353
+ if (field.type === 'editor') {
354
+ const content = field.lines.join('\n').trim()
355
+ if (content === '') return field.label
356
+ }
357
+ }
358
+ return null
359
+ }
360
+
361
+ // ──────────────────────────────────────────────────────────────────────────────
362
+ // Field-specific keypress handlers (pure)
363
+ // ──────────────────────────────────────────────────────────────────────────────
364
+
365
+ /**
366
+ * Handle a keypress on a focused TextField. Returns updated field.
367
+ * @param {TextField} field
368
+ * @param {{ name: string, sequence?: string, ctrl?: boolean, shift?: boolean }} key
369
+ * @returns {TextField}
370
+ */
371
+ function handleTextFieldKey(field, key) {
372
+ const {value, cursor} = field
373
+
374
+ if (key.name === 'backspace') {
375
+ if (cursor === 0) return field
376
+ return {
377
+ ...field,
378
+ value: value.slice(0, cursor - 1) + value.slice(cursor),
379
+ cursor: cursor - 1,
380
+ }
381
+ }
382
+
383
+ if (key.name === 'delete') {
384
+ if (cursor >= value.length) return field
385
+ return {
386
+ ...field,
387
+ value: value.slice(0, cursor) + value.slice(cursor + 1),
388
+ }
389
+ }
390
+
391
+ if (key.name === 'left') {
392
+ return {...field, cursor: Math.max(0, cursor - 1)}
393
+ }
394
+ if (key.name === 'right') {
395
+ return {...field, cursor: Math.min(value.length, cursor + 1)}
396
+ }
397
+ if (key.name === 'home') {
398
+ return {...field, cursor: 0}
399
+ }
400
+ if (key.name === 'end') {
401
+ return {...field, cursor: value.length}
402
+ }
403
+
404
+ // Printable character
405
+ if (key.sequence && key.sequence.length === 1 && !key.ctrl) {
406
+ const ch = key.sequence
407
+ if (ch >= ' ') {
408
+ return {
409
+ ...field,
410
+ value: value.slice(0, cursor) + ch + value.slice(cursor),
411
+ cursor: cursor + 1,
412
+ }
413
+ }
414
+ }
415
+
416
+ return field
417
+ }
418
+
419
+ /**
420
+ * Handle a keypress on a focused SelectorField. Returns updated field.
421
+ * @param {SelectorField} field
422
+ * @param {{ name: string }} key
423
+ * @returns {SelectorField}
424
+ */
425
+ function handleSelectorFieldKey(field, key) {
426
+ const len = field.options.length
427
+ if (len === 0) return field
428
+
429
+ if (key.name === 'left') {
430
+ return {...field, selectedIndex: (field.selectedIndex - 1 + len) % len}
431
+ }
432
+ if (key.name === 'right') {
433
+ return {...field, selectedIndex: (field.selectedIndex + 1) % len}
434
+ }
435
+
436
+ return field
437
+ }
438
+
439
+ /**
440
+ * Handle a keypress on a focused MultiSelectField.
441
+ * Returns updated field or { advanceField: true } signal object.
442
+ * @param {MultiSelectField} field
443
+ * @param {{ name: string }} key
444
+ * @returns {MultiSelectField | { advanceField: true }}
445
+ */
446
+ function handleMultiSelectFieldKey(field, key) {
447
+ const len = field.options.length
448
+
449
+ if (key.name === 'up') {
450
+ return {...field, focusedOptionIndex: Math.max(0, field.focusedOptionIndex - 1)}
451
+ }
452
+ if (key.name === 'down') {
453
+ return {...field, focusedOptionIndex: Math.min(len - 1, field.focusedOptionIndex + 1)}
454
+ }
455
+
456
+ if (key.name === 'space') {
457
+ const opt = field.options[field.focusedOptionIndex]
458
+ if (!opt) return field
459
+ const newSelected = new Set(field.selected)
460
+ if (newSelected.has(opt.id)) {
461
+ newSelected.delete(opt.id)
462
+ } else {
463
+ newSelected.add(opt.id)
464
+ }
465
+ return {...field, selected: newSelected}
466
+ }
467
+
468
+ if (key.name === 'return') {
469
+ return {advanceField: /** @type {true} */ (true)}
470
+ }
471
+
472
+ return field
473
+ }
474
+
475
+ /**
476
+ * Handle a keypress on a focused MiniEditorField.
477
+ * Returns updated field or { advanceField: true } signal object.
478
+ * @param {MiniEditorField} field
479
+ * @param {{ name: string, sequence?: string, ctrl?: boolean }} key
480
+ * @returns {MiniEditorField | { advanceField: true }}
481
+ */
482
+ function handleEditorFieldKey(field, key) {
483
+ const {lines, cursorLine, cursorCol} = field
484
+
485
+ // Esc exits the editor — move to next field
486
+ if (key.name === 'escape') {
487
+ return {advanceField: /** @type {true} */ (true)}
488
+ }
489
+
490
+ if (key.name === 'left') {
491
+ if (cursorCol > 0) {
492
+ return {...field, cursorCol: cursorCol - 1}
493
+ }
494
+ if (cursorLine > 0) {
495
+ const prevLine = lines[cursorLine - 1]
496
+ return {...field, cursorLine: cursorLine - 1, cursorCol: prevLine.length}
497
+ }
498
+ return field
499
+ }
500
+
501
+ if (key.name === 'right') {
502
+ const line = lines[cursorLine]
503
+ if (cursorCol < line.length) {
504
+ return {...field, cursorCol: cursorCol + 1}
505
+ }
506
+ if (cursorLine < lines.length - 1) {
507
+ return {...field, cursorLine: cursorLine + 1, cursorCol: 0}
508
+ }
509
+ return field
510
+ }
511
+
512
+ if (key.name === 'up') {
513
+ if (cursorLine === 0) return field
514
+ const newLine = cursorLine - 1
515
+ const newCol = Math.min(cursorCol, lines[newLine].length)
516
+ return {...field, cursorLine: newLine, cursorCol: newCol}
517
+ }
518
+
519
+ if (key.name === 'down') {
520
+ if (cursorLine >= lines.length - 1) return field
521
+ const newLine = cursorLine + 1
522
+ const newCol = Math.min(cursorCol, lines[newLine].length)
523
+ return {...field, cursorLine: newLine, cursorCol: newCol}
524
+ }
525
+
526
+ if (key.name === 'home') {
527
+ return {...field, cursorCol: 0}
528
+ }
529
+
530
+ if (key.name === 'end') {
531
+ return {...field, cursorCol: lines[cursorLine].length}
532
+ }
533
+
534
+ if (key.name === 'backspace') {
535
+ if (cursorCol > 0) {
536
+ const newLines = [...lines]
537
+ const ln = newLines[cursorLine]
538
+ newLines[cursorLine] = ln.slice(0, cursorCol - 1) + ln.slice(cursorCol)
539
+ return {...field, lines: newLines, cursorCol: cursorCol - 1}
540
+ }
541
+ if (cursorLine > 0) {
542
+ // Merge current line into previous
543
+ const newLines = [...lines]
544
+ const prevLine = newLines[cursorLine - 1]
545
+ const currLine = newLines[cursorLine]
546
+ const mergedCol = prevLine.length
547
+ newLines.splice(cursorLine, 1)
548
+ newLines[cursorLine - 1] = prevLine + currLine
549
+ return {...field, lines: newLines, cursorLine: cursorLine - 1, cursorCol: mergedCol}
550
+ }
551
+ return field
552
+ }
553
+
554
+ if (key.name === 'delete') {
555
+ const line = lines[cursorLine]
556
+ if (cursorCol < line.length) {
557
+ const newLines = [...lines]
558
+ newLines[cursorLine] = line.slice(0, cursorCol) + line.slice(cursorCol + 1)
559
+ return {...field, lines: newLines}
560
+ }
561
+ if (cursorLine < lines.length - 1) {
562
+ // Merge next line
563
+ const newLines = [...lines]
564
+ newLines[cursorLine] = newLines[cursorLine] + newLines[cursorLine + 1]
565
+ newLines.splice(cursorLine + 1, 1)
566
+ return {...field, lines: newLines}
567
+ }
568
+ return field
569
+ }
570
+
571
+ // Enter inserts a new line after the cursor position
572
+ if (key.name === 'return') {
573
+ const line = lines[cursorLine]
574
+ const before = line.slice(0, cursorCol)
575
+ const after = line.slice(cursorCol)
576
+ const newLines = [...lines]
577
+ newLines.splice(cursorLine, 1, before, after)
578
+ return {...field, lines: newLines, cursorLine: cursorLine + 1, cursorCol: 0}
579
+ }
580
+
581
+ // Printable character
582
+ if (key.sequence && key.sequence.length === 1 && !key.ctrl) {
583
+ const ch = key.sequence
584
+ if (ch >= ' ') {
585
+ const newLines = [...lines]
586
+ const ln = newLines[cursorLine]
587
+ newLines[cursorLine] = ln.slice(0, cursorCol) + ch + ln.slice(cursorCol)
588
+ return {...field, lines: newLines, cursorCol: cursorCol + 1}
589
+ }
590
+ }
591
+
592
+ return field
593
+ }
594
+
595
+ // ──────────────────────────────────────────────────────────────────────────────
596
+ // handleFormKeypress
597
+ // ──────────────────────────────────────────────────────────────────────────────
598
+
599
+ /**
600
+ * Pure reducer for form keypresses.
601
+ *
602
+ * Global keys handled regardless of focused field:
603
+ * - Tab: move to next field
604
+ * - Shift+Tab: move to previous field (wraps)
605
+ * - Ctrl+S: validate and submit
606
+ * - Esc: cancel → return `{ cancelled: true }`
607
+ * - Enter on last field: validate and submit
608
+ *
609
+ * Field-specific handling when field is focused:
610
+ * - TextField: printable chars append to value, Backspace deletes, ← → move cursor, Home/End jump
611
+ * - SelectorField: ← → cycle options
612
+ * - MultiSelectField: ↑ ↓ navigate, Space toggle, Enter advances to next field
613
+ * - MiniEditorField: printable chars insert, Enter inserts new line, Esc exits to next field
614
+ *
615
+ * On submit: validates required fields. If invalid, sets `errorMessage` and returns
616
+ * the state. If valid, returns `{ submitted: true, values: extractValues(formState) }`.
617
+ *
618
+ * @param {FormState} formState
619
+ * @param {{ name: string, sequence?: string, ctrl?: boolean, shift?: boolean }} key
620
+ * @returns {FormState | SubmitResult | CancelResult}
621
+ */
622
+ export function handleFormKeypress(formState, key) {
623
+ const {fields, focusedFieldIndex} = formState
624
+ const lastFieldIndex = fields.length - 1
625
+
626
+ // ── Esc: cancel (unless inside a MiniEditorField) ─────────────────────────
627
+ // For editor fields, Esc is handled inside the field handler to advance focus,
628
+ // not cancel the form. Only cancel when a non-editor field is focused.
629
+ const focusedField = fields[focusedFieldIndex]
630
+ if (key.name === 'escape' && focusedField?.type !== 'editor') {
631
+ return {cancelled: /** @type {true} */ (true)}
632
+ }
633
+
634
+ // ── Ctrl+S: submit ────────────────────────────────────────────────────────
635
+ if (key.ctrl && key.name === 's') {
636
+ return attemptSubmit(formState)
637
+ }
638
+
639
+ // ── Tab: next field ───────────────────────────────────────────────────────
640
+ if (key.name === 'tab' && !key.shift) {
641
+ return {
642
+ ...formState,
643
+ focusedFieldIndex: (focusedFieldIndex + 1) % fields.length,
644
+ errorMessage: null,
645
+ }
646
+ }
647
+
648
+ // ── Shift+Tab: previous field ─────────────────────────────────────────────
649
+ if (key.name === 'tab' && key.shift) {
650
+ return {
651
+ ...formState,
652
+ focusedFieldIndex: (focusedFieldIndex - 1 + fields.length) % fields.length,
653
+ errorMessage: null,
654
+ }
655
+ }
656
+
657
+ // ── Enter on last non-editor field: submit ─────────────────────────────────
658
+ if (
659
+ key.name === 'return' &&
660
+ focusedFieldIndex === lastFieldIndex &&
661
+ focusedField?.type !== 'editor' &&
662
+ focusedField?.type !== 'multiselect'
663
+ ) {
664
+ return attemptSubmit(formState)
665
+ }
666
+
667
+ // ── Delegate to focused field ─────────────────────────────────────────────
668
+ if (!focusedField) return formState
669
+
670
+ if (focusedField.type === 'text') {
671
+ const updated = handleTextFieldKey(focusedField, key)
672
+ if (updated === focusedField) return formState
673
+ return {
674
+ ...formState,
675
+ errorMessage: null,
676
+ fields: replaceAt(fields, focusedFieldIndex, updated),
677
+ }
678
+ }
679
+
680
+ if (focusedField.type === 'selector') {
681
+ const updated = handleSelectorFieldKey(focusedField, key)
682
+ if (updated === focusedField) return formState
683
+ return {
684
+ ...formState,
685
+ fields: replaceAt(fields, focusedFieldIndex, updated),
686
+ }
687
+ }
688
+
689
+ if (focusedField.type === 'multiselect') {
690
+ const result = handleMultiSelectFieldKey(focusedField, key)
691
+ if ('advanceField' in result) {
692
+ return {
693
+ ...formState,
694
+ focusedFieldIndex: Math.min(focusedFieldIndex + 1, lastFieldIndex),
695
+ }
696
+ }
697
+ if (result === focusedField) return formState
698
+ return {
699
+ ...formState,
700
+ fields: replaceAt(fields, focusedFieldIndex, result),
701
+ }
702
+ }
703
+
704
+ if (focusedField.type === 'editor') {
705
+ const result = handleEditorFieldKey(focusedField, key)
706
+ if ('advanceField' in result) {
707
+ // Esc in editor cancels the form only if we treat it as a field-level escape.
708
+ // Per spec, Esc in editor moves to next field.
709
+ return {
710
+ ...formState,
711
+ focusedFieldIndex: Math.min(focusedFieldIndex + 1, lastFieldIndex),
712
+ }
713
+ }
714
+ if (result === focusedField) return formState
715
+ return {
716
+ ...formState,
717
+ errorMessage: null,
718
+ fields: replaceAt(fields, focusedFieldIndex, result),
719
+ }
720
+ }
721
+
722
+ return formState
723
+ }
724
+
725
+ /**
726
+ * Attempt to submit the form: validate, then return SubmitResult or FormState with error.
727
+ * @param {FormState} formState
728
+ * @returns {FormState | SubmitResult}
729
+ */
730
+ function attemptSubmit(formState) {
731
+ const invalidLabel = validateForm(formState)
732
+ if (invalidLabel !== null) {
733
+ return {
734
+ ...formState,
735
+ errorMessage: `"${invalidLabel}" is required.`,
736
+ }
737
+ }
738
+ return {
739
+ submitted: /** @type {true} */ (true),
740
+ values: extractValues(formState),
741
+ }
742
+ }
743
+
744
+ /**
745
+ * Return a new array with element at `index` replaced by `value`.
746
+ * @template T
747
+ * @param {T[]} arr
748
+ * @param {number} index
749
+ * @param {T} value
750
+ * @returns {T[]}
751
+ */
752
+ function replaceAt(arr, index, value) {
753
+ return arr.map((item, i) => (i === index ? value : item))
754
+ }
755
+
756
+ // ──────────────────────────────────────────────────────────────────────────────
757
+ // Form field definitions
758
+ // ──────────────────────────────────────────────────────────────────────────────
759
+
760
+ /**
761
+ * Return form fields for creating or editing an MCP entry.
762
+ *
763
+ * Fields: name (text), environments (multiselect), transport (selector), command (text),
764
+ * args (text), url (text), description (text, optional).
765
+ *
766
+ * @param {import('../../types.js').CategoryEntry|null} [entry] - Existing entry to pre-fill from, or null to create
767
+ * @param {import('../../types.js').DetectedEnvironment[]} [compatibleEnvs] - Environments compatible with this category type
768
+ * @returns {Field[]}
769
+ */
770
+ export function getMCPFormFields(entry = null, compatibleEnvs = []) {
771
+ /** @type {import('../../types.js').MCPParams|null} */
772
+ const p = entry ? /** @type {import('../../types.js').MCPParams} */ (entry.params) : null
773
+
774
+ const transportOptions = ['stdio', 'sse', 'streamable-http']
775
+ const transportIndex = p ? Math.max(0, transportOptions.indexOf(p.transport)) : 0
776
+
777
+ return [
778
+ /** @type {TextField} */ ({
779
+ type: 'text',
780
+ label: 'Name',
781
+ key: 'name',
782
+ value: entry ? entry.name : '',
783
+ cursor: entry ? entry.name.length : 0,
784
+ required: true,
785
+ placeholder: 'my-mcp-server',
786
+ }),
787
+ /** @type {MultiSelectField} */ ({
788
+ type: 'multiselect',
789
+ label: 'Environments',
790
+ key: 'environments',
791
+ options: compatibleEnvs.map((env) => ({id: env.id, label: env.name})),
792
+ selected: new Set(entry ? entry.environments : []),
793
+ focusedOptionIndex: 0,
794
+ required: true,
795
+ }),
796
+ /** @type {SelectorField} */ ({
797
+ type: 'selector',
798
+ label: 'Transport',
799
+ key: 'transport',
800
+ options: transportOptions,
801
+ selectedIndex: transportIndex,
802
+ required: true,
803
+ }),
804
+ /** @type {TextField} */ ({
805
+ type: 'text',
806
+ label: 'Command',
807
+ key: 'command',
808
+ value: p?.command ?? '',
809
+ cursor: (p?.command ?? '').length,
810
+ required: false,
811
+ placeholder: 'npx my-mcp-server',
812
+ }),
813
+ /** @type {TextField} */ ({
814
+ type: 'text',
815
+ label: 'Args',
816
+ key: 'args',
817
+ value: p?.args ? p.args.join(' ') : '',
818
+ cursor: p?.args ? p.args.join(' ').length : 0,
819
+ required: false,
820
+ placeholder: '--port 3000 --verbose',
821
+ }),
822
+ /** @type {TextField} */ ({
823
+ type: 'text',
824
+ label: 'URL',
825
+ key: 'url',
826
+ value: p?.url ?? '',
827
+ cursor: (p?.url ?? '').length,
828
+ required: false,
829
+ placeholder: 'https://mcp.example.com',
830
+ }),
831
+ /** @type {TextField} */ ({
832
+ type: 'text',
833
+ label: 'Description',
834
+ key: 'description',
835
+ value: p?.description ?? (entry?.params ? /** @type {any} */ ((entry.params).description ?? '') : ''),
836
+ cursor: 0,
837
+ required: false,
838
+ placeholder: 'Optional description',
839
+ }),
840
+ ]
841
+ }
842
+
843
+ /**
844
+ * Return form fields for creating or editing a Command entry.
845
+ *
846
+ * Fields: name (text), environments (multiselect), description (text, optional), content (editor).
847
+ *
848
+ * @param {import('../../types.js').CategoryEntry|null} [entry] - Existing entry to pre-fill from, or null to create
849
+ * @param {import('../../types.js').DetectedEnvironment[]} [compatibleEnvs] - Environments compatible with this category type
850
+ * @returns {Field[]}
851
+ */
852
+ export function getCommandFormFields(entry = null, compatibleEnvs = []) {
853
+ /** @type {import('../../types.js').CommandParams|null} */
854
+ const p = entry ? /** @type {import('../../types.js').CommandParams} */ (entry.params) : null
855
+ const contentStr = p?.content ?? ''
856
+ const contentLines = contentStr.length > 0 ? contentStr.split('\n') : ['']
857
+
858
+ return [
859
+ /** @type {TextField} */ ({
860
+ type: 'text',
861
+ label: 'Name',
862
+ key: 'name',
863
+ value: entry ? entry.name : '',
864
+ cursor: entry ? entry.name.length : 0,
865
+ required: true,
866
+ placeholder: 'my-command',
867
+ }),
868
+ /** @type {MultiSelectField} */ ({
869
+ type: 'multiselect',
870
+ label: 'Environments',
871
+ key: 'environments',
872
+ options: compatibleEnvs.map((env) => ({id: env.id, label: env.name})),
873
+ selected: new Set(entry ? entry.environments : []),
874
+ focusedOptionIndex: 0,
875
+ required: true,
876
+ }),
877
+ /** @type {TextField} */ ({
878
+ type: 'text',
879
+ label: 'Description',
880
+ key: 'description',
881
+ value: p?.description ?? '',
882
+ cursor: (p?.description ?? '').length,
883
+ required: false,
884
+ placeholder: 'Optional description',
885
+ }),
886
+ /** @type {MiniEditorField} */ ({
887
+ type: 'editor',
888
+ label: 'Content',
889
+ key: 'content',
890
+ lines: contentLines,
891
+ cursorLine: 0,
892
+ cursorCol: 0,
893
+ required: true,
894
+ }),
895
+ ]
896
+ }
897
+
898
+ /**
899
+ * Return form fields for creating or editing a Skill entry.
900
+ *
901
+ * Fields: name (text), environments (multiselect), description (text, optional), content (editor).
902
+ *
903
+ * @param {import('../../types.js').CategoryEntry|null} [entry] - Existing entry to pre-fill from, or null to create
904
+ * @param {import('../../types.js').DetectedEnvironment[]} [compatibleEnvs] - Environments compatible with this category type
905
+ * @returns {Field[]}
906
+ */
907
+ export function getSkillFormFields(entry = null, compatibleEnvs = []) {
908
+ /** @type {import('../../types.js').SkillParams|null} */
909
+ const p = entry ? /** @type {import('../../types.js').SkillParams} */ (entry.params) : null
910
+ const contentStr = p?.content ?? ''
911
+ const contentLines = contentStr.length > 0 ? contentStr.split('\n') : ['']
912
+
913
+ return [
914
+ /** @type {TextField} */ ({
915
+ type: 'text',
916
+ label: 'Name',
917
+ key: 'name',
918
+ value: entry ? entry.name : '',
919
+ cursor: entry ? entry.name.length : 0,
920
+ required: true,
921
+ placeholder: 'my-skill',
922
+ }),
923
+ /** @type {MultiSelectField} */ ({
924
+ type: 'multiselect',
925
+ label: 'Environments',
926
+ key: 'environments',
927
+ options: compatibleEnvs.map((env) => ({id: env.id, label: env.name})),
928
+ selected: new Set(entry ? entry.environments : []),
929
+ focusedOptionIndex: 0,
930
+ required: true,
931
+ }),
932
+ /** @type {TextField} */ ({
933
+ type: 'text',
934
+ label: 'Description',
935
+ key: 'description',
936
+ value: p?.description ?? '',
937
+ cursor: (p?.description ?? '').length,
938
+ required: false,
939
+ placeholder: 'Optional description',
940
+ }),
941
+ /** @type {MiniEditorField} */ ({
942
+ type: 'editor',
943
+ label: 'Content',
944
+ key: 'content',
945
+ lines: contentLines,
946
+ cursorLine: 0,
947
+ cursorCol: 0,
948
+ required: true,
949
+ }),
950
+ ]
951
+ }
952
+
953
+ /**
954
+ * Return form fields for creating or editing an Agent entry.
955
+ *
956
+ * Fields: name (text), environments (multiselect), description (text, optional), instructions (editor).
957
+ *
958
+ * @param {import('../../types.js').CategoryEntry|null} [entry] - Existing entry to pre-fill from, or null to create
959
+ * @param {import('../../types.js').DetectedEnvironment[]} [compatibleEnvs] - Environments compatible with this category type
960
+ * @returns {Field[]}
961
+ */
962
+ export function getAgentFormFields(entry = null, compatibleEnvs = []) {
963
+ /** @type {import('../../types.js').AgentParams|null} */
964
+ const p = entry ? /** @type {import('../../types.js').AgentParams} */ (entry.params) : null
965
+ const instructionsStr = p?.instructions ?? ''
966
+ const instructionLines = instructionsStr.length > 0 ? instructionsStr.split('\n') : ['']
967
+
968
+ return [
969
+ /** @type {TextField} */ ({
970
+ type: 'text',
971
+ label: 'Name',
972
+ key: 'name',
973
+ value: entry ? entry.name : '',
974
+ cursor: entry ? entry.name.length : 0,
975
+ required: true,
976
+ placeholder: 'my-agent',
977
+ }),
978
+ /** @type {MultiSelectField} */ ({
979
+ type: 'multiselect',
980
+ label: 'Environments',
981
+ key: 'environments',
982
+ options: compatibleEnvs.map((env) => ({id: env.id, label: env.name})),
983
+ selected: new Set(entry ? entry.environments : []),
984
+ focusedOptionIndex: 0,
985
+ required: true,
986
+ }),
987
+ /** @type {TextField} */ ({
988
+ type: 'text',
989
+ label: 'Description',
990
+ key: 'description',
991
+ value: p?.description ?? '',
992
+ cursor: (p?.description ?? '').length,
993
+ required: false,
994
+ placeholder: 'Optional description',
995
+ }),
996
+ /** @type {MiniEditorField} */ ({
997
+ type: 'editor',
998
+ label: 'Instructions',
999
+ key: 'instructions',
1000
+ lines: instructionLines,
1001
+ cursorLine: 0,
1002
+ cursorCol: 0,
1003
+ required: true,
1004
+ }),
1005
+ ]
1006
+ }