@huynle/hub 0.6.1 → 0.7.2

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 (355) hide show
  1. package/CHANGELOG.md +870 -0
  2. package/Dockerfile +33 -0
  3. package/README.md +1007 -66
  4. package/package.json +47 -2
  5. package/packages/claude-code/package.json +12 -0
  6. package/packages/claude-code/src/bridge.ts +79 -0
  7. package/packages/claude-code/src/daemon.ts +339 -0
  8. package/packages/claude-code/src/hook-input.ts +44 -0
  9. package/packages/claude-code/src/ids.ts +27 -0
  10. package/packages/claude-code/src/index.ts +18 -0
  11. package/packages/claude-code/src/launcher.ts +265 -0
  12. package/packages/claude-code/src/main.ts +127 -0
  13. package/packages/claude-code/src/mcp.ts +109 -0
  14. package/packages/claude-code/src/paths.ts +34 -0
  15. package/packages/claude-code/src/plugin.ts +99 -0
  16. package/packages/claude-code/src/references.ts +51 -0
  17. package/packages/claude-code/src/sessions.ts +600 -0
  18. package/packages/claude-code/src/tools.ts +66 -0
  19. package/packages/claude-code/src/wire.ts +71 -0
  20. package/packages/claude-code/src/wrapper.ts +156 -0
  21. package/packages/cli/dist/hub-cli.js +21647 -0
  22. package/packages/cli/package.json +11 -0
  23. package/packages/cli/src/acting.ts +70 -0
  24. package/packages/cli/src/args.ts +91 -0
  25. package/packages/cli/src/bootstrap/adapters/archive-stream.ts +276 -0
  26. package/packages/cli/src/bootstrap/adapters/claude/README.md +63 -0
  27. package/packages/cli/src/bootstrap/adapters/claude/adapter.ts +250 -0
  28. package/packages/cli/src/bootstrap/adapters/claude/index.ts +42 -0
  29. package/packages/cli/src/bootstrap/adapters/claude/node.ts +99 -0
  30. package/packages/cli/src/bootstrap/adapters/claude/openpgp.ts +221 -0
  31. package/packages/cli/src/bootstrap/adapters/claude/ports.ts +44 -0
  32. package/packages/cli/src/bootstrap/adapters/claude/source.ts +160 -0
  33. package/packages/cli/src/bootstrap/adapters/claude/verify.ts +165 -0
  34. package/packages/cli/src/bootstrap/adapters/codex/archive.ts +103 -0
  35. package/packages/cli/src/bootstrap/adapters/codex/index.ts +60 -0
  36. package/packages/cli/src/bootstrap/adapters/codex/plan.ts +129 -0
  37. package/packages/cli/src/bootstrap/adapters/codex/sigstore.ts +298 -0
  38. package/packages/cli/src/bootstrap/adapters/codex/source.ts +102 -0
  39. package/packages/cli/src/bootstrap/adapters/codex/verify.ts +298 -0
  40. package/packages/cli/src/bootstrap/adapters/inflate.ts +433 -0
  41. package/packages/cli/src/bootstrap/adapters/node-services/control.ts +392 -0
  42. package/packages/cli/src/bootstrap/adapters/node-services/index.ts +23 -0
  43. package/packages/cli/src/bootstrap/adapters/node-services/launch.ts +223 -0
  44. package/packages/cli/src/bootstrap/adapters/node-services/plan.ts +222 -0
  45. package/packages/cli/src/bootstrap/adapters/node-services/render.ts +183 -0
  46. package/packages/cli/src/bootstrap/adapters/node-services/validate.ts +197 -0
  47. package/packages/cli/src/bootstrap/adapters/opencode/README.md +204 -0
  48. package/packages/cli/src/bootstrap/adapters/opencode/apply.ts +205 -0
  49. package/packages/cli/src/bootstrap/adapters/opencode/archive.ts +91 -0
  50. package/packages/cli/src/bootstrap/adapters/opencode/config.ts +212 -0
  51. package/packages/cli/src/bootstrap/adapters/opencode/index.ts +81 -0
  52. package/packages/cli/src/bootstrap/adapters/opencode/plan.ts +266 -0
  53. package/packages/cli/src/bootstrap/adapters/opencode/verify.ts +208 -0
  54. package/packages/cli/src/bootstrap/adapters/readiness.ts +19 -0
  55. package/packages/cli/src/bootstrap/ansible/index.ts +2 -0
  56. package/packages/cli/src/bootstrap/ansible/render.ts +279 -0
  57. package/packages/cli/src/bootstrap/contracts/actions.ts +470 -0
  58. package/packages/cli/src/bootstrap/contracts/artifact.ts +323 -0
  59. package/packages/cli/src/bootstrap/contracts/canonical.ts +54 -0
  60. package/packages/cli/src/bootstrap/contracts/credentials.ts +108 -0
  61. package/packages/cli/src/bootstrap/contracts/discovery.ts +215 -0
  62. package/packages/cli/src/bootstrap/contracts/envelope.ts +143 -0
  63. package/packages/cli/src/bootstrap/contracts/exec.ts +74 -0
  64. package/packages/cli/src/bootstrap/contracts/harness.ts +202 -0
  65. package/packages/cli/src/bootstrap/contracts/index.ts +28 -0
  66. package/packages/cli/src/bootstrap/contracts/journal.ts +198 -0
  67. package/packages/cli/src/bootstrap/contracts/paths.ts +91 -0
  68. package/packages/cli/src/bootstrap/contracts/plan.ts +155 -0
  69. package/packages/cli/src/bootstrap/contracts/platform.ts +62 -0
  70. package/packages/cli/src/bootstrap/contracts/preview.ts +190 -0
  71. package/packages/cli/src/bootstrap/contracts/profile.ts +105 -0
  72. package/packages/cli/src/bootstrap/contracts/secrets.ts +52 -0
  73. package/packages/cli/src/bootstrap/contracts/validate.ts +212 -0
  74. package/packages/cli/src/bootstrap/discovery/discover.ts +146 -0
  75. package/packages/cli/src/bootstrap/discovery/executables.ts +134 -0
  76. package/packages/cli/src/bootstrap/discovery/host.ts +96 -0
  77. package/packages/cli/src/bootstrap/discovery/index.ts +6 -0
  78. package/packages/cli/src/bootstrap/discovery/node-probe.ts +97 -0
  79. package/packages/cli/src/bootstrap/discovery/probe.ts +41 -0
  80. package/packages/cli/src/bootstrap/discovery/util.ts +13 -0
  81. package/packages/cli/src/bootstrap/enrollment/enroll.ts +226 -0
  82. package/packages/cli/src/bootstrap/enrollment/index.ts +7 -0
  83. package/packages/cli/src/bootstrap/enrollment/policy.ts +54 -0
  84. package/packages/cli/src/bootstrap/enrollment/result.ts +63 -0
  85. package/packages/cli/src/bootstrap/enrollment/status.ts +119 -0
  86. package/packages/cli/src/bootstrap/enrollment/store.ts +173 -0
  87. package/packages/cli/src/bootstrap/preview/index.ts +5 -0
  88. package/packages/cli/src/bootstrap/preview/links.ts +31 -0
  89. package/packages/cli/src/bootstrap/preview/plan.ts +242 -0
  90. package/packages/cli/src/bootstrap/preview/preview.ts +104 -0
  91. package/packages/cli/src/bootstrap/preview/render.ts +30 -0
  92. package/packages/cli/src/bootstrap/profiles/audit.ts +87 -0
  93. package/packages/cli/src/bootstrap/profiles/catalog.ts +49 -0
  94. package/packages/cli/src/bootstrap/profiles/desired.ts +96 -0
  95. package/packages/cli/src/bootstrap/profiles/drift.ts +286 -0
  96. package/packages/cli/src/bootstrap/profiles/history.ts +263 -0
  97. package/packages/cli/src/bootstrap/profiles/index.ts +11 -0
  98. package/packages/cli/src/bootstrap/profiles/upgrade.ts +276 -0
  99. package/packages/cli/src/bootstrap/profiles/versions.ts +50 -0
  100. package/packages/cli/src/bootstrap/release/README.md +115 -0
  101. package/packages/cli/src/bootstrap/release/assets.ts +39 -0
  102. package/packages/cli/src/bootstrap/release/cli.ts +332 -0
  103. package/packages/cli/src/bootstrap/release/harness-pins.json +94 -0
  104. package/packages/cli/src/bootstrap/release/manifest.ts +118 -0
  105. package/packages/cli/src/bootstrap/release/pins.ts +192 -0
  106. package/packages/cli/src/bootstrap/release/text-assets.d.ts +9 -0
  107. package/packages/cli/src/bootstrap/release/trust-root.json +13 -0
  108. package/packages/cli/src/bootstrap/release/trust-root.ts +39 -0
  109. package/packages/cli/src/bootstrap/release/verify.ts +88 -0
  110. package/packages/cli/src/bootstrap/transaction/builtin.ts +312 -0
  111. package/packages/cli/src/bootstrap/transaction/engine.ts +994 -0
  112. package/packages/cli/src/bootstrap/transaction/executor.ts +112 -0
  113. package/packages/cli/src/bootstrap/transaction/fs.ts +194 -0
  114. package/packages/cli/src/bootstrap/transaction/index.ts +12 -0
  115. package/packages/cli/src/bootstrap/transaction/json-merge.ts +403 -0
  116. package/packages/cli/src/bootstrap/transaction/lock.ts +132 -0
  117. package/packages/cli/src/bootstrap/transaction/resolve.ts +48 -0
  118. package/packages/cli/src/bootstrap/transaction/result.ts +60 -0
  119. package/packages/cli/src/bootstrap/transaction/state.ts +88 -0
  120. package/packages/cli/src/bootstrap/transaction/store.ts +189 -0
  121. package/packages/cli/src/bootstrap/transaction/stream.ts +254 -0
  122. package/packages/cli/src/cli.ts +814 -0
  123. package/packages/cli/src/credentials.ts +75 -0
  124. package/packages/cli/src/decisions.ts +106 -0
  125. package/packages/cli/src/format.ts +185 -0
  126. package/packages/cli/src/grants.ts +105 -0
  127. package/packages/cli/src/http.ts +86 -0
  128. package/packages/cli/src/launches.ts +157 -0
  129. package/packages/cli/src/login.ts +160 -0
  130. package/packages/cli/src/main.ts +63 -0
  131. package/packages/cli/src/mcp.ts +879 -0
  132. package/packages/cli/src/messaging.ts +108 -0
  133. package/packages/cli/src/node.ts +279 -0
  134. package/packages/cli/src/openshell.ts +166 -0
  135. package/packages/cli/src/setup/commands.ts +809 -0
  136. package/packages/cli/src/setup/contributors.ts +171 -0
  137. package/packages/cli/src/setup/dispatch.ts +74 -0
  138. package/packages/cli/src/setup/launch-driver.ts +109 -0
  139. package/packages/cli/src/setup/runtime.ts +456 -0
  140. package/packages/cli/src/setup/spoke-probe.ts +120 -0
  141. package/packages/cli/src/setup/state.ts +244 -0
  142. package/packages/cli/src/setup/user-bin.ts +39 -0
  143. package/packages/cli/src/spoke-services.ts +109 -0
  144. package/packages/cli/src/watch.ts +28 -0
  145. package/packages/codex/package.json +12 -0
  146. package/packages/codex/src/app-server.ts +44 -0
  147. package/packages/codex/src/ids.ts +28 -0
  148. package/packages/codex/src/index.ts +11 -0
  149. package/packages/codex/src/jsonrpc.ts +168 -0
  150. package/packages/codex/src/main.ts +123 -0
  151. package/packages/codex/src/paths.ts +65 -0
  152. package/packages/codex/src/sessions.ts +578 -0
  153. package/packages/codex/src/spoke.ts +252 -0
  154. package/packages/codex/src/stdio.ts +101 -0
  155. package/packages/codex/src/websocket.ts +206 -0
  156. package/packages/codex/src/wrapper.ts +104 -0
  157. package/packages/node/package.json +12 -0
  158. package/packages/node/src/config.ts +184 -0
  159. package/packages/node/src/connection.ts +283 -0
  160. package/packages/node/src/daemon.ts +281 -0
  161. package/packages/node/src/git.ts +107 -0
  162. package/packages/node/src/harnesses.ts +95 -0
  163. package/packages/node/src/host.ts +319 -0
  164. package/packages/node/src/index.ts +17 -0
  165. package/packages/node/src/main.ts +11 -0
  166. package/packages/node/src/openshell.ts +73 -0
  167. package/packages/node/src/run.ts +106 -0
  168. package/packages/preset/package.json +12 -0
  169. package/packages/preset/presets/aider.json +8 -0
  170. package/packages/preset/presets/goose.json +8 -0
  171. package/packages/preset/src/index.ts +2 -0
  172. package/packages/preset/src/main.ts +195 -0
  173. package/packages/preset/src/presets.ts +87 -0
  174. package/packages/preset/src/spoke.ts +251 -0
  175. package/packages/preset/src/tmux.ts +160 -0
  176. package/packages/protocol/package.json +12 -0
  177. package/packages/protocol/src/harness-events.ts +207 -0
  178. package/packages/protocol/src/index.ts +1215 -0
  179. package/packages/protocol/src/launch.ts +172 -0
  180. package/packages/protocol/src/node.ts +409 -0
  181. package/packages/protocol/src/preset.ts +144 -0
  182. package/packages/protocol/src/webhook.ts +62 -0
  183. package/packages/protocol/src/wire.ts +54 -0
  184. package/packages/runtime-openshell/image/Dockerfile +36 -0
  185. package/packages/runtime-openshell/image/README.md +78 -0
  186. package/packages/runtime-openshell/image/build.ts +203 -0
  187. package/packages/runtime-openshell/image/entry.sh +163 -0
  188. package/packages/runtime-openshell/image/image.ts +119 -0
  189. package/packages/runtime-openshell/image/start.sh +24 -0
  190. package/packages/runtime-openshell/package.json +12 -0
  191. package/packages/runtime-openshell/src/cli.ts +584 -0
  192. package/packages/runtime-openshell/src/index.ts +6 -0
  193. package/packages/runtime-openshell/src/policy.ts +155 -0
  194. package/packages/runtime-openshell/src/profiles.ts +181 -0
  195. package/packages/runtime-openshell/src/runtime.ts +475 -0
  196. package/packages/runtime-sdk/package.json +12 -0
  197. package/packages/runtime-sdk/src/index.ts +87 -0
  198. package/packages/sdk/package.json +12 -0
  199. package/packages/sdk/src/address.ts +25 -0
  200. package/packages/sdk/src/client.ts +553 -0
  201. package/packages/sdk/src/envelope.ts +108 -0
  202. package/packages/sdk/src/errors.ts +107 -0
  203. package/packages/sdk/src/index.ts +10 -0
  204. package/packages/sdk/src/sse.ts +70 -0
  205. package/packages/sdk/src/types.ts +517 -0
  206. package/packages/sdk/src/webhook.ts +102 -0
  207. package/packages/spoke-sdk/package.json +12 -0
  208. package/packages/spoke-sdk/src/activity.ts +3 -0
  209. package/packages/spoke-sdk/src/connection.ts +295 -0
  210. package/packages/spoke-sdk/src/delivery.ts +119 -0
  211. package/packages/spoke-sdk/src/enroll.ts +101 -0
  212. package/packages/spoke-sdk/src/harness-hello.ts +79 -0
  213. package/packages/spoke-sdk/src/harness-spoke.ts +263 -0
  214. package/packages/spoke-sdk/src/index.ts +12 -0
  215. package/packages/spoke-sdk/src/prompt-refs.ts +154 -0
  216. package/packages/spoke-sdk/src/prompt-source.ts +8 -0
  217. package/packages/spoke-sdk/src/session-ids.ts +49 -0
  218. package/packages/spoke-sdk/src/session-tokens.ts +78 -0
  219. package/scripts/hub-claude-code.service +34 -0
  220. package/scripts/hub-codex-spoke.service +34 -0
  221. package/scripts/hub-node.service +35 -0
  222. package/scripts/io.github.huynle.hub-claude-code.plist +53 -0
  223. package/scripts/io.github.huynle.hub-codex-spoke.plist +53 -0
  224. package/scripts/io.github.huynle.hub-node.plist +52 -0
  225. package/src/central/actions.ts +233 -0
  226. package/src/central/agent/acting-routes.ts +133 -0
  227. package/src/central/agent/acting-switch.ts +93 -0
  228. package/src/central/agent/acting.ts +599 -0
  229. package/src/central/agent/actor.ts +193 -0
  230. package/src/central/agent/app-tokens.ts +152 -0
  231. package/src/central/agent/archive-inactive.ts +66 -0
  232. package/src/central/agent/audit.ts +185 -0
  233. package/src/central/agent/decisions.ts +530 -0
  234. package/src/central/agent/delivery.ts +678 -0
  235. package/src/central/agent/directory-routes.ts +328 -0
  236. package/src/central/agent/directory.ts +473 -0
  237. package/src/central/agent/envelope.ts +51 -0
  238. package/src/central/agent/errors.ts +74 -0
  239. package/src/central/agent/escalation.ts +219 -0
  240. package/src/central/agent/event-cursor.ts +49 -0
  241. package/src/central/agent/event-writers.ts +181 -0
  242. package/src/central/agent/events-routes.ts +148 -0
  243. package/src/central/agent/events.ts +277 -0
  244. package/src/central/agent/grant-requests.ts +221 -0
  245. package/src/central/agent/grant-routes.ts +70 -0
  246. package/src/central/agent/grants.ts +235 -0
  247. package/src/central/agent/launch-engine.ts +1025 -0
  248. package/src/central/agent/launch-routes.ts +252 -0
  249. package/src/central/agent/launch-spec.ts +289 -0
  250. package/src/central/agent/launch-store.ts +264 -0
  251. package/src/central/agent/launch-tokens.ts +206 -0
  252. package/src/central/agent/message-limits.ts +123 -0
  253. package/src/central/agent/message-routes.ts +385 -0
  254. package/src/central/agent/messages.ts +442 -0
  255. package/src/central/agent/names.ts +86 -0
  256. package/src/central/agent/placement.ts +419 -0
  257. package/src/central/agent/policy.ts +378 -0
  258. package/src/central/agent/read-routes.ts +385 -0
  259. package/src/central/agent/routes.ts +281 -0
  260. package/src/central/agent/session-tokens.ts +227 -0
  261. package/src/central/agent/versioned.ts +26 -0
  262. package/src/central/agent/watch.ts +112 -0
  263. package/src/central/agent/webhook-routes.ts +86 -0
  264. package/src/central/agent/webhooks.ts +493 -0
  265. package/src/central/agent-data.ts +177 -0
  266. package/src/central/auth.ts +350 -121
  267. package/src/central/backend/file-state.ts +240 -0
  268. package/src/central/backend/index.ts +116 -0
  269. package/src/central/backend/memory-bus.ts +93 -0
  270. package/src/central/backend/memory-catalog.ts +90 -0
  271. package/src/central/backend/memory-counters.ts +102 -0
  272. package/src/central/backend/memory-events.ts +45 -0
  273. package/src/central/backend/memory-kv.ts +38 -0
  274. package/src/central/backend/memory-state.ts +77 -0
  275. package/src/central/backend/postgres-migrate.ts +100 -0
  276. package/src/central/backend/postgres-state.ts +197 -0
  277. package/src/central/backend/types.ts +246 -0
  278. package/src/central/backend/valkey-bus.ts +425 -0
  279. package/src/central/backend/valkey-catalog.ts +190 -0
  280. package/src/central/backend/valkey-client.ts +36 -0
  281. package/src/central/backend/valkey-counters.ts +38 -0
  282. package/src/central/backend/valkey-events.ts +168 -0
  283. package/src/central/backend/valkey-kv.ts +54 -0
  284. package/src/central/backend/valkey-locks.ts +92 -0
  285. package/src/central/cluster/directory.ts +141 -0
  286. package/src/central/cluster/feed.ts +84 -0
  287. package/src/central/cluster/leader.ts +232 -0
  288. package/src/central/cluster/live.ts +151 -0
  289. package/src/central/cluster/recommend.ts +59 -0
  290. package/src/central/cluster/rpc.ts +150 -0
  291. package/src/central/cluster/view.ts +232 -0
  292. package/src/central/config.ts +197 -3
  293. package/src/central/done.ts +96 -0
  294. package/src/central/events-log.ts +100 -0
  295. package/src/central/http.ts +1149 -170
  296. package/src/central/import.ts +318 -0
  297. package/src/central/main.ts +211 -12
  298. package/src/central/mcp-http.ts +116 -0
  299. package/src/central/nodes.ts +314 -0
  300. package/src/central/oidc.ts +34 -14
  301. package/src/central/push-target.ts +118 -18
  302. package/src/central/push.ts +256 -82
  303. package/src/central/registry.ts +902 -92
  304. package/src/central/say-stream.ts +128 -0
  305. package/src/central/session-token-push.ts +233 -0
  306. package/src/central/speech/openai.ts +21 -14
  307. package/src/central/speech/openrouter.ts +27 -10
  308. package/src/central/speech/provider.ts +54 -0
  309. package/src/central/speech/service.ts +22 -7
  310. package/src/central/spoke-ws.ts +522 -44
  311. package/src/central/store-shared.ts +518 -0
  312. package/src/central/store.ts +625 -189
  313. package/src/central/token-keys.ts +165 -0
  314. package/src/config.ts +118 -7
  315. package/src/index.ts +195 -43
  316. package/src/protocol.ts +3 -650
  317. package/src/rpc.ts +27 -2
  318. package/src/spoke/client.ts +3 -272
  319. package/src/spoke/conversation.ts +16 -12
  320. package/src/spoke/deliver.ts +75 -0
  321. package/src/spoke/enroll.ts +2 -79
  322. package/src/spoke/env-inject.ts +160 -0
  323. package/src/spoke/hub-rpc.ts +115 -0
  324. package/src/spoke/hub-tool.ts +409 -0
  325. package/src/spoke/launch-source.ts +22 -0
  326. package/src/spoke/launchers.ts +174 -0
  327. package/src/spoke/locations.ts +3 -2
  328. package/src/spoke/node-roots.ts +61 -0
  329. package/src/spoke/prompt-refs.ts +116 -0
  330. package/src/spoke/session-tokens.ts +2 -0
  331. package/src/spoke/spoke.ts +465 -28
  332. package/src/spoke/taskboard.ts +243 -0
  333. package/src/spoke/timeline.ts +184 -6
  334. package/src/spoke/tracker.ts +79 -4
  335. package/src/status.ts +203 -10
  336. package/src/tui-commands.ts +13 -6
  337. package/src/tui-messaging.ts +93 -0
  338. package/src/tui.ts +15 -0
  339. package/web/dist/apple-touch-icon.png +0 -0
  340. package/web/dist/assets/highlight-y4rJE6Tt.js +5 -0
  341. package/web/dist/assets/index-DWnMhXk9.js +20 -0
  342. package/web/dist/assets/index-DmUXEnw9.css +1 -0
  343. package/web/dist/assets/ort-wasm-simd-threaded-CxTQ5xH-.wasm +0 -0
  344. package/web/dist/assets/silero-worker-C8-8vLVS.js +11 -0
  345. package/web/dist/assets/silero_vad-Dt9-bTDn.onnx +0 -0
  346. package/web/dist/favicon.ico +0 -0
  347. package/web/dist/icon-192.png +0 -0
  348. package/web/dist/icon-512.png +0 -0
  349. package/web/dist/icon-maskable-512.png +0 -0
  350. package/web/dist/icon.svg +5 -5
  351. package/web/dist/index.html +17 -4
  352. package/web/dist/manifest.webmanifest +2 -2
  353. package/web/dist/sw.js +79 -15
  354. package/web/dist/assets/index-DClBvfLL.css +0 -1
  355. package/web/dist/assets/index-Djo_yF0a.js +0 -12
