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/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
- # Switch terminal to non-canonical mode where input is read immediately
34
- tty.setcbreak(fd)
35
-
36
- # Discard all input
37
- while True:
38
- # Check if there is input to be read
39
- if sys.stdin in select.select([sys.stdin], [], [], 0)[0]:
40
- sys.stdin.read(
41
- 1
42
- ) # Read one character at a time to flush the input buffer
43
- else:
44
- break
45
- finally:
46
- # Restore old terminal settings
47
- termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)
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
- if not isinstance(content, str):
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
- input_tokens += len(enc.encode(content))
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 Callable, Literal, Optional, ParamSpec, Sequence, TypeVar, TypedDict
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
- ) # type: ignore[arg-type]
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
- SHELL.sendline("echo $?")
106
+ # First reset the prompt in case venv was sourced or other reasons.
107
+ SHELL.sendline('export PS1="#@@"')
87
108
  SHELL.expect("#@@")
88
- assert isinstance(SHELL.before, str)
89
- return int((SHELL.before))
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
- def get_output_of_last_command(enc: tiktoken.Encoding) -> str:
109
- global SHELL, BASH_STATE
110
- output = render_terminal_output(SHELL.before)
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(WETTING_INPUT_MESSAGE)
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 = timeout = 5
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
- text = SHELL.before
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
- BASH_STATE, cost = is_waiting_user_input(text)
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
- if len(tokens) >= 2048:
192
- text = "...(truncated)\n" + enc.decode(tokens[-2047:])
206
+ if max_tokens and len(tokens) >= max_tokens:
207
+ text = "...(truncated)\n" + enc.decode(tokens[-(max_tokens - 1) :])
193
208
 
194
- last_line = (
195
- "(waiting for input)"
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) >= 2048:
208
- output = "...(truncated)\n" + enc.decode(tokens[-2047:])
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(WETTING_INPUT_MESSAGE)
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
- writefile.file_path = os.path.join(current_dir, writefile.file_path)
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 | Confirmation | ExecuteBash | Writefile | AIAssistant | DoneFlag,
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
- is_waiting_user_input: Callable[[str], tuple[BASH_CLF_OUTPUT, float]],
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, is_waiting_user_input)
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, AddTasks):
338
- console.print("Calling add task tool")
339
- output = add_task(arg), 0
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(model: Models, cost_data: CostData):
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 false if it's waiting for external resources or just waiting to finish.
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 `normal` otherwise.
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
- if user_input:
406
- with execution_lock:
407
- try:
408
- console.log(
409
- execute_bash(
410
- default_enc,
411
- ExecuteBash(
412
- send_ascii=[ord(x) for x in user_input] + [ord("\n")]
413
- ),
414
- lambda x: ("wont_exit", 0),
415
- )[0]
416
- )
417
- except Exception as e:
418
- traceback.print_exc()
419
- console.log(f"Error: {e}")
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
- client_uuid = str(uuid.uuid4())
426
- print(f"Connecting with UUID: {client_uuid}")
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 not isinstance(output, DoneFlag)
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
- def run() -> None:
461
- if len(sys.argv) > 1:
462
- server_url = sys.argv[1]
463
- else:
464
- server_url = "ws://localhost:8000/register"
465
- asyncio.run(register_client(server_url))
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
+ [![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)
30
+ [![Build](https://github.com/rusiaaman/wcgw/actions/workflows/python-publish.yml/badge.svg)](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
+ ![Screenshot](https://github.com/rusiaaman/wcgw/blob/main/static/ss1.png?raw=true)
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.