stcode 0.0.1a0__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 (111) hide show
  1. stcode/__init__.py +10 -0
  2. stcode/cli/__init__.py +9 -0
  3. stcode/cli/bootstrap.py +70 -0
  4. stcode/cli/labels.py +621 -0
  5. stcode/cli/logic/__init__.py +0 -0
  6. stcode/cli/logic/commands.py +36 -0
  7. stcode/cli/logic/completion.py +43 -0
  8. stcode/cli/logic/connection.py +46 -0
  9. stcode/cli/logic/requests.py +18 -0
  10. stcode/cli/logic/sessions.py +67 -0
  11. stcode/cli/logic/settings.py +35 -0
  12. stcode/cli/logic/state.py +43 -0
  13. stcode/cli/logic/workspace.py +17 -0
  14. stcode/cli/main.py +290 -0
  15. stcode/cli/models.py +61 -0
  16. stcode/cli/services/__init__.py +0 -0
  17. stcode/cli/services/client.py +393 -0
  18. stcode/cli/services/launcher.py +159 -0
  19. stcode/cli/services/paths.py +18 -0
  20. stcode/cli/services/preferences.py +107 -0
  21. stcode/cli/ui/__init__.py +0 -0
  22. stcode/cli/ui/app.py +136 -0
  23. stcode/cli/ui/app.tcss +3 -0
  24. stcode/cli/ui/components/__init__.py +0 -0
  25. stcode/cli/ui/components/banner.py +78 -0
  26. stcode/cli/ui/components/cards.py +229 -0
  27. stcode/cli/ui/components/prompt.py +225 -0
  28. stcode/cli/ui/components/status.py +95 -0
  29. stcode/cli/ui/components/transcript/__init__.py +51 -0
  30. stcode/cli/ui/components/transcript/entries.py +232 -0
  31. stcode/cli/ui/components/transcript/formatting.py +188 -0
  32. stcode/cli/ui/components/transcript/view.py +101 -0
  33. stcode/cli/ui/screens/__init__.py +0 -0
  34. stcode/cli/ui/screens/chat/__init__.py +0 -0
  35. stcode/cli/ui/screens/chat/chat.tcss +12 -0
  36. stcode/cli/ui/screens/chat/presenter.py +187 -0
  37. stcode/cli/ui/screens/chat/screen.py +682 -0
  38. stcode/cli/ui/screens/connect.py +126 -0
  39. stcode/cli/ui/screens/connect.tcss +49 -0
  40. stcode/cli/ui/screens/dialog.tcss +40 -0
  41. stcode/cli/ui/screens/sessions.py +105 -0
  42. stcode/cli/ui/screens/settings.py +137 -0
  43. stcode/cli/ui/screens/settings.tcss +69 -0
  44. stcode/cli/ui/screens/trust.py +50 -0
  45. stcode/cli/ui/theme.py +221 -0
  46. stcode/core/__main__.py +7 -0
  47. stcode/core/agent/__init__.py +41 -0
  48. stcode/core/agent/agent.py +651 -0
  49. stcode/core/agent/events.py +97 -0
  50. stcode/core/agent/supervisor.py +226 -0
  51. stcode/core/agent/task.py +120 -0
  52. stcode/core/common/__init__.py +21 -0
  53. stcode/core/common/compat.py +27 -0
  54. stcode/core/common/ids.py +43 -0
  55. stcode/core/common/tools.py +50 -0
  56. stcode/core/common/trace.py +406 -0
  57. stcode/core/common/truncate.py +76 -0
  58. stcode/core/configs.py +490 -0
  59. stcode/core/daemon/__init__.py +32 -0
  60. stcode/core/daemon/autonomy.py +71 -0
  61. stcode/core/daemon/main.py +126 -0
  62. stcode/core/daemon/protocol.py +499 -0
  63. stcode/core/daemon/runner.py +359 -0
  64. stcode/core/daemon/server.py +682 -0
  65. stcode/core/daemon/workspace.py +88 -0
  66. stcode/core/harness/__init__.py +81 -0
  67. stcode/core/harness/approvals.py +85 -0
  68. stcode/core/harness/context.py +260 -0
  69. stcode/core/harness/errors.py +40 -0
  70. stcode/core/harness/harness.py +417 -0
  71. stcode/core/harness/mcp.py +530 -0
  72. stcode/core/harness/outputs.py +99 -0
  73. stcode/core/harness/prompts/__init__.py +138 -0
  74. stcode/core/harness/prompts/execute.py +51 -0
  75. stcode/core/harness/prompts/plan.py +57 -0
  76. stcode/core/harness/prompts/sections.py +238 -0
  77. stcode/core/harness/skills/__init__.py +30 -0
  78. stcode/core/harness/skills/loader.py +247 -0
  79. stcode/core/harness/tools/__init__.py +111 -0
  80. stcode/core/harness/tools/base.py +640 -0
  81. stcode/core/harness/tools/files.py +276 -0
  82. stcode/core/harness/tools/interact.py +72 -0
  83. stcode/core/harness/tools/registry.py +113 -0
  84. stcode/core/harness/tools/repl.py +97 -0
  85. stcode/core/harness/tools/schema.py +186 -0
  86. stcode/core/harness/tools/search.py +356 -0
  87. stcode/core/harness/tools/shell.py +372 -0
  88. stcode/core/harness/tools/skill.py +66 -0
  89. stcode/core/harness/tools/todo.py +62 -0
  90. stcode/core/harness/tools/web.py +163 -0
  91. stcode/core/providers/__init__.py +31 -0
  92. stcode/core/providers/anthropic_claude.py +243 -0
  93. stcode/core/providers/base.py +73 -0
  94. stcode/core/providers/gateway.py +267 -0
  95. stcode/core/providers/google_gemini.py +225 -0
  96. stcode/core/providers/openai_gpt.py +250 -0
  97. stcode/core/providers/registry.py +46 -0
  98. stcode/core/providers/types.py +148 -0
  99. stcode/core/repl/__init__.py +19 -0
  100. stcode/core/repl/_worker.py +179 -0
  101. stcode/core/repl/client.py +374 -0
  102. stcode/core/session/__init__.py +39 -0
  103. stcode/core/session/session.py +493 -0
  104. stcode/core/team/__init__.py +38 -0
  105. stcode/core/team/mailbox.py +249 -0
  106. stcode/core/team/tools.py +145 -0
  107. stcode-0.0.1a0.dist-info/METADATA +229 -0
  108. stcode-0.0.1a0.dist-info/RECORD +111 -0
  109. stcode-0.0.1a0.dist-info/WHEEL +4 -0
  110. stcode-0.0.1a0.dist-info/entry_points.txt +3 -0
  111. stcode-0.0.1a0.dist-info/licenses/LICENSE +201 -0
