caudate-cli 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.
- api/__init__.py +5 -0
- api/anthropic_compat.py +1518 -0
- api/artifact_viewer.py +366 -0
- api/caudate_middleware.py +618 -0
- api/forge_bootstrapper_routes.py +377 -0
- api/forge_routes.py +630 -0
- api/forge_system_routes.py +294 -0
- api/openai_compat.py +1993 -0
- api/server.py +667 -0
- api/storyboard_page.py +677 -0
- caudate_cli-0.1.0.dist-info/METADATA +354 -0
- caudate_cli-0.1.0.dist-info/RECORD +153 -0
- caudate_cli-0.1.0.dist-info/WHEEL +5 -0
- caudate_cli-0.1.0.dist-info/entry_points.txt +2 -0
- caudate_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
- caudate_cli-0.1.0.dist-info/top_level.txt +14 -0
- cognos_mcp/__init__.py +4 -0
- cognos_mcp/bridge.py +41 -0
- cognos_mcp/client.py +70 -0
- cognos_mcp/config.py +49 -0
- cognos_mcp/server.py +66 -0
- config.py +82 -0
- core/__init__.py +0 -0
- core/agent.py +468 -0
- core/agentic_loop.py +731 -0
- core/anthropic_auth.py +91 -0
- core/background.py +113 -0
- core/banner.py +134 -0
- core/bootstrap.py +292 -0
- core/citations.py +131 -0
- core/compaction.py +109 -0
- core/constitution.py +198 -0
- core/diff_viewer.py +87 -0
- core/export.py +85 -0
- core/file_refs.py +119 -0
- core/files.py +199 -0
- core/hooks.py +209 -0
- core/image.py +599 -0
- core/input.py +91 -0
- core/loop.py +238 -0
- core/memory_md.py +147 -0
- core/notifications.py +99 -0
- core/ownership.py +181 -0
- core/paste.py +81 -0
- core/permissions.py +210 -0
- core/plan_mode.py +215 -0
- core/sandbox_prompt.py +185 -0
- core/scheduler.py +195 -0
- core/schemas.py +202 -0
- core/session.py +90 -0
- core/settings.py +132 -0
- core/skills.py +398 -0
- core/slash_commands.py +977 -0
- core/statusline.py +61 -0
- core/subagent.py +300 -0
- core/thinking.py +50 -0
- core/updater.py +122 -0
- core/usage.py +109 -0
- core/worktree.py +93 -0
- execution/__init__.py +0 -0
- execution/executor.py +329 -0
- execution/plugins.py +108 -0
- execution/tools/__init__.py +0 -0
- execution/tools/agent_tool.py +107 -0
- execution/tools/agentic_tool.py +297 -0
- execution/tools/artifact_tool.py +191 -0
- execution/tools/ask_user_question_tool.py +137 -0
- execution/tools/base.py +81 -0
- execution/tools/calculator_tool.py +137 -0
- execution/tools/cognos_card_tool.py +124 -0
- execution/tools/cron_tool.py +215 -0
- execution/tools/datetime_tool.py +215 -0
- execution/tools/describe_image_tool.py +161 -0
- execution/tools/draw_tool.py +164 -0
- execution/tools/edit_image_tool.py +262 -0
- execution/tools/edit_tool.py +245 -0
- execution/tools/file_tool.py +90 -0
- execution/tools/find_anywhere_tool.py +255 -0
- execution/tools/forge_feature_tools.py +377 -0
- execution/tools/glob_tool.py +59 -0
- execution/tools/grep_tool.py +89 -0
- execution/tools/http_request_tool.py +224 -0
- execution/tools/load_skill_tool.py +104 -0
- execution/tools/longcat_avatar_tool.py +384 -0
- execution/tools/mcp_tool.py +100 -0
- execution/tools/notebook_tool.py +279 -0
- execution/tools/openapi_tool.py +440 -0
- execution/tools/plan_mode_tool.py +95 -0
- execution/tools/push_notification_tool.py +157 -0
- execution/tools/python_tool.py +61 -0
- execution/tools/respond_tool.py +40 -0
- execution/tools/sandbox_tool.py +378 -0
- execution/tools/search_tool.py +153 -0
- execution/tools/semantic_search_tool.py +106 -0
- execution/tools/shell_tool.py +283 -0
- execution/tools/speak_tool.py +134 -0
- execution/tools/storyboard_tool.py +727 -0
- execution/tools/system_info_tool.py +212 -0
- execution/tools/task_tool.py +323 -0
- execution/tools/think_tool.py +49 -0
- execution/tools/transcribe_audio_tool.py +86 -0
- execution/tools/update_memory_tool.py +92 -0
- execution/tools/web_fetch_tool.py +82 -0
- execution/tools/worktree_tool.py +174 -0
- llm/__init__.py +0 -0
- llm/fallback.py +116 -0
- llm/models.py +320 -0
- llm/provider.py +1356 -0
- llm/router.py +373 -0
- main.py +1889 -0
- memory/__init__.py +0 -0
- memory/episodic.py +99 -0
- memory/procedural.py +145 -0
- memory/semantic.py +71 -0
- memory/working.py +64 -0
- nn/__init__.py +43 -0
- nn/auto_evolve.py +245 -0
- nn/caudate.py +136 -0
- nn/config.py +141 -0
- nn/consolidator.py +81 -0
- nn/data.py +1635 -0
- nn/encoder.py +258 -0
- nn/forge_advisor.py +303 -0
- nn/format.py +235 -0
- nn/heads.py +432 -0
- nn/observer.py +994 -0
- nn/policy.py +214 -0
- nn/runtime.py +343 -0
- nn/scorer.py +175 -0
- nn/trainer.py +515 -0
- nn/vision.py +352 -0
- personality/__init__.py +23 -0
- personality/engine.py +129 -0
- personality/identity.py +144 -0
- personality/inner_voice.py +100 -0
- personality/mood.py +205 -0
- planning/__init__.py +0 -0
- planning/dev_server.py +221 -0
- planning/forge_models.py +718 -0
- planning/orchestrator.py +1363 -0
- planning/planner.py +451 -0
- planning/task_graph.py +61 -0
- reflection/__init__.py +0 -0
- reflection/meta_learner.py +156 -0
- reflection/reflector.py +127 -0
- ui/__init__.py +5 -0
- ui/display.py +88 -0
- voice/__init__.py +0 -0
- voice/conversation.py +125 -0
- voice/listener.py +111 -0
- voice/speaker.py +59 -0
- voice/stt.py +126 -0
- voice/tts.py +214 -0
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""Grep tool — regex content search across files."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from core.schemas import ToolResult
|
|
10
|
+
from execution.tools.base import BaseTool
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class GrepTool(BaseTool):
|
|
14
|
+
name = "Grep"
|
|
15
|
+
description = (
|
|
16
|
+
"Search for a regex pattern across files. Returns matching lines "
|
|
17
|
+
"with file:line:text. Supports include-glob filtering."
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
@property
|
|
21
|
+
def input_schema(self) -> dict:
|
|
22
|
+
return {
|
|
23
|
+
"type": "object",
|
|
24
|
+
"properties": {
|
|
25
|
+
"pattern": {
|
|
26
|
+
"type": "string",
|
|
27
|
+
"description": "Regex pattern to search for",
|
|
28
|
+
},
|
|
29
|
+
"path": {
|
|
30
|
+
"type": "string",
|
|
31
|
+
"description": "Root directory to search. Defaults to cwd.",
|
|
32
|
+
},
|
|
33
|
+
"include": {
|
|
34
|
+
"type": "string",
|
|
35
|
+
"description": "Glob to restrict files, e.g. '**/*.py'",
|
|
36
|
+
"default": "**/*",
|
|
37
|
+
},
|
|
38
|
+
"case_insensitive": {
|
|
39
|
+
"type": "boolean",
|
|
40
|
+
"description": "If true, ignore case. Default false.",
|
|
41
|
+
"default": False,
|
|
42
|
+
},
|
|
43
|
+
"limit": {
|
|
44
|
+
"type": "integer",
|
|
45
|
+
"description": "Maximum matching lines to return (default 200)",
|
|
46
|
+
"default": 200,
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
"required": ["pattern"],
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async def execute(self, **kwargs: Any) -> ToolResult:
|
|
53
|
+
pattern = kwargs.get("pattern", "")
|
|
54
|
+
if not pattern:
|
|
55
|
+
return self._error("No pattern provided")
|
|
56
|
+
|
|
57
|
+
root = Path(kwargs.get("path") or ".").resolve()
|
|
58
|
+
include = kwargs.get("include", "**/*")
|
|
59
|
+
flags = re.IGNORECASE if kwargs.get("case_insensitive") else 0
|
|
60
|
+
limit = int(kwargs.get("limit", 200))
|
|
61
|
+
|
|
62
|
+
try:
|
|
63
|
+
regex = re.compile(pattern, flags)
|
|
64
|
+
except re.error as e:
|
|
65
|
+
return self._error(f"Invalid regex: {e}")
|
|
66
|
+
|
|
67
|
+
matches: list[str] = []
|
|
68
|
+
try:
|
|
69
|
+
for path in root.glob(include):
|
|
70
|
+
if not path.is_file():
|
|
71
|
+
continue
|
|
72
|
+
try:
|
|
73
|
+
# Skip obviously binary files
|
|
74
|
+
text = path.read_text(errors="ignore")
|
|
75
|
+
except Exception:
|
|
76
|
+
continue
|
|
77
|
+
for i, line in enumerate(text.splitlines(), 1):
|
|
78
|
+
if regex.search(line):
|
|
79
|
+
matches.append(f"{path}:{i}: {line.rstrip()}")
|
|
80
|
+
if len(matches) >= limit:
|
|
81
|
+
break
|
|
82
|
+
if len(matches) >= limit:
|
|
83
|
+
break
|
|
84
|
+
except Exception as e:
|
|
85
|
+
return self._error(str(e))
|
|
86
|
+
|
|
87
|
+
if not matches:
|
|
88
|
+
return self._success(f"No matches for /{pattern}/ in {root}/{include}")
|
|
89
|
+
return self._success("\n".join(matches))
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
"""HttpRequest — generic REST tool for any HTTP API.
|
|
2
|
+
|
|
3
|
+
Inspired by the Vercel AI SDK's universal-tool pattern: instead of
|
|
4
|
+
writing a custom tool per service, give the LLM one well-shaped HTTP
|
|
5
|
+
tool and let it figure out the right URL/headers/body for whatever
|
|
6
|
+
API the user names.
|
|
7
|
+
|
|
8
|
+
Safety:
|
|
9
|
+
- Defaults timeout to 30s
|
|
10
|
+
- Caps response size (so a 1GB response can't OOM the agent)
|
|
11
|
+
- No follow-redirects to private network ranges by default (basic
|
|
12
|
+
SSRF guard — overridable for advanced users)
|
|
13
|
+
- Body size limited
|
|
14
|
+
- Only HTTP/HTTPS schemes accepted
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import ipaddress
|
|
20
|
+
import json
|
|
21
|
+
import logging
|
|
22
|
+
import re
|
|
23
|
+
import socket
|
|
24
|
+
from typing import Any
|
|
25
|
+
from urllib.parse import urlparse
|
|
26
|
+
|
|
27
|
+
import httpx
|
|
28
|
+
|
|
29
|
+
from core.schemas import ToolResult
|
|
30
|
+
from execution.tools.base import BaseTool
|
|
31
|
+
|
|
32
|
+
logger = logging.getLogger(__name__)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
_ALLOWED_METHODS = frozenset(("GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"))
|
|
36
|
+
_MAX_RESPONSE_BYTES = 5 * 1024 * 1024 # 5 MB
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _is_private_host(host: str) -> bool:
|
|
40
|
+
"""Return True if the hostname resolves into a private network
|
|
41
|
+
range (RFC1918, link-local, loopback, IPv6 ULA, etc). Used as a
|
|
42
|
+
basic SSRF guard so the LLM can't accidentally probe localhost
|
|
43
|
+
services or the user's LAN."""
|
|
44
|
+
try:
|
|
45
|
+
ip = ipaddress.ip_address(host)
|
|
46
|
+
except ValueError:
|
|
47
|
+
# Not a literal IP — try DNS lookup. Multiple A records may
|
|
48
|
+
# come back; conservatively flag as private if ANY of them is.
|
|
49
|
+
try:
|
|
50
|
+
infos = socket.getaddrinfo(host, None)
|
|
51
|
+
except OSError:
|
|
52
|
+
return False
|
|
53
|
+
for info in infos:
|
|
54
|
+
sockaddr = info[4]
|
|
55
|
+
try:
|
|
56
|
+
if ipaddress.ip_address(sockaddr[0]).is_private:
|
|
57
|
+
return True
|
|
58
|
+
if ipaddress.ip_address(sockaddr[0]).is_loopback:
|
|
59
|
+
return True
|
|
60
|
+
if ipaddress.ip_address(sockaddr[0]).is_link_local:
|
|
61
|
+
return True
|
|
62
|
+
except ValueError:
|
|
63
|
+
continue
|
|
64
|
+
return False
|
|
65
|
+
return ip.is_private or ip.is_loopback or ip.is_link_local
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class HttpRequestTool(BaseTool):
|
|
69
|
+
mutates = True
|
|
70
|
+
name = "HttpRequest"
|
|
71
|
+
description = (
|
|
72
|
+
"Make a generic HTTP request to any public API. Use when the "
|
|
73
|
+
"user asks to interact with a service that doesn't have a "
|
|
74
|
+
"dedicated Cognos tool — e.g. 'fetch this Stripe customer', "
|
|
75
|
+
"'POST to my webhook', 'get the GitHub rate limit'. Specify "
|
|
76
|
+
"method, URL, headers (auth tokens go here), and optional "
|
|
77
|
+
"JSON body. Doesn't follow links into private networks by "
|
|
78
|
+
"default; set `allow_private=true` ONLY if the user explicitly "
|
|
79
|
+
"wants to hit localhost/LAN."
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
@property
|
|
83
|
+
def input_schema(self) -> dict[str, Any]:
|
|
84
|
+
return {
|
|
85
|
+
"type": "object",
|
|
86
|
+
"properties": {
|
|
87
|
+
"method": {
|
|
88
|
+
"type": "string",
|
|
89
|
+
"enum": list(_ALLOWED_METHODS),
|
|
90
|
+
"description": "HTTP method.",
|
|
91
|
+
},
|
|
92
|
+
"url": {
|
|
93
|
+
"type": "string",
|
|
94
|
+
"description": "Full URL (must be http:// or https://).",
|
|
95
|
+
},
|
|
96
|
+
"headers": {
|
|
97
|
+
"type": "object",
|
|
98
|
+
"description": "Optional headers as a JSON object. Auth tokens go here.",
|
|
99
|
+
"additionalProperties": {"type": "string"},
|
|
100
|
+
},
|
|
101
|
+
"body_json": {
|
|
102
|
+
"type": "object",
|
|
103
|
+
"description": "Optional JSON request body (sent as application/json).",
|
|
104
|
+
},
|
|
105
|
+
"body_text": {
|
|
106
|
+
"type": "string",
|
|
107
|
+
"description": "Optional raw text request body. Use body_json or body_text, not both.",
|
|
108
|
+
},
|
|
109
|
+
"timeout_s": {
|
|
110
|
+
"type": "number",
|
|
111
|
+
"description": "Timeout in seconds (default 30, max 120).",
|
|
112
|
+
},
|
|
113
|
+
"allow_private": {
|
|
114
|
+
"type": "boolean",
|
|
115
|
+
"description": "Set true only when explicitly hitting localhost/LAN endpoints.",
|
|
116
|
+
"default": False,
|
|
117
|
+
},
|
|
118
|
+
},
|
|
119
|
+
"required": ["method", "url"],
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async def execute(self, **kwargs: Any) -> ToolResult:
|
|
123
|
+
method = (kwargs.get("method") or "").upper().strip()
|
|
124
|
+
url = (kwargs.get("url") or "").strip()
|
|
125
|
+
headers = kwargs.get("headers") or {}
|
|
126
|
+
body_json = kwargs.get("body_json")
|
|
127
|
+
body_text = kwargs.get("body_text")
|
|
128
|
+
timeout_s = float(kwargs.get("timeout_s") or 30.0)
|
|
129
|
+
allow_private = bool(kwargs.get("allow_private") or False)
|
|
130
|
+
|
|
131
|
+
# Validate
|
|
132
|
+
if method not in _ALLOWED_METHODS:
|
|
133
|
+
return ToolResult(
|
|
134
|
+
tool_name=self.name, status="error",
|
|
135
|
+
error=f"unsupported method {method!r}",
|
|
136
|
+
)
|
|
137
|
+
timeout_s = max(1.0, min(120.0, timeout_s))
|
|
138
|
+
parsed = urlparse(url)
|
|
139
|
+
if parsed.scheme not in ("http", "https"):
|
|
140
|
+
return ToolResult(
|
|
141
|
+
tool_name=self.name, status="error",
|
|
142
|
+
error=f"only http/https schemes allowed, got {parsed.scheme!r}",
|
|
143
|
+
)
|
|
144
|
+
if not parsed.hostname:
|
|
145
|
+
return ToolResult(
|
|
146
|
+
tool_name=self.name, status="error",
|
|
147
|
+
error="missing hostname in URL",
|
|
148
|
+
)
|
|
149
|
+
if not allow_private and _is_private_host(parsed.hostname):
|
|
150
|
+
return ToolResult(
|
|
151
|
+
tool_name=self.name, status="error",
|
|
152
|
+
error=(f"refusing to call private host {parsed.hostname!r}; "
|
|
153
|
+
f"set allow_private=true if you really mean it"),
|
|
154
|
+
)
|
|
155
|
+
if body_json is not None and body_text is not None:
|
|
156
|
+
return ToolResult(
|
|
157
|
+
tool_name=self.name, status="error",
|
|
158
|
+
error="provide body_json OR body_text, not both",
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
# Send
|
|
162
|
+
request_kwargs: dict[str, Any] = {
|
|
163
|
+
"method": method,
|
|
164
|
+
"url": url,
|
|
165
|
+
"headers": dict(headers) if headers else None,
|
|
166
|
+
"timeout": timeout_s,
|
|
167
|
+
}
|
|
168
|
+
if body_json is not None:
|
|
169
|
+
request_kwargs["json"] = body_json
|
|
170
|
+
elif body_text is not None:
|
|
171
|
+
request_kwargs["content"] = body_text
|
|
172
|
+
|
|
173
|
+
try:
|
|
174
|
+
async with httpx.AsyncClient(follow_redirects=True) as client:
|
|
175
|
+
resp = await client.request(**request_kwargs)
|
|
176
|
+
except httpx.TimeoutException:
|
|
177
|
+
return ToolResult(
|
|
178
|
+
tool_name=self.name, status="error",
|
|
179
|
+
error=f"timed out after {timeout_s}s",
|
|
180
|
+
)
|
|
181
|
+
except httpx.RequestError as e:
|
|
182
|
+
return ToolResult(
|
|
183
|
+
tool_name=self.name, status="error",
|
|
184
|
+
error=f"network error: {e}",
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
# Read body, capped
|
|
188
|
+
body_bytes = resp.content[:_MAX_RESPONSE_BYTES]
|
|
189
|
+
truncated = len(resp.content) > _MAX_RESPONSE_BYTES
|
|
190
|
+
ctype = resp.headers.get("content-type", "")
|
|
191
|
+
|
|
192
|
+
# Decode best-effort
|
|
193
|
+
body_repr: Any
|
|
194
|
+
if "json" in ctype:
|
|
195
|
+
try:
|
|
196
|
+
body_repr = json.loads(body_bytes.decode("utf-8", errors="replace"))
|
|
197
|
+
body_str = json.dumps(body_repr, indent=2)[:4000]
|
|
198
|
+
except Exception:
|
|
199
|
+
body_str = body_bytes.decode("utf-8", errors="replace")[:4000]
|
|
200
|
+
body_repr = body_str
|
|
201
|
+
elif ctype.startswith("text/") or "html" in ctype or "xml" in ctype:
|
|
202
|
+
body_str = body_bytes.decode("utf-8", errors="replace")[:4000]
|
|
203
|
+
body_repr = body_str
|
|
204
|
+
else:
|
|
205
|
+
body_str = f"<binary {ctype} {len(body_bytes)}B>"
|
|
206
|
+
body_repr = None
|
|
207
|
+
|
|
208
|
+
out = (
|
|
209
|
+
f"{method} {url}\n"
|
|
210
|
+
f" status: {resp.status_code} {resp.reason_phrase}\n"
|
|
211
|
+
f" type: {ctype or '?'}\n"
|
|
212
|
+
f" bytes: {len(resp.content)}{' (truncated)' if truncated else ''}\n"
|
|
213
|
+
f"\n--- body ---\n{body_str}"
|
|
214
|
+
)
|
|
215
|
+
return ToolResult(
|
|
216
|
+
tool_name=self.name, status="success",
|
|
217
|
+
output=out,
|
|
218
|
+
metadata={
|
|
219
|
+
"status_code": resp.status_code,
|
|
220
|
+
"headers": dict(resp.headers),
|
|
221
|
+
"body": body_repr,
|
|
222
|
+
"url": str(resp.url),
|
|
223
|
+
},
|
|
224
|
+
)
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""LoadSkill — fetch the full body of an installed skill.
|
|
2
|
+
|
|
3
|
+
Skills are listed in the system prompt as a TOC (just name +
|
|
4
|
+
description) to keep prompt size bounded. When the LLM decides a
|
|
5
|
+
skill is relevant for the current turn, it calls this tool to get
|
|
6
|
+
the full markdown instructions for that skill.
|
|
7
|
+
|
|
8
|
+
Companion to `core/skills.py`. Reload via `RescanSkills` after the
|
|
9
|
+
user runs `npx skills add ...`.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import logging
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from core.schemas import ToolResult
|
|
18
|
+
from core.skills import get_skills, reload_skills
|
|
19
|
+
from execution.tools.base import BaseTool
|
|
20
|
+
|
|
21
|
+
logger = logging.getLogger(__name__)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class LoadSkillTool(BaseTool):
|
|
25
|
+
name = "LoadSkill"
|
|
26
|
+
description = (
|
|
27
|
+
"Fetch the full instructions for an installed skill. The system "
|
|
28
|
+
"prompt lists skills as a table of contents (name + one-line "
|
|
29
|
+
"description); call this tool to read the actual markdown body "
|
|
30
|
+
"of a skill that looks relevant to the current turn. Use the "
|
|
31
|
+
"exact `name` from the TOC."
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
@property
|
|
35
|
+
def input_schema(self) -> dict[str, Any]:
|
|
36
|
+
return {
|
|
37
|
+
"type": "object",
|
|
38
|
+
"properties": {
|
|
39
|
+
"name": {
|
|
40
|
+
"type": "string",
|
|
41
|
+
"description": "Skill name (exact, from the TOC).",
|
|
42
|
+
},
|
|
43
|
+
},
|
|
44
|
+
"required": ["name"],
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async def execute(self, **kwargs: Any) -> ToolResult:
|
|
48
|
+
name = (kwargs.get("name") or "").strip()
|
|
49
|
+
if not name:
|
|
50
|
+
return ToolResult(
|
|
51
|
+
tool_name=self.name, status="error",
|
|
52
|
+
error="`name` is required",
|
|
53
|
+
)
|
|
54
|
+
registry = get_skills()
|
|
55
|
+
skill = registry.get(name)
|
|
56
|
+
if skill is None:
|
|
57
|
+
close = [n for n in registry.list_names()
|
|
58
|
+
if name.lower() in n.lower() or n.lower() in name.lower()]
|
|
59
|
+
hint = (f" did you mean: {close[:5]}?" if close else
|
|
60
|
+
f" available: {registry.list_names()[:10]}")
|
|
61
|
+
return ToolResult(
|
|
62
|
+
tool_name=self.name, status="error",
|
|
63
|
+
error=f"skill {name!r} not found.{hint}",
|
|
64
|
+
)
|
|
65
|
+
return ToolResult(
|
|
66
|
+
tool_name=self.name, status="success",
|
|
67
|
+
output=(
|
|
68
|
+
f"# {skill.name}\n"
|
|
69
|
+
f"{skill.description}\n"
|
|
70
|
+
f"\n---\n\n{skill.body}"
|
|
71
|
+
),
|
|
72
|
+
metadata={
|
|
73
|
+
"name": skill.name,
|
|
74
|
+
"description": skill.description,
|
|
75
|
+
"path": str(skill.path),
|
|
76
|
+
"source_dir": str(skill.source_dir),
|
|
77
|
+
},
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class RescanSkillsTool(BaseTool):
|
|
82
|
+
name = "RescanSkills"
|
|
83
|
+
description = (
|
|
84
|
+
"Re-scan the skill directories after running `npx skills add` "
|
|
85
|
+
"or manually adding/removing a skill. Returns the new count + "
|
|
86
|
+
"list of names."
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
@property
|
|
90
|
+
def input_schema(self) -> dict[str, Any]:
|
|
91
|
+
return {"type": "object", "properties": {}, "required": []}
|
|
92
|
+
|
|
93
|
+
async def execute(self, **_: Any) -> ToolResult:
|
|
94
|
+
registry = reload_skills()
|
|
95
|
+
names = registry.list_names()
|
|
96
|
+
return ToolResult(
|
|
97
|
+
tool_name=self.name, status="success",
|
|
98
|
+
output=(
|
|
99
|
+
f"Rescanned. Found {len(names)} skill(s):\n"
|
|
100
|
+
+ "\n".join(f" - {n}" for n in names)
|
|
101
|
+
if names else "Rescanned. No skills found."
|
|
102
|
+
),
|
|
103
|
+
metadata={"count": len(names), "names": names},
|
|
104
|
+
)
|