@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.
- package/README.md +154 -0
- package/dist/auth/file-backend.d.ts +16 -0
- package/dist/auth/file-backend.js +98 -0
- package/dist/auth/file-backend.js.map +1 -0
- package/dist/auth/keychain.d.ts +54 -0
- package/dist/auth/keychain.js +232 -0
- package/dist/auth/keychain.js.map +1 -0
- package/dist/auth/resolve-token.d.ts +34 -0
- package/dist/auth/resolve-token.js +91 -0
- package/dist/auth/resolve-token.js.map +1 -0
- package/dist/auth/secure-write.d.ts +2 -0
- package/dist/auth/secure-write.js +30 -0
- package/dist/auth/secure-write.js.map +1 -0
- package/dist/auth/token-store.d.ts +104 -0
- package/dist/auth/token-store.js +208 -0
- package/dist/auth/token-store.js.map +1 -0
- package/dist/cli.d.ts +16 -0
- package/dist/cli.js +238 -0
- package/dist/cli.js.map +1 -0
- package/dist/commands/agents.d.ts +2 -0
- package/dist/commands/agents.js +370 -0
- package/dist/commands/agents.js.map +1 -0
- package/dist/commands/ask.d.ts +2 -0
- package/dist/commands/ask.js +246 -0
- package/dist/commands/ask.js.map +1 -0
- package/dist/commands/context.d.ts +72 -0
- package/dist/commands/context.js +7 -0
- package/dist/commands/context.js.map +1 -0
- package/dist/commands/doctor.d.ts +2 -0
- package/dist/commands/doctor.js +237 -0
- package/dist/commands/doctor.js.map +1 -0
- package/dist/commands/flags.d.ts +25 -0
- package/dist/commands/flags.js +100 -0
- package/dist/commands/flags.js.map +1 -0
- package/dist/commands/handlers.d.ts +2 -0
- package/dist/commands/handlers.js +26 -0
- package/dist/commands/handlers.js.map +1 -0
- package/dist/commands/http.d.ts +8 -0
- package/dist/commands/http.js +19 -0
- package/dist/commands/http.js.map +1 -0
- package/dist/commands/inbox.d.ts +2 -0
- package/dist/commands/inbox.js +111 -0
- package/dist/commands/inbox.js.map +1 -0
- package/dist/commands/login.d.ts +2 -0
- package/dist/commands/login.js +272 -0
- package/dist/commands/login.js.map +1 -0
- package/dist/commands/logout.d.ts +2 -0
- package/dist/commands/logout.js +35 -0
- package/dist/commands/logout.js.map +1 -0
- package/dist/commands/messaging/await.d.ts +43 -0
- package/dist/commands/messaging/await.js +125 -0
- package/dist/commands/messaging/await.js.map +1 -0
- package/dist/commands/messaging/build.d.ts +46 -0
- package/dist/commands/messaging/build.js +66 -0
- package/dist/commands/messaging/build.js.map +1 -0
- package/dist/commands/messaging/http.d.ts +22 -0
- package/dist/commands/messaging/http.js +270 -0
- package/dist/commands/messaging/http.js.map +1 -0
- package/dist/commands/messaging/identity.d.ts +29 -0
- package/dist/commands/messaging/identity.js +63 -0
- package/dist/commands/messaging/identity.js.map +1 -0
- package/dist/commands/messaging/shared.d.ts +53 -0
- package/dist/commands/messaging/shared.js +135 -0
- package/dist/commands/messaging/shared.js.map +1 -0
- package/dist/commands/messaging/state.d.ts +26 -0
- package/dist/commands/messaging/state.js +82 -0
- package/dist/commands/messaging/state.js.map +1 -0
- package/dist/commands/messaging/validate.d.ts +40 -0
- package/dist/commands/messaging/validate.js +193 -0
- package/dist/commands/messaging/validate.js.map +1 -0
- package/dist/commands/messaging/wire.d.ts +133 -0
- package/dist/commands/messaging/wire.js +16 -0
- package/dist/commands/messaging/wire.js.map +1 -0
- package/dist/commands/notify.d.ts +2 -0
- package/dist/commands/notify.js +68 -0
- package/dist/commands/notify.js.map +1 -0
- package/dist/commands/registry.d.ts +14 -0
- package/dist/commands/registry.js +144 -0
- package/dist/commands/registry.js.map +1 -0
- package/dist/commands/stub.d.ts +1 -0
- package/dist/commands/stub.js +9 -0
- package/dist/commands/stub.js.map +1 -0
- package/dist/commands/task.d.ts +2 -0
- package/dist/commands/task.js +223 -0
- package/dist/commands/task.js.map +1 -0
- package/dist/commands/whoami.d.ts +6 -0
- package/dist/commands/whoami.js +90 -0
- package/dist/commands/whoami.js.map +1 -0
- package/dist/config-file.d.ts +38 -0
- package/dist/config-file.js +233 -0
- package/dist/config-file.js.map +1 -0
- package/dist/config.d.ts +64 -0
- package/dist/config.js +97 -0
- package/dist/config.js.map +1 -0
- package/dist/envelope.d.ts +25 -0
- package/dist/envelope.js +41 -0
- package/dist/envelope.js.map +1 -0
- package/dist/exit-codes.d.ts +51 -0
- package/dist/exit-codes.js +57 -0
- package/dist/exit-codes.js.map +1 -0
- package/dist/help.d.ts +1 -0
- package/dist/help.js +17 -0
- package/dist/help.js.map +1 -0
- package/dist/terminal.d.ts +5 -0
- package/dist/terminal.js +18 -0
- package/dist/terminal.js.map +1 -0
- package/dist/version.d.ts +8 -0
- package/dist/version.js +23 -0
- package/dist/version.js.map +1 -0
- 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
|
package/dist/cli.js.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"cli.js","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":"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"}
|
|
@@ -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
|