wcgw 2.0.4__tar.gz → 2.1.0__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.0}/PKG-INFO +16 -3
  2. {wcgw-2.0.4 → wcgw-2.1.0}/README.md +14 -2
  3. {wcgw-2.0.4 → wcgw-2.1.0}/pyproject.toml +2 -1
  4. {wcgw-2.0.4 → wcgw-2.1.0}/src/wcgw/client/anthropic_client.py +0 -2
  5. {wcgw-2.0.4 → wcgw-2.1.0}/src/wcgw/client/computer_use.py +0 -1
  6. {wcgw-2.0.4 → wcgw-2.1.0}/src/wcgw/client/openai_client.py +0 -2
  7. {wcgw-2.0.4 → wcgw-2.1.0}/src/wcgw/client/tools.py +140 -92
  8. {wcgw-2.0.4 → wcgw-2.1.0}/uv.lock +12 -1
  9. {wcgw-2.0.4 → wcgw-2.1.0}/.github/workflows/python-publish.yml +0 -0
  10. {wcgw-2.0.4 → wcgw-2.1.0}/.github/workflows/python-tests.yml +0 -0
  11. {wcgw-2.0.4 → wcgw-2.1.0}/.github/workflows/python-types.yml +0 -0
  12. {wcgw-2.0.4 → wcgw-2.1.0}/.gitignore +0 -0
  13. {wcgw-2.0.4 → wcgw-2.1.0}/.python-version +0 -0
  14. {wcgw-2.0.4 → wcgw-2.1.0}/.vscode/settings.json +0 -0
  15. {wcgw-2.0.4 → wcgw-2.1.0}/gpt_action_json_schema.json +0 -0
  16. {wcgw-2.0.4 → wcgw-2.1.0}/gpt_instructions.txt +0 -0
  17. {wcgw-2.0.4 → wcgw-2.1.0}/openai.md +0 -0
  18. {wcgw-2.0.4 → wcgw-2.1.0}/src/__init__.py +0 -0
  19. {wcgw-2.0.4 → wcgw-2.1.0}/src/wcgw/__init__.py +0 -0
  20. {wcgw-2.0.4 → wcgw-2.1.0}/src/wcgw/client/__init__.py +0 -0
  21. {wcgw-2.0.4 → wcgw-2.1.0}/src/wcgw/client/__main__.py +0 -0
  22. {wcgw-2.0.4 → wcgw-2.1.0}/src/wcgw/client/cli.py +0 -0
  23. {wcgw-2.0.4 → wcgw-2.1.0}/src/wcgw/client/common.py +0 -0
  24. {wcgw-2.0.4 → wcgw-2.1.0}/src/wcgw/client/diff-instructions.txt +0 -0
  25. {wcgw-2.0.4 → wcgw-2.1.0}/src/wcgw/client/mcp_server/Readme.md +0 -0
  26. {wcgw-2.0.4 → wcgw-2.1.0}/src/wcgw/client/mcp_server/__init__.py +0 -0
  27. {wcgw-2.0.4 → wcgw-2.1.0}/src/wcgw/client/mcp_server/server.py +0 -0
  28. {wcgw-2.0.4 → wcgw-2.1.0}/src/wcgw/client/openai_utils.py +0 -0
  29. {wcgw-2.0.4 → wcgw-2.1.0}/src/wcgw/client/sys_utils.py +0 -0
  30. {wcgw-2.0.4 → wcgw-2.1.0}/src/wcgw/relay/serve.py +0 -0
  31. {wcgw-2.0.4 → wcgw-2.1.0}/src/wcgw/relay/static/privacy.txt +0 -0
  32. {wcgw-2.0.4 → wcgw-2.1.0}/src/wcgw/types_.py +0 -0
  33. {wcgw-2.0.4 → wcgw-2.1.0}/static/claude-ss.jpg +0 -0
  34. {wcgw-2.0.4 → wcgw-2.1.0}/static/computer-use.jpg +0 -0
  35. {wcgw-2.0.4 → wcgw-2.1.0}/static/example.jpg +0 -0
  36. {wcgw-2.0.4 → wcgw-2.1.0}/static/rocket-icon.png +0 -0
  37. {wcgw-2.0.4 → wcgw-2.1.0}/static/ss1.png +0 -0
  38. {wcgw-2.0.4 → wcgw-2.1.0}/tests/test_basic.py +0 -0
  39. {wcgw-2.0.4 → wcgw-2.1.0}/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.0
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.0"
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,6 +1,7 @@
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
@@ -23,6 +24,7 @@ from typing import (
23
24
  TypedDict,
24
25
  )
