@agentsdock/server 1.0.7-beta.5 → 1.0.8

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 (45) hide show
  1. package/README.md +20 -6
  2. package/package.json +1 -1
  3. package/server/VERSION +1 -1
  4. package/server/agent_server.py +5283 -698
  5. package/server/agentsdock_chats.py +2 -1
  6. package/server/chat_mailbox.py +23 -6
  7. package/server/claude_history_repair.py +45 -4
  8. package/server/claude_model_catalog.py +200 -0
  9. package/server/claude_sdk_client.py +63 -24
  10. package/server/claude_side_question.py +1 -8
  11. package/server/codex_app_server.py +90 -30
  12. package/server/codex_auth.py +119 -0
  13. package/server/codex_history_repair.py +79 -14
  14. package/server/codex_provider.py +160 -77
  15. package/server/codex_side_question.py +154 -57
  16. package/server/cursor_agent_client.py +27 -1
  17. package/server/cursor_api_key.py +64 -0
  18. package/server/cursor_history.py +219 -0
  19. package/server/cursor_provider_mcp.py +290 -0
  20. package/server/execution_durability.py +10 -6
  21. package/server/execution_preparation.py +6 -2
  22. package/server/install.sh +282 -78
  23. package/server/instances.sh +15 -0
  24. package/server/interactive_chat_native.py +4 -3
  25. package/server/interactive_chat_projection.py +6 -19
  26. package/server/interactive_chat_share_routes.py +71 -45
  27. package/server/interactive_chat_share_web.py +2 -2
  28. package/server/interactive_chat_shares.py +6 -11
  29. package/server/local_session_ownership.py +232 -0
  30. package/server/opencode_agent_client.py +685 -0
  31. package/server/provider_commands.py +579 -0
  32. package/server/provider_connections.py +665 -0
  33. package/server/provider_usage.py +241 -0
  34. package/server/public_chat_shares.py +60 -62
  35. package/server/public_chat_transcript.py +9 -31
  36. package/server/pyproject.toml +1 -0
  37. package/server/server_instances.py +931 -0
  38. package/server/shared_chat_video_stream.py +21 -2
  39. package/server/shared_chat_videos.py +54 -24
  40. package/server/side_questions.py +493 -10
  41. package/server/title_generation.py +4 -1
  42. package/server/uninstall.sh +95 -17
  43. package/server/update_preparation.py +4 -1
  44. package/server/update_runner.py +16 -0
  45. package/server/uv.lock +3 -1
