thwip-cli 1.0.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.
@@ -0,0 +1,184 @@
1
+ """
2
+ Tool registry and schema translator for LLMs.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ from typing import Any
8
+ from thwip.tools.file_editor import FileEditor
9
+ from thwip.tools.terminal import TerminalRunner
10
+ from thwip.tools.code_runner import CodeRunner
11
+ from thwip.tools.git_ops import GitOps
12
+
13
+
14
+ class ToolManager:
15
+ """Manages tool execution and provides schemas for agent tool calling."""
16
+
17
+ def __init__(self, project_path: str = ".") -> None:
18
+ self.project_path = project_path
19
+ self.file_editor = FileEditor(project_path)
20
+ self.terminal = TerminalRunner(project_path)
21
+ self.code_runner = CodeRunner(project_path)
22
+ self.git = GitOps(project_path)
23
+
24
+ def get_openai_tools(self) -> list[dict[str, Any]]:
25
+ """Return tool definitions formatted for OpenAI / DeepSeek / Groq."""
26
+ return [
27
+ {
28
+ "type": "function",
29
+ "function": {
30
+ "name": "read_file",
31
+ "description": "Read content of a file in the project.",
32
+ "parameters": {
33
+ "type": "object",
34
+ "properties": {
35
+ "file_path": {"type": "string", "description": "Relative path to file"},
36
+ },
37
+ "required": ["file_path"],
38
+ },
39
+ },
40
+ },
41
+ {
42
+ "type": "function",
43
+ "function": {
44
+ "name": "write_file",
45
+ "description": "Write or overwrite content in a file.",
46
+ "parameters": {
47
+ "type": "object",
48
+ "properties": {
49
+ "file_path": {"type": "string", "description": "Relative path to file"},
50
+ "content": {"type": "string", "description": "Full file content"},
51
+ },
52
+ "required": ["file_path", "content"],
53
+ },
54
+ },
55
+ },
56
+ {
57
+ "type": "function",
58
+ "function": {
59
+ "name": "edit_file",
60
+ "description": "Replace exact target string with replacement text in a file.",
61
+ "parameters": {
62
+ "type": "object",
63
+ "properties": {
64
+ "file_path": {"type": "string", "description": "Relative path to file"},
65
+ "old_str": {"type": "string", "description": "Exact text to find and replace"},
66
+ "new_str": {"type": "string", "description": "Replacement text"},
67
+ },
68
+ "required": ["file_path", "old_str", "new_str"],
69
+ },
70
+ },
71
+ },
72
+ {
73
+ "type": "function",
74
+ "function": {
75
+ "name": "run_command",
76
+ "description": "Run a shell command in project directory.",
77
+ "parameters": {
78
+ "type": "object",
79
+ "properties": {
80
+ "command": {"type": "string", "description": "Terminal command to run"},
81
+ },
82
+ "required": ["command"],
83
+ },
84
+ },
85
+ },
86
+ {
87
+ "type": "function",
88
+ "function": {
89
+ "name": "list_files",
90
+ "description": "List files in the project workspace.",
91
+ "parameters": {
92
+ "type": "object",
93
+ "properties": {
94
+ "sub_dir": {"type": "string", "description": "Subdirectory to list (default .)"},
95
+ },
96
+ },
97
+ },
98
+ },
99
+ ]
100
+
101
+ def get_anthropic_tools(self) -> list[dict[str, Any]]:
102
+ """Return tool definitions formatted for Anthropic Claude."""
103
+ return [
104
+ {
105
+ "name": "read_file",
106
+ "description": "Read content of a file in the project.",
107
+ "input_schema": {
108
+ "type": "object",
109
+ "properties": {
110
+ "file_path": {"type": "string", "description": "Relative path to file"},
111
+ },
112
+ "required": ["file_path"],
113
+ },
114
+ },
115
+ {
116
+ "name": "write_file",
117
+ "description": "Write or overwrite content in a file.",
118
+ "input_schema": {
119
+ "type": "object",
120
+ "properties": {
121
+ "file_path": {"type": "string", "description": "Relative path to file"},
122
+ "content": {"type": "string", "description": "Full file content"},
123
+ },
124
+ "required": ["file_path", "content"],
125
+ },
126
+ },
127
+ {
128
+ "name": "edit_file",
129
+ "description": "Replace exact target string with replacement text in a file.",
130
+ "input_schema": {
131
+ "type": "object",
132
+ "properties": {
133
+ "file_path": {"type": "string", "description": "Relative path to file"},
134
+ "old_str": {"type": "string", "description": "Exact text to find and replace"},
135
+ "new_str": {"type": "string", "description": "Replacement text"},
136
+ },
137
+ "required": ["file_path", "old_str", "new_str"],
138
+ },
139
+ },
140
+ {
141
+ "name": "run_command",
142
+ "description": "Run a shell command in project directory.",
143
+ "input_schema": {
144
+ "type": "object",
145
+ "properties": {
146
+ "command": {"type": "string", "description": "Terminal command to run"},
147
+ },
148
+ "required": ["command"],
149
+ },
150
+ },
151
+ {
152
+ "name": "list_files",
153
+ "description": "List files in the project workspace.",
154
+ "input_schema": {
155
+ "type": "object",
156
+ "properties": {
157
+ "sub_dir": {"type": "string", "description": "Subdirectory to list (default .)"},
158
+ },
159
+ },
160
+ },
161
+ ]
162
+
163
+ def execute_tool(self, tool_name: str, args: dict[str, Any]) -> str:
164
+ """Execute a tool by name with arguments and return output."""
165
+ if tool_name == "read_file":
166
+ return self.file_editor.read_file(args.get("file_path", ""))
167
+ elif tool_name == "write_file":
168
+ return self.file_editor.write_file(args.get("file_path", ""), args.get("content", ""))
169
+ elif tool_name == "edit_file":
170
+ return self.file_editor.edit_file(
171
+ args.get("file_path", ""), args.get("old_str", ""), args.get("new_str", "")
172
+ )
173
+ elif tool_name == "run_command":
174
+ return self.terminal.run_command(args.get("command", ""))
175
+ elif tool_name == "list_files":
176
+ return self.file_editor.list_files(args.get("sub_dir", "."))
177
+ elif tool_name == "git_status":
178
+ return self.git.status()
179
+ elif tool_name == "git_diff":
180
+ return self.git.diff(args.get("staged", False))
181
+ elif tool_name == "run_python":
182
+ return self.code_runner.run_python(args.get("code", ""))
183
+ else:
184
+ return f"Error: Unknown tool '{tool_name}'."
@@ -0,0 +1,62 @@
1
+ """
2
+ Code execution runners for Python, Node, and scripts.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ import subprocess
8
+ import tempfile
9
+ from pathlib import Path
10
+
11
+
12
+ class CodeRunner:
13
+ """Executes arbitrary code snippets in isolated temporary files."""
14
+
15
+ def __init__(self, project_path: str | Path = ".") -> None:
16
+ self.project_path = Path(project_path).resolve()
17
+
18
+ def run_python(self, code: str, timeout: int = 20) -> str:
19
+ """Execute a Python snippet."""
20
+ with tempfile.NamedTemporaryFile(suffix=".py", mode="w", delete=False) as f:
21
+ f.write(code)
22
+ temp_file = f.name
23
+ try:
24
+ res = subprocess.run(
25
+ ["python3", temp_file],
26
+ cwd=str(self.project_path),
27
+ capture_output=True,
28
+ text=True,
29
+ timeout=timeout,
30
+ )
31
+ out = res.stdout.strip()
32
+ err = res.stderr.strip()
33
+ return f"Return code: {res.returncode}\n{out}\n{err}".strip()
34
+ except subprocess.TimeoutExpired:
35
+ return f"Timeout: Python script exceeded {timeout}s."
36
+ except Exception as e:
37
+ return f"Execution error: {e}"
38
+ finally:
39
+ Path(temp_file).unlink(missing_ok=True)
40
+
41
+ def run_node(self, code: str, timeout: int = 20) -> str:
42
+ """Execute a Node.js snippet."""
43
+ with tempfile.NamedTemporaryFile(suffix=".js", mode="w", delete=False) as f:
44
+ f.write(code)
45
+ temp_file = f.name
46
+ try:
47
+ res = subprocess.run(
48
+ ["node", temp_file],
49
+ cwd=str(self.project_path),
50
+ capture_output=True,
51
+ text=True,
52
+ timeout=timeout,
53
+ )
54
+ out = res.stdout.strip()
55
+ err = res.stderr.strip()
56
+ return f"Return code: {res.returncode}\n{out}\n{err}".strip()
57
+ except subprocess.TimeoutExpired:
58
+ return f"Timeout: Node script exceeded {timeout}s."
59
+ except Exception as e:
60
+ return f"Execution error: {e}"
61
+ finally:
62
+ Path(temp_file).unlink(missing_ok=True)
@@ -0,0 +1,92 @@
1
+ """
2
+ File operations tool for coding agents.
3
+
4
+ Read, write, edit, search files within the project working directory.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+
14
+ class FileEditor:
15
+ """Provides safe file operations rooted in a project directory."""
16
+
17
+ def __init__(self, project_path: str | Path = ".") -> None:
18
+ self.project_path = Path(project_path).resolve()
19
+
20
+ def _resolve_path(self, file_path: str) -> Path:
21
+ p = Path(file_path)
22
+ if not p.is_absolute():
23
+ p = self.project_path / p
24
+ return p.resolve()
25
+
26
+ def read_file(self, file_path: str, max_lines: int = 500) -> str:
27
+ """Read content of a file."""
28
+ target = self._resolve_path(file_path)
29
+ if not target.is_file():
30
+ return f"Error: File '{file_path}' does not exist."
31
+ try:
32
+ lines = target.read_text(encoding="utf-8", errors="replace").splitlines()
33
+ if len(lines) > max_lines:
34
+ preview = "\n".join(lines[:max_lines])
35
+ return f"{preview}\n\n... [Truncated: showing first {max_lines} of {len(lines)} lines]"
36
+ return "\n".join(lines)
37
+ except Exception as e:
38
+ return f"Error reading file '{file_path}': {e}"
39
+
40
+ def write_file(self, file_path: str, content: str) -> str:
41
+ """Write/overwrite content to a file, creating parent directories if needed."""
42
+ target = self._resolve_path(file_path)
43
+ try:
44
+ target.parent.mkdir(parents=True, exist_ok=True)
45
+ target.write_text(content, encoding="utf-8")
46
+ return f"Successfully wrote {len(content.splitlines())} lines to '{file_path}'."
47
+ except Exception as e:
48
+ return f"Error writing to file '{file_path}': {e}"
49
+
50
+ def edit_file(self, file_path: str, old_str: str, new_str: str) -> str:
51
+ """Replace exact target content with new content in a file."""
52
+ target = self._resolve_path(file_path)
53
+ if not target.is_file():
54
+ return f"Error: File '{file_path}' does not exist."
55
+ try:
56
+ content = target.read_text(encoding="utf-8")
57
+ if old_str not in content:
58
+ return f"Error: Target text to replace not found in '{file_path}'."
59
+ count = content.count(old_str)
60
+ if count > 1:
61
+ return f"Error: Target text found {count} times. Please specify a more unique target block."
62
+ new_content = content.replace(old_str, new_str, 1)
63
+ target.write_text(new_content, encoding="utf-8")
64
+ return f"Successfully updated '{file_path}'."
65
+ except Exception as e:
66
+ return f"Error editing file '{file_path}': {e}"
67
+
68
+ def list_files(self, sub_dir: str = ".", max_entries: int = 100) -> str:
69
+ """List files and directories within project."""
70
+ target = self._resolve_path(sub_dir)
71
+ if not target.is_dir():
72
+ return f"Error: Directory '{sub_dir}' does not exist."
73
+ entries = []
74
+ try:
75
+ for root, dirs, files in os.walk(target):
76
+ # Skip hidden folders like .git, node_modules, __pycache__
77
+ dirs[:] = [d for d in dirs if not d.startswith(".") and d != "node_modules" and d != "__pycache__"]
78
+ for f in files:
79
+ if f.startswith("."):
80
+ continue
81
+ full_p = Path(root) / f
82
+ rel_p = full_p.relative_to(self.project_path)
83
+ entries.append(str(rel_p))
84
+ if len(entries) >= max_entries:
85
+ break
86
+ if len(entries) >= max_entries:
87
+ break
88
+ if not entries:
89
+ return "Directory is empty."
90
+ return "\n".join(sorted(entries))
91
+ except Exception as e:
92
+ return f"Error listing directory: {e}"
thwip/tools/git_ops.py ADDED
@@ -0,0 +1,44 @@
1
+ """
2
+ Git operations tool for coding agents.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ import subprocess
8
+ from pathlib import Path
9
+
10
+
11
+ class GitOps:
12
+ """Git version control integration."""
13
+
14
+ def __init__(self, project_path: str | Path = ".") -> None:
15
+ self.project_path = Path(project_path).resolve()
16
+
17
+ def _run_git(self, args: list[str]) -> str:
18
+ try:
19
+ res = subprocess.run(
20
+ ["git"] + args,
21
+ cwd=str(self.project_path),
22
+ capture_output=True,
23
+ text=True,
24
+ timeout=15,
25
+ )
26
+ out = res.stdout.strip()
27
+ err = res.stderr.strip()
28
+ if res.returncode != 0:
29
+ return f"Git error (code {res.returncode}): {err or out}"
30
+ return out or "Success."
31
+ except Exception as e:
32
+ return f"Git operation failed: {e}"
33
+
34
+ def status(self) -> str:
35
+ return self._run_git(["status", "--short"])
36
+
37
+ def diff(self, staged: bool = False) -> str:
38
+ args = ["diff"]
39
+ if staged:
40
+ args.append("--staged")
41
+ return self._run_git(args)
42
+
43
+ def log(self, max_count: int = 5) -> str:
44
+ return self._run_git(["log", f"-n{max_count}", "--oneline"])
@@ -0,0 +1,65 @@
1
+ """
2
+ Terminal execution tools for coding agents.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ import asyncio
8
+ import subprocess
9
+ from pathlib import Path
10
+
11
+
12
+ class TerminalRunner:
13
+ """Executes commands and captures output in the project directory."""
14
+
15
+ def __init__(self, project_path: str | Path = ".") -> None:
16
+ self.project_path = Path(project_path).resolve()
17
+
18
+ def run_command(self, command: str, timeout: int = 30) -> str:
19
+ """Run a shell command synchronously and return combined stdout/stderr."""
20
+ try:
21
+ res = subprocess.run(
22
+ command,
23
+ shell=True,
24
+ cwd=str(self.project_path),
25
+ capture_output=True,
26
+ text=True,
27
+ timeout=timeout,
28
+ )
29
+ out = res.stdout.strip()
30
+ err = res.stderr.strip()
31
+ ret = res.returncode
32
+ res_str = f"Exit code: {ret}\n"
33
+ if out:
34
+ res_str += f"STDOUT:\n{out}\n"
35
+ if err:
36
+ res_str += f"STDERR:\n{err}\n"
37
+ return res_str.strip()
38
+ except subprocess.TimeoutExpired:
39
+ return f"Error: Command timed out after {timeout} seconds."
40
+ except Exception as e:
41
+ return f"Error executing command: {e}"
42
+
43
+ async def run_command_async(self, command: str, timeout: int = 30) -> str:
44
+ """Run a shell command asynchronously."""
45
+ try:
46
+ proc = await asyncio.create_subprocess_shell(
47
+ command,
48
+ cwd=str(self.project_path),
49
+ stdout=asyncio.subprocess.PIPE,
50
+ stderr=asyncio.subprocess.PIPE,
51
+ )
52
+ stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=timeout)
53
+ out = stdout.decode("utf-8", errors="replace").strip()
54
+ err = stderr.decode("utf-8", errors="replace").strip()
55
+ ret = proc.returncode
56
+ res_str = f"Exit code: {ret}\n"
57
+ if out:
58
+ res_str += f"STDOUT:\n{out}\n"
59
+ if err:
60
+ res_str += f"STDERR:\n{err}\n"
61
+ return res_str.strip()
62
+ except asyncio.TimeoutError:
63
+ return f"Error: Command timed out after {timeout} seconds."
64
+ except Exception as e:
65
+ return f"Error executing command: {e}"
thwip/utils.py ADDED
@@ -0,0 +1,110 @@
1
+ """
2
+ Utility functions for thwip.
3
+
4
+ Streaming helpers, markdown rendering, token counting, cost estimation.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import asyncio
10
+ import sys
11
+ from typing import AsyncIterator
12
+
13
+
14
+ # ---------------------------------------------------------------------------
15
+ # Token & Cost Estimation
16
+ # ---------------------------------------------------------------------------
17
+
18
+ # Approximate cost per 1M tokens (input/output): updated periodically
19
+ MODEL_PRICING: dict[str, dict[str, float]] = {
20
+ # Anthropic
21
+ "claude-sonnet-4": {"input": 3.0, "output": 15.0},
22
+ "claude-opus-4": {"input": 15.0, "output": 75.0},
23
+ "claude-haiku-3.5": {"input": 0.80, "output": 4.0},
24
+ # Google
25
+ "gemini-2.5-pro": {"input": 1.25, "output": 10.0},
26
+ "gemini-2.5-flash": {"input": 0.15, "output": 0.60},
27
+ # OpenAI
28
+ "gpt-4.1": {"input": 2.0, "output": 8.0},
29
+ "gpt-4o": {"input": 2.5, "output": 10.0},
30
+ "o3": {"input": 2.0, "output": 8.0},
31
+ "o4-mini": {"input": 1.10, "output": 4.40},
32
+ "codex-mini": {"input": 1.50, "output": 6.0},
33
+ # DeepSeek
34
+ "deepseek-chat": {"input": 0.14, "output": 0.28},
35
+ "deepseek-reasoner": {"input": 0.55, "output": 2.19},
36
+ # Groq
37
+ "llama-3.3-70b-versatile": {"input": 0.59, "output": 0.79},
38
+ "mixtral-8x7b-32768": {"input": 0.24, "output": 0.24},
39
+ # Mistral
40
+ "mistral-large-latest": {"input": 2.0, "output": 6.0},
41
+ "codestral-latest": {"input": 0.30, "output": 0.90},
42
+ }
43
+
44
+
45
+ def estimate_cost(model: str, input_tokens: int, output_tokens: int) -> float:
46
+ """Estimate cost in USD for a given model and token counts."""
47
+ pricing = MODEL_PRICING.get(model)
48
+ if not pricing:
49
+ return 0.0
50
+ input_cost = (input_tokens / 1_000_000) * pricing["input"]
51
+ output_cost = (output_tokens / 1_000_000) * pricing["output"]
52
+ return input_cost + output_cost
53
+
54
+
55
+ def format_cost(cost: float) -> str:
56
+ """Format cost as a readable string."""
57
+ if cost < 0.01:
58
+ return f"${cost:.6f}"
59
+ elif cost < 1.0:
60
+ return f"${cost:.4f}"
61
+ else:
62
+ return f"${cost:.2f}"
63
+
64
+
65
+ def format_tokens(count: int) -> str:
66
+ """Format token count with commas."""
67
+ if count >= 1_000_000:
68
+ return f"{count / 1_000_000:.1f}M"
69
+ elif count >= 1_000:
70
+ return f"{count / 1_000:.1f}K"
71
+ return str(count)
72
+
73
+
74
+ # ---------------------------------------------------------------------------
75
+ # Async Helpers
76
+ # ---------------------------------------------------------------------------
77
+
78
+ async def collect_stream(stream: AsyncIterator[str]) -> str:
79
+ """Collect all chunks from an async stream into a single string."""
80
+ parts: list[str] = []
81
+ async for chunk in stream:
82
+ parts.append(chunk)
83
+ return "".join(parts)
84
+
85
+
86
+ # ---------------------------------------------------------------------------
87
+ # String Helpers
88
+ # ---------------------------------------------------------------------------
89
+
90
+ def truncate(text: str, max_len: int = 80) -> str:
91
+ """Truncate text with ellipsis."""
92
+ if len(text) <= max_len:
93
+ return text
94
+ return text[: max_len - 3] + "..."
95
+
96
+
97
+ def mask_key(key: str) -> str:
98
+ """Mask an API key for display: sk-ant-...xyz123"""
99
+ if not key or len(key) < 10:
100
+ return "****"
101
+ return f"{key[:7]}...{key[-6:]}"
102
+
103
+
104
+ def slugify(text: str) -> str:
105
+ """Convert text to a URL/filename-safe slug."""
106
+ import re
107
+ text = text.lower().strip()
108
+ text = re.sub(r"[^\w\s-]", "", text)
109
+ text = re.sub(r"[\s_]+", "-", text)
110
+ return text.strip("-")