tether-sdk 0.0.2__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.
- tether/__init__.py +35 -0
- tether/cli.py +235 -0
- tether/context.py +170 -0
- tether/decorator.py +280 -0
- tether/harness.py +435 -0
- tether/multiplexer.py +124 -0
- tether/providers/__init__.py +66 -0
- tether/providers/anthropic.py +135 -0
- tether/providers/base.py +136 -0
- tether/providers/ollama.py +112 -0
- tether/providers/openai.py +89 -0
- tether/sandbox.py +342 -0
- tether/skills.py +157 -0
- tether/store.py +660 -0
- tether/tools/__init__.py +22 -0
- tether/tools/agent.py +190 -0
- tether/tools/bash.py +29 -0
- tether/tools/done.py +33 -0
- tether/tools/edit.py +107 -0
- tether/tools/mem.py +93 -0
- tether/tools/web.py +222 -0
- tether_sdk-0.0.2.dist-info/METADATA +346 -0
- tether_sdk-0.0.2.dist-info/RECORD +26 -0
- tether_sdk-0.0.2.dist-info/WHEEL +4 -0
- tether_sdk-0.0.2.dist-info/entry_points.txt +2 -0
- tether_sdk-0.0.2.dist-info/licenses/LICENSE +20 -0
tether/sandbox.py
ADDED
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
"""Deterministic security sandbox for Tether (workspace jail, env scrubbing, Linux bwrap)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
import shlex
|
|
9
|
+
import shutil
|
|
10
|
+
import signal
|
|
11
|
+
import subprocess
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class SandboxViolationError(PermissionError):
|
|
18
|
+
"""Raised when a tool call violates the deterministic SandboxPolicy."""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
_SENSITIVE_FILENAMES = {
|
|
22
|
+
".env",
|
|
23
|
+
".git/config",
|
|
24
|
+
".git/hooks",
|
|
25
|
+
".tether",
|
|
26
|
+
"id_rsa",
|
|
27
|
+
"id_ed25519",
|
|
28
|
+
"authorized_keys",
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
_SECRET_ENV_RE = re.compile(
|
|
32
|
+
r"(API_KEY|SECRET|TOKEN|PASSWORD|CREDENTIAL|PRIVATE_KEY|^AWS_|^OPENAI_|^ANTHROPIC_|^GH_)",
|
|
33
|
+
re.IGNORECASE,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
_BLOCKED_BASH_PATTERNS: list[tuple[re.Pattern[str], str]] = [
|
|
37
|
+
(re.compile(r"(?:^|[;&|()\s])sudo\b"), "`sudo` privilege escalation is blocked"),
|
|
38
|
+
(re.compile(r"(?:^|[;&|()\s])su\b"), "`su` user switching is blocked"),
|
|
39
|
+
(
|
|
40
|
+
re.compile(r"(?:^|[;&|()\s])(?:mkfs|fdisk|parted|mount|umount)\b"),
|
|
41
|
+
"disk utilities are blocked",
|
|
42
|
+
),
|
|
43
|
+
(re.compile(r"(?:^|[;&|()\s])dd\s+if="), "`dd` raw block operations are blocked"),
|
|
44
|
+
(re.compile(r"rm\s+-[a-zA-Z]*r[a-zA-Z]*f?[a-zA-Z]*\s+/(?:\s|$|\*)"), "`rm -rf /` is blocked"),
|
|
45
|
+
(re.compile(r"chmod\s+(?:-R\s+)?777\b"), "`chmod 777` is blocked"),
|
|
46
|
+
(re.compile(r":\(\)\s*\{\s*:\s*\|\s*:\s*&\s*\}\s*;"), "fork bombs are blocked"),
|
|
47
|
+
(
|
|
48
|
+
re.compile(r"(?:^|[\s/\"'])(\.env(?:\.[a-zA-Z0-9_-]+)?)\b"),
|
|
49
|
+
"access to `.env` files is blocked",
|
|
50
|
+
),
|
|
51
|
+
(
|
|
52
|
+
re.compile(r"(?:^|[\s/\"'])\.tether(?:/|\b)"),
|
|
53
|
+
"access to `.tether` internal store is blocked",
|
|
54
|
+
),
|
|
55
|
+
(
|
|
56
|
+
re.compile(r"(?:^|[\s/\"'])\.git/(?:config|hooks)\b"),
|
|
57
|
+
"access to `.git/config` or `hooks` is blocked",
|
|
58
|
+
),
|
|
59
|
+
(
|
|
60
|
+
re.compile(r"(?:~|\$HOME)/\.ssh\b|/etc/(?:shadow|sudoers)\b"),
|
|
61
|
+
"access to system secrets is blocked",
|
|
62
|
+
),
|
|
63
|
+
]
|
|
64
|
+
|
|
65
|
+
_READONLY_ALLOWED_BINARIES = {
|
|
66
|
+
"ls",
|
|
67
|
+
"cat",
|
|
68
|
+
"head",
|
|
69
|
+
"tail",
|
|
70
|
+
"grep",
|
|
71
|
+
"rg",
|
|
72
|
+
"find",
|
|
73
|
+
"wc",
|
|
74
|
+
"pwd",
|
|
75
|
+
"echo",
|
|
76
|
+
"stat",
|
|
77
|
+
"file",
|
|
78
|
+
"which",
|
|
79
|
+
"uname",
|
|
80
|
+
"date",
|
|
81
|
+
"sort",
|
|
82
|
+
"uniq",
|
|
83
|
+
"cut",
|
|
84
|
+
"tr",
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
_BWRAP_USABLE: bool | None = None
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _can_use_bwrap() -> bool:
|
|
91
|
+
"""Probe once whether `bwrap` is installed and permitted by host kernel namespaces."""
|
|
92
|
+
global _BWRAP_USABLE
|
|
93
|
+
if _BWRAP_USABLE is not None:
|
|
94
|
+
return _BWRAP_USABLE
|
|
95
|
+
|
|
96
|
+
bwrap_bin = shutil.which("bwrap")
|
|
97
|
+
if not bwrap_bin:
|
|
98
|
+
_BWRAP_USABLE = False
|
|
99
|
+
return False
|
|
100
|
+
|
|
101
|
+
try:
|
|
102
|
+
proc = subprocess.run(
|
|
103
|
+
[bwrap_bin, "--ro-bind", "/", "/", "--unshare-all", "--", "true"],
|
|
104
|
+
stdout=subprocess.DEVNULL,
|
|
105
|
+
stderr=subprocess.DEVNULL,
|
|
106
|
+
timeout=2.0,
|
|
107
|
+
check=False,
|
|
108
|
+
)
|
|
109
|
+
_BWRAP_USABLE = proc.returncode == 0
|
|
110
|
+
except Exception:
|
|
111
|
+
_BWRAP_USABLE = False
|
|
112
|
+
return _BWRAP_USABLE
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
@dataclass(slots=True)
|
|
116
|
+
class SandboxPolicy:
|
|
117
|
+
"""Deterministic security envelope enforced by the harness (never by the LLM)."""
|
|
118
|
+
|
|
119
|
+
profile: str = "workspace-write" # "workspace-write" | "read-only"
|
|
120
|
+
workspace_root: Path = field(default_factory=lambda: Path.cwd().resolve())
|
|
121
|
+
timeout_seconds: float = 30.0
|
|
122
|
+
max_output_chars: int = 12000
|
|
123
|
+
use_kernel_sandbox: bool = True
|
|
124
|
+
|
|
125
|
+
def __post_init__(self) -> None:
|
|
126
|
+
self.workspace_root = Path(self.workspace_root).resolve()
|
|
127
|
+
if self.profile not in ("workspace-write", "read-only"):
|
|
128
|
+
raise ValueError(f"Unsupported sandbox profile: {self.profile}")
|
|
129
|
+
|
|
130
|
+
def to_dict(self) -> dict[str, Any]:
|
|
131
|
+
return {
|
|
132
|
+
"profile": self.profile,
|
|
133
|
+
"workspace_root": str(self.workspace_root),
|
|
134
|
+
"timeout_seconds": self.timeout_seconds,
|
|
135
|
+
"max_output_chars": self.max_output_chars,
|
|
136
|
+
"use_kernel_sandbox": self.use_kernel_sandbox,
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
@classmethod
|
|
140
|
+
def from_dict(cls, data: dict[str, Any] | None) -> SandboxPolicy:
|
|
141
|
+
if not data:
|
|
142
|
+
return cls()
|
|
143
|
+
return cls(
|
|
144
|
+
profile=str(data.get("profile", "workspace-write")),
|
|
145
|
+
workspace_root=Path(str(data.get("workspace_root", Path.cwd()))).resolve(),
|
|
146
|
+
timeout_seconds=float(data.get("timeout_seconds", 30.0)),
|
|
147
|
+
max_output_chars=int(data.get("max_output_chars", 12000)),
|
|
148
|
+
use_kernel_sandbox=bool(data.get("use_kernel_sandbox", True)),
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
def attenuate(self, child_profile: str | None = None) -> SandboxPolicy:
|
|
152
|
+
"""Create a child SandboxPolicy that can only narrow (never escalate) privileges."""
|
|
153
|
+
if self.profile == "read-only":
|
|
154
|
+
effective_profile = "read-only"
|
|
155
|
+
else:
|
|
156
|
+
effective_profile = child_profile if child_profile == "read-only" else self.profile
|
|
157
|
+
|
|
158
|
+
return SandboxPolicy(
|
|
159
|
+
profile=effective_profile,
|
|
160
|
+
workspace_root=self.workspace_root,
|
|
161
|
+
timeout_seconds=self.timeout_seconds,
|
|
162
|
+
max_output_chars=self.max_output_chars,
|
|
163
|
+
use_kernel_sandbox=self.use_kernel_sandbox,
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
def resolve_safe_path(self, raw_path: str | Path, *, write: bool = False) -> Path:
|
|
167
|
+
"""Resolve a path and enforce workspace confinement + sensitive-file protection."""
|
|
168
|
+
if write and self.profile == "read-only":
|
|
169
|
+
raise SandboxViolationError(
|
|
170
|
+
"Sandbox violation: file writes are disabled in `read-only` profile."
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
p = Path(raw_path)
|
|
174
|
+
candidate = (self.workspace_root / p).resolve() if not p.is_absolute() else p.resolve()
|
|
175
|
+
|
|
176
|
+
if not candidate.is_relative_to(self.workspace_root):
|
|
177
|
+
raise SandboxViolationError(
|
|
178
|
+
f"Sandbox violation: path `{raw_path}` resolves outside "
|
|
179
|
+
f"workspace `{self.workspace_root}`."
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
rel = candidate.relative_to(self.workspace_root)
|
|
183
|
+
rel_str = rel.as_posix()
|
|
184
|
+
parts = rel.parts
|
|
185
|
+
|
|
186
|
+
# Check sensitive patterns
|
|
187
|
+
if (
|
|
188
|
+
rel_str in _SENSITIVE_FILENAMES
|
|
189
|
+
or any(part.startswith(".env") for part in parts)
|
|
190
|
+
or ".tether" in parts
|
|
191
|
+
or (len(parts) >= 2 and parts[0] == ".git" and parts[1] in ("config", "hooks"))
|
|
192
|
+
or candidate.name in ("id_rsa", "id_ed25519", "authorized_keys")
|
|
193
|
+
or candidate.suffix in (".pem", ".key")
|
|
194
|
+
):
|
|
195
|
+
raise SandboxViolationError(
|
|
196
|
+
f"Sandbox violation: access to protected path `{rel_str}` is denied."
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
return candidate
|
|
200
|
+
|
|
201
|
+
def scrub_env(self, base_env: dict[str, str] | None = None) -> dict[str, str]:
|
|
202
|
+
"""Return a sanitized environment dictionary with secrets stripped."""
|
|
203
|
+
source = base_env if base_env is not None else dict(os.environ)
|
|
204
|
+
clean: dict[str, str] = {}
|
|
205
|
+
for k, v in source.items():
|
|
206
|
+
if _SECRET_ENV_RE.search(k):
|
|
207
|
+
continue
|
|
208
|
+
clean[k] = v
|
|
209
|
+
clean["HOME"] = str(self.workspace_root)
|
|
210
|
+
clean["PWD"] = str(self.workspace_root)
|
|
211
|
+
clean["PAGER"] = "cat"
|
|
212
|
+
return clean
|
|
213
|
+
|
|
214
|
+
def validate_bash_command(self, cmd: str) -> None:
|
|
215
|
+
"""Deterministically validate a shell command against sandbox rules."""
|
|
216
|
+
stripped = cmd.strip()
|
|
217
|
+
if not stripped:
|
|
218
|
+
raise SandboxViolationError("Empty command.")
|
|
219
|
+
|
|
220
|
+
for pattern, reason in _BLOCKED_BASH_PATTERNS:
|
|
221
|
+
if pattern.search(stripped):
|
|
222
|
+
raise SandboxViolationError(f"Sandbox violation: {reason}.")
|
|
223
|
+
|
|
224
|
+
# Block explicit parent directory traversal (`..`) or absolute paths outside workspace
|
|
225
|
+
try:
|
|
226
|
+
tokens = shlex.split(stripped, posix=True)
|
|
227
|
+
except ValueError:
|
|
228
|
+
tokens = stripped.split()
|
|
229
|
+
|
|
230
|
+
for tok in tokens:
|
|
231
|
+
if tok == ".." or tok.startswith("../") or "/../" in tok or tok.endswith("/.."):
|
|
232
|
+
raise SandboxViolationError(
|
|
233
|
+
"Sandbox violation: parent directory traversal (`..`) is blocked."
|
|
234
|
+
)
|
|
235
|
+
if tok.startswith("/") and not tok.startswith(("/dev/null", "/tmp")):
|
|
236
|
+
resolved = Path(tok).resolve()
|
|
237
|
+
if not resolved.is_relative_to(self.workspace_root):
|
|
238
|
+
raise SandboxViolationError(
|
|
239
|
+
f"Sandbox violation: absolute path `{tok}` is outside workspace."
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
if self.profile == "read-only":
|
|
243
|
+
if any(
|
|
244
|
+
op in stripped
|
|
245
|
+
for op in (">", ">>", "| tee", "rm ", "mv ", "cp ", "mkdir ", "touch ")
|
|
246
|
+
):
|
|
247
|
+
raise SandboxViolationError(
|
|
248
|
+
"Sandbox violation: mutating shell commands are blocked in `read-only` profile."
|
|
249
|
+
)
|
|
250
|
+
first_bin = Path(tokens[0]).name if tokens else ""
|
|
251
|
+
if first_bin not in _READONLY_ALLOWED_BINARIES:
|
|
252
|
+
raise SandboxViolationError(
|
|
253
|
+
f"Sandbox violation: binary `{first_bin}` is not in the `read-only` allowlist."
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
def _build_exec_argv(self, cmd: str) -> list[str]:
|
|
257
|
+
"""Wrap command in Linux `bwrap` kernel sandbox if available, else standard bash."""
|
|
258
|
+
if self.use_kernel_sandbox and _can_use_bwrap():
|
|
259
|
+
ws = str(self.workspace_root)
|
|
260
|
+
bwrap_cmd = [
|
|
261
|
+
"bwrap",
|
|
262
|
+
"--ro-bind",
|
|
263
|
+
"/",
|
|
264
|
+
"/",
|
|
265
|
+
"--dev",
|
|
266
|
+
"/dev",
|
|
267
|
+
"--proc",
|
|
268
|
+
"/proc",
|
|
269
|
+
"--tmpfs",
|
|
270
|
+
"/tmp",
|
|
271
|
+
"--tmpfs",
|
|
272
|
+
os.path.expanduser("~"),
|
|
273
|
+
"--bind" if self.profile == "workspace-write" else "--ro-bind",
|
|
274
|
+
ws,
|
|
275
|
+
ws,
|
|
276
|
+
"--chdir",
|
|
277
|
+
ws,
|
|
278
|
+
"--die-with-parent",
|
|
279
|
+
"--new-session",
|
|
280
|
+
"--",
|
|
281
|
+
"bash",
|
|
282
|
+
"-c",
|
|
283
|
+
cmd,
|
|
284
|
+
]
|
|
285
|
+
return bwrap_cmd
|
|
286
|
+
return ["bash", "-c", cmd]
|
|
287
|
+
|
|
288
|
+
async def execute_bash(self, cmd: str, stdin: str | None = None) -> str:
|
|
289
|
+
"""Execute a shell command inside the deterministic sandbox."""
|
|
290
|
+
effective_cmd = cmd.strip()
|
|
291
|
+
if not effective_cmd and stdin:
|
|
292
|
+
effective_cmd = stdin.strip()
|
|
293
|
+
stdin = None
|
|
294
|
+
|
|
295
|
+
self.validate_bash_command(effective_cmd)
|
|
296
|
+
argv = self._build_exec_argv(effective_cmd)
|
|
297
|
+
env = self.scrub_env()
|
|
298
|
+
|
|
299
|
+
proc = await asyncio.create_subprocess_exec(
|
|
300
|
+
*argv,
|
|
301
|
+
stdin=asyncio.subprocess.PIPE if stdin is not None else asyncio.subprocess.DEVNULL,
|
|
302
|
+
stdout=asyncio.subprocess.PIPE,
|
|
303
|
+
stderr=asyncio.subprocess.PIPE,
|
|
304
|
+
cwd=str(self.workspace_root),
|
|
305
|
+
env=env,
|
|
306
|
+
start_new_session=True,
|
|
307
|
+
)
|
|
308
|
+
|
|
309
|
+
stdin_bytes = stdin.encode("utf-8") if stdin is not None else None
|
|
310
|
+
try:
|
|
311
|
+
stdout_b, stderr_b = await asyncio.wait_for(
|
|
312
|
+
proc.communicate(input=stdin_bytes),
|
|
313
|
+
timeout=self.timeout_seconds,
|
|
314
|
+
)
|
|
315
|
+
except TimeoutError:
|
|
316
|
+
try:
|
|
317
|
+
os.killpg(proc.pid, signal.SIGKILL)
|
|
318
|
+
except ProcessLookupError:
|
|
319
|
+
pass
|
|
320
|
+
await proc.wait()
|
|
321
|
+
return f"Error: command timed out after {self.timeout_seconds:.1f}s (SIGKILL)."
|
|
322
|
+
|
|
323
|
+
stdout_str = stdout_b.decode("utf-8", errors="replace")
|
|
324
|
+
stderr_str = stderr_b.decode("utf-8", errors="replace")
|
|
325
|
+
combined = stdout_str
|
|
326
|
+
if stderr_str:
|
|
327
|
+
combined = f"{stdout_str}\n[stderr]\n{stderr_str}".strip()
|
|
328
|
+
|
|
329
|
+
if not combined:
|
|
330
|
+
combined = f"[exit {proc.returncode}]"
|
|
331
|
+
elif proc.returncode != 0:
|
|
332
|
+
combined = f"{combined}\n[exit {proc.returncode}]"
|
|
333
|
+
|
|
334
|
+
if len(combined) > self.max_output_chars:
|
|
335
|
+
half = self.max_output_chars // 2
|
|
336
|
+
omitted = len(combined) - self.max_output_chars
|
|
337
|
+
combined = (
|
|
338
|
+
f"{combined[:half]}\n"
|
|
339
|
+
f"... [truncated {omitted:,} chars by sandbox cap] ...\n"
|
|
340
|
+
f"{combined[-half:]}"
|
|
341
|
+
)
|
|
342
|
+
return combined
|
tether/skills.py
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"""Progressive Skill discovery, YAML frontmatter parser, and companion tools.py loader."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import importlib.util
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
import yaml
|
|
11
|
+
|
|
12
|
+
from tether.decorator import ToolSpec, build_tool_spec
|
|
13
|
+
from tether.multiplexer import Multiplexer
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(slots=True)
|
|
17
|
+
class Skill:
|
|
18
|
+
name: str
|
|
19
|
+
description: str
|
|
20
|
+
instructions: str
|
|
21
|
+
model: str | None = None
|
|
22
|
+
tools: list[str] = field(default_factory=list)
|
|
23
|
+
path: Path | None = None
|
|
24
|
+
custom_tools: list[ToolSpec] = field(default_factory=list)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def parse_skill_markdown(
|
|
28
|
+
raw_text: str, default_name: str, source_path: Path | None = None
|
|
29
|
+
) -> Skill:
|
|
30
|
+
"""Parse a Markdown file with optional YAML frontmatter into a Skill object."""
|
|
31
|
+
text = raw_text.strip()
|
|
32
|
+
meta: dict[str, Any] = {}
|
|
33
|
+
body = text
|
|
34
|
+
|
|
35
|
+
if text.startswith("---"):
|
|
36
|
+
parts = text.split("---", 2)
|
|
37
|
+
if len(parts) >= 3:
|
|
38
|
+
yaml_block = parts[1].strip()
|
|
39
|
+
body = parts[2].strip()
|
|
40
|
+
loaded = yaml.safe_load(yaml_block)
|
|
41
|
+
if isinstance(loaded, dict):
|
|
42
|
+
meta = loaded
|
|
43
|
+
|
|
44
|
+
name = str(meta.get("name") or default_name).strip()
|
|
45
|
+
desc = str(meta.get("description") or f"Skill {name}").strip()
|
|
46
|
+
model = str(meta["model"]).strip() if meta.get("model") else None
|
|
47
|
+
raw_tools = meta.get("tools") or []
|
|
48
|
+
tools_list = (
|
|
49
|
+
[str(t).strip() for t in raw_tools]
|
|
50
|
+
if isinstance(raw_tools, list)
|
|
51
|
+
else [t.strip() for t in str(raw_tools).split(",") if t.strip()]
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
custom_tools: list[ToolSpec] = []
|
|
55
|
+
if source_path and source_path.name == "SKILL.md":
|
|
56
|
+
tools_py = source_path.parent / "tools.py"
|
|
57
|
+
if tools_py.is_file():
|
|
58
|
+
custom_tools = _load_tools_module(tools_py)
|
|
59
|
+
|
|
60
|
+
return Skill(
|
|
61
|
+
name=name,
|
|
62
|
+
description=desc,
|
|
63
|
+
instructions=body,
|
|
64
|
+
model=model,
|
|
65
|
+
tools=tools_list,
|
|
66
|
+
path=source_path,
|
|
67
|
+
custom_tools=custom_tools,
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _load_tools_module(tools_py: Path) -> list[ToolSpec]:
|
|
72
|
+
"""Dynamically import a skill's sibling `tools.py` and extract `@tool` functions."""
|
|
73
|
+
spec = importlib.util.spec_from_file_location(f"tether_skill_{tools_py.parent.name}", tools_py)
|
|
74
|
+
if spec is None or spec.loader is None:
|
|
75
|
+
return []
|
|
76
|
+
module = importlib.util.module_from_spec(spec)
|
|
77
|
+
spec.loader.exec_module(module)
|
|
78
|
+
found: list[ToolSpec] = []
|
|
79
|
+
for attr_name in dir(module):
|
|
80
|
+
obj = getattr(module, attr_name)
|
|
81
|
+
if hasattr(obj, "__tether_tool__"):
|
|
82
|
+
t_spec: ToolSpec = obj.__tether_tool__
|
|
83
|
+
found.append(t_spec)
|
|
84
|
+
return found
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class SkillLoader:
|
|
88
|
+
"""Discovers and loads skills from `./skills/` and `~/.tether/skills/`."""
|
|
89
|
+
|
|
90
|
+
def __init__(self, search_dirs: list[str | Path] | None = None) -> None:
|
|
91
|
+
if search_dirs is not None:
|
|
92
|
+
self.search_dirs = [Path(d).resolve() for d in search_dirs]
|
|
93
|
+
else:
|
|
94
|
+
self.search_dirs = [
|
|
95
|
+
(Path.cwd() / "skills").resolve(),
|
|
96
|
+
(Path.home() / ".tether" / "skills").resolve(),
|
|
97
|
+
]
|
|
98
|
+
|
|
99
|
+
def discover(self) -> dict[str, Skill]:
|
|
100
|
+
skills: dict[str, Skill] = {}
|
|
101
|
+
for base in self.search_dirs:
|
|
102
|
+
if not base.is_dir():
|
|
103
|
+
continue
|
|
104
|
+
# 1. Single-file skills: skills/<name>.md
|
|
105
|
+
for md_file in sorted(base.glob("*.md")):
|
|
106
|
+
default_name = md_file.stem
|
|
107
|
+
sk = parse_skill_markdown(
|
|
108
|
+
md_file.read_text(encoding="utf-8"), default_name, md_file
|
|
109
|
+
)
|
|
110
|
+
skills.setdefault(sk.name, sk)
|
|
111
|
+
# 2. Directory skills: skills/<name>/SKILL.md
|
|
112
|
+
for skill_md in sorted(base.glob("*/SKILL.md")):
|
|
113
|
+
default_name = skill_md.parent.name
|
|
114
|
+
sk = parse_skill_markdown(
|
|
115
|
+
skill_md.read_text(encoding="utf-8"), default_name, skill_md
|
|
116
|
+
)
|
|
117
|
+
skills.setdefault(sk.name, sk)
|
|
118
|
+
return skills
|
|
119
|
+
|
|
120
|
+
def load(self, name: str) -> Skill | None:
|
|
121
|
+
return self.discover().get(name)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def make_skill_tool(loader: SkillLoader, mux: Multiplexer) -> ToolSpec:
|
|
125
|
+
"""Create the progressive `skill` tool (`skill list | load <name>`)."""
|
|
126
|
+
help_str = (
|
|
127
|
+
"Usage: skill list | skill load <name>\n"
|
|
128
|
+
"Desc : List available skills or load a skill's instructions and tools."
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
def _dispatch(args: list[str], stdin: str | None = None) -> str:
|
|
132
|
+
if not args or args[0] in ("--help", "-h"):
|
|
133
|
+
return help_str
|
|
134
|
+
sub = args[0]
|
|
135
|
+
if sub == "list":
|
|
136
|
+
discovered = loader.discover()
|
|
137
|
+
if not discovered:
|
|
138
|
+
return "No skills found in ./skills/ or ~/.tether/skills/."
|
|
139
|
+
return "\n".join(f"- {s.name}: {s.description}" for s in discovered.values())
|
|
140
|
+
if sub == "load":
|
|
141
|
+
if len(args) < 2:
|
|
142
|
+
return f"Error: missing `<name>`.\n{help_str}"
|
|
143
|
+
sk = loader.load(args[1])
|
|
144
|
+
if not sk:
|
|
145
|
+
return f"Error: skill `{args[1]}` not found."
|
|
146
|
+
for ct in sk.custom_tools:
|
|
147
|
+
mux.register(ct)
|
|
148
|
+
return f"[Skill `{sk.name}` loaded]\n{sk.instructions}"
|
|
149
|
+
return f"Error: unknown subcommand `{sub}`.\n{help_str}"
|
|
150
|
+
|
|
151
|
+
return build_tool_spec(
|
|
152
|
+
_dispatch,
|
|
153
|
+
name="skill",
|
|
154
|
+
description="Discover and load specialized skills",
|
|
155
|
+
custom_help=help_str,
|
|
156
|
+
raw_dispatch=True,
|
|
157
|
+
)
|