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), + )