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.
Files changed (46) hide show
  1. toolframe_core/__init__.py +3 -0
  2. toolframe_core/_state_postgres.py +420 -0
  3. toolframe_core/_state_sqlite.py +339 -0
  4. toolframe_core/access.py +513 -0
  5. toolframe_core/api.py +700 -0
  6. toolframe_core/bundled_skills/toolframe-plugin-design/LICENSE +21 -0
  7. toolframe_core/bundled_skills/toolframe-plugin-design/SKILL.md +59 -0
  8. toolframe_core/bundled_skills/toolframe-plugin-design/agents/openai.yaml +4 -0
  9. toolframe_core/bundled_skills/toolframe-plugin-design/assets/sample-plugin/LICENSE +21 -0
  10. toolframe_core/bundled_skills/toolframe-plugin-design/assets/sample-plugin/pyproject.toml +21 -0
  11. toolframe_core/bundled_skills/toolframe-plugin-design/assets/sample-plugin/src/toolframe_sample_notes/__init__.py +1 -0
  12. toolframe_core/bundled_skills/toolframe-plugin-design/assets/sample-plugin/src/toolframe_sample_notes/docs/usage.md +13 -0
  13. toolframe_core/bundled_skills/toolframe-plugin-design/assets/sample-plugin/src/toolframe_sample_notes/migrations/0001_init.sql +5 -0
  14. toolframe_core/bundled_skills/toolframe-plugin-design/assets/sample-plugin/src/toolframe_sample_notes/page.py +25 -0
  15. toolframe_core/bundled_skills/toolframe-plugin-design/assets/sample-plugin/src/toolframe_sample_notes/plugin.py +119 -0
  16. toolframe_core/bundled_skills/toolframe-plugin-design/assets/sample-plugin/src/toolframe_sample_notes/service.py +48 -0
  17. toolframe_core/bundled_skills/toolframe-plugin-design/references/design.md +60 -0
  18. toolframe_core/bundled_skills/toolframe-plugin-design/references/operations.md +133 -0
  19. toolframe_core/bundled_skills/toolframe-plugin-design/references/plugin-api.md +186 -0
  20. toolframe_core/cli.py +681 -0
  21. toolframe_core/config.py +267 -0
  22. toolframe_core/diagnostics.py +200 -0
  23. toolframe_core/graph.py +207 -0
  24. toolframe_core/host.py +2610 -0
  25. toolframe_core/maintenance.py +868 -0
  26. toolframe_core/mcp_access.py +259 -0
  27. toolframe_core/network.py +97 -0
  28. toolframe_core/oidc_access.py +187 -0
  29. toolframe_core/packages.py +728 -0
  30. toolframe_core/process.py +212 -0
  31. toolframe_core/redaction.py +120 -0
  32. toolframe_core/scaffold.py +142 -0
  33. toolframe_core/skills.py +210 -0
  34. toolframe_core/state.py +226 -0
  35. toolframe_core/state_admin.py +270 -0
  36. toolframe_core/stores.py +88 -0
  37. toolframe_core/templates.json +6 -0
  38. toolframe_core/templates.py +56 -0
  39. toolframe_core-0.6.2.dist-info/METADATA +465 -0
  40. toolframe_core-0.6.2.dist-info/RECORD +46 -0
  41. toolframe_core-0.6.2.dist-info/WHEEL +4 -0
  42. toolframe_core-0.6.2.dist-info/entry_points.txt +2 -0
  43. toolframe_core-0.6.2.dist-info/licenses/LICENSE +202 -0
  44. toolframe_core-0.6.2.dist-info/licenses/NOTICE +14 -0
  45. toolframe_core-0.6.2.dist-info/licenses/src/toolframe_core/bundled_skills/toolframe-plugin-design/LICENSE +21 -0
  46. 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
+ }
@@ -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