mosscode 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- mosscode/__init__.py +10 -0
- mosscode/__main__.py +3 -0
- mosscode/agent.py +486 -0
- mosscode/agents/__init__.py +249 -0
- mosscode/agents/builtin/explore.md +16 -0
- mosscode/agents/builtin/general.md +12 -0
- mosscode/agents/builtin/plan.md +16 -0
- mosscode/agents/runtime.py +685 -0
- mosscode/agents/tasks.py +193 -0
- mosscode/appearance.py +184 -0
- mosscode/builtin_commands.py +1363 -0
- mosscode/cli.py +547 -0
- mosscode/commands.py +254 -0
- mosscode/compaction.py +259 -0
- mosscode/config.py +337 -0
- mosscode/context.py +102 -0
- mosscode/conversation.py +1046 -0
- mosscode/coordinator.py +54 -0
- mosscode/doctor.py +303 -0
- mosscode/hooks/__init__.py +22 -0
- mosscode/hooks/actions.py +151 -0
- mosscode/hooks/conditions.py +170 -0
- mosscode/hooks/config.py +404 -0
- mosscode/hooks/engine.py +401 -0
- mosscode/hooks/events.py +48 -0
- mosscode/launcher.py +177 -0
- mosscode/mcp_client.py +460 -0
- mosscode/mcp_config.py +216 -0
- mosscode/member_ui.py +289 -0
- mosscode/memory.py +291 -0
- mosscode/memory_extract.py +245 -0
- mosscode/messages.py +216 -0
- mosscode/paths.py +31 -0
- mosscode/permissions.py +733 -0
- mosscode/prompts.py +483 -0
- mosscode/providers.py +587 -0
- mosscode/scrollback.py +106 -0
- mosscode/security.py +282 -0
- mosscode/serialization.py +121 -0
- mosscode/session.py +563 -0
- mosscode/setup.py +248 -0
- mosscode/skills/__init__.py +455 -0
- mosscode/skills/builtin/commit/SKILL.md +15 -0
- mosscode/skills/builtin/review/SKILL.md +15 -0
- mosscode/skills/builtin/test/SKILL.md +13 -0
- mosscode/skills/builtin/test/scripts/detect.py +96 -0
- mosscode/skills/builtin/test/tool.json +9 -0
- mosscode/teams/__init__.py +216 -0
- mosscode/teams/backends.py +259 -0
- mosscode/teams/board.py +230 -0
- mosscode/teams/lock.py +173 -0
- mosscode/teams/mailbox.py +172 -0
- mosscode/teams/member.py +427 -0
- mosscode/teams/merge.py +207 -0
- mosscode/teams/pane.py +20 -0
- mosscode/teams/pane_app.py +167 -0
- mosscode/teams/process.py +371 -0
- mosscode/teams/runtime.py +1355 -0
- mosscode/teams/view.py +182 -0
- mosscode/tool_budget.py +86 -0
- mosscode/tools/__init__.py +38 -0
- mosscode/tools/agent.py +127 -0
- mosscode/tools/base.py +384 -0
- mosscode/tools/diffing.py +16 -0
- mosscode/tools/execution.py +99 -0
- mosscode/tools/files.py +287 -0
- mosscode/tools/memory.py +65 -0
- mosscode/tools/plan.py +30 -0
- mosscode/tools/process.py +216 -0
- mosscode/tools/search.py +229 -0
- mosscode/tools/search_worker.py +243 -0
- mosscode/tools/shell.py +121 -0
- mosscode/tools/skill.py +118 -0
- mosscode/tools/team.py +447 -0
- mosscode/tools/windows_job.py +75 -0
- mosscode/tui.py +2325 -0
- mosscode/usage.py +68 -0
- mosscode/windows_inline.py +148 -0
- mosscode/worktree/__init__.py +29 -0
- mosscode/worktree/changes.py +59 -0
- mosscode/worktree/git.py +125 -0
- mosscode/worktree/manager.py +462 -0
- mosscode/worktree/meta.py +82 -0
- mosscode/worktree/names.py +52 -0
- mosscode/worktree/setup.py +119 -0
- mosscode-0.1.0.dist-info/METADATA +92 -0
- mosscode-0.1.0.dist-info/RECORD +91 -0
- mosscode-0.1.0.dist-info/WHEEL +5 -0
- mosscode-0.1.0.dist-info/entry_points.txt +3 -0
- mosscode-0.1.0.dist-info/licenses/LICENSE +21 -0
- mosscode-0.1.0.dist-info/top_level.txt +1 -0
mosscode/tools/search.py
ADDED
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import shutil
|
|
3
|
+
import sys
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Literal
|
|
6
|
+
|
|
7
|
+
from pydantic import Field
|
|
8
|
+
|
|
9
|
+
from .base import Params, Tool, ToolFailure
|
|
10
|
+
from .process import run_process
|
|
11
|
+
|
|
12
|
+
SKIP_DIRS = (".git", ".venv", "venv", "node_modules", "__pycache__")
|
|
13
|
+
WORKTREES = (".mosscode", "worktrees") # Skipped unless the search starts inside one.
|
|
14
|
+
GLOB_LIMIT = 200
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class GlobParams(Params):
|
|
18
|
+
pattern: str = Field(min_length=1)
|
|
19
|
+
path: str = "."
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class GrepParams(Params):
|
|
23
|
+
pattern: str
|
|
24
|
+
path: str = "."
|
|
25
|
+
glob: str | None = None
|
|
26
|
+
output_mode: Literal["files_with_matches", "content", "count"] = "files_with_matches"
|
|
27
|
+
ignore_case: bool = False
|
|
28
|
+
context: int = Field(default=0, ge=0, le=10)
|
|
29
|
+
head_limit: int = Field(default=200, ge=1, le=2000)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def rg_command():
|
|
33
|
+
executable = shutil.which("rg")
|
|
34
|
+
if executable is None:
|
|
35
|
+
raise ToolFailure("search_unavailable", "搜索工具需要 ripgrep(rg),请安装并加入 PATH。")
|
|
36
|
+
# .gitignore applies even outside a git repository, matching the fallback.
|
|
37
|
+
return [executable, "--hidden", "--no-require-git"]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def fallback_command(mode, arguments):
|
|
41
|
+
return [sys.executable, "-m", "mosscode.tools.search_worker", mode, json.dumps(arguments, ensure_ascii=False)]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def stderr_hint(stderr: str) -> str:
|
|
45
|
+
text = stderr.strip()[:500]
|
|
46
|
+
return f"\n{text}" if text else ""
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def inside_worktrees(path: Path) -> bool:
|
|
50
|
+
parts = path.parts
|
|
51
|
+
return any(parts[i : i + 2] == WORKTREES for i in range(len(parts) - 1))
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def skip_globs(path: Path | None = None):
|
|
55
|
+
# Later rg globs override earlier ones, so exclusions follow user filters.
|
|
56
|
+
globs = [arg for name in SKIP_DIRS for arg in ("-g", f"!**/{name}/**")]
|
|
57
|
+
if path is None or not inside_worktrees(path):
|
|
58
|
+
globs.extend(("-g", "!**/.mosscode/worktrees/**"))
|
|
59
|
+
return globs
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def newest_first(paths):
|
|
63
|
+
def mtime(path):
|
|
64
|
+
try:
|
|
65
|
+
return path.stat().st_mtime_ns
|
|
66
|
+
except OSError:
|
|
67
|
+
return 0
|
|
68
|
+
|
|
69
|
+
return sorted(paths, key=lambda path: (-mtime(path), str(path)))
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class SearchTool(Tool):
|
|
73
|
+
read_only = True
|
|
74
|
+
|
|
75
|
+
def base(self, path: Path) -> str | None:
|
|
76
|
+
# The fallback reads .gitignore files from the project root down to `path`.
|
|
77
|
+
return str(self.root) if path.is_relative_to(self.root) else None
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class Glob(SearchTool):
|
|
81
|
+
name = "glob"
|
|
82
|
+
description = (
|
|
83
|
+
f"按 glob 模式查找文件,例如 **/*.py,支持花括号多选如 *.{{py,md}}、{{src,docs}}/**/*,可指定搜索目录 path。遵守 .gitignore,并跳过 .git、.venv、node_modules "
|
|
84
|
+
f"和缓存目录(以及 .mosscode/worktrees,除非 path 指向其中);结果按修改时间从新到旧排列,最多 {GLOB_LIMIT} 项。项目目录之外的路径需要用户确认。"
|
|
85
|
+
)
|
|
86
|
+
prompt_summary = "按文件名或路径模式定位文件"
|
|
87
|
+
prompt_guidelines = (
|
|
88
|
+
"按文件名或路径找文件时优先 glob;pattern 是路径模式,不是内容正则。根据已知目录缩小范围,结果截断时细化模式。",
|
|
89
|
+
)
|
|
90
|
+
params_model = GlobParams
|
|
91
|
+
|
|
92
|
+
async def execute(self, params: GlobParams):
|
|
93
|
+
path = self.read_path(params.path)
|
|
94
|
+
if not path.is_dir():
|
|
95
|
+
raise ToolFailure("invalid_path", "glob 的 path 必须是存在的目录。")
|
|
96
|
+
if shutil.which("rg"):
|
|
97
|
+
args = [*rg_command(), "--files", "-g", params.pattern, *skip_globs(path), "--", "."]
|
|
98
|
+
else:
|
|
99
|
+
args = fallback_command("glob", {"pattern": params.pattern, "path": str(path), "base": self.base(path)})
|
|
100
|
+
details = await run_process(args, path, check=False, limit=262144)
|
|
101
|
+
if details["exit_code"] not in (0, 1):
|
|
102
|
+
raise ToolFailure(
|
|
103
|
+
"search_failed", "文件搜索失败。" + stderr_hint(details["stderr"]), stderr=details["stderr"]
|
|
104
|
+
)
|
|
105
|
+
lines = details["stdout"].splitlines()
|
|
106
|
+
if details["truncated"] and lines:
|
|
107
|
+
lines.pop() # The last path may be only a captured prefix.
|
|
108
|
+
files = newest_first(found for line in lines if (found := self.read_path(str(path / line))).is_file())
|
|
109
|
+
truncated = details["truncated"] or len(files) > GLOB_LIMIT
|
|
110
|
+
shown = [self.display(item) for item in files[:GLOB_LIMIT]]
|
|
111
|
+
if not shown:
|
|
112
|
+
return "没有匹配文件。", {"count": 0, "truncated": False}
|
|
113
|
+
note = (
|
|
114
|
+
f"\n(共 {len(files)}{'+' if details['truncated'] else ''} 项,仅显示最新的 {GLOB_LIMIT} 项;请细化模式)"
|
|
115
|
+
if truncated
|
|
116
|
+
else ""
|
|
117
|
+
)
|
|
118
|
+
return "\n".join(shown) + note, {"count": len(shown), "truncated": truncated}
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
class Grep(SearchTool):
|
|
122
|
+
name = "grep"
|
|
123
|
+
description = (
|
|
124
|
+
"用正则搜索文本内容。output_mode:files_with_matches(默认,只列文件,按修改时间从新到旧)、"
|
|
125
|
+
"content(路径:行号: 匹配行,context 指定前后各几行)、count(每个文件的匹配行数)。"
|
|
126
|
+
"path 指定范围,glob 限定文件,ignore_case 忽略大小写,head_limit 限制条数(默认 200)。"
|
|
127
|
+
"遵守 .gitignore;无匹配是成功。项目目录之外的路径需要用户确认。"
|
|
128
|
+
)
|
|
129
|
+
prompt_summary = "搜索代码内容,返回文件、匹配行或计数"
|
|
130
|
+
prompt_guidelines = (
|
|
131
|
+
"按内容定位代码时优先 grep;先用默认的 files_with_matches 找到相关文件,需要看匹配行时再用 output_mode=content,必要时加 context。使用 path 和 glob 限定范围,结果截断时细化搜索。",
|
|
132
|
+
"grep 的匹配行用于定位;修改前仍需读取相关原文和周边上下文。无匹配只说明当前范围和模式没有命中,必要时调整搜索。",
|
|
133
|
+
)
|
|
134
|
+
params_model = GrepParams
|
|
135
|
+
|
|
136
|
+
async def execute(self, params: GrepParams):
|
|
137
|
+
path = self.read_path(params.path)
|
|
138
|
+
if not path.exists():
|
|
139
|
+
raise FileNotFoundError()
|
|
140
|
+
counting = params.output_mode != "content"
|
|
141
|
+
if shutil.which("rg"):
|
|
142
|
+
args = rg_command()
|
|
143
|
+
if params.ignore_case:
|
|
144
|
+
args.append("-i")
|
|
145
|
+
args.extend(["--count", "--with-filename", "--null"] if counting else ["--json", "-C", str(params.context)])
|
|
146
|
+
if params.glob is not None:
|
|
147
|
+
args.extend(["-g", params.glob])
|
|
148
|
+
args.extend([*skip_globs(path), "-e", params.pattern, "--", str(path)])
|
|
149
|
+
else:
|
|
150
|
+
args = fallback_command(
|
|
151
|
+
"count" if counting else "grep",
|
|
152
|
+
{
|
|
153
|
+
"pattern": params.pattern,
|
|
154
|
+
"path": str(path),
|
|
155
|
+
"base": self.base(path),
|
|
156
|
+
"glob": params.glob,
|
|
157
|
+
"ignore_case": params.ignore_case,
|
|
158
|
+
"context": params.context,
|
|
159
|
+
},
|
|
160
|
+
)
|
|
161
|
+
details = await run_process(args, self.root, check=False, limit=262144)
|
|
162
|
+
if details["exit_code"] not in (0, 1):
|
|
163
|
+
raise ToolFailure(
|
|
164
|
+
"search_failed",
|
|
165
|
+
"搜索模式无效或目标无法访问。" + stderr_hint(details["stderr"]),
|
|
166
|
+
stderr=details["stderr"],
|
|
167
|
+
)
|
|
168
|
+
lines = details["stdout"].splitlines()
|
|
169
|
+
if details["truncated"] and lines:
|
|
170
|
+
lines.pop()
|
|
171
|
+
if counting:
|
|
172
|
+
counts = {}
|
|
173
|
+
for line in lines:
|
|
174
|
+
name, _, number = line.rpartition("\0")
|
|
175
|
+
if name and number.isdigit():
|
|
176
|
+
counts[self.read_path(name)] = int(number)
|
|
177
|
+
files = newest_first(counts)
|
|
178
|
+
entries = (
|
|
179
|
+
[self.display(item) for item in files]
|
|
180
|
+
if params.output_mode == "files_with_matches"
|
|
181
|
+
else [f"{self.display(item)}:{counts[item]}" for item in files]
|
|
182
|
+
)
|
|
183
|
+
total_matches = sum(counts.values())
|
|
184
|
+
empty = "没有匹配内容。"
|
|
185
|
+
unit = "个文件"
|
|
186
|
+
else:
|
|
187
|
+
events = {}
|
|
188
|
+
for line in lines:
|
|
189
|
+
try:
|
|
190
|
+
event = json.loads(line)
|
|
191
|
+
except ValueError:
|
|
192
|
+
continue
|
|
193
|
+
if event.get("type") not in {"match", "context"}:
|
|
194
|
+
continue
|
|
195
|
+
data = event["data"]
|
|
196
|
+
if "text" not in data["path"] or "text" not in data["lines"]:
|
|
197
|
+
continue
|
|
198
|
+
name = self.display(self.read_path(data["path"]["text"]))
|
|
199
|
+
separator = ":" if event["type"] == "match" else "-"
|
|
200
|
+
events.setdefault(name, {})[data["line_number"]] = (
|
|
201
|
+
f"{name}{separator}{data['line_number']}{separator} {data['lines']['text'].rstrip()}",
|
|
202
|
+
event["type"] == "match",
|
|
203
|
+
)
|
|
204
|
+
entries, total_matches = [], 0
|
|
205
|
+
for name in sorted(events):
|
|
206
|
+
previous = None
|
|
207
|
+
for number in sorted(events[name]):
|
|
208
|
+
text, matched = events[name][number]
|
|
209
|
+
if params.context and previous is not None and number > previous + 1:
|
|
210
|
+
entries.append("--")
|
|
211
|
+
entries.append(text)
|
|
212
|
+
total_matches += matched
|
|
213
|
+
previous = number
|
|
214
|
+
empty = "没有匹配内容。"
|
|
215
|
+
unit = "行"
|
|
216
|
+
if not entries:
|
|
217
|
+
return empty, {"count": 0, "matches": 0, "truncated": False}
|
|
218
|
+
truncated = details["truncated"] or len(entries) > params.head_limit
|
|
219
|
+
shown = entries[: params.head_limit]
|
|
220
|
+
if truncated:
|
|
221
|
+
shown.append(
|
|
222
|
+
f"(共 {len(entries)}{'+' if details['truncated'] else ''} {unit},仅显示前 {params.head_limit} {unit};请缩小范围或调整 head_limit)"
|
|
223
|
+
)
|
|
224
|
+
return "\n".join(shown), {
|
|
225
|
+
"count": len(entries),
|
|
226
|
+
"matches": total_matches,
|
|
227
|
+
"truncated": truncated,
|
|
228
|
+
"output_mode": params.output_mode,
|
|
229
|
+
}
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
"""Standard-library fallback, isolated so regex/search can be timed out.
|
|
2
|
+
|
|
3
|
+
Output mirrors the ripgrep invocations in search.py: relative paths for glob,
|
|
4
|
+
`--json` match/context events for grep content, `path NUL count` for counts.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import fnmatch
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
import re
|
|
11
|
+
import sys
|
|
12
|
+
from collections import deque
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
SKIP_DIRS = {".git", ".venv", "venv", "node_modules", "__pycache__"}
|
|
16
|
+
FOLD = os.name == "nt" # Git for Windows matches ignore patterns case-insensitively.
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def expand_braces(pattern):
|
|
20
|
+
"""Expand `{a,b}` alternatives (nested too) like rg globs; unbalanced braces stay literal."""
|
|
21
|
+
depth, start = 0, None
|
|
22
|
+
for index, char in enumerate(pattern):
|
|
23
|
+
if char == "{":
|
|
24
|
+
if depth == 0:
|
|
25
|
+
start = index
|
|
26
|
+
depth += 1
|
|
27
|
+
elif char == "}" and depth:
|
|
28
|
+
depth -= 1
|
|
29
|
+
if depth:
|
|
30
|
+
continue
|
|
31
|
+
body, rest = pattern[start + 1 : index], pattern[index + 1 :]
|
|
32
|
+
alternatives, level, last = [], 0, 0
|
|
33
|
+
for position, inner in enumerate(body):
|
|
34
|
+
if inner == "{":
|
|
35
|
+
level += 1
|
|
36
|
+
elif inner == "}":
|
|
37
|
+
level -= 1
|
|
38
|
+
elif inner == "," and level == 0:
|
|
39
|
+
alternatives.append(body[last:position])
|
|
40
|
+
last = position + 1
|
|
41
|
+
if not alternatives: # `{x}` has no alternatives: keep it, but expand inside and after it.
|
|
42
|
+
return [
|
|
43
|
+
pattern[:start] + "{" + inner + "}" + tail
|
|
44
|
+
for inner in expand_braces(body)
|
|
45
|
+
for tail in expand_braces(rest)
|
|
46
|
+
]
|
|
47
|
+
alternatives.append(body[last:])
|
|
48
|
+
return list(
|
|
49
|
+
dict.fromkeys(
|
|
50
|
+
expanded
|
|
51
|
+
for alternative in alternatives
|
|
52
|
+
for expanded in expand_braces(pattern[:start] + alternative + rest)
|
|
53
|
+
)
|
|
54
|
+
)
|
|
55
|
+
if depth: # The first '{' never closed: literal, but later braces may still pair up.
|
|
56
|
+
return [pattern[: start + 1] + tail for tail in expand_braces(pattern[start + 1 :])]
|
|
57
|
+
return [pattern]
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def matches(path, pattern):
|
|
61
|
+
# Glob '*' matches a single path segment; '**' spans zero or more.
|
|
62
|
+
parts = path.replace("\\", "/").split("/")
|
|
63
|
+
patterns = pattern.replace("\\", "/").split("/")
|
|
64
|
+
|
|
65
|
+
def match(index, position):
|
|
66
|
+
if position == len(patterns):
|
|
67
|
+
return index == len(parts)
|
|
68
|
+
if patterns[position] == "**":
|
|
69
|
+
return match(index, position + 1) or (index < len(parts) and match(index + 1, position))
|
|
70
|
+
return (
|
|
71
|
+
index < len(parts)
|
|
72
|
+
and fnmatch.fnmatchcase(parts[index], patterns[position])
|
|
73
|
+
and match(index + 1, position + 1)
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
return match(0, 0)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class Ignore:
|
|
80
|
+
"""The subset of .gitignore semantics that decides most real files:
|
|
81
|
+
comments, `!` negation, `/` anchoring, trailing-`/` directory patterns, `**`."""
|
|
82
|
+
|
|
83
|
+
def __init__(self, rules=()):
|
|
84
|
+
self.rules = tuple(rules)
|
|
85
|
+
|
|
86
|
+
def extend(self, directory: Path) -> "Ignore":
|
|
87
|
+
try:
|
|
88
|
+
text = (directory / ".gitignore").read_text(encoding="utf-8", errors="replace")
|
|
89
|
+
except OSError:
|
|
90
|
+
return self
|
|
91
|
+
rules = list(self.rules)
|
|
92
|
+
for line in text.splitlines():
|
|
93
|
+
line = line.rstrip()
|
|
94
|
+
if not line or line.startswith("#"):
|
|
95
|
+
continue
|
|
96
|
+
negate = line.startswith("!")
|
|
97
|
+
if negate:
|
|
98
|
+
line = line[1:]
|
|
99
|
+
if line.startswith("\\"):
|
|
100
|
+
line = line[1:]
|
|
101
|
+
dir_only = line.endswith("/")
|
|
102
|
+
line = line.rstrip("/")
|
|
103
|
+
anchored = "/" in line
|
|
104
|
+
line = line.lstrip("/")
|
|
105
|
+
if line:
|
|
106
|
+
rules.append((directory, line.casefold() if FOLD else line, negate, dir_only, anchored))
|
|
107
|
+
return Ignore(rules)
|
|
108
|
+
|
|
109
|
+
def ignored(self, path: Path, is_dir: bool) -> bool:
|
|
110
|
+
result = False
|
|
111
|
+
name = path.name.casefold() if FOLD else path.name
|
|
112
|
+
for base, pattern, negate, dir_only, anchored in self.rules:
|
|
113
|
+
if dir_only and not is_dir:
|
|
114
|
+
continue
|
|
115
|
+
if anchored:
|
|
116
|
+
try:
|
|
117
|
+
relative = path.relative_to(base).as_posix()
|
|
118
|
+
except ValueError:
|
|
119
|
+
continue
|
|
120
|
+
hit = matches(relative.casefold() if FOLD else relative, pattern)
|
|
121
|
+
else:
|
|
122
|
+
hit = fnmatch.fnmatchcase(name, pattern)
|
|
123
|
+
if hit:
|
|
124
|
+
result = not negate # Last matching rule wins.
|
|
125
|
+
return result
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def initial_ignore(root: Path, base: Path | None) -> Ignore:
|
|
129
|
+
"""Ignore files from the project root down to the search root (inclusive)."""
|
|
130
|
+
ignore = Ignore()
|
|
131
|
+
if base is None or not root.is_relative_to(base):
|
|
132
|
+
return ignore.extend(root)
|
|
133
|
+
parts = root.relative_to(base).parts
|
|
134
|
+
for depth in range(len(parts) + 1):
|
|
135
|
+
ignore = ignore.extend(base.joinpath(*parts[:depth]))
|
|
136
|
+
return ignore
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def files(root: Path, base: Path | None):
|
|
140
|
+
if root.is_file():
|
|
141
|
+
yield root
|
|
142
|
+
return
|
|
143
|
+
if not root.is_dir():
|
|
144
|
+
raise FileNotFoundError()
|
|
145
|
+
boundary = root.resolve()
|
|
146
|
+
ignores = {root: initial_ignore(root, base)}
|
|
147
|
+
for directory, folders, names in os.walk(root):
|
|
148
|
+
current = Path(directory)
|
|
149
|
+
ignore = ignores.pop(current, None) or Ignore()
|
|
150
|
+
kept = []
|
|
151
|
+
for name in folders:
|
|
152
|
+
path = current / name
|
|
153
|
+
if (
|
|
154
|
+
name in SKIP_DIRS
|
|
155
|
+
or (name == "worktrees" and current.name == ".mosscode")
|
|
156
|
+
or path.is_symlink()
|
|
157
|
+
or path.is_junction()
|
|
158
|
+
or not path.resolve().is_relative_to(boundary)
|
|
159
|
+
or ignore.ignored(path, True)
|
|
160
|
+
):
|
|
161
|
+
continue
|
|
162
|
+
kept.append(name)
|
|
163
|
+
ignores[path] = ignore.extend(path)
|
|
164
|
+
folders[:] = kept
|
|
165
|
+
for name in names:
|
|
166
|
+
path = current / name
|
|
167
|
+
if ignore.ignored(path, False):
|
|
168
|
+
continue
|
|
169
|
+
try:
|
|
170
|
+
if path.resolve().is_relative_to(boundary):
|
|
171
|
+
yield path
|
|
172
|
+
except (OSError, RuntimeError):
|
|
173
|
+
continue
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def grep_file(path, expression, context):
|
|
177
|
+
"""Yield (kind, line_number, text) like rg --json match/context events."""
|
|
178
|
+
before = deque(maxlen=context)
|
|
179
|
+
after = 0
|
|
180
|
+
with path.open(encoding="utf-8") as stream:
|
|
181
|
+
for number, line in enumerate(stream, 1):
|
|
182
|
+
if "\0" in line:
|
|
183
|
+
return
|
|
184
|
+
if expression.search(line):
|
|
185
|
+
yield from (("context", n, text) for n, text in before)
|
|
186
|
+
before.clear()
|
|
187
|
+
yield "match", number, line
|
|
188
|
+
after = context
|
|
189
|
+
elif after:
|
|
190
|
+
yield "context", number, line
|
|
191
|
+
after -= 1
|
|
192
|
+
elif context:
|
|
193
|
+
before.append((number, line))
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def main():
|
|
197
|
+
mode, arguments = sys.argv[1], json.loads(sys.argv[2])
|
|
198
|
+
root = Path(arguments["path"])
|
|
199
|
+
base = Path(arguments["base"]) if arguments.get("base") else None
|
|
200
|
+
flags = re.IGNORECASE if arguments.get("ignore_case") else 0
|
|
201
|
+
expression = re.compile(arguments["pattern"], flags) if mode != "glob" else None
|
|
202
|
+
pattern = arguments["pattern"] if mode == "glob" else arguments.get("glob")
|
|
203
|
+
# Braces are expanded for user patterns only; .gitignore has no brace syntax.
|
|
204
|
+
patterns = expand_braces(pattern) if pattern else []
|
|
205
|
+
for path in files(root, base):
|
|
206
|
+
relative = path.name if root.is_file() else path.relative_to(root).as_posix()
|
|
207
|
+
if patterns and not any(
|
|
208
|
+
matches(relative, item) or ("/" not in item and fnmatch.fnmatchcase(path.name, item)) for item in patterns
|
|
209
|
+
):
|
|
210
|
+
continue
|
|
211
|
+
if mode == "glob":
|
|
212
|
+
print(relative, flush=True)
|
|
213
|
+
continue
|
|
214
|
+
try:
|
|
215
|
+
if mode == "count":
|
|
216
|
+
count = sum(1 for kind, _, _ in grep_file(path, expression, 0) if kind == "match")
|
|
217
|
+
if count:
|
|
218
|
+
sys.stdout.write(f"{path}\0{count}\n")
|
|
219
|
+
sys.stdout.flush()
|
|
220
|
+
continue
|
|
221
|
+
for kind, number, line in grep_file(path, expression, arguments.get("context", 0)):
|
|
222
|
+
print(
|
|
223
|
+
json.dumps(
|
|
224
|
+
{
|
|
225
|
+
"type": kind,
|
|
226
|
+
"data": {"path": {"text": str(path)}, "line_number": number, "lines": {"text": line}},
|
|
227
|
+
},
|
|
228
|
+
ensure_ascii=False,
|
|
229
|
+
),
|
|
230
|
+
flush=True,
|
|
231
|
+
)
|
|
232
|
+
except (UnicodeError, PermissionError, IsADirectoryError):
|
|
233
|
+
continue
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
if __name__ == "__main__":
|
|
237
|
+
# pipe text encoding is explicit even under legacy Windows code pages.
|
|
238
|
+
sys.stdout.reconfigure(encoding="utf-8")
|
|
239
|
+
try:
|
|
240
|
+
main()
|
|
241
|
+
except (ValueError, OSError, re.error) as exc:
|
|
242
|
+
print(type(exc).__name__, file=sys.stderr)
|
|
243
|
+
raise SystemExit(2) from None
|
mosscode/tools/shell.py
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""Pick the command shell once per registry: Git Bash on Windows when present."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import shutil
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass(frozen=True)
|
|
10
|
+
class Shell:
|
|
11
|
+
kind: str # "bash", "sh" or "powershell"
|
|
12
|
+
path: str
|
|
13
|
+
label: str # Shown to the model and the user.
|
|
14
|
+
|
|
15
|
+
def args(self, command: str) -> list[str]:
|
|
16
|
+
if self.kind == "powershell":
|
|
17
|
+
prelude = "[Console]::OutputEncoding=[System.Text.UTF8Encoding]::new($false); $OutputEncoding=[Console]::OutputEncoding; $LASTEXITCODE=$null; "
|
|
18
|
+
suffix = "\n$mosscodeCommandSucceeded=$?; if ($null -ne $LASTEXITCODE) { exit $LASTEXITCODE }; if (-not $mosscodeCommandSucceeded) { exit 1 }"
|
|
19
|
+
return [self.path, "-NoLogo", "-NoProfile", "-NonInteractive", "-Command", prelude + command + suffix]
|
|
20
|
+
if self.kind == "bash" and os.name == "nt":
|
|
21
|
+
return [self.path, "--noprofile", "--norc", "-c", f'eval "${COMMAND_VARIABLE}"']
|
|
22
|
+
if self.kind == "bash":
|
|
23
|
+
return [self.path, "--noprofile", "--norc", "-c", command]
|
|
24
|
+
return [self.path, "-c", command]
|
|
25
|
+
|
|
26
|
+
def env(self, command: str) -> dict[str, str] | None:
|
|
27
|
+
# MSYS bash re-parses its Windows command line Cygwin-style, which
|
|
28
|
+
# collapses `\\` inside quoted arguments; the environment is passed verbatim.
|
|
29
|
+
if self.kind == "bash" and os.name == "nt":
|
|
30
|
+
return {**os.environ, COMMAND_VARIABLE: command}
|
|
31
|
+
return None
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
COMMAND_VARIABLE = "MOSSCODE_COMMAND"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class ShellNotFound(ValueError):
|
|
38
|
+
pass
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _is_wsl(path: Path) -> bool:
|
|
42
|
+
# System32\bash.exe starts WSL, a different filesystem and toolchain.
|
|
43
|
+
system = Path(os.environ.get("SystemRoot", r"C:\Windows"))
|
|
44
|
+
try:
|
|
45
|
+
return path.resolve().is_relative_to(system.resolve())
|
|
46
|
+
except OSError:
|
|
47
|
+
return True
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _unshim(path: Path) -> Path:
|
|
51
|
+
# Scoop shims are tiny launchers; the real target is in <name>.shim.
|
|
52
|
+
shim = path.with_suffix(".shim")
|
|
53
|
+
try:
|
|
54
|
+
for line in shim.read_text(encoding="utf-8").splitlines():
|
|
55
|
+
key, _, value = line.partition("=")
|
|
56
|
+
if key.strip() == "path":
|
|
57
|
+
return Path(value.strip().strip('"'))
|
|
58
|
+
except (OSError, UnicodeError):
|
|
59
|
+
pass
|
|
60
|
+
return path
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def git_bash() -> Path | None:
|
|
64
|
+
candidates = []
|
|
65
|
+
git = shutil.which("git")
|
|
66
|
+
if git:
|
|
67
|
+
# git.exe lives in <root>\cmd, <root>\bin or <root>\mingw64\bin.
|
|
68
|
+
candidates.extend(parent / "bin" / "bash.exe" for parent in _unshim(Path(git)).resolve().parents[:3])
|
|
69
|
+
bash = shutil.which("bash")
|
|
70
|
+
if bash:
|
|
71
|
+
bash = _unshim(Path(bash)).resolve()
|
|
72
|
+
# Prefer the <root>\bin wrapper: it puts /usr/bin and /mingw64/bin on PATH.
|
|
73
|
+
if bash.parent.name.lower() == "bin" and bash.parent.parent.name.lower() == "usr":
|
|
74
|
+
candidates.append(bash.parents[2] / "bin" / "bash.exe")
|
|
75
|
+
candidates.append(bash)
|
|
76
|
+
for variable in ("ProgramFiles", "ProgramW6432", "ProgramFiles(x86)"):
|
|
77
|
+
if os.environ.get(variable):
|
|
78
|
+
candidates.append(Path(os.environ[variable]) / "Git" / "bin" / "bash.exe")
|
|
79
|
+
if os.environ.get("LOCALAPPDATA"):
|
|
80
|
+
candidates.append(Path(os.environ["LOCALAPPDATA"]) / "Programs" / "Git" / "bin" / "bash.exe")
|
|
81
|
+
return next((path for path in candidates if path.is_file() and not _is_wsl(path)), None)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _powershell() -> Shell | None:
|
|
85
|
+
path = shutil.which("pwsh") or shutil.which("powershell")
|
|
86
|
+
return Shell("powershell", path, "PowerShell") if path else None
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def resolve_shell(preference: str = "auto") -> Shell:
|
|
90
|
+
preference = (preference or "auto").strip()
|
|
91
|
+
lowered = preference.lower()
|
|
92
|
+
if lowered == "powershell":
|
|
93
|
+
shell = _powershell()
|
|
94
|
+
if shell is None:
|
|
95
|
+
raise ShellNotFound("配置要求使用 PowerShell,但找不到 pwsh 或 powershell。")
|
|
96
|
+
return shell
|
|
97
|
+
if lowered in {"auto", "bash"}:
|
|
98
|
+
if os.name == "nt":
|
|
99
|
+
found = git_bash()
|
|
100
|
+
if found is not None:
|
|
101
|
+
return Shell("bash", str(found), "Git Bash")
|
|
102
|
+
if lowered == "bash":
|
|
103
|
+
raise ShellNotFound(
|
|
104
|
+
"配置要求使用 bash,但没有找到 Git Bash;可安装 Git for Windows,或在 shell 中填写 bash.exe 的路径。"
|
|
105
|
+
)
|
|
106
|
+
shell = _powershell()
|
|
107
|
+
if shell is None:
|
|
108
|
+
raise ShellNotFound("找不到 Git Bash,也找不到 PowerShell。")
|
|
109
|
+
return shell
|
|
110
|
+
found = shutil.which("bash")
|
|
111
|
+
if found:
|
|
112
|
+
return Shell("bash", found, "bash")
|
|
113
|
+
if lowered == "bash":
|
|
114
|
+
raise ShellNotFound("配置要求使用 bash,但 PATH 中没有 bash。")
|
|
115
|
+
return Shell("sh", "/bin/sh", "sh")
|
|
116
|
+
path = Path(preference).expanduser()
|
|
117
|
+
if not path.is_file():
|
|
118
|
+
raise ShellNotFound(f"shell 配置的路径不存在:{path}")
|
|
119
|
+
if os.name == "nt" and _is_wsl(path):
|
|
120
|
+
raise ShellNotFound("shell 不支持 System32 下的 WSL bash;请填写 Git Bash 的 bash.exe。")
|
|
121
|
+
return Shell("bash", str(path), "Git Bash" if os.name == "nt" else "bash")
|