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.
Files changed (91) hide show
  1. mosscode/__init__.py +10 -0
  2. mosscode/__main__.py +3 -0
  3. mosscode/agent.py +486 -0
  4. mosscode/agents/__init__.py +249 -0
  5. mosscode/agents/builtin/explore.md +16 -0
  6. mosscode/agents/builtin/general.md +12 -0
  7. mosscode/agents/builtin/plan.md +16 -0
  8. mosscode/agents/runtime.py +685 -0
  9. mosscode/agents/tasks.py +193 -0
  10. mosscode/appearance.py +184 -0
  11. mosscode/builtin_commands.py +1363 -0
  12. mosscode/cli.py +547 -0
  13. mosscode/commands.py +254 -0
  14. mosscode/compaction.py +259 -0
  15. mosscode/config.py +337 -0
  16. mosscode/context.py +102 -0
  17. mosscode/conversation.py +1046 -0
  18. mosscode/coordinator.py +54 -0
  19. mosscode/doctor.py +303 -0
  20. mosscode/hooks/__init__.py +22 -0
  21. mosscode/hooks/actions.py +151 -0
  22. mosscode/hooks/conditions.py +170 -0
  23. mosscode/hooks/config.py +404 -0
  24. mosscode/hooks/engine.py +401 -0
  25. mosscode/hooks/events.py +48 -0
  26. mosscode/launcher.py +177 -0
  27. mosscode/mcp_client.py +460 -0
  28. mosscode/mcp_config.py +216 -0
  29. mosscode/member_ui.py +289 -0
  30. mosscode/memory.py +291 -0
  31. mosscode/memory_extract.py +245 -0
  32. mosscode/messages.py +216 -0
  33. mosscode/paths.py +31 -0
  34. mosscode/permissions.py +733 -0
  35. mosscode/prompts.py +483 -0
  36. mosscode/providers.py +587 -0
  37. mosscode/scrollback.py +106 -0
  38. mosscode/security.py +282 -0
  39. mosscode/serialization.py +121 -0
  40. mosscode/session.py +563 -0
  41. mosscode/setup.py +248 -0
  42. mosscode/skills/__init__.py +455 -0
  43. mosscode/skills/builtin/commit/SKILL.md +15 -0
  44. mosscode/skills/builtin/review/SKILL.md +15 -0
  45. mosscode/skills/builtin/test/SKILL.md +13 -0
  46. mosscode/skills/builtin/test/scripts/detect.py +96 -0
  47. mosscode/skills/builtin/test/tool.json +9 -0
  48. mosscode/teams/__init__.py +216 -0
  49. mosscode/teams/backends.py +259 -0
  50. mosscode/teams/board.py +230 -0
  51. mosscode/teams/lock.py +173 -0
  52. mosscode/teams/mailbox.py +172 -0
  53. mosscode/teams/member.py +427 -0
  54. mosscode/teams/merge.py +207 -0
  55. mosscode/teams/pane.py +20 -0
  56. mosscode/teams/pane_app.py +167 -0
  57. mosscode/teams/process.py +371 -0
  58. mosscode/teams/runtime.py +1355 -0
  59. mosscode/teams/view.py +182 -0
  60. mosscode/tool_budget.py +86 -0
  61. mosscode/tools/__init__.py +38 -0
  62. mosscode/tools/agent.py +127 -0
  63. mosscode/tools/base.py +384 -0
  64. mosscode/tools/diffing.py +16 -0
  65. mosscode/tools/execution.py +99 -0
  66. mosscode/tools/files.py +287 -0
  67. mosscode/tools/memory.py +65 -0
  68. mosscode/tools/plan.py +30 -0
  69. mosscode/tools/process.py +216 -0
  70. mosscode/tools/search.py +229 -0
  71. mosscode/tools/search_worker.py +243 -0
  72. mosscode/tools/shell.py +121 -0
  73. mosscode/tools/skill.py +118 -0
  74. mosscode/tools/team.py +447 -0
  75. mosscode/tools/windows_job.py +75 -0
  76. mosscode/tui.py +2325 -0
  77. mosscode/usage.py +68 -0
  78. mosscode/windows_inline.py +148 -0
  79. mosscode/worktree/__init__.py +29 -0
  80. mosscode/worktree/changes.py +59 -0
  81. mosscode/worktree/git.py +125 -0
  82. mosscode/worktree/manager.py +462 -0
  83. mosscode/worktree/meta.py +82 -0
  84. mosscode/worktree/names.py +52 -0
  85. mosscode/worktree/setup.py +119 -0
  86. mosscode-0.1.0.dist-info/METADATA +92 -0
  87. mosscode-0.1.0.dist-info/RECORD +91 -0
  88. mosscode-0.1.0.dist-info/WHEEL +5 -0
  89. mosscode-0.1.0.dist-info/entry_points.txt +3 -0
  90. mosscode-0.1.0.dist-info/licenses/LICENSE +21 -0
  91. mosscode-0.1.0.dist-info/top_level.txt +1 -0
@@ -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
@@ -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")