@follenfang/fupload 0.0.0-bootstrap.0 → 0.0.1

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 (43) hide show
  1. package/README.md +208 -3
  2. package/fupload/SKILL.md +129 -0
  3. package/fupload/agents/openai.yaml +4 -0
  4. package/fupload/examples/dd-config-delete.json +5 -0
  5. package/fupload/examples/dd-config-update.json +25 -0
  6. package/fupload/examples/dd-plugin-delete.json +5 -0
  7. package/fupload/examples/dd-plugin-update.json +9 -0
  8. package/fupload/examples/dd-wa-delete.json +5 -0
  9. package/fupload/examples/dd-wa-edit.json +9 -0
  10. package/fupload/examples/newbee-config-delete.json +5 -0
  11. package/fupload/examples/newbee-config-update.json +10 -0
  12. package/fupload/examples/newbee-plugin-create.json +14 -0
  13. package/fupload/examples/newbee-plugin-delete.json +5 -0
  14. package/fupload/examples/newbee-wa-delete.json +5 -0
  15. package/fupload/examples/newbee-wa-update.json +8 -0
  16. package/fupload/references/dd.md +105 -0
  17. package/fupload/references/newbee-official-cli.md +288 -0
  18. package/fupload/references/newbee.md +80 -0
  19. package/fupload/references/workflow.md +67 -0
  20. package/fupload/scripts/fupload.py +17 -0
  21. package/fupload/scripts/fupload_cli/__init__.py +3 -0
  22. package/fupload/scripts/fupload_cli/cli.py +266 -0
  23. package/fupload/scripts/fupload_cli/dd.py +2406 -0
  24. package/fupload/scripts/fupload_cli/dd_broker.py +634 -0
  25. package/fupload/scripts/fupload_cli/dd_sidecar.py +860 -0
  26. package/fupload/scripts/fupload_cli/errors.py +94 -0
  27. package/fupload/scripts/fupload_cli/io.py +125 -0
  28. package/fupload/scripts/fupload_cli/newbee.py +1412 -0
  29. package/fupload/scripts/fupload_cli/newbee_auth.py +135 -0
  30. package/fupload/scripts/fupload_cli/schema.py +539 -0
  31. package/fupload/scripts/fupload_cli/transport.py +125 -0
  32. package/fupload/scripts/fupload_cli/trust.py +207 -0
  33. package/npm/bin/fupload.mjs +90 -0
  34. package/npm/lib/managed-install.mjs +86 -0
  35. package/npm/lib/options.mjs +38 -0
  36. package/npm/lib/python.mjs +45 -0
  37. package/npm/lib/skill-installer.mjs +228 -0
  38. package/npm/lib/uninstall.mjs +211 -0
  39. package/npm/lib/update.mjs +99 -0
  40. package/npm/lib/versions.mjs +63 -0
  41. package/npm/postinstall.mjs +18 -0
  42. package/npm/skill-manifest.json +164 -0
  43. package/package.json +50 -6
