@passioncode-ai/fabric-agent-adapter 0.39.1 → 0.40.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 +79 -0
- package/README.md +9 -3
- package/package.json +1 -1
- package/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +15 -4
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/README.md +64 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/config.example.json +15 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/knowledge.example.json +13 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/support_agent.py +264 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/connectors.md +93 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/estate-record.md +3 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/kit-modules.md +128 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/usage.md +2 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +10 -183
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_rules.py +204 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-auth.mjs +286 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-backups.mjs +327 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-base.mjs +47 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-fixture.mjs +79 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-session.mjs +840 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-shapes.mjs +340 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board.mjs +42 -1237
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-ceremonies.mjs +1 -16
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-crypto.mjs +41 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-delegation.mjs +13 -45
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-descriptor.mjs +387 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-estate.mjs +8 -67
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-events.mjs +52 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-http.mjs +231 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop.mjs +2 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-jsvalue.mjs +67 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-lock.mjs +202 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-logs.mjs +136 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-openrouter.mjs +335 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-package.mjs +2 -12
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-paths.mjs +69 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-platform.mjs +164 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-profiles.mjs +1 -32
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-protocols.mjs +122 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +177 -2541
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-share.mjs +1 -8
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-supervisors.mjs +400 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-time.mjs +46 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-usage.mjs +458 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_auth.py +402 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_backups.py +399 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_base.py +78 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board.py +64 -1398
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_fixture.py +116 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_session.py +902 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_shapes.py +457 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_ceremonies.py +36 -38
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_crypto.py +289 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_delegation.py +15 -180
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_descriptor.py +586 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_estate.py +39 -400
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_events.py +137 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_http.py +402 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop.py +1 -6
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_jsvalue.py +310 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_kit.py +243 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_lock.py +308 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_logs.py +202 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_openrouter.py +431 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_package.py +21 -43
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_paths.py +102 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_platform.py +338 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_profiles.py +31 -38
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_protocols.py +173 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +298 -3450
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_share.py +8 -8
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_supervisors.py +412 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_time.py +120 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_usage.py +697 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/kit.json +1526 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +1 -1
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/models-and-providers.md +3 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +1 -1
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session.py +116 -1392
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_cli.py +270 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_env.py +508 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_hosts.py +265 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_mail.py +155 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_mcp.py +335 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/host_hooks.py +1 -0
|
@@ -0,0 +1,508 @@
|
|
|
1
|
+
"""working-with-fabric, part of fabric_session.py (FAA-65): where Fabric and the session's files are, whether a
|
|
2
|
+
project uses Fabric, whether Fabric launched this session, and the log and spool the hooks write. Standard library only."""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
import subprocess
|
|
9
|
+
import sys
|
|
10
|
+
import time
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any
|
|
13
|
+
from typing import Dict
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
HUB_PROTOCOL = "fabric-hub/0.1"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
HOOK_FEATURE = "session-hooks/1"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
BOARD_STATUS_TOOL = "com.status"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
WORKER_DEADLINE_S = 10.0
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
GIT_TIMEOUT_S = 1.5
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
PS_TIMEOUT_S = 0.5
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
LOG_CAP_BYTES = 1_000_000
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
SPOOL_CAP = 50
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
LOCK_STALE_S = 30.0
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
#: A session folder untouched this long belongs to a session that will not spool again: its worker failed, or the
|
|
44
|
+
#: session ended without SessionEnd. Nothing retries it, so the next spooling hook removes it (FAA-50).
|
|
45
|
+
SESSION_STALE_S = 7 * 86400
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
READ_CAP = 64 * 1024
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
CONFIG_CAP = 32 * 1024 * 1024
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
SESSION_ID = re.compile(r"[A-Za-z0-9._:-]{1,128}")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
LOOPBACK_ORIGIN = re.compile(r"http://127\.0\.0\.1:(\d{1,5})")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
VERSION = "0.40.0"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
HOSTS = {"claude": "Claude Code", "codex": "Codex", "kimi": "Kimi Code",
|
|
64
|
+
"hermes": "Hermes", "opencode": "OpenCode", "openclaw": "OpenClaw", "pi": "Pi"}
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
NATIVE_PLUGIN_HOSTS = frozenset(("hermes", "opencode", "openclaw", "pi"))
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
# A session Fabric launched. FABRIC_LAUNCHED_SESSION=1 is the marker Fabric is asked to set (need D6); the others are
|
|
71
|
+
# what Fabric already gives a launched session (fabric sessionBundle.ts): only their NAMES are looked at, never a value
|
|
72
|
+
# — two of them carry the session's credential.
|
|
73
|
+
LAUNCH_MARKER = "FABRIC_LAUNCHED_SESSION"
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
LAUNCH_SIGNALS = ("FABRIC_ACP_SESSION", "FABRIC_BRIDGE_URL", "KILO_CONFIG_CONTENT")
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
#: Files that make a folder a Fabric project: the provider manifest and the contract pin adapt_project.py writes, and
|
|
80
|
+
#: a fabric-package/1 folder's manifest.
|
|
81
|
+
FABRIC_MARKERS = ("fabric-agent.json", "fabric-contract.lock.json", "fabric.package.json")
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
UNREAD_LINE = "%d unread on the project board: com.list"
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _home(env: Optional[Dict[str, str]] = None) -> Path:
|
|
88
|
+
env = os.environ if env is None else env
|
|
89
|
+
return Path(env.get("HOME") or env.get("USERPROFILE") or str(Path.home()))
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def fabric_root(platform: str = sys.platform, env: Optional[Dict[str, str]] = None) -> Path:
|
|
93
|
+
"""Fabric's registry root, where hub.json lives (fabric `agentRegistry.ts#registryDirs`)."""
|
|
94
|
+
env = os.environ if env is None else env
|
|
95
|
+
home = _home(env)
|
|
96
|
+
if platform == "darwin":
|
|
97
|
+
return home / "Library/Application Support/ai.passioncode.fabric"
|
|
98
|
+
if platform == "win32":
|
|
99
|
+
return Path(env.get("LOCALAPPDATA") or str(home / "AppData" / "Local")) / "passioncode-fabric"
|
|
100
|
+
return Path(env.get("XDG_DATA_HOME") or str(home / ".local/share")) / "passioncode-fabric"
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def state_root(platform: str = sys.platform, env: Optional[Dict[str, str]] = None) -> Path:
|
|
104
|
+
"""The hooks' own folder: the log and the per-session spool. Never Fabric's folder."""
|
|
105
|
+
env = os.environ if env is None else env
|
|
106
|
+
home = _home(env)
|
|
107
|
+
if platform == "darwin":
|
|
108
|
+
return home / "Library/Caches/ai.passioncode.fabric-agent-adapter"
|
|
109
|
+
if platform == "win32":
|
|
110
|
+
return Path(env.get("LOCALAPPDATA") or str(home / "AppData" / "Local")) / "fabric-agent-adapter"
|
|
111
|
+
return Path(env.get("XDG_CACHE_HOME") or str(home / ".cache")) / "fabric-agent-adapter"
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def fabric_bin_candidates(platform: str = sys.platform, env: Optional[Dict[str, str]] = None) -> List[Path]:
|
|
115
|
+
"""Where `<Fabric>/bin/fabric-mcp` is on a default install (ADR-0134 amendment 2; electron-builder targets)."""
|
|
116
|
+
env = os.environ if env is None else env
|
|
117
|
+
home = _home(env)
|
|
118
|
+
if platform == "darwin":
|
|
119
|
+
return [Path("/Applications/Fabric.app/Contents/Resources/bin/fabric-mcp"),
|
|
120
|
+
home / "Applications/Fabric.app/Contents/Resources/bin/fabric-mcp"]
|
|
121
|
+
if platform == "win32":
|
|
122
|
+
local = Path(env.get("LOCALAPPDATA") or str(home / "AppData" / "Local"))
|
|
123
|
+
return [local / "Programs" / "Fabric" / "resources" / "bin" / "fabric-mcp.cmd"]
|
|
124
|
+
return [Path("/opt/Fabric/resources/bin/fabric-mcp")]
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def find_fabric_bin(platform: str = sys.platform, env: Optional[Dict[str, str]] = None) -> Optional[Path]:
|
|
128
|
+
for candidate in fabric_bin_candidates(platform, env):
|
|
129
|
+
if candidate.is_file():
|
|
130
|
+
return candidate
|
|
131
|
+
return None
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def read_small(path: Path, cap: int = READ_CAP) -> Optional[str]:
|
|
135
|
+
"""A regular file's text, or None: no FIFO, device or link target is read, nothing past `cap`."""
|
|
136
|
+
try:
|
|
137
|
+
st = os.lstat(str(path))
|
|
138
|
+
except OSError:
|
|
139
|
+
return None
|
|
140
|
+
import stat as _stat
|
|
141
|
+
if not _stat.S_ISREG(st.st_mode) or st.st_size > cap:
|
|
142
|
+
return None
|
|
143
|
+
try:
|
|
144
|
+
with open(str(path), "r", encoding="utf-8") as handle:
|
|
145
|
+
return handle.read(cap)
|
|
146
|
+
except (OSError, UnicodeDecodeError):
|
|
147
|
+
return None
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def pid_alive(pid: int) -> bool:
|
|
151
|
+
if pid <= 0:
|
|
152
|
+
return False
|
|
153
|
+
if sys.platform == "win32":
|
|
154
|
+
# os.kill(pid, 0) on Windows sends CTRL_C_EVENT: never use it to probe.
|
|
155
|
+
import ctypes
|
|
156
|
+
kernel = ctypes.windll.kernel32 # type: ignore[attr-defined]
|
|
157
|
+
handle = kernel.OpenProcess(0x1000, False, pid) # PROCESS_QUERY_LIMITED_INFORMATION
|
|
158
|
+
if not handle:
|
|
159
|
+
return False
|
|
160
|
+
try:
|
|
161
|
+
code = ctypes.c_ulong()
|
|
162
|
+
return bool(kernel.GetExitCodeProcess(handle, ctypes.byref(code))) and code.value == 259 # STILL_ACTIVE
|
|
163
|
+
finally:
|
|
164
|
+
kernel.CloseHandle(handle)
|
|
165
|
+
try:
|
|
166
|
+
os.kill(pid, 0)
|
|
167
|
+
except PermissionError:
|
|
168
|
+
return True
|
|
169
|
+
except OSError:
|
|
170
|
+
return False
|
|
171
|
+
return True
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def locate_hub(root: Path, alive=pid_alive) -> Dict[str, Any]:
|
|
175
|
+
"""hub.json read the way an agent must (fabric `hub.ts#locateHub`), minus the door token: never read here."""
|
|
176
|
+
hub_file = root / "hub.json"
|
|
177
|
+
if not os.path.lexists(str(hub_file)):
|
|
178
|
+
return {"state": "not-running", "reason": "there is no hub.json"}
|
|
179
|
+
text = read_small(hub_file)
|
|
180
|
+
if text is None:
|
|
181
|
+
return {"state": "not-a-hub", "reason": "hub.json is not a small regular file"}
|
|
182
|
+
try:
|
|
183
|
+
doc = json.loads(text)
|
|
184
|
+
except ValueError:
|
|
185
|
+
return {"state": "not-a-hub", "reason": "hub.json is not JSON"}
|
|
186
|
+
if not isinstance(doc, dict) or doc.get("protocol") != HUB_PROTOCOL:
|
|
187
|
+
return {"state": "not-a-hub", "reason": "hub.json does not speak %s" % HUB_PROTOCOL}
|
|
188
|
+
match = LOOPBACK_ORIGIN.fullmatch(doc.get("origin") if isinstance(doc.get("origin"), str) else "")
|
|
189
|
+
if not match or not 1024 <= int(match.group(1)) <= 65535 or doc.get("mcp") != "/mcp":
|
|
190
|
+
return {"state": "not-a-hub", "reason": "hub.json names another origin or MCP path"}
|
|
191
|
+
pid = doc.get("pid")
|
|
192
|
+
if not isinstance(pid, int) or isinstance(pid, bool) or pid <= 0:
|
|
193
|
+
return {"state": "not-a-hub", "reason": "hub.json names no process"}
|
|
194
|
+
if not alive(pid):
|
|
195
|
+
return {"state": "not-running", "reason": "hub.json names process %d, which is not running" % pid}
|
|
196
|
+
features = doc.get("features")
|
|
197
|
+
features = [f for f in features if isinstance(f, str)] if isinstance(features, list) else []
|
|
198
|
+
return {"state": "running", "pid": pid, "features": features}
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _ancestors(cwd: Path) -> List[Path]:
|
|
202
|
+
try:
|
|
203
|
+
cwd = cwd.resolve()
|
|
204
|
+
except OSError:
|
|
205
|
+
pass
|
|
206
|
+
return [cwd] + list(cwd.parents)
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _stdio_entry(entry: Any) -> Optional[Dict[str, Any]]:
|
|
210
|
+
if not isinstance(entry, dict) or not isinstance(entry.get("command"), str):
|
|
211
|
+
return None
|
|
212
|
+
if entry.get("type") not in (None, "stdio"):
|
|
213
|
+
return None
|
|
214
|
+
args = entry.get("args") if isinstance(entry.get("args"), list) else []
|
|
215
|
+
command = entry["command"]
|
|
216
|
+
name = re.split(r"[\\/]", command)[-1].lower()
|
|
217
|
+
return {"command": command, "args": [str(a) for a in args],
|
|
218
|
+
"fabricMcp": name in ("fabric-mcp", "fabric-mcp.cmd")}
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def claude_registration(cwd: Path, env: Optional[Dict[str, str]] = None) -> Optional[Dict[str, Any]]:
|
|
222
|
+
"""Claude Code's MCP server `fabric` for this folder: local, then project (.mcp.json), then user scope.
|
|
223
|
+
Only command and args are returned; an entry's env is never read out."""
|
|
224
|
+
home = _home(env)
|
|
225
|
+
config: Dict[str, Any] = {}
|
|
226
|
+
text = read_small(home / ".claude.json", CONFIG_CAP)
|
|
227
|
+
if text:
|
|
228
|
+
try:
|
|
229
|
+
loaded = json.loads(text)
|
|
230
|
+
config = loaded if isinstance(loaded, dict) else {}
|
|
231
|
+
except ValueError:
|
|
232
|
+
config = {}
|
|
233
|
+
projects = config.get("projects") if isinstance(config.get("projects"), dict) else {}
|
|
234
|
+
for folder in _ancestors(cwd):
|
|
235
|
+
servers = (projects.get(str(folder)) or {}).get("mcpServers") if isinstance(projects.get(str(folder)), dict) else None
|
|
236
|
+
found = _stdio_entry((servers or {}).get("fabric")) if isinstance(servers, dict) else None
|
|
237
|
+
if found:
|
|
238
|
+
return dict(found, scope="local")
|
|
239
|
+
for folder in _ancestors(cwd):
|
|
240
|
+
text = read_small(folder / ".mcp.json")
|
|
241
|
+
if text:
|
|
242
|
+
try:
|
|
243
|
+
servers = json.loads(text).get("mcpServers")
|
|
244
|
+
except (ValueError, AttributeError):
|
|
245
|
+
servers = None
|
|
246
|
+
found = _stdio_entry(servers.get("fabric")) if isinstance(servers, dict) else None
|
|
247
|
+
if found:
|
|
248
|
+
return dict(found, scope="project")
|
|
249
|
+
servers = config.get("mcpServers")
|
|
250
|
+
found = _stdio_entry(servers.get("fabric")) if isinstance(servers, dict) else None
|
|
251
|
+
return dict(found, scope="user") if found else None
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def codex_registered(env: Optional[Dict[str, str]] = None) -> bool:
|
|
255
|
+
env = os.environ if env is None else env
|
|
256
|
+
codex_home = Path(env.get("CODEX_HOME") or str(_home(env) / ".codex"))
|
|
257
|
+
text = read_small(codex_home / "config.toml", CONFIG_CAP) or ""
|
|
258
|
+
return re.search(r'^\s*\[\s*"?mcp_servers"?\s*\.\s*"?fabric"?\s*\]\s*$', text, re.MULTILINE) is not None
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def kimi_registered(env: Optional[Dict[str, str]] = None) -> bool:
|
|
262
|
+
"""Kimi Code keeps its MCP servers in ~/.kimi-code/mcp.json (`mcpServers`, Claude's shape)."""
|
|
263
|
+
text = read_small(_home(env) / ".kimi-code" / "mcp.json", CONFIG_CAP)
|
|
264
|
+
try:
|
|
265
|
+
servers = json.loads(text).get("mcpServers") if text else None
|
|
266
|
+
except (ValueError, AttributeError):
|
|
267
|
+
return False
|
|
268
|
+
return isinstance(servers, dict) and _stdio_entry(servers.get("fabric")) is not None
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def host_registered(host: str, cwd: Path, env: Optional[Dict[str, str]] = None) -> bool:
|
|
272
|
+
if host == "claude":
|
|
273
|
+
return claude_registration(cwd, env) is not None
|
|
274
|
+
if host == "codex":
|
|
275
|
+
return codex_registered(env)
|
|
276
|
+
if host == "kimi":
|
|
277
|
+
return kimi_registered(env)
|
|
278
|
+
return False
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def services_dir(platform: str = sys.platform, env: Optional[Dict[str, str]] = None) -> Path:
|
|
282
|
+
"""Where fabric-service/0.1 descriptors live: FABRIC_SERVICES_DIR, else Fabric's folder per OS + /services."""
|
|
283
|
+
env = os.environ if env is None else env
|
|
284
|
+
if env.get("FABRIC_SERVICES_DIR"):
|
|
285
|
+
return Path(env["FABRIC_SERVICES_DIR"])
|
|
286
|
+
return fabric_root(platform, env) / "services"
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def checkout_root(cwd: Path, env: Optional[Dict[str, str]] = None) -> Optional[Path]:
|
|
290
|
+
"""The nearest folder at or above cwd with a `.git` entry (a worktree's `.git` is a file), never past HOME; None
|
|
291
|
+
outside a checkout. No subprocess."""
|
|
292
|
+
home = _home(env)
|
|
293
|
+
for folder in _ancestors(cwd):
|
|
294
|
+
if os.path.lexists(str(folder / ".git")):
|
|
295
|
+
return folder
|
|
296
|
+
if folder == home:
|
|
297
|
+
break
|
|
298
|
+
return None
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def _repository_key(url: str) -> str:
|
|
302
|
+
"""One spelling of a remote: scheme, user, `.git` and case dropped; `git@host:a/b` is `host/a/b`."""
|
|
303
|
+
url = url.strip().lower()
|
|
304
|
+
url = re.sub(r"^git@([^:]+):", r"\1/", url)
|
|
305
|
+
url = re.sub(r"^[a-z+]+://", "", url)
|
|
306
|
+
url = re.sub(r"^[^@/]+@", "", url)
|
|
307
|
+
return re.sub(r"\.git$", "", url).rstrip("/")
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def _origin(root: Path) -> str:
|
|
311
|
+
try:
|
|
312
|
+
done = subprocess.run(["git", "--no-optional-locks", "-C", str(root), "config", "--get", "remote.origin.url"],
|
|
313
|
+
stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL,
|
|
314
|
+
timeout=GIT_TIMEOUT_S, check=False)
|
|
315
|
+
except (OSError, subprocess.SubprocessError):
|
|
316
|
+
return ""
|
|
317
|
+
return done.stdout.decode("utf-8", "replace").strip() if done.returncode == 0 else ""
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def owns_a_service(root: Path, env: Optional[Dict[str, str]] = None, platform: str = sys.platform) -> bool:
|
|
321
|
+
"""Whether a service descriptor names this checkout's origin as its `source.repository`. Reads each descriptor's
|
|
322
|
+
`source` only; never its auth block or a token file."""
|
|
323
|
+
origin = _origin(root)
|
|
324
|
+
if not origin:
|
|
325
|
+
return False
|
|
326
|
+
directory = services_dir(platform, env)
|
|
327
|
+
try:
|
|
328
|
+
names = sorted(p for p in os.listdir(str(directory)) if p.endswith(".json") and not p.startswith("."))
|
|
329
|
+
except OSError:
|
|
330
|
+
return False
|
|
331
|
+
key = _repository_key(origin)
|
|
332
|
+
for name in names:
|
|
333
|
+
try:
|
|
334
|
+
doc = json.loads(read_small(directory / name) or "null")
|
|
335
|
+
except ValueError:
|
|
336
|
+
continue
|
|
337
|
+
source = doc.get("source") if isinstance(doc, dict) else None
|
|
338
|
+
repository = source.get("repository") if isinstance(source, dict) else None
|
|
339
|
+
if isinstance(repository, str) and repository and _repository_key(repository) == key:
|
|
340
|
+
return True
|
|
341
|
+
return False
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def project_uses_fabric(cwd: Path, env: Optional[Dict[str, str]] = None,
|
|
345
|
+
registration: Optional[Dict[str, Any]] = None, platform: str = sys.platform) -> Optional[str]:
|
|
346
|
+
"""Which sign says the project at cwd works with Fabric, or None. Cheapest first: `registration` when Claude Code
|
|
347
|
+
has `fabric` at local or project scope (bound to this folder; user scope says nothing about it); a FABRIC_MARKERS
|
|
348
|
+
file from cwd up to its checkout's root (cwd alone outside a checkout); `service` when a descriptor names the
|
|
349
|
+
checkout's origin."""
|
|
350
|
+
if registration and registration.get("scope") in ("local", "project"):
|
|
351
|
+
return "registration"
|
|
352
|
+
root = checkout_root(cwd, env)
|
|
353
|
+
folders = _ancestors(cwd)
|
|
354
|
+
folders = folders[:folders.index(root) + 1] if root in folders else folders[:1]
|
|
355
|
+
import stat as _stat
|
|
356
|
+
for folder in folders:
|
|
357
|
+
for name in FABRIC_MARKERS:
|
|
358
|
+
try:
|
|
359
|
+
if _stat.S_ISREG(os.lstat(str(folder / name)).st_mode):
|
|
360
|
+
return name
|
|
361
|
+
except OSError:
|
|
362
|
+
continue
|
|
363
|
+
if root is not None and owns_a_service(root, env, platform):
|
|
364
|
+
return "service"
|
|
365
|
+
return None
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def fabric_launched(env: Optional[Dict[str, str]] = None, platform: str = sys.platform) -> Optional[str]:
|
|
369
|
+
"""The name of the signal that says Fabric launched this session, or None. Cheap: the environment only."""
|
|
370
|
+
env = os.environ if env is None else env
|
|
371
|
+
marker = env.get(LAUNCH_MARKER)
|
|
372
|
+
if marker and marker != "0":
|
|
373
|
+
return LAUNCH_MARKER
|
|
374
|
+
for name in LAUNCH_SIGNALS:
|
|
375
|
+
if name in env: # the name only: never the value
|
|
376
|
+
return name
|
|
377
|
+
codex_home = env.get("CODEX_HOME")
|
|
378
|
+
if codex_home:
|
|
379
|
+
sessions = os.path.normpath(str(fabric_root(platform, env) / "sessions")) + os.sep
|
|
380
|
+
if os.path.normpath(codex_home).startswith(sessions):
|
|
381
|
+
return "CODEX_HOME"
|
|
382
|
+
return None
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def _process(pid: int) -> Tuple[Optional[str], int]:
|
|
386
|
+
"""A process's command line and its parent's pid, or (None, 0). POSIX only; bounded."""
|
|
387
|
+
try:
|
|
388
|
+
if sys.platform.startswith("linux"):
|
|
389
|
+
with open("/proc/%d/cmdline" % pid, "rb") as handle:
|
|
390
|
+
command = handle.read(READ_CAP).replace(b"\0", b" ").decode("utf-8", "replace")
|
|
391
|
+
with open("/proc/%d/stat" % pid, "r", encoding="utf-8", errors="replace") as handle:
|
|
392
|
+
parent = int(handle.read(4096).rsplit(")", 1)[1].split()[1])
|
|
393
|
+
return command, parent
|
|
394
|
+
if sys.platform == "win32":
|
|
395
|
+
return None, 0
|
|
396
|
+
done = subprocess.run(["ps", "-ww", "-o", "ppid=", "-o", "command=", "-p", str(pid)], stdin=subprocess.DEVNULL,
|
|
397
|
+
stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, timeout=PS_TIMEOUT_S, check=False)
|
|
398
|
+
parent_text, _, command = done.stdout.decode("utf-8", "replace").strip().partition(" ")
|
|
399
|
+
return (command, int(parent_text)) if done.returncode == 0 and parent_text.isdigit() else (None, 0)
|
|
400
|
+
except (OSError, ValueError, IndexError, subprocess.SubprocessError):
|
|
401
|
+
return None, 0
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def launched_by_ancestor(env: Optional[Dict[str, str]] = None, depth: int = 4) -> bool:
|
|
405
|
+
"""A Claude Code Fabric launched runs with `--mcp-config <Fabric>/sessions/<id>/mcp.json` (sessionBundle.ts,
|
|
406
|
+
adapter mcp-config-flag) and no environment marker yet: look for that in this hook's few ancestors. Only the
|
|
407
|
+
path is matched; nothing read here is kept or logged."""
|
|
408
|
+
marker = os.path.normpath(str(fabric_root(env=env) / "sessions")) + os.sep
|
|
409
|
+
pid = os.getppid()
|
|
410
|
+
for _ in range(depth):
|
|
411
|
+
if pid <= 1:
|
|
412
|
+
return False
|
|
413
|
+
command, parent = _process(pid)
|
|
414
|
+
if command is None:
|
|
415
|
+
return False
|
|
416
|
+
if "--mcp-config" in command and marker in command:
|
|
417
|
+
return True
|
|
418
|
+
pid = parent
|
|
419
|
+
return False
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
def log(line: str, env: Optional[Dict[str, str]] = None) -> None:
|
|
423
|
+
"""One structured line, 0600, rotated at 1 MB. Never raises."""
|
|
424
|
+
try:
|
|
425
|
+
root = state_root(env=env)
|
|
426
|
+
root.mkdir(parents=True, exist_ok=True)
|
|
427
|
+
path = root / "hooks.log"
|
|
428
|
+
if path.exists() and path.stat().st_size > LOG_CAP_BYTES:
|
|
429
|
+
os.replace(str(path), str(root / "hooks.log.1"))
|
|
430
|
+
fd = os.open(str(path), os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o600)
|
|
431
|
+
with os.fdopen(fd, "a", encoding="utf-8") as handle:
|
|
432
|
+
handle.write("%s %s\n" % (time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), line))
|
|
433
|
+
except Exception: # noqa: BLE001 — logging never breaks a hook
|
|
434
|
+
pass
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def _write_private(path: Path, text: str) -> None:
|
|
438
|
+
tmp = path.with_name(".%s.%d.tmp" % (path.name, os.getpid()))
|
|
439
|
+
fd = os.open(str(tmp), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
|
440
|
+
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
|
441
|
+
handle.write(text)
|
|
442
|
+
os.replace(str(tmp), str(path))
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
def session_dir(session_id: str, env: Optional[Dict[str, str]] = None) -> Path:
|
|
446
|
+
return state_root(env=env) / "sessions" / session_id
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
def spool(directory: Path, entry: Dict[str, Any]) -> Path:
|
|
450
|
+
directory.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
451
|
+
path = directory / ("%020d-%s.json" % (time.time_ns(), entry["event"]))
|
|
452
|
+
_write_private(path, json.dumps(entry, sort_keys=True))
|
|
453
|
+
turns = sorted(directory.glob("*-turn.json"))
|
|
454
|
+
for old in turns[:max(0, len(turns) - SPOOL_CAP)]:
|
|
455
|
+
try:
|
|
456
|
+
old.unlink()
|
|
457
|
+
except OSError:
|
|
458
|
+
pass
|
|
459
|
+
return path
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
def prune_stale_sessions(env: Optional[Dict[str, str]] = None, now: Optional[float] = None,
|
|
463
|
+
keep: Optional[str] = None) -> int:
|
|
464
|
+
"""Remove session folders whose newest file is older than SESSION_STALE_S (a running worker's lock is a fresh file,
|
|
465
|
+
so its folder is never stale); never the folder of the session `keep`. Returns how many were removed. Never
|
|
466
|
+
raises."""
|
|
467
|
+
now = time.time() if now is None else now
|
|
468
|
+
removed = 0
|
|
469
|
+
try:
|
|
470
|
+
folders = list((state_root(env=env) / "sessions").iterdir())
|
|
471
|
+
except OSError:
|
|
472
|
+
return 0
|
|
473
|
+
for folder in folders:
|
|
474
|
+
try:
|
|
475
|
+
if folder.name == keep or not SESSION_ID.fullmatch(folder.name) or not folder.is_dir():
|
|
476
|
+
continue
|
|
477
|
+
newest = max([folder.stat().st_mtime] + [f.stat().st_mtime for f in folder.iterdir()])
|
|
478
|
+
if now - newest > SESSION_STALE_S:
|
|
479
|
+
_remove_tree(folder)
|
|
480
|
+
removed += 1
|
|
481
|
+
except OSError:
|
|
482
|
+
continue
|
|
483
|
+
return removed
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
def lock_held(directory: Path) -> bool:
|
|
487
|
+
lock = directory / "worker.lock"
|
|
488
|
+
try:
|
|
489
|
+
return time.time() - lock.stat().st_mtime < LOCK_STALE_S
|
|
490
|
+
except OSError:
|
|
491
|
+
return False
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
def spawn_worker(directory: Path) -> None:
|
|
495
|
+
"""Start `deliver` detached: the hook returns at once and the worker outlives the session if it must."""
|
|
496
|
+
argv = [sys.executable, "-S", str(Path(__file__).resolve().with_name("fabric_session.py")), "deliver", str(directory)] # the entry point, not this module
|
|
497
|
+
options: Dict[str, Any] = {"stdin": subprocess.DEVNULL, "stdout": subprocess.DEVNULL,
|
|
498
|
+
"stderr": subprocess.DEVNULL, "close_fds": True}
|
|
499
|
+
if sys.platform == "win32":
|
|
500
|
+
options["creationflags"] = 0x00000008 | 0x00000200 # DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP
|
|
501
|
+
else:
|
|
502
|
+
options["start_new_session"] = True
|
|
503
|
+
subprocess.Popen(argv, **options) # noqa: S603 — our own script, argv holds no secret
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
def _remove_tree(directory: Path) -> None:
|
|
507
|
+
import shutil
|
|
508
|
+
shutil.rmtree(str(directory), ignore_errors=True)
|