march-control-cli 0.1.3

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 (53) hide show
  1. package/README.md +220 -0
  2. package/core/apply.js +152 -0
  3. package/core/backup.js +53 -0
  4. package/core/constants.js +55 -0
  5. package/core/desktop-service.js +219 -0
  6. package/core/desktop-state.js +511 -0
  7. package/core/index.js +1293 -0
  8. package/core/paths.js +71 -0
  9. package/core/presets.js +171 -0
  10. package/core/probe.js +70 -0
  11. package/core/store.js +218 -0
  12. package/core/utils.js +178 -0
  13. package/core/writers/codex.js +102 -0
  14. package/core/writers/index.js +16 -0
  15. package/core/writers/openclaw.js +93 -0
  16. package/core/writers/opencode.js +91 -0
  17. package/desktop/assets/march-mark.svg +21 -0
  18. package/desktop/main.js +192 -0
  19. package/desktop/preload.js +49 -0
  20. package/desktop/renderer/app.js +327 -0
  21. package/desktop/renderer/index.html +130 -0
  22. package/desktop/renderer/styles.css +413 -0
  23. package/package.json +106 -0
  24. package/scripts/desktop-dev.mjs +90 -0
  25. package/scripts/postinstall.mjs +28 -0
  26. package/scripts/serve-site.mjs +51 -0
  27. package/site/app.js +10 -0
  28. package/site/assets/march-mark.svg +22 -0
  29. package/site/index.html +286 -0
  30. package/site/styles.css +566 -0
  31. package/src/App.tsx +1186 -0
  32. package/src/components/layout/app-sidebar.tsx +103 -0
  33. package/src/components/layout/top-toolbar.tsx +44 -0
  34. package/src/components/layout/workspace-tabs.tsx +32 -0
  35. package/src/components/providers/inspector-panel.tsx +84 -0
  36. package/src/components/providers/metric-strip.tsx +26 -0
  37. package/src/components/providers/provider-editor.tsx +87 -0
  38. package/src/components/providers/provider-table.tsx +85 -0
  39. package/src/components/ui/logo-mark.tsx +16 -0
  40. package/src/features/mcp/mcp-view.tsx +45 -0
  41. package/src/features/prompts/prompts-view.tsx +40 -0
  42. package/src/features/providers/providers-view.tsx +40 -0
  43. package/src/features/providers/types.ts +8 -0
  44. package/src/features/skills/skills-view.tsx +44 -0
  45. package/src/hooks/use-control-workspace.ts +184 -0
  46. package/src/index.css +22 -0
  47. package/src/lib/client.ts +944 -0
  48. package/src/lib/query-client.ts +3 -0
  49. package/src/lib/workspace-sections.ts +34 -0
  50. package/src/main.tsx +14 -0
  51. package/src/types.ts +76 -0
  52. package/src/vite-env.d.ts +56 -0
  53. package/src-tauri/README.md +11 -0
