windows-development-sandbox 1.0.5 → 1.1.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 (368) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +22 -6
  3. package/config.example.json +17 -0
  4. package/dist/api.js +2 -1
  5. package/dist/cli/args.d.ts +20 -0
  6. package/dist/cli/args.js +35 -3
  7. package/dist/cli/colors.d.ts +24 -0
  8. package/dist/cli/colors.js +42 -0
  9. package/dist/cli/commands/agent-commands.d.ts +6 -0
  10. package/dist/cli/commands/agent-commands.js +61 -0
  11. package/dist/cli/commands/context.d.ts +2 -0
  12. package/dist/cli/commands/context.js +3 -0
  13. package/dist/cli/commands/data/clean.d.ts +15 -0
  14. package/dist/cli/commands/data/clean.js +101 -0
  15. package/dist/cli/commands/data/collect.d.ts +11 -0
  16. package/dist/cli/commands/data/collect.js +54 -0
  17. package/dist/cli/commands/data/index.d.ts +4 -0
  18. package/dist/cli/commands/data/index.js +45 -0
  19. package/dist/cli/commands/data/remove.d.ts +2 -0
  20. package/dist/cli/commands/data/remove.js +30 -0
  21. package/dist/cli/commands/index.d.ts +5 -3
  22. package/dist/cli/commands/index.js +5 -3
  23. package/dist/cli/commands/launch.d.ts +26 -2
  24. package/dist/cli/commands/launch.js +220 -39
  25. package/dist/cli/commands/maintenance.js +33 -4
  26. package/dist/cli/commands/project.d.ts +29 -0
  27. package/dist/cli/commands/project.js +63 -0
  28. package/dist/cli/commands/projects.d.ts +14 -0
  29. package/dist/cli/commands/projects.js +146 -0
  30. package/dist/cli/commands/repair/checks.d.ts +2 -0
  31. package/dist/cli/commands/{repair.js → repair/checks.js} +19 -181
  32. package/dist/cli/commands/{repair.d.ts → repair/finders.d.ts} +0 -29
  33. package/dist/cli/commands/repair/finders.js +108 -0
  34. package/dist/cli/commands/repair/index.d.ts +12 -0
  35. package/dist/cli/commands/repair/index.js +61 -0
  36. package/dist/cli/commands/repair/types.d.ts +26 -0
  37. package/dist/cli/commands/repair/types.js +3 -0
  38. package/dist/cli/commands/restart.d.ts +6 -0
  39. package/dist/cli/commands/restart.js +85 -0
  40. package/dist/cli/commands/sessions.d.ts +2 -1
  41. package/dist/cli/commands/sessions.js +189 -45
  42. package/dist/cli/commands/setup/checks.d.ts +13 -0
  43. package/dist/cli/commands/setup/checks.js +71 -0
  44. package/dist/cli/commands/{setup.d.ts → setup/facts.d.ts} +1 -15
  45. package/dist/cli/commands/setup/facts.js +60 -0
  46. package/dist/cli/commands/setup/index.d.ts +2 -0
  47. package/dist/cli/commands/setup/index.js +43 -0
  48. package/dist/cli/commands/user-config.d.ts +8 -0
  49. package/dist/cli/commands/user-config.js +41 -0
  50. package/dist/cli/history.d.ts +11 -0
  51. package/dist/cli/history.js +21 -0
  52. package/dist/cli/index.d.ts +7 -1
  53. package/dist/cli/index.js +38 -13
  54. package/dist/cli/manifest.d.ts +8 -0
  55. package/dist/cli/manifest.js +35 -15
  56. package/dist/cli/menu-multi.d.ts +12 -0
  57. package/dist/cli/menu-multi.js +82 -0
  58. package/dist/cli/menu.d.ts +5 -13
  59. package/dist/cli/menu.js +149 -46
  60. package/dist/cli/progress.d.ts +9 -0
  61. package/dist/cli/progress.js +49 -0
  62. package/dist/cli/settings/edit.d.ts +13 -0
  63. package/dist/cli/settings/edit.js +19 -0
  64. package/dist/cli/settings/index.d.ts +8 -0
  65. package/dist/cli/settings/index.js +131 -0
  66. package/dist/cli/settings/prompts.d.ts +7 -0
  67. package/dist/cli/settings/prompts.js +48 -0
  68. package/dist/cli/settings/schema.d.ts +33 -0
  69. package/dist/cli/settings/schema.js +29 -0
  70. package/dist/cli/validate.d.ts +17 -0
  71. package/dist/cli/validate.js +89 -0
  72. package/dist/core/config.js +4 -1
  73. package/dist/core/disk.d.ts +16 -0
  74. package/dist/core/disk.js +55 -0
  75. package/dist/core/local-config.d.ts +8 -0
  76. package/dist/core/local-config.js +18 -5
  77. package/dist/core/project-policy.d.ts +15 -0
  78. package/dist/core/project-policy.js +35 -0
  79. package/dist/core/prompt.d.ts +4 -0
  80. package/dist/core/prompt.js +15 -0
  81. package/dist/core/safe-paths.d.ts +4 -0
  82. package/dist/core/safe-paths.js +37 -0
  83. package/dist/core/schema/common.d.ts +17 -0
  84. package/dist/core/schema/common.js +15 -0
  85. package/dist/core/schema/config.d.ts +594 -0
  86. package/dist/core/schema/config.js +63 -0
  87. package/dist/core/schema/databases.d.ts +33 -0
  88. package/dist/core/schema/databases.js +35 -0
  89. package/dist/core/schema/harness.d.ts +12 -0
  90. package/dist/core/schema/harness.js +17 -0
  91. package/dist/core/schema/json-schema.d.ts +5 -0
  92. package/dist/core/schema/json-schema.js +25 -0
  93. package/dist/core/schema/profile.d.ts +27 -0
  94. package/dist/core/schema/profile.js +29 -0
  95. package/dist/core/schema/projects.d.ts +9 -0
  96. package/dist/core/schema/projects.js +11 -0
  97. package/dist/core/schema/sandbox.d.ts +50 -0
  98. package/dist/core/schema/sandbox.js +36 -0
  99. package/dist/core/schema/toolchains.d.ts +93 -0
  100. package/dist/core/schema/toolchains.js +83 -0
  101. package/dist/core/schema.d.ts +6 -604
  102. package/dist/core/schema.js +8 -264
  103. package/dist/core/types.d.ts +29 -1
  104. package/dist/core/util.d.ts +7 -0
  105. package/dist/core/util.js +12 -3
  106. package/dist/core/workspace.d.ts +0 -5
  107. package/dist/core/workspace.js +12 -46
  108. package/dist/guest/boot-env.d.ts +4 -0
  109. package/dist/guest/boot-env.js +25 -0
  110. package/dist/guest/cmdline.d.ts +12 -0
  111. package/dist/guest/cmdline.js +23 -0
  112. package/dist/guest/daemon/exec.d.ts +5 -0
  113. package/dist/guest/daemon/exec.js +32 -0
  114. package/dist/guest/daemon/project.d.ts +14 -0
  115. package/dist/guest/daemon/project.js +132 -0
  116. package/dist/guest/daemon/session.d.ts +13 -0
  117. package/dist/guest/daemon/session.js +139 -0
  118. package/dist/guest/daemon/shutdown.d.ts +1 -0
  119. package/dist/guest/daemon/shutdown.js +21 -0
  120. package/dist/guest/daemon/state.d.ts +17 -0
  121. package/dist/guest/daemon/state.js +31 -0
  122. package/dist/guest/daemon.js +8 -141
  123. package/dist/guest/db.js +18 -3
  124. package/dist/guest/env.d.ts +2 -0
  125. package/dist/guest/env.js +4 -2
  126. package/dist/guest/guide.d.ts +6 -2
  127. package/dist/guest/guide.js +5 -4
  128. package/dist/guest/helpers.d.ts +3 -0
  129. package/dist/guest/helpers.js +26 -0
  130. package/dist/guest/main.js +17 -7
  131. package/dist/guest/ports.d.ts +6 -0
  132. package/dist/guest/ports.js +22 -0
  133. package/dist/guest/project-context.d.ts +7 -0
  134. package/dist/guest/project-context.js +10 -0
  135. package/dist/guest/project-env.d.ts +20 -0
  136. package/dist/guest/project-env.js +20 -0
  137. package/dist/guest/project-status.d.ts +5 -0
  138. package/dist/guest/project-status.js +17 -0
  139. package/dist/guest/ptyhost.d.ts +0 -18
  140. package/dist/guest/ptyhost.js +5 -63
  141. package/dist/guest/setup/agents.d.ts +5 -2
  142. package/dist/guest/setup/agents.js +22 -8
  143. package/dist/guest/setup/claude.js +11 -3
  144. package/dist/guest/setup/environment.d.ts +13 -3
  145. package/dist/guest/setup/environment.js +14 -10
  146. package/dist/guest/setup/hooks.d.ts +9 -1
  147. package/dist/guest/setup/hooks.js +28 -14
  148. package/dist/guest/toolchains/catalogue.d.ts +4 -0
  149. package/dist/guest/toolchains/catalogue.js +2 -2
  150. package/dist/guest/toolchains/cli.js +25 -17
  151. package/dist/guest/toolchains/common.d.ts +18 -1
  152. package/dist/guest/toolchains/common.js +34 -11
  153. package/dist/guest/toolchains/databases/dynamodb.d.ts +8 -0
  154. package/dist/guest/toolchains/databases/dynamodb.js +52 -0
  155. package/dist/guest/toolchains/{databases.d.ts → databases/index.d.ts} +2 -3
  156. package/dist/guest/toolchains/databases/index.js +62 -0
  157. package/dist/guest/toolchains/databases/mongodb.d.ts +3 -0
  158. package/dist/guest/toolchains/databases/mongodb.js +31 -0
  159. package/dist/guest/toolchains/databases/mysql.d.ts +3 -0
  160. package/dist/guest/toolchains/databases/mysql.js +38 -0
  161. package/dist/guest/toolchains/databases/postgres.d.ts +5 -0
  162. package/dist/guest/toolchains/databases/postgres.js +38 -0
  163. package/dist/guest/toolchains/databases/redis.d.ts +3 -0
  164. package/dist/guest/toolchains/databases/redis.js +16 -0
  165. package/dist/guest/toolchains/databases/server.d.ts +14 -0
  166. package/dist/guest/toolchains/databases/server.js +52 -0
  167. package/dist/guest/toolchains/git.js +25 -16
  168. package/dist/guest/toolchains/go.js +1 -1
  169. package/dist/guest/toolchains/index.d.ts +13 -9
  170. package/dist/guest/toolchains/index.js +40 -82
  171. package/dist/guest/toolchains/java.js +5 -5
  172. package/dist/guest/toolchains/leftovers.d.ts +2 -0
  173. package/dist/guest/toolchains/leftovers.js +26 -0
  174. package/dist/guest/toolchains/managers.js +12 -10
  175. package/dist/guest/toolchains/{binstall.js → native/binstall.js} +6 -9
  176. package/dist/guest/toolchains/native/cpp.d.ts +4 -0
  177. package/dist/guest/toolchains/native/cpp.js +38 -0
  178. package/dist/guest/toolchains/native/dotnet.d.ts +4 -0
  179. package/dist/guest/toolchains/native/dotnet.js +103 -0
  180. package/dist/guest/toolchains/native/mesa.d.ts +7 -0
  181. package/dist/guest/toolchains/native/mesa.js +46 -0
  182. package/dist/guest/toolchains/native/msvc/environment.d.ts +16 -0
  183. package/dist/guest/toolchains/native/msvc/environment.js +57 -0
  184. package/dist/guest/toolchains/native/msvc/index.d.ts +6 -0
  185. package/dist/guest/toolchains/native/msvc/index.js +48 -0
  186. package/dist/guest/toolchains/native/msvc/packages.d.ts +6 -0
  187. package/dist/guest/toolchains/native/msvc/packages.js +156 -0
  188. package/dist/guest/toolchains/native/msvc/tree.d.ts +15 -0
  189. package/dist/guest/toolchains/native/msvc/tree.js +21 -0
  190. package/dist/guest/toolchains/native/rust.d.ts +3 -0
  191. package/dist/guest/toolchains/native/rust.js +45 -0
  192. package/dist/guest/toolchains/node.js +26 -6
  193. package/dist/guest/toolchains/plan.d.ts +22 -0
  194. package/dist/guest/toolchains/plan.js +96 -0
  195. package/dist/guest/toolchains/pwsh.js +1 -1
  196. package/dist/guest/toolchains/python.js +17 -10
  197. package/dist/guest/toolchains/runtimes.d.ts +2 -0
  198. package/dist/guest/toolchains/runtimes.js +6 -8
  199. package/dist/guest/toolchains/scripting/php.d.ts +3 -0
  200. package/dist/guest/toolchains/scripting/php.js +71 -0
  201. package/dist/guest/toolchains/scripting/ruby.d.ts +3 -0
  202. package/dist/guest/toolchains/scripting/ruby.js +47 -0
  203. package/dist/guest/toolchains/scripting/vcruntime.d.ts +7 -0
  204. package/dist/guest/toolchains/scripting/vcruntime.js +45 -0
  205. package/dist/guest/view.js +21 -1
  206. package/dist/host/accounts.js +14 -5
  207. package/dist/host/agent-commands.d.ts +25 -0
  208. package/dist/host/agent-commands.js +145 -0
  209. package/dist/host/attach.d.ts +17 -0
  210. package/dist/host/attach.js +69 -0
  211. package/dist/host/channel.d.ts +23 -22
  212. package/dist/host/channel.js +66 -84
  213. package/dist/host/claude-profile.js +7 -3
  214. package/dist/host/cleanup.js +2 -11
  215. package/dist/host/database-plan.d.ts +12 -0
  216. package/dist/host/database-plan.js +52 -0
  217. package/dist/host/generate.d.ts +4 -12
  218. package/dist/host/generate.js +48 -135
  219. package/dist/host/join.d.ts +28 -0
  220. package/dist/host/join.js +76 -0
  221. package/dist/host/launch/mappings.d.ts +3 -0
  222. package/dist/host/launch/mappings.js +50 -0
  223. package/dist/host/launch/projects.d.ts +15 -0
  224. package/dist/host/launch/projects.js +56 -0
  225. package/dist/host/launch/stage.d.ts +11 -0
  226. package/dist/host/launch/stage.js +66 -0
  227. package/dist/host/launch/wsb.d.ts +10 -0
  228. package/dist/host/launch/wsb.js +34 -0
  229. package/dist/host/pc-scan.d.ts +6 -0
  230. package/dist/host/pc-scan.js +37 -0
  231. package/dist/host/platform.d.ts +2 -0
  232. package/dist/host/platform.js +13 -1
  233. package/dist/host/project-scan.d.ts +1 -17
  234. package/dist/host/project-scan.js +2 -86
  235. package/dist/host/projects.d.ts +100 -0
  236. package/dist/host/projects.js +147 -0
  237. package/dist/host/repo-guard.d.ts +15 -2
  238. package/dist/host/repo-guard.js +103 -12
  239. package/dist/host/terminal.js +3 -0
  240. package/dist/host/test-guard.d.ts +6 -0
  241. package/dist/host/test-guard.js +15 -0
  242. package/dist/host/window.js +7 -3
  243. package/dist/host/wsb.d.ts +37 -0
  244. package/dist/host/wsb.js +101 -0
  245. package/dist/runtime/daemon.js +10997 -2794
  246. package/dist/runtime/db.js +6428 -464
  247. package/dist/runtime/main.js +7359 -1135
  248. package/dist/runtime/view.js +5922 -47
  249. package/dist/selftest/accounts.test.d.ts +2 -0
  250. package/dist/selftest/accounts.test.js +44 -0
  251. package/dist/selftest/agent-commands.test.d.ts +2 -0
  252. package/dist/selftest/agent-commands.test.js +47 -0
  253. package/dist/selftest/channel.test.d.ts +2 -0
  254. package/dist/selftest/channel.test.js +37 -0
  255. package/dist/selftest/cli.test.d.ts +2 -2
  256. package/dist/selftest/cli.test.js +115 -27
  257. package/dist/selftest/cmdline.test.d.ts +2 -0
  258. package/dist/selftest/cmdline.test.js +45 -0
  259. package/dist/selftest/config.test.d.ts +2 -3
  260. package/dist/selftest/config.test.js +36 -72
  261. package/dist/selftest/data.test.d.ts +2 -2
  262. package/dist/selftest/data.test.js +77 -70
  263. package/dist/selftest/detect.test.d.ts +2 -2
  264. package/dist/selftest/detect.test.js +54 -38
  265. package/dist/selftest/fixtures.d.ts +44 -4
  266. package/dist/selftest/fixtures.js +40 -23
  267. package/dist/selftest/generate.test.d.ts +2 -0
  268. package/dist/selftest/generate.test.js +63 -0
  269. package/dist/selftest/git-access.test.d.ts +2 -0
  270. package/dist/selftest/git-access.test.js +26 -0
  271. package/dist/selftest/guest-boot.test.d.ts +2 -0
  272. package/dist/selftest/guest-boot.test.js +76 -0
  273. package/dist/selftest/harness.d.ts +44 -7
  274. package/dist/selftest/harness.js +62 -21
  275. package/dist/selftest/host-sim.test.d.ts +2 -0
  276. package/dist/selftest/host-sim.test.js +93 -0
  277. package/dist/selftest/hostile-input.test.d.ts +2 -0
  278. package/dist/selftest/hostile-input.test.js +54 -0
  279. package/dist/selftest/index.d.ts +8 -1
  280. package/dist/selftest/index.js +134 -35
  281. package/dist/selftest/links.test.d.ts +2 -0
  282. package/dist/selftest/links.test.js +50 -0
  283. package/dist/selftest/live-daemon.test.d.ts +2 -0
  284. package/dist/selftest/live-daemon.test.js +91 -0
  285. package/dist/selftest/live-git.test.d.ts +2 -0
  286. package/dist/selftest/live-git.test.js +60 -0
  287. package/dist/selftest/live-projects.test.d.ts +2 -0
  288. package/dist/selftest/live-projects.test.js +127 -0
  289. package/dist/selftest/live.d.ts +65 -0
  290. package/dist/selftest/live.js +109 -0
  291. package/dist/selftest/local-config.test.d.ts +2 -0
  292. package/dist/selftest/local-config.test.js +107 -0
  293. package/dist/selftest/mirror.test.d.ts +2 -3
  294. package/dist/selftest/mirror.test.js +92 -65
  295. package/dist/selftest/network.test.d.ts +2 -0
  296. package/dist/selftest/network.test.js +28 -0
  297. package/dist/selftest/personalization.test.d.ts +2 -0
  298. package/dist/selftest/personalization.test.js +53 -0
  299. package/dist/selftest/projects.test.d.ts +2 -0
  300. package/dist/selftest/projects.test.js +254 -0
  301. package/dist/selftest/repair.test.d.ts +2 -0
  302. package/dist/selftest/repair.test.js +42 -0
  303. package/dist/selftest/repo-guard.test.d.ts +2 -0
  304. package/dist/selftest/repo-guard.test.js +63 -0
  305. package/dist/selftest/secrets.test.d.ts +2 -0
  306. package/dist/selftest/secrets.test.js +71 -0
  307. package/dist/selftest/setup.test.d.ts +2 -2
  308. package/dist/selftest/setup.test.js +45 -22
  309. package/dist/selftest/suite.d.ts +13 -0
  310. package/dist/selftest/suite.js +1 -0
  311. package/dist/selftest/sync.test.d.ts +2 -3
  312. package/dist/selftest/sync.test.js +90 -78
  313. package/dist/selftest/test-guard.test.d.ts +2 -0
  314. package/dist/selftest/test-guard.test.js +49 -0
  315. package/dist/selftest/toolchains.test.d.ts +2 -0
  316. package/dist/selftest/toolchains.test.js +103 -0
  317. package/dist/selftest/workspace.test.d.ts +2 -0
  318. package/dist/selftest/workspace.test.js +53 -0
  319. package/dist/shared/download.d.ts +3 -1
  320. package/dist/shared/download.js +19 -4
  321. package/dist/shared/paths.d.ts +5 -0
  322. package/dist/shared/paths.js +6 -0
  323. package/dist/shared/projects.d.ts +53 -0
  324. package/dist/shared/projects.js +67 -0
  325. package/dist/shared/rundir.d.ts +10 -1
  326. package/dist/shared/rundir.js +12 -4
  327. package/dist/shared/safe-remove.js +11 -0
  328. package/dist/shared/schema.d.ts +443 -0
  329. package/dist/shared/schema.js +41 -2
  330. package/dist/shared/test-guard.d.ts +4 -0
  331. package/dist/shared/test-guard.js +37 -0
  332. package/dist/sync/claude-mem.js +3 -1
  333. package/dist/sync/history.d.ts +23 -12
  334. package/dist/sync/history.js +52 -20
  335. package/dist/sync/watcher.d.ts +8 -0
  336. package/dist/sync/watcher.js +23 -4
  337. package/package.json +6 -6
  338. package/sandbox/bootstrap.ps1 +258 -258
  339. package/sandbox/cpucap.ps1 +51 -0
  340. package/sandbox/logon.cmd +18 -16
  341. package/sandbox/openssl.cs +42 -0
  342. package/sandbox/ptyhost.cs +15 -4
  343. package/sandbox/trust.cmd +35 -35
  344. package/schemas/config.schema.json +100 -1
  345. package/schemas/sandbox-config.schema.json +17 -1
  346. package/dist/cli/commands/data.d.ts +0 -46
  347. package/dist/cli/commands/data.js +0 -249
  348. package/dist/cli/commands/setup.js +0 -152
  349. package/dist/cli/settings.d.ts +0 -11
  350. package/dist/cli/settings.js +0 -156
  351. package/dist/guest/toolchains/databases.js +0 -226
  352. package/dist/guest/toolchains/msvc.d.ts +0 -10
  353. package/dist/guest/toolchains/msvc.js +0 -216
  354. package/dist/guest/toolchains/native.d.ts +0 -12
  355. package/dist/guest/toolchains/native.js +0 -220
  356. package/dist/guest/toolchains/scripting.d.ts +0 -10
  357. package/dist/guest/toolchains/scripting.js +0 -137
  358. package/dist/selftest/guest.test.d.ts +0 -3
  359. package/dist/selftest/guest.test.js +0 -61
  360. package/dist/selftest/helpers.test.d.ts +0 -2
  361. package/dist/selftest/helpers.test.js +0 -125
  362. package/dist/selftest/host.test.d.ts +0 -2
  363. package/dist/selftest/host.test.js +0 -71
  364. package/dist/selftest/live.test.d.ts +0 -7
  365. package/dist/selftest/live.test.js +0 -127
  366. package/dist/selftest/security.test.d.ts +0 -3
  367. package/dist/selftest/security.test.js +0 -143
  368. /package/dist/guest/toolchains/{binstall.d.ts → native/binstall.d.ts} +0 -0
