wcgw 2.0.3__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.3 → wcgw-2.1.0}/PKG-INFO +37 -7
  2. {wcgw-2.0.3 → wcgw-2.1.0}/README.md +34 -5
  3. {wcgw-2.0.3 → wcgw-2.1.0}/openai.md +2 -2
  4. {wcgw-2.0.3 → wcgw-2.1.0}/pyproject.toml +3 -2
  5. {wcgw-2.0.3 → wcgw-2.1.0}/src/wcgw/client/anthropic_client.py +0 -2
  6. {wcgw-2.0.3 → wcgw-2.1.0}/src/wcgw/client/computer_use.py +0 -1
  7. {wcgw-2.0.3 → wcgw-2.1.0}/src/wcgw/client/openai_client.py +0 -2
  8. {wcgw-2.0.3 → wcgw-2.1.0}/src/wcgw/client/tools.py +157 -95
  9. {wcgw-2.0.3 → wcgw-2.1.0}/uv.lock +12 -1
  10. {wcgw-2.0.3 → wcgw-2.1.0}/.github/workflows/python-publish.yml +0 -0
  11. {wcgw-2.0.3 → wcgw-2.1.0}/.github/workflows/python-tests.yml +0 -0
  12. {wcgw-2.0.3 → wcgw-2.1.0}/.github/workflows/python-types.yml +0 -0
  13. {wcgw-2.0.3 → wcgw-2.1.0}/.gitignore +0 -0
  14. {wcgw-2.0.3 → wcgw-2.1.0}/.python-version +0 -0
  15. {wcgw-2.0.3 → wcgw-2.1.0}/.vscode/settings.json +0 -0
  16. {wcgw-2.0.3 → wcgw-2.1.0}/gpt_action_json_schema.json +0 -0
  17. {wcgw-2.0.3 → wcgw-2.1.0}/gpt_instructions.txt +0 -0
  18. {wcgw-2.0.3 → wcgw-2.1.0}/src/__init__.py +0 -0
  19. {wcgw-2.0.3 → wcgw-2.1.0}/src/wcgw/__init__.py +0 -0
  20. {wcgw-2.0.3 → wcgw-2.1.0}/src/wcgw/client/__init__.py +0 -0
  21. {wcgw-2.0.3 → wcgw-2.1.0}/src/wcgw/client/__main__.py +0 -0
  22. {wcgw-2.0.3 → wcgw-2.1.0}/src/wcgw/client/cli.py +0 -0
  23. {wcgw-2.0.3 → wcgw-2.1.0}/src/wcgw/client/common.py +0 -0
  24. {wcgw-2.0.3 → wcgw-2.1.0}/src/wcgw/client/diff-instructions.txt +0 -0
  25. {wcgw-2.0.3 → wcgw-2.1.0}/src/wcgw/client/mcp_server/Readme.md +0 -0
  26. {wcgw-2.0.3 → wcgw-2.1.0}/src/wcgw/client/mcp_server/__init__.py +0 -0
  27. {wcgw-2.0.3 → wcgw-2.1.0}/src/wcgw/client/mcp_server/server.py +0 -0
  28. {wcgw-2.0.3 → wcgw-2.1.0}/src/wcgw/client/openai_utils.py +0 -0
  29. {wcgw-2.0.3 → wcgw-2.1.0}/src/wcgw/client/sys_utils.py +0 -0
  30. {wcgw-2.0.3 → wcgw-2.1.0}/src/wcgw/relay/serve.py +0 -0
  31. {wcgw-2.0.3 → wcgw-2.1.0}/src/wcgw/relay/static/privacy.txt +0 -0
  32. {wcgw-2.0.3 → wcgw-2.1.0}/src/wcgw/types_.py +0 -0
  33. {wcgw-2.0.3 → wcgw-2.1.0}/static/claude-ss.jpg +0 -0
  34. {wcgw-2.0.3 → wcgw-2.1.0}/static/computer-use.jpg +0 -0
  35. {wcgw-2.0.3 → wcgw-2.1.0}/static/example.jpg +0 -0
  36. {wcgw-2.0.3 → wcgw-2.1.0}/static/rocket-icon.png +0 -0
  37. {wcgw-2.0.3 → wcgw-2.1.0}/static/ss1.png +0 -0
  38. {wcgw-2.0.3 → wcgw-2.1.0}/tests/test_basic.py +0 -0
  39. {wcgw-2.0.3 → 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.3
4
- Summary: What could go wrong giving full shell access to chatgpt?
3
+ Version: 2.1.0
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
@@ -29,8 +30,8 @@ Description-Content-Type: text/markdown
29
30
 
30
31
  # Shell and Coding agent for Claude and Chatgpt
31
32
 
32
- - Claude - An MCP server on claude desktop for autonomous shell, coding and desktop control agent.
33
- - Chatgpt - Allows custom gpt to talk to your shell via a relay server.
33
+ - Claude - An MCP server on claude desktop for autonomous shell, coding and desktop control agent. (mac only)
34
+ - Chatgpt - Allows custom gpt to talk to your shell via a relay server. (linux or mac)
34
35
 
35
36
  [![Tests](https://github.com/rusiaaman/wcgw/actions/workflows/python-tests.yml/badge.svg?branch=main)](https://github.com/rusiaaman/wcgw/actions/workflows/python-tests.yml)
36
37
  [![Mypy strict](https://github.com/rusiaaman/wcgw/actions/workflows/python-types.yml/badge.svg?branch=main)](https://github.com/rusiaaman/wcgw/actions/workflows/python-types.yml)
@@ -47,12 +48,30 @@ Description-Content-Type: text/markdown
47
48
  - ⚡ **Full Shell Access**: No restrictions, complete control.
48
49
  - ⚡ **Desktop control on Claude**: Screen capture, mouse control, keyboard control on claude desktop (on mac with docker linux)
49
50
  - ⚡ **Create, Execute, Iterate**: Ask claude to keep running compiler checks till all errors are fixed, or ask it to keep checking for the status of a long running command till it's done.
51
+ - ⚡ **Large file edit**: Supports large file incremental edits to avoid token limit issues. Faster than full file write.
50
52
  - ⚡ **Interactive Command Handling**: Supports interactive commands using arrow keys, interrupt, and ansi escape sequences.
51
53
  - ⚡ **REPL support**: [beta] Supports python/node and other REPL execution.
52
54
 
55
+ ## Top use cases examples
56
+
57
+ - Solve problem X using python, create and run test cases and fix any issues. Do it in a temporary directory
58
+ - Find instances of code with X behavior in my repository
59
+ - Git clone https://github.com/my/repo in my home directory, then understand the project, set up the environment and build
60
+ - Create a golang htmx tailwind webapp, then open browser to see if it works (use with puppeteer mcp)
61
+ - Edit or update a large file
62
+ - In a separate branch create feature Y, then use github cli to create a PR to original branch
63
+ - Command X is failing in Y directory, please run and fix issues
64
+ - Using X virtual environment run Y command
65
+ - Using cli tools, create build and test an android app. Finally run it using emulator for me to use
66
+ - Fix all mypy issues in my repo at X path.
67
+ - Using 'screen' run my server in background instead, then run another api server in bg, finally run the frontend build. Keep checking logs for any issues in all three
68
+ - Create repo wide unittest cases. Keep iterating through files and creating cases. Also keep running the tests after each update. Do not modify original code.
69
+
53
70
  ## Claude Setup
54
71
 
55
- Update `claude_desktop_config.json` (~/Library/Application Support/Claude/claude_desktop_config.json)
72
+ First install `uv` https://docs.astral.sh/uv/getting-started/installation/#installation-methods
73
+
74
+ Then update `claude_desktop_config.json` (~/Library/Application Support/Claude/claude_desktop_config.json)
56
75
 
57
76
  ```json
58
77
  {
@@ -75,6 +94,13 @@ Update `claude_desktop_config.json` (~/Library/Application Support/Claude/claude
75
94
 
76
95
  Then restart claude app.
77
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
+
78
104
  ### [Optional] Computer use support using desktop on docker
79
105
 
80
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.
@@ -111,6 +137,12 @@ Then ask claude desktop app to control the docker os. It'll connect to the docke
111
137
 
112
138
  Connect to `http://localhost:6080/vnc.html` for desktop view (VNC) of the system running in the docker.
113
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
+
114
146
  ## Usage
115
147
 
116
148
  Wait for a few seconds. You should be able to see this icon if everything goes right.
@@ -124,7 +156,6 @@ Then ask claude to execute shell commands, read files, edit files, run your code
124
156
 
125
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.
126
158
 
127
-
128
159
  ## Chatgpt Setup
129
160
 
130
161
  Read here: https://github.com/rusiaaman/wcgw/blob/main/openai.md
@@ -139,7 +170,6 @@ Read here: https://github.com/rusiaaman/wcgw/blob/main/openai.md
139
170
 
140
171
  ![example](https://github.com/rusiaaman/wcgw/blob/main/static/example.jpg?raw=true)
141
172
 
142
-
143
173
  ## [Optional] Local shell access with openai API key or anthropic API key
144
174
 
145
175
  ### Openai
@@ -1,7 +1,7 @@
1
1
  # Shell and Coding agent for Claude and Chatgpt
2
2
 
3
- - Claude - An MCP server on claude desktop for autonomous shell, coding and desktop control agent.
4
- - Chatgpt - Allows custom gpt to talk to your shell via a relay server.
3
+ - Claude - An MCP server on claude desktop for autonomous shell, coding and desktop control agent. (mac only)
4
+ - Chatgpt - Allows custom gpt to talk to your shell via a relay server. (linux or mac)
5
5
 
6
6
  [![Tests](https://github.com/rusiaaman/wcgw/actions/workflows/python-tests.yml/badge.svg?branch=main)](https://github.com/rusiaaman/wcgw/actions/workflows/python-tests.yml)
7
7
  [![Mypy strict](https://github.com/rusiaaman/wcgw/actions/workflows/python-types.yml/badge.svg?branch=main)](https://github.com/rusiaaman/wcgw/actions/workflows/python-types.yml)
@@ -18,12 +18,30 @@
18
18
  - ⚡ **Full Shell Access**: No restrictions, complete control.
19
19
  - ⚡ **Desktop control on Claude**: Screen capture, mouse control, keyboard control on claude desktop (on mac with docker linux)
20
20
  - ⚡ **Create, Execute, Iterate**: Ask claude to keep running compiler checks till all errors are fixed, or ask it to keep checking for the status of a long running command till it's done.
21
+ - ⚡ **Large file edit**: Supports large file incremental edits to avoid token limit issues. Faster than full file write.
21
22
  - ⚡ **Interactive Command Handling**: Supports interactive commands using arrow keys, interrupt, and ansi escape sequences.
22
23
  - ⚡ **REPL support**: [beta] Supports python/node and other REPL execution.
23
24
 
25
+ ## Top use cases examples
26
+
27
+ - Solve problem X using python, create and run test cases and fix any issues. Do it in a temporary directory
28
+ - Find instances of code with X behavior in my repository
29
+ - Git clone https://github.com/my/repo in my home directory, then understand the project, set up the environment and build
30
+ - Create a golang htmx tailwind webapp, then open browser to see if it works (use with puppeteer mcp)
31
+ - Edit or update a large file
32
+ - In a separate branch create feature Y, then use github cli to create a PR to original branch
33
+ - Command X is failing in Y directory, please run and fix issues
34
+ - Using X virtual environment run Y command
35
+ - Using cli tools, create build and test an android app. Finally run it using emulator for me to use
36
+ - Fix all mypy issues in my repo at X path.
37
+ - Using 'screen' run my server in background instead, then run another api server in bg, finally run the frontend build. Keep checking logs for any issues in all three
38
+ - Create repo wide unittest cases. Keep iterating through files and creating cases. Also keep running the tests after each update. Do not modify original code.
39
+
24
40
  ## Claude Setup
25
41
 
26
- Update `claude_desktop_config.json` (~/Library/Application Support/Claude/claude_desktop_config.json)
42
+ First install `uv` https://docs.astral.sh/uv/getting-started/installation/#installation-methods
43
+
44
+ Then update `claude_desktop_config.json` (~/Library/Application Support/Claude/claude_desktop_config.json)
27
45
 
28
46
  ```json
29
47
  {
@@ -46,6 +64,13 @@ Update `claude_desktop_config.json` (~/Library/Application Support/Claude/claude
46
64
 
47
65
  Then restart claude app.
48
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
+
49
74
  ### [Optional] Computer use support using desktop on docker
50
75
 
51
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.
@@ -82,6 +107,12 @@ Then ask claude desktop app to control the docker os. It'll connect to the docke
82
107
 
83
108
  Connect to `http://localhost:6080/vnc.html` for desktop view (VNC) of the system running in the docker.
84
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
+
85
116
  ## Usage
86
117
 
87
118
  Wait for a few seconds. You should be able to see this icon if everything goes right.
@@ -95,7 +126,6 @@ Then ask claude to execute shell commands, read files, edit files, run your code
95
126
 
96
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.
97
128
 
98
-
99
129
  ## Chatgpt Setup
100
130
 
101
131
  Read here: https://github.com/rusiaaman/wcgw/blob/main/openai.md
@@ -110,7 +140,6 @@ Read here: https://github.com/rusiaaman/wcgw/blob/main/openai.md
110
140
 
111
141
  ![example](https://github.com/rusiaaman/wcgw/blob/main/static/example.jpg?raw=true)
112
142
 
113
-
114
143
  ## [Optional] Local shell access with openai API key or anthropic API key
115
144
 
116
145
  ### Openai
@@ -4,8 +4,8 @@
4
4
 
5
5
  1. Run a relay server with a domain name and https support (or use ngrok) use the instructions in next section.
6
6
  2. Create a custom gpt that connects to the relay server, instructions in next sections.
7
- 3. Run the [cli client](https://github.com/rusiaaman/wcgw?tab=readme-ov-file#client) in any directory of choice.
8
- 4. The custom GPT can now run any command on your cli
7
+ 3. Run the client in any directory of choice. `uvx wcgw@latest`
8
+ 4. The custom GPT can now run any command on your terminal
9
9
 
10
10
  ## Creating the relay server
11
11
 
@@ -1,8 +1,8 @@
1
1
  [project]
2
2
  authors = [{ name = "Aman Rusia", email = "gapypi@arcfu.com" }]
3
3
  name = "wcgw"
4
- version = "2.0.3"
5
- description = "What could go wrong giving full shell access to chatgpt?"
4
+ version = "2.1.0"
5
+ description = "Shell and coding agent on claude and chatgpt"
6
6
  readme = "README.md"
7
7
  requires-python = ">=3.11, <3.13"
8
8
  dependencies = [
@@ -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
@@ -106,20 +108,31 @@ PROMPT = PROMPT_CONST
106
108
 
107
109
 
108
110
  def start_shell() -> pexpect.spawn: # type: ignore
109
- SHELL = pexpect.spawn(
110
- "/bin/bash --noprofile --norc",
111
- env={**os.environ, **{"PS1": PROMPT}}, # type: ignore[arg-type]
112
- echo=False,
113
- encoding="utf-8",
114
- timeout=TIMEOUT,
115
- )
116
- SHELL.expect(PROMPT, timeout=TIMEOUT)
117
- SHELL.sendline("stty -icanon -echo")
118
- SHELL.expect(PROMPT, timeout=TIMEOUT)
119
- return SHELL
120
-
111
+ try:
112
+ shell = pexpect.spawn(
113
+ "/bin/bash",
114
+ env={**os.environ, **{"PS1": PROMPT}}, # type: ignore[arg-type]
115
+ echo=False,
116
+ encoding="utf-8",
117
+ timeout=TIMEOUT,
118
+ )
119
+ shell.sendline(f"export PS1={PROMPT}")
120
+ except Exception as e:
121
+ traceback.print_exc()
122
+ console.log(f"Error starting shell: {e}. Retrying without rc ...")
123
+
124
+ shell = pexpect.spawn(
125
+ "/bin/bash --noprofile --norc",
126
+ env={**os.environ, **{"PS1": PROMPT}}, # type: ignore[arg-type]
127
+ echo=False,
128
+ encoding="utf-8",
129
+ timeout=TIMEOUT,
130
+ )
121
131
 
122
- SHELL = start_shell()
132
+ shell.expect(PROMPT, timeout=TIMEOUT)
133
+ shell.sendline("stty -icanon -echo")
134
+ shell.expect(PROMPT, timeout=TIMEOUT)
135
+ return shell
123
136
 
124
137
 
125
138
  def _is_int(mystr: str) -> bool:
@@ -130,26 +143,26 @@ def _is_int(mystr: str) -> bool:
130
143
  return False
131
144
 
132
145
 
133
- def _get_exit_code() -> int:
146
+ def _get_exit_code(shell: pexpect.spawn) -> int: # type: ignore
134
147
  if PROMPT != PROMPT_CONST:
135
148
  return 0
136
149
  # First reset the prompt in case venv was sourced or other reasons.
137
- SHELL.sendline(f"export PS1={PROMPT}")
138
- SHELL.expect(PROMPT, timeout=0.2)
150
+ shell.sendline(f"export PS1={PROMPT}")
151
+ shell.expect(PROMPT, timeout=0.2)
139
152
  # Reset echo also if it was enabled
140
- SHELL.sendline("stty -icanon -echo")
141
- SHELL.expect(PROMPT, timeout=0.2)
142
- SHELL.sendline("echo $?")
153
+ shell.sendline("stty -icanon -echo")
154
+ shell.expect(PROMPT, timeout=0.2)
155
+ shell.sendline("echo $?")
143
156
  before = ""
144
157
  while not _is_int(before): # Consume all previous output
145
158
  try:
146
- SHELL.expect(PROMPT, timeout=0.2)
159
+ shell.expect(PROMPT, timeout=0.2)
147
160
  except pexpect.TIMEOUT:
148
161
  print(f"Couldn't get exit code, before: {before}")
149
162
  raise
150
- assert isinstance(SHELL.before, str)
163
+ assert isinstance(shell.before, str)
151
164
  # Render because there could be some anscii escape sequences still set like in google colab env
152
- before = render_terminal_output(SHELL.before).strip()
165
+ before = render_terminal_output(shell.before).strip()
153
166
 
154
167
  try:
155
168
  return int((before))
@@ -158,9 +171,71 @@ def _get_exit_code() -> int:
158
171
 
159
172
 
160
173
  BASH_CLF_OUTPUT = Literal["repl", "pending"]
161
- BASH_STATE: BASH_CLF_OUTPUT = "repl"
162
- IS_IN_DOCKER: Optional[str] = ""
163
- 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()
164
239
 
165
240
 
166
241
  def initial_info() -> str:
@@ -169,18 +244,13 @@ def initial_info() -> str:
169
244
  return f"""
170
245
  System: {uname_sysname}
171
246
  Machine: {uname_machine}
172
- Current working directory: {CWD}
247
+ Current working directory: {BASH_STATE.cwd}
173
248
  wcgw version: {importlib.metadata.version("wcgw")}
174
249
  """
175
250
 
176
251
 
177
252
  def reset_shell() -> str:
178
- global SHELL, BASH_STATE, CWD, IS_IN_DOCKER
179
- SHELL.close(True)
180
- SHELL = start_shell()
181
- BASH_STATE = "repl"
182
- IS_IN_DOCKER = ""
183
- CWD = os.getcwd()
253
+ BASH_STATE.reset()
184
254
  return "Reset successful" + get_status()
185
255
 
186
256
 
@@ -195,11 +265,11 @@ WAITING_INPUT_MESSAGE = """A command is already running. NOTE: You can't run mul
195
265
  def update_repl_prompt(command: str) -> bool:
196
266
  global PROMPT
197
267
  if re.match(r"^wcgw_update_prompt\(\)$", command.strip()):
198
- SHELL.sendintr()
199
- 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)
200
270
  if index == 0:
201
271
  return False
202
- before = SHELL.before or ""
272
+ before = BASH_STATE.shell.before or ""
203
273
  assert before, "Something went wrong updating repl prompt"
204
274
  PROMPT = before.split("\n")[-1].strip()
205
275
  # Escape all regex
@@ -208,33 +278,24 @@ def update_repl_prompt(command: str) -> bool:
208
278
  index = 0
209
279
  while index == 0:
210
280
  # Consume all REPL prompts till now
211
- index = SHELL.expect([PROMPT, pexpect.TIMEOUT], timeout=0.2)
281
+ index = BASH_STATE.shell.expect([PROMPT, pexpect.TIMEOUT], timeout=0.2)
212
282
  print(f"Prompt updated to: {PROMPT}")
213
283
  return True
214
284
  return False
215
285
 
216
286
 
217
- def get_cwd() -> str:
218
- SHELL.sendline("pwd")
219
- SHELL.expect(PROMPT, timeout=0.2)
220
- assert isinstance(SHELL.before, str)
221
- current_dir = render_terminal_output(SHELL.before).strip()
222
- return current_dir
223
-
224
-
225
287
  def get_status() -> str:
226
- global CWD
227
288
  exit_code: Optional[int] = None
228
289
 
229
290
  status = "\n\n---\n\n"
230
- if BASH_STATE == "pending":
291
+ if BASH_STATE.state == "pending":
231
292
  status += "status = still running\n"
232
- status += "cwd = " + CWD + "\n"
293
+ status += "running for = " + BASH_STATE.get_pending_for() + "\n"
294
+ status += "cwd = " + BASH_STATE.cwd + "\n"
233
295
  else:
234
- exit_code = _get_exit_code()
296
+ exit_code = _get_exit_code(BASH_STATE.shell)
235
297
  status += f"status = exited with code {exit_code}\n"
236
- CWD = get_cwd()
237
- status += "cwd = " + CWD + "\n"
298
+ status += "cwd = " + BASH_STATE.update_cwd() + "\n"
238
299
 
239
300
  return status.rstrip()
240
301
 
@@ -245,13 +306,12 @@ def execute_bash(
245
306
  max_tokens: Optional[int],
246
307
  timeout_s: Optional[float],
247
308
  ) -> tuple[str, float]:
248
- global SHELL, BASH_STATE, CWD
249
309
  try:
250
310
  is_interrupt = False
251
311
  if isinstance(bash_arg, BashCommand):
252
312
  updated_repl_mode = update_repl_prompt(bash_arg.command)
253
313
  if updated_repl_mode:
254
- BASH_STATE = "repl"
314
+ BASH_STATE.set_repl()
255
315
  response = (
256
316
  "Prompt updated, you can execute REPL lines using BashCommand now"
257
317
  )
@@ -262,7 +322,7 @@ def execute_bash(
262
322
  )
263
323
 
264
324
  console.print(f"$ {bash_arg.command}")
265
- if BASH_STATE == "pending":
325
+ if BASH_STATE.state == "pending":
266
326
  raise ValueError(WAITING_INPUT_MESSAGE)
267
327
  command = bash_arg.command.strip()
268
328
 
@@ -271,7 +331,7 @@ def execute_bash(
271
331
  "Command should not contain newline character in middle. Run only one command at a time."
272
332
  )
273
333
 
274
- SHELL.sendline(command)
334
+ BASH_STATE.shell.sendline(command)
275
335
 
276
336
  else:
277
337
  if (
@@ -292,29 +352,29 @@ def execute_bash(
292
352
  console.print(f"Sending special sequence: {bash_arg.send_specials}")
293
353
  for char in bash_arg.send_specials:
294
354
  if char == "Key-up":
295
- SHELL.send("\033[A")
355
+ BASH_STATE.shell.send("\033[A")
296
356
  elif char == "Key-down":
297
- SHELL.send("\033[B")
357
+ BASH_STATE.shell.send("\033[B")
298
358
  elif char == "Key-left":
299
- SHELL.send("\033[D")
359
+ BASH_STATE.shell.send("\033[D")
300
360
  elif char == "Key-right":
301
- SHELL.send("\033[C")
361
+ BASH_STATE.shell.send("\033[C")
302
362
  elif char == "Enter":
303
- SHELL.send("\n")
363
+ BASH_STATE.shell.send("\n")
304
364
  elif char == "Ctrl-c":
305
- SHELL.sendintr()
365
+ BASH_STATE.shell.sendintr()
306
366
  is_interrupt = True
307
367
  elif char == "Ctrl-d":
308
- SHELL.sendintr()
368
+ BASH_STATE.shell.sendintr()
309
369
  is_interrupt = True
310
370
  elif char == "Ctrl-z":
311
- SHELL.send("\x1a")
371
+ BASH_STATE.shell.send("\x1a")
312
372
  else:
313
373
  raise Exception(f"Unknown special character: {char}")
314
374
  elif bash_arg.send_ascii:
315
375
  console.print(f"Sending ASCII sequence: {bash_arg.send_ascii}")
316
376
  for ascii_char in bash_arg.send_ascii:
317
- SHELL.send(chr(ascii_char))
377
+ BASH_STATE.shell.send(chr(ascii_char))
318
378
  if ascii_char == 3:
319
379
  is_interrupt = True
320
380
  else:
@@ -326,7 +386,7 @@ def execute_bash(
326
386
 
327
387
  updated_repl_mode = update_repl_prompt(bash_arg.send_text)
328
388
  if updated_repl_mode:
329
- BASH_STATE = "repl"
389
+ BASH_STATE.set_repl()
330
390
  response = "Prompt updated, you can execute REPL lines using BashCommand now"
331
391
  console.print(response)
332
392
  return (
@@ -334,20 +394,18 @@ def execute_bash(
334
394
  0,
335
395
  )
336
396
  console.print(f"Interact text: {bash_arg.send_text}")
337
- SHELL.sendline(bash_arg.send_text)
338
-
339
- BASH_STATE = "repl"
397
+ BASH_STATE.shell.sendline(bash_arg.send_text)
340
398
 
341
399
  except KeyboardInterrupt:
342
- SHELL.sendintr()
343
- SHELL.expect(PROMPT)
400
+ BASH_STATE.shell.sendintr()
401
+ BASH_STATE.shell.expect(PROMPT)
344
402
  return "---\n\nFailure: user interrupted the execution", 0.0
345
403
 
346
404
  wait = timeout_s or TIMEOUT
347
- index = SHELL.expect([PROMPT, pexpect.TIMEOUT], timeout=wait)
405
+ index = BASH_STATE.shell.expect([PROMPT, pexpect.TIMEOUT], timeout=wait)
348
406
  if index == 1:
349
- BASH_STATE = "pending"
350
- text = SHELL.before or ""
407
+ BASH_STATE.set_pending()
408
+ text = BASH_STATE.shell.before or ""
351
409
 
352
410
  text = render_terminal_output(text[-100_000:])
353
411
  tokens = enc.encode(text)
@@ -372,11 +430,13 @@ Otherwise, you may want to try Ctrl-c again or program specific exit interactive
372
430
 
373
431
  return text, 0
374
432
 
433
+ BASH_STATE.set_repl()
434
+
375
435
  if is_interrupt:
376
436
  return "Interrupt successful", 0.0
377
437
 
378
- assert isinstance(SHELL.before, str)
379
- output = render_terminal_output(SHELL.before)
438
+ assert isinstance(BASH_STATE.shell.before, str)
439
+ output = render_terminal_output(BASH_STATE.shell.before)
380
440
 
381
441
  tokens = enc.encode(output)
382
442
  if max_tokens and len(tokens) >= max_tokens:
@@ -390,8 +450,7 @@ Otherwise, you may want to try Ctrl-c again or program specific exit interactive
390
450
  traceback.print_exc()
391
451
  console.print("Malformed output, restarting shell", style="red")
392
452
  # Malformed output, restart shell
393
- SHELL.close(True)
394
- SHELL = start_shell()
453
+ BASH_STATE.reset()
395
454
  output = "(exit shell has restarted)"
396
455
  return output, 0
397
456
 
@@ -435,8 +494,7 @@ T = TypeVar("T")
435
494
 
436
495
  def ensure_no_previous_output(func: Callable[Param, T]) -> Callable[Param, T]:
437
496
  def wrapper(*args: Param.args, **kwargs: Param.kwargs) -> T:
438
- global BASH_STATE
439
- if BASH_STATE == "pending":
497
+ if BASH_STATE.state == "pending":
440
498
  raise ValueError(WAITING_INPUT_MESSAGE)
441
499
 
442
500
  return func(*args, **kwargs)
@@ -446,9 +504,9 @@ def ensure_no_previous_output(func: Callable[Param, T]) -> Callable[Param, T]:
446
504
 
447
505
  def read_image_from_shell(file_path: str) -> ImageData:
448
506
  if not os.path.isabs(file_path):
449
- file_path = os.path.join(CWD, file_path)
507
+ file_path = os.path.join(BASH_STATE.cwd, file_path)
450
508
 
451
- if not IS_IN_DOCKER:
509
+ if not BASH_STATE.is_in_docker:
452
510
  if not os.path.exists(file_path):
453
511
  raise ValueError(f"File {file_path} does not exist")
454
512
 
@@ -459,7 +517,9 @@ def read_image_from_shell(file_path: str) -> ImageData:
459
517
  return ImageData(media_type=image_type, data=image_b64) # type: ignore
460
518
  else:
461
519
  with TemporaryDirectory() as tmpdir:
462
- 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
+ )
463
523
  if rcode != 0:
464
524
  raise Exception(f"Error: Read failed with code {rcode}")
465
525
  path_ = os.path.join(tmpdir, os.path.basename(file_path))
@@ -472,11 +532,11 @@ def read_image_from_shell(file_path: str) -> ImageData:
472
532
 
473
533
  def write_file(writefile: CreateFileNew, error_on_exist: bool) -> str:
474
534
  if not os.path.isabs(writefile.file_path):
475
- 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}"
476
536
  else:
477
537
  path_ = writefile.file_path
478
538
 
479
- if not IS_IN_DOCKER:
539
+ if not BASH_STATE.is_in_docker:
480
540
  if error_on_exist and os.path.exists(path_):
481
541
  file_data = Path(path_).read_text()
482
542
  if file_data:
@@ -494,7 +554,7 @@ def write_file(writefile: CreateFileNew, error_on_exist: bool) -> str:
494
554
  if error_on_exist:
495
555
  # Check if it exists using os.system
496
556
  cmd = f"test -f {path_}"
497
- 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}"')
498
558
  if status == 0:
499
559
  return f"Error: can't write to existing file {path_}, use other functions to edit the file"
500
560
 
@@ -504,11 +564,13 @@ def write_file(writefile: CreateFileNew, error_on_exist: bool) -> str:
504
564
  f.write(writefile.file_content)
505
565
  os.chmod(tmppath, 0o777)
506
566
  parent_dir = os.path.dirname(path_)
507
- 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
+ )
508
570
  if rcode != 0:
509
571
  return f"Error: Write failed with code while creating dirs {rcode}"
510
572
 
511
- 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_}")
512
574
  if rcode != 0:
513
575
  return f"Error: Write failed with code {rcode}"
514
576
 
@@ -585,12 +647,12 @@ def do_diff_edit(fedit: FileEdit) -> str:
585
647
 
586
648
  if not os.path.isabs(fedit.file_path):
587
649
  raise Exception(
588
- 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}"
589
651
  )
590
652
  else:
591
653
  path_ = fedit.file_path
592
654
 
593
- if not IS_IN_DOCKER:
655
+ if not BASH_STATE.is_in_docker:
594
656
  if not os.path.exists(path_):
595
657
  raise Exception(f"Error: file {path_} does not exist")
596
658
 
@@ -599,7 +661,7 @@ def do_diff_edit(fedit: FileEdit) -> str:
599
661
  else:
600
662
  # Copy from docker
601
663
  with TemporaryDirectory() as tmpdir:
602
- 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}")
603
665
  if rcode != 0:
604
666
  raise Exception(f"Error: Read failed with code {rcode}")
605
667
  path_tmp = os.path.join(tmpdir, os.path.basename(path_))
@@ -652,7 +714,7 @@ def do_diff_edit(fedit: FileEdit) -> str:
652
714
  "Error: no valid search-replace blocks found, please check your syntax for FileEdit"
653
715
  )
654
716
 
655
- if not IS_IN_DOCKER:
717
+ if not BASH_STATE.is_in_docker:
656
718
  with open(path_, "w") as f:
657
719
  f.write(apply_diff_to)
658
720
  else:
@@ -662,7 +724,7 @@ def do_diff_edit(fedit: FileEdit) -> str:
662
724
  f.write(apply_diff_to)
663
725
  os.chmod(path_tmp, 0o777)
664
726
  # Copy to docker using docker cp
665
- 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_}")
666
728
  if rcode != 0:
667
729
  raise Exception(f"Error: Write failed with code {rcode}")
668
730
 
@@ -856,7 +918,7 @@ def get_tool_output(
856
918
  if imgBs64:
857
919
  console.print("Captured screenshot")
858
920
  outputs.append(ImageData(media_type="image/png", data=imgBs64))
859
- if not IS_IN_DOCKER and isinstance(arg, GetScreenInfo):
921
+ if not BASH_STATE.is_in_docker and isinstance(arg, GetScreenInfo):
860
922
  try:
861
923
  # At this point we should go into the docker env
862
924
  res, _ = execute_bash(
@@ -882,7 +944,7 @@ def get_tool_output(
882
944
  raise Exception(
883
945
  f"Some error happened while going inside docker. I've reset the shell. Please start again. Error {e}"
884
946
  )
885
- IS_IN_DOCKER = arg.docker_image_id
947
+ BASH_STATE.set_in_docker(arg.docker_image_id)
886
948
  return outputs, outputs_cost[1]
887
949
  else:
888
950
  raise ValueError(f"Unknown tool: {arg}")
@@ -987,9 +1049,9 @@ def read_file(readfile: ReadFile, max_tokens: Optional[int]) -> str:
987
1049
  console.print(f"Reading file: {readfile.file_path}")
988
1050
 
989
1051
  if not os.path.isabs(readfile.file_path):
990
- 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}"
991
1053
 
992
- if not IS_IN_DOCKER:
1054
+ if not BASH_STATE.is_in_docker:
993
1055
  path = Path(readfile.file_path)
994
1056
  if not path.exists():
995
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.2"
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