@adversity/coding-tool-x 2.4.0 → 2.4.1
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/CHANGELOG.md +17 -0
- package/dist/web/assets/icons-Dom8a0SN.js +1 -0
- package/dist/web/assets/index-CQeUIH7E.css +41 -0
- package/dist/web/assets/index-YrjlFzC4.js +14 -0
- package/dist/web/assets/naive-ui-BjMHakwv.js +1 -0
- package/dist/web/assets/vendors-DtJKdpSj.js +7 -0
- package/dist/web/assets/vue-vendor-VFuFB5f4.js +44 -0
- package/dist/web/index.html +6 -2
- package/package.json +2 -2
- package/src/commands/export-config.js +205 -0
- package/src/server/api/config-sync.js +155 -0
- package/src/server/api/projects.js +2 -2
- package/src/server/api/sessions.js +70 -70
- package/src/server/api/skills.js +206 -0
- package/src/server/api/terminal.js +26 -0
- package/src/server/index.js +3 -0
- package/src/server/services/config-export-service.js +229 -23
- package/src/server/services/config-sync-service.js +515 -0
- package/src/server/services/enhanced-cache.js +196 -0
- package/src/server/services/pty-manager.js +35 -1
- package/src/server/services/sessions.js +122 -44
- package/src/server/services/skill-service.js +252 -2
- package/src/server/services/workspace-service.js +36 -18
- package/src/server/websocket-server.js +4 -1
- package/dist/web/assets/index-Bu1oPcKu.js +0 -4009
- package/dist/web/assets/index-XSok7-mN.css +0 -41
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
|
|
6
6
|
const os = require('os');
|
|
7
7
|
const path = require('path');
|
|
8
|
+
const fs = require('fs');
|
|
8
9
|
|
|
9
10
|
// 尝试加载 node-pty,如果失败则提示
|
|
10
11
|
let pty = null;
|
|
@@ -40,7 +41,22 @@ class PtyManager {
|
|
|
40
41
|
if (process.platform === 'win32') {
|
|
41
42
|
return process.env.COMSPEC || 'cmd.exe';
|
|
42
43
|
}
|
|
43
|
-
|
|
44
|
+
|
|
45
|
+
// 优先使用环境变量指定的 shell
|
|
46
|
+
if (process.env.SHELL && fs.existsSync(process.env.SHELL)) {
|
|
47
|
+
return process.env.SHELL;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// 回退到常见的 shell,按优先级检查
|
|
51
|
+
const commonShells = ['/bin/bash', '/bin/sh', '/usr/bin/bash', '/usr/bin/sh'];
|
|
52
|
+
for (const shell of commonShells) {
|
|
53
|
+
if (fs.existsSync(shell)) {
|
|
54
|
+
return shell;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// 最后回退
|
|
59
|
+
return '/bin/sh';
|
|
44
60
|
}
|
|
45
61
|
|
|
46
62
|
/**
|
|
@@ -78,6 +94,9 @@ class PtyManager {
|
|
|
78
94
|
// 检查 PTY 是否可用
|
|
79
95
|
if (!pty) {
|
|
80
96
|
const errMsg = this.getPtyError() || 'node-pty is not available';
|
|
97
|
+
console.error('[PTY] Cannot create terminal:', errMsg);
|
|
98
|
+
console.error('[PTY] Node version:', process.version);
|
|
99
|
+
console.error('[PTY] Platform:', process.platform);
|
|
81
100
|
throw new Error(`Cannot create terminal: ${errMsg}`);
|
|
82
101
|
}
|
|
83
102
|
|
|
@@ -94,6 +113,21 @@ class PtyManager {
|
|
|
94
113
|
startCommand = null
|
|
95
114
|
} = options;
|
|
96
115
|
|
|
116
|
+
// 验证 shell 和 cwd 存在
|
|
117
|
+
if (!fs.existsSync(shell)) {
|
|
118
|
+
const error = `Shell not found: ${shell}`;
|
|
119
|
+
console.error('[PTY]', error);
|
|
120
|
+
throw new Error(error);
|
|
121
|
+
}
|
|
122
|
+
if (!fs.existsSync(cwd)) {
|
|
123
|
+
const error = `Working directory not found: ${cwd}`;
|
|
124
|
+
console.error('[PTY]', error);
|
|
125
|
+
throw new Error(error);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
console.log(`[PTY] Creating terminal: shell=${shell}, cwd=${cwd}`);
|
|
129
|
+
|
|
130
|
+
|
|
97
131
|
const terminalId = `term_${this.nextId++}_${Date.now()}`;
|
|
98
132
|
|
|
99
133
|
// 合并环境变量
|
|
@@ -11,6 +11,7 @@ const {
|
|
|
11
11
|
checkHasMessagesCache,
|
|
12
12
|
rememberHasMessages
|
|
13
13
|
} = require('./session-cache');
|
|
14
|
+
const { globalCache, CacheKeys } = require('./enhanced-cache');
|
|
14
15
|
const { PATHS } = require('../../config/paths');
|
|
15
16
|
|
|
16
17
|
// Base directory for cc-tool data
|
|
@@ -82,15 +83,15 @@ function saveForkRelations(relations) {
|
|
|
82
83
|
fs.writeFileSync(relationsFile, JSON.stringify(relations, null, 2), 'utf8');
|
|
83
84
|
}
|
|
84
85
|
|
|
85
|
-
// Get all projects with stats
|
|
86
|
-
function getProjects(config) {
|
|
86
|
+
// Get all projects with stats (async version)
|
|
87
|
+
async function getProjects(config) {
|
|
87
88
|
const projectsDir = config.projectsDir;
|
|
88
89
|
|
|
89
90
|
if (!fs.existsSync(projectsDir)) {
|
|
90
91
|
return [];
|
|
91
92
|
}
|
|
92
93
|
|
|
93
|
-
const entries = fs.
|
|
94
|
+
const entries = await fs.promises.readdir(projectsDir, { withFileTypes: true });
|
|
94
95
|
return entries
|
|
95
96
|
.filter(entry => entry.isDirectory())
|
|
96
97
|
.map(entry => entry.name);
|
|
@@ -283,32 +284,43 @@ function extractCwdFromSessionHeader(sessionFile) {
|
|
|
283
284
|
return null;
|
|
284
285
|
}
|
|
285
286
|
|
|
286
|
-
// Get projects with detailed stats (with caching)
|
|
287
|
-
function getProjectsWithStats(config, options = {}) {
|
|
287
|
+
// Get projects with detailed stats (with caching) - async version
|
|
288
|
+
async function getProjectsWithStats(config, options = {}) {
|
|
288
289
|
if (!options.force) {
|
|
290
|
+
// Check enhanced cache first
|
|
291
|
+
const cacheKey = `${CacheKeys.PROJECTS}${config.projectsDir}`;
|
|
292
|
+
const enhancedCached = globalCache.get(cacheKey);
|
|
293
|
+
if (enhancedCached) {
|
|
294
|
+
return enhancedCached;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// Check old cache
|
|
289
298
|
const cached = getCachedProjects(config);
|
|
290
299
|
if (cached) {
|
|
300
|
+
globalCache.set(cacheKey, cached, 300000); // 5分钟
|
|
291
301
|
return cached;
|
|
292
302
|
}
|
|
293
303
|
}
|
|
294
304
|
|
|
295
|
-
const data = buildProjectsWithStats(config);
|
|
305
|
+
const data = await buildProjectsWithStats(config);
|
|
296
306
|
setCachedProjects(config, data);
|
|
307
|
+
globalCache.set(`${CacheKeys.PROJECTS}${config.projectsDir}`, data, 300000);
|
|
297
308
|
return data;
|
|
298
309
|
}
|
|
299
310
|
|
|
300
|
-
function buildProjectsWithStats(config) {
|
|
311
|
+
async function buildProjectsWithStats(config) {
|
|
301
312
|
const projectsDir = config.projectsDir;
|
|
302
313
|
|
|
303
314
|
if (!fs.existsSync(projectsDir)) {
|
|
304
315
|
return [];
|
|
305
316
|
}
|
|
306
317
|
|
|
307
|
-
const entries = fs.
|
|
318
|
+
const entries = await fs.promises.readdir(projectsDir, { withFileTypes: true });
|
|
308
319
|
|
|
309
|
-
|
|
320
|
+
// Process all projects concurrently
|
|
321
|
+
const projectPromises = entries
|
|
310
322
|
.filter(entry => entry.isDirectory())
|
|
311
|
-
.map(entry => {
|
|
323
|
+
.map(async (entry) => {
|
|
312
324
|
const projectName = entry.name;
|
|
313
325
|
const projectPath = path.join(projectsDir, projectName);
|
|
314
326
|
|
|
@@ -320,24 +332,29 @@ function buildProjectsWithStats(config) {
|
|
|
320
332
|
let lastUsed = null;
|
|
321
333
|
|
|
322
334
|
try {
|
|
323
|
-
const files = fs.
|
|
335
|
+
const files = await fs.promises.readdir(projectPath);
|
|
324
336
|
const jsonlFiles = files.filter(f => f.endsWith('.jsonl') && !f.startsWith('agent-'));
|
|
325
337
|
|
|
326
|
-
// Filter: only count sessions that have actual messages (
|
|
327
|
-
const
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
338
|
+
// Filter: only count sessions that have actual messages (in parallel)
|
|
339
|
+
const sessionChecks = await Promise.all(
|
|
340
|
+
jsonlFiles.map(async (f) => {
|
|
341
|
+
const filePath = path.join(projectPath, f);
|
|
342
|
+
const hasMessages = await hasActualMessages(filePath);
|
|
343
|
+
return hasMessages ? f : null;
|
|
344
|
+
})
|
|
345
|
+
);
|
|
331
346
|
|
|
347
|
+
const sessionFilesWithMessages = sessionChecks.filter(f => f !== null);
|
|
332
348
|
sessionCount = sessionFilesWithMessages.length;
|
|
333
349
|
|
|
334
350
|
// Find most recent session (only from sessions with messages)
|
|
335
351
|
if (sessionFilesWithMessages.length > 0) {
|
|
336
|
-
const
|
|
352
|
+
const statPromises = sessionFilesWithMessages.map(async (f) => {
|
|
337
353
|
const filePath = path.join(projectPath, f);
|
|
338
|
-
const stat = fs.
|
|
354
|
+
const stat = await fs.promises.stat(filePath);
|
|
339
355
|
return stat.mtime.getTime();
|
|
340
356
|
});
|
|
357
|
+
const stats = await Promise.all(statPromises);
|
|
341
358
|
lastUsed = Math.max(...stats);
|
|
342
359
|
}
|
|
343
360
|
} catch (err) {
|
|
@@ -351,8 +368,10 @@ function buildProjectsWithStats(config) {
|
|
|
351
368
|
sessionCount,
|
|
352
369
|
lastUsed
|
|
353
370
|
};
|
|
354
|
-
})
|
|
355
|
-
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
const projects = await Promise.all(projectPromises);
|
|
374
|
+
return projects.sort((a, b) => (b.lastUsed || 0) - (a.lastUsed || 0)); // Sort by last used
|
|
356
375
|
}
|
|
357
376
|
|
|
358
377
|
// 获取 Claude 项目/会话数量(轻量统计)
|
|
@@ -383,16 +402,27 @@ function getProjectAndSessionCounts(config) {
|
|
|
383
402
|
return { projectCount, sessionCount };
|
|
384
403
|
}
|
|
385
404
|
|
|
386
|
-
// Check if a session file has actual messages (
|
|
387
|
-
function hasActualMessages(filePath) {
|
|
405
|
+
// Check if a session file has actual messages (async with enhanced caching)
|
|
406
|
+
async function hasActualMessages(filePath) {
|
|
388
407
|
try {
|
|
389
|
-
const stats = fs.
|
|
390
|
-
|
|
408
|
+
const stats = await fs.promises.stat(filePath);
|
|
409
|
+
|
|
410
|
+
// Check enhanced cache first
|
|
411
|
+
const cacheKey = `${CacheKeys.HAS_MESSAGES}${filePath}:${stats.mtime.getTime()}`;
|
|
412
|
+
const cached = globalCache.get(cacheKey);
|
|
391
413
|
if (typeof cached === 'boolean') {
|
|
392
414
|
return cached;
|
|
393
415
|
}
|
|
394
416
|
|
|
395
|
-
|
|
417
|
+
// Check old cache mechanism
|
|
418
|
+
const oldCached = checkHasMessagesCache(filePath, stats);
|
|
419
|
+
if (typeof oldCached === 'boolean') {
|
|
420
|
+
globalCache.set(cacheKey, oldCached, 600000); // 10分钟
|
|
421
|
+
return oldCached;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
const result = await scanSessionFileForMessagesAsync(filePath);
|
|
425
|
+
globalCache.set(cacheKey, result, 600000);
|
|
396
426
|
rememberHasMessages(filePath, stats, result);
|
|
397
427
|
return result;
|
|
398
428
|
} catch (err) {
|
|
@@ -434,30 +464,74 @@ function scanSessionFileForMessages(filePath) {
|
|
|
434
464
|
}
|
|
435
465
|
}
|
|
436
466
|
|
|
437
|
-
//
|
|
438
|
-
function
|
|
467
|
+
// Async version using streams for better performance
|
|
468
|
+
function scanSessionFileForMessagesAsync(filePath) {
|
|
469
|
+
return new Promise((resolve) => {
|
|
470
|
+
const stream = fs.createReadStream(filePath, { encoding: 'utf8', highWaterMark: 64 * 1024 });
|
|
471
|
+
const pattern = /"type"\s*:\s*"(user|assistant|summary)"/;
|
|
472
|
+
let found = false;
|
|
473
|
+
let leftover = '';
|
|
474
|
+
|
|
475
|
+
stream.on('data', (chunk) => {
|
|
476
|
+
if (found) return;
|
|
477
|
+
const combined = leftover + chunk;
|
|
478
|
+
if (pattern.test(combined)) {
|
|
479
|
+
found = true;
|
|
480
|
+
stream.destroy();
|
|
481
|
+
resolve(true);
|
|
482
|
+
}
|
|
483
|
+
leftover = combined.slice(-64);
|
|
484
|
+
});
|
|
485
|
+
|
|
486
|
+
stream.on('end', () => {
|
|
487
|
+
if (!found) resolve(false);
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
stream.on('error', () => {
|
|
491
|
+
resolve(false);
|
|
492
|
+
});
|
|
493
|
+
});
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
// Get sessions for a project - async version
|
|
497
|
+
async function getSessionsForProject(config, projectName) {
|
|
498
|
+
// Check cache first
|
|
499
|
+
const cacheKey = `${CacheKeys.SESSIONS}${projectName}`;
|
|
500
|
+
const cached = globalCache.get(cacheKey);
|
|
501
|
+
if (cached) {
|
|
502
|
+
return cached;
|
|
503
|
+
}
|
|
504
|
+
|
|
439
505
|
const projectConfig = { ...config, currentProject: projectName };
|
|
440
506
|
const sessions = getAllSessions(projectConfig);
|
|
441
507
|
const forkRelations = getForkRelations();
|
|
442
508
|
const savedOrder = getSessionOrder(projectName);
|
|
443
509
|
|
|
444
|
-
// Parse session info and calculate total size, filter out sessions with no messages
|
|
510
|
+
// Parse session info and calculate total size, filter out sessions with no messages (in parallel)
|
|
445
511
|
let totalSize = 0;
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
.map(session => {
|
|
449
|
-
const
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
512
|
+
|
|
513
|
+
const sessionChecks = await Promise.all(
|
|
514
|
+
sessions.map(async (session) => {
|
|
515
|
+
const hasMessages = await hasActualMessages(session.filePath);
|
|
516
|
+
return hasMessages ? session : null;
|
|
517
|
+
})
|
|
518
|
+
);
|
|
519
|
+
|
|
520
|
+
const validSessions = sessionChecks.filter(s => s !== null);
|
|
521
|
+
|
|
522
|
+
const sessionsWithInfo = validSessions.map(session => {
|
|
523
|
+
const info = parseSessionInfoFast(session.filePath);
|
|
524
|
+
totalSize += session.size || 0;
|
|
525
|
+
return {
|
|
526
|
+
sessionId: session.sessionId,
|
|
527
|
+
mtime: session.mtime,
|
|
528
|
+
size: session.size,
|
|
529
|
+
filePath: session.filePath,
|
|
530
|
+
gitBranch: info.gitBranch || null,
|
|
531
|
+
firstMessage: info.firstMessage || null,
|
|
532
|
+
forkedFrom: forkRelations[session.sessionId] || null
|
|
533
|
+
};
|
|
534
|
+
});
|
|
461
535
|
|
|
462
536
|
// Apply saved order if exists
|
|
463
537
|
let orderedSessions = sessionsWithInfo;
|
|
@@ -478,10 +552,14 @@ function getSessionsForProject(config, projectName) {
|
|
|
478
552
|
orderedSessions = ordered;
|
|
479
553
|
}
|
|
480
554
|
|
|
481
|
-
|
|
555
|
+
const result = {
|
|
482
556
|
sessions: orderedSessions,
|
|
483
557
|
totalSize
|
|
484
558
|
};
|
|
559
|
+
|
|
560
|
+
// Cache for 2 minutes
|
|
561
|
+
globalCache.set(cacheKey, result, 120000);
|
|
562
|
+
return result;
|
|
485
563
|
}
|
|
486
564
|
|
|
487
565
|
// Delete a session
|
|
@@ -847,14 +847,14 @@ class SkillService {
|
|
|
847
847
|
|
|
848
848
|
request.on('error', (err) => {
|
|
849
849
|
file.close();
|
|
850
|
-
fs.unlink(dest, () => {});
|
|
850
|
+
fs.unlink(dest, () => { });
|
|
851
851
|
reject(err);
|
|
852
852
|
});
|
|
853
853
|
|
|
854
854
|
request.on('timeout', () => {
|
|
855
855
|
request.destroy();
|
|
856
856
|
file.close();
|
|
857
|
-
fs.unlink(dest, () => {});
|
|
857
|
+
fs.unlink(dest, () => { });
|
|
858
858
|
reject(new Error('Download timeout'));
|
|
859
859
|
});
|
|
860
860
|
});
|
|
@@ -912,6 +912,256 @@ ${content}
|
|
|
912
912
|
return { success: true, message: '技能创建成功', directory };
|
|
913
913
|
}
|
|
914
914
|
|
|
915
|
+
/**
|
|
916
|
+
* 创建带多文件的技能
|
|
917
|
+
* @param {string} directory - 技能目录名
|
|
918
|
+
* @param {Array<{path: string, content: string}>} files - 文件数组
|
|
919
|
+
* @returns {Object} 创建结果
|
|
920
|
+
*/
|
|
921
|
+
createSkillWithFiles({ directory, files }) {
|
|
922
|
+
const dest = path.join(this.installDir, directory);
|
|
923
|
+
|
|
924
|
+
// 检查是否已存在
|
|
925
|
+
if (fs.existsSync(dest)) {
|
|
926
|
+
throw new Error(`技能目录 "${directory}" 已存在`);
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
// 验证必须包含 SKILL.md
|
|
930
|
+
const hasSkillMd = files.some(f =>
|
|
931
|
+
f.path === 'SKILL.md' || f.path.endsWith('/SKILL.md')
|
|
932
|
+
);
|
|
933
|
+
if (!hasSkillMd) {
|
|
934
|
+
throw new Error('技能必须包含 SKILL.md 文件');
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
// 创建目录
|
|
938
|
+
fs.mkdirSync(dest, { recursive: true });
|
|
939
|
+
|
|
940
|
+
// 写入所有文件
|
|
941
|
+
for (const file of files) {
|
|
942
|
+
const filePath = path.join(dest, file.path);
|
|
943
|
+
const fileDir = path.dirname(filePath);
|
|
944
|
+
|
|
945
|
+
// 确保父目录存在
|
|
946
|
+
if (!fs.existsSync(fileDir)) {
|
|
947
|
+
fs.mkdirSync(fileDir, { recursive: true });
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
// 写入文件内容
|
|
951
|
+
if (file.isBase64) {
|
|
952
|
+
// 二进制文件使用 base64 编码
|
|
953
|
+
fs.writeFileSync(filePath, Buffer.from(file.content, 'base64'));
|
|
954
|
+
} else {
|
|
955
|
+
fs.writeFileSync(filePath, file.content, 'utf-8');
|
|
956
|
+
}
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
// 清除缓存
|
|
960
|
+
this.skillsCache = null;
|
|
961
|
+
this.cacheTime = 0;
|
|
962
|
+
|
|
963
|
+
return {
|
|
964
|
+
success: true,
|
|
965
|
+
message: '技能创建成功',
|
|
966
|
+
directory,
|
|
967
|
+
fileCount: files.length
|
|
968
|
+
};
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
/**
|
|
972
|
+
* 获取技能目录下所有文件列表
|
|
973
|
+
* @param {string} directory - 技能目录名
|
|
974
|
+
* @returns {Array<{path: string, size: number, isDirectory: boolean}>}
|
|
975
|
+
*/
|
|
976
|
+
getSkillFiles(directory) {
|
|
977
|
+
const skillPath = path.join(this.installDir, directory);
|
|
978
|
+
|
|
979
|
+
if (!fs.existsSync(skillPath)) {
|
|
980
|
+
throw new Error(`技能 "${directory}" 不存在`);
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
const files = [];
|
|
984
|
+
this._scanFilesRecursive(skillPath, skillPath, files);
|
|
985
|
+
return files;
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
/**
|
|
989
|
+
* 递归扫描目录获取文件列表
|
|
990
|
+
*/
|
|
991
|
+
_scanFilesRecursive(currentDir, baseDir, files) {
|
|
992
|
+
const entries = fs.readdirSync(currentDir, { withFileTypes: true });
|
|
993
|
+
|
|
994
|
+
for (const entry of entries) {
|
|
995
|
+
const fullPath = path.join(currentDir, entry.name);
|
|
996
|
+
const relativePath = path.relative(baseDir, fullPath);
|
|
997
|
+
|
|
998
|
+
if (entry.isDirectory()) {
|
|
999
|
+
files.push({
|
|
1000
|
+
path: relativePath,
|
|
1001
|
+
size: 0,
|
|
1002
|
+
isDirectory: true
|
|
1003
|
+
});
|
|
1004
|
+
this._scanFilesRecursive(fullPath, baseDir, files);
|
|
1005
|
+
} else {
|
|
1006
|
+
const stats = fs.statSync(fullPath);
|
|
1007
|
+
files.push({
|
|
1008
|
+
path: relativePath,
|
|
1009
|
+
size: stats.size,
|
|
1010
|
+
isDirectory: false
|
|
1011
|
+
});
|
|
1012
|
+
}
|
|
1013
|
+
}
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
/**
|
|
1017
|
+
* 获取技能文件内容
|
|
1018
|
+
* @param {string} directory - 技能目录名
|
|
1019
|
+
* @param {string} filePath - 文件相对路径
|
|
1020
|
+
* @returns {Object} 文件内容
|
|
1021
|
+
*/
|
|
1022
|
+
getSkillFileContent(directory, filePath) {
|
|
1023
|
+
const fullPath = path.join(this.installDir, directory, filePath);
|
|
1024
|
+
|
|
1025
|
+
if (!fs.existsSync(fullPath)) {
|
|
1026
|
+
throw new Error(`文件 "${filePath}" 不存在`);
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
const stats = fs.statSync(fullPath);
|
|
1030
|
+
if (stats.isDirectory()) {
|
|
1031
|
+
throw new Error(`"${filePath}" 是目录,不是文件`);
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
// 判断是否是文本文件
|
|
1035
|
+
const textExtensions = ['.md', '.txt', '.json', '.js', '.ts', '.py', '.sh', '.yaml', '.yml', '.toml', '.xml', '.html', '.css'];
|
|
1036
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
1037
|
+
const isText = textExtensions.includes(ext);
|
|
1038
|
+
|
|
1039
|
+
if (isText) {
|
|
1040
|
+
return {
|
|
1041
|
+
path: filePath,
|
|
1042
|
+
content: fs.readFileSync(fullPath, 'utf-8'),
|
|
1043
|
+
isBase64: false,
|
|
1044
|
+
size: stats.size
|
|
1045
|
+
};
|
|
1046
|
+
} else {
|
|
1047
|
+
return {
|
|
1048
|
+
path: filePath,
|
|
1049
|
+
content: fs.readFileSync(fullPath).toString('base64'),
|
|
1050
|
+
isBase64: true,
|
|
1051
|
+
size: stats.size
|
|
1052
|
+
};
|
|
1053
|
+
}
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
/**
|
|
1057
|
+
* 添加文件到现有技能
|
|
1058
|
+
* @param {string} directory - 技能目录名
|
|
1059
|
+
* @param {Array<{path: string, content: string, isBase64?: boolean}>} files - 文件数组
|
|
1060
|
+
*/
|
|
1061
|
+
addSkillFiles(directory, files) {
|
|
1062
|
+
const skillPath = path.join(this.installDir, directory);
|
|
1063
|
+
|
|
1064
|
+
if (!fs.existsSync(skillPath)) {
|
|
1065
|
+
throw new Error(`技能 "${directory}" 不存在`);
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
const added = [];
|
|
1069
|
+
for (const file of files) {
|
|
1070
|
+
const filePath = path.join(skillPath, file.path);
|
|
1071
|
+
const fileDir = path.dirname(filePath);
|
|
1072
|
+
|
|
1073
|
+
// 确保父目录存在
|
|
1074
|
+
if (!fs.existsSync(fileDir)) {
|
|
1075
|
+
fs.mkdirSync(fileDir, { recursive: true });
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
// 写入文件
|
|
1079
|
+
if (file.isBase64) {
|
|
1080
|
+
fs.writeFileSync(filePath, Buffer.from(file.content, 'base64'));
|
|
1081
|
+
} else {
|
|
1082
|
+
fs.writeFileSync(filePath, file.content, 'utf-8');
|
|
1083
|
+
}
|
|
1084
|
+
added.push(file.path);
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
// 清除缓存
|
|
1088
|
+
this.skillsCache = null;
|
|
1089
|
+
this.cacheTime = 0;
|
|
1090
|
+
|
|
1091
|
+
return { success: true, added };
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
/**
|
|
1095
|
+
* 删除技能中的文件
|
|
1096
|
+
* @param {string} directory - 技能目录名
|
|
1097
|
+
* @param {string} filePath - 文件相对路径
|
|
1098
|
+
*/
|
|
1099
|
+
deleteSkillFile(directory, filePath) {
|
|
1100
|
+
const skillPath = path.join(this.installDir, directory);
|
|
1101
|
+
|
|
1102
|
+
if (!fs.existsSync(skillPath)) {
|
|
1103
|
+
throw new Error(`技能 "${directory}" 不存在`);
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
// 不允许删除 SKILL.md
|
|
1107
|
+
if (filePath === 'SKILL.md') {
|
|
1108
|
+
throw new Error('不能删除 SKILL.md 文件');
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
const fullPath = path.join(skillPath, filePath);
|
|
1112
|
+
|
|
1113
|
+
if (!fs.existsSync(fullPath)) {
|
|
1114
|
+
throw new Error(`文件 "${filePath}" 不存在`);
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
const stats = fs.statSync(fullPath);
|
|
1118
|
+
if (stats.isDirectory()) {
|
|
1119
|
+
fs.rmSync(fullPath, { recursive: true, force: true });
|
|
1120
|
+
} else {
|
|
1121
|
+
fs.unlinkSync(fullPath);
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
// 清除缓存
|
|
1125
|
+
this.skillsCache = null;
|
|
1126
|
+
this.cacheTime = 0;
|
|
1127
|
+
|
|
1128
|
+
return { success: true, deleted: filePath };
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
/**
|
|
1132
|
+
* 更新技能文件内容
|
|
1133
|
+
* @param {string} directory - 技能目录名
|
|
1134
|
+
* @param {string} filePath - 文件相对路径
|
|
1135
|
+
* @param {string} content - 新内容
|
|
1136
|
+
* @param {boolean} isBase64 - 是否为 base64 编码
|
|
1137
|
+
*/
|
|
1138
|
+
updateSkillFile(directory, filePath, content, isBase64 = false) {
|
|
1139
|
+
const skillPath = path.join(this.installDir, directory);
|
|
1140
|
+
|
|
1141
|
+
if (!fs.existsSync(skillPath)) {
|
|
1142
|
+
throw new Error(`技能 "${directory}" 不存在`);
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
const fullPath = path.join(skillPath, filePath);
|
|
1146
|
+
|
|
1147
|
+
if (!fs.existsSync(fullPath)) {
|
|
1148
|
+
throw new Error(`文件 "${filePath}" 不存在`);
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
if (isBase64) {
|
|
1152
|
+
fs.writeFileSync(fullPath, Buffer.from(content, 'base64'));
|
|
1153
|
+
} else {
|
|
1154
|
+
fs.writeFileSync(fullPath, content, 'utf-8');
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
// 清除缓存
|
|
1158
|
+
this.skillsCache = null;
|
|
1159
|
+
this.cacheTime = 0;
|
|
1160
|
+
|
|
1161
|
+
return { success: true, updated: filePath };
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1164
|
+
|
|
915
1165
|
/**
|
|
916
1166
|
* 卸载技能
|
|
917
1167
|
*/
|
|
@@ -396,27 +396,44 @@ function deleteWorkspace(id, removeFiles = false) {
|
|
|
396
396
|
|
|
397
397
|
const workspace = data.workspaces[index];
|
|
398
398
|
|
|
399
|
-
//
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
399
|
+
// 清理 worktrees (无论是否删除工作区目录,都应该清理 worktree)
|
|
400
|
+
for (const proj of workspace.projects) {
|
|
401
|
+
if (proj.isGitRepo && proj.sourcePath && fs.existsSync(proj.sourcePath)) {
|
|
402
|
+
try {
|
|
403
|
+
// 重新扫描实际的 worktrees,确保获取最新状态
|
|
404
|
+
const actualWorktrees = getGitWorktrees(proj.sourcePath);
|
|
405
|
+
for (const wt of actualWorktrees) {
|
|
406
|
+
// 只删除属于这个工作区的 worktree (通过 -ws- 标识符识别)
|
|
407
|
+
if (wt.path && wt.path.includes('-ws-')) {
|
|
408
|
+
try {
|
|
409
|
+
console.log(`清理 worktree: ${wt.path}`);
|
|
410
|
+
execSync(`git worktree remove "${wt.path}" --force`, {
|
|
411
|
+
cwd: proj.sourcePath,
|
|
412
|
+
stdio: 'pipe'
|
|
413
|
+
});
|
|
414
|
+
} catch (error) {
|
|
415
|
+
console.error(`删除 worktree 失败: ${wt.path}`, error.message);
|
|
416
|
+
// 如果 git worktree remove 失败,尝试手动删除目录
|
|
417
|
+
if (fs.existsSync(wt.path)) {
|
|
418
|
+
try {
|
|
419
|
+
fs.rmSync(wt.path, { recursive: true, force: true });
|
|
420
|
+
console.log(`手动删除 worktree 目录: ${wt.path}`);
|
|
421
|
+
} catch (rmError) {
|
|
422
|
+
console.error(`手动删除 worktree 目录失败: ${wt.path}`, rmError.message);
|
|
423
|
+
}
|
|
414
424
|
}
|
|
415
425
|
}
|
|
416
426
|
}
|
|
417
427
|
}
|
|
428
|
+
} catch (error) {
|
|
429
|
+
console.error(`扫描 worktree 失败: ${proj.sourcePath}`, error.message);
|
|
418
430
|
}
|
|
431
|
+
}
|
|
432
|
+
}
|
|
419
433
|
|
|
434
|
+
// 如果需要删除物理文件
|
|
435
|
+
if (removeFiles && fs.existsSync(workspace.path)) {
|
|
436
|
+
try {
|
|
420
437
|
// 删除工作区目录
|
|
421
438
|
fs.rmSync(workspace.path, { recursive: true, force: true });
|
|
422
439
|
} catch (error) {
|
|
@@ -620,10 +637,11 @@ function getAllAvailableProjects() {
|
|
|
620
637
|
const projects = sessionsService.getProjectsWithStats(config, { force: true });
|
|
621
638
|
|
|
622
639
|
for (const proj of projects) {
|
|
623
|
-
// 使用 fullPath
|
|
640
|
+
// 使用 fullPath + channel 组合去重,允许同一项目在不同渠道显示
|
|
624
641
|
const projectPath = proj.fullPath;
|
|
625
|
-
|
|
626
|
-
|
|
642
|
+
const projectKey = `${projectPath}:${channel.name}`;
|
|
643
|
+
if (projectPath && !seenPaths.has(projectKey)) {
|
|
644
|
+
seenPaths.add(projectKey);
|
|
627
645
|
allProjects.push({
|
|
628
646
|
name: proj.name,
|
|
629
647
|
displayName: proj.displayName,
|
|
@@ -175,7 +175,10 @@ function startWebSocketServer(httpServer) {
|
|
|
175
175
|
console.log(`✅ WebSocket server started on ws://127.0.0.1:${port}/ws`);
|
|
176
176
|
}
|
|
177
177
|
|
|
178
|
-
wss.on('connection', (ws) => {
|
|
178
|
+
wss.on('connection', (ws, req) => {
|
|
179
|
+
const clientIp = req.socket.remoteAddress;
|
|
180
|
+
console.log(`[WebSocket] New connection from ${clientIp}`);
|
|
181
|
+
|
|
179
182
|
wsClients.add(ws);
|
|
180
183
|
|
|
181
184
|
// 标记客户端存活
|