devvami 1.5.0 → 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/src/types.js CHANGED
@@ -337,11 +337,11 @@
337
337
  // ──────────────────────────────────────────────────────────────────────────────
338
338
 
339
339
  /**
340
- * @typedef {'mcp'|'command'|'skill'|'agent'} CategoryType
340
+ * @typedef {'mcp'|'command'|'rule'|'skill'|'agent'} CategoryType
341
341
  */
342
342
 
343
343
  /**
344
- * @typedef {'vscode-copilot'|'claude-code'|'opencode'|'gemini-cli'|'copilot-cli'} EnvironmentId
344
+ * @typedef {'vscode-copilot'|'claude-code'|'claude-desktop'|'opencode'|'gemini-cli'|'copilot-cli'|'cursor'|'windsurf'|'continue-dev'|'zed'|'amazon-q'} EnvironmentId
345
345
  */
346
346
 
347
347
  /**
@@ -359,6 +359,12 @@
359
359
  * @property {string} [description] - Short description of the command
360
360
  */
361
361
 
362
+ /**
363
+ * @typedef {Object} RuleParams
364
+ * @property {string} content - Rules/instructions content (multi-line Markdown)
365
+ * @property {string} [description] - Short description of the rule
366
+ */
367
+
362
368
  /**
363
369
  * @typedef {Object} SkillParams
364
370
  * @property {string} content - Skill definition content (multi-line)
@@ -378,7 +384,7 @@
378
384
  * @property {CategoryType} type - Category type
379
385
  * @property {boolean} active - true = deployed to environments, false = removed but kept in store
380
386
  * @property {EnvironmentId[]} environments - Target environments for deployment
381
- * @property {MCPParams|CommandParams|SkillParams|AgentParams} params - Type-specific parameters
387
+ * @property {MCPParams|CommandParams|RuleParams|SkillParams|AgentParams} params - Type-specific parameters
382
388
  * @property {string} createdAt - ISO 8601 timestamp
383
389
  * @property {string} updatedAt - ISO 8601 timestamp
384
390
  */
@@ -400,10 +406,33 @@
400
406
  * @typedef {Object} CategoryCounts
401
407
  * @property {number} mcp
402
408
  * @property {number} command
409
+ * @property {number} rule
403
410
  * @property {number} skill
404
411
  * @property {number} agent
405
412
  */
406
413
 
414
+ /**
415
+ * @typedef {Object} NativeEntry
416
+ * Runtime only — not persisted. Represents an item found in an environment's config
417
+ * file that is NOT managed by dvmi.
418
+ * @property {string} name - Entry name (extracted from config key or filename)
419
+ * @property {CategoryType} type - Category type
420
+ * @property {EnvironmentId} environmentId - Source environment
421
+ * @property {'project'|'global'} level - Whether from project-level or global-level config
422
+ * @property {string} sourcePath - Absolute path to the source config file
423
+ * @property {object} params - Normalized parameters (same structure as managed entry params)
424
+ */
425
+
426
+ /**
427
+ * @typedef {Object} DriftInfo
428
+ * Runtime only — not persisted. Describes a managed entry whose deployed state
429
+ * diverges from dvmi's stored expected state.
430
+ * @property {string} entryId - ID of the managed CategoryEntry that drifted
431
+ * @property {EnvironmentId} environmentId - Environment where drift was detected
432
+ * @property {object} expected - What dvmi expects (from store)
433
+ * @property {object} actual - What was found in the file
434
+ */
435
+
407
436
  /**
408
437
  * @typedef {Object} DetectedEnvironment
409
438
  * @property {EnvironmentId} id - Environment identifier
@@ -414,6 +443,9 @@
414
443
  * @property {string[]} unreadable - Paths that exist but failed to parse
415
444
  * @property {CategoryType[]} supportedCategories - Category types this environment supports
416
445
  * @property {CategoryCounts} counts - Per-category item counts from dvmi-managed entries
446
+ * @property {CategoryCounts} nativeCounts - Per-category native item counts (items in config files)
447
+ * @property {NativeEntry[]} nativeEntries - All native entries found for this environment
448
+ * @property {DriftInfo[]} driftedEntries - Managed entries that have drifted from expected state
417
449
  * @property {'project'|'global'|'both'} scope - Where detection occurred
418
450
  */
419
451
 
@@ -20,6 +20,7 @@ import chalk from 'chalk'
20
20
  * @property {boolean} required
