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.
- stcode/__init__.py +10 -0
- stcode/cli/__init__.py +9 -0
- stcode/cli/bootstrap.py +70 -0
- stcode/cli/labels.py +621 -0
- stcode/cli/logic/__init__.py +0 -0
- stcode/cli/logic/commands.py +36 -0
- stcode/cli/logic/completion.py +43 -0
- stcode/cli/logic/connection.py +46 -0
- stcode/cli/logic/requests.py +18 -0
- stcode/cli/logic/sessions.py +67 -0
- stcode/cli/logic/settings.py +35 -0
- stcode/cli/logic/state.py +43 -0
- stcode/cli/logic/workspace.py +17 -0
- stcode/cli/main.py +290 -0
- stcode/cli/models.py +61 -0
- stcode/cli/services/__init__.py +0 -0
- stcode/cli/services/client.py +393 -0
- stcode/cli/services/launcher.py +159 -0
- stcode/cli/services/paths.py +18 -0
- stcode/cli/services/preferences.py +107 -0
- stcode/cli/ui/__init__.py +0 -0
- stcode/cli/ui/app.py +136 -0
- stcode/cli/ui/app.tcss +3 -0
- stcode/cli/ui/components/__init__.py +0 -0
- stcode/cli/ui/components/banner.py +78 -0
- stcode/cli/ui/components/cards.py +229 -0
- stcode/cli/ui/components/prompt.py +225 -0
- stcode/cli/ui/components/status.py +95 -0
- stcode/cli/ui/components/transcript/__init__.py +51 -0
- stcode/cli/ui/components/transcript/entries.py +232 -0
- stcode/cli/ui/components/transcript/formatting.py +188 -0
- stcode/cli/ui/components/transcript/view.py +101 -0
- stcode/cli/ui/screens/__init__.py +0 -0
- stcode/cli/ui/screens/chat/__init__.py +0 -0
- stcode/cli/ui/screens/chat/chat.tcss +12 -0
- stcode/cli/ui/screens/chat/presenter.py +187 -0
- stcode/cli/ui/screens/chat/screen.py +682 -0
- stcode/cli/ui/screens/connect.py +126 -0
- stcode/cli/ui/screens/connect.tcss +49 -0
- stcode/cli/ui/screens/dialog.tcss +40 -0
- stcode/cli/ui/screens/sessions.py +105 -0
- stcode/cli/ui/screens/settings.py +137 -0
- stcode/cli/ui/screens/settings.tcss +69 -0
- stcode/cli/ui/screens/trust.py +50 -0
- stcode/cli/ui/theme.py +221 -0
- stcode/core/__main__.py +7 -0
- stcode/core/agent/__init__.py +41 -0
- stcode/core/agent/agent.py +651 -0
- stcode/core/agent/events.py +97 -0
- stcode/core/agent/supervisor.py +226 -0
- stcode/core/agent/task.py +120 -0
- stcode/core/common/__init__.py +21 -0
- stcode/core/common/compat.py +27 -0
- stcode/core/common/ids.py +43 -0
- stcode/core/common/tools.py +50 -0
- stcode/core/common/trace.py +406 -0
- stcode/core/common/truncate.py +76 -0
- stcode/core/configs.py +490 -0
- stcode/core/daemon/__init__.py +32 -0
- stcode/core/daemon/autonomy.py +71 -0
- stcode/core/daemon/main.py +126 -0
- stcode/core/daemon/protocol.py +499 -0
- stcode/core/daemon/runner.py +359 -0
- stcode/core/daemon/server.py +682 -0
- stcode/core/daemon/workspace.py +88 -0
- stcode/core/harness/__init__.py +81 -0
- stcode/core/harness/approvals.py +85 -0
- stcode/core/harness/context.py +260 -0
- stcode/core/harness/errors.py +40 -0
- stcode/core/harness/harness.py +417 -0
- stcode/core/harness/mcp.py +530 -0
- stcode/core/harness/outputs.py +99 -0
- stcode/core/harness/prompts/__init__.py +138 -0
- stcode/core/harness/prompts/execute.py +51 -0
- stcode/core/harness/prompts/plan.py +57 -0
- stcode/core/harness/prompts/sections.py +238 -0
- stcode/core/harness/skills/__init__.py +30 -0
- stcode/core/harness/skills/loader.py +247 -0
- stcode/core/harness/tools/__init__.py +111 -0
- stcode/core/harness/tools/base.py +640 -0
- stcode/core/harness/tools/files.py +276 -0
- stcode/core/harness/tools/interact.py +72 -0
- stcode/core/harness/tools/registry.py +113 -0
- stcode/core/harness/tools/repl.py +97 -0
- stcode/core/harness/tools/schema.py +186 -0
- stcode/core/harness/tools/search.py +356 -0
- stcode/core/harness/tools/shell.py +372 -0
- stcode/core/harness/tools/skill.py +66 -0
- stcode/core/harness/tools/todo.py +62 -0
- stcode/core/harness/tools/web.py +163 -0
- stcode/core/providers/__init__.py +31 -0
- stcode/core/providers/anthropic_claude.py +243 -0
- stcode/core/providers/base.py +73 -0
- stcode/core/providers/gateway.py +267 -0
- stcode/core/providers/google_gemini.py +225 -0
- stcode/core/providers/openai_gpt.py +250 -0
- stcode/core/providers/registry.py +46 -0
- stcode/core/providers/types.py +148 -0
- stcode/core/repl/__init__.py +19 -0
- stcode/core/repl/_worker.py +179 -0
- stcode/core/repl/client.py +374 -0
- stcode/core/session/__init__.py +39 -0
- stcode/core/session/session.py +493 -0
- stcode/core/team/__init__.py +38 -0
- stcode/core/team/mailbox.py +249 -0
- stcode/core/team/tools.py +145 -0
- stcode-0.0.1a0.dist-info/METADATA +229 -0
- stcode-0.0.1a0.dist-info/RECORD +111 -0
- stcode-0.0.1a0.dist-info/WHEEL +4 -0
- stcode-0.0.1a0.dist-info/entry_points.txt +3 -0
- 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"]
|