dgxay-code-cli 0.0.0-stage → 1.1.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 (46) hide show
  1. package/LICENSE +674 -0
  2. package/README-zh.md +242 -0
  3. package/README.md +223 -2
  4. package/bin/dgxay.js +42 -0
  5. package/package.json +47 -4
  6. package/src/cli/ansi.js +539 -0
  7. package/src/cli/approval.js +186 -0
  8. package/src/cli/args.js +162 -0
  9. package/src/cli/command-table.js +52 -0
  10. package/src/cli/editor.js +828 -0
  11. package/src/cli/i18n.js +276 -0
  12. package/src/cli/index.js +228 -0
  13. package/src/cli/print.js +148 -0
  14. package/src/cli/render.js +395 -0
  15. package/src/cli/repl-state.js +85 -0
  16. package/src/cli/repl.js +1231 -0
  17. package/src/cli/slash.js +75 -0
  18. package/src/cli/ui.js +52 -0
  19. package/src/commands/_shared.js +20 -0
  20. package/src/commands/chat.js +212 -0
  21. package/src/commands/config.js +363 -0
  22. package/src/commands/context.js +43 -0
  23. package/src/commands/doctor.js +192 -0
  24. package/src/commands/export.js +102 -0
  25. package/src/commands/mcp.js +257 -0
  26. package/src/commands/model.js +223 -0
  27. package/src/commands/oauth.js +246 -0
  28. package/src/commands/permission.js +108 -0
  29. package/src/commands/provider.js +332 -0
  30. package/src/commands/run.js +172 -0
  31. package/src/commands/skill.js +94 -0
  32. package/src/commands/workspace.js +100 -0
  33. package/src/core/agent.js +420 -0
  34. package/src/core/llm.js +709 -0
  35. package/src/core/mcp.js +271 -0
  36. package/src/core/oauth.js +737 -0
  37. package/src/core/prompt.js +269 -0
  38. package/src/core/sessions.js +208 -0
  39. package/src/core/tools.js +391 -0
  40. package/src/services/config.js +182 -0
  41. package/src/services/input.js +91 -0
  42. package/src/services/session-bus.js +104 -0
  43. package/src/services/settings.js +340 -0
  44. package/src/services/skills.js +125 -0
  45. package/src/services/terminal.js +124 -0
  46. package/src/services/version.js +25 -0
