wcgw 2.0.4__tar.gz → 2.1.1__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.

Potentially problematic release.


This version of wcgw might be problematic. Click here for more details.

Files changed (39) hide show
  1. {wcgw-2.0.4 → wcgw-2.1.1}/PKG-INFO +16 -3
  2. {wcgw-2.0.4 → wcgw-2.1.1}/README.md +14 -2
  3. {wcgw-2.0.4 → wcgw-2.1.1}/pyproject.toml +2 -1
  4. {wcgw-2.0.4 → wcgw-2.1.1}/src/wcgw/client/anthropic_client.py +0 -2
  5. {wcgw-2.0.4 → wcgw-2.1.1}/src/wcgw/client/computer_use.py +0 -1
  6. {wcgw-2.0.4 → wcgw-2.1.1}/src/wcgw/client/openai_client.py +0 -2
  7. {wcgw-2.0.4 → wcgw-2.1.1}/src/wcgw/client/tools.py +150 -94
  8. {wcgw-2.0.4 → wcgw-2.1.1}/uv.lock +12 -1
  9. {wcgw-2.0.4 → wcgw-2.1.1}/.github/workflows/python-publish.yml +0 -0
  10. {wcgw-2.0.4 → wcgw-2.1.1}/.github/workflows/python-tests.yml +0 -0
  11. {wcgw-2.0.4 → wcgw-2.1.1}/.github/workflows/python-types.yml +0 -0
  12. {wcgw-2.0.4 → wcgw-2.1.1}/.gitignore +0 -0
  13. {wcgw-2.0.4 → wcgw-2.1.1}/.python-version +0 -0
  14. {wcgw-2.0.4 → wcgw-2.1.1}/.vscode/settings.json +0 -0
  15. {wcgw-2.0.4 → wcgw-2.1.1}/gpt_action_json_schema.json +0 -0
  16. {wcgw-2.0.4 → wcgw-2.1.1}/gpt_instructions.txt +0 -0
  17. {wcgw-2.0.4 → wcgw-2.1.1}/openai.md +0 -0
  18. {wcgw-2.0.4 → wcgw-2.1.1}/src/__init__.py +0 -0
  19. {wcgw-2.0.4 → wcgw-2.1.1}/src/wcgw/__init__.py +0 -0
  20. {wcgw-2.0.4 → wcgw-2.1.1}/src/wcgw/client/__init__.py +0 -0
  21. {wcgw-2.0.4 → wcgw-2.1.1}/src/wcgw/client/__main__.py +0 -0
  22. {wcgw-2.0.4 → wcgw-2.1.1}/src/wcgw/client/cli.py +0 -0
  23. {wcgw-2.0.4 → wcgw-2.1.1}/src/wcgw/client/common.py +0 -0
  24. {wcgw-2.0.4 → wcgw-2.1.1}/src/wcgw/client/diff-instructions.txt +0 -0
  25. {wcgw-2.0.4 → wcgw-2.1.1}/src/wcgw/client/mcp_server/Readme.md +0 -0
  26. {wcgw-2.0.4 → wcgw-2.1.1}/src/wcgw/client/mcp_server/__init__.py +0 -0
  27. {wcgw-2.0.4 → wcgw-2.1.1}/src/wcgw/client/mcp_server/server.py +0 -0
  28. {wcgw-2.0.4 → wcgw-2.1.1}/src/wcgw/client/openai_utils.py +0 -0
  29. {wcgw-2.0.4 → wcgw-2.1.1}/src/wcgw/client/sys_utils.py +0 -0
  30. {wcgw-2.0.4 → wcgw-2.1.1}/src/wcgw/relay/serve.py +0 -0
  31. {wcgw-2.0.4 → wcgw-2.1.1}/src/wcgw/relay/static/privacy.txt +0 -0
  32. {wcgw-2.0.4 → wcgw-2.1.1}/src/wcgw/types_.py +0 -0
  33. {wcgw-2.0.4 → wcgw-2.1.1}/static/claude-ss.jpg +0 -0
  34. {wcgw-2.0.4 → wcgw-2.1.1}/static/computer-use.jpg +0 -0
  35. {wcgw-2.0.4 → wcgw-2.1.1}/static/example.jpg +0 -0
  36. {wcgw-2.0.4 → wcgw-2.1.1}/static/rocket-icon.png +0 -0
  37. {wcgw-2.0.4 → wcgw-2.1.1}/static/ss1.png +0 -0
  38. {wcgw-2.0.4 → wcgw-2.1.1}/tests/test_basic.py +0 -0
  39. {wcgw-2.0.4 → wcgw-2.1.1}/tests/test_tools.py +0 -0
@@ -1,12 +1,13 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: wcgw
3
- Version: 2.0.4
3
+ Version: 2.1.1
4
4
  Summary: Shell and coding agent on claude and chatgpt
5
5
  Project-URL: Homepage, https://github.com/rusiaaman/wcgw
6
6
  Author-email: Aman Rusia <gapypi@arcfu.com>
7
7
  Requires-Python: <3.13,>=3.11
8
8
  Requires-Dist: anthropic>=0.39.0
9
9
  Requires-Dist: fastapi>=0.115.0
