@foxden-app/foxclaw 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (126) hide show
  1. package/.env.example +36 -0
  2. package/LICENSE +22 -0
  3. package/README.md +244 -0
  4. package/README_EN.md +244 -0
  5. package/dist/channels/bridge_messaging_router.d.ts +27 -0
  6. package/dist/channels/bridge_messaging_router.js +85 -0
  7. package/dist/channels/telegram/telegram_channel_adapter.d.ts +12 -0
  8. package/dist/channels/telegram/telegram_channel_adapter.js +21 -0
  9. package/dist/channels/telegram/telegram_messaging_port.d.ts +25 -0
  10. package/dist/channels/telegram/telegram_messaging_port.js +51 -0
  11. package/dist/channels/weixin/account_store.d.ts +15 -0
  12. package/dist/channels/weixin/account_store.js +54 -0
  13. package/dist/channels/weixin/ilink/aes_ecb.d.ts +3 -0
  14. package/dist/channels/weixin/ilink/aes_ecb.js +12 -0
  15. package/dist/channels/weixin/ilink/api.d.ts +44 -0
  16. package/dist/channels/weixin/ilink/api.js +187 -0
  17. package/dist/channels/weixin/ilink/cdn_upload.d.ts +11 -0
  18. package/dist/channels/weixin/ilink/cdn_upload.js +60 -0
  19. package/dist/channels/weixin/ilink/cdn_url.d.ts +7 -0
  20. package/dist/channels/weixin/ilink/cdn_url.js +7 -0
  21. package/dist/channels/weixin/ilink/constants.d.ts +7 -0
  22. package/dist/channels/weixin/ilink/constants.js +27 -0
  23. package/dist/channels/weixin/ilink/context.d.ts +13 -0
  24. package/dist/channels/weixin/ilink/context.js +13 -0
  25. package/dist/channels/weixin/ilink/login_qr.d.ts +34 -0
  26. package/dist/channels/weixin/ilink/login_qr.js +233 -0
  27. package/dist/channels/weixin/ilink/media_image.d.ts +11 -0
  28. package/dist/channels/weixin/ilink/media_image.js +44 -0
  29. package/dist/channels/weixin/ilink/mime.d.ts +3 -0
  30. package/dist/channels/weixin/ilink/mime.js +36 -0
  31. package/dist/channels/weixin/ilink/pic_decrypt.d.ts +2 -0
  32. package/dist/channels/weixin/ilink/pic_decrypt.js +56 -0
  33. package/dist/channels/weixin/ilink/random.d.ts +2 -0
  34. package/dist/channels/weixin/ilink/random.js +7 -0
  35. package/dist/channels/weixin/ilink/redact.d.ts +4 -0
  36. package/dist/channels/weixin/ilink/redact.js +34 -0
  37. package/dist/channels/weixin/ilink/runtime_attach.d.ts +3 -0
  38. package/dist/channels/weixin/ilink/runtime_attach.js +13 -0
  39. package/dist/channels/weixin/ilink/send.d.ts +21 -0
  40. package/dist/channels/weixin/ilink/send.js +108 -0
  41. package/dist/channels/weixin/ilink/session_guard.d.ts +6 -0
  42. package/dist/channels/weixin/ilink/session_guard.js +39 -0
  43. package/dist/channels/weixin/ilink/types.d.ts +155 -0
  44. package/dist/channels/weixin/ilink/types.js +10 -0
  45. package/dist/channels/weixin/ilink/upload.d.ts +15 -0
  46. package/dist/channels/weixin/ilink/upload.js +75 -0
  47. package/dist/channels/weixin/sync_buf_store.d.ts +3 -0
  48. package/dist/channels/weixin/sync_buf_store.js +19 -0
  49. package/dist/channels/weixin/weixin_channel_adapter.d.ts +18 -0
  50. package/dist/channels/weixin/weixin_channel_adapter.js +273 -0
  51. package/dist/channels/weixin/weixin_messaging_port.d.ts +29 -0
  52. package/dist/channels/weixin/weixin_messaging_port.js +113 -0
  53. package/dist/codex_app/client.d.ts +176 -0
  54. package/dist/codex_app/client.js +1230 -0
  55. package/dist/codex_app/deeplink.d.ts +7 -0
  56. package/dist/codex_app/deeplink.js +29 -0
  57. package/dist/codex_app/local_usage.d.ts +16 -0
  58. package/dist/codex_app/local_usage.js +123 -0
  59. package/dist/config.d.ts +44 -0
  60. package/dist/config.js +131 -0
  61. package/dist/controller/access.d.ts +11 -0
  62. package/dist/controller/access.js +33 -0
  63. package/dist/controller/activity.d.ts +62 -0
  64. package/dist/controller/activity.js +330 -0
  65. package/dist/controller/commands.d.ts +6 -0
  66. package/dist/controller/commands.js +17 -0
  67. package/dist/controller/controller.d.ts +326 -0
  68. package/dist/controller/controller.js +7503 -0
  69. package/dist/controller/observer.d.ts +16 -0
  70. package/dist/controller/observer.js +98 -0
  71. package/dist/controller/presentation.d.ts +80 -0
  72. package/dist/controller/presentation.js +568 -0
  73. package/dist/controller/service_tier.d.ts +9 -0
  74. package/dist/controller/service_tier.js +32 -0
  75. package/dist/controller/session_observer.d.ts +22 -0
  76. package/dist/controller/session_observer.js +259 -0
  77. package/dist/controller/status.d.ts +10 -0
  78. package/dist/controller/status.js +28 -0
  79. package/dist/core/bridge_scope.d.ts +18 -0
  80. package/dist/core/bridge_scope.js +46 -0
  81. package/dist/core/channel_port.d.ts +15 -0
  82. package/dist/core/channel_port.js +1 -0
  83. package/dist/i18n.d.ts +1108 -0
  84. package/dist/i18n.js +1154 -0
  85. package/dist/lock.d.ts +7 -0
  86. package/dist/lock.js +80 -0
  87. package/dist/logger.d.ts +12 -0
  88. package/dist/logger.js +57 -0
  89. package/dist/main.d.ts +2 -0
  90. package/dist/main.js +236 -0
  91. package/dist/runtime.d.ts +3 -0
  92. package/dist/runtime.js +14 -0
  93. package/dist/store/database.d.ts +79 -0
  94. package/dist/store/database.js +489 -0
  95. package/dist/store/migrate_bridge_scope.d.ts +6 -0
  96. package/dist/store/migrate_bridge_scope.js +59 -0
  97. package/dist/telegram/addressing.d.ts +33 -0
  98. package/dist/telegram/addressing.js +57 -0
  99. package/dist/telegram/api.d.ts +14 -0
  100. package/dist/telegram/api.js +89 -0
  101. package/dist/telegram/gateway.d.ts +76 -0
  102. package/dist/telegram/gateway.js +383 -0
  103. package/dist/telegram/media.d.ts +34 -0
  104. package/dist/telegram/media.js +180 -0
  105. package/dist/telegram/rendering.d.ts +10 -0
  106. package/dist/telegram/rendering.js +21 -0
  107. package/dist/telegram/scope.d.ts +6 -0
  108. package/dist/telegram/scope.js +24 -0
  109. package/dist/telegram/text.d.ts +7 -0
  110. package/dist/telegram/text.js +47 -0
  111. package/dist/types.d.ts +343 -0
  112. package/dist/types.js +1 -0
  113. package/docs/agent-assisted-install.md +84 -0
  114. package/docs/install-for-beginners.md +287 -0
  115. package/docs/troubleshooting.md +239 -0
  116. package/package.json +62 -0
  117. package/scripts/doctor.sh +3 -0
  118. package/scripts/launchd/install.sh +54 -0
  119. package/scripts/status.sh +3 -0
  120. package/scripts/systemd/install.sh +83 -0
  121. package/scripts/systemd/uninstall.sh +15 -0
  122. package/skills/foxclaw/SKILL.md +167 -0
  123. package/skills/foxclaw/agents/openai.yaml +4 -0
  124. package/skills/foxclaw/references/telegram-setup.md +93 -0
  125. package/skills/foxclaw/scripts/bootstrap_host.py +350 -0
  126. package/skills/foxclaw/scripts/bootstrap_remote.py +67 -0
