evolclaw 2.0.4 → 2.0.6

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.
@@ -7,6 +7,7 @@ import { execFileSync } from 'child_process';
7
7
  import { promisify } from 'util';
8
8
  import { execFile } from 'child_process';
9
9
  import { resolveRoot, resolvePaths, ensureDataDirs, getPackageRoot } from '../paths.js';
10
+ import { isWindows, commandExists } from './platform.js';
10
11
  const execFileAsync = promisify(execFile);
11
12
  // ==================== Helpers ====================
12
13
  function ask(rl, question) {
@@ -18,6 +19,9 @@ async function npmInstallGlobal(pkg) {
18
19
  }
19
20
  catch (e) {
20
21
  if (e.stderr?.includes('EACCES') || e.message?.includes('EACCES')) {
22
+ if (isWindows) {
23
+ throw new Error('权限不足。请以管理员身份运行 PowerShell 或 CMD,然后重试');
24
+ }
21
25
  await execFileAsync('sudo', ['npm', 'install', '-g', pkg], { timeout: 120000 });
22
26
  }
23
27
  else {
@@ -38,6 +42,9 @@ async function sudoExec(cmd, args) {
38
42
  }
39
43
  catch (e) {
40
44
  if (e.stderr?.includes('EACCES') || e.message?.includes('EACCES') || e.code === 'EACCES') {
45
+ if (isWindows) {
46
+ throw new Error('权限不足。请以管理员身份运行 PowerShell 或 CMD,然后重试');
47
+ }
41
48
  await execFileAsync('sudo', [cmd, ...args], { timeout: 120000, env });
42
49
  }
43
50
  else {
@@ -57,15 +64,15 @@ async function checkEnvironment(rl) {
57
64
  console.log(` ✗ Node.js v${process.versions.node} — 需要 >= 22(node:sqlite 依赖)`);
58
65
  // 检测 nvm
59
66
  // 检测 bash 是否存在(nvm 和 n 都依赖 bash)
60
- let hasBash = false;
61
- try {
62
- execFileSync('which', ['bash'], { encoding: 'utf-8' });
63
- hasBash = true;
64
- }
65
- catch { }
67
+ const hasBash = commandExists('bash');
66
68
  if (!hasBash) {
67
- console.log(' ⚠ 当前环境没有 bash(Alpine 容器?),无法自动升级 Node.js');
68
- console.log(' 请手动升级: apk add nodejs-current 或重建容器使用 node:22-alpine');
69
+ if (isWindows) {
70
+ console.log(' Windows 环境,请从 https://nodejs.org 下载安装 Node.js 22+');
71
+ }
72
+ else {
73
+ console.log(' ⚠ 当前环境没有 bash(Alpine 容器?),无法自动升级 Node.js');
74
+ console.log(' → 请手动升级: apk add nodejs-current 或重建容器使用 node:22-alpine');
75
+ }
69
76
  return false;
70
77
  }
71
78
  const hasNvm = !!process.env.NVM_DIR && fs.existsSync(process.env.NVM_DIR);
@@ -91,12 +98,7 @@ async function checkEnvironment(rl) {
91
98
  }
92
99
  else {
93
100
  // 检测 n
94
- let hasN = false;
95
- try {
96
- execFileSync('which', ['n'], { encoding: 'utf-8' });
97
- hasN = true;
98
- }
99
- catch { }
101
+ const hasN = commandExists('n');
100
102
  if (hasN) {
101
103
  const answer = (await ask(rl, ' → 是否通过 n 升级到 Node.js 22?[Y/n] ')).trim().toLowerCase();
102
104
  if (answer === 'n' || answer === 'no') {
@@ -138,48 +140,49 @@ async function checkEnvironment(rl) {
138
140
  }
139
141
  // claude CLI >= 2.1.32
140
142
  const MIN_CLAUDE_VER = [2, 1, 32];
141
- let claudeInstalled = false;
142
- try {
143
- execFileSync('which', ['claude'], { encoding: 'utf-8' });
144
- claudeInstalled = true;
145
- const verOutput = execFileSync('claude', ['--version'], { encoding: 'utf-8' }).trim();
146
- const verMatch = verOutput.match(/^(\d+\.\d+\.\d+)/);
147
- if (verMatch) {
148
- const parts = verMatch[1].split('.').map(Number);
149
- const isOk = parts[0] > MIN_CLAUDE_VER[0]
150
- || (parts[0] === MIN_CLAUDE_VER[0] && parts[1] > MIN_CLAUDE_VER[1])
151
- || (parts[0] === MIN_CLAUDE_VER[0] && parts[1] === MIN_CLAUDE_VER[1] && parts[2] >= MIN_CLAUDE_VER[2]);
152
- if (isOk) {
153
- console.log(` ✓ claude CLI v${verMatch[1]}`);
154
- }
155
- else {
156
- console.log(` ✗ claude CLI v${verMatch[1]} — 需要 >= ${MIN_CLAUDE_VER.join('.')}`);
157
- const answer = (await ask(rl, ' → 是否升级 claude CLI?[Y/n] ')).trim().toLowerCase();
158
- if (answer === 'n' || answer === 'no') {
159
- console.log(' 已取消');
160
- return false;
161
- }
162
- console.log(' 正在升级 claude CLI...');
163
- try {
164
- await npmInstallGlobal('@anthropic-ai/claude-code@latest');
165
- console.log(' ✓ claude CLI 升级完成');
143
+ const claudeInstalled = commandExists('claude');
144
+ if (claudeInstalled) {
145
+ try {
146
+ const verOutput = execFileSync('claude', ['--version'], { encoding: 'utf-8' }).trim();
147
+ const verMatch = verOutput.match(/^(\d+\.\d+\.\d+)/);
148
+ if (verMatch) {
149
+ const parts = verMatch[1].split('.').map(Number);
150
+ const isOk = parts[0] > MIN_CLAUDE_VER[0]
151
+ || (parts[0] === MIN_CLAUDE_VER[0] && parts[1] > MIN_CLAUDE_VER[1])
152
+ || (parts[0] === MIN_CLAUDE_VER[0] && parts[1] === MIN_CLAUDE_VER[1] && parts[2] >= MIN_CLAUDE_VER[2]);
153
+ if (isOk) {
154
+ console.log(` ✓ claude CLI v${verMatch[1]}`);
166
155
  }
167
- catch (e) {
168
- console.log(` ✗ 升级失败: ${e.message?.slice(0, 200) || e}`);
169
- return false;
156
+ else {
157
+ console.log(` ✗ claude CLI v${verMatch[1]} — 需要 >= ${MIN_CLAUDE_VER.join('.')}`);
158
+ const answer = (await ask(rl, ' → 是否升级 claude CLI?[Y/n] ')).trim().toLowerCase();
159
+ if (answer === 'n' || answer === 'no') {
160
+ console.log(' 已取消');
161
+ return false;
162
+ }
163
+ console.log(' 正在升级 claude CLI...');
164
+ try {
165
+ await npmInstallGlobal('@anthropic-ai/claude-code@latest');
166
+ console.log(' ✓ claude CLI 升级完成');
167
+ }
168
+ catch (e) {
169
+ console.log(` ✗ 升级失败: ${e.message?.slice(0, 200) || e}`);
170
+ return false;
171
+ }
170
172
  }
171
173
  }
174
+ else {
175
+ console.log(` ✓ claude CLI (${verOutput})`);
176
+ }
172
177
  }
173
- else {
174
- console.log(` ✓ claude CLI (${verOutput})`);
178
+ catch {
179
+ // claude command exists but --version failed
175
180
  }
176
181
  }
177
- catch {
178
- if (!claudeInstalled) {
179
- console.log(' claude CLI 未找到');
180
- console.log(' → 请先安装: npm install -g @anthropic-ai/claude-code');
181
- return false;
182
- }
182
+ else {
183
+ console.log(' ✗ claude CLI 未找到');
184
+ console.log(' 请先安装: npm install -g @anthropic-ai/claude-code');
185
+ return false;
183
186
  }
184
187
  // @anthropic-ai/claude-agent-sdk >= 0.2.75
185
188
  let sdkAction = 'ok';
@@ -226,6 +229,19 @@ async function checkEnvironment(rl) {
226
229
  }
227
230
  // ==================== Shell Profile ====================
228
231
  function setupEnvVar(home) {
232
+ if (isWindows) {
233
+ // Windows: use setx to set user environment variable
234
+ try {
235
+ execFileSync('setx', ['EVOLCLAW_HOME', home], { encoding: 'utf-8', stdio: 'pipe' });
236
+ console.log(` ✓ 已设置用户环境变量: EVOLCLAW_HOME=${home}`);
237
+ console.log(' ⚠ 请重新打开终端使其生效');
238
+ }
239
+ catch (e) {
240
+ console.log(` ⚠ 设置环境变量失败: ${e.message?.slice(0, 100) || e}`);
241
+ console.log(` → 请手动设置环境变量 EVOLCLAW_HOME=${home}`);
242
+ }
243
+ return;
244
+ }
229
245
  const exportLine = `export EVOLCLAW_HOME="${home}"`;
230
246
  const candidates = [
231
247
  path.join(os.homedir(), '.zshrc'),
@@ -2,6 +2,59 @@
2
2
  * Markdown 到飞书富文本格式转换工具
3
3
  * 使用飞书 post 格式的 md tag 原生渲染 Markdown
4
4
  */
5
+ /**
6
+ * 计算字符串的显示宽度(CJK 字符按 2 宽度计算)
7
+ */
8
+ function displayWidth(str) {
9
+ let width = 0;
10
+ for (const ch of str) {
11
+ const code = ch.codePointAt(0);
12
+ // CJK Unified Ideographs, CJK Compatibility, Fullwidth Forms, etc.
13
+ if ((code >= 0x4E00 && code <= 0x9FFF) || // CJK Unified
14
+ (code >= 0x3400 && code <= 0x4DBF) || // CJK Extension A
15
+ (code >= 0xF900 && code <= 0xFAFF) || // CJK Compatibility
16
+ (code >= 0xFF01 && code <= 0xFF60) || // Fullwidth Forms
17
+ (code >= 0x3000 && code <= 0x303F) // CJK Symbols
18
+ ) {
19
+ width += 2;
20
+ }
21
+ else {
22
+ width += 1;
23
+ }
24
+ }
25
+ return width;
26
+ }
27
+ /**
28
+ * 用空格填充字符串到指定显示宽度
29
+ */
30
+ function padToWidth(str, targetWidth) {
31
+ const current = displayWidth(str);
32
+ const padding = Math.max(0, targetWidth - current);
33
+ return str + ' '.repeat(padding);
34
+ }
35
+ /**
36
+ * 将 Markdown 表格转换为代码块内的对齐文本
37
+ * 飞书 post md tag 不支持标准 markdown 表格,会静默丢弃内容
38
+ * 用代码块 + 等宽对齐保留二维结构
39
+ */
40
+ function convertTablesToText(text) {
41
+ const tableRegex = /^(\|.+\|)\n(\|[\s:|-]+\|)\n((?:\|.+\|\n?)+)/gm;
42
+ return text.replace(tableRegex, (_match, headerLine, _sep, bodyBlock) => {
43
+ const parseRow = (line) => line.split('|').slice(1, -1).map((c) => c.trim());
44
+ const headers = parseRow(headerLine);
45
+ const rows = bodyBlock.trim().split('\n').map(parseRow);
46
+ // 计算每列最大显示宽度
47
+ const colWidths = headers.map((h, i) => {
48
+ const cellWidths = rows.map(r => displayWidth(r[i] || ''));
49
+ return Math.max(displayWidth(h), ...cellWidths);
50
+ });
51
+ // 构建对齐的表格文本
52
+ const headerStr = headers.map((h, i) => padToWidth(h, colWidths[i])).join(' ');
53
+ const sepStr = colWidths.map(w => '-'.repeat(w)).join(' ');
54
+ const rowStrs = rows.map(r => headers.map((_, i) => padToWidth(r[i] || '', colWidths[i])).join(' '));
55
+ return '```\n' + [headerStr, sepStr, ...rowStrs].join('\n') + '\n```';
56
+ });
57
+ }
5
58
  /**
6
59
  * 将 Markdown 文本转换为飞书 post 消息格式
7
60
  * 利用 md tag 让飞书原生渲染,支持代码高亮、嵌套列表、引用等全部语法
@@ -9,7 +62,9 @@
9
62
  export function markdownToFeishuPost(markdown, defaultTitle) {
10
63
  const match = markdown.match(/^# (.+)$/m);
11
64
  const title = match?.[1] ?? defaultTitle ?? '';
12
- const body = match ? markdown.replace(/^# .+\n?/, '') : markdown;
65
+ let body = match ? markdown.replace(/^# .+\n?/, '') : markdown;
66
+ // 转换飞书不支持的 markdown 表格
67
+ body = convertTablesToText(body);
13
68
  return {
14
69
  zh_cn: {
15
70
  title,
@@ -32,7 +87,8 @@ export function hasMarkdownSyntax(text) {
32
87
  /```[\s\S]*?```/, // 代码块
33
88
  /\[.*?\]\(.*?\)/, // 链接
34
89
  /^[\s]*[-*+]\s/m, // 无序列表
35
- /^[\s]*\d+\.\s/m // 有序列表
90
+ /^[\s]*\d+\.\s/m, // 有序列表
91
+ /^\|.+\|$/m // 表格
36
92
  ];
37
93
  return markdownPatterns.some(pattern => pattern.test(text));
38
94
  }
@@ -1,5 +1,6 @@
1
1
  // 危险命令黑名单(正则表达式)
2
2
  const DANGEROUS_PATTERNS = [
3
+ // Unix
3
4
  /\brm\s+-\w*r\w*f/, // rm -rf
4
5
  /\bsudo\b/, // sudo
5
6
  /\bmkfs\b/, // mkfs (格式化文件系统)
@@ -8,6 +9,12 @@ const DANGEROUS_PATTERNS = [
8
9
  />\s*\/dev\//, // 重定向到设备文件
9
10
  /\bshutdown\b/, // 关机
10
11
  /\breboot\b/, // 重启
12
+ // Windows
13
+ /\bformat\s+[a-zA-Z]:/i, // format C: (格式化磁盘)
14
+ /\brd\s+\/s/i, // rd /s (递归删除目录)
15
+ /\bdel\s+\/[sfq]/i, // del /f, /s, /q (强制删除)
16
+ /\breg\s+delete/i, // reg delete (删除注册表)
17
+ /\bnet\s+stop/i, // net stop (停止服务)
11
18
  ];
12
19
  /**
13
20
  * 权限检查回调函数
@@ -0,0 +1,175 @@
1
+ import path from 'path';
2
+ import { fileURLToPath } from 'url';
3
+ import { execFileSync, execFile, spawn } from 'child_process';
4
+ import { promisify } from 'util';
5
+ import fs from 'fs';
6
+ const execFileAsync = promisify(execFile);
7
+ export const isWindows = process.platform === 'win32';
8
+ /**
9
+ * Encode project path as directory name (Claude SDK convention).
10
+ * Replace all path separators with '-'.
11
+ * e.g. /home/user/project -> -home-user-project
12
+ * C:\Users\project -> C--Users-project
13
+ */
14
+ export function encodePath(projectPath) {
15
+ return projectPath.replace(/[/\\:]/g, '-');
16
+ }
17
+ /**
18
+ * Cross-platform process liveness check.
19
+ */
20
+ export function isProcessRunning(pid) {
21
+ try {
22
+ process.kill(pid, 0);
23
+ return true;
24
+ }
25
+ catch (e) {
26
+ // ESRCH = process not found; EPERM = exists but no permission
27
+ return e.code === 'EPERM';
28
+ }
29
+ }
30
+ /**
31
+ * Cross-platform process termination.
32
+ */
33
+ export function killProcess(pid, force = false) {
34
+ if (isWindows && force) {
35
+ try {
36
+ execFileSync('taskkill', ['/PID', String(pid), '/F']);
37
+ }
38
+ catch { }
39
+ }
40
+ else {
41
+ try {
42
+ process.kill(pid, force ? 'SIGKILL' : 'SIGTERM');
43
+ }
44
+ catch { }
45
+ }
46
+ }
47
+ /**
48
+ * Cross-platform process search by command line pattern.
49
+ * Returns list of matching PIDs.
50
+ */
51
+ export function findProcesses(pattern) {
52
+ try {
53
+ if (isWindows) {
54
+ const output = execFileSync('wmic', ['process', 'where', `CommandLine like '%${pattern}%'`, 'get', 'ProcessId'], { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] });
55
+ return output.split('\n')
56
+ .map(line => parseInt(line.trim(), 10))
57
+ .filter(pid => !isNaN(pid) && pid !== process.pid);
58
+ }
59
+ else {
60
+ const output = execFileSync('pgrep', ['-f', pattern], { encoding: 'utf-8' }).trim();
61
+ return output ? output.split('\n').map(Number).filter(pid => pid !== process.pid) : [];
62
+ }
63
+ }
64
+ catch {
65
+ return [];
66
+ }
67
+ }
68
+ export function getProcessInfo(pid) {
69
+ try {
70
+ if (isWindows) {
71
+ // Use wmic on Windows
72
+ const output = execFileSync('wmic', ['process', 'where', `ProcessId=${pid}`, 'get', 'WorkingSetSize,CreationDate'], { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] });
73
+ const lines = output.trim().split('\n').filter(l => l.trim());
74
+ if (lines.length >= 2) {
75
+ const parts = lines[1].trim().split(/\s+/);
76
+ const memKB = parts[1] ? Math.round(parseInt(parts[1], 10) / 1024) : undefined;
77
+ return { memory: memKB ? `${memKB}` : undefined };
78
+ }
79
+ }
80
+ else {
81
+ const uptime = execFileSync('ps', ['-p', String(pid), '-o', 'etime='], { encoding: 'utf-8' }).trim();
82
+ const cpu = execFileSync('ps', ['-p', String(pid), '-o', '%cpu='], { encoding: 'utf-8' }).trim();
83
+ const mem = execFileSync('ps', ['-p', String(pid), '-o', 'rss='], { encoding: 'utf-8' }).trim();
84
+ return { uptime, cpu, memory: mem };
85
+ }
86
+ }
87
+ catch { }
88
+ return {};
89
+ }
90
+ /**
91
+ * Cross-platform command existence check.
92
+ */
93
+ export function commandExists(cmd) {
94
+ try {
95
+ if (isWindows) {
96
+ execFileSync('where', [cmd], { encoding: 'utf-8', stdio: 'pipe' });
97
+ }
98
+ else {
99
+ execFileSync('which', [cmd], { encoding: 'utf-8', stdio: 'pipe' });
100
+ }
101
+ return true;
102
+ }
103
+ catch {
104
+ return false;
105
+ }
106
+ }
107
+ /**
108
+ * Cross-platform live log tailing (replaces tail -f).
109
+ * Returns an abort function.
110
+ */
111
+ export function tailFile(filePath) {
112
+ if (!isWindows) {
113
+ // Unix: use tail -f (more efficient)
114
+ const child = spawn('tail', ['-f', filePath], { stdio: 'inherit' });
115
+ child.on('exit', (code) => process.exit(code || 0));
116
+ return { abort: () => child.kill() };
117
+ }
118
+ // Windows: Node.js-based implementation
119
+ // Output last 20 lines of existing content
120
+ const content = fs.readFileSync(filePath, 'utf-8');
121
+ const lines = content.split('\n');
122
+ const lastLines = lines.slice(-20);
123
+ process.stdout.write(lastLines.join('\n'));
124
+ let position = fs.statSync(filePath).size;
125
+ const watcher = fs.watch(filePath, () => {
126
+ const stat = fs.statSync(filePath);
127
+ if (stat.size > position) {
128
+ const fd = fs.openSync(filePath, 'r');
129
+ const buffer = Buffer.alloc(stat.size - position);
130
+ fs.readSync(fd, buffer, 0, buffer.length, position);
131
+ fs.closeSync(fd);
132
+ process.stdout.write(buffer.toString('utf-8'));
133
+ position = stat.size;
134
+ }
135
+ });
136
+ return { abort: () => watcher.close() };
137
+ }
138
+ /**
139
+ * Resolve file path from import.meta.url (cross-platform safe).
140
+ * Replaces unsafe `new URL('.', import.meta.url).pathname` usage.
141
+ */
142
+ export function dirFromImportMeta(importMetaUrl) {
143
+ return path.dirname(fileURLToPath(importMetaUrl));
144
+ }
145
+ /**
146
+ * Check if current file is the main entry script (cross-platform safe).
147
+ * Replaces unsafe `import.meta.url === \`file://\${process.argv[1]}\`` check.
148
+ */
149
+ export function isMainScript(importMetaUrl) {
150
+ const argv1 = process.argv[1];
151
+ if (!argv1)
152
+ return false;
153
+ try {
154
+ const selfPath = fileURLToPath(importMetaUrl);
155
+ const argvPath = fs.realpathSync(argv1);
156
+ return selfPath === argvPath || fs.realpathSync(selfPath) === argvPath;
157
+ }
158
+ catch {
159
+ return false;
160
+ }
161
+ }
162
+ /**
163
+ * Register graceful shutdown signal handlers (cross-platform safe).
164
+ */
165
+ export function onShutdown(callback) {
166
+ process.on('SIGINT', callback);
167
+ // SIGTERM is not fully supported on Windows, but Node.js can still emit it
168
+ // in some scenarios (e.g., process managers), so register it anyway
169
+ process.on('SIGTERM', callback);
170
+ if (isWindows) {
171
+ // On Windows, also handle SIGHUP for graceful shutdown
172
+ // when the process is terminated via Task Manager or similar
173
+ process.on('SIGHUP', callback);
174
+ }
175
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "evolclaw",
3
- "version": "2.0.4",
3
+ "version": "2.0.6",
4
4
  "description": "Lightweight AI Agent gateway connecting Claude Agent SDK to messaging channels (Feishu, ACP) with multi-project session management",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -14,7 +14,7 @@
14
14
  ],
15
15
  "scripts": {
16
16
  "dev": "tsx watch src/index.ts",
17
- "build": "tsc && node -e \"const f='dist/cli.js',c=require('fs').readFileSync(f,'utf8');if(!c.startsWith('#!'))require('fs').writeFileSync(f,'#!/usr/bin/env node\\n'+c)\" && chmod +x dist/cli.js",
17
+ "build": "tsc && node -e \"const f='dist/cli.js',c=require('fs').readFileSync(f,'utf8');if(!c.startsWith('#!'))require('fs').writeFileSync(f,'#!/usr/bin/env node\\n'+c)\" && node -e \"try{require('child_process').execFileSync('chmod',['+x','dist/cli.js'])}catch{}\"",
18
18
  "start": "node dist/index.js",
19
19
  "test": "vitest run",
20
20
  "test:watch": "vitest",