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/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,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
- 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
+ # 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
- 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
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(WETTING_INPUT_MESSAGE)
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 = timeout = 5
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
- text = SHELL.before
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
- 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)
205
+ tokens = enc.encode(text)
190
206
 
191
- if len(tokens) >= 2048:
192
- text = "...(truncated)\n" + enc.decode(tokens[-2047:])
207
+ if max_tokens and len(tokens) >= max_tokens:
208
+ text = "...(truncated)\n" + enc.decode(tokens[-(max_tokens - 1) :])
193
209
 
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
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) >= 2048:
208
- output = "...(truncated)\n" + enc.decode(tokens[-2047:])
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(WETTING_INPUT_MESSAGE)
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
- writefile.file_path = os.path.join(current_dir, writefile.file_path)
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 | Confirmation | ExecuteBash | Writefile | AIAssistant | DoneFlag,
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
- is_waiting_user_input: Callable[[str], tuple[BASH_CLF_OUTPUT, float]],
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, is_waiting_user_input)
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, 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
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(model: Models, cost_data: CostData):
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 false if it's waiting for external resources or just waiting to finish.
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 `normal` otherwise.
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
- 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:
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
- client_uuid = str(uuid.uuid4())
426
- print(f"Connecting with UUID: {client_uuid}")
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 not isinstance(output, DoneFlag)
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
- 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))
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
+ [![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 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
+ ![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.