silicon-cli 1.0.0__py3-none-any.whl

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,2 @@
1
+ """Silicon CLI — manage silicon instances. Python port of the original bash manager."""
2
+ __version__ = "1.0.0"
silicon_cli/cli.py ADDED
@@ -0,0 +1,250 @@
1
+ """silicon — manage your silicon instances. Dispatch mirrors the original bash CLI."""
2
+ from __future__ import annotations
3
+
4
+ import subprocess
5
+ import sys
6
+ from pathlib import Path
7
+
8
+ from . import glassagent, process, registry, stemcell, sync, ui, update
9
+ from .config import python_run_cmd
10
+
11
+ COMMANDS = ["start", "stop", "restart", "status", "browser", "debug", "attach",
12
+ "pull", "push", "update", "list", "install", "new", "help", "script", "agent"]
13
+
14
+
15
+ # ----------------------------------------------------------------- commands
16
+ def cmd_list() -> None:
17
+ rows = registry.installs()
18
+ if not rows:
19
+ ui.info("No silicon installations found.")
20
+ ui.info("Run 'silicon install' to set up a new instance.")
21
+ return
22
+ print(f"\n{ui.BOLD}{ui.CYAN}Silicon Installations{ui.RESET}\n")
23
+ print(f" {ui.DIM}{'#':<4}{'NAME':<22}{'STATUS':<10}PATH{ui.RESET}")
24
+ print(f" {ui.DIM}{'---':<4}{'----':<22}{'------':<10}----{ui.RESET}")
25
+ for i in rows:
26
+ if process.is_running(i.pid_file):
27
+ pid = process.get_pid(i.pid_file)
28
+ status = f"{ui.GREEN}● running{ui.RESET}"
29
+ extra = f" {ui.DIM}(PID {pid}){ui.RESET}"
30
+ else:
31
+ status, extra = f"{ui.DIM}○ stopped{ui.RESET}", ""
32
+ print(f" {i.index + 1:<4}{i.name:<22}{status}{extra} {ui.DIM}{i.path}{ui.RESET}")
33
+ print()
34
+
35
+
36
+ def _print_status(inst) -> None:
37
+ if process.is_running(inst.pid_file):
38
+ pid = process.get_pid(inst.pid_file)
39
+ print(f"\n{ui.BOLD}{inst.name}{ui.RESET} {ui.GREEN}● running{ui.RESET} (PID {pid})")
40
+ else:
41
+ print(f"\n{ui.BOLD}{inst.name}{ui.RESET} {ui.DIM}○ stopped{ui.RESET}")
42
+ print(f"{ui.DIM} Path: {inst.path}{ui.RESET}\n")
43
+
44
+
45
+ def cmd_status(target: str | None) -> None:
46
+ if target:
47
+ inst = registry.find(target)
48
+ if not inst:
49
+ cmd_list()
50
+ return
51
+ _print_status(inst)
52
+ return
53
+ inst = registry.find()
54
+ if inst:
55
+ _print_status(inst)
56
+ else:
57
+ cmd_list()
58
+
59
+
60
+ def cmd_browser(target: str | None) -> None:
61
+ inst = registry.resolve_one(target)
62
+ ui.info(f"Opening browser for '{inst.name}'...")
63
+ subprocess.run([python_run_cmd(), "main.py", "browser"], cwd=inst.path)
64
+
65
+
66
+ def cmd_debug(target: str | None) -> None:
67
+ inst = registry.resolve_one(target)
68
+ if not process.is_running(inst.pid_file):
69
+ ui.error(f"'{inst.name}' is not running. Start it first with: silicon start {inst.name}")
70
+ sys.exit(1)
71
+ log_file = Path(inst.path) / ".silicon.log"
72
+ if not log_file.exists():
73
+ ui.error(f"No log file found at {log_file}")
74
+ sys.exit(1)
75
+ pid = process.get_pid(inst.pid_file)
76
+ print(f"\n{ui.BOLD}{ui.CYAN}Debugging '{inst.name}'{ui.RESET} (PID {pid})")
77
+ print(f"{ui.DIM} Log: {log_file}{ui.RESET}")
78
+ print(f"{ui.DIM} Press Ctrl+C to detach{ui.RESET}\n")
79
+ try:
80
+ subprocess.run(["tail", "-f", str(log_file)])
81
+ except KeyboardInterrupt:
82
+ pass
83
+
84
+
85
+ def cmd_attach(target_dir: str | None) -> None:
86
+ target = Path(target_dir or ".").resolve()
87
+ if not (target / "main.py").exists() or not (target / "config.py").exists():
88
+ ui.error("This doesn't look like a silicon directory.")
89
+ ui.info(f"Expected main.py and config.py in: {target}")
90
+ ui.info(" silicon attach /path/to/silicon")
91
+ sys.exit(1)
92
+ if not (target / "prompts").is_dir() or not (target / "core").is_dir():
93
+ ui.error("Missing prompts/ or core/ directory. Not a valid silicon.")
94
+ sys.exit(1)
95
+
96
+ for i in registry.installs():
97
+ if i.path == str(target):
98
+ ui.warn(f"This silicon is already registered as '{i.name}'")
99
+ return
100
+
101
+ ui.success(f"Found a silicon at: {target}")
102
+ name = ui.ask("Instance name", target.name)
103
+ if registry.name_taken(name):
104
+ ui.error(f"Name '{name}' is already taken. Pick a different one.")
105
+ sys.exit(1)
106
+
107
+ pid_file = target / ".silicon.pid"
108
+ running = process.is_running(str(pid_file))
109
+ registry.register(name, str(target), str(pid_file))
110
+ ui.success(f"Attached '{name}' at {target}")
111
+ if running:
112
+ print(f"\n {ui.BOLD}{name}{ui.RESET} {ui.GREEN}● running{ui.RESET} (PID {process.get_pid(str(pid_file))})\n")
113
+ else:
114
+ print(f"\n {ui.BOLD}{name}{ui.RESET} {ui.DIM}○ stopped{ui.RESET}")
115
+ print(f" Start it with: {ui.BOLD}silicon start {name}{ui.RESET}\n")
116
+
117
+
118
+ def cmd_agent(subcmd: str | None, target: str | None) -> None:
119
+ if not subcmd:
120
+ ui.error("Usage: silicon agent <start|stop|status> [name]")
121
+ sys.exit(1)
122
+ inst = registry.resolve_one(target)
123
+ if subcmd == "start":
124
+ glassagent.start(inst.path)
125
+ elif subcmd == "stop":
126
+ glassagent.stop(inst.path)
127
+ elif subcmd == "status":
128
+ if glassagent.status(inst.path):
129
+ pid = (Path(inst.path) / ".glass_agent.pid").read_text().strip()
130
+ print(f"{ui.GREEN}●{ui.RESET} Glass agent running (PID {pid})")
131
+ else:
132
+ print(f"{ui.DIM}○{ui.RESET} Glass agent stopped")
133
+ else:
134
+ ui.error(f"Unknown agent command: {subcmd}. Use start, stop, or status.")
135
+ sys.exit(1)
136
+
137
+
138
+ def cmd_new(target: str | None) -> None:
139
+ if target:
140
+ stemcell.hydrate(target)
141
+ return
142
+ # No target: ask for a folder to create (Python-native installer).
143
+ name = ui.ask("New silicon folder name", "silicon")
144
+ if not name:
145
+ ui.error("A folder name is required.")
146
+ sys.exit(1)
147
+ stemcell.hydrate(str(Path.cwd() / name))
148
+
149
+
150
+ def cmd_help() -> None:
151
+ print(f"""
152
+ {ui.BOLD}{ui.CYAN}silicon{ui.RESET} – manage your silicon instances
153
+
154
+ {ui.BOLD}Usage:{ui.RESET}
155
+ silicon Show status or list instances
156
+ silicon new [dir] Create a new Silicon (hydrate from stemcell)
157
+ silicon new . Hydrate the current folder into a runnable silicon
158
+ silicon start <target> Start silicon(s). target = name, index, 1,2,4, or all
159
+ silicon stop <target> Stop silicon(s) (agent stays alive)
160
+ silicon stop --full <target> Stop silicon(s) and glass agent
161
+ silicon restart <target> Restart silicon(s)
162
+ silicon agent <start|stop|status> [name] Manage glass agent
163
+ silicon status [name] Show instance status
164
+ silicon browser [name] Open headed browser for login
165
+ silicon debug [name] Attach to running instance (live logs)
166
+ silicon attach [path] Register an existing silicon instance
167
+ silicon pull <username> Pull a silicon from Glass into a new folder
168
+ silicon push [name] Start hourly backup loop to Glass
169
+ silicon push [name] now Push a one-time backup to Glass
170
+ silicon push [name] stop Stop the hourly backup loop
171
+ silicon update <target> Update silicon(s) to latest. target = name, index, 1,2,4, or all
172
+ silicon list List all instances
173
+ silicon script update Update the silicon CLI itself
174
+ silicon install Install a new instance
175
+ silicon help Show this help
176
+ """)
177
+
178
+
179
+ def suggest_command(inp: str) -> None:
180
+ def score(cmd: str) -> int:
181
+ ld = abs(len(inp) - len(cmd))
182
+ if cmd.startswith(inp) or inp.startswith(cmd):
183
+ return ld
184
+ common = sum(1 for a, b in zip(inp, cmd) if a == b)
185
+ return max(len(cmd), len(inp)) - common + ld
186
+ best = min(COMMANDS + ["ls"], key=score)
187
+ if score(best) <= 3:
188
+ print(f"\n{ui.YELLOW}Did you mean?{ui.RESET}\n silicon {best}\n")
189
+
190
+
191
+ # ----------------------------------------------------------------- dispatch
192
+ def main(argv: list[str] | None = None) -> None:
193
+ argv = argv if argv is not None else sys.argv[1:]
194
+ cmd = argv[0] if argv else ""
195
+ a1 = argv[1] if len(argv) > 1 else None
196
+ a2 = argv[2] if len(argv) > 2 else None
197
+
198
+ if cmd == "_watchdog": # internal: the supervised auto-restart loop
199
+ process.watchdog_loop(name=a2 or "silicon", path=a1, pid_file=argv[3] if len(argv) > 3 else "")
200
+ return
201
+
202
+ if cmd == "start":
203
+ process.start(a1)
204
+ elif cmd == "stop":
205
+ if a1 == "--full":
206
+ process.stop(a2, full=True)
207
+ else:
208
+ process.stop(a1, full=(a2 == "--full"))
209
+ elif cmd == "restart":
210
+ process.restart(a1)
211
+ elif cmd == "status":
212
+ cmd_status(a1)
213
+ elif cmd == "browser":
214
+ cmd_browser(a1)
215
+ elif cmd == "debug":
216
+ cmd_debug(a1)
217
+ elif cmd == "attach":
218
+ cmd_attach(a1)
219
+ elif cmd == "pull":
220
+ sync.pull(a1)
221
+ elif cmd == "push":
222
+ sync.push(a1, a2)
223
+ elif cmd == "update":
224
+ update.update_instance(a1)
225
+ elif cmd in ("list", "ls"):
226
+ cmd_list()
227
+ elif cmd == "agent":
228
+ cmd_agent(a1, a2)
229
+ elif cmd == "script":
230
+ if a1 == "update":
231
+ update.update_cli()
232
+ else:
233
+ ui.error(f"Unknown script command: {a1}. Did you mean: silicon script update?")
234
+ sys.exit(1)
235
+ elif cmd == "new":
236
+ cmd_new(a1)
237
+ elif cmd == "install":
238
+ cmd_new(None)
239
+ elif cmd in ("help", "-h", "--help"):
240
+ cmd_help()
241
+ elif cmd == "":
242
+ cmd_status(None)
243
+ else:
244
+ ui.error(f"Unknown command: {cmd}")
245
+ suggest_command(cmd)
246
+ sys.exit(1)
247
+
248
+
249
+ if __name__ == "__main__":
250
+ main()
silicon_cli/config.py ADDED
@@ -0,0 +1,29 @@
1
+ """Paths + endpoints. Everything is env-overridable so this CLI can point at
2
+ either the original Glass or your own."""
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import shutil
7
+ from pathlib import Path
8
+
9
+ HOME = Path.home()
10
+ REGISTRY_DIR = Path(os.environ.get("SILICON_HOME", HOME / ".silicon"))
11
+ REGISTRY_FILE = REGISTRY_DIR / "registry.json"
12
+ CLI_SOURCE_FILE = REGISTRY_DIR / "cli-source" # where `silicon script update` reinstalls from
13
+
14
+ # Glass sync server (pull/push). Kept as the original default for compatibility;
15
+ # override with GLASS_SERVER_URL to point at your own.
16
+ GLASS_SERVER_URL = os.environ.get("GLASS_SERVER_URL", "https://glass.unlikefraction.com").rstrip("/")
17
+
18
+ # Stemcell — the base every new silicon is hydrated from.
19
+ STEMCELL_REPO = os.environ.get("SILICON_STEMCELL_REPO", "unlikefraction/silicon-stemcell")
20
+ STEMCELL_GIT_URL = f"https://github.com/{STEMCELL_REPO}.git"
21
+ STEMCELL_ZIP_URL = f"https://github.com/{STEMCELL_REPO}/archive/refs/heads/main.zip"
22
+
23
+ # Glass CLI (used by pull/push for backups).
24
+ GLASS_CLI_REPO = os.environ.get("SILICON_GLASS_CLI_REPO", "unlikefraction/glass")
25
+
26
+
27
+ def python_run_cmd() -> str:
28
+ """The interpreter used to RUN a silicon's main.py (not this CLI's venv)."""
29
+ return os.environ.get("SILICON_PYTHON") or shutil.which("python3") or shutil.which("python") or "python3"
@@ -0,0 +1,66 @@
1
+ """The per-silicon Glass agent (glass_agent.py) — remote control / backups.
2
+
3
+ Only relevant when the silicon dir has a .glass.json. Tracked via .glass_agent.pid.
4
+ """
5
+ from __future__ import annotations
6
+
7
+ import os
8
+ import signal
9
+ import subprocess
10
+ import time
11
+ from pathlib import Path
12
+
13
+ from . import ui
14
+ from .config import python_run_cmd
15
+
16
+
17
+ def _pid_file(path: str) -> Path:
18
+ return Path(path) / ".glass_agent.pid"
19
+
20
+
21
+ def _read_pid(path: str) -> int | None:
22
+ try:
23
+ return int(_pid_file(path).read_text().strip())
24
+ except Exception:
25
+ return None
26
+
27
+
28
+ def _alive(pid: int) -> bool:
29
+ try:
30
+ os.kill(pid, 0)
31
+ return True
32
+ except (OSError, ProcessLookupError):
33
+ return False
34
+
35
+
36
+ def status(path: str) -> bool:
37
+ pid = _read_pid(path)
38
+ return bool(pid and _alive(pid))
39
+
40
+
41
+ def start(path: str) -> None:
42
+ if not (Path(path) / ".glass.json").exists():
43
+ return
44
+ if status(path):
45
+ return
46
+ log = open(Path(path) / ".glass_agent.log", "a")
47
+ proc = subprocess.Popen(
48
+ [python_run_cmd(), "-u", "glass_agent.py"], cwd=path,
49
+ stdout=log, stderr=subprocess.STDOUT, start_new_session=True,
50
+ )
51
+ _pid_file(path).write_text(str(proc.pid))
52
+ ui.info(f"Glass agent started (PID {proc.pid})")
53
+
54
+
55
+ def stop(path: str) -> None:
56
+ pid = _read_pid(path)
57
+ if pid and _alive(pid):
58
+ try:
59
+ os.kill(pid, signal.SIGTERM)
60
+ time.sleep(1)
61
+ if _alive(pid):
62
+ os.kill(pid, signal.SIGKILL)
63
+ except (OSError, ProcessLookupError):
64
+ pass
65
+ ui.info("Glass agent stopped")
66
+ _pid_file(path).unlink(missing_ok=True)
silicon_cli/process.py ADDED
@@ -0,0 +1,258 @@
1
+ """Process supervision — start/stop instances with an auto-restart watchdog.
2
+
3
+ Mirrors the bash watchdog: a detached supervisor process runs `python -u main.py`,
4
+ restarts it on exit (with crash-loop detection), honors a .silicon.stop sentinel,
5
+ writes .silicon.log + .silicon.pid (the pid is the *watchdog's*, so a stop signal
6
+ reaches the supervisor which then kills its child and cleans up).
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ import re
12
+ import signal
13
+ import subprocess
14
+ import sys
15
+ import time
16
+ from pathlib import Path
17
+
18
+ from . import glassagent, registry, ui
19
+ from .config import python_run_cmd
20
+
21
+ RESTART_DELAY = 5
22
+ MAX_RAPID = 5
23
+ RAPID_WINDOW = 60
24
+
25
+
26
+ def get_pid(pid_file: str) -> str | None:
27
+ try:
28
+ pid = Path(pid_file).read_text().strip()
29
+ return pid or None
30
+ except Exception:
31
+ return None
32
+
33
+
34
+ def _alive(pid: int) -> bool:
35
+ try:
36
+ os.kill(pid, 0)
37
+ return True
38
+ except (OSError, ProcessLookupError):
39
+ return False
40
+
41
+
42
+ def is_running(pid_file: str) -> bool:
43
+ pid = get_pid(pid_file)
44
+ if not pid:
45
+ return False
46
+ try:
47
+ return _alive(int(pid))
48
+ except ValueError:
49
+ return False
50
+
51
+
52
+ def _floater_pids(path: str, skip: int | None = None) -> list[int]:
53
+ """PIDs of python processes running this dir's main.py (orphans)."""
54
+ main_py = str(Path(path) / "main.py")
55
+ try:
56
+ out = subprocess.run(["ps", "-eo", "pid=,command="], capture_output=True, text=True).stdout
57
+ except Exception:
58
+ return []
59
+ pids = []
60
+ for line in out.splitlines():
61
+ line = line.strip()
62
+ if not line:
63
+ continue
64
+ m = re.match(r"^(\d+)\s+(.*)$", line)
65
+ if not m:
66
+ continue
67
+ pid, cmd = int(m.group(1)), m.group(2)
68
+ if "python" in cmd and main_py in cmd:
69
+ if skip is not None and pid == skip:
70
+ continue
71
+ if pid == os.getpid():
72
+ continue
73
+ pids.append(pid)
74
+ return pids
75
+
76
+
77
+ def kill_floaters(path: str, skip: int | None = None) -> None:
78
+ for pid in _floater_pids(path, skip):
79
+ ui.warn(f"Killing orphaned process (PID {pid}) from {path}")
80
+ try:
81
+ os.kill(pid, signal.SIGTERM)
82
+ time.sleep(1)
83
+ if _alive(pid):
84
+ os.kill(pid, signal.SIGKILL)
85
+ except (OSError, ProcessLookupError):
86
+ pass
87
+
88
+
89
+ # --------------------------------------------------------------- watchdog
90
+ def watchdog_loop(name: str, path: str, pid_file: str) -> None:
91
+ """Runs as the detached `silicon _watchdog` process."""
92
+ log_file = Path(path) / ".silicon.log"
93
+ main_py = str(Path(path) / "main.py")
94
+ stop_file = Path(path) / ".silicon.stop"
95
+ py = python_run_cmd()
96
+ child: subprocess.Popen | None = None
97
+
98
+ def _terminate(signum=None, frame=None):
99
+ if child and child.poll() is None:
100
+ try:
101
+ child.terminate()
102
+ for _ in range(6):
103
+ if child.poll() is not None:
104
+ break
105
+ time.sleep(0.5)
106
+ if child.poll() is None:
107
+ child.kill()
108
+ except Exception:
109
+ pass
110
+ try:
111
+ Path(pid_file).unlink()
112
+ except OSError:
113
+ pass
114
+ sys.exit(0)
115
+
116
+ signal.signal(signal.SIGTERM, _terminate)
117
+ signal.signal(signal.SIGINT, _terminate)
118
+
119
+ restart_times: list[float] = []
120
+ while True:
121
+ kill_floaters(path, skip=os.getpid())
122
+ with open(log_file, "a") as lf:
123
+ child = subprocess.Popen([py, "-u", main_py], cwd=path, stdout=lf, stderr=subprocess.STDOUT)
124
+ exit_code = child.wait()
125
+ child = None
126
+
127
+ if stop_file.exists():
128
+ stop_file.unlink(missing_ok=True)
129
+ Path(pid_file).unlink(missing_ok=True)
130
+ break
131
+
132
+ now = time.time()
133
+ restart_times.append(now)
134
+ cutoff = now - RAPID_WINDOW
135
+ restart_times = [t for t in restart_times if t >= cutoff]
136
+ if len(restart_times) >= MAX_RAPID:
137
+ with open(log_file, "a") as lf:
138
+ lf.write(f"[silicon-watchdog] {time.ctime()}: '{name}' crashed {MAX_RAPID} times "
139
+ f"in {RAPID_WINDOW}s. Giving up.\n")
140
+ Path(pid_file).unlink(missing_ok=True)
141
+ break
142
+
143
+ with open(log_file, "a") as lf:
144
+ lf.write(f"[silicon-watchdog] {time.ctime()}: '{name}' exited (code {exit_code}). "
145
+ f"Restarting in {RESTART_DELAY}s...\n")
146
+ time.sleep(RESTART_DELAY)
147
+
148
+
149
+ # --------------------------------------------------------------- start/stop
150
+ def _spawn_watchdog(name: str, path: str, pid_file: str) -> int:
151
+ """Launch the detached watchdog; return its PID."""
152
+ proc = subprocess.Popen(
153
+ [sys.executable, "-m", "silicon_cli.cli", "_watchdog", path, name, pid_file],
154
+ stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
155
+ start_new_session=True, # detach so it survives this CLI exiting
156
+ )
157
+ return proc.pid
158
+
159
+
160
+ def start_one(target: str | None) -> None:
161
+ inst = registry.resolve_one(target)
162
+ if is_running(inst.pid_file):
163
+ ui.warn(f"'{inst.name}' is already running (PID {get_pid(inst.pid_file)})")
164
+ glassagent.start(inst.path)
165
+ return
166
+
167
+ kill_floaters(inst.path)
168
+ Path(inst.pid_file).unlink(missing_ok=True)
169
+ (Path(inst.path) / ".silicon.stop").unlink(missing_ok=True)
170
+
171
+ ui.info(f"Starting '{inst.name}' (with auto-restart)...")
172
+ pid = _spawn_watchdog(inst.name, inst.path, inst.pid_file)
173
+ Path(inst.pid_file).write_text(str(pid))
174
+
175
+ time.sleep(2)
176
+ if _alive(pid):
177
+ ui.success(f"'{inst.name}' started (PID {pid})")
178
+ ui.info(f"Auto-restart enabled. Logs: {inst.path}/.silicon.log")
179
+ else:
180
+ ui.error(f"'{inst.name}' failed to start. Check logs: {inst.path}/.silicon.log")
181
+ Path(inst.pid_file).unlink(missing_ok=True)
182
+
183
+ glassagent.start(inst.path)
184
+
185
+
186
+ def stop_one(target: str | None, full: bool = False) -> None:
187
+ inst = registry.resolve_one(target)
188
+ if not is_running(inst.pid_file):
189
+ ui.warn(f"'{inst.name}' is not running")
190
+ kill_floaters(inst.path)
191
+ Path(inst.pid_file).unlink(missing_ok=True)
192
+ (Path(inst.path) / ".silicon.stop").unlink(missing_ok=True)
193
+ if full:
194
+ glassagent.stop(inst.path)
195
+ return
196
+
197
+ pid = int(get_pid(inst.pid_file))
198
+ (Path(inst.path) / ".silicon.stop").touch() # tell the watchdog not to restart
199
+ ui.info(f"Stopping '{inst.name}' (PID {pid})...")
200
+ try:
201
+ os.kill(pid, signal.SIGTERM)
202
+ except (OSError, ProcessLookupError):
203
+ pass
204
+ for _ in range(10):
205
+ if not _alive(pid):
206
+ break
207
+ time.sleep(0.5)
208
+ if _alive(pid):
209
+ ui.warn("Force stopping...")
210
+ try:
211
+ os.kill(pid, signal.SIGKILL)
212
+ except (OSError, ProcessLookupError):
213
+ pass
214
+
215
+ kill_floaters(inst.path)
216
+ Path(inst.pid_file).unlink(missing_ok=True)
217
+ (Path(inst.path) / ".silicon.stop").unlink(missing_ok=True)
218
+ ui.success(f"'{inst.name}' stopped")
219
+
220
+ if full:
221
+ glassagent.stop(inst.path)
222
+ else:
223
+ ui.info("Glass agent still running (use --full to stop it too).")
224
+
225
+
226
+ def _multi(target: str, verb: str, fn) -> bool:
227
+ """Dispatch a multi-target selector. Returns True if it handled it."""
228
+ if not (target and registry.is_multi_target(target)):
229
+ return False
230
+ names = registry.resolve_targets(target)
231
+ if not names:
232
+ ui.error("No matching installations")
233
+ sys.exit(1)
234
+ if target == "all":
235
+ joined = ", ".join(names)
236
+ if not ui.confirm(f"Are you sure you want to {verb} the following silicons: {joined}?"):
237
+ return True
238
+ for n in names:
239
+ fn(n)
240
+ return True
241
+
242
+
243
+ def start(target: str | None) -> None:
244
+ if _multi(target or "", "start", start_one):
245
+ return
246
+ start_one(target)
247
+
248
+
249
+ def stop(target: str | None, full: bool = False) -> None:
250
+ if _multi(target or "", "stop", lambda n: stop_one(n, full)):
251
+ return
252
+ stop_one(target, full)
253
+
254
+
255
+ def restart(target: str | None) -> None:
256
+ stop(target)
257
+ time.sleep(1)
258
+ start(target)