termux-dev 1.6.0 → 1.8.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.
Files changed (96) hide show
  1. package/README.md +214 -166
  2. package/assets/banner.svg +23 -28
  3. package/assets/preview.png +0 -0
  4. package/assets/preview.svg +92 -10
  5. package/dist/cli/banner.js +1 -1
  6. package/dist/cli/clipboard-write.js +55 -0
  7. package/dist/cli/clipboard.js +182 -106
  8. package/dist/cli/colors.js +67 -0
  9. package/dist/cli/confirm.js +50 -0
  10. package/dist/cli/doctor.js +4 -4
  11. package/dist/cli/effort.js +80 -0
  12. package/dist/cli/export.js +3 -3
  13. package/dist/cli/files.js +19 -5
  14. package/dist/cli/headless.js +23 -11
  15. package/dist/cli/index.js +1377 -480
  16. package/dist/cli/keys.js +69 -0
  17. package/dist/cli/liveblock.js +45 -0
  18. package/dist/cli/log.js +19 -0
  19. package/dist/cli/markdown.js +158 -36
  20. package/dist/cli/mcp-panel.js +432 -0
  21. package/dist/cli/permission.js +84 -0
  22. package/dist/cli/picker.js +135 -0
  23. package/dist/cli/prompt.js +256 -56
  24. package/dist/cli/resume.js +15 -0
  25. package/dist/cli/rewind.js +51 -0
  26. package/dist/cli/runtime_errors.js +6 -0
  27. package/dist/cli/server.js +85 -24
  28. package/dist/cli/settings.js +307 -0
  29. package/dist/cli/statusline.js +1 -1
  30. package/dist/cli/theme.js +126 -108
  31. package/dist/cli/transcript.js +114 -0
  32. package/dist/cli/typeahead.js +371 -0
  33. package/dist/cli/ui.js +1021 -0
  34. package/dist/cli/updater.js +23 -21
  35. package/dist/cli/usage-panel.js +242 -0
  36. package/dist/cli/width.js +178 -0
  37. package/dist/core/capabilities.js +433 -0
  38. package/dist/core/catalog-util.js +27 -0
  39. package/dist/core/checkpoint.js +90 -0
  40. package/dist/core/context-stats.js +29 -0
  41. package/dist/core/cost.js +191 -0
  42. package/dist/core/formats.js +199 -0
  43. package/dist/core/history.js +51 -1
  44. package/dist/core/hooks.js +79 -0
  45. package/dist/core/i18n.js +42 -0
  46. package/dist/core/local-config.js +69 -0
  47. package/dist/core/locales/de.js +210 -0
  48. package/dist/core/locales/en.js +221 -0
  49. package/dist/core/locales/es.js +210 -0
  50. package/dist/core/locales/fr.js +210 -0
  51. package/dist/core/locales/ja.js +210 -0
  52. package/dist/core/locales/pt.js +210 -0
  53. package/dist/core/locales/ru.js +210 -0
  54. package/dist/core/locales/uk.js +210 -0
  55. package/dist/core/locales/zh.js +210 -0
  56. package/dist/core/loop.js +125 -49
  57. package/dist/core/memory.js +39 -12
  58. package/dist/core/models.js +33 -20
  59. package/dist/core/network.js +12 -11
  60. package/dist/core/notify.js +54 -0
  61. package/dist/core/planfile.js +43 -0
  62. package/dist/core/price-catalog.json +1 -0
  63. package/dist/core/prices.js +249 -0
  64. package/dist/core/reasoning-catalog.json +1 -0
  65. package/dist/core/reasoning.js +393 -0
  66. package/dist/core/redact.js +41 -0
  67. package/dist/core/secure-file.js +22 -0
  68. package/dist/core/session.js +16 -5
  69. package/dist/core/snapshot.js +14 -2
  70. package/dist/core/title.js +61 -0
  71. package/dist/core/types.js +1 -1
  72. package/dist/core/usage.js +43 -7
  73. package/dist/core/vision-catalog.json +1 -0
  74. package/dist/core/vision.js +123 -0
  75. package/dist/mcp/client.js +36 -5
  76. package/dist/mcp/http-client.js +174 -0
  77. package/dist/mcp/manager.js +330 -93
  78. package/dist/permissions/guard.js +385 -117
  79. package/dist/prompts/builder.js +46 -38
  80. package/dist/providers/anthropic.js +517 -0
  81. package/dist/providers/index.js +18 -1
  82. package/dist/providers/openai.js +138 -125
  83. package/dist/providers/shared.js +116 -0
  84. package/dist/providers/toolguard.js +101 -0
  85. package/dist/tools/bash.js +141 -51
  86. package/dist/tools/diagnostics.js +76 -48
  87. package/dist/tools/fs.js +77 -5
  88. package/dist/tools/index.js +2 -1
  89. package/dist/tools/mcp.js +72 -0
  90. package/dist/tools/packages.js +16 -1
  91. package/dist/tools/plan.js +8 -8
  92. package/dist/tools/questions.js +50 -54
  93. package/dist/tools/server.js +6 -0
  94. package/dist/tools/todo.js +20 -18
  95. package/dist/tools/web.js +143 -8
  96. package/package.json +1 -1
