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.
- silicon_cli/__init__.py +2 -0
- silicon_cli/cli.py +250 -0
- silicon_cli/config.py +29 -0
- silicon_cli/glassagent.py +66 -0
- silicon_cli/process.py +258 -0
- silicon_cli/registry.py +145 -0
- silicon_cli/stemcell.py +226 -0
- silicon_cli/sync.py +216 -0
- silicon_cli/ui.py +61 -0
- silicon_cli/update.py +80 -0
- silicon_cli-1.0.0.dist-info/METADATA +74 -0
- silicon_cli-1.0.0.dist-info/RECORD +16 -0
- silicon_cli-1.0.0.dist-info/WHEEL +5 -0
- silicon_cli-1.0.0.dist-info/entry_points.txt +2 -0
- silicon_cli-1.0.0.dist-info/licenses/LICENSE +21 -0
- silicon_cli-1.0.0.dist-info/top_level.txt +1 -0
silicon_cli/registry.py
ADDED
|
@@ -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()
|
silicon_cli/stemcell.py
ADDED
|
@@ -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 ""
|