wcgw 1.2.2__py3-none-any.whl → 1.4.0__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,3 @@
1
1
  from .client.cli import app
2
2
  from .client.tools import run as listen
3
+ from .client.mcp_server import main as mcp_server
@@ -29,7 +29,6 @@ from ..types_ import (
29
29
  FileEdit,
30
30
  ReadFile,
31
31
  ReadImage,
32
- Writefile,
33
32
  ResetShell,
34
33
  )
35
34
 
@@ -165,6 +164,7 @@ def loop(
165
164
  - Optionally `exit shell has restarted` is the output, in which case environment resets, you can run fresh commands.
166
165
  - The first line might be `(...truncated)` if the output is too long.
167
166
  - Always run `pwd` if you get any file or directory not found error to make sure you're not lost.
167
+ - The control will return to you in 5 seconds regardless of the status. For heavy commands, keep checking status using BashInteraction till they are finished.
168
168
  """,
169
169
  ),
170
170
  ToolParam(
@@ -193,7 +193,7 @@ def loop(
193
193
  - Write content to a new file. Provide file path and content. Use this instead of BashCommand for writing new files.
194
194
  - This doesn't create any directories, please create directories using `mkdir -p` BashCommand.
195
195
  - Provide absolute file path only.
196
- - For editing existing files, use FileEdit.
196
+ - For editing existing files, use FileEdit instead of this tool.
197
197
  """,
198
198
  ),