@@ -1,19 +1,224 @@
1
- import fs from 'fs/promises';
2
1
  import fsSync from 'fs';
3
2
  import path from 'path';
4
3
  import os from 'os';
4
+ import { createHash } from 'crypto';
5
5
  import pc from 'picocolors';
6
6
  import { MCPClient } from './client.js';
7
+ import { HttpMCPClient } from './http-client.js';
7
8
  import { getCurrentTheme } from '../cli/theme.js';
8
9
  function stripAnsi(str) {
9
10
  return str.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, '');
10
11
  }
12
+ // Config files that live inside the project. MCP servers are arbitrary commands, so
13
+ // whatever a cloned repository declares here is NOT started until the user approved it.
14
+ const PROJECT_CONFIG_FILES = [
15
+ path.join('.devx', 'mcp.json'),
16
+ path.join('.claude', 'mcp.json'),
17
+ '.devx.json',
18
+ '.mcp.json'
19
+ ];
20
+ /** In the home folder the "project" files are the user's own files: they are not a project's, and need no approval. */
21
+ function isUserFile(file) {
22
+ const abs = path.resolve(file);
23
+ return USER_FILES.some((f) => path.resolve(os.homedir(), f.rel) === abs);
24
+ }
25
+ function readMcpServers(file) {
26
+ const result = {};
27
+ try {
28
+ if (!fsSync.existsSync(file))
29
+ return result;
30
+ const parsed = JSON.parse(fsSync.readFileSync(file, 'utf8'));
31
+ if (parsed && typeof parsed.mcpServers === 'object' && parsed.mcpServers) {
32
+ for (const [name, cfg] of Object.entries(parsed.mcpServers)) {
33
+ if (cfg && typeof cfg === 'object' && (cfg.command || cfg.url)) {
34
+ result[name] = cfg;
35
+ }
36
+ }
37
+ }
38
+ }
39
+ catch { }
40
+ return result;
41
+ }
42
+ /** MCP servers declared by the current project (not started, only listed). */
43
+ export function discoverProjectMcpServers(cwd = process.cwd()) {
44
+ const found = [];
45
+ for (const rel of PROJECT_CONFIG_FILES) {
46
+ const file = path.join(cwd, rel);
47
+ if (isUserFile(file))
48
+ continue;
49
+ for (const [name, config] of Object.entries(readMcpServers(file))) {
50
+ found.push({ file: rel, name, config });
51
+ }
52
+ }
53
+ return found;
54
+ }
55
+ /** Stable fingerprint of the commands, arguments and env of the project's servers. */
56
+ export function hashProjectMcpServers(servers) {
57
+ const normalized = servers
58
+ .map(s => ({
59
+ file: s.file,
60
+ name: s.name,
61
+ command: s.config.command ?? '',
62
+ url: s.config.url ?? '',
63
+ headers: Object.fromEntries(Object.entries(s.config.headers || {}).sort(([a], [b]) => a.localeCompare(b))),
64
+ args: s.config.args || [],
65
+ env: Object.fromEntries(Object.entries(s.config.env || {}).sort(([a], [b]) => a.localeCompare(b))),
66
+ disabled: !!s.config.disabled
67
+ }))
68
+ .sort((a, b) => (a.file + a.name).localeCompare(b.file + b.name));
69
+ return createHash('sha256').update(JSON.stringify(normalized)).digest('hex');
70
+ }
71
+ const USER_FILES = [
72
+ { rel: '.devxrc.json', label: '~/.devxrc.json' },
73
+ { rel: path.join('.devx', 'mcp.json'), label: '~/.devx/mcp.json' },
74
+ ];
75
+ /** What the user switched on or off in the /mcp panel. Kept apart from the config files, which are never edited. */
76
+ /** Provider APIs only accept [A-Za-z0-9_-]{1,64} as a tool name; servers may use dots, spaces or other scripts. */
77
+ export function mcpToolName(server, tool) {
78
+ const clean = (v) => v.replace(/[^A-Za-z0-9_-]+/g, '_');
79
+ const full = `mcp__${clean(server)}__${clean(tool)}`;
80
+ if (full.length <= 64)
81
+ return full;
82
+ let h = 0;
83
+ for (const ch of full)
84
+ h = (h * 31 + ch.charCodeAt(0)) >>> 0;
85
+ return `${full.slice(0, 55)}_${h.toString(36).padStart(8, '0').slice(-8)}`;
86
+ }
87
+ function uniqueName(name, existing) {
88
+ let out = name;
89
+ for (let i = 2; existing.some((t) => t.name === out); i++)
90
+ out = `${name.slice(0, 60)}_${i}`;
91
+ return out;
92
+ }
93
+ const stateFile = () => path.join(os.homedir(), '.devx', 'mcp-state.json');
94
+ function readOverrides() {
95
+ try {
96
+ const parsed = JSON.parse(fsSync.readFileSync(stateFile(), 'utf8'));
97
+ return parsed && typeof parsed.disabled === 'object' && parsed.disabled ? parsed.disabled : {};
98
+ }
99
+ catch {
100
+ return {};
101
+ }
102
+ }
103
+ function writeOverrides(disabled) {
104
+ try {
105
+ const file = stateFile();
106
+ fsSync.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
107
+ fsSync.writeFileSync(file, JSON.stringify({ disabled }, null, 2), { mode: 0o600 });
108
+ }
109
+ catch { }
110
+ }
111
+ const stateKey = (scope, name, cwd = process.cwd()) => (scope === 'project' ? `${cwd}::${name}` : `user::${name}`);
112
+ function toolInfo(def) {
113
+ const props = def.inputSchema?.properties && typeof def.inputSchema.properties === 'object' ? def.inputSchema.properties : {};
114
+ const required = Array.isArray(def.inputSchema?.required) ? def.inputSchema.required : [];
115
+ const a = def.annotations || {};
116
+ return {
117
+ name: def.name,
118
+ description: String(def.description || ''),
119
+ readOnly: a.readOnlyHint === true,
120
+ // MCP assumes a tool may be destructive unless it says otherwise; only say so when it is stated.
121
+ destructive: a.destructiveHint === true && a.readOnlyHint !== true,
122
+ openWorld: a.openWorldHint === true,
123
+ params: Object.entries(props).map(([name, spec]) => ({
124
+ name,
125
+ type: Array.isArray(spec?.type) ? spec.type.join('|') : String(spec?.type ?? 'any'),
126
+ required: required.includes(name),
127
+ description: String(spec?.description || ''),
128
+ })),
129
+ };
130
+ }
131
+ const userMcpFile = () => path.join(os.homedir(), '.devx', 'mcp.json');
132
+ /**
133
+ * The servers of Claude Code (`~/.claude.json`: the user's own and the ones of this folder and its parents).
134
+ * A local server is taken as it is; a remote one (an address) is reached through the
135
+ * `mcp-remote` bridge, which devx can start like any local command.
136
+ */
137
+ export function readClaudeServers(cwd = process.cwd()) {
138
+ const out = {};
139
+ let data;
140
+ try {
141
+ data = JSON.parse(fsSync.readFileSync(path.join(os.homedir(), '.claude.json'), 'utf8'));
142
+ }
143
+ catch {
144
+ return out;
145
+ }
146
+ // Claude Code keeps some servers per folder: the ones of this folder and of the folders above it count.
147
+ const ancestors = [];
148
+ for (let dir = cwd;; dir = path.dirname(dir)) {
149
+ ancestors.unshift(dir);
150
+ if (path.dirname(dir) === dir)
151
+ break;
152
+ }
153
+ const groups = [data?.mcpServers, ...ancestors.map((dir) => data?.projects?.[dir]?.mcpServers)];
154
+ for (const group of groups) {
155
+ if (!group || typeof group !== 'object')
156
+ continue;
157
+ for (const [name, raw] of Object.entries(group)) {
158
+ if (!raw || typeof raw !== 'object')
159
+ continue;
160
+ if (typeof raw.command === 'string' && raw.command) {
161
+ out[name] = { command: raw.command, ...(Array.isArray(raw.args) ? { args: raw.args.map(String) } : {}), ...(raw.env && typeof raw.env === 'object' ? { env: raw.env } : {}) };
162
+ }
163
+ else if (typeof raw.url === 'string' && raw.url) {
164
+ out[name] = { command: 'npx', args: ['-y', 'mcp-remote', raw.url] };
165
+ }
166
+ }
167
+ }
168
+ return out;
169
+ }
170
+ /** Writes servers into the user's own file `~/.devx/mcp.json`. A name that is already there is left alone unless `overwrite`. Returns the names that were written. */
171
+ export function saveUserServers(servers, overwrite = false) {
172
+ const file = userMcpFile();
173
+ let current = {};
174
+ try {
175
+ current = JSON.parse(fsSync.readFileSync(file, 'utf8'));
176
+ }
177
+ catch { }
178
+ const existing = current && typeof current.mcpServers === 'object' && current.mcpServers ? current.mcpServers : {};
179
+ const written = [];
180
+ for (const [name, cfg] of Object.entries(servers)) {
181
+ if (name in existing && !overwrite)
182
+ continue;
183
+ existing[name] = cfg;
184
+ written.push(name);
185
+ }
186
+ if (written.length > 0) {
187
+ fsSync.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
188
+ // The file may hold tokens in `env`: readable by the owner only.
189
+ fsSync.writeFileSync(file, JSON.stringify({ ...current, mcpServers: existing }, null, 2), { mode: 0o600 });
190
+ }
191
+ return written;
192
+ }
193
+ /** Removes a server from the user's own file `~/.devx/mcp.json`. Returns false when it is not there (a server of another file has to be removed by hand). */
194
+ export function removeUserServer(name) {
195
+ const file = userMcpFile();
196
+ try {
197
+ const current = JSON.parse(fsSync.readFileSync(file, 'utf8'));
198
+ if (!current?.mcpServers || !(name in current.mcpServers))
199
+ return false;
200
+ delete current.mcpServers[name];
201
+ fsSync.writeFileSync(file, JSON.stringify(current, null, 2), { mode: 0o600 });
202
+ // What the panel remembered about it (switched off, ...) goes too.
203
+ const overrides = readOverrides();
204
+ if (overrides[stateKey('user', name)] !== undefined) {
205
+ delete overrides[stateKey('user', name)];
206
+ writeOverrides(overrides);
207
+ }
208
+ return true;
209
+ }
210
+ catch {
211
+ return false;
212
+ }
213
+ }
11
214
  export class MCPManager {
12
215
  static instance;
13
216
  clients = new Map();
217
+ entries = new Map();
14
218
  tools = [];
15
219
  serverStatuses = new Map();
16
220
  isInitialized = false;
221
+ trustProject = false;
17
222
  constructor() { }
18
223
  static getInstance() {
19
224
  if (!MCPManager.instance) {
@@ -21,81 +226,98 @@ export class MCPManager {
21
226
  }
22
227
  return MCPManager.instance;
23
228
  }
24
- async init(explicitConfigs) {
229
+ /**
230
+ * @param explicitConfigs use exactly these servers instead of reading config files
231
+ * @param trustProject also start the servers declared inside the current project
232
+ * (only pass true after the user approved them)
233
+ */
234
+ async init(explicitConfigs, trustProject = false) {
25
235
  if (this.isInitialized) {
26
236
  return this.tools;
27
237
  }
28
- const configs = explicitConfigs || await this.loadConfigs();
29
- const serverEntries = Object.entries(configs);
30
- if (serverEntries.length === 0) {
238
+ this.trustProject = trustProject;
239
+ this.entries.clear();
240
+ if (explicitConfigs) {
241
+ for (const [name, config] of Object.entries(explicitConfigs))
242
+ this.entries.set(name, { config, source: 'explicit', scope: 'user' });
243
+ }
244
+ else {
245
+ for (const [name, entry] of await this.loadEntries(trustProject))
246
+ this.entries.set(name, entry);
247
+ }
248
+ if (this.entries.size === 0) {
31
249
  this.isInitialized = true;
32
250
  return [];
33
251
  }
34
- const initPromises = serverEntries.map(async ([name, cfg]) => {
35
- if (cfg.disabled) {
36
- this.serverStatuses.set(name, {
37
- name,
38
- command: `${cfg.command} ${(cfg.args || []).join(' ')}`.trim(),
39
- status: 'disabled',
40
- toolsCount: 0,
41
- tools: []
42
- });
43
- return;
44
- }
252
+ await Promise.all(Array.from(this.entries.keys()).map((name) => this.startServer(name)));
253
+ this.isInitialized = true;
254
+ return this.tools;
255
+ }
256
+ isDisabled(name) {
257
+ const entry = this.entries.get(name);
258
+ const override = readOverrides()[stateKey(entry.scope, name)];
259
+ return override !== undefined ? override : !!entry.config.disabled;
260
+ }
261
+ /** Starts one server and registers its tools. Never throws: a failure becomes the server's status. */
262
+ async startServer(name) {
263
+ const entry = this.entries.get(name);
264
+ if (!entry)
265
+ return;
266
+ const { config: cfg, source, scope } = entry;
267
+ const command = cfg.url ? cfg.url : `${cfg.command ?? ''} ${(cfg.args || []).join(' ')}`.trim();
268
+ const base = { name, command, source, scope, toolsCount: 0, tools: [] };
269
+ if (this.isDisabled(name)) {
270
+ this.serverStatuses.set(name, { ...base, status: 'disabled' });
271
+ return;
272
+ }
273
+ this.serverStatuses.set(name, { ...base, status: 'connecting' });
274
+ const client = cfg.url ? new HttpMCPClient(name, { ...cfg, disabled: false }) : new MCPClient(name, { ...cfg, disabled: false });
275
+ this.clients.set(name, client);
276
+ try {
277
+ const mcpTools = await client.start();
45
278
  this.serverStatuses.set(name, {
46
- name,
47
- command: `${cfg.command} ${(cfg.args || []).join(' ')}`.trim(),
48
- status: 'connecting',
49
- toolsCount: 0,
50
- tools: []
279
+ ...base,
280
+ status: 'connected',
281
+ toolsCount: mcpTools.length,
282
+ tools: mcpTools.map((t) => t.name),
283
+ capabilities: client.getCapabilities(),
284
+ toolInfo: mcpTools.map(toolInfo),
51
285
  });
52
- const client = new MCPClient(name, cfg);
53
- this.clients.set(name, client);
54
- try {
55
- const mcpTools = await client.start();
56
- this.serverStatuses.set(name, {
57
- name,
58
- command: `${cfg.command} ${(cfg.args || []).join(' ')}`.trim(),
59
- status: 'connected',
60
- toolsCount: mcpTools.length,
61
- tools: mcpTools.map(t => t.name)
62
- });
63
- // Convert MCP tools into devx Tool format
64
- for (const mt of mcpTools) {
65
- const namespacedName = `mcp__${name}__${mt.name}`;
66
- const devxTool = {
286
+ // Convert MCP tools into devx Tool format
287
+ for (const mt of mcpTools) {
288
+ const namespacedName = uniqueName(mcpToolName(name, mt.name), this.tools);
289
+ const devxTool = {
290
+ name: namespacedName,
291
+ definition: {
67
292
  name: namespacedName,
68
- definition: {
69
- name: namespacedName,
70
- description: `[MCP: ${name}] ${mt.description || mt.name}`,
71
- parameters: {
72
- type: 'object',
73
- properties: mt.inputSchema?.properties || {},
74
- required: mt.inputSchema?.required
75
- }
293
+ description: `[MCP: ${name}] ${mt.description || mt.name}`,
294
+ parameters: {
295
+ type: 'object',
296
+ properties: mt.inputSchema?.properties || {},
297
+ required: mt.inputSchema?.required,
76
298
  },
77
- validateArgs(_args) { },
78
- async execute(args) {
79
- return await client.callTool(mt.name, args);
80
- }
81
- };
82
- this.tools.push(devxTool);
83
- }
299
+ },
300
+ validateArgs(_args) { },
301
+ async execute(args) {
302
+ return await client.callTool(mt.name, args);
303
+ },
304
+ };
305
+ this.tools.push(devxTool);
84
306
  }
85
- catch (err) {
86
- this.serverStatuses.set(name, {
87
- name,
88
- command: `${cfg.command} ${(cfg.args || []).join(' ')}`.trim(),
89
- status: 'failed',
90
- toolsCount: 0,
91
- tools: [],
92
- error: err.message
93
- });
94
- }
95
- });
96
- await Promise.all(initPromises);
97
- this.isInitialized = true;
98
- return this.tools;
307
+ }
308
+ catch (err) {
309
+ this.clients.delete(name);
310
+ this.serverStatuses.set(name, { ...base, status: 'failed', error: err.message });
311
+ }
312
+ }
313
+ /** Stops a server and takes its tools away from the agent. */
314
+ stopServer(name) {
315
+ try {
316
+ this.clients.get(name)?.close();
317
+ }
318
+ catch { }
319
+ this.clients.delete(name);
320
+ this.tools = this.tools.filter((t) => !t.name.startsWith(mcpToolName(name, '')));
99
321
  }
100
322
  getTools() {
101
323
  return this.tools;
@@ -103,13 +325,36 @@ export class MCPManager {
103
325
  getStatuses() {
104
326
  return Array.from(this.serverStatuses.values());
105
327
  }
328
+ getStatus(name) {
329
+ return this.serverStatuses.get(name);
330
+ }
331
+ /** Starts a server again (also after it failed). The tools of the agent follow at once. */
332
+ async reconnect(name) {
333
+ if (!this.entries.has(name))
334
+ return undefined;
335
+ this.stopServer(name);
336
+ await this.startServer(name);
337
+ return this.serverStatuses.get(name);
338
+ }
339
+ /** Switches a server off: it is stopped and stays off in the next sessions too. */
340
+ async setEnabled(name, enabled) {
341
+ const entry = this.entries.get(name);
342
+ if (!entry)
343
+ return undefined;
344
+ const overrides = readOverrides();
345
+ overrides[stateKey(entry.scope, name)] = !enabled;
346
+ writeOverrides(overrides);
347
+ this.stopServer(name);
348
+ await this.startServer(name);
349
+ return this.serverStatuses.get(name);
350
+ }
106
351
  async reload() {
107
352
  this.stopAll();
108
353
  this.isInitialized = false;
109
354
  this.clients.clear();
110
355
  this.tools = [];
111
356
  this.serverStatuses.clear();
112
- return await this.init();
357
+ return await this.init(undefined, this.trustProject);
113
358
  }
114
359
  stopAll() {
115
360
  for (const client of this.clients.values()) {
@@ -131,7 +376,7 @@ export class MCPManager {
131
376
  const spaces = Math.max(1, innerWidth - plainLen);
132
377
  return `│ ${left}${' '.repeat(spaces)}${right} │`;
133
378
  };
134
- const header = theme.colorFn('┌─ ') + pc.bold('🔌 Model Context Protocol (MCP) Servers') + ' ' + theme.colorFn('─'.repeat(Math.max(2, boxWidth - 43)) + '┐');
379
+ const header = theme.colorFn('┌─ ') + pc.bold('Model Context Protocol (MCP) Servers') + ' ' + theme.colorFn('─'.repeat(Math.max(2, boxWidth - 43)) + '┐');
135
380
  const divider = theme.colorFn('├' + '─'.repeat(boxWidth - 2) + '┤');
136
381
  const footer = theme.colorFn('└' + '─'.repeat(boxWidth - 2) + '┘');
137
382
  const lines = [header];
@@ -145,13 +390,13 @@ export class MCPManager {
145
390
  const s = statuses[i];
146
391
  if (i > 0)
147
392
  lines.push(divider);
148
- let statusBadge = pc.green('🟢 Connected');
393
+ let statusBadge = pc.green('● Connected');
149
394
  if (s.status === 'connecting')
150
- statusBadge = pc.yellow('🟡 Connecting');
395
+ statusBadge = pc.yellow('● Connecting');
151
396
  if (s.status === 'failed')
152
- statusBadge = pc.red('🔴 Failed');
397
+ statusBadge = pc.red('● Failed');
153
398
  if (s.status === 'disabled')
154
- statusBadge = pc.dim('⚪ Disabled');
399
+ statusBadge = pc.dim('○ Disabled');
155
400
  lines.push(padRow(pc.bold(pc.white(`Server: ${s.name}`)), statusBadge));
156
401
  lines.push(padRow(pc.dim(` Command: ${s.command.slice(0, 36)}${s.command.length > 36 ? '...' : ''}`), pc.cyan(`${s.toolsCount} tools`)));
157
402
  if (s.tools.length > 0) {
@@ -166,30 +411,22 @@ export class MCPManager {
166
411
  lines.push(footer);
167
412
  return lines.join('\n') + '\n';
168
413
  }
169
- async loadConfigs() {
170
- const result = {};
171
- const configPaths = [
172
- path.join(os.homedir(), '.devxrc.json'),
173
- path.join(process.cwd(), '.devx', 'mcp.json'),
174
- path.join(process.cwd(), '.claude', 'mcp.json'),
175
- path.join(process.cwd(), '.devx.json'),
176
- path.join(process.cwd(), '.mcp.json')
177
- ];
178
- for (const pth of configPaths) {
179
- try {
180
- if (fsSync.existsSync(pth)) {
181
- const raw = await fs.readFile(pth, 'utf8');
182
- const parsed = JSON.parse(raw);
183
- if (parsed && typeof parsed.mcpServers === 'object') {
184
- for (const [name, cfg] of Object.entries(parsed.mcpServers)) {
185
- if (cfg && typeof cfg === 'object' && cfg.command) {
186
- result[name] = cfg;
187
- }
188
- }
189
- }
414
+ async loadEntries(includeProject) {
415
+ const result = new Map();
416
+ // User-level configuration is always trusted: the user wrote it. A later file wins over an earlier one.
417
+ for (const f of USER_FILES) {
418
+ for (const [name, config] of Object.entries(readMcpServers(path.join(os.homedir(), f.rel)))) {
419
+ result.set(name, { config, source: f.label, scope: 'user' });
420
+ }
421
+ }
422
+ if (includeProject) {
423
+ for (const rel of PROJECT_CONFIG_FILES) {
424
+ if (isUserFile(path.join(process.cwd(), rel)))
425
+ continue;
426
+ for (const [name, config] of Object.entries(readMcpServers(path.join(process.cwd(), rel)))) {
427
+ result.set(name, { config, source: rel, scope: 'project' });
190
428
  }
191
429
  }
192
- catch { }
193
430
  }
194
431
  return result;
195
432
  }