@@ -0,0 +1,391 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * 工具定义、权限矩阵与文件 / 命令 / 联网执行。
5
+ *
6
+ * 权限在主进程逻辑之外再校验一次,渲染层(这里是交互层)无法绕过:
7
+ * - none 仅允许只读工作空间内文件
8
+ * - normal 每次调用都必须经 requestApproval 同意
9
+ * - auto 先由审查模型判断,可疑才询问
10
+ * - full 直接执行
11
+ *
12
+ * 新增工具时必须同步更新:DEFINITIONS / PERMISSION_TOOLS / execute() 分支 /
13
+ * 系统提示中的能力描述 / 审查授权路径。
14
+ */
15
+
16
+ const fs = require('fs');
17
+ const fsp = require('fs/promises');
18
+ const path = require('path');
19
+ const os = require('os');
20
+ const { exec } = require('child_process');
21
+
22
+ const MAX_READ = 120000;
23
+ const MAX_OUTPUT = 24000;
24
+
25
+ const DEFINITIONS = {
26
+ list_directory: {
27
+ type: 'function',
28
+ function: {
29
+ name: 'list_directory',
30
+ description:
31
+ 'List files and folders inside a directory. Use it to understand the workspace before reading or editing files.',
32
+ parameters: {
33
+ type: 'object',
34
+ properties: {
35
+ path: {
36
+ type: 'string',
37
+ description: 'Directory path, absolute or relative to the workspace. Defaults to workspace root.'
38
+ },
39
+ depth: { type: 'integer', description: 'How deep to recurse, 1-3. Default 1.' }
40
+ },
41
+ additionalProperties: false
42
+ }
43
+ }
44
+ },
45
+ read_file: {
46
+ type: 'function',
47
+ function: {
48
+ name: 'read_file',
49
+ description: 'Read the text content of a file. Always read a file before modifying it.',
50
+ parameters: {
51
+ type: 'object',
52
+ properties: {
53
+ path: { type: 'string', description: 'File path, absolute or relative to the workspace.' }
54
+ },
55
+ required: ['path'],
56
+ additionalProperties: false
57
+ }
58
+ }
59
+ },
60
+ write_file: {
61
+ type: 'function',
62
+ function: {
63
+ name: 'write_file',
64
+ description:
65
+ 'Create or overwrite a file with the given content. Parent folders are created automatically. Generated files must stay inside the workspace.',
66
+ parameters: {
67
+ type: 'object',
68
+ properties: {
69
+ path: { type: 'string', description: 'Target file path, preferably relative to the workspace.' },
70
+ content: { type: 'string', description: 'Full file content to write.' }
71
+ },
72
+ required: ['path', 'content'],
73
+ additionalProperties: false
74
+ }
75
+ }
76
+ },
77
+ create_directory: {
78
+ type: 'function',
79
+ function: {
80
+ name: 'create_directory',
81
+ description: 'Create a folder (including missing parents) inside the workspace.',
82
+ parameters: {
83
+ type: 'object',
84
+ properties: { path: { type: 'string', description: 'Folder path.' } },
85
+ required: ['path'],
86
+ additionalProperties: false
87
+ }
88
+ }
89
+ },
90
+ delete_path: {
91
+ type: 'function',
92
+ function: {
93
+ name: 'delete_path',
94
+ description: 'Delete a file or folder. Use with care and only when the user asked for it.',
95
+ parameters: {
96
+ type: 'object',
97
+ properties: { path: { type: 'string', description: 'Path to delete.' } },
98
+ required: ['path'],
99
+ additionalProperties: false
100
+ }
101
+ }
102
+ },
103
+ run_command: {
104
+ type: 'function',
105
+ function: {
106
+ name: 'run_command',
107
+ description: 'Run a shell command on the user machine and return stdout/stderr.',
108
+ parameters: {
109
+ type: 'object',
110
+ properties: {
111
+ command: { type: 'string', description: 'Command line to execute.' },
112
+ cwd: { type: 'string', description: 'Working directory, defaults to the workspace.' }
113
+ },
114
+ required: ['command'],
115
+ additionalProperties: false
116
+ }
117
+ }
118
+ },
119
+ install_mcp_server: {
120
+ type: 'function',
121
+ function: {
122
+ name: 'install_mcp_server',
123
+ description:
124
+ 'Install an MCP server into DgxayCode by saving its startup command to ~/.dcb/mcp.json. The user can start it later with `dgxay mcp start`.',
125
+ parameters: {
126
+ type: 'object',
127
+ properties: {
128
+ name: { type: 'string', description: 'Display name for the MCP server.' },
129
+ command: { type: 'string', description: 'Executable used to start the server, e.g. npx or uvx.' },
130
+ args: { type: 'array', items: { type: 'string' }, description: 'Command arguments.' },
131
+ env: {
132
+ type: 'object',
133
+ additionalProperties: { type: 'string' },
134
+ description: 'Extra environment variables.'
135
+ },
136
+ cwd: { type: 'string', description: 'Optional working directory.' }
137
+ },
138
+ required: ['name', 'command'],
139
+ additionalProperties: false
140
+ }
141
+ }
142
+ },
143
+ fetch_url: {
144
+ type: 'function',
145
+ function: {
146
+ name: 'fetch_url',
147
+ description:
148
+ 'Fetch a URL over HTTP(S) and return the response body as text. In normal mode the user reviews the request before it runs.',
149
+ parameters: {
150
+ type: 'object',
151
+ properties: {
152
+ url: { type: 'string', description: 'Absolute http(s) URL.' },
153
+ method: {
154
+ type: 'string',
155
+ enum: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'],
156
+ description: 'HTTP method, default GET.'
157
+ },
158
+ body: { type: 'string', description: 'Optional request body (JSON or raw text).' }
159
+ },
160
+ required: ['url'],
161
+ additionalProperties: false
162
+ }
163
+ }
164
+ }
165
+ };
166
+
167
+ const PERMISSION_TOOLS = {
168
+ none: ['list_directory', 'read_file'],
169
+ // 普通权限与自动审查都拥有全部能力,区别在授权流程
170
+ normal: Object.keys(DEFINITIONS),
171
+ auto: Object.keys(DEFINITIONS),
172
+ full: Object.keys(DEFINITIONS)
173
+ };
174
+
175
+ function toolsFor(permission) {
176
+ const names = PERMISSION_TOOLS[permission] || PERMISSION_TOOLS.none;
177
+ return names.map((name) => DEFINITIONS[name]);
178
+ }
179
+
180
+ function sameOrInside(root, target) {
181
+ if (!root) return false;
182
+ const rel = path.relative(root.toLowerCase(), target.toLowerCase());
183
+ return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel));
184
+ }
185
+
186
+ function resolveTarget(input, workspace) {
187
+ const raw = String(input || '').trim();
188
+ if (!raw) throw new Error('Missing path argument');
189
+ const base = workspace || os.homedir();
190
+ return path.normalize(path.isAbsolute(raw) ? raw : path.resolve(base, raw));
191
+ }
192
+
193
+ function displayPath(abs, workspace) {
194
+ if (workspace && sameOrInside(workspace, abs)) {
195
+ const rel = path.relative(workspace, abs) || '.';
196
+ return rel.split(path.sep).join('/');
197
+ }
198
+ return abs;
199
+ }
200
+
201
+ function assertReadable(target, ctx) {
202
+ if (ctx.permission === 'none') {
203
+ if (!ctx.workspace) throw new Error('No workspace selected in read-only mode: cannot read files');
204
+ if (!sameOrInside(ctx.workspace, target)) {
205
+ throw new Error('Read-only mode only allows files inside the workspace');
206
+ }
207
+ }
208
+ }
209
+
210
+ function assertWritable(target, ctx) {
211
+ if (ctx.permission === 'none') throw new Error('Read-only mode forbids modifying files');
212
+ const roots = [path.parse(target).root, os.homedir(), ctx.workspace || ''].filter(Boolean);
213
+ for (const root of roots) {
214
+ if (path.normalize(target).toLowerCase() === path.normalize(root).toLowerCase()) {
215
+ throw new Error('Refusing to write to or delete a root directory');
216
+ }
217
+ }
218
+ }
219
+
220
+ async function walkTree(dir, depth, prefix, acc, limit) {
221
+ if (acc.length >= limit) return;
222
+ let entries = [];
223
+ try {
224
+ entries = await fsp.readdir(dir, { withFileTypes: true });
225
+ } catch (err) {
226
+ acc.push(`${prefix}[cannot read: ${err.code || err.message}]`);
227
+ return;
228
+ }
229
+ entries.sort((a, b) => Number(b.isDirectory()) - Number(a.isDirectory()) || a.name.localeCompare(b.name));
230
+ const visible = entries
231
+ .filter((e) => !e.name.startsWith('.') || e.name === '.dcb' || e.name === '.env.example')
232
+ .slice(0, 80);
233
+ for (const entry of visible) {
234
+ if (acc.length >= limit) {
235
+ acc.push(`${prefix}... (truncated)`);
236
+ return;
237
+ }
238
+ const full = path.join(dir, entry.name);
239
+ if (entry.isDirectory()) {
240
+ acc.push(`${prefix}${entry.name}/`);
241
+ if (depth > 1) {
242
+ await walkTree(full, depth - 1, `${prefix} `, acc, limit);
243
+ }
244
+ } else {
245
+ let size = 0;
246
+ try {
247
+ size = (await fsp.stat(full)).size;
248
+ } catch {
249
+ /* ignore */
250
+ }
251
+ acc.push(`${prefix}${entry.name} (${size} B)`);
252
+ }
253
+ }
254
+ }
255
+
256
+ function truncate(text, limit) {
257
+ if (text.length <= limit) return text;
258
+ return `${text.slice(0, limit)}\n\n... (output truncated, ${text.length} chars total)`;
259
+ }
260
+
261
+ async function execute(name, args, ctx) {
262
+ const permission = ctx.permission || 'none';
263
+ const allowed = PERMISSION_TOOLS[permission] || [];
264
+ if (!allowed.includes(name)) {
265
+ throw new Error(`Permission level (${permission}) does not allow tool ${name}`);
266
+ }
267
+ const workspace = ctx.workspace || '';
268
+
269
+ if (permission === 'normal' || permission === 'auto') {
270
+ if (typeof ctx.requestApproval !== 'function') {
271
+ throw new Error('Missing approval callback for this permission level — blocked');
272
+ }
273
+ const ok = await ctx.requestApproval({ name, args });
274
+ if (!ok) throw new Error('The user declined this operation — do not retry');
275
+ }
276
+
277
+ switch (name) {
278
+ case 'list_directory': {
279
+ const target = resolveTarget(args.path || '.', workspace);
280
+ assertReadable(target, ctx);
281
+ const stat = await fsp.stat(target);
282
+ if (!stat.isDirectory()) throw new Error(`${displayPath(target, workspace)} is not a directory`);
283
+ const acc = [`${displayPath(target, workspace)}/`];
284
+ await walkTree(target, Math.min(Math.max(Number(args.depth) || 1, 1), 3), ' ', acc, 200);
285
+ return acc.join('\n');
286
+ }
287
+ case 'read_file': {
288
+ const target = resolveTarget(args.path, workspace);
289
+ assertReadable(target, ctx);
290
+ const stat = await fsp.stat(target);
291
+ if (stat.isDirectory()) throw new Error('That path is a directory — use list_directory');
292
+ if (stat.size > 2 * 1024 * 1024) throw new Error('File too large (>2MB) — use a shell command instead');
293
+ const content = await fsp.readFile(target, 'utf8');
294
+ return `File: ${displayPath(target, workspace)}\n\n${truncate(content, MAX_READ)}`;
295
+ }
296
+ case 'write_file': {
297
+ const target = resolveTarget(args.path, workspace);
298
+ assertWritable(target, ctx);
299
+ const content = typeof args.content === 'string' ? args.content : String(args.content ?? '');
300
+ await fsp.mkdir(path.dirname(target), { recursive: true });
301
+ let action = 'created';
302
+ try {
303
+ await fsp.access(target);
304
+ action = 'updated';
305
+ } catch {
306
+ /* new file */
307
+ }
308
+ await fsp.writeFile(target, content, 'utf8');
309
+ const bytes = Buffer.byteLength(content, 'utf8');
310
+ ctx.onFile?.({ action, path: target, display: displayPath(target, workspace), bytes });
311
+ return `${action === 'created' ? 'Created' : 'Updated'} ${displayPath(target, workspace)} (${bytes} bytes)`;
312
+ }
313
+ case 'create_directory': {
314
+ const target = resolveTarget(args.path, workspace);
315
+ assertWritable(target, ctx);
316
+ await fsp.mkdir(target, { recursive: true });
317
+ return `Created directory ${displayPath(target, workspace)}`;
318
+ }
319
+ case 'delete_path': {
320
+ const target = resolveTarget(args.path, workspace);
321
+ assertWritable(target, ctx);
322
+ const stat = await fsp.stat(target);
323
+ await fsp.rm(target, { recursive: stat.isDirectory(), force: false });
324
+ ctx.onFile?.({ action: 'deleted', path: target, display: displayPath(target, workspace), bytes: 0 });
325
+ return `Deleted ${displayPath(target, workspace)}`;
326
+ }
327
+ case 'run_command': {
328
+ const cwd = args.cwd ? resolveTarget(args.cwd, workspace) : workspace || os.homedir();
329
+ const safeCwd = fs.existsSync(cwd) ? cwd : os.homedir();
330
+ return await new Promise((resolve) => {
331
+ exec(
332
+ String(args.command || ''),
333
+ { cwd: safeCwd, timeout: 180000, maxBuffer: 8 * 1024 * 1024, windowsHide: true, shell: true },
334
+ (err, stdout, stderr) => {
335
+ const parts = [`$ ${args.command}`];
336
+ if (stdout) parts.push(String(stdout).trim());
337
+ if (stderr) parts.push(`[stderr]\n${String(stderr).trim()}`);
338
+ if (err) parts.push(`[exit] code=${err.code ?? 'unknown'} ${err.killed ? '(timeout or killed)' : ''}`);
339
+ if (!stdout && !stderr && !err) parts.push('(no output)');
340
+ resolve(truncate(parts.join('\n\n'), MAX_OUTPUT));
341
+ }
342
+ );
343
+ });
344
+ }
345
+ case 'fetch_url': {
346
+ const url = String(args.url || '');
347
+ let parsed;
348
+ try {
349
+ parsed = new URL(url);
350
+ } catch {
351
+ throw new Error('Invalid URL');
352
+ }
353
+ if (!/^https?:$/.test(parsed.protocol)) throw new Error('Only http/https is supported');
354
+ const controller = new AbortController();
355
+ const timer = setTimeout(() => controller.abort(), 30000);
356
+ try {
357
+ const res = await fetch(url, {
358
+ method: (args.method || 'GET').toUpperCase(),
359
+ headers: {
360
+ 'User-Agent': 'Mozilla/5.0 DgxayCode-CLI/1.0',
361
+ ...(args.body ? { 'Content-Type': 'application/json' } : {})
362
+ },
363
+ body: args.body || undefined,
364
+ signal: controller.signal
365
+ });
366
+ const text = await res.text();
367
+ const contentType = res.headers.get('content-type') || '';
368
+ return truncate(`HTTP ${res.status} ${res.statusText}\ncontent-type: ${contentType}\n\n${text}`, MAX_OUTPUT);
369
+ } finally {
370
+ clearTimeout(timer);
371
+ }
372
+ }
373
+ case 'install_mcp_server': {
374
+ const mcp = require('./mcp');
375
+ const server = mcp.addServer({
376
+ name: args.name,
377
+ command: args.command,
378
+ args: args.args,
379
+ env: args.env,
380
+ cwd: args.cwd
381
+ });
382
+ return `Installed MCP server "${server.name}" (command: ${server.command} ${(server.args || []).join(
383
+ ' '
384
+ )})。请用 dgxay mcp start ${server.id} 或在 /mcp 面板中启动。`;
385
+ }
386
+ default:
387
+ throw new Error(`Unknown tool: ${name}`);
388
+ }
389
+ }
390
+
391
+ module.exports = { toolsFor, execute, PERMISSION_TOOLS, DEFINITIONS, resolveTarget, displayPath, sameOrInside };
@@ -0,0 +1,182 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * 数据目录与配置读写。
5
+ *
6
+ * 数据目录为 %USERPROFILE%\.dcb(Windows)或 ~/.dcb(macOS / Linux),
7
+ * 可用 DGXAY_HOME 覆盖(桌面版等价变量名 DCB_HOME 同样兼容),便于测试隔离。
8
+ *
9
+ * config.json / chats.json / oauth.json / mcp.json 均以 0600 原子写入,目录为 0700。
10
+ */
11
+
12
+ const fs = require('fs');
13
+ const path = require('path');
14
+ const os = require('os');
15
+
16
+ const DCB_DIR = process.env.DGXAY_HOME
17
+ ? path.resolve(process.env.DGXAY_HOME)
18
+ : process.env.DCB_HOME
19
+ ? path.resolve(process.env.DCB_HOME)
20
+ : path.join(os.homedir(), '.dcb');
21
+ const CONFIG_PATH = path.join(DCB_DIR, 'config.json');
22
+ const CHATS_PATH = path.join(DCB_DIR, 'chats.json');
23
+ const MCP_PATH = path.join(DCB_DIR, 'mcp.json');
24
+ const SKILLS_DIR = path.join(DCB_DIR, 'skills');
25
+ const OAUTH_PATH = path.join(DCB_DIR, 'oauth.json');
26
+
27
+ const DEFAULT_CONFIG = {
28
+ version: 1,
29
+ onboarded: false,
30
+ providers: [],
31
+ activeProviderId: '',
32
+ activeModelId: '',
33
+ ui: {
34
+ mode: 'chat',
35
+ permission: 'normal'
36
+ },
37
+ work: {
38
+ workspace: '',
39
+ recentWorkspaces: []
40
+ },
41
+ chat: {
42
+ stream: true,
43
+ temperature: 0.7,
44
+ maxTokens: 4096,
45
+ systemPrompt: ''
46
+ },
47
+ meta: {
48
+ createdAt: null,
49
+ updatedAt: null
50
+ }
51
+ };
52
+
53
+ function isPlainObject(value) {
54
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
55
+ }
56
+
57
+ function deepMerge(base, patch) {
58
+ if (!isPlainObject(patch)) return base;
59
+ const out = Array.isArray(base) ? base.slice() : { ...base };
60
+ for (const [key, value] of Object.entries(patch)) {
61
+ if (isPlainObject(value) && isPlainObject(base[key])) {
62
+ out[key] = deepMerge(base[key], value);
63
+ } else {
64
+ out[key] = value;
65
+ }
66
+ }
67
+ return out;
68
+ }
69
+
70
+ function ensureDir() {
71
+ if (!fs.existsSync(DCB_DIR)) {
72
+ fs.mkdirSync(DCB_DIR, { recursive: true, mode: 0o700 });
73
+ }
74
+ return DCB_DIR;
75
+ }
76
+
77
+ function hasDcbDir() {
78
+ try {
79
+ return fs.existsSync(DCB_DIR);
80
+ } catch {
81
+ return false;
82
+ }
83
+ }
84
+
85
+ function readConfig() {
86
+ const base = JSON.parse(JSON.stringify(DEFAULT_CONFIG));
87
+ if (!fs.existsSync(CONFIG_PATH)) return base;
88
+ try {
89
+ const raw = fs.readFileSync(CONFIG_PATH, 'utf8');
90
+ const parsed = JSON.parse(raw);
91
+ const merged = deepMerge(base, parsed);
92
+ if (!Array.isArray(merged.providers)) merged.providers = [];
93
+ if (!merged.ui) merged.ui = { ...base.ui };
94
+ merged.meta = {
95
+ ...merged.meta,
96
+ createdAt: merged.meta?.createdAt || new Date().toISOString(),
97
+ updatedAt: merged.meta?.updatedAt || new Date().toISOString()
98
+ };
99
+ return merged;
100
+ } catch {
101
+ // 配置损坏时备份,避免用户数据被直接覆盖丢失
102
+ try {
103
+ fs.copyFileSync(CONFIG_PATH, `${CONFIG_PATH}.broken-${Date.now()}`);
104
+ } catch {
105
+ /* ignore */
106
+ }
107
+ return base;
108
+ }
109
+ }
110
+
111
+ function writeJsonAtomic(target, data, mode = 0o600) {
112
+ ensureDir();
113
+ const tmp = `${target}.${process.pid}.tmp`;
114
+ fs.writeFileSync(tmp, `${JSON.stringify(data, null, 2)}\n`, { encoding: 'utf8', mode });
115
+ fs.renameSync(tmp, target);
116
+ }
117
+
118
+ function saveConfig(patch) {
119
+ const current = readConfig();
120
+ const next = deepMerge(current, patch || {});
121
+ const now = new Date().toISOString();
122
+ next.meta = {
123
+ createdAt: next.meta?.createdAt || now,
124
+ updatedAt: now
125
+ };
126
+ writeJsonAtomic(CONFIG_PATH, next);
127
+ return next;
128
+ }
129
+
130
+ function resetConfig() {
131
+ const fresh = JSON.parse(JSON.stringify(DEFAULT_CONFIG));
132
+ fresh.meta.createdAt = new Date().toISOString();
133
+ fresh.meta.updatedAt = fresh.meta.createdAt;
134
+ writeJsonAtomic(CONFIG_PATH, fresh);
135
+ return fresh;
136
+ }
137
+
138
+ function loadChats() {
139
+ try {
140
+ if (!fs.existsSync(CHATS_PATH)) return [];
141
+ const parsed = JSON.parse(fs.readFileSync(CHATS_PATH, 'utf8'));
142
+ return Array.isArray(parsed) ? parsed : [];
143
+ } catch {
144
+ return [];
145
+ }
146
+ }
147
+
148
+ function saveChats(chats) {
149
+ try {
150
+ // 与桌面版一致:最多保留 200 段对话,超出直接丢弃
151
+ writeJsonAtomic(CHATS_PATH, Array.isArray(chats) ? chats.slice(0, 200) : []);
152
+ return true;
153
+ } catch {
154
+ return false;
155
+ }
156
+ }
157
+
158
+ function maskKey(key) {
159
+ if (!key) return '';
160
+ const str = String(key);
161
+ if (str.length <= 8) return '••••••••';
162
+ return `${str.slice(0, 3)}••••••${str.slice(-4)}`;
163
+ }
164
+
165
+ module.exports = {
166
+ DCB_DIR,
167
+ CONFIG_PATH,
168
+ CHATS_PATH,
169
+ MCP_PATH,
170
+ SKILLS_DIR,
171
+ OAUTH_PATH,
172
+ DEFAULT_CONFIG,
173
+ deepMerge,
174
+ hasDcbDir,
175
+ readConfig,
176
+ saveConfig,
177
+ resetConfig,
178
+ loadChats,
179
+ saveChats,
180
+ ensureDir,
181
+ maskKey
182
+ };
@@ -0,0 +1,91 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * 可复用的小工具:提示输入、确认、掩码输入、状态行。
5
+ */
6
+
7
+ const readline = require('readline');
8
+ const { COLORS } = require('../cli/ansi');
9
+
10
+ function ask(prompt) {
11
+ return new Promise((resolve) => {
12
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
13
+ rl.question(prompt, (answer) => {
14
+ rl.close();
15
+ resolve(String(answer || ''));
16
+ });
17
+ rl.on('SIGINT', () => {
18
+ rl.close();
19
+ resolve(null);
20
+ });
21
+ });
22
+ }
23
+
24
+ /** 掩码输入(用于 API Key;非 TTY 时退化为普通读取) */
25
+ function askSecret(prompt, options = {}) {
26
+ if (!process.stdin.isTTY) return ask(prompt);
27
+ return new Promise((resolve) => {
28
+ process.stdout.write(prompt);
29
+ const stdin = process.stdin;
30
+ let value = '';
31
+ readline.emitKeypressEvents(stdin);
32
+ stdin.setRawMode(true);
33
+ stdin.resume();
34
+ const onKey = (str, key = {}) => {
35
+ if (key.ctrl && key.name === 'c') {
36
+ stdin.setRawMode(false);
37
+ stdin.removeListener('keypress', onKey);
38
+ process.stdout.write('\n');
39
+ resolve(null);
40
+ return;
41
+ }
42
+ if (key.name === 'return' || key.name === 'enter') {
43
+ stdin.setRawMode(false);
44
+ stdin.removeListener('keypress', onKey);
45
+ process.stdout.write('\n');
46
+ resolve(value);
47
+ return;
48
+ }
49
+ if (key.name === 'backspace') {
50
+ if (value.length) {
51
+ value = value.slice(0, -1);
52
+ process.stdout.write('\b \b');
53
+ }
54
+ return;
55
+ }
56
+ if (key.ctrl && key.name === 'u') {
57
+ process.stdout.write('\b \b'.repeat(value.length));
58
+ value = '';
59
+ return;
60
+ }
61
+ if (str && !key.ctrl && !key.meta) {
62
+ value += str;
63
+ process.stdout.write(options.showLength ? String(str.length) : '*');
64
+ }
65
+ };
66
+ stdin.on('keypress', onKey);
67
+ });
68
+ }
69
+
70
+ async function confirm(prompt, defaultYes = false) {
71
+ if (!process.stdin.isTTY) return false;
72
+ const suffix = defaultYes ? ' [Y/n] ' : ' [y/N] ';
73
+ const answer = await ask(`${prompt}${suffix}`);
74
+ if (answer === null) return false;
75
+ const text = answer.trim().toLowerCase();
76
+ if (!text) return defaultYes;
77
+ return text === 'y' || text === 'yes';
78
+ }
79
+
80
+ /** 单行状态提示(覆盖当前行,避免刷新时刷屏) */
81
+ function statusLine(text) {
82
+ if (!process.stdout.isTTY) return;
83
+ process.stdout.write(`\r\x1b[2K${COLORS.faint(text)}`);
84
+ }
85
+
86
+ function clearStatus() {
87
+ if (!process.stdout.isTTY) return;
88
+ process.stdout.write('\r\x1b[2K');
89
+ }
90
+
91
+ module.exports = { ask, askSecret, confirm, statusLine, clearStatus };