@passioncode-ai/fabric-agent-adapter 0.3.1 → 0.4.2

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 (19) hide show
  1. package/CHANGELOG.md +52 -0
  2. package/README.md +41 -5
  3. package/SECURITY.md +14 -0
  4. package/bin/fabric-agent-adapter.js +87 -43
  5. package/package.json +10 -5
  6. package/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +2 -3
  7. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +1 -1
  8. package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +240 -0
  9. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard.md +32 -0
  10. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/events-and-notifications.md +44 -0
  11. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/lifecycle.md +63 -0
  12. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/migrating-a-service.md +32 -0
  13. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +83 -0
  14. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/surfaces-and-auth.md +58 -0
  15. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +373 -0
  16. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +380 -0
  17. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +663 -0
  18. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/sample_service.py +226 -0
  19. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +8 -1
@@ -0,0 +1,226 @@
1
+ #!/usr/bin/env python3
2
+ """A complete, minimal fabric-service/0.1 service built on fabric_service.py.
3
+
4
+ It is the worked example the skill points at, the target check_service.py tests
5
+ itself against, and the fixture Fabric Dashboards runs its end-to-end tests on.
6
+
7
+ sample_service.py serve --port 47190 --data-dir DIR [--id sample] [--instance default]
8
+ sample_service.py register --port 47190 --data-dir DIR [--services-dir DIR]
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import argparse
14
+ import html
15
+ import json
16
+ import os
17
+ from http.server import BaseHTTPRequestHandler
18
+ from pathlib import Path
19
+ import signal
20
+ import sys
21
+ import threading
22
+ from typing import Any, Dict, Optional
23
+ from urllib.parse import parse_qs, urlparse
24
+
25
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
26
+ import fabric_service as fs # noqa: E402
27
+
28
+ VERSION = "0.1.0"
29
+ PAGE = """<!doctype html><html lang="en"><head><meta charset="utf-8">
30
+ <meta name="viewport" content="width=device-width,initial-scale=1"><title>{name}</title>
31
+ <style>body{{font:15px system-ui;margin:2rem;color:#e9e4ec;background:#0a070d}}li{{margin:.3rem 0}}</style>
32
+ </head><body><h1>{name}</h1><p>Status: {status}</p><ul>{rows}</ul></body></html>"""
33
+
34
+
35
+ class Service:
36
+ def __init__(self, args: argparse.Namespace):
37
+ self.id = args.id
38
+ self.instance = args.instance
39
+ self.name = args.name
40
+ self.port = args.port
41
+ self.data = Path(args.data_dir)
42
+ self.token_file = Path(args.token_file) if args.token_file else self.data / "service.token"
43
+ self.degraded = [{"source": "demo", "reason": args.degraded}] if args.degraded else []
44
+ self.started_at = fs.now_iso()
45
+ self.build = {"commit": os.environ.get("FABRIC_BUILD_COMMIT", "0000000"), "builtAt": self.started_at}
46
+
47
+ def start(self) -> None:
48
+ # Rule: the lock comes before ANY side effect, including creating the token.
49
+ self.lock = fs.hold_single_instance(self.data)
50
+ self.token = fs.ensure_token(self.token_file)
51
+ self.log = fs.JsonlEventLog(self.data / "events.jsonl")
52
+ self.codes = fs.LoginCodes(self.data / "auth")
53
+ self.log.append("service.started", "info", "%s started on build %s." % (self.name, self.build["commit"]))
54
+
55
+ def well_known(self) -> Dict[str, Any]:
56
+ return fs.build_well_known(
57
+ service_id=self.id, instance=self.instance, name=self.name, version=VERSION, build=self.build,
58
+ started_at=self.started_at, status="ready", degraded=self.degraded,
59
+ summary=[{"label": "Events", "value": len(self.log.fetch(None, fs.EVENTS_MAX_LIMIT))}],
60
+ surfaces={"dashboard": {"path": "/", "login": True}, "events": {"path": "/fabric/v1/events"}},
61
+ )
62
+
63
+
64
+ def make_handler(svc: Service):
65
+ class Handler(BaseHTTPRequestHandler):
66
+ server_version = "fabric-sample/" + VERSION
67
+
68
+ def log_message(self, fmt: str, *args: Any) -> None: # quiet by default
69
+ return
70
+
71
+ def _send(self, status: int, body: Any = None, headers: Optional[Dict[str, str]] = None,
72
+ content_type: str = "application/json") -> None:
73
+ payload = b""
74
+ if body is not None:
75
+ payload = body.encode() if isinstance(body, str) else json.dumps(body).encode()
76
+ self.send_response(status)
77
+ self.send_header("Cache-Control", "no-store")
78
+ self.send_header("X-Content-Type-Options", "nosniff")
79
+ self.send_header("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'; frame-ancestors 'self'")
80
+ if body is not None:
81
+ self.send_header("Content-Type", content_type)
82
+ self.send_header("Content-Length", str(len(payload)))
83
+ for key, value in (headers or {}).items():
84
+ self.send_header(key, value)
85
+ self.end_headers()
86
+ if payload:
87
+ self.wfile.write(payload)
88
+
89
+ def _guard(self) -> bool:
90
+ reason = fs.check_request(svc.port, self.headers.get("Host"), self.headers.get("Origin"),
91
+ self.headers.get("Sec-Fetch-Site"))
92
+ if reason:
93
+ self._send(403, {"error": reason})
94
+ return False
95
+ return True
96
+
97
+ def _token_ok(self) -> bool:
98
+ if fs.token_matches(self.headers.get("Authorization"), svc.token):
99
+ return True
100
+ self._send(401, {"error": "The service token is required."}, {"WWW-Authenticate": "Bearer"})
101
+ return False
102
+
103
+ def _session_ok(self) -> bool:
104
+ return svc.codes.session_valid(fs.cookie_value(self.headers.get("Cookie")))
105
+
106
+ def do_GET(self) -> None: # noqa: N802
107
+ if not self._guard():
108
+ return
109
+ url = urlparse(self.path)
110
+ query = parse_qs(url.query)
111
+ if url.path == "/.well-known/fabric-service":
112
+ return self._send(200, svc.well_known())
113
+ if url.path == "/fabric/v1/events":
114
+ if not self._token_ok():
115
+ return
116
+ try:
117
+ limit = fs.parse_limit((query.get("limit") or [None])[0])
118
+ page = fs.events_page(svc.log.fetch, (query.get("after") or [None])[0], limit)
119
+ except fs.ServiceError as exc:
120
+ return self._send(400, {"error": str(exc)})
121
+ return self._send(200, page)
122
+ if url.path == "/fabric/v1/login":
123
+ cookie = svc.codes.redeem((query.get("code") or [None])[0])
124
+ if not cookie:
125
+ return self._send(403, "This sign-in link was already used or has expired.", content_type="text/plain")
126
+ return self._send(302, None, {"Location": "/", "Set-Cookie": fs.session_cookie_header(cookie)})
127
+ if url.path == "/":
128
+ if not self._session_ok():
129
+ return self._send(401, "Open this dashboard from Fabric Dashboards.", content_type="text/plain")
130
+ rows = "".join("<li>%s — %s</li>" % (html.escape(e["at"]), html.escape(e["text"]))
131
+ for e in reversed(svc.log.fetch(None, 20)))
132
+ return self._send(200, PAGE.format(name=html.escape(svc.name), status="ready", rows=rows),
133
+ content_type="text/html; charset=utf-8")
134
+ self._send(404, {"error": "Not found."})
135
+
136
+ def do_POST(self) -> None: # noqa: N802
137
+ if not self._guard():
138
+ return
139
+ url = urlparse(self.path)
140
+ if url.path == "/fabric/v1/login-code":
141
+ if not self._token_ok():
142
+ return
143
+ return self._send(200, svc.codes.issue())
144
+ if url.path == "/api/emit":
145
+ if not (self._session_ok() or fs.token_matches(self.headers.get("Authorization"), svc.token)):
146
+ return self._send(401, {"error": "Sign in first."})
147
+ if self.headers.get("X-Fabric-Request") != "1":
148
+ return self._send(403, {"error": "Missing request header."})
149
+ length = min(int(self.headers.get("Content-Length") or 0), 65536)
150
+ body = json.loads(self.rfile.read(length) or b"{}")
151
+ event = svc.log.append(body.get("kind", "demo.note"), body.get("level", "info"),
152
+ body.get("text", "A note from the sample service."),
153
+ notify=bool(body.get("notify")), link=body.get("link"))
154
+ return self._send(200, event)
155
+ self._send(404, {"error": "Not found."})
156
+
157
+ return Handler
158
+
159
+
160
+ def make_server(svc: Service) -> fs.LoopbackHTTPServer:
161
+ return fs.LoopbackHTTPServer(("127.0.0.1", svc.port), make_handler(svc))
162
+
163
+
164
+ def serve(args: argparse.Namespace) -> int:
165
+ svc = Service(args)
166
+ svc.start()
167
+ server = make_server(svc)
168
+
169
+ def stop(signum: int, _frame: Any) -> None:
170
+ svc.log.append("service.stopping", "info", "%s is stopping." % svc.name)
171
+ threading.Thread(target=server.shutdown, daemon=True).start()
172
+
173
+ signal.signal(signal.SIGTERM, stop)
174
+ signal.signal(signal.SIGINT, stop)
175
+ try:
176
+ server.serve_forever(poll_interval=0.2)
177
+ finally:
178
+ server.server_close()
179
+ svc.lock.release()
180
+ return 0
181
+
182
+
183
+ def register(args: argparse.Namespace) -> int:
184
+ data = Path(args.data_dir)
185
+ descriptor = {
186
+ "protocol": fs.PROTOCOL, "id": args.id, "instance": args.instance, "name": args.name,
187
+ "summary": "Sample fabric-service/0.1 service.",
188
+ "origin": "http://127.0.0.1:%d" % args.port,
189
+ "auth": {"tokenFile": str(Path(args.token_file) if args.token_file else data / "service.token")},
190
+ "lifecycle": {"manager": "launchd", "label": args.label, "plist": args.plist} if args.label
191
+ else {"manager": "none"},
192
+ "paths": {"data": str(data), "logs": []},
193
+ "installedAt": fs.now_iso(), "installedBy": "sample_service.py register",
194
+ }
195
+ try:
196
+ path = fs.write_descriptor(descriptor, Path(args.services_dir) if args.services_dir else None)
197
+ except fs.ServiceError as exc:
198
+ print(str(exc), file=sys.stderr)
199
+ return 1
200
+ print(path)
201
+ return 0
202
+
203
+
204
+ def main(argv: Optional[list] = None) -> int:
205
+ parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
206
+ sub = parser.add_subparsers(dest="command", required=True)
207
+ for name in ("serve", "register"):
208
+ p = sub.add_parser(name)
209
+ p.add_argument("--port", type=int, required=True)
210
+ p.add_argument("--data-dir", required=True)
211
+ p.add_argument("--token-file")
212
+ p.add_argument("--id", default="sample")
213
+ p.add_argument("--instance", default="default")
214
+ p.add_argument("--name", default="Sample Service")
215
+ if name == "serve":
216
+ p.add_argument("--degraded", help="report one degraded source with this reason")
217
+ else:
218
+ p.add_argument("--services-dir")
219
+ p.add_argument("--label")
220
+ p.add_argument("--plist")
221
+ args = parser.parse_args(argv)
222
+ return serve(args) if args.command == "serve" else register(args)
223
+
224
+
225
+ if __name__ == "__main__":
226
+ raise SystemExit(main())
@@ -5,7 +5,7 @@ license: MIT
5
5
  compatibility: Requires filesystem access and Python 3.9+. Exact schema checks additionally need git, Node.js, pnpm, and the pinned private fabric-agent-contract checkout. Works without those tools in an explicitly degraded structural-check mode. Ships in one plugin with adapting-projects-to-fabric, whose scripts it reuses.
6
6
  metadata:
7
7
  author: passioncode-ai
8
- version: "0.3.1"
8
+ version: "0.4.2"
9
9
  contract-version: "0.1.0"
10
10
  contract-commit: "20a818e648a4c09a60df0126d11626922e8b9094"
11
11
  ---
@@ -93,6 +93,13 @@ ready for admission.
93
93
 
94
94
  ## Step 4 — implement against the observables
95
95
 
96
+ If the agent keeps running on the operator's computer — it answers other agents, runs
97
+ long jobs or shows a dashboard — build it as a service with the sibling skill
98
+ `building-fabric-services`: its instance lock, launchd plist, descriptor, well-known
99
+ document and events feed are part of this step, not a later retrofit. If that skill is
100
+ absent, apply the `fabric-service/0.1` rules from the pinned contract's
101
+ `docs/specification/service.md` by hand and mark its probe `NOT_RUN`.
102
+
96
103
  Build the capability behind the chosen surface. Keep model choice and internal reasoning
97
104
  outside the contract; expose typed outcomes, evidence, and protocol-visible state. Every
98
105
  provider output is untrusted until schemas and semantic assertions pass. The step-2