Skip to content

Commit ca85516

Browse files
Merge pull request #3 from martinmoldrup/features/make-the-create-vscode-tasks-json-available-as-a-command
Enable task creation via CLI and improve error handling
2 parents e9ffb9a + 7fbef19 commit ca85516

File tree

10 files changed

+480
-320
lines changed

10 files changed

+480
-320
lines changed

.python-version

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
3.11
1+
3.10

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,10 @@ All notable changes to this project will be documented in this file.
33

44
*NOTE:* Version 0.X.X might have breaking changes in bumps of the minor version number. This is because the project is still in early development and the API is not yet stable. It will still be marked clearly in the release notes.
55

6+
## [0.3.0] - 09-09-2025
7+
- Added toolit `create-vscode-tasks-json` CLI command to create the `.vscode/tasks.json` file from the command line.
8+
- Improved error handling and user feedback when the `devtools` folder does not exist.
9+
610
## [0.2.0] - 16-06-2025
711
- Fix problem with subdependencies not being installed
812
- Add support for python 3.9

README.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
# Toolit
2-
MCP Server and Typer CLI in one, provides an easy way to configure your own DevTools in a project.
2+
Model Context Protocol (MCP) Server, Typer CLI and Visual Studio Code tasks in one, provides an easy way to configure your own DevTools in a project.
33

44
## Installation
55
To get started with Toolit, install the package via pip:
@@ -19,6 +19,7 @@ Note: MCP support is not available on python 3.9, since it is not supported by t
1919
Add a folder called `devtools` to your project root. Create python modules, you decide the name, in this folder. Add the tool decorator to functions you want to expose as commands.
2020

