tinychat-builtin-tools 0.1.0__tar.gz

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,8 @@
1
+ Metadata-Version: 2.4
2
+ Name: tinychat-builtin-tools
3
+ Version: 0.1.0
4
+ Summary: Built-in tools for TinyChat: file I/O, shell execution, search, and workflow
5
+ License-Expression: MIT
6
+ Requires-Python: >=3.9
7
+ Description-Content-Type: text/markdown
8
+ Requires-Dist: tinychat-ai
@@ -0,0 +1,28 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61.0"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "tinychat-builtin-tools"
7
+ version = "0.1.0"
8
+ description = "Built-in tools for TinyChat: file I/O, shell execution, search, and workflow"
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ requires-python = ">=3.9"
12
+ dependencies = [
13
+ "tinychat-ai",
14
+ ]
15
+
16
+ [project.entry-points."tinychat.tools"]
17
+ read_file = "tinychat_builtin_tools.files:ReadFileTool"
18
+ write_file = "tinychat_builtin_tools.files:WriteFileTool"
19
+ edit_file = "tinychat_builtin_tools.files:EditFileTool"
20
+ exec_shell = "tinychat_builtin_tools.shell:ExecShellTool"
21
+ list_directory = "tinychat_builtin_tools.fs:ListDirectoryTool"
22
+ glob = "tinychat_builtin_tools.search:GlobTool"
23
+ grep = "tinychat_builtin_tools.search:GrepTool"
24
+ todo_list = "tinychat_builtin_tools.todos:TodoListTool"
25
+ no_action = "tinychat_builtin_tools.no_action:NoActionTool"
26
+
27
+ [tool.setuptools.packages.find]
28
+ where = ["src"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,3 @@
1
+ """TinyChat built-in tools plugin."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,141 @@
1
+ """Command configuration loader for shell tool whitelist."""
2
+
3
+ from dataclasses import dataclass, field
4
+ from typing import Any
5
+ from pathlib import Path
6
+ import logging
7
+ import os
8
+
9
+ try:
10
+ import tomllib
11
+ except ImportError:
12
+ import tomli as tomllib
13
+
14
+ from tinychat_builtin_tools.shell_defaults import get_default_commands
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+
19
+ @dataclass
20
+ class CommandConfig:
21
+ """Configuration for command whitelist."""
22
+
23
+ version: int = 1
24
+ override_defaults: bool = False
25
+ commands: dict[str, Any] = field(default_factory=dict)
26
+
27
+
28
+ class CommandConfigLoader:
29
+ """Loads command whitelist configuration from TOML file."""
30
+
31
+ def __init__(self) -> None:
32
+ """Initialize the config loader."""
33
+ self._config_path: Path | None = None
34
+
35
+ def get_config_path(self) -> Path:
36
+ """Return the path to commands.toml configuration file.
37
+
38
+ Uses XDG_CONFIG_HOME if set, otherwise ~/.config
39
+
40
+ Returns:
41
+ Path to ~/.config/tinychat/commands.toml or ${XDG_CONFIG_HOME}/tinychat/commands.toml
42
+ """
43
+ if self._config_path:
44
+ return self._config_path
45
+
46
+ xdg_config = os.environ.get("XDG_CONFIG_HOME")
47
+ if xdg_config:
48
+ config_dir = Path(xdg_config) / "tinychat"
49
+ else:
50
+ config_dir = Path.home() / ".config" / "tinychat"
51
+
52
+ self._config_path = config_dir / "commands.toml"
53
+ return self._config_path
54
+
55
+ def load(self) -> CommandConfig:
56
+ """Load command configuration from file.
57
+
58
+ If file doesn't exist, returns default configuration.
59
+ If override_defaults is False, merges user config with defaults.
60
+
61
+ Returns:
62
+ CommandConfig with loaded settings
63
+ """
64
+ config_path = self.get_config_path()
65
+
66
+ if not config_path.exists():
67
+ logger.debug(f"Config file not found at {config_path}, using defaults")
68
+ return CommandConfig(
69
+ version=1,
70
+ override_defaults=False,
71
+ commands=get_default_commands(),
72
+ )
73
+
74
+ try:
75
+ with open(config_path, "rb") as f:
76
+ data = tomllib.load(f)
77
+ except (tomllib.TOMLDecodeError, OSError) as e:
78
+ logger.warning(f"Failed to parse config file {config_path}: {e}")
79
+ return CommandConfig(
80
+ version=1,
81
+ override_defaults=False,
82
+ commands=get_default_commands(),
83
+ )
84
+
85
+ version = data.get("version", 1)
86
+ override_defaults = data.get("override_defaults", False)
87
+ user_commands = self._parse_commands(data)
88
+
89
+ if override_defaults:
90
+ # Use only user-defined commands
91
+ final_commands = user_commands
92
+ else:
93
+ # Merge defaults with user commands (user takes precedence)
94
+ final_commands = get_default_commands()
95
+ final_commands.update(user_commands)
96
+
97
+ return CommandConfig(
98
+ version=version,
99
+ override_defaults=override_defaults,
100
+ commands=final_commands,
101
+ )
102
+
103
+ def _parse_commands(self, data: dict[str, Any]) -> dict[str, Any]:
104
+ """Parse command definitions from TOML data.
105
+
106
+ Supports two formats:
107
+ 1. Array format: [[commands]]
108
+ 2. Dict format: [commands.name]
109
+
110
+ Args:
111
+ data: Parsed TOML data
112
+
113
+ Returns:
114
+ Dict mapping command name to its configuration
115
+ """
116
+ result: dict[str, Any] = {}
117
+
118
+ # Try array format: [[commands]]
119
+ commands_list = data.get("commands", [])
120
+ if isinstance(commands_list, list):
121
+ for cmd_def in commands_list:
122
+ if not isinstance(cmd_def, dict):
123
+ continue
124
+ name = cmd_def.get("name")
125
+ if not name:
126
+ continue
127
+ result[name] = {
128
+ "allowed_args": cmd_def.get("allowed_args", []),
129
+ "blocked_args": cmd_def.get("blocked_args", []),
130
+ }
131
+ elif isinstance(commands_list, dict):
132
+ # Dict format: [commands.name]
133
+ for name, cmd_def in commands_list.items():
134
+ if not isinstance(cmd_def, dict):
135
+ continue
136
+ result[name] = {
137
+ "allowed_args": cmd_def.get("allowed_args", []),
138
+ "blocked_args": cmd_def.get("blocked_args", []),
139
+ }
140
+
141
+ return result
@@ -0,0 +1,295 @@
1
+ """File operation tools: read_file and write_file."""
2
+
3
+ import os
4
+ from typing import Any
5
+
6
+ from tinychat.tools.base import Tool, ToolParameter, ToolResult
7
+
8
+
9
+ class ReadFileTool(Tool):
10
+ """Tool for reading text files with offset/limit/tail support."""
11
+
12
+ @property
13
+ def name(self) -> str:
14
+ return "read_file"
15
+
16
+ @property
17
+ def description(self) -> str:
18
+ return (
19
+ "Read the contents of a text file from the filesystem. "
20
+ "Use when you need to inspect actual file contents to answer a question "
21
+ "or when the user explicitly asks to read/view a file. "
22
+ "Do NOT use this for: translation, explaining concepts, or any task "
23
+ "that does not require reading a specific file. "
24
+ "Returns: ToolResult with success (bool), output (str), error (str|None), "
25
+ "metadata.total_lines (int), metadata.lines_returned (int)."
26
+ )
27
+
28
+ @property
29
+ def parameters(self) -> list[ToolParameter]:
30
+ return [
31
+ ToolParameter(
32
+ name="path",
33
+ type="string",
34
+ description="Absolute or relative path to the file to read",
35
+ required=True,
36
+ ),
37
+ ToolParameter(
38
+ name="offset",
39
+ type="number",
40
+ description="Line number to start reading from (1-indexed)",
41
+ required=False,
42
+ default=1,
43
+ ),
44
+ ToolParameter(
45
+ name="limit",
46
+ type="number",
47
+ description="Maximum number of lines to read",
48
+ required=False,
49
+ default=2000,
50
+ ),
51
+ ToolParameter(
52
+ name="tail",
53
+ type="number",
54
+ description="Read the last N lines of the file (overrides offset/limit)",
55
+ required=False,
56
+ ),
57
+ ]
58
+
59
+ async def execute(self, arguments: dict[str, Any]) -> ToolResult:
60
+ path = arguments.get("path", "")
61
+ offset = arguments.get("offset", 1)
62
+ limit = arguments.get("limit", 2000)
63
+ tail = arguments.get("tail")
64
+
65
+ if not os.path.isfile(path):
66
+ return ToolResult(
67
+ success=False,
68
+ output="",
69
+ error=f"File not found: {path}",
70
+ )
71
+
72
+ try:
73
+ with open(path, "r", encoding="utf-8", errors="replace") as f:
74
+ all_lines = f.readlines()
75
+ except Exception as e:
76
+ return ToolResult(
77
+ success=False,
78
+ output="",
79
+ error=f"Error reading file: {e}",
80
+ )
81
+
82
+ if tail is not None and tail > 0:
83
+ selected = all_lines[-tail:]
84
+ else:
85
+ start = max(0, offset - 1)
86
+ selected = all_lines[start : start + limit]
87
+
88
+ output = "".join(selected)
89
+ total_lines = len(all_lines)
90
+
91
+ return ToolResult(
92
+ success=True,
93
+ output=output,
94
+ metadata={
95
+ "total_lines": total_lines,
96
+ "lines_returned": len(selected),
97
+ },
98
+ )
99
+
100
+
101
+ class WriteFileTool(Tool):
102
+ """Tool for writing content to files."""
103
+
104
+ @property
105
+ def name(self) -> str:
106
+ return "write_file"
107
+
108
+ @property
109
+ def description(self) -> str:
110
+ return (
111
+ "Write content to a file. Creates the file if it does not exist, "
112
+ "or overwrites it if it does. Parent directories will be created automatically. "
113
+ "Use when you need to create or edit a file that the user explicitly asks for. "
114
+ "Do NOT use this for generating text, answering questions, or any task "
115
+ "that does not require writing to a specific file. "
116
+ "Returns: ToolResult with success (bool), output (str), error (str|None), "
117
+ "metadata.path (str), metadata.characters_written (int)."
118
+ )
119
+
120
+ @property
121
+ def parameters(self) -> list[ToolParameter]:
122
+ return [
123
+ ToolParameter(
124
+ name="path",
125
+ type="string",
126
+ description="Absolute or relative path to the file to write",
127
+ required=True,
128
+ ),
129
+ ToolParameter(
130
+ name="content",
131
+ type="string",
132
+ description="The content to write to the file",
133
+ required=True,
134
+ ),
135
+ ]
136
+
137
+ async def execute(self, arguments: dict[str, Any]) -> ToolResult:
138
+ path = arguments.get("path", "")
139
+ content = arguments.get("content", "")
140
+
141
+ if not path:
142
+ return ToolResult(
143
+ success=False,
144
+ output="",
145
+ error="No file path provided",
146
+ )
147
+
148
+ try:
149
+ parent = os.path.dirname(path)
150
+ if parent:
151
+ os.makedirs(parent, exist_ok=True)
152
+
153
+ with open(path, "w", encoding="utf-8") as f:
154
+ f.write(content)
155
+
156
+ return ToolResult(
157
+ success=True,
158
+ output=f"Successfully wrote {len(content)} characters to {path}",
159
+ metadata={
160
+ "path": path,
161
+ "characters_written": len(content),
162
+ },
163
+ )
164
+ except Exception as e:
165
+ return ToolResult(
166
+ success=False,
167
+ output="",
168
+ error=f"Error writing file: {e}",
169
+ )
170
+
171
+
172
+ class EditFileTool(Tool):
173
+ """Tool for editing files by replacing strings."""
174
+
175
+ @property
176
+ def name(self) -> str:
177
+ return "edit_file"
178
+
179
+ @property
180
+ def description(self) -> str:
181
+ return (
182
+ "Edit a text file by replacing a specific string with new content. "
183
+ "Supports replacing all occurrences with the replace_all flag. "
184
+ "Use when you need to modify specific content in an existing file. "
185
+ "Do NOT use this for creating new files, appending content, "
186
+ "or any task that does not require editing an existing file. "
187
+ "Returns: ToolResult with success (bool), output (str), error (str|None), "
188
+ "metadata.replacements_made (int)."
189
+ )
190
+
191
+ @property
192
+ def parameters(self) -> list[ToolParameter]:
193
+ return [
194
+ ToolParameter(
195
+ name="path",
196
+ type="string",
197
+ description="Absolute or relative path to the file to edit",
198
+ required=True,
199
+ ),
200
+ ToolParameter(
201
+ name="old_string",
202
+ type="string",
203
+ description="The exact string to find and replace",
204
+ required=True,
205
+ ),
206
+ ToolParameter(
207
+ name="new_string",
208
+ type="string",
209
+ description="The replacement string",
210
+ required=True,
211
+ ),
212
+ ToolParameter(
213
+ name="replace_all",
214
+ type="boolean",
215
+ description="Replace all occurrences of old_string (default: false)",
216
+ required=False,
217
+ default=False,
218
+ ),
219
+ ]
220
+
221
+ async def execute(self, arguments: dict[str, Any]) -> ToolResult:
222
+ path = arguments.get("path", "")
223
+ old_string = arguments.get("old_string", "")
224
+ new_string = arguments.get("new_string", "")
225
+ replace_all = arguments.get("replace_all", False)
226
+
227
+ if not path:
228
+ return ToolResult(
229
+ success=False,
230
+ output="",
231
+ error="No file path provided",
232
+ )
233
+
234
+ if not old_string:
235
+ return ToolResult(
236
+ success=False,
237
+ output="",
238
+ error="old_string cannot be empty",
239
+ )
240
+
241
+ if not os.path.isfile(path):
242
+ return ToolResult(
243
+ success=False,
244
+ output="",
245
+ error=f"File not found: {path}",
246
+ )
247
+
248
+ try:
249
+ with open(path, "r", encoding="utf-8", errors="replace") as f:
250
+ content = f.read()
251
+ except Exception as e:
252
+ return ToolResult(
253
+ success=False,
254
+ output="",
255
+ error=f"Error reading file: {e}",
256
+ )
257
+
258
+ if replace_all:
259
+ new_content = content.replace(old_string, new_string)
260
+ replacements_made = content.count(old_string)
261
+ else:
262
+ if old_string in content:
263
+ new_content = content.replace(old_string, new_string, 1)
264
+ replacements_made = 1
265
+ else:
266
+ return ToolResult(
267
+ success=False,
268
+ output="",
269
+ error=f"String '{old_string}' not found in file",
270
+ )
271
+
272
+ if replacements_made == 0 and not replace_all:
273
+ return ToolResult(
274
+ success=False,
275
+ output="",
276
+ error=f"String '{old_string}' not found in file",
277
+ )
278
+
279
+ try:
280
+ with open(path, "w", encoding="utf-8") as f:
281
+ f.write(new_content)
282
+
283
+ return ToolResult(
284
+ success=True,
285
+ output=f"Successfully made {replacements_made} replacement(s) in {path}",
286
+ metadata={
287
+ "replacements_made": replacements_made,
288
+ },
289
+ )
290
+ except Exception as e:
291
+ return ToolResult(
292
+ success=False,
293
+ output="",
294
+ error=f"Error writing file: {e}",
295
+ )
@@ -0,0 +1,76 @@
1
+ """Filesystem operation tools."""
2
+
3
+ import os
4
+ from typing import Any
5
+
6
+ from tinychat.tools.base import Tool, ToolParameter, ToolResult
7
+
8
+
9
+ class ListDirectoryTool(Tool):
10
+ """Tool for listing directory contents."""
11
+
12
+ @property
13
+ def name(self) -> str:
14
+ return "list_directory"
15
+
16
+ @property
17
+ def description(self) -> str:
18
+ return (
19
+ "List the contents of a directory. "
20
+ "Use when you need to explore the filesystem, find files, "
21
+ "or understand the structure of a directory. "
22
+ "Do NOT use this for reading file contents, writing files, "
23
+ "translation, or any task that does not require listing directory contents. "
24
+ "Returns: ToolResult with success (bool), output (str), error (str|None), "
25
+ "metadata.entry_count (int). Subdirectories are marked with '/' suffix."
26
+ )
27
+
28
+ @property
29
+ def parameters(self) -> list[ToolParameter]:
30
+ return [
31
+ ToolParameter(
32
+ name="path",
33
+ type="string",
34
+ description="Absolute or relative path to the directory to list",
35
+ required=False,
36
+ default=".",
37
+ ),
38
+ ]
39
+
40
+ async def execute(self, arguments: dict[str, Any]) -> ToolResult:
41
+ path = arguments.get("path", ".")
42
+
43
+ if not os.path.isdir(path):
44
+ return ToolResult(
45
+ success=False,
46
+ output="",
47
+ error=f"Directory not found: {path}",
48
+ )
49
+
50
+ try:
51
+ entries = os.listdir(path)
52
+ sorted_entries = sorted(entries)
53
+
54
+ output_lines = []
55
+ for entry in sorted_entries:
56
+ entry_path = os.path.join(path, entry)
57
+ if os.path.isdir(entry_path):
58
+ output_lines.append(f"{entry}/")
59
+ else:
60
+ output_lines.append(entry)
61
+
62
+ output = "\n".join(output_lines)
63
+
64
+ return ToolResult(
65
+ success=True,
66
+ output=output,
67
+ metadata={
68
+ "entry_count": len(sorted_entries),
69
+ },
70
+ )
71
+ except Exception as e:
72
+ return ToolResult(
73
+ success=False,
74
+ output="",
75
+ error=f"Error listing directory: {e}",
76
+ )
@@ -0,0 +1,27 @@
1
+ """No-action tool: explicit exit channel to avoid unnecessary tool calls."""
2
+
3
+ from typing import Any
4
+
5
+ from tinychat.tools.base import Tool, ToolParameter, ToolResult
6
+
7
+
8
+ class NoActionTool(Tool):
9
+ @property
10
+ def name(self) -> str:
11
+ return "no_action"
12
+
13
+ @property
14
+ def description(self) -> str:
15
+ return (
16
+ "Use this tool when you can answer the user's question directly "
17
+ "from your own knowledge without needing any external tool. "
18
+ "This is the default choice for most questions. "
19
+ "Do NOT use any other tool if you can answer directly."
20
+ )
21
+
22
+ @property
23
+ def parameters(self) -> list[ToolParameter]:
24
+ return []
25
+
26
+ async def execute(self, arguments: dict[str, Any]) -> ToolResult:
27
+ return ToolResult(success=True, output="")