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.
- package/README.md +72 -0
- package/oclif.manifest.json +275 -235
- package/package.json +2 -1
- package/src/commands/auth/login.js +20 -16
- package/src/commands/changelog.js +12 -12
- package/src/commands/costs/get.js +14 -24
- package/src/commands/costs/trend.js +13 -24
- package/src/commands/create/repo.js +72 -54
- package/src/commands/docs/list.js +29 -25
- package/src/commands/docs/projects.js +58 -24
- package/src/commands/docs/read.js +56 -39
- package/src/commands/docs/search.js +37 -25
- package/src/commands/doctor.js +37 -35
- package/src/commands/dotfiles/add.js +51 -39
- package/src/commands/dotfiles/setup.js +62 -33
- package/src/commands/dotfiles/status.js +18 -18
- package/src/commands/dotfiles/sync.js +62 -46
- package/src/commands/init.js +143 -132
- package/src/commands/logs/index.js +10 -16
- package/src/commands/open.js +12 -12
- package/src/commands/pipeline/logs.js +8 -11
- package/src/commands/pipeline/rerun.js +21 -16
- package/src/commands/pipeline/status.js +28 -24
- package/src/commands/pr/create.js +40 -27
- package/src/commands/pr/detail.js +9 -7
- package/src/commands/pr/review.js +18 -19
- package/src/commands/pr/status.js +27 -21
- package/src/commands/prompts/browse.js +15 -15
- package/src/commands/prompts/download.js +15 -16
- package/src/commands/prompts/install-speckit.js +11 -12
- package/src/commands/prompts/list.js +12 -12
- package/src/commands/prompts/run.js +16 -19
- package/src/commands/repo/list.js +57 -41
- package/src/commands/search.js +20 -18
- package/src/commands/security/setup.js +38 -34
- package/src/commands/sync-config-ai/index.js +257 -0
- package/src/commands/tasks/assigned.js +43 -33
- package/src/commands/tasks/list.js +43 -33
- package/src/commands/tasks/today.js +32 -30
- package/src/commands/upgrade.js +18 -17
- package/src/commands/vuln/detail.js +8 -8
- package/src/commands/vuln/scan.js +39 -20
- package/src/commands/vuln/search.js +23 -18
- package/src/commands/welcome.js +2 -2
- package/src/commands/whoami.js +19 -23
- package/src/formatters/ai-config.js +215 -0
- package/src/formatters/charts.js +6 -23
- package/src/formatters/cost.js +1 -7
- package/src/formatters/dotfiles.js +48 -19
- package/src/formatters/markdown.js +11 -6
- package/src/formatters/openapi.js +7 -9
- package/src/formatters/prompts.js +69 -78
- package/src/formatters/security.js +2 -2
- package/src/formatters/status.js +1 -1
- package/src/formatters/table.js +1 -3
- package/src/formatters/vuln.js +33 -20
- package/src/help.js +162 -164
- package/src/hooks/init.js +1 -3
- package/src/hooks/postrun.js +5 -7
- package/src/index.js +1 -1
- package/src/services/ai-config-store.js +349 -0
- package/src/services/ai-env-deployer.js +650 -0
- package/src/services/ai-env-scanner.js +983 -0
- package/src/services/audit-detector.js +2 -2
- package/src/services/audit-runner.js +40 -31
- package/src/services/auth.js +9 -9
- package/src/services/awesome-copilot.js +7 -4
- package/src/services/aws-costs.js +22 -22
- package/src/services/clickup.js +26 -26
- package/src/services/cloudwatch-logs.js +5 -9
- package/src/services/config.js +13 -13
- package/src/services/docs.js +19 -20
- package/src/services/dotfiles.js +149 -51
- package/src/services/github.js +22 -24
- package/src/services/nvd.js +21 -31
- package/src/services/platform.js +2 -2
- package/src/services/prompts.js +23 -35
- package/src/services/security.js +135 -61
- package/src/services/shell.js +4 -4
- package/src/services/skills-sh.js +3 -9
- package/src/services/speckit.js +4 -7
- package/src/services/version-check.js +10 -10
- package/src/types.js +117 -0
- package/src/utils/aws-vault.js +18 -41
- package/src/utils/banner.js +5 -7
- package/src/utils/errors.js +42 -46
- package/src/utils/frontmatter.js +4 -4
- package/src/utils/gradient.js +18 -16
- package/src/utils/open-browser.js +3 -3
- package/src/utils/tui/form.js +1184 -0
- package/src/utils/tui/modal.js +15 -14
- package/src/utils/tui/navigable-table.js +16 -16
- package/src/utils/tui/tab-tui.js +1089 -0
- package/src/utils/typewriter.js +3 -3
- package/src/utils/welcome.js +18 -21
- 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
|
+
}
|