wcgw 0.1.0__py3-none-any.whl → 0.1.1__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 +178 -125
- wcgw-0.1.1.dist-info/METADATA +120 -0
- wcgw-0.1.1.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.1.dist-info}/WHEEL +0 -0
- {wcgw-0.1.0.dist-info → wcgw-0.1.1.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,32 @@ 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
|
+
before = SHELL.before
|
|
118
|
+
|
|
119
|
+
try:
|
|
120
|
+
return int((before))
|
|
121
|
+
except ValueError:
|
|
122
|
+
raise ValueError(f"Malformed output: {before}")
|
|
90
123
|
|
|
91
124
|
|
|
92
125
|
Specials = Literal["Key-up", "Key-down", "Key-left", "Key-right", "Enter", "Ctrl-c"]
|
|
@@ -97,41 +130,24 @@ class ExecuteBash(BaseModel):
|
|
|
97
130
|
send_ascii: Optional[Sequence[int | Specials]] = None
|
|
98
131
|
|
|
99
132
|
|
|
100
|
-
class GetShellOutputLastCommand(BaseModel):
|
|
101
|
-
type: Literal["get_output_of_last_command"] = "get_output_of_last_command"
|
|
102
|
-
|
|
103
|
-
|
|
104
133
|
BASH_CLF_OUTPUT = Literal["running", "waiting_for_input", "wont_exit"]
|
|
105
134
|
BASH_STATE: BASH_CLF_OUTPUT = "running"
|
|
106
135
|
|
|
107
136
|
|
|
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
|
|
137
|
+
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.
|
|
138
|
+
1. Get its output using `send_ascii: [10]`
|
|
139
|
+
2. Use `send_ascii` to give inputs to the running program, don't use `execute_command` OR
|
|
122
140
|
3. kill the previous program by sending ctrl+c first using `send_ascii`"""
|
|
123
141
|
|
|
124
142
|
|
|
125
143
|
def execute_bash(
|
|
126
|
-
enc: tiktoken.Encoding,
|
|
127
|
-
bash_arg: ExecuteBash,
|
|
128
|
-
is_waiting_user_input: Callable[[str], tuple[BASH_CLF_OUTPUT, float]],
|
|
144
|
+
enc: tiktoken.Encoding, bash_arg: ExecuteBash, max_tokens: Optional[int]
|
|
129
145
|
) -> tuple[str, float]:
|
|
130
146
|
global SHELL, BASH_STATE
|
|
131
147
|
try:
|
|
132
148
|
if bash_arg.execute_command:
|
|
133
149
|
if BASH_STATE == "waiting_for_input":
|
|
134
|
-
raise ValueError(
|
|
150
|
+
raise ValueError(WAITING_INPUT_MESSAGE)
|
|
135
151
|
elif BASH_STATE == "wont_exit":
|
|
136
152
|
raise ValueError(
|
|
137
153
|
"""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 +188,41 @@ def execute_bash(
|
|
|
172
188
|
SHELL = start_shell()
|
|
173
189
|
raise
|
|
174
190
|
|
|
175
|
-
wait =
|
|
191
|
+
wait = 5
|
|
176
192
|
index = SHELL.expect(["#@@", pexpect.TIMEOUT], timeout=wait)
|
|
177
193
|
running = ""
|
|
178
194
|
while index == 1:
|
|
179
195
|
if wait > TIMEOUT:
|
|
180
196
|
raise TimeoutError("Timeout while waiting for shell prompt")
|
|
181
197
|
|
|
182
|
-
|
|
198
|
+
BASH_STATE = "waiting_for_input"
|
|
199
|
+
text = SHELL.before or ""
|
|
183
200
|
print(text[len(running) :])
|
|
184
201
|
running = text
|
|
185
202
|
|
|
186
203
|
text = render_terminal_output(text)
|
|
187
|
-
|
|
188
|
-
if BASH_STATE == "waiting_for_input" or BASH_STATE == "wont_exit":
|
|
189
|
-
tokens = enc.encode(text)
|
|
204
|
+
tokens = enc.encode(text)
|
|
190
205
|
|
|
191
|
-
|
|
192
|
-
|
|
206
|
+
if max_tokens and len(tokens) >= max_tokens:
|
|
207
|
+
text = "...(truncated)\n" + enc.decode(tokens[-(max_tokens - 1) :])
|
|
193
208
|
|
|
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
|
|
209
|
+
last_line = "(pending)"
|
|
210
|
+
return text + f"\n{last_line}", 0
|
|
202
211
|
|
|
203
212
|
assert isinstance(SHELL.before, str)
|
|
204
213
|
output = render_terminal_output(SHELL.before)
|
|
205
214
|
|
|
206
215
|
tokens = enc.encode(output)
|
|
207
|
-
if len(tokens) >=
|
|
208
|
-
output = "...(truncated)\n" + enc.decode(tokens[-
|
|
216
|
+
if max_tokens and len(tokens) >= max_tokens:
|
|
217
|
+
output = "...(truncated)\n" + enc.decode(tokens[-(max_tokens - 1) :])
|
|
209
218
|
|
|
210
219
|
try:
|
|
211
220
|
exit_code = _get_exit_code()
|
|
212
221
|
output += f"\n(exit {exit_code})"
|
|
213
222
|
|
|
214
|
-
except ValueError:
|
|
223
|
+
except ValueError as e:
|
|
224
|
+
console.print(output)
|
|
225
|
+
traceback.print_exc()
|
|
215
226
|
console.print("Malformed output, restarting shell", style="red")
|
|
216
227
|
# Malformed output, restart shell
|
|
217
228
|
SHELL.close(True)
|
|
@@ -220,6 +231,35 @@ def execute_bash(
|
|
|
220
231
|
return output, 0
|
|
221
232
|
|
|
222
233
|
|
|
234
|
+
class ReadImage(BaseModel):
|
|
235
|
+
file_path: str
|
|
236
|
+
type: Literal["ReadImage"] = "ReadImage"
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def serve_image_in_bg(file_path: str, client_uuid: str, name: str) -> None:
|
|
240
|
+
if not client_uuid:
|
|
241
|
+
client_uuid = str(uuid.uuid4())
|
|
242
|
+
|
|
243
|
+
server_url = "wss://wcgw.arcfu.com/register_serve_image"
|
|
244
|
+
|
|
245
|
+
with open(file_path, "rb") as image_file:
|
|
246
|
+
image_bytes = image_file.read()
|
|
247
|
+
media_type = mimetypes.guess_type(file_path)[0]
|
|
248
|
+
image_b64 = base64.b64encode(image_bytes).decode("utf-8")
|
|
249
|
+
uu = {"name": name, "image_b64": image_b64, "media_type": media_type}
|
|
250
|
+
|
|
251
|
+
with syncconnect(f"{server_url}/{client_uuid}") as websocket:
|
|
252
|
+
try:
|
|
253
|
+
websocket.send(json.dumps(uu))
|
|
254
|
+
except websockets.ConnectionClosed:
|
|
255
|
+
print(f"Connection closed for UUID: {client_uuid}, retrying")
|
|
256
|
+
serve_image_in_bg(file_path, client_uuid, name)
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
class ImageData(BaseModel):
|
|
260
|
+
dataurl: str
|
|
261
|
+
|
|
262
|
+
|
|
223
263
|
Param = ParamSpec("Param")
|
|
224
264
|
|
|
225
265
|
T = TypeVar("T")
|
|
@@ -229,7 +269,7 @@ def ensure_no_previous_output(func: Callable[Param, T]) -> Callable[Param, T]:
|
|
|
229
269
|
def wrapper(*args: Param.args, **kwargs: Param.kwargs) -> T:
|
|
230
270
|
global BASH_STATE
|
|
231
271
|
if BASH_STATE == "waiting_for_input":
|
|
232
|
-
raise ValueError(
|
|
272
|
+
raise ValueError(WAITING_INPUT_MESSAGE)
|
|
233
273
|
elif BASH_STATE == "wont_exit":
|
|
234
274
|
raise ValueError(
|
|
235
275
|
"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,6 +279,25 @@ def ensure_no_previous_output(func: Callable[Param, T]) -> Callable[Param, T]:
|
|
|
239
279
|
return wrapper
|
|
240
280
|
|
|
241
281
|
|
|
282
|
+
@ensure_no_previous_output
|
|
283
|
+
def read_image_from_shell(file_path: str) -> ImageData:
|
|
284
|
+
if not os.path.isabs(file_path):
|
|
285
|
+
SHELL.sendline("pwd")
|
|
286
|
+
SHELL.expect("#@@")
|
|
287
|
+
assert isinstance(SHELL.before, str)
|
|
288
|
+
current_dir = SHELL.before.strip()
|
|
289
|
+
file_path = os.path.join(current_dir, file_path)
|
|
290
|
+
|
|
291
|
+
if not os.path.exists(file_path):
|
|
292
|
+
raise ValueError(f"File {file_path} does not exist")
|
|
293
|
+
|
|
294
|
+
with open(file_path, "rb") as image_file:
|
|
295
|
+
image_bytes = image_file.read()
|
|
296
|
+
image_b64 = base64.b64encode(image_bytes).decode("utf-8")
|
|
297
|
+
image_type = mimetypes.guess_type(file_path)[0]
|
|
298
|
+
return ImageData(dataurl=f"data:{image_type};base64,{image_b64}")
|
|
299
|
+
|
|
300
|
+
|
|
242
301
|
@ensure_no_previous_output
|
|
243
302
|
def write_file(writefile: Writefile) -> str:
|
|
244
303
|
if not os.path.isabs(writefile.file_path):
|
|
@@ -246,7 +305,7 @@ def write_file(writefile: Writefile) -> str:
|
|
|
246
305
|
SHELL.expect("#@@")
|
|
247
306
|
assert isinstance(SHELL.before, str)
|
|
248
307
|
current_dir = SHELL.before.strip()
|
|
249
|
-
|
|
308
|
+
return f"Failure: Use absolute path only. FYI current working directory is '{current_dir}'"
|
|
250
309
|
os.makedirs(os.path.dirname(writefile.file_path), exist_ok=True)
|
|
251
310
|
try:
|
|
252
311
|
with open(writefile.file_path, "w") as f:
|
|
@@ -280,51 +339,40 @@ def take_help_of_ai_assistant(
|
|
|
280
339
|
return output, cost
|
|
281
340
|
|
|
282
341
|
|
|
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
342
|
def which_tool(args: str) -> BaseModel:
|
|
301
343
|
adapter = TypeAdapter[
|
|
302
|
-
Confirmation | ExecuteBash | Writefile | AIAssistant | DoneFlag
|
|
303
|
-
](Confirmation | ExecuteBash | Writefile | AIAssistant | DoneFlag)
|
|
344
|
+
Confirmation | ExecuteBash | Writefile | AIAssistant | DoneFlag | ReadImage
|
|
345
|
+
](Confirmation | ExecuteBash | Writefile | AIAssistant | DoneFlag | ReadImage)
|
|
304
346
|
return adapter.validate_python(json.loads(args))
|
|
305
347
|
|
|
306
348
|
|
|
307
349
|
def get_tool_output(
|
|
308
|
-
args: dict
|
|
350
|
+
args: dict[object, object]
|
|
351
|
+
| Confirmation
|
|
352
|
+
| ExecuteBash
|
|
353
|
+
| Writefile
|
|
354
|
+
| AIAssistant
|
|
355
|
+
| DoneFlag
|
|
356
|
+
| ReadImage,
|
|
309
357
|
enc: tiktoken.Encoding,
|
|
310
358
|
limit: float,
|
|
311
359
|
loop_call: Callable[[str, float], tuple[str, float]],
|
|
312
|
-
|
|
313
|
-
) -> tuple[str | DoneFlag, float]:
|
|
360
|
+
max_tokens: Optional[int],
|
|
361
|
+
) -> tuple[str | ImageData | DoneFlag, float]:
|
|
314
362
|
if isinstance(args, dict):
|
|
315
363
|
adapter = TypeAdapter[
|
|
316
|
-
Confirmation | ExecuteBash | Writefile | AIAssistant | DoneFlag
|
|
317
|
-
](Confirmation | ExecuteBash | Writefile | AIAssistant | DoneFlag)
|
|
364
|
+
Confirmation | ExecuteBash | Writefile | AIAssistant | DoneFlag | ReadImage
|
|
365
|
+
](Confirmation | ExecuteBash | Writefile | AIAssistant | DoneFlag | ReadImage)
|
|
318
366
|
arg = adapter.validate_python(args)
|
|
319
367
|
else:
|
|
320
368
|
arg = args
|
|
321
|
-
output: tuple[str | DoneFlag, float]
|
|
369
|
+
output: tuple[str | DoneFlag | ImageData, float]
|
|
322
370
|
if isinstance(arg, Confirmation):
|
|
323
371
|
console.print("Calling ask confirmation tool")
|
|
324
372
|
output = ask_confirmation(arg), 0.0
|
|
325
373
|
elif isinstance(arg, ExecuteBash):
|
|
326
374
|
console.print("Calling execute bash tool")
|
|
327
|
-
output = execute_bash(enc, arg,
|
|
375
|
+
output = execute_bash(enc, arg, max_tokens)
|
|
328
376
|
elif isinstance(arg, Writefile):
|
|
329
377
|
console.print("Calling write file tool")
|
|
330
378
|
output = write_file(arg), 0
|
|
@@ -334,12 +382,9 @@ def get_tool_output(
|
|
|
334
382
|
elif isinstance(arg, AIAssistant):
|
|
335
383
|
console.print("Calling AI assistant tool")
|
|
336
384
|
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
|
|
385
|
+
elif isinstance(arg, ReadImage):
|
|
386
|
+
console.print("Calling read image tool")
|
|
387
|
+
output = read_image_from_shell(arg.file_path), 0.0
|
|
343
388
|
else:
|
|
344
389
|
raise ValueError(f"Unknown tool: {arg}")
|
|
345
390
|
|
|
@@ -350,12 +395,14 @@ def get_tool_output(
|
|
|
350
395
|
History = list[ChatCompletionMessageParam]
|
|
351
396
|
|
|
352
397
|
|
|
353
|
-
def get_is_waiting_user_input(
|
|
398
|
+
def get_is_waiting_user_input(
|
|
399
|
+
model: Models, cost_data: CostData
|
|
400
|
+
) -> Callable[[str], tuple[BASH_CLF_OUTPUT, float]]:
|
|
354
401
|
enc = tiktoken.encoding_for_model(model if not model.startswith("o1") else "gpt-4o")
|
|
355
402
|
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
|
|
403
|
+
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
404
|
Return `wont_exit` if the program won't exit, for example if it's a server.
|
|
358
|
-
Return `
|
|
405
|
+
Return `running` otherwise.
|
|
359
406
|
"""
|
|
360
407
|
history: History = [{"role": "system", "content": system_prompt}]
|
|
361
408
|
client = OpenAI()
|
|
@@ -402,64 +449,70 @@ def execute_user_input() -> None:
|
|
|
402
449
|
while True:
|
|
403
450
|
discard_input()
|
|
404
451
|
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:
|
|
452
|
+
with execution_lock:
|
|
453
|
+
try:
|
|
454
|
+
console.log(
|
|
455
|
+
execute_bash(
|
|
456
|
+
default_enc,
|
|
457
|
+
ExecuteBash(
|
|
458
|
+
send_ascii=[ord(x) for x in user_input] + [ord("\n")]
|
|
459
|
+
),
|
|
460
|
+
max_tokens=None,
|
|
461
|
+
)[0]
|
|
462
|
+
)
|
|
463
|
+
except Exception as e:
|
|
464
|
+
traceback.print_exc()
|
|
465
|
+
console.log(f"Error: {e}")
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
async def register_client(server_url: str, client_uuid: str = "") -> None:
|
|
423
469
|
global default_enc, default_model, curr_cost
|
|
424
470
|
# Generate a unique UUID for this client
|
|
425
|
-
|
|
426
|
-
|
|
471
|
+
if not client_uuid:
|
|
472
|
+
client_uuid = str(uuid.uuid4())
|
|
427
473
|
|
|
428
474
|
# Create the WebSocket connection
|
|
429
475
|
async with websockets.connect(f"{server_url}/{client_uuid}") as websocket:
|
|
476
|
+
print(
|
|
477
|
+
f"Connected. Share this user id with the chatbot: {client_uuid} \nLink: https://chatgpt.com/g/g-Us0AAXkRh-wcgw-giving-shell-access"
|
|
478
|
+
)
|
|
430
479
|
try:
|
|
431
480
|
while True:
|
|
432
481
|
# Wait to receive data from the server
|
|
433
482
|
message = await websocket.recv()
|
|
434
|
-
print(message, type(message))
|
|
435
483
|
mdata = Mdata.model_validate_json(message)
|
|
436
484
|
with execution_lock:
|
|
437
|
-
is_waiting_user_input = get_is_waiting_user_input(
|
|
438
|
-
default_model, default_cost
|
|
439
|
-
)
|
|
440
485
|
try:
|
|
441
486
|
output, cost = get_tool_output(
|
|
442
|
-
mdata.data,
|
|
443
|
-
default_enc,
|
|
444
|
-
0.0,
|
|
445
|
-
lambda x, y: ("", 0),
|
|
446
|
-
is_waiting_user_input,
|
|
487
|
+
mdata.data, default_enc, 0.0, lambda x, y: ("", 0), None
|
|
447
488
|
)
|
|
448
489
|
curr_cost += cost
|
|
449
490
|
print(f"{curr_cost=}")
|
|
450
491
|
except Exception as e:
|
|
451
492
|
output = f"GOT EXCEPTION while calling tool. Error: {e}"
|
|
452
493
|
traceback.print_exc()
|
|
453
|
-
assert
|
|
494
|
+
assert isinstance(output, str)
|
|
454
495
|
await websocket.send(output)
|
|
455
496
|
|
|
456
|
-
except websockets.ConnectionClosed:
|
|
457
|
-
print(f"Connection closed for UUID: {client_uuid}")
|
|
497
|
+
except (websockets.ConnectionClosed, ConnectionError):
|
|
498
|
+
print(f"Connection closed for UUID: {client_uuid}, retrying")
|
|
499
|
+
await register_client(server_url, client_uuid)
|
|
458
500
|
|
|
459
501
|
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
502
|
+
run = Typer(pretty_exceptions_show_locals=False, no_args_is_help=True)
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
@run.command()
|
|
506
|
+
def app(
|
|
507
|
+
server_url: str = "wss://wcgw.arcfu.com/register", client_uuid: Optional[str] = None
|
|
508
|
+
) -> None:
|
|
509
|
+
thread1 = threading.Thread(target=execute_user_input)
|
|
510
|
+
thread2 = threading.Thread(
|
|
511
|
+
target=asyncio.run, args=(register_client(server_url, client_uuid or ""),)
|
|
512
|
+
)
|
|
513
|
+
|
|
514
|
+
thread1.start()
|
|
515
|
+
thread2.start()
|
|
516
|
+
|
|
517
|
+
thread1.join()
|
|
518
|
+
thread2.join()
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: wcgw
|
|
3
|
+
Version: 0.1.1
|
|
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 I've hosted at https://wcgw.arcfu.com. The code for that is at `src/relay/serve.py`.
|
|
75
|
+
|
|
76
|
+
Chat gpt 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.
|