@@ -0,0 +1,814 @@
1
+ // The hub CLI (`hub` / `hubctl`, design §2.2, §3, §4, §5, §8): reads, names, messages and acts on sessions through
2
+ // the hub's /api/agent, with the hub SDK (packages/sdk) as its client.
3
+ // `run(argv, io)` is the whole program (main.ts wires it to the process); it returns the exit code.
4
+ import type { Asked, HubClient, LaunchState, Sent, SentAll } from "../../sdk/src/index.ts"
5
+ import pkg from "../package.json" with { type: "json" }
6
+ import { parse, str, UsageError, type CommandSpec, type Parsed } from "./args"
7
+ import { loginFile, readLogin, removeLogin, resolveCredentials, writeLogin, type Credentials, type Env } from "./credentials"
8
+ import { atName, logText, whoamiText, type Actor, roleText, sessionsText, showText, statusText, whoText } from "./format"
9
+ import { CliError, EXIT, withHub } from "./http"
10
+ import { deviceLogin, promptText, scopeNotes } from "./login"
11
+ import { defaultMachineName } from "./bootstrap/enrollment/enroll.ts"
12
+ import { readSpokeCredentials } from "../../node/src/config.ts"
13
+ import { hostname } from "node:os"
14
+ import { serveMcp } from "./mcp"
15
+ import { ASK_EXPIRES_DEFAULT_S, askOutcome, declinedText, durationArg, inboxText, mutedText, repliedText, sentText, textArg } from "./messaging"
16
+ import { watch } from "./watch"
17
+ import { DECISION_FLAGS, decisionOptions, decisionsText, openedText, stateArg } from "./decisions"
18
+ import { GRANT_FLAGS, GRANT_REQUEST_FLAGS, grantError, grantRequestArg, grantSpecArg, grantsText, grantText, requestedText, userOnly } from "./grants"
19
+ import { answeredText, approvedText, deliveryArg, deniedText, formAnswerArg, needsUserLines, promptedText, stoppedText } from "./acting"
20
+ import { currentProgram, installNode, nodeStatus, nodeStatusText, runNodeCommand, uninstallNode, type NodeIo, type Program } from "./node"
21
+ import { checkOpenShell, openShellCheckText } from "./openshell"
22
+ import { LAUNCH_FLAGS, launchedText, launchesText, launchSpecArg, launchText, stoppedLaunchText } from "./launches"
23
+ import { claudeCodeMain } from "../../claude-code/src/main.ts"
24
+ import { runClaude } from "../../claude-code/src/wrapper.ts"
25
+ import { presetMain } from "../../preset/src/main.ts"
26
+ import { codexMain } from "../../codex/src/main.ts"
27
+ import { installSpokeService, isServiceVerb, SERVICE_VERBS, spokeServiceStatus, spokeServiceStatusText, uninstallSpokeService, type SpokeServiceName } from "./spoke-services"
28
+ import { runCodex } from "../../codex/src/wrapper.ts"
29
+ import { BOOTSTRAP_FLAGS, bootstrapCommand, MACHINE_VERBS, SETUP_VERBS, type BootstrapSeams } from "./setup/commands.ts"
30
+
31
+ export { EXIT } from "./http"
32
+
33
+ export const VERSION: string = pkg.version
34
+
35
+ export interface Io {
36
+ env: Env
37
+ home: string
38
+ stdout(s: string): void
39
+ stderr(s: string): void
40
+ /** The whole of stdin (login reads the token from it with `--token -`, or when piped and --token is not given). */
41
+ stdin?: () => Promise<string>
42
+ /** stdin is a terminal: `hub login` without --token runs the device code instead of reading a token. */
43
+ stdinIsTerminal?: boolean
44
+ /** `hub login` device code: test seams (poll interval, the name the approval page shows, fetch). */
45
+ login?: { readonly intervalMs?: number; readonly clientName?: string; readonly fetch?: typeof fetch }
46
+ /** Ends `watch` and `mcp` (main.ts aborts it on SIGINT/SIGTERM). */
47
+ signal?: AbortSignal
48
+ /** stdin as a stream (`hub mcp` reads JSON-RPC lines from it). */
49
+ input?: AsyncIterable<Uint8Array | string>
50
+ /** The working directory (`hub launch` defaults --repo to it); default process.cwd(). */
51
+ cwd?: string
52
+ /** `hub node install`: the platform (default process.platform), user ID and what runs this CLI. */
53
+ platform?: NodeJS.Platform
54
+ uid?: number
55
+ program?: Program
56
+ /** `hub node openshell check`: the openshell executable (default "openshell" on PATH). */
57
+ openshellBinary?: string
58
+ /** `hubctl machine …` / `hubctl setup …`: test seams (fakes for the network, clock and service manager). */
59
+ bootstrap?: BootstrapSeams
60
+ }
61
+
62
+ export const USAGE = `Usage: hub <command> [options] (also installed as hubctl)
63
+
64
+ Reading
65
+ hub status machines → projects → sessions, and what needs attention
66
+ hub sessions [--project P] [--state S[,S]] [--machine M] [--label k=v]
67
+ hub show <addr> one session: state, blockers, activity, summary
68
+ hub log <addr> [-n 20] the newest timeline rows (1–200)
69
+ hub watch [--blocked] [--session <addr>] events as JSON lines until interrupted
70
+
71
+ Identity
72
+ hub whoami who this credential is
73
+ hub who [--project P] [--role R] named and role-holding sessions
74
+ hub name <@name> [--session <addr>]
75
+ hub role claim <role[.project]> [--exclusive] [--session <addr>]
76
+ hub role release <role[.project]> [--session <addr>]
77
+
78
+ Messaging (text "-" reads it from stdin)
79
+ hub msg <addr> "text" [--all] [--no-wake] [--urgent] tell: fire and forget
80
+ hub ask <addr> "text" [--wait [secs]] [--expires 2h] [--all] [--no-wake] [--urgent]
81
+ tracked until answered, declined or expired; --wait alone waits until
82
+ it expires; exit 0 answered, 1 declined or no answer yet, 8 expired
83
+ hub reply <msgID> "text" [--no-wake] goes to the real sender
84
+ hub decline <msgID> --why "reason"
85
+ hub ask @me "question" [--option id=label[:safe]]... [--recommend id [--why "…"]] [--urgency low|normal|high]
86
+ [--deadline 2h [--on-timeout <safe id>]] [--tier confirm|tap] [--about <addr>]...
87
+ opens a Decision for the user (answered in the console; the answer
88
+ comes back as a reply); --on-timeout picks a safe option at the deadline
89
+ hub decisions list [--state open|answered|expired|closed] the Decisions you asked
90
+ hub inbox asks to answer, your open asks, recent messages, mutes
91
+ hub mute <addr> · hub unmute <addr> refuse (or accept again) messages from a session
92
+
93
+ Acting (users; agents under a grant, else exit 4 needs user with the evaluation)
94
+ hub approve <addr> <requestID> [--always] [--why "…"] answer a permission request (once, or always)
95
+ hub deny <addr> <requestID> [--why "…"] reject it; the reason reaches the harness
96
+ hub answer <addr> <formID> key=value… | --json '{…}' | --json - | --cancel [--why "…"]
97
+ answer a form (a repeated key is a list); --cancel dismisses it
98
+ hub prompt <addr> "text" [--delivery queue|steer] [--why "…"] prompt a session (text "-" reads stdin)
99
+ hub stop <addr> [--why "…"] stop its running turn
100
+
101
+ Grants (the user only: the console, or \`hub login\` with a device token; exit 5 otherwise)
102
+ hub grant list · hub grant show <grantID> · hub grant revoke <grantID>
103
+ hub grant create --holder <ses_…|@name|@@orchestrator…|app:<id>> --project <path>... --allow approve,deny,…
104
+ [--permit <pattern>]... [--never <pattern>]... [--expires 8h] [--max-per-hour N] [--allow-always]
105
+ | --json '{…}' | --json - what a session or app token may do, in which projects, until when
106
+ verbs: approve, deny, answer, prompt, stop, roles, launch (start sessions)
107
+ hub grant request --project <path>... --allow approve,deny,… [--permission-allow <pattern>]... [--never <pattern>]...
108
+ [--expires 8h] [--max-per-hour N] [--allow-always] [--holder <@@role you hold>] --why "…"
109
+ (agents) ask the user for a grant: opens a tap Decision; the answer comes
110
+ back as a reply (with the grant id when approved)
111
+
112
+ Launching (users; agents and app tokens with sessions:launch under a grant with verb launch)
113
+ hub launch [--repo <path, default .>] [--mode worktree|checkout|clone] [--branch B] [--base B] [--remote URL]
114
+ [--name <@name>] [--role R]... [--harness H] [--agent A] [--model provider/model]
115
+ [--runtime host|openshell [--profile P]] [--machine M] [--node-label k=v]... [--prefer]
116
+ [--label k=v]... [--prompt "text" | --prompt -] [--end idle|asked|never] [--timeout 4h]
117
+ [--cleanup keep|remove] [--key K]
118
+ start a session; the same --key again answers the same launch;
119
+ --label tags it for apps, --node-label picks nodes (--prefer: not required)
120
+ hub launches list [--state S[,S]] [--label k=v]... your launches (agents: the ones they started)
121
+ hub launches show <launchID> · hub launches stop <launchID> (stop: exit 8 when it already ended)
122
+
123
+ Machine (this machine's one identity; no harness needed)
124
+ hub machine enroll --server <hub URL> [--name N] [--reenroll] [--no-verify]
125
+ device-code enrollment: approve the code on the hub (Ctrl-C cancels)
126
+ hub machine status [--no-verify] unconfigured, configured, verified, revoked (exit 9), offline (exit 7)
127
+ or insecure (a plain-http hub: reported, never sent the credential)
128
+
129
+ Setup (discover → preview → explicit apply; docs/bootstrap-contracts.md)
130
+ hub setup discover what this host has (read-only)
131
+ hub setup preview [--profile session-only|launch-node|full-harness|<file>|-] [--manifest <file>]
132
+ [--out <file>] [--enrollment inline|separate] [--server <hub URL> [--name N]]
133
+ [--workspace-root <dir>]... [--verify-launch <harness>] [--confirm-activation] [--allow-downgrade]
134
+ the exact plan and the manual edits it needs; writes nothing (--out
135
+ keeps the preview for apply, mode 0600)
136
+ hub setup apply --preview <file> [--manifest <file>] [--escalate] [--confirm-activation] [--allow-downgrade]
137
+ rediscover, re-plan and apply exactly that plan (journaled; 12 when the
138
+ host changed since the preview, 13 locked, 14 recover first)
139
+ hub setup verify [--profile P] real probes: version, provider login, spoke connected (15 not ready)
140
+ hub setup drift [--profile P] hub's managed fields: in sync, missing, outdated or changed by you
141
+ hub setup rollback [--to <txID>] [--dry-run] undo applied transactions (your later edits are kept)
142
+ hub setup recover [--resume] finish or roll back an interrupted apply
143
+ hub setup ansible --profile P --manifest <file> --release-url <url> [--plan <preview>] [consent flags]
144
+ the vars of contrib/ansible/roles/hub_bootstrap (no secrets)
145
+
146
+ Node (this machine runs launches; design §6.3)
147
+ hub node run the node daemon until Ctrl-C/SIGTERM (needs an enrolled machine and
148
+ ~/.config/hub/node.json with "workspaceRoots")
149
+ hub node install [--dry-run] write the launchd agent (macOS) or systemd user unit (Linux) and print
150
+ the commands that load it (never run by hub); --dry-run only prints
151
+ hub node uninstall [--dry-run] remove it and print the commands that stop it
152
+ hub node status the service file, enrolment and node.json
153
+ hub node openshell check can this node run sandboxed launches: the openshell CLI (0.1.x), its
154
+ gateway, workspace hub, node.json's openshell section, profiles, image
155
+ and providers (read-only; exit 1 when something is wrong)
156
+
157
+ Claude Code (hooks spoke)
158
+ hub claude [claude args…] run Claude Code with the hub plugin (--plugin-dir; ~/.claude is never
159
+ changed), starting the per-user daemon when needed (an enrolled machine);
160
+ env HUB_CLAUDE_PERMISSION_WAIT=<s> (default 120, 0: report only), HUB_CLAUDE_BIN
161
+ hub claude-code daemon|hook|mcp the spoke's daemon, hook bridge and MCP pipe (run by hub claude and the plugin)
162
+ hub claude-code install|uninstall [--dry-run] | status
163
+ the daemon as a launchd agent / systemd user unit (like hub node install:
164
+ load commands printed, never run), so launches need no hub claude run
165
+
166
+ Presets (harnesses without a spoke, driven through a tmux pane; evidence screen)
167
+ hub preset list the official presets
168
+ hub preset check <name|file> validate a preset (data only: an argv, never run through a shell)
169
+ hub preset run <name|file> [--socket <name> | --current] [--cwd <dir>] [--resume <id>] [--no-hub] [-- args…]
170
+ start it in a new pane on a private tmux server (-L hub-preset; your own
171
+ tmux only with --current) and connect it to the hub until the pane ends;
172
+ hub messages are pasted (never confirmed), Ctrl-C for stop/interrupt
173
+ Codex (app-server spoke)
174
+ hub codex [codex args…] run Codex with the shared app-server daemon on (--enable daemon_auto_start;
175
+ ~/.codex is never changed), starting the per-user spoke when needed (an
176
+ enrolled machine); env HUB_CODEX_BIN, HUB_CODEX_CONFIG (-c lines for the spoke)
177
+ hub codex-spoke serve the spoke service (run by hub codex); attaches to the daemon's threads
178
+ hub codex-spoke install|uninstall [--dry-run] | status
179
+ the spoke service as a launchd agent / systemd user unit (printed, never loaded)
180
+
181
+ MCP
182
+ hub mcp an MCP server on stdio: these verbs as tools, with this CLI's credential
183
+
184
+ Login (outside a hub session; a machine enrollment is not a user login)
185
+ hub login [--url URL] [--scope S]... [--name N]
186
+ device code: approve it in the console as yourself and choose the scopes
187
+ (default sessions:read, messages:send, messages:read; launching needs
188
+ --scope sessions:launch, or tick it on the page, plus a grant with verb launch);
189
+ the URL defaults to HUB_URL, the last login, else this machine's enrollment
190
+ hub login [--url URL] --token APP_TOKEN|- store an app token (- or a pipe: read from stdin)
191
+ hub logout
192
+
193
+ Options: --json (raw responses), --help, --version
194
+ Addresses: ses_… (or an unambiguous prefix), @name, @@role, @name.project, @me (the user)
195
+ Credentials: HUB_URL + HUB_SESSION_TOKEN (set in a session's shells), else the \`hub login\` file.
196
+ Exit codes: 0 ok, 1 other, 2 usage, 3 not found/ambiguous/taken, 4 needs user, 5 forbidden/paused,
197
+ 6 rate limited, 7 unsupported/offline, 8 gone, 9 unauthorized/expired`
198
+
199
+ interface Context {
200
+ readonly io: Io
201
+ readonly parsed: Parsed
202
+ readonly json: boolean
203
+ credentials(): Credentials
204
+ /** Calls the hub with the resolved credentials (SDK errors become CliErrors). */
205
+ hub<T>(use: (hub: HubClient) => Promise<T>): Promise<T>
206
+ }
207
+
208
+ interface Command extends CommandSpec {
209
+ /** Resolves the exit code when it is not 0. */
210
+ handle(context: Context): Promise<number | void>
211
+ }
212
+
213
+ const out = (io: Io, text: string) => io.stdout(text.endsWith("\n") ? text : `${text}\n`)
214
+ const printJSON = (io: Io, value: unknown) => out(io, JSON.stringify(value, null, 2))
215
+
216
+ /** Warns when this shell's OpenCode session is not the one the credential acts as (design §1.3, 31swaq28). */
217
+ function crossCheck(io: Io, actor: Actor | undefined) {
218
+ const own = io.env.OPENCODE_SESSION_ID
219
+ if (!own || !actor || (actor.kind === "session" && actor.sessionID === own)) return
220
+ const as = actor.kind === "session" ? `session ${actor.sessionID}` : actor.kind === "app" ? "an app token" : "the user"
221
+ io.stderr(`hub: warning: this shell belongs to OpenCode session ${own}, but the credential acts as ${as}\n`)
222
+ }
223
+
224
+ /** Prints the raw answer with --json, else its text. */
225
+ const answer = (context: Context, body: unknown, text: (body: any) => string) => (context.json ? printJSON(context.io, body) : out(context.io, text(body)))
226
+
227
+ /** context.hub for the grant routes: a forbidden says who may manage grants. */
228
+ const grantHub = <T>(context: Context, use: (hub: HubClient) => Promise<T>): Promise<T> =>
229
+ context.hub(use).catch((error) => {
230
+ throw grantError(error)
231
+ })
232
+
233
+ /** A forbidden launch with an app token (`hub login`): what else it needs. */
234
+ function launchError(error: unknown, source: string): unknown {
235
+ if (!(error instanceof CliError) || error.exit !== EXIT.forbidden || source !== "login") return error
236
+ const hint = "an app token launches only under a grant with verb launch covering the repo: create one in the console, or `hub grant request --project <repo> --allow launch --why \"…\"`"
237
+ return new CliError(error.exit, error.message, [error.hint, hint].filter(Boolean).join("; "), error.envelope)
238
+ }
239
+
240
+ /** No user credential: says so, and when this machine is enrolled, that its enrollment is not a user login. */
241
+ function notLoggedIn(io: Io, label: string): CliError {
242
+ let machine: { server: string; machineID?: string } | undefined
243
+ try {
244
+ machine = readSpokeCredentials(io.env, io.home)
245
+ } catch {
246
+ machine = undefined
247
+ }
248
+ if (machine) {
249
+ return new CliError(
250
+ EXIT.unauthorized,
251
+ `this machine is enrolled (${machine.machineID ?? "machine"}), but ${label === "launch" ? "launching" : `\`hub ${label}\``} needs a user login: run \`hub login\``,
252
+ `the machine credential identifies this machine to ${machine.server}, never you; \`hub login\` opens a device code you approve in the console as yourself`,
253
+ )
254
+ }
255
+ return new CliError(EXIT.unauthorized, "not logged in to a hub", "Run `hub login --url <hub URL>` and approve the code in the console, or run inside a hub session's shell (HUB_URL + HUB_SESSION_TOKEN)")
256
+ }
257
+
258
+ /** --why as SDK act options. */
259
+ const whyOf = (flags: Parsed["flags"]) => {
260
+ const why = str(flags, "why")
261
+ return why !== undefined ? { why } : {}
262
+ }
263
+
264
+ /** --no-wake and --urgent as SDK send options. */
265
+ const sendFlags = (flags: Parsed["flags"]) => ({ ...(flags["no-wake"] === true ? { wake: false } : {}), ...(flags.urgent === true ? { urgent: true } : {}) })
266
+
267
+ const nodeIo = (io: Io): NodeIo => ({
268
+ env: io.env,
269
+ home: io.home,
270
+ platform: io.platform ?? process.platform,
271
+ uid: io.uid ?? process.getuid?.() ?? 0,
272
+ program: io.program ?? currentProgram(),
273
+ })
274
+
275
+ const spokeStatusOutput = (context: Context, name: SpokeServiceName) => {
276
+ const status = spokeServiceStatus(name, nodeIo(context.io))
277
+ if (context.json) printJSON(context.io, status)
278
+ else out(context.io, spokeServiceStatusText(name, status))
279
+ }
280
+
281
+ const nodeOutput = (io: Io, output: { stdout: string; warnings: readonly string[] }) => {
282
+ for (const warning of output.warnings) io.stderr(`hub: warning: ${warning}\n`)
283
+ out(io, output.stdout)
284
+ }
285
+
286
+ const COMMANDS: Record<string, Command> = {
287
+ whoami: {
288
+ async handle(context) {
289
+ const body = await context.hub((hub) => hub.whoami())
290
+ answer(context, body, whoamiText)
291
+ crossCheck(context.io, body.actor)
292
+ },
293
+ },
294
+ status: {
295
+ async handle(context) {
296
+ const body = await context.hub((hub) => hub.status())
297
+ answer(context, body, statusText)
298
+ crossCheck(context.io, body.you)
299
+ },
300
+ },
301
+ sessions: {
302
+ flags: { project: "string", state: "string", machine: "string", label: "multi" },
303
+ async handle(context) {
304
+ const { flags } = context.parsed
305
+ const filter = { project: str(flags, "project"), state: str(flags, "state"), machine: str(flags, "machine"), label: flags.label as string[] | undefined }
306
+ const body = await context.hub((hub) => hub.sessions(filter))
307
+ answer(context, body, (b) => sessionsText(b.sessions ?? []))
308
+ },
309
+ },
310
+ show: {
311
+ args: ["<addr>"],
312
+ async handle(context) {
313
+ const body = await context.hub((hub) => hub.show(context.parsed.args[0]!))
314
+ answer(context, body, showText)
315
+ },
316
+ },
317
+ log: {
318
+ args: ["<addr>"],
319
+ flags: { n: "string" },
320
+ async handle(context) {
321
+ const n = str(context.parsed.flags, "n")
322
+ if (n !== undefined && !/^\d+$/.test(n)) throw new UsageError("-n must be a number (1–200)")
323
+ const body = await context.hub((hub) => hub.log(context.parsed.args[0]!, { n }))
324
+ answer(context, body, (b) => logText(b.rows ?? []))
325
+ },
326
+ },
327
+ who: {
328
+ args: ["@@role?"],
329
+ flags: { project: "string", role: "string" },
330
+ async handle(context) {
331
+ const { flags, args } = context.parsed
332
+ const role = str(flags, "role") ?? args[0]
333
+ const body = await context.hub((hub) => hub.who({ project: str(flags, "project"), role }))
334
+ answer(context, body, (b) => whoText(b.sessions ?? []))
335
+ },
336
+ },
337
+ name: {
338
+ args: ["<@name>"],
339
+ flags: { session: "string" },
340
+ async handle(context) {
341
+ const session = str(context.parsed.flags, "session")
342
+ const body = await context.hub((hub) => hub.name(context.parsed.args[0]!, { session }))
343
+ answer(context, body, (b) => `${b.sessionID} is ${atName(b.name)}${b.alias ? ` (was ${atName(b.alias)}, kept as an alias for 24 h)` : ""}`)
344
+ },
345
+ },
346
+ "role claim": {
347
+ args: ["<role>"],
348
+ flags: { exclusive: "bool", session: "string" },
349
+ async handle(context) {
350
+ const { flags, args } = context.parsed
351
+ const session = str(flags, "session")
352
+ const body = await context.hub((hub) => hub.claimRole(args[0]!, { exclusive: flags.exclusive === true, session }))
353
+ answer(context, body, (b) => `${b.sessionID} holds ${roleText(b)}${b.exclusive ? " (exclusive)" : ""}${b.generation !== undefined ? ` generation ${b.generation}` : ""}`)
354
+ },
355
+ },
356
+ "role release": {
357
+ args: ["<role>"],
358
+ flags: { session: "string" },
359
+ async handle(context) {
360
+ const session = str(context.parsed.flags, "session")
361
+ const body = await context.hub((hub) => hub.releaseRole(context.parsed.args[0]!, { session }))
362
+ answer(context, body, (b) => `${b.sessionID} released ${roleText(b)}`)
363
+ },
364
+ },
365
+ watch: {
366
+ flags: { blocked: "bool", session: "string" },
367
+ async handle({ io, parsed, credentials }) {
368
+ await watch(credentials(), { blocked: parsed.flags.blocked === true, session: str(parsed.flags, "session") }, (line) => io.stdout(line), io.signal)
369
+ },
370
+ },
371
+ msg: {
372
+ args: ["<addr>", "<text>"],
373
+ flags: { all: "bool", "no-wake": "bool", urgent: "bool" },
374
+ async handle(context) {
375
+ const { flags, args } = context.parsed
376
+ const text = await textArg(args[1]!, context.io.stdin)
377
+ const options = sendFlags(flags)
378
+ const body = await context.hub<Sent | SentAll>((hub) => (flags.all === true ? hub.msg(args[0]!, text, { ...options, all: true }) : hub.msg(args[0]!, text, options)))
379
+ answer(context, body, (b) => ("sent" in b ? b.sent.map(sentText).join("\n") || "no recipients" : sentText(b)))
380
+ },
381
+ },
382
+ ask: {
383
+ args: ["<addr>", "<text>"],
384
+ flags: { all: "bool", "no-wake": "bool", urgent: "bool", expires: "string", wait: "optional", ...DECISION_FLAGS },
385
+ async handle(context) {
386
+ const { flags, args } = context.parsed
387
+ const decision = decisionOptions(flags, args[0]!)
388
+ const text = await textArg(args[1]!, context.io.stdin)
389
+ const expiresFlag = str(flags, "expires")
390
+ const expires = expiresFlag === undefined ? undefined : durationArg(expiresFlag, "--expires")
391
+ const wait = flags.wait === undefined ? undefined : flags.wait === true ? (expires ?? ASK_EXPIRES_DEFAULT_S) : durationArg(String(flags.wait), "--wait")
392
+ const options = { ...sendFlags(flags), ...(expires !== undefined ? { expires } : {}) }
393
+ if (flags.all === true) {
394
+ if (wait !== undefined) throw new UsageError("--wait cannot be combined with --all")
395
+ const body = await context.hub((hub) => hub.ask(args[0]!, text, { ...options, all: true }))
396
+ answer(context, body, (b) => b.sent.map(sentText).join("\n") || "no recipients")
397
+ return
398
+ }
399
+ const body = await context.hub<Asked>((hub) => hub.ask(args[0]!, text, { ...options, ...decision, ...(wait !== undefined ? { wait } : {}) }))
400
+ const outcome = askOutcome(body, wait)
401
+ answer(context, body, () => (body.decision ? `${outcome.text}\n${openedText(body.decision)}` : outcome.text))
402
+ if (outcome.note) context.io.stderr(`hub: ${outcome.note}\n`)
403
+ return outcome.exit
404
+ },
405
+ },
406
+ reply: {
407
+ args: ["<msgID>", "<text>"],
408
+ flags: { "no-wake": "bool" },
409
+ async handle(context) {
410
+ const { flags, args } = context.parsed
411
+ const text = await textArg(args[1]!, context.io.stdin)
412
+ const body = await context.hub((hub) => hub.reply(args[0]!, text, flags["no-wake"] === true ? { wake: false } : {}))
413
+ answer(context, body, repliedText)
414
+ },
415
+ },
416
+ decline: {
417
+ args: ["<msgID>"],
418
+ flags: { why: "string" },
419
+ async handle(context) {
420
+ const { flags, args } = context.parsed
421
+ const why = str(flags, "why")
422
+ if (why === undefined) throw new UsageError('needs --why "reason"')
423
+ const reason = await textArg(why, context.io.stdin, "reason")
424
+ const body = await context.hub((hub) => hub.decline(args[0]!, reason))
425
+ answer(context, body, declinedText)
426
+ },
427
+ },
428
+ "decisions list": {
429
+ flags: { state: "string" },
430
+ async handle(context) {
431
+ const state = stateArg(str(context.parsed.flags, "state"))
432
+ const body = await context.hub((hub) => hub.decisions(state ? { state } : {}))
433
+ answer(context, body, (b) => decisionsText(b.decisions ?? []))
434
+ },
435
+ },
436
+ inbox: {
437
+ async handle(context) {
438
+ const body = await context.hub((hub) => hub.inbox())
439
+ answer(context, body, inboxText)
440
+ },
441
+ },
442
+ mute: {
443
+ args: ["<addr>"],
444
+ async handle(context) {
445
+ const body = await context.hub((hub) => hub.mute(context.parsed.args[0]!))
446
+ answer(context, body, (b) => mutedText(b.muted))
447
+ },
448
+ },
449
+ unmute: {
450
+ args: ["<addr>"],
451
+ async handle(context) {
452
+ const body = await context.hub((hub) => hub.unmute(context.parsed.args[0]!))
453
+ answer(context, body, (b) => mutedText(b.muted))
454
+ },
455
+ },
456
+ approve: {
457
+ args: ["<addr>", "<requestID>"],
458
+ flags: { always: "bool", why: "string" },
459
+ async handle(context) {
460
+ const { flags, args } = context.parsed
461
+ const body = await context.hub((hub) => hub.approve(args[0]!, args[1]!, { ...(flags.always === true ? { always: true } : {}), ...whyOf(flags) }))
462
+ answer(context, body, approvedText)
463
+ },
464
+ },
465
+ deny: {
466
+ args: ["<addr>", "<requestID>"],
467
+ flags: { why: "string" },
468
+ async handle(context) {
469
+ const { flags, args } = context.parsed
470
+ const body = await context.hub((hub) => hub.deny(args[0]!, args[1]!, whyOf(flags)))
471
+ answer(context, body, deniedText)
472
+ },
473
+ },
474
+ answer: {
475
+ args: ["<addr>", "<formID>", "key=value..."],
476
+ flags: { json: "json", cancel: "bool", why: "string" },
477
+ async handle(context) {
478
+ const { flags, args } = context.parsed
479
+ const [addr, formID, ...pairs] = args as [string, string, ...string[]]
480
+ const given = await formAnswerArg(pairs, flags, context.io.stdin)
481
+ const options = whyOf(flags)
482
+ const body = await context.hub((hub) => (given === "cancel" ? hub.cancelForm(addr, formID, options) : hub.answer(addr, formID, given, options)))
483
+ answer(context, body, (b) => answeredText(b, given === "cancel"))
484
+ },
485
+ },
486
+ prompt: {
487
+ args: ["<addr>", "<text>"],
488
+ flags: { delivery: "string", why: "string" },
489
+ async handle(context) {
490
+ const { flags, args } = context.parsed
491
+ const delivery = deliveryArg(str(flags, "delivery"))
492
+ const text = await textArg(args[1]!, context.io.stdin)
493
+ const body = await context.hub((hub) => hub.prompt(args[0]!, text, { ...(delivery ? { delivery } : {}), ...whyOf(flags) }))
494
+ answer(context, body, promptedText)
495
+ },
496
+ },
497
+ stop: {
498
+ args: ["<addr>"],
499
+ flags: { why: "string" },
500
+ async handle(context) {
501
+ const { flags, args } = context.parsed
502
+ const body = await context.hub((hub) => hub.stop(args[0]!, whyOf(flags)))
503
+ answer(context, body, stoppedText)
504
+ },
505
+ },
506
+ "grant list": {
507
+ async handle(context) {
508
+ const body = await grantHub(context, (hub) => hub.grants())
509
+ answer(context, body, (b) => grantsText(b.grants ?? []))
510
+ },
511
+ },
512
+ "grant show": {
513
+ args: ["<grantID>"],
514
+ async handle(context) {
515
+ const body = await grantHub(context, (hub) => hub.grant(context.parsed.args[0]!))
516
+ answer(context, body, (b) => grantText(b.grant))
517
+ },
518
+ },
519
+ "grant create": {
520
+ flags: GRANT_FLAGS,
521
+ async handle(context) {
522
+ userOnly(context.credentials().source, "create")
523
+ const spec = await grantSpecArg(context.parsed.flags, context.io.stdin)
524
+ const body = await grantHub(context, (hub) => hub.createGrant(spec))
525
+ answer(context, body, (b) => `created ${grantText(b.grant)}`)
526
+ },
527
+ },
528
+ "grant request": {
529
+ flags: GRANT_REQUEST_FLAGS,
530
+ async handle(context) {
531
+ const why = str(context.parsed.flags, "why")
532
+ if (!why?.trim()) throw new UsageError('needs --why "…" (the reason the user reads)')
533
+ const grant = await grantRequestArg(context.parsed.flags, context.io.stdin)
534
+ const body = await context.hub((hub) => hub.requestGrant(grant, why))
535
+ answer(context, body, requestedText)
536
+ },
537
+ },
538
+ "grant revoke": {
539
+ args: ["<grantID>"],
540
+ async handle(context) {
541
+ userOnly(context.credentials().source, "revoke")
542
+ const body = await grantHub(context, (hub) => hub.revokeGrant(context.parsed.args[0]!))
543
+ answer(context, body, (b) => `revoked ${b.grant.id} (${b.grant.holder})`)
544
+ },
545
+ },
546
+ launch: {
547
+ flags: LAUNCH_FLAGS,
548
+ async handle(context) {
549
+ const spec = await launchSpecArg(context.parsed.flags, { cwd: context.io.cwd ?? process.cwd(), ...(context.io.stdin ? { stdin: context.io.stdin } : {}) })
550
+ const body = await context.hub((hub) => hub.launches.create(spec)).catch((error) => {
551
+ throw launchError(error, context.credentials().source)
552
+ })
553
+ if (context.json) printJSON(context.io, { launch: body.launch })
554
+ else out(context.io, launchedText(body.launch, body.created, spec.key))
555
+ },
556
+ },
557
+ "launches list": {
558
+ flags: { state: "string", label: "multi" },
559
+ async handle(context) {
560
+ const { flags } = context.parsed
561
+ const state = str(flags, "state")?.split(",").filter(Boolean) as LaunchState[] | undefined
562
+ const body = await context.hub((hub) => hub.launches.list({ ...(state?.length ? { state } : {}), ...(flags.label ? { label: flags.label as string[] } : {}) }))
563
+ answer(context, body, (b) => launchesText(b.launches ?? []))
564
+ },
565
+ },
566
+ "launches show": {
567
+ args: ["<launchID>"],
568
+ async handle(context) {
569
+ const body = await context.hub((hub) => hub.launches.get(context.parsed.args[0]!))
570
+ answer(context, body, (b) => launchText(b.launch))
571
+ },
572
+ },
573
+ "launches stop": {
574
+ args: ["<launchID>"],
575
+ async handle(context) {
576
+ const body = await context.hub((hub) => hub.launches.stop(context.parsed.args[0]!))
577
+ answer(context, body, (b) => stoppedLaunchText(b.launch))
578
+ },
579
+ },
580
+ "node run": {
581
+ async handle({ io }) {
582
+ return runNodeCommand(io)
583
+ },
584
+ },
585
+ "node install": {
586
+ flags: { "dry-run": "bool" },
587
+ async handle(context) {
588
+ nodeOutput(context.io, installNode(nodeIo(context.io), { dryRun: context.parsed.flags["dry-run"] === true }))
589
+ },
590
+ },
591
+ "node uninstall": {
592
+ flags: { "dry-run": "bool" },
593
+ async handle(context) {
594
+ nodeOutput(context.io, uninstallNode(nodeIo(context.io), { dryRun: context.parsed.flags["dry-run"] === true }))
595
+ },
596
+ },
597
+ "node status": {
598
+ async handle(context) {
599
+ const status = nodeStatus(nodeIo(context.io))
600
+ if (context.json) printJSON(context.io, status)
601
+ else out(context.io, nodeStatusText(status))
602
+ },
603
+ },
604
+ "claude-code install": {
605
+ flags: { "dry-run": "bool" },
606
+ async handle(context) {
607
+ nodeOutput(context.io, installSpokeService("claude-code", nodeIo(context.io), { dryRun: context.parsed.flags["dry-run"] === true }))
608
+ },
609
+ },
610
+ "claude-code uninstall": {
611
+ flags: { "dry-run": "bool" },
612
+ async handle(context) {
613
+ nodeOutput(context.io, uninstallSpokeService("claude-code", nodeIo(context.io), { dryRun: context.parsed.flags["dry-run"] === true }))
614
+ },
615
+ },
616
+ "claude-code status": {
617
+ async handle(context) {
618
+ spokeStatusOutput(context, "claude-code")
619
+ },
620
+ },
621
+ "codex-spoke install": {
622
+ flags: { "dry-run": "bool" },
623
+ async handle(context) {
624
+ nodeOutput(context.io, installSpokeService("codex-spoke", nodeIo(context.io), { dryRun: context.parsed.flags["dry-run"] === true }))
625
+ },
626
+ },
627
+ "codex-spoke uninstall": {
628
+ flags: { "dry-run": "bool" },
629
+ async handle(context) {
630
+ nodeOutput(context.io, uninstallSpokeService("codex-spoke", nodeIo(context.io), { dryRun: context.parsed.flags["dry-run"] === true }))
631
+ },
632
+ },
633
+ "codex-spoke status": {
634
+ async handle(context) {
635
+ spokeStatusOutput(context, "codex-spoke")
636
+ },
637
+ },
638
+ "node openshell check": {
639
+ async handle(context) {
640
+ const { io } = context
641
+ const report = await checkOpenShell({ env: io.env, home: io.home, ...(io.openshellBinary ? { binary: io.openshellBinary } : {}) })
642
+ if (context.json) printJSON(io, report)
643
+ else out(io, openShellCheckText(report))
644
+ return report.ok ? EXIT.ok : EXIT.other
645
+ },
646
+ },
647
+ mcp: {
648
+ async handle({ io, credentials }) {
649
+ if (!io.input) throw new UsageError("needs stdin")
650
+ await serveMcp({ input: io.input, write: io.stdout, log: io.stderr, credentials, version: VERSION, ...(io.signal ? { signal: io.signal } : {}) })
651
+ },
652
+ },
653
+ login: {
654
+ flags: { url: "string", token: "string", scope: "multi", name: "string" },
655
+ async handle(context) {
656
+ const { io, parsed } = context
657
+ const file = loginFile(io.env, io.home)
658
+ const machine = (() => {
659
+ try {
660
+ return readSpokeCredentials(io.env, io.home)
661
+ } catch {
662
+ return undefined
663
+ }
664
+ })()
665
+ const url = (str(parsed.flags, "url") ?? io.env.HUB_URL ?? readLogin(file)?.url ?? machine?.server)?.replace(/\/+$/, "")
666
+ if (!url) throw new UsageError("needs --url <hub URL> (or HUB_URL)")
667
+ const given = str(parsed.flags, "token")
668
+ // A token: --token, `--token -` or a pipe (stdin); else (a terminal) the device code.
669
+ const piped = given === undefined && io.stdin !== undefined && io.stdinIsTerminal !== true
670
+ let token: string
671
+ let notes: string[] = []
672
+ let scopes: readonly string[] | undefined
673
+ if (given !== undefined || piped) {
674
+ if (parsed.flags.scope !== undefined) throw new UsageError("--scope is for the device code (without --token); an app token's scopes are set when it is created")
675
+ token = (given !== undefined && given !== "-" ? given : io.stdin ? await io.stdin() : "").trim()
676
+ if (!token) throw new UsageError("needs an app token: --token, or on stdin (create one in the console: Settings → Agents), or run `hub login` in a terminal for a device code")
677
+ } else {
678
+ const requested = ((parsed.flags.scope as string[] | undefined) ?? []).flatMap((s) => s.split(",")).map((s) => s.trim()).filter(Boolean)
679
+ const login = await deviceLogin({
680
+ server: url,
681
+ clientName: str(parsed.flags, "name") ?? io.login?.clientName ?? defaultMachineName(hostname()),
682
+ scopes: requested,
683
+ ...(io.signal ? { signal: io.signal } : {}),
684
+ ...(io.login?.fetch ? { fetch: io.login.fetch } : {}),
685
+ ...(io.login?.intervalMs !== undefined ? { intervalMs: io.login.intervalMs } : {}),
686
+ onPrompt: (prompt) => io.stderr(`${promptText(prompt)}\n`),
687
+ })
688
+ token = login.token
689
+ scopes = login.scopes
690
+ notes = scopeNotes(login.scopes, login.tokenID)
691
+ }
692
+ const credentials: Credentials = { url, token, source: "login" }
693
+ const body = await withHub(credentials, (hub) => hub.whoami())
694
+ writeLogin(file, { url, token })
695
+ answer(context, body, (b) => [`logged in to ${url}`, whoamiText(b), ...(scopes ? [`scopes: ${scopes.join(", ")}`] : []), `saved in ${file}`, ...notes].join("\n"))
696
+ },
697
+ },
698
+ logout: {
699
+ async handle(context) {
700
+ const file = loginFile(context.io.env, context.io.home)
701
+ out(context.io, removeLogin(file) ? `logged out (deleted ${file}; the app token stays valid until revoked in the console)` : "not logged in")
702
+ },
703
+ },
704
+ }
705
+
706
+ // `hubctl machine …` and `hubctl setup …` print hub.bootstrap.result envelopes and map exits through exitFor.
707
+ for (const label of Object.keys(BOOTSTRAP_FLAGS))
708
+ COMMANDS[label] = {
709
+ flags: BOOTSTRAP_FLAGS[label],
710
+ handle: ({ io, parsed }) =>
711
+ bootstrapCommand(label, parsed, {
712
+ env: io.env,
713
+ home: io.home,
714
+ stdout: io.stdout,
715
+ stderr: io.stderr,
716
+ ...(io.stdin ? { stdin: io.stdin } : {}),
717
+ ...(io.signal ? { signal: io.signal } : {}),
718
+ ...(io.cwd ? { cwd: io.cwd } : {}),
719
+ program: io.program ?? currentProgram(),
720
+ version: VERSION,
721
+ ...(io.bootstrap ? { bootstrap: io.bootstrap } : {}),
722
+ }),
723
+ }
724
+
725
+ /** Commands with sub-verbs (`hub role claim`). */
726
+ const GROUPS: Record<string, string[]> = { machine: [...MACHINE_VERBS], setup: [...SETUP_VERBS], "claude-code": [...SERVICE_VERBS], "codex-spoke": [...SERVICE_VERBS], role: ["claim", "release"], decisions: ["list"], grant: ["list", "show", "create", "revoke", "request"], launches: ["list", "show", "stop"], node: ["run", "install", "uninstall", "status", "openshell"], "node openshell": ["check"] }
727
+
728
+ const groupVerbs = (label: string): string[] | undefined => (Object.hasOwn(GROUPS, label) ? GROUPS[label] : undefined)
729
+
730
+ function reportError(io: Io, error: CliError, json: boolean) {
731
+ if (json && error.envelope) printJSON(io, { error: error.envelope })
732
+ io.stderr(`hub: ${error.message}\n`)
733
+ const envelope = error.envelope
734
+ const candidates = envelope?.candidates
735
+ if (Array.isArray(candidates) && candidates.length) {
736
+ io.stderr("candidates:\n")
737
+ for (const c of candidates as { sessionID?: string; name?: string; title?: string }[]) {
738
+ io.stderr(` ${[c.sessionID, c.name ? `@${String(c.name).replace(/^@/, "")}` : undefined, c.title].filter(Boolean).join(" ")}\n`)
739
+ }
740
+ }
741
+ if (typeof envelope?.feature === "string") io.stderr(`missing feature: ${envelope.feature}${typeof envelope.harness === "string" ? ` (${envelope.harness})` : ""}\n`)
742
+ if (envelope?.code === "needs_user") for (const line of needsUserLines(envelope)) io.stderr(`${line}\n`)
743
+ if (typeof envelope?.retryAfter === "number") io.stderr(`retry after ${Math.ceil(envelope.retryAfter)} s\n`)
744
+ if (error.hint) io.stderr(`hint: ${error.hint}\n`)
745
+ }
746
+
747
+ export async function run(argv: string[], io: Io): Promise<number> {
748
+ const [name, ...rest] = argv
749
+ if (name === undefined) {
750
+ io.stderr(`${USAGE}\n`)
751
+ return EXIT.usage
752
+ }
753
+ if (name === "help" || name === "--help" || name === "-h") {
754
+ out(io, USAGE)
755
+ return EXIT.ok
756
+ }
757
+ if (name === "--version" || name === "-V" || name === "version") {
758
+ out(io, VERSION)
759
+ return EXIT.ok
760
+ }
761
+ // `hub claude` passes every other argument to Claude Code; `hub claude-code` has its own subcommands.
762
+ if (name === "claude") return runClaude(rest, { env: io.env, home: io.home, stderr: io.stderr, program: io.program ?? currentProgram() })
763
+ // Their service verbs (install, uninstall, status) are commands of this CLI, like `hub node install`.
764
+ if (name === "claude-code" && !isServiceVerb(rest[0])) return claudeCodeMain(rest, io)
765
+ if (name === "preset") return presetMain(rest, io)
766
+ // `hub codex` passes every other argument to Codex; `hub codex-spoke serve` is the spoke service it starts.
767
+ if (name === "codex") return runCodex(rest, { env: io.env, home: io.home, stderr: io.stderr, program: io.program ?? currentProgram() })
768
+ if (name === "codex-spoke" && !isServiceVerb(rest[0])) return codexMain(rest, io)
769
+ let label = name
770
+ // Groups nest (`hub node openshell check`): each level picks one of its sub-verbs.
771
+ for (let verbs = groupVerbs(label); verbs; verbs = groupVerbs(label)) {
772
+ const verb = rest.shift()
773
+ if (verb === "--help" || verb === "-h") {
774
+ out(io, USAGE)
775
+ return EXIT.ok
776
+ }
777
+ if (verb === undefined || !verbs.includes(verb)) {
778
+ io.stderr(`hub ${label}: ${verb === undefined ? "needs" : `unknown subcommand ${verb}; use`} ${verbs.join(" or ")}\n`)
779
+ return EXIT.usage
780
+ }
781
+ label = `${label} ${verb}`
782
+ }
783
+ const command = COMMANDS[label]
784
+ if (!command) {
785
+ io.stderr(`hub: unknown command ${name}\nRun \`hub help\` for the commands.\n`)
786
+ return EXIT.usage
787
+ }
788
+ let json = false
789
+ try {
790
+ const parsed = parse(rest, command)
791
+ if (parsed.flags.help) {
792
+ out(io, USAGE)
793
+ return EXIT.ok
794
+ }
795
+ json = parsed.flags.json === true
796
+ const credentials = () => {
797
+ const found = resolveCredentials(io.env, io.home)
798
+ if (!found) throw notLoggedIn(io, label)
799
+ return found
800
+ }
801
+ return (await command.handle({ io, parsed, json, credentials, hub: (use) => withHub(credentials(), use) })) ?? EXIT.ok
802
+ } catch (cause) {
803
+ if (cause instanceof UsageError) {
804
+ io.stderr(`hub ${label}: ${cause.message}\nRun \`hub help\` for usage.\n`)
805
+ return EXIT.usage
806
+ }
807
+ if (cause instanceof CliError) {
808
+ reportError(io, cause, json)
809
+ return cause.exit
810
+ }
811
+ io.stderr(`hub: ${cause instanceof Error ? cause.message : String(cause)}\n`)
812
+ return EXIT.other
813
+ }
814
+ }