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.
- package/LICENSE +674 -0
- package/README-zh.md +242 -0
- package/README.md +223 -2
- package/bin/dgxay.js +42 -0
- package/package.json +47 -4
- package/src/cli/ansi.js +539 -0
- package/src/cli/approval.js +186 -0
- package/src/cli/args.js +162 -0
- package/src/cli/command-table.js +52 -0
- package/src/cli/editor.js +828 -0
- package/src/cli/i18n.js +276 -0
- package/src/cli/index.js +228 -0
- package/src/cli/print.js +148 -0
- package/src/cli/render.js +395 -0
- package/src/cli/repl-state.js +85 -0
- package/src/cli/repl.js +1231 -0
- package/src/cli/slash.js +75 -0
- package/src/cli/ui.js +52 -0
- package/src/commands/_shared.js +20 -0
- package/src/commands/chat.js +212 -0
- package/src/commands/config.js +363 -0
- package/src/commands/context.js +43 -0
- package/src/commands/doctor.js +192 -0
- package/src/commands/export.js +102 -0
- package/src/commands/mcp.js +257 -0
- package/src/commands/model.js +223 -0
- package/src/commands/oauth.js +246 -0
- package/src/commands/permission.js +108 -0
- package/src/commands/provider.js +332 -0
- package/src/commands/run.js +172 -0
- package/src/commands/skill.js +94 -0
- package/src/commands/workspace.js +100 -0
- package/src/core/agent.js +420 -0
- package/src/core/llm.js +709 -0
- package/src/core/mcp.js +271 -0
- package/src/core/oauth.js +737 -0
- package/src/core/prompt.js +269 -0
- package/src/core/sessions.js +208 -0
- package/src/core/tools.js +391 -0
- package/src/services/config.js +182 -0
- package/src/services/input.js +91 -0
- package/src/services/session-bus.js +104 -0
- package/src/services/settings.js +340 -0
- package/src/services/skills.js +125 -0
- package/src/services/terminal.js +124 -0
- 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: 'No permission',
|
|
19
|
+
short: 'read-only',
|
|
20
|
+
desc: 'Read-only access inside the workspace'
|
|
21
|
+
},
|
|
22
|
+
normal: {
|
|
23
|
+
label: 'Normal',
|
|
24
|
+
short: 'normal',
|
|
25
|
+
desc: 'All capabilities, but every operation asks you'
|
|
26
|
+
},
|
|
27
|
+
auto: {
|
|
28
|
+
label: 'Auto review',
|
|
29
|
+
short: 'auto',
|
|
30
|
+
desc: 'All capabilities; a reviewer model decides, suspicious ones ask you'
|
|
31
|
+
},
|
|
32
|
+
full: {
|
|
33
|
+
label: 'Full access',
|
|
34
|
+
short: 'full',
|
|
35
|
+
desc: 'Run commands, touch any file, unrestricted network'
|
|
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: 'e.g. 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: 'e.g. 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: 'e.g. 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(`Provider not found: ${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 must start with http:// or https://');
|
|
145
|
+
|
|
146
|
+
const models = normalizeModels(input.models, existing?.models);
|
|
147
|
+
if (!models.length) throw new Error('Provide at least one model 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('Pass --oauth-provider (openai/anthropic/xai/google) to use an OAuth account');
|
|
153
|
+
const account = oauth.listProviders().find((item) => item.id === oauthProvider);
|
|
154
|
+
if (!account?.connection || account.connection.expired) {
|
|
155
|
+
throw new Error(`${oauthProvider} is not connected or the login expired. Run dgxay oauth connect ${oauthProvider}`);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const record = {
|
|
160
|
+
id: existing?.id || newId('prov'),
|
|
161
|
+
name: String(input.name || existing?.name || 'My provider').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(`Provider not found: ${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(`Provider not found: ${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('No provider available');
|
|
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 ${provider.name} has no model ${modelRef}`);
|
|
238
|
+
return saveConfig({ activeProviderId: provider.id, activeModelId: model.id });
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function setPermission(permission) {
|
|
242
|
+
if (!PERMISSIONS[permission]) throw new Error(`Unknown permission: ${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} is not a directory`);
|
|
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('Missing 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… (skill content truncated)` : 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… (output too long, truncated)\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[failed to start] ${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… (command ran longer than 5 minutes, terminated)\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 };
|