chcode 0.1.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.
- chcode/__init__.py +0 -0
- chcode/__main__.py +5 -0
- chcode/agent_setup.py +395 -0
- chcode/agents/__init__.py +0 -0
- chcode/agents/definitions.py +158 -0
- chcode/agents/loader.py +104 -0
- chcode/agents/runner.py +159 -0
- chcode/chat.py +1630 -0
- chcode/cli.py +142 -0
- chcode/config.py +571 -0
- chcode/display.py +325 -0
- chcode/prompts.py +640 -0
- chcode/session.py +149 -0
- chcode/skill_manager.py +165 -0
- chcode/utils/__init__.py +3 -0
- chcode/utils/enhanced_chat_openai.py +368 -0
- chcode/utils/git_checker.py +38 -0
- chcode/utils/git_manager.py +261 -0
- chcode/utils/modelscope_ratelimit.py +65 -0
- chcode/utils/multimodal.py +268 -0
- chcode/utils/shell/__init__.py +17 -0
- chcode/utils/shell/output.py +63 -0
- chcode/utils/shell/provider.py +128 -0
- chcode/utils/shell/result.py +14 -0
- chcode/utils/shell/semantics.py +55 -0
- chcode/utils/shell/session.py +159 -0
- chcode/utils/skill_loader.py +565 -0
- chcode/utils/text_utils.py +14 -0
- chcode/utils/tool_result_pipeline.py +244 -0
- chcode/utils/tools.py +1724 -0
- chcode/vision_config.py +371 -0
- chcode-0.1.0.dist-info/METADATA +275 -0
- chcode-0.1.0.dist-info/RECORD +36 -0
- chcode-0.1.0.dist-info/WHEEL +4 -0
- chcode-0.1.0.dist-info/entry_points.txt +2 -0
- chcode-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import tempfile
|
|
5
|
+
import uuid
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
|
|
8
|
+
MAX_OUTPUT_LINES = 2000
|
|
9
|
+
MAX_OUTPUT_BYTES = 51200
|
|
10
|
+
MAX_PERSISTED_BYTES = 64 * 1024 * 1024
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class TruncatedOutput:
|
|
15
|
+
content: str
|
|
16
|
+
truncated: bool = False
|
|
17
|
+
persisted_path: str | None = None
|
|
18
|
+
total_bytes: int = 0
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def truncate_output(stdout: str) -> TruncatedOutput:
|
|
22
|
+
total_bytes = len(stdout.encode("utf-8", errors="replace"))
|
|
23
|
+
lines = stdout.splitlines(keepends=True)
|
|
24
|
+
needs_line_trunc = len(lines) > MAX_OUTPUT_LINES
|
|
25
|
+
needs_byte_trunc = total_bytes > MAX_OUTPUT_BYTES
|
|
26
|
+
|
|
27
|
+
if not needs_line_trunc and not needs_byte_trunc:
|
|
28
|
+
return TruncatedOutput(content=stdout, total_bytes=total_bytes)
|
|
29
|
+
|
|
30
|
+
persisted_path = _persist_to_file(stdout)
|
|
31
|
+
|
|
32
|
+
preview_lines = lines[:MAX_OUTPUT_LINES]
|
|
33
|
+
preview = "".join(preview_lines)
|
|
34
|
+
if len(preview.encode("utf-8", errors="replace")) > MAX_OUTPUT_BYTES:
|
|
35
|
+
preview = preview[:MAX_OUTPUT_BYTES]
|
|
36
|
+
|
|
37
|
+
truncation_notice = (
|
|
38
|
+
f"\n\n[Output truncated: {len(lines)} lines, {total_bytes} bytes total. "
|
|
39
|
+
f"Full output saved to: {persisted_path}]"
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
return TruncatedOutput(
|
|
43
|
+
content=preview + truncation_notice,
|
|
44
|
+
truncated=True,
|
|
45
|
+
persisted_path=persisted_path,
|
|
46
|
+
total_bytes=total_bytes,
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _persist_to_file(content: str) -> str:
|
|
51
|
+
output_dir = os.path.join(tempfile.gettempdir(), "chcode-output")
|
|
52
|
+
os.makedirs(output_dir, exist_ok=True)
|
|
53
|
+
file_id = uuid.uuid4().hex[:8]
|
|
54
|
+
path = os.path.join(output_dir, f"output-{file_id}.txt")
|
|
55
|
+
|
|
56
|
+
encoded = content.encode("utf-8", errors="replace")
|
|
57
|
+
if len(encoded) > MAX_PERSISTED_BYTES:
|
|
58
|
+
encoded = encoded[:MAX_PERSISTED_BYTES]
|
|
59
|
+
|
|
60
|
+
with open(path, "wb") as f:
|
|
61
|
+
f.write(encoded)
|
|
62
|
+
|
|
63
|
+
return path
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import contextlib
|
|
4
|
+
import os
|
|
5
|
+
import shutil
|
|
6
|
+
import tempfile
|
|
7
|
+
import uuid
|
|
8
|
+
from abc import ABC, abstractmethod
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class ShellProvider(ABC):
|
|
12
|
+
@property
|
|
13
|
+
@abstractmethod
|
|
14
|
+
def shell_path(self) -> str: ...
|
|
15
|
+
|
|
16
|
+
@property
|
|
17
|
+
@abstractmethod
|
|
18
|
+
def is_available(self) -> bool: ...
|
|
19
|
+
|
|
20
|
+
@property
|
|
21
|
+
@abstractmethod
|
|
22
|
+
def display_name(self) -> str: ...
|
|
23
|
+
|
|
24
|
+
@abstractmethod
|
|
25
|
+
def build_command(self, command: str, cwd_file: str) -> str: ...
|
|
26
|
+
|
|
27
|
+
@abstractmethod
|
|
28
|
+
def get_spawn_args(self, command_string: str) -> list[str]: ...
|
|
29
|
+
|
|
30
|
+
def get_env_overrides(self) -> dict[str, str]:
|
|
31
|
+
return {}
|
|
32
|
+
|
|
33
|
+
def create_cwd_file(self) -> str:
|
|
34
|
+
tmpdir = tempfile.gettempdir()
|
|
35
|
+
file_id = uuid.uuid4().hex[:8]
|
|
36
|
+
return os.path.join(tmpdir, f"chcode-cwd-{file_id}")
|
|
37
|
+
|
|
38
|
+
def read_cwd_file(self, cwd_file: str) -> str | None:
|
|
39
|
+
try:
|
|
40
|
+
with open(cwd_file, encoding="utf-8-sig") as f:
|
|
41
|
+
return f.read().strip()
|
|
42
|
+
except OSError:
|
|
43
|
+
return None
|
|
44
|
+
|
|
45
|
+
def cleanup_cwd_file(self, cwd_file: str) -> None:
|
|
46
|
+
with contextlib.suppress(OSError):
|
|
47
|
+
os.unlink(cwd_file)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class BashProvider(ShellProvider):
|
|
51
|
+
def __init__(self) -> None:
|
|
52
|
+
self._shell = self._detect_shell()
|
|
53
|
+
|
|
54
|
+
@property
|
|
55
|
+
def shell_path(self) -> str:
|
|
56
|
+
return self._shell
|
|
57
|
+
|
|
58
|
+
@property
|
|
59
|
+
def is_available(self) -> bool:
|
|
60
|
+
return self._shell != ""
|
|
61
|
+
|
|
62
|
+
@property
|
|
63
|
+
def display_name(self) -> str:
|
|
64
|
+
return "bash"
|
|
65
|
+
|
|
66
|
+
def _detect_shell(self) -> str:
|
|
67
|
+
if os.name == "nt":
|
|
68
|
+
git_path = shutil.which("git")
|
|
69
|
+
if git_path:
|
|
70
|
+
git_bin = os.path.dirname(git_path)
|
|
71
|
+
candidate = os.path.join(git_bin, "bash.exe")
|
|
72
|
+
if os.path.isfile(candidate):
|
|
73
|
+
return candidate
|
|
74
|
+
candidate = os.path.join(git_bin, "..", "bin", "bash.exe") # pragma: no cover
|
|
75
|
+
if os.path.isfile(candidate): # pragma: no cover
|
|
76
|
+
return os.path.normpath(candidate) # pragma: no cover
|
|
77
|
+
bash_path = shutil.which("bash") # pragma: no cover
|
|
78
|
+
if bash_path and os.path.isfile(bash_path): # pragma: no cover
|
|
79
|
+
return bash_path # pragma: no cover
|
|
80
|
+
return ""
|
|
81
|
+
env_shell = os.environ.get("SHELL", "")
|
|
82
|
+
if env_shell and os.path.isfile(env_shell):
|
|
83
|
+
return env_shell
|
|
84
|
+
for candidate in ["/bin/bash", "/usr/bin/bash", "/bin/zsh", "/usr/bin/zsh"]:
|
|
85
|
+
if os.path.isfile(candidate):
|
|
86
|
+
return candidate
|
|
87
|
+
return shutil.which("bash") or shutil.which("zsh") or ""
|
|
88
|
+
|
|
89
|
+
def build_command(self, command: str, cwd_file: str) -> str:
|
|
90
|
+
escaped_cwd = cwd_file.replace("'", "'\\''")
|
|
91
|
+
escaped_cmd = command.replace("'", "'\\''")
|
|
92
|
+
return f"eval '{escaped_cmd}' && pwd -P >| '{escaped_cwd}'"
|
|
93
|
+
|
|
94
|
+
def get_spawn_args(self, command_string: str) -> list[str]:
|
|
95
|
+
return ["-c", command_string]
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class PowerShellProvider(ShellProvider):
|
|
99
|
+
@property
|
|
100
|
+
def shell_path(self) -> str:
|
|
101
|
+
return "powershell"
|
|
102
|
+
|
|
103
|
+
@property
|
|
104
|
+
def is_available(self) -> bool:
|
|
105
|
+
import platform
|
|
106
|
+
|
|
107
|
+
return platform.system() == "Windows" and shutil.which("powershell") is not None
|
|
108
|
+
|
|
109
|
+
@property
|
|
110
|
+
def display_name(self) -> str:
|
|
111
|
+
return "powershell"
|
|
112
|
+
|
|
113
|
+
def build_command(self, command: str, cwd_file: str) -> str:
|
|
114
|
+
escaped = cwd_file.replace("'", "''")
|
|
115
|
+
return (
|
|
116
|
+
f"{command}\n"
|
|
117
|
+
f"; $_ec = if ($null -ne $LASTEXITCODE) {{ $LASTEXITCODE }} "
|
|
118
|
+
f"elseif ($?) {{ 0 }} else {{ 1 }}\n"
|
|
119
|
+
f"; (Get-Location).Path | Out-File -FilePath '{escaped}' "
|
|
120
|
+
f"-Encoding utf8 -NoNewline\n"
|
|
121
|
+
f"; exit $_ec"
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
def get_spawn_args(self, command_string: str) -> list[str]:
|
|
125
|
+
return ["-NoProfile", "-NonInteractive", "-Command", command_string]
|
|
126
|
+
|
|
127
|
+
def get_env_overrides(self) -> dict[str, str]:
|
|
128
|
+
return {"PSMODULEPATH": ""}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@dataclass
|
|
7
|
+
class ShellResult:
|
|
8
|
+
stdout: str = ""
|
|
9
|
+
stderr: str = ""
|
|
10
|
+
exit_code: int = 0
|
|
11
|
+
interrupted: bool = False
|
|
12
|
+
timed_out: bool = False
|
|
13
|
+
output_file_path: str | None = None
|
|
14
|
+
output_file_size: int | None = None
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass
|
|
8
|
+
class Interpretation:
|
|
9
|
+
is_error: bool
|
|
10
|
+
message: str | None = None
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
_RULES: list[tuple[list[str], dict[int, str]]] = [
|
|
14
|
+
(["grep", "rg", "ag", "ack", "findstr", "select-string"], {1: "No matches found"}),
|
|
15
|
+
(["diff", "compare-object", "fc"], {1: "Files or inputs differ"}),
|
|
16
|
+
(["test", "["], {1: "Test condition evaluated to false"}),
|
|
17
|
+
(["ping"], {1: "Host unreachable or no response"}),
|
|
18
|
+
(["which", "where", "where.exe", "command", "get-command"], {1: "Command not found"}),
|
|
19
|
+
(["type", "cat", "get-content"], {1: "File not found or unreadable"}),
|
|
20
|
+
(["mkdir"], {1: "Directory creation failed"}),
|
|
21
|
+
(["robocopy"], {1: "Files copied successfully (robocopy exit 1 = success)"}),
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _get_base_command(command: str) -> str:
|
|
26
|
+
segments = re.split(r"[|;&]", command)
|
|
27
|
+
last = segments[-1].strip()
|
|
28
|
+
for op in ["&&", "||"]:
|
|
29
|
+
parts = last.split(op)
|
|
30
|
+
last = parts[-1].strip()
|
|
31
|
+
tokens = last.split()
|
|
32
|
+
if not tokens:
|
|
33
|
+
return ""
|
|
34
|
+
base = tokens[0]
|
|
35
|
+
if "/" in base or "\\" in base:
|
|
36
|
+
base = base.rsplit("/", 1)[-1].rsplit("\\", 1)[-1]
|
|
37
|
+
return base.lower()
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def interpret_command_result(command: str, exit_code: int) -> Interpretation:
|
|
41
|
+
if exit_code == 0:
|
|
42
|
+
return Interpretation(is_error=False)
|
|
43
|
+
|
|
44
|
+
base = _get_base_command(command)
|
|
45
|
+
if not base:
|
|
46
|
+
return Interpretation(is_error=True, message=f"Exit code {exit_code}")
|
|
47
|
+
|
|
48
|
+
for commands, exit_map in _RULES:
|
|
49
|
+
if base in commands or base.removesuffix("exe") in commands:
|
|
50
|
+
if exit_code in exit_map:
|
|
51
|
+
return Interpretation(is_error=False, message=exit_map[exit_code])
|
|
52
|
+
if 1 in exit_map and exit_code > 1:
|
|
53
|
+
return Interpretation(is_error=True, message=f"Exit code {exit_code}")
|
|
54
|
+
|
|
55
|
+
return Interpretation(is_error=True, message=f"Exit code {exit_code}")
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import contextlib
|
|
4
|
+
import locale
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
import signal
|
|
8
|
+
import subprocess
|
|
9
|
+
|
|
10
|
+
from charset_normalizer import from_bytes
|
|
11
|
+
|
|
12
|
+
from chcode.utils.shell.output import TruncatedOutput, truncate_output
|
|
13
|
+
from chcode.utils.shell.provider import ShellProvider
|
|
14
|
+
from chcode.utils.shell.result import ShellResult
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ShellSession:
|
|
18
|
+
def __init__(self, provider: ShellProvider) -> None:
|
|
19
|
+
self._provider = provider
|
|
20
|
+
self._cwd: str = os.getcwd()
|
|
21
|
+
|
|
22
|
+
@property
|
|
23
|
+
def cwd(self) -> str:
|
|
24
|
+
return self._cwd
|
|
25
|
+
|
|
26
|
+
@cwd.setter
|
|
27
|
+
def cwd(self, value: str) -> None:
|
|
28
|
+
if os.path.isdir(value):
|
|
29
|
+
self._cwd = value
|
|
30
|
+
|
|
31
|
+
@property
|
|
32
|
+
def provider_name(self) -> str:
|
|
33
|
+
return self._provider.display_name
|
|
34
|
+
|
|
35
|
+
def execute(
|
|
36
|
+
self,
|
|
37
|
+
command: str,
|
|
38
|
+
timeout: int | None = 120000,
|
|
39
|
+
workdir: str | None = None,
|
|
40
|
+
) -> tuple[ShellResult, TruncatedOutput]:
|
|
41
|
+
cwd_file = self._provider.create_cwd_file()
|
|
42
|
+
full_command = self._provider.build_command(command, cwd_file)
|
|
43
|
+
|
|
44
|
+
exec_cwd = workdir or self._cwd
|
|
45
|
+
if not os.path.isdir(exec_cwd):
|
|
46
|
+
exec_cwd = self._cwd
|
|
47
|
+
|
|
48
|
+
spawn_args = self._provider.get_spawn_args(full_command)
|
|
49
|
+
env = {**os.environ, **self._provider.get_env_overrides()}
|
|
50
|
+
|
|
51
|
+
timeout_sec = (timeout / 1000) if timeout else None
|
|
52
|
+
timed_out = False
|
|
53
|
+
|
|
54
|
+
try:
|
|
55
|
+
proc = subprocess.Popen(
|
|
56
|
+
[self._provider.shell_path, *spawn_args],
|
|
57
|
+
stdout=subprocess.PIPE,
|
|
58
|
+
stderr=subprocess.PIPE,
|
|
59
|
+
cwd=exec_cwd,
|
|
60
|
+
env=env,
|
|
61
|
+
)
|
|
62
|
+
except FileNotFoundError:
|
|
63
|
+
return (
|
|
64
|
+
ShellResult(exit_code=127, stderr=f"Shell not found: {self._provider.shell_path}"),
|
|
65
|
+
truncate_output(""),
|
|
66
|
+
)
|
|
67
|
+
except OSError as e:
|
|
68
|
+
return (
|
|
69
|
+
ShellResult(exit_code=126, stderr=f"Failed to execute: {e}"),
|
|
70
|
+
truncate_output(""),
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
try:
|
|
74
|
+
stdout_bytes, stderr_bytes = proc.communicate(timeout=timeout_sec)
|
|
75
|
+
except subprocess.TimeoutExpired:
|
|
76
|
+
timed_out = True
|
|
77
|
+
with contextlib.suppress(OSError):
|
|
78
|
+
_kill_proc_tree(proc)
|
|
79
|
+
try:
|
|
80
|
+
stdout_bytes, stderr_bytes = proc.communicate(timeout=5)
|
|
81
|
+
except subprocess.TimeoutExpired:
|
|
82
|
+
stdout_bytes, stderr_bytes = b"", b""
|
|
83
|
+
|
|
84
|
+
stdout = _robust_decode(stdout_bytes) if stdout_bytes else ""
|
|
85
|
+
stderr = _robust_decode(stderr_bytes) if stderr_bytes else ""
|
|
86
|
+
|
|
87
|
+
new_cwd = self._provider.read_cwd_file(cwd_file)
|
|
88
|
+
if new_cwd and not workdir:
|
|
89
|
+
if os.name == "nt" and new_cwd.startswith("/"):
|
|
90
|
+
match = re.match(r"^/([a-zA-Z])(/.*)?$", new_cwd)
|
|
91
|
+
if match:
|
|
92
|
+
drive = match.group(1).upper()
|
|
93
|
+
rest = match.group(2) or "\\"
|
|
94
|
+
new_cwd = f"{drive}:{rest.replace('/', chr(92))}"
|
|
95
|
+
if os.path.isdir(new_cwd):
|
|
96
|
+
self._cwd = new_cwd
|
|
97
|
+
|
|
98
|
+
self._provider.cleanup_cwd_file(cwd_file)
|
|
99
|
+
|
|
100
|
+
result = ShellResult(
|
|
101
|
+
stdout=stdout,
|
|
102
|
+
stderr=stderr,
|
|
103
|
+
exit_code=proc.returncode if proc.returncode is not None else 1,
|
|
104
|
+
timed_out=timed_out,
|
|
105
|
+
interrupted=timed_out,
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
truncated = truncate_output(result.stdout)
|
|
109
|
+
if truncated.truncated:
|
|
110
|
+
result.output_file_path = truncated.persisted_path
|
|
111
|
+
result.output_file_size = truncated.total_bytes
|
|
112
|
+
|
|
113
|
+
return result, truncated
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _robust_decode(data: bytes) -> str:
|
|
117
|
+
if not data:
|
|
118
|
+
return ""
|
|
119
|
+
system_encoding = locale.getpreferredencoding() or "utf-8"
|
|
120
|
+
if len(data) >= 4:
|
|
121
|
+
bom = data[:4]
|
|
122
|
+
if bom[:3] == b"\xef\xbb\xbf":
|
|
123
|
+
return data[3:].decode("utf-8", errors="replace")
|
|
124
|
+
if bom[:2] in (b"\xff\xfe", b"\xfe\xff"):
|
|
125
|
+
return data.decode("utf-16", errors="replace")
|
|
126
|
+
result = from_bytes(data)
|
|
127
|
+
best = result.best() if result else None
|
|
128
|
+
if best and best.coherence > 0.5:
|
|
129
|
+
return str(best)
|
|
130
|
+
for enc in ["utf-8", "gb18030", system_encoding, "latin-1"]:
|
|
131
|
+
try:
|
|
132
|
+
return data.decode(enc, errors="strict")
|
|
133
|
+
except (UnicodeDecodeError, LookupError):
|
|
134
|
+
continue
|
|
135
|
+
return data.decode(system_encoding, errors="replace") # pragma: no cover
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _kill_proc_tree(proc: subprocess.Popen) -> None:
|
|
139
|
+
pid = proc.pid
|
|
140
|
+
if pid is None:
|
|
141
|
+
return
|
|
142
|
+
|
|
143
|
+
try:
|
|
144
|
+
import psutil
|
|
145
|
+
|
|
146
|
+
parent = psutil.Process(pid)
|
|
147
|
+
children = parent.children(recursive=True)
|
|
148
|
+
for child in children:
|
|
149
|
+
with contextlib.suppress(psutil.NoSuchProcess):
|
|
150
|
+
child.kill()
|
|
151
|
+
parent.kill()
|
|
152
|
+
except ImportError:
|
|
153
|
+
if os.name == "nt":
|
|
154
|
+
proc.kill()
|
|
155
|
+
else:
|
|
156
|
+
with contextlib.suppress(OSError, ProcessLookupError):
|
|
157
|
+
os.killpg(pid, signal.SIGKILL)
|
|
158
|
+
return
|
|
159
|
+
proc.kill()
|