@passioncode-ai/passioncode 0.1.31 → 0.1.33
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 +26 -0
- package/family.json +1 -1
- package/package.json +1 -1
- package/payload/.claude-plugin/marketplace.json +2 -2
- package/payload/manifest.json +8 -8
- package/payload/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
- package/payload/plugins/fabric-agent-adapter/hooks/hooks.json +17 -0
- package/payload/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +33 -3
- package/payload/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/adapt_project.py +2 -2
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +21 -8
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/demo.html +110 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-dashboard.css +198 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-dashboard.js +253 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/kit.json +28 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/themes.json +350 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-kit.md +133 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard.md +4 -3
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +18 -8
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_dashboard_layout.mjs +295 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +58 -4
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/dashboard_kit.py +437 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +51 -1
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +327 -25
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/sample_service.py +21 -3
- package/payload/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +35 -6
- package/payload/plugins/passioncode/.claude-plugin/plugin.json +1 -1
|
@@ -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())
|
|
@@ -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
|
|