25
26
  import uuid
27
+ import humanize
26
28
  from pydantic import BaseModel, TypeAdapter
27
29
  import typer
28
30
  from .computer_use import run_computer_tool
@@ -107,19 +109,19 @@ PROMPT = PROMPT_CONST
107
109
 
108
110
  def start_shell() -> pexpect.spawn: # type: ignore
109
111
  try:
110
- SHELL = pexpect.spawn(
112
+ shell = pexpect.spawn(
111
113
  "/bin/bash",
112
114
  env={**os.environ, **{"PS1": PROMPT}}, # type: ignore[arg-type]
113
115
  echo=False,
114
116
  encoding="utf-8",
115
117
  timeout=TIMEOUT,
116
118
  )
117
- SHELL.sendline(f"export PS1={PROMPT}")
119
+ shell.sendline(f"export PS1={PROMPT}")
118
120
  except Exception as e:
119
121
  traceback.print_exc()
120
122
  console.log(f"Error starting shell: {e}. Retrying without rc ...")
121
123
 
122
- SHELL = pexpect.spawn(
124
+ shell = pexpect.spawn(
123
125
  "/bin/bash --noprofile --norc",
124
126
  env={**os.environ, **{"PS1": PROMPT}}, # type: ignore[arg-type]
125
127
  echo=False,
@@ -127,13 +129,10 @@ def start_shell() -> pexpect.spawn: # type: ignore
127
129
  timeout=TIMEOUT,
128
130
  )
129
131
 
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()
132
+ shell.expect(PROMPT, timeout=TIMEOUT)
133
+ shell.sendline("stty -icanon -echo")
134
+ shell.expect(PROMPT, timeout=TIMEOUT)
135
+ return shell
137
136
 
138
137
 
139
138
  def _is_int(mystr: str) -> bool:
@@ -144,26 +143,26 @@ def _is_int(mystr: str) -> bool:
144
143
  return False
145
144
 
146
145
 
147
- def _get_exit_code() -> int:
146
+ def _get_exit_code(shell: pexpect.spawn) -> int: # type: ignore
148
147
  if PROMPT != PROMPT_CONST:
149
148
  return 0
150
149
  # 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)
150
+ shell.sendline(f"export PS1={PROMPT}")
151
+ shell.expect(PROMPT, timeout=0.2)
153
152
  # Reset echo also if it was enabled
154
- SHELL.sendline("stty -icanon -echo")
155
- SHELL.expect(PROMPT, timeout=0.2)
156
- SHELL.sendline("echo $?")
153
+ shell.sendline("stty -icanon -echo")
154
+ shell.expect(PROMPT, timeout=0.2)
155
+ shell.sendline("echo $?")
157
156
  before = ""
158
157
  while not _is_int(before): # Consume all previous output
159
158
  try:
160
- SHELL.expect(PROMPT, timeout=0.2)
159
+ shell.expect(PROMPT, timeout=0.2)
161
160
  except pexpect.TIMEOUT:
162
161
  print(f"Couldn't get exit code, before: {before}")
163
162
  raise
164
- assert isinstance(SHELL.before, str)
163
+ assert isinstance(shell.before, str)
165
164
  # Render because there could be some anscii escape sequences still set like in google colab env
166
- before = render_terminal_output(SHELL.before).strip()
165
+ before = render_terminal_output(shell.before).strip()
167
166
 
168
167
  try:
169
168
  return int((before))
@@ -172,9 +171,71 @@ def _get_exit_code() -> int:
172
171
 
173
172
 
174
173
  BASH_CLF_OUTPUT = Literal["repl", "pending"]
175
- BASH_STATE: BASH_CLF_OUTPUT = "repl"
176
- IS_IN_DOCKER: Optional[str] = ""
177
- CWD = os.getcwd()
174
+
175
+
176
+ class BashState:
177
+ def __init__(self) -> None:
178
+ self._init()
179
+
180
+ def _init(self) -> None:
181
+ self._state: Literal["repl"] | datetime.datetime = "repl"
182
+ self._is_in_docker: Optional[str] = ""
183
+ self._cwd: str = os.getcwd()
184
+ self._shell = start_shell()
185
+
186
+ # Get exit info to ensure shell is ready
187
+ _get_exit_code(self._shell)
188
+
189
+ @property
190
+ def shell(self) -> pexpect.spawn: # type: ignore
191
+ return self._shell
192
+
193
+ def set_pending(self) -> None:
194
+ if not isinstance(self._state, datetime.datetime):
195
+ self._state = datetime.datetime.now()
196
+
197
+ def set_repl(self) -> None:
198
+ self._state = "repl"
199
+
200
+ @property
201
+ def state(self) -> BASH_CLF_OUTPUT:
202
+ if self._state == "repl":
203
+ return "repl"
204
+ return "pending"
205
+
206
+ @property
207
+ def is_in_docker(self) -> Optional[str]:
208
+ return self._is_in_docker
209
+
210
+ def set_in_docker(self, docker_image_id: str) -> None:
211
+ self._is_in_docker = docker_image_id
212
+
213
+ @property
214
+ def cwd(self) -> str:
215
+ return self._cwd
216
+
217
+ def update_cwd(self) -> str:
218
+ BASH_STATE.shell.sendline("pwd")
219
+ BASH_STATE.shell.expect(PROMPT, timeout=0.2)
220
+ assert isinstance(BASH_STATE.shell.before, str)
221
+ current_dir = render_terminal_output(BASH_STATE.shell.before).strip()
222
+ self._cwd = current_dir
223
+ return current_dir
224
+
225
+ def reset(self) -> None:
226
+ self.shell.close(True)
227
+ self._init()
228
+
229
+ def get_pending_for(self) -> str:
230
+ if isinstance(self._state, datetime.datetime):
231
+ timedelta = datetime.datetime.now() - self._state
232
+ return humanize.naturaldelta(
233
+ timedelta + datetime.timedelta(seconds=TIMEOUT)
234
+ )
235
+ return "Not pending"
236
+
237
+
238
+ BASH_STATE = BashState()
178
239
 
179
240
 
180
241
  def initial_info() -> str:
@@ -183,18 +244,13 @@ def initial_info() -> str:
183
244
  return f"""
184
245
  System: {uname_sysname}
185
246
  Machine: {uname_machine}
186
- Current working directory: {CWD}
247
+ Current working directory: {BASH_STATE.cwd}
187
248
  wcgw version: {importlib.metadata.version("wcgw")}
188
249
  """
189
250
 
190
251
 
191
252
  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()
253
+ BASH_STATE.reset()
198
254
  return "Reset successful" + get_status()
199
255
 
200
256
 
@@ -209,11 +265,11 @@ WAITING_INPUT_MESSAGE = """A command is already running. NOTE: You can't run mul
209
265
  def update_repl_prompt(command: str) -> bool:
210
266
  global PROMPT
211
267
  if re.match(r"^wcgw_update_prompt\(\)$", command.strip()):
212
- SHELL.sendintr()
213
- index = SHELL.expect([PROMPT, pexpect.TIMEOUT], timeout=0.2)
268
+ BASH_STATE.shell.sendintr()
269
+ index = BASH_STATE.shell.expect([PROMPT, pexpect.TIMEOUT], timeout=0.2)
214
270
  if index == 0:
215
271
  return False
216
- before = SHELL.before or ""
272
+ before = BASH_STATE.shell.before or ""
217
273
  assert before, "Something went wrong updating repl prompt"
218
274
  PROMPT = before.split("\n")[-1].strip()
219
275
  # Escape all regex
@@ -222,33 +278,24 @@ def update_repl_prompt(command: str) -> bool:
222
278
  index = 0
223
279
  while index == 0:
224
280
  # Consume all REPL prompts till now
225
- index = SHELL.expect([PROMPT, pexpect.TIMEOUT], timeout=0.2)
281
+ index = BASH_STATE.shell.expect([PROMPT, pexpect.TIMEOUT], timeout=0.2)
226
282
  print(f"Prompt updated to: {PROMPT}")
227
283
  return True
228
284
  return False
229
285
 
230
286
 
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
287
  def get_status() -> str:
240
- global CWD
241
288
  exit_code: Optional[int] = None
242
289
 
243
290
  status = "\n\n---\n\n"
244
- if BASH_STATE == "pending":
291
+ if BASH_STATE.state == "pending":
245
292
  status += "status = still running\n"
246
- status += "cwd = " + CWD + "\n"
293
+ status += "running for = " + BASH_STATE.get_pending_for() + "\n"
294
+ status += "cwd = " + BASH_STATE.cwd + "\n"
247
295
  else:
248
- exit_code = _get_exit_code()
296
+ exit_code = _get_exit_code(BASH_STATE.shell)
249
297
  status += f"status = exited with code {exit_code}\n"
250
- CWD = get_cwd()
251
- status += "cwd = " + CWD + "\n"
298
+ status += "cwd = " + BASH_STATE.update_cwd() + "\n"
252
299
 
253
300
  return status.rstrip()
254
301
 
@@ -259,13 +306,12 @@ def execute_bash(
259
306
  max_tokens: Optional[int],
260
307
  timeout_s: Optional[float],
261
308
  ) -> tuple[str, float]:
262
- global SHELL, BASH_STATE, CWD
263
309
  try:
264
310
  is_interrupt = False
265
311
  if isinstance(bash_arg, BashCommand):
266
312
  updated_repl_mode = update_repl_prompt(bash_arg.command)
267
313
  if updated_repl_mode:
268
- BASH_STATE = "repl"
314
+ BASH_STATE.set_repl()
269
315
  response = (
270
316
  "Prompt updated, you can execute REPL lines using BashCommand now"
271
317
  )
@@ -276,7 +322,7 @@ def execute_bash(
276
322
  )
277
323
 
278
324
  console.print(f"$ {bash_arg.command}")
279
- if BASH_STATE == "pending":
325
+ if BASH_STATE.state == "pending":
280
326
  raise ValueError(WAITING_INPUT_MESSAGE)
281
327
  command = bash_arg.command.strip()
282
328
 
@@ -285,7 +331,7 @@ def execute_bash(
285
331
  "Command should not contain newline character in middle. Run only one command at a time."
286
332
  )
287
333
 
288
- SHELL.sendline(command)
334
+ BASH_STATE.shell.sendline(command)
289
335
 
290
336
  else:
291
337
  if (
@@ -306,29 +352,29 @@ def execute_bash(
306
352
  console.print(f"Sending special sequence: {bash_arg.send_specials}")
307
353
  for char in bash_arg.send_specials:
308
354
  if char == "Key-up":
309
- SHELL.send("\033[A")
355
+ BASH_STATE.shell.send("\033[A")
310
356
  elif char == "Key-down":
311
- SHELL.send("\033[B")
357
+ BASH_STATE.shell.send("\033[B")
312
358
  elif char == "Key-left":
313
- SHELL.send("\033[D")
359
+ BASH_STATE.shell.send("\033[D")
314
360
  elif char == "Key-right":
315
- SHELL.send("\033[C")
361
+ BASH_STATE.shell.send("\033[C")
316
362
  elif char == "Enter":
317
- SHELL.send("\n")
363
+ BASH_STATE.shell.send("\n")
318
364
  elif char == "Ctrl-c":
319
- SHELL.sendintr()
365
+ BASH_STATE.shell.sendintr()
320
366
  is_interrupt = True
321
367
  elif char == "Ctrl-d":
322
- SHELL.sendintr()
368
+ BASH_STATE.shell.sendintr()
323
369
  is_interrupt = True
324
370
  elif char == "Ctrl-z":
325
- SHELL.send("\x1a")
371
+ BASH_STATE.shell.send("\x1a")
326
372
  else:
327
373
  raise Exception(f"Unknown special character: {char}")
328
374
  elif bash_arg.send_ascii:
329
375
  console.print(f"Sending ASCII sequence: {bash_arg.send_ascii}")
330
376
  for ascii_char in bash_arg.send_ascii:
331
- SHELL.send(chr(ascii_char))
377
+ BASH_STATE.shell.send(chr(ascii_char))
332
378
  if ascii_char == 3:
333
379
  is_interrupt = True
334
380
  else:
@@ -340,7 +386,7 @@ def execute_bash(
340
386
 
341
387
  updated_repl_mode = update_repl_prompt(bash_arg.send_text)
342
388
  if updated_repl_mode:
343
- BASH_STATE = "repl"
389
+ BASH_STATE.set_repl()
344
390
  response = "Prompt updated, you can execute REPL lines using BashCommand now"
345
391
  console.print(response)
346
392
  return (
@@ -348,20 +394,18 @@ def execute_bash(
348
394
  0,
349
395
  )
350
396
  console.print(f"Interact text: {bash_arg.send_text}")
351
- SHELL.sendline(bash_arg.send_text)
352
-
353
- BASH_STATE = "repl"
397
+ BASH_STATE.shell.sendline(bash_arg.send_text)
354
398
 
355
399
  except KeyboardInterrupt:
356
- SHELL.sendintr()
357
- SHELL.expect(PROMPT)
400
+ BASH_STATE.shell.sendintr()
401
+ BASH_STATE.shell.expect(PROMPT)
358
402
  return "---\n\nFailure: user interrupted the execution", 0.0
359
403
 
360
404
  wait = timeout_s or TIMEOUT
361
- index = SHELL.expect([PROMPT, pexpect.TIMEOUT], timeout=wait)
405
+ index = BASH_STATE.shell.expect([PROMPT, pexpect.TIMEOUT], timeout=wait)
362
406
  if index == 1:
363
- BASH_STATE = "pending"
364
- text = SHELL.before or ""
407
+ BASH_STATE.set_pending()
408
+ text = BASH_STATE.shell.before or ""
365
409
 
366
410
  text = render_terminal_output(text[-100_000:])
367
411
  tokens = enc.encode(text)
@@ -386,11 +430,13 @@ Otherwise, you may want to try Ctrl-c again or program specific exit interactive
386
430
 
387
431
  return text, 0
388
432
 
433
+ BASH_STATE.set_repl()
434
+
389
435
  if is_interrupt:
390
436
  return "Interrupt successful", 0.0
391
437
 
392
- assert isinstance(SHELL.before, str)
393
- output = render_terminal_output(SHELL.before)
438
+ assert isinstance(BASH_STATE.shell.before, str)
439
+ output = render_terminal_output(BASH_STATE.shell.before)
394
440
 
395
441
  tokens = enc.encode(output)
396
442
  if max_tokens and len(tokens) >= max_tokens:
@@ -404,8 +450,7 @@ Otherwise, you may want to try Ctrl-c again or program specific exit interactive
404
450
  traceback.print_exc()
405
451
  console.print("Malformed output, restarting shell", style="red")
406
452
  # Malformed output, restart shell
407
- SHELL.close(True)
408
- SHELL = start_shell()
453
+ BASH_STATE.reset()
409
454
  output = "(exit shell has restarted)"
410
455
  return output, 0
411
456
 
@@ -449,8 +494,7 @@ T = TypeVar("T")
449
494
 
450
495
  def ensure_no_previous_output(func: Callable[Param, T]) -> Callable[Param, T]:
451
496
  def wrapper(*args: Param.args, **kwargs: Param.kwargs) -> T:
452
- global BASH_STATE
453
- if BASH_STATE == "pending":
497
+ if BASH_STATE.state == "pending":
454
498
  raise ValueError(WAITING_INPUT_MESSAGE)
455
499
 
456
500
  return func(*args, **kwargs)
@@ -460,9 +504,9 @@ def ensure_no_previous_output(func: Callable[Param, T]) -> Callable[Param, T]:
460
504
 
461
505
  def read_image_from_shell(file_path: str) -> ImageData:
462
506
  if not os.path.isabs(file_path):
463
- file_path = os.path.join(CWD, file_path)
507
+ file_path = os.path.join(BASH_STATE.cwd, file_path)
464
508
 
465
- if not IS_IN_DOCKER:
509
+ if not BASH_STATE.is_in_docker:
466
510
  if not os.path.exists(file_path):
467
511
  raise ValueError(f"File {file_path} does not exist")
468
512
 
@@ -473,7 +517,9 @@ def read_image_from_shell(file_path: str) -> ImageData:
473
517
  return ImageData(media_type=image_type, data=image_b64) # type: ignore
474
518
  else:
475
519
  with TemporaryDirectory() as tmpdir:
476
- rcode = os.system(f"docker cp {IS_IN_DOCKER}:{file_path} {tmpdir}")
520
+ rcode = os.system(
521
+ f"docker cp {BASH_STATE.is_in_docker}:{file_path} {tmpdir}"
522
+ )
477
523
  if rcode != 0:
478
524
  raise Exception(f"Error: Read failed with code {rcode}")
479
525
  path_ = os.path.join(tmpdir, os.path.basename(file_path))
@@ -486,11 +532,11 @@ def read_image_from_shell(file_path: str) -> ImageData:
486
532
 
487
533
  def write_file(writefile: CreateFileNew, error_on_exist: bool) -> str:
488
534
  if not os.path.isabs(writefile.file_path):
489
- return f"Failure: file_path should be absolute path, current working directory is {CWD}"
535
+ return f"Failure: file_path should be absolute path, current working directory is {BASH_STATE.cwd}"
490
536
  else:
491
537
  path_ = writefile.file_path
492
538
 
493
- if not IS_IN_DOCKER:
539
+ if not BASH_STATE.is_in_docker:
494
540
  if error_on_exist and os.path.exists(path_):
495
541
  file_data = Path(path_).read_text()
496
542
  if file_data:
@@ -508,7 +554,7 @@ def write_file(writefile: CreateFileNew, error_on_exist: bool) -> str:
508
554
  if error_on_exist:
509
555
  # Check if it exists using os.system
510
556
  cmd = f"test -f {path_}"
511
- status = os.system(f'docker exec {IS_IN_DOCKER} bash -c "{cmd}"')
557
+ status = os.system(f'docker exec {BASH_STATE.is_in_docker} bash -c "{cmd}"')
512
558
  if status == 0:
513
559
  return f"Error: can't write to existing file {path_}, use other functions to edit the file"
514
560
 
@@ -518,11 +564,13 @@ def write_file(writefile: CreateFileNew, error_on_exist: bool) -> str:
518
564
  f.write(writefile.file_content)
519
565
  os.chmod(tmppath, 0o777)
520
566
  parent_dir = os.path.dirname(path_)
521
- rcode = os.system(f"docker exec {IS_IN_DOCKER} mkdir -p {parent_dir}")
567
+ rcode = os.system(
568
+ f"docker exec {BASH_STATE.is_in_docker} mkdir -p {parent_dir}"
569
+ )
522
570
  if rcode != 0:
523
571
  return f"Error: Write failed with code while creating dirs {rcode}"
524
572
 
525
- rcode = os.system(f"docker cp {tmppath} {IS_IN_DOCKER}:{path_}")
573
+ rcode = os.system(f"docker cp {tmppath} {BASH_STATE.is_in_docker}:{path_}")
526
574
  if rcode != 0:
527
575
  return f"Error: Write failed with code {rcode}"
528
576
 
@@ -599,12 +647,12 @@ def do_diff_edit(fedit: FileEdit) -> str:
599
647
 
600
648
  if not os.path.isabs(fedit.file_path):
601
649
  raise Exception(
602
- f"Failure: file_path should be absolute path, current working directory is {CWD}"
650
+ f"Failure: file_path should be absolute path, current working directory is {BASH_STATE.cwd}"
603
651
  )
604
652
  else:
605
653
  path_ = fedit.file_path
606
654
 
607
- if not IS_IN_DOCKER:
655
+ if not BASH_STATE.is_in_docker:
608
656
  if not os.path.exists(path_):
609
657
  raise Exception(f"Error: file {path_} does not exist")
610
658
 
@@ -613,7 +661,7 @@ def do_diff_edit(fedit: FileEdit) -> str:
613
661
  else:
614
662
  # Copy from docker
615
663
  with TemporaryDirectory() as tmpdir:
616
- rcode = os.system(f"docker cp {IS_IN_DOCKER}:{path_} {tmpdir}")
664
+ rcode = os.system(f"docker cp {BASH_STATE.is_in_docker}:{path_} {tmpdir}")
617
665
  if rcode != 0:
618
666
  raise Exception(f"Error: Read failed with code {rcode}")
619
667
  path_tmp = os.path.join(tmpdir, os.path.basename(path_))
@@ -666,7 +714,7 @@ def do_diff_edit(fedit: FileEdit) -> str:
666
714
  "Error: no valid search-replace blocks found, please check your syntax for FileEdit"
667
715
  )
668
716
 
669
- if not IS_IN_DOCKER:
717
+ if not BASH_STATE.is_in_docker:
670
718
  with open(path_, "w") as f:
671
719
  f.write(apply_diff_to)
672
720
  else:
@@ -676,7 +724,7 @@ def do_diff_edit(fedit: FileEdit) -> str:
676
724
  f.write(apply_diff_to)
677
725
  os.chmod(path_tmp, 0o777)
678
726
  # Copy to docker using docker cp
679
- rcode = os.system(f"docker cp {path_tmp} {IS_IN_DOCKER}:{path_}")
727
+ rcode = os.system(f"docker cp {path_tmp} {BASH_STATE.is_in_docker}:{path_}")
680
728
  if rcode != 0:
681
729
  raise Exception(f"Error: Write failed with code {rcode}")
682
730
 
@@ -870,7 +918,7 @@ def get_tool_output(
870
918
  if imgBs64:
871
919
  console.print("Captured screenshot")
872
920
  outputs.append(ImageData(media_type="image/png", data=imgBs64))
873
- if not IS_IN_DOCKER and isinstance(arg, GetScreenInfo):
921
+ if not BASH_STATE.is_in_docker and isinstance(arg, GetScreenInfo):
874
922
  try:
875
923
  # At this point we should go into the docker env
876
924
  res, _ = execute_bash(
@@ -896,7 +944,7 @@ def get_tool_output(
896
944
  raise Exception(
897
945
  f"Some error happened while going inside docker. I've reset the shell. Please start again. Error {e}"
898
946
  )
899
- IS_IN_DOCKER = arg.docker_image_id
947
+ BASH_STATE.set_in_docker(arg.docker_image_id)
900
948
  return outputs, outputs_cost[1]
901
949
  else:
902
950
  raise ValueError(f"Unknown tool: {arg}")
@@ -1001,9 +1049,9 @@ def read_file(readfile: ReadFile, max_tokens: Optional[int]) -> str:
1001
1049
  console.print(f"Reading file: {readfile.file_path}")
1002
1050
 
1003
1051
  if not os.path.isabs(readfile.file_path):
1004
- return f"Failure: file_path should be absolute path, current working directory is {CWD}"
1052
+ return f"Failure: file_path should be absolute path, current working directory is {BASH_STATE.cwd}"
1005
1053
 
1006
- if not IS_IN_DOCKER:
1054
+ if not BASH_STATE.is_in_docker:
1007
1055
  path = Path(readfile.file_path)
1008
1056
  if not path.exists():
1009
1057
  return f"Error: file {readfile.file_path} does not exist"
@@ -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.0"
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