@@ -0,0 +1,350 @@
1
+ #!/usr/bin/env python3
2
+ import argparse
3
+ import base64
4
+ import json
5
+ import os
6
+ import platform
7
+ import shutil
8
+ import subprocess
9
+ import sys
10
+ import tarfile
11
+ import tempfile
12
+ import urllib.request
13
+
14
+
15
+ REPO_URL_DEFAULT = "https://github.com/foxden-app/foxclaw.git"
16
+ REPO_REF_DEFAULT = "main"
17
+ NODE_INDEX_URL = "https://nodejs.org/dist/index.json"
18
+ NODE_MAJOR_DEFAULT = 24
19
+
20
+
21
+ def parse_args() -> argparse.Namespace:
22
+ parser = argparse.ArgumentParser(description="Bootstrap FoxClaw on macOS.")
23
+ parser.add_argument("--config-b64")
24
+ parser.add_argument("--repo-url")
25
+ parser.add_argument("--repo-ref")
26
+ parser.add_argument("--install-dir")
27
+ parser.add_argument("--default-cwd")
28
+ parser.add_argument("--tg-bot-token")
29
+ parser.add_argument("--tg-allowed-user-id")
30
+ parser.add_argument("--tg-allowed-chat-id")
31
+ parser.add_argument("--tg-allowed-topic-id")
32
+ parser.add_argument("--code-approval-policy")
33
+ parser.add_argument("--default-sandbox-mode")
34
+ parser.add_argument("--node-major", type=int)
35
+ parser.add_argument("--no-start", action="store_const", const=True, default=None)
36
+ return parser.parse_args()
37
+
38
+
39
+ def merged_config(args: argparse.Namespace) -> dict:
40
+ defaults = {
41
+ "repo_url": REPO_URL_DEFAULT,
42
+ "repo_ref": REPO_REF_DEFAULT,
43
+ "install_dir": os.path.expanduser("~/foxclaw"),
44
+ "default_cwd": os.path.expanduser("~/foxclaw"),
45
+ "tg_bot_token": None,
46
+ "tg_allowed_user_id": None,
47
+ "tg_allowed_chat_id": None,
48
+ "tg_allowed_topic_id": None,
49
+ "code_approval_policy": "on-request",
50
+ "default_sandbox_mode": "workspace-write",
51
+ "node_major": NODE_MAJOR_DEFAULT,
52
+ "no_start": False,
53
+ }
54
+ if args.config_b64:
55
+ payload = json.loads(base64.b64decode(args.config_b64).decode("utf-8"))
56
+ defaults.update(payload)
57
+ cli = {
58
+ "repo_url": args.repo_url,
59
+ "repo_ref": args.repo_ref,
60
+ "install_dir": args.install_dir,
61
+ "default_cwd": args.default_cwd,
62
+ "tg_bot_token": args.tg_bot_token,
63
+ "tg_allowed_user_id": args.tg_allowed_user_id,
64
+ "tg_allowed_chat_id": args.tg_allowed_chat_id,
65
+ "tg_allowed_topic_id": args.tg_allowed_topic_id,
66
+ "code_approval_policy": args.code_approval_policy,
67
+ "default_sandbox_mode": args.default_sandbox_mode,
68
+ "node_major": args.node_major,
69
+ "no_start": args.no_start,
70
+ }
71
+ for key, value in cli.items():
72
+ if value is not None:
73
+ defaults[key] = value
74
+ defaults["install_dir"] = os.path.abspath(os.path.expanduser(defaults["install_dir"]))
75
+ defaults["default_cwd"] = os.path.abspath(os.path.expanduser(defaults["default_cwd"]))
76
+ defaults["node_major"] = int(defaults["node_major"])
77
+ defaults["no_start"] = bool(defaults["no_start"])
78
+ return defaults
79
+
80
+
81
+ def require(value: str, name: str) -> str:
82
+ if value is None or str(value).strip() == "":
83
+ raise SystemExit(f"{name} is required")
84
+ return str(value).strip()
85
+
86
+
87
+ def log(message: str) -> None:
88
+ print(f"[foxclaw] {message}", flush=True)
89
+
90
+
91
+ def run(cmd, env=None, cwd=None, check=True, capture_output=False) -> subprocess.CompletedProcess:
92
+ kwargs = {
93
+ "env": env,
94
+ "cwd": cwd,
95
+ "check": check,
96
+ "text": True,
97
+ }
98
+ if capture_output:
99
+ kwargs["stdout"] = subprocess.PIPE
100
+ kwargs["stderr"] = subprocess.PIPE
101
+ return subprocess.run(cmd, **kwargs)
102
+
103
+
104
+ def command_output(cmd, env=None, cwd=None) -> str:
105
+ result = run(cmd, env=env, cwd=cwd, capture_output=True)
106
+ return result.stdout.rstrip()
107
+
108
+
109
+ def detect_arch() -> tuple:
110
+ machine = platform.machine().lower()
111
+ if machine in ("arm64", "aarch64"):
112
+ return "osx-arm64-tar", "darwin-arm64"
113
+ if machine in ("x86_64", "amd64"):
114
+ return "osx-x64-tar", "darwin-x64"
115
+ raise SystemExit(f"Unsupported macOS architecture: {machine}")
116
+
117
+
118
+ def ensure_local_node(node_major: int, tools_root: str) -> str:
119
+ file_key, archive_suffix = detect_arch()
120
+ os.makedirs(tools_root, exist_ok=True)
121
+ with urllib.request.urlopen(NODE_INDEX_URL) as response:
122
+ releases = json.load(response)
123
+ selected = None
124
+ for release in releases:
125
+ version = str(release.get("version", ""))
126
+ if not version.startswith(f"v{node_major}."):
127
+ continue
128
+ files = set(release.get("files", []))
129
+ if file_key in files:
130
+ selected = version
131
+ break
132
+ if selected is None:
133
+ raise SystemExit(f"Unable to find a Node.js v{node_major} macOS build")
134
+
135
+ install_root = os.path.join(tools_root, f"node-{selected}-{archive_suffix}")
136
+ node_bin = os.path.join(install_root, "bin", "node")
137
+ if os.path.exists(node_bin):
138
+ return install_root
139
+
140
+ url = f"https://nodejs.org/dist/{selected}/node-{selected}-{archive_suffix}.tar.gz"
141
+ log(f"Installing Node.js {selected} into {install_root}")
142
+ with urllib.request.urlopen(url) as response:
143
+ data = response.read()
144
+ parent_dir = os.path.dirname(install_root)
145
+ os.makedirs(parent_dir, exist_ok=True)
146
+ with tempfile.NamedTemporaryFile(delete=False) as archive_file:
147
+ archive_file.write(data)
148
+ archive_path = archive_file.name
149
+ try:
150
+ with tarfile.open(archive_path, "r:gz") as tar:
151
+ tar.extractall(parent_dir)
152
+ finally:
153
+ os.unlink(archive_path)
154
+ if not os.path.exists(node_bin):
155
+ raise SystemExit(f"Node install failed: {node_bin} not found")
156
+ return install_root
157
+
158
+
159
+ def parse_major(version_text: str) -> int:
160
+ version = version_text.strip().lstrip("v")
161
+ return int(version.split(".", 1)[0])
162
+
163
+
164
+ def ensure_node(config: dict, tools_root: str) -> tuple:
165
+ system_node = shutil.which("node")
166
+ if system_node:
167
+ try:
168
+ version = command_output([system_node, "-v"])
169
+ if parse_major(version) >= config["node_major"]:
170
+ return system_node, os.path.dirname(system_node)
171
+ except Exception:
172
+ pass
173
+
174
+ install_root = ensure_local_node(config["node_major"], os.path.join(tools_root, "node"))
175
+ node_bin_dir = os.path.join(install_root, "bin")
176
+ return os.path.join(node_bin_dir, "node"), node_bin_dir
177
+
178
+
179
+ def ensure_codex(env: dict, tools_root: str) -> str:
180
+ existing = shutil.which("codex", path=env.get("PATH"))
181
+ if existing:
182
+ return existing
183
+
184
+ npm_global = os.path.join(tools_root, "npm-global")
185
+ os.makedirs(npm_global, exist_ok=True)
186
+ install_env = env.copy()
187
+ install_env["NPM_CONFIG_PREFIX"] = npm_global
188
+ install_env["PATH"] = os.path.join(npm_global, "bin") + os.pathsep + install_env["PATH"]
189
+ log("Installing Codex CLI with npm")
190
+ run(["npm", "install", "-g", "@openai/codex"], env=install_env)
191
+ codex_bin = os.path.join(npm_global, "bin", "codex")
192
+ if not os.path.exists(codex_bin):
193
+ raise SystemExit(f"Codex CLI install failed: {codex_bin} not found")
194
+ env["PATH"] = os.path.join(npm_global, "bin") + os.pathsep + env["PATH"]
195
+ return codex_bin
196
+
197
+
198
+ def ensure_codex_wrapper(codex_bin: str, node_bin_dir: str, tools_root: str) -> str:
199
+ wrapper_dir = os.path.join(tools_root, "bin")
200
+ os.makedirs(wrapper_dir, exist_ok=True)
201
+ wrapper_path = os.path.join(wrapper_dir, "codex-wrapper")
202
+ wrapper = "\n".join([
203
+ "#!/bin/sh",
204
+ f'export PATH="{node_bin_dir}:{os.path.dirname(codex_bin)}:$PATH"',
205
+ f'exec "{codex_bin}" "$@"',
206
+ "",
207
+ ])
208
+ with open(wrapper_path, "w", encoding="utf-8") as handle:
209
+ handle.write(wrapper)
210
+ os.chmod(wrapper_path, 0o755)
211
+ return wrapper_path
212
+
213
+
214
+ def ensure_repo(config: dict) -> None:
215
+ install_dir = config["install_dir"]
216
+ parent_dir = os.path.dirname(install_dir)
217
+ os.makedirs(parent_dir, exist_ok=True)
218
+ git_dir = os.path.join(install_dir, ".git")
219
+ if not os.path.isdir(git_dir):
220
+ log(f"Cloning bridge repo into {install_dir}")
221
+ run([
222
+ "git",
223
+ "clone",
224
+ "--depth",
225
+ "1",
226
+ "--branch",
227
+ config["repo_ref"],
228
+ config["repo_url"],
229
+ install_dir,
230
+ ])
231
+ return
232
+
233
+ status = command_output(["git", "-C", install_dir, "status", "--porcelain"])
234
+ if status:
235
+ allowed = {".env", "dist/", "dist", "package-lock.json"}
236
+ lines = [line for line in status.splitlines() if line.strip()]
237
+ paths = [line[3:] for line in lines if len(line) >= 4]
238
+ if any(path not in allowed for path in paths):
239
+ raise SystemExit(f"Refusing to update dirty repo: {install_dir}")
240
+ if any(path == "package-lock.json" for path in paths):
241
+ run(["git", "-C", install_dir, "checkout", "--", "package-lock.json"])
242
+ shutil.rmtree(os.path.join(install_dir, "dist"), ignore_errors=True)
243
+ env_path = os.path.join(install_dir, ".env")
244
+ if os.path.exists(env_path):
245
+ os.remove(env_path)
246
+ log(f"Updating bridge repo in {install_dir}")
247
+ run(["git", "-C", install_dir, "remote", "set-url", "origin", config["repo_url"]])
248
+ run(["git", "-C", install_dir, "fetch", "origin", config["repo_ref"], "--depth", "1"])
249
+ run(["git", "-C", install_dir, "checkout", "-B", "foxclaw-deploy", "FETCH_HEAD"])
250
+
251
+
252
+ def write_env_file(config: dict, codex_bin: str) -> str:
253
+ env_path = os.path.join(config["install_dir"], ".env")
254
+ lines = [
255
+ f"TG_BOT_TOKEN={config['tg_bot_token']}",
256
+ f"TG_ALLOWED_USER_ID={config['tg_allowed_user_id']}",
257
+ f"TG_ALLOWED_CHAT_ID={config['tg_allowed_chat_id'] or ''}",
258
+ f"TG_ALLOWED_TOPIC_ID={config['tg_allowed_topic_id'] or ''}",
259
+ "CODEX_APP_AUTOLAUNCH=true",
260
+ f"CODEX_APP_LAUNCH_CMD={codex_bin} app",
261
+ "CODEX_APP_SYNC_ON_OPEN=true",
262
+ "CODEX_APP_SYNC_ON_TURN_COMPLETE=false",
263
+ "STORE_PATH=",
264
+ "LOG_LEVEL=info",
265
+ f"DEFAULT_CWD={config['default_cwd']}",
266
+ f"DEFAULT_APPROVAL_POLICY={config['code_approval_policy']}",
267
+ f"DEFAULT_SANDBOX_MODE={config['default_sandbox_mode']}",
268
+ "TELEGRAM_POLL_INTERVAL_MS=1200",
269
+ "TELEGRAM_PREVIEW_THROTTLE_MS=800",
270
+ "THREAD_LIST_LIMIT=10",
271
+ f"CODEX_CLI_BIN={codex_bin}",
272
+ ]
273
+ with open(env_path, "w", encoding="utf-8") as handle:
274
+ handle.write("\n".join(lines) + "\n")
275
+ os.chmod(env_path, 0o600)
276
+ return env_path
277
+
278
+
279
+ def check_codex_login(codex_bin: str, env: dict) -> str:
280
+ try:
281
+ result = run([codex_bin, "login", "status"], env=env, capture_output=True)
282
+ output = (result.stdout or "").strip()
283
+ return output or "Codex login status returned no output"
284
+ except subprocess.CalledProcessError as error:
285
+ combined = "\n".join(part for part in [error.stdout, error.stderr] if part)
286
+ return combined.strip() or "Codex login status failed"
287
+
288
+
289
+ def install_bridge(config: dict, env: dict) -> None:
290
+ install_dir = config["install_dir"]
291
+ log("Installing npm dependencies")
292
+ run(["npm", "ci"], env=env, cwd=install_dir)
293
+ log("Building bridge")
294
+ run(["npm", "run", "build"], env=env, cwd=install_dir)
295
+ log("Running doctor checks")
296
+ run(["npm", "run", "doctor"], env=env, cwd=install_dir)
297
+
298
+
299
+ def maybe_install_launchd(config: dict, env: dict) -> bool:
300
+ if config["no_start"]:
301
+ log("Skipping launchd install because --no-start was set")
302
+ return False
303
+ log("Installing launchd service")
304
+ run(["bash", "scripts/launchd/install.sh"], env=env, cwd=config["install_dir"])
305
+ return True
306
+
307
+
308
+ def main() -> None:
309
+ if platform.system() != "Darwin":
310
+ raise SystemExit("This bootstrap currently supports macOS only")
311
+
312
+ args = parse_args()
313
+ config = merged_config(args)
314
+ config["tg_bot_token"] = require(config["tg_bot_token"], "TG_BOT_TOKEN")
315
+ config["tg_allowed_user_id"] = require(config["tg_allowed_user_id"], "TG_ALLOWED_USER_ID")
316
+ os.makedirs(config["default_cwd"], exist_ok=True)
317
+
318
+ tools_root = os.path.expanduser("~/.local/foxclaw")
319
+ node_bin, node_bin_dir = ensure_node(config, tools_root)
320
+ env = os.environ.copy()
321
+ env["PATH"] = node_bin_dir + os.pathsep + env.get("PATH", "")
322
+ npm_global_bin = os.path.join(tools_root, "npm-global", "bin")
323
+ if os.path.isdir(npm_global_bin):
324
+ env["PATH"] = npm_global_bin + os.pathsep + env["PATH"]
325
+
326
+ installed_codex_bin = ensure_codex(env, tools_root)
327
+ codex_dir = os.path.dirname(installed_codex_bin)
328
+ env["PATH"] = codex_dir + os.pathsep + env["PATH"]
329
+ codex_bin = ensure_codex_wrapper(installed_codex_bin, node_bin_dir, tools_root)
330
+
331
+ ensure_repo(config)
332
+ env_path = write_env_file(config, codex_bin)
333
+ install_bridge(config, env)
334
+ started = maybe_install_launchd(config, env)
335
+ login_status = check_codex_login(codex_bin, env)
336
+
337
+ summary = {
338
+ "installDir": config["install_dir"],
339
+ "envPath": env_path,
340
+ "defaultCwd": config["default_cwd"],
341
+ "nodeBin": node_bin,
342
+ "codexBin": codex_bin,
343
+ "started": started,
344
+ "loginStatus": login_status,
345
+ }
346
+ print(json.dumps(summary, indent=2))
347
+
348
+
349
+ if __name__ == "__main__":
350
+ main()
@@ -0,0 +1,67 @@
1
+ #!/usr/bin/env python3
2
+ import argparse
3
+ import base64
4
+ import json
5
+ import os
6
+ import subprocess
7
+ import sys
8
+
9
+
10
+ def parse_args() -> argparse.Namespace:
11
+ parser = argparse.ArgumentParser(description="Run FoxClaw bootstrap on a remote Mac over SSH.")
12
+ parser.add_argument("--ssh-host", required=True)
13
+ parser.add_argument("--repo-url", default="https://github.com/foxden-app/foxclaw.git")
14
+ parser.add_argument("--repo-ref", default="main")
15
+ parser.add_argument("--install-dir", default="~/foxclaw")
16
+ parser.add_argument("--default-cwd", required=True)
17
+ parser.add_argument("--tg-bot-token", required=True)
18
+ parser.add_argument("--tg-allowed-user-id", required=True)
19
+ parser.add_argument("--tg-allowed-chat-id")
20
+ parser.add_argument("--tg-allowed-topic-id")
21
+ parser.add_argument("--code-approval-policy", default="on-request")
22
+ parser.add_argument("--default-sandbox-mode", default="workspace-write")
23
+ parser.add_argument("--node-major", type=int, default=24)
24
+ parser.add_argument("--no-start", action="store_true")
25
+ return parser.parse_args()
26
+
27
+
28
+ def main() -> None:
29
+ args = parse_args()
30
+ script_path = os.path.join(os.path.dirname(__file__), "bootstrap_host.py")
31
+ with open(script_path, "rb") as handle:
32
+ script_bytes = handle.read()
33
+
34
+ payload = {
35
+ "repo_url": args.repo_url,
36
+ "repo_ref": args.repo_ref,
37
+ "install_dir": args.install_dir,
38
+ "default_cwd": args.default_cwd,
39
+ "tg_bot_token": args.tg_bot_token,
40
+ "tg_allowed_user_id": args.tg_allowed_user_id,
41
+ "tg_allowed_chat_id": args.tg_allowed_chat_id,
42
+ "tg_allowed_topic_id": args.tg_allowed_topic_id,
43
+ "code_approval_policy": args.code_approval_policy,
44
+ "default_sandbox_mode": args.default_sandbox_mode,
45
+ "node_major": args.node_major,
46
+ "no_start": args.no_start,
47
+ }
48
+ payload_b64 = base64.b64encode(json.dumps(payload).encode("utf-8")).decode("ascii")
49
+
50
+ command = [
51
+ "ssh",
52
+ "-o",
53
+ "BatchMode=yes",
54
+ "-o",
55
+ "StrictHostKeyChecking=no",
56
+ args.ssh_host,
57
+ "python3",
58
+ "-",
59
+ "--config-b64",
60
+ payload_b64,
61
+ ]
62
+ result = subprocess.run(command, input=script_bytes)
63
+ sys.exit(result.returncode)
64
+
65
+
66
+ if __name__ == "__main__":
67
+ main()