@passioncode-ai/fabric-agent-adapter 0.8.3 → 0.9.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 +49 -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 +7 -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 +31 -0
- 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 +51 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +65 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/sample_service.py +21 -3
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +3 -3
|
@@ -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())
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs
CHANGED
|
@@ -11,6 +11,11 @@ import path from 'node:path';
|
|
|
11
11
|
|
|
12
12
|
export const PROTOCOL = 'fabric-service/0.1';
|
|
13
13
|
export const EXTENSION_KEY = 'https://fabric.passioncode.ai/agent-contract/extensions/service/0.1';
|
|
14
|
+
// The embedded dashboard declaration (fabric-dashboard/0.1, DEC-0036 ED-9), under the well-known
|
|
15
|
+
// document's `extensions`. The kit is assets/dashboard-kit; its version is the one in kit.json.
|
|
16
|
+
export const DASHBOARD_EXTENSION_KEY = 'https://fabric.passioncode.ai/agent-contract/extensions/dashboard/0.1';
|
|
17
|
+
export const DASHBOARD_KIT = 'fabric-dashboard-kit@1.0.0';
|
|
18
|
+
export const REFERENCE_THEMES = ['graphite', 'fabric', 'paper', 'forest', 'ocean', 'ember', 'contrast'];
|
|
14
19
|
export const EXIT_ALREADY_RUNNING = 75;
|
|
15
20
|
// Lifecycle contract (fabric-workspace knowledge/lifecycle.md), the same values as fabric_service.py.
|
|
16
21
|
// A launchd-supervised copy that finds the lock held backs off in-process before its exit 75 (LC-03).
|
|
@@ -339,7 +344,45 @@ export function removeDescriptor(id, instance = 'default', dir = servicesDir())
|
|
|
339
344
|
|
|
340
345
|
// --- well-known and events ------------------------------------------------------------
|
|
341
346
|
|
|
342
|
-
|
|
347
|
+
const THEME_ID = /^[a-z][a-z0-9-]{0,31}$/;
|
|
348
|
+
const KIT_REF = /^[a-z][a-z0-9-]{0,63}@(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z.-]+)?$/;
|
|
349
|
+
|
|
350
|
+
// What makes value not a valid ED-9 declaration (service-dashboard-extension.schema.json).
|
|
351
|
+
export function dashboardExtensionProblems(value) {
|
|
352
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return ['the declaration is not an object'];
|
|
353
|
+
const out = [];
|
|
354
|
+
const extra = Object.keys(value).filter((k) => !['minWidth', 'appearance', 'themes', 'kit'].includes(k)).sort();
|
|
355
|
+
if (extra.length) out.push(`unknown field(s): ${extra.join(', ')}`);
|
|
356
|
+
const w = value.minWidth;
|
|
357
|
+
if (!Number.isInteger(w)) out.push('minWidth is required and must be a whole number of CSS pixels');
|
|
358
|
+
else if (w < 360 || w > 1280) out.push(`minWidth ${w} is outside 360…1280`);
|
|
359
|
+
if ('appearance' in value) {
|
|
360
|
+
const a = value.appearance;
|
|
361
|
+
if (!Array.isArray(a) || a.length > 2 || new Set(a).size !== a.length || a.some((x) => x !== 'scheme' && x !== 'theme')) out.push('appearance is a list of distinct fields from scheme, theme');
|
|
362
|
+
}
|
|
363
|
+
if ('themes' in value) {
|
|
364
|
+
const t = value.themes;
|
|
365
|
+
if (!Array.isArray(t) || t.length < 1 || t.length > 32 || new Set(t).size !== t.length || t.some((x) => typeof x !== 'string' || !THEME_ID.test(x))) out.push('themes is 1…32 distinct theme ids matching ^[a-z][a-z0-9-]{0,31}$');
|
|
366
|
+
}
|
|
367
|
+
if ('kit' in value && !(typeof value.kit === 'string' && KIT_REF.test(value.kit))) out.push(`kit is name@semver, for example ${DASHBOARD_KIT}`);
|
|
368
|
+
return out;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// The `extensions` entry a dashboard built from the kit declares (DEC-0036 ED-9). Declare it only
|
|
372
|
+
// when the page meets ED-1…ED-6 — check_dashboard_layout.mjs measures that.
|
|
373
|
+
export function dashboardExtension({ minWidth = 360, appearance = ['scheme', 'theme'], themes = REFERENCE_THEMES, kit = DASHBOARD_KIT } = {}) {
|
|
374
|
+
const value = { minWidth };
|
|
375
|
+
if (appearance?.length) value.appearance = [...appearance];
|
|
376
|
+
if (themes?.length) value.themes = [...themes];
|
|
377
|
+
if (kit) value.kit = kit;
|
|
378
|
+
const problems = dashboardExtensionProblems(value);
|
|
379
|
+
if (problems.length) throw new ServiceError(`Dashboard declaration: ${problems.join('; ')}.`);
|
|
380
|
+
return { [DASHBOARD_EXTENSION_KEY]: value };
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// `extensions` is kept as given (a reader ignores keys it does not know); a dashboard declaration in
|
|
384
|
+
// it must be valid (dashboardExtension builds one).
|
|
385
|
+
export function buildWellKnown({ id, instance, name, version, build, startedAt, status, degraded = [], surfaces, summary, updateAvailable = null, pid = process.pid, extensions }) {
|
|
343
386
|
if (!STATUSES.includes(status)) throw new ServiceError(`status must be one of ${STATUSES.join(', ')}.`);
|
|
344
387
|
if (!surfaces?.events) throw new ServiceError('surfaces.events is required.');
|
|
345
388
|
if (!(build?.commit || build?.digest)) throw new ServiceError('build needs a commit or a digest.');
|
|
@@ -353,6 +396,13 @@ export function buildWellKnown({ id, instance, name, version, build, startedAt,
|
|
|
353
396
|
update: { available: updateAvailable },
|
|
354
397
|
};
|
|
355
398
|
if (summary?.length) doc.summary = summary.slice(0, 6);
|
|
399
|
+
if (extensions && Object.keys(extensions).length) {
|
|
400
|
+
if (DASHBOARD_EXTENSION_KEY in extensions) {
|
|
401
|
+
const problems = dashboardExtensionProblems(extensions[DASHBOARD_EXTENSION_KEY]);
|
|
402
|
+
if (problems.length) throw new ServiceError(`Dashboard declaration: ${problems.join('; ')}.`);
|
|
403
|
+
}
|
|
404
|
+
doc.extensions = { ...extensions };
|
|
405
|
+
}
|
|
356
406
|
return doc;
|
|
357
407
|
}
|
|
358
408
|
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py
CHANGED
|
@@ -40,6 +40,11 @@ import urllib.request
|
|
|
40
40
|
|
|
41
41
|
PROTOCOL = "fabric-service/0.1"
|
|
42
42
|
EXTENSION_KEY = "https://fabric.passioncode.ai/agent-contract/extensions/service/0.1"
|
|
43
|
+
# The embedded dashboard declaration (fabric-dashboard/0.1, DEC-0036 ED-9), under the well-known
|
|
44
|
+
# document's `extensions`. The kit is assets/dashboard-kit; its version is the one in kit.json.
|
|
45
|
+
DASHBOARD_EXTENSION_KEY = "https://fabric.passioncode.ai/agent-contract/extensions/dashboard/0.1"
|
|
46
|
+
DASHBOARD_KIT = "fabric-dashboard-kit@1.0.0"
|
|
47
|
+
REFERENCE_THEMES = ("graphite", "fabric", "paper", "forest", "ocean", "ember", "contrast")
|
|
43
48
|
EXIT_ALREADY_RUNNING = 75
|
|
44
49
|
# Lifecycle contract (fabric-workspace knowledge/lifecycle.md). A launchd-supervised copy that finds
|
|
45
50
|
# the lock held backs off in-process for this long before its exit 75 (LC-03, F8): under KeepAlive
|
|
@@ -701,11 +706,63 @@ def build_info(repo_root: Optional[Path] = None, package_file: Optional[Path] =
|
|
|
701
706
|
return info
|
|
702
707
|
|
|
703
708
|
|
|
709
|
+
_THEME_ID = re.compile(r"^[a-z][a-z0-9-]{0,31}$")
|
|
710
|
+
_KIT_REF = re.compile(r"^[a-z][a-z0-9-]{0,63}@(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z.-]+)?$")
|
|
711
|
+
|
|
712
|
+
|
|
713
|
+
def dashboard_extension_problems(value: Any) -> List[str]:
|
|
714
|
+
"""What makes value not a valid ED-9 declaration (service-dashboard-extension.schema.json)."""
|
|
715
|
+
if not isinstance(value, dict):
|
|
716
|
+
return ["the declaration is not an object"]
|
|
717
|
+
out: List[str] = []
|
|
718
|
+
extra = sorted(set(value) - {"minWidth", "appearance", "themes", "kit"})
|
|
719
|
+
if extra:
|
|
720
|
+
out.append("unknown field(s): %s" % ", ".join(extra))
|
|
721
|
+
width = value.get("minWidth")
|
|
722
|
+
if isinstance(width, bool) or not isinstance(width, int):
|
|
723
|
+
out.append("minWidth is required and must be a whole number of CSS pixels")
|
|
724
|
+
elif not 360 <= width <= 1280:
|
|
725
|
+
out.append("minWidth %d is outside 360…1280" % width)
|
|
726
|
+
if "appearance" in value:
|
|
727
|
+
a = value["appearance"]
|
|
728
|
+
if not isinstance(a, list) or len(a) > 2 or len(set(map(str, a))) != len(a) or any(x not in ("scheme", "theme") for x in a):
|
|
729
|
+
out.append("appearance is a list of distinct fields from scheme, theme")
|
|
730
|
+
if "themes" in value:
|
|
731
|
+
t = value["themes"]
|
|
732
|
+
if not isinstance(t, list) or not 1 <= len(t) <= 32 or len(set(map(str, t))) != len(t) \
|
|
733
|
+
or any(not isinstance(x, str) or not _THEME_ID.match(x) for x in t):
|
|
734
|
+
out.append("themes is 1…32 distinct theme ids matching ^[a-z][a-z0-9-]{0,31}$")
|
|
735
|
+
if "kit" in value and not (isinstance(value["kit"], str) and _KIT_REF.match(value["kit"])):
|
|
736
|
+
out.append("kit is name@semver, for example %s" % DASHBOARD_KIT)
|
|
737
|
+
return out
|
|
738
|
+
|
|
739
|
+
|
|
740
|
+
def dashboard_extension(min_width: int = 360, appearance: Sequence[str] = ("scheme", "theme"),
|
|
741
|
+
themes: Optional[Sequence[str]] = REFERENCE_THEMES,
|
|
742
|
+
kit: Optional[str] = DASHBOARD_KIT) -> Dict[str, Any]:
|
|
743
|
+
"""The `extensions` entry a dashboard built from the kit declares (DEC-0036 ED-9). Declare it only
|
|
744
|
+
when the page meets ED-1…ED-6 — check_dashboard_layout.mjs measures that."""
|
|
745
|
+
value: Dict[str, Any] = {"minWidth": min_width}
|
|
746
|
+
if appearance:
|
|
747
|
+
value["appearance"] = list(appearance)
|
|
748
|
+
if themes:
|
|
749
|
+
value["themes"] = list(themes)
|
|
750
|
+
if kit:
|
|
751
|
+
value["kit"] = kit
|
|
752
|
+
problems = dashboard_extension_problems(value)
|
|
753
|
+
if problems:
|
|
754
|
+
raise ServiceError("Dashboard declaration: %s." % "; ".join(problems))
|
|
755
|
+
return {DASHBOARD_EXTENSION_KEY: value}
|
|
756
|
+
|
|
757
|
+
|
|
704
758
|
def build_well_known(*, service_id: str, instance: str, name: str, version: str, build: Dict[str, Any],
|
|
705
759
|
started_at: str, status: str, degraded: Sequence[Dict[str, str]],
|
|
706
760
|
surfaces: Dict[str, Any], summary: Optional[Sequence[Dict[str, Any]]] = None,
|
|
707
|
-
update_available: Optional[str] = None, pid: Optional[int] = None
|
|
708
|
-
|
|
761
|
+
update_available: Optional[str] = None, pid: Optional[int] = None,
|
|
762
|
+
extensions: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
|
763
|
+
"""The unauthenticated identity answer. `degraded` is always present; ready means empty.
|
|
764
|
+
`extensions` is kept as given (a reader ignores keys it does not know); a dashboard declaration
|
|
765
|
+
in it must be valid (dashboard_extension builds one)."""
|
|
709
766
|
if status not in STATUSES:
|
|
710
767
|
raise ServiceError("status must be one of %s." % ", ".join(STATUSES))
|
|
711
768
|
degraded = list(degraded)
|
|
@@ -724,6 +781,12 @@ def build_well_known(*, service_id: str, instance: str, name: str, version: str,
|
|
|
724
781
|
}
|
|
725
782
|
if summary:
|
|
726
783
|
doc["summary"] = list(summary)[:6]
|
|
784
|
+
if extensions:
|
|
785
|
+
if DASHBOARD_EXTENSION_KEY in extensions:
|
|
786
|
+
problems = dashboard_extension_problems(extensions[DASHBOARD_EXTENSION_KEY])
|
|
787
|
+
if problems:
|
|
788
|
+
raise ServiceError("Dashboard declaration: %s." % "; ".join(problems))
|
|
789
|
+
doc["extensions"] = dict(extensions)
|
|
727
790
|
return doc
|
|
728
791
|
|
|
729
792
|
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/sample_service.py
CHANGED
|
@@ -29,10 +29,21 @@ import fabric_service as fs # noqa: E402
|
|
|
29
29
|
import fabric_interop as fi # noqa: E402
|
|
30
30
|
|
|
31
31
|
VERSION = "0.1.0"
|
|
32
|
+
# The dashboard is built from the dashboard kit (fabric-dashboard/0.1, DEC-0036) and declares it
|
|
33
|
+
# in the well-known document only when the kit is there to serve.
|
|
34
|
+
KIT_DIR = Path(__file__).resolve().parent.parent / "assets" / "dashboard-kit"
|
|
35
|
+
KIT_FILES = {"/kit/fabric-dashboard.css": ("fabric-dashboard.css", "text/css; charset=utf-8"),
|
|
36
|
+
"/kit/fabric-dashboard.js": ("fabric-dashboard.js", "text/javascript; charset=utf-8")}
|
|
32
37
|
PAGE = """<!doctype html><html lang="en"><head><meta charset="utf-8">
|
|
33
38
|
<meta name="viewport" content="width=device-width,initial-scale=1"><title>{name}</title>
|
|
34
|
-
<
|
|
35
|
-
</head><body><
|
|
39
|
+
<link rel="stylesheet" href="/kit/fabric-dashboard.css"><script src="/kit/fabric-dashboard.js" defer></script>
|
|
40
|
+
</head><body><div class="fd-chrome"><header class="fd-header"><p class="fd-title">{name}</p>
|
|
41
|
+
<span class="fd-chip fd-s-ok"><span class="fd-dot"></span>Status: {status}</span></header>
|
|
42
|
+
<nav class="fd-nav" aria-label="Sections"><ul class="fd-nav-list"><li><a href="#events" aria-current="page">Events</a></li>
|
|
43
|
+
<li><a href="#about">About</a></li></ul></nav></div>
|
|
44
|
+
<main class="fd-page"><section id="events"><h2>Events</h2><ul>{rows}</ul></section>
|
|
45
|
+
<section id="about"><h2>About</h2><p class="fd-hint">A sample fabric-service/0.1 service built on the kit.</p></section></main>
|
|
46
|
+
</body></html>"""
|
|
36
47
|
|
|
37
48
|
|
|
38
49
|
def _schema(name: str, body: Dict[str, Any]) -> Dict[str, Any]:
|
|
@@ -130,6 +141,7 @@ class Service:
|
|
|
130
141
|
surfaces={"dashboard": {"path": "/", "login": True}, "events": {"path": "/fabric/v1/events"},
|
|
131
142
|
"mcp": {"path": "/mcp", "transport": "streamable-http", "capabilities": [c["name"] for c in CAPABILITIES]},
|
|
132
143
|
"usage": {"path": fs.USAGE_PATH}},
|
|
144
|
+
extensions=fs.dashboard_extension() if (KIT_DIR / "fabric-dashboard.css").is_file() else None,
|
|
133
145
|
)
|
|
134
146
|
|
|
135
147
|
|
|
@@ -148,7 +160,7 @@ def make_handler(svc: Service):
|
|
|
148
160
|
self.send_response(status)
|
|
149
161
|
self.send_header("Cache-Control", "no-store")
|
|
150
162
|
self.send_header("X-Content-Type-Options", "nosniff")
|
|
151
|
-
self.send_header("Content-Security-Policy", "default-src 'none'; style-src '
|
|
163
|
+
self.send_header("Content-Security-Policy", "default-src 'none'; style-src 'self'; script-src 'self'; frame-ancestors 'self'")
|
|
152
164
|
if body is not None:
|
|
153
165
|
self.send_header("Content-Type", content_type)
|
|
154
166
|
self.send_header("Content-Length", str(len(payload)))
|
|
@@ -203,6 +215,12 @@ def make_handler(svc: Service):
|
|
|
203
215
|
if not cookie:
|
|
204
216
|
return self._send(403, "This sign-in link was already used or has expired.", content_type="text/plain")
|
|
205
217
|
return self._send(302, None, {"Location": "/", "Set-Cookie": fs.session_cookie_header(cookie)})
|
|
218
|
+
if url.path in KIT_FILES:
|
|
219
|
+
name, kind = KIT_FILES[url.path]
|
|
220
|
+
try:
|
|
221
|
+
return self._send(200, (KIT_DIR / name).read_text(encoding="utf-8"), content_type=kind)
|
|
222
|
+
except OSError:
|
|
223
|
+
return self._send(404, {"error": "Not found."})
|
|
206
224
|
if url.path == "/":
|
|
207
225
|
if not self._session_ok():
|
|
208
226
|
return self._send(401, "Open this dashboard from Fabric Dashboards.", content_type="text/plain")
|