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,145 @@
1
+ """~/.silicon/registry.json — the list of known silicon installations.
2
+
3
+ Same file + schema as the original bash CLI, so installs carry over unchanged:
4
+ {"installations": [{"name", "path", "pid_file"}, ...]}
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import json
9
+ import os
10
+ import sys
11
+ from dataclasses import dataclass
12
+ from pathlib import Path
13
+
14
+ from . import ui
15
+ from .config import REGISTRY_DIR, REGISTRY_FILE
16
+
17
+
18
+ @dataclass
19
+ class Install:
20
+ index: int
21
+ name: str
22
+ path: str
23
+ pid_file: str
24
+
25
+
26
+ def _load() -> dict:
27
+ if REGISTRY_FILE.exists():
28
+ try:
29
+ return json.loads(REGISTRY_FILE.read_text())
30
+ except Exception:
31
+ return {"installations": []}
32
+ return {"installations": []}
33
+
34
+
35
+ def _save(reg: dict) -> None:
36
+ REGISTRY_DIR.mkdir(parents=True, exist_ok=True)
37
+ REGISTRY_FILE.write_text(json.dumps(reg, indent=2))
38
+
39
+
40
+ def installs() -> list[Install]:
41
+ reg = _load()
42
+ out = []
43
+ for i, inst in enumerate(reg.get("installations", [])):
44
+ out.append(Install(i, inst["name"], inst["path"], inst.get("pid_file", "")))
45
+ return out
46
+
47
+
48
+ def count() -> int:
49
+ return len(_load().get("installations", []))
50
+
51
+
52
+ def register(name: str, path: str, pid_file: str | None = None) -> str:
53
+ """Add an installation. Returns 'added' or 'exists'."""
54
+ path = str(Path(path))
55
+ pid_file = pid_file or str(Path(path) / ".silicon.pid")
56
+ reg = _load()
57
+ for inst in reg.get("installations", []):
58
+ if inst.get("path") == path or inst.get("name") == name:
59
+ return "exists"
60
+ reg.setdefault("installations", []).append({"name": name, "path": path, "pid_file": pid_file})
61
+ _save(reg)
62
+ return "added"
63
+
64
+
65
+ def name_taken(name: str) -> bool:
66
+ return any(i.name == name for i in installs())
67
+
68
+
69
+ def find(search: str | None = None) -> Install | None:
70
+ """By name if given, else the install whose path contains the cwd."""
71
+ rows = installs()
72
+ if search:
73
+ for i in rows:
74
+ if i.name == search:
75
+ return i
76
+ return None
77
+ cwd = os.getcwd()
78
+ for i in rows:
79
+ if cwd == i.path or cwd.startswith(i.path.rstrip("/") + "/"):
80
+ return i
81
+ return None
82
+
83
+
84
+ def is_multi_target(s: str) -> bool:
85
+ if s == "all":
86
+ return True
87
+ parts = s.split(",")
88
+ return all(p.strip().isdigit() for p in parts) and bool(parts)
89
+
90
+
91
+ def resolve_targets(selector: str) -> list[str]:
92
+ """'all' | '1,2,4' (1-based indices) → list of install names."""
93
+ rows = installs()
94
+ if selector == "all":
95
+ return [i.name for i in rows]
96
+ out = []
97
+ for num in selector.split(","):
98
+ num = num.strip()
99
+ if not num.isdigit():
100
+ continue
101
+ idx = int(num) - 1
102
+ for i in rows:
103
+ if i.index == idx:
104
+ out.append(i.name)
105
+ return out
106
+
107
+
108
+ def pick() -> Install:
109
+ """Interactive picker; auto-selects when there's exactly one."""
110
+ rows = installs()
111
+ if not rows:
112
+ ui.error("No silicon installations found. Run 'silicon install' first.")
113
+ sys.exit(1)
114
+ if len(rows) == 1:
115
+ return rows[0]
116
+
117
+ from .process import is_running
118
+ sys.stderr.write(f"\n{ui.BOLD}Select a silicon instance:{ui.RESET}\n\n")
119
+ for i in rows:
120
+ running = is_running(i.pid_file)
121
+ status = f"{ui.GREEN}● running{ui.RESET}" if running else f"{ui.DIM}○ stopped{ui.RESET}"
122
+ sys.stderr.write(f" {ui.BOLD}{i.index + 1}){ui.RESET} {i.name:<20} {status} {ui.DIM}{i.path}{ui.RESET}\n")
123
+ sys.stderr.write("\n")
124
+ choice = ui.ask("Choice", "1")
125
+ try:
126
+ target_idx = int(choice) - 1
127
+ except ValueError:
128
+ ui.error("Invalid choice")
129
+ sys.exit(1)
130
+ for i in rows:
131
+ if i.index == target_idx:
132
+ return i
133
+ ui.error("Invalid choice")
134
+ sys.exit(1)
135
+
136
+
137
+ def resolve_one(target: str | None) -> Install:
138
+ """For single-target commands: by name, else cwd, else interactive pick."""
139
+ if target:
140
+ inst = find(target)
141
+ if not inst:
142
+ ui.error(f"Silicon '{target}' not found")
143
+ sys.exit(1)
144
+ return inst
145
+ return find() or pick()
@@ -0,0 +1,226 @@
1
+ """Create / hydrate a silicon from the silicon-stemcell base.
2
+
3
+ `silicon new <dir>` downloads the stemcell, copies in any files the target is
4
+ missing (never clobbering env.py / silicon.json / .glass.json), seeds config +
5
+ env keys, prompts for tokens + brain/worker providers, installs requirements,
6
+ and registers the instance — same flow as the original bash CLI.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import os
12
+ import re
13
+ import shutil
14
+ import subprocess
15
+ import sys
16
+ import tempfile
17
+ from pathlib import Path
18
+
19
+ from . import registry, ui
20
+ from .config import STEMCELL_GIT_URL, STEMCELL_ZIP_URL, python_run_cmd
21
+
22
+ SKIP_NAMES = {".git", "__pycache__", ".DS_Store"}
23
+ PRESERVE_ROOT = {"env.py", "silicon.json", ".glass.json"}
24
+ ALLOWED_PROVIDERS = {"claude", "codex", "chatgpt"}
25
+
26
+
27
+ def download_stemcell(target: str) -> None:
28
+ shutil.rmtree(target, ignore_errors=True)
29
+ os.makedirs(target, exist_ok=True)
30
+ if shutil.which("git"):
31
+ subprocess.run(["git", "clone", "--depth", "1", STEMCELL_GIT_URL, target],
32
+ stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=True)
33
+ shutil.rmtree(Path(target) / ".git", ignore_errors=True)
34
+ return
35
+ # Fallback: download the zip
36
+ dl = shutil.which("curl") or shutil.which("wget")
37
+ if not dl:
38
+ ui.error("Need git, curl, or wget to download Silicon.")
39
+ sys.exit(1)
40
+ tmp_zip = tempfile.mktemp(suffix=".zip", prefix="silicon-")
41
+ if "curl" in dl:
42
+ subprocess.run([dl, "-fsSL", STEMCELL_ZIP_URL, "-o", tmp_zip], check=True)
43
+ else:
44
+ subprocess.run([dl, "-q", STEMCELL_ZIP_URL, "-O", tmp_zip], check=True)
45
+ subprocess.run(["unzip", "-q", tmp_zip, "-d", target], check=True)
46
+ os.unlink(tmp_zip)
47
+ extracted = [p for p in Path(target).iterdir() if p.is_dir() and p.name.startswith("silicon-")]
48
+ if extracted:
49
+ inner = extracted[0]
50
+ for item in inner.iterdir():
51
+ shutil.move(str(item), str(Path(target) / item.name))
52
+ shutil.rmtree(inner, ignore_errors=True)
53
+
54
+
55
+ def _env_value(env_path: Path, key: str) -> str:
56
+ if not env_path.exists():
57
+ return ""
58
+ m = re.search(rf'^{key}\s*=\s*["\'](.*)["\']\s*$', env_path.read_text(), re.M)
59
+ return m.group(1) if m else ""
60
+
61
+
62
+ def _env_upsert(env_path: Path, key: str, value: str) -> None:
63
+ text = env_path.read_text() if env_path.exists() else ""
64
+ pattern = rf'^{key}\s*=\s*["\'].*["\']\s*$'
65
+ replacement = f'{key} = "{value}"'
66
+ if re.search(pattern, text, re.M):
67
+ text = re.sub(pattern, replacement, text, flags=re.M)
68
+ else:
69
+ text = (text.rstrip() + "\n" if text.strip() else "") + replacement + "\n"
70
+ env_path.write_text(text.rstrip() + "\n")
71
+
72
+
73
+ def _provider_list(value, default):
74
+ if not isinstance(value, list):
75
+ return default
76
+ out = []
77
+ for item in value:
78
+ if isinstance(item, str) and item in ALLOWED_PROVIDERS:
79
+ v = "codex" if item == "chatgpt" else item
80
+ if v not in out:
81
+ out.append(v)
82
+ return out or default
83
+
84
+
85
+ def _choose_provider_order(worker_type: str, default_choice: str = "claude") -> list[str]:
86
+ choice = ui.ask(f"Which provider should {worker_type} workers use – claude or codex?", default_choice)
87
+ if choice == "codex":
88
+ return ["codex", "claude"] if ui.confirm(f"Keep claude as fallback for {worker_type} workers?") else ["codex"]
89
+ return ["claude", "codex"] if ui.confirm(f"Keep codex as fallback for {worker_type} workers?") else ["claude"]
90
+
91
+
92
+ def hydrate(target: str) -> None:
93
+ abs_target = str(Path(target).resolve())
94
+ os.makedirs(abs_target, exist_ok=True)
95
+ dst = Path(abs_target)
96
+
97
+ tmp_src = tempfile.mkdtemp(prefix="silicon-src-")
98
+ try:
99
+ ui.info("Downloading Silicon stemcell...")
100
+ download_stemcell(tmp_src)
101
+ src = Path(tmp_src)
102
+
103
+ # Instance name: silicon.json address/name, else folder name
104
+ name = ""
105
+ sj = dst / "silicon.json"
106
+ if sj.exists():
107
+ try:
108
+ data = json.loads(sj.read_text())
109
+ name = (data.get("address") or data.get("name") or "").strip()
110
+ except Exception:
111
+ pass
112
+ if not name:
113
+ name = dst.name
114
+
115
+ ui.info(f"Hydrating {abs_target}...")
116
+ for path in src.rglob("*"):
117
+ rel = path.relative_to(src)
118
+ if any(part in SKIP_NAMES for part in rel.parts):
119
+ continue
120
+ tgt = dst / rel
121
+ if path.is_dir():
122
+ tgt.mkdir(parents=True, exist_ok=True)
123
+ continue
124
+ if len(rel.parts) == 1 and rel.parts[0] in PRESERVE_ROOT and tgt.exists():
125
+ continue
126
+ if tgt.exists():
127
+ continue
128
+ tgt.parent.mkdir(parents=True, exist_ok=True)
129
+ shutil.copy2(path, tgt)
130
+
131
+ # Seed silicon.json
132
+ silicon = {}
133
+ if sj.exists():
134
+ try:
135
+ silicon = json.loads(sj.read_text())
136
+ except json.JSONDecodeError:
137
+ silicon = {}
138
+ silicon.setdefault("name", "Silicon")
139
+ silicon.setdefault("run", "python main.py")
140
+ silicon.setdefault("brain", "claude")
141
+ silicon.setdefault("workers", {"browser": ["claude"], "terminal": ["claude"], "writer": ["claude"]})
142
+ if not silicon.get("address"): # the stemcell ships an empty address — fill it
143
+ silicon["address"] = name
144
+ silicon.pop("version", None)
145
+ sj.write_text(json.dumps(silicon, indent=4) + "\n")
146
+
147
+ # Seed env.py required keys
148
+ env_path = dst / "env.py"
149
+ for key, default in {"TELEGRAM_BOT_TOKEN": "", "OPENAI_API_KEY": "", "GEMINI_API_KEY": "", "BROWSER_PROFILE": name}.items():
150
+ if not _env_value(env_path, key):
151
+ _env_upsert(env_path, key, default)
152
+
153
+ # Run the stemcell's snapshot hook (for safe future updates), best-effort
154
+ updater = src / "scripts" / "silicon_update.py"
155
+ if updater.exists():
156
+ subprocess.run([python_run_cmd(), str(updater), "snapshot", "--source", str(src), "--target", abs_target],
157
+ stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
158
+
159
+ # Interactive setup
160
+ if ui.interactive():
161
+ _interactive_setup(dst, env_path, sj, name)
162
+
163
+ # Install dependencies
164
+ req = dst / "requirements.txt"
165
+ if req.exists():
166
+ ui.info("Installing Python dependencies...")
167
+ r = subprocess.run([python_run_cmd(), "-m", "pip", "install", "-r", str(req), "--quiet"])
168
+ if r.returncode != 0:
169
+ subprocess.run([python_run_cmd(), "-m", "pip", "install", "-r", str(req), "--quiet", "--user"])
170
+
171
+ registry.register(name, abs_target)
172
+ ui.success(f"Hydrated '{name}' at {abs_target}")
173
+ ui.info(f"Run 'silicon start {name}' when you're ready.")
174
+ finally:
175
+ shutil.rmtree(tmp_src, ignore_errors=True)
176
+
177
+
178
+ def _interactive_setup(dst: Path, env_path: Path, sj: Path, name: str) -> None:
179
+ if not _env_value(env_path, "TELEGRAM_BOT_TOKEN"):
180
+ ui.info("You need a Telegram bot token to use Silicon.")
181
+ sys.stderr.write(f"{ui.DIM} 1. Open Telegram and search for @BotFather{ui.RESET}\n")
182
+ sys.stderr.write(f"{ui.DIM} 2. Send /newbot and follow the prompts{ui.RESET}\n")
183
+ sys.stderr.write(f"{ui.DIM} 3. Copy the token BotFather gives you{ui.RESET}\n")
184
+ token = ui.read_secret("Telegram bot token")
185
+ if not token:
186
+ ui.error("Telegram bot token is required.")
187
+ sys.exit(1)
188
+ _env_upsert(env_path, "TELEGRAM_BOT_TOKEN", token)
189
+
190
+ if not _env_value(env_path, "OPENAI_API_KEY"):
191
+ ui.info("OpenAI API key (for incoming voice transcription via Whisper). Enter to skip.")
192
+ v = ui.read_secret("OpenAI API key (optional)")
193
+ if v:
194
+ _env_upsert(env_path, "OPENAI_API_KEY", v)
195
+
196
+ if not _env_value(env_path, "GEMINI_API_KEY"):
197
+ ui.info("Gemini API key (for outgoing text-to-speech). Enter to skip.")
198
+ v = ui.read_secret("Gemini API key (optional)")
199
+ if v:
200
+ _env_upsert(env_path, "GEMINI_API_KEY", v)
201
+
202
+ # Brain / worker providers — only ask when both claude + codex are present
203
+ brain = "claude"
204
+ workers = {"browser": ["claude"], "terminal": ["claude"], "writer": ["claude"]}
205
+ have_claude = bool(shutil.which("claude"))
206
+ have_codex = bool(shutil.which("codex"))
207
+ if have_claude and have_codex:
208
+ ui.info("Detected both claude and codex.")
209
+ brain = "codex" if ui.ask("Which brain should Silicon use – claude or codex?", "claude") == "codex" else "claude"
210
+ # Each worker defaults to the chosen brain (matches the current stemcell CLI).
211
+ workers = {
212
+ "browser": _choose_provider_order("browser", brain),
213
+ "terminal": _choose_provider_order("terminal", brain),
214
+ "writer": _choose_provider_order("writer", brain),
215
+ }
216
+ elif have_codex:
217
+ brain = "codex"
218
+ workers = {"browser": ["codex"], "terminal": ["codex"], "writer": ["codex"]}
219
+
220
+ try:
221
+ silicon = json.loads(sj.read_text())
222
+ except Exception:
223
+ silicon = {}
224
+ silicon["brain"] = brain
225
+ silicon["workers"] = {k: _provider_list(v, ["claude"]) for k, v in workers.items()}
226
+ sj.write_text(json.dumps(silicon, indent=4) + "\n")
silicon_cli/sync.py ADDED
@@ -0,0 +1,216 @@
1
+ """Glass sync — pull a silicon from a Glass server and run backups (push).
2
+
3
+ Faithful port of the bash `pull`/`push`. HTTP via stdlib urllib. Backups shell
4
+ out to the `glass` CLI (auto-installed if missing), same as the original.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import hashlib
9
+ import json
10
+ import os
11
+ import shutil
12
+ import socket
13
+ import subprocess
14
+ import sys
15
+ import tarfile
16
+ import tempfile
17
+ import urllib.request
18
+ from pathlib import Path
19
+
20
+ from . import registry, stemcell, ui
21
+ from .config import GLASS_CLI_REPO, GLASS_SERVER_URL
22
+
23
+
24
+ def _has_glass() -> bool:
25
+ return bool(shutil.which("glass"))
26
+
27
+
28
+ def ensure_glass_cli() -> None:
29
+ if _has_glass():
30
+ return
31
+ ui.info("glass CLI not found. Installing it...")
32
+ install_glass_cli()
33
+ if not _has_glass():
34
+ ui.error("glass CLI installation failed")
35
+ sys.exit(1)
36
+
37
+
38
+ def install_glass_cli() -> None:
39
+ glass_dir = Path.home() / ".glass"
40
+ bin_dir = Path.home() / ".local" / "bin"
41
+ bin_dir.mkdir(parents=True, exist_ok=True)
42
+ archive = f"https://codeload.github.com/{GLASS_CLI_REPO}/tar.gz/refs/heads/main"
43
+ tmp = tempfile.mkdtemp(prefix="glass-install-")
44
+ try:
45
+ ui.info("Installing glass CLI...")
46
+ tarball = Path(tmp) / "glass.tar.gz"
47
+ urllib.request.urlretrieve(archive, tarball)
48
+ with tarfile.open(tarball) as tf:
49
+ tf.extractall(tmp)
50
+ src = next((p for p in Path(tmp).iterdir() if p.is_dir() and p.name.startswith("glass-")), None)
51
+ if not src or not (src / "glass").exists():
52
+ ui.warn("Could not auto-install glass CLI. Downloaded archive was invalid.")
53
+ return
54
+ shutil.rmtree(glass_dir, ignore_errors=True)
55
+ glass_dir.mkdir(parents=True, exist_ok=True)
56
+ for item in src.iterdir():
57
+ if item.name in {".git", "__pycache__"}:
58
+ continue
59
+ dest = glass_dir / item.name
60
+ if item.is_dir():
61
+ shutil.copytree(item, dest, dirs_exist_ok=True)
62
+ else:
63
+ shutil.copy2(item, dest)
64
+ os.chmod(glass_dir / "glass", 0o755)
65
+ wrapper = bin_dir / "glass"
66
+ if wrapper.exists() or wrapper.is_symlink():
67
+ wrapper.unlink()
68
+ wrapper.symlink_to(glass_dir / "glass")
69
+ ui.success("glass CLI installed") if _has_glass() else ui.warn("glass installed but not on PATH (add ~/.local/bin).")
70
+ finally:
71
+ shutil.rmtree(tmp, ignore_errors=True)
72
+
73
+
74
+ def _post_json(url: str, payload: dict) -> tuple[int, dict]:
75
+ data = json.dumps(payload).encode()
76
+ req = urllib.request.Request(url, data=data, headers={"Content-Type": "application/json"})
77
+ try:
78
+ with urllib.request.urlopen(req) as resp:
79
+ return resp.status, json.loads(resp.read().decode() or "{}")
80
+ except urllib.error.HTTPError as e:
81
+ try:
82
+ return e.code, json.loads(e.read().decode() or "{}")
83
+ except Exception:
84
+ return e.code, {}
85
+ except Exception as e:
86
+ return 0, {"error": str(e)}
87
+
88
+
89
+ def pull(username: str | None) -> None:
90
+ if not username:
91
+ ui.error("Usage: silicon pull <silicon-username>")
92
+ sys.exit(1)
93
+ ensure_glass_cli()
94
+
95
+ target = Path.cwd() / username
96
+ if target.exists():
97
+ ui.error(f"Target folder already exists: {target}")
98
+ sys.exit(1)
99
+
100
+ connector_code = ui.read_secret("Connector code")
101
+ target.mkdir(parents=True)
102
+ fingerprint = hashlib.sha256(f"{socket.gethostname()}::{target.resolve()}".encode()).hexdigest()
103
+
104
+ code, claim = _post_json(f"{GLASS_SERVER_URL}/sync/api/pull/claim/", {
105
+ "username": username, "connector_code": connector_code,
106
+ "folder_label": username, "folder_fingerprint": fingerprint,
107
+ })
108
+ if not (200 <= code < 300):
109
+ shutil.rmtree(target, ignore_errors=True)
110
+ ui.error(claim.get("error", "Pull claim failed."))
111
+ sys.exit(1)
112
+
113
+ if claim.get("has_snapshot"):
114
+ archive = tempfile.mktemp(suffix=".tar.gz", prefix="silicon-pull-")
115
+ req = urllib.request.Request(
116
+ f"{GLASS_SERVER_URL}/sync/api/silicons/{username}/latest.tar.gz",
117
+ headers={"X-Source-Token": claim["source_token"]},
118
+ )
119
+ with urllib.request.urlopen(req) as resp, open(archive, "wb") as f:
120
+ shutil.copyfileobj(resp, f)
121
+ with tarfile.open(archive) as tf:
122
+ tf.extractall(target)
123
+ os.unlink(archive)
124
+
125
+ (target / ".glass.json").write_text(json.dumps({
126
+ "server_url": GLASS_SERVER_URL, "silicon_username": username,
127
+ "source_token": claim["source_token"], "api_key": claim["api_key"],
128
+ "folder_fingerprint": fingerprint, "last_tree_hash": claim.get("latest_tree_hash", ""),
129
+ }, indent=2) + "\n")
130
+
131
+ sj = target / "silicon.json"
132
+ silicon = {}
133
+ if sj.exists():
134
+ try:
135
+ silicon = json.loads(sj.read_text())
136
+ except json.JSONDecodeError:
137
+ silicon = {}
138
+ silicon.setdefault("name", "Silicon")
139
+ silicon.setdefault("run", "python main.py")
140
+ silicon.setdefault("brain", "claude")
141
+ silicon.setdefault("workers", {"browser": ["claude"], "terminal": ["claude"], "writer": ["claude"]})
142
+ silicon.pop("version", None)
143
+ silicon["address"] = username
144
+ silicon["glass"] = {"server_url": GLASS_SERVER_URL, "silicon_username": username,
145
+ "api_key": claim["api_key"], "source_token": claim["source_token"]}
146
+ sj.write_text(json.dumps(silicon, indent=4) + "\n")
147
+ stemcell._env_upsert(target / "env.py", "GLASS_API_KEY", claim["api_key"])
148
+
149
+ registry.register(username, str(target))
150
+ ui.success(f"Pulled '{username}' into {target}")
151
+ ui.info("Registered as a silicon instance.")
152
+
153
+ # Empty-repo detection → offer to populate
154
+ bare = {".glass.json", "silicon.json", "env.py"}
155
+ real = [f for f in target.iterdir()
156
+ if not (f.name.startswith(".") and f.name != ".glass.json")
157
+ and f.name != "__pycache__" and f.name not in bare]
158
+ if not real and ui.interactive():
159
+ ui.warn("This looks like an empty repository (only silicon.json and env.py).")
160
+ if ui.confirm("Do you want to populate it with Silicon?"):
161
+ stemcell.hydrate(str(target))
162
+
163
+ if ui.interactive() and ui.confirm("Do you want to enable backups for this silicon?"):
164
+ ensure_glass_cli()
165
+ ui.info("Running initial backup...")
166
+ if subprocess.run(["glass", "push", "now"], cwd=str(target)).returncode == 0:
167
+ ui.success("Backup complete.")
168
+ _start_backup_loop(str(target), username)
169
+ else:
170
+ ui.warn(f"Initial backup failed. Retry with: silicon push {username} now")
171
+
172
+
173
+ def _start_backup_loop(path: str, name: str) -> None:
174
+ ui.info("Starting hourly backup loop in background...")
175
+ log = open(Path(path) / ".glass-push.log", "a")
176
+ proc = subprocess.Popen(["glass", "push"], cwd=path, stdout=log, stderr=subprocess.STDOUT,
177
+ start_new_session=True)
178
+ (Path(path) / ".glass-push.pid").write_text(str(proc.pid))
179
+ ui.success(f"Hourly backups running (PID {proc.pid}). Logs: {path}/.glass-push.log")
180
+ ui.info(f"Use 'silicon push {name} now' for a manual backup anytime.")
181
+
182
+
183
+ def push(target: str | None, subcmd: str | None) -> None:
184
+ inst = registry.resolve_one(target)
185
+ if not (Path(inst.path) / ".glass.json").exists():
186
+ ui.error(f"'{inst.name}' is not connected to Glass. No .glass.json found.")
187
+ sys.exit(1)
188
+ ensure_glass_cli()
189
+ pid_file = Path(inst.path) / ".glass-push.pid"
190
+
191
+ if subcmd == "now":
192
+ ui.info(f"Pushing '{inst.name}' to Glass...")
193
+ ok = subprocess.run(["glass", "push", "now"], cwd=inst.path).returncode == 0
194
+ ui.success("Backup complete.") if ok else ui.error("Push failed.")
195
+ elif subcmd == "stop":
196
+ try:
197
+ pid = int(pid_file.read_text().strip())
198
+ os.kill(pid, 15)
199
+ pid_file.unlink(missing_ok=True)
200
+ ui.success(f"Stopped backup loop for '{inst.name}'.")
201
+ return
202
+ except Exception:
203
+ ui.warn(f"No backup loop running for '{inst.name}'.")
204
+ pid_file.unlink(missing_ok=True)
205
+ else:
206
+ if pid_file.exists():
207
+ try:
208
+ pid = int(pid_file.read_text().strip())
209
+ os.kill(pid, 0)
210
+ ui.warn(f"Backup loop already running for '{inst.name}' (PID {pid})")
211
+ return
212
+ except Exception:
213
+ pass
214
+ ui.info(f"Starting hourly backup loop for '{inst.name}'...")
215
+ subprocess.run(["glass", "push", "now"], cwd=inst.path)
216
+ _start_backup_loop(inst.path, inst.name)
silicon_cli/ui.py ADDED
@@ -0,0 +1,61 @@
1
+ """Terminal UI helpers — colors, status glyphs, prompts. Mirrors the bash CLI."""
2
+ from __future__ import annotations
3
+
4
+ import sys
5
+
6
+ RED = "\033[0;31m"; GREEN = "\033[0;32m"; YELLOW = "\033[1;33m"
7
+ BLUE = "\033[0;34m"; CYAN = "\033[0;36m"; BOLD = "\033[1m"
8
+ DIM = "\033[2m"; RESET = "\033[0m"
9
+
10
+
11
+ def _p(msg: str) -> None:
12
+ sys.stdout.write(msg + "\n")
13
+ sys.stdout.flush()
14
+
15
+
16
+ def error(msg: str) -> None: _p(f"{RED}✗{RESET} {msg}")
17
+ def info(msg: str) -> None: _p(f"{BLUE}→{RESET} {msg}")
18
+ def success(msg: str) -> None: _p(f"{GREEN}✓{RESET} {msg}")
19
+ def warn(msg: str) -> None: _p(f"{YELLOW}⚠{RESET} {msg}")
20
+
21
+
22
+ def interactive() -> bool:
23
+ return sys.stdin.isatty() and sys.stdout.isatty()
24
+
25
+
26
+ def confirm(question: str, default_yes: bool = True) -> bool:
27
+ if not interactive():
28
+ return default_yes
29
+ suffix = "[Y/n]" if default_yes else "[y/N]"
30
+ try:
31
+ ans = input(f"{BOLD}? {question} {suffix}{RESET} ").strip().lower()
32
+ except EOFError:
33
+ return default_yes
34
+ if not ans:
35
+ return default_yes
36
+ return ans[0] != "n" if default_yes else ans[0] == "y"
37
+
38
+
39
+ def ask(question: str, default: str = "") -> str:
40
+ if not interactive(): # non-TTY: don't block on input(), take the default
41
+ return default
42
+ label = f"{BOLD}? {question}" + (f" [{default}]" if default else "") + f":{RESET} "
43
+ try:
44
+ ans = input(label).strip()
45
+ except EOFError:
46
+ ans = ""
47
+ return ans or default
48
+
49
+
50
+ def read_secret(prompt: str) -> str:
51
+ """Masked input (echoes * per char) on a TTY; falls back to getpass."""
52
+ import getpass
53
+ if not interactive():
54
+ return ""
55
+ try:
56
+ sys.stderr.write(f"{BOLD}? {prompt}:{RESET} ")
57
+ sys.stderr.flush()
58
+ # getpass reads without echo; we accept that over fragile raw-tty masking.
59
+ return getpass.getpass("")
60
+ except Exception:
61
+ return ""