From 101670612ad04d49036517a5d3e848506862285d Mon Sep 17 00:00:00 2001
From: Suntion <149924916+SunYanbox@users.noreply.github.com>
Date: Wed, 6 May 2026 18:34:38 +0800
Subject: [PATCH 1/9] =?UTF-8?q?feat(workspace):=20=E9=87=8D=E6=9E=84?=
=?UTF-8?q?=E6=8E=92=E9=99=A4=E4=B8=8E=E6=9D=83=E9=99=90=E7=AE=A1=E7=90=86?=
=?UTF-8?q?=E6=9E=B6=E6=9E=84=EF=BC=8C=E7=BB=9F=E4=B8=80=E5=AE=89=E5=85=A8?=
=?UTF-8?q?=E7=AD=96=E7=95=A5=20-=20=E6=96=B0=E5=A2=9E=E5=8A=9F=E8=83=BD:?=
=?UTF-8?q?=20=E5=BC=95=E5=85=A5=E7=BB=9F=E4=B8=80=E7=9A=84=E6=96=87?=
=?UTF-8?q?=E4=BB=B6=E6=8E=92=E9=99=A4=E4=B8=8E=E6=9D=83=E9=99=90=E5=86=B3?=
=?UTF-8?q?=E7=AD=96=E5=BC=95=E6=93=8E=20=20=20*=20=E6=96=B0=E5=A2=9E=20`E?=
=?UTF-8?q?xclusionManager`=20=E7=B1=BB=EF=BC=8C=E8=81=9A=E5=90=88?=
=?UTF-8?q?=E9=BB=98=E8=AE=A4=E6=80=A7=E8=83=BD=E6=8E=92=E9=99=A4=E3=80=81?=
=?UTF-8?q?.gitignore=20=E8=A7=84=E5=88=99=E5=8F=8A=E7=94=A8=E6=88=B7?=
=?UTF-8?q?=E8=87=AA=E5=AE=9A=E4=B9=89=E5=BF=BD=E7=95=A5=E9=A1=B9=20=20=20?=
=?UTF-8?q?*=20=E6=96=B0=E5=A2=9E=20`PermissionManager`=20=E7=B1=BB?=
=?UTF-8?q?=EF=BC=8C=E6=8F=90=E4=BE=9B=E5=9F=BA=E4=BA=8E=E6=93=8D=E4=BD=9C?=
=?UTF-8?q?=E7=B1=BB=E5=9E=8B=20(READ/WRITE/SEARCH)=20=E7=9A=84=E7=BB=9F?=
=?UTF-8?q?=E4=B8=80=E6=9D=83=E9=99=90=E5=86=B3=E7=AD=96=E6=8E=A5=E5=8F=A3?=
=?UTF-8?q?=20=20=20*=20=E6=96=B0=E5=A2=9E=20`SensitiveFileError`=20?=
=?UTF-8?q?=E5=BC=82=E5=B8=B8=E7=B1=BB=EF=BC=8C=E7=94=A8=E4=BA=8E=E5=9C=A8?=
=?UTF-8?q?=E8=B7=AF=E5=BE=84=E6=A0=A1=E9=AA=8C=E9=98=B6=E6=AE=B5=E6=8B=A6?=
=?UTF-8?q?=E6=88=AA=E6=95=8F=E6=84=9F=E6=96=87=E4=BB=B6=E8=AE=BF=E9=97=AE?=
=?UTF-8?q?=20=20=20*=20=E5=B7=A5=E5=85=B7=E5=B1=82=E9=9B=86=E6=88=90?=
=?UTF-8?q?=EF=BC=9A`ls=5Ftool`,=20`glob=5Ftool`,=20`regex=5Fsearch=5Ftool?=
=?UTF-8?q?`,=20`exact=5Fsearch=5Ftool`=20=E5=9D=87=E6=8E=A5=E5=85=A5=20`e?=
=?UTF-8?q?xclusion=5Fmanager`=20=E8=BF=9B=E8=A1=8C=E8=B7=AF=E5=BE=84?=
=?UTF-8?q?=E8=BF=87=E6=BB=A4=20-=20=E4=BF=AE=E5=A4=8D=E9=97=AE=E9=A2=98:?=
=?UTF-8?q?=20=E5=A2=9E=E5=BC=BA=E6=95=8F=E6=84=9F=E6=96=87=E4=BB=B6?=
=?UTF-8?q?=E4=BF=9D=E6=8A=A4=E6=9C=BA=E5=88=B6=20=20=20*=20=E5=9C=A8=20`P?=
=?UTF-8?q?athValidator`=20=E4=B8=AD=E5=A2=9E=E5=8A=A0=20`=5Fraise=5Fif=5F?=
=?UTF-8?q?sensitive`=20=E9=80=BB=E8=BE=91=EF=BC=8C=E7=9B=B4=E6=8E=A5?=
=?UTF-8?q?=E7=A6=81=E6=AD=A2=E8=AE=BF=E9=97=AE=20`.env`,=20`*.pem`,=20`id?=
=?UTF-8?q?=5Frsa`=20=E7=AD=89=E6=95=8F=E6=84=9F=E6=96=87=E4=BB=B6=20=20?=
=?UTF-8?q?=20*=20=E5=9C=A8=20`BaseTool`=20=E7=9A=84=E5=BC=82=E5=B8=B8?=
=?UTF-8?q?=E5=A4=84=E7=90=86=E8=A3=85=E9=A5=B0=E5=99=A8=E4=B8=AD=E6=8D=95?=
=?UTF-8?q?=E8=8E=B7=20`SensitiveFileError`=EF=BC=8C=E8=BF=94=E5=9B=9E?=
=?UTF-8?q?=E6=98=8E=E7=A1=AE=E7=9A=84=E6=8B=92=E7=BB=9D=E8=AE=BF=E9=97=AE?=
=?UTF-8?q?=E6=8F=90=E7=A4=BA=20=20=20*=20=E5=B0=86=E5=8E=9F=E6=9C=AC?=
=?UTF-8?q?=E7=A1=AC=E7=BC=96=E7=A0=81=E7=9A=84=20`DEFAULT=5FEXCLUDED=5FDI?=
=?UTF-8?q?RS`=20=E6=9B=BF=E6=8D=A2=E4=B8=BA=E5=8A=A8=E6=80=81=E5=8A=A0?=
=?UTF-8?q?=E8=BD=BD=E7=9A=84=20`ExclusionManager`=20=E5=AE=9E=E4=BE=8B=20?=
=?UTF-8?q?-=20=E9=87=8D=E6=9E=84=E4=BC=98=E5=8C=96:=20=E7=AE=80=E5=8C=96?=
=?UTF-8?q?=E6=90=9C=E7=B4=A2=E4=B8=8E=E9=81=8D=E5=8E=86=E9=80=BB=E8=BE=91?=
=?UTF-8?q?=20=20=20*=20=E7=A7=BB=E9=99=A4=20`workspace.py`=20=E5=92=8C?=
=?UTF-8?q?=E5=90=84=E7=B1=BB=20Search=20Tool=20=E4=B8=AD=E9=87=8D?=
=?UTF-8?q?=E5=A4=8D=E7=9A=84=E6=AD=A3=E5=88=99=E7=BC=96=E8=AF=91=E4=B8=8E?=
=?UTF-8?q?=E6=8E=92=E9=99=A4=E9=80=BB=E8=BE=91=20=20=20*=20=E4=BD=BF?=
=?UTF-8?q?=E7=94=A8=20`merge=5Fignore=5Fregexes`=20=E6=96=B9=E6=B3=95?=
=?UTF-8?q?=E7=BB=9F=E4=B8=80=E5=90=88=E5=B9=B6=E9=BB=98=E8=AE=A4=E8=A7=84?=
=?UTF-8?q?=E5=88=99=E4=B8=8E=E7=94=A8=E6=88=B7=E4=BC=A0=E5=85=A5=E7=9A=84?=
=?UTF-8?q?=20ignore=20=E6=A8=A1=E5=BC=8F=20=20=20*=20=E5=88=A9=E7=94=A8?=
=?UTF-8?q?=20`is=5Fignored=5Fby=5Fgitignore`=20=E5=87=BD=E6=95=B0?=
=?UTF-8?q?=E7=BB=9F=E4=B8=80=E5=A4=84=E7=90=86=20.gitignore=20=E8=A7=84?=
=?UTF-8?q?=E5=88=99=E7=9A=84=E5=8C=B9=E9=85=8D=E4=B8=8E=E5=90=A6=E5=AE=9A?=
=?UTF-8?q?=E9=80=BB=E8=BE=91=20-=20=E6=96=87=E6=A1=A3=E6=9B=B4=E6=96=B0:?=
=?UTF-8?q?=20=E8=A1=A5=E5=85=85=E6=A8=A1=E5=9D=97=E8=AE=BE=E8=AE=A1?=
=?UTF-8?q?=E8=AF=B4=E6=98=8E=20=20=20*=20=E6=B7=BB=E5=8A=A0=20`ExclusionM?=
=?UTF-8?q?anager`=20=E5=92=8C=20`PermissionManager`=20=E7=9A=84=E7=B1=BB?=
=?UTF-8?q?=E7=BA=A7=E6=96=87=E6=A1=A3=E5=AD=97=E7=AC=A6=E4=B8=B2=EF=BC=8C?=
=?UTF-8?q?=E6=98=8E=E7=A1=AE=E5=8C=BA=E5=88=86=E6=80=A7=E8=83=BD=E6=8E=92?=
=?UTF-8?q?=E9=99=A4=E4=B8=8E=E5=AE=89=E5=85=A8=E6=8E=92=E9=99=A4=E5=9C=BA?=
=?UTF-8?q?=E6=99=AF?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
src/workspace/exclusion_manager.py | 219 +++++++++++++++++++++++
src/workspace/gitignore_loader.py | 167 +++++++++++++++++
src/workspace/path_validator.py | 36 +++-
src/workspace/permissions.py | 126 +++++++++++++
src/workspace/tools/base_tool.py | 11 +-
src/workspace/tools/exact_search_tool.py | 10 +-
src/workspace/tools/glob_tool.py | 2 +
src/workspace/tools/ls_tool.py | 2 +
src/workspace/tools/regex_search_tool.py | 10 +-
src/workspace/workspace.py | 26 ++-
10 files changed, 578 insertions(+), 31 deletions(-)
create mode 100644 src/workspace/exclusion_manager.py
create mode 100644 src/workspace/gitignore_loader.py
create mode 100644 src/workspace/permissions.py
diff --git a/src/workspace/exclusion_manager.py b/src/workspace/exclusion_manager.py
new file mode 100644
index 0000000..2bd5b33
--- /dev/null
+++ b/src/workspace/exclusion_manager.py
@@ -0,0 +1,219 @@
+"""统一排除管理器 —— 合并 gitignore、用户 ignore、默认排除规则.
+
+区分两类排除:
+- 性能排除: 缓存/构建产物等不影响安全的目录
+- 安全排除: 隐私/凭据文件等不应被 AI 访问的路径
+"""
+
+import os
+import re
+from pathlib import Path
+from typing import ClassVar
+
+from src.workspace.gitignore_loader import is_ignored_by_gitignore, load_gitignore
+
+
+class ExclusionManager:
+ """排除规则统一管理器.
+
+ 聚合三类排除源:
+ 1. 默认排除(内置的缓存/构建/IDE 目录)
+ 2. .gitignore 规则(如项目中有 .gitignore 文件)
+ 3. 用户临时 ignore 参数
+
+ Args:
+ workspace_root: 工作区根目录
+ """
+
+ # 性能排除 —— 缓存、构建产物、IDE 配置等
+ PERFORMANCE_EXCLUSIONS: frozenset[str] = frozenset(
+ {
+ ".git",
+ "__pycache__",
+ "node_modules",
+ ".venv",
+ "venv",
+ "dist",
+ "build",
+ ".idea",
+ ".vscode",
+ ".ruff_cache",
+ ".pytest_cache",
+ ".mypy_cache",
+ ".hypothesis",
+ "htmlcov",
+ ".coverage",
+ "*.pyc",
+ "*.pyo",
+ ".eggs",
+ "*.egg-info",
+ ".tox",
+ ".nox",
+ ".svn",
+ ".hg",
+ ".bzr",
+ "target", # Rust build
+ ".next", # Next.js build
+ ".nuxt", # Nuxt build
+ ".output", # Nuxt output
+ }
+ )
+
+ # 安全排除 —— 敏感文件, AI 不应读取
+ SECURITY_EXCLUSIONS: frozenset[str] = frozenset(
+ {
+ ".env",
+ ".env.*",
+ "*.pem",
+ "credentials.*",
+ "*.key",
+ "*.cert",
+ "id_rsa",
+ "id_ed25519",
+ "*.cred",
+ "*.secret",
+ "**/vault/**",
+ }
+ )
+
+ # 安全排除 —— 需要精确匹配的特定文件
+ SENSITIVE_FILE_PATTERNS: ClassVar[list[str]] = [
+ r"\.env$",
+ r"\.env\..+$",
+ r".*\.pem$",
+ r"credentials\..*$",
+ r".*\.key$",
+ r".*\.cert$",
+ r"id_rsa$",
+ r"id_ed25519$",
+ r".*\.cred$",
+ r".*\.secret$",
+ ]
+
+ def __init__(self, workspace_root: str | Path):
+ self._workspace_root = Path(workspace_root).resolve()
+ # 从 .gitignore 加载
+ self._raw_gitignore_patterns: list[str] = []
+ self._gitignore_exclude_res: list[re.Pattern] = []
+ self._gitignore_negate_res: list[re.Pattern] = []
+
+ self._reload_gitignore()
+
+ # 编译敏感文件正则
+ self._sensitive_file_res: list[re.Pattern] = []
+ for pat in self.SENSITIVE_FILE_PATTERNS:
+ try:
+ self._sensitive_file_res.append(re.compile(pat))
+ except re.error:
+ continue
+
+ def _reload_gitignore(self) -> None:
+ """(重新)加载 .gitignore."""
+ raw, exclude_res, negate_res = load_gitignore(self._workspace_root)
+ self._raw_gitignore_patterns = raw
+ self._gitignore_exclude_res = exclude_res
+ self._gitignore_negate_res = negate_res
+
+ def _check_performance_exclusion(self, rel_path_str: str) -> bool:
+ """检查路径是否匹配性能排除规则(基于目录名)."""
+ # 将路径拆分为各层, 检查每层是否在排除集合中
+ parts = rel_path_str.replace(os.sep, "/").split("/")
+ for part in parts:
+ # 检查部分匹配: "node_modules" 或通配匹配
+ if part in self.PERFORMANCE_EXCLUSIONS:
+ return True
+ # 检查 *.xxx 模式
+ for exclude in self.PERFORMANCE_EXCLUSIONS:
+ if exclude.startswith("*.") and part.endswith(exclude[1:]):
+ return True
+ return False
+
+ def should_exclude_dir(self, dir_name: str) -> bool:
+ """检查目录名是否应该被排除(基于名称的快速检查).
+
+ 用于 glob/ls 等基于目录名的过滤场景.
+ """
+ return dir_name in self.PERFORMANCE_EXCLUSIONS
+
+ def should_exclude_path(self, path: Path) -> bool:
+ """检查路径是否应被排除(全面检查).
+
+ 依次检查: 默认排除目录名 → gitignore 规则 → 否定规则
+
+ Args:
+ path: 文件的绝对路径
+
+ Returns:
+ True 表示应排除
+ """
+ try:
+ rel_path = path.relative_to(self._workspace_root)
+ except ValueError:
+ # 在工作区外, 不在这里处理(由 PathValidator 处理)
+ return False
+
+ rel_str = str(rel_path).replace(os.sep, "/")
+
+ # 1. 性能排除: 检查所有父目录
+ if self._check_performance_exclusion(rel_str):
+ return True
+
+ # 2. gitignore 排除
+ return is_ignored_by_gitignore(rel_str, self._gitignore_exclude_res, self._gitignore_negate_res)
+
+ def is_sensitive_file(self, path: Path) -> bool:
+ """检查路径是否为敏感文件.
+
+ Args:
+ path: 文件绝对路径
+
+ Returns:
+ True 表示是敏感文件
+ """
+ try:
+ rel_str = str(path.relative_to(self._workspace_root)).replace(os.sep, "/")
+ except ValueError:
+ return False
+
+ return any(regex.search(rel_str) for regex in self._sensitive_file_res)
+
+ def merge_ignore_regexes(self, user_ignore: list[str] | None = None) -> list[re.Pattern]:
+ """合并默认排除 + gitignore + 用户 ignore 为正则列表.
+
+ 用于 search_content 等需要正则匹配排除的场景.
+
+ Args:
+ user_ignore: 用户传入的忽略正则列表
+
+ Returns:
+ 编译后的正则列表
+ """
+ result: list[re.Pattern] = []
+
+ # 默认排除目录名 → 正则
+ for excl in self.PERFORMANCE_EXCLUSIONS:
+ # 处理 *.pyc 类模式
+ if excl.startswith("*."):
+ pat = excl[1:] # .pyc
+ result.append(re.compile(re.escape(pat) + "$"))
+ else:
+ # 匹配路径中的此目录名
+ result.append(re.compile(r"(^|/)" + re.escape(excl) + r"(/|$)"))
+
+ # gitignore 排除正则
+ result.extend(self._gitignore_exclude_res)
+
+ # 用户传入的 ignore 正则
+ if user_ignore:
+ for ign in user_ignore:
+ try:
+ result.append(re.compile(ign))
+ except re.error:
+ continue
+
+ return result
+
+ @property
+ def excluded_dir_names(self) -> set[str]:
+ """获取所有排除目录名集合(用于快速 in 检查)."""
+ return {d for d in self.PERFORMANCE_EXCLUSIONS if not d.startswith("*")}
diff --git a/src/workspace/gitignore_loader.py b/src/workspace/gitignore_loader.py
new file mode 100644
index 0000000..64c86e1
--- /dev/null
+++ b/src/workspace/gitignore_loader.py
@@ -0,0 +1,167 @@
+"""Parse .gitignore files and convert patterns to regex for exclusion matching."""
+
+import os
+import re
+from pathlib import Path
+
+
+def _convert_gitignore_to_regex(pattern: str) -> str | None:
+ """将 .gitignore 模式转换为正则表达式.
+
+ Args:
+ pattern: .gitignore 模式(如 *.log, build/, /foo)
+
+ Returns:
+ 对应的正则表达式字符串, 如果模式无效则返回 None
+ """
+ # 保留原始模式用于锚定判断
+ original = pattern
+ is_dir_only = pattern.endswith("/")
+ if is_dir_only:
+ pattern = pattern.rstrip("/")
+
+ # 处理否定模式(仅用于判断是否为目录模式, 不处理逻辑)
+ if pattern.startswith("!"):
+ pattern = pattern[1:]
+
+ # 转义正则特殊字符, 再处理 gitignore 通配符
+ # 先处理 ** (多级通配符)
+ parts = []
+ i = 0
+ while i < len(pattern):
+ if pattern[i : i + 2] == "**":
+ parts.append(".*")
+ i += 2
+ elif pattern[i] == "*":
+ # 单级通配符, 不匹配路径分隔符
+ parts.append(r"[^/]*")
+ i += 1
+ elif pattern[i] == "?":
+ parts.append(r"[^/]")
+ i += 1
+ elif pattern[i] in ".+^${}()|[]\\":
+ parts.append("\\" + pattern[i])
+ i += 1
+ else:
+ parts.append(pattern[i])
+ i += 1
+
+ regex_str = "".join(parts)
+
+ # 锚定: / 开头表示从根目录匹配, 否则匹配任意路径
+ if original.startswith("/"):
+ regex_str = "^" + regex_str[1:] # 去掉开头的 /
+ elif original.startswith("!"):
+ # 处理否定模式 - 保持锚定逻辑不变
+ regex_str = "^" + regex_str[1:] if original[1:].startswith("/") else "(^|/)" + regex_str
+ else:
+ regex_str = "(^|/)" + regex_str
+
+ if is_dir_only:
+ regex_str += "(/.*)?$"
+ else:
+ regex_str += "$"
+
+ return regex_str
+
+
+def parse_gitignore(gitignore_path: str | Path) -> list[str]:
+ """解析 .gitignore 文件, 返回非否定排除模式列表.
+
+ Args:
+ gitignore_path: .gitignore 文件路径
+
+ Returns:
+ 排除模式列表(目录名/通配符等原始 gitignore 格式)
+ """
+ patterns: list[str] = []
+ gitignore_path = Path(gitignore_path)
+
+ if not gitignore_path.exists():
+ return patterns
+
+ try:
+ text = gitignore_path.read_text(encoding="utf-8")
+ except Exception:
+ return patterns
+
+ for line in text.splitlines():
+ stripped = line.strip()
+
+ # 跳过空行和注释
+ if not stripped or stripped.startswith("#"):
+ continue
+
+ # 保留否定模式供外部处理, 返回原始行
+ patterns.append(stripped)
+
+ return patterns
+
+
+def compile_gitignore_patterns(patterns: list[str]) -> tuple[list[re.Pattern], list[re.Pattern]]:
+ """将 gitignore 模式编译为正则表达式.
+
+ Args:
+ patterns: 原始 gitignore 模式列表
+
+ Returns:
+ (排除正则列表, 否定排除正则列表) 的元组
+ """
+ exclude_res: list[re.Pattern] = []
+ negate_res: list[re.Pattern] = []
+
+ for pattern in patterns:
+ if pattern.startswith("!"):
+ # 否定模式: 取消排除
+ negate_regex = _convert_gitignore_to_regex(pattern)
+ if negate_regex:
+ try:
+ negate_res.append(re.compile(negate_regex))
+ except re.error:
+ continue
+ else:
+ regex = _convert_gitignore_to_regex(pattern)
+ if regex:
+ try:
+ exclude_res.append(re.compile(regex))
+ except re.error:
+ continue
+
+ return exclude_res, negate_res
+
+
+def is_ignored_by_gitignore(path: str | Path, exclude_res: list[re.Pattern], negate_res: list[re.Pattern]) -> bool:
+ """检查路径是否被 .gitignore 规则忽略.
+
+ Args:
+ path: 要检查的相对路径(字符串形式)
+ exclude_res: 排除正则列表
+ negate_res: 否定排除正则列表
+
+ Returns:
+ 是否应该被忽略
+ """
+ path_str = str(path).replace(os.sep, "/")
+
+ # 先检查否定模式(优先级更高)
+ for negate_re in negate_res:
+ if negate_re.search(path_str):
+ return False
+
+ # 再检查排除模式
+ return any(exclude_re.search(path_str) for exclude_re in exclude_res)
+
+
+def load_gitignore(workspace_root: str | Path) -> tuple[list[str], list[re.Pattern], list[re.Pattern]]:
+ """从工作区根目录加载 .gitignore.
+
+ Args:
+ workspace_root: 工作区根目录
+
+ Returns:
+ (原始模式列表, 排除正则列表, 否定排除正则列表)
+ """
+ gitignore_path = Path(workspace_root) / ".gitignore"
+ raw_patterns = parse_gitignore(gitignore_path)
+ exclude_res, negate_res = compile_gitignore_patterns(raw_patterns)
+ return raw_patterns, exclude_res, negate_res
diff --git a/src/workspace/path_validator.py b/src/workspace/path_validator.py
index 2a12d7d..388df77 100644
--- a/src/workspace/path_validator.py
+++ b/src/workspace/path_validator.py
@@ -1,5 +1,7 @@
import os
+import re
from pathlib import Path
+from typing import ClassVar
class WorkspaceBoundaryError(Exception):
@@ -14,6 +16,12 @@ class PathNotFoundError(Exception):
pass
+class SensitiveFileError(Exception):
+ """访问敏感文件时抛出"""
+
+ pass
+
+
class PathValidator:
"""工作区路径安全校验器,防止路径遍历和符号链接逃逸
@@ -21,12 +29,27 @@ class PathValidator:
workspace_root: 工作区根目录,默认为当前目录
"""
+ # 敏感文件匹配模式
+ SENSITIVE_FILE_PATTERNS: ClassVar[list[re.Pattern]] = [
+ re.compile(r"\.env$"),
+ re.compile(r"\.env\..+$"),
+ re.compile(r".*\.pem$"),
+ re.compile(r"credentials\..*$"),
+ re.compile(r".*\.key$"),
+ re.compile(r".*\.cert$"),
+ re.compile(r"id_rsa$"),
+ re.compile(r"id_ed25519$"),
+ re.compile(r".*\.cred$"),
+ re.compile(r".*\.secret$"),
+ re.compile(r"\.ManualAid[/\\].*\.db$"),
+ ]
+
def __init__(self, workspace_root: str | Path = "."):
"""初始化路径验证器.
Args:
workspace_root: 工作区根目录路径,可以是字符串或 Path 对象
- 所有后续的路径验证都将以此目录为边界
+ 所有后续的路径验证都将以此目录为基准
Raises:
FileNotFoundError: 当 workspace_root 不存在时抛出
@@ -71,8 +94,19 @@ def resolve_path(self, target: str | Path) -> Path:
if not str(resolved).startswith(str(self.root) + os.sep) and resolved != self.root:
raise WorkspaceBoundaryError(f"路径越界: {target}")
+ # 敏感文件检查
+ self._raise_if_sensitive(resolved, target)
+
return resolved
+ @classmethod
+ def _raise_if_sensitive(cls, resolved: Path, original_target: str | Path) -> None:
+ """检查路径是否匹配敏感文件模式."""
+ rel_str = str(resolved).replace("\\", "/")
+ for pattern in cls.SENSITIVE_FILE_PATTERNS:
+ if pattern.search(rel_str):
+ raise SensitiveFileError(f"禁止访问敏感文件: {original_target}")
+
def create_file_with_parents(self, target: str | Path, content: str = "") -> Path:
"""在工作区内创建文件,自动创建所有不存在的父目录.
diff --git a/src/workspace/permissions.py b/src/workspace/permissions.py
new file mode 100644
index 0000000..a87b5ad
--- /dev/null
+++ b/src/workspace/permissions.py
@@ -0,0 +1,126 @@
+"""统一权限决策引擎 —— 路径级细粒度权限控制.
+
+整合现有权限机制:
+1. BaseTool 的 read_permission/write_permission 布尔属性
+2. PathValidator 的边界检查
+3. binary_detector 的文件类型检测
+4. 敏感文件保护(新增)
+5. Git 工具的安全模型(白名单+拦截正则, 后续提取)
+6. mtime 校验
+7. 审计审批层
+
+提供统一的 "工具 X 能否对路径 Y 执行操作 Z" 查询接口.
+"""
+
+from __future__ import annotations
+
+from enum import Enum, auto
+from pathlib import Path
+
+
+class Operation(Enum):
+ """权限操作类型."""
+
+ READ = auto()
+ WRITE = auto()
+ SEARCH = auto()
+ EXECUTE = auto()
+ DELETE = auto()
+
+
+class Decision(Enum):
+ """权限决策结果."""
+
+ ALLOWED = "allowed"
+ DENIED = "denied"
+
+
+class PermissionManager:
+ """统一权限决策引擎.
+
+ 使用方式(从 Workspace 获取):
+ perm = workspace.permission_manager
+ if perm.is_allowed("read_tool", path, Operation.READ):
+ ...
+
+ Args:
+ workspace_root: 工作区根目录
+ """
+
+ def __init__(self, workspace_root: Path):
+ self._root = workspace_root
+
+ # 敏感文件正则列表(与 ExclusionManager 保持一致)
+ self._sensitive_patterns: list[str] = [
+ r"\.env$",
+ r"\.env\..+$",
+ r".*\.pem$",
+ r"credentials\..*$",
+ r".*\.key$",
+ r".*\.cert$",
+ r"id_rsa$",
+ r"id_ed25519$",
+ r".*\.cred$",
+ r".*\.secret$",
+ ]
+
+ # 操作 → 所需权限级别映射
+ self._operation_permissions: dict[Operation, str] = {
+ Operation.READ: "read",
+ Operation.WRITE: "write",
+ Operation.SEARCH: "read",
+ Operation.EXECUTE: "write",
+ Operation.DELETE: "write",
+ }
+
+ def _is_sensitive_path(self, path: Path) -> bool:
+ """检查路径是否匹配敏感文件模式."""
+ import re
+
+ try:
+ rel_str = str(path.relative_to(self._root)).replace("\\", "/")
+ except ValueError:
+ return True # 工作区外的路径视为敏感
+
+ return any(re.search(pattern, rel_str) for pattern in self._sensitive_patterns)
+
+ def check(self, tool_name: str, path: Path, operation: Operation) -> Decision:
+ """检查工具能否对路径执行操作.
+
+ 决策流程:
+ 1. 如果路径在工作区外 → DENIED
+ 2. 如果是敏感文件且操作非 SEARCH → DENIED
+ 3. 如果是二进制文件且操作是 READ/WRITE → 特殊处理(记录而非禁止)
+ 4. 否则 → ALLOWED
+
+ Args:
+ tool_name: 工具名称(如 "read_tool", "write_tool")
+ path: 目标路径
+ operation: 操作类型
+
+ Returns:
+ 权限决策结果
+ """
+ # 1. 工作区边界(双重保障, PathValidator 已做)
+ try:
+ path.relative_to(self._root)
+ except ValueError:
+ return Decision.DENIED
+
+ # 2. 敏感文件保护(禁止 READ/WRITE/EXECUTE/DELETE)
+ if operation in (
+ Operation.READ,
+ Operation.WRITE,
+ Operation.EXECUTE,
+ Operation.DELETE,
+ ) and self._is_sensitive_path(path):
+ return Decision.DENIED
+
+ # 3. 二进制文件: 允许但标记 (记录由调用方处理)
+ # 这里不做禁止, 仅在 query 中返回信息
+
+ return Decision.ALLOWED
+
+ def is_allowed(self, tool_name: str, path: Path, operation: Operation) -> bool:
+ """快捷方法: 是否允许操作."""
+ return self.check(tool_name, path, operation) == Decision.ALLOWED
diff --git a/src/workspace/tools/base_tool.py b/src/workspace/tools/base_tool.py
index 70e6ab4..e958559 100644
--- a/src/workspace/tools/base_tool.py
+++ b/src/workspace/tools/base_tool.py
@@ -244,7 +244,7 @@ def handle_tool_exceptions(func) -> Callable[..., ToolResult]:
"""工具方法异常处理装饰器 —— 将异常转换为 ToolResult 失败结果"""
from functools import wraps
- from src.workspace.path_validator import PathNotFoundError, WorkspaceBoundaryError
+ from src.workspace.path_validator import PathNotFoundError, SensitiveFileError, WorkspaceBoundaryError
@wraps(func)
def wrapper(self, *args, **kwargs):
@@ -269,13 +269,20 @@ def wrapper(self, *args, **kwargs):
func_kwargs=kwargs,
error=f"{err2.__class__.__name__}: {err2}",
)
- except PermissionError as err3:
+ except SensitiveFileError as err3:
return ToolResult(
success=False,
func_name=func.__name__,
func_kwargs=kwargs,
error=f"{err3.__class__.__name__}: {err3}",
)
+ except PermissionError as err4:
+ return ToolResult(
+ success=False,
+ func_name=func.__name__,
+ func_kwargs=kwargs,
+ error=f"{err4.__class__.__name__}: {err4}",
+ )
except Exception as err:
return ToolResult(
success=False, func_name=func.__name__, func_kwargs=kwargs, error=f"{err.__class__.__name__}: {err}"
diff --git a/src/workspace/tools/exact_search_tool.py b/src/workspace/tools/exact_search_tool.py
index 873df3a..3e11fd1 100644
--- a/src/workspace/tools/exact_search_tool.py
+++ b/src/workspace/tools/exact_search_tool.py
@@ -1,4 +1,3 @@
-import contextlib
import re
from pathlib import Path
@@ -86,6 +85,7 @@ def __init__(self, workspace: Workspace):
"limit": "最大匹配数量限制",
"ignore": "忽略匹配正则的文件或文件夹列表",
}
+ self._exclusion_manager = workspace.exclusion_manager
@BaseTool.handle_tool_exceptions
def exact_search(
@@ -107,12 +107,8 @@ def exact_search(
# 准备搜索字符串
search_string = pattern if case_sensitive else pattern.lower()
- # 收集忽略模式
- ignore_patterns = []
- if ignore:
- for ignore_pattern in ignore:
- with contextlib.suppress(re.error):
- ignore_patterns.append(re.compile(ignore_pattern))
+ # 收集忽略模式: 合并默认排除 + 用户传入的 ignore
+ ignore_patterns = self._exclusion_manager.merge_ignore_regexes(ignore)
# 搜索结果
results = []
diff --git a/src/workspace/tools/glob_tool.py b/src/workspace/tools/glob_tool.py
index df158c0..f47274a 100644
--- a/src/workspace/tools/glob_tool.py
+++ b/src/workspace/tools/glob_tool.py
@@ -15,6 +15,7 @@ def __init__(self, workspace: Workspace):
"path": "目录路径",
"max_ret": "最多返回多少条检索结果",
}
+ self._exclusion_manager = workspace.exclusion_manager
@BaseTool.handle_tool_exceptions
def glob(self, pattern: str, path: str = ".", max_ret: int = 1000) -> ToolResult:
@@ -30,5 +31,6 @@ def glob(self, pattern: str, path: str = ".", max_ret: int = 1000) -> ToolResult
data=[
f"{'[Folder]' if item.is_dir() else '[File]'} {item.relative_to(self.workspace.root_path)}"
for item in islice(root_path.glob(pattern), max_ret)
+ if not self._exclusion_manager.should_exclude_path(item)
],
)
diff --git a/src/workspace/tools/ls_tool.py b/src/workspace/tools/ls_tool.py
index 33f035d..cbb176a 100644
--- a/src/workspace/tools/ls_tool.py
+++ b/src/workspace/tools/ls_tool.py
@@ -13,6 +13,7 @@ def __init__(self, workspace: Workspace):
self.param_descriptions = {
"path": "目录路径",
}
+ self._exclusion_manager = workspace.exclusion_manager
@BaseTool.handle_tool_exceptions
def ls(self, path: str = ".") -> ToolResult:
@@ -27,5 +28,6 @@ def ls(self, path: str = ".") -> ToolResult:
data=[
f"{'[Folder]' if item.is_dir() else '[File]'} {item.relative_to(self.workspace.root_path)}"
for item in folder_path.iterdir()
+ if not self._exclusion_manager.should_exclude_path(item)
],
)
diff --git a/src/workspace/tools/regex_search_tool.py b/src/workspace/tools/regex_search_tool.py
index a8b6549..b96cd57 100644
--- a/src/workspace/tools/regex_search_tool.py
+++ b/src/workspace/tools/regex_search_tool.py
@@ -1,4 +1,3 @@
-import contextlib
import re
from pathlib import Path
@@ -111,6 +110,7 @@ def __init__(self, workspace: Workspace):
"limit": "最大匹配数量限制",
"ignore": "忽略匹配正则的文件或文件夹列表",
}
+ self._exclusion_manager = workspace.exclusion_manager
@BaseTool.handle_tool_exceptions
def regex_search(
@@ -134,12 +134,8 @@ def regex_search(
except re.error as e:
return self.make_failed_response(kwargs=locals().copy(), error=f"无效的正则表达式: {e}")
- # 收集忽略模式
- ignore_patterns = []
- if ignore:
- for ignore_pattern in ignore:
- with contextlib.suppress(re.error):
- ignore_patterns.append(re.compile(ignore_pattern))
+ # 收集忽略模式: 合并默认排除 + 用户传入的 ignore
+ ignore_patterns = self._exclusion_manager.merge_ignore_regexes(ignore)
# 搜索结果
results = []
diff --git a/src/workspace/workspace.py b/src/workspace/workspace.py
index 56699f6..fa9e960 100644
--- a/src/workspace/workspace.py
+++ b/src/workspace/workspace.py
@@ -5,10 +5,9 @@
from pathlib import Path
from src.models.tool_error_response import ToolErrorResponse
+from src.workspace.exclusion_manager import ExclusionManager
from src.workspace.path_validator import PathNotFoundError, PathValidator, WorkspaceBoundaryError
-
-# 默认排除的目录 后续改为从项目配置加载
-DEFAULT_EXCLUDED_DIRS = {".git", "__pycache__", "node_modules", ".venv", "venv", "dist", "build", ".idea", ".vscode"}
+from src.workspace.permissions import PermissionManager
def _highlight_matches(line: str, regex: re.Pattern) -> str:
@@ -47,6 +46,8 @@ def __init__(self, path: str):
return
self.root_path = Path(path).resolve()
self.path_validator: PathValidator = PathValidator(self.root_path)
+ self.exclusion_manager: ExclusionManager = ExclusionManager(self.root_path)
+ self.permission_manager: PermissionManager = PermissionManager(self.root_path)
self.is_git_repo: bool = (self.root_path / ".git").is_dir()
self.platform: str = sys.platform
self.date: str = date.today().strftime("%y-%m-%d")
@@ -84,8 +85,11 @@ def search_content(
try:
path = self.path_validator.validate(folder_path)
- # 初始化排除目录集合
- exclude_set = set(exclude_dirs or DEFAULT_EXCLUDED_DIRS)
+ # 初始化排除目录集合: 合并默认排除 + 用户传入排除
+ if exclude_dirs is not None:
+ exclude_set = set(exclude_dirs) | self.exclusion_manager.excluded_dir_names
+ else:
+ exclude_set = self.exclusion_manager.excluded_dir_names
# 编译正则表达式
flags = 0 if case_sensitive else re.IGNORECASE
@@ -218,14 +222,8 @@ def search_content_multi_pattern(
try:
path = self.path_validator.validate(folder_path)
- # 预编译 ignore 正则
- ignore_res: list[re.Pattern] = []
- if ignore:
- for ign in ignore:
- try:
- ignore_res.append(re.compile(ign))
- except re.error:
- continue
+ # 预编译 ignore 正则: 合并默认排除 + 用户传入的 ignore
+ ignore_res: list[re.Pattern] = self.exclusion_manager.merge_ignore_regexes(ignore)
# 收集文件(一次遍历)
files_to_search: list[Path] = []
@@ -234,7 +232,7 @@ def search_content_multi_pattern(
else:
for file_path in path.rglob(file_pattern):
if file_path.is_file():
- if any(p.name in DEFAULT_EXCLUDED_DIRS for p in file_path.parents):
+ if any(self.exclusion_manager.should_exclude_dir(p.name) for p in file_path.parents):
continue
rel = str(file_path.relative_to(self.root_path))
if any(ir.search(rel) for ir in ignore_res):
From 3b034db38a5216c5b63134575cb200329f2cbddb Mon Sep 17 00:00:00 2001
From: Suntion <149924916+SunYanbox@users.noreply.github.com>
Date: Wed, 6 May 2026 20:15:36 +0800
Subject: [PATCH 2/9] =?UTF-8?q?refactor(workspace):=20=E9=87=8D=E6=9E=84?=
=?UTF-8?q?=E6=9D=83=E9=99=90=E4=B8=8E=E5=AE=89=E5=85=A8=E6=A0=A1=E9=AA=8C?=
=?UTF-8?q?=E6=9E=B6=E6=9E=84=EF=BC=8C=E7=BB=9F=E4=B8=80=E6=95=8F=E6=84=9F?=
=?UTF-8?q?=E6=96=87=E4=BB=B6=E8=A7=84=E5=88=99=E6=9D=A5=E6=BA=90=20-=20?=
=?UTF-8?q?=E7=A0=B4=E5=9D=8F=E6=80=A7=E5=8F=98=E6=9B=B4:=20=E7=A7=BB?=
=?UTF-8?q?=E9=99=A4=20`PermissionManager`=20=E5=8F=8A=E5=85=B6=E7=9B=B8?=
=?UTF-8?q?=E5=85=B3=20API=20=20=20*=20=E5=88=A0=E9=99=A4=20`src/workspace?=
=?UTF-8?q?/permissions.py`=20=E6=96=87=E4=BB=B6=20=20=20*=20=E7=A7=BB?=
=?UTF-8?q?=E9=99=A4=20`Workspace`=20=E7=B1=BB=E4=B8=AD=E7=9A=84=20`self.p?=
=?UTF-8?q?ermission=5Fmanager`=20=E5=B1=9E=E6=80=A7=E5=8F=8A=E5=88=9D?=
=?UTF-8?q?=E5=A7=8B=E5=8C=96=E9=80=BB=E8=BE=91=20=20=20*=20=E8=B0=83?=
=?UTF-8?q?=E7=94=A8=E6=96=B9=E9=9C=80=E7=9B=B4=E6=8E=A5=E4=BE=9D=E8=B5=96?=
=?UTF-8?q?=20`PathValidator`=20=E8=BF=9B=E8=A1=8C=E6=95=8F=E6=84=9F?=
=?UTF-8?q?=E6=96=87=E4=BB=B6=E6=8B=A6=E6=88=AA=EF=BC=8C=E4=B8=8D=E5=86=8D?=
=?UTF-8?q?=E9=80=9A=E8=BF=87=20`workspace.permission=5Fmanager.is=5Fallow?=
=?UTF-8?q?ed()`=20=E6=9F=A5=E8=AF=A2=20-=20=E9=87=8D=E6=9E=84=E4=BC=98?=
=?UTF-8?q?=E5=8C=96:=20=E6=95=B4=E5=90=88=E6=95=8F=E6=84=9F=E6=96=87?=
=?UTF-8?q?=E4=BB=B6=E8=A7=84=E5=88=99=E8=87=B3=20`ExclusionManager`=20=20?=
=?UTF-8?q?=20*=20=E5=B0=86=20`SENSITIVE=5FFILE=5FPATTERNS`=20=E4=BB=8E?=
=?UTF-8?q?=E7=A1=AC=E7=BC=96=E7=A0=81=E7=A7=BB=E8=87=B3=20`ExclusionManag?=
=?UTF-8?q?er.SENSITIVE=5FFILE=5FPATTERNS`=20=20=20*=20`PathValidator`=20?=
=?UTF-8?q?=E5=BC=95=E5=85=A5=20`ExclusionManager`=20=E4=BD=9C=E4=B8=BA?=
=?UTF-8?q?=E5=94=AF=E4=B8=80=E6=95=8F=E6=84=9F=E6=96=87=E4=BB=B6=E6=A8=A1?=
=?UTF-8?q?=E5=BC=8F=E6=9D=A5=E6=BA=90=20(`re.compile(p)=20for=20p=20in=20?=
=?UTF-8?q?ExclusionManager.SENSITIVE=5FFILE=5FPATTERNS`)=20=20=20*=20?=
=?UTF-8?q?=E6=9B=B4=E6=96=B0=E6=AD=A3=E5=88=99=E8=A1=A8=E8=BE=BE=E5=BC=8F?=
=?UTF-8?q?=E5=89=8D=E7=BC=80=E4=BB=A5=E6=94=AF=E6=8C=81=E8=B7=AF=E5=BE=84?=
=?UTF-8?q?=E8=BE=B9=E7=95=8C=E5=8C=B9=E9=85=8D=20(=E6=B7=BB=E5=8A=A0=20`(?=
=?UTF-8?q?^|/)`)=20-=20=E4=BB=A3=E7=A0=81=E6=B8=85=E7=90=86:=20=E7=A7=BB?=
=?UTF-8?q?=E9=99=A4=E5=86=97=E4=BD=99=E7=9A=84=E6=8E=92=E9=99=A4=E6=A3=80?=
=?UTF-8?q?=E6=9F=A5=E9=80=BB=E8=BE=91=20=20=20*=20=E5=9C=A8=20`Workspace.?=
=?UTF-8?q?=5Fsearch=5Fcontent`=20=E4=B8=AD=E7=A7=BB=E9=99=A4=E5=9F=BA?=
=?UTF-8?q?=E4=BA=8E=20`should=5Fexclude=5Fdir`=20=E7=9A=84=E7=88=B6?=
=?UTF-8?q?=E7=9B=AE=E5=BD=95=E9=81=8D=E5=8E=86=E6=A3=80=E6=9F=A5=20=20=20?=
=?UTF-8?q?*=20=E7=A7=BB=E9=99=A4=20`ExclusionManager`=20=E4=B8=AD?=
=?UTF-8?q?=E5=B7=B2=E5=BA=9F=E5=BC=83=E7=9A=84=20`=5Fsensitive=5Ffile=5Fr?=
=?UTF-8?q?es`=20=E9=A2=84=E7=BC=96=E8=AF=91=E7=BC=93=E5=AD=98=E9=80=BB?=
=?UTF-8?q?=E8=BE=91=20=20=20*=20=E7=A7=BB=E9=99=A4=20`ExclusionManager`?=
=?UTF-8?q?=20=E4=B8=AD=E4=B8=8D=E5=86=8D=E4=BD=BF=E7=94=A8=E7=9A=84=20`is?=
=?UTF-8?q?=5Fsensitive=5Ffile`=20=E5=92=8C=20`should=5Fexclude=5Fdir`=20?=
=?UTF-8?q?=E6=96=B9=E6=B3=95=20-=20=E6=96=87=E6=A1=A3=E6=9B=B4=E6=96=B0:?=
=?UTF-8?q?=20=E8=A1=A5=E5=85=85=20`gitignore=5Floader`=20=E5=B7=B2?=
=?UTF-8?q?=E7=9F=A5=E5=B1=80=E9=99=90=E6=80=A7=E8=AF=B4=E6=98=8E=20=20=20?=
=?UTF-8?q?*=20=E6=98=8E=E7=A1=AE=E6=A0=87=E6=B3=A8=E4=B8=8D=E6=94=AF?=
=?UTF-8?q?=E6=8C=81=E5=B5=8C=E5=A5=97=20`.gitignore`=E3=80=81=E8=A1=8C?=
=?UTF-8?q?=E5=B0=BE=E7=BB=AD=E8=A1=8C=E5=8F=8A=E5=AD=97=E7=AC=A6=E7=B1=BB?=
=?UTF-8?q?=E6=89=A9=E5=B1=95=E8=AF=AD=E6=B3=95=20=20=20*=20=E8=AE=B0?=
=?UTF-8?q?=E5=BD=95=E5=90=A6=E5=AE=9A=E6=A8=A1=E5=BC=8F=E4=BC=98=E5=85=88?=
=?UTF-8?q?=E7=BA=A7=E5=A4=84=E7=90=86=E4=B8=8E=E7=9C=9F=E5=AE=9E=20Git=20?=
=?UTF-8?q?=E7=9A=84=E5=B7=AE=E5=BC=82=E5=8F=8A=E8=AE=BE=E8=AE=A1=E7=90=86?=
=?UTF-8?q?=E7=94=B1?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
src/workspace/exclusion_manager.py | 78 +++++-------------
src/workspace/gitignore_loader.py | 13 ++-
src/workspace/path_validator.py | 20 ++---
src/workspace/permissions.py | 126 -----------------------------
src/workspace/workspace.py | 4 -
5 files changed, 36 insertions(+), 205 deletions(-)
delete mode 100644 src/workspace/permissions.py
diff --git a/src/workspace/exclusion_manager.py b/src/workspace/exclusion_manager.py
index 2bd5b33..7e8bbe3 100644
--- a/src/workspace/exclusion_manager.py
+++ b/src/workspace/exclusion_manager.py
@@ -5,6 +5,8 @@
- 安全排除: 隐私/凭据文件等不应被 AI 访问的路径
"""
+from __future__ import annotations
+
import os
import re
from pathlib import Path
@@ -14,7 +16,7 @@
class ExclusionManager:
- """排除规则统一管理器.
+ """排除规则统一管理器
聚合三类排除源:
1. 默认排除(内置的缓存/构建/IDE 目录)
@@ -59,35 +61,20 @@ class ExclusionManager:
}
)
- # 安全排除 —— 敏感文件, AI 不应读取
- SECURITY_EXCLUSIONS: frozenset[str] = frozenset(
- {
- ".env",
- ".env.*",
- "*.pem",
- "credentials.*",
- "*.key",
- "*.cert",
- "id_rsa",
- "id_ed25519",
- "*.cred",
- "*.secret",
- "**/vault/**",
- }
- )
-
- # 安全排除 —— 需要精确匹配的特定文件
+ # 安全排除 —— 需要精确匹配的敏感文件正则(与 SECURITY_EXCLUSIONS 合并后的唯一来源)
+ # 注意: (^|/) 前缀表示匹配路径开始或目录分隔符后; .* 前缀表示匹配任意位置的文件扩展名
SENSITIVE_FILE_PATTERNS: ClassVar[list[str]] = [
- r"\.env$",
- r"\.env\..+$",
+ r"(^|/)\.env$",
+ r"(^|/)\.env\..+$",
r".*\.pem$",
- r"credentials\..*$",
+ r"(^|/)credentials\..*$",
r".*\.key$",
r".*\.cert$",
- r"id_rsa$",
- r"id_ed25519$",
+ r"(^|/)id_rsa$",
+ r"(^|/)id_ed25519$",
r".*\.cred$",
r".*\.secret$",
+ r"(^|/)\.ManualAid[/\\].*\.db$",
]
def __init__(self, workspace_root: str | Path):
@@ -99,14 +86,6 @@ def __init__(self, workspace_root: str | Path):
self._reload_gitignore()
- # 编译敏感文件正则
- self._sensitive_file_res: list[re.Pattern] = []
- for pat in self.SENSITIVE_FILE_PATTERNS:
- try:
- self._sensitive_file_res.append(re.compile(pat))
- except re.error:
- continue
-
def _reload_gitignore(self) -> None:
"""(重新)加载 .gitignore."""
raw, exclude_res, negate_res = load_gitignore(self._workspace_root)
@@ -115,7 +94,7 @@ def _reload_gitignore(self) -> None:
self._gitignore_negate_res = negate_res
def _check_performance_exclusion(self, rel_path_str: str) -> bool:
- """检查路径是否匹配性能排除规则(基于目录名)."""
+ """检查路径是否匹配性能排除规则(基于目录名)"""
# 将路径拆分为各层, 检查每层是否在排除集合中
parts = rel_path_str.replace(os.sep, "/").split("/")
for part in parts:
@@ -128,15 +107,8 @@ def _check_performance_exclusion(self, rel_path_str: str) -> bool:
return True
return False
- def should_exclude_dir(self, dir_name: str) -> bool:
- """检查目录名是否应该被排除(基于名称的快速检查).
-
- 用于 glob/ls 等基于目录名的过滤场景.
- """
- return dir_name in self.PERFORMANCE_EXCLUSIONS
-
def should_exclude_path(self, path: Path) -> bool:
- """检查路径是否应被排除(全面检查).
+ """检查路径是否应被排除(全面检查)
依次检查: 默认排除目录名 → gitignore 规则 → 否定规则
@@ -161,26 +133,12 @@ def should_exclude_path(self, path: Path) -> bool:
# 2. gitignore 排除
return is_ignored_by_gitignore(rel_str, self._gitignore_exclude_res, self._gitignore_negate_res)
- def is_sensitive_file(self, path: Path) -> bool:
- """检查路径是否为敏感文件.
-
- Args:
- path: 文件绝对路径
-
- Returns:
- True 表示是敏感文件
- """
- try:
- rel_str = str(path.relative_to(self._workspace_root)).replace(os.sep, "/")
- except ValueError:
- return False
-
- return any(regex.search(rel_str) for regex in self._sensitive_file_res)
-
def merge_ignore_regexes(self, user_ignore: list[str] | None = None) -> list[re.Pattern]:
- """合并默认排除 + gitignore + 用户 ignore 为正则列表.
+ """合并默认排除 + gitignore + 用户 ignore 为正则列表
+
+ 用于 search_content 等需要正则匹配排除的场景
- 用于 search_content 等需要正则匹配排除的场景.
+ 敏感文件由 PathValidator 在写入/读取时拦截,搜索场景不额外过滤
Args:
user_ignore: 用户传入的忽略正则列表
@@ -215,5 +173,5 @@ def merge_ignore_regexes(self, user_ignore: list[str] | None = None) -> list[re.
@property
def excluded_dir_names(self) -> set[str]:
- """获取所有排除目录名集合(用于快速 in 检查)."""
+ """获取所有排除目录名集合(用于快速 in 检查)"""
return {d for d in self.PERFORMANCE_EXCLUSIONS if not d.startswith("*")}
diff --git a/src/workspace/gitignore_loader.py b/src/workspace/gitignore_loader.py
index 64c86e1..d1a136e 100644
--- a/src/workspace/gitignore_loader.py
+++ b/src/workspace/gitignore_loader.py
@@ -1,4 +1,15 @@
-"""Parse .gitignore files and convert patterns to regex for exclusion matching."""
+"""Parse .gitignore files and convert patterns to regex for exclusion matching.
+
+已知局限性:
+- 不支持嵌套 .gitignore(仅读取根目录下的 .gitignore)
+- 不支持行尾 \\ 续行
+- 不支持 gitignore 扩展语法中的字符类(如 [abc] 会被错误转义)
+- 否定模式的优先级处理与真实 Git 不一致: 当前实现将所有否定模式提升为最高优先级,
+ 而真实 Git 按行号顺序逐条处理(后出现的规则覆盖先出现的).
+ 当前行为对于 AI 工具场景偏安全(宁可少排除), 故保留此简化实现.
+"""
+
+from __future__ import annotations
import os
import re
diff --git a/src/workspace/path_validator.py b/src/workspace/path_validator.py
index 388df77..99a4bee 100644
--- a/src/workspace/path_validator.py
+++ b/src/workspace/path_validator.py
@@ -3,6 +3,8 @@
from pathlib import Path
from typing import ClassVar
+from src.workspace.exclusion_manager import ExclusionManager
+
class WorkspaceBoundaryError(Exception):
"""访问工作区外的路径时抛出"""
@@ -29,19 +31,9 @@ class PathValidator:
workspace_root: 工作区根目录,默认为当前目录
"""
- # 敏感文件匹配模式
+ # 敏感文件匹配模式(从 ExclusionManager 统一来源引用)
SENSITIVE_FILE_PATTERNS: ClassVar[list[re.Pattern]] = [
- re.compile(r"\.env$"),
- re.compile(r"\.env\..+$"),
- re.compile(r".*\.pem$"),
- re.compile(r"credentials\..*$"),
- re.compile(r".*\.key$"),
- re.compile(r".*\.cert$"),
- re.compile(r"id_rsa$"),
- re.compile(r"id_ed25519$"),
- re.compile(r".*\.cred$"),
- re.compile(r".*\.secret$"),
- re.compile(r"\.ManualAid[/\\].*\.db$"),
+ re.compile(p) for p in ExclusionManager.SENSITIVE_FILE_PATTERNS
]
def __init__(self, workspace_root: str | Path = "."):
@@ -102,9 +94,9 @@ def resolve_path(self, target: str | Path) -> Path:
@classmethod
def _raise_if_sensitive(cls, resolved: Path, original_target: str | Path) -> None:
"""检查路径是否匹配敏感文件模式."""
- rel_str = str(resolved).replace("\\", "/")
+ resolved_str = str(resolved).replace(os.sep, "/")
for pattern in cls.SENSITIVE_FILE_PATTERNS:
- if pattern.search(rel_str):
+ if pattern.search(resolved_str):
raise SensitiveFileError(f"禁止访问敏感文件: {original_target}")
def create_file_with_parents(self, target: str | Path, content: str = "") -> Path:
diff --git a/src/workspace/permissions.py b/src/workspace/permissions.py
deleted file mode 100644
index a87b5ad..0000000
--- a/src/workspace/permissions.py
+++ /dev/null
@@ -1,126 +0,0 @@
-"""统一权限决策引擎 —— 路径级细粒度权限控制.
-
-整合现有权限机制:
-1. BaseTool 的 read_permission/write_permission 布尔属性
-2. PathValidator 的边界检查
-3. binary_detector 的文件类型检测
-4. 敏感文件保护(新增)
-5. Git 工具的安全模型(白名单+拦截正则, 后续提取)
-6. mtime 校验
-7. 审计审批层
-
-提供统一的 "工具 X 能否对路径 Y 执行操作 Z" 查询接口.
-"""
-
-from __future__ import annotations
-
-from enum import Enum, auto
-from pathlib import Path
-
-
-class Operation(Enum):
- """权限操作类型."""
-
- READ = auto()
- WRITE = auto()
- SEARCH = auto()
- EXECUTE = auto()
- DELETE = auto()
-
-
-class Decision(Enum):
- """权限决策结果."""
-
- ALLOWED = "allowed"
- DENIED = "denied"
-
-
-class PermissionManager:
- """统一权限决策引擎.
-
- 使用方式(从 Workspace 获取):
- perm = workspace.permission_manager
- if perm.is_allowed("read_tool", path, Operation.READ):
- ...
-
- Args:
- workspace_root: 工作区根目录
- """
-
- def __init__(self, workspace_root: Path):
- self._root = workspace_root
-
- # 敏感文件正则列表(与 ExclusionManager 保持一致)
- self._sensitive_patterns: list[str] = [
- r"\.env$",
- r"\.env\..+$",
- r".*\.pem$",
- r"credentials\..*$",
- r".*\.key$",
- r".*\.cert$",
- r"id_rsa$",
- r"id_ed25519$",
- r".*\.cred$",
- r".*\.secret$",
- ]
-
- # 操作 → 所需权限级别映射
- self._operation_permissions: dict[Operation, str] = {
- Operation.READ: "read",
- Operation.WRITE: "write",
- Operation.SEARCH: "read",
- Operation.EXECUTE: "write",
- Operation.DELETE: "write",
- }
-
- def _is_sensitive_path(self, path: Path) -> bool:
- """检查路径是否匹配敏感文件模式."""
- import re
-
- try:
- rel_str = str(path.relative_to(self._root)).replace("\\", "/")
- except ValueError:
- return True # 工作区外的路径视为敏感
-
- return any(re.search(pattern, rel_str) for pattern in self._sensitive_patterns)
-
- def check(self, tool_name: str, path: Path, operation: Operation) -> Decision:
- """检查工具能否对路径执行操作.
-
- 决策流程:
- 1. 如果路径在工作区外 → DENIED
- 2. 如果是敏感文件且操作非 SEARCH → DENIED
- 3. 如果是二进制文件且操作是 READ/WRITE → 特殊处理(记录而非禁止)
- 4. 否则 → ALLOWED
-
- Args:
- tool_name: 工具名称(如 "read_tool", "write_tool")
- path: 目标路径
- operation: 操作类型
-
- Returns:
- 权限决策结果
- """
- # 1. 工作区边界(双重保障, PathValidator 已做)
- try:
- path.relative_to(self._root)
- except ValueError:
- return Decision.DENIED
-
- # 2. 敏感文件保护(禁止 READ/WRITE/EXECUTE/DELETE)
- if operation in (
- Operation.READ,
- Operation.WRITE,
- Operation.EXECUTE,
- Operation.DELETE,
- ) and self._is_sensitive_path(path):
- return Decision.DENIED
-
- # 3. 二进制文件: 允许但标记 (记录由调用方处理)
- # 这里不做禁止, 仅在 query 中返回信息
-
- return Decision.ALLOWED
-
- def is_allowed(self, tool_name: str, path: Path, operation: Operation) -> bool:
- """快捷方法: 是否允许操作."""
- return self.check(tool_name, path, operation) == Decision.ALLOWED
diff --git a/src/workspace/workspace.py b/src/workspace/workspace.py
index fa9e960..295b2d6 100644
--- a/src/workspace/workspace.py
+++ b/src/workspace/workspace.py
@@ -7,7 +7,6 @@
from src.models.tool_error_response import ToolErrorResponse
from src.workspace.exclusion_manager import ExclusionManager
from src.workspace.path_validator import PathNotFoundError, PathValidator, WorkspaceBoundaryError
-from src.workspace.permissions import PermissionManager
def _highlight_matches(line: str, regex: re.Pattern) -> str:
@@ -47,7 +46,6 @@ def __init__(self, path: str):
self.root_path = Path(path).resolve()
self.path_validator: PathValidator = PathValidator(self.root_path)
self.exclusion_manager: ExclusionManager = ExclusionManager(self.root_path)
- self.permission_manager: PermissionManager = PermissionManager(self.root_path)
self.is_git_repo: bool = (self.root_path / ".git").is_dir()
self.platform: str = sys.platform
self.date: str = date.today().strftime("%y-%m-%d")
@@ -232,8 +230,6 @@ def search_content_multi_pattern(
else:
for file_path in path.rglob(file_pattern):
if file_path.is_file():
- if any(self.exclusion_manager.should_exclude_dir(p.name) for p in file_path.parents):
- continue
rel = str(file_path.relative_to(self.root_path))
if any(ir.search(rel) for ir in ignore_res):
continue
From 024619ef5ab4b7b850ce74f8fd597fb3694ae211 Mon Sep 17 00:00:00 2001
From: Suntion <149924916+SunYanbox@users.noreply.github.com>
Date: Thu, 7 May 2026 16:06:40 +0800
Subject: [PATCH 3/9] =?UTF-8?q?feat(core,=20ui):=20=E6=96=B0=E5=A2=9E?=
=?UTF-8?q?=E9=85=8D=E7=BD=AE=E7=AE=A1=E7=90=86=E4=B8=8E=E6=8A=80=E8=83=BD?=
=?UTF-8?q?=E5=8F=91=E7=8E=B0=E7=B3=BB=E7=BB=9F=20(#152,=20#153)=20-=20?=
=?UTF-8?q?=E6=96=B0=E5=A2=9E=E5=8A=9F=E8=83=BD:=20=E5=AE=9E=E7=8E=B0?=
=?UTF-8?q?=E7=BB=9F=E4=B8=80=E7=9A=84=E9=85=8D=E7=BD=AE=E7=AE=A1=E7=90=86?=
=?UTF-8?q?=E5=99=A8=20(ConfigManager)=20=20=20*=20=E5=BC=95=E5=85=A5=20`s?=
=?UTF-8?q?rc/core/config=5Fmanager.py`=20=E5=8D=95=E4=BE=8B=E7=B1=BB?=
=?UTF-8?q?=EF=BC=8C=E6=94=AF=E6=8C=81=E7=8E=AF=E5=A2=83=E5=8F=98=E9=87=8F?=
=?UTF-8?q?=E3=80=81Skill=20=E5=8F=8A=E9=80=9A=E7=94=A8=E9=85=8D=E7=BD=AE?=
=?UTF-8?q?=E7=9A=84=E8=AF=BB=E5=8F=96=E4=B8=8E=E6=8C=81=E4=B9=85=E5=8C=96?=
=?UTF-8?q?=20=20=20*=20=E6=8F=90=E4=BE=9B=20`get`,=20`set`,=20`delete`=20?=
=?UTF-8?q?=E7=AD=89=E6=A0=B8=E5=BF=83=20API=20=E6=8E=A5=E5=8F=A3=EF=BC=8C?=
=?UTF-8?q?=E6=94=AF=E6=8C=81=E9=80=9A=E8=BF=87=20`DatabaseManager`=20?=
=?UTF-8?q?=E8=AF=BB=E5=86=99=20SQLite=20=E6=95=B0=E6=8D=AE=E5=BA=93=20=20?=
=?UTF-8?q?=20*=20=E5=AE=9E=E7=8E=B0=20`apply=5Fenv=5Fconfigs`=20=E6=96=B9?=
=?UTF-8?q?=E6=B3=95=E8=87=AA=E5=8A=A8=E5=B0=86=E9=85=8D=E7=BD=AE=E5=90=8C?=
=?UTF-8?q?=E6=AD=A5=E8=87=B3=20`os.environ`=20=20=20*=20=E5=AE=9A?=
=?UTF-8?q?=E4=B9=89=20`DEFAULT=5FENVS`=20=E5=B8=B8=E9=87=8F=EF=BC=8C?=
=?UTF-8?q?=E5=8C=85=E5=90=AB=20`TOOL=5FMAX=5FDOC=5FLENGTH`,=20`RESULT=5FE?=
=?UTF-8?q?XPIRE=5FMINUTES`=20=E7=AD=89=E9=BB=98=E8=AE=A4=E5=80=BC=20-=20?=
=?UTF-8?q?=E6=96=B0=E5=A2=9E=E5=8A=9F=E8=83=BD:=20=E6=9E=84=E5=BB=BA=20Sk?=
=?UTF-8?q?ill=20=E5=8F=91=E7=8E=B0=E4=B8=8E=E7=AE=A1=E7=90=86=E6=9C=8D?=
=?UTF-8?q?=E5=8A=A1=20(SkillManager)=20=20=20*=20=E5=88=9B=E5=BB=BA=20`sr?=
=?UTF-8?q?c/core/skill=5Fmanager.py`=20=E5=8D=95=E4=BE=8B=E7=B1=BB?=
=?UTF-8?q?=EF=BC=8C=E6=94=AF=E6=8C=81=E4=BB=8E=E5=85=A8=E5=B1=80=E8=B7=AF?=
=?UTF-8?q?=E5=BE=84=20(~/.claude/skills)=20=E5=92=8C=E9=A1=B9=E7=9B=AE?=
=?UTF-8?q?=E8=B7=AF=E5=BE=84=20(.ManualAid/skills)=20=E6=89=AB=E6=8F=8F?=
=?UTF-8?q?=E6=8A=80=E8=83=BD=20=20=20*=20=E9=9B=86=E6=88=90=20`src/models?=
=?UTF-8?q?/skill.py`=20=E6=95=B0=E6=8D=AE=E6=A8=A1=E5=9E=8B=EF=BC=8C?=
=?UTF-8?q?=E8=A7=A3=E6=9E=90=20SKILL.md=20=E7=9A=84=20YAML=20frontmatter?=
=?UTF-8?q?=20=E6=8F=90=E5=8F=96=E5=90=8D=E7=A7=B0=E5=92=8C=E6=8F=8F?=
=?UTF-8?q?=E8=BF=B0=20=20=20*=20=E5=AE=9E=E7=8E=B0=20`set=5Fdisabled`=20?=
=?UTF-8?q?=E5=92=8C=20`get=5Fdisabled=5Fskills`=20=E6=8E=A5=E5=8F=A3?=
=?UTF-8?q?=EF=BC=8C=E6=94=AF=E6=8C=81=E7=A6=81=E7=94=A8=E7=89=B9=E5=AE=9A?=
=?UTF-8?q?=E6=8A=80=E8=83=BD=E5=B9=B6=E6=8C=81=E4=B9=85=E5=8C=96=E5=88=B0?=
=?UTF-8?q?=E6=95=B0=E6=8D=AE=E5=BA=93=20=20=20*=20=E6=96=B0=E5=A2=9E=20`S?=
=?UTF-8?q?killTool`=20=E5=B7=A5=E5=85=B7=20(`src/workspace/tools/skill=5F?=
=?UTF-8?q?tool.py`)=EF=BC=8C=E5=85=81=E8=AE=B8=E7=94=A8=E6=88=B7=E5=8A=A0?=
=?UTF-8?q?=E8=BD=BD=E6=8C=87=E5=AE=9A=E6=8A=80=E8=83=BD=E5=86=85=E5=AE=B9?=
=?UTF-8?q?=E6=B3=A8=E5=85=A5=E4=BC=9A=E8=AF=9D=20-=20=E6=96=B0=E5=A2=9E?=
=?UTF-8?q?=E5=8A=9F=E8=83=BD:=20=E6=89=A9=E5=B1=95=20TUI=20=E8=AE=BE?=
=?UTF-8?q?=E7=BD=AE=E7=95=8C=E9=9D=A2=20=20=20*=20=E5=9C=A8=20`tui=5Fcons?=
=?UTF-8?q?ole.py`=20=E4=B8=AD=E6=96=B0=E5=A2=9E=20"Settings"=20=E6=A0=87?=
=?UTF-8?q?=E7=AD=BE=E9=A1=B5=EF=BC=8C=E6=95=B4=E5=90=88=20`SettingsTab`?=
=?UTF-8?q?=20=20=20*=20=E5=AE=9E=E7=8E=B0=20`EnvConfigTab`=20=E7=94=A8?=
=?UTF-8?q?=E4=BA=8E=E7=BC=96=E8=BE=91=E9=A2=84=E5=AE=9A=E4=B9=89=E7=8E=AF?=
=?UTF-8?q?=E5=A2=83=E5=8F=98=E9=87=8F=EF=BC=8C=E6=94=AF=E6=8C=81=E4=BB=8E?=
=?UTF-8?q?=20`.env`=20=E6=96=87=E4=BB=B6=E5=8A=A0=E8=BD=BD/=E4=BF=9D?=
=?UTF-8?q?=E5=AD=98=E5=B7=AE=E5=BC=82=E9=85=8D=E7=BD=AE=20=20=20*=20?=
=?UTF-8?q?=E5=AE=9E=E7=8E=B0=20`SkillConfigTab`=20=E5=B1=95=E7=A4=BA?=
=?UTF-8?q?=E6=8A=80=E8=83=BD=E5=88=97=E8=A1=A8=EF=BC=8C=E6=94=AF=E6=8C=81?=
=?UTF-8?q?=E5=90=AF=E7=94=A8/=E7=A6=81=E7=94=A8=E6=93=8D=E4=BD=9C?=
=?UTF-8?q?=E5=8F=8A=E6=9F=A5=E7=9C=8B=E8=AF=A6=E6=83=85=20=20=20*=20?=
=?UTF-8?q?=E4=BF=AE=E6=94=B9=20`main.py`=20=E5=90=AF=E5=8A=A8=E6=B5=81?=
=?UTF-8?q?=E7=A8=8B=EF=BC=8C=E5=88=9D=E5=A7=8B=E5=8C=96=20`ConfigManager`?=
=?UTF-8?q?=20=E5=92=8C=20`SkillManager`=20=E5=B9=B6=E5=9C=A8=E5=BA=94?=
=?UTF-8?q?=E7=94=A8=E5=90=AF=E5=8A=A8=E6=97=B6=E6=89=A7=E8=A1=8C=E7=8E=AF?=
=?UTF-8?q?=E5=A2=83=E9=85=8D=E7=BD=AE=E5=BA=94=E7=94=A8=E5=92=8C=E6=8A=80?=
=?UTF-8?q?=E8=83=BD=E5=8F=91=E7=8E=B0=20-=20=E6=96=87=E6=A1=A3=E6=9B=B4?=
=?UTF-8?q?=E6=96=B0:=20=E4=BE=9D=E8=B5=96=E9=A1=B9=E4=B8=8E=E6=95=B0?=
=?UTF-8?q?=E6=8D=AE=E5=BA=93=E7=BB=93=E6=9E=84=E5=8D=87=E7=BA=A7=20=20=20?=
=?UTF-8?q?*=20=E5=9C=A8=20`requirements.txt`=20=E4=B8=AD=E6=B7=BB?=
=?UTF-8?q?=E5=8A=A0=20`pyyaml`=20=E4=BE=9D=E8=B5=96=E4=BB=A5=E6=94=AF?=
=?UTF-8?q?=E6=8C=81=20YAML=20=E8=A7=A3=E6=9E=90=20=20=20*=20=E6=89=A9?=
=?UTF-8?q?=E5=B1=95=20`database=5Fmanager.py`=EF=BC=8C=E6=96=B0=E5=A2=9E?=
=?UTF-8?q?=20`config`=20=E8=A1=A8=E7=BB=93=E6=9E=84=E5=AD=98=E5=82=A8?=
=?UTF-8?q?=E9=94=AE=E5=80=BC=E5=AF=B9=E9=85=8D=E7=BD=AE=20=20=20*=20?=
=?UTF-8?q?=E4=B8=BA=20`config`=20=E8=A1=A8=E6=B7=BB=E5=8A=A0=E8=BF=81?=
=?UTF-8?q?=E7=A7=BB=E9=80=BB=E8=BE=91=20(Phase=206)=EF=BC=8C=E7=A1=AE?=
=?UTF-8?q?=E4=BF=9D=E6=97=A7=E7=89=88=E6=9C=AC=E6=95=B0=E6=8D=AE=E5=BA=93?=
=?UTF-8?q?=E5=85=BC=E5=AE=B9?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
requirements.txt | 3 +-
src/console/main.py | 11 +
src/console/ui/repl.py | 6 +
src/console/ui/tui_console.py | 11 +
src/console/ui/widgets/__init__.py | 15 ++
src/console/ui/widgets/env_config_tab.py | 283 +++++++++++++++++++++
src/console/ui/widgets/settings_tab.py | 72 ++++++
src/console/ui/widgets/skill_config_tab.py | 220 ++++++++++++++++
src/core/config_manager.py | 244 ++++++++++++++++++
src/core/database_manager.py | 116 +++++++++
src/core/skill_manager.py | 257 +++++++++++++++++++
src/core/tool_registry.py | 2 +
src/models/skill.py | 127 +++++++++
src/workspace/tools/skill_tool.py | 75 ++++++
14 files changed, 1441 insertions(+), 1 deletion(-)
create mode 100644 src/console/ui/widgets/env_config_tab.py
create mode 100644 src/console/ui/widgets/settings_tab.py
create mode 100644 src/console/ui/widgets/skill_config_tab.py
create mode 100644 src/core/config_manager.py
create mode 100644 src/core/skill_manager.py
create mode 100644 src/models/skill.py
create mode 100644 src/workspace/tools/skill_tool.py
diff --git a/requirements.txt b/requirements.txt
index e022fb4..f38a9b6 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -7,4 +7,5 @@ pytest-cov==7.1.0
rich==15.0.0
ruff==0.15.11
textual
-python-dotenv
\ No newline at end of file
+python-dotenv
+pyyaml
\ No newline at end of file
diff --git a/src/console/main.py b/src/console/main.py
index 65a8d15..07fe49f 100644
--- a/src/console/main.py
+++ b/src/console/main.py
@@ -14,6 +14,8 @@
from src.console.folder_picker import pick_folder
from src.console.result_manager import ResultManager
from src.console.ui.repl import REPL
+from src.core.config_manager import ConfigManager
+from src.core.skill_manager import SkillManager
from src.core.tool_registry import ToolRegistry
from src.workspace.workspace import Workspace
@@ -87,6 +89,15 @@ def init_workspace(start_path: str | None = None) -> Workspace | None:
agent_manager.initialize(workspace.root_path)
agent_manager.write_default(workspace.root_path)
+ # Initialize ConfigManager and apply environment configs
+ config_manager = ConfigManager()
+ config_manager.initialize(workspace.root_path)
+ config_manager.apply_env_configs()
+
+ # Initialize SkillManager and discover skills
+ skill_manager = SkillManager()
+ skill_manager.discover(workspace.root_path)
+
# 在创建新会话之前清理孤立的会话
_cleanup_orphaned_sessions(workspace.db)
diff --git a/src/console/ui/repl.py b/src/console/ui/repl.py
index 326fc45..75c1fae 100644
--- a/src/console/ui/repl.py
+++ b/src/console/ui/repl.py
@@ -226,6 +226,12 @@ def on_mount(self) -> None:
getattr(self.tool_registry, "_current_session_id", None),
)
+ # 注入设置标签页
+ from src.core.skill_manager import SkillManager
+
+ skill_manager = SkillManager()
+ tui_console.settings_tab.set_managers(self.workspace.root_path, skill_manager)
+
# 创建 command handler
self.command_handler = CommandHandler(
self.workspace,
diff --git a/src/console/ui/tui_console.py b/src/console/ui/tui_console.py
index f93f1e1..5486e0e 100644
--- a/src/console/ui/tui_console.py
+++ b/src/console/ui/tui_console.py
@@ -9,6 +9,7 @@
from textual.widgets import Collapsible, RichLog, Static, TabbedContent, TabPane
from src.console.ui.widgets.audit_tab import AuditTab
+from src.console.ui.widgets.settings_tab import SettingsTab
from src.console.ui.widgets.stats_tab import StatsTab
@@ -20,6 +21,7 @@ class TuiConsole(Vertical):
- Tab 2 (Tool Calls): 用于显示工具调用情况.
- Tab 3 (Audit): 用于审核待处理的写入/编辑操作.
- Tab 4 (Statistics): 用于查看会话统计与工具使用排名.
+ - Tab 5 (Settings): 用于配置环境变量和 Skill.
"""
DEFAULT_CSS = """
@@ -62,6 +64,8 @@ def compose(self):
yield AuditTab()
with TabPane("Statistics", id="tab-stats"):
yield StatsTab()
+ with TabPane("Settings", id="tab-settings"):
+ yield SettingsTab()
@property
def main_log(self) -> RichLog:
@@ -79,12 +83,19 @@ def audit_tab(self) -> AuditTab:
def stats_tab(self) -> StatsTab:
return self.query_one(StatsTab)
+ @property
+ def settings_tab(self) -> SettingsTab:
+ return self.query_one(SettingsTab)
+
async def on_tabbed_content_tab_activated(self, event: TabbedContent.TabActivated) -> None:
"""切换标签页时刷新内容."""
if event.pane.id == "tab-audit":
await self.audit_tab._refresh()
elif event.pane.id == "tab-stats":
await self.stats_tab._refresh()
+ elif event.pane.id == "tab-settings":
+ # Settings tab refreshes automatically when managers are set
+ pass
def print(self, *args) -> None:
"""将内容写入主日志区"""
diff --git a/src/console/ui/widgets/__init__.py b/src/console/ui/widgets/__init__.py
index e69de29..afa8218 100644
--- a/src/console/ui/widgets/__init__.py
+++ b/src/console/ui/widgets/__init__.py
@@ -0,0 +1,15 @@
+"""TUI widgets package."""
+
+from src.console.ui.widgets.audit_tab import AuditTab
+from src.console.ui.widgets.env_config_tab import EnvConfigTab
+from src.console.ui.widgets.settings_tab import SettingsTab
+from src.console.ui.widgets.skill_config_tab import SkillConfigTab
+from src.console.ui.widgets.stats_tab import StatsTab
+
+__all__ = [
+ "AuditTab",
+ "EnvConfigTab",
+ "SettingsTab",
+ "SkillConfigTab",
+ "StatsTab",
+]
diff --git a/src/console/ui/widgets/env_config_tab.py b/src/console/ui/widgets/env_config_tab.py
new file mode 100644
index 0000000..4c4ba45
--- /dev/null
+++ b/src/console/ui/widgets/env_config_tab.py
@@ -0,0 +1,283 @@
+from __future__ import annotations
+
+from pathlib import Path
+from typing import ClassVar
+
+from textual.containers import Horizontal, Vertical
+from textual.screen import ModalScreen
+from textual.widgets import Button, DataTable, Input, Label, Static
+
+from src.core.config_manager import DEFAULT_ENVS
+
+
+class EnvEditDialog(ModalScreen[str | None]):
+ """Modal dialog for editing an environment variable value"""
+
+ DEFAULT_CSS = """
+ EnvEditDialog {
+ align: center middle;
+ }
+
+ #env-edit-dialog {
+ width: 50;
+ height: auto;
+ padding: 2;
+ border: thick $primary;
+ background: $surface;
+ }
+
+ #env-edit-dialog > Label {
+ text-style: bold;
+ margin-bottom: 1;
+ }
+
+ .env-edit-field {
+ margin-bottom: 1;
+ }
+
+ #env-key-display {
+ margin-bottom: 1;
+ color: $text;
+ text-style: bold;
+ }
+
+ #env-edit-buttons {
+ height: auto;
+ align: right middle;
+ }
+
+ #env-edit-buttons Button {
+ margin-left: 1;
+ }
+ """
+
+ def __init__(self, key: str = "", value: str = "") -> None:
+ super().__init__()
+ self._key = key
+ self._value = value
+
+ def compose(self):
+ with Vertical(id="env-edit-dialog"):
+ yield Label("编辑环境变量")
+ yield Label("键:", classes="env-edit-field")
+ yield Static(self._key, id="env-key-display")
+ yield Label("值:", classes="env-edit-field")
+ yield Input(value=self._value, id="env-value-input", placeholder="配置值")
+ with Horizontal(id="env-edit-buttons"):
+ yield Button("取消", id="cancel-btn", variant="default")
+ yield Button("确定", id="ok-btn", variant="primary")
+
+ def on_button_pressed(self, event: Button.Pressed) -> None:
+ if event.button.id == "ok-btn":
+ value = self.query_one("#env-value-input", Input).value
+ self.dismiss(value)
+ elif event.button.id == "cancel-btn":
+ self.dismiss(None)
+
+
+class EnvConfigTab(Vertical):
+ """环境变量配置标签页.
+
+ 显示和编辑预定义的环境变量配置,支持:
+ - 查看所有预定义的环境变量
+ - 编辑环境变量的值
+ - 恢复环境变量为默认值
+ - 从 .env 文件读取/写入配置
+
+ 注意:不支持添加自定义环境变量,键字段为只读.
+ """
+
+ DEFAULT_CSS: ClassVar[str] = """
+ EnvConfigTab {
+ height: 1fr;
+ width: 1fr;
+ padding: 0 1;
+ overflow-y: auto;
+ }
+
+ #env-header {
+ height: auto;
+ padding: 1 0;
+ text-style: bold;
+ color: $text;
+ border-bottom: solid $primary;
+ }
+
+ #env-toolbar {
+ height: 1fr;
+ padding: 1 0;
+ align: left middle;
+ }
+
+ #env-toolbar Button {
+ margin-right: 1;
+ }
+
+ #env-table {
+ height: 1fr;
+ }
+
+ #env-empty {
+ height: 100%;
+ content-align: center middle;
+ color: $text-muted;
+ }
+
+ #env-help {
+ height: auto;
+ padding: 1;
+ margin-top: 1;
+ background: $surface;
+ border: solid $primary;
+ color: $text-muted;
+ }
+ """
+
+ def __init__(self) -> None:
+ super().__init__()
+ self._workspace_root: Path | None = None
+ self._env_data: dict[str, str] = {} # 当前环境变量(包含默认值和用户自定义)
+ self._user_envs: dict[str, str] = {} # 用户在 .env 中的配置
+
+ def compose(self):
+ yield Label("环境变量配置", id="env-header")
+ with Horizontal(id="env-toolbar"):
+ yield Button("编辑", id="env-edit-btn", variant="primary")
+ yield Button("恢复默认", id="env-reset-btn", variant="warning")
+ yield DataTable(id="env-table")
+
+ def set_workspace_root(self, workspace_root: Path) -> None:
+ """设置工作区根目录."""
+ self._workspace_root = workspace_root
+ self._load_env_file()
+ self._refresh()
+
+ def on_mount(self) -> None:
+ table = self.query_one("#env-table", DataTable)
+ table.add_columns("键", "值", "默认值", "说明")
+ table.cursor_type = "row"
+
+ def _load_env_file(self) -> None:
+ """从 .env 文件加载环境变量.
+
+ 只加载预定义的环境变量,忽略自定义变量.
+ """
+ self._user_envs.clear()
+ self._env_data.clear()
+
+ # 加载默认值
+ for key, config in DEFAULT_ENVS.items():
+ self._env_data[key] = config["value"]
+
+ # 从 .env 文件加载用户配置(仅限预定义变量)
+ if self._workspace_root:
+ env_file = self._workspace_root / ".env"
+ if env_file.exists():
+ try:
+ for line in env_file.read_text(encoding="utf-8").splitlines():
+ line = line.strip()
+ if line and not line.startswith("#") and "=" in line:
+ key, _, value = line.partition("=")
+ key = key.strip()
+ value = value.strip().strip('"').strip("'")
+ # 只接受预定义的环境变量
+ if key in DEFAULT_ENVS:
+ self._user_envs[key] = value
+ self._env_data[key] = value
+ except Exception:
+ pass
+
+ def _save_env_file(self) -> None:
+ """保存非默认值到 .env 文件.
+
+ 只保存预定义变量中与默认值不同的配置.
+ """
+ if not self._workspace_root:
+ return
+
+ env_file = self._workspace_root / ".env"
+
+ # 只保存与默认值不同的预定义变量
+ non_default = {}
+ for key, value in self._env_data.items():
+ if key in DEFAULT_ENVS:
+ default_config = DEFAULT_ENVS[key]
+ if value != default_config["value"]:
+ non_default[key] = value
+
+ try:
+ if non_default:
+ lines = ["# ManualAid 环境变量配置\n"]
+ for key, value in sorted(non_default.items()):
+ lines.append(f"{key}={value}\n")
+ env_file.write_text("".join(lines), encoding="utf-8")
+ elif env_file.exists():
+ # 如果所有值都是默认值,删除 .env 文件
+ env_file.unlink()
+ except Exception as e:
+ self.notify(f"保存失败: {e}", severity="error")
+
+ def _refresh(self) -> None:
+ """刷新环境变量列表.
+
+ 只显示预定义的环境变量.
+ """
+ table = self.query_one("#env-table", DataTable)
+ table.clear()
+
+ # 只显示预定义的环境变量
+ for key, config in DEFAULT_ENVS.items():
+ value = self._env_data.get(key, config["value"])
+ default_value = config["value"]
+ desc = config["description"]
+ table.add_row(key, value, default_value, desc)
+
+ async def on_button_pressed(self, event: Button.Pressed) -> None:
+ button_id = event.button.id or ""
+
+ if button_id == "env-edit-btn":
+ table = self.query_one("#env-table", DataTable)
+ if table.cursor_row is None or table.cursor_row < 0:
+ self.notify("请先选择一行", severity="warning")
+ return
+
+ row_data = table.get_row_at(table.cursor_row)
+ if row_data:
+ key, value, _, _ = row_data
+ self.app.push_screen(EnvEditDialog(key=key, value=value), self._on_edit_result)
+
+ elif button_id == "env-reset-btn":
+ # 重置选中行为默认值
+ table = self.query_one("#env-table", DataTable)
+ if table.cursor_row is None or table.cursor_row < 0:
+ self.notify("请先选择一行", severity="warning")
+ return
+
+ row_data = table.get_row_at(table.cursor_row)
+ if row_data:
+ key = row_data[0]
+ if key in DEFAULT_ENVS:
+ self._env_data[key] = DEFAULT_ENVS[key]["value"]
+ if key in self._user_envs:
+ del self._user_envs[key]
+ self._save_env_file()
+ self._refresh()
+ self.notify(f"已恢复默认值: {key}")
+
+ def _on_edit_result(self, result: str | None) -> None:
+ """处理编辑对话框的结果.
+
+ Args:
+ result: 编辑后的值,或 None 表示取消
+ """
+ if result is not None:
+ table = self.query_one("#env-table", DataTable)
+ if table.cursor_row is not None and table.cursor_row >= 0:
+ row_data = table.get_row_at(table.cursor_row)
+ if row_data:
+ key = row_data[0]
+ self._env_data[key] = result
+ self._user_envs[key] = result
+ self._save_env_file()
+ self._refresh()
+ self.notify(f"已更新: {key}")
diff --git a/src/console/ui/widgets/settings_tab.py b/src/console/ui/widgets/settings_tab.py
new file mode 100644
index 0000000..b46a2e2
--- /dev/null
+++ b/src/console/ui/widgets/settings_tab.py
@@ -0,0 +1,72 @@
+from __future__ import annotations
+
+from pathlib import Path
+from typing import ClassVar
+
+from textual.containers import Vertical
+from textual.widgets import TabbedContent, TabPane
+
+from src.console.ui.widgets.env_config_tab import EnvConfigTab
+from src.console.ui.widgets.skill_config_tab import SkillConfigTab
+
+
+class SettingsTab(Vertical):
+ """设置标签页.
+
+ 包含多个子标签页:
+ - 环境变量配置
+ - Skill 配置
+ """
+
+ DEFAULT_CSS: ClassVar[str] = """
+ SettingsTab {
+ height: 1fr;
+ width: 1fr;
+ padding: 0;
+ }
+
+ SettingsTab TabbedContent {
+ height: 1fr;
+ }
+
+ SettingsTab TabbedContent > TabPane {
+ padding: 0;
+ }
+ """
+
+ def __init__(self) -> None:
+ super().__init__()
+ self._workspace_root: Path | None = None
+ self._skill_manager = None
+
+ def compose(self):
+ with TabbedContent():
+ with TabPane("环境变量", id="tab-env"):
+ yield EnvConfigTab()
+ with TabPane("Skills", id="tab-skills"):
+ yield SkillConfigTab()
+
+ def on_mount(self) -> None:
+ """初始化子标签页."""
+ pass
+
+ def set_managers(self, workspace_root: Path, skill_manager) -> None:
+ """设置工作区根目录和管理器."""
+ self._workspace_root = workspace_root
+ self._skill_manager = skill_manager
+
+ # 传递给环境变量配置
+ env_tab = self.query_one(EnvConfigTab)
+ env_tab.set_workspace_root(workspace_root)
+
+ # 传递给 Skill 配置
+ skill_tab = self.query_one(SkillConfigTab)
+ skill_tab.set_managers(skill_manager, workspace_root)
+
+ @property
+ def env_config_tab(self) -> EnvConfigTab:
+ return self.query_one(EnvConfigTab)
+
+ @property
+ def skill_config_tab(self) -> SkillConfigTab:
+ return self.query_one(SkillConfigTab)
diff --git a/src/console/ui/widgets/skill_config_tab.py b/src/console/ui/widgets/skill_config_tab.py
new file mode 100644
index 0000000..e4aa636
--- /dev/null
+++ b/src/console/ui/widgets/skill_config_tab.py
@@ -0,0 +1,220 @@
+from __future__ import annotations
+
+from typing import ClassVar
+
+from textual.containers import Horizontal, Vertical
+from textual.widgets import Button, DataTable, Label, Static
+
+
+class SkillConfigTab(Vertical):
+ """Skill 配置标签页.
+
+ 显示所有发现的 Skill,支持:
+ - 查看全局和项目级 Skill
+ - 启用/禁用 Skill
+ - 查看 Skill 详情
+ """
+
+ DEFAULT_CSS: ClassVar[str] = """
+ SkillConfigTab {
+ height: 1fr;
+ width: 1fr;
+ padding: 0 1;
+ overflow-y: auto;
+ }
+
+ #skill-header {
+ height: auto;
+ padding: 1 0;
+ text-style: bold;
+ color: $text;
+ border-bottom: solid $primary;
+ }
+
+ #skill-toolbar {
+ height: auto;
+ padding: 1 0;
+ align: left middle;
+ }
+
+ #skill-toolbar Button {
+ margin-right: 1;
+ }
+
+ #skill-table {
+ height: 1fr;
+ }
+
+ #skill-detail {
+ height: auto;
+ max-height: 10;
+ padding: 1;
+ margin-top: 1;
+ background: $surface;
+ border: solid $primary;
+ overflow-y: auto;
+ }
+
+ #skill-empty {
+ height: 100%;
+ content-align: center middle;
+ color: $text-muted;
+ }
+
+ .skill-row-enabled {
+ color: $text;
+ }
+
+ .skill-row-disabled {
+ color: $text-muted;
+ }
+ """
+
+ def __init__(self) -> None:
+ super().__init__()
+ self._skill_manager = None
+ self._workspace_root = None
+ self._skills_data: dict = {}
+ self._columns_initialized = False # 标记列是否已初始化
+
+ def compose(self):
+ yield Label("Skill 配置", id="skill-header")
+ with Horizontal(id="skill-toolbar"):
+ yield Button("刷新", id="skill-refresh-btn", variant="default")
+ yield Button("启用全部", id="skill-enable-all-btn", variant="success")
+ yield Button("禁用全部", id="skill-disable-all-btn", variant="warning")
+ yield DataTable(id="skill-table")
+ yield Static("选择 Skill 查看详情", id="skill-detail")
+
+ def set_managers(self, skill_manager, workspace_root) -> None:
+ """设置 Skill 管理器和工作区根目录."""
+ self._skill_manager = skill_manager
+ self._workspace_root = workspace_root
+ # 发现 skills
+ if workspace_root:
+ from pathlib import Path
+
+ skill_manager.discover(Path(workspace_root))
+ self._refresh()
+
+ def on_mount(self) -> None:
+ table = self.query_one("#skill-table", DataTable)
+ table.add_columns("启用", "名称", "类型", "描述")
+ table.cursor_type = "row"
+ self._columns_initialized = True
+ # 如果已经有数据,刷新显示
+ if self._skills_data:
+ self._refresh()
+
+ def _refresh(self) -> None:
+ """刷新 Skill 列表."""
+ if self._skill_manager is None:
+ return
+
+ self._skills_data = self._skill_manager.get_all()
+
+ # 如果列还没初始化,不刷新(等 on_mount 后自动刷新)
+ if not self._columns_initialized:
+ return
+
+ table = self.query_one("#skill-table", DataTable)
+ table.clear()
+
+ for name, skill in self._skills_data.items():
+ is_global = skill.metadata.get("is_global", True)
+ skill_type = "全局" if is_global else "项目"
+ status = "✓" if skill.enabled else "✗"
+ description = skill.description[:50] + "..." if len(skill.description) > 50 else skill.description
+ table.add_row(status, name, skill_type, description)
+
+ def _update_detail(self, row_index: int) -> None:
+ """更新详情显示."""
+ if self._skill_manager is None:
+ return
+
+ table = self.query_one("#skill-table", DataTable)
+ if row_index is None or row_index < 0:
+ return
+
+ row_data = table.get_row_at(row_index)
+ if not row_data:
+ return
+
+ name = row_data[1]
+ skill = self._skills_data.get(name)
+ if not skill:
+ return
+
+ detail_text = (
+ f"[bold]{skill.name}[/bold]\n"
+ f"位置: {skill.location}\n"
+ f"类型: {'全局' if skill.metadata.get('is_global', True) else '项目'}\n"
+ f"状态: {'启用' if skill.enabled else '禁用'}\n\n"
+ f"描述: {skill.description}"
+ )
+
+ detail = self.query_one("#skill-detail", Static)
+ detail.update(detail_text)
+
+ async def on_data_table_row_highlighted(self, event: DataTable.RowHighlighted) -> None:
+ """行高亮时更新详情."""
+ if event.data_table.id == "skill-table":
+ self._update_detail(event.cursor_row)
+
+ async def on_button_pressed(self, event: Button.Pressed) -> None:
+ if self._skill_manager is None:
+ return
+
+ button_id = event.button.id or ""
+
+ if button_id == "skill-refresh-btn":
+ if self._workspace_root:
+ from pathlib import Path
+
+ self._skill_manager.discover(Path(self._workspace_root))
+ self._refresh()
+ self.notify("已刷新 Skill 列表")
+
+ elif button_id == "skill-enable-all-btn":
+ self._skill_manager.set_disabled(set(), persist=True)
+ self._refresh()
+ self.notify("已启用所有 Skill")
+
+ elif button_id == "skill-disable-all-btn":
+ all_names = set(self._skills_data.keys())
+ self._skill_manager.set_disabled(all_names, persist=True)
+ self._refresh()
+ self.notify("已禁用所有 Skill")
+
+ async def on_data_table_cell_selected(self, event: DataTable.CellSelected) -> None:
+ """单元格选中时切换启用状态(点击启用列)."""
+ if event.data_table.id != "skill-table":
+ return
+
+ if event.column_key != 0: # 只在"启用"列点击时切换
+ return
+
+ if self._skill_manager is None:
+ return
+
+ row_data = event.data_table.get_row_at(event.cursor_row)
+ if not row_data:
+ return
+
+ name = row_data[1]
+ skill = self._skills_data.get(name)
+ if not skill:
+ return
+
+ # 切换状态
+ disabled = self._skill_manager.get_disabled()
+ if name in disabled:
+ disabled.discard(name)
+ else:
+ disabled.add(name)
+
+ self._skill_manager.set_disabled(disabled, persist=True)
+ self._refresh()
+
+ status = "启用" if name not in disabled else "禁用"
+ self.notify(f"已{status}: {name}")
diff --git a/src/core/config_manager.py b/src/core/config_manager.py
new file mode 100644
index 0000000..8acc952
--- /dev/null
+++ b/src/core/config_manager.py
@@ -0,0 +1,244 @@
+"""配置管理器 - 统一管理应用程序配置."""
+
+from __future__ import annotations
+
+import json
+import threading
+from pathlib import Path
+from typing import Any, ClassVar
+
+from src.core.database_manager import DatabaseManager
+
+# 默认环境变量配置(与 .env.example 保持一致)
+DEFAULT_ENVS = {
+ "TOOL_MAX_DOC_LENGTH": {"value": "360", "description": "工具文档最大长度(字符数)"},
+ "TOOL_MAX_FUNC_NAME_LENGTH": {"value": "80", "description": "函数名最大长度(字符数)"},
+ "TOOL_MAX_RESULT_LENGTH": {"value": "30000", "description": "结果输出最大长度(字符数)"},
+ "TOOL_LIST_TRUNCATE_THRESHOLD": {"value": "100", "description": "列表截断阈值(项目数量上限)"},
+ "TOOL_DICT_TRUNCATE_THRESHOLD": {"value": "100", "description": "字典截断阈值(键值对数量上限)"},
+ "RESULT_EXPIRE_MINUTES": {"value": "5", "description": "结果过期时间(分钟)"},
+ "RESULT_CLEANUP_MINUTES": {"value": "15", "description": "清理任务间隔时间(分钟)"},
+ "MANUALAID_AUTO_COPY": {"value": "true", "description": "是否自动复制结果(支持 true/false/yes/no/on/off)"},
+ "SESSION_UPDATE_INTERVAL": {"value": "30", "description": "会话持续时间持久化间隔(秒)"},
+ "SESSION_FLAG_CHECK_INTERVAL": {"value": "5", "description": "会话标志检查间隔(秒)"},
+}
+
+# 默认配置值
+DEFAULTS: dict[str, Any] = {
+ # Skill 配置
+ "skills.disabled": [],
+ # 通用配置
+ "general.theme": "dark",
+ "general.log_level": "INFO",
+}
+
+for __k, __v in DEFAULT_ENVS.items():
+ DEFAULTS[f"env.{__k}"] = __v
+
+
+def _parse_value(value: str) -> Any:
+ """解析配置值.
+
+ 尝试解析为 JSON,失败则返回原始字符串.
+
+ Args:
+ value: 原始字符串值
+
+ Returns:
+ 解析后的值
+ """
+ try:
+ return json.loads(value)
+ except json.JSONDecodeError, TypeError:
+ return value
+
+
+def _serialize_value(value: Any) -> str:
+ """序列化配置值.
+
+ Args:
+ value: 配置值
+
+ Returns:
+ 序列化后的字符串
+ """
+ if isinstance(value, str):
+ return value
+ return json.dumps(value, ensure_ascii=False)
+
+
+class ConfigManager:
+ """配置管理器(单例模式).
+
+ 提供统一的配置访问接口,支持多种配置类型:
+ - 环境变量配置
+ - Skill 配置
+ - 通用配置
+ """
+
+ _instance: ClassVar[ConfigManager | None] = None
+ _instance_lock: ClassVar[threading.Lock] = threading.Lock()
+
+ def __new__(cls) -> ConfigManager:
+ with cls._instance_lock:
+ if cls._instance is None:
+ instance = super().__new__(cls)
+ instance._initialized = False
+ cls._instance = instance
+ return cls._instance
+
+ def __init__(self) -> None:
+ if self._initialized:
+ return
+
+ self._db: DatabaseManager | None = None
+ self._cache: dict[str, Any] = {}
+ self._initialized = True
+
+ def initialize(self, workspace_root: Path) -> None:
+ """初始化配置管理器.
+
+ Args:
+ workspace_root: 工作区根目录
+ """
+ self._db = DatabaseManager(str(workspace_root))
+ self._cache.clear()
+ self._load_from_db()
+
+ def _load_from_db(self) -> None:
+ """从数据库加载所有配置到缓存."""
+ if self._db is None:
+ return
+
+ rows = self._db.get_all_config()
+ for key, value, _category, _updated_at in rows:
+ self._cache[key] = _parse_value(value)
+
+ def get(self, key: str, default: Any = None) -> Any:
+ """获取配置值.
+
+ Args:
+ key: 配置键
+ default: 默认值
+
+ Returns:
+ 配置值
+ """
+ # 优先从缓存读取
+ if key in self._cache:
+ return self._cache[key]
+
+ # 尝试从默认值获取
+ if key in DEFAULTS:
+ return DEFAULTS[key]
+
+ return default
+
+ def set(self, key: str, value: Any, category: str = "general") -> None:
+ """设置配置值.
+
+ Args:
+ key: 配置键
+ value: 配置值
+ category: 配置类别
+ """
+ serialized = _serialize_value(value)
+ self._cache[key] = value
+
+ if self._db:
+ self._db.set_config(key, serialized, category)
+
+ def delete(self, key: str) -> None:
+ """删除配置值.
+
+ Args:
+ key: 配置键
+ """
+ if key in self._cache:
+ del self._cache[key]
+
+ if self._db:
+ self._db.delete_config(key)
+
+ def get_category(self, category: str) -> dict[str, Any]:
+ """获取指定类别的所有配置.
+
+ Args:
+ category: 配置类别
+
+ Returns:
+ 配置字典
+ """
+ if self._db is None:
+ return {}
+
+ rows = self._db.get_all_config(category)
+ return {row[0]: _parse_value(row[1]) for row in rows}
+
+ def get_env_configs(self) -> dict[str, str]:
+ """获取所有环境变量配置.
+
+ Returns:
+ 环境变量配置字典
+ """
+ return {k[4:]: v for k, v in self._cache.items() if k.startswith("env.")}
+
+ def set_env_config(self, key: str, value: str) -> None:
+ """设置环境变量配置.
+
+ Args:
+ key: 环境变量名(不含 env. 前缀)
+ value: 配置值
+ """
+ self.set(f"env.{key}", value, category="env")
+
+ def get_env_config(self, key: str, default: str = "") -> str:
+ """获取环境变量配置.
+
+ Args:
+ key: 环境变量名(不含 env. 前缀)
+ default: 默认值
+
+ Returns:
+ 配置值
+ """
+ return str(self.get(f"env.{key}", default))
+
+ def apply_env_configs(self) -> None:
+ """应用环境变量配置到 os.environ."""
+ import os
+
+ env_configs = self.get_env_configs()
+ for key, value in env_configs.items():
+ if value: # 只设置非空值
+ os.environ[key] = str(value)
+
+ # -- Skill 配置快捷方法 --
+
+ def get_disabled_skills(self) -> set[str]:
+ """获取禁用的 Skill 列表.
+
+ Returns:
+ 禁用的 Skill 名称集合
+ """
+ if self._db:
+ return self._db.get_disabled_skills()
+ return set()
+
+ def set_disabled_skills(self, *names: str) -> None:
+ """设置禁用的 Skill 列表.
+
+ Args:
+ names: 禁用的 Skill 名称集合
+ """
+ if self._db:
+ self._db.set_disabled_skills(*names)
+
+ @classmethod
+ def reset_instance(cls) -> None:
+ """重置单例实例(用于测试)."""
+ with cls._instance_lock:
+ if cls._instance is not None:
+ cls._instance._cache.clear()
+ cls._instance._db = None
+ cls._instance = None
diff --git a/src/core/database_manager.py b/src/core/database_manager.py
index e3a7f41..61fcb5d 100644
--- a/src/core/database_manager.py
+++ b/src/core/database_manager.py
@@ -1,4 +1,5 @@
import contextlib
+import json
import sqlite3
import threading
import time
@@ -114,6 +115,13 @@ def _init_tables(self) -> None:
PRIMARY KEY (session_id, func_name, kwargs_json),
FOREIGN KEY (session_id) REFERENCES sessions(id)
);
+
+ CREATE TABLE IF NOT EXISTS config (
+ key TEXT PRIMARY KEY,
+ value TEXT NOT NULL,
+ category TEXT NOT NULL DEFAULT 'general',
+ updated_at REAL NOT NULL
+ );
"""
)
@@ -132,6 +140,19 @@ def _init_tables(self) -> None:
if not any(row[1] == "deleted" for row in conn.execute("PRAGMA table_info(sessions)")):
conn.execute("ALTER TABLE sessions ADD COLUMN deleted INTEGER NOT NULL DEFAULT 0")
+ # Phase 6 migration: add config table
+ if not any(row[1] == "key" for row in conn.execute("PRAGMA table_info(config)")):
+ conn.execute(
+ """
+ CREATE TABLE IF NOT EXISTS config (
+ key TEXT PRIMARY KEY,
+ value TEXT NOT NULL,
+ category TEXT NOT NULL DEFAULT 'general',
+ updated_at REAL NOT NULL
+ )
+ """
+ )
+
# Create all indexes after migrations so they apply to both
# fresh databases and those upgraded from older schemas.
conn.execute("CREATE INDEX IF NOT EXISTS idx_tool_calls_session ON tool_calls(session_id)")
@@ -486,3 +507,98 @@ def get_tool_call_summaries(self, session_id: int) -> list[tuple]:
"FROM tool_call_summaries WHERE session_id = ? ORDER BY timestamp DESC",
(session_id,),
)
+
+ # -- Configuration management --
+
+ def get_config(self, key: str, default: str | None = None) -> str | None:
+ """Get a configuration value by key.
+
+ Args:
+ key: Configuration key
+ default: Default value if key not found
+
+ Returns:
+ Configuration value or default
+ """
+ row = self.fetchone("SELECT value FROM config WHERE key = ?", (key,))
+ return row[0] if row else default
+
+ def set_config(self, key: str, value: str, category: str = "general") -> None:
+ """Set a configuration value.
+
+ Args:
+ key: Configuration key
+ value: Configuration value
+ category: Configuration category (general, skill, env, etc.)
+ """
+ self.execute(
+ "INSERT INTO config (key, value, category, updated_at) VALUES (?, ?, ?, ?) "
+ "ON CONFLICT(key) DO UPDATE SET value = excluded.value, category = excluded.category, "
+ "updated_at = excluded.updated_at",
+ (key, value, category, time.time()),
+ )
+
+ def delete_config(self, key: str) -> None:
+ """Delete a configuration value.
+
+ Args:
+ key: Configuration key
+ """
+ self.execute("DELETE FROM config WHERE key = ?", (key,))
+
+ def get_all_config(self, category: str | None = None) -> list[tuple]:
+ """Get all configuration values, optionally filtered by category.
+
+ Args:
+ category: Optional category filter
+
+ Returns:
+ List of (key, value, category, updated_at) tuples
+ """
+ if category:
+ return self.fetchall(
+ "SELECT key, value, category, updated_at FROM config WHERE category = ? ORDER BY key",
+ (category,),
+ )
+ return self.fetchall("SELECT key, value, category, updated_at FROM config ORDER BY category, key")
+
+ def get_config_by_prefix(self, prefix: str) -> dict[str, str]:
+ """Get all configuration values with a given key prefix.
+
+ Args:
+ prefix: Key prefix to filter by
+
+ Returns:
+ Dictionary of key-value pairs
+ """
+ rows = self.fetchall(
+ "SELECT key, value FROM config WHERE key LIKE ? ORDER BY key",
+ (f"{prefix}%",),
+ )
+ return {row[0]: row[1] for row in rows}
+
+ # -- Skill configuration shortcuts --
+
+ def get_disabled_skills(self) -> set[str]:
+ """Get the set of disabled skill names.
+
+ Returns:
+ Set of disabled skill names
+ """
+ value = self.get_config("skills.disabled")
+ if not value:
+ return set()
+ import json
+
+ try:
+ return set(json.loads(value))
+ except json.JSONDecodeError, TypeError:
+ return set()
+
+ def set_disabled_skills(self, *names: str) -> None:
+ """Set the disabled skill names.
+
+ Args:
+ names: Set of skill names to disable
+ """
+ self.set_config("skills.disabled", json.dumps(sorted(set(names))), category="skill")
diff --git a/src/core/skill_manager.py b/src/core/skill_manager.py
new file mode 100644
index 0000000..248f737
--- /dev/null
+++ b/src/core/skill_manager.py
@@ -0,0 +1,257 @@
+"""Skill 发现和管理服务."""
+
+from __future__ import annotations
+
+import threading
+from pathlib import Path
+from typing import ClassVar
+
+from src.models.skill import SkillInfo
+
+
+class SkillManager:
+ """Skill 发现和管理服务(单例模式).
+
+ 负责从多个位置发现 Skill,并提供查询和加载功能.
+ """
+
+ _instance: ClassVar[SkillManager | None] = None
+ _instance_lock: ClassVar[threading.Lock] = threading.Lock()
+
+ # Skill 发现路径模板
+ GLOBAL_PATHS: ClassVar[list[str]] = [
+ "~/.claude/skills",
+ "~/.agents/skills",
+ ]
+
+ PROJECT_PATHS: ClassVar[list[str]] = [
+ ".claude/skills",
+ ".agents/skills",
+ ".ManualAid/skills",
+ ".opencode/skill",
+ ".opencode/skills",
+ ]
+
+ def __new__(cls) -> SkillManager:
+ with cls._instance_lock:
+ if cls._instance is None:
+ instance = super().__new__(cls)
+ instance._initialized = False
+ cls._instance = instance
+ return cls._instance
+
+ def __init__(self) -> None:
+ if self._initialized:
+ return
+
+ self._skills: dict[str, SkillInfo] = {}
+ self._disabled_skills: set[str] = set()
+ self._workspace_root: Path | None = None
+ self._initialized = True
+
+ def discover(self, workspace_root: Path | None = None) -> dict[str, SkillInfo]:
+ """发现所有可用的 Skill.
+
+ Args:
+ workspace_root: 工作区根目录,用于发现项目级 Skill
+
+ Returns:
+ Skill 名称到 SkillInfo 的映射
+ """
+ self._skills.clear()
+ self._workspace_root = workspace_root
+
+ # 1. 从数据库加载禁用的 Skill 列表
+ if workspace_root:
+ self._load_disabled_from_db(workspace_root)
+
+ # 2. 发现全局 Skill
+ for path_template in self.GLOBAL_PATHS:
+ skills_dir = Path(path_template).expanduser()
+ if skills_dir.is_dir():
+ self._discover_in_dir(skills_dir, is_global=True)
+
+ # 3. 发现项目级 Skill
+ if workspace_root:
+ for relative_path in self.PROJECT_PATHS:
+ skills_dir = workspace_root / relative_path
+ if skills_dir.is_dir():
+ self._discover_in_dir(skills_dir, is_global=False)
+
+ return self._skills
+
+ def _load_disabled_from_db(self, workspace_root: Path) -> None:
+ """从数据库加载禁用的 Skill 列表.
+
+ Args:
+ workspace_root: 工作区根目录
+ """
+ try:
+ from src.core.database_manager import DatabaseManager
+
+ db = DatabaseManager(str(workspace_root))
+ self._disabled_skills = db.get_disabled_skills()
+ except Exception:
+ self._disabled_skills = set()
+
+ def save_disabled_to_db(self, workspace_root: Path | None = None) -> None:
+ """保存禁用的 Skill 列表到数据库.
+
+ Args:
+ workspace_root: 工作区根目录,如果为 None 则使用缓存的值
+ """
+ root = workspace_root or self._workspace_root
+ if root is None:
+ return
+
+ try:
+ from src.core.database_manager import DatabaseManager
+
+ db = DatabaseManager(str(root))
+ db.set_disabled_skills(self._disabled_skills)
+ except Exception:
+ pass
+
+ def _discover_in_dir(self, skills_dir: Path, is_global: bool = True) -> None:
+ """在指定目录中发现 Skill.
+
+ Args:
+ skills_dir: Skill 目录
+ is_global: 是否为全局目录
+ """
+ try:
+ for item in skills_dir.iterdir():
+ if item.is_dir():
+ skill_info = SkillInfo.from_dir(item)
+ if skill_info:
+ skill_info.metadata["is_global"] = is_global
+ # 如果已存在同名 Skill,项目级覆盖全局
+ if skill_info.name in self._skills:
+ existing = self._skills[skill_info.name]
+ # 项目级优先
+ if not existing.metadata.get("is_global", True):
+ continue
+ # 应用禁用状态
+ if skill_info.name in self._disabled_skills:
+ skill_info.enabled = False
+ self._skills[skill_info.name] = skill_info
+ except Exception:
+ pass
+
+ def get(self, name: str) -> SkillInfo | None:
+ """获取指定名称的 Skill.
+
+ Args:
+ name: Skill 名称
+
+ Returns:
+ SkillInfo 实例,如果不存在则返回 None
+ """
+ return self._skills.get(name)
+
+ def get_all(self) -> dict[str, SkillInfo]:
+ """获取所有 Skill.
+
+ Returns:
+ Skill 名称到 SkillInfo 的映射
+ """
+ return self._skills.copy()
+
+ def get_enabled(self) -> dict[str, SkillInfo]:
+ """获取所有启用的 Skill.
+
+ Returns:
+ Skill 名称到 SkillInfo 的映射
+ """
+ return {name: skill for name, skill in self._skills.items() if skill.enabled}
+
+ def set_disabled(self, names: set[str], persist: bool = True) -> None:
+ """设置禁用的 Skill 列表.
+
+ Args:
+ names: 要禁用的 Skill 名称集合
+ persist: 是否持久化到数据库
+ """
+ self._disabled_skills = names.copy()
+ for skill in self._skills.values():
+ skill.enabled = skill.name not in self._disabled_skills
+
+ if persist:
+ self.save_disabled_to_db()
+
+ def get_disabled(self) -> set[str]:
+ """获取禁用的 Skill 名称集合.
+
+ Returns:
+ 禁用的 Skill 名称集合
+ """
+ return self._disabled_skills.copy()
+
+ def format_skills_list(self, verbose: bool = False) -> str:
+ """格式化 Skill 列表为字符串.
+
+ Args:
+ verbose: 是否输出详细信息
+
+ Returns:
+ 格式化后的字符串
+ """
+ if not self._skills:
+ return "No skills available."
+
+ if verbose:
+ lines = [""]
+ for skill in sorted(self._skills.values(), key=lambda s: s.name):
+ lines.append(" ")
+ lines.append(f" {skill.name}")
+ lines.append(f" {skill.description}")
+ lines.append(" ")
+ lines.append("")
+ return "\n".join(lines)
+ else:
+ lines = ["## Available Skills"]
+ for skill in sorted(self._skills.values(), key=lambda s: s.name):
+ status = "" if skill.enabled else " [DISABLED]"
+ lines.append(f"- **{skill.name}**: {skill.description}{status}")
+ return "\n".join(lines)
+
+ def load_skill_content(self, name: str) -> str | None:
+ """加载 Skill 的完整内容(用于注入到提示词).
+
+ Args:
+ name: Skill 名称
+
+ Returns:
+ Skill 内容字符串,如果不存在则返回 None
+ """
+ skill = self.get(name)
+ if not skill:
+ return None
+
+ lines = [
+ f'',
+ f"# Skill: {skill.name}",
+ "",
+ skill.content.strip(),
+ "",
+ f"Base directory for this skill: {skill.location}",
+ "",
+ ]
+
+ for filename in skill.files:
+ lines.append(f" - {filename}")
+
+ lines.append("")
+ lines.append("")
+
+ return "\n".join(lines)
+
+ @classmethod
+ def reset_instance(cls) -> None:
+ """重置单例实例(用于测试)."""
+ with cls._instance_lock:
+ if cls._instance is not None:
+ cls._instance._skills.clear()
+ cls._instance._disabled_skills.clear()
+ cls._instance._workspace_root = None
+ cls._instance = None
diff --git a/src/core/tool_registry.py b/src/core/tool_registry.py
index 55f8650..cb99426 100644
--- a/src/core/tool_registry.py
+++ b/src/core/tool_registry.py
@@ -85,6 +85,7 @@ def register(self, workspace: Workspace) -> None:
from src.workspace.tools.ls_tool import LsTool
from src.workspace.tools.read_tool import ReadTool
from src.workspace.tools.regex_search_tool import RegexSearchTool
+ from src.workspace.tools.skill_tool import SkillTool
from src.workspace.tools.stat_tool import StatTool
from src.workspace.tools.symbol_ref_tool import SymbolRefTool
from src.workspace.tools.write_tool import WriteTool
@@ -102,6 +103,7 @@ def register(self, workspace: Workspace) -> None:
SymbolRefTool,
EditTool,
GitTool,
+ SkillTool,
):
try:
tool = cls(workspace)
diff --git a/src/models/skill.py b/src/models/skill.py
new file mode 100644
index 0000000..1a7c2f2
--- /dev/null
+++ b/src/models/skill.py
@@ -0,0 +1,127 @@
+"""Skill 数据模型."""
+
+from __future__ import annotations
+
+from contextlib import suppress
+from dataclasses import dataclass, field
+from pathlib import Path
+from typing import Any
+
+import yaml
+
+
+@dataclass
+class SkillInfo:
+ """Skill 信息模型.
+
+ Attributes:
+ name: Skill 名称(来自目录名)
+ description: Skill 描述(来自 SKILL.md 第一行或 skill.txt)
+ location: Skill 所在目录的绝对路径
+ content: SKILL.md 的完整内容
+ files: Skill 目录中的其他文件列表(排除 SKILL.md)
+ enabled: 是否启用(用于配置)
+ """
+
+ name: str
+ description: str = ""
+ location: str = ""
+ content: str = ""
+ files: list[str] = field(default_factory=list)
+ enabled: bool = True
+ metadata: dict[str, Any] = field(default_factory=dict)
+
+ @classmethod
+ def from_dir(cls, skill_dir: Path) -> SkillInfo | None:
+ """从目录加载 Skill 信息.
+
+ Args:
+ skill_dir: Skill 目录路径
+
+ Returns:
+ SkillInfo 实例,如果目录无效则返回 None
+ """
+ skill_md = skill_dir / "SKILL.md"
+ skill_txt = skill_dir / "skill.txt"
+
+ # 必须有 SKILL.md 文件
+ if not skill_md.exists():
+ return None
+
+ try:
+ content = skill_md.read_text(encoding="utf-8")
+ except Exception:
+ return None
+
+ # 解析 YAML frontmatter
+ name = skill_dir.name # 默认使用目录名
+ description = ""
+
+ if content.startswith("---"):
+ # 解析 YAML frontmatter
+ parts = content.split("---", 2)
+ if len(parts) >= 3:
+ try:
+ frontmatter = yaml.safe_load(parts[1])
+ if frontmatter:
+ name = frontmatter.get("name", name)
+ description = frontmatter.get("description", "")
+ except Exception:
+ pass
+
+ # 如果没有从 frontmatter 获取到描述,尝试其他方式
+ if not description:
+ # 优先从 skill.txt
+ if skill_txt.exists():
+ with suppress(Exception):
+ description = skill_txt.read_text(encoding="utf-8").strip()
+
+ if not description:
+ # 从 SKILL.md 第一行提取标题
+ first_line = content.split("\n")[0] if content else ""
+ if first_line.startswith("#"):
+ description = first_line.lstrip("#").strip()
+ else:
+ description = first_line.strip() or skill_dir.name
+
+ # 收集其他文件
+ files: list[str] = []
+ try:
+ for f in skill_dir.iterdir():
+ if f.is_file() and f.name not in ("SKILL.md", "skill.txt"):
+ files.append(f.name)
+ except Exception:
+ pass
+
+ return cls(
+ name=name,
+ description=description,
+ location=str(skill_dir),
+ content=content,
+ files=sorted(files),
+ enabled=True,
+ )
+
+ def to_dict(self) -> dict[str, Any]:
+ """转换为字典格式."""
+ return {
+ "name": self.name,
+ "description": self.description,
+ "location": self.location,
+ "files": self.files,
+ "enabled": self.enabled,
+ "metadata": self.metadata,
+ }
+
+ @classmethod
+ def from_dict(cls, data: dict[str, Any]) -> SkillInfo:
+ """从字典创建实例."""
+ return cls(
+ name=data.get("name", ""),
+ description=data.get("description", ""),
+ location=data.get("location", ""),
+ content=data.get("content", ""),
+ files=data.get("files", []),
+ enabled=data.get("enabled", True),
+ metadata=data.get("metadata", {}),
+ )
diff --git a/src/workspace/tools/skill_tool.py b/src/workspace/tools/skill_tool.py
new file mode 100644
index 0000000..95c60b0
--- /dev/null
+++ b/src/workspace/tools/skill_tool.py
@@ -0,0 +1,75 @@
+"""Skill 工具实现."""
+
+from __future__ import annotations
+
+from src.models.tools.tool_result import ToolResult
+from src.workspace.tools.base_tool import BaseTool
+from src.workspace.workspace import Workspace
+
+
+class SkillTool(BaseTool):
+ """Skill 加载工具.
+
+ 用于加载指定的 Skill 并将其内容注入到当前会话中.
+ """
+
+ def __init__(self, workspace: Workspace) -> None:
+ super().__init__(
+ workspace=workspace,
+ name="skill",
+ doc="当**当前任务**与系统提示中列出的某一技能相匹配时, 加载该专业技能"
+ "使用此工具将技能的指令和资源注入当前对话.输出内容可能包含详细的工作流程指导, "
+ "以及对该技能所在目录中的脚本、文件等的引用"
+ "技能名称必须与系统提示中列出的某一技能完全一致",
+ read_permission=True,
+ write_permission=False,
+ )
+ self.func = self._execute
+ self.params = self.extract_params(self._execute)
+ self.param_descriptions = {
+ "name": "The name of the skill from available_skills",
+ }
+
+ def _execute(self, name: str) -> ToolResult:
+ """执行 Skill 加载.
+
+ Args:
+ name: Skill 名称
+
+ Returns:
+ ToolResult 包含 Skill 内容或错误信息
+ """
+ from src.core.skill_manager import SkillManager
+
+ skill_manager = SkillManager()
+
+ # 确保 Skill 已发现
+ if not skill_manager.get_all():
+ skill_manager.discover(self.workspace.root_path)
+
+ skill = skill_manager.get(name)
+
+ if skill is None:
+ return self.make_failed_response(
+ kwargs={"name": name},
+ error=f'Skill "{name}" not found. Use a skill name from the available_skills list.',
+ )
+
+ if not skill.enabled:
+ return self.make_failed_response(
+ kwargs={"name": name},
+ error=f'Skill "{name}" is disabled. Enable it in the configuration.',
+ )
+
+ content = skill_manager.load_skill_content(name)
+
+ if content is None:
+ return self.make_failed_response(
+ kwargs={"name": name},
+ error=f'Failed to load skill "{name}".',
+ )
+
+ return self.make_success_response(
+ kwargs={"name": name},
+ data={"title": f"Loaded skill: {name}", "output": content},
+ )
From 2a670fdd60facfc34ad207cd06d588f6035e5fce Mon Sep 17 00:00:00 2001
From: Suntion <149924916+SunYanbox@users.noreply.github.com>
Date: Thu, 7 May 2026 16:53:24 +0800
Subject: [PATCH 4/9] =?UTF-8?q?feat(workspaces):=20=E5=BC=95=E5=85=A5?=
=?UTF-8?q?=E6=8A=80=E8=83=BD=E9=85=8D=E7=BD=AE=E7=AE=A1=E7=90=86=E5=B9=B6?=
=?UTF-8?q?=E9=9B=86=E6=88=90=E8=87=B3=E7=B3=BB=E7=BB=9F=E6=8F=90=E7=A4=BA?=
=?UTF-8?q?=20(#153)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 新增功能: 实现技能启用/禁用状态管理与持久化
* `SkillManager` 新增 `get_enabled()` 和 `get_disabled()` 方法,用于从数据库获取技能状态
* `workspace_cmd.py` 新增 `_generate_skill_prompt_section()` 函数,动态生成 `` XML 块
* `skill_config_tab.py` 新增“启用选中”和“禁用选中”按钮逻辑,支持通过 `set_disabled()` 方法更新持久化状态
* 系统提示组装流程中增加技能段落的生成与注册,通过 `register_extension_hook` 注入技能信息
- 修复问题: 修正 UI 显示状态与数据库不一致的缺陷
* `skill_config_tab.py` 在刷新表格时改用 `get_disabled()` 集合判断状态,确保列表显示的启用/禁用标记(✓/✗)与数据库持久化数据严格同步
* 移除对 `skill.enabled` 属性的直接依赖,统一使用禁用集合进行状态校验
- 重构优化: 简化接口参数类型定义
* `database_manager.py` 将 `set_disabled_skills` 方法的参数类型从 `*names: str` 调整为 `names` (支持 set, list, tuple),内部自动转换为集合处理
---
.../commands/workspaces/workspace_cmd.py | 40 +++++++++++++-
src/console/ui/widgets/skill_config_tab.py | 54 ++++++++++++++++++-
src/core/database_manager.py | 4 +-
3 files changed, 93 insertions(+), 5 deletions(-)
diff --git a/src/console/commands/workspaces/workspace_cmd.py b/src/console/commands/workspaces/workspace_cmd.py
index a5ae3e3..2272949 100644
--- a/src/console/commands/workspaces/workspace_cmd.py
+++ b/src/console/commands/workspaces/workspace_cmd.py
@@ -9,8 +9,10 @@
TOOL_RULES,
WORKFLOW_GUIDELINES,
generate_extensions_section,
+ register_extension_hook,
)
from src.core.agent_manager import AgentManager
+from src.core.skill_manager import SkillManager
from src.models.agent import AgentConfig
from src.models.commands import Command, CommandContext, CommandResult
@@ -19,7 +21,7 @@
AGENTS_MD_FENCE_END = ""
-def _generate_tool_definitions_section(context: CommandContext, agent: AgentConfig) -> str:
+def _generate_tool_definitions_section(context: CommandContext, agent: AgentConfig, enable_skill: bool = False) -> str:
"""Generate XML block with doc for each registered tool,
filtered by the current agent's tool permissions."""
tools = context.tool_registry.list_tools()
@@ -29,6 +31,9 @@ def _generate_tool_definitions_section(context: CommandContext, agent: AgentConf
docs: list[str] = [""]
for name in tools["sync"]:
+ # 与src/workspace/tools/skill_tool.py一致
+ if (not enable_skill) and name == "skill":
+ continue
# Filter by agent permissions
if not agent.tool_permissions.is_tool_allowed(name):
continue
@@ -106,6 +111,33 @@ def _generate_agent_directive_section(agent: AgentConfig) -> str:
return "\n".join(parts)
+def _generate_skill_prompt_section() -> str:
+ """Generate Skill-related prompt sections: skill instructions and available skills list.
+
+ Returns:
+ XML-formatted string containing skill prompt and available skills
+ """
+ skill_manager = SkillManager()
+
+ # 获取启用的 skills(会根据数据库中的禁用状态过滤)
+ enabled_skills = skill_manager.get_enabled()
+
+ if not enabled_skills:
+ return ""
+
+ parts = ["", ""]
+
+ # 可用 Skill 列表
+ for skill in sorted(enabled_skills.values(), key=lambda s: s.name):
+ parts.append(f"""
+ {skill.name}
+ {skill.description}
+ """)
+ parts.append("")
+
+ return "\n".join(parts)
+
+
def _assemble_full_prompt(context: CommandContext) -> str:
"""Assemble the complete system prompt from ordered XML sections.
@@ -113,6 +145,7 @@ def _assemble_full_prompt(context: CommandContext) -> str:
→ workflow → workspace_context → augmentation → extensions
"""
agent = AgentManager().get_current()
+ skill_prompt = _generate_skill_prompt_section()
sections = [
"",
@@ -139,7 +172,7 @@ def _assemble_full_prompt(context: CommandContext) -> str:
sections.append("")
# ⑤ Tool definitions
- sections.append(_generate_tool_definitions_section(context, agent))
+ sections.append(_generate_tool_definitions_section(context, agent, len(skill_prompt) > 0))
sections.append("")
# ⑥ Workflow guidelines — skip if agent provides its own workflow
@@ -157,6 +190,9 @@ def _assemble_full_prompt(context: CommandContext) -> str:
sections.append(augmentations)
sections.append("")
+ if len(skill_prompt) > 0:
+ register_extension_hook(lambda: skill_prompt)
+
# ⑨ Extensions (Skills / MCP hooks)
sections.append(generate_extensions_section())
sections.append("")
diff --git a/src/console/ui/widgets/skill_config_tab.py b/src/console/ui/widgets/skill_config_tab.py
index e4aa636..1e16296 100644
--- a/src/console/ui/widgets/skill_config_tab.py
+++ b/src/console/ui/widgets/skill_config_tab.py
@@ -81,6 +81,8 @@ def compose(self):
yield Label("Skill 配置", id="skill-header")
with Horizontal(id="skill-toolbar"):
yield Button("刷新", id="skill-refresh-btn", variant="default")
+ yield Button("启用选中", id="skill-enable-btn", variant="success")
+ yield Button("禁用选中", id="skill-disable-btn", variant="warning")
yield Button("启用全部", id="skill-enable-all-btn", variant="success")
yield Button("禁用全部", id="skill-disable-all-btn", variant="warning")
yield DataTable(id="skill-table")
@@ -111,6 +113,7 @@ def _refresh(self) -> None:
if self._skill_manager is None:
return
+ # 重新获取所有技能(会从数据库加载禁用状态)
self._skills_data = self._skill_manager.get_all()
# 如果列还没初始化,不刷新(等 on_mount 后自动刷新)
@@ -120,10 +123,15 @@ def _refresh(self) -> None:
table = self.query_one("#skill-table", DataTable)
table.clear()
+ # 获取当前禁用状态用于显示
+ disabled_set = self._skill_manager.get_disabled()
+
for name, skill in self._skills_data.items():
is_global = skill.metadata.get("is_global", True)
skill_type = "全局" if is_global else "项目"
- status = "✓" if skill.enabled else "✗"
+ # 使用禁用集合判断状态,确保与持久化数据一致
+ is_enabled = name not in disabled_set
+ status = "✓" if is_enabled else "✗"
description = skill.description[:50] + "..." if len(skill.description) > 50 else skill.description
table.add_row(status, name, skill_type, description)
@@ -175,6 +183,50 @@ async def on_button_pressed(self, event: Button.Pressed) -> None:
self._refresh()
self.notify("已刷新 Skill 列表")
+ elif button_id == "skill-enable-btn":
+ # 启用选中的 Skill
+ table = self.query_one("#skill-table", DataTable)
+ row_index = table.cursor_row
+ if row_index is None or row_index < 0:
+ self.notify("请先选择一个 Skill", severity="warning")
+ return
+
+ row_data = table.get_row_at(row_index)
+ if not row_data:
+ return
+
+ name = row_data[1]
+ disabled = self._skill_manager.get_disabled()
+ if name in disabled:
+ disabled.discard(name)
+ self._skill_manager.set_disabled(disabled, persist=True)
+ self._refresh()
+ self.notify(f"已启用: {name}")
+ else:
+ self.notify(f"{name} 已经是启用状态", severity="information")
+
+ elif button_id == "skill-disable-btn":
+ # 禁用选中的 Skill
+ table = self.query_one("#skill-table", DataTable)
+ row_index = table.cursor_row
+ if row_index is None or row_index < 0:
+ self.notify("请先选择一个 Skill", severity="warning")
+ return
+
+ row_data = table.get_row_at(row_index)
+ if not row_data:
+ return
+
+ name = row_data[1]
+ disabled = self._skill_manager.get_disabled()
+ if name not in disabled:
+ disabled.add(name)
+ self._skill_manager.set_disabled(disabled, persist=True)
+ self._refresh()
+ self.notify(f"已禁用: {name}")
+ else:
+ self.notify(f"{name} 已经是禁用状态", severity="information")
+
elif button_id == "skill-enable-all-btn":
self._skill_manager.set_disabled(set(), persist=True)
self._refresh()
diff --git a/src/core/database_manager.py b/src/core/database_manager.py
index 61fcb5d..8cc4b4b 100644
--- a/src/core/database_manager.py
+++ b/src/core/database_manager.py
@@ -595,10 +595,10 @@ def get_disabled_skills(self) -> set[str]:
except json.JSONDecodeError, TypeError:
return set()
- def set_disabled_skills(self, *names: str) -> None:
+ def set_disabled_skills(self, names) -> None:
"""Set the disabled skill names.
Args:
- names: Set of skill names to disable
+ names: Collection of skill names to disable (set, list, or tuple)
"""
self.set_config("skills.disabled", json.dumps(sorted(set(names))), category="skill")
From 93bd44cda6d06b1303f6b65da2c3408815839f01 Mon Sep 17 00:00:00 2001
From: Suntion <149924916+SunYanbox@users.noreply.github.com>
Date: Thu, 7 May 2026 17:39:04 +0800
Subject: [PATCH 5/9] =?UTF-8?q?feat(utils):=20=E6=89=A9=E5=B1=95=E4=BA=8C?=
=?UTF-8?q?=E8=BF=9B=E5=88=B6=E6=96=87=E4=BB=B6=E6=A3=80=E6=B5=8B=E5=99=A8?=
=?UTF-8?q?=E4=BB=A5=E6=94=AF=E6=8C=81Godot=E9=A1=B9=E7=9B=AE=E6=A0=BC?=
=?UTF-8?q?=E5=BC=8F=20-=20=E6=96=B0=E5=A2=9E=E5=8A=9F=E8=83=BD:=20?=
=?UTF-8?q?=E5=9C=A8=E6=96=87=E4=BB=B6=E6=89=A9=E5=B1=95=E5=90=8D=E7=99=BD?=
=?UTF-8?q?=E5=90=8D=E5=8D=95=E4=B8=AD=E6=B7=BB=E5=8A=A0Godot=E7=9B=B8?=
=?UTF-8?q?=E5=85=B3=E5=90=8E=E7=BC=80=20=20=20*=20=E6=B7=BB=E5=8A=A0=20`.?=
=?UTF-8?q?godot`=20=E5=92=8C=20`.gd`=20=E6=94=AF=E6=8C=81=20=20=20*=20?=
=?UTF-8?q?=E6=B7=BB=E5=8A=A0=20`.gd.uid`=20=E5=92=8C=20`.tscn`=20?=
=?UTF-8?q?=E6=94=AF=E6=8C=81?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
src/utils/binary_detector.py | 5 +++++
1 file changed, 5 insertions(+)
diff --git a/src/utils/binary_detector.py b/src/utils/binary_detector.py
index cdbf2a8..420278a 100644
--- a/src/utils/binary_detector.py
+++ b/src/utils/binary_detector.py
@@ -83,6 +83,11 @@
".vbs", # VBScript
".reg", # Windows Registry
".desktop",
+ # Godot
+ ".godot",
+ ".gd",
+ ".gd.uid",
+ ".tscn",
}
)
From 0a77885c79c0f4e7c9694731e5ef9a6a65b33d17 Mon Sep 17 00:00:00 2001
From: Suntion <149924916+SunYanbox@users.noreply.github.com>
Date: Thu, 7 May 2026 17:46:45 +0800
Subject: [PATCH 6/9] =?UTF-8?q?feat(utils):=20=E6=89=A9=E5=B1=95=E4=BA=8C?=
=?UTF-8?q?=E8=BF=9B=E5=88=B6=E6=96=87=E4=BB=B6=E6=A3=80=E6=B5=8B=E5=90=8E?=
=?UTF-8?q?=E7=BC=80=E5=88=97=E8=A1=A8=20-=20=E6=96=B0=E5=A2=9E=E5=8A=9F?=
=?UTF-8?q?=E8=83=BD:=20=E5=9C=A8=20binary=5Fdetector.py=20=E7=9A=84=20FIL?=
=?UTF-8?q?E=5FEXTENSIONS=20=E9=9B=86=E5=90=88=E4=B8=AD=E5=A2=9E=E5=8A=A0?=
=?UTF-8?q?=E5=AF=B9=E7=89=B9=E5=AE=9A=E7=BC=96=E8=AF=91=E4=BA=A7=E7=89=A9?=
=?UTF-8?q?=E5=92=8C=E6=95=B0=E6=8D=AE=E5=BA=93=E6=96=87=E4=BB=B6=E7=9A=84?=
=?UTF-8?q?=E8=AF=86=E5=88=AB=20=20=20*=20=E6=B7=BB=E5=8A=A0=20.pdb=20(?=
=?UTF-8?q?=E7=A8=8B=E5=BA=8F=E6=95=B0=E6=8D=AE=E5=BA=93)=20=E5=90=8E?=
=?UTF-8?q?=E7=BC=80=20=20=20*=20=E6=B7=BB=E5=8A=A0=20.pyd=20(Python=20?=
=?UTF-8?q?=E5=8A=A8=E6=80=81=E9=93=BE=E6=8E=A5=E5=BA=93)=20=E5=90=8E?=
=?UTF-8?q?=E7=BC=80=20=20=20*=20=E6=B7=BB=E5=8A=A0=20.o=20(=E7=9B=AE?=
=?UTF-8?q?=E6=A0=87=E6=96=87=E4=BB=B6)=20=E5=90=8E=E7=BC=80?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
src/utils/binary_detector.py | 3 +++
1 file changed, 3 insertions(+)
diff --git a/src/utils/binary_detector.py b/src/utils/binary_detector.py
index 420278a..39af0f7 100644
--- a/src/utils/binary_detector.py
+++ b/src/utils/binary_detector.py
@@ -188,6 +188,9 @@
".db",
".sqlite",
".sqlite3",
+ ".pdb",
+ ".pyd",
+ ".o",
}
)
From 83563eeb178a3016fb952bf4aab26924c2e6e3d4 Mon Sep 17 00:00:00 2001
From: Suntion <149924916+SunYanbox@users.noreply.github.com>
Date: Thu, 7 May 2026 17:47:48 +0800
Subject: [PATCH 7/9] =?UTF-8?q?feat(workspace/tools):=20=E4=BC=98=E5=8C=96?=
=?UTF-8?q?=E6=96=87=E4=BB=B6=E6=90=9C=E7=B4=A2=E9=80=BB=E8=BE=91=E5=B9=B6?=
=?UTF-8?q?=E5=A2=9E=E5=8A=A0=E4=BA=8C=E8=BF=9B=E5=88=B6=E6=96=87=E4=BB=B6?=
=?UTF-8?q?=E8=BF=87=E6=BB=A4=20(#154)=20-=20=E6=96=B0=E5=A2=9E=E5=8A=9F?=
=?UTF-8?q?=E8=83=BD:=20=E9=9B=86=E6=88=90=E4=BA=8C=E8=BF=9B=E5=88=B6?=
=?UTF-8?q?=E6=96=87=E4=BB=B6=E6=A3=80=E6=B5=8B=E6=9C=BA=E5=88=B6=20=20=20?=
=?UTF-8?q?*=20=E5=9C=A8=20`regex=5Fsearch=5Ftool.py`=20=E5=92=8C=20`exact?=
=?UTF-8?q?=5Fsearch=5Ftool.py`=20=E4=B8=AD=E5=AF=BC=E5=85=A5=20`src.utils?=
=?UTF-8?q?.binary=5Fdetector.is=5Fbinary=5Ffile`=20=20=20*=20=E5=9C=A8?=
=?UTF-8?q?=E9=81=8D=E5=8E=86=E6=96=87=E4=BB=B6=E5=88=97=E8=A1=A8=E6=97=B6?=
=?UTF-8?q?=E5=A2=9E=E5=8A=A0=20`is=5Fbinary=5Ffile(file=5Fpath)`=20?=
=?UTF-8?q?=E5=88=A4=E6=96=AD=EF=BC=8C=E8=87=AA=E5=8A=A8=E8=B7=B3=E8=BF=87?=
=?UTF-8?q?=E4=BA=8C=E8=BF=9B=E5=88=B6=E6=96=87=E4=BB=B6=20-=20=E9=87=8D?=
=?UTF-8?q?=E6=9E=84=E4=BC=98=E5=8C=96:=20=E5=A2=9E=E5=BC=BA=E6=96=87?=
=?UTF-8?q?=E4=BB=B6=E8=B7=AF=E5=BE=84=E7=AD=9B=E9=80=89=E4=B8=8E=E6=8E=92?=
=?UTF-8?q?=E9=99=A4=E9=80=BB=E8=BE=91=20=20=20*=20=E4=BF=AE=E6=94=B9=20`f?=
=?UTF-8?q?iles=5Fto=5Fsearch`=20=E7=94=9F=E6=88=90=E9=80=BB=E8=BE=91?=
=?UTF-8?q?=EF=BC=8C=E5=B0=86=E7=AE=80=E5=8D=95=E7=9A=84=20`rglob`=20?=
=?UTF-8?q?=E8=BD=AC=E6=8D=A2=E4=B8=BA=E5=8C=85=E5=90=AB=E6=9D=A1=E4=BB=B6?=
=?UTF-8?q?=E8=BF=87=E6=BB=A4=E7=9A=84=E5=88=97=E8=A1=A8=E6=8E=A8=E5=AF=BC?=
=?UTF-8?q?=E5=BC=8F=20=20=20*=20=E5=BC=95=E5=85=A5=20`self.=5Fexclusion?=
=?UTF-8?q?=5Fmanager.should=5Fexclude=5Fpath(p)`=20=E6=96=B9=E6=B3=95?=
=?UTF-8?q?=EF=BC=8C=E6=94=AF=E6=8C=81=E8=87=AA=E5=AE=9A=E4=B9=89=E8=B7=AF?=
=?UTF-8?q?=E5=BE=84=E6=8E=92=E9=99=A4=E8=A7=84=E5=88=99=20=20=20*=20?=
=?UTF-8?q?=E7=A1=AE=E4=BF=9D=E4=BB=85=E5=A4=84=E7=90=86=20`p.is=5Ffile()`?=
=?UTF-8?q?=20=E4=B8=94=E6=9C=AA=E8=A2=AB=E6=8E=92=E9=99=A4=E7=9A=84?=
=?UTF-8?q?=E6=9C=89=E6=95=88=E6=96=87=E4=BB=B6?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
src/workspace/tools/exact_search_tool.py | 15 ++++++++++++++-
src/workspace/tools/regex_search_tool.py | 15 ++++++++++++++-
2 files changed, 28 insertions(+), 2 deletions(-)
diff --git a/src/workspace/tools/exact_search_tool.py b/src/workspace/tools/exact_search_tool.py
index 3e11fd1..8456b04 100644
--- a/src/workspace/tools/exact_search_tool.py
+++ b/src/workspace/tools/exact_search_tool.py
@@ -2,6 +2,7 @@
from pathlib import Path
from src.models.tools.tool_result import ToolResult
+from src.utils.binary_detector import is_binary_file
from src.workspace.tools.base_tool import BaseTool
from src.workspace.workspace import Workspace
@@ -117,12 +118,24 @@ def exact_search(
warnings = [""]
# 确定要搜索的文件列表(支持单文件或目录)
- files_to_search = [search_path] if search_path.is_file() else list(search_path.rglob(file_pattern))
+ files_to_search = (
+ [search_path]
+ if search_path.is_file()
+ else [
+ p
+ for p in search_path.rglob(file_pattern)
+ if p.is_file() and not self._exclusion_manager.should_exclude_path(p)
+ ]
+ )
# 遍历所有文件
for file_path in files_to_search:
if not file_path.is_file():
continue
+
+ if is_binary_file(file_path):
+ continue
+
# 检查是否达到限制
if total_matches >= limit:
break
diff --git a/src/workspace/tools/regex_search_tool.py b/src/workspace/tools/regex_search_tool.py
index b96cd57..7ec6fad 100644
--- a/src/workspace/tools/regex_search_tool.py
+++ b/src/workspace/tools/regex_search_tool.py
@@ -2,6 +2,7 @@
from pathlib import Path
from src.models.tools.tool_result import ToolResult
+from src.utils.binary_detector import is_binary_file
from src.workspace.tools.base_tool import BaseTool
from src.workspace.workspace import Workspace
@@ -144,12 +145,24 @@ def regex_search(
warnings = [""]
# 确定要搜索的文件列表(支持单文件或目录)
- files_to_search = [search_path] if search_path.is_file() else list(search_path.rglob(file_pattern))
+ files_to_search = (
+ [search_path]
+ if search_path.is_file()
+ else [
+ p
+ for p in search_path.rglob(file_pattern)
+ if p.is_file() and not self._exclusion_manager.should_exclude_path(p)
+ ]
+ )
# 遍历文件
for file_path in files_to_search:
if not file_path.is_file():
continue
+
+ if is_binary_file(file_path):
+ continue
+
# 检查是否达到限制
if total_matches >= limit:
break
From 520f2fc11360474fbb44fa09a26ddb2588d10e27 Mon Sep 17 00:00:00 2001
From: Suntion <149924916+SunYanbox@users.noreply.github.com>
Date: Thu, 7 May 2026 18:42:46 +0800
Subject: [PATCH 8/9] =?UTF-8?q?feat(console):=20=E6=B8=85=E7=90=86?=
=?UTF-8?q?=E6=89=A9=E5=B1=95=E9=92=A9=E5=AD=90=E4=BB=A5=E9=98=B2=E6=AD=A2?=
=?UTF-8?q?=E9=87=8D=E5=A4=8D=E6=B3=A8=E5=86=8C=20(#157)=20-=20=E6=96=B0?=
=?UTF-8?q?=E5=A2=9E=E5=8A=9F=E8=83=BD:=20=E5=AE=9E=E7=8E=B0=E6=89=A9?=
=?UTF-8?q?=E5=B1=95=E9=92=A9=E5=AD=90=E7=9A=84=E9=87=8D=E7=BD=AE=E6=9C=BA?=
=?UTF-8?q?=E5=88=B6=20=20=20*=20=E6=96=B0=E5=A2=9E=20`clear=5Fextension?=
=?UTF-8?q?=5Fhooks`=20=E5=87=BD=E6=95=B0=E7=94=A8=E4=BA=8E=E6=B8=85?=
=?UTF-8?q?=E7=A9=BA=E5=B7=B2=E6=B3=A8=E5=86=8C=E7=9A=84=E9=92=A9=E5=AD=90?=
=?UTF-8?q?=E5=88=97=E8=A1=A8=20=20=20*=20=E5=9C=A8=20`generate=5Fsystem?=
=?UTF-8?q?=5Fprompt`=20=E6=B5=81=E7=A8=8B=E6=9C=AB=E5=B0=BE=E8=B0=83?=
=?UTF-8?q?=E7=94=A8=20`clear=5Fextension=5Fhooks()`=20-=20=E9=87=8D?=
=?UTF-8?q?=E6=9E=84=E4=BC=98=E5=8C=96:=20=E5=B0=86=E6=89=A9=E5=B1=95?=
=?UTF-8?q?=E9=92=A9=E5=AD=90=E5=AD=98=E5=82=A8=E7=A7=81=E6=9C=89=E5=8C=96?=
=?UTF-8?q?=20=20=20*=20=E5=B0=86=E5=85=A8=E5=B1=80=E5=8F=98=E9=87=8F=20`E?=
=?UTF-8?q?XTENSION=5FHOOKS`=20=E9=87=8D=E5=91=BD=E5=90=8D=E4=B8=BA=20`=5F?=
=?UTF-8?q?=5FEXTENSION=5FHOOKS`=20=20=20*=20=E6=9B=B4=E6=96=B0=20`registe?=
=?UTF-8?q?r=5Fextension=5Fhook`=20=E5=92=8C=20`generate=5Fextensions=5Fse?=
=?UTF-8?q?ction`=20=E5=86=85=E9=83=A8=E9=80=BB=E8=BE=91=E4=BB=A5=E4=BD=BF?=
=?UTF-8?q?=E7=94=A8=E7=A7=81=E6=9C=89=E5=8F=98=E9=87=8F?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../commands/workspaces/workspace_cmd.py | 3 +++
src/constants/prompts.py | 17 +++++++++++++----
2 files changed, 16 insertions(+), 4 deletions(-)
diff --git a/src/console/commands/workspaces/workspace_cmd.py b/src/console/commands/workspaces/workspace_cmd.py
index 2272949..bb94736 100644
--- a/src/console/commands/workspaces/workspace_cmd.py
+++ b/src/console/commands/workspaces/workspace_cmd.py
@@ -8,6 +8,7 @@
SYSTEM_ROLE,
TOOL_RULES,
WORKFLOW_GUIDELINES,
+ clear_extension_hooks,
generate_extensions_section,
register_extension_hook,
)
@@ -198,6 +199,8 @@ def _assemble_full_prompt(context: CommandContext) -> str:
sections.append("")
sections.append("")
+ clear_extension_hooks()
+
return "\n".join(sections)
diff --git a/src/constants/prompts.py b/src/constants/prompts.py
index 4a8c4f0..e342577 100644
--- a/src/constants/prompts.py
+++ b/src/constants/prompts.py
@@ -73,7 +73,7 @@
# Extension hooks(Skills / MCP placeholders)
# ---------------------------------------------------------------------------
-EXTENSION_HOOKS: list[Callable[[], str]] = []
+__EXTENSION_HOOKS: list[Callable[[], str]] = []
def register_extension_hook(hook: Callable[[], str]) -> None:
@@ -81,15 +81,24 @@ def register_extension_hook(hook: Callable[[], str]) -> None:
For use by future Skills/MCP modules at import time.
"""
- EXTENSION_HOOKS.append(hook)
+ __EXTENSION_HOOKS.append(hook)
+
+
+def clear_extension_hooks() -> None:
+ """Clear all registered extension hooks.
+
+ Should be called after generating extensions section to prevent
+ duplicate registrations on subsequent command invocations.
+ """
+ __EXTENSION_HOOKS.clear()
def generate_extensions_section() -> str:
"""Run all registered extension hooks and emit their output inside ."""
- if not EXTENSION_HOOKS:
+ if not __EXTENSION_HOOKS:
return "\n \n"
parts = [""]
- for hook in EXTENSION_HOOKS:
+ for hook in __EXTENSION_HOOKS:
content = hook()
if content:
parts.append(content)
From df0c5b57588248d7b831b534bf7916934404d598 Mon Sep 17 00:00:00 2001
From: Suntion <149924916+SunYanbox@users.noreply.github.com>
Date: Thu, 7 May 2026 20:27:46 +0800
Subject: [PATCH 9/9] =?UTF-8?q?feat(console):=20=E6=96=B0=E5=A2=9E=20Shell?=
=?UTF-8?q?=20=E5=91=BD=E4=BB=A4=E5=AE=A1=E6=A0=B8=E4=B8=8E=E6=89=A7?=
=?UTF-8?q?=E8=A1=8C=E7=BB=93=E6=9E=9C=E6=9F=A5=E7=9C=8B=E5=8A=9F=E8=83=BD?=
=?UTF-8?q?=20(#156)=20-=20=E6=96=B0=E5=A2=9E=E5=8A=9F=E8=83=BD:=20?=
=?UTF-8?q?=E5=AE=9E=E7=8E=B0=20Shell=20=E5=91=BD=E4=BB=A4=E7=9A=84?=
=?UTF-8?q?=E6=8F=90=E4=BA=A4=E3=80=81=E5=AE=A1=E6=A0=B8=E5=8F=8A=E6=89=A7?=
=?UTF-8?q?=E8=A1=8C=E9=97=AD=E7=8E=AF=20=20=20*=20=E6=96=B0=E5=A2=9E=20`s?=
=?UTF-8?q?rc/workspace/tools/shell=5Ftool.py`=EF=BC=8C=E9=9B=86=E6=88=90?=
=?UTF-8?q?=20`ShellTool`=20=E7=B1=BB=EF=BC=8C=E8=B0=83=E7=94=A8=E6=97=B6?=
=?UTF-8?q?=E5=88=9B=E5=BB=BA=20`PENDING=5FAUDIT`=20=E7=8A=B6=E6=80=81?=
=?UTF-8?q?=E8=AE=B0=E5=BD=95=20=20=20*=20=E6=96=B0=E5=A2=9E=20`src/consol?=
=?UTF-8?q?e/ui/widgets/shell=5Fresult=5Ftab.py`=20(`ShellResultTab`)?=
=?UTF-8?q?=EF=BC=8C=E5=B1=95=E7=A4=BA=E5=B7=B2=E5=AE=8C=E6=88=90=E7=9A=84?=
=?UTF-8?q?=20Shell=20=E5=91=BD=E4=BB=A4=E5=8F=8A=E5=85=B6=E8=BE=93?=
=?UTF-8?q?=E5=87=BA=EF=BC=8C=E6=94=AF=E6=8C=81=E5=B1=95=E5=BC=80=E8=AF=A6?=
=?UTF-8?q?=E6=83=85=E5=92=8C=E4=B8=80=E9=94=AE=E5=A4=8D=E5=88=B6=20=20=20?=
=?UTF-8?q?*=20=E6=95=B0=E6=8D=AE=E5=BA=93=E5=B1=82=E9=9D=A2=E6=96=B0?=
=?UTF-8?q?=E5=A2=9E=20`shell=5Faudit`=20=E8=A1=A8=EF=BC=8C=E5=8C=85?=
=?UTF-8?q?=E5=90=AB=20`command`,=20`description`,=20`audit=5Fstatus`,=20`?=
=?UTF-8?q?output`,=20`exit=5Fcode`=20=E7=AD=89=E5=AD=97=E6=AE=B5=20=20=20?=
=?UTF-8?q?*=20`database=5Fmanager.py`=20=E5=A2=9E=E5=8A=A0=20`record=5Fsh?=
=?UTF-8?q?ell=5Fcommand`,=20`update=5Fshell=5Faudit`,=20`get=5Fshell=5Fpe?=
=?UTF-8?q?nding=5Faudits`,=20`get=5Fshell=5Fcompleted`=20=E7=AD=89?=
=?UTF-8?q?=E6=95=B0=E6=8D=AE=E8=AE=BF=E9=97=AE=E6=96=B9=E6=B3=95=20-=20?=
=?UTF-8?q?=E4=BF=AE=E5=A4=8D=E9=97=AE=E9=A2=98:=20=E5=AE=8C=E5=96=84?=
=?UTF-8?q?=E5=AE=A1=E6=A0=B8=E6=B5=81=E7=A8=8B=E4=BB=A5=E6=94=AF=E6=8C=81?=
=?UTF-8?q?=20Shell=20=E5=91=BD=E4=BB=A4=E6=93=8D=E4=BD=9C=20=20=20*=20`sr?=
=?UTF-8?q?c/core/audit=5Fcommitter.py`=20=E6=96=B0=E5=A2=9E=20`commit=5Fs?=
=?UTF-8?q?hell`=20=E6=96=B9=E6=B3=95=EF=BC=8C=E5=A4=84=E7=90=86=E6=89=B9?=
=?UTF-8?q?=E5=87=86=E5=90=8E=E7=9A=84=20`subprocess.run`=20=E6=89=A7?=
=?UTF-8?q?=E8=A1=8C=E9=80=BB=E8=BE=91=EF=BC=8C=E5=8C=85=E5=90=AB=E8=B6=85?=
=?UTF-8?q?=E6=97=B6=E6=8E=A7=E5=88=B6=20(300s)=20=E5=92=8C=E8=BE=93?=
=?UTF-8?q?=E5=87=BA=E6=88=AA=E6=96=AD=20(10000=20chars)=20=20=20*=20`src/?=
=?UTF-8?q?console/ui/widgets/audit=5Ftab.py`=20=E9=87=8D=E6=9E=84?=
=?UTF-8?q?=E6=B8=B2=E6=9F=93=E9=80=BB=E8=BE=91=EF=BC=8C=E5=9C=A8=E6=96=87?=
=?UTF-8?q?=E4=BB=B6=E5=8F=98=E6=9B=B4=E5=88=97=E8=A1=A8=E5=89=8D=E4=BC=98?=
=?UTF-8?q?=E5=85=88=E5=B1=95=E7=A4=BA=E5=BE=85=E5=AE=A1=E6=A0=B8=E7=9A=84?=
=?UTF-8?q?=20Shell=20=E5=91=BD=E4=BB=A4=EF=BC=8C=E6=B7=BB=E5=8A=A0=20"?=
=?UTF-8?q?=E6=89=B9=E5=87=86"=20=E5=92=8C=20"=E6=8B=92=E7=BB=9D"=20?=
=?UTF-8?q?=E6=8C=89=E9=92=AE=E5=A4=84=E7=90=86=E4=BA=8B=E4=BB=B6=20=20=20?=
=?UTF-8?q?*=20`tui=5Fconsole.py`=20=E5=92=8C=20`repl.py`=20=E5=AE=8C?=
=?UTF-8?q?=E6=88=90=20`ShellResultTab`=20=E7=9A=84=E6=B3=A8=E5=86=8C?=
=?UTF-8?q?=E3=80=81=E4=BE=9D=E8=B5=96=E6=B3=A8=E5=85=A5=E5=8F=8A=E5=88=B7?=
=?UTF-8?q?=E6=96=B0=E9=80=BB=E8=BE=91=E7=BB=91=E5=AE=9A?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
src/console/ui/repl.py | 3 +
src/console/ui/tui_console.py | 14 +-
src/console/ui/widgets/__init__.py | 2 +
src/console/ui/widgets/audit_tab.py | 114 +++++++++-----
src/console/ui/widgets/shell_result_tab.py | 173 +++++++++++++++++++++
src/core/audit_committer.py | 61 ++++++++
src/core/database_manager.py | 114 +++++++++++++-
src/core/tool_registry.py | 2 +
src/workspace/tools/shell_tool.py | 53 +++++++
9 files changed, 494 insertions(+), 42 deletions(-)
create mode 100644 src/console/ui/widgets/shell_result_tab.py
create mode 100644 src/workspace/tools/shell_tool.py
diff --git a/src/console/ui/repl.py b/src/console/ui/repl.py
index 75c1fae..a3900bc 100644
--- a/src/console/ui/repl.py
+++ b/src/console/ui/repl.py
@@ -220,6 +220,9 @@ def on_mount(self) -> None:
audit_committer = AuditCommitter(self.workspace)
tui_console.audit_tab.set_committer(audit_committer)
+ # 注入 Shell 结果标签页
+ tui_console.shell_result_tab.set_database(self.workspace.db)
+
# 注入统计标签页
tui_console.stats_tab.set_database(
self.workspace.db,
diff --git a/src/console/ui/tui_console.py b/src/console/ui/tui_console.py
index 5486e0e..282f9ce 100644
--- a/src/console/ui/tui_console.py
+++ b/src/console/ui/tui_console.py
@@ -10,6 +10,7 @@
from src.console.ui.widgets.audit_tab import AuditTab
from src.console.ui.widgets.settings_tab import SettingsTab
+from src.console.ui.widgets.shell_result_tab import ShellResultTab
from src.console.ui.widgets.stats_tab import StatsTab
@@ -20,8 +21,9 @@ class TuiConsole(Vertical):
- Tab 1 (RichLog): 用于普通富文本日志.
- Tab 2 (Tool Calls): 用于显示工具调用情况.
- Tab 3 (Audit): 用于审核待处理的写入/编辑操作.
- - Tab 4 (Statistics): 用于查看会话统计与工具使用排名.
- - Tab 5 (Settings): 用于配置环境变量和 Skill.
+ - Tab 4 (Shell Results): 用于查看已执行的 Shell 命令输出.
+ - Tab 5 (Statistics): 用于查看会话统计与工具使用排名.
+ - Tab 6 (Settings): 用于配置环境变量和 Skill.
"""
DEFAULT_CSS = """
@@ -62,6 +64,8 @@ def compose(self):
yield Vertical(id="tui-console-tool-calls")
with TabPane("Audit", id="tab-audit"):
yield AuditTab()
+ with TabPane("Shell Results", id="tab-shell-results"):
+ yield ShellResultTab()
with TabPane("Statistics", id="tab-stats"):
yield StatsTab()
with TabPane("Settings", id="tab-settings"):
@@ -79,6 +83,10 @@ def tool_calls_container(self) -> Vertical:
def audit_tab(self) -> AuditTab:
return self.query_one(AuditTab)
+ @property
+ def shell_result_tab(self) -> ShellResultTab:
+ return self.query_one(ShellResultTab)
+
@property
def stats_tab(self) -> StatsTab:
return self.query_one(StatsTab)
@@ -91,6 +99,8 @@ async def on_tabbed_content_tab_activated(self, event: TabbedContent.TabActivate
"""切换标签页时刷新内容."""
if event.pane.id == "tab-audit":
await self.audit_tab._refresh()
+ elif event.pane.id == "tab-shell-results":
+ await self.shell_result_tab._refresh()
elif event.pane.id == "tab-stats":
await self.stats_tab._refresh()
elif event.pane.id == "tab-settings":
diff --git a/src/console/ui/widgets/__init__.py b/src/console/ui/widgets/__init__.py
index afa8218..28c73c8 100644
--- a/src/console/ui/widgets/__init__.py
+++ b/src/console/ui/widgets/__init__.py
@@ -3,6 +3,7 @@
from src.console.ui.widgets.audit_tab import AuditTab
from src.console.ui.widgets.env_config_tab import EnvConfigTab
from src.console.ui.widgets.settings_tab import SettingsTab
+from src.console.ui.widgets.shell_result_tab import ShellResultTab
from src.console.ui.widgets.skill_config_tab import SkillConfigTab
from src.console.ui.widgets.stats_tab import StatsTab
@@ -10,6 +11,7 @@
"AuditTab",
"EnvConfigTab",
"SettingsTab",
+ "ShellResultTab",
"SkillConfigTab",
"StatsTab",
]
diff --git a/src/console/ui/widgets/audit_tab.py b/src/console/ui/widgets/audit_tab.py
index 3233a89..c955b60 100644
--- a/src/console/ui/widgets/audit_tab.py
+++ b/src/console/ui/widgets/audit_tab.py
@@ -81,6 +81,13 @@ class AuditTab(Vertical):
padding: 0 1;
margin-bottom: 1;
}
+
+ .audit-section-label {
+ height: auto;
+ padding: 0 0 0 0;
+ text-style: bold;
+ color: $text;
+ }
"""
def __init__(self) -> None:
@@ -108,55 +115,80 @@ async def _refresh(self) -> None:
await self.remove_children()
- pending = self._committer.workspace.db.get_snapshots_by_audit_status("PENDING_AUDIT")
+ # Result area for showing commit results
+ result_log = Vertical(id="audit-result-log")
+ await self.mount(result_log)
+
+ pending_files = self._committer.workspace.db.get_snapshots_by_audit_status("PENDING_AUDIT")
+ pending_shells = self._committer.workspace.db.get_shell_pending_audits()
- if not pending:
+ total_items = len(pending_files) + len(pending_shells)
+
+ if total_items == 0:
await self.mount(Label("没有待审核的更改.", id="audit-empty"))
return
- # Group by file_path
- grouped: defaultdict[str, list[tuple]] = defaultdict(list)
- for snap in pending:
- grouped[snap[1]].append(snap)
-
- # Result area for showing commit results
- result_log = Vertical(id="audit-result-log")
- await self.mount(result_log)
+ # Group file snapshots by file_path
+ file_grouped: defaultdict[str, list[tuple]] = defaultdict(list)
+ for snap in pending_files:
+ file_grouped[snap[1]].append(snap)
header = Label(
- f"待审核更改 ({sum(len(snaps) for snaps in grouped.values())} 项)",
+ f"待审核更改 ({total_items} 项)",
id="audit-header",
)
await self.mount(header)
- for file_path in sorted(grouped):
- snaps = grouped[file_path]
- # Collect all children for all snaps of this file
- all_snap_widgets: list[Static | Horizontal] = []
- for snap in snaps:
- snap_id = snap[0]
- diff_content = snap[4] or "(空 diff)"
+ # Render pending shell commands first
+ if pending_shells:
+ shell_children: list[Label | Collapsible] = [Label("Shell 命令:", classes="audit-section-label")]
+ for shell in pending_shells:
+ shell_id, command, description, _ts, _sid, _status = shell
+ preview = f"$ {command}"
+ if description:
+ preview += f"\n # {description}"
- diff_container = Vertical(Static(diff_content, markup=False), classes="audit-diff")
+ cmd_display = Static(preview, markup=False, classes="audit-diff")
btn_row = Horizontal(
- Button("批准", variant="primary", id=f"approve-{snap_id}", classes="audit-approve"),
- Button("拒绝", variant="error", id=f"reject-{snap_id}", classes="audit-reject"),
+ Button("批准", variant="primary", id=f"shell_approve-{shell_id}", classes="audit-approve"),
+ Button("拒绝", variant="error", id=f"shell_reject-{shell_id}", classes="audit-reject"),
classes="audit-buttons",
)
- all_snap_widgets.append(diff_container)
- all_snap_widgets.append(btn_row)
-
- content_widgets = Vertical(*all_snap_widgets)
-
- collapsible = Collapsible(
- content_widgets,
- title=f"{file_path} ({len(snaps)} 次更改)",
- classes="audit-collapsible",
- )
- # Collapse excess items initially — keep first 3 expanded
- if len(self.children) > 6: # header + result_log + 3 expanded
- collapsible.collapsed = True
- await self.mount(collapsible)
+ collapsible = Collapsible(
+ Vertical(cmd_display, btn_row),
+ title=f"Shell #{shell_id}: {command.strip()[:60]}{'...' if len(command.strip()) > 60 else ''}",
+ classes="audit-collapsible",
+ )
+ shell_children.append(collapsible)
+ await self.mount(Vertical(*shell_children))
+
+ # Render file snapshot changes
+ if pending_files:
+ for file_path in sorted(file_grouped):
+ snaps = file_grouped[file_path]
+ all_snap_widgets: list[Static | Horizontal] = []
+ for snap in snaps:
+ snap_id = snap[0]
+ diff_content = snap[4] or "(空 diff)"
+
+ diff_container = Vertical(Static(diff_content, markup=False), classes="audit-diff")
+ btn_row = Horizontal(
+ Button("批准", variant="primary", id=f"approve-{snap_id}", classes="audit-approve"),
+ Button("拒绝", variant="error", id=f"reject-{snap_id}", classes="audit-reject"),
+ classes="audit-buttons",
+ )
+ all_snap_widgets.append(diff_container)
+ all_snap_widgets.append(btn_row)
+
+ content_widgets = Vertical(*all_snap_widgets)
+ collapsible = Collapsible(
+ content_widgets,
+ title=f"{file_path} ({len(snaps)} 次更改)",
+ classes="audit-collapsible",
+ )
+ if len(self.children) > 6:
+ collapsible.collapsed = True
+ await self.mount(collapsible)
async def on_button_pressed(self, event: Button.Pressed) -> None:
"""处理批准/拒绝按钮点击."""
@@ -169,16 +201,20 @@ async def on_button_pressed(self, event: Button.Pressed) -> None:
if len(parts) != 2:
return
- action, snap_id_str = parts
+ action, id_str = parts
try:
- snapshot_id = int(snap_id_str)
+ item_id = int(id_str)
except ValueError:
return
if action == "approve":
- result = self._committer.commit(snapshot_id, approved=True)
+ result = self._committer.commit(item_id, approved=True)
elif action == "reject":
- result = self._committer.commit(snapshot_id, approved=False)
+ result = self._committer.commit(item_id, approved=False)
+ elif action == "shell_approve":
+ result = self._committer.commit_shell(item_id, approved=True)
+ elif action == "shell_reject":
+ result = self._committer.commit_shell(item_id, approved=False)
else:
return
diff --git a/src/console/ui/widgets/shell_result_tab.py b/src/console/ui/widgets/shell_result_tab.py
new file mode 100644
index 0000000..5986d37
--- /dev/null
+++ b/src/console/ui/widgets/shell_result_tab.py
@@ -0,0 +1,173 @@
+"""Shell 命令结果标签页 — 查看/复制已执行的 Shell 命令输出."""
+
+from __future__ import annotations
+
+import datetime
+from typing import ClassVar
+
+from textual.containers import Horizontal, Vertical
+from textual.widgets import Button, Collapsible, Label, Static
+
+from src.core.copy2clip import copy_to_clipboard
+
+
+class ShellResultTab(Vertical):
+ """Shell 命令执行结果标签页.
+
+ 展示所有已完成(已批准/已拒绝)的 Shell 命令及其输出,
+ 支持展开查看详细输出并复制.
+ """
+
+ DEFAULT_CSS: ClassVar[str] = """
+ ShellResultTab {
+ height: 1fr;
+ width: 1fr;
+ padding: 0 1;
+ overflow-y: auto;
+ }
+
+ #shell-result-placeholder {
+ height: 100%;
+ content-align: center middle;
+ color: $text-muted;
+ }
+
+ #shell-result-empty {
+ height: 100%;
+ content-align: center middle;
+ color: $text-muted;
+ }
+
+ #shell-result-header {
+ height: auto;
+ padding: 1 0;
+ text-style: bold;
+ color: $text;
+ }
+
+ .shell-collapsible {
+ height: auto;
+ margin-bottom: 1;
+ }
+
+ .shell-output-container {
+ max-height: 20;
+ overflow-y: auto;
+ padding: 1;
+ background: $surface;
+ border: solid $primary;
+ margin-bottom: 1;
+ }
+
+ .shell-button-row {
+ height: auto;
+ align: left middle;
+ margin-bottom: 1;
+ }
+ """
+
+ def __init__(self) -> None:
+ super().__init__()
+ self._db = None
+
+ def compose(self):
+ yield Label("正在加载...", id="shell-result-placeholder")
+
+ def set_database(self, db) -> None:
+ """设置数据库引用并刷新."""
+ self._db = db
+ self.set_timer(0.0, self._refresh)
+
+ def on_mount(self) -> None:
+ if self._db is not None:
+ self.set_timer(0.1, self._refresh)
+
+ async def _refresh(self) -> None:
+ """查询已完成的 Shell 命令并重建 UI."""
+ if self._db is None:
+ return
+
+ await self.remove_children()
+
+ shells = self._db.get_shell_completed()
+
+ if not shells:
+ await self.mount(Label("暂无已执行的 Shell 命令.", id="shell-result-empty"))
+ return
+
+ header = Label(f"Shell 命令执行记录 ({len(shells)} 项)", id="shell-result-header")
+ await self.mount(header)
+
+ for i, shell in enumerate(shells):
+ (
+ shell_id,
+ command,
+ description,
+ _ts,
+ _sid,
+ audit_status,
+ output,
+ exit_code,
+ executed_at,
+ ) = shell
+
+ is_approved = audit_status == "APPROVED"
+ status_icon = "✓" if is_approved else "✗"
+ status_color = "green" if is_approved else "red"
+
+ # Build detailed content
+ lines: list[str] = [
+ f"[bold]Command:[/bold] $ {command}",
+ f"[bold]Status:[/bold] [{status_color}]{status_icon} {audit_status}[/{status_color}]",
+ ]
+ if description:
+ lines.append(f"[bold]Description:[/bold] {description}")
+ if exit_code is not None:
+ lines.append(f"[bold]Exit Code:[/bold] {exit_code}")
+ if executed_at:
+ dt_str = datetime.datetime.fromtimestamp(executed_at).strftime("%Y-%m-%d %H:%M:%S")
+ lines.append(f"[bold]Executed At:[/bold] {dt_str}")
+ if output:
+ lines.append(f"\n[bold]Output:[/bold]\n{output}")
+
+ content = "\n".join(lines)
+
+ output_text = Static(content, markup=True)
+ output_container = Vertical(output_text, classes="shell-output-container")
+ copy_btn = Button("复制输出", id=f"shell_copy-{shell_id}")
+ btn_row = Horizontal(copy_btn, classes="shell-button-row")
+
+ # First items expanded by default, rest collapsed
+ collapsed = i > 3
+ collapsible = Collapsible(
+ Vertical(output_container, btn_row),
+ title=f"[{status_color}]{status_icon}[/{status_color}] "
+ f"Shell #{shell_id}: {command.strip()[:60]}{'...' if len(command.strip()) > 60 else ''}",
+ classes="shell-collapsible",
+ collapsed=collapsed,
+ )
+ await self.mount(collapsible)
+
+ async def on_button_pressed(self, event: Button.Pressed) -> None:
+ """处理复制按钮点击."""
+ button_id = event.button.id or ""
+ if not button_id.startswith("shell_copy-"):
+ return
+
+ try:
+ shell_id = int(button_id.split("-", 1)[1])
+ except ValueError, IndexError:
+ return
+
+ if self._db is None:
+ return
+
+ shells = self._db.get_shell_completed()
+ for shell in shells:
+ if shell[0] == shell_id:
+ output = shell[6] or "(空输出)"
+ copy_to_clipboard(output)
+ self.notify("输出已复制到剪贴板", timeout=3)
+ return
+
+ self.notify("未找到对应记录", severity="error", timeout=3)
diff --git a/src/core/audit_committer.py b/src/core/audit_committer.py
index 5e8b916..e89550a 100644
--- a/src/core/audit_committer.py
+++ b/src/core/audit_committer.py
@@ -1,3 +1,4 @@
+import subprocess
from pathlib import Path
from src.utils.binary_detector import is_binary_file
@@ -90,3 +91,63 @@ def commit(self, snapshot_id: int, approved: bool = True) -> str:
except Exception as e:
return f"写入失败: {e.__class__.__name__}({e})"
+
+ def commit_shell(self, shell_id: int, approved: bool = True) -> str:
+ """审核 Shell 命令: 通过后执行, 拒绝则忽略.
+
+ Args:
+ shell_id: Shell 审核记录 ID
+ approved: True=批准执行, False=拒绝
+
+ Returns:
+ 操作结果消息
+ """
+ db = self.workspace.db
+
+ shell = db.get_shell_by_id(shell_id)
+ if shell is None:
+ return f"Shell 命令不存在: {shell_id}"
+
+ (_id, command, _, _ts, _session_id, audit_status, _output, _exit_code, _executed_at) = shell
+
+ if audit_status != "PENDING_AUDIT":
+ return f"Shell 命令已处理 (当前状态: {audit_status})"
+
+ if not approved:
+ db.update_shell_audit(shell_id, "REJECTED")
+ return f"已拒绝 Shell 命令 (id={shell_id})"
+
+ # 批准 -- 执行命令
+ try:
+ result = subprocess.run(
+ command,
+ capture_output=True,
+ text=True,
+ timeout=300,
+ cwd=str(self.workspace.root_path),
+ shell=True,
+ )
+
+ output_parts = []
+ if result.stdout:
+ output_parts.append(result.stdout.rstrip("\n"))
+ if result.stderr:
+ output_parts.append(result.stderr.rstrip("\n"))
+
+ output = "\n".join(output_parts) if output_parts else "(no output)"
+ exit_code = result.returncode
+
+ # 截断超长输出防止 DB 膨胀
+ if len(output) > 10000:
+ output = output[:10000] + "\n... (output truncated at 10000 chars)"
+
+ db.update_shell_audit(shell_id, "APPROVED", output=output, exit_code=exit_code)
+
+ return f"已批准并执行 Shell 命令 (id={shell_id})\nExit code: {exit_code}\nOutput:\n{output}"
+
+ except subprocess.TimeoutExpired:
+ db.update_shell_audit(shell_id, "APPROVED", output="TIMEOUT: 命令执行超时(300s)", exit_code=-1)
+ return f"Shell 命令执行超时 (id={shell_id})"
+ except Exception as e:
+ db.update_shell_audit(shell_id, "APPROVED", output=f"ERROR: {e}", exit_code=-1)
+ return f"Shell 命令执行失败: {e}"
diff --git a/src/core/database_manager.py b/src/core/database_manager.py
index 8cc4b4b..64b2fe6 100644
--- a/src/core/database_manager.py
+++ b/src/core/database_manager.py
@@ -122,6 +122,19 @@ def _init_tables(self) -> None:
category TEXT NOT NULL DEFAULT 'general',
updated_at REAL NOT NULL
);
+
+ CREATE TABLE IF NOT EXISTS shell_audit (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ command TEXT NOT NULL,
+ description TEXT NOT NULL DEFAULT '',
+ timestamp REAL NOT NULL,
+ session_id INTEGER,
+ audit_status TEXT NOT NULL DEFAULT 'PENDING_AUDIT',
+ output TEXT NOT NULL DEFAULT '',
+ exit_code INTEGER,
+ executed_at REAL,
+ FOREIGN KEY (session_id) REFERENCES sessions(id)
+ );
"""
)
@@ -162,6 +175,8 @@ def _init_tables(self) -> None:
"CREATE INDEX IF NOT EXISTS idx_file_read_records_session_path ON file_read_records(session_id, file_path)"
)
conn.execute("CREATE INDEX IF NOT EXISTS idx_tool_call_summaries_session ON tool_call_summaries(session_id)")
+ conn.execute("CREATE INDEX IF NOT EXISTS idx_shell_audit_status ON shell_audit(audit_status)")
+ conn.execute("CREATE INDEX IF NOT EXISTS idx_shell_audit_session ON shell_audit(session_id)")
@staticmethod
def _migrate_add_pending_content(conn: sqlite3.Connection) -> None:
@@ -278,7 +293,7 @@ def is_session_orphaned(self, session_id: int) -> bool:
检查一个会话是否在任何相关表中都没有关联数据
"""
- tables = ["tool_calls", "file_read_records", "file_snapshots", "tool_call_summaries"]
+ tables = ["tool_calls", "file_read_records", "file_snapshots", "tool_call_summaries", "shell_audit"]
for table in tables:
row = self.fetchone(
f"SELECT COUNT(*) FROM {table} WHERE session_id = ?",
@@ -404,6 +419,102 @@ def get_snapshots_by_audit_status(self, status: str) -> list[tuple]:
(status,),
)
+ # -- Shell command audit --
+
+ def record_shell_command(
+ self,
+ command: str,
+ description: str = "",
+ session_id: int | None = None,
+ ) -> int:
+ """记录一条待审核的 Shell 命令.
+
+ Args:
+ command: Shell 命令内容
+ description: 命令描述
+ session_id: 会话 ID
+
+ Returns:
+ 新记录的 ID
+ """
+ cursor = self.execute(
+ "INSERT INTO shell_audit (command, description, timestamp, session_id, audit_status) "
+ "VALUES (?, ?, ?, ?, 'PENDING_AUDIT')",
+ (command, description, time.time(), session_id),
+ )
+ return cursor.lastrowid
+
+ def update_shell_audit(
+ self,
+ shell_id: int,
+ audit_status: str,
+ output: str = "",
+ exit_code: int | None = None,
+ ) -> None:
+ """更新 Shell 命令审核状态及执行结果.
+
+ Args:
+ shell_id: 记录 ID
+ audit_status: 审核状态 (APPROVED/REJECTED)
+ output: 命令执行输出
+ exit_code: 命令退出码
+ """
+ if output or exit_code is not None:
+ self.execute(
+ "UPDATE shell_audit SET audit_status = ?, output = ?, exit_code = ?, executed_at = ? WHERE id = ?",
+ (audit_status, output, exit_code, time.time(), shell_id),
+ )
+ else:
+ self.execute(
+ "UPDATE shell_audit SET audit_status = ? WHERE id = ?",
+ (audit_status, shell_id),
+ )
+
+ def get_shell_pending_audits(self) -> list[tuple]:
+ """获取所有待审核的 Shell 命令.
+
+ Returns:
+ 待审核记录列表 (id, command, description, timestamp, session_id, audit_status)
+ """
+ return self.fetchall(
+ "SELECT id, command, description, timestamp, session_id, audit_status"
+ " FROM shell_audit WHERE audit_status = 'PENDING_AUDIT'"
+ )
+
+ def get_shell_by_id(self, shell_id: int) -> tuple | None:
+ """根据 ID 获取 Shell 命令审核记录.
+
+ Args:
+ shell_id: 记录 ID
+
+ Returns:
+ 记录元组或 None
+ """
+ return self.fetchone(
+ "SELECT id, command, description, timestamp, session_id,"
+ " audit_status, output, exit_code, executed_at"
+ " FROM shell_audit WHERE id = ?",
+ (shell_id,),
+ )
+
+ def get_shell_completed(self, limit: int = 200) -> list[tuple]:
+ """获取所有已完成的 Shell 命令(已批准/已拒绝),按执行时间倒序.
+
+ Args:
+ limit: 最大返回条数
+
+ Returns:
+ 已完成记录列表, 每条含 (id, command, description, timestamp,
+ session_id, audit_status, output, exit_code, executed_at)
+ """
+ return self.fetchall(
+ "SELECT id, command, description, timestamp, session_id,"
+ " audit_status, output, exit_code, executed_at"
+ " FROM shell_audit WHERE audit_status != 'PENDING_AUDIT'"
+ " ORDER BY COALESCE(executed_at, timestamp) DESC LIMIT ?",
+ (limit,),
+ )
+
# -- Session statistics and management --
def get_session_summary(self, session_id: int) -> dict:
@@ -449,6 +560,7 @@ def delete_session(self, session_id: int) -> None:
conn.execute("DELETE FROM tool_calls WHERE session_id = ?", (session_id,))
conn.execute("DELETE FROM file_snapshots WHERE session_id = ?", (session_id,))
conn.execute("DELETE FROM file_read_records WHERE session_id = ?", (session_id,))
+ conn.execute("DELETE FROM shell_audit WHERE session_id = ?", (session_id,))
conn.execute("DELETE FROM sessions WHERE id = ?", (session_id,))
conn.execute("COMMIT")
except Exception:
diff --git a/src/core/tool_registry.py b/src/core/tool_registry.py
index cb99426..aed8639 100644
--- a/src/core/tool_registry.py
+++ b/src/core/tool_registry.py
@@ -85,6 +85,7 @@ def register(self, workspace: Workspace) -> None:
from src.workspace.tools.ls_tool import LsTool
from src.workspace.tools.read_tool import ReadTool
from src.workspace.tools.regex_search_tool import RegexSearchTool
+ from src.workspace.tools.shell_tool import ShellTool
from src.workspace.tools.skill_tool import SkillTool
from src.workspace.tools.stat_tool import StatTool
from src.workspace.tools.symbol_ref_tool import SymbolRefTool
@@ -98,6 +99,7 @@ def register(self, workspace: Workspace) -> None:
LsTool,
ReadTool,
RegexSearchTool,
+ ShellTool,
WriteTool,
StatTool,
SymbolRefTool,
diff --git a/src/workspace/tools/shell_tool.py b/src/workspace/tools/shell_tool.py
new file mode 100644
index 0000000..e2bcfdb
--- /dev/null
+++ b/src/workspace/tools/shell_tool.py
@@ -0,0 +1,53 @@
+"""Shell 命令执行工具 — 审核通过后执行."""
+
+from src.models.tools.tool_result import ToolResult
+from src.workspace.tools.base_tool import BaseTool
+from src.workspace.workspace import Workspace
+
+
+class ShellTool(BaseTool):
+ """Shell 命令执行工具.
+
+ 所有 Shell 命令均需经过审核机制:
+ 1. 调用时创建 PENDING_AUDIT 记录, 不立即执行
+ 2. 审核 UI 中展示待审核命令
+ 3. 审核通过后实际执行命令并记录输出
+ """
+
+ def __init__(self, workspace: Workspace):
+ super().__init__(workspace, "shell", self.shell.__doc__, write_permission=True)
+ self.func = self.shell
+ self.params = BaseTool.extract_params(self.shell)
+ self.param_descriptions = {
+ "command": "要执行的 Shell 命令",
+ "description": "命令描述, 说明命令的目的和作用, 帮助审核人员理解命令意图",
+ }
+
+ @BaseTool.handle_tool_exceptions
+ def shell(self, command: str, description: str = "") -> ToolResult:
+ """
+ 执行 Shell 命令(需审核通过)
+ """
+ if not command or not command.strip():
+ return self.make_failed_response(kwargs=locals().copy(), error=str(ValueError("command 不能为空")))
+
+ session_id = self.workspace.session_id
+ shell_id = self.workspace.db.record_shell_command(
+ command=command.strip(),
+ description=description.strip(),
+ session_id=session_id,
+ )
+
+ preview_parts = [
+ "命令已提交审核系统, 审核通过后将自动执行",
+ "",
+ f"[Shell Preview] ID: {shell_id}",
+ f"Command: {command.strip()}",
+ ]
+ if description.strip():
+ preview_parts.append(f"Description: {description.strip()}")
+
+ return self.make_success_response(
+ kwargs=locals().copy(),
+ data="\n".join(preview_parts),
+ )