package/dist/cli/menu.js CHANGED
@@ -4,31 +4,46 @@
4
4
  import fs from 'node:fs';
5
5
  import path from 'node:path';
6
6
  import { checkbox, confirm, input, select, Separator } from '@inquirer/prompts';
7
- import { z } from 'zod';
8
- import { userDir } from '../core/config.js';
9
- import { tryReadJson, writeJson } from '../core/util.js';
7
+ import { humanBytes } from '../core/disk.js';
8
+ import { errMsg, fail } from '../core/util.js';
10
9
  import { listAccounts } from '../host/accounts.js';
11
10
  import { runningSessions } from '../host/channel.js';
12
11
  import { isSandboxRunning } from '../host/platform.js';
13
- import { settingsMenu } from './settings.js';
14
- import { COMMANDS, Group } from './manifest.js';
15
- const c = { bold: (s) => '\x1b[1m' + s + '\x1b[0m', dim: (s) => '\x1b[2m' + s + '\x1b[0m', cyan: (s) => '\x1b[36m' + s + '\x1b[0m', green: (s) => '\x1b[32m' + s + '\x1b[0m', yellow: (s) => '\x1b[33m' + s + '\x1b[0m' };
16
- // ---- history ---------------------------------------------------------------------------------------------------------
17
- const History = z.array(z.strictObject({ argv: z.array(z.string()).max(200), at: z.string() })).max(20);
18
- const historyFile = () => path.join(userDir(), 'history.json');
19
- const HIDDEN = new Set(['menu', '_watch', 'self-test']);
20
- export const readHistory = () => History.safeParse(tryReadJson(historyFile())).data ?? [];
21
- /** Remembers a command (menu or typed) so the menu can offer it again. Never fails the command itself. */
22
- export const recordHistory = (argv) => {
23
- if (!argv.length || HIDDEN.has(argv[0]) || argv.includes('--help'))
24
- return;
12
+ import { cleanTargetOf, collectDataEntries } from './commands/data/collect.js';
13
+ import { c, clearScreen, pageSizeFor, shorten, shortenMiddle, termWidth } from './colors.js';
14
+ import { commandLine, readHistory } from './history.js';
15
+ import { settingsMenu } from './settings/index.js';
16
+ import { splitArgs } from './args.js';
17
+ import { syncAgentCommands } from './commands/agent-commands.js';
18
+ import { loadConfig } from '../core/config.js';
19
+ import { askedWhen, COMMANDS, Group } from './manifest.js';
20
+ import { askSeveralAgents } from './menu-multi.js';
21
+ import { validNodeSpec, validPythonSpec } from './validate.js';
22
+ // ---- the screen ------------------------------------------------------------------------------------------------------
23
+ /** The settings screen may have switched agentCommands (Beta): apply it now, from the saved file. */
24
+ const applyAgentCommands = (ctx) => {
25
25
  try {
26
- writeJson(historyFile(), [{ argv, at: new Date().toISOString() }, ...readHistory().filter((h) => h.argv.join('\0') !== argv.join('\0'))].slice(0, 10));
26
+ syncAgentCommands(ctx, loadConfig(ctx.loaded.configPath ?? undefined).config);
27
+ }
28
+ catch (e) {
29
+ console.warn('Agent commands (Beta): ' + errMsg(e));
27
30
  }
28
- catch { /* history is a convenience */ }
29
31
  };
30
- /** argv as you would type it (quoted where needed). */
31
- export const commandLine = (argv) => ['sandbox', ...argv].map((a) => (/[\s"]/.test(a) || a === '' ? '"' + a.replace(/"/g, '\\"') + '"' : a)).join(' ');
32
+ /** Waits for one key, so the output of a command stays on screen until you have read it. Ctrl+C leaves. */
33
+ const pressAnyKey = (message) => new Promise((resolve) => {
34
+ process.stdout.write('\n' + c.dim(message));
35
+ const { stdin } = process;
36
+ stdin.setRawMode(true);
37
+ stdin.resume();
38
+ stdin.once('data', (key) => {
39
+ stdin.setRawMode(false);
40
+ stdin.pause();
41
+ process.stdout.write('\n');
42
+ if (key[0] === 3)
43
+ process.exit(130);
44
+ resolve();
45
+ });
46
+ });
32
47
  // ---- asking for one command ------------------------------------------------------------------------------------------
33
48
  /** Values for a choices list the manifest names (filled in from config and the running sandbox). */
34
49
  const choicesFor = (ctx, choices) => {
@@ -43,27 +58,77 @@ const choicesFor = (ctx, choices) => {
43
58
  return [];
44
59
  };
45
60
  /** Splits "a b 'c d'" like a shell would (enough for agent arguments and exec commands). */
46
- export const splitArgs = (text) => [...text.matchAll(/"([^"]*)"|'([^']*)'|(\S+)/g)].map((m) => m[1] ?? m[2] ?? m[3]);
47
- const askArg = async (ctx, arg) => {
61
+ export { splitArgs };
62
+ /** What this option accepts, checked while typing (undefined = anything). */
63
+ export const optionProblem = (o, v) => {
64
+ if (v === '')
65
+ return undefined;
66
+ if (o.type === 'number' && !(Number(v) > 0))
67
+ return 'Enter a number greater than 0.';
68
+ if (o.flag === '--node' && !validNodeSpec(v))
69
+ return 'Use a number like 22 or 22.11.0, or lts, latest.';
70
+ if (o.flag === '--python' && !validPythonSpec(v))
71
+ return 'Use 3, 3.12 or 3.12.4.';
72
+ if (o.value === 'dir' && !fs.existsSync(path.resolve(v)))
73
+ return 'That folder does not exist.';
74
+ if (o.value === 'file') {
75
+ const missing = splitArgs(v).find((f) => !fs.existsSync(path.resolve(f)));
76
+ if (missing)
77
+ return missing + ' does not exist.';
78
+ }
79
+ return undefined;
80
+ };
81
+ /** Targets of `data clean` as a checkbox list with sizes (never "all", never --yes: those are typed on purpose). */
82
+ const askDataTargets = async (ctx) => {
83
+ console.log(c.dim('Measuring the saved data (big folders take a moment)...'));
84
+ const rows = collectDataEntries(ctx.dataPath, ctx.storageRoot).flatMap((e) => { const target = cleanTargetOf(e); return target ? [{ target, bytes: e.bytes }] : []; });
85
+ if (!rows.length) {
86
+ console.log('Nothing is saved yet, so there is nothing to delete.');
87
+ return [];
88
+ }
89
+ const width = Math.max(...rows.map((r) => r.target.length));
90
+ return checkbox({
91
+ message: 'Delete which? ' + c.dim('(space to tick, enter to continue; you confirm once more)'),
92
+ pageSize: pageSizeFor(16),
93
+ choices: rows.map((r) => ({ name: r.target.padEnd(width) + ' ' + humanBytes(r.bytes).padStart(8), value: r.target, short: r.target })),
94
+ validate: (picked) => (picked.length > 0 ? true : 'Tick at least one with space, or press Ctrl+C to go back.'),
95
+ });
96
+ };
97
+ const askArg = async (ctx, spec, arg) => {
98
+ if (spec.name === 'data' && arg.name === 'targets')
99
+ return askDataTargets(ctx);
48
100
  const list = choicesFor(ctx, arg.choices);
49
101
  if (list.length && !arg.variadic) {
50
102
  const picked = await select({ message: arg.description, choices: [...list.map((v) => ({ name: v, value: v })), ...(arg.required ? [] : [{ name: c.dim('(skip)'), value: '' }])] });
51
103
  return picked ? [picked] : [];
52
104
  }
53
- const answer = await input({ message: arg.description + (arg.required ? '' : c.dim(' (optional)')), validate: (v) => (!arg.required || v.trim() !== '' ? true : 'required') });
105
+ // A list the manifest fills in at run time (saved accounts, running sessions) that is empty: nothing to pick, so do not ask.
106
+ if (typeof arg.choices === 'string' && !arg.required)
107
+ return [];
108
+ if (typeof arg.choices === 'string')
109
+ fail('There is nothing to choose from: no ' + arg.choices + ' yet.', arg.choices === 'accounts' ? 'Sign in inside the sandbox, then run: sandbox account save <name>' : 'Start one first (sandbox claude).');
110
+ const answer = await input({ message: arg.description + (arg.required ? '' : c.dim(' (optional)')), validate: (v) => (!arg.required || v.trim() !== '' ? true : 'This is required.') });
54
111
  return arg.variadic ? splitArgs(answer) : answer.trim() ? [answer.trim()] : [];
55
112
  };
56
113
  const askOptions = async (ctx, options) => {
57
114
  const argv = [];
58
115
  const flags = options.filter((o) => o.type === 'boolean');
59
116
  if (flags.length)
60
- argv.push(...await checkbox({ message: 'Options ' + c.dim('(space to toggle, enter to continue)'), choices: flags.map((o) => ({ name: o.flag + c.dim(' ' + o.description), value: o.flag })) }));
117
+ argv.push(...await checkbox({ message: 'Options ' + c.dim('(space to toggle, enter to continue)'), choices: flags.map((o) => ({ name: o.flag + c.dim(' ' + shorten(o.description, Math.max(20, termWidth() - o.flag.length - 8))), value: o.flag })) }));
61
118
  for (const o of options.filter((x) => x.type !== 'boolean')) {
62
119
  const list = choicesFor(ctx, o.choices);
63
120
  const label = o.flag + c.dim(' ' + o.description);
121
+ // A named list that is empty (no saved accounts) has nothing to pick: leave the option out.
122
+ if (typeof o.choices === 'string' && !list.length)
123
+ continue;
124
+ if (o.type === 'list' && list.length) {
125
+ for (const v of await checkbox({ message: label, choices: list.map((v) => ({ name: v, value: v })) }))
126
+ argv.push(o.flag, v);
127
+ continue;
128
+ }
64
129
  const value = list.length
65
- ? await select({ message: label, choices: [{ name: c.dim('(default)'), value: '' }, ...list.map((v) => ({ name: v, value: v }))] })
66
- : await input({ message: label + c.dim(o.default !== undefined ? ' (default ' + o.default + ')' : ' (empty = default)'), validate: (v) => (o.type !== 'number' || v === '' || Number(v) > 0 ? true : 'a positive number') });
130
+ ? await select({ message: label, choices: [{ name: c.dim('(default' + (o.choices === 'harnesses' ? ': ' + ctx.loaded.config.defaultHarness : o.choices === 'accounts' ? ': none chosen' : '') + ')'), value: '' }, ...list.map((v) => ({ name: v, value: v }))] })
131
+ : await input({ message: label + c.dim(o.default !== undefined ? ' (default ' + o.default + ')' : ' (empty = default)'), validate: (v) => optionProblem(o, v) ?? true });
67
132
  if (!value)
68
133
  continue;
69
134
  for (const v of o.type === 'list' ? splitArgs(value) : [value])
@@ -73,16 +138,34 @@ const askOptions = async (ctx, options) => {
73
138
  };
74
139
  /** Asks everything a command takes and returns its argv (or null when the user backs out). */
75
140
  export const askCommand = async (ctx, spec, preset = []) => {
76
- console.log('\n' + c.bold(spec.name) + c.dim(' ' + spec.summary));
141
+ console.log('\n' + c.bold(spec.name) + c.dim(' ' + shorten(spec.summary, termWidth() - spec.name.length - 3)));
77
142
  const argv = [spec.name, ...preset];
78
- for (const arg of spec.args)
79
- argv.push(...await askArg(ctx, arg));
80
- const basic = spec.options.filter((o) => !o.advanced && !preset.includes(o.flag));
81
- const advanced = spec.options.filter((o) => o.advanced);
143
+ // The common case needs one answer: start with the settings you already have, or change them first.
144
+ if (spec.name === 'launch' && preset.length) {
145
+ const quick = await select({ message: 'Start ' + (preset[preset.indexOf('--harness') + 1] ?? 'the agent') + ' in ' + shortenMiddle(process.cwd(), Math.max(20, termWidth() - 30)), choices: [{ name: 'Start now', value: 'now' }, { name: 'Change options first...', value: 'custom' }, { name: c.dim('< back'), value: 'back' }] });
146
+ if (quick === 'back')
147
+ return null;
148
+ if (quick === 'now') {
149
+ console.log(c.dim(' ' + commandLine(argv)));
150
+ return argv;
151
+ }
152
+ }
153
+ let first = '';
154
+ for (const [i, arg] of spec.args.entries()) {
155
+ if (!askedWhen(arg.when, first))
156
+ continue;
157
+ const answer = await askArg(ctx, spec, arg);
158
+ if (i === 0)
159
+ first = answer[0] ?? '';
160
+ argv.push(...answer);
161
+ }
162
+ const offered = (o) => !o.noMenu && askedWhen(o.when, first);
163
+ const basic = spec.options.filter((o) => !o.advanced && !preset.includes(o.flag) && offered(o));
164
+ const advanced = spec.options.filter((o) => o.advanced && offered(o));
82
165
  argv.push(...await askOptions(ctx, basic));
83
166
  if (advanced.length && await confirm({ message: 'Change advanced options (' + advanced.map((o) => o.flag).join(', ') + ')?', default: false }))
84
167
  argv.push(...await askOptions(ctx, advanced));
85
- if (spec.json && await confirm({ message: 'Machine-readable output (--json)?', default: false }))
168
+ if (spec.json && !(spec.name === 'data' && first === 'clean') && await confirm({ message: 'Machine-readable output (--json)?', default: false }))
86
169
  argv.push('--json');
87
170
  if (spec.passthrough) {
88
171
  const extra = splitArgs(await input({ message: spec.passthrough === 'agent' ? 'Extra arguments for the agent ' + c.dim('(optional, e.g. --continue)') : 'Arguments for the command' + c.dim(' (optional)') }));
@@ -99,12 +182,13 @@ const agentInstalled = (ctx, name) => {
99
182
  const dir = path.join(ctx.dataPath, 'tools', 'agents', name);
100
183
  return Boolean(h) && [h.hostExecutable, h.npmBin && h.npmBin + '.cmd', h.uvBin && h.uvBin + '.exe'].some((f) => f && fs.existsSync(path.join(dir, f)));
101
184
  };
102
- const GROUP_TITLES = { Agents: 'Start an agent', Sandbox: 'Sandbox', Sessions: 'Sessions (attach, restart)', Accounts: 'Claude accounts', Data: 'Persisted data, sync and cleanup', Health: 'Diagnostics and repair (doctor, setup, repair, dry run, logs)', Config: 'Configuration' };
185
+ const GROUP_TITLES = { Agents: 'Start an agent', Sandbox: 'Sandbox', Sessions: 'Sessions', Accounts: 'Claude accounts', Data: 'Saved data and cleanup', Health: 'Diagnostics and repair', Config: 'Configuration' };
103
186
  const pickAgent = async (ctx) => {
104
187
  const names = Object.keys(ctx.loaded.config.harnesses);
188
+ clearScreen();
105
189
  const name = await select({
106
190
  message: 'Which agent?',
107
- pageSize: 16,
191
+ pageSize: pageSizeFor(16),
108
192
  choices: [
109
193
  ...names.map((n) => ({ name: n.padEnd(10) + (agentInstalled(ctx, n) ? c.green(' installed') : c.dim(' installs on first start')) + (n === ctx.loaded.config.defaultHarness ? c.dim(' (default)') : ''), value: n })),
110
194
  new Separator(),
@@ -119,13 +203,15 @@ const pickAgent = async (ctx) => {
119
203
  const mainMenu = async (ctx) => {
120
204
  const last = readHistory()[0];
121
205
  const running = isSandboxRunning();
122
- console.log('\n' + c.bold(c.cyan('Windows Development Sandbox')) + c.dim(' ' + process.cwd()) + '\n' + (running ? c.green(' A sandbox is running') + c.dim(' (' + runningSessions(ctx.dataPath).length + ' session(s))') : c.dim(' No sandbox running')));
206
+ clearScreen();
207
+ console.log(c.bold(c.cyan('Windows Development Sandbox')) + c.dim(' ' + shortenMiddle(process.cwd(), Math.max(20, termWidth() - 32))) + '\n' + (running ? c.green(' A sandbox is running') + c.dim(' (' + runningSessions(ctx.dataPath).length + ' session(s))') : c.dim(' No sandbox running')) + '\n');
123
208
  return select({
124
209
  message: 'What would you like to do?',
125
- pageSize: 14,
210
+ pageSize: pageSizeFor(14),
126
211
  choices: [
127
- ...(last ? [{ name: 'Repeat last: ' + c.cyan(commandLine(last.argv)), value: { kind: 'run', argv: last.argv } }] : []),
212
+ ...(last ? [{ name: 'Repeat last: ' + c.cyan(shorten(commandLine(last.argv), Math.max(20, termWidth() - 20))), value: { kind: 'run', argv: last.argv } }] : []),
128
213
  { name: 'Start ' + ctx.loaded.config.defaultHarness + ' in this folder', value: { kind: 'ask', spec: COMMANDS.find((s) => s.name === 'launch'), preset: ['--harness', ctx.loaded.config.defaultHarness] } },
214
+ { name: 'Start several agents (one or more projects, attach to any)...', value: { kind: 'several' } },
129
215
  { name: 'Start another agent...', value: { kind: 'group', group: '@agents' } },
130
216
  { name: 'Start a sandbox without an agent', value: { kind: 'ask', spec: COMMANDS.find((s) => s.name === 'start') } },
131
217
  new Separator(),
@@ -136,14 +222,17 @@ const mainMenu = async (ctx) => {
136
222
  ],
137
223
  });
138
224
  };
139
- const groupMenu = async (group) => select({
140
- message: GROUP_TITLES[group] ?? group,
141
- pageSize: 16,
142
- choices: [
143
- ...COMMANDS.filter((s) => s.group === group && !s.hidden).map((s) => ({ name: s.name.padEnd(10) + c.dim(' ' + s.summary), value: { kind: 'ask', spec: s } })),
144
- { name: c.dim('< back'), value: { kind: 'group', group: '' } },
145
- ],
146
- });
225
+ const groupMenu = async (group) => {
226
+ clearScreen();
227
+ return select({
228
+ message: GROUP_TITLES[group] ?? group,
229
+ pageSize: pageSizeFor(16),
230
+ choices: [
231
+ ...COMMANDS.filter((s) => s.group === group && !s.hidden).map((s) => ({ name: s.name.padEnd(10), description: s.summary, value: { kind: 'ask', spec: s } })),
232
+ { name: c.dim('< back'), value: { kind: 'group', group: '' } },
233
+ ],
234
+ });
235
+ };
147
236
  /**
148
237
  * Runs the menu until Exit, or until a command that takes over the terminal (launch, attach) has run. `run` executes
149
238
  * an argv exactly like typing it.
@@ -159,20 +248,34 @@ export const menu = async (ctx, run) => {
159
248
  return 0;
160
249
  if (pick.kind === 'group' && pick.group === '@settings') {
161
250
  await settingsMenu(ctx.loaded.configPath);
251
+ applyAgentCommands(ctx);
162
252
  pick = await mainMenu(ctx);
163
253
  }
164
254
  else if (pick.kind === 'group')
165
255
  pick = pick.group === '' ? await mainMenu(ctx) : pick.group === '@agents' ? await pickAgent(ctx) : await groupMenu(pick.group);
166
256
  else {
167
- const argv = pick.kind === 'run' ? pick.argv : await askCommand(ctx, pick.spec, pick.preset);
257
+ // Ctrl+C while answering the questions of one command goes back to the menu; on the menu itself it leaves.
258
+ let argv;
259
+ try {
260
+ clearScreen();
261
+ argv = pick.kind === 'run' ? pick.argv : pick.kind === 'several' ? await askSeveralAgents(ctx) : await askCommand(ctx, pick.spec, pick.preset);
262
+ }
263
+ catch (e) {
264
+ if (e instanceof Error && e.name === 'ExitPromptError')
265
+ argv = null;
266
+ else
267
+ throw e;
268
+ }
168
269
  if (!argv) {
169
270
  pick = await mainMenu(ctx);
170
271
  continue;
171
272
  }
273
+ clearScreen();
274
+ console.log(c.dim('$ ' + commandLine(argv)) + '\n');
172
275
  const code = await run(argv);
173
276
  if (COMMANDS.find((s) => s.name === argv[0])?.interactive)
174
277
  return code;
175
- await input({ message: c.dim((code === 0 ? 'Done' : yellowCode(code)) + '. Press Enter for the menu') });
278
+ await pressAnyKey((code === 0 ? c.green('Done.') : yellowCode(code)) + ' Press any key for the menu...');
176
279
  pick = await mainMenu(ctx);
177
280
  }
178
281
  }
@@ -0,0 +1,9 @@
1
+ /** Can a line be redrawn in place? */
2
+ export declare const canAnimate: (env: NodeJS.ProcessEnv, isTTY: boolean) => boolean;
3
+ /** "12 s", "2 min 5 s". */
4
+ export declare const elapsed: (ms: number) => string;
5
+ /**
6
+ * Shows `message` while work runs and removes the line afterwards. The spinner only moves while the event loop is free
7
+ * (waiting on the sandbox); for long synchronous work (measuring folders) the static line is what you see.
8
+ */
9
+ export declare const working: <T>(message: string | (() => string), work: () => Promise<T> | T, enabled?: boolean) => Promise<T>;
@@ -0,0 +1,49 @@
1
+ // Feedback for slow work: a spinner line on stderr (so --json and piped output stay clean). Without a terminal (pipe, CI,
2
+ // TERM=dumb) it prints one plain line instead of animating.
3
+ import { colorEnabled, shorten, termWidth } from './colors.js';
4
+ const FRAMES = ['|', '/', '-', '\\'];
5
+ /** Can a line be redrawn in place? */
6
+ export const canAnimate = (env, isTTY) => isTTY && !env.CI && env.TERM !== 'dumb';
7
+ /** "12 s", "2 min 5 s". */
8
+ export const elapsed = (ms) => {
9
+ const s = Math.round(ms / 1000);
10
+ return s < 60 ? s + ' s' : Math.floor(s / 60) + ' min ' + (s % 60) + ' s';
11
+ };
12
+ /**
13
+ * Shows `message` while work runs and removes the line afterwards. The spinner only moves while the event loop is free
14
+ * (waiting on the sandbox); for long synchronous work (measuring folders) the static line is what you see.
15
+ */
16
+ export const working = async (message, work, enabled = true) => {
17
+ if (!enabled)
18
+ return work();
19
+ const err = process.stderr;
20
+ const start = Date.now();
21
+ // A function: the message follows the work (e.g. the sandbox's boot step); without a terminal each new one is a line.
22
+ const text = () => (typeof message === 'function' ? message() : message);
23
+ if (!canAnimate(process.env, Boolean(err.isTTY))) {
24
+ let last = text();
25
+ err.write(last + '...\n');
26
+ const follow = typeof message === 'function' ? setInterval(() => { const now = text(); if (now !== last) {
27
+ last = now;
28
+ err.write(now + '\n');
29
+ } }, 500) : null;
30
+ try {
31
+ return await work();
32
+ }
33
+ finally {
34
+ if (follow)
35
+ clearInterval(follow);
36
+ }
37
+ }
38
+ let frame = 0;
39
+ const draw = () => { err.write('\r\x1b[2K' + (colorEnabled(process.env, true) ? '\x1b[36m' : '') + FRAMES[frame++ % FRAMES.length] + (colorEnabled(process.env, true) ? '\x1b[0m' : '') + ' ' + shorten(text(), termWidth() - 14) + (Date.now() - start > 3000 ? ' (' + elapsed(Date.now() - start) + ')' : '')); };
40
+ draw();
41
+ const timer = setInterval(draw, 120);
42
+ try {
43
+ return await work();
44
+ }
45
+ finally {
46
+ clearInterval(timer);
47
+ err.write('\r\x1b[2K');
48
+ }
49
+ };
@@ -0,0 +1,13 @@
1
+ /** The user's own config, or a project's .sandbox-config.json (a smaller schema; nothing is merged with defaults). */
2
+ export type SettingsTarget = 'user' | 'project';
3
+ /** What the value is in the file (undefined when it is not set there). */
4
+ export declare const fileValue: (text: string, key: string[]) => unknown;
5
+ /**
6
+ * Applies one change (undefined = remove the line, back to the default) to the config text and returns the new text, or
7
+ * the problems that make the result invalid.
8
+ */
9
+ export declare const editConfigText: (text: string, key: string[], value: unknown, target?: SettingsTarget) => {
10
+ text: string;
11
+ } | {
12
+ problems: string[];
13
+ };
@@ -0,0 +1,19 @@
1
+ // Changing a config file's text: one setting at a time, through jsonc-parser so comments and formatting stay, and never
2
+ // into something invalid. Pure; the caller writes the file.
3
+ import { applyEdits, modify, parse } from 'jsonc-parser';
4
+ import _ from 'lodash';
5
+ import { DEFAULTS, mergeConfig } from '../../core/config.js';
6
+ import { Config, describeIssues, LocalOverrides } from '../../core/schema.js';
7
+ /** What the value is in the file (undefined when it is not set there). */
8
+ export const fileValue = (text, key) => _.get(parse(text) ?? {}, key);
9
+ /**
10
+ * Applies one change (undefined = remove the line, back to the default) to the config text and returns the new text, or
11
+ * the problems that make the result invalid.
12
+ */
13
+ export const editConfigText = (text, key, value, target = 'user') => {
14
+ // Removing a setting the file does not have changes nothing (jsonc-parser throws on it).
15
+ const edits = value === undefined && fileValue(text || '{}', key) === undefined ? [] : modify(text || '{}', key, value, { formattingOptions: { insertSpaces: true, tabSize: 2, eol: '\n' } });
16
+ const next = applyEdits(text || '{}', edits);
17
+ const parsed = target === 'project' ? LocalOverrides.safeParse(parse(next) ?? {}) : Config.safeParse(mergeConfig(DEFAULTS, parse(next) ?? {}));
18
+ return parsed.success ? { text: next.endsWith('\n') ? next : next + '\n' } : { problems: describeIssues(parsed.error) };
19
+ };
@@ -0,0 +1,8 @@
1
+ export { editConfigText } from './edit.js';
2
+ /** Titles of the top-level sections in the menu. A test makes sure every section of the schema has one. */
3
+ export declare const SECTION_TITLES: Record<string, string>;
4
+ /**
5
+ * The settings screen: first which file to edit (your own, or this folder's project file), then its settings. Your file
6
+ * is created when missing; so is the project file once a setting is saved.
7
+ */
8
+ export declare const settingsMenu: (configPath: string | null, projectDir?: string) => Promise<void>;
@@ -0,0 +1,131 @@
1
+ // The settings screen in the menu: every setting, generated from the zod schemas (so nothing needs to be listed by
2
+ // hand), shown with its current value and description, edited with a fitting prompt and saved to a config file. Two
3
+ // files can be edited: your own (all projects) and this folder's .sandbox-config.json (only what a project may set).
4
+ // Edits keep comments and formatting, and nothing is written unless the whole file is still valid.
5
+ import fs from 'node:fs';
6
+ import path from 'node:path';
7
+ import { select, Separator } from '@inquirer/prompts';
8
+ import _ from 'lodash';
9
+ import { DEFAULTS, loadConfig, userConfigPath } from '../../core/config.js';
10
+ import { LOCAL_CONFIG_NAME, NEVER_LOOSER } from '../../core/local-config.js';
11
+ import { Config as ConfigSchema, LocalOverrides } from '../../core/schema.js';
12
+ import { c, clearScreen, pageSizeFor, shortenMiddle, termWidth } from '../colors.js';
13
+ import { editConfigText, fileValue } from './edit.js';
14
+ import { askValue } from './prompts.js';
15
+ import { childrenOf, describeSetting, isSection, unwrap } from './schema.js';
16
+ export { editConfigText } from './edit.js';
17
+ /** Titles of the top-level sections in the menu. A test makes sure every section of the schema has one. */
18
+ export const SECTION_TITLES = {
19
+ toolchains: 'Languages and tools', databases: 'Databases', sandbox: 'The sandbox machine (memory, window, GPU, network)', attach: 'Attaching (auto attach, auto hide)', agentCommands: 'Agent commands: claude, codex ... start in a sandbox (Beta)',
20
+ provisioning: 'Provisioning and git', downloads: 'Downloads (checksums)', secrets: 'Secrets', claudeProfile: 'Your Claude settings and history', storage: 'Storage',
21
+ network: 'Network', hooks: 'Hooks', personalization: 'Desktop look', projects: 'Several projects (project roots, adding folders while running)',
22
+ };
23
+ /** A value in one short line: maps as their keys, long lists shortened. */
24
+ const show = (value) => {
25
+ if (value === undefined)
26
+ return c.dim('(not set)');
27
+ if (typeof value === 'string')
28
+ return value;
29
+ if (value && typeof value === 'object' && !Array.isArray(value)) {
30
+ const keys = Object.keys(value);
31
+ return keys.length ? '{ ' + _.truncate(keys.join(', '), { length: 60 }) + ' }' : '{}';
32
+ }
33
+ return _.truncate(JSON.stringify(value), { length: 70 });
34
+ };
35
+ const readText = (file) => (fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : '');
36
+ /** The user's settings with the built-in defaults filled in (what a sandbox would use without a project file). */
37
+ const userConfig = (file) => loadConfig(fs.existsSync(file) ? file : undefined).config;
38
+ /** What is in effect now: for your own file the merged config, for a project file only what that file sets. */
39
+ const currentValue = (session, key, text, config) => session.target === 'project' ? fileValue(text, key) : _.get(config, key);
40
+ /** Edits one setting: shows it, asks, saves (or resets it to the default / removes it from the project file). */
41
+ const editSetting = async (session, schema, key, config) => {
42
+ const { file, target } = session;
43
+ const text = readText(file);
44
+ const inFile = fileValue(text, key);
45
+ const project = target === 'project';
46
+ console.log('\n' + c.cyan(key.join('.')) + (describeSetting(schema) ? '\n' + c.dim(describeSetting(schema)) : ''));
47
+ if (project && NEVER_LOOSER.includes(key.join('.')))
48
+ console.log(c.yellow('A project can only make this stricter than your own setting; a looser value is ignored.'));
49
+ console.log('Now: ' + (project ? show(inFile) + c.dim(inFile === undefined ? ' (not set in this project; yours: ' + show(_.get(config, key)) + ')' : ' (set in ' + file + ')') : show(_.get(config, key)) + (inFile === undefined ? c.dim(' (built-in default)') : c.dim(' (set in ' + file + ')'))));
50
+ const resetLabel = project ? 'Remove from this project file' : 'Reset to the default (' + show(_.get(DEFAULTS, key)) + ')';
51
+ const action = inFile === undefined ? 'change' : await select({ message: 'What now?', choices: [{ name: 'Change it', value: 'change' }, { name: resetLabel, value: 'reset' }, { name: c.dim('< back'), value: 'back' }] });
52
+ if (action === 'back')
53
+ return;
54
+ const answer = action === 'reset' ? { value: undefined } : await askValue(schema, key, project ? inFile ?? _.get(config, key) : _.get(config, key), config);
55
+ if (!answer)
56
+ return;
57
+ // In your own file the default needs no line; an unchanged value needs no save.
58
+ const value = !project && _.isEqual(answer.value, _.get(DEFAULTS, key)) ? undefined : answer.value;
59
+ if (value === inFile && (project || _.isEqual(answer.value, _.get(config, key)))) {
60
+ console.log(c.dim('Unchanged.'));
61
+ return;
62
+ }
63
+ const result = editConfigText(text, key, value, target);
64
+ if ('problems' in result) {
65
+ console.log(c.red('Not saved: ') + result.problems.join('; '));
66
+ return;
67
+ }
68
+ fs.mkdirSync(path.dirname(file), { recursive: true });
69
+ fs.writeFileSync(file, result.text, 'utf8');
70
+ console.log(c.green('Saved') + c.dim(' to ' + file + (project ? '.' : '. New sandboxes use it.')));
71
+ // The approval is tied to the file's exact contents, so any change makes the project file untrusted again.
72
+ if (project)
73
+ console.log(c.yellow('This project file is not approved any more: the next launch shows what it applies and asks you to approve it.'));
74
+ };
75
+ /** Browses one level of settings; sections open, leaves are edited. */
76
+ const browse = async (session, schema, key) => {
77
+ for (;;) {
78
+ const config = userConfig(session.target === 'user' ? session.file : userConfigPath());
79
+ const entries = childrenOf(schema);
80
+ const text = readText(session.file);
81
+ clearScreen();
82
+ console.log(c.dim('Editing ' + shortenMiddle(session.file, termWidth() - 10)) + '\n' + c.dim('Values in ') + c.yellow('yellow') + c.dim(' (marked *) are set in that file; the rest are defaults.') + '\n');
83
+ const pickName = await select({
84
+ message: key.length ? key.join('.') : 'Settings',
85
+ pageSize: pageSizeFor(18),
86
+ choices: [
87
+ ...entries.map(([name, s]) => {
88
+ const full = [...key, name];
89
+ const changed = fileValue(text, full) !== undefined;
90
+ const shown = show(currentValue(session, full, text, config));
91
+ const label = isSection(s) ? (key.length ? name : SECTION_TITLES[name] ?? name) + c.dim(' ...') : name.padEnd(18) + ' ' + (changed ? c.yellow(shown) + ' *' : shown);
92
+ return { name: label, value: name, description: describeSetting(s) };
93
+ }),
94
+ new Separator(),
95
+ { name: c.dim('< back'), value: '@back' },
96
+ ],
97
+ });
98
+ if (pickName === '@back')
99
+ return;
100
+ const child = unwrap(schema).shape[pickName];
101
+ if (isSection(child))
102
+ await browse(session, child, [...key, pickName]);
103
+ else
104
+ await editSetting(session, child, [...key, pickName], config);
105
+ }
106
+ };
107
+ /**
108
+ * The settings screen: first which file to edit (your own, or this folder's project file), then its settings. Your file
109
+ * is created when missing; so is the project file once a setting is saved.
110
+ */
111
+ export const settingsMenu = async (configPath, projectDir = process.cwd()) => {
112
+ const sessions = {
113
+ user: { target: 'user', file: configPath ?? userConfigPath(), schema: ConfigSchema },
114
+ project: { target: 'project', file: path.join(projectDir, LOCAL_CONFIG_NAME), schema: LocalOverrides },
115
+ };
116
+ for (;;) {
117
+ clearScreen();
118
+ const which = await select({
119
+ message: 'Which settings?',
120
+ choices: [
121
+ { name: 'Your settings (all projects)', description: sessions.user.file, value: 'user' },
122
+ { name: 'This project', description: sessions.project.file + ' (only what a project may set; needs your approval at launch)', value: 'project' },
123
+ new Separator(),
124
+ { name: c.dim('< back'), value: '@back' },
125
+ ],
126
+ });
127
+ if (which === '@back')
128
+ return;
129
+ await browse(sessions[which], sessions[which].schema, []);
130
+ }
131
+ };
@@ -0,0 +1,7 @@
1
+ import type { Config } from '../../core/types.js';
2
+ import { type Schema } from './schema.js';
3
+ export type Answer = {
4
+ value: unknown;
5
+ } | undefined;
6
+ /** `key` is the setting's path; `config` (the user's settings) lets the default agent be picked from the configured ones. */
7
+ export declare const askValue: (schema: Schema, key: string[], current: unknown, config?: Config) => Promise<Answer>;
@@ -0,0 +1,48 @@
1
+ // The prompt that fits a setting's type. Returns the new value, or undefined to change nothing.
2
+ import { input, select } from '@inquirer/prompts';
3
+ import { c } from '../colors.js';
4
+ import { jsonExample, kind, promptKind, unwrap } from './schema.js';
5
+ const CANCEL = '@cancel';
6
+ const cancel = { name: c.dim('< cancel'), value: CANCEL };
7
+ const pick = async (label, choices, current) => {
8
+ const v = await select({ message: label, choices: [...choices, cancel], default: current });
9
+ return v === CANCEL ? undefined : { value: v };
10
+ };
11
+ const typed = async (label, current, validate, convert) => {
12
+ const shown = current === undefined ? '' : typeof current === 'string' ? current : JSON.stringify(current);
13
+ const answer = await input({ message: label + c.dim(' (empty = cancel)'), default: shown, validate: (v) => (v === '' ? true : validate(v)) });
14
+ return answer === '' ? undefined : { value: convert(answer) };
15
+ };
16
+ const BOOLEANS = [{ name: 'true', value: true }, { name: 'false', value: false }];
17
+ /** `key` is the setting's path; `config` (the user's settings) lets the default agent be picked from the configured ones. */
18
+ export const askValue = async (schema, key, current, config) => {
19
+ const label = key.join('.');
20
+ const s = unwrap(schema);
21
+ // The default agent is one of the configured ones: a list, not free text.
22
+ if (label === 'defaultHarness' && config)
23
+ return pick(label, Object.keys(config.harnesses).map((h) => ({ name: h, value: h })), current);
24
+ switch (promptKind(s)) {
25
+ case 'boolean': return pick(label, BOOLEANS, current);
26
+ case 'enum': return pick(label, Object.values(s._zod.def.entries ?? {}).map((v) => ({ name: String(v), value: v })), current);
27
+ case 'union': {
28
+ const options = s._zod.def.options ?? [];
29
+ const literals = options.filter((o) => kind(o) === 'literal').flatMap((o) => unwrap(o)._zod.def.values ?? []);
30
+ const other = options.find((o) => !['literal', 'boolean'].includes(kind(o)));
31
+ const choices = [...(options.some((o) => kind(o) === 'boolean') ? BOOLEANS : []), ...literals.map((v) => ({ name: String(v), value: v }))];
32
+ if (!other)
33
+ return pick(label, choices, current);
34
+ const chosen = await pick(label, [...choices, { name: 'a ' + kind(other) + ' ...', value: '@other' }], current);
35
+ return chosen?.value === '@other' ? askValue(other, key, current) : chosen;
36
+ }
37
+ case 'number': return typed(label, current, (v) => (Number.isFinite(Number(v)) ? true : 'Enter a number, e.g. 8192.'), Number);
38
+ case 'string': return typed(label, current, () => true, (v) => v);
39
+ case 'string-list': return typed(label + c.dim(' (comma separated, [] = none)'), current, () => true, (v) => (v.trim() === '[]' ? [] : v.split(',').map((x) => x.trim()).filter(Boolean)));
40
+ default: return typed(label + c.dim(' (JSON, e.g. ' + jsonExample(key, s) + ')'), current, (v) => { try {
41
+ JSON.parse(v);
42
+ return true;
43
+ }
44
+ catch {
45
+ return 'valid JSON';
46
+ } }, (v) => JSON.parse(v));
47
+ }
48
+ };
@@ -0,0 +1,33 @@
1
+ import type { z } from 'zod';
2
+ export type Schema = z.ZodType & {
3
+ _zod: {
4
+ def: {
5
+ type: string;
6
+ options?: Schema[];
7
+ values?: unknown[];
8
+ entries?: Record<string, string>;
9
+ element?: Schema;
10
+ innerType?: Schema;
11
+ };
12
+ };
13
+ shape?: Record<string, Schema>;
14
+ description?: string;
15
+ };
16
+ /** optional()/default() wrappers hide the real type. */
17
+ export declare const unwrap: (s: Schema) => Schema;
18
+ export declare const kind: (s: Schema) => string;
19
+ export declare const isSection: (s: Schema) => boolean;
20
+ /** The settings one level below a section (or the top level), in schema order. */
21
+ export declare const childrenOf: (schema: Schema) => [string, Schema][];
22
+ /** The description, which `.partial()` and `.optional()` wrappers may carry on the inner schema instead. */
23
+ export declare const describeSetting: (s: Schema) => string | undefined;
24
+ /** Every setting that is edited with a prompt of its own (everything that is not a section), with its path. */
25
+ export declare const leavesOf: (schema: Schema, key?: string[]) => {
26
+ key: string[];
27
+ schema: Schema;
28
+ }[];
29
+ export type PromptKind = 'boolean' | 'enum' | 'union' | 'number' | 'string' | 'string-list' | 'json';
30
+ /** Which prompt edits this setting. Lists of objects and maps (mappings, harnesses, environment) fall back to JSON. */
31
+ export declare const promptKind: (schema: Schema) => PromptKind;
32
+ /** A one-line example of the JSON a setting expects, shown in its prompt. */
33
+ export declare const jsonExample: (key: string[], schema: Schema) => string;