dxai-cli 1.0.0
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/LICENSE +21 -0
- package/README.md +172 -0
- package/bin/cli.js +272 -0
- package/package.json +64 -0
- package/src/auto-update.js +106 -0
- package/src/branding.js +80 -0
- package/src/cleanup.js +615 -0
- package/src/config-remover.js +325 -0
- package/src/config-writer.js +781 -0
- package/src/detect-project.js +316 -0
- package/src/detect.js +587 -0
- package/src/fs-atomic.js +35 -0
- package/src/handshake.js +123 -0
- package/src/index.js +966 -0
- package/src/inspect.js +283 -0
- package/src/manifest.js +179 -0
- package/src/mcp-cmd.js +282 -0
- package/src/net.js +72 -0
- package/src/profile.js +139 -0
- package/src/registry/automation-tools.js +6 -0
- package/src/registry/data/automation-tools.json +37 -0
- package/src/registry/data/mcp-servers.json +451 -0
- package/src/registry/data/skills.json +132 -0
- package/src/registry/loader.js +102 -0
- package/src/registry/mcp-registry.js +292 -0
- package/src/registry/mcp-servers.js +72 -0
- package/src/registry/skills.js +10 -0
- package/src/registry/stacks.js +769 -0
- package/src/registry/validate.js +209 -0
- package/src/rollback.js +182 -0
- package/src/runtime.js +40 -0
- package/src/select.js +72 -0
- package/src/update.js +126 -0
package/src/mcp-cmd.js
ADDED
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
// Fast-path MCP commands: `dxai add <id...>` and `dxai remove <id...>`.
|
|
2
|
+
// No wizard — resolve target agents (from --agents or detection), validate the
|
|
3
|
+
// server IDs, and write/remove directly. Honours --project, --dry-run, --json,
|
|
4
|
+
// and --yes, matching the setup flow's conventions.
|
|
5
|
+
//
|
|
6
|
+
// `add` also accepts official MCP Registry names (`io.github.owner/server`):
|
|
7
|
+
// the record is resolved live and becomes a catalogue entry for this run.
|
|
8
|
+
|
|
9
|
+
import os from 'os';
|
|
10
|
+
import path from 'path';
|
|
11
|
+
|
|
12
|
+
import {
|
|
13
|
+
printBanner, sectionHeader, successMsg, warnMsg, infoMsg, theme, reportMcpResults,
|
|
14
|
+
} from './branding.js';
|
|
15
|
+
import { detectAgents, AGENT_DEFINITIONS } from './detect.js';
|
|
16
|
+
import { MCP_SERVERS, deriveConfigs } from './registry/mcp-servers.js';
|
|
17
|
+
import { fetchRegistryServer, pickTransport, slugForRegistryName } from './registry/mcp-registry.js';
|
|
18
|
+
import { isValidRegistryName, validateRegistryPayload } from './registry/validate.js';
|
|
19
|
+
import {
|
|
20
|
+
writeMcpConfigs, writeProjectMcpConfigs, previewMcpConfigs,
|
|
21
|
+
} from './config-writer.js';
|
|
22
|
+
import {
|
|
23
|
+
scanJsonMcpConfig, removeJsonMcpServers,
|
|
24
|
+
scanTomlMcpConfig, removeTomlMcpServers,
|
|
25
|
+
scanClaudeCodeMcpServers, removeClaudeCodeMcpServers,
|
|
26
|
+
} from './config-remover.js';
|
|
27
|
+
import {
|
|
28
|
+
recordSystemMcp, recordProjectMcp, unrecordSystemMcp, unrecordProjectMcp,
|
|
29
|
+
} from './manifest.js';
|
|
30
|
+
import { normalizeOptions, partitionByKnown } from './runtime.js';
|
|
31
|
+
import { collectMcpInputs } from './index.js';
|
|
32
|
+
|
|
33
|
+
// Validate a list of IDs against a registry; throw a "Known: ..." hint on any miss.
|
|
34
|
+
function requireKnown(ids, knownIds, label) {
|
|
35
|
+
const { valid, invalid } = partitionByKnown(ids, knownIds);
|
|
36
|
+
if (invalid.length) {
|
|
37
|
+
throw new Error(`Unknown ${label}: ${invalid.join(', ')}. Known: ${knownIds.join(', ')}`);
|
|
38
|
+
}
|
|
39
|
+
if (valid.length === 0) {
|
|
40
|
+
throw new Error(`No ${label} given.`);
|
|
41
|
+
}
|
|
42
|
+
return valid;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const KNOWN_MCP_IDS = MCP_SERVERS.map((s) => s.id);
|
|
46
|
+
|
|
47
|
+
// Turn a live registry record into a catalogue entry for this invocation only.
|
|
48
|
+
// Validated like fetched catalogue data before its configs are derived.
|
|
49
|
+
function entryFromRegistry(name, record) {
|
|
50
|
+
const r = pickTransport(record);
|
|
51
|
+
if (!r.transport) throw new Error(`${name}: no usable transport (${r.warnings.join('; ')})`);
|
|
52
|
+
const entry = {
|
|
53
|
+
id: slugForRegistryName(name),
|
|
54
|
+
name: r.title || slugForRegistryName(name),
|
|
55
|
+
description: r.description || '',
|
|
56
|
+
category: 'registry',
|
|
57
|
+
registry: { name, resolved: { version: r.registryVersion, at: new Date().toISOString(), fields: ['transport'] } },
|
|
58
|
+
transport: r.transport,
|
|
59
|
+
};
|
|
60
|
+
if (Object.keys(r.requiresEnv).length) entry.requiresEnv = r.requiresEnv;
|
|
61
|
+
if (Object.keys(r.requiresInput).length) entry.requiresInput = r.requiresInput;
|
|
62
|
+
if (r.stale) Object.assign(entry, { stale: true, staleReason: r.staleReason });
|
|
63
|
+
const problems = validateRegistryPayload('servers', [entry]);
|
|
64
|
+
if (problems.length) throw new Error(`${name}: ${problems.join('; ')}`);
|
|
65
|
+
return { ...entry, configs: deriveConfigs(entry) };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Resolve the requested servers to catalogue ids plus the server list to use.
|
|
69
|
+
// Catalogue ids pass through. A registry name maps to the catalogue entry that
|
|
70
|
+
// links to it, or is looked up live and appended as a synthetic entry. Anything
|
|
71
|
+
// else is unknown.
|
|
72
|
+
async function resolveServerList(requested) {
|
|
73
|
+
const servers = [...MCP_SERVERS];
|
|
74
|
+
const ids = [];
|
|
75
|
+
const unknown = [];
|
|
76
|
+
const live = {};
|
|
77
|
+
for (const raw of requested) {
|
|
78
|
+
if (KNOWN_MCP_IDS.includes(raw)) { ids.push(raw); continue; }
|
|
79
|
+
if (!isValidRegistryName(raw)) { unknown.push(raw); continue; }
|
|
80
|
+
const linked = MCP_SERVERS.find((s) => s.registry?.name === raw);
|
|
81
|
+
if (linked) { ids.push(linked.id); continue; }
|
|
82
|
+
const slug = slugForRegistryName(raw);
|
|
83
|
+
if (KNOWN_MCP_IDS.includes(slug)) {
|
|
84
|
+
throw new Error(`${raw} would use the id "${slug}", which already names a different catalogue server. Use the catalogue id instead.`);
|
|
85
|
+
}
|
|
86
|
+
if (live[slug]) { ids.push(slug); continue; }
|
|
87
|
+
const record = await fetchRegistryServer(raw);
|
|
88
|
+
if (!record) throw new Error(`Not found in the MCP Registry: ${raw}`);
|
|
89
|
+
const entry = entryFromRegistry(raw, record);
|
|
90
|
+
servers.push(entry);
|
|
91
|
+
live[slug] = { registry: raw, requiresEnv: Object.keys(entry.requiresEnv || {}) };
|
|
92
|
+
ids.push(slug);
|
|
93
|
+
}
|
|
94
|
+
if (unknown.length) {
|
|
95
|
+
throw new Error(`Unknown MCP server(s): ${unknown.join(', ')}. Known: ${KNOWN_MCP_IDS.join(', ')} (or an MCP Registry name like io.github.owner/server)`);
|
|
96
|
+
}
|
|
97
|
+
if (ids.length === 0) throw new Error('No MCP server(s) given.');
|
|
98
|
+
return { ids: [...new Set(ids)], servers, live };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Which agents to target: --agents wins (validated); otherwise detected+installed.
|
|
102
|
+
// For --project, keep only agents that support a project-level MCP path.
|
|
103
|
+
function resolveTargetAgents(runtime, home, { project = false } = {}) {
|
|
104
|
+
let agents;
|
|
105
|
+
if (runtime.agents && runtime.agents.length) {
|
|
106
|
+
const known = AGENT_DEFINITIONS.map((a) => a.id);
|
|
107
|
+
const valid = requireKnown(runtime.agents, known, 'agent(s)');
|
|
108
|
+
agents = AGENT_DEFINITIONS.filter((a) => valid.includes(a.id));
|
|
109
|
+
} else {
|
|
110
|
+
agents = detectAgents(home).filter((a) => a.installed);
|
|
111
|
+
}
|
|
112
|
+
if (project) agents = agents.filter((a) => typeof a.projectMcpPath === 'function');
|
|
113
|
+
return agents;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// ── dxai add <id...> ──
|
|
117
|
+
export async function addMcp(serverIds = [], opts = {}) {
|
|
118
|
+
const runtime = normalizeOptions(opts);
|
|
119
|
+
const project = !!opts.project;
|
|
120
|
+
const home = os.homedir();
|
|
121
|
+
|
|
122
|
+
const { ids, servers, live } = await resolveServerList(serverIds);
|
|
123
|
+
const agents = resolveTargetAgents(runtime, home, { project });
|
|
124
|
+
if (agents.length === 0) {
|
|
125
|
+
throw new Error(project
|
|
126
|
+
? `No project-capable agents. Pass --agents with one of: ${AGENT_DEFINITIONS.filter((a) => a.projectMcpPath).map((a) => a.id).join(', ')}.`
|
|
127
|
+
: 'No target agents detected. Pass --agents <ids> or install a supported agent.');
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const inputs = await collectMcpInputs(ids, servers, runtime);
|
|
131
|
+
|
|
132
|
+
if (!runtime.json) {
|
|
133
|
+
printBanner();
|
|
134
|
+
sectionHeader(`Add — ${ids.join(', ')} → ${project ? 'project' : 'system'}`);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (runtime.dryRun && !project) {
|
|
138
|
+
const previews = previewMcpConfigs(agents, ids, servers, inputs);
|
|
139
|
+
if (runtime.json) {
|
|
140
|
+
process.stdout.write(JSON.stringify({ ok: true, dryRun: true, previews }, null, 2) + '\n');
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
for (const p of Object.values(previews)) {
|
|
144
|
+
const tag = p.exists ? theme.dim('(merge)') : theme.dim('(create)');
|
|
145
|
+
console.log(` ${theme.label(p.agent)} ${tag} → ${p.path}`);
|
|
146
|
+
if (p.wouldAdd.length) console.log(` ${theme.success('+ would add:')} ${p.wouldAdd.join(', ')}`);
|
|
147
|
+
if (p.wouldSkip.length) console.log(` ${theme.dim('· already present:')} ${p.wouldSkip.join(', ')}`);
|
|
148
|
+
}
|
|
149
|
+
console.log();
|
|
150
|
+
warnMsg('Dry run — no files were changed.');
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (runtime.dryRun && project) {
|
|
155
|
+
if (runtime.json) {
|
|
156
|
+
process.stdout.write(JSON.stringify({ ok: true, dryRun: true, project: true, wouldAdd: ids, agents: agents.map((a) => a.id) }, null, 2) + '\n');
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
for (const a of agents) infoMsg(`Would add ${ids.join(', ')} to ${a.name} project config (${a.projectMcpPath()})`);
|
|
160
|
+
console.log();
|
|
161
|
+
warnMsg('Dry run — no files were changed.');
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const results = project
|
|
166
|
+
? writeProjectMcpConfigs(agents, ids, servers, inputs)
|
|
167
|
+
: writeMcpConfigs(agents, ids, servers, inputs);
|
|
168
|
+
if (project) recordProjectMcp(results, undefined, live); else recordSystemMcp(results, live);
|
|
169
|
+
|
|
170
|
+
// Per-agent write failures land in results[*].errors — exit non-zero so
|
|
171
|
+
// scripted callers can detect a partial failure.
|
|
172
|
+
const errorCount = Object.values(results).reduce((n, r) => n + (r.errors || []).length, 0);
|
|
173
|
+
if (errorCount > 0) process.exitCode = 1;
|
|
174
|
+
|
|
175
|
+
if (runtime.json) {
|
|
176
|
+
process.stdout.write(JSON.stringify({ ok: errorCount === 0, added: ids, project, results }, null, 2) + '\n');
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
reportMcpResults(results);
|
|
180
|
+
console.log();
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Scan one agent for which of `ids` are actually present in its config.
|
|
184
|
+
function scanPresent(agent, ids, home, project) {
|
|
185
|
+
if (project) {
|
|
186
|
+
const p = path.join(process.cwd(), agent.projectMcpPath());
|
|
187
|
+
const format = agent.projectConfigFormat || agent.configFormat;
|
|
188
|
+
return format === 'toml'
|
|
189
|
+
? { path: p, present: scanTomlMcpConfig(p, ids) }
|
|
190
|
+
: { path: p, present: scanJsonMcpConfig(p, agent.projectMcpKey || agent.mcpKey, ids) };
|
|
191
|
+
}
|
|
192
|
+
switch (agent.configFormat) {
|
|
193
|
+
case 'json': {
|
|
194
|
+
const p = agent.globalMcpPath(home);
|
|
195
|
+
return { path: p, present: scanJsonMcpConfig(p, agent.mcpKey, ids) };
|
|
196
|
+
}
|
|
197
|
+
case 'toml': {
|
|
198
|
+
const p = agent.globalMcpPath(home);
|
|
199
|
+
return { path: p, present: scanTomlMcpConfig(p, ids) };
|
|
200
|
+
}
|
|
201
|
+
case 'cli':
|
|
202
|
+
return { path: null, present: scanClaudeCodeMcpServers(ids) };
|
|
203
|
+
default:
|
|
204
|
+
return { path: null, present: [] };
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Remove `ids` from one agent's config; returns the count removed.
|
|
209
|
+
function removeFrom(agent, ids, configPath) {
|
|
210
|
+
switch (agent.configFormat) {
|
|
211
|
+
case 'json':
|
|
212
|
+
return removeJsonMcpServers(configPath, agent.mcpKey, ids).removed;
|
|
213
|
+
case 'toml':
|
|
214
|
+
return removeTomlMcpServers(configPath, ids).removed;
|
|
215
|
+
case 'cli':
|
|
216
|
+
return removeClaudeCodeMcpServers(ids).removed;
|
|
217
|
+
default:
|
|
218
|
+
return 0;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// ── dxai remove <id...> ──
|
|
223
|
+
export async function removeMcp(serverIds = [], opts = {}) {
|
|
224
|
+
const runtime = normalizeOptions(opts);
|
|
225
|
+
const project = !!opts.project;
|
|
226
|
+
const home = os.homedir();
|
|
227
|
+
|
|
228
|
+
// Removal is by catalogue id or the slug a registry name was added under;
|
|
229
|
+
// no live lookup is needed to take something out of a config.
|
|
230
|
+
const ids = serverIds.map((raw) => (isValidRegistryName(raw) ? slugForRegistryName(raw) : raw));
|
|
231
|
+
if (ids.length === 0) throw new Error('No MCP server(s) given.');
|
|
232
|
+
const agents = resolveTargetAgents(runtime, home, { project });
|
|
233
|
+
if (agents.length === 0) {
|
|
234
|
+
throw new Error('No target agents detected. Pass --agents <ids> or install a supported agent.');
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
if (!runtime.json) {
|
|
238
|
+
printBanner();
|
|
239
|
+
sectionHeader(`Remove — ${ids.join(', ')} from ${project ? 'project' : 'system'}`);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const removed = [];
|
|
243
|
+
for (const agent of agents) {
|
|
244
|
+
const { path: configPath, present } = scanPresent(agent, ids, home, project);
|
|
245
|
+
if (present.length === 0) continue;
|
|
246
|
+
|
|
247
|
+
if (runtime.dryRun) {
|
|
248
|
+
removed.push({ agent: agent.name, agentId: agent.id, path: configPath, removed: present });
|
|
249
|
+
continue;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
let count;
|
|
253
|
+
try {
|
|
254
|
+
count = removeFrom(agent, present, configPath);
|
|
255
|
+
} catch (err) {
|
|
256
|
+
if (!runtime.json) warnMsg(`${agent.name}: ${err.message}`);
|
|
257
|
+
continue;
|
|
258
|
+
}
|
|
259
|
+
if (count > 0) {
|
|
260
|
+
if (project) unrecordProjectMcp(agent.id, present);
|
|
261
|
+
else unrecordSystemMcp(agent.id, present);
|
|
262
|
+
removed.push({ agent: agent.name, agentId: agent.id, path: configPath, removed: present });
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
if (runtime.json) {
|
|
267
|
+
process.stdout.write(JSON.stringify({ ok: true, dryRun: runtime.dryRun, project, removed }, null, 2) + '\n');
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
if (removed.length === 0) {
|
|
272
|
+
infoMsg(`None of [${ids.join(', ')}] were present in the targeted ${project ? 'project' : 'system'} configs.`);
|
|
273
|
+
console.log();
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
for (const r of removed) {
|
|
277
|
+
const verb = runtime.dryRun ? 'would remove' : 'removed';
|
|
278
|
+
successMsg(`${r.agent}: ${verb} ${r.removed.join(', ')}` + (r.path ? ` → ${r.path}` : ''));
|
|
279
|
+
}
|
|
280
|
+
console.log();
|
|
281
|
+
if (runtime.dryRun) warnMsg('Dry run — no files were changed.');
|
|
282
|
+
}
|
package/src/net.js
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// Shared network helpers: fetch with a per-attempt timeout, bounded retries, and
|
|
2
|
+
// exponential backoff. The registry refresh and skill downloads both route through
|
|
3
|
+
// here so timeout/retry behaviour lives in exactly one place instead of being
|
|
4
|
+
// re-implemented (or forgotten) at each call site.
|
|
5
|
+
|
|
6
|
+
const DEFAULT_TIMEOUT_MS = 15000;
|
|
7
|
+
const DEFAULT_RETRIES = 2; // total attempts = retries + 1
|
|
8
|
+
const DEFAULT_BACKOFF_MS = 300; // base delay, doubled each retry
|
|
9
|
+
|
|
10
|
+
// Injectable so tests can advance "time" without real waits.
|
|
11
|
+
function sleep(ms) {
|
|
12
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// Worth retrying? Transient failures (timeouts, dropped connections, 5xx, 429)
|
|
16
|
+
// are; a definitive 4xx (bad URL, 404, 403) is permanent, so retrying just
|
|
17
|
+
// wastes the caller's time and hammers the server.
|
|
18
|
+
export function isRetriable(err) {
|
|
19
|
+
if (!err) return false;
|
|
20
|
+
if (err.name === 'AbortError' || err.name === 'TimeoutError') return true;
|
|
21
|
+
if (typeof err.status === 'number') {
|
|
22
|
+
if (err.status === 429) return true;
|
|
23
|
+
if (err.status >= 500) return true;
|
|
24
|
+
if (err.status >= 400) return false; // other 4xx — don't retry
|
|
25
|
+
}
|
|
26
|
+
// fetch() rejected at the network layer (DNS, connection refused, TLS): retry.
|
|
27
|
+
return true;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Fetch with a fresh AbortSignal.timeout per attempt and exponential backoff
|
|
31
|
+
// between retries. Returns the Response on the first 2xx; throws the last error
|
|
32
|
+
// once retries are exhausted (or immediately for a non-retriable status).
|
|
33
|
+
export async function fetchWithRetry(url, opts = {}) {
|
|
34
|
+
const {
|
|
35
|
+
timeoutMs = DEFAULT_TIMEOUT_MS,
|
|
36
|
+
retries = DEFAULT_RETRIES,
|
|
37
|
+
backoffMs = DEFAULT_BACKOFF_MS,
|
|
38
|
+
redirect = 'follow',
|
|
39
|
+
sleepFn = sleep,
|
|
40
|
+
} = opts;
|
|
41
|
+
|
|
42
|
+
let lastErr;
|
|
43
|
+
for (let attempt = 0; attempt <= retries; attempt++) {
|
|
44
|
+
try {
|
|
45
|
+
const res = await fetch(url, { redirect, signal: AbortSignal.timeout(timeoutMs) });
|
|
46
|
+
if (!res.ok) {
|
|
47
|
+
const err = new Error(`HTTP ${res.status} for ${url}`);
|
|
48
|
+
err.status = res.status;
|
|
49
|
+
throw err;
|
|
50
|
+
}
|
|
51
|
+
return res;
|
|
52
|
+
} catch (err) {
|
|
53
|
+
lastErr = err;
|
|
54
|
+
if (attempt < retries && isRetriable(err)) {
|
|
55
|
+
await sleepFn(backoffMs * 2 ** attempt);
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
throw err;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
throw lastErr;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export async function fetchJson(url, opts) {
|
|
65
|
+
const res = await fetchWithRetry(url, opts);
|
|
66
|
+
return res.json();
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export async function fetchText(url, opts) {
|
|
70
|
+
const res = await fetchWithRetry(url, opts);
|
|
71
|
+
return res.text();
|
|
72
|
+
}
|
package/src/profile.js
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import fs from 'fs-extra';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import os from 'os';
|
|
4
|
+
import { writeJsonAtomic } from './fs-atomic.js';
|
|
5
|
+
|
|
6
|
+
const HOME = os.homedir();
|
|
7
|
+
|
|
8
|
+
// Keys allowed to live in a profile. Anything else is dropped silently
|
|
9
|
+
// (forward-compatibility for new keys without erroring on old ones).
|
|
10
|
+
export const PROFILE_KEYS = [
|
|
11
|
+
'mode', // 'system' | 'project' | 'both'
|
|
12
|
+
'agents', // string[]
|
|
13
|
+
'mcp', // string[]
|
|
14
|
+
'skills', // string[]
|
|
15
|
+
'features', // string[]
|
|
16
|
+
'stack', // string[]
|
|
17
|
+
'mcpInputs', // { [serverId]: { [inputKey]: value } }
|
|
18
|
+
];
|
|
19
|
+
|
|
20
|
+
// Default project-level profile location.
|
|
21
|
+
const PROJECT_PROFILE_PATH = path.join('.dxai', 'profile.json');
|
|
22
|
+
|
|
23
|
+
// Default user-level profile dir.
|
|
24
|
+
const USER_PROFILE_DIR = path.join(HOME, '.dxai', 'profiles');
|
|
25
|
+
|
|
26
|
+
// Auto-discovery: first existing wins.
|
|
27
|
+
function findDefaultProfile(cwd = process.cwd()) {
|
|
28
|
+
const candidates = [
|
|
29
|
+
path.join(cwd, '.dxai', 'profile.json'),
|
|
30
|
+
path.join(HOME, '.dxai', 'config.json'),
|
|
31
|
+
path.join(HOME, '.dxairc'),
|
|
32
|
+
path.join(HOME, '.dxairc.json'),
|
|
33
|
+
];
|
|
34
|
+
for (const p of candidates) {
|
|
35
|
+
if (fs.existsSync(p)) return p;
|
|
36
|
+
}
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Resolve `nameOrPath` to a real file path:
|
|
41
|
+
// - if it ends with .json or contains a path separator, treat as path
|
|
42
|
+
// - otherwise look up by name in user/project dirs
|
|
43
|
+
export function resolveProfile(nameOrPath, cwd = process.cwd()) {
|
|
44
|
+
if (!nameOrPath) return findDefaultProfile(cwd);
|
|
45
|
+
|
|
46
|
+
// Treat as a path if it carries any separator. Check both `/` and `\` so a
|
|
47
|
+
// forward-slash path (e.g. `./team/dev`) is still recognized on Windows, where
|
|
48
|
+
// path.sep is `\`.
|
|
49
|
+
if (nameOrPath.includes('/') || nameOrPath.includes('\\') || nameOrPath.endsWith('.json')) {
|
|
50
|
+
const abs = path.isAbsolute(nameOrPath) ? nameOrPath : path.join(cwd, nameOrPath);
|
|
51
|
+
return fs.existsSync(abs) ? abs : null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const candidates = [
|
|
55
|
+
path.join(cwd, '.dxai', `${nameOrPath}.json`),
|
|
56
|
+
path.join(USER_PROFILE_DIR, `${nameOrPath}.json`),
|
|
57
|
+
path.join(HOME, '.dxai', `${nameOrPath}.json`),
|
|
58
|
+
];
|
|
59
|
+
for (const p of candidates) {
|
|
60
|
+
if (fs.existsSync(p)) return p;
|
|
61
|
+
}
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function readProfile(filePath) {
|
|
66
|
+
if (!fs.existsSync(filePath)) {
|
|
67
|
+
throw new Error(`Profile not found: ${filePath}`);
|
|
68
|
+
}
|
|
69
|
+
let data;
|
|
70
|
+
try {
|
|
71
|
+
data = fs.readJsonSync(filePath);
|
|
72
|
+
} catch (err) {
|
|
73
|
+
throw new Error(`Failed to parse profile ${filePath}: ${err.message}`, { cause: err });
|
|
74
|
+
}
|
|
75
|
+
const out = {};
|
|
76
|
+
for (const k of PROFILE_KEYS) {
|
|
77
|
+
if (data[k] !== undefined) out[k] = data[k];
|
|
78
|
+
}
|
|
79
|
+
return out;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// CLI flags take precedence over profile values.
|
|
83
|
+
// Only fills keys that are undefined on `cliOpts`.
|
|
84
|
+
export function mergeWithProfile(cliOpts, profile) {
|
|
85
|
+
if (!profile) return cliOpts;
|
|
86
|
+
const merged = { ...cliOpts };
|
|
87
|
+
for (const key of PROFILE_KEYS) {
|
|
88
|
+
if (merged[key] === undefined && profile[key] !== undefined) {
|
|
89
|
+
merged[key] = profile[key];
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return merged;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Save a profile. `target` is one of:
|
|
96
|
+
// { user: true, name } → ~/.dxai/profiles/<name>.json
|
|
97
|
+
// { here: true } → ./.dxai/profile.json
|
|
98
|
+
// { path: '...' } → exact path
|
|
99
|
+
export function saveProfile(data, target = { user: true, name: 'default' }) {
|
|
100
|
+
const filtered = {};
|
|
101
|
+
for (const k of PROFILE_KEYS) {
|
|
102
|
+
if (data[k] !== undefined) filtered[k] = data[k];
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
let outPath;
|
|
106
|
+
if (target.path) {
|
|
107
|
+
outPath = target.path;
|
|
108
|
+
} else if (target.here) {
|
|
109
|
+
outPath = path.join(process.cwd(), PROJECT_PROFILE_PATH);
|
|
110
|
+
} else {
|
|
111
|
+
if (!target.name) throw new Error('saveProfile: target.name is required for user profiles');
|
|
112
|
+
outPath = path.join(USER_PROFILE_DIR, `${target.name}.json`);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
writeJsonAtomic(outPath, filtered, { spaces: 2 });
|
|
116
|
+
return outPath;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// List discoverable profiles (user dir + cwd .dxai dir).
|
|
120
|
+
export function listProfiles(cwd = process.cwd()) {
|
|
121
|
+
const found = [];
|
|
122
|
+
|
|
123
|
+
for (const dir of [USER_PROFILE_DIR, path.join(cwd, '.dxai')]) {
|
|
124
|
+
if (!fs.existsSync(dir)) continue;
|
|
125
|
+
try {
|
|
126
|
+
const files = fs.readdirSync(dir).filter((f) => f.endsWith('.json'));
|
|
127
|
+
for (const f of files) {
|
|
128
|
+
found.push({
|
|
129
|
+
name: path.basename(f, '.json'),
|
|
130
|
+
path: path.join(dir, f),
|
|
131
|
+
scope: dir === USER_PROFILE_DIR ? 'user' : 'project',
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
} catch {
|
|
135
|
+
// skip
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return found;
|
|
139
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"categories": [
|
|
3
|
+
{
|
|
4
|
+
"id": "automation",
|
|
5
|
+
"label": "🤖 Automation",
|
|
6
|
+
"description": "CLI tools that give AI agents browser & device control"
|
|
7
|
+
}
|
|
8
|
+
],
|
|
9
|
+
"tools": [
|
|
10
|
+
{
|
|
11
|
+
"id": "agent-browser",
|
|
12
|
+
"name": "Agent Browser",
|
|
13
|
+
"description": "Browser automation CLI for AI agents",
|
|
14
|
+
"category": "automation",
|
|
15
|
+
"recommended": true,
|
|
16
|
+
"detectCommand": "agent-browser",
|
|
17
|
+
"installCommand": {
|
|
18
|
+
"macOS": "npm install -g agent-browser",
|
|
19
|
+
"Linux": "npm install -g agent-browser",
|
|
20
|
+
"Windows": "npm install -g agent-browser"
|
|
21
|
+
}
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
"id": "agent-device",
|
|
25
|
+
"name": "Agent Device",
|
|
26
|
+
"description": "iOS/Android device automation CLI for AI agents",
|
|
27
|
+
"category": "automation",
|
|
28
|
+
"recommended": true,
|
|
29
|
+
"detectCommand": "agent-device",
|
|
30
|
+
"installCommand": {
|
|
31
|
+
"macOS": "npm install -g agent-device",
|
|
32
|
+
"Linux": "npm install -g agent-device",
|
|
33
|
+
"Windows": "npm install -g agent-device"
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
]
|
|
37
|
+
}
|