@@ -0,0 +1,125 @@
1
+ """Small standard-library JSON and multipart HTTP client."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import mimetypes
7
+ import os
8
+ import secrets
9
+ import urllib.error
10
+ import urllib.request
11
+ from typing import Any, Dict, Mapping, Optional
12
+
13
+ from .errors import FuploadError
14
+ from .trust import official_opener, require_official_url
15
+
16
+
17
+ def _http_error_details(raw: bytes, fallback: str) -> tuple[str, Any]:
18
+ """Extract a server error only from a conforming UTF-8 JSON object."""
19
+ try:
20
+ parsed = json.loads(raw.decode("utf-8"))
21
+ except (UnicodeDecodeError, ValueError):
22
+ return fallback, None
23
+ if not isinstance(parsed, dict):
24
+ return fallback, None
25
+ message = parsed.get("message") or parsed.get("msg") or fallback
26
+ return str(message), parsed.get("code")
27
+
28
+
29
+ def json_request(
30
+ url: str,
31
+ *,
32
+ method: str = "GET",
33
+ headers: Optional[Mapping[str, str]] = None,
34
+ body: Any = None,
35
+ timeout: int = 60,
36
+ trusted_service: Optional[str] = None,
37
+ ) -> Any:
38
+ data = None
39
+ request_headers = dict(headers or {})
40
+ if body is not None:
41
+ data = json.dumps(body, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
42
+ request_headers.setdefault("Content-Type", "application/json")
43
+ request = urllib.request.Request(url, data=data, method=method, headers=request_headers)
44
+ try:
45
+ response_context = (
46
+ official_opener(require_official_url(url, trusted_service)).open(request, timeout=timeout)
47
+ if trusted_service else urllib.request.urlopen(request, timeout=timeout)
48
+ )
49
+ with response_context as response:
50
+ raw = response.read()
51
+ status = response.status
52
+ except urllib.error.HTTPError as exc:
53
+ raw = exc.read()
54
+ message, code = _http_error_details(raw, "HTTP %d" % exc.code)
55
+ raise FuploadError(message, endpoint=url, http_status=exc.code, business_code=code) from exc
56
+ except (OSError, urllib.error.URLError) as exc:
57
+ raise FuploadError(
58
+ "network result is uncertain: %s" % exc,
59
+ endpoint=url,
60
+ verification_required=method not in ("GET", "HEAD"),
61
+ ) from exc
62
+ if status < 200 or status >= 300:
63
+ raise FuploadError("HTTP %d" % status, endpoint=url, http_status=status)
64
+ if not raw:
65
+ return {}
66
+ try:
67
+ return json.loads(raw.decode("utf-8"))
68
+ except ValueError as exc:
69
+ raise FuploadError("response was not valid JSON", endpoint=url, http_status=status) from exc
70
+
71
+
72
+ def multipart_request(
73
+ url: str,
74
+ file_path: str,
75
+ *,
76
+ file_field: str = "file",
77
+ fields: Optional[Mapping[str, str]] = None,
78
+ headers: Optional[Mapping[str, str]] = None,
79
+ timeout: int = 600,
80
+ trusted_service: Optional[str] = None,
81
+ ) -> Any:
82
+ boundary = "----fupload-%s" % secrets.token_hex(16)
83
+ chunks = []
84
+ for key, value in (fields or {}).items():
85
+ chunks.extend([
86
+ ("--%s\r\n" % boundary).encode(),
87
+ ('Content-Disposition: form-data; name="%s"\r\n\r\n' % key).encode(),
88
+ str(value).encode("utf-8"), b"\r\n",
89
+ ])
90
+ filename = os.path.basename(file_path)
91
+ content_type = mimetypes.guess_type(filename)[0] or "application/octet-stream"
92
+ chunks.extend([
93
+ ("--%s\r\n" % boundary).encode(),
94
+ ('Content-Disposition: form-data; name="%s"; filename="%s"\r\n' % (file_field, filename)).encode("utf-8"),
95
+ ("Content-Type: %s\r\n\r\n" % content_type).encode(),
96
+ ])
97
+ with open(file_path, "rb") as handle:
98
+ chunks.append(handle.read())
99
+ chunks.extend([b"\r\n", ("--%s--\r\n" % boundary).encode()])
100
+ body = b"".join(chunks)
101
+ request_headers = dict(headers or {})
102
+ request_headers["Content-Type"] = "multipart/form-data; boundary=%s" % boundary
103
+ request = urllib.request.Request(url, data=body, method="POST", headers=request_headers)
104
+ try:
105
+ response_context = (
106
+ official_opener(require_official_url(url, trusted_service)).open(request, timeout=timeout)
107
+ if trusted_service else urllib.request.urlopen(request, timeout=timeout)
108
+ )
109
+ with response_context as response:
110
+ raw = response.read()
111
+ status = response.status
112
+ except urllib.error.HTTPError as exc:
113
+ raw = exc.read()
114
+ message, code = _http_error_details(raw, "HTTP %d" % exc.code)
115
+ raise FuploadError(message, endpoint=url, http_status=exc.code, business_code=code) from exc
116
+ except (OSError, urllib.error.URLError) as exc:
117
+ raise FuploadError(
118
+ "upload result is uncertain: %s" % exc,
119
+ endpoint=url,
120
+ verification_required=True,
121
+ ) from exc
122
+ try:
123
+ return json.loads(raw.decode("utf-8")) if raw else {}
124
+ except ValueError as exc:
125
+ raise FuploadError("upload response was not valid JSON", endpoint=url, http_status=status) from exc
@@ -0,0 +1,207 @@
1
+ """Platform trust primitives for credential and executable boundaries."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import ctypes
6
+ import json
7
+ import os
8
+ import re
9
+ import subprocess
10
+ import urllib.parse
11
+ import urllib.request
12
+ from pathlib import Path
13
+ from typing import Any, Dict, Mapping, Optional, Tuple
14
+
15
+ from .errors import FuploadError
16
+
17
+
18
+ NEWBEE_ORIGINS = {
19
+ "creator": "https://api.newbeebox.com",
20
+ "auth": "https://api.next.newbeebox.com",
21
+ "next": "https://api.next.newbeebox.com",
22
+ "metadata": "https://cdn2.newbeebox.com",
23
+ "upload": "https://api.next.newbeebox.com",
24
+ }
25
+ ALLOWED_DD_PUBLISHERS = (
26
+ "netease (hangzhou) network co., ltd",
27
+ )
28
+ _SUBJECT_ORGANIZATION = re.compile(r"(?:^|,\s*)O=(?:\"([^\"]+)\"|([^,]+))", re.IGNORECASE)
29
+
30
+
31
+ def _origin(value: str) -> Tuple[str, str, int]:
32
+ parsed = urllib.parse.urlsplit(value)
33
+ if parsed.scheme.casefold() != "https" or not parsed.hostname:
34
+ raise FuploadError("untrusted HTTPS origin", kind="trust_boundary")
35
+ host = parsed.hostname.casefold().rstrip(".")
36
+ try:
37
+ port = parsed.port or 443
38
+ except ValueError as exc:
39
+ raise FuploadError("origin contains an invalid port", kind="trust_boundary") from exc
40
+ if parsed.username or parsed.password or parsed.fragment:
41
+ raise FuploadError("origin contains unsupported URL components", kind="trust_boundary")
42
+ return parsed.scheme.casefold(), host, port
43
+
44
+
45
+ def require_official_url(value: str, service: str, *, path_prefix: str = "") -> str:
46
+ expected = _origin(NEWBEE_ORIGINS[service])
47
+ actual = _origin(value)
48
+ if actual != expected:
49
+ raise FuploadError(
50
+ "untrusted %s origin" % service,
51
+ kind="trust_boundary",
52
+ endpoint="%s://%s:%d" % actual,
53
+ )
54
+ parsed = urllib.parse.urlsplit(value)
55
+ if path_prefix and not parsed.path.startswith(path_prefix):
56
+ raise FuploadError("untrusted %s path" % service, kind="trust_boundary")
57
+ return value.rstrip("/")
58
+
59
+
60
+ def same_origin_redirect(original: str, redirected: str) -> bool:
61
+ try:
62
+ return _origin(original) == _origin(redirected)
63
+ except (FuploadError, ValueError):
64
+ return False
65
+
66
+
67
+ class SameOriginRedirectHandler(urllib.request.HTTPRedirectHandler):
68
+ def __init__(self, allowed_origin: str) -> None:
69
+ super().__init__()
70
+ self.allowed_origin = allowed_origin
71
+
72
+ def redirect_request(self, req: urllib.request.Request, fp: Any, code: int, msg: str, headers: Mapping[str, str], newurl: str) -> Optional[urllib.request.Request]:
73
+ if not same_origin_redirect(req.full_url, newurl) or not same_origin_redirect(self.allowed_origin, newurl):
74
+ raise FuploadError("cross-origin redirect rejected", kind="trust_boundary")
75
+ return super().redirect_request(req, fp, code, msg, headers, newurl)
76
+
77
+
78
+ def official_opener(origin: str) -> urllib.request.OpenerDirector:
79
+ return urllib.request.build_opener(SameOriginRedirectHandler(origin))
80
+
81
+
82
+ def _known_folder_roaming() -> Path:
83
+ if os.name != "nt":
84
+ raise FuploadError("Windows Roaming AppData is unavailable", kind="authentication_error")
85
+ try:
86
+ shell32 = ctypes.WinDLL("shell32", use_last_error=True)
87
+ ole32 = ctypes.WinDLL("ole32", use_last_error=True)
88
+ folder_id = ctypes.c_ubyte * 16
89
+ # FOLDERID_RoamingAppData: 3EB685DB-65F9-4CF6-A03A-E3EF65729F3D.
90
+ guid = folder_id(0xDB, 0x85, 0xB6, 0x3E, 0xF9, 0x65, 0xF6, 0x4C,
91
+ 0xA0, 0x3A, 0xE3, 0xEF, 0x65, 0x72, 0x9F, 0x3D)
92
+ shell32.SHGetKnownFolderPath.argtypes = [ctypes.POINTER(folder_id), ctypes.c_uint32, ctypes.c_void_p, ctypes.POINTER(ctypes.c_wchar_p)]
93
+ shell32.SHGetKnownFolderPath.restype = ctypes.c_long
94
+ value = ctypes.c_wchar_p()
95
+ result = shell32.SHGetKnownFolderPath(ctypes.byref(guid), 0, None, ctypes.byref(value))
96
+ if result != 0:
97
+ raise OSError(result, "SHGetKnownFolderPath failed")
98
+ path = Path(value.value)
99
+ ole32.CoTaskMemFree(value)
100
+ return path
101
+ except (AttributeError, OSError, TypeError, ValueError) as exc:
102
+ raise FuploadError("Windows Roaming AppData could not be resolved", kind="authentication_error") from exc
103
+
104
+
105
+ def _known_folder_local() -> Path:
106
+ if os.name != "nt":
107
+ raise FuploadError("Windows Local AppData is unavailable", kind="trust_boundary")
108
+ try:
109
+ shell32 = ctypes.WinDLL("shell32", use_last_error=True)
110
+ ole32 = ctypes.WinDLL("ole32", use_last_error=True)
111
+ folder_id = ctypes.c_ubyte * 16
112
+ # FOLDERID_LocalAppData: F1B32785-6FBA-4FCF-9D55-7B8E7F157091.
113
+ guid = folder_id(0x85, 0x27, 0xB3, 0xF1, 0xBA, 0x6F, 0xCF, 0x4F,
114
+ 0x9D, 0x55, 0x7B, 0x8E, 0x7F, 0x15, 0x70, 0x91)
115
+ shell32.SHGetKnownFolderPath.argtypes = [ctypes.POINTER(folder_id), ctypes.c_uint32, ctypes.c_void_p, ctypes.POINTER(ctypes.c_wchar_p)]
116
+ shell32.SHGetKnownFolderPath.restype = ctypes.c_long
117
+ value = ctypes.c_wchar_p()
118
+ result = shell32.SHGetKnownFolderPath(ctypes.byref(guid), 0, None, ctypes.byref(value))
119
+ if result != 0:
120
+ raise OSError(result, "SHGetKnownFolderPath failed")
121
+ path = Path(value.value)
122
+ ole32.CoTaskMemFree(value)
123
+ return path
124
+ except (AttributeError, OSError, TypeError, ValueError) as exc:
125
+ raise FuploadError("Windows Local AppData could not be resolved", kind="trust_boundary") from exc
126
+
127
+
128
+ def trusted_roaming_dir(*parts: str) -> Path:
129
+ root = _known_folder_roaming().resolve()
130
+ current = root
131
+ for part in parts:
132
+ current = current / part
133
+ if current.exists() and _is_reparse_point(current):
134
+ raise FuploadError("trusted application path contains a reparse point", kind="trust_boundary")
135
+ resolved = current.resolve(strict=False)
136
+ if resolved != root and root not in resolved.parents:
137
+ raise FuploadError("trusted application path escaped Known Folder", kind="trust_boundary")
138
+ return resolved
139
+
140
+
141
+ def trusted_local_dir(*parts: str) -> Path:
142
+ root = _known_folder_local().resolve()
143
+ current = root
144
+ for part in parts:
145
+ current = current / part
146
+ if current.exists() and _is_reparse_point(current):
147
+ raise FuploadError("trusted application path contains a reparse point", kind="trust_boundary")
148
+ resolved = current.resolve(strict=False)
149
+ if resolved != root and root not in resolved.parents:
150
+ raise FuploadError("trusted application path escaped Known Folder", kind="trust_boundary")
151
+ return resolved
152
+
153
+
154
+ def _is_reparse_point(path: Path) -> bool:
155
+ if path.is_symlink():
156
+ return True
157
+ try:
158
+ attributes = os.stat(str(path), follow_symlinks=False).st_file_attributes
159
+ except (AttributeError, OSError):
160
+ return False
161
+ return bool(attributes & 0x400)
162
+
163
+
164
+ def _powershell_path() -> str:
165
+ if os.name != "nt":
166
+ raise FuploadError("DD executable signature validation requires Windows", kind="trust_boundary")
167
+ kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
168
+ kernel32.GetWindowsDirectoryW.argtypes = [ctypes.c_wchar_p, ctypes.c_uint32]
169
+ kernel32.GetWindowsDirectoryW.restype = ctypes.c_uint32
170
+ buffer = ctypes.create_unicode_buffer(32768)
171
+ length = kernel32.GetWindowsDirectoryW(buffer, len(buffer))
172
+ if not length:
173
+ raise FuploadError("Windows system directory could not be resolved", kind="trust_boundary")
174
+ return str(Path(buffer.value) / "System32" / "WindowsPowerShell" / "v1.0" / "powershell.exe")
175
+
176
+
177
+ def verify_dd_executable(executable: Path) -> Dict[str, str]:
178
+ powershell = _powershell_path()
179
+ script = (
180
+ "Import-Module Microsoft.PowerShell.Security -ErrorAction Stop; "
181
+ "$OutputEncoding = [Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false); "
182
+ "$s=Get-AuthenticodeSignature -LiteralPath '%s'; "
183
+ "[pscustomobject]@{Status=[string]$s.Status;Subject=[string]$s.SignerCertificate.Subject} "
184
+ "| ConvertTo-Json -Compress"
185
+ ) % str(executable).replace("'", "''")
186
+ try:
187
+ environment = os.environ.copy()
188
+ windows_root = str(Path(powershell).parents[3])
189
+ environment["PSModulePath"] = windows_root + "\\System32\\WindowsPowerShell\\v1.0\\Modules"
190
+ completed = subprocess.run(
191
+ [powershell, "-NoProfile", "-NonInteractive", "-Command", script],
192
+ capture_output=True, text=True, encoding="utf-8", errors="strict",
193
+ timeout=20, check=False, env=environment,
194
+ )
195
+ if completed.returncode != 0:
196
+ raise FuploadError("DD Authenticode verification process failed", kind="trust_boundary")
197
+ payload = json.loads((completed.stdout or "").strip() or "{}")
198
+ except (OSError, subprocess.SubprocessError, ValueError) as exc:
199
+ raise FuploadError("DD Authenticode verification failed", kind="trust_boundary") from exc
200
+ status = str(payload.get("Status") or "")
201
+ subject = str(payload.get("Subject") or "")
202
+ match = _SUBJECT_ORGANIZATION.search(subject)
203
+ publisher = (match.group(1) or match.group(2)).strip() if match else ""
204
+ normalized_publisher = publisher.casefold()
205
+ if status.casefold() != "valid" or normalized_publisher not in ALLOWED_DD_PUBLISHERS:
206
+ raise FuploadError("DD executable has no trusted official signature", kind="trust_boundary")
207
+ return {"status": status, "publisher": publisher}
@@ -0,0 +1,90 @@
1
+ #!/usr/bin/env node
2
+
3
+ import path from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+
6
+ import { recordManagedSkill } from "../lib/managed-install.mjs";
7
+ import { parseLauncherOptions, resolveSkillDirectory } from "../lib/options.mjs";
8
+ import { discoverPython, runPython } from "../lib/python.mjs";
9
+ import { ensureSkill } from "../lib/skill-installer.mjs";
10
+ import { uninstallSelf } from "../lib/uninstall.mjs";
11
+ import { updateSelf } from "../lib/update.mjs";
12
+
13
+ const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
14
+
15
+ function emitError(code, message, details = undefined) {
16
+ const record = { event: "error", code, message };
17
+ if (details) {
18
+ record.details = details;
19
+ }
20
+ process.stderr.write(`${JSON.stringify(record)}\n`);
21
+ }
22
+
23
+ async function main() {
24
+ let options;
25
+ try {
26
+ options = parseLauncherOptions(process.argv.slice(2));
27
+ } catch (error) {
28
+ emitError("LAUNCHER_ARGUMENT_INVALID", error.message);
29
+ return 2;
30
+ }
31
+ const target = resolveSkillDirectory({ explicit: options.skillDirectory });
32
+ if (options.forwarded.length === 1 && options.forwarded[0] === "update") {
33
+ try {
34
+ const result = await updateSelf({ packageRoot, target });
35
+ process.stdout.write(`${JSON.stringify(result)}\n`);
36
+ return 0;
37
+ } catch (error) {
38
+ emitError(error.code || "FUPLOAD_UPDATE_FAILED", error.message, error.details);
39
+ return 1;
40
+ }
41
+ }
42
+ if (options.forwarded.length === 1 && options.forwarded[0] === "uninstall") {
43
+ try {
44
+ const result = await uninstallSelf({ packageRoot, target });
45
+ process.stdout.write(`${JSON.stringify(result)}\n`);
46
+ return 0;
47
+ } catch (error) {
48
+ emitError(error.code || "FUPLOAD_UNINSTALL_FAILED", error.message, error.details);
49
+ return 1;
50
+ }
51
+ }
52
+
53
+ let ensured;
54
+ try {
55
+ ensured = await ensureSkill({ packageRoot, target });
56
+ recordManagedSkill(target);
57
+ } catch (error) {
58
+ emitError("SKILL_INSTALL_FAILED", error.message, { target });
59
+ return 1;
60
+ }
61
+ if (options.forwarded.length === 1 && options.forwarded[0] === "--version") {
62
+ process.stdout.write(`${ensured.distribution.packageRecord.version}\n`);
63
+ return 0;
64
+ }
65
+
66
+ const python = discoverPython();
67
+ if (!python) {
68
+ emitError(
69
+ "PYTHON_VERSION_UNSUPPORTED",
70
+ "Fuploader requires Python >=3.9,<4.0. Install Python and run the command again.",
71
+ );
72
+ return 1;
73
+ }
74
+ const script = path.join(target, "scripts", "fupload.py");
75
+ const result = runPython(python, script, options.forwarded);
76
+ if (result.error) {
77
+ emitError("PYTHON_LAUNCH_FAILED", result.error.message);
78
+ return 1;
79
+ }
80
+ if (
81
+ result.status === 0 &&
82
+ options.forwarded.length === 1 &&
83
+ ["--help", "-h"].includes(options.forwarded[0])
84
+ ) {
85
+ process.stdout.write("\nnpm management:\n fupload update Update this CLI and all managed Skills.\n fupload uninstall Remove managed Skills, this CLI, and its npm package.\n");
86
+ }
87
+ return result.status ?? 1;
88
+ }
89
+
90
+ process.exitCode = await main();
@@ -0,0 +1,86 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+
5
+ export const MANAGED_INSTALL_SCHEMA = "fupload.npm-managed-install.v1";
6
+ export const MANAGED_INSTALL_FILE = "managed-install.json";
7
+ const PACKAGE_NAME = "@follenfang/fupload";
8
+
9
+ function stateRoot({ platform = process.platform, env = process.env, home = os.homedir() } = {}) {
10
+ if (platform === "win32") {
11
+ return path.join(env.LOCALAPPDATA || path.join(home, "AppData", "Local"), "Fupload", "npm");
12
+ }
13
+ return path.join(env.XDG_STATE_HOME || path.join(home, ".local", "state"), "fupload", "npm");
14
+ }
15
+
16
+ function atomicWriteJson(filename, value) {
17
+ fs.mkdirSync(path.dirname(filename), { recursive: true });
18
+ const temporary = `${filename}.${process.pid}.${Date.now()}.tmp`;
19
+ fs.writeFileSync(temporary, `${JSON.stringify(value, null, 2)}\n`, {
20
+ encoding: "utf8",
21
+ mode: 0o600,
22
+ });
23
+ fs.renameSync(temporary, filename);
24
+ }
25
+
26
+ export function managedInstallFile(options = {}) {
27
+ return path.join(stateRoot(options), MANAGED_INSTALL_FILE);
28
+ }
29
+
30
+ export function readManagedInstall(options = {}) {
31
+ const filename = options.filename || managedInstallFile(options);
32
+ try {
33
+ const value = JSON.parse(fs.readFileSync(filename, "utf8"));
34
+ if (
35
+ value?.schema !== MANAGED_INSTALL_SCHEMA ||
36
+ value?.package_name !== PACKAGE_NAME ||
37
+ !Array.isArray(value.targets)
38
+ ) {
39
+ throw new Error("unsupported schema");
40
+ }
41
+ return value;
42
+ } catch (error) {
43
+ if (error.code === "ENOENT") {
44
+ return { schema: MANAGED_INSTALL_SCHEMA, package_name: PACKAGE_NAME, targets: [] };
45
+ }
46
+ throw new Error(`Managed Skill registry is invalid: ${filename}`, { cause: error });
47
+ }
48
+ }
49
+
50
+ export function recordManagedSkill(target, options = {}) {
51
+ const filename = options.filename || managedInstallFile(options);
52
+ const resolved = path.resolve(target);
53
+ if (resolved === path.parse(resolved).root) {
54
+ throw new Error("A filesystem root cannot be registered as a managed Skill.");
55
+ }
56
+ const state = readManagedInstall({ ...options, filename });
57
+ const targets = state.targets
58
+ .filter((entry) => typeof entry?.path === "string" && path.resolve(entry.path) !== resolved)
59
+ .map((entry) => ({ path: path.resolve(entry.path), registered_at: entry.registered_at }));
60
+ targets.push({ path: resolved, registered_at: new Date().toISOString() });
61
+ targets.sort((left, right) => left.path.localeCompare(right.path));
62
+ atomicWriteJson(filename, {
63
+ schema: MANAGED_INSTALL_SCHEMA,
64
+ package_name: PACKAGE_NAME,
65
+ targets,
66
+ });
67
+ return { filename, target: resolved, count: targets.length };
68
+ }
69
+
70
+ export function clearManagedInstall(options = {}) {
71
+ const filename = options.filename || managedInstallFile(options);
72
+ fs.rmSync(filename, { force: true });
73
+ const npmState = path.dirname(filename);
74
+ const productState = path.dirname(npmState);
75
+ for (const directory of [npmState, productState]) {
76
+ try {
77
+ if (fs.readdirSync(directory).length === 0) {
78
+ fs.rmdirSync(directory);
79
+ }
80
+ } catch (error) {
81
+ if (error.code !== "ENOENT" && error.code !== "ENOTEMPTY") {
82
+ throw error;
83
+ }
84
+ }
85
+ }
86
+ }
@@ -0,0 +1,38 @@
1
+ import os from "node:os";
2
+ import path from "node:path";
3
+
4
+ export function resolveSkillDirectory({ explicit, env = process.env, home } = {}) {
5
+ if (explicit) {
6
+ return path.resolve(explicit);
7
+ }
8
+ if (env.FUPLOAD_AGENT_HOME) {
9
+ return path.resolve(env.FUPLOAD_AGENT_HOME, "skills", "fupload");
10
+ }
11
+ return path.join(home || os.homedir(), ".agents", "skills", "fupload");
12
+ }
13
+
14
+ export function parseLauncherOptions(argv) {
15
+ const forwarded = [];
16
+ let skillDirectory;
17
+ for (let index = 0; index < argv.length; index += 1) {
18
+ const value = argv[index];
19
+ if (value === "--skill-dir") {
20
+ const candidate = argv[index + 1];
21
+ if (!candidate || candidate.startsWith("--")) {
22
+ throw new Error("--skill-dir requires a directory path.");
23
+ }
24
+ skillDirectory = candidate;
25
+ index += 1;
26
+ continue;
27
+ }
28
+ if (value.startsWith("--skill-dir=")) {
29
+ skillDirectory = value.slice("--skill-dir=".length);
30
+ if (!skillDirectory) {
31
+ throw new Error("--skill-dir requires a directory path.");
32
+ }
33
+ continue;
34
+ }
35
+ forwarded.push(value);
36
+ }
37
+ return { forwarded, skillDirectory };
38
+ }
@@ -0,0 +1,45 @@
1
+ import { spawnSync } from "node:child_process";
2
+
3
+ function probe(command, args, minimum) {
4
+ const result = spawnSync(command, [...args, "-c", "import sys; print('.'.join(map(str, sys.version_info[:3])))"], {
5
+ encoding: "utf8",
6
+ shell: false,
7
+ windowsHide: true,
8
+ });
9
+ if (result.status !== 0) {
10
+ return null;
11
+ }
12
+ const match = result.stdout.trim().match(/^(\d+)\.(\d+)\.(\d+)$/);
13
+ if (!match) {
14
+ return null;
15
+ }
16
+ const version = match.slice(1).map(Number);
17
+ if (version[0] !== 3 || version[1] < minimum) {
18
+ return null;
19
+ }
20
+ return { command, args, version };
21
+ }
22
+
23
+ export function discoverPython({ platform = process.platform, minimumMinor = 9 } = {}) {
24
+ const candidates = platform === "win32"
25
+ ? [["python", []], ["py", ["-3"]], ["python3", []]]
26
+ : [["python3", []], ["python", []]];
27
+ for (const [command, args] of candidates) {
28
+ const result = probe(command, args, minimumMinor);
29
+ if (result) {
30
+ return result;
31
+ }
32
+ }
33
+ return null;
34
+ }
35
+
36
+ export function runPython(python, script, args, options = {}) {
37
+ return spawnSync(python.command, [...python.args, script, ...args], {
38
+ cwd: options.cwd || process.cwd(),
39
+ env: options.env || process.env,
40
+ stdio: options.stdio || "inherit",
41
+ encoding: options.encoding,
42
+ shell: false,
43
+ windowsHide: true,
44
+ });
45
+ }