wcgw 0.2.0__py3-none-any.whl → 1.0.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/__init__.py CHANGED
@@ -1,2 +1,2 @@
1
- from .basic import app, loop
2
- from .tools import run as listen
1
+ from .client.basic import app, loop
2
+ from .client.tools import run as listen
@@ -0,0 +1,3 @@
1
+ from .tools import run
2
+
3
+ run()
@@ -20,16 +20,15 @@ import petname # type: ignore[import-untyped]
20
20
  from typer import Typer
21
21
  import uuid
22
22
 
23
+ from ..types_ import BashCommand, BashInteraction, ReadImage, Writefile, ResetShell
24
+
23
25
  from .common import Models, discard_input
24
26
  from .common import CostData, History
25
27
  from .openai_utils import get_input_cost, get_output_cost
26
- from .tools import ExecuteBash, ReadImage, ImageData
28
+ from .tools import ImageData
27
29
 
28
30
  from .tools import (
29
- BASH_CLF_OUTPUT,
30
- Confirmation,
31
31
  DoneFlag,
32
- Writefile,
33
32
  get_tool_output,
34
33
  SHELL,
35
34
  start_shell,
@@ -156,27 +155,32 @@ def loop(
156
155
 
157
156
  tools = [
158
157
  openai.pydantic_function_tool(
159
- ExecuteBash,
158
+ BashCommand,
160
159
  description="""
161
- - Execute a bash script. This is stateful (beware with subsequent calls).
162
- - Execute commands using `execute_command` attribute. You can run python/node/other REPL code lines using `execute_command` too.
160
+ - Execute a bash command. This is stateful (beware with subsequent calls).
163
161
  - Do not use interactive commands like nano. Prefer writing simpler commands.
164
- - Last line will always be `(exit <int code>)` except if
165
- - The last line is `(pending)` if the program is still running or waiting for your input. You can then send input using `send_ascii` attributes. You get status by sending new line `send_ascii: ["Enter"]` or `send_ascii: [10]`.
166
- - Optionally the last line is `(won't exit)` in which case you need to kill the process if you want to run a new command.
162
+ - Status of the command and the current working directory will always be returned at the end.
167
163
  - Optionally `exit shell has restarted` is the output, in which case environment resets, you can run fresh commands.
168
164
  - The first line might be `(...truncated)` if the output is too long.
169
165
  - Always run `pwd` if you get any file or directory not found error to make sure you're not lost.
170
- - You can run python/node/other REPL code lines using `execute_command` too. NOTE: `execute_command` doesn't create a new shell, it uses the same shell.
171
166
  """,
167
+ ),
168
+ openai.pydantic_function_tool(
169
+ BashInteraction,
170
+ description="""
171
+ - Interact with running program using this tool.""",
172
172
  ),
173
173
  openai.pydantic_function_tool(
174
174
  Writefile,
175
- description="Write content to a file. Provide file path and content. Use this instead of ExecuteBash for writing files.",
175
+ description="Write content to a file. Provide file path and content. Use this instead of BashCommand for writing files.",
176
176
  ),
177
177
  openai.pydantic_function_tool(
178
178
  ReadImage, description="Read an image from the shell."
179
179
  ),
180
+ openai.pydantic_function_tool(
181
+ ResetShell,
182
+ description="Resets the shell. Use only if all interrupts and prompt reset attempts have failed repeatedly.",
183
+ ),
180
184
  ]
181
185
  uname_sysname = os.uname().sysname
182
186
  uname_machine = os.uname().machine
@@ -2,9 +2,11 @@ import asyncio
2
2
  import base64
3
3
  import json
4
4
  import mimetypes
5
+ from pathlib import Path
5
6
  import re
6
7
  import sys
7
8
  import threading
9
+ import importlib.metadata
8
10
  import traceback
9
11
  from typing import (
10
12
  Callable,
@@ -12,12 +14,12 @@ from typing import (
12
14
  NewType,
13
15
  Optional,
14
16
  ParamSpec,
15
- Sequence,
16
17
  TypeVar,
17
18
  TypedDict,
18
19
  )
19
20
  import uuid
20
21
  from pydantic import BaseModel, TypeAdapter
22
+ import typer
21
23
  from websockets.sync.client import connect as syncconnect
22
24
 
23
25
  import os
@@ -39,6 +41,14 @@ from openai.types.chat import (
39
41
  ChatCompletionMessage,
40
42
  ParsedChatCompletionMessage,
41
43
  )
44
+ from nltk.metrics.distance import edit_distance
45
+ from ..types_ import FileEditFindReplace, ResetShell, Writefile
46
+
47
+ from ..types_ import BashCommand
48
+
49
+ from ..types_ import BashInteraction
50
+
51
+ from ..types_ import ReadImage
42
52
 
43
53
  from .common import CostData, Models, discard_input
44
54
 
@@ -73,15 +83,10 @@ def ask_confirmation(prompt: Confirmation) -> str:
73
83
  return "Yes" if response.lower() == "y" else "No"
74
84
 
75
85
 
76
- class Writefile(BaseModel):
77
- file_path: str
78
- file_content: str
79
-
80
-
81
86
  PROMPT = "#@@"
82
87
 
83
88
 
84
- def start_shell() -> pexpect.spawn:
89
+ def start_shell() -> pexpect.spawn: # type: ignore
85
90
  SHELL = pexpect.spawn(
86
91
  "/bin/bash --noprofile --norc",
87
92
  env={**os.environ, **{"PS1": PROMPT}}, # type: ignore[arg-type]
@@ -133,22 +138,26 @@ def _get_exit_code() -> int:
133
138
  raise ValueError(f"Malformed output: {before}")
134
139
 
135
140
 
136
- Specials = Literal["Key-up", "Key-down", "Key-left", "Key-right", "Enter", "Ctrl-c"]
137
-
141
+ BASH_CLF_OUTPUT = Literal["repl", "pending"]
142
+ BASH_STATE: BASH_CLF_OUTPUT = "repl"
143
+ CWD = os.getcwd()
138
144
 
139
- class ExecuteBash(BaseModel):
140
- execute_command: Optional[str] = None
141
- send_ascii: Optional[Sequence[int | Specials]] = None
142
145
 
146
+ def reset_shell() -> str:
147
+ global SHELL, BASH_STATE, CWD
148
+ SHELL.close(True)
149
+ SHELL = start_shell()
150
+ BASH_STATE = "repl"
151
+ CWD = os.getcwd()
152
+ return "Reset successful" + get_status()
143
153
 
144
- BASH_CLF_OUTPUT = Literal["running", "waiting_for_input", "wont_exit"]
145
- BASH_STATE: BASH_CLF_OUTPUT = "running"
146
154
 
147
-
148
- 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.
149
- 1. Get its output using `send_ascii: [10] or send_ascii: ["Enter"]`
150
- 2. Use `send_ascii` to give inputs to the running program, don't use `execute_command` OR
151
- 3. kill the previous program by sending ctrl+c first using `send_ascii`"""
155
+ WAITING_INPUT_MESSAGE = """A command is already running. NOTE: You can't run multiple shell sessions, likely a previous program hasn't exited.
156
+ 1. Get its output using `send_ascii: [10] or send_specials: ["Enter"]`
157
+ 2. Use `send_ascii` or `send_specials` to give inputs to the running program, don't use `BashCommand` OR
158
+ 3. kill the previous program by sending ctrl+c first using `send_ascii` or `send_specials`
159
+ 4. Send the process in background using `send_specials: ["Ctrl-z"]` followed by BashCommand: `bg`
160
+ """
152
161
 
153
162
 
154
163
  def update_repl_prompt(command: str) -> bool:
@@ -173,45 +182,66 @@ def update_repl_prompt(command: str) -> bool:
173
182
  return False
174
183
 
175
184
 
185
+ def get_cwd() -> str:
186
+ SHELL.sendline("pwd")
187
+ SHELL.expect(PROMPT)
188
+ assert isinstance(SHELL.before, str)
189
+ current_dir = render_terminal_output(SHELL.before).strip()
190
+ return current_dir
191
+
192
+
193
+ def get_status() -> str:
194
+ global CWD
195
+ exit_code: Optional[int] = None
196
+
197
+ status = "\n\n---\n\n"
198
+ if BASH_STATE == "pending":
199
+ status += "status = still running\n"
200
+ status += "cwd = " + CWD + "\n"
201
+ else:
202
+ exit_code = _get_exit_code()
203
+ status += f"status = exited with code {exit_code}\n"
204
+ CWD = get_cwd()
205
+ status += "cwd = " + CWD + "\n"
206
+
207
+ return status.rstrip()
208
+
209
+
176
210
  def execute_bash(
177
- enc: tiktoken.Encoding, bash_arg: ExecuteBash, max_tokens: Optional[int]
211
+ enc: tiktoken.Encoding,
212
+ bash_arg: BashCommand | BashInteraction,
213
+ max_tokens: Optional[int],
178
214
  ) -> tuple[str, float]:
179
- global SHELL, BASH_STATE
215
+ global SHELL, BASH_STATE, CWD
180
216
  try:
181
217
  is_interrupt = False
182
- if bash_arg.execute_command:
183
- updated_repl_mode = update_repl_prompt(bash_arg.execute_command)
218
+ if isinstance(bash_arg, BashCommand):
219
+ updated_repl_mode = update_repl_prompt(bash_arg.command)
184
220
  if updated_repl_mode:
185
- BASH_STATE = "running"
186
- response = "Prompt updated, you can execute REPL lines using execute_command now"
221
+ BASH_STATE = "repl"
222
+ response = (
223
+ "Prompt updated, you can execute REPL lines using BashCommand now"
224
+ )
187
225
  console.print(response)
188
226
  return (
189
227
  response,
190
228
  0,
191
229
  )
192
230
 
193
- console.print(f"$ {bash_arg.execute_command}")
194
- if BASH_STATE == "waiting_for_input":
231
+ console.print(f"$ {bash_arg.command}")
232
+ if BASH_STATE == "pending":
195
233
  raise ValueError(WAITING_INPUT_MESSAGE)
196
- elif BASH_STATE == "wont_exit":
197
- raise ValueError(
198
- """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.
199
- Kill the previous program by sending ctrl+c first using `send_ascii`"""
200
- )
201
- command = bash_arg.execute_command.strip()
234
+ command = bash_arg.command.strip()
202
235
 
203
236
  if "\n" in command:
204
237
  raise ValueError(
205
238
  "Command should not contain newline character in middle. Run only one command at a time."
206
239
  )
240
+
207
241
  SHELL.sendline(command)
208
- elif bash_arg.send_ascii:
209
- console.print(f"Sending ASCII sequence: {bash_arg.send_ascii}")
210
- for char in bash_arg.send_ascii:
211
- if isinstance(char, int):
212
- SHELL.send(chr(char))
213
- if char == 3:
214
- is_interrupt = True
242
+ elif bash_arg.send_specials:
243
+ console.print(f"Sending special sequence: {bash_arg.send_specials}")
244
+ for char in bash_arg.send_specials:
215
245
  if char == "Key-up":
216
246
  SHELL.send("\033[A")
217
247
  elif char == "Key-down":
@@ -225,21 +255,52 @@ def execute_bash(
225
255
  elif char == "Ctrl-c":
226
256
  SHELL.sendintr()
227
257
  is_interrupt = True
258
+ elif char == "Ctrl-d":
259
+ SHELL.sendintr()
260
+ is_interrupt = True
261
+ elif char == "Ctrl-z":
262
+ SHELL.send("\x1a")
263
+ else:
264
+ raise Exception(f"Unknown special character: {char}")
265
+ elif bash_arg.send_ascii:
266
+ console.print(f"Sending ASCII sequence: {bash_arg.send_ascii}")
267
+ for ascii_char in bash_arg.send_ascii:
268
+ SHELL.send(chr(ascii_char))
269
+ if ascii_char == 3:
270
+ is_interrupt = True
228
271
  else:
229
- raise Exception("Nothing to send")
230
- BASH_STATE = "running"
272
+ if bash_arg.send_text is None:
273
+ return (
274
+ "Failure: at least one of send_text, send_specials or send_ascii should be provided",
275
+ 0.0,
276
+ )
277
+
278
+ updated_repl_mode = update_repl_prompt(bash_arg.send_text)
279
+ if updated_repl_mode:
280
+ BASH_STATE = "repl"
281
+ response = (
282
+ "Prompt updated, you can execute REPL lines using BashCommand now"
283
+ )
284
+ console.print(response)
285
+ return (
286
+ response,
287
+ 0,
288
+ )
289
+ console.print(f"Interact text: {bash_arg.send_text}")
290
+ SHELL.sendline(bash_arg.send_text)
291
+
292
+ BASH_STATE = "repl"
231
293
 
232
294
  except KeyboardInterrupt:
233
- SHELL.close(True)
234
- SHELL = start_shell()
235
- raise
295
+ SHELL.sendintr()
296
+ SHELL.expect(PROMPT)
297
+ return "---\n\nFailure: user interrupted the execution", 0.0
236
298
 
237
299
  wait = 5
238
300
  index = SHELL.expect([PROMPT, pexpect.TIMEOUT], timeout=wait)
239
301
  if index == 1:
240
- BASH_STATE = "waiting_for_input"
302
+ BASH_STATE = "pending"
241
303
  text = SHELL.before or ""
242
- print(text)
243
304
 
244
305
  text = render_terminal_output(text)
245
306
  tokens = enc.encode(text)
@@ -247,23 +308,26 @@ def execute_bash(
247
308
  if max_tokens and len(tokens) >= max_tokens:
248
309
  text = "...(truncated)\n" + enc.decode(tokens[-(max_tokens - 1) :])
249
310
 
250
- last_line = "(pending)"
251
- text = text + f"\n{last_line}"
252
-
253
311
  if is_interrupt:
254
312
  text = (
255
313
  text
256
- + """
257
- Failure interrupting. Have you entered a new REPL like python, node, ipython, etc.? Or have you exited from a previous REPL program?
258
- If yes:
259
- Run execute_command: "wcgw_update_prompt()" to enter the new REPL mode.
260
- If no:
261
- Try Ctrl-c or Ctrl-d again.
314
+ + """---
315
+ ----
316
+ Failure interrupting.
317
+ If any REPL session was previously running or if bashrc was sourced, or if there is issue to other REPL related reasons:
318
+ Run BashCommand: "wcgw_update_prompt()" to reset the PS1 prompt.
319
+ Otherwise, you may want to try Ctrl-c again or program specific exit interactive commands.
262
320
  """
263
321
  )
264
322
 
323
+ exit_status = get_status()
324
+ text += exit_status
325
+
265
326
  return text, 0
266
327
 
328
+ if is_interrupt:
329
+ return "Interrupt successful", 0.0
330
+
267
331
  assert isinstance(SHELL.before, str)
268
332
  output = render_terminal_output(SHELL.before)
269
333
 
@@ -272,9 +336,8 @@ If no:
272
336
  output = "...(truncated)\n" + enc.decode(tokens[-(max_tokens - 1) :])
273
337
 
274
338
  try:
275
- exit_code = _get_exit_code()
276
- output += f"\n(exit {exit_code})"
277
-
339
+ exit_status = get_status()
340
+ output += exit_status
278
341
  except ValueError as e:
279
342
  console.print(output)
280
343
  traceback.print_exc()
@@ -286,11 +349,6 @@ If no:
286
349
  return output, 0
287
350
 
288
351
 
289
- class ReadImage(BaseModel):
290
- file_path: str
291
- type: Literal["ReadImage"] = "ReadImage"
292
-
293
-
294
352
  def serve_image_in_bg(file_path: str, client_uuid: str, name: str) -> None:
295
353
  if not client_uuid:
296
354
  client_uuid = str(uuid.uuid4())
@@ -323,25 +381,17 @@ T = TypeVar("T")
323
381
  def ensure_no_previous_output(func: Callable[Param, T]) -> Callable[Param, T]:
324
382
  def wrapper(*args: Param.args, **kwargs: Param.kwargs) -> T:
325
383
  global BASH_STATE
326
- if BASH_STATE == "waiting_for_input":
384
+ if BASH_STATE == "pending":
327
385
  raise ValueError(WAITING_INPUT_MESSAGE)
328
- elif BASH_STATE == "wont_exit":
329
- raise ValueError(
330
- "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."
331
- )
386
+
332
387
  return func(*args, **kwargs)
333
388
 
334
389
  return wrapper
335
390
 
336
391
 
337
- @ensure_no_previous_output
338
392
  def read_image_from_shell(file_path: str) -> ImageData:
339
393
  if not os.path.isabs(file_path):
340
- SHELL.sendline("pwd")
341
- SHELL.expect(PROMPT)
342
- assert isinstance(SHELL.before, str)
343
- current_dir = render_terminal_output(SHELL.before).strip()
344
- file_path = os.path.join(current_dir, file_path)
394
+ file_path = os.path.join(CWD, file_path)
345
395
 
346
396
  if not os.path.exists(file_path):
347
397
  raise ValueError(f"File {file_path} does not exist")
@@ -353,22 +403,69 @@ def read_image_from_shell(file_path: str) -> ImageData:
353
403
  return ImageData(dataurl=f"data:{image_type};base64,{image_b64}")
354
404
 
355
405
 
356
- @ensure_no_previous_output
357
406
  def write_file(writefile: Writefile) -> str:
358
407
  if not os.path.isabs(writefile.file_path):
359
- SHELL.sendline("pwd")
360
- SHELL.expect(PROMPT)
361
- assert isinstance(SHELL.before, str)
362
- current_dir = render_terminal_output(SHELL.before).strip()
363
- return f"Failure: Use absolute path only. FYI current working directory is '{current_dir}'"
364
- os.makedirs(os.path.dirname(writefile.file_path), exist_ok=True)
408
+ return "Failure: file_path should be absolute path"
409
+ else:
410
+ path_ = writefile.file_path
411
+
412
+ path = Path(path_)
413
+ path.parent.mkdir(parents=True, exist_ok=True)
414
+
365
415
  try:
366
- with open(writefile.file_path, "w") as f:
416
+ with path.open("w") as f:
367
417
  f.write(writefile.file_content)
368
418
  except OSError as e:
369
- console.print(f"Error: {e}", style="red")
370
419
  return f"Error: {e}"
371
- console.print(f"File written to {writefile.file_path}")
420
+ console.print(f"File written to {path_}")
421
+ return "Success"
422
+
423
+
424
+ def find_least_edit_distance_substring(content: str, find_str: str) -> str:
425
+ content_lines = content.split("\n")
426
+ find_lines = find_str.split("\n")
427
+ # Slide window and find one with sum of edit distance least
428
+ min_edit_distance = float("inf")
429
+ min_edit_distance_lines = []
430
+ for i in range(len(content_lines) - len(find_lines) + 1):
431
+ edit_distance_sum = 0
432
+ for j in range(len(find_lines)):
433
+ edit_distance_sum += edit_distance(content_lines[i + j], find_lines[j])
434
+ if edit_distance_sum < min_edit_distance:
435
+ min_edit_distance = edit_distance_sum
436
+ min_edit_distance_lines = content_lines[i : i + len(find_lines)]
437
+ return "\n".join(min_edit_distance_lines)
438
+
439
+
440
+ def file_edit(file_edit: FileEditFindReplace) -> str:
441
+ if not os.path.isabs(file_edit.file_path):
442
+ path_ = os.path.join(CWD, file_edit.file_path)
443
+ else:
444
+ path_ = file_edit.file_path
445
+
446
+ out_string = "\n".join("> " + line for line in file_edit.find_lines.split("\n"))
447
+ in_string = "\n".join(
448
+ "< " + line for line in file_edit.replace_with_lines.split("\n")
449
+ )
450
+ console.log(f"Editing file: {path_}\n---\n{out_string}\n---\n{in_string}\n---")
451
+ try:
452
+ with open(path_) as f:
453
+ content = f.read()
454
+ # First find counts
455
+ count = content.count(file_edit.find_lines)
456
+
457
+ if count == 0:
458
+ closest_match = find_least_edit_distance_substring(
459
+ content, file_edit.find_lines
460
+ )
461
+ return f"Error: no match found for the provided `find_lines` in the file. Closest match:\n---\n{closest_match}\n---\nFile not edited"
462
+
463
+ content = content.replace(file_edit.find_lines, file_edit.replace_with_lines)
464
+ with open(path_, "w") as f:
465
+ f.write(content)
466
+ except OSError as e:
467
+ return f"Error: {e}"
468
+ console.print(f"File written to {path_}")
372
469
  return "Success"
373
470
 
374
471
 
@@ -396,16 +493,37 @@ def take_help_of_ai_assistant(
396
493
 
397
494
  def which_tool(args: str) -> BaseModel:
398
495
  adapter = TypeAdapter[
399
- Confirmation | ExecuteBash | Writefile | AIAssistant | DoneFlag | ReadImage
400
- ](Confirmation | ExecuteBash | Writefile | AIAssistant | DoneFlag | ReadImage)
496
+ Confirmation
497
+ | BashCommand
498
+ | BashInteraction
499
+ | ResetShell
500
+ | Writefile
501
+ | FileEditFindReplace
502
+ | AIAssistant
503
+ | DoneFlag
504
+ | ReadImage
505
+ ](
506
+ Confirmation
507
+ | BashCommand
508
+ | BashInteraction
509
+ | ResetShell
510
+ | Writefile
511
+ | FileEditFindReplace
512
+ | AIAssistant
513
+ | DoneFlag
514
+ | ReadImage
515
+ )
401
516
  return adapter.validate_python(json.loads(args))
402
517
 
403
518
 
404
519
  def get_tool_output(
405
520
  args: dict[object, object]
406
521
  | Confirmation
407
- | ExecuteBash
522
+ | BashCommand
523
+ | BashInteraction
524
+ | ResetShell
408
525
  | Writefile
526
+ | FileEditFindReplace
409
527
  | AIAssistant
410
528
  | DoneFlag
411
529
  | ReadImage,
@@ -416,8 +534,26 @@ def get_tool_output(
416
534
  ) -> tuple[str | ImageData | DoneFlag, float]:
417
535
  if isinstance(args, dict):
418
536
  adapter = TypeAdapter[
419
- Confirmation | ExecuteBash | Writefile | AIAssistant | DoneFlag | ReadImage
420
- ](Confirmation | ExecuteBash | Writefile | AIAssistant | DoneFlag | ReadImage)
537
+ Confirmation
538
+ | BashCommand
539
+ | BashInteraction
540
+ | ResetShell
541
+ | Writefile
542
+ | FileEditFindReplace
543
+ | AIAssistant
544
+ | DoneFlag
545
+ | ReadImage
546
+ ](
547
+ Confirmation
548
+ | BashCommand
549
+ | BashInteraction
550
+ | ResetShell
551
+ | Writefile
552
+ | FileEditFindReplace
553
+ | AIAssistant
554
+ | DoneFlag
555
+ | ReadImage
556
+ )
421
557
  arg = adapter.validate_python(args)
422
558
  else:
423
559
  arg = args
@@ -425,12 +561,15 @@ def get_tool_output(
425
561
  if isinstance(arg, Confirmation):
426
562
  console.print("Calling ask confirmation tool")
427
563
  output = ask_confirmation(arg), 0.0
428
- elif isinstance(arg, ExecuteBash):
564
+ elif isinstance(arg, (BashCommand | BashInteraction)):
429
565
  console.print("Calling execute bash tool")
430
566
  output = execute_bash(enc, arg, max_tokens)
431
567
  elif isinstance(arg, Writefile):
432
568
  console.print("Calling write file tool")
433
569
  output = write_file(arg), 0
570
+ elif isinstance(arg, FileEditFindReplace):
571
+ console.print("Calling file edit tool")
572
+ output = file_edit(arg), 0.0
434
573
  elif isinstance(arg, DoneFlag):
435
574
  console.print("Calling mark finish tool")
436
575
  output = mark_finish(arg), 0.0
@@ -440,6 +579,9 @@ def get_tool_output(
440
579
  elif isinstance(arg, ReadImage):
441
580
  console.print("Calling read image tool")
442
581
  output = read_image_from_shell(arg.file_path), 0.0
582
+ elif isinstance(arg, ResetShell):
583
+ console.print("Calling reset shell tool")
584
+ output = reset_shell(), 0.0
443
585
  else:
444
586
  raise ValueError(f"Unknown tool: {arg}")
445
587
 
@@ -449,44 +591,6 @@ def get_tool_output(
449
591
 
450
592
  History = list[ChatCompletionMessageParam]
451
593
 
452
-
453
- def get_is_waiting_user_input(
454
- model: Models, cost_data: CostData
455
- ) -> Callable[[str], tuple[BASH_CLF_OUTPUT, float]]:
456
- enc = tiktoken.encoding_for_model(model if not model.startswith("o1") else "gpt-4o")
457
- 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.
458
- 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.
459
- Return `wont_exit` if the program won't exit, for example if it's a server.
460
- Return `running` otherwise.
461
- """
462
- history: History = [{"role": "system", "content": system_prompt}]
463
- client = OpenAI()
464
-
465
- class ExpectedOutput(BaseModel):
466
- output_classified: BASH_CLF_OUTPUT
467
-
468
- def is_waiting_user_input(output: str) -> tuple[BASH_CLF_OUTPUT, float]:
469
- # Send only last 30 lines
470
- output = "\n".join(output.split("\n")[-30:])
471
- # Send only max last 200 tokens
472
- output = enc.decode(enc.encode(output)[-200:])
473
-
474
- history.append({"role": "user", "content": output})
475
- response = client.beta.chat.completions.parse(
476
- model=model, messages=history, response_format=ExpectedOutput
477
- )
478
- parsed = response.choices[0].message.parsed
479
- if parsed is None:
480
- raise ValueError("No parsed output")
481
- cost = (
482
- get_input_cost(cost_data, enc, history)[0]
483
- + get_output_cost(cost_data, enc, response.choices[0].message)[0]
484
- )
485
- return parsed.output_classified, cost
486
-
487
- return is_waiting_user_input
488
-
489
-
490
594
  default_enc = tiktoken.encoding_for_model("gpt-4o")
491
595
  default_model: Models = "gpt-4o-2024-08-06"
492
596
  default_cost = CostData(cost_per_1m_input_tokens=0.15, cost_per_1m_output_tokens=0.6)
@@ -494,7 +598,7 @@ curr_cost = 0.0
494
598
 
495
599
 
496
600
  class Mdata(BaseModel):
497
- data: ExecuteBash | Writefile
601
+ data: BashCommand | BashInteraction | Writefile | ResetShell | FileEditFindReplace
498
602
 
499
603
 
500
604
  execution_lock = threading.Lock()
@@ -509,7 +613,7 @@ def execute_user_input() -> None:
509
613
  console.log(
510
614
  execute_bash(
511
615
  default_enc,
512
- ExecuteBash(
616
+ BashInteraction(
513
617
  send_ascii=[ord(x) for x in user_input] + [ord("\n")]
514
618
  ),
515
619
  max_tokens=None,
@@ -528,6 +632,11 @@ async def register_client(server_url: str, client_uuid: str = "") -> None:
528
632
 
529
633
  # Create the WebSocket connection
530
634
  async with websockets.connect(f"{server_url}/{client_uuid}") as websocket:
635
+ server_version = str(await websocket.recv())
636
+ print(f"Server version: {server_version}")
637
+ client_version = importlib.metadata.version("wcgw")
638
+ await websocket.send(client_version)
639
+
531
640
  print(
532
641
  f"Connected. Share this user id with the chatbot: {client_uuid} \nLink: https://chatgpt.com/g/g-Us0AAXkRh-wcgw-giving-shell-access"
533
642
  )
@@ -559,8 +668,15 @@ run = Typer(pretty_exceptions_show_locals=False, no_args_is_help=True)
559
668
 
560
669
  @run.command()
561
670
  def app(
562
- server_url: str = "wss://wcgw.arcfu.com/register", client_uuid: Optional[str] = None
671
+ server_url: str = "wss://wcgw.arcfu.com/v1/register",
672
+ client_uuid: Optional[str] = None,
673
+ version: bool = typer.Option(False, "--version", "-v"),
563
674
  ) -> None:
675
+ if version:
676
+ version_ = importlib.metadata.version("wcgw")
677
+ print(f"wcgw version: {version_}")
678
+ exit()
679
+
564
680
  thread1 = threading.Thread(target=execute_user_input)
565
681
  thread2 = threading.Thread(
566
682
  target=asyncio.run, args=(register_client(server_url, client_uuid or ""),)
wcgw/relay/serve.py ADDED
@@ -0,0 +1,316 @@
1
+ import asyncio
2
+ import base64
3
+ from importlib import metadata
4
+ import semantic_version # type: ignore[import-untyped]
5
+ import threading
6
+ import time
7
+ from typing import Any, Callable, Coroutine, DefaultDict, Literal, Optional, Sequence
8
+ from uuid import UUID
9
+ import fastapi
10
+ from fastapi import Response, WebSocket, WebSocketDisconnect
11
+ from pydantic import BaseModel
12
+ import uvicorn
13
+ from fastapi.staticfiles import StaticFiles
14
+
15
+ from dotenv import load_dotenv
16
+
17
+ from ..types_ import (
18
+ BashCommand,
19
+ BashInteraction,
20
+ FileEditFindReplace,
21
+ ResetShell,
22
+ Writefile,
23
+ Specials,
24
+ )
25
+
26
+
27
+ class Mdata(BaseModel):
28
+ data: BashCommand | BashInteraction | Writefile | ResetShell | FileEditFindReplace
29
+ user_id: UUID
30
+
31
+
32
+ app = fastapi.FastAPI()
33
+
34
+ clients: dict[UUID, Callable[[Mdata], Coroutine[None, None, None]]] = {}
35
+ websockets: dict[UUID, WebSocket] = {}
36
+ gpts: dict[UUID, Callable[[str], None]] = {}
37
+
38
+ images: DefaultDict[UUID, dict[str, dict[str, Any]]] = DefaultDict(dict)
39
+
40
+
41
+ @app.websocket("/register_serve_image/{uuid}")
42
+ async def register_serve_image(websocket: WebSocket, uuid: UUID) -> None:
43
+ raise Exception("Disabled")
44
+ await websocket.accept()
45
+ received_data = await websocket.receive_json()
46
+ name = received_data["name"]
47
+ image_b64 = received_data["image_b64"]
48
+ image_bytes = base64.b64decode(image_b64)
49
+ images[uuid][name] = {
50
+ "content": image_bytes,
51
+ "media_type": received_data["media_type"],
52
+ }
53
+
54
+
55
+ @app.get("/get_image/{uuid}/{name}")
56
+ async def get_image(uuid: UUID, name: str) -> fastapi.responses.Response:
57
+ return fastapi.responses.Response(
58
+ content=images[uuid][name]["content"],
59
+ media_type=images[uuid][name]["media_type"],
60
+ )
61
+
62
+
63
+ @app.websocket("/register/{uuid}")
64
+ async def register_websocket_deprecated(websocket: WebSocket, uuid: UUID) -> None:
65
+ await websocket.accept()
66
+ await websocket.send_text(
67
+ "Outdated client used. Deprecated api is being used. Upgrade the wcgw app."
68
+ )
69
+ await websocket.close(
70
+ reason="This endpoint is deprecated. Please use /v1/register/{uuid}", code=1002
71
+ )
72
+
73
+
74
+ CLIENT_VERSION_MINIMUM = "1.0.0"
75
+
76
+
77
+ @app.websocket("/v1/register/{uuid}")
78
+ async def register_websocket(websocket: WebSocket, uuid: UUID) -> None:
79
+ await websocket.accept()
80
+
81
+ # send server version
82
+ version = metadata.version("wcgw")
83
+ await websocket.send_text(version)
84
+
85
+ # receive client version
86
+ client_version = await websocket.receive_text()
87
+ sem_version_client = semantic_version.Version.coerce(client_version)
88
+ sem_version_server = semantic_version.Version.coerce(CLIENT_VERSION_MINIMUM)
89
+ if sem_version_client < sem_version_server:
90
+ await websocket.send_text(
91
+ f"Client version {client_version} is outdated. Please upgrade to {CLIENT_VERSION_MINIMUM} or higher."
92
+ )
93
+ await websocket.close(
94
+ reason="Client version outdated. Please upgrade to the latest version.",
95
+ code=1002,
96
+ )
97
+ return
98
+
99
+ # Register the callback for this client UUID
100
+ async def send_data_callback(data: Mdata) -> None:
101
+ await websocket.send_text(data.model_dump_json())
102
+
103
+ clients[uuid] = send_data_callback
104
+ websockets[uuid] = websocket
105
+
106
+ try:
107
+ while True:
108
+ received_data = await websocket.receive_text()
109
+ if uuid not in gpts:
110
+ raise fastapi.HTTPException(status_code=400, detail="No call made")
111
+ gpts[uuid](received_data)
112
+ except WebSocketDisconnect:
113
+ # Remove the client if the WebSocket is disconnected
114
+ del clients[uuid]
115
+ del websockets[uuid]
116
+ print(f"Client {uuid} disconnected")
117
+
118
+
119
+ @app.post("/write_file")
120
+ async def write_file_deprecated(write_file_data: Writefile, user_id: UUID) -> Response:
121
+ return Response(
122
+ content="This version of the API is deprecated. Please upgrade your client.",
123
+ status_code=400,
124
+ )
125
+
126
+
127
+ class WritefileWithUUID(Writefile):
128
+ user_id: UUID
129
+
130
+
131
+ @app.post("/v1/write_file")
132
+ async def write_file(write_file_data: WritefileWithUUID) -> str:
133
+ user_id = write_file_data.user_id
134
+ if user_id not in clients:
135
+ return "Failure: id not found, ask the user to check it."
136
+
137
+ results: Optional[str] = None
138
+
139
+ def put_results(result: str) -> None:
140
+ nonlocal results
141
+ results = result
142
+
143
+ gpts[user_id] = put_results
144
+
145
+ await clients[user_id](Mdata(data=write_file_data, user_id=user_id))
146
+
147
+ start_time = time.time()
148
+ while time.time() - start_time < 30:
149
+ if results is not None:
150
+ return results
151
+ await asyncio.sleep(0.1)
152
+
153
+ raise fastapi.HTTPException(status_code=500, detail="Timeout error")
154
+
155
+
156
+ class FileEditFindReplaceWithUUID(FileEditFindReplace):
157
+ user_id: UUID
158
+
159
+
160
+ @app.post("/v1/file_edit_find_replace")
161
+ async def file_edit_find_replace(
162
+ file_edit_find_replace: FileEditFindReplaceWithUUID,
163
+ ) -> str:
164
+ user_id = file_edit_find_replace.user_id
165
+ if user_id not in clients:
166
+ return "Failure: id not found, ask the user to check it."
167
+
168
+ results: Optional[str] = None
169
+
170
+ def put_results(result: str) -> None:
171
+ nonlocal results
172
+ results = result
173
+
174
+ gpts[user_id] = put_results
175
+
176
+ await clients[user_id](
177
+ Mdata(
178
+ data=file_edit_find_replace,
179
+ user_id=user_id,
180
+ )
181
+ )
182
+
183
+ start_time = time.time()
184
+ while time.time() - start_time < 30:
185
+ if results is not None:
186
+ return results
187
+ await asyncio.sleep(0.1)
188
+
189
+ raise fastapi.HTTPException(status_code=500, detail="Timeout error")
190
+
191
+
192
+ class ResetShellWithUUID(ResetShell):
193
+ user_id: UUID
194
+
195
+
196
+ @app.post("/v1/reset_shell")
197
+ async def reset_shell(reset_shell: ResetShellWithUUID) -> str:
198
+ user_id = reset_shell.user_id
199
+ if user_id not in clients:
200
+ return "Failure: id not found, ask the user to check it."
201
+
202
+ results: Optional[str] = None
203
+
204
+ def put_results(result: str) -> None:
205
+ nonlocal results
206
+ results = result
207
+
208
+ gpts[user_id] = put_results
209
+
210
+ await clients[user_id](Mdata(data=reset_shell, user_id=user_id))
211
+
212
+ start_time = time.time()
213
+ while time.time() - start_time < 30:
214
+ if results is not None:
215
+ return results
216
+ await asyncio.sleep(0.1)
217
+
218
+ raise fastapi.HTTPException(status_code=500, detail="Timeout error")
219
+
220
+
221
+ @app.post("/execute_bash")
222
+ async def execute_bash_deprecated(excute_bash_data: Any, user_id: UUID) -> Response:
223
+ return Response(
224
+ content="This version of the API is deprecated. Please upgrade your client.",
225
+ status_code=400,
226
+ )
227
+
228
+
229
+ class CommandWithUUID(BaseModel):
230
+ command: str
231
+ user_id: UUID
232
+
233
+
234
+ @app.post("/v1/bash_command")
235
+ async def bash_command(command: CommandWithUUID) -> str:
236
+ user_id = command.user_id
237
+ if user_id not in clients:
238
+ return "Failure: id not found, ask the user to check it."
239
+
240
+ results: Optional[str] = None
241
+
242
+ def put_results(result: str) -> None:
243
+ nonlocal results
244
+ results = result
245
+
246
+ gpts[user_id] = put_results
247
+
248
+ await clients[user_id](
249
+ Mdata(data=BashCommand(command=command.command), user_id=user_id)
250
+ )
251
+
252
+ start_time = time.time()
253
+ while time.time() - start_time < 30:
254
+ if results is not None:
255
+ return results
256
+ await asyncio.sleep(0.1)
257
+
258
+ raise fastapi.HTTPException(status_code=500, detail="Timeout error")
259
+
260
+
261
+ class BashInteractionWithUUID(BashInteraction):
262
+ user_id: UUID
263
+
264
+
265
+ @app.post("/v1/bash_interaction")
266
+ async def bash_interaction(bash_interaction: BashInteractionWithUUID) -> str:
267
+ user_id = bash_interaction.user_id
268
+ if user_id not in clients:
269
+ return "Failure: id not found, ask the user to check it."
270
+
271
+ results: Optional[str] = None
272
+
273
+ def put_results(result: str) -> None:
274
+ nonlocal results
275
+ results = result
276
+
277
+ gpts[user_id] = put_results
278
+
279
+ await clients[user_id](
280
+ Mdata(
281
+ data=bash_interaction,
282
+ user_id=user_id,
283
+ )
284
+ )
285
+
286
+ start_time = time.time()
287
+ while time.time() - start_time < 30:
288
+ if results is not None:
289
+ return results
290
+ await asyncio.sleep(0.1)
291
+
292
+ raise fastapi.HTTPException(status_code=500, detail="Timeout error")
293
+
294
+
295
+ app.mount("/static", StaticFiles(directory="static"), name="static")
296
+
297
+
298
+ def run() -> None:
299
+ load_dotenv()
300
+
301
+ uvicorn_thread = threading.Thread(
302
+ target=uvicorn.run,
303
+ args=(app,),
304
+ kwargs={
305
+ "host": "0.0.0.0",
306
+ "port": 8000,
307
+ "log_level": "info",
308
+ "access_log": True,
309
+ },
310
+ )
311
+ uvicorn_thread.start()
312
+ uvicorn_thread.join()
313
+
314
+
315
+ if __name__ == "__main__":
316
+ run()
@@ -0,0 +1,7 @@
1
+ Privacy Policy
2
+ I do not collect, store, or share any personal data.
3
+ The data from your terminal is not stored anywhere and it's not logged or collected in any form.
4
+ There is a relay webserver for connecting your terminal to chatgpt the source code for which is open at https://github.com/rusiaaman/wcgw/tree/main/src/relay that you can run on your own.
5
+ Other than the relay webserver there is no further involvement of my servers or services.
6
+ Feel free to me contact at info@arcfu.com for questions on privacy or anything else.
7
+
wcgw/types_.py ADDED
@@ -0,0 +1,37 @@
1
+ from typing import Literal, Optional, Sequence
2
+ from pydantic import BaseModel
3
+
4
+
5
+ class BashCommand(BaseModel):
6
+ command: str
7
+
8
+
9
+ Specials = Literal[
10
+ "Key-up", "Key-down", "Key-left", "Key-right", "Enter", "Ctrl-c", "Ctrl-d", "Ctrl-z"
11
+ ]
12
+
13
+
14
+ class BashInteraction(BaseModel):
15
+ send_text: Optional[str] = None
16
+ send_specials: Optional[Sequence[Specials]] = None
17
+ send_ascii: Optional[Sequence[int]] = None
18
+
19
+
20
+ class ReadImage(BaseModel):
21
+ file_path: str
22
+ type: Literal["ReadImage"] = "ReadImage"
23
+
24
+
25
+ class Writefile(BaseModel):
26
+ file_path: str
27
+ file_content: str
28
+
29
+
30
+ class FileEditFindReplace(BaseModel):
31
+ file_path: str
32
+ find_lines: str
33
+ replace_with_lines: str
34
+
35
+
36
+ class ResetShell(BaseModel):
37
+ should_reset: Literal[True] = True
@@ -1,12 +1,13 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: wcgw
3
- Version: 0.2.0
3
+ Version: 1.0.1
4
4
  Summary: What could go wrong giving full shell access to chatgpt?
5
5
  Project-URL: Homepage, https://github.com/rusiaaman/wcgw
6
6
  Author-email: Aman Rusia <gapypi@arcfu.com>
7
7
  Requires-Python: <3.13,>=3.10
8
8
  Requires-Dist: fastapi>=0.115.0
9
9
  Requires-Dist: mypy>=1.11.2
10
+ Requires-Dist: nltk>=3.9.1
10
11
  Requires-Dist: openai>=1.46.0
11
12
  Requires-Dist: petname>=2.6
12
13
  Requires-Dist: pexpect>=4.9.0
@@ -14,6 +15,7 @@ Requires-Dist: pydantic>=2.9.2
14
15
  Requires-Dist: pyte>=0.8.2
15
16
  Requires-Dist: python-dotenv>=1.0.1
16
17
  Requires-Dist: rich>=13.8.1
18
+ Requires-Dist: semantic-version>=2.10.0
17
19
  Requires-Dist: shell>=1.0.1
18
20
  Requires-Dist: tiktoken==0.7.0
19
21
  Requires-Dist: toml>=0.10.2
@@ -32,7 +34,8 @@ A custom gpt on chatgpt web app to interact with your local shell.
32
34
  ### 🚀 Highlights
33
35
  - ⚡ **Full Shell Access**: No restrictions, complete control.
34
36
  - ⚡ **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.
37
+ - ⚡ **Interactive Command Handling**: Supports interactive commands using arrow keys, interrupt, and ansi escape sequences.
38
+ - ⚡ **REPL support**: [beta] Supports python/node and other REPL execution.
36
39
 
37
40
  ### 🪜 Steps:
38
41
  1. Run the [cli client](https://github.com/rusiaaman/wcgw?tab=readme-ov-file#client) in any directory of choice.
@@ -98,12 +101,12 @@ https://github.com/rusiaaman/wcgw/blob/main/gpt_instructions.txt
98
101
  https://github.com/rusiaaman/wcgw/blob/main/gpt_action_json_schema.json
99
102
 
100
103
  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`
104
+ `gunicorn --worker-class uvicorn.workers.UvicornWorker --bind 0.0.0.0:443 src.wcgw.relay.serve:app --certfile fullchain.pem --keyfile privkey.pem`
102
105
 
103
106
  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
107
 
105
108
  The specify the server url in the `wcgw` command like so
106
- `wcgw --server-url https://your-url/register`
109
+ `wcgw --server-url https://your-url/v1/register`
107
110
 
108
111
  # [Optional] Local shell access with openai API key
109
112
 
@@ -0,0 +1,15 @@
1
+ wcgw/__init__.py,sha256=VMi3gCAN_4_Ft8v5dY74u6bt5X-H1QhFJqTMZRd4fvk,76
2
+ wcgw/types_.py,sha256=qpMRh1y136GkjIONIFowLy56v5p0OcdNqEH2mTmHPqU,756
3
+ wcgw/client/__main__.py,sha256=ngI_vBcLAv7fJgmS4w4U7tuWtalGB8c7W5qebuT6Z6o,30
4
+ wcgw/client/basic.py,sha256=2rY5pKm9dBBRuku0uIBUeLWjNc3iXjTSK9CmfuTxoS8,16196
5
+ wcgw/client/claude.py,sha256=Bp45-UMBIJd-4tzX618nu-SpRbVtkTb1Es6c_gW6xy0,14861
6
+ wcgw/client/common.py,sha256=grH-yV_4tnTQZ29xExn4YicGLxEq98z-HkEZwH0ReSg,1410
7
+ wcgw/client/openai_adapters.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
8
+ wcgw/client/openai_utils.py,sha256=YNwCsA-Wqq7jWrxP0rfQmBTb1dI0s7dWXzQqyTzOZT4,2629
9
+ wcgw/client/tools.py,sha256=Ggm5QrSQiyv3Bnme-O9eo_8dXFwS1srCAoow-pODDSE,21212
10
+ wcgw/relay/serve.py,sha256=e3oo4trAWmEKJWcLOsdZCICyEXUCqrI-71DaFnPKI70,8641
11
+ wcgw/relay/static/privacy.txt,sha256=s9qBdbx2SexCpC_z33sg16TptmAwDEehMCLz4L50JLc,529
12
+ wcgw-1.0.1.dist-info/METADATA,sha256=O2BPohrX_Eo6RRuWfY18Cz8MX6qTlgSnExa52Nd8Ggg,5222
13
+ wcgw-1.0.1.dist-info/WHEEL,sha256=1yFddiXMmvYK7QYTqtRNtX66WJ0Mz8PYEiEUoOUUxRY,87
14
+ wcgw-1.0.1.dist-info/entry_points.txt,sha256=WlIB825-Vm9ZtNzgENQsbHj4DRMkbpVR7uSkQyBlaPA,93
15
+ wcgw-1.0.1.dist-info/RECORD,,
@@ -1,3 +1,4 @@
1
1
  [console_scripts]
2
2
  wcgw = wcgw:listen
3
3
  wcgw_local = wcgw:app
4
+ wcgw_relay = wcgw.relay.serve:run
wcgw/__main__.py DELETED
@@ -1,3 +0,0 @@
1
- from wcgw.tools import run
2
-
3
- run()
@@ -1,12 +0,0 @@
1
- wcgw/__init__.py,sha256=okSsOWpTKDjEQzgOin3Kdpx4Mc3MFX1RunjopHQSIWE,62
2
- wcgw/__main__.py,sha256=MjJnFwfYzA1rW47xuSP1EVsi53DTHeEGqESkQwsELFQ,34
3
- wcgw/basic.py,sha256=aTos3c0URl-ufgXfQ1bkg-5oFCR_SxG_VI5qckBtex0,16426
4
- wcgw/claude.py,sha256=Bp45-UMBIJd-4tzX618nu-SpRbVtkTb1Es6c_gW6xy0,14861
5
- wcgw/common.py,sha256=grH-yV_4tnTQZ29xExn4YicGLxEq98z-HkEZwH0ReSg,1410
6
- wcgw/openai_adapters.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
7
- wcgw/openai_utils.py,sha256=YNwCsA-Wqq7jWrxP0rfQmBTb1dI0s7dWXzQqyTzOZT4,2629
8
- wcgw/tools.py,sha256=UdSU6lAbOGNdG2wiM5x8YTosBDlphiMEo7MHtMjGvRk,18618
9
- wcgw-0.2.0.dist-info/METADATA,sha256=X4vyv9Oaq8JtD301hk8jObC3bHggqtyWcxNvUssG-I4,5076
10
- wcgw-0.2.0.dist-info/WHEEL,sha256=1yFddiXMmvYK7QYTqtRNtX66WJ0Mz8PYEiEUoOUUxRY,87
11
- wcgw-0.2.0.dist-info/entry_points.txt,sha256=T-IH7w6Vc650hr8xksC8kJfbJR4uwN8HDudejwDwrNM,59
12
- wcgw-0.2.0.dist-info/RECORD,,
File without changes
File without changes
File without changes
File without changes
File without changes