@@ -0,0 +1,931 @@
1
+ #!/usr/bin/env python3
2
+ """Current-user instance bindings and manager. No server import or discovery writes.
3
+
4
+ The registry contains names, statuses and optional ports, not deletion paths or credentials.
5
+ Named roots are siblings of the legacy roots: removing default cannot remove them.
6
+ Services are independently configured, but are NOT a same-user security sandbox.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import argparse
11
+ from contextlib import contextmanager
12
+ from dataclasses import dataclass
13
+ import fcntl
14
+ import ipaddress
15
+ import json
16
+ import os
17
+ from pathlib import Path
18
+ import plistlib
19
+ import re
20
+ import shlex
21
+ import shutil
22
+ import socket
23
+ import stat
24
+ import subprocess
25
+ import sys
26
+ import tempfile
27
+ import time
28
+ from urllib.parse import urlsplit
29
+
30
+ INSTANCE_PROTOCOL = 1
31
+ ROOT = Path(__file__).resolve().parent
32
+
33
+
34
+ def instance_name(value: str) -> str:
35
+ if not isinstance(value, str) or not re.fullmatch(r"[a-z][a-z0-9-]{0,31}", value):
36
+ raise ValueError("Instance names must be 1–32 lowercase letters, digits or dashes, starting with a letter.")
37
+ return value
38
+
39
+
40
+ def service_name(name: str) -> str:
41
+ return "agents-server" + ("" if instance_name(name) == "default" else "-" + name)
42
+
43
+
44
+ def launchd_label(name: str) -> str:
45
+ return "com.agentsdock.server" + ("" if instance_name(name) == "default" else "." + name)
46
+
47
+
48
+ @dataclass(frozen=True)
49
+ class Instance:
50
+ name: str
51
+ home: Path
52
+
53
+ def __post_init__(self):
54
+ instance_name(self.name)
55
+
56
+ @property
57
+ def runtime(self) -> Path:
58
+ return self.home / ".local/share" / ("agents-server" if self.name == "default" else f"agents-server-instances/{self.name}")
59
+
60
+ @property
61
+ def config(self) -> Path:
62
+ return self.home / ".config" / ("agents-server" if self.name == "default" else f"agents-server-instances/{self.name}")
63
+
64
+ @property
65
+ def state(self) -> Path:
66
+ return self.home / (".agentsdock" if self.name == "default" else f".agentsdock-instances/{self.name}")
67
+
68
+ @property
69
+ def logs(self) -> Path:
70
+ return self.home / "Library/Logs" / ("AgentsServer" if self.name == "default" else f"AgentsServer-instances/{self.name}")
71
+
72
+ def service_file(self, platform: str = sys.platform) -> Path:
73
+ if platform == "darwin":
74
+ return self.home / "Library/LaunchAgents" / (launchd_label(self.name) + ".plist")
75
+ if platform.startswith("linux"):
76
+ return self.home / ".config/systemd/user" / (service_name(self.name) + ".service")
77
+ raise ValueError("Only macOS and Linux user services are supported.")
78
+
79
+ def environment(self) -> dict[str, str]:
80
+ return {
81
+ "AGENTS_SERVER_INSTANCE": self.name,
82
+ "AGENTS_SERVER_INSTALL_DIR": str(self.runtime),
83
+ "AGENTS_SERVER_CONFIG_DIR": str(self.config),
84
+ "AGENTSDOCK_STATE_DIR": str(self.state),
85
+ }
86
+
87
+ def shell_bindings(self) -> str:
88
+ values = {
89
+ "INSTALL_ROOT": str(self.runtime), "CONFIG_ROOT": str(self.config),
90
+ "STATE_ROOT": str(self.state), "INSTANCE_LOG_DIR": str(self.logs),
91
+ "SERVICE_NAME": service_name(self.name), "LABEL": launchd_label(self.name),
92
+ }
93
+ return "\n".join(f"{key}={shlex.quote(value)}" for key, value in values.items())
94
+
95
+
96
+ def check_path(path: Path, home: Path) -> None:
97
+ """Reject symlinks, foreign owners and unsafe writable parents before mutation."""
98
+ path.relative_to(home)
99
+ if path == home:
100
+ raise ValueError("Refusing a home-directory target.")
101
+ for parent in (path, *path.parents):
102
+ if parent == home.parent:
103
+ break
104
+ try:
105
+ info = parent.lstat()
106
+ except FileNotFoundError:
107
+ continue
108
+ if stat.S_ISLNK(info.st_mode) or info.st_uid != os.getuid() or info.st_mode & 0o022:
109
+ raise ValueError(f"Unsafe managed path (link, owner or permissions): {parent}")
110
+
111
+
112
+ def read_regular(path: Path, *, max_bytes: int = 1024 * 1024) -> bytes:
113
+ fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK)
114
+ with os.fdopen(fd, "rb") as handle:
115
+ info = os.fstat(handle.fileno())
116
+ if not stat.S_ISREG(info.st_mode) or info.st_uid != os.getuid() or info.st_mode & 0o022:
117
+ raise ValueError(f"Unsafe managed file: {path}")
118
+ data = handle.read(max_bytes + 1)
119
+ if len(data) > max_bytes:
120
+ raise ValueError(f"Managed file too large: {path}")
121
+ return data
122
+
123
+
124
+ def read_config(instance: Instance) -> dict[str, str]:
125
+ try:
126
+ text = read_regular(instance.config / "env").decode()
127
+ except FileNotFoundError:
128
+ return {}
129
+ result = {}
130
+ for line in text.splitlines():
131
+ key, separator, value = line.partition("=")
132
+ if separator and re.fullmatch(r"[A-Z_]+", key):
133
+ values = shlex.split(value, comments=False)
134
+ result[key] = values[0] if len(values) == 1 else value
135
+ return result
136
+
137
+
138
+ def validate_binding(instance: Instance, platform: str = sys.platform) -> None:
139
+ for path in (instance.runtime, instance.config, instance.state, instance.logs, instance.service_file(platform)):
140
+ check_path(path, instance.home)
141
+ env = read_config(instance)
142
+ for key, value in instance.environment().items():
143
+ if key in env and env[key] != value:
144
+ raise ValueError(f"{instance.name}: {key} does not match this instance; refusing service changes.")
145
+ service = instance.service_file(platform)
146
+ if service.exists():
147
+ data = read_regular(service)
148
+ if platform == "darwin":
149
+ job = plistlib.loads(data)
150
+ if job.get("Label") != launchd_label(instance.name):
151
+ raise ValueError("Service label does not match instance.")
152
+ job_env = job.get("EnvironmentVariables", {})
153
+ # Legacy default plists did not set CONFIG_DIR / INSTANCE.
154
+ for key, value in instance.environment().items():
155
+ if job_env.get(key, value if instance.name == "default" else None) != value:
156
+ raise ValueError(f"Service {key} does not match instance.")
157
+ args = job.get("ProgramArguments", [])
158
+ if args[:2] != [str(instance.runtime / "current/.venv/bin/python"), str(instance.runtime / "current/agent_server.py")]:
159
+ raise ValueError("Service executable does not match instance.")
160
+ else:
161
+ lines = data.decode().splitlines()
162
+ if f"EnvironmentFile={instance.config / 'env'}" not in lines or not any(
163
+ line.startswith(f"ExecStart={instance.runtime / 'current/.venv/bin/python'} {instance.runtime / 'current/agent_server.py'} serve ")
164
+ for line in lines
165
+ ):
166
+ raise ValueError("Service runtime/configuration does not match instance.")
167
+
168
+
169
+ class Registry:
170
+ def __init__(self, home: Path | None = None):
171
+ self.home = (home or Path.home()).resolve()
172
+ self.root = self.home / ".config/agents-server-manager"
173
+ self.file = self.root / "instances.json"
174
+
175
+ def records(self) -> dict:
176
+ check_path(self.file, self.home)
177
+ try:
178
+ value = json.loads(read_regular(self.file))
179
+ except FileNotFoundError:
180
+ return {}
181
+ if not isinstance(value, dict) or value.get("version") != INSTANCE_PROTOCOL or not isinstance(value.get("instances"), dict):
182
+ raise ValueError("Invalid instance registry; refusing to guess removal targets.")
183
+ for name, record in value["instances"].items():
184
+ instance_name(name)
185
+ if not isinstance(record, dict) or not {"status"} <= set(record) <= {"status", "port"} or record["status"] not in {"pending", "installed", "removed", "failed"}:
186
+ raise ValueError("Invalid instance record.")
187
+ if "port" in record and (type(record["port"]) is not int or not 1 <= record["port"] <= 65535):
188
+ raise ValueError("Invalid saved instance port.")
189
+ return value["instances"]
190
+
191
+ def instances(self, include_removed: bool = False) -> list[Instance]:
192
+ records = self.records()
193
+ names = {name for name, record in records.items() if include_removed or record["status"] != "removed"}
194
+ default = Instance("default", self.home)
195
+ if default.service_file().exists() or (default.config / "env").exists():
196
+ names.add("default")
197
+ # Direct --instance installs are discovered only in fixed owned roots.
198
+ configs = self.home / ".config/agents-server-instances"
199
+ check_path(configs, self.home)
200
+ if configs.exists():
201
+ for child in configs.iterdir():
202
+ instance_name(child.name)
203
+ check_path(child, self.home)
204
+ if (child / "env").is_file():
205
+ names.add(child.name)
206
+ return [Instance(name, self.home) for name in sorted(names, key=lambda name: (name != "default", name))]
207
+
208
+ @contextmanager
209
+ def locked(self):
210
+ check_path(self.root, self.home)
211
+ self.root.mkdir(parents=True, exist_ok=True, mode=0o700)
212
+ with exclusive_lock(self.root / "operation.lock"):
213
+ yield
214
+
215
+ def save(self, instance: Instance, status: str, port: int | None = None):
216
+ if status not in {"pending", "installed", "removed", "failed"}:
217
+ raise ValueError("Invalid lifecycle status.")
218
+ records = self.records()
219
+ record = {**records.get(instance.name, {}), "status": status}
220
+ if port is not None:
221
+ if type(port) is not int or not 1 <= port <= 65535:
222
+ raise ValueError("Invalid saved instance port.")
223
+ record["port"] = port
224
+ records[instance.name] = record
225
+ self._write(records)
226
+
227
+ def forget(self, instance: Instance):
228
+ records = self.records()
229
+ records.pop(instance.name, None)
230
+ self._write(records)
231
+
232
+ def _write(self, records: dict):
233
+ fd, name = tempfile.mkstemp(prefix=".instances-", dir=self.root)
234
+ try:
235
+ with os.fdopen(fd, "w") as handle:
236
+ json.dump({"version": INSTANCE_PROTOCOL, "instances": records}, handle, indent=2)
237
+ handle.write("\n")
238
+ handle.flush()
239
+ os.fsync(handle.fileno())
240
+ os.replace(name, self.file)
241
+ finally:
242
+ if os.path.exists(name):
243
+ os.unlink(name)
244
+
245
+
246
+ @contextmanager
247
+ def exclusive_lock(path: Path):
248
+ fd = os.open(path, os.O_RDWR | os.O_CREAT | os.O_NOFOLLOW, 0o600)
249
+ try:
250
+ info = os.fstat(fd)
251
+ if not stat.S_ISREG(info.st_mode) or info.st_uid != os.getuid() or info.st_nlink != 1 or info.st_mode & 0o077:
252
+ raise ValueError(f"Unsafe lock file: {path}")
253
+ try:
254
+ fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
255
+ except BlockingIOError as exc:
256
+ raise ValueError(f"Another process owns {path}; no changes made.") from exc
257
+ yield
258
+ finally:
259
+ os.close(fd) # Never unlink: waiters must keep the same lock inode.
260
+
261
+
262
+ def acquire_state_lock(state: Path):
263
+ state.mkdir(parents=True, exist_ok=True, mode=0o700)
264
+ lock = exclusive_lock(state / ".server-process.lock")
265
+ lock.__enter__()
266
+ return lock # The serving process retains this object until exit.
267
+
268
+
269
+ PROVIDER_ID_FIELDS = {
270
+ "claude": "claude_session_id", "codex": "codex_thread_id",
271
+ "cursor": "cursor_session_id", "opencode": "opencode_session_id",
272
+ }
273
+ MAX_IMPORT_INDEX_BYTES = 64 * 1024 * 1024
274
+ MAX_IMPORT_INDEX_TOTAL_BYTES = 128 * 1024 * 1024
275
+ MAX_IMPORT_INSTANCES = 256
276
+
277
+
278
+ def provider_session_keys(session: dict, default_backend: str = "claude") -> set[tuple[str, str]]:
279
+ """Include parked provider identities as well as the active legacy ID."""
280
+ backend = str(session.get("backend") or default_backend).strip().lower()
281
+ keys = set()
282
+ for provider, field in PROVIDER_ID_FIELDS.items():
283
+ value = session.get(field)
284
+ if not value and provider == backend:
285
+ value = session.get("provider_session_id") or session.get("session_id")
286
+ if isinstance(value, str) and value.strip():
287
+ keys.add((provider, value.strip()))
288
+ return keys
289
+
290
+
291
+ def other_instance_provider_keys(current_state: Path, *, registry: Registry | None = None) -> set[tuple[str, str]]:
292
+ """Read installed instances' indexes, never transcripts or another service API.
293
+
294
+ Stopped services and archived chats still own their provider IDs. Removed
295
+ instances with preserved history do not. No registry/configuration writes
296
+ occur during discovery. Bad or unsafe indexes fail closed, not silently open.
297
+ """
298
+ registry = registry or Registry()
299
+ instances = registry.instances()
300
+ if len(instances) > MAX_IMPORT_INSTANCES:
301
+ raise ValueError("Too many local instances to verify import ownership.")
302
+ keys = set()
303
+ remaining = MAX_IMPORT_INDEX_TOTAL_BYTES
304
+ current_state = current_state.resolve()
305
+ for instance in instances:
306
+ check_path(instance.config, registry.home)
307
+ config = read_config(instance)
308
+ configured_state = (config.get("AGENTSDOCK_STATE_DIR")
309
+ or config.get("AGENTS_SERVER_STATE_DIR")
310
+ or config.get("ZENITHBOT_AGENT_DIR"))
311
+ state = Path(configured_state) if configured_state else instance.state
312
+ if instance.name != "default" and state != instance.state:
313
+ raise ValueError(f"{instance.name}: unexpected state binding.")
314
+ if not state.is_absolute() or ".." in state.parts:
315
+ raise ValueError(f"{instance.name}: unsafe state binding.")
316
+ if state == current_state:
317
+ continue # The current process has the authoritative in-memory map.
318
+ check_path(state / "sessions.json", registry.home)
319
+ try:
320
+ data = read_regular(state / "sessions.json", max_bytes=min(MAX_IMPORT_INDEX_BYTES, remaining))
321
+ except FileNotFoundError:
322
+ continue # A registered/new instance may not have any chats yet.
323
+ remaining -= len(data)
324
+ sessions = json.loads(data)
325
+ if not isinstance(sessions, dict) or any(not isinstance(row, dict) for row in sessions.values()):
326
+ raise ValueError(f"{instance.name}: invalid sessions index.")
327
+ default_backend = config.get("AGENTSDOCK_BACKEND") or config.get("ZENITHBOT_BACKEND") or "claude"
328
+ for session in sessions.values():
329
+ keys.update(provider_session_keys(session, default_backend))
330
+ return keys
331
+
332
+
333
+ @contextmanager
334
+ def history_import_lock(*, registry: Registry | None = None):
335
+ """Serialize cooperating local imports until their index writes have landed.
336
+
337
+ Separate from service-management locks; nonblocking and OS-released on exit
338
+ or crash. Older servers do not participate, so this is not a provider lock.
339
+ """
340
+ registry = registry or Registry()
341
+ check_path(registry.root, registry.home)
342
+ registry.root.mkdir(parents=True, exist_ok=True, mode=0o700)
343
+ with exclusive_lock(registry.root / "history-import.lock"):
344
+ yield
345
+
346
+
347
+ def validate_runtime_environment(environment: dict[str, str], home: Path) -> None:
348
+ name = instance_name(environment.get("AGENTS_SERVER_INSTANCE", "default"))
349
+ if name == "default":
350
+ return # Existing custom-root default deployments remain supported.
351
+ instance = Instance(name, home.resolve())
352
+ for key, expected in instance.environment().items():
353
+ if environment.get(key) != expected:
354
+ raise ValueError(f"Named instance {name}: {key} must match its isolated binding.")
355
+
356
+
357
+ def clean_environment(instance: Instance) -> dict[str, str]:
358
+ env = {key: value for key, value in os.environ.items() if not key.startswith(("AGENTSDOCK_", "AGENTS_SERVER_", "ZENITHBOT_AGENT_", "ZENITHDOCK_"))}
359
+ env.update(instance.environment())
360
+ if instance.name != "default":
361
+ env["AGENTSDOCK_SERVER_NAME"] = instance.name
362
+ return env
363
+
364
+
365
+ def run(command: list[str], **kwargs):
366
+ return subprocess.run(command, check=True, **kwargs)
367
+
368
+
369
+ def service_status(instance: Instance, platform: str = sys.platform) -> str:
370
+ command = ["launchctl", "print", f"gui/{os.getuid()}/{launchd_label(instance.name)}"] if platform == "darwin" else ["systemctl", "--user", "show", service_name(instance.name) + ".service", "--property=ActiveState", "--value"]
371
+ try:
372
+ result = subprocess.run(command, capture_output=True, text=True, timeout=3, check=False)
373
+ except (OSError, subprocess.TimeoutExpired):
374
+ return "unknown"
375
+ if platform == "darwin":
376
+ if result.returncode == 0:
377
+ return "running" if re.search(r"\bpid = [1-9][0-9]*", result.stdout) else "loaded"
378
+ return "stopped" if "Could not find service" in result.stderr else "unknown"
379
+ return "running" if result.returncode == 0 and result.stdout.strip() == "active" else "stopped" if result.returncode == 0 else "unknown"
380
+
381
+
382
+ def port_available(port: int) -> bool:
383
+ if type(port) is not int or not 1 <= port <= 65535:
384
+ raise ValueError("Port must be between 1 and 65535.")
385
+ for family, address in ((socket.AF_INET, "0.0.0.0"), (socket.AF_INET6, "::")):
386
+ try:
387
+ with socket.socket(family, socket.SOCK_STREAM) as listener:
388
+ if family == socket.AF_INET6:
389
+ listener.setsockopt(socket.IPPROTO_IPV6, socket.IPV6_V6ONLY, 1)
390
+ listener.bind((address, port))
391
+ except OSError as exc:
392
+ import errno
393
+ if family == socket.AF_INET6 and exc.errno in {errno.EAFNOSUPPORT, errno.EPROTONOSUPPORT, errno.EADDRNOTAVAIL}:
394
+ continue
395
+ return False
396
+ return True
397
+
398
+
399
+ def select_port(registry: Registry, explicit: int | None, extra_reserved: set[int] | None = None) -> int:
400
+ reserved = {int(read_config(item).get("AGENTSDOCK_AGENT_PORT", "7850")) for item in registry.instances()}
401
+ reserved.update(extra_reserved or ())
402
+ candidates = [explicit] if explicit is not None else range(7851, 65536)
403
+ for port in candidates:
404
+ if port not in reserved and port_available(port):
405
+ return port
406
+ raise ValueError("Requested port is occupied/reserved, or no free port is available. No existing listener was stopped.")
407
+
408
+
409
+ def candidate_addresses(bind: str, port: int) -> list[str]:
410
+ try:
411
+ address = ipaddress.ip_address(bind.strip("[]"))
412
+ except ValueError:
413
+ if bind == "localhost":
414
+ return [f"http://localhost:{port} (This machine only)"]
415
+ return [f"http://{bind}:{port}"]
416
+ if not address.is_unspecified:
417
+ host = f"[{address}]" if address.version == 6 else str(address)
418
+ return [f"http://{host}:{port}" + (" (This machine only)" if address.is_loopback else "")]
419
+ hosts = set()
420
+ try:
421
+ hosts.update(row[4][0] for row in socket.getaddrinfo(socket.gethostname(), None, socket.AF_INET if address.version == 4 else socket.AF_INET6))
422
+ if sys.platform == "darwin":
423
+ output = subprocess.run(["ifconfig"], capture_output=True, text=True, timeout=3, check=False).stdout
424
+ hosts.update(re.findall(r"\binet " + r"(\d+\.\d+\.\d+\.\d+)", output))
425
+ elif sys.platform.startswith("linux"):
426
+ output = subprocess.run(["hostname", "-I"], capture_output=True, text=True, timeout=3, check=False).stdout
427
+ hosts.update(output.split())
428
+ except (OSError, subprocess.TimeoutExpired):
429
+ pass
430
+ urls = [f"http://127.0.0.1:{port}" if address.version == 4 else f"http://[::1]:{port}"]
431
+ for host in sorted(hosts):
432
+ try:
433
+ item = ipaddress.ip_address(host)
434
+ except ValueError:
435
+ continue
436
+ if item.version != address.version or item.is_loopback or item.is_link_local or item.is_unspecified:
437
+ continue
438
+ urls.append(f"http://{'[' + host + ']' if item.version == 6 else host}:{port}")
439
+ return urls
440
+
441
+
442
+ def tailscale_status(*, home: Path | None = None, platform: str | None = None) -> dict[str, str]:
443
+ """Inspect existing Tailscale only; never launch the GUI, install or log in.
444
+
445
+ macOS app variants bundle the CLI without necessarily adding it to PATH.
446
+ Keep failures distinct from absence, and do not publish stale logged-out IPs.
447
+ """
448
+ home = home or Path.home()
449
+ platform = platform or sys.platform
450
+ candidates = [shutil.which("tailscale"), "/usr/local/bin/tailscale", "/usr/bin/tailscale"]
451
+ app_roots = []
452
+ if platform == "darwin":
453
+ candidates.append("/opt/homebrew/bin/tailscale")
454
+ app_roots = [Path("/Applications/Tailscale.app"), home / "Applications/Tailscale.app"]
455
+ candidates.extend(str(root / "Contents/MacOS/Tailscale") for root in app_roots)
456
+ result = {"status": "unavailable" if any(root.is_dir() for root in app_roots) else "not-installed", "ipv4": ""}
457
+ inspected = set()
458
+ deadline = time.monotonic() + 5
459
+ environment = {key: value for key, value in os.environ.items()
460
+ if key in {"HOME", "PATH", "USER", "LOGNAME", "LANG", "LC_ALL", "TMPDIR"}}
461
+ environment["TAILSCALE_BE_CLI"] = "1"
462
+ for candidate in candidates:
463
+ if not candidate or not Path(candidate).is_file() or not os.access(candidate, os.X_OK):
464
+ continue
465
+ resolved = str(Path(candidate).resolve())
466
+ if resolved in inspected:
467
+ continue
468
+ inspected.add(resolved)
469
+ if result["status"] == "not-installed":
470
+ result["status"] = "unavailable"
471
+ remaining = deadline - time.monotonic()
472
+ if remaining <= 0:
473
+ break
474
+ try:
475
+ response = subprocess.run(
476
+ [candidate, "status", "--json"], stdin=subprocess.DEVNULL,
477
+ capture_output=True, text=True, check=False, timeout=min(3, remaining), env=environment,
478
+ )
479
+ if response.returncode or len(response.stdout) > 4 * 1024 * 1024:
480
+ continue
481
+ status = json.loads(response.stdout)
482
+ if not isinstance(status, dict) or not isinstance(status.get("BackendState"), str):
483
+ continue
484
+ if status["BackendState"] != "Running":
485
+ result = {"status": "disconnected", "ipv4": ""}
486
+ continue
487
+ self_status = status.get("Self")
488
+ if isinstance(self_status, dict) and self_status.get("Online") is False:
489
+ result = {"status": "disconnected", "ipv4": ""}
490
+ continue
491
+ addresses = status.get("TailscaleIPs")
492
+ if not isinstance(addresses, list):
493
+ continue
494
+ for value in addresses:
495
+ if not isinstance(value, str):
496
+ continue
497
+ try:
498
+ address = ipaddress.ip_address(value)
499
+ except ValueError:
500
+ continue
501
+ if (address.version == 4 and not address.is_unspecified
502
+ and not address.is_loopback and not address.is_multicast
503
+ and not address.is_link_local and str(address) == value):
504
+ return {"status": "connected", "ipv4": value}
505
+ result = {"status": "connected", "ipv4": ""}
506
+ except (OSError, ValueError, subprocess.TimeoutExpired):
507
+ continue # Do not echo peer details or CLI errors into setup output.
508
+ return result
509
+
510
+
511
+ def setup_network_bindings(bind: str, port: int) -> str:
512
+ """Shell-quoted, fixed-key installer summary. No credentials or mutations."""
513
+ if not 1 <= port <= 65535:
514
+ raise ValueError("Invalid port.")
515
+ address = ipaddress.ip_address("127.0.0.1" if bind == "localhost" else bind.strip("[]"))
516
+ loopback = address.is_loopback
517
+ host = "127.0.0.1" if bind == "localhost" or str(address) == "0.0.0.0" else "::1" if address.is_unspecified else str(address)
518
+ local_url = f"http://{'[' + host + ']' if ':' in host else host}:{port}"
519
+ tailscale = tailscale_status()
520
+ ip = tailscale["ipv4"]
521
+ tail_binding = bool(ip and (str(address) == "0.0.0.0" or str(address) == ip))
522
+ server_url = f"http://{ip}:{port}" if tail_binding else local_url
523
+ candidates = [url.split(" ", 1)[0] for url in candidate_addresses(bind, port)]
524
+ values = {
525
+ "TAILSCALE_STATUS": tailscale["status"], "TAILSCALE_IP": ip,
526
+ "TAILSCALE_BIND_MATCH": "true" if tail_binding else "false",
527
+ "SERVER_LOCAL_ONLY": "true" if loopback else "false", "SERVER_URL": server_url,
528
+ "NETWORK_URLS": "\n".join(url for url in candidates if url != server_url),
529
+ }
530
+ return "\n".join(f"{key}={shlex.quote(value)}" for key, value in values.items())
531
+
532
+
533
+ def describe(instance: Instance) -> dict:
534
+ env = read_config(instance)
535
+ port = int(env.get("AGENTSDOCK_AGENT_PORT", "7850")) if env else None
536
+ try:
537
+ version = (instance.runtime / "current/VERSION").read_text().strip()[:80]
538
+ except OSError:
539
+ version = "not installed"
540
+ return {
541
+ "name": instance.name, "status": service_status(instance), "port": port,
542
+ "addresses": candidate_addresses(env.get("AGENTSDOCK_AGENT_BIND", "0.0.0.0"), port) if port else [],
543
+ "runtime": str(instance.runtime), "state": str(instance.state),
544
+ "service": str(instance.service_file()),
545
+ "version": version,
546
+ }
547
+
548
+
549
+ def connection_choices(bind: str, port: int, addresses: list[str], network: dict[str, str]) -> dict:
550
+ """Label candidates, never infer Tailscale connectivity from an IP range."""
551
+ choices = {"local": [], "lan": [], "other": [], "tailscale": "", "tailscale_note": ""}
552
+ state, tail_ip = network["status"], network["ipv4"]
553
+ try:
554
+ binding = ipaddress.ip_address("127.0.0.1" if bind == "localhost" else bind.strip("[]"))
555
+ except ValueError:
556
+ binding = None
557
+ if state != "connected":
558
+ choices["tailscale_note"] = {
559
+ "disconnected": "Unavailable: Tailscale is disconnected.",
560
+ "not-installed": "Unavailable: Tailscale was not found.",
561
+ }.get(state, "Not verified: could not read Tailscale status.")
562
+ elif not tail_ip:
563
+ choices["tailscale_note"] = "Not verified: Tailscale has no usable IPv4 address."
564
+ elif binding is not None and binding.is_loopback:
565
+ choices["tailscale_note"] = "Unavailable: this server is bound to this machine only."
566
+ elif binding is not None and (str(binding) == "0.0.0.0" or str(binding) == tail_ip):
567
+ choices["tailscale"] = f"http://{tail_ip}:{port}"
568
+ else:
569
+ choices["tailscale_note"] = f"Not verified: server binding {bind!r} does not confirm access via the Tailscale IPv4 address."
570
+
571
+ lan_ranges = tuple(ipaddress.ip_network(value) for value in ("10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16"))
572
+ for candidate in addresses:
573
+ url = candidate.split(" ", 1)[0]
574
+ if url == choices["tailscale"]:
575
+ continue
576
+ host = urlsplit(url).hostname
577
+ try:
578
+ address = ipaddress.ip_address(host)
579
+ except ValueError:
580
+ group = "local" if host == "localhost" else "other"
581
+ else:
582
+ if address.is_unspecified or address.is_multicast or address.is_link_local:
583
+ continue
584
+ # An unverified/stale VPN IP must not be presented as ordinary LAN.
585
+ group = "local" if address.is_loopback else "lan" if host != tail_ip and any(address in subnet for subnet in lan_ranges) else "other"
586
+ if url not in choices[group]:
587
+ choices[group].append(url)
588
+ return choices
589
+
590
+
591
+ def show(instance: Instance, *, network: dict[str, str] | None = None):
592
+ item = describe(instance)
593
+ name = terminal_color(f"{item['name']:<20}", "34")
594
+ summary = f"{name} {item['status']:<10} {str(item['port'] or '—'):<6}"
595
+ if network is None:
596
+ print(summary + " " + " ".join(item["addresses"]))
597
+ return
598
+ print(summary.rstrip())
599
+ if not item["port"]:
600
+ print(" No saved network configuration.\n")
601
+ return
602
+ choices = connection_choices(read_config(instance).get("AGENTSDOCK_AGENT_BIND", "0.0.0.0"), item["port"], item["addresses"], network)
603
+ tail = choices["tailscale"]
604
+ if tail:
605
+ tail += " (recommended)" if item["status"] == "running" else f" (server status: {item['status']})"
606
+ print(f" Tailscale / other networks: {tail or choices['tailscale_note']}")
607
+ print(" Same Wi-Fi / LAN: " + (", ".join(choices["lan"]) or "No address detected for this binding."))
608
+ print(" This machine only: " + (", ".join(choices["local"]) or "No loopback address for this binding."))
609
+ if choices["other"]:
610
+ print(" Other / unverified: " + ", ".join(choices["other"]))
611
+ print()
612
+
613
+
614
+ def terminal_color(text: str, code: str) -> str:
615
+ color = sys.stdout.isatty() and os.environ.get("TERM") != "dumb" and "NO_COLOR" not in os.environ
616
+ return f"\033[{code}m{text}\033[0m" if color else text
617
+
618
+
619
+ def confirm_removal(instances: list[Instance], purge: bool, yes: bool) -> None:
620
+ print(terminal_color(f"WARNING: uninstall {len(instances)} AgentsServer instance(s)", "1;31"))
621
+ for instance in instances:
622
+ show(instance)
623
+ print(terminal_color(f" Remove runtime: {instance.runtime}", "31"))
624
+ print(terminal_color(f" Remove configuration/token: {instance.config}", "31"))
625
+ print(terminal_color(f" {'PERMANENTLY DELETE' if purge else 'PRESERVE'} history: {instance.state}", "31" if purge else "32"))
626
+ print("Services and tokens will be removed; reinstalling creates a new access token.")
627
+ if purge:
628
+ print(terminal_color("PERMANENT HISTORY DELETION CANNOT BE UNDONE. --yes cannot bypass confirmation.", "1;31"))
629
+ else:
630
+ print("Chat history and files are preserved. Service removal is reinstallable; deleted configuration is not restored automatically.")
631
+ if not yes and any(instance.name != "default" for instance in instances):
632
+ print("After this confirmation, you can also release each name while keeping its chat history in a local backup.")
633
+ target_names = " ".join(instance.name for instance in instances)
634
+ expected = f"{'delete history' if purge else 'uninstall'} {target_names}"
635
+ if purge or not yes:
636
+ if not sys.stdin.isatty() or input(f"Type {expected!r} to confirm: ") != expected:
637
+ raise ValueError("Not confirmed; nothing was uninstalled.")
638
+
639
+
640
+ def install_instance(instance: Instance, port: int, bind: str):
641
+ validate_binding(instance)
642
+ command = ["/bin/bash", str(ROOT / "install.sh"), "--instance", instance.name,
643
+ "--no-port-fallback", "--port", str(port), "--bind", bind]
644
+ # Local terminal users get the installer's optional token-copy prompt;
645
+ # redirected/app-driven installs retain the unattended contract.
646
+ if not (sys.stdin.isatty() and sys.stdout.isatty()):
647
+ command.append("--non-interactive")
648
+ run(command, env=clean_environment(instance), cwd=ROOT)
649
+
650
+
651
+ def validate_released_instance(instance: Instance) -> None:
652
+ """Never reset default, an installed service, or a running state owner."""
653
+ if instance.name == "default":
654
+ raise ValueError("The default instance cannot be released.")
655
+ validate_binding(instance)
656
+ if any(path.exists() or path.is_symlink() for path in (instance.runtime, instance.config, instance.service_file())):
657
+ raise ValueError(f"{instance.name}: still installed or has a partial installation. Remove it before reusing its name.")
658
+ if service_status(instance) != "stopped":
659
+ raise ValueError(f"{instance.name}: cannot confirm the old service is stopped; nothing released.")
660
+ if instance.state.exists() and not instance.state.is_dir():
661
+ raise ValueError(f"{instance.name}: preserved state is not a directory.")
662
+
663
+
664
+ def confirm_name_release(instance: Instance, port: int) -> None:
665
+ print(f"This name was used before: {instance.name}.")
666
+ print(terminal_color(f"The new instance will start with an empty AgentsDock chat list on port {port}.", "31"))
667
+ print(f"Old AgentsDock history, uploads, jobs and credentials at {instance.state} will be moved to a private backup, not erased.")
668
+ print("Original provider chats stored on this machine and project files are not deleted. AgentsDock-only content remains in the backup.")
669
+ print("To keep using the existing history instead, cancel and run:")
670
+ print(f" ./install.sh --instance {instance.name} --port {port}")
671
+ try:
672
+ confirmed = sys.stdin.isatty() and input(f"Release {instance.name!r} and create a fresh instance? [y/N] ").strip().lower() in {"y", "yes"}
673
+ except EOFError:
674
+ confirmed = False
675
+ if not confirmed:
676
+ raise ValueError("Name release not confirmed; existing history was not changed.")
677
+
678
+
679
+ def release_instance_name(instance: Instance, registry: Registry) -> Path | None:
680
+ validate_released_instance(instance) # Recheck after the user prompt.
681
+ if not instance.state.exists():
682
+ return None
683
+ backups = registry.root / "history-backups"
684
+ check_path(backups, registry.home)
685
+ # Holding the old state's lock prevents archiving a running server. Rename
686
+ # the directory itself; never traverse/delete its contents or follow links.
687
+ with exclusive_lock(instance.state / ".server-process.lock"):
688
+ validate_released_instance(instance)
689
+ backups.mkdir(mode=0o700, exist_ok=True)
690
+ destination = Path(tempfile.mkdtemp(prefix=f"{instance.name}-", dir=backups)) / "state"
691
+ instance.state.rename(destination)
692
+ print(terminal_color(f"Preserved old instance data in: {destination}", "32"), flush=True)
693
+ return destination
694
+
695
+
696
+ def confirm_uninstall_name_release(instance: Instance) -> bool:
697
+ if not sys.stdin.isatty():
698
+ return False
699
+ print(f"\nOptional name release: {instance.name}")
700
+ print("Releasing the name clears this server's saved chat list from active use and makes the name available again.")
701
+ print(terminal_color("No chat history is deleted. Original provider chats stay on your computer.", "32"))
702
+ print(f"This server's saved AgentsDock history, uploads, jobs and credentials at {instance.state} will be moved to a private local backup; its location will be printed.")
703
+ print("A new server using this name starts with an empty chat list; the old AgentsDock-only content remains in the backup.")
704
+ print("Project files, earlier backups and independent terminal sessions are kept. Enter keeps the name and saved data in place.")
705
+ try:
706
+ return input("Do you want to release this name as well? [y/N] ").strip().lower() in {"y", "yes"}
707
+ except EOFError:
708
+ return False
709
+
710
+
711
+ def release_uninstalled_name(instance: Instance, registry: Registry) -> None:
712
+ # Called only after a successful uninstall and separate affirmative answer.
713
+ # Preserve even AgentsDock-only content before freeing the name. Reuse the
714
+ # guarded archive path used by `new`, not the explicit --purge-state action.
715
+ release_instance_name(instance, registry)
716
+ registry.forget(instance)
717
+ print(terminal_color(f"Released name {instance.name!r}; it can be used for a new instance.", "32"), flush=True)
718
+
719
+
720
+ def control(instance: Instance, action: str, platform: str = sys.platform):
721
+ validate_binding(instance, platform)
722
+ if not instance.service_file(platform).exists():
723
+ raise ValueError(f"{instance.name}: no installed service.")
724
+ if platform == "darwin":
725
+ target = f"gui/{os.getuid()}/{launchd_label(instance.name)}"
726
+ status = service_status(instance, platform)
727
+ if status == "unknown":
728
+ raise ValueError("Cannot establish service state; no service changes made.")
729
+ if action == "stop":
730
+ if status != "stopped":
731
+ run(["launchctl", "bootout", target])
732
+ elif status == "stopped":
733
+ run(["launchctl", "bootstrap", f"gui/{os.getuid()}", str(instance.service_file(platform))])
734
+ elif action == "restart":
735
+ run(["launchctl", "bootout", target])
736
+ deadline = time.monotonic() + 180
737
+ while service_status(instance, platform) != "stopped":
738
+ if time.monotonic() >= deadline:
739
+ raise ValueError("Service did not stop; refusing to start a second process.")
740
+ time.sleep(0.1)
741
+ run(["launchctl", "bootstrap", f"gui/{os.getuid()}", str(instance.service_file(platform))])
742
+ else:
743
+ run(["systemctl", "--user", action, service_name(instance.name) + ".service"])
744
+
745
+
746
+ def parser() -> argparse.ArgumentParser:
747
+ result = argparse.ArgumentParser(description="Independent AgentsServer instances for the current OS user. Defaults to list.")
748
+ commands = result.add_subparsers(dest="command")
749
+ commands.add_parser("list")
750
+ info = commands.add_parser("info")
751
+ info.add_argument("name", type=instance_name)
752
+ new = commands.add_parser("new")
753
+ new.add_argument("--name", type=instance_name)
754
+ new.add_argument("--port", type=int)
755
+ new.add_argument("--bind", default="0.0.0.0")
756
+ for action in ("start", "stop", "restart", "update", "remove"):
757
+ command = commands.add_parser(action)
758
+ command.add_argument("name", nargs="?", type=instance_name)
759
+ command.add_argument("--instance", dest="named", type=instance_name)
760
+ command.add_argument("--all", action="store_true")
761
+ command.add_argument("--exclude", action="append", default=[], type=instance_name)
762
+ if action == "remove":
763
+ command.add_argument("--yes", action="store_true")
764
+ command.add_argument("--purge-state", action="store_true")
765
+ install = commands.add_parser("install")
766
+ install.add_argument("--manifest", type=Path, required=True)
767
+ # Internal shared bindings; pure read-only output, safely shell-quoted.
768
+ bindings = commands.add_parser("_bindings", help=argparse.SUPPRESS)
769
+ bindings.add_argument("name", type=instance_name)
770
+ network = commands.add_parser("_setup-network", help=argparse.SUPPRESS)
771
+ network.add_argument("--bind", required=True)
772
+ network.add_argument("--port", type=int, required=True)
773
+ return result
774
+
775
+
776
+ def main(argv: list[str] | None = None) -> int:
777
+ args = parser().parse_args(argv)
778
+ registry = Registry()
779
+ try:
780
+ if args.command == "_setup-network":
781
+ print(setup_network_bindings(args.bind, args.port))
782
+ return 0
783
+ if args.command == "_bindings":
784
+ instance = Instance(args.name, registry.home)
785
+ validate_binding(instance)
786
+ print(instance.shell_bindings())
787
+ return 0
788
+ if args.command in {None, "list", "info"}:
789
+ items = registry.instances()
790
+ if args.command == "info":
791
+ items = [item for item in items if item.name == args.name]
792
+ if not items:
793
+ raise ValueError("Unknown instance.")
794
+ print(json.dumps(describe(items[0]), indent=2))
795
+ else:
796
+ network = tailscale_status() if items else None
797
+ print("NAME STATUS PORT")
798
+ for item in items:
799
+ show(item, network=network)
800
+ if not items:
801
+ print("No installations found. Run ./install.sh for default, or ./instances.sh new.")
802
+ return 0
803
+ with registry.locked():
804
+ existing = {item.name: item for item in registry.instances(include_removed=True)}
805
+ if args.command in {"new", "install"}:
806
+ entries = [{"name": args.name, "port": args.port, "bind": args.bind}] if args.command == "new" else json.loads(args.manifest.read_text())
807
+ if not isinstance(entries, list) or not entries:
808
+ raise ValueError("Manifest must be a nonempty JSON array of {name, port?, bind?} objects.")
809
+ plan = []
810
+ records = registry.records()
811
+ names, ports, planned_names = set(existing), set(), set()
812
+ for entry in entries:
813
+ if not isinstance(entry, dict) or set(entry) - {"name", "port", "bind"}:
814
+ raise ValueError("Invalid manifest entry.")
815
+ name = entry.get("name")
816
+ if name is None:
817
+ index = 1
818
+ while f"instance-{index}" in names:
819
+ index += 1
820
+ name = f"instance-{index}"
821
+ if name == "default" or name in planned_names:
822
+ raise ValueError(f"Instance {name!r} already exists/reserved.")
823
+ instance = Instance(instance_name(name), registry.home)
824
+ record = records.get(name)
825
+ reuse = record is not None and record["status"] in {"removed", "failed"}
826
+ if name in names and not reuse:
827
+ raise ValueError(f"Instance {name!r} already exists/reserved. Use update.")
828
+ if reuse:
829
+ validate_released_instance(instance)
830
+ elif any(item.exists() or item.is_symlink() for item in (instance.runtime, instance.config, instance.state, instance.service_file())):
831
+ raise ValueError(f"{name}: existing unmanaged files; refusing to adopt or overwrite.")
832
+ validate_binding(instance)
833
+ requested_port = entry.get("port")
834
+ if reuse and requested_port is None:
835
+ requested_port = record.get("port")
836
+ if requested_port is None and instance.state.exists():
837
+ raise ValueError(f"{name}: the previous port was not recorded. Specify --port to reuse this name.")
838
+ port = select_port(registry, requested_port, ports)
839
+ bind = entry.get("bind", "0.0.0.0")
840
+ ipaddress.ip_address(bind) # Literal bind addresses only; no shell/XML injection.
841
+ names.add(name)
842
+ planned_names.add(name)
843
+ ports.add(port)
844
+ # A failed preflight with no state has nothing to release;
845
+ # retry it directly. Removed names always require consent.
846
+ release = reuse and (record["status"] == "removed" or instance.state.exists())
847
+ plan.append((instance, port, bind, release))
848
+ # Validate the entire manifest and collect all confirmations
849
+ # before moving any history or installing any instance.
850
+ for item, port, _, release in plan:
851
+ if release:
852
+ confirm_name_release(item, port)
853
+ print("Create: " + ", ".join(f"{item.name}:{port}" for item, port, _, _ in plan), flush=True)
854
+ failures = 0
855
+ for item, port, bind, release in plan:
856
+ backup = None
857
+ try:
858
+ if not port_available(port):
859
+ raise ValueError(f"Port {port} became occupied; no instance history was moved.")
860
+ if release:
861
+ backup = release_instance_name(item, registry)
862
+ registry.save(item, "pending", port)
863
+ install_instance(item, port, bind)
864
+ registry.save(item, "installed", port)
865
+ except (OSError, ValueError, subprocess.CalledProcessError) as exc:
866
+ registry.save(item, "failed", port)
867
+ print(f"{item.name}: failed ({exc}); other instances were not rolled back.", file=sys.stderr)
868
+ if backup is not None:
869
+ print(f"Original instance data is safe in {backup}; it was not deleted.", file=sys.stderr)
870
+ failures += 1
871
+ return int(bool(failures))
872
+ name = args.name or args.named
873
+ if (args.name and args.named) or bool(name) == bool(args.all) or (args.exclude and not args.all):
874
+ raise ValueError("Select exactly one instance or --all; --exclude requires --all.")
875
+ unknown_exclusions = set(args.exclude) - set(existing)
876
+ if unknown_exclusions:
877
+ raise ValueError("Unknown excluded instance(s): " + ", ".join(sorted(unknown_exclusions)))
878
+ selected = registry.instances() if args.all else [existing[name]] if name in existing else []
879
+ selected = [item for item in selected if item.name not in args.exclude]
880
+ if not selected:
881
+ raise ValueError("No matching instances; nothing changed.")
882
+ for item in selected:
883
+ validate_binding(item)
884
+ release_names = set()
885
+ if args.command == "remove":
886
+ confirm_removal(selected, args.purge_state, args.yes)
887
+ if not args.purge_state and not args.yes:
888
+ for item in selected:
889
+ if item.name != "default" and confirm_uninstall_name_release(item):
890
+ release_names.add(item.name)
891
+ failures = 0
892
+ for item in selected:
893
+ try:
894
+ if args.command == "remove":
895
+ old_port = read_config(item).get("AGENTSDOCK_AGENT_PORT")
896
+ command = ["/bin/bash", str(ROOT / "uninstall.sh"), "--managed-instance", item.name, "--yes"]
897
+ if args.purge_state:
898
+ command.append("--purge-state") # Still asks for each exact state path.
899
+ elif item.name in release_names:
900
+ # The manager will print the backup location; direct
901
+ # reinstall advice would incorrectly imply reuse.
902
+ command.append("--managed-release-name")
903
+ run(command, env=clean_environment(item), cwd=ROOT)
904
+ registry.save(item, "removed", int(old_port) if old_port else None)
905
+ if args.purge_state and item.name != "default" and not item.state.exists():
906
+ registry.forget(item)
907
+ elif item.name in release_names:
908
+ release_uninstalled_name(item, registry)
909
+ elif not args.purge_state and not args.yes and item.name != "default":
910
+ print(f"Kept name {item.name!r} and its saved data.", flush=True)
911
+ elif args.command == "update":
912
+ env = read_config(item)
913
+ install_instance(item, int(env["AGENTSDOCK_AGENT_PORT"]), env["AGENTSDOCK_AGENT_BIND"])
914
+ registry.save(item, "installed")
915
+ else:
916
+ control(item, args.command)
917
+ if args.command != "remove":
918
+ print(f"{item.name}: {args.command} completed", flush=True)
919
+ except (OSError, ValueError, KeyError, subprocess.CalledProcessError) as exc:
920
+ print(f"{item.name}: failed ({exc})", file=sys.stderr)
921
+ failures += 1
922
+ if args.command == "remove" and not failures:
923
+ print("\n" + terminal_color("Successful!", "32"), flush=True)
924
+ return int(bool(failures))
925
+ except (OSError, ValueError, KeyError) as exc:
926
+ print(f"Instance manager: {exc}", file=sys.stderr)
927
+ return 1
928
+
929
+
930
+ if __name__ == "__main__":
931
+ raise SystemExit(main())