jinzd-ai-cli 0.1.26 → 0.1.27

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 (3) hide show
  1. package/CLAUDE.md +92 -1
  2. package/dist/index.js +98 -38
  3. package/package.json +1 -1
package/CLAUDE.md CHANGED
@@ -337,7 +337,7 @@ const stdinLines = Array.isArray(rawStdin) ? rawStdin.map(String)
337
337
  ## 已知待改进项
338
338
 
339
339
  ### 低优先级
340
- - [ ] **macOS/Linux 完整测试**:跨平台逻辑已处理(`$SHELL` fallback、UTF-8 env vars),但尚未在非 Windows 环境实际运行验证。
340
+ - [x] **macOS/Linux 完整测试**(2026-03-01):已在 Linux 环境完成测试,基本无问题。跨平台逻辑(`$SHELL` fallback、UTF-8 env vars)验证通过。
341
341
  - [x] **Token 用量显示**:已通过 `/cost` 命令实现(v0.1.23),显示 session 累计 input/output/total tokens。
342
342
  - [ ] **GitHub 仓库迁移**:当前在 gitee,npm 包受众需要 GitHub 托管。
343
343
  - [x] **web_fetch DNS 解析时 SSRF 防护**(v0.1.25):新增 `resolveAndCheck()` 函数,用 `dns.promises.lookup()` 预解析域名,检查结果 IP 是否为私有地址。初始 URL 和 redirect 目标均校验。
@@ -802,3 +802,94 @@ const stdinLines = Array.isArray(rawStdin) ? rawStdin.map(String)
802
802
  | run_interactive stdin 截断 | 通过 shell 包装 spawn 时 stdin pipe 被截断 | 直接 `spawn(pythonExe, args)` 不经过任何 shell |
803
803
  | Ctrl+C 在工具确认时异常退出 | SIGINT handler 直接调用 `handleExit()`,未检查 `confirm()` 状态 | SIGINT handler 先检查 `confirming` 标志,是则 `cancelConfirm()` 取消而非退出 |
804
804
  | 日期注入在 pkg exe 中显示错误 | `toLocaleDateString/toLocaleTimeString` 依赖完整 ICU,pkg 精简版不支持 | 改为手动数字拼接:`${year}年${month}月${day}日 ${weekday} ${HH}:${mm}:${ss}` |