21
21
  * @property {string} placeholder
22
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
23
24
  */
24
25
 
25
26
  /**
@@ -30,6 +31,7 @@ import chalk from 'chalk'
30
31
  * @property {number} selectedIndex
31
32
  * @property {boolean} required
32
33
  * @property {string} [key]
34
+ * @property {boolean} [hidden]
33
35
  */
34
36
 
35
37
  /**
@@ -45,6 +47,7 @@ import chalk from 'chalk'
45
47
  * @property {number} focusedOptionIndex
46
48
  * @property {boolean} required
47
49
  * @property {string} [key]
50
+ * @property {boolean} [hidden]
48
51
  */
49
52
 
50
53
  /**
@@ -56,6 +59,7 @@ import chalk from 'chalk'
56
59
  * @property {number} cursorCol
57
60
  * @property {boolean} required
58
61
  * @property {string} [key]
62
+ * @property {boolean} [hidden]
59
63
  */
60
64
 
61
65
  /**
@@ -69,6 +73,7 @@ import chalk from 'chalk'
69
73
  * @property {string} title
70
74
  * @property {'editing'|'submitted'|'cancelled'} status
71
75
  * @property {string|null} errorMessage
76
+ * @property {((state: FormState) => string|null)|null} [customValidator] - Optional transport-specific validator
72
77
  */
73
78
 
74
79
  /**
@@ -97,6 +102,25 @@ function fieldKey(field) {
97
102
  return field.label.toLowerCase().replace(/\s+/g, '_')
98
103
  }
99
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
+
100
124
  /**
101
125
  * Render the text cursor inside a string value at the given position.
102
126
  * Inserts a `|` character at the cursor index.
@@ -263,6 +287,7 @@ export function buildFormScreen(formState, viewportHeight, termCols) {
263
287
 
264
288
  for (let i = 0; i < formState.fields.length; i++) {
265
289
  const field = formState.fields[i]
290
+ if (field.hidden) continue
266
291
  const isFocused = i === formState.focusedFieldIndex
267
292
 
268
293
  // Header line
@@ -311,6 +336,7 @@ export function extractValues(formState) {
311
336
  const result = {}
312
337
 
313
338
  for (const field of formState.fields) {
339
+ if (field.hidden) continue
314
340
  const key = fieldKey(field)
315
341
 
316
342
  if (field.type === 'text') {
@@ -339,6 +365,7 @@ export function extractValues(formState) {
339
365
  */
