wcgw 0.1.0__py3-none-any.whl → 0.1.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of wcgw might be problematic. Click here for more details.
- wcgw/basic.py +105 -32
- wcgw/claude.py +384 -0
- wcgw/common.py +23 -21
- wcgw/openai_utils.py +12 -2
- wcgw/tools.py +180 -126
- wcgw-0.1.2.dist-info/METADATA +120 -0
- wcgw-0.1.2.dist-info/RECORD +12 -0
- wcgw-0.1.0.dist-info/METADATA +0 -23
- wcgw-0.1.0.dist-info/RECORD +0 -11
- {wcgw-0.1.0.dist-info → wcgw-0.1.2.dist-info}/WHEEL +0 -0
- {wcgw-0.1.0.dist-info → wcgw-0.1.2.dist-info}/entry_points.txt +0 -0
wcgw/common.py
CHANGED
|
@@ -23,25 +23,27 @@ Models = Literal["gpt-4o-2024-08-06", "gpt-4o-mini"]
|
|
|
23
23
|
|
|
24
24
|
|
|
25
25
|
def discard_input() -> None:
|
|
26
|
-
# Get the file descriptor for stdin
|
|
27
|
-
fd = sys.stdin.fileno()
|
|
28
|
-
|
|
29
|
-
# Save current terminal settings
|
|
30
|
-
old_settings = termios.tcgetattr(fd)
|
|
31
|
-
|
|
32
26
|
try:
|
|
33
|
-
#
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
#
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
27
|
+
# Get the file descriptor for stdin
|
|
28
|
+
fd = sys.stdin.fileno()
|
|
29
|
+
|
|
30
|
+
# Save current terminal settings
|
|
31
|
+
old_settings = termios.tcgetattr(fd)
|
|
32
|
+
|
|
33
|
+
try:
|
|
34
|
+
# Switch terminal to non-canonical mode where input is read immediately
|
|
35
|
+
tty.setcbreak(fd)
|
|
36
|
+
|
|
37
|
+
# Discard all input
|
|
38
|
+
while True:
|
|
39
|
+
# Check if there is input to be read
|
|
40
|
+
if sys.stdin in select.select([sys.stdin], [], [], 0)[0]:
|
|
41
|
+
sys.stdin.read(1) # Read one character at a time to flush the input buffer
|
|
42
|
+
else:
|
|
43
|
+
break
|
|
44
|
+
finally:
|
|
45
|
+
# Restore old terminal settings
|
|
46
|
+
termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)
|
|
47
|
+
except (termios.error, ValueError) as e:
|
|
48
|
+
# Handle the error gracefully
|
|
49
|
+
print(f"Warning: Unable to discard input. Error: {e}")
|
wcgw/openai_utils.py
CHANGED
|
@@ -28,9 +28,19 @@ def get_input_cost(
|
|
|
28
28
|
input_tokens = 0
|
|
29
29
|
for msg in history:
|
|
30
30
|
content = msg["content"]
|
|
31
|
-
|
|
31
|
+
refusal = msg.get("refusal")
|
|
32
|
+
if isinstance(content, list):
|
|
33
|
+
for part in content:
|
|
34
|
+
if 'text' in part:
|
|
35
|
+
input_tokens += len(enc.encode(part['text']))
|
|
36
|
+
elif content is None:
|
|
37
|
+
if refusal is None:
|
|
38
|
+
raise ValueError("Expected content or refusal to be present")
|
|
39
|
+
input_tokens += len(enc.encode(str(refusal)))
|
|
40
|
+
elif not isinstance(content, str):
|
|
32
41
|
raise ValueError(f"Expected content to be string, got {type(content)}")
|
|
33
|
-
|
|
42
|
+
else:
|
|
43
|
+
input_tokens += len(enc.encode(content))
|
|
34
44
|
cost = input_tokens * cost_map.cost_per_1m_input_tokens / 1_000_000
|
|
35
45
|
return cost, input_tokens
|
|
36
46
|
|
wcgw/tools.py
CHANGED
|
@@ -1,15 +1,27 @@
|
|
|
1
1
|
import asyncio
|
|
2
|
+
import base64
|
|
2
3
|
import json
|
|
4
|
+
import mimetypes
|
|
3
5
|
import sys
|
|
4
6
|
import threading
|
|
5
7
|
import traceback
|
|
6
|
-
from typing import
|
|
8
|
+
from typing import (
|
|
9
|
+
Callable,
|
|
10
|
+
Literal,
|
|
11
|
+
NewType,
|
|
12
|
+
Optional,
|
|
13
|
+
ParamSpec,
|
|
14
|
+
Sequence,
|
|
15
|
+
TypeVar,
|
|
16
|
+
TypedDict,
|
|
17
|
+
)
|
|
7
18
|
import uuid
|
|
8
19
|
from pydantic import BaseModel, TypeAdapter
|
|
20
|
+
from websockets.sync.client import connect as syncconnect
|
|
9
21
|
|
|
10
22
|
import os
|
|
11
23
|
import tiktoken
|
|
12
|
-
import petname # type: ignore[import]
|
|
24
|
+
import petname # type: ignore[import-untyped]
|
|
13
25
|
import pexpect
|
|
14
26
|
from typer import Typer
|
|
15
27
|
import websockets
|
|
@@ -31,7 +43,7 @@ from .common import CostData, Models, discard_input
|
|
|
31
43
|
|
|
32
44
|
from .openai_utils import get_input_cost, get_output_cost
|
|
33
45
|
|
|
34
|
-
console = rich.console.Console(style="magenta", highlight=False)
|
|
46
|
+
console = rich.console.Console(style="magenta", highlight=False, markup=False)
|
|
35
47
|
|
|
36
48
|
TIMEOUT = 30
|
|
37
49
|
|
|
@@ -65,14 +77,14 @@ class Writefile(BaseModel):
|
|
|
65
77
|
file_content: str
|
|
66
78
|
|
|
67
79
|
|
|
68
|
-
def start_shell():
|
|
80
|
+
def start_shell() -> pexpect.spawn:
|
|
69
81
|
SHELL = pexpect.spawn(
|
|
70
|
-
"/bin/bash",
|
|
71
|
-
env={**os.environ, **{"PS1": "#@@"}},
|
|
82
|
+
"/bin/bash --noprofile --norc",
|
|
83
|
+
env={**os.environ, **{"PS1": "#@@"}}, # type: ignore[arg-type]
|
|
72
84
|
echo=False,
|
|
73
85
|
encoding="utf-8",
|
|
74
86
|
timeout=TIMEOUT,
|
|
75
|
-
)
|
|
87
|
+
)
|
|
76
88
|
SHELL.expect("#@@")
|
|
77
89
|
SHELL.sendline("stty -icanon -echo")
|
|
78
90
|
SHELL.expect("#@@")
|
|
@@ -82,11 +94,33 @@ def start_shell():
|
|
|
82
94
|
SHELL = start_shell()
|
|
83
95
|
|
|
84
96
|
|
|
97
|
+
def _is_int(mystr: str) -> bool:
|
|
98
|
+
try:
|
|
99
|
+
int(mystr)
|
|
100
|
+
return True
|
|
101
|
+
except ValueError:
|
|
102
|
+
return False
|
|
103
|
+
|
|
104
|
+
|
|
85
105
|
def _get_exit_code() -> int:
|
|
86
|
-
|
|
106
|
+
# First reset the prompt in case venv was sourced or other reasons.
|
|
107
|
+
SHELL.sendline('export PS1="#@@"')
|
|
87
108
|
SHELL.expect("#@@")
|
|
88
|
-
|
|
89
|
-
|
|
109
|
+
# Reset echo also if it was enabled
|
|
110
|
+
SHELL.sendline("stty -icanon -echo")
|
|
111
|
+
SHELL.expect("#@@")
|
|
112
|
+
SHELL.sendline("echo $?")
|
|
113
|
+
before = ""
|
|
114
|
+
while not _is_int(before): # Consume all previous output
|
|
115
|
+
SHELL.expect("#@@")
|
|
116
|
+
assert isinstance(SHELL.before, str)
|
|
117
|
+
# Render because there could be some anscii escape sequences still set like in google colab env
|
|
118
|
+
before = render_terminal_output(SHELL.before).strip()
|
|
119
|
+
|
|
120
|
+
try:
|
|
121
|
+
return int((before))
|
|
122
|
+
except ValueError:
|
|
123
|
+
raise ValueError(f"Malformed output: {before}")
|
|
90
124
|
|
|
91
125
|
|
|
92
126
|
Specials = Literal["Key-up", "Key-down", "Key-left", "Key-right", "Enter", "Ctrl-c"]
|
|
@@ -97,41 +131,24 @@ class ExecuteBash(BaseModel):
|
|
|
97
131
|
send_ascii: Optional[Sequence[int | Specials]] = None
|
|
98
132
|
|
|
99
133
|
|
|
100
|
-
class GetShellOutputLastCommand(BaseModel):
|
|
101
|
-
type: Literal["get_output_of_last_command"] = "get_output_of_last_command"
|
|
102
|
-
|
|
103
|
-
|
|
104
134
|
BASH_CLF_OUTPUT = Literal["running", "waiting_for_input", "wont_exit"]
|
|
105
135
|
BASH_STATE: BASH_CLF_OUTPUT = "running"
|
|
106
136
|
|
|
107
137
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
tokens = enc.encode(output)
|
|
113
|
-
if len(tokens) >= 2048:
|
|
114
|
-
output = "...(truncated)\n" + enc.decode(tokens[-2047:])
|
|
115
|
-
|
|
116
|
-
return output
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
WETTING_INPUT_MESSAGE = """A command is already running waiting for input. NOTE: You can't run multiple shell sessions, likely a previous program hasn't exited.
|
|
120
|
-
1. Get its output using `GetShellOutputLastCommand` OR
|
|
121
|
-
2. Use `send_ascii` to give inputs to the running program, don't use `execute_command` OR
|
|
138
|
+
WAITING_INPUT_MESSAGE = """A command is already running waiting for input. NOTE: You can't run multiple shell sessions, likely a previous program hasn't exited.
|
|
139
|
+
1. Get its output using `send_ascii: [10]`
|
|
140
|
+
2. Use `send_ascii` to give inputs to the running program, don't use `execute_command` OR
|
|
122
141
|
3. kill the previous program by sending ctrl+c first using `send_ascii`"""
|
|
123
142
|
|
|
124
143
|
|
|
125
144
|
def execute_bash(
|
|
126
|
-
enc: tiktoken.Encoding,
|
|
127
|
-
bash_arg: ExecuteBash,
|
|
128
|
-
is_waiting_user_input: Callable[[str], tuple[BASH_CLF_OUTPUT, float]],
|
|
145
|
+
enc: tiktoken.Encoding, bash_arg: ExecuteBash, max_tokens: Optional[int]
|
|
129
146
|
) -> tuple[str, float]:
|
|
130
147
|
global SHELL, BASH_STATE
|
|
131
148
|
try:
|
|
132
149
|
if bash_arg.execute_command:
|
|
133
150
|
if BASH_STATE == "waiting_for_input":
|
|
134
|
-
raise ValueError(
|
|
151
|
+
raise ValueError(WAITING_INPUT_MESSAGE)
|
|
135
152
|
elif BASH_STATE == "wont_exit":
|
|
136
153
|
raise ValueError(
|
|
137
154
|
"""A command is already running that hasn't exited. NOTE: You can't run multiple shell sessions, likely a previous program is in infinite loop.
|
|
@@ -172,46 +189,41 @@ def execute_bash(
|
|
|
172
189
|
SHELL = start_shell()
|
|
173
190
|
raise
|
|
174
191
|
|
|
175
|
-
wait =
|
|
192
|
+
wait = 5
|
|
176
193
|
index = SHELL.expect(["#@@", pexpect.TIMEOUT], timeout=wait)
|
|
177
194
|
running = ""
|
|
178
195
|
while index == 1:
|
|
179
196
|
if wait > TIMEOUT:
|
|
180
197
|
raise TimeoutError("Timeout while waiting for shell prompt")
|
|
181
198
|
|
|
182
|
-
|
|
199
|
+
BASH_STATE = "waiting_for_input"
|
|
200
|
+
text = SHELL.before or ""
|
|
183
201
|
print(text[len(running) :])
|
|
184
202
|
running = text
|
|
185
203
|
|
|
186
204
|
text = render_terminal_output(text)
|
|
187
|
-
|
|
188
|
-
if BASH_STATE == "waiting_for_input" or BASH_STATE == "wont_exit":
|
|
189
|
-
tokens = enc.encode(text)
|
|
205
|
+
tokens = enc.encode(text)
|
|
190
206
|
|
|
191
|
-
|
|
192
|
-
|
|
207
|
+
if max_tokens and len(tokens) >= max_tokens:
|
|
208
|
+
text = "...(truncated)\n" + enc.decode(tokens[-(max_tokens - 1) :])
|
|
193
209
|
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
if BASH_STATE == "waiting_for_input"
|
|
197
|
-
else "(won't exit)"
|
|
198
|
-
)
|
|
199
|
-
return text + f"\n{last_line}", cost
|
|
200
|
-
index = SHELL.expect(["#@@", pexpect.TIMEOUT], timeout=wait)
|
|
201
|
-
wait += timeout
|
|
210
|
+
last_line = "(pending)"
|
|
211
|
+
return text + f"\n{last_line}", 0
|
|
202
212
|
|
|
203
213
|
assert isinstance(SHELL.before, str)
|
|
204
214
|
output = render_terminal_output(SHELL.before)
|
|
205
215
|
|
|
206
216
|
tokens = enc.encode(output)
|
|
207
|
-
if len(tokens) >=
|
|
208
|
-
output = "...(truncated)\n" + enc.decode(tokens[-
|
|
217
|
+
if max_tokens and len(tokens) >= max_tokens:
|
|
218
|
+
output = "...(truncated)\n" + enc.decode(tokens[-(max_tokens - 1) :])
|
|
209
219
|
|
|
210
220
|
try:
|
|
211
221
|
exit_code = _get_exit_code()
|
|
212
222
|
output += f"\n(exit {exit_code})"
|
|
213
223
|
|
|
214
|
-
except ValueError:
|
|
224
|
+
except ValueError as e:
|
|
225
|
+
console.print(output)
|
|
226
|
+
traceback.print_exc()
|
|
215
227
|
console.print("Malformed output, restarting shell", style="red")
|
|
216
228
|
# Malformed output, restart shell
|
|
217
229
|
SHELL.close(True)
|
|
@@ -220,6 +232,35 @@ def execute_bash(
|
|
|
220
232
|
return output, 0
|
|
221
233
|
|
|
222
234
|
|
|
235
|
+
class ReadImage(BaseModel):
|
|
236
|
+
file_path: str
|
|
237
|
+
type: Literal["ReadImage"] = "ReadImage"
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def serve_image_in_bg(file_path: str, client_uuid: str, name: str) -> None:
|
|
241
|
+
if not client_uuid:
|
|
242
|
+
client_uuid = str(uuid.uuid4())
|
|
243
|
+
|
|
244
|
+
server_url = "wss://wcgw.arcfu.com/register_serve_image"
|
|
245
|
+
|
|
246
|
+
with open(file_path, "rb") as image_file:
|
|
247
|
+
image_bytes = image_file.read()
|
|
248
|
+
media_type = mimetypes.guess_type(file_path)[0]
|
|
249
|
+
image_b64 = base64.b64encode(image_bytes).decode("utf-8")
|
|
250
|
+
uu = {"name": name, "image_b64": image_b64, "media_type": media_type}
|
|
251
|
+
|
|
252
|
+
with syncconnect(f"{server_url}/{client_uuid}") as websocket:
|
|
253
|
+
try:
|
|
254
|
+
websocket.send(json.dumps(uu))
|
|
255
|
+
except websockets.ConnectionClosed:
|
|
256
|
+
print(f"Connection closed for UUID: {client_uuid}, retrying")
|
|
257
|
+
serve_image_in_bg(file_path, client_uuid, name)
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
class ImageData(BaseModel):
|
|
261
|
+
dataurl: str
|
|
262
|
+
|
|
263
|
+
|
|
223
264
|
Param = ParamSpec("Param")
|
|
224
265
|
|
|
225
266
|
T = TypeVar("T")
|
|
@@ -229,7 +270,7 @@ def ensure_no_previous_output(func: Callable[Param, T]) -> Callable[Param, T]:
|
|
|
229
270
|
def wrapper(*args: Param.args, **kwargs: Param.kwargs) -> T:
|
|
230
271
|
global BASH_STATE
|
|
231
272
|
if BASH_STATE == "waiting_for_input":
|
|
232
|
-
raise ValueError(
|
|
273
|
+
raise ValueError(WAITING_INPUT_MESSAGE)
|
|
233
274
|
elif BASH_STATE == "wont_exit":
|
|
234
275
|
raise ValueError(
|
|
235
276
|
"A command is already running that hasn't exited. NOTE: You can't run multiple shell sessions, likely the previous program is in infinite loop. Please kill the previous program by sending ctrl+c first."
|
|
@@ -239,14 +280,33 @@ def ensure_no_previous_output(func: Callable[Param, T]) -> Callable[Param, T]:
|
|
|
239
280
|
return wrapper
|
|
240
281
|
|
|
241
282
|
|
|
283
|
+
@ensure_no_previous_output
|
|
284
|
+
def read_image_from_shell(file_path: str) -> ImageData:
|
|
285
|
+
if not os.path.isabs(file_path):
|
|
286
|
+
SHELL.sendline("pwd")
|
|
287
|
+
SHELL.expect("#@@")
|
|
288
|
+
assert isinstance(SHELL.before, str)
|
|
289
|
+
current_dir = render_terminal_output(SHELL.before).strip()
|
|
290
|
+
file_path = os.path.join(current_dir, file_path)
|
|
291
|
+
|
|
292
|
+
if not os.path.exists(file_path):
|
|
293
|
+
raise ValueError(f"File {file_path} does not exist")
|
|
294
|
+
|
|
295
|
+
with open(file_path, "rb") as image_file:
|
|
296
|
+
image_bytes = image_file.read()
|
|
297
|
+
image_b64 = base64.b64encode(image_bytes).decode("utf-8")
|
|
298
|
+
image_type = mimetypes.guess_type(file_path)[0]
|
|
299
|
+
return ImageData(dataurl=f"data:{image_type};base64,{image_b64}")
|
|
300
|
+
|
|
301
|
+
|
|
242
302
|
@ensure_no_previous_output
|
|
243
303
|
def write_file(writefile: Writefile) -> str:
|
|
244
304
|
if not os.path.isabs(writefile.file_path):
|
|
245
305
|
SHELL.sendline("pwd")
|
|
246
306
|
SHELL.expect("#@@")
|
|
247
307
|
assert isinstance(SHELL.before, str)
|
|
248
|
-
current_dir = SHELL.before.strip()
|
|
249
|
-
|
|
308
|
+
current_dir = render_terminal_output(SHELL.before).strip()
|
|
309
|
+
return f"Failure: Use absolute path only. FYI current working directory is '{current_dir}'"
|
|
250
310
|
os.makedirs(os.path.dirname(writefile.file_path), exist_ok=True)
|
|
251
311
|
try:
|
|
252
312
|
with open(writefile.file_path, "w") as f:
|
|
@@ -280,51 +340,40 @@ def take_help_of_ai_assistant(
|
|
|
280
340
|
return output, cost
|
|
281
341
|
|
|
282
342
|
|
|
283
|
-
class AddTasks(BaseModel):
|
|
284
|
-
task_statement: str
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
def add_task(addtask: AddTasks) -> str:
|
|
288
|
-
petname_id = petname.Generate(2, "-")
|
|
289
|
-
return petname_id
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
class RemoveTask(BaseModel):
|
|
293
|
-
task_id: str
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
def remove_task(removetask: RemoveTask) -> str:
|
|
297
|
-
return "removed"
|
|
298
|
-
|
|
299
|
-
|
|
300
343
|
def which_tool(args: str) -> BaseModel:
|
|
301
344
|
adapter = TypeAdapter[
|
|
302
|
-
Confirmation | ExecuteBash | Writefile | AIAssistant | DoneFlag
|
|
303
|
-
](Confirmation | ExecuteBash | Writefile | AIAssistant | DoneFlag)
|
|
345
|
+
Confirmation | ExecuteBash | Writefile | AIAssistant | DoneFlag | ReadImage
|
|
346
|
+
](Confirmation | ExecuteBash | Writefile | AIAssistant | DoneFlag | ReadImage)
|
|
304
347
|
return adapter.validate_python(json.loads(args))
|
|
305
348
|
|
|
306
349
|
|
|
307
350
|
def get_tool_output(
|
|
308
|
-
args: dict
|
|
351
|
+
args: dict[object, object]
|
|
352
|
+
| Confirmation
|
|
353
|
+
| ExecuteBash
|
|
354
|
+
| Writefile
|
|
355
|
+
| AIAssistant
|
|
356
|
+
| DoneFlag
|
|
357
|
+
| ReadImage,
|
|
309
358
|
enc: tiktoken.Encoding,
|
|
310
359
|
limit: float,
|
|
311
360
|
loop_call: Callable[[str, float], tuple[str, float]],
|
|
312
|
-
|
|
313
|
-
) -> tuple[str | DoneFlag, float]:
|
|
361
|
+
max_tokens: Optional[int],
|
|
362
|
+
) -> tuple[str | ImageData | DoneFlag, float]:
|
|
314
363
|
if isinstance(args, dict):
|
|
315
364
|
adapter = TypeAdapter[
|
|
316
|
-
Confirmation | ExecuteBash | Writefile | AIAssistant | DoneFlag
|
|
317
|
-
](Confirmation | ExecuteBash | Writefile | AIAssistant | DoneFlag)
|
|
365
|
+
Confirmation | ExecuteBash | Writefile | AIAssistant | DoneFlag | ReadImage
|
|
366
|
+
](Confirmation | ExecuteBash | Writefile | AIAssistant | DoneFlag | ReadImage)
|
|
318
367
|
arg = adapter.validate_python(args)
|
|
319
368
|
else:
|
|
320
369
|
arg = args
|
|
321
|
-
output: tuple[str | DoneFlag, float]
|
|
370
|
+
output: tuple[str | DoneFlag | ImageData, float]
|
|
322
371
|
if isinstance(arg, Confirmation):
|
|
323
372
|
console.print("Calling ask confirmation tool")
|
|
324
373
|
output = ask_confirmation(arg), 0.0
|
|
325
374
|
elif isinstance(arg, ExecuteBash):
|
|
326
375
|
console.print("Calling execute bash tool")
|
|
327
|
-
output = execute_bash(enc, arg,
|
|
376
|
+
output = execute_bash(enc, arg, max_tokens)
|
|
328
377
|
elif isinstance(arg, Writefile):
|
|
329
378
|
console.print("Calling write file tool")
|
|
330
379
|
output = write_file(arg), 0
|
|
@@ -334,12 +383,9 @@ def get_tool_output(
|
|
|
334
383
|
elif isinstance(arg, AIAssistant):
|
|
335
384
|
console.print("Calling AI assistant tool")
|
|
336
385
|
output = take_help_of_ai_assistant(arg, limit, loop_call)
|
|
337
|
-
elif isinstance(arg,
|
|
338
|
-
console.print("Calling
|
|
339
|
-
output =
|
|
340
|
-
elif isinstance(arg, get_output_of_last_command):
|
|
341
|
-
console.print("Calling get output of last program tool")
|
|
342
|
-
output = get_output_of_last_command(enc), 0
|
|
386
|
+
elif isinstance(arg, ReadImage):
|
|
387
|
+
console.print("Calling read image tool")
|
|
388
|
+
output = read_image_from_shell(arg.file_path), 0.0
|
|
343
389
|
else:
|
|
344
390
|
raise ValueError(f"Unknown tool: {arg}")
|
|
345
391
|
|
|
@@ -350,12 +396,14 @@ def get_tool_output(
|
|
|
350
396
|
History = list[ChatCompletionMessageParam]
|
|
351
397
|
|
|
352
398
|
|
|
353
|
-
def get_is_waiting_user_input(
|
|
399
|
+
def get_is_waiting_user_input(
|
|
400
|
+
model: Models, cost_data: CostData
|
|
401
|
+
) -> Callable[[str], tuple[BASH_CLF_OUTPUT, float]]:
|
|
354
402
|
enc = tiktoken.encoding_for_model(model if not model.startswith("o1") else "gpt-4o")
|
|
355
403
|
system_prompt = """You need to classify if a bash program is waiting for user input based on its stdout, or if it won't exit. You'll be given the output of any program.
|
|
356
|
-
Return `waiting_for_input` if the program is waiting for INTERACTIVE input only, Return
|
|
404
|
+
Return `waiting_for_input` if the program is waiting for INTERACTIVE input only, Return 'running' if it's waiting for external resources or just waiting to finish.
|
|
357
405
|
Return `wont_exit` if the program won't exit, for example if it's a server.
|
|
358
|
-
Return `
|
|
406
|
+
Return `running` otherwise.
|
|
359
407
|
"""
|
|
360
408
|
history: History = [{"role": "system", "content": system_prompt}]
|
|
361
409
|
client = OpenAI()
|
|
@@ -402,64 +450,70 @@ def execute_user_input() -> None:
|
|
|
402
450
|
while True:
|
|
403
451
|
discard_input()
|
|
404
452
|
user_input = input()
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
async def register_client(server_url: str) -> None:
|
|
453
|
+
with execution_lock:
|
|
454
|
+
try:
|
|
455
|
+
console.log(
|
|
456
|
+
execute_bash(
|
|
457
|
+
default_enc,
|
|
458
|
+
ExecuteBash(
|
|
459
|
+
send_ascii=[ord(x) for x in user_input] + [ord("\n")]
|
|
460
|
+
),
|
|
461
|
+
max_tokens=None,
|
|
462
|
+
)[0]
|
|
463
|
+
)
|
|
464
|
+
except Exception as e:
|
|
465
|
+
traceback.print_exc()
|
|
466
|
+
console.log(f"Error: {e}")
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
async def register_client(server_url: str, client_uuid: str = "") -> None:
|
|
423
470
|
global default_enc, default_model, curr_cost
|
|
424
471
|
# Generate a unique UUID for this client
|
|
425
|
-
|
|
426
|
-
|
|
472
|
+
if not client_uuid:
|
|
473
|
+
client_uuid = str(uuid.uuid4())
|
|
427
474
|
|
|
428
475
|
# Create the WebSocket connection
|
|
429
476
|
async with websockets.connect(f"{server_url}/{client_uuid}") as websocket:
|
|
477
|
+
print(
|
|
478
|
+
f"Connected. Share this user id with the chatbot: {client_uuid} \nLink: https://chatgpt.com/g/g-Us0AAXkRh-wcgw-giving-shell-access"
|
|
479
|
+
)
|
|
430
480
|
try:
|
|
431
481
|
while True:
|
|
432
482
|
# Wait to receive data from the server
|
|
433
483
|
message = await websocket.recv()
|
|
434
|
-
print(message, type(message))
|
|
435
484
|
mdata = Mdata.model_validate_json(message)
|
|
436
485
|
with execution_lock:
|
|
437
|
-
is_waiting_user_input = get_is_waiting_user_input(
|
|
438
|
-
default_model, default_cost
|
|
439
|
-
)
|
|
440
486
|
try:
|
|
441
487
|
output, cost = get_tool_output(
|
|
442
|
-
mdata.data,
|
|
443
|
-
default_enc,
|
|
444
|
-
0.0,
|
|
445
|
-
lambda x, y: ("", 0),
|
|
446
|
-
is_waiting_user_input,
|
|
488
|
+
mdata.data, default_enc, 0.0, lambda x, y: ("", 0), None
|
|
447
489
|
)
|
|
448
490
|
curr_cost += cost
|
|
449
491
|
print(f"{curr_cost=}")
|
|
450
492
|
except Exception as e:
|
|
451
493
|
output = f"GOT EXCEPTION while calling tool. Error: {e}"
|
|
452
494
|
traceback.print_exc()
|
|
453
|
-
assert
|
|
495
|
+
assert isinstance(output, str)
|
|
454
496
|
await websocket.send(output)
|
|
455
497
|
|
|
456
|
-
except websockets.ConnectionClosed:
|
|
457
|
-
print(f"Connection closed for UUID: {client_uuid}")
|
|
498
|
+
except (websockets.ConnectionClosed, ConnectionError):
|
|
499
|
+
print(f"Connection closed for UUID: {client_uuid}, retrying")
|
|
500
|
+
await register_client(server_url, client_uuid)
|
|
458
501
|
|
|
459
502
|
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
503
|
+
run = Typer(pretty_exceptions_show_locals=False, no_args_is_help=True)
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
@run.command()
|
|
507
|
+
def app(
|
|
508
|
+
server_url: str = "wss://wcgw.arcfu.com/register", client_uuid: Optional[str] = None
|
|
509
|
+
) -> None:
|
|
510
|
+
thread1 = threading.Thread(target=execute_user_input)
|
|
511
|
+
thread2 = threading.Thread(
|
|
512
|
+
target=asyncio.run, args=(register_client(server_url, client_uuid or ""),)
|
|
513
|
+
)
|
|
514
|
+
|
|
515
|
+
thread1.start()
|
|
516
|
+
thread2.start()
|
|
517
|
+
|
|
518
|
+
thread1.join()
|
|
519
|
+
thread2.join()
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: wcgw
|
|
3
|
+
Version: 0.1.2
|
|
4
|
+
Summary: What could go wrong giving full shell access to chatgpt?
|
|
5
|
+
Project-URL: Homepage, https://github.com/rusiaaman/wcgw
|
|
6
|
+
Author-email: Aman Rusia <gapypi@arcfu.com>
|
|
7
|
+
Requires-Python: <3.13,>=3.10
|
|
8
|
+
Requires-Dist: fastapi>=0.115.0
|
|
9
|
+
Requires-Dist: mypy>=1.11.2
|
|
10
|
+
Requires-Dist: openai>=1.46.0
|
|
11
|
+
Requires-Dist: petname>=2.6
|
|
12
|
+
Requires-Dist: pexpect>=4.9.0
|
|
13
|
+
Requires-Dist: pydantic>=2.9.2
|
|
14
|
+
Requires-Dist: pyte>=0.8.2
|
|
15
|
+
Requires-Dist: python-dotenv>=1.0.1
|
|
16
|
+
Requires-Dist: rich>=13.8.1
|
|
17
|
+
Requires-Dist: shell>=1.0.1
|
|
18
|
+
Requires-Dist: tiktoken==0.7.0
|
|
19
|
+
Requires-Dist: toml>=0.10.2
|
|
20
|
+
Requires-Dist: typer>=0.12.5
|
|
21
|
+
Requires-Dist: types-pexpect>=4.9.0.20240806
|
|
22
|
+
Requires-Dist: uvicorn>=0.31.0
|
|
23
|
+
Requires-Dist: websockets>=13.1
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
|
|
26
|
+
# Enable shell access on chatgpt.com
|
|
27
|
+
A custom gpt on chatgpt web app to interact with your local shell.
|
|
28
|
+
|
|
29
|
+
[](https://github.com/rusiaaman/wcgw/actions/workflows/python-tests.yml)
|
|
30
|
+
[](https://github.com/rusiaaman/wcgw/actions/workflows/python-publish.yml)
|
|
31
|
+
|
|
32
|
+
### 🚀 Highlights
|
|
33
|
+
- ⚡ **Full Shell Access**: No restrictions, complete control.
|
|
34
|
+
- ⚡ **Create, Execute, Iterate**: Ask the gpt 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.
|
|
35
|
+
- ⚡ **Interactive Command Handling**: [beta] Supports interactive commands using arrow keys, interrupt, and ansi escape sequences.
|
|
36
|
+
|
|
37
|
+
### 🪜 Steps:
|
|
38
|
+
1. Run the [cli client](https://github.com/rusiaaman/wcgw?tab=readme-ov-file#client) in any directory of choice.
|
|
39
|
+
2. Share the generated id with this GPT: `https://chatgpt.com/g/g-Us0AAXkRh-wcgw-giving-shell-access`
|
|
40
|
+
3. The custom GPT can now run any command on your cli
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
## Client
|
|
44
|
+
You need to keep running this client for GPT to access your shell. Run it in a version controlled project's root.
|
|
45
|
+
|
|
46
|
+
### Option 1: using uv [Recommended]
|
|
47
|
+
```sh
|
|
48
|
+
$ curl -LsSf https://astral.sh/uv/install.sh | sh
|
|
49
|
+
$ uv tool run --python 3.12 wcgw@latest
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
### Option 2: using pip
|
|
53
|
+
Supports python >=3.10 and <3.13
|
|
54
|
+
```sh
|
|
55
|
+
$ pip3 install wcgw
|
|
56
|
+
$ wcgw
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
This will print a UUID that you need to share with the gpt.
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
## Chat
|
|
64
|
+
Open the following link or search the "wcgw" custom gpt using "Explore GPTs" on chatgpt.com
|
|
65
|
+
|
|
66
|
+
https://chatgpt.com/g/g-Us0AAXkRh-wcgw-giving-shell-access
|
|
67
|
+
|
|
68
|
+
Finally, let the chatgpt know your user id in any format. E.g., "user_id=<your uuid>" followed by rest of your instructions.
|
|
69
|
+
|
|
70
|
+
NOTE: you can resume a broken connection
|
|
71
|
+
`wcgw --client-uuid $previous_uuid`
|
|
72
|
+
|
|
73
|
+
# How it works
|
|
74
|
+
Your commands are relayed through a server to the terminal client. [You could host the server on your own](https://github.com/rusiaaman/wcgw?tab=readme-ov-file#creating-your-own-custom-gpt-and-the-relay-server). For public convenience I've hosted one at https://wcgw.arcfu.com thanks to the gcloud free tier plan.
|
|
75
|
+
|
|
76
|
+
Chatgpt sends a request to the relay server using the user id that you share with it. The relay server holds a websocket with the terminal client against the user id and acts as a proxy to pass the request.
|
|
77
|
+
|
|
78
|
+
It's secure in both the directions. Either a malicious actor or a malicious Chatgpt has to correctly guess your UUID for any security breach.
|
|
79
|
+
|
|
80
|
+
# Showcase
|
|
81
|
+
|
|
82
|
+
## Unit tests and github actions
|
|
83
|
+
[The first version of unit tests and github workflow to test on multiple python versions were written by the custom chatgpt](https://chatgpt.com/share/6717f922-8998-8005-b825-45d4b348b4dd)
|
|
84
|
+
|
|
85
|
+
## Create a todo app using react + typescript + vite
|
|
86
|
+

|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
# Privacy
|
|
90
|
+
The relay server doesn't store any data. I can't access any information passing through it and only secure channels are used to communicate.
|
|
91
|
+
|
|
92
|
+
You may host the server on your own and create a custom gpt using the following section.
|
|
93
|
+
|
|
94
|
+
# Creating your own custom gpt and the relay server.
|
|
95
|
+
I've used the following instructions and action json schema to create the custom GPT. (Replace wcgw.arcfu.com with the address to your server)
|
|
96
|
+
|
|
97
|
+
https://github.com/rusiaaman/wcgw/blob/main/gpt_instructions.txt
|
|
98
|
+
https://github.com/rusiaaman/wcgw/blob/main/gpt_action_json_schema.json
|
|
99
|
+
|
|
100
|
+
Run the server
|
|
101
|
+
`gunicorn --worker-class uvicorn.workers.UvicornWorker --bind 0.0.0.0:443 src.relay.serve:app --certfile fullchain.pem --keyfile privkey.pem`
|
|
102
|
+
|
|
103
|
+
If you don't have public ip and domain name, you can use `ngrok` or similar services to get a https address to the api.
|
|
104
|
+
|
|
105
|
+
The specify the server url in the `wcgw` command like so
|
|
106
|
+
`wcgw --server-url https://your-url/register`
|
|
107
|
+
|
|
108
|
+
# [Optional] Local shell access with openai API key
|
|
109
|
+
|
|
110
|
+
Add `OPENAI_API_KEY` and `OPENAI_ORG_ID` env variables.
|
|
111
|
+
|
|
112
|
+
Clone the repo and run to install `wcgw_local` command
|
|
113
|
+
|
|
114
|
+
`pip install .`
|
|
115
|
+
|
|
116
|
+
Then run
|
|
117
|
+
|
|
118
|
+
`wcgw_local --limit 0.1` # Cost limit $0.1
|
|
119
|
+
|
|
120
|
+
You can now directly write messages or press enter key to open vim for multiline message and text pasting.
|