805
+
806
+ ---
807
+
808
+ ## 代码审查报告(2026-03-01)
809
+
810
+ > 全面扫描 src/ 目录 35+ 个 TypeScript 源文件,共发现 **4 高危 + 8 中危 + 7 低危** 问题。
811
+
812
+ ### 🔴 高危问题(立即修复)
813
+
814
+ **H1 — MCP pendingRequests 内存泄漏**
815
+ - **文件**:`src/mcp/client.ts`
816
+ - **描述**:进程崩溃时 `pendingRequests.delete(id)` 可能不触发,timer 未清理;长期运行时内存持续增长
817
+ - **修复**:在 `sendRequest` 中用 `finally` 确保 `pendingRequests.delete(id)` + `clearTimeout(timer)` 总被执行
818
+
819
+ **H2 — readline 竞态条件:`confirming` 标志失效**
820
+ - **文件**:`src/tools/executor.ts`(行 58, 155, 307, 320-356)
821
+ - **描述**:`batchConfirm()` 提前返回 `'none'` 时 `confirming = true` 与实际状态不匹配;`once('line')` 在 edge case 下可能触发两次,误消费用户输入;`ask_user` 工具的 `askUserContext.prompting` 存在同样问题
822
+ - **修复**:使用 `completed` 布尔标志防止二次触发,或改用 Promise-based 状态机
823
+
824
+ **H3 — 工具执行错误语义混淆(isError 混用)**
825
+ - **文件**:`src/tools/executor.ts`(行 128, 160, 170)
826
+ - **描述**:权限 deny 和用户取消均返回 `{ isError: false }`,AI 无法区分"用户拒绝"与"工具正常返回",可能产生错误推理
827
+ - **修复**:统一为 `isError: true`,内容前缀加 `[User cancelled]` 或 `[Permission denied]`
828
+
829
+ **H4 — MCP 断开后无恢复机制**
830
+ - **文件**:`src/mcp/client.ts` + `src/mcp/manager.ts`
831
+ - **描述**:服务器子进程意外退出后,客户端对象仍留在 Map 中;工具列表可见但调用时 silent failure;用户需重启 CLI 才能恢复
832
+ - **修复**:`callTool()` 中检测 `isConnected`,返回友好错误提示;可选:实现指数退避自动重连
833
+
834
+ ### 🟠 中危问题(近期修复)
835
+
836
+ | # | 文件 | 问题描述 | 修复方向 |
837
+ |---|------|---------|---------|
838
+ | M5 | `src/tools/builtin/bash.ts:76-86` | `cwd` 指向不存在目录时不报错,AI 误判 cd 成功 | `existsSync` 校验后再设置 `effectiveCwd` |
839
+ | M6 | `src/tools/builtin/web-fetch.ts` | SSRF:重定向链中间 URL 不检查(仅检查首尾) | 对每个中间重定向 URL 均调用 `resolveAndCheck()` |
840
+ | M7 | `src/tools/builtin/run-interactive.ts:131-144` | stdin 写入无背压处理,大量输入可能溢出 | 监听 `drain` 事件,写满后暂停直到缓冲区释放 |
841
+ | M8 | `src/session/session-manager.ts` | 会话 JSON 损坏时错误被吞噬,无日志,用户无法诊断 | `catch` 中输出 `stderr` 警告含文件名和错误信息 |
842
+ | M9 | `src/mcp/client.ts` | `close()` 时事件监听器未移除,长期运行积累僵尸监听器 | `killProcess()` 中调用 `removeAllListeners()` |
843
+ | M10 | `src/tools/permissions.ts` | `pathPattern` 直接 `new RegExp()`,恶意配置可触发 ReDoS | 加 try/catch 包裹正则编译,或限制正则复杂度 |
844
+ | M11 | `src/repl/repl.ts`(上下文加载) | `contextFile` 设为相对路径可能遍历项目外目录 | `resolve()` 后校验路径以项目目录为前缀 |
845
+ | M12 | `src/mcp/manager.ts` | MCP 工具 schema 扁平化时丢失嵌套结构,AI 传参可能失败 | `convertDefinition()` 支持递归转换嵌套 object/array |
846
+
847
+ ### 🟡 低危问题(后续改进)
848
+
849
+ | # | 文件 | 问题描述 |
850
+ |---|------|---------|
851
+ | L1 | `src/tools/builtin/run-tests.ts:35` | `JSON.parse(package.json)` 无细粒度错误处理 |
852
+ | L2 | `src/tools/builtin/web-fetch.ts` | 恶意 HTML 大量 script 标签时正则性能下降 |
853
+ | L3 | `src/session/session.ts` | `new Date(d.created)` 非法字符串返回 Invalid Date,比较失败 |
854
+ | L4 | `src/tools/builtin/ask-user.ts` / `google-search.ts` | 模块级全局上下文,多会话架构下会串扰 |
855
+ | L5 | `src/config/schema.ts` | `modelParams.timeout` 与 provider 级 `timeout` 优先级不清晰 |
856
+ | L6 | `src/tools/executor.ts` | `call.arguments['path']` 未验证是否绝对路径,null 传入可能异常 |
857
+ | L7 | `src/repl/repl.ts` | 主循环 `line` handler 是 async 但无整体 try/catch,未捕获异常可能停响应 |
858
+
859
+ ---
860
+
861
+ ## 与 Claude Code 功能对比差距(2026-03-01)
862
+
863
+ ### ✅ ai-cli 已超越或持平 Claude Code 的功能
864
+
865
+ - 多 Provider 支持(Claude Code 仅支持 Claude 系列)
866
+ - 三层级上下文文件(Claude Code 为双层)
867
+ - Dev State 开发状态交接
868
+ - Agent Skills 系统
869
+ - 企业级权限规则 + Hooks 系统
870
+
871
+ ### ❌ 缺失功能路线图(新增)
872
+
873
+ #### P0 — 核心竞争力缺口
874
+ - [ ] **中断生成**:`Escape` 键立即停止 AI 流式输出(当前无法中断,必须等待完成)
875
+ - [ ] **多模态输入(图片)**:支持粘贴/引用图片路径作为输入(当前纯文本)
876
+ - [ ] **`/add-dir` 命令**:运行时动态添加目录到上下文
877
+ - [ ] **并行工具调用**:AI 一次返回多个工具同时执行(当前为分组串行)
878
+
879
+ #### P1 — 重要差距
880
+ - [ ] **`/memory` 命令**:查看/编辑/清理 memory.md 内容(当前只能由工具写入)
881
+ - [ ] **`/doctor` 健康检查**:诊断 API Key、网络、配置问题
882
+ - [ ] **`/bug` 反馈命令**:一键提交 bug 报告
883
+ - [ ] **Vim 编辑模式**:命令行输入支持 vim 键绑定
884
+ - [ ] **桌面通知**:长任务完成时发送系统通知
885
+ - [ ] **`--allowedTools` / `--blockedTools` 启动参数**:启动时动态限制工具集
886
+ - [ ] **流式 JSON 输出**:`--output-format streaming-json` 逐行输出(当前仅支持一次性 `--json`)
887
+
888
+ #### P2 — 体验增强
889
+ - [ ] **项目级 `.mcp.json`**:项目根目录独立 MCP 配置,优先全局 config
890
+ - [ ] **IDE 集成**:VS Code / JetBrains 扩展(架构已准备就绪)
891
+ - [ ] **OAuth/浏览器登录**:无需手动填 API Key
892
+ - [ ] **`--resume <id>` 启动参数**:命令行直接恢复指定会话
893
+ - [ ] **Extended Thinking**:Claude 3.7 深度推理模式集成
894
+ - [ ] **Word wrap 配置**:终端输出折行宽度可配置
895
+ - [ ] **主题/颜色自定义**:dark/light/custom 主题
package/dist/index.js CHANGED
@@ -1526,7 +1526,11 @@ var SessionManager = class {
1526
1526
  updated: new Date(data.updated),
1527
1527
  title: data.title
1528
1528
  });
