@oh-hai/cli 0.1.0-beta.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (110) hide show
  1. package/README.md +154 -0
  2. package/dist/auth/file-backend.d.ts +16 -0
  3. package/dist/auth/file-backend.js +98 -0
  4. package/dist/auth/file-backend.js.map +1 -0
  5. package/dist/auth/keychain.d.ts +54 -0
  6. package/dist/auth/keychain.js +232 -0
  7. package/dist/auth/keychain.js.map +1 -0
  8. package/dist/auth/resolve-token.d.ts +34 -0
  9. package/dist/auth/resolve-token.js +91 -0
  10. package/dist/auth/resolve-token.js.map +1 -0
  11. package/dist/auth/secure-write.d.ts +2 -0
  12. package/dist/auth/secure-write.js +30 -0
  13. package/dist/auth/secure-write.js.map +1 -0
  14. package/dist/auth/token-store.d.ts +104 -0
  15. package/dist/auth/token-store.js +208 -0
  16. package/dist/auth/token-store.js.map +1 -0
  17. package/dist/cli.d.ts +16 -0
  18. package/dist/cli.js +238 -0
  19. package/dist/cli.js.map +1 -0
  20. package/dist/commands/agents.d.ts +2 -0
  21. package/dist/commands/agents.js +370 -0
  22. package/dist/commands/agents.js.map +1 -0
  23. package/dist/commands/ask.d.ts +2 -0
  24. package/dist/commands/ask.js +246 -0
  25. package/dist/commands/ask.js.map +1 -0
  26. package/dist/commands/context.d.ts +72 -0
  27. package/dist/commands/context.js +7 -0
  28. package/dist/commands/context.js.map +1 -0
  29. package/dist/commands/doctor.d.ts +2 -0
  30. package/dist/commands/doctor.js +237 -0
  31. package/dist/commands/doctor.js.map +1 -0
  32. package/dist/commands/flags.d.ts +25 -0
  33. package/dist/commands/flags.js +100 -0
  34. package/dist/commands/flags.js.map +1 -0
  35. package/dist/commands/handlers.d.ts +2 -0
  36. package/dist/commands/handlers.js +26 -0
  37. package/dist/commands/handlers.js.map +1 -0
  38. package/dist/commands/http.d.ts +8 -0
  39. package/dist/commands/http.js +19 -0
  40. package/dist/commands/http.js.map +1 -0
  41. package/dist/commands/inbox.d.ts +2 -0
  42. package/dist/commands/inbox.js +111 -0
  43. package/dist/commands/inbox.js.map +1 -0
  44. package/dist/commands/login.d.ts +2 -0
  45. package/dist/commands/login.js +272 -0
  46. package/dist/commands/login.js.map +1 -0
  47. package/dist/commands/logout.d.ts +2 -0
  48. package/dist/commands/logout.js +35 -0
  49. package/dist/commands/logout.js.map +1 -0
  50. package/dist/commands/messaging/await.d.ts +43 -0
  51. package/dist/commands/messaging/await.js +125 -0
  52. package/dist/commands/messaging/await.js.map +1 -0
  53. package/dist/commands/messaging/build.d.ts +46 -0
  54. package/dist/commands/messaging/build.js +66 -0
  55. package/dist/commands/messaging/build.js.map +1 -0
  56. package/dist/commands/messaging/http.d.ts +22 -0
  57. package/dist/commands/messaging/http.js +270 -0
  58. package/dist/commands/messaging/http.js.map +1 -0
  59. package/dist/commands/messaging/identity.d.ts +29 -0
  60. package/dist/commands/messaging/identity.js +63 -0
  61. package/dist/commands/messaging/identity.js.map +1 -0
  62. package/dist/commands/messaging/shared.d.ts +53 -0
  63. package/dist/commands/messaging/shared.js +135 -0
  64. package/dist/commands/messaging/shared.js.map +1 -0
  65. package/dist/commands/messaging/state.d.ts +26 -0
  66. package/dist/commands/messaging/state.js +82 -0
  67. package/dist/commands/messaging/state.js.map +1 -0
  68. package/dist/commands/messaging/validate.d.ts +40 -0
  69. package/dist/commands/messaging/validate.js +193 -0
  70. package/dist/commands/messaging/validate.js.map +1 -0
  71. package/dist/commands/messaging/wire.d.ts +133 -0
  72. package/dist/commands/messaging/wire.js +16 -0
  73. package/dist/commands/messaging/wire.js.map +1 -0
  74. package/dist/commands/notify.d.ts +2 -0
  75. package/dist/commands/notify.js +68 -0
  76. package/dist/commands/notify.js.map +1 -0
  77. package/dist/commands/registry.d.ts +14 -0
  78. package/dist/commands/registry.js +144 -0
  79. package/dist/commands/registry.js.map +1 -0
  80. package/dist/commands/stub.d.ts +1 -0
  81. package/dist/commands/stub.js +9 -0
  82. package/dist/commands/stub.js.map +1 -0
  83. package/dist/commands/task.d.ts +2 -0
  84. package/dist/commands/task.js +223 -0
  85. package/dist/commands/task.js.map +1 -0
  86. package/dist/commands/whoami.d.ts +6 -0
  87. package/dist/commands/whoami.js +90 -0
  88. package/dist/commands/whoami.js.map +1 -0
  89. package/dist/config-file.d.ts +38 -0
  90. package/dist/config-file.js +233 -0
  91. package/dist/config-file.js.map +1 -0
  92. package/dist/config.d.ts +64 -0
  93. package/dist/config.js +97 -0
  94. package/dist/config.js.map +1 -0
  95. package/dist/envelope.d.ts +25 -0
  96. package/dist/envelope.js +41 -0
  97. package/dist/envelope.js.map +1 -0
  98. package/dist/exit-codes.d.ts +51 -0
  99. package/dist/exit-codes.js +57 -0
  100. package/dist/exit-codes.js.map +1 -0
  101. package/dist/help.d.ts +1 -0
  102. package/dist/help.js +17 -0
  103. package/dist/help.js.map +1 -0
  104. package/dist/terminal.d.ts +5 -0
  105. package/dist/terminal.js +18 -0
  106. package/dist/terminal.js.map +1 -0
  107. package/dist/version.d.ts +8 -0
  108. package/dist/version.js +23 -0
  109. package/dist/version.js.map +1 -0
  110. package/package.json +38 -0
