devvami 1.4.2 → 1.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. package/README.md +72 -0
  2. package/oclif.manifest.json +275 -235
  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 +257 -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 +39 -20
  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 +215 -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 +349 -0
  62. package/src/services/ai-env-deployer.js +650 -0
  63. package/src/services/ai-env-scanner.js +983 -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 +117 -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 +1184 -0
  91. package/src/utils/tui/modal.js +15 -14
  92. package/src/utils/tui/navigable-table.js +16 -16
  93. package/src/utils/tui/tab-tui.js +1089 -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,1184 @@
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
+ * @property {boolean} [hidden] - When true, field is skipped in rendering, navigation, validation, and extraction
24
+ */
25
+
26
+ /**
27
+ * @typedef {Object} SelectorField
28
+ * @property {'selector'} type
29
+ * @property {string} label
30
+ * @property {string[]} options
31
+ * @property {number} selectedIndex
32
+ * @property {boolean} required
33
+ * @property {string} [key]
34
+ * @property {boolean} [hidden]
35
+ */
36
+
37
+ /**
38
+ * @typedef {{ id: string, label: string }} MultiSelectOption
39
+ */
40
+
41
+ /**
42
+ * @typedef {Object} MultiSelectField
43
+ * @property {'multiselect'} type
44
+ * @property {string} label
45
+ * @property {MultiSelectOption[]} options
46
+ * @property {Set<string>} selected
47
+ * @property {number} focusedOptionIndex
48
+ * @property {boolean} required
49
+ * @property {string} [key]
50
+ * @property {boolean} [hidden]
51
+ */
52
+
53
+ /**
54
+ * @typedef {Object} MiniEditorField
55
+ * @property {'editor'} type
56
+ * @property {string} label
57
+ * @property {string[]} lines
58
+ * @property {number} cursorLine
59
+ * @property {number} cursorCol
60
+ * @property {boolean} required
61
+ * @property {string} [key]
62
+ * @property {boolean} [hidden]
63
+ */
64
+
65
+ /**
66
+ * @typedef {TextField|SelectorField|MultiSelectField|MiniEditorField} Field
67
+ */
68
+
69
+ /**
70
+ * @typedef {Object} FormState
71
+ * @property {Field[]} fields
72
+ * @property {number} focusedFieldIndex
73
+ * @property {string} title
74
+ * @property {'editing'|'submitted'|'cancelled'} status
75
+ * @property {string|null} errorMessage
76
+ * @property {((state: FormState) => string|null)|null} [customValidator] - Optional transport-specific validator
77
+ */
78
+
79
+ /**
80
+ * @typedef {Object} SubmitResult
81
+ * @property {true} submitted
82
+ * @property {object} values
83
+ */
84
+
85
+ /**
86
+ * @typedef {Object} CancelResult
87
+ * @property {true} cancelled
88
+ */
89
+
90
+ // ──────────────────────────────────────────────────────────────────────────────
91
+ // Internal helpers
92
+ // ──────────────────────────────────────────────────────────────────────────────
93
+
94
+ /**
95
+ * Convert a field label to a plain object key (lowercase, spaces → underscores).
96
+ * If the field has a `key` property, use that instead.
97
+ * @param {Field} field
98
+ * @returns {string}
99
+ */
100
+ function fieldKey(field) {
101
+ if (field.key) return field.key
102
+ return field.label.toLowerCase().replace(/\s+/g, '_')
103
+ }
104
+
105
+ /**
106
+ * Find the next visible (non-hidden) field index in a given direction.
107
+ * Wraps around the array. Returns current index if all fields are hidden.
108
+ * @param {Field[]} fields
109
+ * @param {number} current - Current focused index
110
+ * @param {1|-1} direction - 1 for forward, -1 for backward
111
+ * @returns {number}
112
+ */
113
+ function nextVisibleIndex(fields, current, direction) {
114
+ const len = fields.length
115
+ let next = (current + direction + len) % len
116
+ let checked = 0
117
+ while (fields[next]?.hidden && checked < len) {
118
+ next = (next + direction + len) % len
119
+ checked++
120
+ }
121
+ return next
122
+ }
123
+
124
+ /**
125
+ * Render the text cursor inside a string value at the given position.
126
+ * Inserts a `|` character at the cursor index.
127
+ * @param {string} value
128
+ * @param {number} cursor
129
+ * @returns {string}
130
+ */
131
+ function renderCursor(value, cursor) {
132
+ return value.slice(0, cursor) + chalk.inverse('|') + value.slice(cursor)
133
+ }
134
+
135
+ // ──────────────────────────────────────────────────────────────────────────────
136
+ // buildFieldLine
137
+ // ──────────────────────────────────────────────────────────────────────────────
138
+
139
+ /**
140
+ * Render a single form field as a terminal line.
141
+ *
142
+ * - TextField: ` [label]: [value with cursor shown as |]`
143
+ * - SelectorField: ` [label]: < option >`
144
+ * - MultiSelectField: ` [label]: [N/total checked]`
145
+ * - MiniEditorField: ` [label]: [N lines]`
146
+ *
147
+ * When focused, the line is prefixed with a bold `> ` indicator instead of ` `.
148
+ *
149
+ * @param {Field} field
150
+ * @param {boolean} focused
151
+ * @returns {string}
152
+ */
153
+ export function buildFieldLine(field, focused) {
154
+ const prefix = focused ? chalk.bold('> ') : ' '
155
+
156
+ if (field.type === 'text') {
157
+ const display = focused
158
+ ? renderCursor(field.value, field.cursor)
159
+ : field.value || chalk.dim(field.placeholder || '')
160
+ return `${prefix}${chalk.bold(field.label)}: ${display}`
161
+ }
162
+
163
+ if (field.type === 'selector') {
164
+ const option = field.options[field.selectedIndex] ?? ''
165
+ const arrows = focused ? `${chalk.bold('< ')}${chalk.cyan(option)}${chalk.bold(' >')}` : `< ${option} >`
166
+ return `${prefix}${chalk.bold(field.label)}: ${arrows}`
167
+ }
168
+
169
+ if (field.type === 'multiselect') {
170
+ const count = field.selected.size
171
+ const total = field.options.length
172
+ const summary = focused ? chalk.cyan(`${count}/${total} selected`) : `${count}/${total} selected`
173
+ return `${prefix}${chalk.bold(field.label)}: ${summary}`
174
+ }
175
+
176
+ if (field.type === 'editor') {
177
+ const lineCount = field.lines.length
178
+ const summary = focused
179
+ ? chalk.cyan(`${lineCount} line${lineCount === 1 ? '' : 's'}`)
180
+ : `${lineCount} line${lineCount === 1 ? '' : 's'}`
181
+ return `${prefix}${chalk.bold(field.label)}: ${summary}`
182
+ }
183
+
184
+ return `${prefix}${chalk.bold(/** @type {any} */ (field).label)}: —`
185
+ }
186
+
187
+ // ──────────────────────────────────────────────────────────────────────────────
188
+ // buildMultiSelectLines
189
+ // ──────────────────────────────────────────────────────────────────────────────
190
+
191
+ /**
192
+ * Render expanded MultiSelectField options as multiple lines (shown when focused).
193
+ * Each option shows `[x]` when selected and `[ ]` when not.
194
+ * The option under the cursor is highlighted with chalk.bold.
195
+ *
196
+ * @param {MultiSelectField} field
197
+ * @param {boolean} focused
198
+ * @param {number} maxLines - Maximum number of lines to return
199
+ * @returns {string[]}
200
+ */
201
+ export function buildMultiSelectLines(field, focused, maxLines) {
202
+ const lines = []
203
+ for (let i = 0; i < field.options.length; i++) {
204
+ const opt = field.options[i]
205
+ const checked = field.selected.has(opt.id) ? chalk.green('[x]') : '[ ]'
206
+ const label = opt.label
207
+ const isCursor = focused && i === field.focusedOptionIndex
208
+ const line = isCursor ? chalk.bold(` ${checked} ${label}`) : ` ${checked} ${label}`
209
+ lines.push(line)
210
+ if (lines.length >= maxLines) break
211
+ }
212
+ return lines
213
+ }
214
+
215
+ // ──────────────────────────────────────────────────────────────────────────────
216
+ // buildMiniEditorLines
217
+ // ──────────────────────────────────────────────────────────────────────────────
218
+
219
+ /**
220
+ * Render MiniEditorField content with line numbers.
221
+ * When focused, inserts `|` at the cursor column on the active line.
222
+ * Returns up to `maxLines` lines.
223
+ *
224
+ * @param {MiniEditorField} field
225
+ * @param {boolean} focused
226
+ * @param {number} maxLines - Maximum number of lines to return
227
+ * @returns {string[]}
228
+ */
229
+ export function buildMiniEditorLines(field, focused, maxLines) {
230
+ const lines = []
231
+ const numWidth = String(field.lines.length).length
232
+
233
+ for (let i = 0; i < field.lines.length; i++) {
234
+ const lineNum = String(i + 1).padStart(numWidth)
235
+ const rawLine = field.lines[i]
236
+ let content
237
+ if (focused && i === field.cursorLine) {
238
+ content = renderCursor(rawLine, field.cursorCol)
239
+ } else {
240
+ content = rawLine
241
+ }
242
+ lines.push(` ${chalk.dim(lineNum + ' │')} ${content}`)
243
+ if (lines.length >= maxLines) break
244
+ }
245
+ return lines
246
+ }
247
+
248
+ // ──────────────────────────────────────────────────────────────────────────────
249
+ // buildFormScreen
250
+ // ──────────────────────────────────────────────────────────────────────────────
251
+
252
+ /**
253
+ * Render all form fields into an array of terminal lines.
254
+ *
255
+ * For the currently focused field:
256
+ * - MultiSelectField: renders expanded options below the field header line
257
+ * - MiniEditorField: renders editor content lines below the field header line
258
+ * - Other types: renders just the single header line
259
+ *
260
+ * Returns an array of lines (no ANSI clear/home — the parent handles that).
261
+ * Includes the form title at the top, an error message if set, all fields,
262
+ * and a footer hint line at the bottom.
263
+ *
264
+ * @param {FormState} formState
265
+ * @param {number} viewportHeight - Available content lines
266
+ * @param {number} termCols - Terminal width
267
+ * @returns {string[]}
268
+ */
269
+ export function buildFormScreen(formState, viewportHeight, termCols) {
270
+ const lines = []
271
+
272
+ // ── Title ──────────────────────────────────────────────────────────────────
273
+ lines.push('')
274
+ lines.push(` ${chalk.bold.cyan(formState.title)}`)
275
+ lines.push(` ${chalk.dim('─'.repeat(Math.min(termCols - 4, 60)))}`)
276
+
277
+ // ── Error message ─────────────────────────────────────────────────────────
278
+ if (formState.errorMessage) {
279
+ lines.push(` ${chalk.red('✖ ' + formState.errorMessage)}`)
280
+ }
281
+
282
+ lines.push('')
283
+
284
+ // ── Fields ────────────────────────────────────────────────────────────────
285
+ const FOOTER_RESERVE = 2
286
+ const availableForFields = viewportHeight - lines.length - FOOTER_RESERVE
287
+
288
+ for (let i = 0; i < formState.fields.length; i++) {
289
+ const field = formState.fields[i]
290
+ if (field.hidden) continue
291
+ const isFocused = i === formState.focusedFieldIndex
292
+
293
+ // Header line
294
+ lines.push(buildFieldLine(field, isFocused))
295
+
296
+ // Expanded inline content for focused multiselect / editor
297
+ if (isFocused) {
298
+ const remaining = availableForFields - lines.length
299
+ if (field.type === 'multiselect' && remaining > 0) {
300
+ const expanded = buildMultiSelectLines(field, true, remaining)
301
+ lines.push(...expanded)
302
+ } else if (field.type === 'editor' && remaining > 0) {
303
+ const expanded = buildMiniEditorLines(field, true, remaining)
304
+ lines.push(...expanded)
305
+ }
306
+ }
307
+ }
308
+
309
+ // ── Footer hint ───────────────────────────────────────────────────────────
310
+ lines.push('')
311
+ lines.push(chalk.dim(' Tab next field Shift+Tab prev Ctrl+S save Esc cancel'))
312
+
313
+ return lines
314
+ }
315
+
316
+ // ──────────────────────────────────────────────────────────────────────────────
317
+ // extractValues
318
+ // ──────────────────────────────────────────────────────────────────────────────
319
+
320
+ /**
321
+ * Extract form field values into a plain object.
322
+ *
323
+ * - TextField → string value
324
+ * - SelectorField → selected option string
325
+ * - MultiSelectField → array of selected ids
326
+ * - MiniEditorField → lines joined with `\n`
327
+ *
328
+ * The key for each field is `field.key` if set, otherwise the label lowercased
329
+ * with spaces replaced by underscores.
330
+ *
331
+ * @param {FormState} formState
332
+ * @returns {object}
333
+ */
334
+ export function extractValues(formState) {
335
+ /** @type {Record<string, unknown>} */
336
+ const result = {}
337
+
338
+ for (const field of formState.fields) {
339
+ if (field.hidden) continue
340
+ const key = fieldKey(field)
341
+
342
+ if (field.type === 'text') {
343
+ result[key] = field.value
344
+ } else if (field.type === 'selector') {
345
+ result[key] = field.options[field.selectedIndex] ?? ''
346
+ } else if (field.type === 'multiselect') {
347
+ result[key] = Array.from(field.selected)
348
+ } else if (field.type === 'editor') {
349
+ result[key] = field.lines.join('\n')
350
+ }
351
+ }
352
+
353
+ return result
354
+ }
355
+
356
+ // ──────────────────────────────────────────────────────────────────────────────
357
+ // Validation helper
358
+ // ──────────────────────────────────────────────────────────────────────────────
359
+
360
+ /**
361
+ * Check that all required fields have a non-empty value.
362
+ * Returns the label of the first invalid field, or null if all are valid.
363
+ * @param {FormState} formState
364
+ * @returns {string|null}
365
+ */
366
+ function validateForm(formState) {
367
+ for (const field of formState.fields) {
368
+ if (field.hidden) continue
369
+ if (!field.required) continue
370
+
371
+ if (field.type === 'text' && field.value.trim() === '') {
372
+ return field.label
373
+ }
374
+ if (field.type === 'selector' && field.options.length === 0) {
375
+ return field.label
376
+ }
377
+ if (field.type === 'multiselect' && field.selected.size === 0) {
378
+ return field.label
379
+ }
380
+ if (field.type === 'editor') {
381
+ const content = field.lines.join('\n').trim()
382
+ if (content === '') return field.label
383
+ }
384
+ }
385
+ return null
386
+ }
387
+
388
+ // ──────────────────────────────────────────────────────────────────────────────
389
+ // Field-specific keypress handlers (pure)
390
+ // ──────────────────────────────────────────────────────────────────────────────
391
+
392
+ /**
393
+ * Handle a keypress on a focused TextField. Returns updated field.
394
+ * @param {TextField} field
395
+ * @param {{ name: string, sequence?: string, ctrl?: boolean, shift?: boolean }} key
396
+ * @returns {TextField}
397
+ */
398
+ function handleTextFieldKey(field, key) {
399
+ const {value, cursor} = field
400
+
401
+ if (key.name === 'backspace') {
402
+ if (cursor === 0) return field
403
+ return {
404
+ ...field,
405
+ value: value.slice(0, cursor - 1) + value.slice(cursor),
406
+ cursor: cursor - 1,
407
+ }
408
+ }
409
+
410
+ if (key.name === 'delete') {
411
+ if (cursor >= value.length) return field
412
+ return {
413
+ ...field,
414
+ value: value.slice(0, cursor) + value.slice(cursor + 1),
415
+ }
416
+ }
417
+
418
+ if (key.name === 'left') {
419
+ return {...field, cursor: Math.max(0, cursor - 1)}
420
+ }
421
+ if (key.name === 'right') {
422
+ return {...field, cursor: Math.min(value.length, cursor + 1)}
423
+ }
424
+ if (key.name === 'home') {
425
+ return {...field, cursor: 0}
426
+ }
427
+ if (key.name === 'end') {
428
+ return {...field, cursor: value.length}
429
+ }
430
+
431
+ // Printable character
432
+ if (key.sequence && key.sequence.length === 1 && !key.ctrl) {
433
+ const ch = key.sequence
434
+ if (ch >= ' ') {
435
+ return {
436
+ ...field,
437
+ value: value.slice(0, cursor) + ch + value.slice(cursor),
438
+ cursor: cursor + 1,
439
+ }
440
+ }
441
+ }
442
+
443
+ return field
444
+ }
445
+
446
+ /**
447
+ * Handle a keypress on a focused SelectorField. Returns updated field.
448
+ * @param {SelectorField} field
449
+ * @param {{ name: string }} key
450
+ * @returns {SelectorField}
451
+ */
452
+ function handleSelectorFieldKey(field, key) {
453
+ const len = field.options.length
454
+ if (len === 0) return field
455
+
456
+ if (key.name === 'left') {
457
+ return {...field, selectedIndex: (field.selectedIndex - 1 + len) % len}
458
+ }
459
+ if (key.name === 'right') {
460
+ return {...field, selectedIndex: (field.selectedIndex + 1) % len}
461
+ }
462
+
463
+ return field
464
+ }
465
+
466
+ /**
467
+ * Handle a keypress on a focused MultiSelectField.
468
+ * Returns updated field or { advanceField: true } signal object.
469
+ * @param {MultiSelectField} field
470
+ * @param {{ name: string }} key
471
+ * @returns {MultiSelectField | { advanceField: true }}
472
+ */
473
+ function handleMultiSelectFieldKey(field, key) {
474
+ const len = field.options.length
475
+
476
+ if (key.name === 'up') {
477
+ return {...field, focusedOptionIndex: Math.max(0, field.focusedOptionIndex - 1)}
478
+ }
479
+ if (key.name === 'down') {
480
+ return {...field, focusedOptionIndex: Math.min(len - 1, field.focusedOptionIndex + 1)}
481
+ }
482
+
483
+ if (key.name === 'space') {
484
+ const opt = field.options[field.focusedOptionIndex]
485
+ if (!opt) return field
486
+ const newSelected = new Set(field.selected)
487
+ if (newSelected.has(opt.id)) {
488
+ newSelected.delete(opt.id)
489
+ } else {
490
+ newSelected.add(opt.id)
491
+ }
492
+ return {...field, selected: newSelected}
493
+ }
494
+
495
+ if (key.name === 'return') {
496
+ return {advanceField: /** @type {true} */ (true)}
497
+ }
498
+
499
+ return field
500
+ }
501
+
502
+ /**
503
+ * Handle a keypress on a focused MiniEditorField.
504
+ * Returns updated field or { advanceField: true } signal object.
505
+ * @param {MiniEditorField} field
506
+ * @param {{ name: string, sequence?: string, ctrl?: boolean }} key
507
+ * @returns {MiniEditorField | { advanceField: true }}
508
+ */
509
+ function handleEditorFieldKey(field, key) {
510
+ const {lines, cursorLine, cursorCol} = field
511
+
512
+ // Esc exits the editor — move to next field
513
+ if (key.name === 'escape') {
514
+ return {advanceField: /** @type {true} */ (true)}
515
+ }
516
+
517
+ if (key.name === 'left') {
518
+ if (cursorCol > 0) {
519
+ return {...field, cursorCol: cursorCol - 1}
520
+ }
521
+ if (cursorLine > 0) {
522
+ const prevLine = lines[cursorLine - 1]
523
+ return {...field, cursorLine: cursorLine - 1, cursorCol: prevLine.length}
524
+ }
525
+ return field
526
+ }
527
+
528
+ if (key.name === 'right') {
529
+ const line = lines[cursorLine]
530
+ if (cursorCol < line.length) {
531
+ return {...field, cursorCol: cursorCol + 1}
532
+ }
533
+ if (cursorLine < lines.length - 1) {
534
+ return {...field, cursorLine: cursorLine + 1, cursorCol: 0}
535
+ }
536
+ return field
537
+ }
538
+
539
+ if (key.name === 'up') {
540
+ if (cursorLine === 0) return field
541
+ const newLine = cursorLine - 1
542
+ const newCol = Math.min(cursorCol, lines[newLine].length)
543
+ return {...field, cursorLine: newLine, cursorCol: newCol}
544
+ }
545
+
546
+ if (key.name === 'down') {
547
+ if (cursorLine >= lines.length - 1) return field
548
+ const newLine = cursorLine + 1
549
+ const newCol = Math.min(cursorCol, lines[newLine].length)
550
+ return {...field, cursorLine: newLine, cursorCol: newCol}
551
+ }
552
+
553
+ if (key.name === 'home') {
554
+ return {...field, cursorCol: 0}
555
+ }
556
+
557
+ if (key.name === 'end') {
558
+ return {...field, cursorCol: lines[cursorLine].length}
559
+ }
560
+
561
+ if (key.name === 'backspace') {
562
+ if (cursorCol > 0) {
563
+ const newLines = [...lines]
564
+ const ln = newLines[cursorLine]
565
+ newLines[cursorLine] = ln.slice(0, cursorCol - 1) + ln.slice(cursorCol)
566
+ return {...field, lines: newLines, cursorCol: cursorCol - 1}
567
+ }
568
+ if (cursorLine > 0) {
569
+ // Merge current line into previous
570
+ const newLines = [...lines]
571
+ const prevLine = newLines[cursorLine - 1]
572
+ const currLine = newLines[cursorLine]
573
+ const mergedCol = prevLine.length
574
+ newLines.splice(cursorLine, 1)
575
+ newLines[cursorLine - 1] = prevLine + currLine
576
+ return {...field, lines: newLines, cursorLine: cursorLine - 1, cursorCol: mergedCol}
577
+ }
578
+ return field
579
+ }
580
+
581
+ if (key.name === 'delete') {
582
+ const line = lines[cursorLine]
583
+ if (cursorCol < line.length) {
584
+ const newLines = [...lines]
585
+ newLines[cursorLine] = line.slice(0, cursorCol) + line.slice(cursorCol + 1)
586
+ return {...field, lines: newLines}
587
+ }
588
+ if (cursorLine < lines.length - 1) {
589
+ // Merge next line
590
+ const newLines = [...lines]
591
+ newLines[cursorLine] = newLines[cursorLine] + newLines[cursorLine + 1]
592
+ newLines.splice(cursorLine + 1, 1)
593
+ return {...field, lines: newLines}
594
+ }
595
+ return field
596
+ }
597
+
598
+ // Enter inserts a new line after the cursor position
599
+ if (key.name === 'return') {
600
+ const line = lines[cursorLine]
601
+ const before = line.slice(0, cursorCol)
602
+ const after = line.slice(cursorCol)
603
+ const newLines = [...lines]
604
+ newLines.splice(cursorLine, 1, before, after)
605
+ return {...field, lines: newLines, cursorLine: cursorLine + 1, cursorCol: 0}
606
+ }
607
+
608
+ // Printable character
609
+ if (key.sequence && key.sequence.length === 1 && !key.ctrl) {
610
+ const ch = key.sequence
611
+ if (ch >= ' ') {
612
+ const newLines = [...lines]
613
+ const ln = newLines[cursorLine]
614
+ newLines[cursorLine] = ln.slice(0, cursorCol) + ch + ln.slice(cursorCol)
615
+ return {...field, lines: newLines, cursorCol: cursorCol + 1}
616
+ }
617
+ }
618
+
619
+ return field
620
+ }
621
+
622
+ // ──────────────────────────────────────────────────────────────────────────────
623
+ // handleFormKeypress
624
+ // ──────────────────────────────────────────────────────────────────────────────
625
+
626
+ /**
627
+ * Pure reducer for form keypresses.
628
+ *
629
+ * Global keys handled regardless of focused field:
630
+ * - Tab: move to next field
631
+ * - Shift+Tab: move to previous field (wraps)
632
+ * - Ctrl+S: validate and submit
633
+ * - Esc: cancel → return `{ cancelled: true }`
634
+ * - Enter on last field: validate and submit
635
+ *
636
+ * Field-specific handling when field is focused:
637
+ * - TextField: printable chars append to value, Backspace deletes, ← → move cursor, Home/End jump
638
+ * - SelectorField: ← → cycle options
639
+ * - MultiSelectField: ↑ ↓ navigate, Space toggle, Enter advances to next field
640
+ * - MiniEditorField: printable chars insert, Enter inserts new line, Esc exits to next field
641
+ *
642
+ * On submit: validates required fields. If invalid, sets `errorMessage` and returns
643
+ * the state. If valid, returns `{ submitted: true, values: extractValues(formState) }`.
644
+ *
645
+ * @param {FormState} formState
646
+ * @param {{ name: string, sequence?: string, ctrl?: boolean, shift?: boolean }} key
647
+ * @returns {FormState | SubmitResult | CancelResult}
648
+ */
649
+ export function handleFormKeypress(formState, key) {
650
+ const {fields, focusedFieldIndex} = formState
651
+ const lastFieldIndex = fields.length - 1
652
+
653
+ // ── Esc: cancel (unless inside a MiniEditorField) ─────────────────────────
654
+ // For editor fields, Esc is handled inside the field handler to advance focus,
655
+ // not cancel the form. Only cancel when a non-editor field is focused.
656
+ const focusedField = fields[focusedFieldIndex]
657
+ if (key.name === 'escape' && focusedField?.type !== 'editor') {
658
+ return {cancelled: /** @type {true} */ (true)}
659
+ }
660
+
661
+ // ── Ctrl+S: submit ────────────────────────────────────────────────────────
662
+ if (key.ctrl && key.name === 's') {
663
+ return attemptSubmit(formState)
664
+ }
665
+
666
+ // ── Tab: next field (skip hidden) ──────────────────────────────────────────
667
+ if (key.name === 'tab' && !key.shift) {
668
+ return {
669
+ ...formState,
670
+ focusedFieldIndex: nextVisibleIndex(fields, focusedFieldIndex, 1),
671
+ errorMessage: null,
672
+ }
673
+ }
674
+
675
+ // ── Shift+Tab: previous field (skip hidden) ──────────────────────────────
676
+ if (key.name === 'tab' && key.shift) {
677
+ return {
678
+ ...formState,
679
+ focusedFieldIndex: nextVisibleIndex(fields, focusedFieldIndex, -1),
680
+ errorMessage: null,
681
+ }
682
+ }
683
+
684
+ // ── Enter on last non-editor field: submit ─────────────────────────────────
685
+ if (
686
+ key.name === 'return' &&
687
+ focusedFieldIndex === lastFieldIndex &&
688
+ focusedField?.type !== 'editor' &&
689
+ focusedField?.type !== 'multiselect'
690
+ ) {
691
+ return attemptSubmit(formState)
692
+ }
693
+
694
+ // ── Delegate to focused field ─────────────────────────────────────────────
695
+ if (!focusedField) return formState
696
+
697
+ if (focusedField.type === 'text') {
698
+ const updated = handleTextFieldKey(focusedField, key)
699
+ if (updated === focusedField) return formState
700
+ return {
701
+ ...formState,
702
+ errorMessage: null,
703
+ fields: replaceAt(fields, focusedFieldIndex, updated),
704
+ }
705
+ }
706
+
707
+ if (focusedField.type === 'selector') {
708
+ const updated = handleSelectorFieldKey(focusedField, key)
709
+ if (updated === focusedField) return formState
710
+ let newFields = replaceAt(fields, focusedFieldIndex, updated)
711
+ // Dynamic visibility: when transport selector changes, toggle field visibility
712
+ if (fieldKey(focusedField) === 'transport') {
713
+ const newTransport = updated.options[updated.selectedIndex]
714
+ newFields = updateMCPFieldVisibility(newFields, newTransport)
715
+ }
716
+ return {
717
+ ...formState,
718
+ fields: newFields,
719
+ }
720
+ }
721
+
722
+ if (focusedField.type === 'multiselect') {
723
+ const result = handleMultiSelectFieldKey(focusedField, key)
724
+ if ('advanceField' in result) {
725
+ return {
726
+ ...formState,
727
+ focusedFieldIndex: nextVisibleIndex(fields, focusedFieldIndex, 1),
728
+ }
729
+ }
730
+ if (result === focusedField) return formState
731
+ return {
732
+ ...formState,
733
+ fields: replaceAt(fields, focusedFieldIndex, result),
734
+ }
735
+ }
736
+
737
+ if (focusedField.type === 'editor') {
738
+ const result = handleEditorFieldKey(focusedField, key)
739
+ if ('advanceField' in result) {
740
+ // Esc in editor moves to next field (skip hidden)
741
+ return {
742
+ ...formState,
743
+ focusedFieldIndex: nextVisibleIndex(fields, focusedFieldIndex, 1),
744
+ }
745
+ }
746
+ if (result === focusedField) return formState
747
+ return {
748
+ ...formState,
749
+ errorMessage: null,
750
+ fields: replaceAt(fields, focusedFieldIndex, result),
751
+ }
752
+ }
753
+
754
+ return formState
755
+ }
756
+
757
+ /**
758
+ * Attempt to submit the form: validate, then return SubmitResult or FormState with error.
759
+ * @param {FormState} formState
760
+ * @returns {FormState | SubmitResult}
761
+ */
762
+ function attemptSubmit(formState) {
763
+ const invalidLabel = validateForm(formState)
764
+ if (invalidLabel !== null) {
765
+ return {
766
+ ...formState,
767
+ errorMessage: `"${invalidLabel}" is required.`,
768
+ }
769
+ }
770
+ // Run custom validator (e.g. MCP transport-specific checks)
771
+ if (formState.customValidator) {
772
+ const customError = formState.customValidator(formState)
773
+ if (customError !== null) {
774
+ return {...formState, errorMessage: customError}
775
+ }
776
+ }
777
+ return {
778
+ submitted: /** @type {true} */ (true),
779
+ values: extractValues(formState),
780
+ }
781
+ }
782
+
783
+ /**
784
+ * Return a new array with element at `index` replaced by `value`.
785
+ * @template T
786
+ * @param {T[]} arr
787
+ * @param {number} index
788
+ * @param {T} value
789
+ * @returns {T[]}
790
+ */
791
+ function replaceAt(arr, index, value) {
792
+ return arr.map((item, i) => (i === index ? value : item))
793
+ }
794
+
795
+ // ──────────────────────────────────────────────────────────────────────────────
796
+ // Form field definitions
797
+ // ──────────────────────────────────────────────────────────────────────────────
798
+
799
+ /**
800
+ * Return form fields for creating or editing an MCP entry.
801
+ *
802
+ * Fields: name (text), environments (multiselect), transport (selector),
803
+ * command (text, stdio only), args (editor, stdio only), url (text, remote only),
804
+ * env vars (editor), description (text, optional).
805
+ *
806
+ * Fields are dynamically shown/hidden based on the selected transport.
807
+ *
808
+ * @param {import('../../types.js').CategoryEntry|null} [entry] - Existing entry to pre-fill from, or null to create
809
+ * @param {import('../../types.js').DetectedEnvironment[]} [compatibleEnvs] - Environments compatible with this category type
810
+ * @returns {Field[]}
811
+ */
812
+ export function getMCPFormFields(entry = null, compatibleEnvs = []) {
813
+ /** @type {import('../../types.js').MCPParams|null} */
814
+ const p = entry ? /** @type {import('../../types.js').MCPParams} */ (entry.params) : null
815
+
816
+ const transportOptions = ['stdio', 'sse', 'streamable-http']
817
+ const transportIndex = p ? Math.max(0, transportOptions.indexOf(p.transport)) : 0
818
+ const transport = transportOptions[transportIndex]
819
+
820
+ /** @type {Field[]} */
821
+ const fields = [
822
+ /** @type {TextField} */ ({
823
+ type: 'text',
824
+ label: 'Name',
825
+ key: 'name',
826
+ value: entry ? entry.name : '',
827
+ cursor: entry ? entry.name.length : 0,
828
+ required: true,
829
+ placeholder: 'my-mcp-server',
830
+ }),
831
+ /** @type {MultiSelectField} */ ({
832
+ type: 'multiselect',
833
+ label: 'Environments',
834
+ key: 'environments',
835
+ options: compatibleEnvs.map((env) => ({id: env.id, label: env.name})),
836
+ selected: new Set(entry ? entry.environments : []),
837
+ focusedOptionIndex: 0,
838
+ required: true,
839
+ }),
840
+ /** @type {SelectorField} */ ({
841
+ type: 'selector',
842
+ label: 'Transport',
843
+ key: 'transport',
844
+ options: transportOptions,
845
+ selectedIndex: transportIndex,
846
+ required: true,
847
+ }),
848
+ /** @type {TextField} */ ({
849
+ type: 'text',
850
+ label: 'Command',
851
+ key: 'command',
852
+ value: p?.command ?? '',
853
+ cursor: (p?.command ?? '').length,
854
+ required: false,
855
+ placeholder: 'npx my-mcp-server',
856
+ }),
857
+ /** @type {MiniEditorField} */ ({
858
+ type: 'editor',
859
+ label: 'Args',
860
+ key: 'args',
861
+ lines: p?.args?.length > 0 ? [...p.args] : [''],
862
+ cursorLine: 0,
863
+ cursorCol: 0,
864
+ required: false,
865
+ }),
866
+ /** @type {TextField} */ ({
867
+ type: 'text',
868
+ label: 'URL',
869
+ key: 'url',
870
+ value: p?.url ?? '',
871
+ cursor: (p?.url ?? '').length,
872
+ required: false,
873
+ placeholder: 'https://mcp.example.com',
874
+ }),
875
+ /** @type {MiniEditorField} */ ({
876
+ type: 'editor',
877
+ label: 'Env Vars',
878
+ key: 'env',
879
+ lines: p?.env ? Object.entries(p.env).map(([k, v]) => `${k}=${v}`) : [''],
880
+ cursorLine: 0,
881
+ cursorCol: 0,
882
+ required: false,
883
+ }),
884
+ /** @type {TextField} */ ({
885
+ type: 'text',
886
+ label: 'Description',
887
+ key: 'description',
888
+ value: p?.description ?? (entry?.params ? /** @type {any} */ ((entry.params).description ?? '') : ''),
889
+ cursor: 0,
890
+ required: false,
891
+ placeholder: 'Optional description',
892
+ }),
893
+ ]
894
+
895
+ return updateMCPFieldVisibility(fields, transport)
896
+ }
897
+
898
+ /**
899
+ * Toggle visibility of MCP-specific fields based on the selected transport.
900
+ * - stdio: show Command + Args, hide URL
901
+ * - sse/streamable-http: show URL, hide Command + Args
902
+ * - Env Vars and Description are always visible
903
+ *
904
+ * @param {Field[]} fields
905
+ * @param {string} transport
906
+ * @returns {Field[]}
907
+ */
908
+ export function updateMCPFieldVisibility(fields, transport) {
909
+ const isStdio = transport === 'stdio'
910
+ return fields.map((f) => {
911
+ const key = f.key || f.label.toLowerCase().replace(/\s+/g, '_')
912
+ if (key === 'command' || key === 'args') {
913
+ return {...f, hidden: !isStdio}
914
+ }
915
+ if (key === 'url') {
916
+ return {...f, hidden: isStdio}
917
+ }
918
+ return f
919
+ })
920
+ }
921
+
922
+ /**
923
+ * MCP-specific form validator. Checks that:
924
+ * - stdio transport has a non-empty command
925
+ * - sse/streamable-http transport has a non-empty URL
926
+ * - Env var lines (if any) follow KEY=VALUE format
927
+ *
928
+ * @param {FormState} formState
929
+ * @returns {string|null} Error message or null if valid
930
+ */
931
+ export function validateMCPForm(formState) {
932
+ const values = extractValues(formState)
933
+ const transport = values.transport
934
+
935
+ if (transport === 'stdio') {
936
+ if (!values.command || /** @type {string} */ (values.command).trim() === '') {
937
+ return 'Command is required for stdio transport'
938
+ }
939
+ } else if (transport === 'sse' || transport === 'streamable-http') {
940
+ if (!values.url || /** @type {string} */ (values.url).trim() === '') {
941
+ return 'URL is required for sse/streamable-http transport'
942
+ }
943
+ }
944
+
945
+ if (values.env && typeof values.env === 'string') {
946
+ const lines = /** @type {string} */ (values.env).split('\n').filter((l) => l.trim() !== '')
947
+ for (const line of lines) {
948
+ if (!line.includes('=')) {
949
+ return `Invalid env var format: "${line}" (expected KEY=VALUE)`
950
+ }
951
+ }
952
+ }
953
+
954
+ return null
955
+ }
956
+
957
+ /**
958
+ * Return form fields for creating or editing a Command entry.
959
+ *
960
+ * Fields: name (text), environments (multiselect), description (text, optional), content (editor).
961
+ *
962
+ * @param {import('../../types.js').CategoryEntry|null} [entry] - Existing entry to pre-fill from, or null to create
963
+ * @param {import('../../types.js').DetectedEnvironment[]} [compatibleEnvs] - Environments compatible with this category type
964
+ * @returns {Field[]}
965
+ */
966
+ export function getCommandFormFields(entry = null, compatibleEnvs = []) {
967
+ /** @type {import('../../types.js').CommandParams|null} */
968
+ const p = entry ? /** @type {import('../../types.js').CommandParams} */ (entry.params) : null
969
+ const contentStr = p?.content ?? ''
970
+ const contentLines = contentStr.length > 0 ? contentStr.split('\n') : ['']
971
+
972
+ return [
973
+ /** @type {TextField} */ ({
974
+ type: 'text',
975
+ label: 'Name',
976
+ key: 'name',
977
+ value: entry ? entry.name : '',
978
+ cursor: entry ? entry.name.length : 0,
979
+ required: true,
980
+ placeholder: 'my-command',
981
+ }),
982
+ /** @type {MultiSelectField} */ ({
983
+ type: 'multiselect',
984
+ label: 'Environments',
985
+ key: 'environments',
986
+ options: compatibleEnvs.map((env) => ({id: env.id, label: env.name})),
987
+ selected: new Set(entry ? entry.environments : []),
988
+ focusedOptionIndex: 0,
989
+ required: true,
990
+ }),
991
+ /** @type {TextField} */ ({
992
+ type: 'text',
993
+ label: 'Description',
994
+ key: 'description',
995
+ value: p?.description ?? '',
996
+ cursor: (p?.description ?? '').length,
997
+ required: false,
998
+ placeholder: 'Optional description',
999
+ }),
1000
+ /** @type {MiniEditorField} */ ({
1001
+ type: 'editor',
1002
+ label: 'Content',
1003
+ key: 'content',
1004
+ lines: contentLines,
1005
+ cursorLine: 0,
1006
+ cursorCol: 0,
1007
+ required: true,
1008
+ }),
1009
+ ]
1010
+ }
1011
+
1012
+ /**
1013
+ * Return form fields for creating or editing a Skill entry.
1014
+ *
1015
+ * Fields: name (text), environments (multiselect), description (text, optional), content (editor).
1016
+ *
1017
+ * @param {import('../../types.js').CategoryEntry|null} [entry] - Existing entry to pre-fill from, or null to create
1018
+ * @param {import('../../types.js').DetectedEnvironment[]} [compatibleEnvs] - Environments compatible with this category type
1019
+ * @returns {Field[]}
1020
+ */
1021
+ export function getSkillFormFields(entry = null, compatibleEnvs = []) {
1022
+ /** @type {import('../../types.js').SkillParams|null} */
1023
+ const p = entry ? /** @type {import('../../types.js').SkillParams} */ (entry.params) : null
1024
+ const contentStr = p?.content ?? ''
1025
+ const contentLines = contentStr.length > 0 ? contentStr.split('\n') : ['']
1026
+
1027
+ return [
1028
+ /** @type {TextField} */ ({
1029
+ type: 'text',
1030
+ label: 'Name',
1031
+ key: 'name',
1032
+ value: entry ? entry.name : '',
1033
+ cursor: entry ? entry.name.length : 0,
1034
+ required: true,
1035
+ placeholder: 'my-skill',
1036
+ }),
1037
+ /** @type {MultiSelectField} */ ({
1038
+ type: 'multiselect',
1039
+ label: 'Environments',
1040
+ key: 'environments',
1041
+ options: compatibleEnvs.map((env) => ({id: env.id, label: env.name})),
1042
+ selected: new Set(entry ? entry.environments : []),
1043
+ focusedOptionIndex: 0,
1044
+ required: true,
1045
+ }),
1046
+ /** @type {TextField} */ ({
1047
+ type: 'text',
1048
+ label: 'Description',
1049
+ key: 'description',
1050
+ value: p?.description ?? '',
1051
+ cursor: (p?.description ?? '').length,
1052
+ required: false,
1053
+ placeholder: 'Optional description',
1054
+ }),
1055
+ /** @type {MiniEditorField} */ ({
1056
+ type: 'editor',
1057
+ label: 'Content',
1058
+ key: 'content',
1059
+ lines: contentLines,
1060
+ cursorLine: 0,
1061
+ cursorCol: 0,
1062
+ required: true,
1063
+ }),
1064
+ ]
1065
+ }
1066
+
1067
+ /**
1068
+ * Return form fields for creating or editing an Agent entry.
1069
+ *
1070
+ * Fields: name (text), environments (multiselect), description (text, optional), instructions (editor).
1071
+ *
1072
+ * @param {import('../../types.js').CategoryEntry|null} [entry] - Existing entry to pre-fill from, or null to create
1073
+ * @param {import('../../types.js').DetectedEnvironment[]} [compatibleEnvs] - Environments compatible with this category type
1074
+ * @returns {Field[]}
1075
+ */
1076
+ /**
1077
+ * Return form fields for creating or editing a Rule entry.
1078
+ *
1079
+ * Fields: name (text), environments (multiselect), description (text, optional), content (editor).
1080
+ *
1081
+ * @param {import('../../types.js').CategoryEntry|null} [entry] - Existing entry to pre-fill from, or null to create
1082
+ * @param {import('../../types.js').DetectedEnvironment[]} [compatibleEnvs] - Environments compatible with this category type
1083
+ * @returns {Field[]}
1084
+ */
1085
+ export function getRuleFormFields(entry = null, compatibleEnvs = []) {
1086
+ /** @type {import('../../types.js').RuleParams|null} */
1087
+ const p = entry ? /** @type {import('../../types.js').RuleParams} */ (entry.params) : null
1088
+ const contentStr = p?.content ?? ''
1089
+ const contentLines = contentStr.length > 0 ? contentStr.split('\n') : ['']
1090
+
1091
+ return [
1092
+ /** @type {TextField} */ ({
1093
+ type: 'text',
1094
+ label: 'Name',
1095
+ key: 'name',
1096
+ value: entry ? entry.name : '',
1097
+ cursor: entry ? entry.name.length : 0,
1098
+ required: true,
1099
+ placeholder: 'my-rule',
1100
+ }),
1101
+ /** @type {MultiSelectField} */ ({
1102
+ type: 'multiselect',
1103
+ label: 'Environments',
1104
+ key: 'environments',
1105
+ options: compatibleEnvs.map((env) => ({id: env.id, label: env.name})),
1106
+ selected: new Set(entry ? entry.environments : []),
1107
+ focusedOptionIndex: 0,
1108
+ required: true,
1109
+ }),
1110
+ /** @type {TextField} */ ({
1111
+ type: 'text',
1112
+ label: 'Description',
1113
+ key: 'description',
1114
+ value: p?.description ?? '',
1115
+ cursor: (p?.description ?? '').length,
1116
+ required: false,
1117
+ placeholder: 'Optional description',
1118
+ }),
1119
+ /** @type {MiniEditorField} */ ({
1120
+ type: 'editor',
1121
+ label: 'Content',
1122
+ key: 'content',
1123
+ lines: contentLines,
1124
+ cursorLine: 0,
1125
+ cursorCol: 0,
1126
+ required: true,
1127
+ }),
1128
+ ]
1129
+ }
1130
+
1131
+ /**
1132
+ * Return form fields for creating or editing an Agent entry.
1133
+ *
1134
+ * Fields: name (text), environments (multiselect), description (text, optional), instructions (editor).
1135
+ *
1136
+ * @param {import('../../types.js').CategoryEntry|null} [entry] - Existing entry to pre-fill from, or null to create
1137
+ * @param {import('../../types.js').DetectedEnvironment[]} [compatibleEnvs] - Environments compatible with this category type
1138
+ * @returns {Field[]}
1139
+ */
1140
+ export function getAgentFormFields(entry = null, compatibleEnvs = []) {
1141
+ /** @type {import('../../types.js').AgentParams|null} */
1142
+ const p = entry ? /** @type {import('../../types.js').AgentParams} */ (entry.params) : null
1143
+ const instructionsStr = p?.instructions ?? ''
1144
+ const instructionLines = instructionsStr.length > 0 ? instructionsStr.split('\n') : ['']
1145
+
1146
+ return [
1147
+ /** @type {TextField} */ ({
1148
+ type: 'text',
1149
+ label: 'Name',
1150
+ key: 'name',
1151
+ value: entry ? entry.name : '',
1152
+ cursor: entry ? entry.name.length : 0,
1153
+ required: true,
1154
+ placeholder: 'my-agent',
1155
+ }),
1156
+ /** @type {MultiSelectField} */ ({
1157
+ type: 'multiselect',
1158
+ label: 'Environments',
1159
+ key: 'environments',
1160
+ options: compatibleEnvs.map((env) => ({id: env.id, label: env.name})),
1161
+ selected: new Set(entry ? entry.environments : []),
1162
+ focusedOptionIndex: 0,
1163
+ required: true,
1164
+ }),
1165
+ /** @type {TextField} */ ({
1166
+ type: 'text',
1167
+ label: 'Description',
1168
+ key: 'description',
1169
+ value: p?.description ?? '',
1170
+ cursor: (p?.description ?? '').length,
1171
+ required: false,
1172
+ placeholder: 'Optional description',
1173
+ }),
1174
+ /** @type {MiniEditorField} */ ({
1175
+ type: 'editor',
1176
+ label: 'Instructions',
1177
+ key: 'instructions',
1178
+ lines: instructionLines,
1179
+ cursorLine: 0,
1180
+ cursorCol: 0,
1181
+ required: true,
1182
+ }),
1183
+ ]
1184
+ }