@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,75 @@
1
+ // Which hub and token the CLI uses: HUB_URL + HUB_SESSION_TOKEN (a session's identity, set by the spoke in its
2
+ // shells) win; else the login file written by `hub login` ($XDG_CONFIG_HOME or ~/.config, then hub/cli.json,
3
+ // mode 0600) with an app token.
4
+ import { randomBytes } from "node:crypto"
5
+ import { chmodSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs"
6
+ import path from "node:path"
7
+ import type { Fetch } from "../../sdk/src/index.ts"
8
+
9
+ export type Env = Record<string, string | undefined>
10
+
11
+ export interface Credentials {
12
+ readonly url: string
13
+ readonly token: string
14
+ /** env: HUB_URL + HUB_SESSION_TOKEN; login: the login file; http: the caller of the hub's POST /mcp. */
15
+ readonly source: "env" | "login" | "http"
16
+ /** How to reach the hub (default: the global fetch); POST /mcp dispatches in process. */
17
+ readonly fetch?: Fetch
18
+ }
19
+
20
+ export interface Login {
21
+ readonly url: string
22
+ readonly token: string
23
+ }
24
+
25
+ export const loginFile = (env: Env, home: string): string => path.join(env.XDG_CONFIG_HOME || path.join(home, ".config"), "hub", "cli.json")
26
+
27
+ export function readLogin(file: string): Login | undefined {
28
+ let raw: string
29
+ try {
30
+ raw = readFileSync(file, "utf8")
31
+ } catch {
32
+ return undefined
33
+ }
34
+ try {
35
+ const value = JSON.parse(raw) as Partial<Login>
36
+ if (typeof value.url === "string" && value.url && typeof value.token === "string" && value.token) return { url: value.url, token: value.token }
37
+ } catch {
38
+ // unreadable: treated as logged out
39
+ }
40
+ return undefined
41
+ }
42
+
43
+ /**
44
+ * Writes the login file, owner-only (0600; its directory 0700 when created), atomically: a private temporary file
45
+ * next to it is renamed over it, so a reader never sees half a file and a failed write leaves the old one.
46
+ */
47
+ export function writeLogin(file: string, login: Login): void {
48
+ mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 })
49
+ const tmp = `${file}.${process.pid}.${randomBytes(4).toString("hex")}.tmp`
50
+ try {
51
+ writeFileSync(tmp, `${JSON.stringify({ url: login.url, token: login.token }, null, 2)}\n`, { mode: 0o600, flag: "wx" })
52
+ chmodSync(tmp, 0o600)
53
+ renameSync(tmp, file)
54
+ } catch (cause) {
55
+ rmSync(tmp, { force: true })
56
+ throw cause
57
+ }
58
+ }
59
+
60
+ /** Deletes the login file; true when there was one. */
61
+ export function removeLogin(file: string): boolean {
62
+ try {
63
+ readFileSync(file)
64
+ } catch {
65
+ return false
66
+ }
67
+ rmSync(file, { force: true })
68
+ return true
69
+ }
70
+
71
+ export function resolveCredentials(env: Env, home: string): Credentials | undefined {
72
+ if (env.HUB_URL && env.HUB_SESSION_TOKEN) return { url: env.HUB_URL, token: env.HUB_SESSION_TOKEN, source: "env" }
73
+ const login = readLogin(loginFile(env, home))
74
+ return login ? { ...login, source: "login" } : undefined
75
+ }
@@ -0,0 +1,106 @@
1
+ // Decisions in the CLI (design §12.1): the `hub ask @me` options (--option id=label[:safe], --recommend id --why,
2
+ // --urgency, --deadline, --on-timeout, --tier, --about) and the text of a Decision (ask @me, `hub decisions list`).
3
+ // The shapes are packages/sdk's types (src/central/agent/decisions.ts).
4
+ import type { AskOptions, Decision, DecisionOption, DecisionState } from "../../sdk/src/index.ts"
5
+ import { str, UsageError, type FlagKind, type Parsed } from "./args"
6
+ import { oneLine } from "./format"
7
+ import { durationArg, partyText } from "./messaging"
8
+
9
+ /** The ask flags that make a Decision (only for asks to @me). */
10
+ export const DECISION_FLAGS: Readonly<Record<string, FlagKind>> = {
11
+ option: "multi",
12
+ recommend: "string",
13
+ why: "string",
14
+ urgency: "string",
15
+ deadline: "string",
16
+ "on-timeout": "string",
17
+ tier: "string",
18
+ about: "multi",
19
+ }
20
+
21
+ const URGENCIES = ["low", "normal", "high"]
22
+ const TIERS = ["confirm", "tap"]
23
+ const STATES: readonly DecisionState[] = ["open", "answered", "expired", "closed"]
24
+ export const DECISION_STATES = STATES
25
+
26
+ /** `id=label` or `id=label:safe` (the label may contain colons; only a trailing `:safe` marks it safe). */
27
+ export function optionArg(value: string): DecisionOption {
28
+ const eq = value.indexOf("=")
29
+ if (eq <= 0) throw new UsageError(`--option takes id=label[:safe] (got "${value}")`)
30
+ const id = value.slice(0, eq)
31
+ let label = value.slice(eq + 1)
32
+ const safe = label.endsWith(":safe")
33
+ if (safe) label = label.slice(0, -":safe".length)
34
+ if (!label.trim()) throw new UsageError(`--option ${id}: the label is empty`)
35
+ return { id, label, ...(safe ? { safe: true } : {}) }
36
+ }
37
+
38
+ const oneOf = (value: string | undefined, allowed: readonly string[], flag: string) => {
39
+ if (value !== undefined && !allowed.includes(value)) throw new UsageError(`${flag} is one of ${allowed.join(", ")}`)
40
+ return value
41
+ }
42
+
43
+ /** The Decision fields of `hub ask` (empty when none is given); usage errors for bad ones or a non-@me ask. */
44
+ export function decisionOptions(flags: Parsed["flags"], to: string): Partial<AskOptions> {
45
+ const given = Object.keys(DECISION_FLAGS).filter((flag) => flags[flag] !== undefined)
46
+ if (!given.length) return {}
47
+ if (to !== "@me") throw new UsageError(`${given.map((f) => `--${f}`).join(", ")}: only for asks to @me`)
48
+ const recommend = str(flags, "recommend")
49
+ const why = str(flags, "why")
50
+ if (why !== undefined && recommend === undefined) throw new UsageError("--why explains --recommend")
51
+ const deadline = str(flags, "deadline")
52
+ const urgency = oneOf(str(flags, "urgency"), URGENCIES, "--urgency") as AskOptions["urgency"]
53
+ const tier = oneOf(str(flags, "tier"), TIERS, "--tier") as AskOptions["tier"]
54
+ const onTimeout = str(flags, "on-timeout")
55
+ const options = flags.option as string[] | undefined
56
+ const about = flags.about as string[] | undefined
57
+ return {
58
+ ...(options ? { options: options.map(optionArg) } : {}),
59
+ ...(recommend !== undefined ? { recommendation: { option: recommend, ...(why !== undefined ? { why } : {}) } } : {}),
60
+ ...(urgency ? { urgency } : {}),
61
+ ...(tier ? { tier } : {}),
62
+ ...(deadline !== undefined ? { deadline: durationArg(deadline, "--deadline") } : {}),
63
+ ...(onTimeout !== undefined ? { onTimeout } : {}),
64
+ ...(about ? { about } : {}),
65
+ }
66
+ }
67
+
68
+ /** The --state filter of `hub decisions list`. */
69
+ export function stateArg(value: string | undefined): DecisionState | undefined {
70
+ return oneOf(value, STATES, "--state") as DecisionState | undefined
71
+ }
72
+
73
+ const iso = (ms: number) => new Date(ms).toISOString().replace(/\.\d{3}Z$/, "Z")
74
+
75
+ /** `go Proceed · hold Hold (safe) — recommended: hold (why)`; empty for a free-text Decision without one. */
76
+ function choicesText(decision: Decision): string {
77
+ const options = decision.options.map((o) => `${o.id} ${oneLine(o.label, 80)}${o.safe ? " (safe)" : ""}`).join(" · ")
78
+ const r = decision.recommendation
79
+ const recommended = r ? `recommended: ${r.option}${r.why ? ` (${oneLine(r.why, 200)})` : ""}` : ""
80
+ return [options, recommended].filter(Boolean).join(" — ")
81
+ }
82
+
83
+ function stateText(decision: Decision): string {
84
+ if (decision.state === "answered") {
85
+ const a = decision.answer
86
+ return `answered${a?.option ? ` ${a.option}` : ""}${a?.answeredBy === "timeout" ? " (timeout)" : ""}`
87
+ }
88
+ if (decision.state === "open" && decision.deadline !== undefined) return `open (deadline ${iso(decision.deadline)})`
89
+ return decision.state
90
+ }
91
+
92
+ /** One Decision: `dec_1 open high from @api Question` and its choices on the next line. */
93
+ export function decisionText(decision: Decision): string {
94
+ const head = `${decision.id} ${stateText(decision)} ${decision.urgency} from ${partyText(decision.from)} ${oneLine(decision.question, 160)}`
95
+ const choices = choicesText(decision)
96
+ return choices ? `${head}\n ${choices}` : head
97
+ }
98
+
99
+ export const decisionsText = (decisions: readonly Decision[]): string => (decisions.length ? decisions.map(decisionText).join("\n") : "no decisions")
100
+
101
+ /** What `hub ask @me` adds: the Decision it opened. */
102
+ export function openedText(decision: Decision): string {
103
+ const tier = decision.tier === "tap" ? ", tap in the console" : ""
104
+ const choices = choicesText(decision)
105
+ return `decision ${decision.id} (${decision.urgency}${tier}): ${oneLine(decision.question, 160)}${choices ? `\n ${choices}` : ""}\n the user answers it in the console`
106
+ }
@@ -0,0 +1,185 @@
1
+ // Short human text for the CLI's default output (--json prints the hub's answers unchanged). The shapes are those
2
+ // of src/central/agent/read-routes.ts and directory.ts; unknown fields are ignored, missing ones skipped.
3
+
4
+ export interface Blocker {
5
+ readonly kind: string
6
+ readonly id?: string
7
+ readonly summary: string
8
+ }
9
+
10
+ export interface SessionView {
11
+ readonly id: string
12
+ readonly name?: string
13
+ readonly roles?: readonly string[]
14
+ readonly title?: string
15
+ readonly state: string
16
+ readonly harness?: string
17
+ readonly now?: string
18
+ readonly blockers?: readonly Blocker[]
19
+ }
20
+
21
+ const at = (name: string) => `@${name.replace(/^@+/, "")}`
22
+ export const oneLine = (text: string, max = 160): string => {
23
+ const flat = text.replace(/\s+/g, " ").trim()
24
+ return flat.length > max ? `${flat.slice(0, max - 1)}…` : flat
25
+ }
26
+ const blockerText = (b: Blocker) => `${b.kind}: ${oneLine(b.summary, 100)}`
27
+
28
+ /** `@name ses_… state harness now [kind: summary; …]` */
29
+ export function sessionLine(s: SessionView): string {
30
+ const parts = [[s.name ? at(s.name) : undefined, s.id].filter(Boolean).join(" "), s.state]
31
+ if (s.harness) parts.push(s.harness)
32
+ if (s.now) parts.push(oneLine(s.now, 80))
33
+ if (s.blockers?.length) parts.push(`[${s.blockers.map(blockerText).join("; ")}]`)
34
+ return parts.join(" ")
35
+ }
36
+
37
+ interface Snapshot {
38
+ readonly machines: readonly {
39
+ id: string
40
+ name?: string
41
+ online: boolean
42
+ harnesses?: readonly string[]
43
+ projects: readonly { root: string; name: string; sessions: readonly SessionView[] }[]
44
+ }[]
45
+ readonly attention: readonly { kind: string; sessionID: string; name?: string; id?: string; summary: string }[]
46
+ }
47
+
48
+ export function statusText(snapshot: Snapshot): string {
49
+ const lines: string[] = []
50
+ for (const m of snapshot.machines) {
51
+ lines.push([m.name ?? m.id, m.online ? "online" : "offline", ...(m.harnesses ?? [])].join(" "))
52
+ for (const p of m.projects) {
53
+ lines.push(` ${p.name}`)
54
+ for (const s of p.sessions) lines.push(` ${sessionLine(s)}`)
55
+ }
56
+ }
57
+ if (lines.length === 0) lines.push("no machines")
58
+ if (snapshot.attention.length) {
59
+ lines.push("attention:")
60
+ for (const a of snapshot.attention) lines.push(` ${[a.name ? at(a.name) : undefined, a.sessionID].filter(Boolean).join(" ")} ${a.kind}${a.id ? ` ${a.id}` : ""}: ${oneLine(a.summary, 100)}`)
61
+ }
62
+ return lines.join("\n")
63
+ }
64
+
65
+ export const sessionsText = (sessions: readonly SessionView[]): string => (sessions.length ? sessions.map(sessionLine).join("\n") : "no sessions")
66
+
67
+ interface Shown extends SessionView {
68
+ readonly online?: boolean
69
+ readonly project?: string
70
+ readonly directory?: string
71
+ readonly machineID?: string
72
+ readonly parentID?: string
73
+ readonly lastSeen?: number
74
+ readonly children?: number
75
+ readonly summary?: {
76
+ branch?: string
77
+ context?: { used: number; limit?: number; percent?: number; model?: string }
78
+ changes?: { additions: number; deletions: number; totalFiles: number }
79
+ mcp?: readonly { name: string; status: string }[]
80
+ }
81
+ readonly unsupported?: readonly { feature?: string; harness?: string }[]
82
+ readonly failed?: readonly { feature?: string }[]
83
+ }
84
+
85
+ export function showText(s: Shown): string {
86
+ const lines = [[s.name ? at(s.name) : undefined, s.id].filter(Boolean).join(" ")]
87
+ const field = (label: string, value: string | number | undefined) => {
88
+ if (value !== undefined && value !== "") lines.push(`${label}: ${value}`)
89
+ }
90
+ field("title", s.title)
91
+ field("state", [s.state, s.harness].filter(Boolean).join(" "))
92
+ field("project", [s.project, s.directory && s.directory !== s.project ? `(${s.directory})` : undefined].filter(Boolean).join(" ") || undefined)
93
+ field("machine", s.machineID)
94
+ field("parent", s.parentID)
95
+ if (s.roles?.length) field("roles", s.roles.map((r) => `@${at(r)}`).join(" "))
96
+ field("now", s.now)
97
+ if (s.children) field("subagents", s.children)
98
+ if (s.online === false && s.lastSeen !== undefined) field("last seen", new Date(s.lastSeen).toISOString())
99
+ if (s.blockers?.length) {
100
+ lines.push("blockers:")
101
+ for (const b of s.blockers) lines.push(` ${b.kind}${b.id ? ` ${b.id}` : ""}: ${oneLine(b.summary, 200)}`)
102
+ }
103
+ const sum = s.summary
104
+ if (sum) {
105
+ field("branch", sum.branch)
106
+ if (sum.context) field("context", `${sum.context.used} tokens${sum.context.percent !== undefined ? ` (${sum.context.percent}%)` : ""}${sum.context.model ? ` ${sum.context.model}` : ""}`)
107
+ if (sum.changes) field("changes", `${sum.changes.totalFiles} files +${sum.changes.additions} -${sum.changes.deletions}`)
108
+ if (sum.mcp?.length) field("mcp", sum.mcp.map((m) => `${m.name} ${m.status}`).join(", "))
109
+ }
110
+ for (const u of s.unsupported ?? []) lines.push(`unsupported: ${u.feature ?? "?"}${u.harness ? ` (${u.harness})` : ""}`)
111
+ for (const f of s.failed ?? []) lines.push(`failed: ${f.feature ?? "?"}`)
112
+ return lines.join("\n")
113
+ }
114
+
115
+ interface Row {
116
+ readonly kind: string
117
+ readonly text?: string
118
+ readonly name?: string
119
+ readonly state?: string
120
+ readonly title?: string
121
+ readonly at?: number
122
+ }
123
+
124
+ const time = (ms: number | undefined) => (ms === undefined ? "--:--:--" : new Date(ms).toISOString().slice(11, 19))
125
+
126
+ /** Timeline rows (the hub sends newest first), printed oldest first. */
127
+ export function logText(rows: readonly Row[]): string {
128
+ if (!rows.length) return "no rows"
129
+ return [...rows]
130
+ .reverse()
131
+ .map((r) => {
132
+ const head = r.kind === "tool" ? `tool ${r.name ?? "?"}${r.state ? ` (${r.state})` : ""}` : r.kind
133
+ const body = oneLine(r.text ?? r.title ?? "", 200)
134
+ return `${time(r.at)} ${head}${body ? ` ${body}` : ""}`
135
+ })
136
+ .join("\n")
137
+ }
138
+
139
+ interface WhoEntry {
140
+ readonly sessionID: string
141
+ readonly name?: string
142
+ readonly project?: string
143
+ readonly title?: string
144
+ readonly state?: string
145
+ readonly online: boolean
146
+ readonly roles: readonly { role: string; project?: string }[]
147
+ }
148
+
149
+ export function whoText(entries: readonly WhoEntry[]): string {
150
+ if (!entries.length) return "no sessions"
151
+ return entries
152
+ .map((e) => {
153
+ const parts = [[e.name ? at(e.name) : undefined, e.sessionID].filter(Boolean).join(" "), e.online ? (e.state ?? "online") : "offline"]
154
+ if (e.roles.length) parts.push(e.roles.map((r) => `@@${r.role}${r.project !== undefined ? `.${r.project}` : ""}`).join(" "))
155
+ if (e.project) parts.push(e.project)
156
+ if (e.title) parts.push(oneLine(e.title, 60))
157
+ return parts.join(" ")
158
+ })
159
+ .join("\n")
160
+ }
161
+
162
+ /** A caller as whoami / status `you` report it. */
163
+ export interface Actor {
164
+ readonly kind: string
165
+ readonly sessionID?: string
166
+ readonly machineID?: string
167
+ readonly tokenID?: string
168
+ readonly label?: string
169
+ readonly scopes?: readonly string[]
170
+ readonly deviceID?: string
171
+ }
172
+
173
+ export function whoamiText(body: { actor: Actor; user: { id: string; name?: string; email?: string; role?: string } }): string {
174
+ const { actor, user } = body
175
+ const lines: string[] = []
176
+ if (actor.kind === "session") lines.push(`session ${actor.sessionID}${actor.machineID ? ` on machine ${actor.machineID}` : ""}`)
177
+ else if (actor.kind === "app") lines.push(`app token ${actor.tokenID}${actor.label ? ` (${actor.label})` : ""}${actor.scopes?.length ? ` scopes ${actor.scopes.join(",")}` : ""}`)
178
+ else lines.push(`user${actor.deviceID ? ` (device ${actor.deviceID})` : ""}`)
179
+ const who = [user.name, user.email ? `<${user.email}>` : undefined].filter(Boolean).join(" ") || user.id
180
+ lines.push(`user ${who}${user.role ? ` (${user.role})` : ""}`)
181
+ return lines.join("\n")
182
+ }
183
+
184
+ export const roleText = (r: { role: string; project?: string }) => `@@${r.role}${r.project !== undefined ? `.${r.project}` : ""}`
185
+ export { at as atName }
@@ -0,0 +1,105 @@
1
+ // `hub grant list | show | create | revoke | request` (design §5): the spec of `hub grant create` (flags, or --json
2
+ // '{…}' / --json -) and the text of grants. Only the user manages grants (src/central/agent/grant-routes.ts); a
3
+ // session's own credential is refused here without asking the hub, any other the hub refuses (forbidden, exit 5).
4
+ // Agents (a session, an app token with messages:send) `hub grant request` one instead: the same flags (the holder
5
+ // defaults to the requester) and a --why; the user's tap in the console creates it.
6
+ import type { Grant, GrantRequested, GrantRequestSpec, GrantSpec, GrantVerb } from "../../sdk/src/index.ts"
7
+ import { jsonObjectArg, str, UsageError, type FlagKind, type Parsed } from "./args"
8
+ import { CliError, EXIT } from "./http"
9
+
10
+ /** The flags of `hub grant create`. */
11
+ export const GRANT_FLAGS: Readonly<Record<string, FlagKind>> = {
12
+ json: "json",
13
+ holder: "string",
14
+ project: "multi",
15
+ allow: "multi",
16
+ permit: "multi",
17
+ never: "multi",
18
+ expires: "string",
19
+ "max-per-hour": "string",
20
+ "allow-always": "bool",
21
+ }
22
+
23
+ /** The flags of `hub grant request`: create's, --permission-allow (as --permit), and the reason the user reads. */
24
+ export const GRANT_REQUEST_FLAGS: Readonly<Record<string, FlagKind>> = { ...GRANT_FLAGS, "permission-allow": "multi", why: "string" }
25
+
26
+ export const GRANT_USER_HINT = "only the user manages grants: use the console, or `hub login` with the user's device token"
27
+
28
+ /** Refuses a session's own credential (HUB_SESSION_TOKEN): a session never creates or revokes a grant. */
29
+ export function userOnly(source: string, what: string): void {
30
+ if (source === "env") throw new CliError(EXIT.forbidden, `only the user can ${what} a grant, not a session`, GRANT_USER_HINT)
31
+ }
32
+
33
+ /** A forbidden from the grant routes, with the hint of who may manage grants. */
34
+ export function grantError(error: unknown): unknown {
35
+ if (error instanceof CliError && error.exit === EXIT.forbidden) return new CliError(error.exit, error.message, [error.hint, GRANT_USER_HINT].filter(Boolean).join("; "), error.envelope)
36
+ return error
37
+ }
38
+
39
+ /** Comma-separated and repeated values, in order. */
40
+ const listOf = (flags: Parsed["flags"], name: string): string[] => ((flags[name] as string[] | undefined) ?? []).flatMap((v) => v.split(",")).map((v) => v.trim()).filter(Boolean)
41
+
42
+ /** The grant of `hub grant create`: a JSON spec (--json), or the flags. */
43
+ export async function grantSpecArg(flags: Parsed["flags"], stdin: (() => Promise<string>) | undefined): Promise<GrantSpec> {
44
+ return (await grantArg(flags, stdin, true)) as GrantSpec
45
+ }
46
+
47
+ /** The grant of `hub grant request`: as create's, the holder optional (the requester by default). */
48
+ export function grantRequestArg(flags: Parsed["flags"], stdin: (() => Promise<string>) | undefined): Promise<GrantRequestSpec> {
49
+ return grantArg(flags, stdin, false)
50
+ }
51
+
52
+ async function grantArg(flags: Parsed["flags"], stdin: (() => Promise<string>) | undefined, holderRequired: boolean): Promise<GrantRequestSpec> {
53
+ const json = typeof flags.json === "string" ? flags.json : undefined
54
+ const flagged = Object.keys(GRANT_REQUEST_FLAGS).filter((name) => name !== "json" && name !== "why" && flags[name] !== undefined)
55
+ if (json !== undefined) {
56
+ if (flagged.length) throw new UsageError(`give either --json '{…}' or ${flagged.map((f) => `--${f}`).join(", ")}`)
57
+ return (await jsonObjectArg(json, stdin, "grant")) as unknown as GrantRequestSpec
58
+ }
59
+ const holder = str(flags, "holder")
60
+ if (holder === undefined && holderRequired) throw new UsageError("needs --holder (ses_…, @name, @@orchestrator…, or app:<token id>)")
61
+ const projects = listOf(flags, "project")
62
+ if (!projects.length) throw new UsageError("needs --project <path> (repeatable)")
63
+ const allow = listOf(flags, "allow") as GrantVerb[]
64
+ if (!allow.length) throw new UsageError("needs --allow <verbs> (approve, deny, answer, prompt, stop, roles, launch)")
65
+ const permit = [...listOf(flags, "permit"), ...listOf(flags, "permission-allow")]
66
+ const never = listOf(flags, "never")
67
+ const expires = str(flags, "expires")
68
+ const cap = str(flags, "max-per-hour")
69
+ if (cap !== undefined && !/^\d+$/.test(cap)) throw new UsageError("--max-per-hour is a whole number")
70
+ return {
71
+ ...(holder !== undefined ? { holder } : {}),
72
+ scope: { projects },
73
+ allow,
74
+ ...(permit.length || never.length ? { permissions: { ...(permit.length ? { allow: permit } : {}), ...(never.length ? { never } : {}) } } : {}),
75
+ ...(expires !== undefined ? { expires } : {}),
76
+ ...(cap !== undefined ? { maxActionsPerHour: Number(cap) } : {}),
77
+ ...(flags["allow-always"] === true ? { allowAlways: true } : {}),
78
+ }
79
+ }
80
+
81
+ const iso = (ms: number) => new Date(ms).toISOString().replace(/\.\d{3}Z$/, "Z")
82
+
83
+ /** `g_1 active @@orchestrator approve,deny /p until 2026-…Z` */
84
+ export const grantLine = (g: Grant): string => [g.id, g.state, g.holder, g.allow.join(","), g.scope.projects.join(","), `until ${iso(g.expiresAt)}`].join(" ")
85
+
86
+ export const grantsText = (grants: readonly Grant[]): string => (grants.length ? grants.map(grantLine).join("\n") : "no grants")
87
+
88
+ /** One grant in full. */
89
+ export function grantText(g: Grant): string {
90
+ const list = (values: readonly string[]) => (values.length ? values.join(", ") : "none")
91
+ return [
92
+ `${g.id} ${g.state}`,
93
+ `holder: ${g.holder}${g.holderSessionID && g.holderSessionID !== g.holder ? ` (${g.holderSessionID})` : ""}`,
94
+ `projects: ${list(g.scope.projects)}`,
95
+ `verbs: ${list(g.allow)}`,
96
+ `permissions allow: ${list(g.permissions.allow)}`,
97
+ `permissions never: ${list(g.permissions.never)}`,
98
+ `always: ${g.allowAlways ? "yes" : "no"}`,
99
+ `max actions per hour: ${g.maxActionsPerHour}`,
100
+ g.revokedAt !== undefined ? `revoked: ${iso(g.revokedAt)}` : `expires: ${iso(g.expiresAt)}`,
101
+ ].join("\n")
102
+ }
103
+
104
+ /** `requested; waiting for the user (Decision dec_…)` */
105
+ export const requestedText = (body: GrantRequested): string => `requested; waiting for the user (Decision ${body.decision.id})`
@@ -0,0 +1,86 @@
1
+ // The CLI's side of talking to the hub: a HubClient (packages/sdk) for the resolved credentials, and the SDK's
2
+ // errors turned into CliErrors with the exit code of each error code (design §8, plus usage and unauthorized) and
3
+ // a hint for credential errors.
4
+ import { HubClient, HubError, HubTransportError, type Envelope } from "../../sdk/src/index.ts"
5
+ import type { Credentials } from "./credentials"
6
+
7
+ export type { Envelope } from "../../sdk/src/index.ts"
8
+
9
+ export const EXIT = {
10
+ ok: 0,
11
+ other: 1,
12
+ usage: 2,
13
+ notFound: 3,
14
+ needsUser: 4,
15
+ forbidden: 5,
16
+ rateLimited: 6,
17
+ unavailable: 7,
18
+ gone: 8,
19
+ unauthorized: 9,
20
+ } as const
21
+
22
+ const EXIT_OF: Record<string, number> = {
23
+ invalid: EXIT.usage,
24
+ not_found: EXIT.notFound,
25
+ ambiguous: EXIT.notFound,
26
+ name_taken: EXIT.notFound,
27
+ needs_user: EXIT.needsUser,
28
+ forbidden: EXIT.forbidden,
29
+ paused: EXIT.forbidden,
30
+ rate_limited: EXIT.rateLimited,
31
+ unsupported: EXIT.unavailable,
32
+ offline: EXIT.unavailable,
33
+ gone: EXIT.gone,
34
+ unauthorized: EXIT.unauthorized,
35
+ token_expired: EXIT.unauthorized,
36
+ }
37
+
38
+ /** A failure to report: message (and hint) on stderr, the envelope on stdout with --json, then exit. */
39
+ export class CliError extends Error {
40
+ constructor(
41
+ readonly exit: number,
42
+ message: string,
43
+ readonly hint?: string,
44
+ readonly envelope?: Envelope,
45
+ ) {
46
+ super(message)
47
+ }
48
+ }
49
+
50
+ export const exitOf = (code: string): number => EXIT_OF[code] ?? EXIT.other
51
+
52
+ /** The hub's hint, plus what to do about a credential error from here. */
53
+ function hintFor(envelope: Envelope, credentials: Credentials): string | undefined {
54
+ const own =
55
+ credentials.source === "http"
56
+ ? undefined // a POST /mcp caller has no CLI login or shell to fix
57
+ : envelope.code === "token_expired"
58
+ ? credentials.source === "env"
59
+ ? "session tokens are renewed for new shells: start a new shell"
60
+ : "run `hub login` with a new app token"
61
+ : envelope.code === "unauthorized"
62
+ ? credentials.source === "env"
63
+ ? "HUB_SESSION_TOKEN is not valid here (is the session still live?)"
64
+ : "run `hub login` again"
65
+ : undefined
66
+ return [envelope.hint, own].filter(Boolean).join("; ") || undefined
67
+ }
68
+
69
+ /** An SDK error as the CLI reports it; anything else is returned unchanged. */
70
+ export function cliError(error: unknown, credentials: Credentials): unknown {
71
+ if (error instanceof HubError) return new CliError(exitOf(error.code), error.message, hintFor(error.envelope, credentials), error.envelope)
72
+ if (error instanceof HubTransportError) return new CliError(EXIT.other, error.message, error.kind === "unreachable" ? "Check HUB_URL or the URL given to `hub login`" : undefined)
73
+ return error
74
+ }
75
+
76
+ export const clientFor = (credentials: Credentials): HubClient =>
77
+ new HubClient({ url: credentials.url, token: credentials.token, ...(credentials.fetch ? { fetch: credentials.fetch } : {}) })
78
+
79
+ /** Runs `use` with a client for `credentials`; SDK errors become CliErrors. */
80
+ export async function withHub<T>(credentials: Credentials, use: (hub: HubClient) => Promise<T>): Promise<T> {
81
+ try {
82
+ return await use(clientFor(credentials))
83
+ } catch (error) {
84
+ throw cliError(error, credentials)
85
+ }
86
+ }