python-agent-harness 1.5.5.2__tar.gz → 1.5.5.3__tar.gz
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.
- {python_agent_harness-1.5.5.2/python_agent_harness.egg-info → python_agent_harness-1.5.5.3}/PKG-INFO +3 -2
- {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.3}/README.md +1 -1
- {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.3}/pyproject.toml +2 -1
- {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.3}/python_agent_harness/__init__.py +1 -1
- {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.3}/python_agent_harness/cli.py +3 -3
- {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.3}/python_agent_harness/client.py +16 -4
- {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.3}/python_agent_harness/config.py +14 -4
- {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.3}/python_agent_harness/tools/__init__.py +17 -4
- {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.3}/python_agent_harness/tools/bash.py +5 -3
- python_agent_harness-1.5.5.3/python_agent_harness/tools/bash_win.py +232 -0
- python_agent_harness-1.5.5.3/python_agent_harness/tools/edit_win.py +56 -0
- {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.3}/python_agent_harness/tools/glob.py +2 -2
- python_agent_harness-1.5.5.3/python_agent_harness/tools/glob_win.py +90 -0
- python_agent_harness-1.5.5.3/python_agent_harness/tools/grep_win.py +136 -0
- {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.3}/python_agent_harness/tui/commands.py +7 -2
- {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.3}/python_agent_harness/tui/input.py +61 -11
- {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.3/python_agent_harness.egg-info}/PKG-INFO +3 -2
- {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.3}/python_agent_harness.egg-info/SOURCES.txt +4 -0
- {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.3}/tests/test_client.py +7 -3
- {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.3}/tests/test_commands.py +2 -1
- {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.3}/tests/test_concurrent_subagents.py +4 -0
- {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.3}/tests/test_filesystem.py +346 -18
- {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.3}/tests/test_invariants.py +4 -0
- {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.3}/tests/test_planmode.py +5 -1
- {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.3}/tests/test_scenarios.py +4 -0
- {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.3}/tests/test_session.py +18 -7
- {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.3}/tests/test_tools_misc.py +1 -0
- {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.3}/LICENSE +0 -0
- {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.3}/python_agent_harness/__main__.py +0 -0
- {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.3}/python_agent_harness/agent.py +0 -0
- {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.3}/python_agent_harness/commands.py +0 -0
- {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.3}/python_agent_harness/context_manager.py +0 -0
- {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.3}/python_agent_harness/diffrender.py +0 -0
- {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.3}/python_agent_harness/mcp/__init__.py +0 -0
- {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.3}/python_agent_harness/mcp/client.py +0 -0
- {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.3}/python_agent_harness/mcp/config.py +0 -0
- {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.3}/python_agent_harness/mcp/manager.py +0 -0
- {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.3}/python_agent_harness/models.py +0 -0
- {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.3}/python_agent_harness/persistence.py +0 -0
- {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.3}/python_agent_harness/planmode.py +0 -0
- {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.3}/python_agent_harness/prompts/agent.md +0 -0
- {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.3}/python_agent_harness/prompts/agents/reviewer.md +0 -0
- {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.3}/python_agent_harness/prompts/build-switch.md +0 -0
- {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.3}/python_agent_harness/prompts/commands/explain.md +0 -0
- {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.3}/python_agent_harness/prompts/compact.md +0 -0
- {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.3}/python_agent_harness/prompts/initialize.md +0 -0
- {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.3}/python_agent_harness/prompts/plan-mode.md +0 -0
- {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.3}/python_agent_harness/prompts/plan.md +0 -0
- {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.3}/python_agent_harness/prompts/review.md +0 -0
- {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.3}/python_agent_harness/prompts/subagent.md +0 -0
- {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.3}/python_agent_harness/prompts/summary.md +0 -0
- {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.3}/python_agent_harness/prompts/task-completion-rules.md +0 -0
- {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.3}/python_agent_harness/prompts/title.md +0 -0
- {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.3}/python_agent_harness/prompts.py +0 -0
- {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.3}/python_agent_harness/session.py +0 -0
- {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.3}/python_agent_harness/subagent.py +0 -0
- {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.3}/python_agent_harness/token_estimator.py +0 -0
- {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.3}/python_agent_harness/tool_runner.py +0 -0
- {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.3}/python_agent_harness/tools/agent_tool.py +0 -0
- {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.3}/python_agent_harness/tools/base.py +0 -0
- {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.3}/python_agent_harness/tools/diffapply.py +0 -0
- {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.3}/python_agent_harness/tools/edit.py +0 -0
- {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.3}/python_agent_harness/tools/edit_mac.py +0 -0
- {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.3}/python_agent_harness/tools/filesystem.py +0 -0
- {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.3}/python_agent_harness/tools/glob_mac.py +0 -0
- {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.3}/python_agent_harness/tools/grep.py +0 -0
- {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.3}/python_agent_harness/tools/grep_mac.py +0 -0
- {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.3}/python_agent_harness/tools/insert.py +0 -0
- {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.3}/python_agent_harness/tools/mcp.py +0 -0
- {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.3}/python_agent_harness/tools/mkdir.py +0 -0
- {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.3}/python_agent_harness/tools/planexit.py +0 -0
- {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.3}/python_agent_harness/tools/question.py +0 -0
- {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.3}/python_agent_harness/tools/read.py +0 -0
- {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.3}/python_agent_harness/tools/skill.py +0 -0
- {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.3}/python_agent_harness/tools/todo.py +0 -0
- {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.3}/python_agent_harness/tools/write.py +0 -0
- {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.3}/python_agent_harness/tui/__init__.py +0 -0
- {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.3}/python_agent_harness/tui/core.py +0 -0
- {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.3}/python_agent_harness/tui/render.py +0 -0
- {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.3}/python_agent_harness.egg-info/dependency_links.txt +0 -0
- {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.3}/python_agent_harness.egg-info/entry_points.txt +0 -0
- {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.3}/python_agent_harness.egg-info/requires.txt +0 -0
- {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.3}/python_agent_harness.egg-info/top_level.txt +0 -0
- {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.3}/setup.cfg +0 -0
- {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.3}/tests/test_agents_md.py +0 -0
- {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.3}/tests/test_cli.py +0 -0
- {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.3}/tests/test_config.py +0 -0
- {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.3}/tests/test_context_rules.py +0 -0
- {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.3}/tests/test_custom_agents.py +0 -0
- {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.3}/tests/test_diffrender.py +0 -0
- {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.3}/tests/test_mcp.py +0 -0
- {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.3}/tests/test_models.py +0 -0
- {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.3}/tests/test_persistence.py +0 -0
- {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.3}/tests/test_prompts.py +0 -0
- {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.3}/tests/test_subagent.py +0 -0
- {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.3}/tests/test_subagent_isolation.py +0 -0
- {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.3}/tests/test_todos_scope.py +0 -0
- {python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.3}/tests/test_token_estimator.py +0 -0
{python_agent_harness-1.5.5.2/python_agent_harness.egg-info → python_agent_harness-1.5.5.3}/PKG-INFO
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: python-agent-harness
|
|
3
|
-
Version: 1.5.5.
|
|
3
|
+
Version: 1.5.5.3
|
|
4
4
|
Summary: A lightweight, hackable mini-OpenCode written in Python.
|
|
5
5
|
Author-email: Huming Chen <chenhuming@gmail.com>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -9,6 +9,7 @@ Classifier: Development Status :: 5 - Production/Stable
|
|
|
9
9
|
Classifier: Environment :: Console
|
|
10
10
|
Classifier: Intended Audience :: Developers
|
|
11
11
|
Classifier: Operating System :: MacOS
|
|
12
|
+
Classifier: Operating System :: Microsoft :: Windows
|
|
12
13
|
Classifier: Operating System :: POSIX :: Linux
|
|
13
14
|
Classifier: Programming Language :: Python :: 3.11
|
|
14
15
|
Classifier: Programming Language :: Python :: 3.12
|
|
@@ -276,7 +277,7 @@ python_agent_harness/
|
|
|
276
277
|
|
|
277
278
|
## Development
|
|
278
279
|
|
|
279
|
-
Requires Python ≥ 3.11. CI runs against Python 3.11, 3.12, and 3.13 on Linux and
|
|
280
|
+
Requires Python ≥ 3.11. CI runs against Python 3.11, 3.12, and 3.13 on Linux, MacOS and Windows.
|
|
280
281
|
|
|
281
282
|
```sh
|
|
282
283
|
make test # unit tests
|
|
@@ -243,7 +243,7 @@ python_agent_harness/
|
|
|
243
243
|
|
|
244
244
|
## Development
|
|
245
245
|
|
|
246
|
-
Requires Python ≥ 3.11. CI runs against Python 3.11, 3.12, and 3.13 on Linux and
|
|
246
|
+
Requires Python ≥ 3.11. CI runs against Python 3.11, 3.12, and 3.13 on Linux, MacOS and Windows.
|
|
247
247
|
|
|
248
248
|
```sh
|
|
249
249
|
make test # unit tests
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "python-agent-harness"
|
|
7
|
-
version = "1.5.5.
|
|
7
|
+
version = "1.5.5.3"
|
|
8
8
|
description = "A lightweight, hackable mini-OpenCode written in Python."
|
|
9
9
|
authors = [
|
|
10
10
|
{name = "Huming Chen", email = "chenhuming@gmail.com"}
|
|
@@ -17,6 +17,7 @@ classifiers = [
|
|
|
17
17
|
"Environment :: Console",
|
|
18
18
|
"Intended Audience :: Developers",
|
|
19
19
|
"Operating System :: MacOS",
|
|
20
|
+
"Operating System :: Microsoft :: Windows",
|
|
20
21
|
"Operating System :: POSIX :: Linux",
|
|
21
22
|
"Programming Language :: Python :: 3.11",
|
|
22
23
|
"Programming Language :: Python :: 3.12",
|
|
@@ -9,7 +9,7 @@ sessions, restore and summary/explain — are TUI slash commands only;
|
|
|
9
9
|
they are NOT registered as CLI subcommands.
|
|
10
10
|
|
|
11
11
|
Configuration (LLM etc.) is read from a JSON file, by default
|
|
12
|
-
|
|
12
|
+
from the OS config directory; see `config --init`.
|
|
13
13
|
"""
|
|
14
14
|
|
|
15
15
|
from __future__ import annotations
|
|
@@ -175,7 +175,7 @@ def cmd_config(args: argparse.Namespace) -> int:
|
|
|
175
175
|
if path.exists() and not args.force:
|
|
176
176
|
print(f"config already exists: {path} (use --force to overwrite)")
|
|
177
177
|
return 1
|
|
178
|
-
template = config.CONFIG_TEMPLATE.format(path=path)
|
|
178
|
+
template = config.CONFIG_TEMPLATE.format(path=str(path).replace("\\", "\\\\"))
|
|
179
179
|
path.write_text(template, encoding="utf-8")
|
|
180
180
|
print(f"wrote config template: {path}")
|
|
181
181
|
return 0
|
|
@@ -250,7 +250,7 @@ def _add_config_arg(parser: argparse.ArgumentParser, suppress: bool = False) ->
|
|
|
250
250
|
"--config",
|
|
251
251
|
metavar="PATH",
|
|
252
252
|
default=argparse.SUPPRESS if suppress else None,
|
|
253
|
-
help="path to config.json (default:
|
|
253
|
+
help="path to config.json (default: OS config dir / python-agent-harness / config.json)",
|
|
254
254
|
)
|
|
255
255
|
|
|
256
256
|
|
{python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.3}/python_agent_harness/client.py
RENAMED
|
@@ -13,6 +13,7 @@ import os
|
|
|
13
13
|
import random
|
|
14
14
|
import socket as _socket
|
|
15
15
|
import sys
|
|
16
|
+
import tempfile
|
|
16
17
|
import threading
|
|
17
18
|
import time
|
|
18
19
|
import uuid
|
|
@@ -128,7 +129,7 @@ def _llm_log_path() -> Path:
|
|
|
128
129
|
d = Path(log_dir)
|
|
129
130
|
d.mkdir(parents=True, exist_ok=True)
|
|
130
131
|
return d / f"python-agent-harness-{date_str}-{session_id}.json"
|
|
131
|
-
return Path(f"
|
|
132
|
+
return Path(tempfile.gettempdir()) / f"python-agent-harness-{date_str}-{session_id}.json"
|
|
132
133
|
|
|
133
134
|
|
|
134
135
|
def _log_llm_interaction(
|
|
@@ -196,8 +197,12 @@ def _resolve_ca_bundle() -> str | bool:
|
|
|
196
197
|
"/etc/pki/tls/certs/ca-bundle.crt",
|
|
197
198
|
"/etc/ssl/certs/ca-certificates.crt",
|
|
198
199
|
"/etc/ssl/ca-bundle.pem",
|
|
200
|
+
# Windows (Git for Windows, conda, etc.)
|
|
201
|
+
os.path.join(os.environ.get("PROGRAMFILES", ""), "Git", "mingw64", "ssl", "cert.pem"),
|
|
202
|
+
os.path.join(os.environ.get("PROGRAMFILES(X86)", ""), "Git", "mingw64", "ssl", "cert.pem"),
|
|
203
|
+
os.path.join(os.environ.get("CONDA_PREFIX", ""), "ssl", "cert.pem"),
|
|
199
204
|
):
|
|
200
|
-
if os.path.isfile(cand):
|
|
205
|
+
if cand and os.path.isfile(cand):
|
|
201
206
|
return cand
|
|
202
207
|
return True
|
|
203
208
|
|
|
@@ -877,9 +882,16 @@ def _shutdown_and_close(sock: _socket.socket) -> None:
|
|
|
877
882
|
after, so a new socket could reuse the fd number before the
|
|
878
883
|
reader observed the EOF. The grace sleep delays the fd release
|
|
879
884
|
so the woken thread can observe the EOF on the old fd first.
|
|
885
|
+
|
|
886
|
+
On Windows, ``shutdown(SHUT_RDWR)`` on the client socket causes
|
|
887
|
+
``WinError 10058`` on the server side when it tries to read,
|
|
888
|
+
producing noisy BrokenPipeError tracebacks. Skipping shutdown
|
|
889
|
+
and just closing the fd is sufficient — Windows ``close()``
|
|
890
|
+
triggers a TCP RST that reliably wakes the blocked read.
|
|
880
891
|
"""
|
|
881
|
-
|
|
882
|
-
|
|
892
|
+
if sys.platform != "win32":
|
|
893
|
+
with contextlib.suppress(OSError):
|
|
894
|
+
sock.shutdown(_socket.SHUT_RDWR)
|
|
883
895
|
if sys.platform == "darwin":
|
|
884
896
|
# macOS only; on Linux shutdown() alone reliably wakes the
|
|
885
897
|
# blocked recv (measured: <200ms unblock, no sleeps needed).
|
{python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.3}/python_agent_harness/config.py
RENAMED
|
@@ -4,6 +4,7 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
import json
|
|
6
6
|
import os
|
|
7
|
+
import sys
|
|
7
8
|
from pathlib import Path
|
|
8
9
|
|
|
9
10
|
from .mcp.config import MCPConfig
|
|
@@ -50,7 +51,10 @@ CALIBRATION_MIN = 0.5
|
|
|
50
51
|
CALIBRATION_MAX = 3.0
|
|
51
52
|
|
|
52
53
|
# ---- sessions ---------------------------------------------------------------
|
|
53
|
-
|
|
54
|
+
if sys.platform == "win32":
|
|
55
|
+
SESSION_DIR = Path(os.environ.get("LOCALAPPDATA", Path.home() / "AppData" / "Local"))
|
|
56
|
+
else:
|
|
57
|
+
SESSION_DIR = Path(os.environ.get("XDG_DATA_HOME", Path.home() / ".local" / "share"))
|
|
54
58
|
SESSION_SUBDIR = "python-agent-harness/sessions"
|
|
55
59
|
AUTO_SAVE_SESSION = True
|
|
56
60
|
|
|
@@ -164,9 +168,15 @@ DEFAULT_AGENT_PROMPT_FILE = PROMPTS_DIR / "agent.md"
|
|
|
164
168
|
DEFAULT_SUBAGENT_PROMPT_FILE = PROMPTS_DIR / "subagent.md"
|
|
165
169
|
|
|
166
170
|
# ---- configuration file ---------------------------------------------------------
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
)
|
|
171
|
+
if sys.platform == "win32":
|
|
172
|
+
CONFIG_DIR = (
|
|
173
|
+
Path(os.environ.get("APPDATA", Path.home() / "AppData" / "Roaming"))
|
|
174
|
+
/ "python-agent-harness"
|
|
175
|
+
)
|
|
176
|
+
else:
|
|
177
|
+
CONFIG_DIR = (
|
|
178
|
+
Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")) / "python-agent-harness"
|
|
179
|
+
)
|
|
170
180
|
CONFIG_FILE = CONFIG_DIR / "config.json"
|
|
171
181
|
|
|
172
182
|
DEFAULT_LLM: dict = {
|
{python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.3}/python_agent_harness/tools/__init__.py
RENAMED
|
@@ -7,11 +7,15 @@ import sys
|
|
|
7
7
|
from .agent_tool import AgentTool
|
|
8
8
|
from .base import PendingToolResult, Registry, Tool, ToolContext
|
|
9
9
|
from .bash import Bash
|
|
10
|
+
from .bash_win import BashWindows
|
|
10
11
|
from .edit import Edit
|
|
11
12
|
from .edit_mac import EditMac
|
|
13
|
+
from .edit_win import EditWindows
|
|
12
14
|
from .filesystem import GlobTool, Grep, Insert, Mkdir, Read, Write
|
|
13
15
|
from .glob_mac import GlobMac
|
|
16
|
+
from .glob_win import GlobWindows
|
|
14
17
|
from .grep_mac import GrepMac
|
|
18
|
+
from .grep_win import GrepWindows
|
|
15
19
|
from .mcp import MCPTool, mcp_tools_from_manager, normalize_mcp_result
|
|
16
20
|
from .planexit import PlanExit
|
|
17
21
|
from .question import Question
|
|
@@ -25,12 +29,16 @@ __all__ = [
|
|
|
25
29
|
"ToolContext",
|
|
26
30
|
"AgentTool",
|
|
27
31
|
"Bash",
|
|
32
|
+
"BashWindows",
|
|
28
33
|
"Edit",
|
|
29
34
|
"EditMac",
|
|
35
|
+
"EditWindows",
|
|
30
36
|
"GlobMac",
|
|
31
37
|
"GlobTool",
|
|
38
|
+
"GlobWindows",
|
|
32
39
|
"Grep",
|
|
33
40
|
"GrepMac",
|
|
41
|
+
"GrepWindows",
|
|
34
42
|
"Insert",
|
|
35
43
|
"MCPTool",
|
|
36
44
|
"Mkdir",
|
|
@@ -50,10 +58,15 @@ def default_registry() -> Registry:
|
|
|
50
58
|
# macOS lacks GNU `patch`, `tree`, and PCRE-enabled git — use
|
|
51
59
|
# pure-Python / POSIX-compatible Mac variants that register under
|
|
52
60
|
# the same tool names so callers are platform-independent.
|
|
61
|
+
# Windows lacks process groups, select on pipes, SIGKILL, `patch`,
|
|
62
|
+
# `tree`, `find`, and `grep` — Windows variants use pure-Python
|
|
63
|
+
# fallbacks and CREATE_NEW_PROCESS_GROUP + taskkill instead.
|
|
53
64
|
_mac = sys.platform == "darwin"
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
65
|
+
_win = sys.platform == "win32"
|
|
66
|
+
edit_tool = EditWindows() if _win else EditMac() if _mac else Edit()
|
|
67
|
+
glob_tool = GlobWindows() if _win else GlobMac() if _mac else GlobTool()
|
|
68
|
+
grep_tool = GrepWindows() if _win else GrepMac() if _mac else Grep()
|
|
69
|
+
bash_tool = BashWindows() if _win else Bash()
|
|
57
70
|
for tool in (
|
|
58
71
|
AgentTool(),
|
|
59
72
|
TodoWrite(),
|
|
@@ -64,7 +77,7 @@ def default_registry() -> Registry:
|
|
|
64
77
|
edit_tool,
|
|
65
78
|
Write(),
|
|
66
79
|
Mkdir(),
|
|
67
|
-
|
|
80
|
+
bash_tool,
|
|
68
81
|
Skill(),
|
|
69
82
|
Question(),
|
|
70
83
|
):
|
{python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.3}/python_agent_harness/tools/bash.py
RENAMED
|
@@ -30,6 +30,7 @@ import os
|
|
|
30
30
|
import select
|
|
31
31
|
import signal
|
|
32
32
|
import subprocess
|
|
33
|
+
import sys
|
|
33
34
|
import threading
|
|
34
35
|
import time
|
|
35
36
|
from collections import deque
|
|
@@ -56,7 +57,7 @@ def _kill_pgid(pgid: int) -> None:
|
|
|
56
57
|
stdout pipe open is still running in the group.
|
|
57
58
|
"""
|
|
58
59
|
with contextlib.suppress(ProcessLookupError, PermissionError, OSError):
|
|
59
|
-
os.killpg(pgid, signal.SIGKILL)
|
|
60
|
+
os.killpg(pgid, signal.SIGKILL) # pyright: ignore[reportAttributeAccessIssue]
|
|
60
61
|
|
|
61
62
|
|
|
62
63
|
def _kill_graceful(pgid: int, proc: subprocess.Popen) -> None:
|
|
@@ -66,7 +67,7 @@ def _kill_graceful(pgid: int, proc: subprocess.Popen) -> None:
|
|
|
66
67
|
children before the hard kill.
|
|
67
68
|
"""
|
|
68
69
|
with contextlib.suppress(ProcessLookupError, PermissionError, OSError):
|
|
69
|
-
os.killpg(pgid, signal.SIGTERM)
|
|
70
|
+
os.killpg(pgid, signal.SIGTERM) # pyright: ignore[reportAttributeAccessIssue]
|
|
70
71
|
try:
|
|
71
72
|
proc.wait(timeout=2)
|
|
72
73
|
except subprocess.TimeoutExpired:
|
|
@@ -144,7 +145,8 @@ def _collect_output(proc: subprocess.Popen, cancel: threading.Event | None) -> t
|
|
|
144
145
|
if stdout is None: # unreachable (stdout=PIPE), kept for the type checker
|
|
145
146
|
return "", "ok"
|
|
146
147
|
fd = stdout.fileno()
|
|
147
|
-
|
|
148
|
+
if sys.platform != "win32":
|
|
149
|
+
os.set_blocking(fd, False) # pyright: ignore[reportAttributeAccessIssue]
|
|
148
150
|
decoder = codecs.getincrementaldecoder("utf-8")(errors="replace")
|
|
149
151
|
head: list[str] = []
|
|
150
152
|
head_len = 0
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
"""Windows Bash tool: process management for win32.
|
|
2
|
+
|
|
3
|
+
Windows lacks ``os.killpg``, ``os.set_blocking``, ``select.select``
|
|
4
|
+
on pipe fds, and ``start_new_session``. This subclass overrides
|
|
5
|
+
``_execute`` to use Windows-native process creation and termination
|
|
6
|
+
while inheriting the shared output-truncation, timeout, and exit-code
|
|
7
|
+
helpers from :class:`Bash`.
|
|
8
|
+
|
|
9
|
+
Process creation uses ``CREATE_NEW_PROCESS_GROUP`` so the child is
|
|
10
|
+
isolated from console Ctrl-C signals (the TUI handles cancel itself).
|
|
11
|
+
Process termination uses ``taskkill /F /T /PID`` to kill the process
|
|
12
|
+
and all its children (the Windows equivalent of killing a process
|
|
13
|
+
group). Output is collected via a reader thread + queue, since
|
|
14
|
+
``select.select`` cannot poll Windows pipes.
|
|
15
|
+
|
|
16
|
+
Only the process-creation and output-collection strategy differs;
|
|
17
|
+
the tool name, description, parameters, timeout semantics, and
|
|
18
|
+
exit-code reporting are inherited so callers, the tool registry,
|
|
19
|
+
and the plan-mode write guard are platform-independent.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import codecs
|
|
25
|
+
import contextlib
|
|
26
|
+
import os
|
|
27
|
+
import queue
|
|
28
|
+
import subprocess
|
|
29
|
+
import threading
|
|
30
|
+
import time
|
|
31
|
+
from collections import deque
|
|
32
|
+
|
|
33
|
+
from ..config import BASH_TIMEOUT_MAX, BASH_TIMEOUT_SILENCE
|
|
34
|
+
from ..config import MAX_OUTPUT_CHARS as _MAX_OUTPUT
|
|
35
|
+
from .base import PendingToolResult, ToolContext
|
|
36
|
+
from .bash import (
|
|
37
|
+
_DRAIN_GRACE,
|
|
38
|
+
_POLL_INTERVAL,
|
|
39
|
+
_READ_CHUNK,
|
|
40
|
+
_TAIL_LINES,
|
|
41
|
+
Bash,
|
|
42
|
+
_append_exit_code,
|
|
43
|
+
_assemble_truncated,
|
|
44
|
+
_timeout_message,
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _kill_process_tree(pid: int) -> None:
|
|
49
|
+
"""Kill the process PID and all its children (Windows equivalent of
|
|
50
|
+
``os.killpg`` with ``SIGKILL``).
|
|
51
|
+
|
|
52
|
+
Uses ``taskkill /F /T /PID`` which recursively terminates the
|
|
53
|
+
process tree. Errors are suppressed (already gone, etc.).
|
|
54
|
+
"""
|
|
55
|
+
with contextlib.suppress(Exception):
|
|
56
|
+
subprocess.run(
|
|
57
|
+
["taskkill", "/F", "/T", "/PID", str(pid)],
|
|
58
|
+
stdin=subprocess.DEVNULL,
|
|
59
|
+
stdout=subprocess.DEVNULL,
|
|
60
|
+
stderr=subprocess.DEVNULL,
|
|
61
|
+
timeout=10,
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _kill_process_tree_graceful(pid: int, proc: subprocess.Popen) -> None:
|
|
66
|
+
"""Graceful kill: terminate, then taskkill /F /T if still alive.
|
|
67
|
+
|
|
68
|
+
Windows has no SIGTERM equivalent for process trees;
|
|
69
|
+
``proc.terminate()`` calls ``TerminateProcess`` on the immediate
|
|
70
|
+
child. If it doesn't die within 2s, ``taskkill /F /T`` force-kills
|
|
71
|
+
the whole tree (children included).
|
|
72
|
+
"""
|
|
73
|
+
with contextlib.suppress(ProcessLookupError, OSError):
|
|
74
|
+
proc.terminate()
|
|
75
|
+
try:
|
|
76
|
+
proc.wait(timeout=2)
|
|
77
|
+
except subprocess.TimeoutExpired:
|
|
78
|
+
_kill_process_tree(pid)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _collect_output_win(proc: subprocess.Popen, cancel: threading.Event | None) -> tuple[str, str]:
|
|
82
|
+
"""Read PROC's merged output on Windows; return (text, status).
|
|
83
|
+
|
|
84
|
+
Windows lacks ``select.select`` on pipe fds and ``os.set_blocking``,
|
|
85
|
+
so a reader thread does blocking ``os.read`` calls and feeds chunks
|
|
86
|
+
into a ``queue.Queue``. The collector polls the queue with
|
|
87
|
+
``_POLL_INTERVAL``, checking cancel/timeout conditions between
|
|
88
|
+
reads — mirroring the Unix ``_collect_output`` logic exactly.
|
|
89
|
+
|
|
90
|
+
Status is one of ``"ok"``, ``"cancelled"``, ``"timeout_silence"``,
|
|
91
|
+
``"timeout_max"``. Keeps the head (first ``_MAX_OUTPUT`` chars)
|
|
92
|
+
and the tail (last ``_TAIL_LINES`` lines) and discards the middle,
|
|
93
|
+
so memory stays bounded no matter how much the process writes.
|
|
94
|
+
"""
|
|
95
|
+
stdout = proc.stdout
|
|
96
|
+
if stdout is None:
|
|
97
|
+
return "", "ok"
|
|
98
|
+
|
|
99
|
+
chunk_queue: queue.Queue[bytes | None] = queue.Queue()
|
|
100
|
+
|
|
101
|
+
def _reader() -> None:
|
|
102
|
+
"""Blocking reader thread: reads raw bytes and feeds the queue."""
|
|
103
|
+
try:
|
|
104
|
+
fd = stdout.fileno()
|
|
105
|
+
while True:
|
|
106
|
+
raw = os.read(fd, _READ_CHUNK)
|
|
107
|
+
if not raw:
|
|
108
|
+
break
|
|
109
|
+
chunk_queue.put(raw)
|
|
110
|
+
except OSError:
|
|
111
|
+
pass
|
|
112
|
+
finally:
|
|
113
|
+
chunk_queue.put(None)
|
|
114
|
+
|
|
115
|
+
threading.Thread(target=_reader, daemon=True).start()
|
|
116
|
+
|
|
117
|
+
decoder = codecs.getincrementaldecoder("utf-8")(errors="replace")
|
|
118
|
+
head: list[str] = []
|
|
119
|
+
head_len = 0
|
|
120
|
+
tail: deque[str] = deque(maxlen=_TAIL_LINES)
|
|
121
|
+
pending_line = ""
|
|
122
|
+
total = 0
|
|
123
|
+
exited = False
|
|
124
|
+
drain_until: float | None = None
|
|
125
|
+
start = time.monotonic()
|
|
126
|
+
last_output = start
|
|
127
|
+
|
|
128
|
+
def finish() -> str:
|
|
129
|
+
nonlocal pending_line
|
|
130
|
+
if pending_line:
|
|
131
|
+
if len(pending_line) > _MAX_OUTPUT:
|
|
132
|
+
pending_line = pending_line[:_MAX_OUTPUT]
|
|
133
|
+
tail.append(pending_line)
|
|
134
|
+
head_text = "".join(head)
|
|
135
|
+
if total > _MAX_OUTPUT:
|
|
136
|
+
return _assemble_truncated(head_text, tail)
|
|
137
|
+
return head_text
|
|
138
|
+
|
|
139
|
+
while True:
|
|
140
|
+
if cancel is not None and cancel.is_set():
|
|
141
|
+
return "", "cancelled"
|
|
142
|
+
now = time.monotonic()
|
|
143
|
+
if not exited:
|
|
144
|
+
if BASH_TIMEOUT_SILENCE is not None and now - last_output >= BASH_TIMEOUT_SILENCE:
|
|
145
|
+
return finish(), "timeout_silence"
|
|
146
|
+
if BASH_TIMEOUT_MAX is not None and now - start >= BASH_TIMEOUT_MAX:
|
|
147
|
+
return finish(), "timeout_max"
|
|
148
|
+
if exited and drain_until is not None and now >= drain_until:
|
|
149
|
+
break
|
|
150
|
+
try:
|
|
151
|
+
raw = chunk_queue.get(timeout=_POLL_INTERVAL)
|
|
152
|
+
except queue.Empty:
|
|
153
|
+
if not exited and proc.poll() is not None:
|
|
154
|
+
exited = True
|
|
155
|
+
drain_until = time.monotonic() + _DRAIN_GRACE
|
|
156
|
+
continue
|
|
157
|
+
if raw is None:
|
|
158
|
+
break
|
|
159
|
+
chunk = decoder.decode(raw).replace("\r\n", "\n")
|
|
160
|
+
total += len(chunk)
|
|
161
|
+
last_output = time.monotonic()
|
|
162
|
+
if head_len < _MAX_OUTPUT:
|
|
163
|
+
take = chunk[: _MAX_OUTPUT - head_len]
|
|
164
|
+
head.append(take)
|
|
165
|
+
head_len += len(take)
|
|
166
|
+
parts = chunk.split("\n")
|
|
167
|
+
parts[0] = pending_line + parts[0]
|
|
168
|
+
pending_line = parts.pop()
|
|
169
|
+
for line in parts:
|
|
170
|
+
if len(line) > _MAX_OUTPUT:
|
|
171
|
+
line = line[:_MAX_OUTPUT]
|
|
172
|
+
tail.append(line)
|
|
173
|
+
return finish(), "ok"
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
class BashWindows(Bash):
|
|
177
|
+
"""Bash for Windows: ``CREATE_NEW_PROCESS_GROUP`` + ``taskkill``.
|
|
178
|
+
|
|
179
|
+
Overrides :meth:`_execute` to use Windows-native process creation
|
|
180
|
+
(``CREATE_NEW_PROCESS_GROUP``) and termination (``taskkill /F /T``).
|
|
181
|
+
All shared logic — timeout messages, output truncation, exit-code
|
|
182
|
+
reporting, the ``PendingToolResult`` async contract — is inherited
|
|
183
|
+
unchanged from :class:`Bash`.
|
|
184
|
+
"""
|
|
185
|
+
|
|
186
|
+
def _execute(self, command: str, ctx: ToolContext) -> str | PendingToolResult:
|
|
187
|
+
cancel = ctx.cancel_event
|
|
188
|
+
if cancel is not None and cancel.is_set():
|
|
189
|
+
return "Error: Bash command cancelled."
|
|
190
|
+
try:
|
|
191
|
+
proc = subprocess.Popen(
|
|
192
|
+
command,
|
|
193
|
+
shell=True,
|
|
194
|
+
stdin=subprocess.DEVNULL,
|
|
195
|
+
stdout=subprocess.PIPE,
|
|
196
|
+
stderr=subprocess.STDOUT,
|
|
197
|
+
bufsize=0,
|
|
198
|
+
cwd=ctx.cwd,
|
|
199
|
+
creationflags=getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0),
|
|
200
|
+
)
|
|
201
|
+
except OSError as e:
|
|
202
|
+
return f"Error: {e}"
|
|
203
|
+
|
|
204
|
+
pid = proc.pid
|
|
205
|
+
pending = PendingToolResult()
|
|
206
|
+
|
|
207
|
+
def deliverer() -> None:
|
|
208
|
+
try:
|
|
209
|
+
out, status = _collect_output_win(proc, cancel)
|
|
210
|
+
except Exception as e: # noqa: BLE001 - delivered as an error string
|
|
211
|
+
out = f"Error: Bash failed — {e}"
|
|
212
|
+
else:
|
|
213
|
+
if status == "cancelled":
|
|
214
|
+
_kill_process_tree(pid)
|
|
215
|
+
out = "Error: Bash command cancelled."
|
|
216
|
+
elif status == "timeout_silence":
|
|
217
|
+
_kill_process_tree_graceful(pid, proc)
|
|
218
|
+
out = _timeout_message(out, silence=True)
|
|
219
|
+
elif status == "timeout_max":
|
|
220
|
+
_kill_process_tree_graceful(pid, proc)
|
|
221
|
+
out = _timeout_message(out, silence=False)
|
|
222
|
+
elif status == "ok":
|
|
223
|
+
out = _append_exit_code(out, proc)
|
|
224
|
+
pending.deliver(out)
|
|
225
|
+
with contextlib.suppress(Exception):
|
|
226
|
+
if proc.stdout is not None:
|
|
227
|
+
proc.stdout.close()
|
|
228
|
+
with contextlib.suppress(Exception):
|
|
229
|
+
proc.wait(timeout=2)
|
|
230
|
+
|
|
231
|
+
threading.Thread(target=deliverer, daemon=True).start()
|
|
232
|
+
return pending
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Windows Edit tool: pure-Python diff applier (no ``patch`` binary).
|
|
2
|
+
|
|
3
|
+
Windows does not ship ``patch.exe``, so the diff/patch mode of
|
|
4
|
+
:class:`Edit` (which shells out to ``patch --forward``) fails on a
|
|
5
|
+
stock Windows install. The pure-Python applier (``diffapply.py``)
|
|
6
|
+
parses the diff structurally and matches hunks with fuzz, so the same
|
|
7
|
+
diffs apply on Windows, macOS, and Linux.
|
|
8
|
+
|
|
9
|
+
Only the diff-application strategy differs; the tool name stays
|
|
10
|
+
``Edit`` so callers, the tool registry and the plan-mode write guard
|
|
11
|
+
are platform-independent.
|
|
12
|
+
|
|
13
|
+
The implementation is identical to :class:`EditMac` — both platforms
|
|
14
|
+
lack a reliable ``patch`` binary and use the same Python applier.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import os
|
|
20
|
+
|
|
21
|
+
from ..diffrender import unified_diff
|
|
22
|
+
from .base import ToolContext
|
|
23
|
+
from .diffapply import apply_unified_diff
|
|
24
|
+
from .edit import Edit, _strip_diff_fence
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class EditWindows(Edit):
|
|
28
|
+
"""Edit with a pure-Python diff/patch backend for Windows."""
|
|
29
|
+
|
|
30
|
+
def _apply_patch(self, path: str, cwd: str, diff: str, ctx: ToolContext) -> str:
|
|
31
|
+
text = diff if diff.endswith("\n") else diff + "\n"
|
|
32
|
+
text = _strip_diff_fence(text)
|
|
33
|
+
is_file = os.path.isfile(path)
|
|
34
|
+
old_content = None
|
|
35
|
+
if is_file:
|
|
36
|
+
try:
|
|
37
|
+
with open(path, encoding="utf-8", errors="replace") as f:
|
|
38
|
+
old_content = f.read()
|
|
39
|
+
except OSError:
|
|
40
|
+
old_content = None
|
|
41
|
+
ok, msg = apply_unified_diff(text, cwd, path if is_file else None)
|
|
42
|
+
if not ok:
|
|
43
|
+
return f"Error: Failed to apply diff to {path}.\n{msg}"
|
|
44
|
+
if old_content is not None and os.path.isfile(path):
|
|
45
|
+
try:
|
|
46
|
+
with open(path, encoding="utf-8", errors="replace") as f:
|
|
47
|
+
new_content = f.read()
|
|
48
|
+
diff_text = unified_diff(old_content, new_content, path)
|
|
49
|
+
if diff_text:
|
|
50
|
+
ctx.record_diff(diff_text)
|
|
51
|
+
except OSError:
|
|
52
|
+
pass
|
|
53
|
+
return (
|
|
54
|
+
f"Diff successfully applied to {path}.\n"
|
|
55
|
+
f"Applied with the built-in Python diff applier.\n{msg}"
|
|
56
|
+
)
|
{python_agent_harness-1.5.5.2 → python_agent_harness-1.5.5.3}/python_agent_harness/tools/glob.py
RENAMED
|
@@ -156,10 +156,10 @@ def _git_glob_results(raw: str, git_root: str, base: str, depth: object) -> str:
|
|
|
156
156
|
"""
|
|
157
157
|
lines = [line for line in raw.split("\0") if line]
|
|
158
158
|
if _natnump(depth):
|
|
159
|
-
rel_base = os.path.relpath(base, git_root)
|
|
159
|
+
rel_base = os.path.relpath(base, git_root).replace(os.sep, "/")
|
|
160
160
|
base_depth = 0 if rel_base == "." else 1 + rel_base.count("/")
|
|
161
161
|
lines = [line for line in lines if line.count("/") < base_depth + depth]
|
|
162
|
-
out = "\n".join(os.path.join(git_root, line) for line in lines)
|
|
162
|
+
out = "\n".join(os.path.join(git_root, line).replace(os.sep, "/") for line in lines)
|
|
163
163
|
if not out:
|
|
164
164
|
return ""
|
|
165
165
|
return _spool(out + "\n", "glob")
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""Windows Glob tool: ``git ls-files`` inside git repos, ``pathlib`` outside.
|
|
2
|
+
|
|
3
|
+
Windows ships neither ``tree`` nor ``find``, so the non-git fallback
|
|
4
|
+
in :class:`GlobTool` (which shells out to ``tree``) and the macOS
|
|
5
|
+
variant :class:`GlobMac` (which shells out to ``find``) both fail on
|
|
6
|
+
a stock Windows install. ``GlobWindows`` replaces that fallback with
|
|
7
|
+
a pure-Python :meth:`pathlib.Path.rglob` approach: Python handles
|
|
8
|
+
directory traversal, pattern matching, and mtime sorting.
|
|
9
|
+
|
|
10
|
+
This is slower than the C-based ``tree``/``find`` on large directory
|
|
11
|
+
trees, but produces identical results and has no external dependencies
|
|
12
|
+
— critical on Windows where neither binary is guaranteed to exist.
|
|
13
|
+
|
|
14
|
+
Only the non-git fallback differs; the git path, the tool name, and
|
|
15
|
+
the result format are inherited unchanged so callers, the tool
|
|
16
|
+
registry, and the plan-mode write guard are platform-independent.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import os
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
|
|
24
|
+
from .base import ToolContext
|
|
25
|
+
from .filesystem import _natnump, _spool
|
|
26
|
+
from .glob import GlobTool
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class GlobWindows(GlobTool):
|
|
30
|
+
"""Glob with a pure-Python ``pathlib.rglob`` non-git fallback for Windows."""
|
|
31
|
+
|
|
32
|
+
def _rglob_fallback(self, pattern: str, base: str, depth: object) -> str:
|
|
33
|
+
"""Use ``pathlib.Path.rglob`` for traversal + matching, Python for mtime sort.
|
|
34
|
+
|
|
35
|
+
Walks the directory tree with :meth:`pathlib.Path.rglob`, filters
|
|
36
|
+
hidden directories (``.git``, etc.), and sorts results by
|
|
37
|
+
modification time (newest first), matching the ``tree --sort=mtime``
|
|
38
|
+
order of the Linux fallback. Symlinks are followed by default
|
|
39
|
+
via ``Path.rglob``.
|
|
40
|
+
"""
|
|
41
|
+
root = Path(base)
|
|
42
|
+
max_depth = depth if _natnump(depth) else None
|
|
43
|
+
|
|
44
|
+
matches: list[tuple[float, str]] = []
|
|
45
|
+
try:
|
|
46
|
+
for p in root.rglob(pattern):
|
|
47
|
+
if not p.is_file():
|
|
48
|
+
continue
|
|
49
|
+
# skip hidden directories (.git, etc.)
|
|
50
|
+
if any(part.startswith(".") for part in p.relative_to(root).parts[:-1]):
|
|
51
|
+
continue
|
|
52
|
+
if max_depth is not None:
|
|
53
|
+
rel_depth = len(p.relative_to(root).parts)
|
|
54
|
+
if rel_depth > max_depth:
|
|
55
|
+
continue
|
|
56
|
+
try:
|
|
57
|
+
mtime = p.stat().st_mtime
|
|
58
|
+
except OSError:
|
|
59
|
+
mtime = 0.0
|
|
60
|
+
matches.append((mtime, str(p)))
|
|
61
|
+
except OSError as e:
|
|
62
|
+
return f"Error: {e}"
|
|
63
|
+
|
|
64
|
+
matches.sort(key=lambda t: t[0], reverse=True)
|
|
65
|
+
out = "\n".join(path for _, path in matches)
|
|
66
|
+
if not out:
|
|
67
|
+
return ""
|
|
68
|
+
return _spool(out + "\n", "glob")
|
|
69
|
+
|
|
70
|
+
def run(self, args: dict, ctx: ToolContext) -> str:
|
|
71
|
+
pattern = args.get("pattern") or ""
|
|
72
|
+
if not pattern:
|
|
73
|
+
return "Error: pattern must not be empty"
|
|
74
|
+
path = args.get("path")
|
|
75
|
+
if path:
|
|
76
|
+
if not (os.path.isdir(path) and os.access(path, os.R_OK)):
|
|
77
|
+
return f"Error: path {path} is not readable"
|
|
78
|
+
else:
|
|
79
|
+
path = ctx.cwd
|
|
80
|
+
base = os.path.realpath(path)
|
|
81
|
+
depth = args.get("depth")
|
|
82
|
+
|
|
83
|
+
from .filesystem import _git_root
|
|
84
|
+
|
|
85
|
+
git_root = _git_root(base)
|
|
86
|
+
|
|
87
|
+
if git_root:
|
|
88
|
+
return super().run(args, ctx)
|
|
89
|
+
|
|
90
|
+
return self._rglob_fallback(pattern, base, depth)
|