340
366
  function validateForm(formState) {
341
367
  for (const field of formState.fields) {
368
+ if (field.hidden) continue
342
369
  if (!field.required) continue
343
370
 
344
371
  if (field.type === 'text' && field.value.trim() === '') {
@@ -636,20 +663,20 @@ export function handleFormKeypress(formState, key) {
636
663
  return attemptSubmit(formState)
637
664
  }
638
665
 
639
- // ── Tab: next field ───────────────────────────────────────────────────────
666
+ // ── Tab: next field (skip hidden) ──────────────────────────────────────────
640
667
  if (key.name === 'tab' && !key.shift) {
641
668
  return {
642
669
  ...formState,
643
- focusedFieldIndex: (focusedFieldIndex + 1) % fields.length,
670
+ focusedFieldIndex: nextVisibleIndex(fields, focusedFieldIndex, 1),
644
671
  errorMessage: null,
645
672
  }
646
673
  }
647
674
 
648
- // ── Shift+Tab: previous field ─────────────────────────────────────────────
675
+ // ── Shift+Tab: previous field (skip hidden) ──────────────────────────────
649
676
  if (key.name === 'tab' && key.shift) {
650
677
  return {
651
678
  ...formState,
652
- focusedFieldIndex: (focusedFieldIndex - 1 + fields.length) % fields.length,
679
+ focusedFieldIndex: nextVisibleIndex(fields, focusedFieldIndex, -1),
653
680
  errorMessage: null,
654
681
  }
655
682
  }
@@ -680,9 +707,15 @@ export function handleFormKeypress(formState, key) {
680
707
  if (focusedField.type === 'selector') {
681
708
  const updated = handleSelectorFieldKey(focusedField, key)
682
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
+ }
683
716
  return {
684
717
  ...formState,
685
- fields: replaceAt(fields, focusedFieldIndex, updated),
718
+ fields: newFields,
686
719
  }
687
720
  }
688
721
 
@@ -691,7 +724,7 @@ export function handleFormKeypress(formState, key) {
691
724
  if ('advanceField' in result) {
692
725
  return {
693
726
  ...formState,
694
- focusedFieldIndex: Math.min(focusedFieldIndex + 1, lastFieldIndex),
727
+ focusedFieldIndex: nextVisibleIndex(fields, focusedFieldIndex, 1),
695
728
  }
696
729
  }
697
730
  if (result === focusedField) return formState
@@ -704,11 +737,10 @@ export function handleFormKeypress(formState, key) {
704
737
  if (focusedField.type === 'editor') {
705
738
  const result = handleEditorFieldKey(focusedField, key)
706
739
  if ('advanceField' in result) {
707
- // Esc in editor cancels the form only if we treat it as a field-level escape.
708
- // Per spec, Esc in editor moves to next field.
740
+ // Esc in editor moves to next field (skip hidden)
709
741
  return {
710
742
  ...formState,
711
- focusedFieldIndex: Math.min(focusedFieldIndex + 1, lastFieldIndex),
743
+ focusedFieldIndex: nextVisibleIndex(fields, focusedFieldIndex, 1),
712
744
  }
713
745
  }
714
746
  if (result === focusedField) return formState
@@ -735,6 +767,13 @@ function attemptSubmit(formState) {
735
767
  errorMessage: `"${invalidLabel}" is required.`,
736
768
  }
737
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
+ }
738
777
  return {
739
778
  submitted: /** @type {true} */ (true),
740
779
  values: extractValues(formState),
@@ -760,8 +799,11 @@ function replaceAt(arr, index, value) {
760
799
  /**
761
800
  * Return form fields for creating or editing an MCP entry.
762
801
  *
763
- * Fields: name (text), environments (multiselect), transport (selector), command (text),
764
- * args (text), url (text), description (text, optional).
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.
765
807
  *
766
808
  * @param {import('../../types.js').CategoryEntry|null} [entry] - Existing entry to pre-fill from, or null to create
767
809
  * @param {import('../../types.js').DetectedEnvironment[]} [compatibleEnvs] - Environments compatible with this category type
@@ -773,8 +815,10 @@ export function getMCPFormFields(entry = null, compatibleEnvs = []) {
773
815
 
774
816
  const transportOptions = ['stdio', 'sse', 'streamable-http']
775
817
  const transportIndex = p ? Math.max(0, transportOptions.indexOf(p.transport)) : 0
818
+ const transport = transportOptions[transportIndex]
776
819
 
777
- return [
820
+ /** @type {Field[]} */
821
+ const fields = [
778
822
  /** @type {TextField} */ ({
779
823
  type: 'text',
780
824
  label: 'Name',
@@ -810,14 +854,14 @@ export function getMCPFormFields(entry = null, compatibleEnvs = []) {
810
854
  required: false,
811
855
  placeholder: 'npx my-mcp-server',
812
856
  }),
813
- /** @type {TextField} */ ({
814
- type: 'text',
857
+ /** @type {MiniEditorField} */ ({
858
+ type: 'editor',
815
859
  label: 'Args',
816
860
  key: 'args',
817
- value: p?.args ? p.args.join(' ') : '',
818
- cursor: p?.args ? p.args.join(' ').length : 0,
861
+ lines: p?.args?.length > 0 ? [...p.args] : [''],
862
+ cursorLine: 0,
863
+ cursorCol: 0,
819
864
  required: false,
820
- placeholder: '--port 3000 --verbose',
821
865
  }),
822
866
  /** @type {TextField} */ ({
823
867
  type: 'text',
@@ -828,6 +872,15 @@ export function getMCPFormFields(entry = null, compatibleEnvs = []) {
828
872
  required: false,
829
873
  placeholder: 'https://mcp.example.com',
830
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
+ }),
831
884
  /** @type {TextField} */ ({
832
885
  type: 'text',
833
886
  label: 'Description',
@@ -838,6 +891,67 @@ export function getMCPFormFields(entry = null, compatibleEnvs = []) {
838
891
  placeholder: 'Optional description',
839
892
  }),
840
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
841
955
  }
842
956
 
843
957
  /**
@@ -950,6 +1064,70 @@ export function getSkillFormFields(entry = null, compatibleEnvs = []) {
950
1064
  ]
951
1065
  }
952
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
+
953
1131
  /**
954
1132
  * Return form fields for creating or editing an Agent entry.
955
1133
  *