dgxay-code-cli 0.0.0-stage → 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.
Files changed (45) hide show
  1. package/LICENSE +674 -0
  2. package/README-zh.md +200 -0
  3. package/README.md +182 -2
  4. package/bin/dgxay.js +42 -0
  5. package/package.json +47 -4
  6. package/src/cli/ansi.js +509 -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 +574 -0
  11. package/src/cli/index.js +228 -0
  12. package/src/cli/print.js +148 -0
  13. package/src/cli/render.js +324 -0
  14. package/src/cli/repl-state.js +85 -0
  15. package/src/cli/repl.js +1167 -0
  16. package/src/cli/slash.js +74 -0
  17. package/src/cli/ui.js +52 -0
  18. package/src/commands/_shared.js +20 -0
  19. package/src/commands/chat.js +212 -0
  20. package/src/commands/config.js +363 -0
  21. package/src/commands/context.js +43 -0
  22. package/src/commands/doctor.js +192 -0
  23. package/src/commands/export.js +102 -0
  24. package/src/commands/mcp.js +257 -0
  25. package/src/commands/model.js +223 -0
  26. package/src/commands/oauth.js +246 -0
  27. package/src/commands/permission.js +108 -0
  28. package/src/commands/provider.js +332 -0
  29. package/src/commands/run.js +172 -0
  30. package/src/commands/skill.js +94 -0
  31. package/src/commands/workspace.js +100 -0
  32. package/src/core/agent.js +419 -0
  33. package/src/core/llm.js +709 -0
  34. package/src/core/mcp.js +271 -0
  35. package/src/core/oauth.js +737 -0
  36. package/src/core/prompt.js +269 -0
  37. package/src/core/sessions.js +208 -0
  38. package/src/core/tools.js +391 -0
  39. package/src/services/config.js +182 -0
  40. package/src/services/input.js +91 -0
  41. package/src/services/session-bus.js +104 -0
  42. package/src/services/settings.js +340 -0
  43. package/src/services/skills.js +125 -0
  44. package/src/services/terminal.js +124 -0
  45. package/src/services/version.js +25 -0
