@huynle/hub 0.7.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.
- package/CHANGELOG.md +70 -0
- package/Dockerfile +3 -0
- package/README.md +148 -11
- package/package.json +3 -1
- package/packages/claude-code/package.json +1 -1
- package/packages/claude-code/src/main.ts +4 -4
- package/packages/claude-code/src/wrapper.ts +3 -2
- package/packages/cli/dist/hub-cli.js +19334 -7849
- package/packages/cli/package.json +11 -0
- package/packages/cli/src/acting.ts +70 -0
- package/packages/cli/src/args.ts +91 -0
- package/packages/cli/src/bootstrap/adapters/archive-stream.ts +276 -0
- package/packages/cli/src/bootstrap/adapters/claude/README.md +63 -0
- package/packages/cli/src/bootstrap/adapters/claude/adapter.ts +250 -0
- package/packages/cli/src/bootstrap/adapters/claude/index.ts +42 -0
- package/packages/cli/src/bootstrap/adapters/claude/node.ts +99 -0
- package/packages/cli/src/bootstrap/adapters/claude/openpgp.ts +221 -0
- package/packages/cli/src/bootstrap/adapters/claude/ports.ts +44 -0
- package/packages/cli/src/bootstrap/adapters/claude/source.ts +160 -0
- package/packages/cli/src/bootstrap/adapters/claude/verify.ts +165 -0
- package/packages/cli/src/bootstrap/adapters/codex/archive.ts +103 -0
- package/packages/cli/src/bootstrap/adapters/codex/index.ts +60 -0
- package/packages/cli/src/bootstrap/adapters/codex/plan.ts +129 -0
- package/packages/cli/src/bootstrap/adapters/codex/sigstore.ts +298 -0
- package/packages/cli/src/bootstrap/adapters/codex/source.ts +102 -0
- package/packages/cli/src/bootstrap/adapters/codex/verify.ts +298 -0
- package/packages/cli/src/bootstrap/adapters/inflate.ts +433 -0
- package/packages/cli/src/bootstrap/adapters/node-services/control.ts +392 -0
- package/packages/cli/src/bootstrap/adapters/node-services/index.ts +23 -0
- package/packages/cli/src/bootstrap/adapters/node-services/launch.ts +223 -0
- package/packages/cli/src/bootstrap/adapters/node-services/plan.ts +222 -0
- package/packages/cli/src/bootstrap/adapters/node-services/render.ts +183 -0
- package/packages/cli/src/bootstrap/adapters/node-services/validate.ts +197 -0
- package/packages/cli/src/bootstrap/adapters/opencode/README.md +204 -0
- package/packages/cli/src/bootstrap/adapters/opencode/apply.ts +205 -0
- package/packages/cli/src/bootstrap/adapters/opencode/archive.ts +91 -0
- package/packages/cli/src/bootstrap/adapters/opencode/config.ts +212 -0
- package/packages/cli/src/bootstrap/adapters/opencode/index.ts +81 -0
- package/packages/cli/src/bootstrap/adapters/opencode/plan.ts +266 -0
- package/packages/cli/src/bootstrap/adapters/opencode/verify.ts +208 -0
- package/packages/cli/src/bootstrap/adapters/readiness.ts +19 -0
- package/packages/cli/src/bootstrap/ansible/index.ts +2 -0
- package/packages/cli/src/bootstrap/ansible/render.ts +279 -0
- package/packages/cli/src/bootstrap/contracts/actions.ts +470 -0
- package/packages/cli/src/bootstrap/contracts/artifact.ts +323 -0
- package/packages/cli/src/bootstrap/contracts/canonical.ts +54 -0
- package/packages/cli/src/bootstrap/contracts/credentials.ts +108 -0
- package/packages/cli/src/bootstrap/contracts/discovery.ts +215 -0
- package/packages/cli/src/bootstrap/contracts/envelope.ts +143 -0
- package/packages/cli/src/bootstrap/contracts/exec.ts +74 -0
- package/packages/cli/src/bootstrap/contracts/harness.ts +202 -0
- package/packages/cli/src/bootstrap/contracts/index.ts +28 -0
- package/packages/cli/src/bootstrap/contracts/journal.ts +198 -0
- package/packages/cli/src/bootstrap/contracts/paths.ts +91 -0
- package/packages/cli/src/bootstrap/contracts/plan.ts +155 -0
- package/packages/cli/src/bootstrap/contracts/platform.ts +62 -0
- package/packages/cli/src/bootstrap/contracts/preview.ts +190 -0
- package/packages/cli/src/bootstrap/contracts/profile.ts +105 -0
- package/packages/cli/src/bootstrap/contracts/secrets.ts +52 -0
- package/packages/cli/src/bootstrap/contracts/validate.ts +212 -0
- package/packages/cli/src/bootstrap/discovery/discover.ts +146 -0
- package/packages/cli/src/bootstrap/discovery/executables.ts +134 -0
- package/packages/cli/src/bootstrap/discovery/host.ts +96 -0
- package/packages/cli/src/bootstrap/discovery/index.ts +6 -0
- package/packages/cli/src/bootstrap/discovery/node-probe.ts +97 -0
- package/packages/cli/src/bootstrap/discovery/probe.ts +41 -0
- package/packages/cli/src/bootstrap/discovery/util.ts +13 -0
- package/packages/cli/src/bootstrap/enrollment/enroll.ts +226 -0
- package/packages/cli/src/bootstrap/enrollment/index.ts +7 -0
- package/packages/cli/src/bootstrap/enrollment/policy.ts +54 -0
- package/packages/cli/src/bootstrap/enrollment/result.ts +63 -0
- package/packages/cli/src/bootstrap/enrollment/status.ts +119 -0
- package/packages/cli/src/bootstrap/enrollment/store.ts +173 -0
- package/packages/cli/src/bootstrap/preview/index.ts +5 -0
- package/packages/cli/src/bootstrap/preview/links.ts +31 -0
- package/packages/cli/src/bootstrap/preview/plan.ts +242 -0
- package/packages/cli/src/bootstrap/preview/preview.ts +104 -0
- package/packages/cli/src/bootstrap/preview/render.ts +30 -0
- package/packages/cli/src/bootstrap/profiles/audit.ts +87 -0
- package/packages/cli/src/bootstrap/profiles/catalog.ts +49 -0
- package/packages/cli/src/bootstrap/profiles/desired.ts +96 -0
- package/packages/cli/src/bootstrap/profiles/drift.ts +286 -0
- package/packages/cli/src/bootstrap/profiles/history.ts +263 -0
- package/packages/cli/src/bootstrap/profiles/index.ts +11 -0
- package/packages/cli/src/bootstrap/profiles/upgrade.ts +276 -0
- package/packages/cli/src/bootstrap/profiles/versions.ts +50 -0
- package/packages/cli/src/bootstrap/release/README.md +115 -0
- package/packages/cli/src/bootstrap/release/assets.ts +39 -0
- package/packages/cli/src/bootstrap/release/cli.ts +332 -0
- package/packages/cli/src/bootstrap/release/harness-pins.json +94 -0
- package/packages/cli/src/bootstrap/release/manifest.ts +118 -0
- package/packages/cli/src/bootstrap/release/pins.ts +192 -0
- package/packages/cli/src/bootstrap/release/text-assets.d.ts +9 -0
- package/packages/cli/src/bootstrap/release/trust-root.json +13 -0
- package/packages/cli/src/bootstrap/release/trust-root.ts +39 -0
- package/packages/cli/src/bootstrap/release/verify.ts +88 -0
- package/packages/cli/src/bootstrap/transaction/builtin.ts +312 -0
- package/packages/cli/src/bootstrap/transaction/engine.ts +994 -0
- package/packages/cli/src/bootstrap/transaction/executor.ts +112 -0
- package/packages/cli/src/bootstrap/transaction/fs.ts +194 -0
- package/packages/cli/src/bootstrap/transaction/index.ts +12 -0
- package/packages/cli/src/bootstrap/transaction/json-merge.ts +403 -0
- package/packages/cli/src/bootstrap/transaction/lock.ts +132 -0
- package/packages/cli/src/bootstrap/transaction/resolve.ts +48 -0
- package/packages/cli/src/bootstrap/transaction/result.ts +60 -0
- package/packages/cli/src/bootstrap/transaction/state.ts +88 -0
- package/packages/cli/src/bootstrap/transaction/store.ts +189 -0
- package/packages/cli/src/bootstrap/transaction/stream.ts +254 -0
- package/packages/cli/src/cli.ts +814 -0
- package/packages/cli/src/credentials.ts +75 -0
- package/packages/cli/src/decisions.ts +106 -0
- package/packages/cli/src/format.ts +185 -0
- package/packages/cli/src/grants.ts +105 -0
- package/packages/cli/src/http.ts +86 -0
- package/packages/cli/src/launches.ts +157 -0
- package/packages/cli/src/login.ts +160 -0
- package/packages/cli/src/main.ts +63 -0
- package/packages/cli/src/mcp.ts +879 -0
- package/packages/cli/src/messaging.ts +108 -0
- package/packages/cli/src/node.ts +279 -0
- package/packages/cli/src/openshell.ts +166 -0
- package/packages/cli/src/setup/commands.ts +809 -0
- package/packages/cli/src/setup/contributors.ts +171 -0
- package/packages/cli/src/setup/dispatch.ts +74 -0
- package/packages/cli/src/setup/launch-driver.ts +109 -0
- package/packages/cli/src/setup/runtime.ts +456 -0
- package/packages/cli/src/setup/spoke-probe.ts +120 -0
- package/packages/cli/src/setup/state.ts +244 -0
- package/packages/cli/src/setup/user-bin.ts +39 -0
- package/packages/cli/src/spoke-services.ts +109 -0
- package/packages/cli/src/watch.ts +28 -0
- package/packages/codex/package.json +1 -1
- package/packages/codex/src/main.ts +3 -2
- package/packages/codex/src/spoke.ts +4 -1
- package/packages/codex/src/wrapper.ts +7 -3
- package/packages/node/package.json +1 -1
- package/packages/node/src/harnesses.ts +39 -4
- package/packages/node/src/run.ts +1 -1
- package/packages/preset/package.json +1 -1
- package/packages/protocol/package.json +1 -1
- package/packages/runtime-openshell/package.json +1 -1
- package/packages/runtime-sdk/package.json +1 -1
- package/packages/sdk/package.json +1 -1
- package/packages/sdk/src/client.ts +55 -4
- package/packages/sdk/src/errors.ts +1 -0
- package/packages/sdk/src/sse.ts +4 -1
- package/packages/sdk/src/types.ts +59 -0
- package/packages/spoke-sdk/package.json +1 -1
- package/packages/spoke-sdk/src/enroll.ts +26 -4
- package/src/central/agent/app-tokens.ts +27 -17
- package/src/central/agent/archive-inactive.ts +66 -0
- package/src/central/agent/directory-routes.ts +155 -12
- package/src/central/agent/directory.ts +1 -1
- package/src/central/agent/errors.ts +2 -0
- package/src/central/agent/event-cursor.ts +49 -0
- package/src/central/agent/event-writers.ts +181 -0
- package/src/central/agent/events-routes.ts +148 -0
- package/src/central/agent/events.ts +277 -0
- package/src/central/agent/names.ts +1 -1
- package/src/central/agent/read-routes.ts +32 -36
- package/src/central/agent/routes.ts +14 -11
- package/src/central/agent/watch.ts +65 -174
- package/src/central/agent/webhook-routes.ts +10 -2
- package/src/central/agent/webhooks.ts +49 -5
- package/src/central/auth.ts +127 -1
- package/src/central/backend/index.ts +3 -0
- package/src/central/backend/types.ts +2 -0
- package/src/central/http.ts +115 -32
- package/src/central/mcp-http.ts +116 -0
- package/src/central/store.ts +53 -0
- package/src/config.ts +70 -7
- package/src/index.ts +90 -91
- package/src/spoke/launchers.ts +109 -3
- package/web/dist/apple-touch-icon.png +0 -0
- package/web/dist/assets/index-DWnMhXk9.js +20 -0
- package/web/dist/assets/index-DmUXEnw9.css +1 -0
- package/web/dist/favicon.ico +0 -0
- package/web/dist/icon-192.png +0 -0
- package/web/dist/icon-512.png +0 -0
- package/web/dist/icon-maskable-512.png +0 -0
- package/web/dist/icon.svg +5 -5
- package/web/dist/index.html +10 -4
- package/web/dist/manifest.webmanifest +2 -2
- package/web/dist/assets/index-D9zMbxSg.css +0 -1
- package/web/dist/assets/index-db1z4duA.js +0 -20
|
@@ -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
|
+
}
|