toolframe-core 0.6.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.
- toolframe_core/__init__.py +3 -0
- toolframe_core/_state_postgres.py +420 -0
- toolframe_core/_state_sqlite.py +339 -0
- toolframe_core/access.py +513 -0
- toolframe_core/api.py +700 -0
- toolframe_core/bundled_skills/toolframe-plugin-design/LICENSE +21 -0
- toolframe_core/bundled_skills/toolframe-plugin-design/SKILL.md +59 -0
- toolframe_core/bundled_skills/toolframe-plugin-design/agents/openai.yaml +4 -0
- toolframe_core/bundled_skills/toolframe-plugin-design/assets/sample-plugin/LICENSE +21 -0
- toolframe_core/bundled_skills/toolframe-plugin-design/assets/sample-plugin/pyproject.toml +21 -0
- toolframe_core/bundled_skills/toolframe-plugin-design/assets/sample-plugin/src/toolframe_sample_notes/__init__.py +1 -0
- toolframe_core/bundled_skills/toolframe-plugin-design/assets/sample-plugin/src/toolframe_sample_notes/docs/usage.md +13 -0
- toolframe_core/bundled_skills/toolframe-plugin-design/assets/sample-plugin/src/toolframe_sample_notes/migrations/0001_init.sql +5 -0
- toolframe_core/bundled_skills/toolframe-plugin-design/assets/sample-plugin/src/toolframe_sample_notes/page.py +25 -0
- toolframe_core/bundled_skills/toolframe-plugin-design/assets/sample-plugin/src/toolframe_sample_notes/plugin.py +119 -0
- toolframe_core/bundled_skills/toolframe-plugin-design/assets/sample-plugin/src/toolframe_sample_notes/service.py +48 -0
- toolframe_core/bundled_skills/toolframe-plugin-design/references/design.md +60 -0
- toolframe_core/bundled_skills/toolframe-plugin-design/references/operations.md +133 -0
- toolframe_core/bundled_skills/toolframe-plugin-design/references/plugin-api.md +186 -0
- toolframe_core/cli.py +681 -0
- toolframe_core/config.py +267 -0
- toolframe_core/diagnostics.py +200 -0
- toolframe_core/graph.py +207 -0
- toolframe_core/host.py +2610 -0
- toolframe_core/maintenance.py +868 -0
- toolframe_core/mcp_access.py +259 -0
- toolframe_core/network.py +97 -0
- toolframe_core/oidc_access.py +187 -0
- toolframe_core/packages.py +728 -0
- toolframe_core/process.py +212 -0
- toolframe_core/redaction.py +120 -0
- toolframe_core/scaffold.py +142 -0
- toolframe_core/skills.py +210 -0
- toolframe_core/state.py +226 -0
- toolframe_core/state_admin.py +270 -0
- toolframe_core/stores.py +88 -0
- toolframe_core/templates.json +6 -0
- toolframe_core/templates.py +56 -0
- toolframe_core-0.6.2.dist-info/METADATA +465 -0
- toolframe_core-0.6.2.dist-info/RECORD +46 -0
- toolframe_core-0.6.2.dist-info/WHEEL +4 -0
- toolframe_core-0.6.2.dist-info/entry_points.txt +2 -0
- toolframe_core-0.6.2.dist-info/licenses/LICENSE +202 -0
- toolframe_core-0.6.2.dist-info/licenses/NOTICE +14 -0
- toolframe_core-0.6.2.dist-info/licenses/src/toolframe_core/bundled_skills/toolframe-plugin-design/LICENSE +21 -0
- toolframe_core-0.6.2.dist-info/licenses/src/toolframe_core/bundled_skills/toolframe-plugin-design/assets/sample-plugin/LICENSE +21 -0
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
"""Own a worker process, its protocol channel, stderr tail and actual exit."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import contextlib
|
|
7
|
+
import os
|
|
8
|
+
import signal
|
|
9
|
+
from collections import deque
|
|
10
|
+
|
|
11
|
+
from toolframe_sdk import MAX_FRAME_BYTES, PluginError
|
|
12
|
+
from toolframe_sdk.rpc import RpcPeer
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def child_environment():
|
|
16
|
+
"""Build/preflight environments exclude Core and telemetry credentials."""
|
|
17
|
+
return {
|
|
18
|
+
key: value
|
|
19
|
+
for key, value in os.environ.items()
|
|
20
|
+
if key not in ("PYTHONPATH", "PYTHONHOME")
|
|
21
|
+
and not key.startswith("TOOLFRAME_CORE_")
|
|
22
|
+
and not key.startswith("OTEL_")
|
|
23
|
+
and key not in {"TOOLFRAME_TRACING_ENABLED", "TOOLFRAME_LOGS_LEVEL"}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class WorkerProcess:
|
|
28
|
+
def __init__(
|
|
29
|
+
self,
|
|
30
|
+
process,
|
|
31
|
+
handler,
|
|
32
|
+
*,
|
|
33
|
+
log_callback=None,
|
|
34
|
+
tracing=None,
|
|
35
|
+
trace_attributes=None,
|
|
36
|
+
):
|
|
37
|
+
self.process = process
|
|
38
|
+
self.expected_exit = False
|
|
39
|
+
self.logs = deque(maxlen=200)
|
|
40
|
+
self.log_callback = log_callback
|
|
41
|
+
self.trace_attributes = trace_attributes or {}
|
|
42
|
+
self.peer = RpcPeer(
|
|
43
|
+
process.stdout,
|
|
44
|
+
process.stdin,
|
|
45
|
+
handler,
|
|
46
|
+
tracing=tracing,
|
|
47
|
+
trace_attributes={
|
|
48
|
+
"toolframe.target.plugin.id": self.trace_attributes.get(
|
|
49
|
+
"toolframe.plugin.id", ""
|
|
50
|
+
),
|
|
51
|
+
"toolframe.target.generation": self.trace_attributes.get(
|
|
52
|
+
"toolframe.plugin.generation", ""
|
|
53
|
+
),
|
|
54
|
+
},
|
|
55
|
+
)
|
|
56
|
+
self.reader_task = asyncio.create_task(self.peer.run())
|
|
57
|
+
self.log_task = asyncio.create_task(self._logs())
|
|
58
|
+
|
|
59
|
+
@classmethod
|
|
60
|
+
async def start(
|
|
61
|
+
cls,
|
|
62
|
+
python,
|
|
63
|
+
entrypoint,
|
|
64
|
+
handler,
|
|
65
|
+
*,
|
|
66
|
+
cwd,
|
|
67
|
+
log_callback=None,
|
|
68
|
+
host_token=None,
|
|
69
|
+
tracing=None,
|
|
70
|
+
trace_attributes=None,
|
|
71
|
+
):
|
|
72
|
+
environment = child_environment()
|
|
73
|
+
if tracing:
|
|
74
|
+
environment.update(
|
|
75
|
+
tracing.child_environment(
|
|
76
|
+
(trace_attributes or {}).get("service.name", "toolframe.worker")
|
|
77
|
+
)
|
|
78
|
+
)
|
|
79
|
+
process = await asyncio.create_subprocess_exec(
|
|
80
|
+
str(python),
|
|
81
|
+
"-I",
|
|
82
|
+
"-u",
|
|
83
|
+
"-m",
|
|
84
|
+
"toolframe_sdk.worker",
|
|
85
|
+
"--entrypoint",
|
|
86
|
+
entrypoint,
|
|
87
|
+
*(["--host-token", host_token] if host_token else []),
|
|
88
|
+
cwd=cwd,
|
|
89
|
+
env=environment,
|
|
90
|
+
stdin=asyncio.subprocess.PIPE,
|
|
91
|
+
stdout=asyncio.subprocess.PIPE,
|
|
92
|
+
stderr=asyncio.subprocess.PIPE,
|
|
93
|
+
limit=MAX_FRAME_BYTES,
|
|
94
|
+
start_new_session=True,
|
|
95
|
+
)
|
|
96
|
+
return cls(
|
|
97
|
+
process,
|
|
98
|
+
handler,
|
|
99
|
+
log_callback=log_callback,
|
|
100
|
+
tracing=tracing,
|
|
101
|
+
trace_attributes=trace_attributes,
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
@property
|
|
105
|
+
def pid(self):
|
|
106
|
+
return self.process.pid
|
|
107
|
+
|
|
108
|
+
async def _logs(self):
|
|
109
|
+
remainder = b""
|
|
110
|
+
while data := await self.process.stderr.read(4096):
|
|
111
|
+
remainder += data
|
|
112
|
+
lines = remainder.split(b"\n")
|
|
113
|
+
remainder = lines.pop()[-4096:]
|
|
114
|
+
for line in lines:
|
|
115
|
+
text = line[-4096:].decode(errors="replace")
|
|
116
|
+
self.logs.append(text)
|
|
117
|
+
if self.log_callback:
|
|
118
|
+
self.log_callback(text)
|
|
119
|
+
|
|
120
|
+
async def request(self, method, params=None, *, timeout=30):
|
|
121
|
+
if method == "runtime.describe":
|
|
122
|
+
params = {**(params or {}), "logging": self.trace_attributes}
|
|
123
|
+
if self.peer.tracing:
|
|
124
|
+
params["tracing"] = self.trace_attributes
|
|
125
|
+
return await self.peer.request(method, params, timeout=timeout)
|
|
126
|
+
|
|
127
|
+
async def close(self, timeout=10, *, activated=True, allow_exited=False):
|
|
128
|
+
# A crashed worker may be replaced only after its entire process group
|
|
129
|
+
# is gone. A worker that fails during this disposal still fails cleanup.
|
|
130
|
+
exited_before_disposal = self.process.returncode is not None
|
|
131
|
+
self.expected_exit = True
|
|
132
|
+
if self.process.returncode is None:
|
|
133
|
+
if activated:
|
|
134
|
+
await self.request("runtime.dispose", timeout=timeout)
|
|
135
|
+
self.process.stdin.close()
|
|
136
|
+
try:
|
|
137
|
+
await asyncio.wait_for(self.process.wait(), timeout)
|
|
138
|
+
except TimeoutError as error:
|
|
139
|
+
raise PluginError(
|
|
140
|
+
"cleanup_failed",
|
|
141
|
+
"Worker did not exit after disposal",
|
|
142
|
+
{"pid": self.pid},
|
|
143
|
+
) from error
|
|
144
|
+
await self.peer.close()
|
|
145
|
+
if group_exists(self.pid):
|
|
146
|
+
raise PluginError(
|
|
147
|
+
"cleanup_failed",
|
|
148
|
+
"Worker process group still has descendants",
|
|
149
|
+
{"pgid": self.pid},
|
|
150
|
+
)
|
|
151
|
+
await asyncio.wait_for(
|
|
152
|
+
asyncio.gather(self.reader_task, self.log_task, return_exceptions=True),
|
|
153
|
+
timeout,
|
|
154
|
+
)
|
|
155
|
+
if self.process.returncode not in (0, None) and not (
|
|
156
|
+
allow_exited and exited_before_disposal
|
|
157
|
+
):
|
|
158
|
+
raise PluginError(
|
|
159
|
+
"cleanup_failed",
|
|
160
|
+
"Worker exited unsuccessfully",
|
|
161
|
+
{"returncode": self.process.returncode},
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
async def force_stop(self):
|
|
165
|
+
self.expected_exit = True
|
|
166
|
+
await stop_group(self.pid)
|
|
167
|
+
if self.process.returncode is None:
|
|
168
|
+
await asyncio.wait_for(self.process.wait(), 5)
|
|
169
|
+
await self.peer.close()
|
|
170
|
+
await asyncio.wait_for(
|
|
171
|
+
asyncio.gather(self.reader_task, self.log_task, return_exceptions=True), 5
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def group_exists(pid):
|
|
176
|
+
try:
|
|
177
|
+
os.killpg(pid, 0)
|
|
178
|
+
return True
|
|
179
|
+
except ProcessLookupError:
|
|
180
|
+
return False
|
|
181
|
+
except PermissionError:
|
|
182
|
+
# EPERM does not prove absence. Darwin can also return it while the
|
|
183
|
+
# group's last descendants are zombies awaiting collection.
|
|
184
|
+
return True
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
async def stop_group(pid):
|
|
188
|
+
for sig in (signal.SIGTERM, signal.SIGKILL):
|
|
189
|
+
if group_exists(pid):
|
|
190
|
+
with contextlib.suppress(ProcessLookupError, PermissionError):
|
|
191
|
+
os.killpg(pid, sig)
|
|
192
|
+
deadline = asyncio.get_running_loop().time() + 2
|
|
193
|
+
while group_exists(pid) and asyncio.get_running_loop().time() < deadline:
|
|
194
|
+
await asyncio.sleep(0.05)
|
|
195
|
+
if group_exists(pid):
|
|
196
|
+
raise PluginError(
|
|
197
|
+
"cleanup_failed",
|
|
198
|
+
"Process group did not exit after forced stop",
|
|
199
|
+
{"pgid": pid},
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
async def process_inventory():
|
|
204
|
+
process = await asyncio.create_subprocess_exec(
|
|
205
|
+
"ps", "-axo", "pid=,pgid=,command=", stdout=asyncio.subprocess.PIPE
|
|
206
|
+
)
|
|
207
|
+
output, _ = await process.communicate()
|
|
208
|
+
return {
|
|
209
|
+
int(parts[0]): {"pgid": int(parts[1]), "command": parts[2]}
|
|
210
|
+
for line in output.decode().splitlines()
|
|
211
|
+
if len(parts := line.split(None, 2)) == 3
|
|
212
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""Redact management views without changing the configuration used by workers."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from urllib.parse import quote
|
|
5
|
+
|
|
6
|
+
import tomlkit
|
|
7
|
+
|
|
8
|
+
REDACTED = "[REDACTED]"
|
|
9
|
+
SENSITIVE_KEY = re.compile(
|
|
10
|
+
r"password|passphrase|secret|token|api[_-]?key|authorization|credential",
|
|
11
|
+
re.IGNORECASE,
|
|
12
|
+
)
|
|
13
|
+
URL_AUTH = re.compile(r"((?:git\+)?(?:https?|ssh)://)[^\s/@]+@", re.IGNORECASE)
|
|
14
|
+
URL_QUERY = re.compile(
|
|
15
|
+
r"([?&](?:token|api[_-]?key|password|secret|authorization|code|state|code_verifier)=)[^&\s]+",
|
|
16
|
+
re.IGNORECASE,
|
|
17
|
+
)
|
|
18
|
+
BEARER = re.compile(r"(\bBearer\s+)[^\s\"']+", re.IGNORECASE)
|
|
19
|
+
MCP_KEY = re.compile(r"tf_(?:mcp|oauth|refresh|user)_[A-Za-z0-9_-]{43}")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def secret_values(value, schema=None, *, key="", root=None):
|
|
23
|
+
schema = schema if isinstance(schema, dict) else {}
|
|
24
|
+
root = schema if root is None else root
|
|
25
|
+
reference = schema.get("$ref", "")
|
|
26
|
+
if reference.startswith("#/"):
|
|
27
|
+
resolved = root
|
|
28
|
+
for part in reference[2:].split("/"):
|
|
29
|
+
resolved = (
|
|
30
|
+
resolved.get(part.replace("~1", "/").replace("~0", "~"), {})
|
|
31
|
+
if isinstance(resolved, dict)
|
|
32
|
+
else {}
|
|
33
|
+
)
|
|
34
|
+
schema = {**(resolved if isinstance(resolved, dict) else {}), **schema}
|
|
35
|
+
protected = (
|
|
36
|
+
SENSITIVE_KEY.search(key)
|
|
37
|
+
or schema.get("writeOnly")
|
|
38
|
+
or schema.get("x-secret")
|
|
39
|
+
or schema.get("format") == "password"
|
|
40
|
+
)
|
|
41
|
+
if protected:
|
|
42
|
+
if isinstance(value, str) and value:
|
|
43
|
+
return {value}
|
|
44
|
+
if type(value) in (int, float):
|
|
45
|
+
return {str(value)}
|
|
46
|
+
result = set()
|
|
47
|
+
if isinstance(value, dict):
|
|
48
|
+
for name, item in value.items():
|
|
49
|
+
child = schema.get("properties", {}).get(name, {})
|
|
50
|
+
if protected:
|
|
51
|
+
child = {
|
|
52
|
+
**(child if isinstance(child, dict) else {}),
|
|
53
|
+
"writeOnly": True,
|
|
54
|
+
}
|
|
55
|
+
result.update(secret_values(item, child, key=name, root=root))
|
|
56
|
+
elif isinstance(value, (list, tuple)):
|
|
57
|
+
for item in value:
|
|
58
|
+
child = schema.get("items", {})
|
|
59
|
+
if protected:
|
|
60
|
+
child = {
|
|
61
|
+
**(child if isinstance(child, dict) else {}),
|
|
62
|
+
"writeOnly": True,
|
|
63
|
+
}
|
|
64
|
+
result.update(secret_values(item, child, key=key, root=root))
|
|
65
|
+
return result
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def redact(value, secrets, *, field=""):
|
|
69
|
+
if isinstance(value, dict):
|
|
70
|
+
if field == "request" and "config" in value:
|
|
71
|
+
value = {**value, "config": REDACTED}
|
|
72
|
+
return {
|
|
73
|
+
key: REDACTED
|
|
74
|
+
if SENSITIVE_KEY.search(key)
|
|
75
|
+
else redact(item, secrets, field=key)
|
|
76
|
+
for key, item in value.items()
|
|
77
|
+
}
|
|
78
|
+
if isinstance(value, (list, tuple)):
|
|
79
|
+
return [redact(item, secrets, field=field) for item in value]
|
|
80
|
+
if not isinstance(value, str):
|
|
81
|
+
return (
|
|
82
|
+
REDACTED if type(value) in (int, float) and str(value) in secrets else value
|
|
83
|
+
)
|
|
84
|
+
if value in secrets:
|
|
85
|
+
return REDACTED
|
|
86
|
+
# Content/generation IDs remain useful even with short numeric passwords.
|
|
87
|
+
if re.fullmatch(r"[a-f0-9]{32}|[a-f0-9]{64}", value):
|
|
88
|
+
return value
|
|
89
|
+
for secret in sorted(secrets, key=len, reverse=True):
|
|
90
|
+
if len(secret) >= 4 or field in {
|
|
91
|
+
"message",
|
|
92
|
+
"error",
|
|
93
|
+
"reason",
|
|
94
|
+
"lines",
|
|
95
|
+
"logs",
|
|
96
|
+
"traceback",
|
|
97
|
+
"stderr",
|
|
98
|
+
"stdout",
|
|
99
|
+
}:
|
|
100
|
+
value = value.replace(secret, REDACTED).replace(
|
|
101
|
+
quote(secret, safe=""), REDACTED
|
|
102
|
+
)
|
|
103
|
+
value = URL_AUTH.sub(r"\1[REDACTED]@", value)
|
|
104
|
+
value = URL_QUERY.sub(r"\1[REDACTED]", value)
|
|
105
|
+
return MCP_KEY.sub(REDACTED, BEARER.sub(r"\1[REDACTED]", value))
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def redact_toml(text, redactor):
|
|
109
|
+
document = tomlkit.parse(text)
|
|
110
|
+
sanitized = redactor(document.unwrap())
|
|
111
|
+
|
|
112
|
+
def overlay(original, safe):
|
|
113
|
+
for key, value in safe.items():
|
|
114
|
+
if isinstance(value, dict):
|
|
115
|
+
overlay(original[key], value)
|
|
116
|
+
elif original[key] != value:
|
|
117
|
+
original[key] = value
|
|
118
|
+
|
|
119
|
+
overlay(document, sanitized)
|
|
120
|
+
return tomlkit.dumps(document)
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""Render the packaged Notes example into an exclusively created source directory."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import ast
|
|
6
|
+
import io
|
|
7
|
+
import json
|
|
8
|
+
import re
|
|
9
|
+
import shutil
|
|
10
|
+
import tokenize
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
import tomlkit
|
|
14
|
+
from toolframe_sdk import PluginError
|
|
15
|
+
|
|
16
|
+
from .config import validate_plugin_id
|
|
17
|
+
from .skills import bundled_skill
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _files(directory, prefix=Path()):
|
|
21
|
+
# Traversable also works when core's resources are not ordinary source files.
|
|
22
|
+
for child in sorted(directory.iterdir(), key=lambda item: item.name):
|
|
23
|
+
relative = prefix / child.name
|
|
24
|
+
if child.is_dir():
|
|
25
|
+
yield from _files(child, relative)
|
|
26
|
+
else:
|
|
27
|
+
yield relative, child.read_bytes()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _python(source, rename):
|
|
31
|
+
tokens = []
|
|
32
|
+
for token in tokenize.generate_tokens(io.StringIO(source).readline):
|
|
33
|
+
if token.type == tokenize.STRING:
|
|
34
|
+
value = ast.literal_eval(token.string)
|
|
35
|
+
if isinstance(value, str) and (replacement := rename(value)) != value:
|
|
36
|
+
token = token._replace(string=json.dumps(replacement))
|
|
37
|
+
elif token.type == tokenize.NAME:
|
|
38
|
+
token = token._replace(string=rename(token.string))
|
|
39
|
+
tokens.append(token)
|
|
40
|
+
return tokenize.untokenize(tokens)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _render(identifier, package, module):
|
|
44
|
+
template = dict(_files(bundled_skill().joinpath("assets", "sample-plugin")))
|
|
45
|
+
document = tomlkit.parse(template[Path("pyproject.toml")].decode("utf-8"))
|
|
46
|
+
project = document["project"]
|
|
47
|
+
entries = project["entry-points"]["toolframe.plugins"]
|
|
48
|
+
if len(entries) != 1:
|
|
49
|
+
raise ValueError("Bundled template must declare exactly one plugin entry point")
|
|
50
|
+
original_id, entry = next(iter(entries.items()))
|
|
51
|
+
original_module = entry.split(".", 1)[0]
|
|
52
|
+
wheel = document["tool"]["hatch"]["build"]["targets"]["wheel"]
|
|
53
|
+
if wheel["packages"] != [f"src/{original_module}"]:
|
|
54
|
+
raise ValueError("Bundled template must declare one src-layout Python package")
|
|
55
|
+
|
|
56
|
+
identities = {
|
|
57
|
+
original_id: identifier,
|
|
58
|
+
project["name"]: package,
|
|
59
|
+
original_module: module,
|
|
60
|
+
}
|
|
61
|
+
prefixes = {
|
|
62
|
+
f"{original_id}.": f"{identifier}.", # Service namespace.
|
|
63
|
+
f"{original_id}-": f"{identifier}-", # Streamlit widget keys.
|
|
64
|
+
f"{original_id}__": f"{identifier}__", # Qualified MCP tool names.
|
|
65
|
+
f"{original_module}.": f"{module}.", # Imports and module:factory references.
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
def rename(value):
|
|
69
|
+
if value in identities:
|
|
70
|
+
return identities[value]
|
|
71
|
+
for before, after in prefixes.items():
|
|
72
|
+
if value.startswith(before):
|
|
73
|
+
return after + value[len(before) :]
|
|
74
|
+
return value
|
|
75
|
+
|
|
76
|
+
project["name"] = package
|
|
77
|
+
project["entry-points"]["toolframe.plugins"] = {identifier: rename(entry)}
|
|
78
|
+
wheel["packages"] = [f"src/{module}"]
|
|
79
|
+
template[Path("pyproject.toml")] = tomlkit.dumps(document).encode("utf-8")
|
|
80
|
+
|
|
81
|
+
rendered = {}
|
|
82
|
+
for relative, content in template.items():
|
|
83
|
+
if relative.parts[:2] == ("src", original_module):
|
|
84
|
+
relative = Path("src", module, *relative.parts[2:])
|
|
85
|
+
if relative.suffix == ".py":
|
|
86
|
+
source = _python(content.decode("utf-8"), rename)
|
|
87
|
+
compile(source, str(relative), "exec")
|
|
88
|
+
content = source.encode("utf-8")
|
|
89
|
+
elif relative.suffix == ".md":
|
|
90
|
+
# Only code spans name callable tools/packages; prose stays verbatim.
|
|
91
|
+
source = re.sub(
|
|
92
|
+
r"`([^`\n]+)`",
|
|
93
|
+
lambda match: f"`{rename(match[1])}`",
|
|
94
|
+
content.decode("utf-8"),
|
|
95
|
+
)
|
|
96
|
+
content = source.encode("utf-8")
|
|
97
|
+
rendered[relative] = content
|
|
98
|
+
return rendered
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def new_plugin(home: Path, identifier: str, *, output: Path | None = None):
|
|
102
|
+
validate_plugin_id(identifier)
|
|
103
|
+
package = f"toolframe-{identifier}"
|
|
104
|
+
# The existing ID grammar permits a trailing '-', unlike distribution names.
|
|
105
|
+
if package.endswith("-"):
|
|
106
|
+
package += "plugin"
|
|
107
|
+
module = package.replace("-", "_")
|
|
108
|
+
# Do not resolve the last component: that would follow a destination symlink.
|
|
109
|
+
destination = (
|
|
110
|
+
output if output is not None else home / "plugins" / identifier
|
|
111
|
+
).absolute()
|
|
112
|
+
conflict = PluginError(
|
|
113
|
+
"scaffold_conflict",
|
|
114
|
+
f"Refusing to overwrite existing target: {destination}",
|
|
115
|
+
{"path": str(destination)},
|
|
116
|
+
)
|
|
117
|
+
if destination.exists() or destination.is_symlink():
|
|
118
|
+
raise conflict
|
|
119
|
+
rendered = _render(identifier, package, module)
|
|
120
|
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
121
|
+
try:
|
|
122
|
+
# mkdir is exclusive, including an empty directory or dangling symlink
|
|
123
|
+
# created after the first check. Only our successfully created tree is ours.
|
|
124
|
+
destination.mkdir()
|
|
125
|
+
except FileExistsError as error:
|
|
126
|
+
raise conflict from error
|
|
127
|
+
try:
|
|
128
|
+
for relative, content in rendered.items():
|
|
129
|
+
path = destination / relative
|
|
130
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
131
|
+
with path.open("xb") as handle:
|
|
132
|
+
handle.write(content)
|
|
133
|
+
except BaseException:
|
|
134
|
+
shutil.rmtree(destination)
|
|
135
|
+
raise
|
|
136
|
+
return {
|
|
137
|
+
"plugin_id": identifier,
|
|
138
|
+
"package_name": package,
|
|
139
|
+
"python_module": module,
|
|
140
|
+
"path": str(destination),
|
|
141
|
+
"status": "created",
|
|
142
|
+
}
|
toolframe_core/skills.py
ADDED
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
"""Versioned, project-local agent guidance with protection for customized copies."""
|
|
2
|
+
|
|
3
|
+
import fcntl
|
|
4
|
+
import hashlib
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
import shutil
|
|
8
|
+
import tempfile
|
|
9
|
+
import uuid
|
|
10
|
+
import zipfile
|
|
11
|
+
from importlib.resources import as_file, files
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
import yaml
|
|
15
|
+
from toolframe_sdk import PluginError
|
|
16
|
+
|
|
17
|
+
SKILL_NAME = "toolframe-plugin-design"
|
|
18
|
+
RECEIPT = ".toolframe-install.json"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def bundled_skill():
|
|
22
|
+
return files("toolframe_core").joinpath("bundled_skills", SKILL_NAME)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _hashes(directory, *, include_receipt=False, exclude_evals=False):
|
|
26
|
+
if not directory.is_dir() or directory.is_symlink():
|
|
27
|
+
return None
|
|
28
|
+
result = {}
|
|
29
|
+
for path in sorted(directory.rglob("*")):
|
|
30
|
+
relative = path.relative_to(directory)
|
|
31
|
+
if exclude_evals and relative.parts[0] == "evals":
|
|
32
|
+
continue
|
|
33
|
+
if path.is_symlink():
|
|
34
|
+
return None
|
|
35
|
+
if path.is_file():
|
|
36
|
+
relative = relative.as_posix()
|
|
37
|
+
if relative != RECEIPT or include_receipt:
|
|
38
|
+
result[relative] = hashlib.sha256(path.read_bytes()).hexdigest()
|
|
39
|
+
elif not path.is_dir():
|
|
40
|
+
return None
|
|
41
|
+
return result
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _record(destination):
|
|
45
|
+
try:
|
|
46
|
+
value = json.loads((destination / RECEIPT).read_text())
|
|
47
|
+
if (
|
|
48
|
+
value["format"] == 1
|
|
49
|
+
and value["name"] == SKILL_NAME
|
|
50
|
+
and re.fullmatch(r"\d+\.\d+\.\d+", value["version"])
|
|
51
|
+
and isinstance(value["files"], dict)
|
|
52
|
+
and value["files"]
|
|
53
|
+
and all(
|
|
54
|
+
isinstance(k, str) and isinstance(v, str)
|
|
55
|
+
for k, v in value["files"].items()
|
|
56
|
+
)
|
|
57
|
+
):
|
|
58
|
+
return value
|
|
59
|
+
except OSError, ValueError, TypeError, KeyError:
|
|
60
|
+
pass
|
|
61
|
+
return None
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _publish(source, destination, record, expected, *, backup):
|
|
65
|
+
staging = Path(tempfile.mkdtemp(prefix=f".{SKILL_NAME}-", dir=destination.parent))
|
|
66
|
+
previous = staging / "previous"
|
|
67
|
+
published = False
|
|
68
|
+
backup_path = None
|
|
69
|
+
try:
|
|
70
|
+
candidate = staging / "candidate"
|
|
71
|
+
candidate.mkdir()
|
|
72
|
+
# Publish exactly the files in the receipt, excluding development evals
|
|
73
|
+
# even when this installer runs directly from a source checkout.
|
|
74
|
+
for relative in record["files"]:
|
|
75
|
+
target = candidate / relative
|
|
76
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
77
|
+
shutil.copy2(source / relative, target)
|
|
78
|
+
(candidate / RECEIPT).write_text(json.dumps(record, indent=2) + "\n")
|
|
79
|
+
if destination.exists() or destination.is_symlink():
|
|
80
|
+
if (
|
|
81
|
+
expected is None
|
|
82
|
+
or _hashes(destination, include_receipt=True) != expected
|
|
83
|
+
):
|
|
84
|
+
raise PluginError(
|
|
85
|
+
"skill_conflict",
|
|
86
|
+
"Skill changed during installation; retry for a fresh comparison.",
|
|
87
|
+
)
|
|
88
|
+
destination.rename(previous)
|
|
89
|
+
if _hashes(previous, include_receipt=True) != expected:
|
|
90
|
+
raise PluginError(
|
|
91
|
+
"skill_conflict",
|
|
92
|
+
"Skill changed during installation; the original was retained.",
|
|
93
|
+
)
|
|
94
|
+
if backup:
|
|
95
|
+
backup_path = (
|
|
96
|
+
destination.parent / f".{SKILL_NAME}-backup-{uuid.uuid4().hex}.zip"
|
|
97
|
+
)
|
|
98
|
+
with zipfile.ZipFile(backup_path, "x", zipfile.ZIP_DEFLATED) as archive:
|
|
99
|
+
for path in previous.rglob("*"):
|
|
100
|
+
if path.is_file():
|
|
101
|
+
archive.write(path, path.relative_to(previous))
|
|
102
|
+
candidate.rename(destination)
|
|
103
|
+
published = True
|
|
104
|
+
return str(backup_path) if backup_path else None
|
|
105
|
+
finally:
|
|
106
|
+
if previous.exists() and not published:
|
|
107
|
+
if destination.exists() or destination.is_symlink():
|
|
108
|
+
# Keep both copies if an external writer claimed the destination.
|
|
109
|
+
raise PluginError(
|
|
110
|
+
"skill_conflict", f"Original skill retained at {previous}"
|
|
111
|
+
)
|
|
112
|
+
previous.rename(destination)
|
|
113
|
+
shutil.rmtree(staging)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def install_skill(
|
|
117
|
+
home: Path, *, output=None, overwrite=False, confirm=None, upgrade_only=False
|
|
118
|
+
):
|
|
119
|
+
destination = (output or home / ".agents" / "skills" / SKILL_NAME).absolute()
|
|
120
|
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
121
|
+
# Cooperating launches/installers must compare and publish under one lock.
|
|
122
|
+
with (destination.parent / f".{SKILL_NAME}.lock").open("a") as lock:
|
|
123
|
+
fcntl.flock(lock.fileno(), fcntl.LOCK_EX)
|
|
124
|
+
with as_file(bundled_skill()) as source:
|
|
125
|
+
document = (source / "SKILL.md").read_text()
|
|
126
|
+
match = re.match(
|
|
127
|
+
r"\A---\r?\n(.*?)\r?\n---(?:\r?\n|\Z)", document, re.DOTALL
|
|
128
|
+
)
|
|
129
|
+
try:
|
|
130
|
+
metadata = yaml.safe_load(match[1]) if match else None
|
|
131
|
+
except yaml.YAMLError as error:
|
|
132
|
+
raise ValueError(
|
|
133
|
+
"Bundled SKILL.md frontmatter is invalid YAML"
|
|
134
|
+
) from error
|
|
135
|
+
version = metadata.get("version") if isinstance(metadata, dict) else None
|
|
136
|
+
if not isinstance(version, str) or not re.fullmatch(
|
|
137
|
+
r"\d+\.\d+\.\d+", version
|
|
138
|
+
):
|
|
139
|
+
raise ValueError(
|
|
140
|
+
'Bundled SKILL.md frontmatter requires version: "x.x.x"'
|
|
141
|
+
)
|
|
142
|
+
record = {
|
|
143
|
+
"format": 1,
|
|
144
|
+
"name": SKILL_NAME,
|
|
145
|
+
"version": version,
|
|
146
|
+
"files": _hashes(source, exclude_evals=True),
|
|
147
|
+
}
|
|
148
|
+
result = {
|
|
149
|
+
"name": SKILL_NAME,
|
|
150
|
+
"path": str(destination),
|
|
151
|
+
"bundled_version": version,
|
|
152
|
+
"confirmation_required": False,
|
|
153
|
+
}
|
|
154
|
+
exists = destination.exists() or destination.is_symlink()
|
|
155
|
+
current = _hashes(destination) if exists else None
|
|
156
|
+
if exists and current is None:
|
|
157
|
+
return {
|
|
158
|
+
**result,
|
|
159
|
+
"status": "preserved",
|
|
160
|
+
"reason": "non_regular_destination",
|
|
161
|
+
}
|
|
162
|
+
prior = _record(destination) if exists else None
|
|
163
|
+
result["previous_version"] = prior["version"] if prior else None
|
|
164
|
+
if exists and current == record["files"] and prior == record:
|
|
165
|
+
return {**result, "status": "unchanged"}
|
|
166
|
+
if upgrade_only and prior == record:
|
|
167
|
+
return {
|
|
168
|
+
**result,
|
|
169
|
+
"status": "preserved",
|
|
170
|
+
"reason": "customized_current_version",
|
|
171
|
+
}
|
|
172
|
+
changed = exists and (prior is None or current != prior["files"])
|
|
173
|
+
expected = _hashes(destination, include_receipt=True) if exists else None
|
|
174
|
+
if changed and not overwrite:
|
|
175
|
+
conflict = {
|
|
176
|
+
**result,
|
|
177
|
+
"status": "preserved",
|
|
178
|
+
"reason": "local_changes" if prior else "unmanaged_copy",
|
|
179
|
+
"confirmation_required": True,
|
|
180
|
+
}
|
|
181
|
+
if confirm is None:
|
|
182
|
+
return conflict
|
|
183
|
+
if not confirm(conflict):
|
|
184
|
+
return {
|
|
185
|
+
**conflict,
|
|
186
|
+
"confirmation_required": False,
|
|
187
|
+
"reason": "user_declined",
|
|
188
|
+
}
|
|
189
|
+
backup = _publish(source, destination, record, expected, backup=changed)
|
|
190
|
+
return {
|
|
191
|
+
**result,
|
|
192
|
+
"status": "updated" if exists else "installed",
|
|
193
|
+
"backup": backup,
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def installation_message(result):
|
|
198
|
+
if result["status"] == "preserved":
|
|
199
|
+
message = f"Kept existing skill at {result['path']} ({result['reason']})."
|
|
200
|
+
if result["confirmation_required"]:
|
|
201
|
+
message += " Run skill install interactively to review replacement, or use --overwrite after confirmation; --output PATH exports a separate copy."
|
|
202
|
+
elif result["reason"] == "non_regular_destination":
|
|
203
|
+
message += " Use --output PATH for a new regular directory."
|
|
204
|
+
return message
|
|
205
|
+
message = (
|
|
206
|
+
f"Skill {result['status']} ({result['bundled_version']}): {result['path']}"
|
|
207
|
+
)
|
|
208
|
+
if result.get("backup"):
|
|
209
|
+
message += f" Previous customized copy: {result['backup']}"
|
|
210
|
+
return message
|