@@ -0,0 +1,104 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * 会话状态桥:让另一个终端里的子命令可以看到并使用当前交互会话的
5
+ * 工作空间、模型、权限、会话 id。
6
+ *
7
+ * 状态文件:~/.dcb/repl-state.json(0600,原子写入,每 5 秒刷新心跳)。
8
+ * 这样即使两个进程同时运行,也只会各自读一份最新状态,不互相覆盖配置。
9
+ */
10
+
11
+ const fs = require('fs');
12
+ const path = require('path');
13
+ const { DCB_DIR } = require('./config');
14
+
15
+ const STATE_PATH = path.join(DCB_DIR, 'repl-state.json');
16
+ const HEARTBEAT_MS = 5000;
17
+ const STALE_MS = 60000;
18
+
19
+ function read() {
20
+ try {
21
+ const raw = fs.readFileSync(STATE_PATH, 'utf8');
22
+ const parsed = JSON.parse(raw);
23
+ return parsed && typeof parsed === 'object' ? parsed : null;
24
+ } catch {
25
+ return null;
26
+ }
27
+ }
28
+
29
+ function write(state) {
30
+ try {
31
+ fs.mkdirSync(DCB_DIR, { recursive: true, mode: 0o700 });
32
+ const tmp = `${STATE_PATH}.${process.pid}.tmp`;
33
+ fs.writeFileSync(tmp, `${JSON.stringify({ ...state, updatedAt: Date.now() }, null, 2)}\n`, {
34
+ encoding: 'utf8',
35
+ mode: 0o600
36
+ });
37
+ fs.renameSync(tmp, STATE_PATH);
38
+ return true;
39
+ } catch {
40
+ return false;
41
+ }
42
+ }
43
+
44
+ function clear(state = null) {
45
+ const current = state || read();
46
+ if (current && current.pid && current.pid !== process.pid) return false;
47
+ try {
48
+ if (fs.existsSync(STATE_PATH)) fs.unlinkSync(STATE_PATH);
49
+ return true;
50
+ } catch {
51
+ return false;
52
+ }
53
+ }
54
+
55
+ function pidAlive(pid) {
56
+ if (!Number.isInteger(pid) || pid <= 0) return false;
57
+ try {
58
+ process.kill(pid, 0);
59
+ return true;
60
+ } catch (err) {
61
+ return err.code === 'EPERM';
62
+ }
63
+ }
64
+
65
+ /** 读取仍然「活着」的会话状态 */
66
+ function live() {
67
+ const state = read();
68
+ if (!state) return null;
69
+ if (!pidAlive(state.pid)) return null;
70
+ if (state.updatedAt && Date.now() - Number(state.updatedAt) > STALE_MS) return null;
71
+ return state;
72
+ }
73
+
74
+ /** 由 REPL 持有:周期性刷新心跳 */
75
+ class Heartbeat {
76
+ constructor(getState) {
77
+ this.getState = getState;
78
+ this.timer = null;
79
+ }
80
+
81
+ start() {
82
+ this.stop();
83
+ const beat = () => {
84
+ try {
85
+ const state = this.getState();
86
+ if (state) write({ ...state, pid: process.pid });
87
+ } catch {
88
+ /* ignore */
89
+ }
90
+ };
91
+ beat();
92
+ this.timer = setInterval(beat, HEARTBEAT_MS);
93
+ this.timer.unref?.();
94
+ }
95
+
96
+ stop() {
97
+ if (this.timer) {
98
+ clearInterval(this.timer);
99
+ this.timer = null;
100
+ }
101
+ }
102
+ }
103
+
104
+ module.exports = { STATE_PATH, read, write, clear, live, Heartbeat, pidAlive, STALE_MS };
@@ -0,0 +1,340 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * 配置操作层:供应商 / 模型 / 权限 / 首选项的读写,供子命令与 REPL 共用。
5
+ */
6
+
7
+ const fs = require('fs');
8
+ const os = require('os');
9
+ const path = require('path');
10
+ const cfg = require('../services/config');
11
+ const oauth = require('../core/oauth');
12
+ const { PROTOCOLS } = require('../core/llm');
13
+ const { resolveReviewer, resolveProviderAuth } = require('../core/prompt');
14
+ const { listModels } = require('../core/llm');
15
+
16
+ const PERMISSIONS = {
17
+ none: {
18
+ label: '无权限',
19
+ short: '只读',
20
+ desc: '仅可只读访问工作空间文件,禁止联网与修改文件'
21
+ },
22
+ normal: {
23
+ label: '普通权限',
24
+ short: '普通',
25
+ desc: '拥有全部能力,但每次操作都需要你确认'
26
+ },
27
+ auto: {
28
+ label: '自动审查',
29
+ short: '审查',
30
+ desc: '拥有全部能力,先由审查模型判断,可疑操作再询问你'
31
+ },
32
+ full: {
33
+ label: '完全权限',
34
+ short: '完全',
35
+ desc: '可执行终端命令、访问任意文件、无限制联网'
36
+ }
37
+ };
38
+
39
+ const PROTOCOL_META = {
40
+ openai: {
41
+ short: 'ChatCompletions',
42
+ full: 'OpenAI ChatCompletions',
43
+ path: '/v1/chat/completions',
44
+ basePlaceholder: 'https://api.openai.com/v1',
45
+ modelPlaceholder: '如 gpt-4o、deepseek-chat'
46
+ },
47
+ anthropic: {
48
+ short: 'Messages',
49
+ full: 'Anthropic Messages',
50
+ path: '/v1/messages',
51
+ basePlaceholder: 'https://api.anthropic.com',
52
+ modelPlaceholder: '如 claude-sonnet-4-5'
53
+ },
54
+ responses: {
55
+ short: 'Responses',
56
+ full: 'OpenAI Responses API',
57
+ path: '/v1/responses',
58
+ basePlaceholder: 'https://api.openai.com/v1',
59
+ modelPlaceholder: '如 gpt-5、o4-mini'
60
+ }
61
+ };
62
+
63
+ function newId(prefix) {
64
+ return `${prefix}_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 6)}`;
65
+ }
66
+
67
+ function readConfig() {
68
+ return cfg.readConfig();
69
+ }
70
+
71
+ function saveConfig(patch) {
72
+ return cfg.saveConfig(patch || {});
73
+ }
74
+
75
+ function providers() {
76
+ return cfg.readConfig().providers || [];
77
+ }
78
+
79
+ function findProvider(ref) {
80
+ if (!ref) return null;
81
+ const list = providers();
82
+ const text = String(ref).trim();
83
+ return (
84
+ list.find((p) => p.id === text) ||
85
+ list.find((p) => String(p.name).toLowerCase() === text.toLowerCase()) ||
86
+ null
87
+ );
88
+ }
89
+
90
+ function activeProvider(config = readConfig()) {
91
+ const list = config.providers || [];
92
+ return list.find((p) => p.id === config.activeProviderId) || list[0] || null;
93
+ }
94
+
95
+ function activeModel(config = readConfig()) {
96
+ const provider = activeProvider(config);
97
+ if (!provider) return null;
98
+ const models = provider.models || [];
99
+ const model = models.find((m) => m.id === config.activeModelId) || models[0];
100
+ if (!model) return null;
101
+ return {
102
+ id: model.id,
103
+ name: model.name || model.id,
104
+ providerId: provider.id,
105
+ providerName: provider.name,
106
+ protocol: provider.protocol || 'openai',
107
+ contextWindow: Number(model.contextWindow) || 0
108
+ };
109
+ }
110
+
111
+ function availablePermissions(config = readConfig()) {
112
+ const list = ['none', 'normal'];
113
+ if (resolveReviewer(config, config.activeProviderId)) list.push('auto');
114
+ list.push('full');
115
+ return list;
116
+ }
117
+
118
+ function hasReviewModel(config = readConfig()) {
119
+ return Boolean(resolveReviewer(config, config.activeProviderId));
120
+ }
121
+
122
+ function currentPermission(config = readConfig()) {
123
+ const value = config.ui?.permission || 'normal';
124
+ if (value === 'auto' && !hasReviewModel(config)) return 'normal';
125
+ return PERMISSIONS[value] ? value : 'normal';
126
+ }
127
+
128
+ function currentMode(config = readConfig()) {
129
+ return config.ui?.mode === 'work' ? 'work' : 'chat';
130
+ }
131
+
132
+ /**
133
+ * 新增或修改供应商。
134
+ * @param {object} input { ref, name, protocol, baseUrl, apiKey, authMode, oauthProvider, reviewModelId, models }
135
+ */
136
+ function upsertProvider(input = {}) {
137
+ const config = readConfig();
138
+ const list = config.providers || [];
139
+ const existing = input.ref ? findProvider(input.ref) : null;
140
+ if (input.ref && !existing) throw new Error(`找不到供应商:${input.ref}`);
141
+
142
+ const protocol = PROTOCOLS.includes(input.protocol) ? input.protocol : existing?.protocol || 'openai';
143
+ const baseUrl = String(input.baseUrl || existing?.baseUrl || '').trim().replace(/\/+$/, '');
144
+ if (!/^https?:\/\//i.test(baseUrl)) throw new Error('Base URL 必须以 http:// 或 https:// 开头');
145
+
146
+ const models = normalizeModels(input.models, existing?.models);
147
+ if (!models.length) throw new Error('请至少提供一个模型 ID(--model)');
148
+
149
+ const authMode = input.authMode === 'oauth' ? 'oauth' : 'apiKey';
150
+ const oauthProvider = authMode === 'oauth' ? String(input.oauthProvider || '').trim() : '';
151
+ if (authMode === 'oauth') {
152
+ if (!oauthProvider) throw new Error('使用 OAuth 账户时必须指定 --oauth-provider(openai/anthropic/xai/google)');
153
+ const account = oauth.listProviders().find((item) => item.id === oauthProvider);
154
+ if (!account?.connection || account.connection.expired) {
155
+ throw new Error(`${oauthProvider} 账户尚未连接或登录已过期,请先运行 dgxay oauth connect ${oauthProvider}`);
156
+ }
157
+ }
158
+
159
+ const record = {
160
+ id: existing?.id || newId('prov'),
161
+ name: String(input.name || existing?.name || '我的供应商').trim(),
162
+ protocol,
163
+ baseUrl,
164
+ apiKey: input.apiKey !== undefined ? String(input.apiKey) : existing?.apiKey || '',
165
+ authMode,
166
+ oauthProvider,
167
+ reviewModelId:
168
+ input.reviewModelId !== undefined
169
+ ? String(input.reviewModelId || '')
170
+ : existing?.reviewModelId || models[0].id,
171
+ models
172
+ };
173
+ if (!models.some((m) => m.id === record.reviewModelId)) record.reviewModelId = models[0].id;
174
+
175
+ const next = list.map((p) => (p.id === record.id ? { ...p, ...record } : p));
176
+ if (!existing) next.push(record);
177
+
178
+ const patch = { providers: next };
179
+ if (!config.activeProviderId || existing?.id === config.activeProviderId) {
180
+ patch.activeProviderId = record.id;
181
+ const stillValid = models.find((m) => m.id === config.activeModelId);
182
+ if (existing?.id !== config.activeProviderId || !stillValid) patch.activeModelId = record.reviewModelId;
183
+ }
184
+ return { provider: record, config: saveConfig(patch), created: !existing };
185
+ }
186
+
187
+ function normalizeModels(input, fallback = []) {
188
+ let list = input;
189
+ if (typeof list === 'string') list = list.split(',').map((item) => item.trim()).filter(Boolean);
190
+ if (!Array.isArray(list) || !list.length) {
191
+ list = (fallback || []).map((m) => ({ id: m.id, name: m.name, contextWindow: m.contextWindow }));
192
+ }
193
+ const out = [];
194
+ for (const item of list) {
195
+ const model =
196
+ typeof item === 'string' ? { id: item } : { id: item?.id, name: item?.name, contextWindow: item?.contextWindow };
197
+ const id = String(model.id || '').trim();
198
+ if (!id) continue;
199
+ if (out.find((m) => m.id === id)) continue;
200
+ out.push({ id, name: String(model.name || id).trim(), contextWindow: Number(model.contextWindow) || 0 });
201
+ }
202
+ return out;
203
+ }
204
+
205
+ function removeProvider(ref) {
206
+ const config = readConfig();
207
+ const target = findProvider(ref);
208
+ if (!target) throw new Error(`找不到供应商:${ref}`);
209
+ const next = (config.providers || []).filter((p) => p.id !== target.id);
210
+ const patch = { providers: next };
211
+ if (config.activeProviderId === target.id) {
212
+ patch.activeProviderId = next[0]?.id || '';
213
+ patch.activeModelId = next[0]?.models?.[0]?.id || '';
214
+ }
215
+ return saveConfig(patch);
216
+ }
217
+
218
+ function moveProvider(ref, direction) {
219
+ const config = readConfig();
220
+ const list = (config.providers || []).slice();
221
+ const target = findProvider(ref);
222
+ if (!target) throw new Error(`找不到供应商:${ref}`);
223
+ const index = list.findIndex((p) => p.id === target.id);
224
+ const nextIndex = direction < 0 ? index - 1 : index + 1;
225
+ if (nextIndex < 0 || nextIndex >= list.length) return config;
226
+ const [item] = list.splice(index, 1);
227
+ list.splice(nextIndex, 0, item);
228
+ return saveConfig({ providers: list });
229
+ }
230
+
231
+ function setActiveModel(providerRef, modelRef) {
232
+ const config = readConfig();
233
+ const provider = providerRef ? findProvider(providerRef) : activeProvider(config);
234
+ if (!provider) throw new Error('没有可用的供应商');
235
+ const models = provider.models || [];
236
+ const model = models.find((m) => m.id === modelRef) || models.find((m) => m.id.includes(String(modelRef)));
237
+ if (!model) throw new Error(`供应商 ${provider.name} 下没有模型 ${modelRef}`);
238
+ return saveConfig({ activeProviderId: provider.id, activeModelId: model.id });
239
+ }
240
+
241
+ function setPermission(permission) {
242
+ if (!PERMISSIONS[permission]) throw new Error(`未知权限:${permission}`);
243
+ return saveConfig({ ui: { permission } });
244
+ }
245
+
246
+ function setMode(mode) {
247
+ return saveConfig({ ui: { mode: mode === 'work' ? 'work' : 'chat' } });
248
+ }
249
+
250
+ function setChatOptions(patch) {
251
+ return saveConfig({ chat: patch });
252
+ }
253
+
254
+ function setWorkspace(dir) {
255
+ const config = readConfig();
256
+ const target = dir ? path.resolve(dir) : '';
257
+ if (target) {
258
+ const stat = fs.statSync(target);
259
+ if (!stat.isDirectory()) throw new Error(`${target} 不是文件夹`);
260
+ }
261
+ const recents = (config.work?.recentWorkspaces || []).filter((item) => item !== target);
262
+ if (target) recents.unshift(target);
263
+ return saveConfig({
264
+ work: {
265
+ workspace: target,
266
+ recentWorkspaces: recents.slice(0, 12)
267
+ }
268
+ });
269
+ }
270
+
271
+ /** 拉取模型列表(失败不阻断,允许手动填写) */
272
+ async function fetchModels({ providerRef, baseUrl, apiKey, protocol }) {
273
+ const config = readConfig();
274
+ const provider = providerRef ? findProvider(providerRef) : activeProvider(config);
275
+ const targetBaseUrl = baseUrl || provider?.baseUrl;
276
+ const targetProtocol = protocol || provider?.protocol || 'openai';
277
+ if (!targetBaseUrl) throw new Error('缺少 Base URL');
278
+ let token = apiKey ?? provider?.apiKey ?? '';
279
+ let authType = 'apiKey';
280
+ if (provider && provider.authMode === 'oauth' && provider.oauthProvider && apiKey === undefined) {
281
+ token = await oauth.getAccessToken(provider.oauthProvider);
282
+ authType = 'oauth';
283
+ }
284
+ const controller = new AbortController();
285
+ const timer = setTimeout(() => controller.abort(), 20000);
286
+ try {
287
+ return await listModels({ baseUrl: targetBaseUrl, apiKey: token, protocol: targetProtocol, authType, signal: controller.signal });
288
+ } finally {
289
+ clearTimeout(timer);
290
+ }
291
+ }
292
+
293
+ async function authOf(provider) {
294
+ return resolveProviderAuth(provider);
295
+ }
296
+
297
+ function paths() {
298
+ return {
299
+ dataDir: cfg.DCB_DIR,
300
+ config: cfg.CONFIG_PATH,
301
+ chats: cfg.CHATS_PATH,
302
+ mcp: cfg.MCP_PATH,
303
+ skills: cfg.SKILLS_DIR,
304
+ oauth: cfg.OAUTH_PATH
305
+ };
306
+ }
307
+
308
+ function homeRelative(target) {
309
+ const home = os.homedir();
310
+ return target && String(target).startsWith(home) ? `~${String(target).slice(home.length)}` : target;
311
+ }
312
+
313
+ module.exports = {
314
+ PERMISSIONS,
315
+ PROTOCOL_META,
316
+ readConfig,
317
+ saveConfig,
318
+ providers,
319
+ findProvider,
320
+ activeProvider,
321
+ activeModel,
322
+ availablePermissions,
323
+ hasReviewModel,
324
+ currentPermission,
325
+ currentMode,
326
+ upsertProvider,
327
+ normalizeModels,
328
+ removeProvider,
329
+ moveProvider,
330
+ setActiveModel,
331
+ setPermission,
332
+ setMode,
333
+ setChatOptions,
334
+ setWorkspace,
335
+ fetchModels,
336
+ authOf,
337
+ paths,
338
+ homeRelative,
339
+ newId
340
+ };
@@ -0,0 +1,125 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * 技能扫描:~/.dcb/skills
5
+ * - 含 SKILL.md 的文件夹
6
+ * - 单个 name.md
7
+ * 支持 name / description frontmatter;单次注入上限 40000 字符。
8
+ */
9
+
10
+ const fs = require('fs');
11
+ const path = require('path');
12
+ const { SKILLS_DIR } = require('./config');
13
+
14
+ const MAX_SKILL_CONTENT = 40000;
15
+
16
+ function ensureSkillsDir() {
17
+ try {
18
+ fs.mkdirSync(SKILLS_DIR, { recursive: true, mode: 0o700 });
19
+ } catch {
20
+ /* ignore */
21
+ }
22
+ }
23
+
24
+ function parseFrontmatter(text) {
25
+ const normalized = String(text || '').replace(/\r\n/g, '\n');
26
+ if (!normalized.startsWith('---\n')) {
27
+ return { meta: {}, body: normalized };
28
+ }
29
+ const end = normalized.indexOf('\n---', 4);
30
+ if (end === -1) return { meta: {}, body: normalized };
31
+ const raw = normalized.slice(4, end);
32
+ const meta = {};
33
+ for (const line of raw.split('\n')) {
34
+ const idx = line.indexOf(':');
35
+ if (idx === -1) continue;
36
+ const key = line.slice(0, idx).trim();
37
+ let value = line.slice(idx + 1).trim();
38
+ if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
39
+ value = value.slice(1, -1);
40
+ }
41
+ if (key) meta[key] = value;
42
+ }
43
+ return { meta, body: normalized.slice(end + 4).replace(/^\n+/, '') };
44
+ }
45
+
46
+ function describe(body) {
47
+ const lines = String(body || '')
48
+ .split('\n')
49
+ .map((line) => line.trim())
50
+ .filter(Boolean)
51
+ .filter((line) => !line.startsWith('#') && !line.startsWith('```') && !line.startsWith('---'));
52
+ const text = lines.join(' ');
53
+ return text.length > 160 ? `${text.slice(0, 160)}…` : text;
54
+ }
55
+
56
+ function candidateFiles() {
57
+ ensureSkillsDir();
58
+ let entries = [];
59
+ try {
60
+ entries = fs.readdirSync(SKILLS_DIR, { withFileTypes: true });
61
+ } catch {
62
+ return [];
63
+ }
64
+ const files = [];
65
+ for (const entry of entries) {
66
+ if (entry.name.startsWith('.')) continue;
67
+ const full = path.join(SKILLS_DIR, entry.name);
68
+ if (entry.isDirectory()) {
69
+ const skillFile = path.join(full, 'SKILL.md');
70
+ if (fs.existsSync(skillFile)) files.push({ file: skillFile, fallbackName: entry.name });
71
+ } else if (entry.isFile() && /\.md$/i.test(entry.name)) {
72
+ files.push({ file: full, fallbackName: entry.name.replace(/\.md$/i, '') });
73
+ }
74
+ }
75
+ return files;
76
+ }
77
+
78
+ function listSkills() {
79
+ const skills = [];
80
+ for (const { file, fallbackName } of candidateFiles()) {
81
+ let text = '';
82
+ try {
83
+ text = fs.readFileSync(file, 'utf8');
84
+ } catch {
85
+ continue;
86
+ }
87
+ const { meta, body } = parseFrontmatter(text);
88
+ const name = String(meta.name || fallbackName).trim();
89
+ if (!name) continue;
90
+ skills.push({
91
+ name,
92
+ description: String(meta.description || describe(body) || '').trim(),
93
+ path: file
94
+ });
95
+ }
96
+ skills.sort((a, b) => a.name.localeCompare(b.name));
97
+ return skills;
98
+ }
99
+
100
+ function readSkill(name) {
101
+ const target = String(name || '').trim().toLowerCase();
102
+ if (!target) return null;
103
+ for (const { file, fallbackName } of candidateFiles()) {
104
+ let text = '';
105
+ try {
106
+ text = fs.readFileSync(file, 'utf8');
107
+ } catch {
108
+ continue;
109
+ }
110
+ const { meta, body } = parseFrontmatter(text);
111
+ const skillName = String(meta.name || fallbackName).trim();
112
+ if (skillName.toLowerCase() !== target) continue;
113
+ const content =
114
+ body.length > MAX_SKILL_CONTENT ? `${body.slice(0, MAX_SKILL_CONTENT)}\n\n…(技能内容已截断)` : body;
115
+ return {
116
+ name: skillName,
117
+ description: String(meta.description || describe(body) || '').trim(),
118
+ content,
119
+ path: file
120
+ };
121
+ }
122
+ return null;
123
+ }
124
+
125
+ module.exports = { SKILLS_DIR, listSkills, readSkill, ensureSkillsDir };
@@ -0,0 +1,124 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * 终端命令执行(对应桌面版的 `!` 命令)。
5
+ *
6
+ * 与工具 run_command 的区别:这是用户自己发起的命令,输出会展示并进入对话上下文。
7
+ * 权限与工具一致:none 拒绝;normal 每次询问;auto 先审查;full 直接执行。
8
+ * 同一时间只允许一个命令;超过 5 分钟自动终止;累计输出超过 300000 字符截断。
9
+ */
10
+
11
+ const { spawn, execFile } = require('child_process');
12
+ const os = require('os');
13
+ const fs = require('fs');
14
+
15
+ const MAX_TOTAL = 300000;
16
+ const TIMEOUT_MS = 300000;
17
+
18
+ function killProcessTree(child) {
19
+ if (!child || !child.pid) return;
20
+ if (process.platform === 'win32') {
21
+ execFile('taskkill', ['/pid', String(child.pid), '/T', '/F'], () => {});
22
+ return;
23
+ }
24
+ try {
25
+ child.kill('SIGTERM');
26
+ } catch {
27
+ /* ignore */
28
+ }
29
+ setTimeout(() => {
30
+ try {
31
+ if (!child.killed) child.kill('SIGKILL');
32
+ } catch {
33
+ /* ignore */
34
+ }
35
+ }, 2000).unref?.();
36
+ }
37
+
38
+ function resolveCwd(workspace) {
39
+ const target = workspace || os.homedir();
40
+ try {
41
+ return fs.statSync(target).isDirectory() ? target : os.homedir();
42
+ } catch {
43
+ return os.homedir();
44
+ }
45
+ }
46
+
47
+ /**
48
+ * 运行命令。
49
+ * @param {object} options
50
+ * @param {string} options.command
51
+ * @param {string} [options.workspace]
52
+ * @param {(chunk: string, stream: 'stdout'|'stderr') => void} [options.onOutput]
53
+ * @returns {Promise<{ ok:boolean, code:number|null, signal:string|null, output:string, stderr:string, aborted:boolean, error?:string }>}
54
+ */
55
+ function runShellCommand({ command, workspace, onOutput }) {
56
+ return new Promise((resolve) => {
57
+ const isWindows = process.platform === 'win32';
58
+ const shellCommand = isWindows ? `chcp 65001>nul & ${command}` : command;
59
+ const cwd = resolveCwd(workspace);
60
+ let child;
61
+ try {
62
+ child = spawn(shellCommand, {
63
+ cwd,
64
+ shell: true,
65
+ windowsHide: true,
66
+ env: { ...process.env },
67
+ stdio: ['ignore', 'pipe', 'pipe']
68
+ });
69
+ } catch (err) {
70
+ resolve({ ok: false, code: null, signal: null, output: '', stderr: '', aborted: false, error: err.message });
71
+ return;
72
+ }
73
+
74
+ let output = '';
75
+ let stderr = '';
76
+ let total = 0;
77
+ let truncated = false;
78
+ let finished = false;
79
+
80
+ const push = (stream, chunk) => {
81
+ const text = chunk.toString('utf8').replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, '');
82
+ total += text.length;
83
+ if (total > MAX_TOTAL) {
84
+ if (!truncated) {
85
+ truncated = true;
86
+ const note = '\n…(输出过长,已截断)\n';
87
+ stderr += note;
88
+ onOutput?.(note, 'stderr');
89
+ }
90
+ return;
91
+ }
92
+ if (stream === 'stderr') stderr += text;
93
+ else output += text;
94
+ onOutput?.(text, stream);
95
+ };
96
+
97
+ child.stdout?.on('data', (chunk) => push('stdout', chunk));
98
+ child.stderr?.on('data', (chunk) => push('stderr', chunk));
99
+
100
+ const finish = (code, signal) => {
101
+ if (finished) return;
102
+ finished = true;
103
+ clearTimeout(timer);
104
+ resolve({ ok: true, code, signal, output, stderr, aborted: signal === 'SIGTERM' || signal === 'SIGKILL' });
105
+ };
106
+
107
+ child.on('error', (err) => {
108
+ onOutput?.(`\n[启动失败] ${err.message}\n`, 'stderr');
109
+ finish(-1, null);
110
+ });
111
+ child.on('exit', (code, signal) => finish(code, signal));
112
+
113
+ const timer = setTimeout(() => {
114
+ onOutput?.('\n…(命令执行超过 5 分钟,已终止)\n', 'stderr');
115
+ killProcessTree(child);
116
+ }, TIMEOUT_MS);
117
+ child.on('exit', () => clearTimeout(timer));
118
+ if (child.unref) {
119
+ /* 保持引用,等待退出 */
120
+ }
121
+ });
122
+ }
123
+
124
+ module.exports = { runShellCommand, killProcessTree, MAX_TOTAL, TIMEOUT_MS };