@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.
Files changed (26) hide show
  1. package/CHANGELOG.md +100 -0
  2. package/README.md +14 -3
  3. package/package.json +1 -1
  4. package/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
  5. package/plugins/fabric-agent-adapter/hooks/hooks.json +17 -0
  6. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +3 -3
  7. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/adapt_project.py +2 -2
  8. package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +21 -8
  9. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/demo.html +110 -0
  10. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-dashboard.css +198 -0
  11. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-dashboard.js +253 -0
  12. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/kit.json +28 -0
  13. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/themes.json +350 -0
  14. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-kit.md +133 -0
  15. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard.md +4 -3
  16. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +25 -3
  17. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/remote-placement.md +7 -4
  18. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/surfaces-and-auth.md +19 -2
  19. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_dashboard_layout.mjs +295 -0
  20. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +88 -5
  21. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/dashboard_kit.py +437 -0
  22. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +143 -17
  23. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +195 -36
  24. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/sample-remote-service.mjs +9 -1
  25. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/sample_service.py +29 -4
  26. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +3 -3
@@ -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
- own = bool(mcp_surface) and mcp_surface.get("auth") == "own"
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
- # DEC-0024: the MCP surface has credentials of its own and must refuse the host's token.
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 own, yet the MCP surface accepted the descriptor's token")
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: own says" % exc) if refused else str(exc))
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 own: the MCP surface takes its callers' credentials, not the host's token (DEC-0024)" if own
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())