package/core/paths.js ADDED
@@ -0,0 +1,71 @@
1
+ import os from "node:os";
2
+ import path from "node:path";
3
+
4
+ export function getHomeDir() {
5
+ return process.env.MARCH_HOME ? path.resolve(process.env.MARCH_HOME) : os.homedir();
6
+ }
7
+
8
+ export function getMarchDir() {
9
+ return path.join(getHomeDir(), ".march");
10
+ }
11
+
12
+ export function getMarchStorePath(platform) {
13
+ return path.join(getMarchDir(), `${platform}.json`);
14
+ }
15
+
16
+ export function getMarchDesktopStatePath() {
17
+ return path.join(getMarchDir(), "desktop-state.json");
18
+ }
19
+
20
+ export function getMarchPresetPath() {
21
+ return path.join(getMarchDir(), "presets.json");
22
+ }
23
+
24
+ export function getBackupDirRoot() {
25
+ return path.join(getMarchDir(), "backups");
26
+ }
27
+
28
+ export function getCodexDir() {
29
+ return path.join(getHomeDir(), ".codex");
30
+ }
31
+
32
+ export function getCodexConfigPath() {
33
+ return path.join(getCodexDir(), "config.toml");
34
+ }
35
+
36
+ export function getCodexAuthPath() {
37
+ return path.join(getCodexDir(), "auth.json");
38
+ }
39
+
40
+ export function getOpenCodeDir() {
41
+ return path.join(getHomeDir(), ".config", "opencode");
42
+ }
43
+
44
+ export function getOpenCodeConfigPath() {
45
+ return path.join(getOpenCodeDir(), "opencode.json");
46
+ }
47
+
48
+ export function getOpenClawDir() {
49
+ return path.join(getHomeDir(), ".openclaw");
50
+ }
51
+
52
+ export function getOpenClawConfigPath() {
53
+ return path.join(getOpenClawDir(), "openclaw.json");
54
+ }
55
+
56
+ export function getOpenClawModelsPath() {
57
+ return path.join(getOpenClawDir(), "agents", "main", "agent", "models.json");
58
+ }
59
+
60
+ export function getPlatformTargetFiles(platform) {
61
+ switch (platform) {
62
+ case "codex":
63
+ return [getMarchStorePath(platform), getCodexConfigPath(), getCodexAuthPath()];
64
+ case "opencode":
65
+ return [getMarchStorePath(platform), getOpenCodeConfigPath()];
66
+ case "openclaw":
67
+ return [getMarchStorePath(platform), getOpenClawConfigPath(), getOpenClawModelsPath()];
68
+ default:
69
+ throw new Error(`Unsupported platform: ${platform}`);
70
+ }
71
+ }
@@ -0,0 +1,171 @@
1
+ import { BUILTIN_PRESETS, DEFAULT_PRIMARY_MODEL } from "./constants.js";
2
+ import { getMarchDir, getMarchPresetPath } from "./paths.js";
3
+ import { buildOpenClawBaseUrl, ensureDir, normalizeBaseUrl, readJson, writeJson } from "./utils.js";
4
+
5
+ function createEmptyPresetStore() {
6
+ return {
7
+ presets: []
8
+ };
9
+ }
10
+
11
+ function toNormalizedPreset(input, fallback = {}) {
12
+ const name = `${input?.name || fallback?.name || ""}`.trim();
13
+ if (!name) {
14
+ throw new Error("Preset name cannot be empty");
15
+ }
16
+
17
+ const providerName = `${input?.providerName || fallback?.providerName || name}`.trim() || name;
18
+ const commonBaseUrl = normalizeBaseUrl(
19
+ `${input?.commonBaseUrl || input?.baseUrl || fallback?.commonBaseUrl || fallback?.baseUrl || ""}`.trim()
20
+ );
21
+ if (!commonBaseUrl) {
22
+ throw new Error("Preset base URL cannot be empty");
23
+ }
24
+
25
+ const openclawBaseUrl = normalizeBaseUrl(
26
+ `${input?.openclawBaseUrl || fallback?.openclawBaseUrl || buildOpenClawBaseUrl(commonBaseUrl)}`.trim()
27
+ );
28
+
29
+ return {
30
+ name,
31
+ providerName,
32
+ commonBaseUrl,
33
+ openclawBaseUrl,
34
+ model: `${input?.model || fallback?.model || DEFAULT_PRIMARY_MODEL}`.trim() || DEFAULT_PRIMARY_MODEL
35
+ };
36
+ }
37
+
38
+ function normalizeStoreEntry(entry) {
39
+ const preset = toNormalizedPreset(entry, entry);
40
+ return {
41
+ ...preset,
42
+ createdAt: entry?.createdAt || null,
43
+ updatedAt: entry?.updatedAt || null
44
+ };
45
+ }
46
+
47
+ function loadCustomPresetStore() {
48
+ const raw = readJson(getMarchPresetPath(), createEmptyPresetStore());
49
+ return {
50
+ presets: Array.isArray(raw?.presets) ? raw.presets.map((item) => normalizeStoreEntry(item)) : []
51
+ };
52
+ }
53
+
54
+ function saveCustomPresetStore(store) {
55
+ ensureDir(getMarchDir());
56
+ writeJson(getMarchPresetPath(), {
57
+ presets: Array.isArray(store.presets) ? store.presets : []
58
+ });
59
+ }
60
+
61
+ function listBuiltinPresets() {
62
+ return BUILTIN_PRESETS.map((preset) => ({
63
+ ...toNormalizedPreset(preset, preset),
64
+ source: "builtin",
65
+ readonly: true,
66
+ createdAt: null,
67
+ updatedAt: null
68
+ }));
69
+ }
70
+
71
+ function listCustomPresets() {
72
+ return loadCustomPresetStore().presets.map((preset) => ({
73
+ ...preset,
74
+ source: "custom",
75
+ readonly: false
76
+ }));
77
+ }
78
+
79
+ function nameKey(name) {
80
+ return `${name || ""}`.trim().toLowerCase();
81
+ }
82
+
83
+ export function listPresets() {
84
+ const merged = [...listBuiltinPresets(), ...listCustomPresets()];
85
+ const seen = new Set();
86
+ const result = [];
87
+
88
+ // custom preset with same name overrides builtin view
89
+ for (const preset of [...merged].reverse()) {
90
+ const key = nameKey(preset.name);
91
+ if (!key || seen.has(key)) {
92
+ continue;
93
+ }
94
+ seen.add(key);
95
+ result.push(preset);
96
+ }
97
+
98
+ return result.reverse();
99
+ }
100
+
101
+ export function getPreset(name) {
102
+ const key = nameKey(name);
103
+ if (!key) {
104
+ return null;
105
+ }
106
+
107
+ return listPresets().find((preset) => nameKey(preset.name) === key) || null;
108
+ }
109
+
110
+ export function upsertPreset(input) {
111
+ const normalized = toNormalizedPreset(input, input);
112
+ const store = loadCustomPresetStore();
113
+ const targetKey = nameKey(normalized.name);
114
+ const existing = store.presets.find((preset) => nameKey(preset.name) === targetKey);
115
+ const now = new Date().toISOString();
116
+
117
+ if (existing) {
118
+ const next = {
119
+ ...existing,
120
+ ...normalized,
121
+ updatedAt: now
122
+ };
123
+ store.presets = store.presets.map((preset) =>
124
+ nameKey(preset.name) === targetKey ? next : preset
125
+ );
126
+ saveCustomPresetStore(store);
127
+ return {
128
+ ...next,
129
+ source: "custom",
130
+ readonly: false
131
+ };
132
+ }
133
+
134
+ const created = {
135
+ ...normalized,
136
+ createdAt: now,
137
+ updatedAt: now
138
+ };
139
+ store.presets.push(created);
140
+ saveCustomPresetStore(store);
141
+ return {
142
+ ...created,
143
+ source: "custom",
144
+ readonly: false
145
+ };
146
+ }
147
+
148
+ export function removePreset(name) {
149
+ const key = nameKey(name);
150
+ if (!key) {
151
+ throw new Error("Preset name cannot be empty");
152
+ }
153
+
154
+ const store = loadCustomPresetStore();
155
+ const existing = store.presets.find((preset) => nameKey(preset.name) === key);
156
+ if (!existing) {
157
+ const builtin = listBuiltinPresets().find((preset) => nameKey(preset.name) === key);
158
+ if (builtin) {
159
+ throw new Error("Built-in preset is read-only");
160
+ }
161
+ throw new Error(`Preset not found: ${name}`);
162
+ }
163
+
164
+ store.presets = store.presets.filter((preset) => nameKey(preset.name) !== key);
165
+ saveCustomPresetStore(store);
166
+ return {
167
+ ...existing,
168
+ source: "custom",
169
+ readonly: false
170
+ };
171
+ }
package/core/probe.js ADDED
@@ -0,0 +1,70 @@
1
+ import { dedupeStrings, normalizeBaseUrl } from "./utils.js";
2
+
3
+ async function fetchWithTimeout(url, timeoutMs) {
4
+ const controller = new AbortController();
5
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
6
+ const startedAt = Date.now();
7
+
8
+ try {
9
+ const response = await fetch(url, {
10
+ method: "GET",
11
+ headers: {
12
+ Accept: "application/json"
13
+ },
14
+ signal: controller.signal
15
+ });
16
+
17
+ return {
18
+ ok: true,
19
+ url,
20
+ latency: Date.now() - startedAt,
21
+ status: response.status
22
+ };
23
+ } catch (error) {
24
+ return {
25
+ ok: false,
26
+ url,
27
+ latency: Date.now() - startedAt,
28
+ error: error.name === "AbortError" ? "timeout" : error.message
29
+ };
30
+ } finally {
31
+ clearTimeout(timer);
32
+ }
33
+ }
34
+
35
+ export async function probeBaseUrls(baseUrls, options = {}) {
36
+ const timeoutMs = options.timeoutMs ?? 5000;
37
+ const normalized = dedupeStrings(baseUrls).map((value) => normalizeBaseUrl(value));
38
+ const results = [];
39
+
40
+ for (const baseUrl of normalized) {
41
+ const modelUrl = `${baseUrl}/models`;
42
+ const modelResult = await fetchWithTimeout(modelUrl, timeoutMs);
43
+
44
+ if (modelResult.ok) {
45
+ results.push({
46
+ ...modelResult,
47
+ baseUrl
48
+ });
49
+ continue;
50
+ }
51
+
52
+ const fallbackResult = await fetchWithTimeout(baseUrl, timeoutMs);
53
+ results.push({
54
+ ...fallbackResult,
55
+ baseUrl
56
+ });
57
+ }
58
+
59
+ return results.sort((left, right) => {
60
+ if (left.ok !== right.ok) {
61
+ return left.ok ? -1 : 1;
62
+ }
63
+
64
+ return left.latency - right.latency;
65
+ });
66
+ }
67
+
68
+ export function getBestProbeResult(results) {
69
+ return results.find((result) => result.ok) || results[0] || null;
70
+ }
package/core/store.js ADDED
@@ -0,0 +1,218 @@
1
+ import { DEFAULT_PRIMARY_MODEL } from "./constants.js";
2
+ import { getMarchDir, getMarchStorePath } from "./paths.js";
3
+ import {
4
+ ensureDir,
5
+ findByName,
6
+ findByNameOrId,
7
+ generateId,
8
+ readJson,
9
+ writeJson
10
+ } from "./utils.js";
11
+
12
+ function createEmptyStore() {
13
+ return {
14
+ providers: [],
15
+ currentProviderId: null
16
+ };
17
+ }
18
+
19
+ export function loadPlatformStore(platform) {
20
+ const storePath = getMarchStorePath(platform);
21
+ const loaded = readJson(storePath, createEmptyStore());
22
+
23
+ return {
24
+ providers: Array.isArray(loaded?.providers) ? loaded.providers : [],
25
+ currentProviderId: typeof loaded?.currentProviderId === "string" ? loaded.currentProviderId : null
26
+ };
27
+ }
28
+
29
+ export function savePlatformStore(platform, store) {
30
+ ensureDir(getMarchDir());
31
+ writeJson(getMarchStorePath(platform), {
32
+ providers: Array.isArray(store.providers) ? store.providers : [],
33
+ currentProviderId: store.currentProviderId || null
34
+ });
35
+ }
36
+
37
+ export function listProviders(platform) {
38
+ return loadPlatformStore(platform).providers;
39
+ }
40
+
41
+ export function getCurrentProvider(platform) {
42
+ const store = loadPlatformStore(platform);
43
+ return store.providers.find((provider) => provider.id === store.currentProviderId) || null;
44
+ }
45
+
46
+ function ensureUniqueProviderName(providers, name, exceptId = null) {
47
+ const target = name.trim().toLowerCase();
48
+ const conflict = providers.find(
49
+ (provider) =>
50
+ provider.id !== exceptId && provider.name.trim().toLowerCase() === target
51
+ );
52
+
53
+ if (conflict) {
54
+ throw new Error(`Provider name already exists: ${name}`);
55
+ }
56
+ }
57
+
58
+ export function upsertProvider(platform, input, options = {}) {
59
+ const store = loadPlatformStore(platform);
60
+ const name = input.name.trim();
61
+ const now = new Date().toISOString();
62
+ const existing = findByName(store.providers, name);
63
+
64
+ let provider;
65
+
66
+ if (existing) {
67
+ provider = {
68
+ ...existing,
69
+ baseUrl: input.baseUrl,
70
+ apiKey: input.apiKey,
71
+ model: input.model || existing.model || DEFAULT_PRIMARY_MODEL,
72
+ updatedAt: now
73
+ };
74
+ store.providers = store.providers.map((item) => (item.id === provider.id ? provider : item));
75
+ } else {
76
+ provider = {
77
+ id: generateId(platform),
78
+ name,
79
+ baseUrl: input.baseUrl,
80
+ apiKey: input.apiKey,
81
+ model: input.model || DEFAULT_PRIMARY_MODEL,
82
+ createdAt: now,
83
+ updatedAt: now
84
+ };
85
+ store.providers.push(provider);
86
+ }
87
+
88
+ if (options.activate !== false) {
89
+ store.currentProviderId = provider.id;
90
+ }
91
+
92
+ savePlatformStore(platform, store);
93
+ return provider;
94
+ }
95
+
96
+ export function updateProvider(platform, nameOrId, updates, options = {}) {
97
+ const store = loadPlatformStore(platform);
98
+ const current = findByNameOrId(store.providers, nameOrId);
99
+
100
+ if (!current) {
101
+ throw new Error(`Provider not found: ${nameOrId}`);
102
+ }
103
+
104
+ const nextName = `${updates?.name ?? current.name}`.trim();
105
+ if (!nextName) {
106
+ throw new Error("Provider name cannot be empty");
107
+ }
108
+
109
+ ensureUniqueProviderName(store.providers, nextName, current.id);
110
+
111
+ const nextProvider = {
112
+ ...current,
113
+ name: nextName,
114
+ baseUrl: `${updates?.baseUrl ?? current.baseUrl}`.trim() || current.baseUrl,
115
+ apiKey: `${updates?.apiKey ?? current.apiKey}`.trim() || current.apiKey,
116
+ model: `${updates?.model ?? current.model ?? DEFAULT_PRIMARY_MODEL}`.trim() || DEFAULT_PRIMARY_MODEL,
117
+ updatedAt: new Date().toISOString()
118
+ };
119
+
120
+ store.providers = store.providers.map((provider) => (provider.id === current.id ? nextProvider : provider));
121
+
122
+ if (options.activate === true) {
123
+ store.currentProviderId = nextProvider.id;
124
+ }
125
+
126
+ savePlatformStore(platform, store);
127
+ return nextProvider;
128
+ }
129
+
130
+ export function cloneProvider(platform, nameOrId, input, options = {}) {
131
+ const store = loadPlatformStore(platform);
132
+ const source = findByNameOrId(store.providers, nameOrId);
133
+
134
+ if (!source) {
135
+ throw new Error(`Provider not found: ${nameOrId}`);
136
+ }
137
+
138
+ const nextName = `${input?.name || ""}`.trim();
139
+ if (!nextName) {
140
+ throw new Error("Clone name cannot be empty");
141
+ }
142
+
143
+ ensureUniqueProviderName(store.providers, nextName);
144
+ const now = new Date().toISOString();
145
+ const cloned = {
146
+ ...source,
147
+ id: generateId(platform),
148
+ name: nextName,
149
+ baseUrl: `${input?.baseUrl ?? source.baseUrl}`.trim() || source.baseUrl,
150
+ apiKey: `${input?.apiKey ?? source.apiKey}`.trim() || source.apiKey,
151
+ model: `${input?.model ?? source.model ?? DEFAULT_PRIMARY_MODEL}`.trim() || DEFAULT_PRIMARY_MODEL,
152
+ clonedFrom: source.id,
153
+ createdAt: now,
154
+ updatedAt: now
155
+ };
156
+
157
+ store.providers.push(cloned);
158
+ if (options.activate === true) {
159
+ store.currentProviderId = cloned.id;
160
+ }
161
+
162
+ savePlatformStore(platform, store);
163
+ return cloned;
164
+ }
165
+
166
+ export function removeProvider(platform, nameOrId, options = {}) {
167
+ const store = loadPlatformStore(platform);
168
+ const target = findByNameOrId(store.providers, nameOrId);
169
+
170
+ if (!target) {
171
+ throw new Error(`Provider not found: ${nameOrId}`);
172
+ }
173
+
174
+ store.providers = store.providers.filter((provider) => provider.id !== target.id);
175
+ let nextCurrent = null;
176
+
177
+ if (store.currentProviderId === target.id) {
178
+ if (options.activateFallback === false || store.providers.length === 0) {
179
+ store.currentProviderId = null;
180
+ } else {
181
+ nextCurrent = store.providers[store.providers.length - 1];
182
+ store.currentProviderId = nextCurrent.id;
183
+ }
184
+ } else {
185
+ nextCurrent = store.providers.find((provider) => provider.id === store.currentProviderId) || null;
186
+ }
187
+
188
+ savePlatformStore(platform, store);
189
+
190
+ return {
191
+ removedProvider: target,
192
+ currentProvider: nextCurrent
193
+ };
194
+ }
195
+
196
+ export function switchProvider(platform, nameOrId) {
197
+ const store = loadPlatformStore(platform);
198
+ const provider = findByNameOrId(store.providers, nameOrId);
199
+
200
+ if (!provider) {
201
+ throw new Error(`Provider not found: ${nameOrId}`);
202
+ }
203
+
204
+ store.currentProviderId = provider.id;
205
+ savePlatformStore(platform, store);
206
+ return provider;
207
+ }
208
+
209
+ export function resolveStoredProvider(platform, nameOrId) {
210
+ const store = loadPlatformStore(platform);
211
+ const provider = findByNameOrId(store.providers, nameOrId);
212
+
213
+ if (!provider) {
214
+ throw new Error(`Provider not found: ${nameOrId}`);
215
+ }
216
+
217
+ return provider;
218
+ }
package/core/utils.js ADDED
@@ -0,0 +1,178 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+
4
+ export function isRecord(value) {
5
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
6
+ }
7
+
8
+ export function ensureDir(dirPath) {
9
+ fs.mkdirSync(dirPath, { recursive: true, mode: 0o700 });
10
+ }
11
+
12
+ export function readJson(filePath, fallback = null) {
13
+ if (!fs.existsSync(filePath)) {
14
+ return fallback;
15
+ }
16
+
17
+ try {
18
+ return JSON.parse(fs.readFileSync(filePath, "utf8"));
19
+ } catch {
20
+ return fallback;
21
+ }
22
+ }
23
+
24
+ export function writeJson(filePath, value) {
25
+ ensureDir(path.dirname(filePath));
26
+ fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
27
+ }
28
+
29
+ export function readText(filePath, fallback = "") {
30
+ if (!fs.existsSync(filePath)) {
31
+ return fallback;
32
+ }
33
+
34
+ try {
35
+ return fs.readFileSync(filePath, "utf8");
36
+ } catch {
37
+ return fallback;
38
+ }
39
+ }
40
+
41
+ export function writeText(filePath, value) {
42
+ ensureDir(path.dirname(filePath));
43
+ fs.writeFileSync(filePath, value, { mode: 0o600 });
44
+ }
45
+
46
+ export function deepMerge(base, patch) {
47
+ if (!isRecord(base)) {
48
+ return cloneValue(patch);
49
+ }
50
+
51
+ if (!isRecord(patch)) {
52
+ return cloneValue(base);
53
+ }
54
+
55
+ const result = { ...base };
56
+
57
+ for (const [key, value] of Object.entries(patch)) {
58
+ if (isRecord(value) && isRecord(result[key])) {
59
+ result[key] = deepMerge(result[key], value);
60
+ continue;
61
+ }
62
+
63
+ result[key] = cloneValue(value);
64
+ }
65
+
66
+ return result;
67
+ }
68
+
69
+ function cloneValue(value) {
70
+ if (Array.isArray(value)) {
71
+ return value.map((item) => cloneValue(item));
72
+ }
73
+
74
+ if (isRecord(value)) {
75
+ return Object.fromEntries(
76
+ Object.entries(value).map(([key, nested]) => [key, cloneValue(nested)])
77
+ );
78
+ }
79
+
80
+ return value;
81
+ }
82
+
83
+ export function normalizeBaseUrl(url) {
84
+ return url.trim().replace(/\/+$/, "");
85
+ }
86
+
87
+ export function buildOpenClawBaseUrl(baseUrl) {
88
+ return `${normalizeBaseUrl(baseUrl)}/v1`;
89
+ }
90
+
91
+ export function dedupeStrings(values) {
92
+ const seen = new Set();
93
+ const result = [];
94
+
95
+ for (const rawValue of values) {
96
+ const value = typeof rawValue === "string" ? rawValue.trim() : "";
97
+ if (!value) {
98
+ continue;
99
+ }
100
+
101
+ const key = value.toLowerCase();
102
+ if (seen.has(key)) {
103
+ continue;
104
+ }
105
+
106
+ seen.add(key);
107
+ result.push(value);
108
+ }
109
+
110
+ return result;
111
+ }
112
+
113
+ export function parseCommaList(value) {
114
+ if (!value) {
115
+ return [];
116
+ }
117
+
118
+ return dedupeStrings(value.split(",").map((item) => item.trim()).filter(Boolean));
119
+ }
120
+
121
+ export function generateId(prefix) {
122
+ return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
123
+ }
124
+
125
+ export function findByName(items, name) {
126
+ const target = name.trim().toLowerCase();
127
+ return items.find((item) => item.name.trim().toLowerCase() === target);
128
+ }
129
+
130
+ export function findByNameOrId(items, value) {
131
+ const trimmed = value.trim();
132
+ const lowered = trimmed.toLowerCase();
133
+ return items.find((item) => item.id === trimmed || item.name.trim().toLowerCase() === lowered);
134
+ }
135
+
136
+ export function replaceCaseInsensitiveKey(record, nextKey, nextValue) {
137
+ const result = isRecord(record) ? { ...record } : {};
138
+ const target = nextKey.toLowerCase();
139
+
140
+ for (const key of Object.keys(result)) {
141
+ if (key.toLowerCase() === target) {
142
+ delete result[key];
143
+ }
144
+ }
145
+
146
+ result[nextKey] = nextValue;
147
+ return result;
148
+ }
149
+
150
+ export function providerKeyFromName(name) {
151
+ const normalized = name
152
+ .trim()
153
+ .toLowerCase()
154
+ .replace(/[^a-z0-9]+/g, "-")
155
+ .replace(/^-+|-+$/g, "");
156
+
157
+ return normalized || "march";
158
+ }
159
+
160
+ export function maskApiKey(apiKey) {
161
+ if (!apiKey) {
162
+ return "(empty)";
163
+ }
164
+
165
+ if (apiKey.length <= 10) {
166
+ return `${apiKey.slice(0, 2)}***${apiKey.slice(-2)}`;
167
+ }
168
+
169
+ return `${apiKey.slice(0, 4)}***${apiKey.slice(-4)}`;
170
+ }
171
+
172
+ export function formatLatency(result) {
173
+ if (!result.ok) {
174
+ return result.error || "failed";
175
+ }
176
+
177
+ return `${result.latency} ms`;
178
+ }