@@ -0,0 +1,186 @@
1
+ """
2
+ Signature → JSON Schema. The half of `@tool` that talks to the model.
3
+
4
+ A Python signature already describes a call completely, so a hand-written schema is
5
+ duplication that drifts. Here the signature *is* the schema and the docstring *is* the
6
+ description.
7
+
8
+ * **`Annotated` wins over the docstring.** `Annotated[str, Field(description=...)]` sits
9
+ next to the type, where a constraint belongs; the `Args:` block is the fallback.
10
+ * **`title` is stripped everywhere.** Pydantic titles every field, restating the key it
11
+ is filed under, and those tokens sit in every turn's cached prefix.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import inspect
17
+ from typing import Annotated, Any, Callable, get_type_hints
18
+
19
+ import docstring_parser
20
+ from pydantic import BaseModel, Field, create_model
21
+ from pydantic.fields import FieldInfo
22
+
23
+ _SKIPPED_KINDS = frozenset({inspect.Parameter.VAR_POSITIONAL, inspect.Parameter.VAR_KEYWORD})
24
+ """Dropped rather than rejected: a tool may have them for Python-side callers, but they
25
+ have no JSON Schema spelling, so they are not part of its advertised interface."""
26
+
27
+ # Keys whose values are themselves schema nodes. Anything else is left alone — notably
28
+ # the *keys* under `properties`, which are parameter names, not schema keywords.
29
+ _NODE_VALUES = ("items", "additionalProperties", "not", "contains", "propertyNames")
30
+ _NODE_LISTS = ("anyOf", "oneOf", "allOf", "prefixItems")
31
+ _NODE_MAPS = ("properties", "$defs", "definitions", "patternProperties")
32
+
33
+
34
+ def split_docstring(fn: Callable[..., Any]) -> tuple[str, dict[str, str]]:
35
+ """The prose a tool advertises, and its per-parameter descriptions.
36
+
37
+ `Args:` is excluded from the prose: those descriptions are about to be attached to
38
+ the parameters themselves, and repeating them costs tokens to say the same thing
39
+ somewhere the model is less likely to connect to the argument it is filling in.
40
+ """
41
+ raw = inspect.getdoc(fn) or ""
42
+ if not raw.strip():
43
+ return "", {}
44
+
45
+ parsed = docstring_parser.parse(raw)
46
+ prose = "\n\n".join(part for part in (parsed.short_description, parsed.long_description) if part)
47
+ params = {
48
+ param.arg_name: param.description.strip()
49
+ for param in parsed.params
50
+ if param.arg_name and param.description
51
+ }
52
+
53
+ # Returns/Raises/Examples survive only if the parser did not claim them. Re-attach
54
+ # Returns when it did — "what comes back" is the one section a caller needs.
55
+ if parsed.returns is not None and parsed.returns.description:
56
+ prose = f"{prose}\n\nReturns: {parsed.returns.description.strip()}"
57
+ return prose.strip(), params
58
+
59
+
60
+ def strip_titles(schema: Any) -> Any:
61
+ """Recursively drop pydantic's auto-generated `title` keys, in place.
62
+
63
+ Walks only keys that hold schema nodes, so a parameter called `title` keeps both
64
+ its name and its own description.
65
+ """
66
+ if isinstance(schema, list):
67
+ for item in schema:
68
+ strip_titles(item)
69
+ return schema
70
+ if not isinstance(schema, dict):
71
+ return schema
72
+
73
+ schema.pop("title", None)
74
+ for key in _NODE_VALUES:
75
+ if isinstance(schema.get(key), dict):
76
+ strip_titles(schema[key])
77
+ for key in _NODE_LISTS:
78
+ if isinstance(schema.get(key), list):
79
+ strip_titles(schema[key])
80
+ for key in _NODE_MAPS:
81
+ node = schema.get(key)
82
+ if isinstance(node, dict):
83
+ for value in node.values():
84
+ strip_titles(value)
85
+ return schema
86
+
87
+
88
+ def build_params_model(
89
+ fn: Callable[..., Any],
90
+ *,
91
+ exclude: frozenset[str] = frozenset(),
92
+ model_name: str | None = None,
93
+ ) -> type[BaseModel]:
94
+ """Build the pydantic model that validates one call's arguments.
95
+
96
+ `exclude` is how the runtime parameter disappears. It is filtered before the model
97
+ is built rather than deleted from the schema after, so validation *rejects* a model
98
+ that tries to pass one instead of quietly accepting it.
99
+ """
100
+ signature = inspect.signature(fn)
101
+ # `include_extras` keeps Annotated metadata alive; without it every Field(...)
102
+ # description is erased before we can read it.
103
+ try:
104
+ hints = get_type_hints(fn, include_extras=True)
105
+ except Exception:
106
+ # An unresolvable annotation (a TYPE_CHECKING-only import, a local forward
107
+ # reference) should degrade to an untyped parameter, not take the harness down
108
+ # at import time.
109
+ hints = {}
110
+
111
+ _, doc_params = split_docstring(fn)
112
+ fields: dict[str, tuple[Any, FieldInfo]] = {}
113
+
114
+ for name, parameter in signature.parameters.items():
115
+ if name in exclude or name == "self" or parameter.kind in _SKIPPED_KINDS:
116
+ continue
117
+
118
+ annotation = hints.get(name, parameter.annotation)
119
+ if annotation is inspect.Parameter.empty:
120
+ annotation = Any
121
+
122
+ field = _field_for(annotation, parameter.default, doc_params.get(name))
123
+ fields[name] = (annotation, field)
124
+
125
+ name = model_name or f"{getattr(fn, '__name__', 'tool').title().replace('_', '')}Params"
126
+ return create_model(name, **fields) # type: ignore[call-overload, no-any-return]
127
+
128
+
129
+ def _field_for(annotation: Any, default: Any, doc_description: str | None) -> FieldInfo:
130
+ """The field metadata pydantic cannot read off the signature itself.
131
+
132
+ Minimal: pydantic already merges `Annotated[..., Field(...)]` with the field beside
133
+ it, so this supplies only the default (which lives on the parameter) and a
134
+ description when the annotation lacks one. Merging the annotated `FieldInfo` here
135
+ instead would lose the default — the annotation's copy has none, and it wins.
136
+ """
137
+ kwargs: dict[str, Any] = {}
138
+ if doc_description and not _annotated_field_has_description(annotation):
139
+ kwargs["description"] = doc_description
140
+ if default is not inspect.Parameter.empty:
141
+ kwargs["default"] = default
142
+ # Passing `description=None` explicitly is not the same as omitting it: pydantic
143
+ # tracks which attributes were *set*, and an explicit None clobbers the description
144
+ # the annotation already carried.
145
+ return Field(**kwargs)
146
+
147
+
148
+ def _annotated_field_has_description(annotation: Any) -> bool:
149
+ if not get_origin_is_annotated(annotation):
150
+ return False
151
+ return any(
152
+ isinstance(meta, FieldInfo) and meta.description for meta in annotation.__metadata__
153
+ )
154
+
155
+
156
+ def get_origin_is_annotated(annotation: Any) -> bool:
157
+ return hasattr(annotation, "__metadata__") and getattr(annotation, "__origin__", None) is not None
158
+
159
+
160
+ def json_schema_for(model: type[BaseModel]) -> dict[str, Any]:
161
+ """Render a params model as the object schema a `ToolDefinition` carries."""
162
+ schema = model.model_json_schema()
163
+ strip_titles(schema)
164
+ schema.setdefault("type", "object")
165
+ schema.setdefault("properties", {})
166
+ schema.pop("description", None) # the params model's docstring, not the tool's
167
+ return schema
168
+
169
+
170
+ def schema_from_signature(
171
+ fn: Callable[..., Any], *, exclude: frozenset[str] = frozenset()
172
+ ) -> dict[str, Any]:
173
+ """Convenience path for callers that want the schema without the model."""
174
+ return json_schema_for(build_params_model(fn, exclude=exclude))
175
+
176
+
177
+ __all__ = [
178
+ "build_params_model",
179
+ "json_schema_for",
180
+ "schema_from_signature",
181
+ "split_docstring",
182
+ "strip_titles",
183
+ ]
184
+
185
+ # Re-exported for tools that want to attach a description to a parameter inline.
186
+ Annotated = Annotated
@@ -0,0 +1,356 @@
1
+ """
2
+ Search tools — `glob`, `grep`, `ls`.
3
+
4
+ Shell out to `ripgrep` and `fd`; do not hand-roll. Not just for speed — `.gitignore`
5
+ semantics, binary detection, encoding recovery and PCRE2 are each a project's worth of
6
+ edge cases, and a Python reimplementation gets them wrong in ways that surface as an
7
+ agent confidently reporting a symbol does not exist.
8
+
9
+ `ripgrep` is a declared dependency (a wheel drops `rg` next to the interpreter). `fd`
10
+ has no wheel, so `glob` degrades to `rg --files`, then to `pathlib`. A missing binary
11
+ should cost speed, not the tool.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import asyncio
17
+ import os
18
+ import shutil
19
+ import sys
20
+ from pathlib import Path
21
+ from typing import Annotated, Literal, Sequence
22
+
23
+ from pydantic import Field
24
+
25
+ from stcode.core.harness.approvals import ToolPermission
26
+ from stcode.core.harness.context import DEFAULT_IGNORED_DIRS, HarnessContext
27
+ from stcode.core.harness.tools.base import Runtime, ToolError, tool
28
+
29
+ GREP_MAX_OUTPUT = 16384
30
+ """`output_mode="content"` is the one search that legitimately returns prose. Above this
31
+ it is elided into `tool_out`, where the agent can filter it in Python."""
32
+
33
+ _SUBPROCESS_TIMEOUT = 60.0
34
+
35
+
36
+ async def run_capture(
37
+ argv: Sequence[str], *, cwd: Path, env: dict[str, str] | None = None, timeout: float = _SUBPROCESS_TIMEOUT
38
+ ) -> tuple[int, str, str]:
39
+ """Run a command and capture it. Shared by every tool here.
40
+
41
+ `create_subprocess_exec`, never `_shell`: these argv lists hold agent-authored
42
+ patterns, and a pattern containing a backtick would run a command.
43
+ """
44
+ try:
45
+ process = await asyncio.create_subprocess_exec(
46
+ *argv,
47
+ cwd=str(cwd),
48
+ env=env,
49
+ stdout=asyncio.subprocess.PIPE,
50
+ stderr=asyncio.subprocess.PIPE,
51
+ )
52
+ except FileNotFoundError as exc:
53
+ raise ToolError(f"{Path(argv[0]).name} is not installed or not on PATH.") from exc
54
+
55
+ try:
56
+ stdout, stderr = await asyncio.wait_for(process.communicate(), timeout)
57
+ except asyncio.TimeoutError:
58
+ process.kill()
59
+ await process.wait()
60
+ raise ToolError(
61
+ f"{Path(argv[0]).name} did not finish within {timeout:.0f}s. Narrow the search."
62
+ ) from None
63
+
64
+ return (
65
+ process.returncode or 0,
66
+ stdout.decode("utf-8", errors="replace"),
67
+ stderr.decode("utf-8", errors="replace"),
68
+ )
69
+
70
+
71
+ def find_binary(name: str) -> str | None:
72
+ """Locate a helper binary on PATH, or next to the interpreter.
73
+
74
+ The second half is what makes the bundled ripgrep work: a wheel-installed binary
75
+ lands beside `python` in the virtualenv, which is on PATH when it is activated and
76
+ not when something invokes `.venv/bin/python` directly.
77
+ """
78
+ found = shutil.which(name)
79
+ if found:
80
+ return found
81
+ local = Path(sys.executable).parent / name
82
+ return str(local) if local.exists() and os.access(local, os.X_OK) else None
83
+
84
+
85
+ def _have(binary: str) -> bool:
86
+ return find_binary(binary) is not None
87
+
88
+
89
+ # ---- glob ----------------------------------------------------------------------
90
+
91
+
92
+ @tool(permission=ToolPermission.READ)
93
+ async def glob(
94
+ pattern: str,
95
+ path: str = ".",
96
+ limit: Annotated[int, Field(ge=1, le=5000)] = 500,
97
+ runtime: Runtime[HarnessContext] = None, # type: ignore[assignment]
98
+ ) -> str:
99
+ """Find files by name pattern, most recently modified first.
100
+
101
+ Fast on any repository size. Use it when you know something about a file's name or
102
+ extension and not its contents — `grep` is the tool for the other direction.
103
+
104
+ Results are ordered by modification time, newest first, which is usually the order
105
+ you want: the files someone has been working in are the files a task is about.
106
+
107
+ Args:
108
+ pattern: Glob pattern, e.g. `**/*.py`, `src/**/test_*.ts`, `Dockerfile*`.
109
+ path: Directory to search in. Defaults to the working directory.
110
+ limit: Maximum paths to return.
111
+ """
112
+ context = runtime.context
113
+ root = context.resolve(path)
114
+ if not root.is_dir():
115
+ raise ToolError(f"{root} is not a directory.")
116
+
117
+ matches = await _glob_paths(pattern, root, context)
118
+ if not matches:
119
+ return f"No files matching {pattern!r} under {root}."
120
+
121
+ # Stat once, sort once. Files can vanish between listing and stat in a live repo.
122
+
123
+ def mtime(candidate: Path) -> float:
124
+ try:
125
+ return candidate.stat().st_mtime
126
+ except OSError:
127
+ return 0.0
128
+
129
+ matches.sort(key=mtime, reverse=True)
130
+ shown = matches[:limit]
131
+ body = "\n".join(str(match) for match in shown)
132
+ if len(matches) > limit:
133
+ body += f"\n\n[{len(matches):,} matches, showing the {limit} most recent]"
134
+ return body
135
+
136
+
137
+ # Flags for each lister, in preference order; each is called as FLAGS + [pattern, "."].
138
+ # fd's `--glob` matches the whole pattern instead of treating it as a regex, and
139
+ # `--full-path` is what lets `src/**/*.py` mean what it says.
140
+ _LISTERS = (
141
+ ("fd", ("--glob", "--full-path", "--type", "f", "--hidden", "--exclude", ".git")),
142
+ ("rg", ("--files", "--hidden", "--glob", "!.git", "--glob")),
143
+ )
144
+
145
+
146
+ async def _glob_paths(pattern: str, root: Path, context: HarnessContext) -> list[Path]:
147
+ for name, flags in _LISTERS:
148
+ binary = find_binary(name)
149
+ if binary is None:
150
+ continue
151
+ code, out, err = await run_capture([binary, *flags, pattern, "."], cwd=root)
152
+ if code not in (0, 1):
153
+ raise ToolError(f"{name} failed: {err.strip()}")
154
+ return [root / line for line in out.splitlines() if line]
155
+
156
+ # Neither binary present. Correct, just slower, and it does its own ignoring.
157
+ return [
158
+ candidate
159
+ for candidate in root.glob(pattern)
160
+ if candidate.is_file() and not _ignored(candidate, root)
161
+ ]
162
+
163
+
164
+ def _ignored(candidate: Path, root: Path) -> bool:
165
+ return any(part in DEFAULT_IGNORED_DIRS for part in candidate.relative_to(root).parts)
166
+
167
+
168
+ # ---- grep ----------------------------------------------------------------------
169
+
170
+
171
+ @tool(permission=ToolPermission.READ, max_output=GREP_MAX_OUTPUT)
172
+ async def grep(
173
+ pattern: str,
174
+ path: str = ".",
175
+ glob: str | None = None,
176
+ type: str | None = None,
177
+ output_mode: Literal["files_with_matches", "content", "count"] = "files_with_matches",
178
+ case_insensitive: bool = False,
179
+ context_lines: Annotated[int, Field(ge=0, le=20)] = 0,
180
+ multiline: bool = False,
181
+ head_limit: Annotated[int | None, Field(ge=1)] = None,
182
+ runtime: Runtime[HarnessContext] = None, # type: ignore[assignment]
183
+ ) -> str:
184
+ """Search file contents with a regular expression. Respects `.gitignore`.
185
+
186
+ This is a ripgrep front end, so `pattern` is Rust regex syntax — `\\b`, `\\d`, and
187
+ lookaround-free assertions all work; PCRE backreferences do not. Literal braces and
188
+ parentheses need escaping (`\\{`, `\\(`).
189
+
190
+ Start with the default `files_with_matches` to find out *where* something lives,
191
+ then re-run with `output_mode="content"` on a narrower path. Asking for content
192
+ across a whole repository is how a search turns into a context-window problem.
193
+
194
+ Args:
195
+ pattern: Regular expression to search for.
196
+ path: File or directory to search. Defaults to the working directory.
197
+ glob: Only search files matching this glob, e.g. `*.py`, `**/*.{ts,tsx}`.
198
+ type: Only search this ripgrep file type, e.g. `py`, `rust`, `js`. Faster
199
+ than `glob` when it fits.
200
+ output_mode: `files_with_matches` lists paths, `content` shows matching lines,
201
+ `count` reports how many matches each file has.
202
+ case_insensitive: Match without regard to case.
203
+ context_lines: Lines of context to show either side of a match. Only meaningful
204
+ with `output_mode="content"`.
205
+ multiline: Let the pattern span line breaks, so `.` matches newlines too.
206
+ head_limit: Keep only the first N results.
207
+ """
208
+ context = runtime.context
209
+ target = context.resolve(path)
210
+ if not target.exists():
211
+ raise ToolError(f"{target} does not exist.")
212
+ if not _have("rg"):
213
+ raise ToolError(
214
+ "ripgrep (`rg`) could not be found, though it is a declared dependency of "
215
+ "stcode. Reinstall the environment (`uv sync`), or put `rg` on PATH."
216
+ )
217
+
218
+ argv = [find_binary("rg") or "rg", "--no-messages"]
219
+ if output_mode == "files_with_matches":
220
+ argv.append("--files-with-matches")
221
+ elif output_mode == "count":
222
+ argv.append("--count-matches")
223
+ else:
224
+ argv += ["--line-number", "--with-filename"]
225
+ if context_lines:
226
+ argv += ["--context", str(context_lines)]
227
+ if case_insensitive:
228
+ argv.append("--ignore-case")
229
+ if multiline:
230
+ argv += ["--multiline", "--multiline-dotall"]
231
+ if glob:
232
+ argv += ["--glob", glob]
233
+ if type:
234
+ argv += ["--type", type]
235
+ argv += ["--regexp", pattern, str(target)]
236
+
237
+ code, out, err = await run_capture(argv, cwd=context.cwd)
238
+ if code not in (0, 1):
239
+ raise ToolError(f"rg failed: {err.strip() or f'exit {code}'}")
240
+ if not out.strip():
241
+ hint = "" if output_mode == "content" else " Try `output_mode=\"content\"` for a wider view."
242
+ return f"No matches for {pattern!r} in {target}.{hint}"
243
+
244
+ lines = out.splitlines()
245
+ total = len(lines)
246
+ if head_limit is not None:
247
+ lines = lines[:head_limit]
248
+ body = "\n".join(lines)
249
+ if head_limit is not None and total > head_limit:
250
+ body += f"\n\n[{total:,} results, showing first {head_limit}]"
251
+ return body
252
+
253
+
254
+ # ---- ls ------------------------------------------------------------------------
255
+
256
+
257
+ @tool(permission=ToolPermission.READ)
258
+ async def ls(
259
+ path: str = ".",
260
+ ignore: list[str] | None = None,
261
+ runtime: Runtime[HarnessContext] = None, # type: ignore[assignment]
262
+ ) -> str:
263
+ """List the contents of a directory, skipping anything `.gitignore` excludes.
264
+
265
+ One level only. Use `glob` to look deeper, and prefer it whenever you already know
266
+ what you are looking for — listing a tree to find a file you could have matched by
267
+ name spends a lot of context on directory names.
268
+
269
+ Args:
270
+ path: Directory to list. Defaults to the working directory.
271
+ ignore: Extra glob patterns to skip, e.g. `["*.lock", "snapshots"]`.
272
+ """
273
+ context = runtime.context
274
+ target = context.resolve(path)
275
+ if not target.exists():
276
+ raise ToolError(f"{target} does not exist.")
277
+ if not target.is_dir():
278
+ raise ToolError(f"{target} is a file, not a directory. Use `read` to see it.")
279
+
280
+ try:
281
+ entries = sorted(target.iterdir(), key=lambda p: (p.is_file(), p.name.lower()))
282
+ except PermissionError as exc:
283
+ raise ToolError(f"Cannot list {target}: {exc.strerror}.") from exc
284
+
285
+ ignored = await _gitignored(entries, target, context)
286
+ patterns = tuple(ignore or ())
287
+
288
+ rows: list[str] = []
289
+ hidden = 0
290
+ for entry in entries:
291
+ if entry in ignored or entry.name in DEFAULT_IGNORED_DIRS:
292
+ hidden += 1
293
+ continue
294
+ if any(entry.match(pattern) for pattern in patterns):
295
+ hidden += 1
296
+ continue
297
+ rows.append(_describe(entry))
298
+
299
+ if not rows:
300
+ return f"{target} is empty." if not hidden else f"{target} has {hidden} entries, all ignored."
301
+ footer = f"\n\n[{hidden} ignored entries hidden]" if hidden else ""
302
+ return f"{target}\n" + "\n".join(rows) + footer
303
+
304
+
305
+ def _describe(entry: Path) -> str:
306
+ if entry.is_dir():
307
+ return f" {entry.name}/"
308
+ try:
309
+ size = entry.stat().st_size
310
+ except OSError:
311
+ return f" {entry.name}"
312
+ return f" {entry.name} ({_human_size(size)})"
313
+
314
+
315
+ def _human_size(size: int) -> str:
316
+ scaled = float(size)
317
+ for unit in ("B", "KB", "MB", "GB"):
318
+ if scaled < 1024 or unit == "GB":
319
+ return f"{scaled:.0f}B" if unit == "B" else f"{scaled:.1f}{unit}"
320
+ scaled /= 1024
321
+ return f"{scaled:.1f}GB"
322
+
323
+
324
+ async def _gitignored(entries: list[Path], target: Path, context: HarnessContext) -> set[Path]:
325
+ """Ask git which entries are ignored — one subprocess, not one per entry.
326
+
327
+ `git check-ignore` is the only thing that reads `.gitignore` the way git does:
328
+ nested files, negations, `core.excludesFile`, `.git/info/exclude`. Outside a repo it
329
+ exits non-zero and we fall back to `DEFAULT_IGNORED_DIRS`.
330
+ """
331
+ if not entries or not _have("git"):
332
+ return set()
333
+ try:
334
+ process = await asyncio.create_subprocess_exec(
335
+ "git", "check-ignore", "--stdin",
336
+ cwd=str(target),
337
+ stdin=asyncio.subprocess.PIPE,
338
+ stdout=asyncio.subprocess.PIPE,
339
+ stderr=asyncio.subprocess.DEVNULL,
340
+ )
341
+ except (FileNotFoundError, NotImplementedError):
342
+ return set()
343
+
344
+ payload = "\n".join(entry.name for entry in entries).encode()
345
+ try:
346
+ stdout, _ = await asyncio.wait_for(process.communicate(payload), 10.0)
347
+ except asyncio.TimeoutError:
348
+ process.kill()
349
+ await process.wait()
350
+ return set()
351
+
352
+ names = {line for line in stdout.decode("utf-8", errors="replace").splitlines() if line}
353
+ return {entry for entry in entries if entry.name in names}
354
+
355
+
356
+ __all__ = ["find_binary", "glob", "grep", "ls", "run_capture"]