1529
- } catch {
1529
+ } catch (err) {
1530
+ process.stderr.write(
1531
+ `[Warning] Skipping corrupted session file "${file}": ${err instanceof Error ? err.message : String(err)}
1532
+ `
1533
+ );
1530
1534
  }
1531
1535
  }
1532
1536
  return metas.sort((a, b) => b.updated.getTime() - a.updated.getTime());
@@ -1578,7 +1582,11 @@ var SessionManager = class {
1578
1582
  matches
1579
1583
  });
1580
1584
  }
1581
- } catch {
1585
+ } catch (err) {
1586
+ process.stderr.write(
1587
+ `[Warning] Skipping corrupted session file "${filePath}": ${err instanceof Error ? err.message : String(err)}
1588
+ `
1589
+ );
1582
1590
  }
1583
1591
  }
1584
1592
  return results.sort((a, b) => b.sessionMeta.updated.getTime() - a.sessionMeta.updated.getTime());
@@ -3455,12 +3463,13 @@ var bashTool = {
3455
3463
  let effectiveCwd = persistentCwd;
3456
3464
  if (cwdArg) {
3457
3465
  const resolved = resolve2(persistentCwd, cwdArg);
3458
- if (existsSync6(resolved)) {
3459
- effectiveCwd = resolved;
3460
- persistentCwd = resolved;
3461
- } else {
3462
- effectiveCwd = resolved;
3466
+ if (!existsSync6(resolved)) {
3467
+ throw new Error(
3468
+ `cwd directory does not exist: "${resolved}". Create it first (e.g. mkdir -p "${resolved}") before specifying it as cwd.`
3469
+ );
3463
3470
  }
3471
+ effectiveCwd = resolved;
3472
+ persistentCwd = resolved;
3464
3473
  }
3465
3474
  let actualCommand;
3466
3475
  if (IS_WINDOWS) {
@@ -4300,10 +4309,13 @@ var runInteractiveTool = {
4300
4309
  let lineIdx = 0;
4301
4310
  const writeNextLine = () => {
4302
4311
  if (lineIdx < stdinLines.length && !child.stdin.destroyed) {
4303
- setTimeout(() => {
4304
- child.stdin.write(stdinLines[lineIdx++] + (IS_WINDOWS2 ? "\r\n" : "\n"));
4305
- writeNextLine();
4306
- }, 150);
4312
+ const line = stdinLines[lineIdx++] + (IS_WINDOWS2 ? "\r\n" : "\n");
4313
+ const canContinue = child.stdin.write(line);
4314
+ if (canContinue) {
4315
+ setTimeout(writeNextLine, 150);
4316
+ } else {
4317
+ child.stdin.once("drain", () => setTimeout(writeNextLine, 150));
4318
+ }
4307
4319
  } else if (!child.stdin.destroyed) {
4308
4320
  child.stdin.end();
4309
4321
  }
@@ -4450,28 +4462,46 @@ var webFetchTool = {
4450
4462
  let rawHtml;
4451
4463
  let finalUrl;
4452
4464
  let contentType;
4465
+ const MAX_REDIRECTS = 10;
4466
+ const FETCH_HEADERS = {
4467
+ "User-Agent": "Mozilla/5.0 (compatible; ai-cli/1.0; +https://github.com/ai-cli)",
4468
+ Accept: "text/html,application/xhtml+xml,text/plain,*/*",
4469
+ "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8"
4470
+ };
4453
4471
  try {
4454
- const resp = await fetch(url, {
4455
- signal: controller.signal,
4456
- headers: {
4457
- "User-Agent": "Mozilla/5.0 (compatible; ai-cli/1.0; +https://github.com/ai-cli)",
4458
- Accept: "text/html,application/xhtml+xml,text/plain,*/*",
4459
- "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8"
4460
- },
4461
- redirect: "follow"
4462
- });
4463
- clearTimeout(timeoutId);
4464
- finalUrl = resp.url;
4465
- contentType = resp.headers.get("content-type") ?? "";
4466
- try {
4467
- const finalParsed = new URL(finalUrl);
4468
- if (isPrivateHost(finalParsed.hostname)) {
4469
- throw new Error(`Blocked: redirect landed on private address "${finalUrl}".`);
4472
+ let currentUrl = url;
4473
+ let resp = null;
4474
+ for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
4475
+ const parsedHop = new URL(currentUrl);
4476
+ if (isPrivateHost(parsedHop.hostname)) {
4477
+ throw new Error(`Blocked: redirect to private/internal address "${currentUrl}".`);
4478
+ }
4479
+ await resolveAndCheck(parsedHop.hostname);
4480
+ const r = await fetch(currentUrl, {
4481
+ signal: controller.signal,
4482
+ headers: FETCH_HEADERS,
4483
+ redirect: "manual"
4484
+ // 手动控制重定向
4485
+ });
4486
+ if (r.status >= 300 && r.status < 400) {
4487
+ if (hop >= MAX_REDIRECTS) {
4488
+ throw new Error(`Too many redirects (>${MAX_REDIRECTS}): ${url}`);
4489
+ }
4490
+ const location = r.headers.get("Location");
4491
+ if (!location) {
4492
+ resp = r;
4493
+ break;
4494
+ }
4495
+ currentUrl = new URL(location, currentUrl).href;
4496
+ continue;
4470
4497
  }
4471
- await resolveAndCheck(finalParsed.hostname);
4472
- } catch (e) {
4473
- if (e.message.startsWith("Blocked:")) throw e;
4498
+ resp = r;
4499
+ break;
4474
4500
  }
4501
+ clearTimeout(timeoutId);
4502
+ if (!resp) throw new Error(`Too many redirects (>${MAX_REDIRECTS}): ${url}`);
4503
+ finalUrl = currentUrl;
4504
+ contentType = resp.headers.get("content-type") ?? "";
4475
4505
  if (!resp.ok) {
4476
4506
  throw new Error(`HTTP ${resp.status} ${resp.statusText}`);
4477
4507
  }
@@ -5592,7 +5622,7 @@ var ToolExecutor = class {
5592
5622
  if (this.permissionRules.length > 0) {
5593
5623
  const action = checkPermission(call.name, call.arguments, dangerLevel, this.permissionRules, this.defaultPermission);
5594
5624
  if (action === "deny") {
5595
- return { callId: call.id, content: `Permission denied by rule for tool: ${call.name}`, isError: false };
5625
+ return { callId: call.id, content: `Permission denied by rule for tool: ${call.name}`, isError: true };
5596
5626
  }
5597
5627
  if (action === "auto-approve") {
5598
5628
  this.printToolCall(call);
@@ -5619,7 +5649,7 @@ var ToolExecutor = class {
5619
5649
  return {
5620
5650
  callId: call.id,
5621
5651
  content: "User cancelled the operation.",
5622
- isError: false
5652
+ isError: true
5623
5653
  };
5624
5654
  }
5625
5655
  } else if (dangerLevel === "destructive") {
@@ -5628,7 +5658,7 @@ var ToolExecutor = class {
5628
5658
  return {
5629
5659
  callId: call.id,
5630
5660
  content: "User cancelled the operation.",
5631
- isError: false
5661
+ isError: true
5632
5662
  };
5633
5663
  }
5634
5664
  this.printToolCall(call);
@@ -5721,7 +5751,7 @@ var ToolExecutor = class {
5721
5751
  }
5722
5752
  } else {
5723
5753
  console.log(chalk8.gray(` [${i + 1}] `) + chalk8.dim("rejected"));
5724
- results.push({ callId: call.id, content: "User rejected the operation.", isError: false });
5754
+ results.push({ callId: call.id, content: "User rejected the operation.", isError: true });
5725
5755
  }
5726
5756
  }
5727
5757
  return results;
@@ -5752,7 +5782,10 @@ var ToolExecutor = class {
5752
5782
  this.confirming = false;
5753
5783
  resolve5(result);
5754
5784
  };
5785
+ let completed = false;
5755
5786
  const onLine = (line) => {
5787
+ if (completed) return;
5788
+ completed = true;
5756
5789
  const input2 = line.trim().toLowerCase();
5757
5790
  if (input2 === "a" || input2 === "all" || input2 === "y") {
5758
5791
  cleanup("all");
@@ -5768,6 +5801,8 @@ var ToolExecutor = class {
5768
5801
  }
5769
5802
  };
5770
5803
  this.cancelConfirmFn = () => {
5804
+ if (completed) return;
5805
+ completed = true;
5771
5806
  process.stdout.write(chalk8.gray("\n(cancelled)\n"));
5772
5807
  cleanup("none");
5773
5808
  };
@@ -5916,8 +5951,15 @@ var ToolExecutor = class {
5916
5951
  this.confirming = false;
5917
5952
  resolve5(answer === "y");
5918
5953
  };
5919
- const onLine = (line) => cleanup(line.trim().toLowerCase());
5954
+ let completed = false;
5955
+ const onLine = (line) => {
5956
+ if (completed) return;
5957
+ completed = true;
5958
+ cleanup(line.trim().toLowerCase());
5959
+ };
5920
5960
  this.cancelConfirmFn = () => {
5961
+ if (completed) return;
5962
+ completed = true;
5921
5963
  process.stdout.write(chalk8.gray("\n(cancelled)\n"));
5922
5964
  cleanup("n");
5923
5965
  };
@@ -6525,7 +6567,9 @@ var McpClient = class {
6525
6567
  // ══════════════════════════════════════════════════════════════════
6526
6568
  ensureConnected() {
6527
6569
  if (!this.connected) {
6528
- throw new Error(`MCP server [${this.serverId}] is not connected`);
6570
+ throw new Error(
6571
+ `MCP server [${this.serverId}] is not connected` + (this.errorMessage ? `: ${this.errorMessage}` : "")
6572
+ );
6529
6573
  }
6530
6574
  }
6531
6575
  /** Promise 超时包装 */
@@ -6551,9 +6595,13 @@ var McpClient = class {
6551
6595
  }
6552
6596
  this.pendingRequests.clear();
6553
6597
  }
6554
- /** 杀掉子进程 */
6598
+ /** 杀掉子进程,并移除所有事件监听器防止僵尸引用 */
6555
6599
  killProcess() {
6556
6600
  if (this.process) {
6601
+ this.process.removeAllListeners("error");
6602
+ this.process.removeAllListeners("exit");
6603
+ this.process.stdout?.removeAllListeners("data");
6604
+ this.process.stderr?.removeAllListeners("data");
6557
6605
  try {
6558
6606
  this.process.stdin?.end();
6559
6607
  this.process.kill();
@@ -6661,6 +6709,11 @@ var McpManager = class {
6661
6709
  return {
6662
6710
  definition,
6663
6711
  execute: async (args) => {
6712
+ if (!client.isConnected) {
6713
+ throw new Error(
6714
+ `MCP server [${serverId}] is disconnected` + (client.errorMessage ? `: ${client.errorMessage}` : "") + `. Use /mcp to check status, or restart ai-cli to reconnect.`
6715
+ );
6716
+ }
6664
6717
  try {
6665
6718
  const result = await client.callTool(mcpTool.name, args);
6666
6719
  if (result.isError) {
@@ -7011,7 +7064,14 @@ var Repl = class {
7011
7064
  if (setting === false) return { layers: [], mergedContent: "" };
7012
7065
  const cwd = process.cwd();
7013
7066
  if (setting !== "auto") {
7014
- const fullPath = join13(cwd, setting);
7067
+ const fullPath = resolve4(cwd, String(setting));
7068
+ if (!fullPath.startsWith(resolve4(cwd))) {
7069
+ process.stderr.write(
7070
+ `[Warning] contextFile path "${setting}" is outside current directory, ignoring.
7071
+ `
7072
+ );
7073
+ return { layers: [], mergedContent: "" };
7074
+ }
7015
7075
  if (existsSync18(fullPath)) {
7016
7076
  const content = readFileSync13(fullPath, "utf-8").trim();
7017
7077
  if (content) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jinzd-ai-cli",
3
- "version": "0.1.26",
3
+ "version": "0.1.27",
4
4
  "description": "Cross-platform REPL-style AI CLI with multi-provider support",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",