199
199
  ToolParam(
@@ -219,7 +219,7 @@ def loop(
219
219
  uname_machine = os.uname().machine
220
220
 
221
221
  system = f"""
222
- You're a cli assistant.
222
+ You're an expert software engineer with shell and code knowledge.
223
223
 
224
224
  Instructions:
225
225
 
@@ -227,10 +227,11 @@ Instructions:
227
227
  - First understand about the project by getting the folder structure (ignoring .git, node_modules, venv, etc.)
228
228
  - Always read relevant files before editing.
229
229
  - Do not provide code snippets unless asked by the user, instead directly edit the code.
230
-
230
+
231
231
  System information:
232
232
  - System: {uname_sysname}
233
233
  - Machine: {uname_machine}
234
+ - Current directory: {os.getcwd()}
234
235
  """
235
236
 
236
237
  with open(os.path.join(os.path.dirname(__file__), "diff-instructions.txt")) as f:
@@ -361,7 +362,7 @@ System information:
361
362
  enc,
362
363
  limit - cost,
363
364
  loop,
364
- max_tokens=8096,
365
+ max_tokens=8000,
365
366
  )
366
367
  except Exception as e:
367
368
  output_or_done = (
@@ -4,6 +4,7 @@ Instructions for editing files.
4
4
 
5
5
  Only edit the files using the following SEARCH/REPLACE blocks.
6
6
  ```
7
+ file_edit_using_search_replace_blocks="""
7
8
  <<<<<<< SEARCH
8
9
  def hello():
9
10
  "print a greeting"
@@ -32,6 +33,7 @@ def call_hello_renamed():
32
33
  hello_renamed()
33
34
  impl2()
34
35
  >>>>>>> REPLACE
36
+ """
35
37
  ```
36
38
 
37
39
  # *SEARCH/REPLACE block* Rules:
@@ -43,7 +45,7 @@ Every *SEARCH/REPLACE block* must use this format:
43
45
  4. The lines to replace into the source code
44
46
  5. The end of the replace block: >>>>>>> REPLACE
45
47
 
46
- Every "<<<<<<< SEARCH" section must *EXACTLY MATCH* the existing file content, character for character, including all comments, docstrings, etc.
48
+ Every "<<<<<<< SEARCH" section must *EXACTLY MATCH* the existing file content, character for character, including all comments, docstrings, whitespaces, etc.
47
49
 
48
50
  *SEARCH/REPLACE* blocks will *only* replace the first match occurrence.
49
51
  Including multiple unique *SEARCH/REPLACE* blocks if needed.
@@ -53,3 +55,5 @@ Keep *SEARCH/REPLACE* blocks concise.
53
55
  Break large *SEARCH/REPLACE* blocks into a series of smaller blocks that each change a small portion of the file.
54
56
  Include just the changing lines, and a few surrounding lines if needed for uniqueness.
55
57
  Do not include long runs of unchanging lines in *SEARCH/REPLACE* blocks.
58
+
59
+ Preserve leading spaces and indentations in both SEARCH and REPLACE blocks.
@@ -0,0 +1,26 @@
1
+ # Claude desktop support
2
+
3
+ ## Setup
4
+
5
+ Update `claude_desktop_config.json`
6
+
7
+ ```json
8
+ {
9
+ "mcpServers": {
10
+ "wcgw": {
11
+ "command": "uvx",
12
+ "args": ["--from", "wcgw", "wcgw_mcp"]
13
+ }
14
+ }
15
+ }
16
+ ```
17
+
18
+ Then restart claude app.
19
+
20
+ ## Usage
21
+
22
+ You should be able to see this icon if everything goes right.
23
+
24
+ ![mcp icon](https://github.com/rusiaaman/wcgw/blob/main/static/rocket-icon.png?raw=true)
25
+
26
+ Then ask claude to execute shell commands, read files, edit files, run your code, etc.
@@ -0,0 +1,11 @@
1
+ from wcgw.client.mcp_server import server
2
+ import asyncio
3
+
4
+
5
+ def main():
6
+ """Main entry point for the package."""
7
+ asyncio.run(server.main())
8
+
9
+
10
+ # Optionally expose other important items at package level
11
+ __all__ = ["main", "server"]
@@ -0,0 +1,222 @@
1
+ import asyncio
2
+ import importlib
3
+ import json
4
+ import os
5
+ import traceback
6
+ from typing import Any
7
+
8
+ from mcp.server.models import InitializationOptions
9
+ import mcp.types as types
10
+ from mcp.types import Tool as ToolParam
11
+ from mcp.server import NotificationOptions, Server
12
+ from pydantic import AnyUrl, ValidationError
13
+ import mcp.server.stdio
14
+ from ..tools import DoneFlag, get_tool_output, which_tool_name, default_enc
15
+ from ...types_ import (
16
+ BashCommand,
17
+ BashInteraction,
18
+ CreateFileNew,
19
+ FileEdit,
20
+ ReadFile,
21
+ ReadImage,
22
+ ResetShell,
23
+ Initialize,
24
+ )
25
+
26
+
27
+ server = Server("wcgw")
28
+
29
+
30
+ @server.list_resources()
31
+ async def handle_list_resources() -> list[types.Resource]:
32
+ return []
33
+
34
+
35
+ @server.read_resource()
36
+ async def handle_read_resource(uri: AnyUrl) -> str:
37
+ raise ValueError("No resources available")
38
+
39
+
40
+ @server.list_prompts()
41
+ async def handle_list_prompts() -> list[types.Prompt]:
42
+ return []
43
+
44
+
45
+ @server.get_prompt()
46
+ async def handle_get_prompt(
47
+ name: str, arguments: dict[str, str] | None
48
+ ) -> types.GetPromptResult:
49
+ types.GetPromptResult(messages=[])
50
+
51
+
52
+ @server.list_tools()
53
+ async def handle_list_tools() -> list[types.Tool]:
54
+ """
55
+ List available tools.
56
+ Each tool specifies its arguments using JSON Schema validation.
57
+ """
58
+
59
+ with open(
60
+ os.path.join(
61
+ os.path.dirname(os.path.dirname(__file__)), "diff-instructions.txt"
62
+ )
63
+ ) as f:
64
+ diffinstructions = f.read()
65
+ return [
66
+ ToolParam(
67
+ inputSchema=Initialize.model_json_schema(),
68
+ name="Initialize",
69
+ description="""
70
+ - Always call this at the start of the conversation before anything else.
71
+ """,
72
+ ),
73
+ ToolParam(
74
+ inputSchema=BashCommand.model_json_schema(),
75
+ name="BashCommand",
76
+ description="""
77
+ - Execute a bash command. This is stateful (beware with subsequent calls).
78
+ - Do not use interactive commands like nano. Prefer writing simpler commands.
79
+ - Status of the command and the current working directory will always be returned at the end.
80
+ - Optionally `exit shell has restarted` is the output, in which case environment resets, you can run fresh commands.
81
+ - The first line might be `(...truncated)` if the output is too long.
82
+ - Always run `pwd` if you get any file or directory not found error to make sure you're not lost.
83
+ - The control will return to you in 5 seconds regardless of the status. For heavy commands, keep checking status using BashInteraction till they are finished.
84
+ """,
85
+ ),
86
+ ToolParam(
87
+ inputSchema=BashInteraction.model_json_schema(),
88
+ name="BashInteraction",
89
+ description="""
90
+ - Interact with running program using this tool
91
+ - Special keys like arrows, interrupts, enter, etc.
92
+ - Send text input to the running program.
93
+ - Send send_specials=["Enter"] to recheck status of a running program.
94
+ - Only one of send_text, send_specials, send_ascii should be provided.
95
+ """,
96
+ ),
97
+ ToolParam(
98
+ inputSchema=ReadFile.model_json_schema(),
99
+ name="ReadFile",
100
+ description="""
101
+ - Read full file content
102
+ - Provide absolute file path only
103
+ """,
104
+ ),
105
+ ToolParam(
106
+ inputSchema=CreateFileNew.model_json_schema(),
107
+ name="CreateFileNew",
108
+ description="""
109
+ - Write content to a new file. Provide file path and content. Use this instead of BashCommand for writing new files.
110
+ - This doesn't create any directories, please create directories using `mkdir -p` BashCommand.
111
+ - Provide absolute file path only.
112
+ - For editing existing files, use FileEdit instead of this tool.
113
+ """,
114
+ ),
115
+ ToolParam(
116
+ inputSchema=ReadImage.model_json_schema(),
117
+ name="ReadImage",
118
+ description="Read an image from the shell.",
119
+ ),
120
+ ToolParam(
121
+ inputSchema=ResetShell.model_json_schema(),
122
+ name="ResetShell",
123
+ description="Resets the shell. Use only if all interrupts and prompt reset attempts have failed repeatedly.",
124
+ ),
125
+ ToolParam(
126
+ inputSchema=FileEdit.model_json_schema(),
127
+ name="FileEdit",
128
+ description="""
129
+ - Use absolute file path only.
130
+ - Use SEARCH/REPLACE blocks to edit the file.
131
+ """
132
+ + diffinstructions,
133
+ ),
134
+ ToolParam(
135
+ inputSchema=ReadImage.model_json_schema(),
136
+ name="ReadImage",
137
+ description="""
138
+ - Read an image from the shell.
139
+ """,
140
+ ),
141
+ ]
142
+
143
+
144
+ @server.call_tool()
145
+ async def handle_call_tool(
146
+ name: str, arguments: dict | None
147
+ ) -> list[types.TextContent | types.ImageContent | types.EmbeddedResource]:
148
+ if not arguments:
149
+ raise ValueError("Missing arguments")
150
+
151
+ tool_type = which_tool_name(name)
152
+
153
+ try:
154
+ tool_call = tool_type(**arguments)
155
+ except ValidationError:
156
+
157
+ def try_json(x: str) -> Any:
158
+ try:
159
+ return json.loads(x)
160
+ except json.JSONDecodeError:
161
+ return x
162
+
163
+ tool_call = tool_type(**{k: try_json(v) for k, v in arguments.items()})
164
+
165
+ try:
166
+ output_or_done, _ = get_tool_output(
167
+ tool_call, default_enc, 0.0, lambda x, y: ("", 0), 8000
168
+ )
169
+
170
+ except Exception as e:
171
+ output_or_done = f"GOT EXCEPTION while calling tool. Error: {e}"
172
+ tb = traceback.format_exc()
173
+ print(output_or_done + "\n" + tb)
174
+
175
+ assert not isinstance(output_or_done, DoneFlag)
176
+ if isinstance(output_or_done, str):
177
+ if issubclass(tool_type, Initialize):
178
+ output_or_done += """
179
+
180
+ You're an expert software engineer with shell and code knowledge.
181
+
182
+ Instructions:
183
+
184
+ - You should use the provided bash execution, reading and writing file tools to complete objective.
185
+ - First understand about the project by getting the folder structure (ignoring .git, node_modules, venv, etc.)
186
+ - Always read relevant files before editing.
187
+ - Do not provide code snippets unless asked by the user, instead directly edit the code.
188
+
189
+
190
+ Additional instructions:
191
+ Always run `pwd` if you get any file or directory not found error to make sure you're not lost, or to get absolute cwd.
192
+
193
+ Always write production ready, syntactically correct code.
194
+ """
195
+
196
+ return [types.TextContent(type="text", text=output_or_done)]
197
+
198
+ return [
199
+ types.ImageContent(
200
+ type="image",
201
+ data=output_or_done.data,
202
+ mimeType=output_or_done.media_type,
203
+ )
204
+ ]
205
+
206
+
207
+ async def main() -> None:
208
+ version = importlib.metadata.version("wcgw")
209
+ # Run the server using stdin/stdout streams
210
+ async with mcp.server.stdio.stdio_server() as (read_stream, write_stream):
211
+ await server.run(
212
+ read_stream,
213
+ write_stream,
214
+ InitializationOptions(
215
+ server_name="wcgw",
216
+ server_version=version,
217
+ capabilities=server.get_capabilities(
218
+ notification_options=NotificationOptions(),
219
+ experimental_capabilities={},
220
+ ),
221
+ ),
222
+ )
@@ -27,7 +27,6 @@ from ..types_ import (
27
27
  FileEdit,
28
28
  ReadImage,
29
29
  ReadFile,
30
- Writefile,
31
30
  ResetShell,
32
31
  )
33
32
 
@@ -148,8 +147,7 @@ def loop(
148
147
  my_dir = os.path.dirname(__file__)
149
148
 
150
149
  config = Config(
151
- model=cast(
152
- Models, os.getenv("OPENAI_MODEL", "gpt-4o-2024-08-06").lower()),
150
+ model=cast(Models, os.getenv("OPENAI_MODEL", "gpt-4o-2024-08-06").lower()),
153
151
  cost_limit=0.1,
154
152
  cost_unit="$",
155
153
  cost_file={
@@ -177,6 +175,7 @@ def loop(
177
175
  - Optionally `exit shell has restarted` is the output, in which case environment resets, you can run fresh commands.
178
176
  - The first line might be `(...truncated)` if the output is too long.
179
177
  - Always run `pwd` if you get any file or directory not found error to make sure you're not lost.
178
+ - The control will return to you in 5 seconds regardless of the status. For heavy commands, keep checking status using BashInteraction till they are finished.
180
179
  """,
181
180
  ),
182
181
  openai.pydantic_function_tool(
@@ -201,7 +200,7 @@ def loop(
201
200
  - Write content to a new file. Provide file path and content. Use this instead of BashCommand for writing new files.
202
201
  - This doesn't create any directories, please create directories using `mkdir -p` BashCommand.
203
202
  - Provide absolute file path only.
204
- - For editing existing files, use FileEdit.""",
203
+ - For editing existing files, use FileEdit instead of this tool.""",
205
204
  ),
206
205
  openai.pydantic_function_tool(
207
206
  FileEdit,
@@ -223,7 +222,7 @@ def loop(
223
222
  uname_machine = os.uname().machine
224
223
 
225
224
  system = f"""
226
- You're a cli assistant.
225
+ You're an expert software engineer with shell and code knowledge.
227
226
 
228
227
  Instructions:
229
228
 
@@ -235,6 +234,7 @@ Instructions:
235
234
  System information:
236
235
  - System: {uname_sysname}
237
236
  - Machine: {uname_machine}
237
+ - Current directory: {os.getcwd()}
238
238
 
239
239
  """
240
240
 
@@ -341,7 +341,7 @@ System information:
341
341
  enc,
342
342
  limit - cost,
343
343
  loop,
344
- max_tokens=2048,
344
+ max_tokens=8000,
345
345
  )
346
346
  except Exception as e:
347
347
  output_or_done = (
wcgw/client/tools.py CHANGED
@@ -45,7 +45,7 @@ from openai.types.chat import (
45
45
  ChatCompletionMessage,
46
46
  ParsedChatCompletionMessage,
47
47
  )
48
- from nltk.metrics.distance import edit_distance
48
+ from nltk.metrics.distance import edit_distance # type: ignore[import-untyped]
49
49
 
50
50
  from ..types_ import (
51
51
  BashCommand,
@@ -53,10 +53,10 @@ from ..types_ import (
53
53
  CreateFileNew,
54
54
  FileEditFindReplace,
55
55
  FileEdit,
56
+ Initialize,
56
57
  ReadFile,
57
58
  ReadImage,
58
59
  ResetShell,
59
- Writefile,
60
60
  )
61
61
 
62
62
  from .common import CostData, Models, discard_input
@@ -95,7 +95,8 @@ def ask_confirmation(prompt: Confirmation) -> str:
95
95
  return "Yes" if response.lower() == "y" else "No"
96
96
 
97
97
 
98
- PROMPT = "#@@"
98
+ PROMPT_CONST = "#@wcgw@#"
99
+ PROMPT = PROMPT_CONST
99
100
 
100
101
 
101
102
  def start_shell() -> pexpect.spawn: # type: ignore
@@ -124,7 +125,7 @@ def _is_int(mystr: str) -> bool:
124
125
 
125
126
 
126
127
  def _get_exit_code() -> int:
127
- if PROMPT != "#@@":
128
+ if PROMPT != PROMPT_CONST:
128
129
  return 0
129
130
  # First reset the prompt in case venv was sourced or other reasons.
130
131
  SHELL.sendline(f"export PS1={PROMPT}")
@@ -155,6 +156,16 @@ BASH_STATE: BASH_CLF_OUTPUT = "repl"
155
156
  CWD = os.getcwd()
156
157
 
157
158
 
159
+ def initial_info() -> str:
160
+ uname_sysname = os.uname().sysname
161
+ uname_machine = os.uname().machine
162
+ return f"""
163
+ System: {uname_sysname}
164
+ Machine: {uname_machine}
165
+ Current working directory: {CWD}
166
+ """
167
+
168
+
158
169
  def reset_shell() -> str:
159
170
  global SHELL, BASH_STATE, CWD
160
171
  SHELL.close(True)
@@ -306,9 +317,7 @@ def execute_bash(
306
317
  updated_repl_mode = update_repl_prompt(bash_arg.send_text)
307
318
  if updated_repl_mode:
308
319
  BASH_STATE = "repl"
309
- response = (
310
- "Prompt updated, you can execute REPL lines using BashCommand now"
311
- )
320
+ response = "Prompt updated, you can execute REPL lines using BashCommand now"
312
321
  console.print(response)
313
322
  return (
314
323
  response,
@@ -334,7 +343,7 @@ def execute_bash(
334
343
  tokens = enc.encode(text)
335
344
 
336
345
  if max_tokens and len(tokens) >= max_tokens:
337
- text = "...(truncated)\n" + enc.decode(tokens[-(max_tokens - 1):])
346
+ text = "...(truncated)\n" + enc.decode(tokens[-(max_tokens - 1) :])
338
347
 
339
348
  if is_interrupt:
340
349
  text = (
@@ -361,7 +370,7 @@ Otherwise, you may want to try Ctrl-c again or program specific exit interactive
361
370
 
362
371
  tokens = enc.encode(output)
363
372
  if max_tokens and len(tokens) >= max_tokens:
364
- output = "...(truncated)\n" + enc.decode(tokens[-(max_tokens - 1):])
373
+ output = "...(truncated)\n" + enc.decode(tokens[-(max_tokens - 1) :])
365
374
 
366
375
  try:
367
376
  exit_status = get_status()
@@ -436,10 +445,10 @@ def read_image_from_shell(file_path: str) -> ImageData:
436
445
  image_bytes = image_file.read()
437
446
  image_b64 = base64.b64encode(image_bytes).decode("utf-8")
438
447
  image_type = mimetypes.guess_type(file_path)[0]
439
- return ImageData(media_type=image_type, data=image_b64)
448
+ return ImageData(media_type=image_type, data=image_b64) # type: ignore
440
449
 
441
450
 
442
- def write_file(writefile: Writefile | CreateFileNew, error_on_exist: bool) -> str:
451
+ def write_file(writefile: CreateFileNew, error_on_exist: bool) -> str:
443
452
  if not os.path.isabs(writefile.file_path):
444
453
  return f"Failure: file_path should be absolute path, current working directory is {CWD}"
445
454
  else:
@@ -488,15 +497,21 @@ def find_least_edit_distance_substring(
488
497
  edit_distance_sum = 0
489
498
  for j in range(len(find_lines)):
490
499
  if (i + j) < len(content_lines):
491
- edit_distance_sum += edit_distance(
492
- content_lines[i + j], find_lines[j])
500
+ edit_distance_sum += edit_distance(content_lines[i + j], find_lines[j])
493
501
  else:
494
502
  edit_distance_sum += len(find_lines[j])
495
503
  if edit_distance_sum < min_edit_distance:
496
504
  min_edit_distance = edit_distance_sum
497
505
  orig_start_index = new_to_original_indices[i]
498
- orig_end_index = new_to_original_indices.get(i + len(find_lines) - 1, len(orig_content_lines) - 1) + 1
499
- min_edit_distance_lines = orig_content_lines[orig_start_index:orig_end_index]
506
+ orig_end_index = (
507
+ new_to_original_indices.get(
508
+ i + len(find_lines) - 1, len(orig_content_lines) - 1
509
+ )
510
+ + 1
511
+ )
512
+ min_edit_distance_lines = orig_content_lines[
513
+ orig_start_index:orig_end_index
514
+ ]
500
515
  return "\n".join(min_edit_distance_lines), min_edit_distance
501
516
 
502
517
 
@@ -525,7 +540,8 @@ def do_diff_edit(fedit: FileEdit) -> str:
525
540
 
526
541
  if not os.path.isabs(fedit.file_path):
527
542
  raise Exception(
528
- f"Failure: file_path should be absolute path, current working directory is {CWD}")
543
+ f"Failure: file_path should be absolute path, current working directory is {CWD}"
544
+ )
529
545
  else:
530
546
  path_ = fedit.file_path
531
547
 
@@ -535,13 +551,16 @@ def do_diff_edit(fedit: FileEdit) -> str:
535
551
  with open(path_) as f:
536
552
  apply_diff_to = f.read()
537
553
 
554
+ fedit.file_edit_using_search_replace_blocks = (
555
+ fedit.file_edit_using_search_replace_blocks.strip()
556
+ )
538
557
  lines = fedit.file_edit_using_search_replace_blocks.split("\n")
539
558
 
540
559
  if not lines or not re.match(r"^<<<<<<+\s*SEARCH\s*$", lines[0]):
541
560
  raise Exception(
542
561
  "Error: first line should be `<<<<<< SEARCH` to start a search-replace block"
543
562
  )
544
-
563
+
545
564
  n_lines = len(lines)
546
565
  i = 0
547
566
  replacement_count = 0
@@ -561,15 +580,14 @@ def do_diff_edit(fedit: FileEdit) -> str:
561
580
 
562
581
  for line in search_block:
563
582
  console.log("> " + line)
564
- console.log("---")
583
+ console.log("=======")
565
584
  for line in replace_block:
566
585
  console.log("< " + line)
567
-
586
+ console.log("\n\n\n\n")
568
587
  search_block_ = "\n".join(search_block)
569
588
  replace_block_ = "\n".join(replace_block)
570
589
 
571
- apply_diff_to = edit_content(
572
- apply_diff_to, search_block_, replace_block_)
590
+ apply_diff_to = edit_content(apply_diff_to, search_block_, replace_block_)
573
591
  replacement_count += 1
574
592
  else:
575
593
  i += 1
@@ -588,7 +606,8 @@ def do_diff_edit(fedit: FileEdit) -> str:
588
606
  def file_edit(fedit: FileEditFindReplace) -> str:
589
607
  if not os.path.isabs(fedit.file_path):
590
608
  raise Exception(
591
- f"Failure: file_path should be absolute path, current working directory is {CWD}")
609
+ f"Failure: file_path should be absolute path, current working directory is {CWD}"
610
+ )
592
611
  else:
593
612
  path_ = fedit.file_path
594
613
 
@@ -598,17 +617,14 @@ def file_edit(fedit: FileEditFindReplace) -> str:
598
617
  if not fedit.find_lines:
599
618
  raise Exception("Error: `find_lines` cannot be empty")
600
619
 
601
- out_string = "\n".join(
602
- "> " + line for line in fedit.find_lines.split("\n"))
603
- in_string = "\n".join(
604
- "< " + line for line in fedit.replace_with_lines.split("\n"))
620
+ out_string = "\n".join("> " + line for line in fedit.find_lines.split("\n"))
621
+ in_string = "\n".join("< " + line for line in fedit.replace_with_lines.split("\n"))
605
622
  console.log(f"Editing file: {path_}\n---\n{out_string}\n---\n{in_string}\n---")
606
623
  try:
607
624
  with open(path_) as f:
608
625
  content = f.read()
609
626
 
610
- content = edit_content(content, fedit.find_lines,
611
- fedit.replace_with_lines)
627
+ content = edit_content(content, fedit.find_lines, fedit.replace_with_lines)
612
628
 
613
629
  with open(path_, "w") as f:
614
630
  f.write(content)
@@ -645,7 +661,6 @@ TOOLS = (
645
661
  | BashCommand
646
662
  | BashInteraction
647
663
  | ResetShell
648
- | Writefile
649
664
  | CreateFileNew
650
665
  | FileEditFindReplace
651
666
  | FileEdit
@@ -653,6 +668,7 @@ TOOLS = (
653
668
  | DoneFlag
654
669
  | ReadImage
655
670
  | ReadFile
671
+ | Initialize
656
672
  )
657
673
 
658
674
 
@@ -670,8 +686,6 @@ def which_tool_name(name: str) -> Type[TOOLS]:
670
686
  return BashInteraction
671
687
  elif name == "ResetShell":
672
688
  return ResetShell
673
- elif name == "Writefile":
674
- return Writefile
675
689
  elif name == "CreateFileNew":
676
690
  return CreateFileNew
677
691
  elif name == "FileEditFindReplace":
@@ -686,6 +700,8 @@ def which_tool_name(name: str) -> Type[TOOLS]:
686
700
  return ReadImage
687
701
  elif name == "ReadFile":
688
702
  return ReadFile
703
+ elif name == "Initialize":
704
+ return Initialize
689
705
  else:
690
706
  raise ValueError(f"Unknown tool name: {name}")
691
707
 
@@ -696,13 +712,13 @@ def get_tool_output(
696
712
  | BashCommand
697
713
  | BashInteraction
698
714
  | ResetShell
699
- | Writefile
700
715
  | CreateFileNew
701
716
  | FileEditFindReplace
702
717
  | FileEdit
703
718
  | AIAssistant
704
719
  | DoneFlag
705
720
  | ReadImage
721
+ | Initialize
706
722
  | ReadFile,
707
723
  enc: tiktoken.Encoding,
708
724
  limit: float,
@@ -715,7 +731,6 @@ def get_tool_output(
715
731
  | BashCommand
716
732
  | BashInteraction
717
733
  | ResetShell
718
- | Writefile
719
734
  | CreateFileNew
720
735
  | FileEditFindReplace
721
736
  | FileEdit
@@ -723,12 +738,12 @@ def get_tool_output(
723
738
  | DoneFlag
724
739
  | ReadImage
725
740
  | ReadFile
741
+ | Initialize
726
742
  ](
727
743
  Confirmation
728
744
  | BashCommand
729
745
  | BashInteraction
730
746
  | ResetShell
731
- | Writefile
732
747
  | CreateFileNew
733
748
  | FileEditFindReplace
734
749
  | FileEdit
@@ -736,6 +751,7 @@ def get_tool_output(
736
751
  | DoneFlag
737
752
  | ReadImage
738
753
  | ReadFile
754
+ | Initialize
739
755
  )
740
756
  arg = adapter.validate_python(args)
741
757
  else:
@@ -747,9 +763,6 @@ def get_tool_output(
747
763
  elif isinstance(arg, (BashCommand | BashInteraction)):
748
764
  console.print("Calling execute bash tool")
749
765
  output = execute_bash(enc, arg, max_tokens)
750
- elif isinstance(arg, Writefile):
751
- console.print("Calling write file tool")
752
- output = write_file(arg, False), 0
753
766
  elif isinstance(arg, CreateFileNew):
754
767
  console.print("Calling write file tool")
755
768
  output = write_file(arg, True), 0
@@ -770,10 +783,13 @@ def get_tool_output(
770
783
  output = read_image_from_shell(arg.file_path), 0.0
771
784
  elif isinstance(arg, ReadFile):
772
785
  console.print("Calling read file tool")
773
- output = read_file(arg), 0.0
786
+ output = read_file(arg, max_tokens), 0.0
774
787
  elif isinstance(arg, ResetShell):
775
788
  console.print("Calling reset shell tool")
776
789
  output = reset_shell(), 0.0
790
+ elif isinstance(arg, Initialize):
791
+ console.print("Calling initial info tool")
792
+ output = initial_info(), 0.0
777
793
  else:
778
794
  raise ValueError(f"Unknown tool: {arg}")
779
795
 
@@ -785,8 +801,7 @@ History = list[ChatCompletionMessageParam]
785
801
 
786
802
  default_enc = tiktoken.encoding_for_model("gpt-4o")
787
803
  default_model: Models = "gpt-4o-2024-08-06"
788
- default_cost = CostData(cost_per_1m_input_tokens=0.15,
789
- cost_per_1m_output_tokens=0.6)
804
+ default_cost = CostData(cost_per_1m_input_tokens=0.15, cost_per_1m_output_tokens=0.6)
790
805
  curr_cost = 0.0
791
806
 
792
807
 
@@ -794,13 +809,13 @@ class Mdata(BaseModel):
794
809
  data: (
795
810
  BashCommand
796
811
  | BashInteraction
797
- | Writefile
798
812
  | CreateFileNew
799
813
  | ResetShell
800
814
  | FileEditFindReplace
801
815
  | FileEdit
802
816
  | str
803
817
  | ReadFile
818
+ | Initialize
804
819
  )
805
820
 
806
821
 
@@ -829,8 +844,7 @@ def register_client(server_url: str, client_uuid: str = "") -> None:
829
844
  raise Exception(mdata)
830
845
  try:
831
846
  output, cost = get_tool_output(
832
- mdata.data, default_enc, 0.0, lambda x, y: (
833
- "", 0), None
847
+ mdata.data, default_enc, 0.0, lambda x, y: ("", 0), 8000
834
848
  )
835
849
  curr_cost += cost
836
850
  print(f"{curr_cost=}")
@@ -863,8 +877,7 @@ def app(
863
877
  register_client(server_url, client_uuid or "")
864
878
 
865
879
 
866
- def read_file(readfile: ReadFile) -> str:
867
-
880
+ def read_file(readfile: ReadFile, max_tokens: Optional[int]) -> str:
868
881
  console.print(f"Reading file: {readfile.file_path}")
869
882
 
870
883
  if not os.path.isabs(readfile.file_path):
@@ -876,4 +889,11 @@ def read_file(readfile: ReadFile) -> str:
876
889
 
877
890
  with path.open("r") as f:
878
891
  content = f.read()
892
+
893
+ if max_tokens is not None:
894
+ tokens = default_enc.encode(content)
895
+ if len(tokens) > max_tokens:
896
+ content = default_enc.decode(tokens[: max_tokens - 5])
897
+ content += "\n...(truncated)"
898
+
879
899
  return content
wcgw/relay/serve.py CHANGED
@@ -20,9 +20,9 @@ from ..types_ import (
20
20
  CreateFileNew,
21
21
  FileEditFindReplace,
22
22
  FileEdit,
23
+ Initialize,
23
24
  ReadFile,
24
25
  ResetShell,
25
- Writefile,
26
26
  Specials,
27
27
  )
28
28
 
@@ -31,12 +31,12 @@ class Mdata(BaseModel):
31
31
  data: (
32
32
  BashCommand
33
33
  | BashInteraction
34
- | Writefile
35
34
  | CreateFileNew
36
35
  | ResetShell
37
36
  | FileEditFindReplace
38
37
  | FileEdit
39
38
  | ReadFile
39
+ | Initialize
40
40
  | str
41
41
  )
42
42
  user_id: UUID
@@ -51,7 +51,7 @@ gpts: dict[UUID, Callable[[str], None]] = {}
51
51
  images: DefaultDict[UUID, dict[str, dict[str, Any]]] = DefaultDict(dict)
52
52
 
53
53
 
54
- CLIENT_VERSION_MINIMUM = "1.2.0"
54
+ CLIENT_VERSION_MINIMUM = "1.3.0"
55
55
 
56
56
 
57
57
  @app.websocket("/v1/register/{uuid}")
@@ -65,14 +65,12 @@ async def register_websocket(websocket: WebSocket, uuid: UUID) -> None:
65
65
  # receive client version
66
66
  client_version = await websocket.receive_text()
67
67
  sem_version_client = semantic_version.Version.coerce(client_version)
68
- sem_version_server = semantic_version.Version.coerce(
69
- CLIENT_VERSION_MINIMUM)
68
+ sem_version_server = semantic_version.Version.coerce(CLIENT_VERSION_MINIMUM)
70
69
  if sem_version_client < sem_version_server:
71
70
  await websocket.send_text(
72
71
  Mdata(
73
72
  user_id=uuid,
74
- data=f"Client version {client_version} is outdated. Please upgrade to {
75
- CLIENT_VERSION_MINIMUM} or higher.",
73
+ data=f"Client version {client_version} is outdated. Please upgrade to {CLIENT_VERSION_MINIMUM} or higher.",
76
74
  ).model_dump_json()
77
75
  )
78
76
  await websocket.close(
@@ -92,8 +90,7 @@ async def register_websocket(websocket: WebSocket, uuid: UUID) -> None:
92
90
  while True:
93
91
  received_data = await websocket.receive_text()
94
92
  if uuid not in gpts:
95
- raise fastapi.HTTPException(
96
- status_code=400, detail="No call made")
93
+ raise fastapi.HTTPException(status_code=400, detail="No call made")
97
94
  gpts[uuid](received_data)
98
95
  except WebSocketDisconnect:
99
96
  # Remove the client if the WebSocket is disconnected
@@ -280,11 +277,36 @@ async def read_file_endpoint(read_file_data: ReadFileWithUUID) -> str:
280
277
 
281
278
  gpts[user_id] = put_results
282
279
 
283
- await clients[user_id](
284
- Mdata(data=ReadFile(file_path=read_file_data.file_path,
285
- type=read_file_data.type
286
- ), user_id=user_id)
287
- )
280
+ await clients[user_id](Mdata(data=read_file_data, user_id=user_id))
281
+
282
+ start_time = time.time()
283
+ while time.time() - start_time < 30:
284
+ if results is not None:
285
+ return results
286
+ await asyncio.sleep(0.1)
287
+
288
+ raise fastapi.HTTPException(status_code=500, detail="Timeout error")
289
+
290
+
291
+ class InitializeWithUUID(Initialize):
292
+ user_id: UUID
293
+
294
+
295
+ @app.post("/v1/initialize")
296
+ async def initialize(initialize_data: InitializeWithUUID) -> str:
297
+ user_id = initialize_data.user_id
298
+ if user_id not in clients:
299
+ return "Failure: id not found, ask the user to check it."
300
+
301
+ results: Optional[str] = None
302
+
303
+ def put_results(result: str) -> None:
304
+ nonlocal results
305
+ results = result
306
+
307
+ gpts[user_id] = put_results
308
+
309
+ await clients[user_id](Mdata(data=initialize_data, user_id=user_id))
288
310
 
289
311
  start_time = time.time()
290
312
  while time.time() - start_time < 30:
wcgw/types_.py CHANGED
@@ -24,11 +24,6 @@ class ReadImage(BaseModel):
24
24
  type: Literal["ReadImage"]
25
25
 
26
26
 
27
- class Writefile(BaseModel):
28
- file_path: str
29
- file_content: str
30
-
31
-
32
27
  class CreateFileNew(BaseModel):
33
28
  file_path: str
34
29
  file_content: str
@@ -52,3 +47,7 @@ class ResetShell(BaseModel):
52
47
  class FileEdit(BaseModel):
53
48
  file_path: str
54
49
  file_edit_using_search_replace_blocks: str
50
+
51
+
52
+ class Initialize(BaseModel):
53
+ type: Literal["Initialize"]
@@ -1,12 +1,13 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: wcgw
3
- Version: 1.2.2
3
+ Version: 1.4.0
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: anthropic>=0.39.0
9
9
  Requires-Dist: fastapi>=0.115.0
10
+ Requires-Dist: mcp>=1.0.0
10
11
  Requires-Dist: mypy>=1.11.2
11
12
  Requires-Dist: nltk>=3.9.1
12
13
  Requires-Dist: openai>=1.46.0
@@ -50,7 +51,7 @@ You need to keep running this client for GPT to access your shell. Run it in a v
50
51
  ### Option 1: using uv [Recommended]
51
52
  ```sh
52
53
  $ curl -LsSf https://astral.sh/uv/install.sh | sh
53
- $ uv tool run --python 3.12 wcgw@latest
54
+ $ uvx wcgw@latest
54
55
  ```
55
56
 
56
57
  ### Option 2: using pip
@@ -109,6 +110,9 @@ If you don't have public ip and domain name, you can use `ngrok` or similar serv
109
110
  The specify the server url in the `wcgw` command like so
110
111
  `wcgw --server-url https://your-url/v1/register`
111
112
 
113
+ # Claude Support
114
+ WCGW now supports Claude Desktop through the MCP protocol, allowing you to use Claude's capabilities directly from your desktop environment. This integration enables seamless interaction between Claude and your local shell.
115
+
112
116
  # [Optional] Local shell access with openai API key
113
117
 
114
118
  Add `OPENAI_API_KEY` and `OPENAI_ORG_ID` env variables.
@@ -0,0 +1,20 @@
1
+ wcgw/__init__.py,sha256=9K2QW7QuSLhMTVbKbBYd9UUp-ZyrfBrxcjuD_xk458k,118
2
+ wcgw/types_.py,sha256=AWQUtzv7S_YQ2_36RMQOAA2gJ26ZQZcOA6l5EvFR6hk,1054
3
+ wcgw/client/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
4
+ wcgw/client/__main__.py,sha256=ngI_vBcLAv7fJgmS4w4U7tuWtalGB8c7W5qebuT6Z6o,30
5
+ wcgw/client/anthropic_client.py,sha256=G7uCtUd5pt7ZQO7k2TJI3FtgK4QtzdIvd4Z1S18xGNU,15595
6
+ wcgw/client/cli.py,sha256=Oja42CHkVO8puqOXflko9NeephYCMa85aBmQTEjBZtI,932
7
+ wcgw/client/common.py,sha256=grH-yV_4tnTQZ29xExn4YicGLxEq98z-HkEZwH0ReSg,1410
8
+ wcgw/client/diff-instructions.txt,sha256=s5AJKG23JsjwRYhFZFQVvwDpF67vElawrmdXwvukR1A,1683
9
+ wcgw/client/openai_client.py,sha256=kQCtQX0x-jsnw-tHBxi41XqnOkCaoTUqY8K4gHeI4xM,17703
10
+ wcgw/client/openai_utils.py,sha256=YNwCsA-Wqq7jWrxP0rfQmBTb1dI0s7dWXzQqyTzOZT4,2629
11
+ wcgw/client/tools.py,sha256=jjQ8eFQvcE8xfoGR5WkoOgWYE0dOIThxFrrppY4gQDQ,27954
12
+ wcgw/client/mcp_server/Readme.md,sha256=GH59caNySMMphqiimsVKrteU-jmNC7kcdBu4MPxtZZ8,477
13
+ wcgw/client/mcp_server/__init__.py,sha256=cQ7PUrEmXUpio8x0SEoGWP5hCRPd7z2bAkNCbYbtTys,236
14
+ wcgw/client/mcp_server/server.py,sha256=G8fyXQzVurM8rzekiXU62YaseDIT2mthc0lj64FDJKs,7089
15
+ wcgw/relay/serve.py,sha256=RUcUeyL4Xt0EEo12Ul6VQjb4tRle4uIdsa85v7XXxEw,8771
16
+ wcgw/relay/static/privacy.txt,sha256=s9qBdbx2SexCpC_z33sg16TptmAwDEehMCLz4L50JLc,529
17
+ wcgw-1.4.0.dist-info/METADATA,sha256=I31G-r47C-9acBusZsKFt2BjbqgP1S83amCnQJuL_XQ,5501
18
+ wcgw-1.4.0.dist-info/WHEEL,sha256=C2FUgwZgiLbznR-k0b_5k3Ai_1aASOXDss3lzCUsUug,87
19
+ wcgw-1.4.0.dist-info/entry_points.txt,sha256=eKo1omwbAggWlQ0l7GKoR7uV1-j16nk9tK0BhC2Oz_E,120
20
+ wcgw-1.4.0.dist-info/RECORD,,
@@ -1,4 +1,5 @@
1
1
  [console_scripts]
2
2
  wcgw = wcgw:listen
3
3
  wcgw_local = wcgw:app
4
+ wcgw_mcp = wcgw:mcp_server
4
5
  wcgw_relay = wcgw.relay.serve:run
@@ -1,17 +0,0 @@
1
- wcgw/__init__.py,sha256=PNWvBvjUKA3aj4bHOtIqBKCAtOW88pr0hAXZ7RylVr8,68
2
- wcgw/types_.py,sha256=HND4V3KruqkAhzzEf2ve7HhF8y_o5w11Fu784U8xN_I,1062
3
- wcgw/client/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
4
- wcgw/client/__main__.py,sha256=ngI_vBcLAv7fJgmS4w4U7tuWtalGB8c7W5qebuT6Z6o,30
5
- wcgw/client/anthropic_client.py,sha256=YQ2puVKlrgbI6QvV_DQjWkvq2qw6KhPPrivm0OGfRsc,15353
6
- wcgw/client/cli.py,sha256=Oja42CHkVO8puqOXflko9NeephYCMa85aBmQTEjBZtI,932
7
- wcgw/client/common.py,sha256=grH-yV_4tnTQZ29xExn4YicGLxEq98z-HkEZwH0ReSg,1410
8
- wcgw/client/diff-instructions.txt,sha256=Fy6op3wkr5gYgMHCRcitG4804zLrTxMbs-VSMB1oIzA,1548
9
- wcgw/client/openai_client.py,sha256=v6ZBW4oh1CJpyWLTt16S-7ew8Gmq6YrJzaTux2ffywI,17470
10
- wcgw/client/openai_utils.py,sha256=YNwCsA-Wqq7jWrxP0rfQmBTb1dI0s7dWXzQqyTzOZT4,2629
11
- wcgw/client/tools.py,sha256=zeUOemmFwReY1jEJnnAkhPbBwkL4pjZvQ6G5BWPux4c,27301
12
- wcgw/relay/serve.py,sha256=sMbERQTM1GhpnPWLzdtpcb23TIsgbP-ZuqVj-vjp5Rw,8186
13
- wcgw/relay/static/privacy.txt,sha256=s9qBdbx2SexCpC_z33sg16TptmAwDEehMCLz4L50JLc,529
14
- wcgw-1.2.2.dist-info/METADATA,sha256=0zQpOcQD8eRiQMVibiksflPhrngU49FSgpEHAmXQ_DA,5255
15
- wcgw-1.2.2.dist-info/WHEEL,sha256=C2FUgwZgiLbznR-k0b_5k3Ai_1aASOXDss3lzCUsUug,87
16
- wcgw-1.2.2.dist-info/entry_points.txt,sha256=WlIB825-Vm9ZtNzgENQsbHj4DRMkbpVR7uSkQyBlaPA,93
17
- wcgw-1.2.2.dist-info/RECORD,,
File without changes