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.
- package/README.md +220 -0
- package/core/apply.js +152 -0
- package/core/backup.js +53 -0
- package/core/constants.js +55 -0
- package/core/desktop-service.js +219 -0
- package/core/desktop-state.js +511 -0
- package/core/index.js +1293 -0
- package/core/paths.js +71 -0
- package/core/presets.js +171 -0
- package/core/probe.js +70 -0
- package/core/store.js +218 -0
- package/core/utils.js +178 -0
- package/core/writers/codex.js +102 -0
- package/core/writers/index.js +16 -0
- package/core/writers/openclaw.js +93 -0
- package/core/writers/opencode.js +91 -0
- package/desktop/assets/march-mark.svg +21 -0
- package/desktop/main.js +192 -0
- package/desktop/preload.js +49 -0
- package/desktop/renderer/app.js +327 -0
- package/desktop/renderer/index.html +130 -0
- package/desktop/renderer/styles.css +413 -0
- package/package.json +106 -0
- package/scripts/desktop-dev.mjs +90 -0
- package/scripts/postinstall.mjs +28 -0
- package/scripts/serve-site.mjs +51 -0
- package/site/app.js +10 -0
- package/site/assets/march-mark.svg +22 -0
- package/site/index.html +286 -0
- package/site/styles.css +566 -0
- package/src/App.tsx +1186 -0
- package/src/components/layout/app-sidebar.tsx +103 -0
- package/src/components/layout/top-toolbar.tsx +44 -0
- package/src/components/layout/workspace-tabs.tsx +32 -0
- package/src/components/providers/inspector-panel.tsx +84 -0
- package/src/components/providers/metric-strip.tsx +26 -0
- package/src/components/providers/provider-editor.tsx +87 -0
- package/src/components/providers/provider-table.tsx +85 -0
- package/src/components/ui/logo-mark.tsx +16 -0
- package/src/features/mcp/mcp-view.tsx +45 -0
- package/src/features/prompts/prompts-view.tsx +40 -0
- package/src/features/providers/providers-view.tsx +40 -0
- package/src/features/providers/types.ts +8 -0
- package/src/features/skills/skills-view.tsx +44 -0
- package/src/hooks/use-control-workspace.ts +184 -0
- package/src/index.css +22 -0
- package/src/lib/client.ts +944 -0
- package/src/lib/query-client.ts +3 -0
- package/src/lib/workspace-sections.ts +34 -0
- package/src/main.tsx +14 -0
- package/src/types.ts +76 -0
- package/src/vite-env.d.ts +56 -0
- 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
|
+
}
|
package/core/presets.js
ADDED
|
@@ -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
|
+
}
|