package/dist/cli.js ADDED
@@ -0,0 +1,238 @@
1
+ #!/usr/bin/env node
2
+ // The `oh-hai` entrypoint (docs/specs/cli.md §4). It stays thin: parse global flags →
3
+ // handle --help / --version → dispatch. Every command now has a real body wired through the
4
+ // command-handler registry (auth #106, messaging #107, agents #209, doctor #108); the honest
5
+ // not-implemented stub (§7 exit 1, §8 `not_implemented` envelope) remains a defensive fallback
6
+ // for any future registry entry added without a handler. A thrown CliError is translated once
7
+ // here into the §7 exit code and, under --json, the §8 failure envelope.
8
+ import { realpathSync } from "node:fs";
9
+ import { createInterface } from "node:readline";
10
+ import { fileURLToPath } from "node:url";
11
+ import { parseArgs } from "node:util";
12
+ import { openStore } from "./auth/token-store.js";
13
+ import { GLOBAL_OPTIONS, flagOn } from "./commands/flags.js";
14
+ import { COMMAND_HANDLERS } from "./commands/handlers.js";
15
+ import { findCommand } from "./commands/registry.js";
16
+ import { notImplemented } from "./commands/stub.js";
17
+ import { resolveConfig } from "./config.js";
18
+ import { loadConfigFiles, nodeConfigFileIo } from "./config-file.js";
19
+ import { CliError, buildErr, buildOk, serializeEnvelope } from "./envelope.js";
20
+ import { ExitCode } from "./exit-codes.js";
21
+ import { TOP_LEVEL_HELP } from "./help.js";
22
+ import { MA2H_WIRE_VERSION, cliVersion } from "./version.js";
23
+ export { flagOn } from "./commands/flags.js";
24
+ // Node's console methods are bound to the console instance, so they can be used directly.
25
+ const consoleIo = { log: console.log, err: console.error };
26
+ /** Read all of stdin as a UTF-8 string (the production `login --token-stdin` reader). */
27
+ async function readAllStdin() {
28
+ const chunks = [];
29
+ for await (const chunk of process.stdin) {
30
+ chunks.push(chunk);
31
+ }
32
+ return Buffer.concat(chunks).toString("utf8");
33
+ }
34
+ /** Read a single line from stdin (the production interactive-prompt reader). Resolves on the first
35
+ * newline (Enter) rather than waiting for EOF, so an interactive `agents revoke` confirm doesn't
36
+ * hang; returns "" if stdin closes with no line. */
37
+ async function readOneStdinLine() {
38
+ const rl = createInterface({ input: process.stdin });
39
+ try {
40
+ for await (const line of rl) {
41
+ return line;
42
+ }
43
+ return "";
44
+ }
45
+ finally {
46
+ rl.close();
47
+ }
48
+ }
49
+ /** Production runtime dependencies — the real keychain-or-file store, stdin, and fetch. Tests
50
+ * pass their own to `run()` so no command touches the real keychain, stdin, or network. */
51
+ const productionRuntime = {
52
+ openStore,
53
+ readStdin: readAllStdin,
54
+ readLine: readOneStdinLine,
55
+ fetchImpl: (url, init) => fetch(url, init),
56
+ loadConfigFiles: () => loadConfigFiles(nodeConfigFileIo()),
57
+ sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
58
+ // A write-callback barrier: a zero-length write whose callback fires only AFTER every
59
+ // previously-queued stdout write has been flushed (stream writes complete in order). `inbox watch`
60
+ // awaits this before acking, so a directive still sitting in Node's stdout buffer can't be consumed
61
+ // by the Hub before it reaches the consumer. `writableNeedDrain` alone is insufficient — it's false
62
+ // for a queued-but-below-high-water-mark write. On a write error (e.g. EPIPE — the consumer closed
63
+ // the pipe) the barrier rejects, so the loop exits WITHOUT acking an undelivered directive.
64
+ flushOutput: () => new Promise((resolve, reject) => process.stdout.write("", (err) => (err ? reject(err) : resolve()))),
65
+ };
66
+ /**
67
+ * True when `moduleUrl`'s file is the process entrypoint. Compares REAL paths (resolving
68
+ * symlinks) so a bin installed as a symlink by npm/Homebrew — where `process.argv[1]` is the
69
+ * symlink but `import.meta.url` is the resolved target — is still recognized as the entry.
70
+ */
71
+ export function isMainModule(invokedPath, moduleUrl) {
72
+ if (invokedPath === undefined) {
73
+ return false;
74
+ }
75
+ try {
76
+ return realpathSync(invokedPath) === realpathSync(fileURLToPath(moduleUrl));
77
+ }
78
+ catch {
79
+ return false;
80
+ }
81
+ }
82
+ /** Map parsed global flags to the config resolver's `RawFlags` shape (§6). */
83
+ function toRawFlags(values) {
84
+ const str = (v) => (typeof v === "string" ? v : undefined);
85
+ const timeoutRaw = str(values.timeout);
86
+ const timeoutMs = timeoutRaw !== undefined ? Number.parseInt(timeoutRaw, 10) : undefined;
87
+ return {
88
+ baseUrl: str(values["base-url"]),
89
+ account: str(values.account) ?? str(values.agent),
90
+ // Tristate: undefined when --json is absent (config `output` may then decide), true/false when
91
+ // explicitly set — so an explicit --json=false can override a configured `output = "json"`.
92
+ json: values.json === undefined ? undefined : flagOn(values.json),
93
+ noColor: flagOn(values["no-color"]),
94
+ timeoutMs: timeoutMs !== undefined && Number.isFinite(timeoutMs) && timeoutMs >= 0 ? timeoutMs : undefined,
95
+ };
96
+ }
97
+ /** Reject a malformed `--timeout` as a usage error (§7 exit 2) rather than letting a negative
98
+ * value reach `AbortSignal.timeout(-1)`, which throws and would surface as a generic exit 1. */
99
+ function validateTimeoutFlag(values) {
100
+ const raw = values.timeout;
101
+ if (typeof raw !== "string")
102
+ return;
103
+ const parsed = Number.parseInt(raw, 10);
104
+ if (!Number.isFinite(parsed) || parsed < 0) {
105
+ throw new CliError("usage", `invalid --timeout '${raw}': must be a non-negative integer (milliseconds).`);
106
+ }
107
+ }
108
+ /** The `MA2H_*` subset of the environment the config resolver consumes (§6). */
109
+ function toEnvVars(env) {
110
+ return {
111
+ MA2H_BASE_URL: env.MA2H_BASE_URL,
112
+ MA2H_AGENT_ID: env.MA2H_AGENT_ID,
113
+ MA2H_AGENT_TOKEN: env.MA2H_AGENT_TOKEN,
114
+ MA2H_TIMEOUT_MS: env.MA2H_TIMEOUT_MS,
115
+ NO_COLOR: env.NO_COLOR,
116
+ };
117
+ }
118
+ /**
119
+ * Run the CLI for the given argv (excluding node + script path) and return the process
120
+ * exit code. Never calls process.exit — the caller sets `process.exitCode`. `runtime` is
121
+ * injectable so command tests stay hermetic.
122
+ */
123
+ export async function run(argv, io = consoleIo, runtime = productionRuntime) {
124
+ let json = false;
125
+ let commandLabel = "oh-hai";
126
+ try {
127
+ const { values, positionals } = parseArgs({
128
+ args: argv,
129
+ allowPositionals: true,
130
+ strict: false,
131
+ options: GLOBAL_OPTIONS,
132
+ });
133
+ json = flagOn(values.json);
134
+ // Under --json, stdout is reserved for the envelope (§8), so help text goes to stderr.
135
+ const emitHelp = (text) => {
136
+ if (json) {
137
+ io.err(text);
138
+ }
139
+ else {
140
+ io.log(text);
141
+ }
142
+ };
143
+ // --version wins, before help or dispatch.
144
+ if (flagOn(values.version)) {
145
+ if (json) {
146
+ io.log(serializeEnvelope(buildOk("version", { version: cliVersion(), wire_version: MA2H_WIRE_VERSION })));
147
+ }
148
+ else {
149
+ io.log(cliVersion());
150
+ }
151
+ return ExitCode.SUCCESS;
152
+ }
153
+ const command = positionals[0];
154
+ // No command (bare invocation or top-level --help) → top-level help. Under
155
+ // noUncheckedIndexedAccess, `command === undefined` is exactly "no positionals".
156
+ if (command === undefined) {
157
+ emitHelp(TOP_LEVEL_HELP);
158
+ return ExitCode.SUCCESS;
159
+ }
160
+ commandLabel = command;
161
+ // Resolve config (§6) as soon as a command is NAMED — valid or not — so a config-selected output
162
+ // format applies uniformly to every command path: the unknown-command error, per-command --help,
163
+ // the not-implemented decline, and real handlers (matching --json, which already reaches them all).
164
+ // Pure meta flags (--version, bare / top-level --help) returned above, so they stay config-free —
165
+ // no config I/O or credential warnings on a bare `oh-hai` or `oh-hai --version`. `warnings` carries
166
+ // the credential-safety rejections (a per-project config that tried to set base_url / account) —
167
+ // surfaced on stderr so they never pollute the --json envelope on stdout.
168
+ const { userConfig, projectConfig } = runtime.loadConfigFiles();
169
+ const { config, warnings } = resolveConfig({
170
+ flags: toRawFlags(values),
171
+ env: toEnvVars(process.env),
172
+ userConfig,
173
+ projectConfig,
174
+ });
175
+ for (const warning of warnings) {
176
+ io.err(`oh-hai: ${warning}`);
177
+ }
178
+ // `output = "json"` implies the machine envelope even without --json. Set it before any error can
179
+ // throw so the catch-block error envelope (an unknown command, a bad --timeout, a stub decline)
180
+ // respects the configured format too. `config.output` already folds in the --json flag.
181
+ json = config.output === "json";
182
+ const spec = findCommand(command);
183
+ if (spec === undefined) {
184
+ throw new CliError("usage", `unknown command: ${command}. Run 'oh-hai --help'.`);
185
+ }
186
+ // Resolve the subcommand and set the §8 command label ("ask.submit" / "agents.list") for BOTH
187
+ // implemented handlers and the stub, so a command's success AND failure (`--json`) envelopes agree
188
+ // on `command`. strict:false pollutes positionals with the values of command-specific flags, so
189
+ // pick the first positional that IS a known subcommand rather than a bare positionals[1].
190
+ const sub = spec.subcommands.length > 0
191
+ ? positionals.slice(1).find((token) => spec.subcommands.includes(token))
192
+ : undefined;
193
+ commandLabel = sub !== undefined ? `${spec.name}.${sub}` : spec.name;
194
+ // Per-command help.
195
+ if (flagOn(values.help)) {
196
+ emitHelp(spec.help);
197
+ return ExitCode.SUCCESS;
198
+ }
199
+ // Implemented commands (login / logout / whoami, #106; notify / ask / task, #107; agents, #209;
200
+ // doctor, #108) dispatch to their handler with the resolved config + injected runtime. The handler
201
+ // parses its own command-specific flags (and, for subcommand commands, re-derives the subcommand
202
+ // from its argv). A handler normally resolves to `void` → exit 0; `doctor` instead RETURNS a
203
+ // numeric exit code so it can print its full checks report AND exit non-zero on a failed required
204
+ // check (a throw would replace the `--json` checks envelope with a `buildErr` one).
205
+ const handler = COMMAND_HANDLERS[spec.name];
206
+ if (handler !== undefined) {
207
+ validateTimeoutFlag(values);
208
+ const result = await handler({ io, json, config, runtime, argv });
209
+ return typeof result === "number" ? result : ExitCode.SUCCESS;
210
+ }
211
+ // Not-yet-implemented commands route to the stub.
212
+ const display = sub !== undefined ? `${spec.name} ${sub}` : spec.name;
213
+ notImplemented(display, spec.issue);
214
+ }
215
+ catch (error) {
216
+ const cliError = error instanceof CliError
217
+ ? error
218
+ : new CliError("error", error instanceof Error ? error.message : String(error));
219
+ if (json) {
220
+ io.log(serializeEnvelope(buildErr(commandLabel, cliError.code, cliError.message)));
221
+ }
222
+ else {
223
+ io.err(`oh-hai: ${cliError.message}`);
224
+ }
225
+ return cliError.exitCode;
226
+ }
227
+ // Unreachable: the dispatch path ends in notImplemented() (throws) or a handler return.
228
+ // Present so the function has a definite ending return under strict TS.
229
+ return ExitCode.SUCCESS;
230
+ }
231
+ async function main() {
232
+ process.exitCode = await run(process.argv.slice(2));
233
+ }
234
+ // Run only when executed as the binary (not when imported by tests).
235
+ if (isMainModule(process.argv[1], import.meta.url)) {
236
+ void main();
237
+ }
238
+ //# sourceMappingURL=cli.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"cli.js","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,2 @@
1
+ import type { CommandContext } from "./context.js";
2
+ export declare function agentsCommand(ctx: CommandContext): Promise<void>;
@@ -0,0 +1,370 @@
1
+ // `oh-hai agents` (docs/specs/cli.md §4.5). Account / identity management — the OPERATOR (human)
2
+ // leg of the CLI, over the per-account agent-token API shipped in #96 (server/src/routes/agents.ts,
3
+ // every route `requireHuman`):
4
+ // - `list` — enumerate the account's agent identities (metadata only, never a token).
5
+ // - `create` — mint a new identity + its bearer, storing the bearer Hub-scoped exactly like
6
+ // `login`. The token is NEVER printed and `--token-stdout` is not offered (§5.4).
7
+ // - `revoke` — revoke an identity server-side and scrub its local credential like `logout`.
8
+ //
9
+ // The command sends the resolved OPERATOR bearer (resolveIdentity, as `whoami` does) and faithfully
10
+ // surfaces the Hub's `403 human session required` when the presented credential is an agent token —
11
+ // acquiring a human session is out of scope here (#209). This is the counterpart to the notify/ask/
12
+ // task messaging leg (#107), which speaks the agent surface.
13
+ //
14
+ // HTTP is self-contained (a small authenticated fetch + `readJson` + a status→§7 exit-code mapping),
15
+ // mirroring the private copies in commands/login.ts and the deliberate duplication in
16
+ // commands/messaging/http.ts (#107) — kept local so this command merges independently of that PR.
17
+ // Only the transport-error → exit-code mapping is shared (commands/http.ts). A future
18
+ // shared CLI-core package could unify the duplicated readers (distinct from `@oh-hai/ma2h-core`,
19
+ // the protocol crypto/lifecycle/validation core, which does not hold these HTTP readers).
20
+ import { parseArgs } from "node:util";
21
+ import { originOf, resolveIdentity } from "../auth/resolve-token.js";
22
+ import { CliError, buildOk, serializeEnvelope } from "../envelope.js";
23
+ import { sanitizeForTerminal } from "../terminal.js";
24
+ import { GLOBAL_OPTIONS, flagOn } from "./flags.js";
25
+ import { throwTransportError } from "./http.js";
26
+ const SUBCOMMANDS = ["list", "create", "revoke"];
27
+ /** The agents-specific flags layered on the globals: `--label` (create), `--target-agent` + `--yes`
28
+ * (revoke). Declared here rather than in flags.ts so #209 doesn't collide with the sibling #107 PR
29
+ * that owns flags.ts changes. */
30
+ const AGENTS_OPTIONS = {
31
+ label: { type: "string" },
32
+ "target-agent": { type: "string" },
33
+ yes: { type: "boolean" },
34
+ };
35
+ /** Default per-request bound when no `--timeout`/`MA2H_TIMEOUT_MS` is set — matches the other
36
+ * network commands so an unresponsive Hub can't hang a command indefinitely (§9). */
37
+ const DEFAULT_TIMEOUT_MS = 10_000;
38
+ const NOT_AUTHENTICATED = "not authenticated — no stored token (run `oh-hai login`, or set MA2H_AGENT_TOKEN).";
39
+ export async function agentsCommand(ctx) {
40
+ const { subcommand, values } = parseAgentsArgs(ctx.argv);
41
+ // Each branch validates its own flags FIRST (a bad invocation is a usage error, exit 2, before we
42
+ // touch credentials or the Hub), then resolves the operator bearer, then does its network work.
43
+ switch (subcommand) {
44
+ case "list": {
45
+ const { token } = await resolveOperator(ctx);
46
+ return listAgents(ctx, token);
47
+ }
48
+ case "create": {
49
+ const label = requireFlag(values.label, "agents create requires --label <label>.");
50
+ const { token, store } = await resolveOperator(ctx);
51
+ return createAgent(ctx, token, store, label);
52
+ }
53
+ case "revoke": {
54
+ // --target-agent is distinct from the global operator --account/--agent, so an operator can
55
+ // revoke a DIFFERENT agent unambiguously (§4.5).
56
+ const targetId = requireFlag(values["target-agent"], "agents revoke requires --target-agent <id>.");
57
+ const yes = flagOn(values.yes);
58
+ if (ctx.json && !yes) {
59
+ throw new CliError("usage", "refusing to revoke without --yes under --json (interactive confirmation is unavailable in machine mode).");
60
+ }
61
+ const { token, store } = await resolveOperator(ctx);
62
+ if (!yes && !(await promptConfirm(ctx, targetId))) {
63
+ ctx.io.err("Aborted — no changes made.");
64
+ return;
65
+ }
66
+ return revokeAgent(ctx, token, store, targetId);
67
+ }
68
+ }
69
+ }
70
+ /** Resolve the OPERATOR bearer the way `whoami` does (env token wins; else an explicit --account,
71
+ * else zero-config sole-identity discovery). The store is opened only when the CI env token is
72
+ * absent (skip probing the OS keychain otherwise); it rides along so create/revoke can write/delete
73
+ * the local credential without re-opening. A missing token is an auth error (exit 3). */
74
+ async function resolveOperator(ctx) {
75
+ const store = ctx.config.tokenSource === "env" ? undefined : await ctx.runtime.openStore();
76
+ const { token } = await resolveIdentity(ctx.config, store);
77
+ if (token === undefined) {
78
+ throw new CliError("auth", NOT_AUTHENTICATED);
79
+ }
80
+ return { token, store };
81
+ }
82
+ /** The store to write/delete a local credential in: reuse the operator-resolution store, or open one
83
+ * on demand — `list` (and the env-token path) skips opening it, but create/revoke must write/delete
84
+ * locally regardless. */
85
+ async function ensureStore(ctx, store) {
86
+ return store ?? (await ctx.runtime.openStore());
87
+ }
88
+ // --- argument parsing -------------------------------------------------------
89
+ /** Parse argv for the agents command: global flags + the agents-specific flags, then extract the
90
+ * required subcommand operand. Mirrors `parseCommandArgs`'s unknown-flag rejection (a typo is a
91
+ * usage error, not a silently-ignored flag). Kept self-contained; a follow-up can dedupe onto
92
+ * #107's `parseSubcommandArgs` once both PRs land. */
93
+ function parseAgentsArgs(argv) {
94
+ const options = { ...GLOBAL_OPTIONS, ...AGENTS_OPTIONS };
95
+ const parsed = parseArgs({ args: argv, allowPositionals: true, strict: false, options });
96
+ const allowed = new Set(Object.keys(options));
97
+ for (const key of Object.keys(parsed.values)) {
98
+ if (!allowed.has(key)) {
99
+ throw new CliError("usage", `unknown flag: --${key}. Run 'oh-hai agents --help'.`);
100
+ }
101
+ }
102
+ // positionals[0] is the command token ("agents"); positionals[1] MUST be a known subcommand.
103
+ // With correct option types no flag value leaks into positionals, so exactly [agents, sub] is
104
+ // expected — a missing subcommand, an unknown one, or an extra operand is a usage error (exit 2).
105
+ const subcommand = parsed.positionals[1];
106
+ if (subcommand === undefined) {
107
+ throw new CliError("usage", `missing subcommand: expected one of ${SUBCOMMANDS.join(" | ")}.`);
108
+ }
109
+ if (!isSubcommand(subcommand)) {
110
+ throw new CliError("usage", `unknown subcommand: ${subcommand} (expected ${SUBCOMMANDS.join(" | ")}).`);
111
+ }
112
+ if (parsed.positionals.length > 2) {
113
+ throw new CliError("usage", `unexpected argument: ${parsed.positionals[2]}.`);
114
+ }
115
+ return { subcommand, values: parsed.values };
116
+ }
117
+ function isSubcommand(value) {
118
+ return SUBCOMMANDS.includes(value);
119
+ }
120
+ /** Extract a required non-empty flag value (trimmed), or throw a usage error (exit 2) with `message`
121
+ * — validated locally, before any Hub call. Used for create's `--label` and revoke's
122
+ * `--target-agent`. A repeated flag is an array (last wins); a non-string/absent value is the empty
123
+ * string, which trips the usage error. */
124
+ function requireFlag(raw, message) {
125
+ const raw1 = Array.isArray(raw) ? raw[raw.length - 1] : raw;
126
+ const value = typeof raw1 === "string" ? raw1.trim() : "";
127
+ // Empty → the flag is missing. A `--`-prefixed value means the flag's argument was omitted and the
128
+ // NEXT long-option token was swallowed as its value under `strict:false` — whether a real flag
129
+ // (`--label --json`) or a typo (`--label --jsoon`) — so reject it rather than mint/revoke against
130
+ // "--jsoon". A single-dash value (`-prod`, or `--label=-prod`) is a legitimate label and is allowed.
131
+ if (value === "" || value.startsWith("--")) {
132
+ throw new CliError("usage", message);
133
+ }
134
+ return value;
135
+ }
136
+ /** Interactive revoke confirmation (only reached in human mode without `--yes`; the `--json`+no-`--yes`
137
+ * case is rejected up-front). Prompt on stderr and read a SINGLE line via `readLine` — a TTY prompt
138
+ * must resolve on Enter, not block until EOF the way `readStdin` (built for `login --token-stdin`)
139
+ * would. Falls back to `readStdin` for harnesses that don't inject `readLine`. */
140
+ async function promptConfirm(ctx, targetId) {
141
+ ctx.io.err(`About to revoke agent ${sanitizeForTerminal(targetId)} — this cannot be undone.`);
142
+ ctx.io.err('Type "y" to confirm: ');
143
+ const readOne = ctx.runtime.readLine ?? ctx.runtime.readStdin;
144
+ const answer = (await readOne()).trim().toLowerCase();
145
+ return answer === "y" || answer === "yes";
146
+ }
147
+ async function listAgents(ctx, token) {
148
+ const res = await authFetch(ctx, "GET", "/v1/agents", token);
149
+ if (res.status !== 200) {
150
+ throw hubStatusToCliError(res.status, await readErrorMessage(res));
151
+ }
152
+ const body = await readJson(res);
153
+ // Distinguish a genuinely EMPTY account (`agents: []`) from a MALFORMED response (missing / non-array
154
+ // `agents`, which `readJson` also collapses toward `{}`): silently showing "No agents." for the
155
+ // latter would hide a Hub/proxy regression from the operator. Treat non-array as a server error.
156
+ if (!Array.isArray(body.agents)) {
157
+ throw new CliError("server", "the Hub returned a malformed agent list (missing `agents` array).");
158
+ }
159
+ const agents = body.agents.map(toAgentView);
160
+ if (ctx.json) {
161
+ // The §4.5 contract is exactly { id, label, created_at, last_used_at, revoked } — toAgentView
162
+ // already projected the Hub's extra `account_id` out. Ids/labels are emitted raw; JSON.stringify
163
+ // escapes any control character so nothing reaches the terminal unescaped (§5.4).
164
+ ctx.io.log(serializeEnvelope(buildOk("agents.list", { agents })));
165
+ return;
166
+ }
167
+ if (agents.length === 0) {
168
+ ctx.io.log("No agents.");
169
+ return;
170
+ }
171
+ // Human table: fields are already terminal-safe (toAgentView sanitized every server string); widths
172
+ // from the data. Absent timestamps render as an em dash.
173
+ const rows = agents.map((a) => ({
174
+ id: a.id,
175
+ label: a.label,
176
+ status: a.revoked ? "revoked" : "active",
177
+ created: a.created_at ?? "—",
178
+ lastUsed: a.last_used_at ?? "—",
179
+ }));
180
+ const display = [{ id: "ID", label: "LABEL", status: "STATUS", created: "CREATED", lastUsed: "LAST-USED" }, ...rows];
181
+ const idW = Math.max(...display.map((r) => r.id.length));
182
+ const labelW = Math.max(...display.map((r) => r.label.length));
183
+ const statusW = Math.max(...display.map((r) => r.status.length));
184
+ const createdW = Math.max(...display.map((r) => r.created.length));
185
+ for (const r of display) {
186
+ ctx.io.log(`${r.id.padEnd(idW)} ${r.label.padEnd(labelW)} ${r.status.padEnd(statusW)} ${r.created.padEnd(createdW)} ${r.lastUsed}`);
187
+ }
188
+ }
189
+ async function createAgent(ctx, token, store, label) {
190
+ // 1) Mint the identity.
191
+ const createRes = await authFetch(ctx, "POST", "/v1/agents", token, { label });
192
+ if (createRes.status !== 201) {
193
+ throw hubStatusToCliError(createRes.status, await readErrorMessage(createRes));
194
+ }
195
+ const createBody = await readJson(createRes);
196
+ const agent = createBody.agent !== null && typeof createBody.agent === "object"
197
+ ? createBody.agent
198
+ : {};
199
+ const rawId = str(agent.id);
200
+ if (rawId === undefined) {
201
+ throw new CliError("server", "the Hub created the agent but returned no id (unexpected response).");
202
+ }
203
+ // Sanitize the server-supplied id ONCE here, so the store key, token-mint URL, the `--json`
204
+ // envelope, and the human line all use the same terminal-safe value — `JSON.stringify` doesn't
205
+ // escape C1 controls, so an unsanitized id would inject into `agents create --json` too (§5.4).
206
+ const agentId = sanitizeForTerminal(rawId);
207
+ // From here the identity EXISTS server-side. Minting its bearer (returned exactly once) or storing
208
+ // it locally can still fail, leaving a tokenless/orphaned identity the operator can neither use nor
209
+ // easily notice. On ANY such failure, tell them which agent to clean up — never the token (§5.4) —
210
+ // then surface the original error.
211
+ try {
212
+ // 2) Mint its bearer (the raw secret is returned exactly once).
213
+ const tokenRes = await authFetch(ctx, "POST", `/v1/agents/${encodeURIComponent(agentId)}/tokens`, token, { label });
214
+ if (tokenRes.status !== 201) {
215
+ throw hubStatusToCliError(tokenRes.status, await readErrorMessage(tokenRes));
216
+ }
217
+ const tokenBody = await readJson(tokenRes);
218
+ const minted = str(tokenBody.token);
219
+ if (minted === undefined) {
220
+ throw new CliError("server", "the Hub minted the token but returned no secret (unexpected response).");
221
+ }
222
+ // Guard against a malformed mint binding the token to a DIFFERENT agent than we created: storing
223
+ // it under our agentId would later authenticate as someone else and fail the Hub's agent-id check
224
+ // on ingest. A conformant response echoes `agent_id`; require it to match before writing.
225
+ if (str(tokenBody.agent_id) !== agentId) {
226
+ throw new CliError("server", "the Hub minted the token for a different agent than was created (unexpected response).");
227
+ }
228
+ // 3) Store the bearer Hub-scoped, exactly like `login` — never printed (§5.4).
229
+ const writeStore = await ensureStore(ctx, store);
230
+ await writeStore.set(originOf(ctx.config.baseUrl), agentId, minted);
231
+ if (ctx.json) {
232
+ ctx.io.log(serializeEnvelope(buildOk("agents.create", { agent_id: agentId, label, storage: writeStore.backendName })));
233
+ }
234
+ else {
235
+ ctx.io.log(`Created agent "${sanitizeForTerminal(label)}" (${agentId}) — token stored in ${writeStore.backendName}.`);
236
+ }
237
+ }
238
+ catch (error) {
239
+ ctx.io.err(`oh-hai: agent ${sanitizeForTerminal(agentId)} was created but its token could not be minted or stored — ` +
240
+ `revoke it with \`oh-hai agents revoke --target-agent ${sanitizeForTerminal(agentId)}\`.`);
241
+ throw error;
242
+ }
243
+ }
244
+ async function revokeAgent(ctx, token, store, targetId) {
245
+ const res = await authFetch(ctx, "DELETE", `/v1/agents/${encodeURIComponent(targetId)}`, token);
246
+ if (res.status !== 204) {
247
+ // 404 → not_found (exit 4) via the uniform mapping, using the Hub's "unknown agent" message.
248
+ throw hubStatusToCliError(res.status, await readErrorMessage(res));
249
+ }
250
+ // The Hub 204 is authoritative; scrub the local credential like `logout` (including when the
251
+ // target is the resolved operator account). Best-effort — a local-delete failure must not fail the
252
+ // command, since the token is already dead server-side.
253
+ const delStore = await ensureStore(ctx, store);
254
+ let localRemoved = false;
255
+ try {
256
+ localRemoved = await delStore.delete(originOf(ctx.config.baseUrl), targetId);
257
+ }
258
+ catch (error) {
259
+ ctx.io.err(`oh-hai: revoked on the Hub, but failed to remove the local token: ${error instanceof Error ? sanitizeForTerminal(error.message) : "unknown error"}.`);
260
+ }
261
+ if (ctx.json) {
262
+ ctx.io.log(serializeEnvelope(buildOk("agents.revoke", { agent_id: targetId, revoked: true })));
263
+ }
264
+ else {
265
+ ctx.io.log(`Revoked agent ${sanitizeForTerminal(targetId)}.${localRemoved ? " Local token removed." : ""}`);
266
+ }
267
+ }
268
+ // --- self-contained Hub HTTP (see file header) ------------------------------
269
+ /** An authenticated request to the Hub over the injected `fetchImpl`. Attaches the operator bearer,
270
+ * sets `Content-Type` only when there's a body, bounds the request with the configured timeout, and
271
+ * maps a transport failure (before any HTTP response) to the right §7 exit code. */
272
+ async function authFetch(ctx, method, path, token, body) {
273
+ const headers = { Authorization: `Bearer ${token}` };
274
+ if (body !== undefined) {
275
+ headers["Content-Type"] = "application/json";
276
+ }
277
+ const init = {
278
+ method,
279
+ headers,
280
+ ...(body !== undefined ? { body: JSON.stringify(body) } : {}),
281
+ signal: AbortSignal.timeout(ctx.config.timeoutMs ?? DEFAULT_TIMEOUT_MS),
282
+ };
283
+ try {
284
+ return await ctx.runtime.fetchImpl(`${ctx.config.baseUrl}${path}`, init);
285
+ }
286
+ catch (error) {
287
+ // A per-request timeout is exit 7 (§7); a DNS/connection failure is exit 5 (§9).
288
+ throwTransportError(error, ctx.config.baseUrl);
289
+ }
290
+ }
291
+ /** Project the Hub's agent row to the §4.5 CLI contract (dropping the extra `account_id`). Every
292
+ * server-supplied string is sanitized HERE, at the projection boundary — the `--json` envelope is
293
+ * printed to the terminal too and `JSON.stringify` does NOT escape C1 controls (U+0080–U+009F), so a
294
+ * hostile Hub could otherwise inject escape sequences via any field in `agents list --json` (§5.4).
295
+ * Defensive about field types so a malformed Hub row can't crash the render. */
296
+ function toAgentView(raw) {
297
+ const r = raw !== null && typeof raw === "object" ? raw : {};
298
+ const clean = (value) => {
299
+ const s = str(value);
300
+ return s === undefined ? null : sanitizeForTerminal(s);
301
+ };
302
+ // `id` is required — a blank/defaulted id can't be used for a follow-up revoke and would let a
303
+ // malformed Hub/proxy row masquerade as a real agent. Reject the row rather than default it.
304
+ const id = clean(r.id);
305
+ if (id === null) {
306
+ throw new CliError("server", "the Hub returned a malformed agent row (missing id).");
307
+ }
308
+ return {
309
+ id,
310
+ label: clean(r.label) ?? "",
311
+ created_at: clean(r.created_at),
312
+ last_used_at: clean(r.last_used_at),
313
+ revoked: r.revoked === true,
314
+ };
315
+ }
316
+ /** Parse a response body as a JSON object, defensively — a fake without `json()`, a non-JSON body,
317
+ * or a non-object (array / string / null) all collapse to `{}`, so callers read fields off a plain
318
+ * record without unchecked casts. */
319
+ async function readJson(res) {
320
+ if (typeof res.json !== "function")
321
+ return {};
322
+ try {
323
+ const parsed = await res.json();
324
+ return parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)
325
+ ? parsed
326
+ : {};
327
+ }
328
+ catch {
329
+ return {};
330
+ }
331
+ }
332
+ /** Read the A2H error envelope `{ error: { message } }` from a non-success response, else undefined. */
333
+ async function readErrorMessage(res) {
334
+ const body = await readJson(res);
335
+ const error = body.error;
336
+ const record = error !== null && typeof error === "object" ? error : {};
337
+ return str(record.message);
338
+ }
339
+ /** Map a Hub HTTP status to the right §7 exit code (via its stable `error.code` string, §8),
340
+ * preferring the server's message. The message is stripped of terminal control characters — the
341
+ * top-level catch prints a CliError's message to the terminal, so a hostile Hub could otherwise
342
+ * inject escape sequences (§5.4). */
343
+ function hubStatusToCliError(status, message) {
344
+ const detail = message !== undefined ? sanitizeForTerminal(message) : `Hub returned ${status}.`;
345
+ if (status === 401 || status === 403)
346
+ return new CliError("auth", detail);
347
+ if (status === 404)
348
+ return new CliError("not_found", detail);
349
+ if (status === 409)
350
+ return new CliError("conflict", detail);
351
+ if (status === 413)
352
+ return new CliError("payload_too_large", detail);
353
+ if (status === 422)
354
+ return new CliError("validation_error", detail);
355
+ if (status === 400)
356
+ return new CliError("bad_request", detail);
357
+ if (status >= 500)
358
+ return new CliError("server", detail);
359
+ return new CliError("error", detail);
360
+ }
361
+ /** A trimmed non-empty string from an unknown field, else undefined — validates a server-supplied
362
+ * field's type and drops empties/whitespace so a padded value never rides into a key or the output.
363
+ * Matches login.ts:str (the two files' HTTP readers are deliberate duplicates; see file header). */
364
+ function str(value) {
365
+ if (typeof value !== "string")
366
+ return undefined;
367
+ const trimmed = value.trim();
368
+ return trimmed === "" ? undefined : trimmed;
369
+ }
370
+ //# sourceMappingURL=agents.js.map