10
+ Requires-Dist: humanize>=4.11.0
10
11
  Requires-Dist: mcp
11
12
  Requires-Dist: mypy>=1.11.2
12
13
  Requires-Dist: nltk>=3.9.1
@@ -52,6 +53,7 @@ Description-Content-Type: text/markdown
52
53
  - ⚡ **REPL support**: [beta] Supports python/node and other REPL execution.
53
54
 
54
55
  ## Top use cases examples
56
+
55
57
  - Solve problem X using python, create and run test cases and fix any issues. Do it in a temporary directory
56
58
  - Find instances of code with X behavior in my repository
57
59
  - Git clone https://github.com/my/repo in my home directory, then understand the project, set up the environment and build
@@ -92,6 +94,13 @@ Then update `claude_desktop_config.json` (~/Library/Application Support/Claude/c
92
94
 
93
95
  Then restart claude app.
94
96
 
97
+ _If there's an error in setting up_
98
+
99
+ - Make sure `uv` in the system PATH by running `uv --version` and also ensure `uv tool run wcgw --version` works globally.
100
+ Otherwise, re-install uv and follow instructions to add it into your .zshrc or .bashrc
101
+ - If there's still an issue, check that `uv tool run --from wcgw@latest --python 3.12 wcgw_mcp` runs in your terminal. It should have no output and shouldn't exit.
102
+ - Debug the mcp server using `npx @modelcontextprotocol/inspector@0.1.7 uv tool run --from wcgw@latest --python 3.12 wcgw_mcp`
103
+
95
104
  ### [Optional] Computer use support using desktop on docker
96
105
 
97
106
  Computer use is disabled by default. Add `--computer-use` to enable it. This will add necessary tools to Claude including ScreenShot, Mouse and Keyboard control.
@@ -128,6 +137,12 @@ Then ask claude desktop app to control the docker os. It'll connect to the docke
128
137
 
129
138
  Connect to `http://localhost:6080/vnc.html` for desktop view (VNC) of the system running in the docker.
130
139
 
140
+ The following requirements should be installed and working in the linux docker image:
141
+
142
+ 1. Needs `xdotool` to execute commands on the desktop.
143
+ 2. Needs `scrot` to take screenshots.
144
+ 3. Needs `convert` from imagemagick to convert images.
145
+
131
146
  ## Usage
132
147
 
133
148
  Wait for a few seconds. You should be able to see this icon if everything goes right.
@@ -141,7 +156,6 @@ Then ask claude to execute shell commands, read files, edit files, run your code
141
156
 
142
157
  If you've run the docker for LLM to access, you can ask it to control the "docker os". If you don't provide the docker container id to it, it'll try to search for available docker using `docker ps` command.
143
158
 
144
-
145
159
  ## Chatgpt Setup
146
160
 
147
161
  Read here: https://github.com/rusiaaman/wcgw/blob/main/openai.md
@@ -156,7 +170,6 @@ Read here: https://github.com/rusiaaman/wcgw/blob/main/openai.md
156
170
 
157
171
  ![example](https://github.com/rusiaaman/wcgw/blob/main/static/example.jpg?raw=true)
158
172
 
159
-
160
173
  ## [Optional] Local shell access with openai API key or anthropic API key
161
174
 
162
175
  ### Openai
@@ -23,6 +23,7 @@
23
23
  - ⚡ **REPL support**: [beta] Supports python/node and other REPL execution.
24
24
 
25
25
  ## Top use cases examples
26
+
26
27
  - Solve problem X using python, create and run test cases and fix any issues. Do it in a temporary directory
27
28
  - Find instances of code with X behavior in my repository
28
29
  - Git clone https://github.com/my/repo in my home directory, then understand the project, set up the environment and build
@@ -63,6 +64,13 @@ Then update `claude_desktop_config.json` (~/Library/Application Support/Claude/c
63
64
 
64
65
  Then restart claude app.
65
66
 
67
+ _If there's an error in setting up_
68
+
69
+ - Make sure `uv` in the system PATH by running `uv --version` and also ensure `uv tool run wcgw --version` works globally.
70
+ Otherwise, re-install uv and follow instructions to add it into your .zshrc or .bashrc
71
+ - If there's still an issue, check that `uv tool run --from wcgw@latest --python 3.12 wcgw_mcp` runs in your terminal. It should have no output and shouldn't exit.
72
+ - Debug the mcp server using `npx @modelcontextprotocol/inspector@0.1.7 uv tool run --from wcgw@latest --python 3.12 wcgw_mcp`
73
+
66
74
  ### [Optional] Computer use support using desktop on docker
67
75
 
68
76
  Computer use is disabled by default. Add `--computer-use` to enable it. This will add necessary tools to Claude including ScreenShot, Mouse and Keyboard control.
@@ -99,6 +107,12 @@ Then ask claude desktop app to control the docker os. It'll connect to the docke
99
107
 
100
108
  Connect to `http://localhost:6080/vnc.html` for desktop view (VNC) of the system running in the docker.
101
109
 
110
+ The following requirements should be installed and working in the linux docker image:
111
+
112
+ 1. Needs `xdotool` to execute commands on the desktop.
113
+ 2. Needs `scrot` to take screenshots.
114
+ 3. Needs `convert` from imagemagick to convert images.
115
+
102
116
  ## Usage
103
117
 
104
118
  Wait for a few seconds. You should be able to see this icon if everything goes right.
@@ -112,7 +126,6 @@ Then ask claude to execute shell commands, read files, edit files, run your code
112
126
 
113
127
  If you've run the docker for LLM to access, you can ask it to control the "docker os". If you don't provide the docker container id to it, it'll try to search for available docker using `docker ps` command.
114
128
 
115
-
116
129
  ## Chatgpt Setup
117
130
 
118
131
  Read here: https://github.com/rusiaaman/wcgw/blob/main/openai.md
@@ -127,7 +140,6 @@ Read here: https://github.com/rusiaaman/wcgw/blob/main/openai.md
127
140
 
128
141
  ![example](https://github.com/rusiaaman/wcgw/blob/main/static/example.jpg?raw=true)
129
142
 
130
-
131
143
  ## [Optional] Local shell access with openai API key or anthropic API key
132
144
 
133
145
  ### Openai
@@ -1,7 +1,7 @@
1
1
  [project]
2
2
  authors = [{ name = "Aman Rusia", email = "gapypi@arcfu.com" }]
3
3
  name = "wcgw"
4
- version = "2.0.4"
4
+ version = "2.1.1"
5
5
  description = "Shell and coding agent on claude and chatgpt"
6
6
  readme = "README.md"
7
7
  requires-python = ">=3.11, <3.13"
@@ -26,6 +26,7 @@ dependencies = [
26
26
  "nltk>=3.9.1",
27
27
  "anthropic>=0.39.0",
28
28
  "mcp",
29
+ "humanize>=4.11.0",
29
30
  ]
30
31
 
31
32
  [project.urls]
@@ -44,8 +44,6 @@ from .computer_use import Computer
44
44
  from .tools import (
45
45
  DoneFlag,
46
46
  get_tool_output,
47
- SHELL,
48
- start_shell,
49
47
  which_tool_name,
50
48
  )
51
49
  import tiktoken
@@ -133,7 +133,6 @@ class ComputerTool:
133
133
  """
134
134
 
135
135
  name: Literal["computer"] = "computer"
136
- api_type: Literal["computer_20241022"] = "computer_20241022"
137
136
  width: Optional[int]
138
137
  height: Optional[int]
139
138
  display_num: Optional[int]
@@ -38,8 +38,6 @@ from .tools import ImageData
38
38
  from .tools import (
39
39
  DoneFlag,
40
40
  get_tool_output,
41
- SHELL,
42
- start_shell,
43
41
  which_tool,
44
42
  )
45
43
  import tiktoken
@@ -1,11 +1,13 @@
1
1
  import asyncio
2
2
  import base64
3
3
  from concurrent.futures import ThreadPoolExecutor, as_completed
4
+ import datetime
4
5
  from io import BytesIO
5
6
  import json
6
7
  import mimetypes
7
8
  from pathlib import Path
8
9
  import re
10
+ import shlex
9
11
  import sys
10
12
  import threading
11
13
  import importlib.metadata
@@ -23,6 +25,7 @@ from typing import (
23
25
  TypedDict,
24
26
  )
25
27
  import uuid
28
+ import humanize
26
29
  from pydantic import BaseModel, TypeAdapter
27
30
  import typer
28
31
  from .computer_use import run_computer_tool
@@ -107,19 +110,19 @@ PROMPT = PROMPT_CONST
107
110
 
108
111
  def start_shell() -> pexpect.spawn: # type: ignore
109
112
  try:
110
- SHELL = pexpect.spawn(
113
+ shell = pexpect.spawn(
111
114
  "/bin/bash",
112
115
  env={**os.environ, **{"PS1": PROMPT}}, # type: ignore[arg-type]
113
116
  echo=False,
114
117
  encoding="utf-8",
115
118
  timeout=TIMEOUT,
116
119
  )
117
- SHELL.sendline(f"export PS1={PROMPT}")
120
+ shell.sendline(f"export PS1={PROMPT}")
118
121
  except Exception as e:
119
122
  traceback.print_exc()
120
123
  console.log(f"Error starting shell: {e}. Retrying without rc ...")
121
124
 
122
- SHELL = pexpect.spawn(
125
+ shell = pexpect.spawn(
123
126
  "/bin/bash --noprofile --norc",
124
127
  env={**os.environ, **{"PS1": PROMPT}}, # type: ignore[arg-type]
125
128
  echo=False,
@@ -127,13 +130,10 @@ def start_shell() -> pexpect.spawn: # type: ignore
127
130
  timeout=TIMEOUT,
128
131
  )
129
132
 
130
- SHELL.expect(PROMPT, timeout=TIMEOUT)
131
- SHELL.sendline("stty -icanon -echo")
132
- SHELL.expect(PROMPT, timeout=TIMEOUT)
133
- return SHELL
134
-
135
-
136
- SHELL = start_shell()
133
+ shell.expect(PROMPT, timeout=TIMEOUT)
134
+ shell.sendline("stty -icanon -echo")
135
+ shell.expect(PROMPT, timeout=TIMEOUT)
136
+ return shell
137
137
 
138
138
 
139
139
  def _is_int(mystr: str) -> bool:
@@ -144,26 +144,26 @@ def _is_int(mystr: str) -> bool:
144
144
  return False
145
145
 
146
146
 
147
- def _get_exit_code() -> int:
147
+ def _get_exit_code(shell: pexpect.spawn) -> int: # type: ignore
148
148
  if PROMPT != PROMPT_CONST:
149
149
  return 0
150
150
  # First reset the prompt in case venv was sourced or other reasons.
151
- SHELL.sendline(f"export PS1={PROMPT}")
152
- SHELL.expect(PROMPT, timeout=0.2)
151
+ shell.sendline(f"export PS1={PROMPT}")
152
+ shell.expect(PROMPT, timeout=0.2)
153
153
  # Reset echo also if it was enabled
154
- SHELL.sendline("stty -icanon -echo")
155
- SHELL.expect(PROMPT, timeout=0.2)
156
- SHELL.sendline("echo $?")
154
+ shell.sendline("stty -icanon -echo")
155
+ shell.expect(PROMPT, timeout=0.2)
156
+ shell.sendline("echo $?")
157
157
  before = ""
158
158
  while not _is_int(before): # Consume all previous output
159
159
  try:
160
- SHELL.expect(PROMPT, timeout=0.2)
160
+ shell.expect(PROMPT, timeout=0.2)
161
161
  except pexpect.TIMEOUT:
162
162
  print(f"Couldn't get exit code, before: {before}")
163
163
  raise
164
- assert isinstance(SHELL.before, str)
164
+ assert isinstance(shell.before, str)
165
165
  # Render because there could be some anscii escape sequences still set like in google colab env
166
- before = render_terminal_output(SHELL.before).strip()
166
+ before = render_terminal_output(shell.before).strip()
167
167
 
168
168
  try:
169
169
  return int((before))
@@ -172,9 +172,71 @@ def _get_exit_code() -> int:
172
172
 
173
173
 
174
174
  BASH_CLF_OUTPUT = Literal["repl", "pending"]
175
- BASH_STATE: BASH_CLF_OUTPUT = "repl"
176
- IS_IN_DOCKER: Optional[str] = ""
177
- CWD = os.getcwd()
175
+
176
+
177
+ class BashState:
178
+ def __init__(self) -> None:
179
+ self._init()
180
+
181
+ def _init(self) -> None:
182
+ self._state: Literal["repl"] | datetime.datetime = "repl"
183
+ self._is_in_docker: Optional[str] = ""
184
+ self._cwd: str = os.getcwd()
185
+ self._shell = start_shell()
186
+
187
+ # Get exit info to ensure shell is ready
188
+ _get_exit_code(self._shell)
189
+
190
+ @property
191
+ def shell(self) -> pexpect.spawn: # type: ignore
192
+ return self._shell
193
+
194
+ def set_pending(self) -> None:
195
+ if not isinstance(self._state, datetime.datetime):
196
+ self._state = datetime.datetime.now()
197
+
198
+ def set_repl(self) -> None:
199
+ self._state = "repl"
200
+
201
+ @property
202
+ def state(self) -> BASH_CLF_OUTPUT:
203
+ if self._state == "repl":
204
+ return "repl"
205
+ return "pending"
206
+
207
+ @property
208
+ def is_in_docker(self) -> Optional[str]:
209
+ return self._is_in_docker
210
+
211
+ def set_in_docker(self, docker_image_id: str) -> None:
212
+ self._is_in_docker = docker_image_id
213
+
214
+ @property
215
+ def cwd(self) -> str:
216
+ return self._cwd
217
+
218
+ def update_cwd(self) -> str:
219
+ BASH_STATE.shell.sendline("pwd")
220
+ BASH_STATE.shell.expect(PROMPT, timeout=0.2)
221
+ assert isinstance(BASH_STATE.shell.before, str)
222
+ current_dir = render_terminal_output(BASH_STATE.shell.before).strip()
223
+ self._cwd = current_dir
224
+ return current_dir
225
+
226
+ def reset(self) -> None:
227
+ self.shell.close(True)
228
+ self._init()
229
+
230
+ def get_pending_for(self) -> str:
231
+ if isinstance(self._state, datetime.datetime):
232
+ timedelta = datetime.datetime.now() - self._state
233
+ return humanize.naturaldelta(
234
+ timedelta + datetime.timedelta(seconds=TIMEOUT)
235
+ )
236
+ return "Not pending"
237
+
238
+
239
+ BASH_STATE = BashState()
178
240
 
179
241
 
180
242
  def initial_info() -> str:
@@ -183,18 +245,13 @@ def initial_info() -> str:
183
245
  return f"""
184
246
  System: {uname_sysname}
185
247
  Machine: {uname_machine}
186
- Current working directory: {CWD}
248
+ Current working directory: {BASH_STATE.cwd}
187
249
  wcgw version: {importlib.metadata.version("wcgw")}
188
250
  """
189
251
 
190
252
 
191
253
  def reset_shell() -> str:
192
- global SHELL, BASH_STATE, CWD, IS_IN_DOCKER
193
- SHELL.close(True)
194
- SHELL = start_shell()
195
- BASH_STATE = "repl"
196
- IS_IN_DOCKER = ""
197
- CWD = os.getcwd()
254
+ BASH_STATE.reset()
198
255
  return "Reset successful" + get_status()
199
256
 
200
257
 
@@ -209,11 +266,11 @@ WAITING_INPUT_MESSAGE = """A command is already running. NOTE: You can't run mul
209
266
  def update_repl_prompt(command: str) -> bool:
210
267
  global PROMPT
211
268
  if re.match(r"^wcgw_update_prompt\(\)$", command.strip()):
212
- SHELL.sendintr()
213
- index = SHELL.expect([PROMPT, pexpect.TIMEOUT], timeout=0.2)
269
+ BASH_STATE.shell.sendintr()
270
+ index = BASH_STATE.shell.expect([PROMPT, pexpect.TIMEOUT], timeout=0.2)
214
271
  if index == 0:
215
272
  return False
216
- before = SHELL.before or ""
273
+ before = BASH_STATE.shell.before or ""
217
274
  assert before, "Something went wrong updating repl prompt"
218
275
  PROMPT = before.split("\n")[-1].strip()
219
276
  # Escape all regex
@@ -222,33 +279,24 @@ def update_repl_prompt(command: str) -> bool:
222
279
  index = 0
223
280
  while index == 0:
224
281
  # Consume all REPL prompts till now
225
- index = SHELL.expect([PROMPT, pexpect.TIMEOUT], timeout=0.2)
282
+ index = BASH_STATE.shell.expect([PROMPT, pexpect.TIMEOUT], timeout=0.2)
226
283
  print(f"Prompt updated to: {PROMPT}")
227
284
  return True
228
285
  return False
229
286
 
230
287
 
231
- def get_cwd() -> str:
232
- SHELL.sendline("pwd")
233
- SHELL.expect(PROMPT, timeout=0.2)
234
- assert isinstance(SHELL.before, str)
235
- current_dir = render_terminal_output(SHELL.before).strip()
236
- return current_dir
237
-
238
-
239
288
  def get_status() -> str:
240
- global CWD
241
289
  exit_code: Optional[int] = None
242
290
 
243
291
  status = "\n\n---\n\n"
244
- if BASH_STATE == "pending":
292
+ if BASH_STATE.state == "pending":
245
293
  status += "status = still running\n"
246
- status += "cwd = " + CWD + "\n"
294
+ status += "running for = " + BASH_STATE.get_pending_for() + "\n"
295
+ status += "cwd = " + BASH_STATE.cwd + "\n"
247
296
  else:
248
- exit_code = _get_exit_code()
297
+ exit_code = _get_exit_code(BASH_STATE.shell)
249
298
  status += f"status = exited with code {exit_code}\n"
250
- CWD = get_cwd()
251
- status += "cwd = " + CWD + "\n"
299
+ status += "cwd = " + BASH_STATE.update_cwd() + "\n"
252
300
 
253
301
  return status.rstrip()
254
302
 
@@ -259,13 +307,12 @@ def execute_bash(
259
307
  max_tokens: Optional[int],
260
308
  timeout_s: Optional[float],
261
309
  ) -> tuple[str, float]:
262
- global SHELL, BASH_STATE, CWD
263
310
  try:
264
311
  is_interrupt = False
265
312
  if isinstance(bash_arg, BashCommand):
266
313
  updated_repl_mode = update_repl_prompt(bash_arg.command)
267
314
  if updated_repl_mode:
268
- BASH_STATE = "repl"
315
+ BASH_STATE.set_repl()
269
316
  response = (
270
317
  "Prompt updated, you can execute REPL lines using BashCommand now"
271
318
  )
@@ -276,7 +323,7 @@ def execute_bash(
276
323
  )
277
324
 
278
325
  console.print(f"$ {bash_arg.command}")
279
- if BASH_STATE == "pending":
326
+ if BASH_STATE.state == "pending":
280
327
  raise ValueError(WAITING_INPUT_MESSAGE)
281
328
  command = bash_arg.command.strip()
282
329
 
@@ -285,7 +332,7 @@ def execute_bash(
285
332
  "Command should not contain newline character in middle. Run only one command at a time."
286
333
  )
287
334
 
288
- SHELL.sendline(command)
335
+ BASH_STATE.shell.sendline(command)
289
336
 
290
337
  else:
291
338
  if (
@@ -306,29 +353,29 @@ def execute_bash(
306
353
  console.print(f"Sending special sequence: {bash_arg.send_specials}")
307
354
  for char in bash_arg.send_specials:
308
355
  if char == "Key-up":
309
- SHELL.send("\033[A")
356
+ BASH_STATE.shell.send("\033[A")
310
357
  elif char == "Key-down":
311
- SHELL.send("\033[B")
358
+ BASH_STATE.shell.send("\033[B")
312
359
  elif char == "Key-left":
313
- SHELL.send("\033[D")
360
+ BASH_STATE.shell.send("\033[D")
314
361
  elif char == "Key-right":
315
- SHELL.send("\033[C")
362
+ BASH_STATE.shell.send("\033[C")
316
363
  elif char == "Enter":
317
- SHELL.send("\n")
364
+ BASH_STATE.shell.send("\n")
318
365
  elif char == "Ctrl-c":
319
- SHELL.sendintr()
366
+ BASH_STATE.shell.sendintr()
320
367
  is_interrupt = True
321
368
  elif char == "Ctrl-d":
322
- SHELL.sendintr()
369
+ BASH_STATE.shell.sendintr()
323
370
  is_interrupt = True
324
371
  elif char == "Ctrl-z":
325
- SHELL.send("\x1a")
372
+ BASH_STATE.shell.send("\x1a")
326
373
  else:
327
374
  raise Exception(f"Unknown special character: {char}")
328
375
  elif bash_arg.send_ascii:
329
376
  console.print(f"Sending ASCII sequence: {bash_arg.send_ascii}")
330
377
  for ascii_char in bash_arg.send_ascii:
331
- SHELL.send(chr(ascii_char))
378
+ BASH_STATE.shell.send(chr(ascii_char))
332
379
  if ascii_char == 3:
333
380
  is_interrupt = True
334
381
  else:
@@ -340,7 +387,7 @@ def execute_bash(
340
387
 
341
388
  updated_repl_mode = update_repl_prompt(bash_arg.send_text)
342
389
  if updated_repl_mode:
343
- BASH_STATE = "repl"
390
+ BASH_STATE.set_repl()
344
391
  response = "Prompt updated, you can execute REPL lines using BashCommand now"
345
392
  console.print(response)
346
393
  return (
@@ -348,20 +395,18 @@ def execute_bash(
348
395
  0,
349
396
  )
350
397
  console.print(f"Interact text: {bash_arg.send_text}")
351
- SHELL.sendline(bash_arg.send_text)
352
-
353
- BASH_STATE = "repl"
398
+ BASH_STATE.shell.sendline(bash_arg.send_text)
354
399
 
355
400
  except KeyboardInterrupt:
356
- SHELL.sendintr()
357
- SHELL.expect(PROMPT)
401
+ BASH_STATE.shell.sendintr()
402
+ BASH_STATE.shell.expect(PROMPT)
358
403
  return "---\n\nFailure: user interrupted the execution", 0.0
359
404
 
360
405
  wait = timeout_s or TIMEOUT
361
- index = SHELL.expect([PROMPT, pexpect.TIMEOUT], timeout=wait)
406
+ index = BASH_STATE.shell.expect([PROMPT, pexpect.TIMEOUT], timeout=wait)
362
407
  if index == 1:
363
- BASH_STATE = "pending"
364
- text = SHELL.before or ""
408
+ BASH_STATE.set_pending()
409
+ text = BASH_STATE.shell.before or ""
365
410
 
366
411
  text = render_terminal_output(text[-100_000:])
367
412
  tokens = enc.encode(text)
@@ -386,11 +431,13 @@ Otherwise, you may want to try Ctrl-c again or program specific exit interactive
386
431
 
387
432
  return text, 0
388
433
 
434
+ BASH_STATE.set_repl()
435
+
389
436
  if is_interrupt:
390
437
  return "Interrupt successful", 0.0
391
438
 
392
- assert isinstance(SHELL.before, str)
393
- output = render_terminal_output(SHELL.before)
439
+ assert isinstance(BASH_STATE.shell.before, str)
440
+ output = render_terminal_output(BASH_STATE.shell.before)
394
441
 
395
442
  tokens = enc.encode(output)
396
443
  if max_tokens and len(tokens) >= max_tokens:
@@ -404,8 +451,7 @@ Otherwise, you may want to try Ctrl-c again or program specific exit interactive
404
451
  traceback.print_exc()
405
452
  console.print("Malformed output, restarting shell", style="red")
406
453
  # Malformed output, restart shell
407
- SHELL.close(True)
408
- SHELL = start_shell()
454
+ BASH_STATE.reset()
409
455
  output = "(exit shell has restarted)"
410
456
  return output, 0
411
457
 
@@ -449,8 +495,7 @@ T = TypeVar("T")
449
495
 
450
496
  def ensure_no_previous_output(func: Callable[Param, T]) -> Callable[Param, T]:
451
497
  def wrapper(*args: Param.args, **kwargs: Param.kwargs) -> T:
452
- global BASH_STATE
453
- if BASH_STATE == "pending":
498
+ if BASH_STATE.state == "pending":
454
499
  raise ValueError(WAITING_INPUT_MESSAGE)
455
500
 
456
501
  return func(*args, **kwargs)
@@ -460,9 +505,9 @@ def ensure_no_previous_output(func: Callable[Param, T]) -> Callable[Param, T]:
460
505
 
461
506
  def read_image_from_shell(file_path: str) -> ImageData:
462
507
  if not os.path.isabs(file_path):
463
- file_path = os.path.join(CWD, file_path)
508
+ file_path = os.path.join(BASH_STATE.cwd, file_path)
464
509
 
465
- if not IS_IN_DOCKER:
510
+ if not BASH_STATE.is_in_docker:
466
511
  if not os.path.exists(file_path):
467
512
  raise ValueError(f"File {file_path} does not exist")
468
513
 
@@ -473,7 +518,9 @@ def read_image_from_shell(file_path: str) -> ImageData:
473
518
  return ImageData(media_type=image_type, data=image_b64) # type: ignore
474
519
  else:
475
520
  with TemporaryDirectory() as tmpdir:
476
- rcode = os.system(f"docker cp {IS_IN_DOCKER}:{file_path} {tmpdir}")
521
+ rcode = os.system(
522
+ f"docker cp {BASH_STATE.is_in_docker}:{shlex.quote(file_path)} {tmpdir}"
523
+ )
477
524
  if rcode != 0:
478
525
  raise Exception(f"Error: Read failed with code {rcode}")
479
526
  path_ = os.path.join(tmpdir, os.path.basename(file_path))
@@ -486,11 +533,11 @@ def read_image_from_shell(file_path: str) -> ImageData:
486
533
 
487
534
  def write_file(writefile: CreateFileNew, error_on_exist: bool) -> str:
488
535
  if not os.path.isabs(writefile.file_path):
489
- return f"Failure: file_path should be absolute path, current working directory is {CWD}"
536
+ return f"Failure: file_path should be absolute path, current working directory is {BASH_STATE.cwd}"
490
537
  else:
491
538
  path_ = writefile.file_path
492
539
 
493
- if not IS_IN_DOCKER:
540
+ if not BASH_STATE.is_in_docker:
494
541
  if error_on_exist and os.path.exists(path_):
495
542
  file_data = Path(path_).read_text()
496
543
  if file_data:
@@ -507,8 +554,8 @@ def write_file(writefile: CreateFileNew, error_on_exist: bool) -> str:
507
554
  else:
508
555
  if error_on_exist:
509
556
  # Check if it exists using os.system
510
- cmd = f"test -f {path_}"
511
- status = os.system(f'docker exec {IS_IN_DOCKER} bash -c "{cmd}"')
557
+ cmd = f"test -f {shlex.quote(path_)}"
558
+ status = os.system(f'docker exec {BASH_STATE.is_in_docker} bash -c "{cmd}"')
512
559
  if status == 0:
513
560
  return f"Error: can't write to existing file {path_}, use other functions to edit the file"
514
561
 
@@ -518,11 +565,15 @@ def write_file(writefile: CreateFileNew, error_on_exist: bool) -> str:
518
565
  f.write(writefile.file_content)
519
566
  os.chmod(tmppath, 0o777)
520
567
  parent_dir = os.path.dirname(path_)
521
- rcode = os.system(f"docker exec {IS_IN_DOCKER} mkdir -p {parent_dir}")
568
+ rcode = os.system(
569
+ f"docker exec {BASH_STATE.is_in_docker} mkdir -p {parent_dir}"
570
+ )
522
571
  if rcode != 0:
523
572
  return f"Error: Write failed with code while creating dirs {rcode}"
524
573
 
525
- rcode = os.system(f"docker cp {tmppath} {IS_IN_DOCKER}:{path_}")
574
+ rcode = os.system(
575
+ f"docker cp {shlex.quote(tmppath)} {BASH_STATE.is_in_docker}:{shlex.quote(path_)}"
576
+ )
526
577
  if rcode != 0:
527
578
  return f"Error: Write failed with code {rcode}"
528
579
 
@@ -599,12 +650,12 @@ def do_diff_edit(fedit: FileEdit) -> str:
599
650
 
600
651
  if not os.path.isabs(fedit.file_path):
601
652
  raise Exception(
602
- f"Failure: file_path should be absolute path, current working directory is {CWD}"
653
+ f"Failure: file_path should be absolute path, current working directory is {BASH_STATE.cwd}"
603
654
  )
604
655
  else:
605
656
  path_ = fedit.file_path
606
657
 
607
- if not IS_IN_DOCKER:
658
+ if not BASH_STATE.is_in_docker:
608
659
  if not os.path.exists(path_):
609
660
  raise Exception(f"Error: file {path_} does not exist")
610
661
 
@@ -613,7 +664,9 @@ def do_diff_edit(fedit: FileEdit) -> str:
613
664
  else:
614
665
  # Copy from docker
615
666
  with TemporaryDirectory() as tmpdir:
616
- rcode = os.system(f"docker cp {IS_IN_DOCKER}:{path_} {tmpdir}")
667
+ rcode = os.system(
668
+ f"docker cp {BASH_STATE.is_in_docker}:{shlex.quote(path_)} {tmpdir}"
669
+ )
617
670
  if rcode != 0:
618
671
  raise Exception(f"Error: Read failed with code {rcode}")
619
672
  path_tmp = os.path.join(tmpdir, os.path.basename(path_))
@@ -666,7 +719,7 @@ def do_diff_edit(fedit: FileEdit) -> str:
666
719
  "Error: no valid search-replace blocks found, please check your syntax for FileEdit"
667
720
  )
668
721
 
669
- if not IS_IN_DOCKER:
722
+ if not BASH_STATE.is_in_docker:
670
723
  with open(path_, "w") as f:
671
724
  f.write(apply_diff_to)
672
725
  else:
@@ -676,7 +729,9 @@ def do_diff_edit(fedit: FileEdit) -> str:
676
729
  f.write(apply_diff_to)
677
730
  os.chmod(path_tmp, 0o777)
678
731
  # Copy to docker using docker cp
679
- rcode = os.system(f"docker cp {path_tmp} {IS_IN_DOCKER}:{path_}")
732
+ rcode = os.system(
733
+ f"docker cp {shlex.quote(path_tmp)} {BASH_STATE.is_in_docker}:{shlex.quote(path_)}"
734
+ )
680
735
  if rcode != 0:
681
736
  raise Exception(f"Error: Write failed with code {rcode}")
682
737
 
@@ -870,7 +925,7 @@ def get_tool_output(
870
925
  if imgBs64:
871
926
  console.print("Captured screenshot")
872
927
  outputs.append(ImageData(media_type="image/png", data=imgBs64))
873
- if not IS_IN_DOCKER and isinstance(arg, GetScreenInfo):
928
+ if not BASH_STATE.is_in_docker and isinstance(arg, GetScreenInfo):
874
929
  try:
875
930
  # At this point we should go into the docker env
876
931
  res, _ = execute_bash(
@@ -896,7 +951,7 @@ def get_tool_output(
896
951
  raise Exception(
897
952
  f"Some error happened while going inside docker. I've reset the shell. Please start again. Error {e}"
898
953
  )
899
- IS_IN_DOCKER = arg.docker_image_id
954
+ BASH_STATE.set_in_docker(arg.docker_image_id)
900
955
  return outputs, outputs_cost[1]
901
956
  else:
902
957
  raise ValueError(f"Unknown tool: {arg}")
@@ -1001,9 +1056,9 @@ def read_file(readfile: ReadFile, max_tokens: Optional[int]) -> str:
1001
1056
  console.print(f"Reading file: {readfile.file_path}")
1002
1057
 
1003
1058
  if not os.path.isabs(readfile.file_path):
1004
- return f"Failure: file_path should be absolute path, current working directory is {CWD}"
1059
+ return f"Failure: file_path should be absolute path, current working directory is {BASH_STATE.cwd}"
1005
1060
 
1006
- if not IS_IN_DOCKER:
1061
+ if not BASH_STATE.is_in_docker:
1007
1062
  path = Path(readfile.file_path)
1008
1063
  if not path.exists():
1009
1064
  return f"Error: file {readfile.file_path} does not exist"
@@ -1013,7 +1068,8 @@ def read_file(readfile: ReadFile, max_tokens: Optional[int]) -> str:
1013
1068
 
1014
1069
  else:
1015
1070
  return_code, content, stderr = command_run(
1016
- f"cat {readfile.file_path}", timeout=TIMEOUT
1071
+ f"docker exec {BASH_STATE.is_in_docker} cat {shlex.quote(readfile.file_path)}",
1072
+ timeout=TIMEOUT,
1017
1073
  )
1018
1074
  if return_code != 0:
1019
1075
  raise Exception(
@@ -234,6 +234,15 @@ wheels = [
234
234
  { url = "https://files.pythonhosted.org/packages/e1/9b/a181f281f65d776426002f330c31849b86b31fc9d848db62e16f03ff739f/httpx_sse-0.4.0-py3-none-any.whl", hash = "sha256:f329af6eae57eaa2bdfd962b42524764af68075ea87370a2de920af5341e318f", size = 7819 },
235
235
  ]
236
236
 
237
+ [[package]]
238
+ name = "humanize"
239
+ version = "4.11.0"
240
+ source = { registry = "https://pypi.org/simple" }
241
+ sdist = { url = "https://files.pythonhosted.org/packages/6a/40/64a912b9330786df25e58127194d4a5a7441f818b400b155e748a270f924/humanize-4.11.0.tar.gz", hash = "sha256:e66f36020a2d5a974c504bd2555cf770621dbdbb6d82f94a6857c0b1ea2608be", size = 80374 }
242
+ wheels = [
243
+ { url = "https://files.pythonhosted.org/packages/92/75/4bc3e242ad13f2e6c12e0b0401ab2c5e5c6f0d7da37ec69bc808e24e0ccb/humanize-4.11.0-py3-none-any.whl", hash = "sha256:b53caaec8532bcb2fff70c8826f904c35943f8cecaca29d272d9df38092736c0", size = 128055 },
244
+ ]
245
+
237
246
  [[package]]
238
247
  name = "idna"
239
248
  version = "3.10"
@@ -860,11 +869,12 @@ wheels = [
860
869
 
861
870
  [[package]]
862
871
  name = "wcgw"
863
- version = "2.0.4"
872
+ version = "2.1.1"
864
873
  source = { editable = "." }
865
874
  dependencies = [
866
875
  { name = "anthropic" },
867
876
  { name = "fastapi" },
877
+ { name = "humanize" },
868
878
  { name = "mcp" },
869
879
  { name = "mypy" },
870
880
  { name = "nltk" },
@@ -898,6 +908,7 @@ dev = [
898
908
  requires-dist = [
899
909
  { name = "anthropic", specifier = ">=0.39.0" },
900
910
  { name = "fastapi", specifier = ">=0.115.0" },
911
+ { name = "humanize", specifier = ">=4.11.0" },
901
912
  { name = "mcp", git = "https://github.com/rusiaaman/python-sdk?rev=53b69f397eae6ac81a51b84b34ff52b3119f11cb" },
902
913
  { name = "mypy", specifier = ">=1.11.2" },
903
914
  { name = "nltk", specifier = ">=3.9.1" },
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes