@andespindola/crow-skills 0.25.0 → 0.26.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.
@@ -0,0 +1,632 @@
1
+ """Opt-in, reversible native-host adapters for crow-uny-mcp.
2
+
3
+ Discovery never starts an MCP or prints commands, environment or credentials.
4
+ Host configuration writes require explicit approval and a private recovery record.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import argparse
9
+ from contextlib import contextmanager
10
+ import hashlib
11
+ import importlib.util
12
+ import json
13
+ import os
14
+ from pathlib import Path
15
+ import re
16
+ import stat
17
+ import sys
18
+ import tempfile
19
+ import time
20
+ import uuid
21
+
22
+ try:
23
+ import tomllib
24
+ except ImportError: # Codex TOML is observational-only on Python 3.10.
25
+ tomllib = None
26
+
27
+ SCHEMA = "crow-mcp-adapters/1"
28
+ HOSTS = (
29
+ ("codex", ".codex/config.toml", "toml"),
30
+ ("claude", ".claude.json", "claude"),
31
+ ("devin", ".config/devin/mcp_config.json", "json"),
32
+ ("claude-desktop", "Library/Application Support/Claude/claude_desktop_config.json", "json"),
33
+ ("agents", ".config/agents/mcp-servers.json", "flat"),
34
+ )
35
+ MAX_CONFIG = 16 * 1024 * 1024
36
+
37
+
38
+ class MCPError(ValueError):
39
+ """Safe-to-display adapter error: never include configuration values."""
40
+
41
+
42
+ def _digest(data):
43
+ return hashlib.sha256(data).hexdigest()
44
+
45
+
46
+ def _home(value):
47
+ return Path(os.path.abspath(os.path.expanduser(str(value))))
48
+
49
+
50
+ def _safe(path, home):
51
+ path, home = Path(os.path.abspath(path)), _home(home)
52
+ try:
53
+ rel = path.relative_to(home)
54
+ except ValueError:
55
+ raise MCPError("path outside selected home") from None
56
+ current = home
57
+ if current.is_symlink():
58
+ raise MCPError("symlink home refused")
59
+ for part in rel.parts:
60
+ current = current / part
61
+ if current.is_symlink():
62
+ raise MCPError("symlink configuration/state path refused")
63
+ return path
64
+
65
+
66
+ def _read(path, home):
67
+ path = _safe(path, home)
68
+ fd = os.open(path, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0))
69
+ try:
70
+ st = os.fstat(fd)
71
+ if not stat.S_ISREG(st.st_mode) or st.st_nlink != 1 or st.st_size > MAX_CONFIG:
72
+ raise MCPError("configuration is not a bounded regular file")
73
+ with os.fdopen(fd, "rb", closefd=False) as f:
74
+ data = f.read(MAX_CONFIG + 1)
75
+ if len(data) > MAX_CONFIG:
76
+ raise MCPError("configuration exceeds size limit")
77
+ return data
78
+ finally:
79
+ os.close(fd)
80
+
81
+
82
+ def _mkdir(path, home):
83
+ path = _safe(path, home)
84
+ path.mkdir(parents=True, exist_ok=True, mode=0o700)
85
+ os.chmod(path, 0o700)
86
+ return path
87
+
88
+
89
+ def _atomic(path, data, home, expected=None, private=False):
90
+ path = _safe(path, home)
91
+ _mkdir(path.parent, home) if private else path.parent.mkdir(parents=True, exist_ok=True)
92
+ mode = 0o600 if private else stat.S_IMODE(path.stat().st_mode) if path.exists() else 0o600
93
+ fd, temp = tempfile.mkstemp(prefix="." + path.name + ".", dir=path.parent)
94
+ try:
95
+ os.fchmod(fd, mode)
96
+ with os.fdopen(fd, "wb") as handle:
97
+ handle.write(data)
98
+ handle.flush()
99
+ os.fsync(handle.fileno())
100
+ # Optimistic compare immediately before replace. External applications
101
+ # do not share our lock: a filesystem-wide atomic CAS is not claimed.
102
+ if expected is not None and (not path.exists() or _digest(_read(path, home)) != expected):
103
+ raise MCPError("configuration changed during operation; refused overwrite")
104
+ _safe(path, home)
105
+ os.replace(temp, path)
106
+ finally:
107
+ if os.path.lexists(temp):
108
+ os.unlink(temp)
109
+
110
+
111
+ def _encode(value):
112
+ return (json.dumps(value, ensure_ascii=False, indent=2) + "\n").encode()
113
+
114
+
115
+ def _state(home):
116
+ return _home(home) / ".local/state/crow-skills/mcp"
117
+
118
+
119
+ @contextmanager
120
+ def _mutation_lock(home):
121
+ """Serialize this manager's mutations; external hosts still need digest checks."""
122
+ directory = _mkdir(_state(home), home)
123
+ path = _safe(directory / "adapter.lock", home)
124
+ fd = os.open(path, os.O_RDWR | os.O_CREAT | getattr(os, "O_NOFOLLOW", 0), 0o600)
125
+ locked = False
126
+ try:
127
+ os.fchmod(fd, 0o600)
128
+ if os.name == "nt":
129
+ import msvcrt
130
+ if os.fstat(fd).st_size == 0:
131
+ os.write(fd, b"0")
132
+ os.lseek(fd, 0, os.SEEK_SET)
133
+ try:
134
+ msvcrt.locking(fd, msvcrt.LK_NBLCK, 1)
135
+ except OSError:
136
+ raise MCPError("another MCP configuration operation is active") from None
137
+ else:
138
+ import fcntl
139
+ try:
140
+ fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
141
+ except OSError:
142
+ raise MCPError("another MCP configuration operation is active") from None
143
+ locked = True
144
+ yield
145
+ finally:
146
+ if locked:
147
+ if os.name == "nt":
148
+ import msvcrt
149
+ os.lseek(fd, 0, os.SEEK_SET)
150
+ msvcrt.locking(fd, msvcrt.LK_UNLCK, 1)
151
+ else:
152
+ import fcntl
153
+ fcntl.flock(fd, fcntl.LOCK_UN)
154
+ os.close(fd)
155
+
156
+
157
+ def _platform_supported():
158
+ return sys.platform == "darwin" or sys.platform.startswith("linux")
159
+
160
+
161
+ def _require_platform():
162
+ if not _platform_supported():
163
+ raise MCPError("unsupported-platform: lifecycle wrapping requires macOS or Linux kernel process identity")
164
+
165
+
166
+ def _runtime(runtime=None):
167
+ candidate = Path(runtime) if runtime is not None else Path(__file__).resolve().parents[1] / "skills/crow-uny-mcp/scripts/crow_mcp.py"
168
+ if not candidate.is_absolute() or candidate.name != "crow_mcp.py" or not candidate.is_file():
169
+ raise MCPError("absolute crow_mcp.py runtime file required")
170
+ return str(candidate.resolve())
171
+
172
+
173
+ def _is_managed(config):
174
+ args = config.get("args", [])
175
+ return (isinstance(args, list) and len(args) >= 2 and isinstance(args[0], str)
176
+ and Path(args[0]).name == "crow_mcp.py" and args[1] == "serve")
177
+
178
+
179
+ def _tables(text):
180
+ """Return root [mcp_servers.NAME] spans; nested env tables are untouched."""
181
+ if tomllib is None:
182
+ return {}
183
+ headers = list(re.finditer(r"(?m)^[ \t]*\[[^\n]+\][ \t]*(?:\#[^\n]*)?(?:\n|$)", text))
184
+ result = {}
185
+ for i, match in enumerate(headers):
186
+ header = match.group().strip()
187
+ if header.startswith("[["):
188
+ continue
189
+ try:
190
+ parsed = tomllib.loads(header + "\n__crow_probe = true\n")
191
+ servers = parsed.get("mcp_servers")
192
+ if not isinstance(servers, dict) or len(servers) != 1:
193
+ continue
194
+ name, value = next(iter(servers.items()))
195
+ if not isinstance(value, dict) or value != {"__crow_probe": True}:
196
+ continue
197
+ except (ValueError, TypeError):
198
+ continue
199
+ end = headers[i + 1].start() if i + 1 < len(headers) else len(text)
200
+ result[name] = (match.end(), end)
201
+ return result
202
+
203
+
204
+ def _toml_replace(text, name, command, args):
205
+ spans = _tables(text)
206
+ if name not in spans:
207
+ raise MCPError("unsupported Codex TOML layout; no changes applied")
208
+ start, end = spans[name]
209
+ body = text[start:end]
210
+ edits = []
211
+ found = set()
212
+ lines = body.splitlines(keepends=True)
213
+ offsets, pos = [], 0
214
+ for line in lines:
215
+ offsets.append(pos)
216
+ pos += len(line)
217
+ for i, line in enumerate(lines):
218
+ match = re.match(r"^[ \t]*(command|args)[ \t]*=", line)
219
+ if not match:
220
+ continue
221
+ key = match.group(1)
222
+ for j in range(i + 1, len(lines) + 1):
223
+ try:
224
+ parsed = tomllib.loads("".join(lines[i:j]))
225
+ except ValueError:
226
+ continue
227
+ if set(parsed) == {key}:
228
+ oldend = offsets[j] if j < len(lines) else len(body)
229
+ value = command if key == "command" else args
230
+ edits.append((offsets[i], oldend, key + " = " + json.dumps(value, ensure_ascii=False) + "\n"))
231
+ found.add(key)
232
+ break
233
+ else:
234
+ raise MCPError("unsupported Codex assignment; no changes applied")
235
+ for left, right, replacement in reversed(edits):
236
+ body = body[:left] + replacement + body[right:]
237
+ for key, value in (("command", command), ("args", args)):
238
+ if key not in found:
239
+ body += ("" if not body or body.endswith("\n") else "\n") + key + " = " + json.dumps(value, ensure_ascii=False) + "\n"
240
+ changed = text[:start] + body + text[end:]
241
+ try:
242
+ tomllib.loads(changed)
243
+ except ValueError:
244
+ raise MCPError("generated Codex TOML rejected") from None
245
+ return changed
246
+
247
+
248
+ def _maps(document, kind):
249
+ if kind == "flat":
250
+ yield "", document
251
+ else:
252
+ servers = document.get("mcpServers", {})
253
+ if isinstance(servers, dict):
254
+ yield "", servers
255
+ if kind == "claude":
256
+ projects = document.get("projects", {})
257
+ if isinstance(projects, dict):
258
+ for index, (_path, project) in enumerate(projects.items()):
259
+ if isinstance(project, dict) and isinstance(project.get("mcpServers"), dict):
260
+ # Do not disclose project paths in observations.
261
+ yield "project-" + str(index), project["mcpServers"]
262
+
263
+
264
+ def _inventory(home):
265
+ home = _home(home)
266
+ files = []
267
+ rows = []
268
+ for host, rel, kind in HOSTS:
269
+ path = home / rel
270
+ try:
271
+ _safe(path, home)
272
+ if not path.exists():
273
+ continue
274
+ raw = _read(path, home)
275
+ if kind == "toml":
276
+ if tomllib is None:
277
+ rows.append({"host": host, "name": "*", "status": "unsupported-python-toml", "managed": False})
278
+ continue
279
+ doc = tomllib.loads(raw.decode())
280
+ maps = [("", doc.get("mcp_servers", {}))]
281
+ spans = _tables(raw.decode())
282
+ else:
283
+ doc = json.loads(raw)
284
+ if not isinstance(doc, dict):
285
+ raise MCPError("invalid configuration shape")
286
+ maps = list(_maps(doc, kind))
287
+ spans = None
288
+ item = {"path": path, "rel": rel, "kind": kind, "raw": raw, "doc": doc, "entries": []}
289
+ for scope, servers in maps:
290
+ if not isinstance(servers, dict):
291
+ continue
292
+ for name, config in servers.items():
293
+ if not isinstance(config, dict):
294
+ continue
295
+ public_name = str(name) if not scope else scope + ":" + str(name)
296
+ managed = _is_managed(config)
297
+ native_http = "url" in config or config.get("type") in ("http", "sse", "streamable-http", "streamableHttp") or config.get("transport") in ("http", "sse", "streamable-http", "streamableHttp")
298
+ eligible = (not native_http and isinstance(config.get("command"), str) and bool(config["command"]) and isinstance(config.get("args", []), list) and all(isinstance(v, str) for v in config.get("args", [])))
299
+ state = "managed" if managed else "native-http-observed" if native_http else "eligible-stdio" if eligible else "unsupported-config"
300
+ if config.get("enabled") is False:
301
+ state, eligible = "disabled-observed", False
302
+ if kind == "toml" and eligible and name not in spans:
303
+ state, eligible = "unsupported-toml-layout", False
304
+ row = {"host": host, "name": public_name, "status": state, "managed": managed}
305
+ if managed:
306
+ argv = config.get("args", [])
307
+ try:
308
+ row["profile"] = argv[argv.index("--profile") + 1]
309
+ except (ValueError, IndexError):
310
+ row["profile"] = "unknown"
311
+ rows.append(row)
312
+ if state == "eligible-stdio":
313
+ item["entries"].append((scope, name, config, row))
314
+ files.append(item)
315
+ except (OSError, ValueError, UnicodeError) as error:
316
+ # No exception text: parsers can include secret input fragments.
317
+ rows.append({"host": host, "name": "*", "status": "unsafe-or-invalid-config", "managed": False})
318
+ return files, rows
319
+
320
+
321
+ def plan(home, runtime=None, restartable=(), hosts=None):
322
+ _files, rows = _inventory(home)
323
+ if hosts is not None:
324
+ rows = [row for row in rows if row["host"] in hosts]
325
+ try:
326
+ resolved = _runtime(runtime)
327
+ runtime_ready = _platform_supported()
328
+ runtime_status = "ready" if runtime_ready else "unsupported-platform"
329
+ except MCPError:
330
+ resolved, runtime_ready = None, False
331
+ runtime_status = "missing-runtime" if _platform_supported() else "unsupported-platform"
332
+ names = set(restartable)
333
+ for row in rows:
334
+ if not row["managed"]:
335
+ row["profile"] = "restartable" if row["name"] in names or row["host"] + ":" + row["name"] in names else "session"
336
+ return {"schema": SCHEMA, "action": "plan", "runtimeReady": runtime_ready, "runtimeStatus": runtime_status, "servers": rows,
337
+ "eligible": sum(r["status"] == "eligible-stdio" for r in rows), "mutation": False}
338
+
339
+
340
+ def _manifest_path(home):
341
+ return _state(home) / "adapter-manifest.json"
342
+
343
+
344
+ def _manifest(home):
345
+ path = _manifest_path(home)
346
+ if not path.exists():
347
+ return {"schema": SCHEMA, "files": []}
348
+ try:
349
+ data = json.loads(_read(path, home))
350
+ if data.get("schema") != SCHEMA or not isinstance(data.get("files"), list):
351
+ raise MCPError("invalid adapter recovery record")
352
+ known = {rel for _host, rel, _kind in HOSTS}
353
+ for record in data["files"]:
354
+ if (not isinstance(record, dict) or record.get("path") not in known
355
+ or not re.fullmatch(r"\.local/state/crow-skills/mcp/backups/[0-9a-f]{32}\.original", str(record.get("backup", "")))
356
+ or any(not re.fullmatch(r"[0-9a-f]{64}", str(record.get(key, ""))) for key in ("originalSha256", "appliedSha256"))
357
+ or record.get("state") not in ("prepared", "applied", "restored")):
358
+ raise MCPError("invalid adapter recovery record")
359
+ return data
360
+ except (OSError, ValueError, TypeError):
361
+ raise MCPError("invalid or unsafe adapter recovery record") from None
362
+
363
+
364
+ def _save_manifest(home, data):
365
+ _atomic(_manifest_path(home), _encode(data), home, private=True)
366
+
367
+
368
+ def apply(home, runtime=None, restartable=(), yes=False, hosts=None):
369
+ if not yes:
370
+ raise MCPError("explicit --yes approval required for host configuration changes")
371
+ _require_platform()
372
+ _runtime(runtime)
373
+ with _mutation_lock(home):
374
+ return _apply(home, runtime=runtime, restartable=restartable, yes=yes, hosts=hosts)
375
+
376
+
377
+ def _apply(home, runtime=None, restartable=(), yes=False, hosts=None):
378
+ if not yes:
379
+ raise MCPError("explicit --yes approval required for host configuration changes")
380
+ home = _home(home)
381
+ runtime = _runtime(runtime)
382
+ files, rows = _inventory(home)
383
+ if hosts is not None:
384
+ selected = set(hosts)
385
+ supported = {host for host, _rel, _kind in HOSTS}
386
+ if not selected or not selected <= supported:
387
+ raise MCPError("explicit supported host scope required")
388
+ rows = [row for row in rows if row["host"] in selected]
389
+ files = [item for item in files if any(row["host"] in selected for _scope, _name, _config, row in item["entries"])]
390
+ if any(row["status"] == "unsafe-or-invalid-config" for row in rows):
391
+ raise MCPError("unsafe or invalid host configuration; no changes applied")
392
+ manifest = _manifest(home)
393
+ existing = {r["path"] for r in manifest["files"]}
394
+ names = set(restartable)
395
+ for row in rows:
396
+ if row["managed"] and (row["name"] in names or row["host"] + ":" + row["name"] in names) and row.get("profile") != "restartable":
397
+ raise MCPError("managed profile change requires conditional restore before reapply")
398
+ changed = []
399
+ prepared = []
400
+ for item in files:
401
+ entries = item["entries"]
402
+ if entries and not (item["path"].stat().st_mode & 0o222):
403
+ raise MCPError("read-only host configuration; no changes applied")
404
+ if not entries:
405
+ continue
406
+ if item["rel"] in existing:
407
+ raise MCPError("managed configuration has additional changes; restore before reapplying")
408
+ text = item["raw"].decode()
409
+ for _scope, name, config, row in entries:
410
+ profile = "restartable" if row["name"] in names or row["host"] + ":" + row["name"] in names else "session"
411
+ argv = [runtime, "serve", "--name", row["host"] + ":" + row["name"], "--profile", profile, "--state-dir", str(_state(home) / "instances"), "--", config["command"], *config.get("args", [])]
412
+ python = str(Path(sys.executable).resolve())
413
+ if item["kind"] == "toml":
414
+ text = _toml_replace(text, name, python, argv)
415
+ else:
416
+ config["command"], config["args"] = python, argv
417
+ data = text.encode() if item["kind"] == "toml" else _encode(item["doc"])
418
+ prepared.append((item, data))
419
+ # Check every baseline again before creating recovery records or modifying hosts.
420
+ for item, _ in prepared:
421
+ if _read(item["path"], home) != item["raw"]:
422
+ raise MCPError("configuration changed during planning; no changes applied")
423
+ for item, data in prepared:
424
+ backup_rel = ".local/state/crow-skills/mcp/backups/" + uuid.uuid4().hex + ".original"
425
+ _atomic(home / backup_rel, item["raw"], home, private=True)
426
+ record = {"path": item["rel"], "backup": backup_rel, "originalSha256": _digest(item["raw"]),
427
+ "appliedSha256": _digest(data), "state": "prepared", "host": item["entries"][0][3]["host"]}
428
+ manifest["files"].append(record)
429
+ _save_manifest(home, manifest)
430
+ _atomic(item["path"], data, home, expected=_digest(item["raw"]))
431
+ record["state"] = "applied"
432
+ _save_manifest(home, manifest)
433
+ changed.append(item["rel"])
434
+ return {"schema": SCHEMA, "action": "apply", "changed": changed,
435
+ "servers": plan(home, runtime, restartable, hosts=hosts)["servers"], "mutation": bool(changed),
436
+ "runtimeReloadRequired": bool(changed)}
437
+
438
+
439
+ def restore(home, yes=False, hosts=None, dry_run=False):
440
+ if dry_run:
441
+ return _restore(home, yes=yes, hosts=hosts, dry_run=True)
442
+ if not yes:
443
+ raise MCPError("explicit --yes approval required for restore")
444
+ with _mutation_lock(home):
445
+ return _restore(home, yes=yes, hosts=hosts)
446
+
447
+
448
+ def _restore(home, yes=False, hosts=None, dry_run=False):
449
+ if not yes and not dry_run:
450
+ raise MCPError("explicit --yes approval required for restore")
451
+ home = _home(home)
452
+ manifest = _manifest(home)
453
+ ready, conflicts = [], []
454
+ if hosts is not None and (not hosts or not set(hosts) <= {host for host, _rel, _kind in HOSTS}):
455
+ raise MCPError("explicit supported host scope required")
456
+ for record in manifest["files"]:
457
+ owner = record.get("host") or next((host for host, rel, _kind in HOSTS if rel == record.get("path")), None)
458
+ if hosts is not None and owner not in hosts:
459
+ continue
460
+ if record.get("state") == "restored":
461
+ continue
462
+ path = _safe(home / record["path"], home)
463
+ try:
464
+ current = _read(path, home)
465
+ except OSError:
466
+ # External deletion or loss of read access is a restore conflict,
467
+ # never authority to recreate, chmod or overwrite the host file.
468
+ conflicts.append(record["path"])
469
+ continue
470
+ if _digest(current) == record["originalSha256"]:
471
+ record["state"] = "restored"
472
+ continue
473
+ if _digest(current) != record["appliedSha256"]:
474
+ conflicts.append(record["path"])
475
+ continue
476
+ try:
477
+ original = _read(home / record["backup"], home)
478
+ except (OSError, MCPError):
479
+ raise MCPError("backup integrity check failed; no restore applied") from None
480
+ if _digest(original) != record["originalSha256"]:
481
+ raise MCPError("backup integrity check failed; no restore applied")
482
+ ready.append((record, path, original))
483
+ if conflicts:
484
+ raise MCPError("configuration changed externally; restore refused without overwriting edits")
485
+ if dry_run:
486
+ return {"schema": SCHEMA, "action": "restore", "changed": [], "wouldRestore": [r["path"] for r, _p, _o in ready],
487
+ "mutation": False, "dry_run": True}
488
+ changed = []
489
+ for record, path, original in ready:
490
+ _atomic(path, original, home, expected=record["appliedSha256"])
491
+ record["state"] = "restored"
492
+ _save_manifest(home, manifest)
493
+ changed.append(record["path"])
494
+ manifest["files"] = [r for r in manifest["files"] if r.get("state") != "restored"]
495
+ if _manifest_path(home).exists():
496
+ _save_manifest(home, manifest)
497
+ return {"schema": SCHEMA, "action": "restore", "changed": changed, "mutation": bool(changed),
498
+ "runtimeReloadRequired": bool(changed)}
499
+
500
+
501
+ def runtime_dependencies(home, runtime):
502
+ """Observe native wrappers referencing a runtime, never signal or mutate them."""
503
+ target = Path(runtime).resolve()
504
+ files, rows = _inventory(home)
505
+ result = [{"host": row["host"], "name": "*", "unverifiable": True}
506
+ for row in rows if row["status"] == "unsafe-or-invalid-config"]
507
+ for item in files:
508
+ maps = [("", item["doc"].get("mcp_servers", {}))] if item["kind"] == "toml" else _maps(item["doc"], item["kind"])
509
+ host = next(host for host, rel, _kind in HOSTS if rel == item["rel"])
510
+ for scope, servers in maps:
511
+ if not isinstance(servers, dict):
512
+ continue
513
+ for name, config in servers.items():
514
+ if isinstance(config, dict) and _is_managed(config) and Path(config["args"][0]).resolve() == target:
515
+ result.append({"host": host, "name": (scope + ":" if scope else "") + str(name)})
516
+ return result
517
+
518
+
519
+ _IDENTITY_READER = None
520
+
521
+
522
+ def _identity(pid):
523
+ global _IDENTITY_READER
524
+ if not isinstance(pid, int) or isinstance(pid, bool) or pid <= 0:
525
+ return None
526
+ try:
527
+ if _IDENTITY_READER is None:
528
+ spec = importlib.util.spec_from_file_location("crow_mcp_status_identity", _runtime())
529
+ module = importlib.util.module_from_spec(spec)
530
+ spec.loader.exec_module(module)
531
+ _IDENTITY_READER = module.process_identity
532
+ return _IDENTITY_READER(pid)
533
+ except (OSError, ValueError, ImportError, AttributeError):
534
+ return None
535
+
536
+
537
+ def status(home):
538
+ home = _home(home)
539
+ result = plan(home)
540
+ result.update(action="status", configuredManaged=sum(r["managed"] for r in result["servers"]))
541
+ snapshots = []
542
+ directory = _state(home) / "instances"
543
+ if directory.exists():
544
+ _safe(directory, home)
545
+ for path in sorted(directory.glob("runtime-*.json"))[:200]:
546
+ try:
547
+ data = json.loads(_read(path, home))
548
+ # Registry entries are observations, never authority to signal PIDs.
549
+ row = {k: data[k] for k in ("pid", "child_pid", "pending_client", "pending_server", "subscriptions", "startup_ms", "resume_ms")
550
+ if k in data and isinstance(data[k], (int, float, type(None))) and not isinstance(data[k], bool)}
551
+ if data.get("state") in ("cold", "starting", "ready", "busy", "idle", "standby", "stopped", "pinned", "failed", "active", "hibernated", "session_lost"):
552
+ row["state"] = data["state"]
553
+ if data.get("profile") in ("session", "restartable"):
554
+ row["profile"] = data["profile"]
555
+ if isinstance(data.get("retainedMemory"), bool):
556
+ row["retainedMemory"] = data["retainedMemory"]
557
+ for pid_key, identity_key, live_key, verified_key in (("pid", "pid_identity", "pidAlive", "identityVerified"),
558
+ ("child_pid", "child_identity", "childAlive", "childIdentityVerified")):
559
+ current = _identity(data.get(pid_key))
560
+ recorded = data.get(identity_key)
561
+ row[live_key] = current is not None
562
+ row[verified_key] = current is not None and isinstance(recorded, (tuple, list)) and tuple(recorded) == tuple(current)
563
+ row["source"] = "runtime-reported"
564
+ snapshots.append(row)
565
+ except (OSError, ValueError):
566
+ continue
567
+ result["runtimeObservations"] = snapshots
568
+ result["liveSessionsReloaded"] = False
569
+ return result
570
+
571
+
572
+ def activate(home, runtime, approved=False, hosts=None):
573
+ """Installer hook: approved, scoped installation wraps hosts without starting servers.
574
+
575
+ Without installation approval this is observation only. An approved call must
576
+ name a supported host scope; it never expands to unrelated configured apps.
577
+ """
578
+ if not approved:
579
+ return {"schema": SCHEMA, "active": False, "mode": "observe", "wrapped": False,
580
+ "plan": plan(home, runtime=runtime, hosts=hosts)}
581
+ _require_platform()
582
+ if not hosts:
583
+ raise MCPError("explicit supported host scope required for installation activation")
584
+ resolved = _runtime(runtime)
585
+ result = apply(home, runtime=resolved, yes=True, hosts=hosts)
586
+ _atomic(_state(home) / "activation.json", _encode({"schema": SCHEMA, "enabled": True,
587
+ "mode": "managed", "runtime": resolved, "hosts": sorted(set(hosts)),
588
+ "activatedAt": int(time.time())}), home, private=True)
589
+ return {"schema": SCHEMA, "active": True, "mode": "managed", "wrapped": True,
590
+ "result": result, "runtimeReloadRequired": result["runtimeReloadRequired"]}
591
+
592
+
593
+ def main(argv=None):
594
+ parser = argparse.ArgumentParser(prog="crow-skills mcp")
595
+ parser.add_argument("action", choices=("status", "plan", "apply", "restore"))
596
+ parser.add_argument("--home", default=str(Path.home()))
597
+ parser.add_argument("--json", action="store_true")
598
+ parser.add_argument("--yes", action="store_true")
599
+ parser.add_argument("--dry-run", action="store_true")
600
+ parser.add_argument("--runtime")
601
+ parser.add_argument("--hosts", help="comma-separated explicit host scope")
602
+ parser.add_argument("--restartable", default="", help="comma-separated server names or host:name, explicit restartable profile")
603
+ args = parser.parse_args(argv)
604
+ try:
605
+ if args.action == "status":
606
+ result = status(args.home)
607
+ elif args.action == "restore":
608
+ result = restore(args.home, yes=args.yes, dry_run=args.dry_run, hosts=[s.strip() for s in args.hosts.split(",") if s.strip()] if args.hosts is not None else None)
609
+ else:
610
+ opts = {"runtime": args.runtime, "restartable": [s.strip() for s in args.restartable.split(",") if s.strip()],
611
+ "hosts": [s.strip() for s in args.hosts.split(",") if s.strip()] if args.hosts is not None else None}
612
+ result = apply(args.home, yes=args.yes, **opts) if args.action == "apply" and not args.dry_run else plan(args.home, **opts)
613
+ if args.json:
614
+ print(json.dumps({"schema_version": 1, "ok": True, "command": "mcp", "data": result}, ensure_ascii=False))
615
+ else:
616
+ print("MCP " + args.action + ": " + str(len(result.get("changed", []))) + " file(s) changed" if args.action in ("apply", "restore") else
617
+ "MCP " + args.action + ": " + str(len(result.get("servers", []))) + " configured entry/entries")
618
+ for row in result.get("servers", []):
619
+ print((row["host"] + " / " + row["name"] + ": " + row["status"]).encode("unicode_escape").decode())
620
+ return 0
621
+ except (MCPError, OSError) as error:
622
+ # OSError can include paths but not config bodies; keep it generic too.
623
+ message = str(error) if isinstance(error, MCPError) else "filesystem operation failed; no unsafe retry performed"
624
+ if args.json:
625
+ print(json.dumps({"schema_version": 1, "ok": False, "command": "mcp", "error": {"code": "mcp_adapter", "message": message}}))
626
+ else:
627
+ print("MCP: " + message, file=sys.stderr)
628
+ return 2
629
+
630
+
631
+ if __name__ == "__main__":
632
+ raise SystemExit(main())
package/docs/cli.md CHANGED
@@ -622,3 +622,25 @@ Cada execução:
622
622
  3. Para cada agente com skills gerenciadas (claude, codex/`$CODEX_HOME/skills`, gemini, devin, uniflow), roda o equivalente a `update <skills> --agents <agente> --global --yes`: regenera guia, integrações e hooks; a [identidade do pacote](#origem-e-versões) evita `source_mismatch` depois do upgrade. Skills editadas localmente (`modified`), ausentes ou instaladas de outra origem (`other_origin`, por exemplo de um clone Git) ficam como estão e aparecem em `kept`; nada é sobrescrito. Uma reaplicação dos hooks troca as entradas do crow-skills no lugar, sem mudar a posição das suas. Uma recusa num agente (por exemplo, guia editado) aparece em `error` e não interrompe os outros.
623
623
 
624
624
  O resultado fica em `~/.local/state/crow-skills/autoupdate/state.json` (`lastRun` e as últimas 20 execuções), aparece em `autoupdate status`, em `status`/`doctor` (doctor `healthy:false` se a última execução falhou ou o agendador foi alterado/removido) e em `events snapshot`. Duas execuções simultâneas são recusadas (`busy`).
625
+
626
+ ## MCP passivo: crow-uny-mcp
627
+
628
+ `crow-uny-mcp` é uma skill com runtime stdio autocontido, não uma substituição das ferramentas por comandos de uma CLI externa. Uma instalação global explicitamente aprovada configura os adaptadores suportados do agente selecionado e o guia nativo; instalação de projeto não modifica configurações globais. Não instala dependências de terceiros nem acorda servidores para descobrir ferramentas. Recarregue o host: configuração em disco não substitui conexões já abertas.
629
+
630
+ ```sh
631
+ crow-skills install crow-uny-mcp --agents claude --global --yes
632
+ crow-skills mcp status --json
633
+ crow-skills mcp plan --hosts claude --json
634
+ crow-skills mcp apply --hosts claude --yes
635
+ crow-skills mcp restore --hosts claude --yes
636
+ ```
637
+
638
+ Os adaptadores cobrem entradas stdio nos formatos conhecidos de Codex, Claude Code, Devin, Claude Desktop no macOS e catálogo JSON compartilhado de agentes. Entradas HTTP nativas, plugins internos de apps e outros formatos ficam somente observados. O runtime usa sinais e identidade de processos POSIX; isso não é validação em todos os sistemas operacionais ou versões desses hosts.
639
+
640
+ - Padrão `session`: preserva o estado suspendendo a árvore própria ociosa e retomando antes da chamada. A RAM permanece alocada; sockets remotos ainda podem expirar durante a pausa.
641
+ - Perfil `restartable`: encerra a árvore ociosa para liberar RAM e refaz somente o handshake na próxima chamada. Autorize os nomes verificados com `--restartable nome1,nome2`; mudar perfil já gerenciado requer restore/novo apply, não troca silenciosa.
642
+ - Pedidos em andamento, chamadas servidor→cliente e assinaturas impedem standby/reap. O cache privado atende descoberta quente sem iniciar o servidor; descoberta fria ainda precisa de uma inicialização. Não há repetição automática de operação de negócio após falha.
643
+ - O proxy tem overhead próprio. Tempos sintéticos de warm-up não garantem latência de um servidor autenticado real. Processos externos já existentes ou que se desprendem antes da captura de propriedade não são adotados pelo manager.
644
+ - Backup e restauração são privados e condicionais: edição externa bloqueia restore, preservando o arquivo. O plano distingue configurado, observado e não suportado; `status` sem processo observado não prova que a conexão de um app foi recarregada.
645
+
646
+ O guia torna o contrato passivo vigente quando instalado, independentemente do interruptor de coordenação `crow-behaviour`; não confunda isso com poder obrigar um app sem adaptador a obedecer. O usuário continua dono da configuração e das permissões.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@andespindola/crow-skills",
3
- "version": "0.25.0",
3
+ "version": "0.26.0",
4
4
  "description": "Versioned, complementary agent skills with a protected multi-agent installer",
5
5
  "license": "SEE LICENSE IN LICENSE",
6
6
  "repository": {