2121
```python
22+
# devtools/my_commands.py
2223
from toolit import tool
2324
@tool
2425
def my_command(to_print: str = "Hello, World!") -> None:
@@ -40,7 +41,7 @@ You can automatically create a `tasks.json` file for Visual Studio Code to run y
4041

4142
To create the `.vscode/tasks.json` file, run the following command in your terminal:
4243
```bash
43-
python -m toolit.create_tasks_json
44+
toolit create-vscode-tasks-json
4445
```
4546
NOTE: THIS WILL OVERWRITE YOUR EXISTING `.vscode/tasks.json` FILE IF IT EXISTS!
4647

pyproject.toml

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "toolit"
3-
version = "0.2.0"
3+
version = "0.3.0"
44
description = "MCP Server, Typer CLI and vscode tasks in one, provides an easy way to configure your own DevTools and python scripts in a project."
55
readme = "README.md"
66
requires-python = ">=3.9" # mcp[cli] requires Python 3.10+
@@ -25,7 +25,7 @@ classifiers = [
2525
"Programming Language :: Python :: Implementation :: PyPy",
2626
"License :: OSI Approved :: GNU General Public License v3 (GPLv3)",
2727
]
28-
keywords = ["mcp", "cli", "devtools", "typer", "vscode", "scripts", "python", "toolkit", "tools"]
28+
keywords = ["mcp", "cli", "devtools", "typer", "vscode", "scripts", "python", "toolkit", "tools", "development", "development tools"]
2929
authors = [
3030
{name = "Martin Møldrup"},
3131
]
@@ -42,7 +42,9 @@ dev = [
4242
"mypy>=1.16.0",
4343
"pytest>=8.3.5",
4444
"pytest-asyncio>=0.26.0",
45-
"ruff>=0.11.13"
45+
"requests>=2.32.4",
46+
"ruff>=0.11.13",
47+
"toml>=0.10.2",
4648
]
4749

4850
[project.optional-dependencies]

scripts/get_changelog.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
"""Python script for getting the change log entry from the CHANGELOG.md file."""
2+
23
import pathlib
34
import argparse
45

toolit/auto_loader.py

Lines changed: 46 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -8,38 +8,70 @@
88

99
import os
1010
import sys
11+
import typer
1112
import inspect
1213
import pathlib
1314
import importlib
1415
from toolit.constants import MARKER_TOOL, ToolitTypesEnum
1516
from toolit.create_apps_and_register import register_command
1617
from types import FunctionType, ModuleType
18+
from typing import Callable
1719

1820

19-
def load_tools_from_folder(folder_path: pathlib.Path) -> list[FunctionType]:
20-
"""
21-
Load all tools from a given folder and register them as commands.
22-
23-
Folder is relative to the project's working directory.
24-
"""
25-
# If folder_path is relative, compute its absolute path using the current working directory.
21+
def get_items_from_folder(
22+
folder_path: pathlib.Path,
23+
strategy: Callable[[ModuleType], list[FunctionType]],
24+
) -> list[FunctionType]:
25+
"""Get items from a given folder using a strategy function."""
2626
if not folder_path.is_absolute():
2727
folder_path = pathlib.Path.cwd() / folder_path
2828

29-
tools: list[FunctionType] = []
30-
tool_groups: list[FunctionType] = []
29+
items: list[FunctionType] = []
3130
project_root: str = str(pathlib.Path.cwd())
3231
if project_root not in sys.path:
3332
sys.path.insert(0, project_root)
34-
# Iterate over each .py file in the folder
3533
for file in folder_path.iterdir():
3634
if not (file.is_file() and file.suffix == ".py" and not file.name.startswith("__")):
3735
continue
3836
module = import_module(file)
39-
tools_for_file: list[FunctionType] = load_tools_from_file(module, ToolitTypesEnum.TOOL)
40-
tools.extend(tools_for_file)
41-
tool_groups.extend(load_tools_from_file(module, ToolitTypesEnum.SEQUENTIAL_GROUP))
42-
tool_groups.extend(load_tools_from_file(module, ToolitTypesEnum.PARALLEL_GROUP))
37+
items.extend(strategy(module))
38+
return items
39+
40+
41+
def tool_strategy(module: ModuleType) -> list[FunctionType]:
42+
"""Strategy to get tools from a module."""
43+
return load_tools_from_file(module, ToolitTypesEnum.TOOL)
44+
45+
46+
def tool_group_strategy(module: ModuleType) -> list[FunctionType]:
47+
"""Strategy to get tool groups from a module."""
48+
groups: list[FunctionType] = []
49+
groups.extend(load_tools_from_file(module, ToolitTypesEnum.SEQUENTIAL_GROUP))
50+
groups.extend(load_tools_from_file(module, ToolitTypesEnum.PARALLEL_GROUP))
51+
return groups
52+
53+
54+
def load_tools_from_folder(folder_path: pathlib.Path) -> list[FunctionType]:
55+
"""
56+
Load all tools from a given folder and register them as commands.
57+
58+
Folder is relative to the project's working directory.
59+
"""
60+
if not folder_path.exists() or not folder_path.is_dir():
61+
msg = (
62+
"No tools loaded.\n"
63+
"The folder selected for devtools does not exist or is not a directory.\n"
64+
f"{folder_path.absolute().as_posix()}\n"
65+
"Please create it and add your tools there."
66+
)
67+
typer.secho(f"\n{'=' * 60}\nERROR: {msg}\n{'=' * 60}\n", fg=typer.colors.RED, bold=True)
68+
return []
69+
# If folder_path is relative, compute its absolute path using the current working directory.
70+
if not folder_path.is_absolute():
71+
folder_path = pathlib.Path.cwd() / folder_path
72+
73+
tools: list[FunctionType] = get_items_from_folder(folder_path, tool_strategy)
74+
tool_groups: list[FunctionType] = get_items_from_folder(folder_path, tool_group_strategy)
4375
# Register each tool as a command
4476
for tool in tools:
4577
register_command(tool)

toolit/cli.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
"""CLI entry point for the toolit package."""
22
import pathlib
3-
from .auto_loader import load_tools_from_folder
3+
from .auto_loader import load_tools_from_folder, register_command
44
from .create_apps_and_register import app
5+
from .create_tasks_json import create_vscode_tasks_json
56

67
PATH = pathlib.Path() / "devtools"
78
load_tools_from_folder(PATH)
9+
register_command(create_vscode_tasks_json)
810

911
if __name__ == "__main__":
1012
# Run the typer app

toolit/create_apps_and_register.py

Lines changed: 19 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -4,33 +4,36 @@
44

55
import typer
66
from collections.abc import Callable
7-
from typing import Any
7+
from typing import TYPE_CHECKING, Any
88

9-
# Try to import FastMCP, make MCP optional
10-
try:
9+
if TYPE_CHECKING:
1110
from mcp.server.fastmcp import FastMCP
12-
1311
_has_mcp: bool = True
14-
except ImportError:
15-
FastMCP: Any = None # type: ignore[no-redef]
16-
_has_mcp = False
12+
else:
13+
# Make MCP optional
14+
try:
15+
from mcp.server.fastmcp import FastMCP
16+
_has_mcp = True
17+
except ImportError:
18+
FastMCP: Any = None # type: ignore[no-redef]
19+
_has_mcp = False
1720

1821
# Initialize the Typer app
19-
app: typer.Typer = typer.Typer()
22+
app: typer.Typer = typer.Typer(no_args_is_help=True)
2023
# Initialize the MCP server with a name, if available
2124
mcp: FastMCP | None = FastMCP("Toolit MCP Server") if _has_mcp else None
2225

2326

27+
@app.callback()
28+
def initialize() -> None:
29+
"""Welcome to the Toolit CLI."""
30+
31+
2432
def register_command(command_func: Callable[..., Any], name: str | None = None) -> None:
2533
"""Register an external command to the CLI and MCP server if available."""
2634
if not callable(command_func):
2735
msg = f"Command function {command_func} is not callable."
2836
raise TypeError(msg)
29-
if name:
30-
app.command(name=name)(command_func)
31-
if mcp is not None:
32-
mcp.tool(name)(command_func)
33-
else:
34-
app.command()(command_func)
35-
if mcp is not None:
36-
mcp.tool()(command_func)
37+
app.command(name=name)(command_func)
38+
if mcp is not None:
39+
mcp.tool(name)(command_func)

toolit/create_tasks_json.py

Lines changed: 30 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,10 @@
22

33
import enum
44
import json
5+
import typer
56
import inspect
67
import pathlib
7-
from toolit.auto_loader import get_toolit_type, load_tools_from_folder
8+
from toolit.auto_loader import get_items_from_folder, get_toolit_type, tool_group_strategy, tool_strategy
89
from toolit.constants import ToolitTypesEnum
910
from types import FunctionType
1011
from typing import Any
@@ -13,18 +14,12 @@
1314
output_file_path: pathlib.Path = pathlib.Path() / ".vscode" / "tasks.json"
1415

1516

16-
def _is_enum(annotation: Any) -> bool: # noqa: ANN401
17-
"""Check if the annotation is an Enum type."""
18-
return isinstance(annotation, type) and issubclass(annotation, enum.Enum)
19-
20-
21-
def _is_bool(annotation: Any) -> bool: # noqa: ANN401
22-
"""Check if the annotation is a bool type."""
23-
return annotation is bool
24-
25-
26-
def create_vscode_tasks_json(tools: list[FunctionType]) -> None:
17+
def create_vscode_tasks_json() -> None:
2718
"""Create a tasks.json file based on the tools discovered in the project."""
19+
typer.echo(f"Creating tasks.json at {output_file_path}")
20+
tools: list[FunctionType] = get_items_from_folder(PATH, tool_strategy)
21+
tool_groups: list[FunctionType] = get_items_from_folder(PATH, tool_group_strategy)
22+
tools.extend(tool_groups)
2823
json_builder = TaskJsonBuilder()
2924
for tool in tools:
3025
json_builder.process_tool(tool)
@@ -35,12 +30,22 @@ def create_vscode_tasks_json(tools: list[FunctionType]) -> None:
3530
json.dump(tasks_json, f, indent=4)
3631

3732

38-
def create_typer_command_name(tool: FunctionType) -> str:
33+
def _is_enum(annotation: Any) -> bool: # noqa: ANN401
34+
"""Check if the annotation is an Enum type."""
35+
return isinstance(annotation, type) and issubclass(annotation, enum.Enum)
36+
37+
38+
def _is_bool(annotation: Any) -> bool: # noqa: ANN401
39+
"""Check if the annotation is a bool type."""
40+
return annotation is bool
41+
42+
43+
def _create_typer_command_name(tool: FunctionType) -> str:
3944
"""Create a Typer command name from a tool function name."""
4045
return tool.__name__.replace("_", "-").lower()
4146

4247

43-
def create_display_name(tool: FunctionType) -> str:
48+
def _create_display_name(tool: FunctionType) -> str:
4449
"""Create a display name from a tool function name."""
4550
return tool.__name__.replace("_", " ").title()
4651

@@ -54,7 +59,7 @@ def __init__(self) -> None:
5459
self.input_id_map: dict[tuple[str, str], str] = {}
5560
self.tasks: list[dict[str, Any]] = []
5661

57-
def create_args_for_tool(self, tool: FunctionType) -> list[str]:
62+
def _create_args_for_tool(self, tool: FunctionType) -> list[str]:
5863
"""Create argument list and input entries for a given tool."""
5964
sig = inspect.signature(tool)
6065
args: list[str] = []
@@ -94,10 +99,10 @@ def create_args_for_tool(self, tool: FunctionType) -> list[str]:
9499
args.append(f'"${{input:{input_id}}}"')
95100
return args
96101

97-
def create_task_entry(self, tool: FunctionType, args: list[str]) -> None:
102+
def _create_task_entry(self, tool: FunctionType, args: list[str]) -> None:
98103
"""Create a task entry for a given tool."""
99-
name_as_typer_command: str = create_typer_command_name(tool)
100-
display_name: str = tool.__name__.replace("_", " ").title()
104+
name_as_typer_command: str = _create_typer_command_name(tool)
105+
display_name: str = _create_display_name(tool)
101106
task: dict[str, Any] = {
102107
"label": display_name,
103108
"type": "shell",
@@ -108,13 +113,13 @@ def create_task_entry(self, tool: FunctionType, args: list[str]) -> None:
108113
task["detail"] = tool.__doc__.strip()
109114
self.tasks.append(task)
110115

111-
def create_task_group_entry(self, tool: FunctionType, tool_type: ToolitTypesEnum) -> None:
116+
def _create_task_group_entry(self, tool: FunctionType, tool_type: ToolitTypesEnum) -> None:
112117
"""Create a task group entry for a given tool."""
113-
group_name: str = tool.__name__.replace("_", " ").title()
118+
group_name: str = "Group: " + tool.__name__.replace("_", " ").title()
114119
tools: list[FunctionType] = tool() # Call the tool to get the list of tools in the group
115120
task: dict[str, Any] = {
116121
"label": group_name,
117-
"dependsOn": [f"{create_display_name(t)}" for t in tools],
122+
"dependsOn": [f"{_create_display_name(t)}" for t in tools],
118123
"problemMatcher": [],
119124
}
120125
if tool_type == ToolitTypesEnum.SEQUENTIAL_GROUP:
@@ -127,10 +132,10 @@ def process_tool(self, tool: FunctionType) -> None:
127132
"""Process a single tool to create its task entry and inputs."""
128133
tool_type = get_toolit_type(tool)
129134
if tool_type == ToolitTypesEnum.TOOL:
130-
args = self.create_args_for_tool(tool)
131-
self.create_task_entry(tool, args)
135+
args = self._create_args_for_tool(tool)
136+
self._create_task_entry(tool, args)
132137
elif tool_type in {ToolitTypesEnum.SEQUENTIAL_GROUP, ToolitTypesEnum.PARALLEL_GROUP}:
133-
self.create_task_group_entry(tool, tool_type)
138+
self._create_task_group_entry(tool, tool_type)
134139

135140
def create_tasks_json(self) -> dict:
136141
"""Create the final tasks.json structure."""
@@ -143,5 +148,4 @@ def create_tasks_json(self) -> dict:
143148

144149

145150
if __name__ == "__main__":
146-
tools: list[FunctionType] = load_tools_from_folder(PATH)
147-
create_vscode_tasks_json(tools)
151+
create_vscode_tasks_json()

0 commit comments

Comments
 (0)