@passioncode-ai/fabric-agent-adapter 0.8.3 → 0.10.0
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.
- package/CHANGELOG.md +100 -0
- package/README.md +14 -3
- package/package.json +1 -1
- package/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
- package/plugins/fabric-agent-adapter/hooks/hooks.json +17 -0
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +3 -3
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/adapt_project.py +2 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +21 -8
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/demo.html +110 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-dashboard.css +198 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-dashboard.js +253 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/kit.json +28 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/themes.json +350 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-kit.md +133 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard.md +4 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +25 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/remote-placement.md +7 -4
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/surfaces-and-auth.md +19 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_dashboard_layout.mjs +295 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +88 -5
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/dashboard_kit.py +437 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +143 -17
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +195 -36
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/sample-remote-service.mjs +9 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/sample_service.py +29 -4
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +3 -3
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py
CHANGED
|
@@ -24,6 +24,7 @@ exist. They never call a capability.
|
|
|
24
24
|
from __future__ import annotations
|
|
25
25
|
|
|
26
26
|
import argparse
|
|
27
|
+
import email.utils
|
|
27
28
|
import errno
|
|
28
29
|
import http.client
|
|
29
30
|
import json
|
|
@@ -366,6 +367,31 @@ class Probe:
|
|
|
366
367
|
self.add("usage.report", "FAIL" if problems else "PASS",
|
|
367
368
|
"; ".join(problems[:5]) if problems else "%d day(s), unknown cost kept null" % len(days or []))
|
|
368
369
|
|
|
370
|
+
# dashboard (fabric-dashboard/0.1, DEC-0036) --------------------------------
|
|
371
|
+
def dashboard_rules(self) -> None:
|
|
372
|
+
wk = self.wk or {}
|
|
373
|
+
if not (wk.get("surfaces") or {}).get("dashboard"):
|
|
374
|
+
self.add("dashboard.declared", "NOT_RUN", "no surfaces.dashboard: nothing to declare")
|
|
375
|
+
return
|
|
376
|
+
extensions = wk.get("extensions") if isinstance(wk.get("extensions"), dict) else {}
|
|
377
|
+
if fs.DASHBOARD_EXTENSION_KEY not in extensions:
|
|
378
|
+
self.add("dashboard.declared", "NOT_RUN", "a legacy dashboard: no extensions dashboard/0.1 declaration (ED-9 SHOULD); "
|
|
379
|
+
"a host still gives it 480 px (ED-7). Build it from the dashboard kit and declare it")
|
|
380
|
+
return
|
|
381
|
+
value = extensions[fs.DASHBOARD_EXTENSION_KEY]
|
|
382
|
+
problems = fs.dashboard_extension_problems(value)
|
|
383
|
+
if problems:
|
|
384
|
+
self.add("dashboard.declared", "FAIL", "; ".join(problems))
|
|
385
|
+
return
|
|
386
|
+
note = "minWidth %d, host floor %d" % (value["minWidth"], max(480, value["minWidth"]))
|
|
387
|
+
if value.get("kit"):
|
|
388
|
+
note += ", " + value["kit"]
|
|
389
|
+
name, _, version = str(value["kit"]).partition("@")
|
|
390
|
+
current = fs.DASHBOARD_KIT.partition("@")[2]
|
|
391
|
+
if name == fs.DASHBOARD_KIT.partition("@")[0] and _semver(version) < _semver(current):
|
|
392
|
+
note += " (kit %s is current: dashboard_kit.py status)" % current
|
|
393
|
+
self.add("dashboard.declared", "PASS", note + "; the page itself: check_dashboard_layout.mjs")
|
|
394
|
+
|
|
369
395
|
def login_rules(self) -> None:
|
|
370
396
|
dashboard = ((self.wk or {}).get("surfaces") or {}).get("dashboard")
|
|
371
397
|
if not dashboard or not dashboard.get("login"):
|
|
@@ -391,6 +417,12 @@ class Probe:
|
|
|
391
417
|
"the session cookie is __Host-, Secure, Path=/, no Domain" if host_bound else "cookie: %s" % cookie.split(";", 1)[0].split("=")[0])
|
|
392
418
|
self.add("login.single-use", "PASS" if ok else "FAIL",
|
|
393
419
|
"first redeem HTTP %d (%s), second HTTP %d" % (status1, "cookie ok" if "HttpOnly" in cookie else "no HttpOnly cookie", status2))
|
|
420
|
+
if cookie:
|
|
421
|
+
problem = session_lifetime_problem(cookie)
|
|
422
|
+
self.add("login.session-lifetime", "FAIL" if problem else "PASS",
|
|
423
|
+
problem or "the session cookie lives at most 7 days (DEC-0039); the server's own bounds are not observable from here")
|
|
424
|
+
else:
|
|
425
|
+
self.add("login.session-lifetime", "NOT_RUN", "no session cookie was set")
|
|
394
426
|
|
|
395
427
|
# interop (fabric-interop/0.1) ----------------------------------------------------
|
|
396
428
|
# #region probe-interop — docs: plugins/fabric-agent-adapter/skills/building-fabric-services/references/interop.md#what-the-probe-checks
|
|
@@ -454,21 +486,25 @@ class Probe:
|
|
|
454
486
|
extra = sorted(set(listed) - names)
|
|
455
487
|
self.add("interop.well-known-capabilities", "FAIL" if extra else "PASS",
|
|
456
488
|
("listed but not in the manifest: " + ", ".join(extra)) if extra else "%d listed, all in the manifest" % len(listed))
|
|
457
|
-
|
|
489
|
+
auth = (mcp_surface or {}).get("auth")
|
|
490
|
+
# DEC-0024 own, and DEC-0039 DL-16 delegation: the descriptor's token is not an MCP credential.
|
|
491
|
+
own = bool(mcp_surface) and auth in ("own", "delegation")
|
|
492
|
+
why = "DL-16, DEC-0039" if auth == "delegation" else "DEC-0024"
|
|
493
|
+
self.delegation_declared_rule(mcp_surface)
|
|
458
494
|
if own and self.token:
|
|
459
|
-
#
|
|
495
|
+
# The MCP surface has credentials of its own and must refuse the host's token.
|
|
460
496
|
try:
|
|
461
497
|
self.mcp_call("tools/list", {})
|
|
462
|
-
self.add("interop.mcp-own-auth", "FAIL", "surfaces.mcp.auth is
|
|
498
|
+
self.add("interop.mcp-own-auth", "FAIL", "surfaces.mcp.auth is %s, yet the MCP surface accepted the descriptor's token (%s)" % (auth, why))
|
|
463
499
|
except OSError as exc:
|
|
464
500
|
refused = any(code in str(exc) for code in ("HTTP 401", "HTTP 403"))
|
|
465
501
|
self.add("interop.mcp-own-auth", "PASS" if refused else "NOT_RUN",
|
|
466
|
-
("the MCP surface refuses the host's token (%s), as auth:
|
|
502
|
+
("the MCP surface refuses the host's token (%s), as auth: %s says (%s)" % (exc, auth, why)) if refused else str(exc))
|
|
467
503
|
except ValueError as exc:
|
|
468
504
|
self.add("interop.mcp-own-auth", "NOT_RUN", str(exc))
|
|
469
505
|
if not mcp_surface or not self.token or own:
|
|
470
506
|
reason = ("no MCP surface" if not mcp_surface
|
|
471
|
-
else "surfaces.mcp.auth is
|
|
507
|
+
else "surfaces.mcp.auth is %s: the MCP surface takes its callers' credentials, not the host's token (%s)" % (auth, why) if own
|
|
472
508
|
else "no readable token")
|
|
473
509
|
for rule in ("interop.output-schema-object", "interop.tools-match", "interop.job-tools", "interop.unknown-job", "interop.trace-propagation"):
|
|
474
510
|
self.add(rule, "NOT_RUN", reason)
|
|
@@ -488,6 +524,23 @@ class Probe:
|
|
|
488
524
|
self.trace_rule(listing, sent)
|
|
489
525
|
self.events_trace_rule(self.events)
|
|
490
526
|
|
|
527
|
+
def delegation_declared_rule(self, mcp_surface: Optional[Dict[str, Any]]) -> None:
|
|
528
|
+
"""DL-16 (DEC-0039): a service that accepts delegation tokens names the issuer it trusts and its JWKS
|
|
529
|
+
in auth.delegation; with surfaces.mcp.auth delegation that declaration is required."""
|
|
530
|
+
auth = (self.wk or {}).get("auth")
|
|
531
|
+
declared = auth.get("delegation") if isinstance(auth, dict) else None
|
|
532
|
+
required = bool(mcp_surface) and mcp_surface.get("auth") == "delegation"
|
|
533
|
+
if declared is None and not required:
|
|
534
|
+
self.add("auth.delegation-declared", "NOT_RUN", "the service declares no delegation")
|
|
535
|
+
return
|
|
536
|
+
if not isinstance(declared, dict):
|
|
537
|
+
self.add("auth.delegation-declared", "FAIL", "surfaces.mcp.auth is delegation, yet auth.delegation is missing (DL-16)")
|
|
538
|
+
return
|
|
539
|
+
missing = [k for k in ("issuer", "jwks") if not isinstance(declared.get(k), str) or not declared.get(k)]
|
|
540
|
+
self.add("auth.delegation-declared", "FAIL" if missing else "PASS",
|
|
541
|
+
("auth.delegation lacks %s (DL-16)" % ", ".join(missing)) if missing
|
|
542
|
+
else "auth.delegation names its issuer and JWKS (DL-16)")
|
|
543
|
+
|
|
491
544
|
def object_root_rule(self, tools: Dict[str, Any]) -> None:
|
|
492
545
|
"""FAC-SEM-023 (DEC-0018): every listed tool's outputSchema, when present, has root type object —
|
|
493
546
|
a client may refuse the whole tools/list otherwise, whatever the SDK in the tests accepted."""
|
|
@@ -696,12 +749,42 @@ class Probe:
|
|
|
696
749
|
self.network_rules()
|
|
697
750
|
self.auth_rules()
|
|
698
751
|
self.usage_rules()
|
|
752
|
+
self.dashboard_rules()
|
|
699
753
|
self.login_rules()
|
|
700
754
|
self.interop_rules()
|
|
701
755
|
self.lifecycle_rules()
|
|
702
756
|
return self.results
|
|
703
757
|
|
|
704
758
|
|
|
759
|
+
def session_lifetime_problem(set_cookie: str, now: Optional[float] = None) -> Optional[str]:
|
|
760
|
+
"""DEC-0039: a session cookie set at sign-in lives at most 7 days. Max-Age wins over Expires (RFC 6265
|
|
761
|
+
§5.3); a cookie with neither ends with the browser session. None when it is within the bound."""
|
|
762
|
+
attrs = {}
|
|
763
|
+
for part in set_cookie.split(";")[1:]:
|
|
764
|
+
name, _, value = part.strip().partition("=")
|
|
765
|
+
attrs[name.strip().lower()] = value.strip()
|
|
766
|
+
bound = fs.OPERATOR_SESSION_MAX_SECONDS
|
|
767
|
+
if "max-age" in attrs:
|
|
768
|
+
if not re.fullmatch(r"-?\d+", attrs["max-age"]):
|
|
769
|
+
return "the session cookie's Max-Age %r is not a number" % attrs["max-age"]
|
|
770
|
+
if int(attrs["max-age"]) > bound:
|
|
771
|
+
return "the session cookie's Max-Age %s outlives 7 days (%d s, DEC-0039)" % (attrs["max-age"], bound)
|
|
772
|
+
return None
|
|
773
|
+
if "expires" in attrs:
|
|
774
|
+
try:
|
|
775
|
+
at = email.utils.parsedate_to_datetime(attrs["expires"]).timestamp()
|
|
776
|
+
except (TypeError, ValueError, IndexError, OverflowError):
|
|
777
|
+
return "the session cookie's Expires %r is not a date" % attrs["expires"]
|
|
778
|
+
if at - (time.time() if now is None else now) > bound:
|
|
779
|
+
return "the session cookie's Expires %s outlives 7 days (DEC-0039)" % attrs["expires"]
|
|
780
|
+
return None
|
|
781
|
+
|
|
782
|
+
|
|
783
|
+
def _semver(text: str) -> Tuple[int, int, int]:
|
|
784
|
+
m = re.match(r"^(\d+)\.(\d+)\.(\d+)", text or "")
|
|
785
|
+
return (int(m.group(1)), int(m.group(2)), int(m.group(3))) if m else (0, 0, 0)
|
|
786
|
+
|
|
787
|
+
|
|
705
788
|
def usage_problems(report: Any, service_id: str, instance: str) -> List[str]:
|
|
706
789
|
"""What makes this not a DEC-0021 usage report of the service: shape, identity and the sums of
|
|
707
790
|
FAC-SEM-025. An unknown cost must be null; a day's totals are the sums of its models."""
|
|
@@ -0,0 +1,437 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""The dashboard kit (fabric-dashboard/0.1, Fabric Agent Contract DEC-0036): status, install, build, notice.
|
|
3
|
+
|
|
4
|
+
dashboard_kit.py status [REPO] [--json] # kit copies in a repository: version, state, what differs
|
|
5
|
+
dashboard_kit.py install DIR [--force] [--dry-run]
|
|
6
|
+
# copy fabric-dashboard.css and .js into DIR; a copy the
|
|
7
|
+
# service changed is never overwritten without --force
|
|
8
|
+
dashboard_kit.py build [--check] # regenerate the theme block from themes.json, refresh kit.json
|
|
9
|
+
dashboard_kit.py notice # the plugin's SessionStart hook: one line, or nothing
|
|
10
|
+
|
|
11
|
+
The kit lives next to this script in ../assets/dashboard-kit/. Standard library only; nothing here
|
|
12
|
+
reads a token, opens a port or writes outside the directory it is given. `notice` never writes and
|
|
13
|
+
always exits 0: a hook that fails would cost every session, in every repository.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
# argparse, hashlib and tempfile are imported where they are used: the SessionStart notice runs in
|
|
19
|
+
# every session, so its path imports only what it needs.
|
|
20
|
+
import json
|
|
21
|
+
import os
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
import re
|
|
24
|
+
import subprocess
|
|
25
|
+
import sys
|
|
26
|
+
from typing import Any, Dict, List, Optional, Sequence, Tuple
|
|
27
|
+
|
|
28
|
+
KIT_DIR = Path(__file__).resolve().parent.parent / "assets" / "dashboard-kit"
|
|
29
|
+
KIT_NAME = "fabric-dashboard-kit"
|
|
30
|
+
COPIED = ("fabric-dashboard.css", "fabric-dashboard.js")
|
|
31
|
+
MARKER = re.compile(r"fabric-dashboard-kit@(\d+)\.(\d+)\.(\d+)(?:-[0-9A-Za-z.-]+)?")
|
|
32
|
+
CSS_BEGIN, CSS_END = "/* fabric-dashboard-kit:themes:begin */", "/* fabric-dashboard-kit:themes:end */"
|
|
33
|
+
JS_BEGIN, JS_END = "/* fabric-dashboard-kit:theme-ids:begin */", "/* fabric-dashboard-kit:theme-ids:end */"
|
|
34
|
+
TOKEN_ORDER = ("bg", "panel", "panel-2", "ink", "muted", "border", "border-strong", "edge", "accent",
|
|
35
|
+
"accent-weak", "accent-ink", "ok", "ok-weak", "warn", "warn-weak", "danger", "danger-weak",
|
|
36
|
+
"info", "info-weak")
|
|
37
|
+
SKIP_DIRS = {".git", "node_modules", "dist", "build", ".venv", "venv", "__pycache__", ".next", "out"}
|
|
38
|
+
EXTENSION_KEY = "https://fabric.passioncode.ai/agent-contract/extensions/dashboard/0.1"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class KitError(Exception):
|
|
42
|
+
pass
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
# --- the kit itself ------------------------------------------------------------
|
|
46
|
+
|
|
47
|
+
def load_manifest(kit_dir: Path = KIT_DIR) -> Dict[str, Any]:
|
|
48
|
+
return json.loads((kit_dir / "kit.json").read_text(encoding="utf-8"))
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def sha256(path: Path) -> str:
|
|
52
|
+
import hashlib
|
|
53
|
+
return hashlib.sha256(path.read_bytes()).hexdigest()
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def version_tuple(text: str) -> Optional[Tuple[int, int, int]]:
|
|
57
|
+
m = MARKER.search(text)
|
|
58
|
+
return (int(m.group(1)), int(m.group(2)), int(m.group(3))) if m else None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def claimed_version(path: Path) -> Optional[str]:
|
|
62
|
+
"""The kit version a copied file says it is, from the marker in its first lines."""
|
|
63
|
+
try:
|
|
64
|
+
with path.open("r", encoding="utf-8", errors="replace") as handle:
|
|
65
|
+
head = handle.read(600)
|
|
66
|
+
except OSError:
|
|
67
|
+
return None
|
|
68
|
+
m = MARKER.search(head)
|
|
69
|
+
return m.group(0).split("@", 1)[1] if m else None
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def parse_version(text: str) -> Tuple[int, int, int]:
|
|
73
|
+
found = version_tuple(KIT_NAME + "@" + text)
|
|
74
|
+
if not found:
|
|
75
|
+
raise KitError("%r is not a semantic version." % text)
|
|
76
|
+
return found
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def theme_css(themes: Dict[str, Any]) -> str:
|
|
80
|
+
"""The generated theme block: graphite is the default for :root; every theme has a light rule,
|
|
81
|
+
a [data-scheme=dark] rule and a prefers-color-scheme rule that a data-scheme="light" opts out of."""
|
|
82
|
+
default = themes["default"]
|
|
83
|
+
entries = themes["themes"]
|
|
84
|
+
if default not in entries:
|
|
85
|
+
raise KitError("themes.json: the default theme %r is not defined." % default)
|
|
86
|
+
|
|
87
|
+
def decls(palette: Dict[str, str], scheme: str) -> str:
|
|
88
|
+
missing = [t for t in TOKEN_ORDER if t not in palette]
|
|
89
|
+
if missing:
|
|
90
|
+
raise KitError("themes.json: %s %s misses %s." % (scheme, palette, ", ".join(missing)))
|
|
91
|
+
return "color-scheme: %s; " % scheme + " ".join("--%s: %s;" % (t, palette[t]) for t in TOKEN_ORDER)
|
|
92
|
+
|
|
93
|
+
lines, media = [], []
|
|
94
|
+
for theme_id, theme in entries.items():
|
|
95
|
+
if not re.fullmatch(r"[a-z][a-z0-9-]{0,31}", theme_id):
|
|
96
|
+
raise KitError("themes.json: %r is not a theme id." % theme_id)
|
|
97
|
+
own = ':root[data-theme="%s"]' % theme_id
|
|
98
|
+
if theme_id == default:
|
|
99
|
+
lines.append(":root, %s { %s }" % (own, decls(theme["light"], "light")))
|
|
100
|
+
lines.append(':root[data-scheme="dark"], %s[data-scheme="dark"] { %s }' % (own, decls(theme["dark"], "dark")))
|
|
101
|
+
media.insert(0, ' :root:not([data-scheme="light"]) { %s }' % decls(theme["dark"], "dark"))
|
|
102
|
+
else:
|
|
103
|
+
lines.append("%s { %s }" % (own, decls(theme["light"], "light")))
|
|
104
|
+
lines.append('%s[data-scheme="dark"] { %s }' % (own, decls(theme["dark"], "dark")))
|
|
105
|
+
media.append(' %s:not([data-scheme="light"]) { %s }' % (own, decls(theme["dark"], "dark")))
|
|
106
|
+
return "\n".join(lines + ["@media (prefers-color-scheme: dark) {"] + media + ["}"])
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def replace_between(text: str, begin: str, end: str, body: str, name: str) -> str:
|
|
110
|
+
start, stop = text.find(begin), text.find(end)
|
|
111
|
+
if start < 0 or stop < start:
|
|
112
|
+
raise KitError("%s has lost its %s … %s markers." % (name, begin, end))
|
|
113
|
+
return text[: start + len(begin)] + "\n" + body + "\n" + text[stop:]
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def build(kit_dir: Path = KIT_DIR, check: bool = False) -> List[str]:
|
|
117
|
+
"""Regenerate the theme block and the theme ids from themes.json and refresh kit.json.
|
|
118
|
+
Returns the files that changed (with check=True: that would change, and nothing is written)."""
|
|
119
|
+
themes = json.loads((kit_dir / "themes.json").read_text(encoding="utf-8"))
|
|
120
|
+
css_path, js_path = kit_dir / "fabric-dashboard.css", kit_dir / "fabric-dashboard.js"
|
|
121
|
+
css = css_path.read_text(encoding="utf-8")
|
|
122
|
+
js = js_path.read_text(encoding="utf-8")
|
|
123
|
+
ids = list(themes["themes"])
|
|
124
|
+
new_css = replace_between(css, CSS_BEGIN, CSS_END, theme_css(themes), css_path.name)
|
|
125
|
+
new_js = replace_between(js, JS_BEGIN, JS_END, " var THEMES = %s;" % json.dumps(ids).replace('"', "'"), js_path.name)
|
|
126
|
+
manifest = load_manifest(kit_dir)
|
|
127
|
+
version = manifest["version"]
|
|
128
|
+
for text, name in ((new_css, css_path.name), (new_js, js_path.name)):
|
|
129
|
+
if claimed_version_text(text) != version:
|
|
130
|
+
raise KitError("%s says %s, kit.json says %s; raise them together." % (name, claimed_version_text(text), version))
|
|
131
|
+
import hashlib
|
|
132
|
+
changed = []
|
|
133
|
+
for path, text in ((css_path, new_css), (js_path, new_js)):
|
|
134
|
+
if text != path.read_text(encoding="utf-8"):
|
|
135
|
+
changed.append(path.name)
|
|
136
|
+
if not check:
|
|
137
|
+
atomic_write(path, text)
|
|
138
|
+
digest = {name: hashlib.sha256((new_css if name.endswith(".css") else new_js).encode("utf-8")).hexdigest() for name in COPIED}
|
|
139
|
+
digest["themes.json"] = sha256(kit_dir / "themes.json")
|
|
140
|
+
new_manifest = dict(manifest)
|
|
141
|
+
new_manifest["themes"] = ids
|
|
142
|
+
new_manifest["files"] = digest
|
|
143
|
+
history = dict(manifest.get("history") or {})
|
|
144
|
+
history[version] = {name: digest[name] for name in COPIED}
|
|
145
|
+
new_manifest["history"] = history
|
|
146
|
+
if new_manifest != manifest:
|
|
147
|
+
changed.append("kit.json")
|
|
148
|
+
if not check:
|
|
149
|
+
atomic_write(kit_dir / "kit.json", json.dumps(new_manifest, indent=2) + "\n")
|
|
150
|
+
return changed
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def claimed_version_text(text: str) -> Optional[str]:
|
|
154
|
+
m = MARKER.search(text[:600])
|
|
155
|
+
return m.group(0).split("@", 1)[1] if m else None
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def atomic_write(path: Path, text: str, mode: int = 0o644) -> None:
|
|
159
|
+
import tempfile
|
|
160
|
+
fd, tmp = tempfile.mkstemp(prefix="." + path.name + ".", dir=str(path.parent))
|
|
161
|
+
try:
|
|
162
|
+
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
|
163
|
+
handle.write(text)
|
|
164
|
+
handle.flush()
|
|
165
|
+
os.fsync(handle.fileno())
|
|
166
|
+
os.chmod(tmp, mode)
|
|
167
|
+
os.replace(tmp, path)
|
|
168
|
+
except BaseException:
|
|
169
|
+
try:
|
|
170
|
+
os.unlink(tmp)
|
|
171
|
+
except OSError:
|
|
172
|
+
pass
|
|
173
|
+
raise
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
# --- copies in a repository -------------------------------------------------------
|
|
177
|
+
|
|
178
|
+
def is_kit_source(path: Path) -> bool:
|
|
179
|
+
"""The kit's own directory (this repository, or an installed copy of the plugin) is not a copy."""
|
|
180
|
+
manifest = path.parent / "kit.json"
|
|
181
|
+
try:
|
|
182
|
+
return json.loads(manifest.read_text(encoding="utf-8")).get("name") == KIT_NAME
|
|
183
|
+
except (OSError, ValueError, AttributeError):
|
|
184
|
+
return False
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def git(repo: Path, *args: str, timeout: float = 2.0) -> Optional[str]:
|
|
188
|
+
try:
|
|
189
|
+
done = subprocess.run(["git", "-C", str(repo)] + list(args), capture_output=True, text=True, timeout=timeout)
|
|
190
|
+
except (OSError, subprocess.SubprocessError):
|
|
191
|
+
return None
|
|
192
|
+
return done.stdout if done.returncode == 0 else None
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def repo_root(start: Path) -> Optional[Path]:
|
|
196
|
+
out = git(start, "rev-parse", "--show-toplevel")
|
|
197
|
+
return Path(out.strip()) if out and out.strip() else None
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def find_copies(repo: Path) -> List[Path]:
|
|
201
|
+
listed = git(repo, "ls-files", "-z", "--cached", "--others", "--exclude-standard", "--",
|
|
202
|
+
"*fabric-dashboard.css", "*fabric-dashboard.js")
|
|
203
|
+
if listed is not None:
|
|
204
|
+
paths = [repo / p for p in listed.split("\0") if p]
|
|
205
|
+
else:
|
|
206
|
+
paths = []
|
|
207
|
+
for top, dirs, files in os.walk(repo):
|
|
208
|
+
dirs[:] = [d for d in dirs if d not in SKIP_DIRS]
|
|
209
|
+
if len(Path(top).relative_to(repo).parts) > 6:
|
|
210
|
+
dirs[:] = []
|
|
211
|
+
paths += [Path(top) / f for f in files if f in COPIED]
|
|
212
|
+
return sorted(p for p in paths if p.name in COPIED and p.is_file() and not is_kit_source(p))
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def copy_state(path: Path, manifest: Dict[str, Any]) -> Dict[str, Any]:
|
|
216
|
+
current = manifest["version"]
|
|
217
|
+
version = claimed_version(path)
|
|
218
|
+
digest = sha256(path)
|
|
219
|
+
known = ((manifest.get("history") or {}).get(version or "") or {}).get(path.name)
|
|
220
|
+
if version is None:
|
|
221
|
+
state = "unmarked"
|
|
222
|
+
elif parse_version(version) > parse_version(current):
|
|
223
|
+
state = "newer"
|
|
224
|
+
elif known is not None and digest != known:
|
|
225
|
+
state = "changed"
|
|
226
|
+
elif parse_version(version) < parse_version(current):
|
|
227
|
+
state = "older"
|
|
228
|
+
else:
|
|
229
|
+
state = "current"
|
|
230
|
+
return {"path": str(path), "file": path.name, "version": version, "state": state, "current": current}
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def services_dir() -> Path:
|
|
234
|
+
override = os.environ.get("FABRIC_SERVICES_DIR")
|
|
235
|
+
if override:
|
|
236
|
+
return Path(override).expanduser()
|
|
237
|
+
home = Path.home()
|
|
238
|
+
if sys.platform == "darwin":
|
|
239
|
+
return home / "Library/Application Support/ai.passioncode.fabric/services"
|
|
240
|
+
if sys.platform == "win32":
|
|
241
|
+
base = os.environ.get("LOCALAPPDATA") or str(home / "AppData/Local")
|
|
242
|
+
return Path(base) / "passioncode-fabric" / "services"
|
|
243
|
+
return Path(os.environ.get("XDG_DATA_HOME") or str(home / ".local/share")) / "passioncode-fabric/services"
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def same_repository(a: str, b: str) -> bool:
|
|
247
|
+
def norm(u: str) -> str:
|
|
248
|
+
u = u.strip().lower()
|
|
249
|
+
u = re.sub(r"^git@([^:]+):", r"\1/", u)
|
|
250
|
+
u = re.sub(r"^[a-z+]+://", "", u)
|
|
251
|
+
u = re.sub(r"^[^@/]+@", "", u)
|
|
252
|
+
return re.sub(r"\.git$", "", u).rstrip("/")
|
|
253
|
+
return bool(a and b) and norm(a) == norm(b)
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def services_of(repo: Path, directory: Optional[Path] = None) -> List[str]:
|
|
257
|
+
"""The services whose descriptor names this repository as its source (`source.repository`)."""
|
|
258
|
+
origin = (git(repo, "config", "--get", "remote.origin.url") or "").strip()
|
|
259
|
+
if not origin:
|
|
260
|
+
return []
|
|
261
|
+
found = []
|
|
262
|
+
try:
|
|
263
|
+
entries = sorted((directory or services_dir()).glob("*.json"))
|
|
264
|
+
except OSError:
|
|
265
|
+
return []
|
|
266
|
+
for path in entries:
|
|
267
|
+
try:
|
|
268
|
+
d = json.loads(path.read_text(encoding="utf-8"))
|
|
269
|
+
except (OSError, ValueError):
|
|
270
|
+
continue
|
|
271
|
+
source = str(((d.get("source") or {}) if isinstance(d, dict) else {}).get("repository") or "")
|
|
272
|
+
if same_repository(source, origin):
|
|
273
|
+
found.append("%s.%s" % (d.get("id"), d.get("instance", "default")))
|
|
274
|
+
return found
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def declares_dashboard(repo: Path) -> bool:
|
|
278
|
+
"""Whether the repository's code declares a dashboard surface (a quick git grep, no network)."""
|
|
279
|
+
out = git(repo, "grep", "--untracked", "-l", "-I", "-E", r"""["']?dashboard["']?[[:space:]]*[:=][[:space:]]*\{?[[:space:]]*["']?path""", "--",
|
|
280
|
+
"*.py", "*.mjs", "*.js", "*.ts", "*.json", timeout=2.0)
|
|
281
|
+
return bool(out and out.strip())
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def status(repo: Path, manifest: Optional[Dict[str, Any]] = None, directory: Optional[Path] = None) -> Dict[str, Any]:
|
|
285
|
+
manifest = manifest or load_manifest()
|
|
286
|
+
copies = [copy_state(p, manifest) for p in find_copies(repo)]
|
|
287
|
+
services = services_of(repo, directory)
|
|
288
|
+
return {"repository": str(repo), "kit": "%s@%s" % (KIT_NAME, manifest["version"]), "copies": copies,
|
|
289
|
+
"services": services, "dashboard": bool(copies) or (bool(services) and declares_dashboard(repo))}
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def notice_line(report: Dict[str, Any], script: Path) -> Optional[str]:
|
|
293
|
+
"""One line for the session, or None. Only an older (or unmarked) copy, or a service of this
|
|
294
|
+
repository with a dashboard and no kit at all, earns it."""
|
|
295
|
+
current = report["kit"].split("@", 1)[1]
|
|
296
|
+
stale = [c for c in report["copies"] if c["state"] in ("older", "unmarked")]
|
|
297
|
+
where = ", ".join(report["services"]) or "this repository's dashboard"
|
|
298
|
+
command = 'python3 "%s" status' % script
|
|
299
|
+
tail = ("fabric-dashboard/0.1, contract DEC-0036. Do not change it on your own: tell the operator, and with "
|
|
300
|
+
"their go-ahead run `%s`, then follow building-fabric-services references/dashboard-kit.md, "
|
|
301
|
+
"\"Upgrading an existing dashboard\"." % command)
|
|
302
|
+
if stale:
|
|
303
|
+
versions = sorted({c["version"] or "unmarked" for c in stale})
|
|
304
|
+
return "[fabric-agent-adapter] %s is built from %s %s; %s is current (%s" % (
|
|
305
|
+
where, KIT_NAME, " and ".join(versions), current, tail + ")")
|
|
306
|
+
if not report["copies"] and report["dashboard"] and report["services"]:
|
|
307
|
+
return "[fabric-agent-adapter] %s has a dashboard without %s; %s is current (%s" % (
|
|
308
|
+
where, KIT_NAME, current, tail + ")")
|
|
309
|
+
return None
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def install(target: Path, force: bool = False, dry_run: bool = False, kit_dir: Path = KIT_DIR) -> List[str]:
|
|
313
|
+
"""Copy the kit's page files into target. Returns one sentence per file. Raises KitError, writing
|
|
314
|
+
nothing, when a copy there was changed by the service and force is not given."""
|
|
315
|
+
if not target.is_dir():
|
|
316
|
+
raise KitError("%s is not a directory." % target)
|
|
317
|
+
manifest = load_manifest(kit_dir)
|
|
318
|
+
history = manifest.get("history") or {}
|
|
319
|
+
refused, plan = [], []
|
|
320
|
+
for name in COPIED:
|
|
321
|
+
source, dest = kit_dir / name, target / name
|
|
322
|
+
if not dest.exists():
|
|
323
|
+
plan.append((source, dest, "added %s %s" % (name, manifest["version"])))
|
|
324
|
+
continue
|
|
325
|
+
digest, version = sha256(dest), claimed_version(dest)
|
|
326
|
+
if digest == manifest["files"][name]:
|
|
327
|
+
plan.append((None, dest, "%s is already %s" % (name, manifest["version"])))
|
|
328
|
+
continue
|
|
329
|
+
known = (history.get(version or "") or {}).get(name)
|
|
330
|
+
if known != digest and not force:
|
|
331
|
+
refused.append("%s was changed after it was copied (it says %s); move the page's own rules into its own "
|
|
332
|
+
"stylesheet or script, or pass --force to replace it" % (name, version or "no version"))
|
|
333
|
+
continue
|
|
334
|
+
plan.append((source, dest, "replaced %s %s with %s" % (name, version or "(unmarked)", manifest["version"])))
|
|
335
|
+
if refused:
|
|
336
|
+
raise KitError("; ".join(refused) + ". Nothing was written.")
|
|
337
|
+
out = []
|
|
338
|
+
for source, dest, sentence in plan:
|
|
339
|
+
if source is not None and not dry_run:
|
|
340
|
+
atomic_write(dest, source.read_text(encoding="utf-8"))
|
|
341
|
+
out.append(("would be " + sentence) if dry_run and source is not None else sentence)
|
|
342
|
+
return out
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def declaration(manifest: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
|
346
|
+
manifest = manifest or load_manifest()
|
|
347
|
+
return {EXTENSION_KEY: {"minWidth": 360, "appearance": ["scheme", "theme"], "themes": manifest["themes"],
|
|
348
|
+
"kit": "%s@%s" % (KIT_NAME, manifest["version"])}}
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
# --- command line --------------------------------------------------------------
|
|
352
|
+
|
|
353
|
+
def hook_cwd(stdin_text: str) -> Path:
|
|
354
|
+
try:
|
|
355
|
+
payload = json.loads(stdin_text) if stdin_text.strip() else {}
|
|
356
|
+
cwd = payload.get("cwd") if isinstance(payload, dict) else None
|
|
357
|
+
except ValueError:
|
|
358
|
+
cwd = None
|
|
359
|
+
return Path(cwd or os.getcwd())
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def notice() -> int:
|
|
363
|
+
try:
|
|
364
|
+
stdin_text = "" if sys.stdin is None or sys.stdin.isatty() else sys.stdin.read(65536)
|
|
365
|
+
root = repo_root(hook_cwd(stdin_text))
|
|
366
|
+
if root is not None:
|
|
367
|
+
line = notice_line(status(root), Path(__file__).resolve())
|
|
368
|
+
if line:
|
|
369
|
+
print(line)
|
|
370
|
+
except Exception: # noqa: BLE001 — a hook never breaks a session
|
|
371
|
+
pass
|
|
372
|
+
return 0
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def main(argv: Optional[Sequence[str]] = None) -> int:
|
|
376
|
+
argv = sys.argv[1:] if argv is None else list(argv)
|
|
377
|
+
if argv == ["notice"]:
|
|
378
|
+
return notice()
|
|
379
|
+
import argparse
|
|
380
|
+
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
381
|
+
sub = parser.add_subparsers(dest="command")
|
|
382
|
+
p_status = sub.add_parser("status", help="kit copies in a repository")
|
|
383
|
+
p_status.add_argument("repo", nargs="?", default=".")
|
|
384
|
+
p_status.add_argument("--json", action="store_true")
|
|
385
|
+
p_install = sub.add_parser("install", help="copy the kit into a directory")
|
|
386
|
+
p_install.add_argument("dir")
|
|
387
|
+
p_install.add_argument("--force", action="store_true")
|
|
388
|
+
p_install.add_argument("--dry-run", action="store_true")
|
|
389
|
+
p_build = sub.add_parser("build", help="regenerate the theme block from themes.json")
|
|
390
|
+
p_build.add_argument("--check", action="store_true")
|
|
391
|
+
sub.add_parser("notice", help="the SessionStart hook")
|
|
392
|
+
args = parser.parse_args(argv)
|
|
393
|
+
|
|
394
|
+
if args.command == "notice":
|
|
395
|
+
return notice()
|
|
396
|
+
try:
|
|
397
|
+
if args.command == "status":
|
|
398
|
+
start = Path(args.repo).expanduser().resolve()
|
|
399
|
+
report = status(repo_root(start) or start)
|
|
400
|
+
if args.json:
|
|
401
|
+
print(json.dumps(report, indent=2))
|
|
402
|
+
return 0
|
|
403
|
+
print("%s: current kit %s" % (report["repository"], report["kit"]))
|
|
404
|
+
for c in report["copies"]:
|
|
405
|
+
print(" %-8s %s (%s)" % (c["state"], c["path"], c["version"] or "no version marker"))
|
|
406
|
+
if not report["copies"]:
|
|
407
|
+
print(" no kit copy" + (" — services %s declare a dashboard" % ", ".join(report["services"])
|
|
408
|
+
if report["dashboard"] and report["services"] else ""))
|
|
409
|
+
if report["services"]:
|
|
410
|
+
print(" services from this repository: %s" % ", ".join(report["services"]))
|
|
411
|
+
print(" declaration: %s" % json.dumps(declaration()))
|
|
412
|
+
return 0
|
|
413
|
+
if args.command == "install":
|
|
414
|
+
for sentence in install(Path(args.dir).expanduser(), force=args.force, dry_run=args.dry_run):
|
|
415
|
+
print(sentence)
|
|
416
|
+
print("Next: link both files same-origin, declare %s" % json.dumps(declaration()))
|
|
417
|
+
print("in the well-known document's extensions, and run check_dashboard_layout.mjs.")
|
|
418
|
+
return 0
|
|
419
|
+
if args.command == "build":
|
|
420
|
+
changed = build(check=args.check)
|
|
421
|
+
if args.check:
|
|
422
|
+
if changed:
|
|
423
|
+
print("out of date: %s (run dashboard_kit.py build)" % ", ".join(changed))
|
|
424
|
+
return 1
|
|
425
|
+
print("up to date")
|
|
426
|
+
return 0
|
|
427
|
+
print("rebuilt: %s" % (", ".join(changed) or "nothing changed"))
|
|
428
|
+
return 0
|
|
429
|
+
except KitError as exc:
|
|
430
|
+
print(str(exc), file=sys.stderr)
|
|
431
|
+
return 1
|
|
432
|
+
parser.print_usage(sys.stderr)
|
|
433
|
+
return 2
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
if __name__ == "__main__":
|
|
437
|
+
raise SystemExit(main())
|