@jini-ai/cli 0.4.1 → 0.6.10

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 CHANGED
@@ -1,5 +1,18 @@
1
1
  # @jini-ai/cli
2
2
 
3
+ ## 0.6.10
4
+
5
+ ### Patch Changes
6
+
7
+ - Updated dependencies [22f0619]
8
+ - Updated dependencies [82ed2d3]
9
+ - Updated dependencies [5a3de86]
10
+ - @jini-ai/core@0.6.10
11
+
12
+ ## 0.4.2 — 2026-10-22
13
+
14
+ - 2026-10-22: Complexity refactors, coverage.
15
+
3
16
  ## 0.4.1
4
17
 
5
18
  ### Patch Changes
@@ -28,7 +28,7 @@ export class CommandRegistry {
28
28
  if (this.commands.has(name) && opts.override !== true) {
29
29
  throw new Error(`command "${name}" is already registered; pass { override: true } to replace it intentionally`);
30
30
  }
31
- this.commands.set(name, { name, handler, ...(opts.usage !== undefined ? { usage: opts.usage } : {}) });
31
+ this.commands.set(name, { handler, ...(opts.usage !== undefined ? { usage: opts.usage } : {}) });
32
32
  return this;
33
33
  }
34
34
  has({ name }) {
@@ -56,25 +56,7 @@ export class CommandRegistry {
56
56
  * next token as a value — so the scan skips over it instead.
57
57
  */
58
58
  async dispatch({ argv }, options = {}) {
59
- const valueFlags = options.valueFlags ?? new Set();
60
- let index = -1;
61
- for (let i = 0; i < argv.length; i++) {
62
- // Loop is bounded by argv.length, so this index is always in range —
63
- // noUncheckedIndexedAccess types it as possibly-undefined regardless.
64
- const token = argv[i];
65
- if (!token.startsWith('-')) {
66
- index = i;
67
- break;
68
- }
69
- if (token.startsWith('--')) {
70
- const eq = token.indexOf('=');
71
- if (eq < 0) {
72
- const key = token.slice(2);
73
- if (valueFlags.has(key))
74
- i++; // skip this flag's value token
75
- }
76
- }
77
- }
59
+ const index = findCommandIndex({ argv }, { valueFlags: options.valueFlags ?? new Set() });
78
60
  if (index === -1)
79
61
  return { kind: 'empty' };
80
62
  const name = argv[index];
@@ -86,4 +68,22 @@ export class CommandRegistry {
86
68
  return { kind: 'handled' };
87
69
  }
88
70
  }
71
+ /**
72
+ * Index of the first token in `argv` that does not start with `-`, or `-1` when there is none.
73
+ * A `--name` token (no `=`) whose `name` is in `valueFlags` also skips the token after it, so a
74
+ * value-flag's own value is never taken for the command name (CR-003). Single-dash and
75
+ * `--name=value` tokens never skip.
76
+ */
77
+ function findCommandIndex({ argv }, { valueFlags }) {
78
+ for (let i = 0; i < argv.length; i++) {
79
+ // Loop is bounded by argv.length, so this index is always in range —
80
+ // noUncheckedIndexedAccess types it as possibly-undefined regardless.
81
+ const token = argv[i];
82
+ if (!token.startsWith('-'))
83
+ return i;
84
+ if (token.startsWith('--') && !token.includes('=') && valueFlags.has(token.slice(2)))
85
+ i++; // skip this flag's value token
86
+ }
87
+ return -1;
88
+ }
89
89
  //# sourceMappingURL=command-registry.js.map
@@ -10,30 +10,32 @@ import { isLoopbackHostname } from '@jini-ai/platform/net';
10
10
  */
11
11
  export async function resolveDaemonUrl(_requiredArgs, options = {}) {
12
12
  const env = options.env ?? process.env;
13
- const warn = options.warn ?? (() => { });
14
- const report = (url) => {
15
- const warning = daemonUrlPolicyWarning({ url });
16
- if (warning !== null)
17
- warn({ message: warning });
18
- return url;
19
- };
20
- const flagUrl = options.flagUrl ?? null;
21
- if (flagUrl !== null && flagUrl.length > 0)
22
- return report(flagUrl);
23
- if (options.envVarName !== undefined) {
24
- const envUrl = env[options.envVarName];
25
- if (envUrl !== undefined && envUrl.length > 0)
26
- return report(envUrl);
27
- }
28
- if (options.discover !== undefined) {
29
- const discovered = await options.discover({ env }, { timeoutMs: options.timeoutMs ?? 800 });
30
- if (discovered !== null && discovered.length > 0)
31
- return report(discovered);
13
+ // `??` short-circuits, so discover() is only probed when flag and env both came up empty.
14
+ // defaultUrl is taken as-is (even `''`): only `undefined` skips it.
15
+ const url = nonEmpty(options.flagUrl ?? null) ??
16
+ envCandidate({ env }, options) ??
17
+ (await discoverCandidate({ env }, options)) ??
18
+ options.defaultUrl;
19
+ if (url === undefined) {
20
+ throw new Error('no daemon URL resolved: pass flagUrl, set envVarName on a populated env var, ' +
21
+ 'supply a discover() probe, or provide defaultUrl.');
32
22
  }
33
- if (options.defaultUrl !== undefined)
34
- return report(options.defaultUrl);
35
- throw new Error('no daemon URL resolved: pass flagUrl, set envVarName on a populated env var, ' +
36
- 'supply a discover() probe, or provide defaultUrl.');
23
+ const warning = daemonUrlPolicyWarning({ url });
24
+ if (warning !== null)
25
+ options.warn?.({ message: warning });
26
+ return url;
27
+ }
28
+ /** `value` when it is a non-empty string, else `undefined` (`null` and `''` both mean "unset"). */
29
+ function nonEmpty(value) {
30
+ return value !== null && value.length > 0 ? value : undefined;
31
+ }
32
+ /** The env step: `env[envVarName]`, skipped entirely when no `envVarName` was given. */
33
+ function envCandidate({ env }, { envVarName }) {
34
+ return envVarName === undefined ? undefined : nonEmpty(env[envVarName] ?? null);
35
+ }
36
+ /** The discovery step: one `discover()` probe with the caller timeout (default 800ms), when a probe was given. */
37
+ async function discoverCandidate({ env }, { discover, timeoutMs }) {
38
+ return discover === undefined ? undefined : nonEmpty(await discover({ env }, { timeoutMs: timeoutMs ?? 800 }));
37
39
  }
38
40
  // Matches a scheme + userinfo prefix (`scheme://user:pass@`) on a URL string that failed to
39
41
  // parse via the `URL` constructor, so userinfo can still be stripped from a malformed value
package/dist/errors.js CHANGED
@@ -47,7 +47,7 @@ export function structuredErrorData({ error }) {
47
47
  if (error === undefined)
48
48
  return undefined;
49
49
  const data = {};
50
- if (error.data !== undefined && typeof error.data === 'object' && error.data !== null) {
50
+ if (typeof error.data === 'object' && error.data !== null) {
51
51
  Object.assign(data, sanitizeUnknownDeep({ value: error.data }));
52
52
  }
53
53
  if (error.details !== undefined)
@@ -83,22 +83,37 @@ function extractErrorObject(parsed) {
83
83
  * from the same untrusted response.
84
84
  */
85
85
  export async function structuredHttpFailure({ resp }, { fallbackCode = 'daemon-not-running', ...options } = {}) {
86
+ const { raw, parsed } = await readErrorBody({ resp });
87
+ const errorObj = extractErrorObject(parsed);
88
+ const code = typeof errorObj?.code === 'string' ? stripControlSequences({ text: errorObj.code }) : fallbackCode;
89
+ const structuredMessage = typeof errorObj?.message === 'string' ? sanitizeUntrustedText({ text: errorObj.message }) : undefined;
90
+ const message = structuredMessage ?? `HTTP ${resp.status}`;
91
+ const data = envelopeData({ errorObj, rawExcerptSource: structuredMessage === undefined ? raw : '' });
92
+ // A caller-supplied `options.data` is spread last, so it replaces the daemon-derived data.
93
+ return exitWithStructuredError({ code, message }, { ...(data !== undefined ? { data } : {}), ...options });
94
+ }
95
+ /**
96
+ * Read and JSON-parse a failure body. A `text()` rejection, an empty body, and unparsable JSON
97
+ * all yield `parsed: {}`; `raw` keeps whatever text was read (empty when `text()` rejected).
98
+ */
99
+ async function readErrorBody({ resp }) {
86
100
  let raw = '';
87
- let parsed = {};
88
101
  try {
89
102
  raw = await resp.text();
90
- parsed = raw.length > 0 ? JSON.parse(raw) : {};
103
+ return { raw, parsed: raw.length > 0 ? JSON.parse(raw) : {} };
91
104
  }
92
105
  catch {
93
- parsed = {};
106
+ return { raw, parsed: {} };
94
107
  }
95
- const errorObj = extractErrorObject(parsed);
108
+ }
109
+ /**
110
+ * The envelope `data`: {@link structuredErrorData} plus, when `rawExcerptSource` is non-empty
111
+ * (i.e. there was no structured message), a sanitized `rawExcerpt` of the raw body.
112
+ */
113
+ function envelopeData({ errorObj, rawExcerptSource }) {
96
114
  const data = structuredErrorData({ error: errorObj });
97
- const code = typeof errorObj?.code === 'string' ? stripControlSequences({ text: errorObj.code }) : fallbackCode;
98
- const structuredMessage = typeof errorObj?.message === 'string' ? sanitizeUntrustedText({ text: errorObj.message }) : undefined;
99
- const message = structuredMessage ?? `HTTP ${resp.status}`;
100
- const rawExcerpt = structuredMessage === undefined && raw.length > 0 ? sanitizeUntrustedText({ text: raw }) : undefined;
101
- const mergedData = rawExcerpt !== undefined ? { ...(data ?? {}), rawExcerpt } : data;
102
- return exitWithStructuredError({ code, message }, { ...(mergedData !== undefined ? { data: mergedData } : {}), ...options });
115
+ if (rawExcerptSource.length === 0)
116
+ return data;
117
+ return { ...data, rawExcerpt: sanitizeUntrustedText({ text: rawExcerptSource }) };
103
118
  }
104
119
  //# sourceMappingURL=errors.js.map
package/dist/flags.js CHANGED
@@ -29,39 +29,41 @@ export function parseFlags({ argv }, opts = {}) {
29
29
  const a = argv[i];
30
30
  if (!a.startsWith('--'))
31
31
  continue;
32
- const eq = a.indexOf('=');
33
- const key = eq >= 0 ? a.slice(2, eq) : a.slice(2);
32
+ const { key, inlineValue } = splitFlagToken({ token: a });
34
33
  if (knownFlags.size > 0 && !knownFlags.has(key)) {
35
34
  throw new Error(`unknown flag: --${key}. Run with --help for the list of accepted flags.`);
36
35
  }
37
- if (eq >= 0) {
38
- out[key] = a.slice(eq + 1);
39
- continue;
40
- }
41
- if (booleanFlags.has(key)) {
42
- out[key] = true;
43
- continue;
44
- }
45
- if (stringFlags.has(key)) {
46
- const next = argv[i + 1];
47
- if (next === undefined) {
48
- throw new Error(`flag --${key} requires a value`);
49
- }
50
- out[key] = next;
51
- i++;
52
- continue;
53
- }
54
- const next = argv[i + 1];
55
- if (next !== undefined && !next.startsWith('--')) {
56
- out[key] = next;
36
+ const { value, consumesNext } = resolveFlagValue({ key, inlineValue, next: argv[i + 1] }, { stringFlags, booleanFlags });
37
+ out[key] = value;
38
+ if (consumesNext)
57
39
  i++;
58
- }
59
- else {
60
- out[key] = true;
61
- }
62
40
  }
63
41
  return out;
64
42
  }
43
+ /** Split a `--key` / `--key=value` token; `inlineValue` is everything after the first `=`, if any. */
44
+ function splitFlagToken({ token }) {
45
+ const eq = token.indexOf('=');
46
+ return eq >= 0 ? { key: token.slice(2, eq), inlineValue: token.slice(eq + 1) } : { key: token.slice(2), inlineValue: undefined };
47
+ }
48
+ /**
49
+ * Decide one flag's value, in precedence order: an inline `=value`, then a declared boolean
50
+ * (`true`), then a declared string flag (always takes `next`, which must exist), then the
51
+ * undeclared heuristic (`next` when it does not itself start with `--`, else `true`).
52
+ */
53
+ function resolveFlagValue({ key, inlineValue, next }, { stringFlags, booleanFlags }) {
54
+ if (inlineValue !== undefined)
55
+ return { value: inlineValue, consumesNext: false };
56
+ if (booleanFlags.has(key))
57
+ return { value: true, consumesNext: false };
58
+ if (stringFlags.has(key)) {
59
+ if (next === undefined)
60
+ throw new Error(`flag --${key} requires a value`);
61
+ return { value: next, consumesNext: true };
62
+ }
63
+ if (next !== undefined && !next.startsWith('--'))
64
+ return { value: next, consumesNext: true };
65
+ return { value: true, consumesNext: false };
66
+ }
65
67
  /**
66
68
  * Collect every non-flag token from `argv`, skipping the value token that
67
69
  * follows a known string flag (so `od run start --project p1 foo` yields
@@ -69,12 +71,13 @@ export function parseFlags({ argv }, opts = {}) {
69
71
  */
70
72
  export function positionalArgs({ argv }, opts = {}) {
71
73
  const stringFlags = opts.string ?? new Set();
74
+ const stopAtDoubleDash = opts.stopAtDoubleDash === true;
72
75
  const out = [];
73
76
  for (let i = 0; i < argv.length; i++) {
74
77
  // Loop is bounded by argv.length, so this index is always in range —
75
78
  // noUncheckedIndexedAccess types it as possibly-undefined regardless.
76
79
  const a = argv[i];
77
- if (opts.stopAtDoubleDash === true && a === '--') {
80
+ if (stopAtDoubleDash && a === '--') {
78
81
  out.push(...argv.slice(i + 1));
79
82
  break;
80
83
  }
@@ -82,9 +85,8 @@ export function positionalArgs({ argv }, opts = {}) {
82
85
  out.push(a);
83
86
  continue;
84
87
  }
85
- const eq = a.indexOf('=');
86
- const key = eq >= 0 ? a.slice(2, eq) : a.slice(2);
87
- if (eq < 0 && stringFlags.has(key))
88
+ const { key, inlineValue } = splitFlagToken({ token: a });
89
+ if (inlineValue === undefined && stringFlags.has(key))
88
90
  i++;
89
91
  }
90
92
  return out;
package/dist/http.js CHANGED
@@ -11,6 +11,37 @@
11
11
  import { DEFAULT_CLI_EXIT_CODES, exitWithStructuredError, structuredErrorData } from './errors.js';
12
12
  import { sanitizeDaemonUrlForDisplay } from './daemon-url.js';
13
13
  import { sanitizeUntrustedText } from '@jini-ai/core/text';
14
+ function writeToStderr({ text }) {
15
+ process.stderr.write(text);
16
+ }
17
+ function exitProcess({ code }) {
18
+ return process.exit(code);
19
+ }
20
+ /** `err.message` for an `Error`, `String(err)` for anything else. */
21
+ function errorText(err) {
22
+ return err instanceof Error ? err.message : String(err);
23
+ }
24
+ /** `value[key]` when `value` is an object whose `key` field is a string; `null` otherwise. */
25
+ function stringField(value, key) {
26
+ if (value === null || typeof value !== 'object')
27
+ return null;
28
+ const field = value[key];
29
+ return typeof field === 'string' ? field : null;
30
+ }
31
+ /** `cause.code` (with `cause.message` when present), else `cause.message`, else the error's own text. */
32
+ function describeFetchFailure(err) {
33
+ const cause = err !== null && typeof err === 'object' ? err.cause : null;
34
+ const code = stringField(cause, 'code');
35
+ const causeMessage = stringField(cause, 'message') ?? '';
36
+ // Stringified up front, before the cause-based choice below, exactly as the original did — so a
37
+ // value whose String() throws still throws even when a cause code is present.
38
+ const ownText = errorText(err);
39
+ if (code !== null)
40
+ return { code, detail: causeMessage.length > 0 ? `${code} — ${causeMessage}` : code };
41
+ return { code, detail: causeMessage.length > 0 ? causeMessage : ownText };
42
+ }
43
+ /** Cause codes that indicate an outbound connect denied by a sandbox policy rather than a down daemon. */
44
+ const SANDBOX_DENIAL_CODES = new Set(['EPERM', 'ENETUNREACH']);
14
45
  /**
15
46
  * Format a `fetch()` rejection (network-unreachable, sandbox-denied) into a
16
47
  * human-readable stderr line, with a hint when the underlying cause looks
@@ -21,21 +52,10 @@ import { sanitizeUntrustedText } from '@jini-ai/core/text';
21
52
  * printed verbatim.
22
53
  */
23
54
  export function surfaceFetchError({ err, daemonUrl }, options = {}) {
24
- const write = options.write ?? (({ text }) => { process.stderr.write(text); });
25
- const cause = err !== null && typeof err === 'object' ? err.cause : null;
26
- const code = cause !== null && typeof cause === 'object' && typeof cause.code === 'string'
27
- ? cause.code
28
- : null;
29
- const causeMessage = cause !== null && typeof cause === 'object' && typeof cause.message === 'string'
30
- ? cause.message
31
- : '';
32
- let detail = err instanceof Error ? err.message : String(err);
33
- if (code !== null)
34
- detail = `${code}${causeMessage.length > 0 ? ` — ${causeMessage}` : ''}`;
35
- else if (causeMessage.length > 0)
36
- detail = causeMessage;
55
+ const write = options.write ?? writeToStderr;
56
+ const { code, detail } = describeFetchFailure(err);
37
57
  write({ text: `failed to reach daemon at ${sanitizeDaemonUrlForDisplay({ url: daemonUrl })}: ${sanitizeUntrustedText({ text: detail })}\n` });
38
- if (code === 'EPERM' || code === 'ENETUNREACH') {
58
+ if (code !== null && SANDBOX_DENIAL_CODES.has(code)) {
39
59
  write({ text: 'hint: outbound connect was denied, likely by a sandbox policy. ' +
40
60
  'If this command ran inside an agent sandbox, check its network policy — ' +
41
61
  'the daemon itself is unaffected and can be reached from a regular shell.\n',
@@ -55,6 +75,57 @@ class ResponseTooLargeError extends Error {
55
75
  this.name = 'ResponseTooLargeError';
56
76
  }
57
77
  }
78
+ /** Rejects up front when `resp` declares a numeric `content-length` above `maxBytes`; a missing or non-numeric header is left to the byte-counting read. */
79
+ function assertDeclaredLengthWithin(resp, maxBytes) {
80
+ const contentLength = resp.headers?.get?.('content-length');
81
+ if (contentLength === null || contentLength === undefined)
82
+ return;
83
+ const declared = Number(contentLength);
84
+ if (Number.isFinite(declared) && declared > maxBytes)
85
+ throw new ResponseTooLargeError(maxBytes);
86
+ }
87
+ /** Drains `body` as UTF-8 text, throwing {@link ResponseTooLargeError} as soon as the running byte count passes `maxBytes`. */
88
+ async function readStreamText(body, maxBytes) {
89
+ const reader = body.getReader();
90
+ const decoder = new TextDecoder();
91
+ let text = '';
92
+ let total = 0;
93
+ try {
94
+ for (;;) {
95
+ const { done, value } = await reader.read();
96
+ if (done === true)
97
+ break;
98
+ if (value === undefined)
99
+ continue;
100
+ total += value.byteLength;
101
+ if (total > maxBytes)
102
+ throw new ResponseTooLargeError(maxBytes);
103
+ text += decoder.decode(value, { stream: true });
104
+ }
105
+ return text + decoder.decode();
106
+ }
107
+ finally {
108
+ reader.releaseLock();
109
+ }
110
+ }
111
+ /** `resp.text()`, byte-checked against `maxBytes` before it is parsed. */
112
+ async function readTextWithin(resp, maxBytes) {
113
+ const text = await resp.text();
114
+ if (Buffer.byteLength(text, 'utf8') > maxBytes)
115
+ throw new ResponseTooLargeError(maxBytes);
116
+ return text;
117
+ }
118
+ /** Parses `text` as JSON; an empty or non-JSON body resolves to `{}`. */
119
+ function parseJsonOrEmpty(text) {
120
+ if (text.length === 0)
121
+ return {};
122
+ try {
123
+ return JSON.parse(text);
124
+ }
125
+ catch {
126
+ return {};
127
+ }
128
+ }
58
129
  /**
59
130
  * Read `resp`'s body as JSON without ever buffering past `maxBytes`. Prefers streaming via
60
131
  * `resp.body`'s reader (what a real `fetch()` `Response` provides) so a huge response is
@@ -64,130 +135,114 @@ class ResponseTooLargeError extends Error {
64
135
  * valid JSON resolves to `{}`, matching this module's previous behavior.
65
136
  */
66
137
  async function readJsonWithLimit(resp, maxBytes) {
67
- const contentLength = resp.headers?.get?.('content-length');
68
- if (contentLength !== null && contentLength !== undefined) {
69
- const declared = Number(contentLength);
70
- if (Number.isFinite(declared) && declared > maxBytes)
71
- throw new ResponseTooLargeError(maxBytes);
72
- }
138
+ assertDeclaredLengthWithin(resp, maxBytes);
73
139
  const body = resp.body;
74
- if (body != null && typeof body.getReader === 'function') {
75
- const reader = body.getReader();
76
- const decoder = new TextDecoder();
77
- let text = '';
78
- let total = 0;
79
- try {
80
- for (;;) {
81
- const { done, value } = await reader.read();
82
- if (done === true)
83
- break;
84
- if (value !== undefined) {
85
- total += value.byteLength;
86
- if (total > maxBytes)
87
- throw new ResponseTooLargeError(maxBytes);
88
- text += decoder.decode(value, { stream: true });
89
- }
90
- }
91
- text += decoder.decode();
92
- }
93
- finally {
94
- reader.releaseLock();
95
- }
96
- try {
97
- return text.length > 0 ? JSON.parse(text) : {};
98
- }
99
- catch {
100
- return {};
101
- }
140
+ if (body != null && typeof body.getReader === 'function')
141
+ return parseJsonOrEmpty(await readStreamText(body, maxBytes));
142
+ if (typeof resp.text === 'function')
143
+ return parseJsonOrEmpty(await readTextWithin(resp, maxBytes));
144
+ return await resp.json().catch(() => ({}));
145
+ }
146
+ function structuredExitOptions(options) {
147
+ return {
148
+ write: options.write ?? writeToStderr,
149
+ exit: options.exit ?? exitProcess,
150
+ ...(options.exitCodes !== undefined ? { exitCodes: options.exitCodes } : {}),
151
+ };
152
+ }
153
+ /** Arms the request deadline: aborts `controller` with a route-naming error after `timeoutMs`. */
154
+ function startDeadline({ route, timeoutMs }, { signal }) {
155
+ const controller = new AbortController();
156
+ const handle = setTimeout(() => {
157
+ controller.abort(new Error(`request to ${route} timed out after ${timeoutMs}ms`));
158
+ }, timeoutMs);
159
+ // A short-lived CLI invocation shouldn't be kept alive purely by this timer.
160
+ handle.unref?.();
161
+ return {
162
+ controller,
163
+ signal: signal !== undefined ? AbortSignal.any([signal, controller.signal]) : controller.signal,
164
+ clear: () => { clearTimeout(handle); },
165
+ };
166
+ }
167
+ /** POST adds a JSON content-type (a caller header of the same name still wins) and the serialized body; GET sends only the caller headers. */
168
+ function buildFetchInit(init, headers, signal) {
169
+ if (init.method === 'POST') {
170
+ return { method: 'POST', headers: { 'content-type': 'application/json', ...headers }, body: JSON.stringify(init.body), signal };
102
171
  }
103
- if (typeof resp.text === 'function') {
104
- const text = await resp.text();
105
- if (Buffer.byteLength(text, 'utf8') > maxBytes)
106
- throw new ResponseTooLargeError(maxBytes);
107
- try {
108
- return text.length > 0 ? JSON.parse(text) : {};
109
- }
110
- catch {
111
- return {};
112
- }
172
+ return { method: 'GET', headers: { ...headers }, signal };
173
+ }
174
+ /** A rejected `fetch()` (or a request init that could not be built) exits as `daemon-not-running`, carrying {@link surfaceFetchError}'s lines as diagnostics. */
175
+ function exitForFetchFailure({ err, base }, structured) {
176
+ const diagnostics = [];
177
+ surfaceFetchError({ err, daemonUrl: base }, { write: ({ text }) => { diagnostics.push(text.trimEnd()); } });
178
+ return exitWithStructuredError({ code: 'daemon-not-running', message: `Cannot reach daemon at ${sanitizeDaemonUrlForDisplay({ url: base })}` }, { data: { diagnostics }, ...structured });
179
+ }
180
+ /** A body-read failure: an oversized body aborts the in-flight request and exits `response-too-large`; anything else exits `request-failed`. */
181
+ function exitForReadFailure({ err, base, route, controller }, structured) {
182
+ if (err instanceof ResponseTooLargeError) {
183
+ controller.abort(err);
184
+ return exitWithStructuredError({ code: 'response-too-large', message: `response from ${sanitizeDaemonUrlForDisplay({ url: base })}${route} exceeded the ${err.limitBytes}-byte limit; aborting.` }, structured);
113
185
  }
114
- return await resp.json().catch(() => ({}));
186
+ return exitWithStructuredError({ code: 'request-failed', message: sanitizeUntrustedText({ text: errorText(err) }) }, structured);
115
187
  }
116
188
  /**
117
- * Shared transport core behind {@link postJsonToDaemon} and {@link getJsonFromDaemon}: fetch
118
- * `<base><route>` with a bounded timeout and response-size cap, and map a network failure or a
119
- * non-2xx daemon response through the structured error contract (`errors.ts`) instead of
120
- * returning — this function either resolves with the parsed body or never returns. Per
121
- * CR-004/SEC-RB-009, daemon-supplied error detail is redacted before it reaches stderr rather than
122
- * dumped verbatim. Both public wrappers exist only to fix `init.method`/`init.body` at their own
123
- * call site — nothing method-specific lives here beyond that.
189
+ * Fetches `<base><route>` and reads its JSON body under one deadline. The timeout signal stays
190
+ * live through both the initial fetch *and* the body read — a daemon that returns headers
191
+ * promptly but then drips its body slowly should still be bounded by the same deadline, not
192
+ * just the connection phase.
124
193
  */
125
- async function requestJsonFromDaemon(base, route, init, options = {}) {
194
+ async function fetchJsonWithinDeadline({ base, route, init }, { options, structured }) {
126
195
  const fetchImpl = options.fetchImpl ?? fetch;
127
- const write = options.write ?? (({ text }) => { process.stderr.write(text); });
128
- const exit = options.exit ?? (({ code }) => process.exit(code));
129
- const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
130
196
  const maxResponseBytes = options.maxResponseBytes ?? DEFAULT_MAX_RESPONSE_BYTES;
131
- const structuredOptions = {
132
- write,
133
- exit,
134
- ...(options.exitCodes !== undefined ? { exitCodes: options.exitCodes } : {}),
135
- };
136
- const timeoutController = new AbortController();
137
- const timeoutHandle = setTimeout(() => {
138
- timeoutController.abort(new Error(`request to ${route} timed out after ${timeoutMs}ms`));
139
- }, timeoutMs);
140
- // A short-lived CLI invocation shouldn't be kept alive purely by this timer.
141
- timeoutHandle.unref?.();
142
- const signal = options.signal !== undefined ? AbortSignal.any([options.signal, timeoutController.signal]) : timeoutController.signal;
143
- // The timeout signal stays live through both the initial fetch *and* the body read below — a
144
- // daemon that returns headers promptly but then drips its body slowly should still be bounded
145
- // by the same deadline, not just the connection phase.
146
- let resp;
147
- let data;
197
+ const deadline = startDeadline({ route, timeoutMs: options.timeoutMs ?? DEFAULT_TIMEOUT_MS }, { signal: options.signal });
148
198
  try {
199
+ let resp;
149
200
  try {
150
- resp = await fetchImpl(`${base}${route}`, {
151
- method: init.method,
152
- headers: init.method === 'POST' ? { 'content-type': 'application/json', ...options.headers } : { ...options.headers },
153
- ...(init.method === 'POST' ? { body: JSON.stringify(init.body) } : {}),
154
- signal,
155
- });
201
+ // The init is built inside this try on purpose: a body JSON.stringify cannot serialize is
202
+ // reported through the same daemon-not-running path as a rejected fetch, as it always was.
203
+ resp = await fetchImpl(`${base}${route}`, buildFetchInit(init, options.headers, deadline.signal));
156
204
  }
157
205
  catch (err) {
158
- const diagnostics = [];
159
- surfaceFetchError({ err, daemonUrl: base }, { write: ({ text }) => { diagnostics.push(text.trimEnd()); } });
160
- return exitWithStructuredError({ code: 'daemon-not-running', message: `Cannot reach daemon at ${sanitizeDaemonUrlForDisplay({ url: base })}` }, { data: { diagnostics }, ...structuredOptions });
206
+ return exitForFetchFailure({ err, base }, structured);
161
207
  }
162
208
  try {
163
- data = await readJsonWithLimit(resp, maxResponseBytes);
209
+ return { resp, data: await readJsonWithLimit(resp, maxResponseBytes) };
164
210
  }
165
211
  catch (err) {
166
- if (err instanceof ResponseTooLargeError) {
167
- timeoutController.abort(err);
168
- return exitWithStructuredError({ code: 'response-too-large', message: `response from ${sanitizeDaemonUrlForDisplay({ url: base })}${route} exceeded the ${err.limitBytes}-byte limit; aborting.` }, structuredOptions);
169
- }
170
- const detail = err instanceof Error ? err.message : String(err);
171
- return exitWithStructuredError({ code: 'request-failed', message: sanitizeUntrustedText({ text: detail }) }, structuredOptions);
212
+ return exitForReadFailure({ err, base, route, controller: deadline.controller }, structured);
172
213
  }
173
214
  }
174
215
  finally {
175
- clearTimeout(timeoutHandle);
216
+ deadline.clear();
176
217
  }
177
- if (!resp.ok) {
178
- const errorObj = data?.error;
179
- const exitCodes = { ...DEFAULT_CLI_EXIT_CODES, ...options.exitCodes };
180
- const code = typeof errorObj?.code === 'string' && errorObj.code in exitCodes ? errorObj.code : null;
181
- if (code !== null) {
182
- const rawMessage = typeof errorObj?.message === 'string' ? errorObj.message : `HTTP ${resp.status}`;
183
- const message = sanitizeUntrustedText({ text: rawMessage });
184
- const errorData = structuredErrorData({ error: errorObj });
185
- return exitWithStructuredError({ code, message }, { ...(errorData !== undefined ? { data: errorData } : {}), ...structuredOptions });
186
- }
187
- const rawExcerpt = describeUnrecognizedDaemonPayload(data);
188
- const fallbackCode = typeof errorObj?.code === 'string' ? sanitizeUntrustedText({ text: errorObj.code }) : 'http-error';
189
- return exitWithStructuredError({ code: fallbackCode, message: `${init.method} ${route} failed: ${resp.status}${rawExcerpt}` }, structuredOptions);
218
+ }
219
+ /** Maps a non-2xx daemon response to a structured exit: a recognized `error.code` keeps its own exit code and sanitized message/data; anything else exits with a bounded, redacted payload excerpt. */
220
+ function exitForDaemonError({ resp, data, method, route }, structured) {
221
+ const errorObj = data?.error;
222
+ const exitCodes = { ...DEFAULT_CLI_EXIT_CODES, ...structured.exitCodes };
223
+ const rawCode = errorObj?.code;
224
+ if (typeof rawCode === 'string' && rawCode in exitCodes) {
225
+ const rawMessage = typeof errorObj?.message === 'string' ? errorObj.message : `HTTP ${resp.status}`;
226
+ const errorData = structuredErrorData({ error: errorObj });
227
+ return exitWithStructuredError({ code: rawCode, message: sanitizeUntrustedText({ text: rawMessage }) }, { ...(errorData !== undefined ? { data: errorData } : {}), ...structured });
190
228
  }
229
+ const fallbackCode = typeof rawCode === 'string' ? sanitizeUntrustedText({ text: rawCode }) : 'http-error';
230
+ return exitWithStructuredError({ code: fallbackCode, message: `${method} ${route} failed: ${resp.status}${describeUnrecognizedDaemonPayload(data)}` }, structured);
231
+ }
232
+ /**
233
+ * Shared transport core behind {@link postJsonToDaemon} and {@link getJsonFromDaemon}: fetch
234
+ * `<base><route>` with a bounded timeout and response-size cap, and map a network failure or a
235
+ * non-2xx daemon response through the structured error contract (`errors.ts`) instead of
236
+ * returning — this function either resolves with the parsed body or never returns. Per
237
+ * CR-004/SEC-RB-009, daemon-supplied error detail is redacted before it reaches stderr rather than
238
+ * dumped verbatim. Both public wrappers exist only to fix `init.method`/`init.body` at their own
239
+ * call site — nothing method-specific lives here beyond that.
240
+ */
241
+ async function requestJsonFromDaemon({ base, route, init }, options) {
242
+ const structured = structuredExitOptions(options);
243
+ const { resp, data } = await fetchJsonWithinDeadline({ base, route, init }, { options, structured });
244
+ if (!resp.ok)
245
+ return exitForDaemonError({ resp, data, method: init.method, route }, structured);
191
246
  return data;
192
247
  }
193
248
  /**
@@ -202,7 +257,7 @@ async function requestJsonFromDaemon(base, route, init, options = {}) {
202
257
  * it verbatim.
203
258
  */
204
259
  export async function postJsonToDaemon({ base, route, body }, options = {}) {
205
- return requestJsonFromDaemon(base, route, { method: 'POST', body }, options);
260
+ return requestJsonFromDaemon({ base, route, init: { method: 'POST', body } }, options);
206
261
  }
207
262
  /**
208
263
  * `GET` `<base><route>` and return the parsed JSON response. Same transport contract as
@@ -211,7 +266,7 @@ export async function postJsonToDaemon({ base, route, body }, options = {}) {
211
266
  * `GET /api/runs/:runId`.
212
267
  */
213
268
  export async function getJsonFromDaemon({ base, route }, options = {}) {
214
- return requestJsonFromDaemon(base, route, { method: 'GET' }, options);
269
+ return requestJsonFromDaemon({ base, route, init: { method: 'GET' } }, options);
215
270
  }
216
271
  /** A bounded, redacted excerpt of an unrecognized daemon error payload — never the full raw body. */
217
272
  function describeUnrecognizedDaemonPayload(data) {
@@ -20,7 +20,7 @@ export function introspectProgram({ program, excludedCommands }) {
20
20
  return;
21
21
  }
22
22
  commands.push({
23
- name: fullPath.join(" "), description: command.description() ?? "",
23
+ name: fullPath.join(" "), description: command.description(),
24
24
  arguments: command.registeredArguments.map((arg) => ({ name: arg.name(), required: arg.required, description: arg.description ?? "" })),
25
25
  options: command.options.map((opt) => ({
26
26
  flags: opt.flags, attributeName: opt.attributeName(), description: opt.description ?? "",
@@ -31,7 +31,7 @@ export function introspectProgram({ program, excludedCommands }) {
31
31
  }
32
32
  for (const command of program.commands)
33
33
  visit(command, []);
34
- return { name: program.name(), description: program.description() ?? "", commands };
34
+ return { name: program.name(), description: program.description(), commands };
35
35
  }
36
36
  /**
37
37
  * Project a CLI manifest into string-valued MCP input schemas; boolean switches are omitted.
package/dist/main.js CHANGED
@@ -89,6 +89,9 @@ function defaultWrite({ text }) {
89
89
  function defaultWriteErr({ text }) {
90
90
  process.stderr.write(text);
91
91
  }
92
+ function defaultExit({ code }) {
93
+ return process.exit(code);
94
+ }
92
95
  /** Name of the environment variable {@link resolveDaemonUrl} checks when `--daemon-url` is absent. */
93
96
  const DAEMON_URL_ENV_VAR = 'JINI_DAEMON_URL';
94
97
  const ROOT_USAGE = renderUsage({ usage: ['jini <command> [...args]'] }, { description: 'CLI transport for a Jini daemon over HTTP. Commands: run, daemon, version.', options: [
@@ -114,34 +117,40 @@ const GLOBAL_FLAG_NAMES = ['daemon-url', 'data-dir', 'registry-path'];
114
117
  * failure.
115
118
  */
116
119
  function partitionGlobalArgv(argv) {
117
- const names = new Set(GLOBAL_FLAG_NAMES);
118
120
  const globals = {};
119
121
  const rest = [];
120
122
  for (let i = 0; i < argv.length; i++) {
121
123
  // Loop is bounded by argv.length, so this index is always in range —
122
124
  // noUncheckedIndexedAccess types it as possibly-undefined regardless.
123
125
  const token = argv[i];
124
- if (token.startsWith('--')) {
125
- const eq = token.indexOf('=');
126
- const key = eq >= 0 ? token.slice(2, eq) : token.slice(2);
127
- if (names.has(key)) {
128
- if (eq >= 0) {
129
- globals[key] = token.slice(eq + 1);
130
- }
131
- else {
132
- const value = argv[i + 1];
133
- if (value === undefined)
134
- throw new Error(`flag --${key} requires a value`);
135
- globals[key] = value;
136
- i++;
137
- }
138
- continue;
139
- }
126
+ const match = matchGlobalFlag(token);
127
+ if (match === null) {
128
+ rest.push(token);
129
+ continue;
130
+ }
131
+ if (match.inlineValue !== undefined) {
132
+ globals[match.key] = match.inlineValue;
133
+ continue;
140
134
  }
141
- rest.push(token);
135
+ const value = argv[i + 1];
136
+ if (value === undefined)
137
+ throw new Error(`flag --${match.key} requires a value`);
138
+ globals[match.key] = value;
139
+ i++;
142
140
  }
143
141
  return { globals, rest };
144
142
  }
143
+ const GLOBAL_FLAG_SET = new Set(GLOBAL_FLAG_NAMES);
144
+ /** Recognizes `--<global>` / `--<global>=<value>`; `null` for any token that is not one of this binary's own global flags. */
145
+ function matchGlobalFlag(token) {
146
+ if (!token.startsWith('--'))
147
+ return null;
148
+ const eq = token.indexOf('=');
149
+ const key = eq >= 0 ? token.slice(2, eq) : token.slice(2);
150
+ if (!GLOBAL_FLAG_SET.has(key))
151
+ return null;
152
+ return { key: key, inlineValue: eq >= 0 ? token.slice(eq + 1) : undefined };
153
+ }
145
154
  /** Builds a `resolveDaemonUrl`-backed `resolveBaseUrl` closure from this binary's own parsed global flags. */
146
155
  function buildResolveBaseUrl(globals, env, warn) {
147
156
  const { 'daemon-url': daemonUrl, 'data-dir': dataDir, 'registry-path': registryPath } = globals;
@@ -158,6 +167,35 @@ function buildResolveBaseUrl(globals, env, warn) {
158
167
  warn,
159
168
  });
160
169
  }
170
+ function isRootHelpRequest(argv) {
171
+ return argv.length === 0 || argv[0] === '--help' || argv[0] === '-h';
172
+ }
173
+ // `--version`/`-v` as the very first token is a plain alias for `jini version` — see this
174
+ // file's own module doc for why there is no separate "this package's own version" surface.
175
+ function expandVersionAlias(argv) {
176
+ return argv[0] === '--version' || argv[0] === '-v' ? ['version', ...argv.slice(1)] : argv;
177
+ }
178
+ /** Strips the global flags, registers `run`/`daemon`/`version` against a fresh registry and dispatches the remaining argv. */
179
+ async function dispatchArgv({ argv, env }, { ports, fetchImpl }) {
180
+ const { globals, rest } = partitionGlobalArgv(argv);
181
+ const resolveBaseUrl = buildResolveBaseUrl(globals, env, ({ message }) => ports.writeErr({ text: message }));
182
+ const registry = new CommandRegistry({});
183
+ const commandDeps = { ...ports, ...(fetchImpl === undefined ? {} : { fetchImpl }) };
184
+ registerRunCommands({ registry, resolveBaseUrl }, commandDeps);
185
+ registerDaemonCommands({ registry, resolveBaseUrl }, commandDeps);
186
+ registerVersionCommand({ registry, resolveBaseUrl }, commandDeps);
187
+ return registry.dispatch({ argv: rest });
188
+ }
189
+ /** A dispatch that matched no handler: no command at all prints root usage, an unknown one exits `invalid-flag`. */
190
+ function reportUnmatchedDispatch(result, ports) {
191
+ if (result.kind === 'empty') {
192
+ ports.write({ text: `${ROOT_USAGE}\n` });
193
+ return;
194
+ }
195
+ if (result.kind === 'not-found') {
196
+ exitWithStructuredError({ code: 'invalid-flag', message: `unknown command: "${result.name}". Run "jini --help" for usage.` }, { write: ports.writeErr, exit: ports.exit });
197
+ }
198
+ }
161
199
  /**
162
200
  * Parse `argv` (already stripped of `node`/script-path, i.e. `process.argv.slice(2)`) and
163
201
  * dispatch to the registered `run`/`daemon`/`version` commands. Never throws a raw error to its
@@ -167,15 +205,11 @@ function buildResolveBaseUrl(globals, env, warn) {
167
205
  export async function main({ argv }, deps = {}) {
168
206
  const write = deps.write ?? defaultWrite;
169
207
  const writeErr = deps.writeErr ?? defaultWriteErr;
170
- const rawExit = deps.exit ?? (({ code }) => process.exit(code));
171
- const env = deps.env ?? process.env;
172
- if (argv.length === 0 || argv[0] === '--help' || argv[0] === '-h') {
208
+ const rawExit = deps.exit ?? defaultExit;
209
+ if (isRootHelpRequest(argv)) {
173
210
  write({ text: `${ROOT_USAGE}\n` });
174
211
  return;
175
212
  }
176
- // `--version`/`-v` as the very first token is a plain alias for `jini version` — see this
177
- // file's own module doc for why there is no separate "this package's own version" surface.
178
- const effectiveArgv = argv[0] === '--version' || argv[0] === '-v' ? ['version', ...argv.slice(1)] : argv;
179
213
  // Tracks whether `exit` was ever invoked, so the catch block below can tell "a nested command
180
214
  // already exited cleanly" (re-throw untouched) apart from "a genuinely raw, unhandled throw"
181
215
  // (reformat into a structured error) — see this file's own module doc, "Error boundary."
@@ -184,24 +218,10 @@ export async function main({ argv }, deps = {}) {
184
218
  exitCalled = true;
185
219
  return rawExit({ code });
186
220
  };
221
+ const ports = { write, writeErr, exit };
187
222
  let result;
188
223
  try {
189
- const { globals, rest } = partitionGlobalArgv(effectiveArgv);
190
- const resolveBaseUrl = buildResolveBaseUrl(globals, env, ({ message }) => writeErr({ text: message }));
191
- const registry = new CommandRegistry({});
192
- registerRunCommands({ registry, resolveBaseUrl }, {
193
- write, writeErr, exit,
194
- ...(deps.fetchImpl === undefined ? {} : { fetchImpl: deps.fetchImpl }),
195
- });
196
- registerDaemonCommands({ registry, resolveBaseUrl }, {
197
- write, writeErr, exit,
198
- ...(deps.fetchImpl === undefined ? {} : { fetchImpl: deps.fetchImpl }),
199
- });
200
- registerVersionCommand({ registry, resolveBaseUrl }, {
201
- write, writeErr, exit,
202
- ...(deps.fetchImpl === undefined ? {} : { fetchImpl: deps.fetchImpl }),
203
- });
204
- result = await registry.dispatch({ argv: rest });
224
+ result = await dispatchArgv({ argv: expandVersionAlias(argv), env: deps.env ?? process.env }, { ports, fetchImpl: deps.fetchImpl });
205
225
  }
206
226
  catch (error) {
207
227
  if (exitCalled)
@@ -210,13 +230,7 @@ export async function main({ argv }, deps = {}) {
210
230
  exitWithStructuredError({ code: 'invalid-flag', message }, { write: writeErr, exit });
211
231
  return;
212
232
  }
213
- if (result.kind === 'empty') {
214
- write({ text: `${ROOT_USAGE}\n` });
215
- return;
216
- }
217
- if (result.kind === 'not-found') {
218
- exitWithStructuredError({ code: 'invalid-flag', message: `unknown command: "${result.name}". Run "jini --help" for usage.` }, { write: writeErr, exit });
219
- }
233
+ reportUnmatchedDispatch(result, ports);
220
234
  }
221
235
  // Only run for real when this module is the actual process entrypoint (`node dist/main.js
222
236
  // ...`, or the `jini` bin shim pnpm/npm links to it) — not merely imported, e.g. by this
package/dist/prompt.js CHANGED
@@ -54,13 +54,21 @@ export async function readPromptFromFlags({ flags }, options = {}) {
54
54
  if (typeof promptFile !== 'string' || promptFile.length === 0) {
55
55
  return null;
56
56
  }
57
+ return await readFlagSource({ source: promptFile, readDefaultStdin: defaultReadStdin }, options);
58
+ }
59
+ /**
60
+ * Reads a `--*-file` flag's value: `-` means stdin (the injected `readStdin`, else
61
+ * `readDefaultStdin`), anything else is a path (the injected `readFile`, else the bounded
62
+ * default file reader). The byte cap and signal only reach the default readers.
63
+ */
64
+ async function readFlagSource({ source, readDefaultStdin }, options) {
57
65
  const readLimits = { maxBytes: options.maxBytes ?? DEFAULT_MAX_READ_BYTES, ...(options.signal !== undefined ? { signal: options.signal } : {}) };
58
- if (promptFile === '-') {
59
- const readStdin = options.readStdin ?? (() => defaultReadStdin(readLimits));
66
+ if (source === '-') {
67
+ const readStdin = options.readStdin ?? (() => readDefaultStdin(readLimits));
60
68
  return await readStdin();
61
69
  }
62
70
  const readFile = options.readFile ?? (({ path }) => defaultReadFile(path, readLimits));
63
- return await readFile({ path: promptFile });
71
+ return await readFile({ path: source });
64
72
  }
65
73
  async function defaultReadFile(path, limits) {
66
74
  const { createReadStream } = await import('node:fs');
@@ -163,13 +171,7 @@ export async function readBodyFromFlags({ flags }, options = {}) {
163
171
  const bodyFile = flags['body-file'];
164
172
  if (typeof bodyFile !== 'string')
165
173
  return undefined;
166
- const readLimits = { maxBytes: options.maxBytes ?? DEFAULT_MAX_READ_BYTES, ...(options.signal !== undefined ? { signal: options.signal } : {}) };
167
- if (bodyFile === '-') {
168
- const readStdin = options.readStdin ?? (() => defaultReadBodyStdin(readLimits));
169
- return await readStdin();
170
- }
171
- const readFile = options.readFile ?? (({ path }) => defaultReadFile(path, readLimits));
172
- return await readFile({ path: bodyFile });
174
+ return await readFlagSource({ source: bodyFile, readDefaultStdin: defaultReadBodyStdin }, options);
173
175
  }
174
176
  async function defaultReadBodyStdin(limits) {
175
177
  limits.signal?.throwIfAborted();
@@ -67,16 +67,18 @@ function invalidFlag(deps, message) {
67
67
  function printJsonResult(deps, value) {
68
68
  (deps.write ?? defaultWrite)({ text: `${JSON.stringify(value)}\n` });
69
69
  }
70
- async function resolveBaseUrl(deps) {
71
- return deps.resolveBaseUrl();
70
+ /** Prints `usage` and reports `true` when `args` asks for help (`--help`/`-h` anywhere), so the command returns before touching the daemon. */
71
+ function printUsageIfHelp({ args, usage }, deps) {
72
+ if (!args.includes('--help') && !args.includes('-h'))
73
+ return false;
74
+ (deps.write ?? defaultWrite)({ text: `${usage}\n` });
75
+ return true;
72
76
  }
73
77
  /** `run start --context-ref <ref> [--agent-id <id>] [--idempotency-key <key>]` */
74
78
  export async function runStartCommand({ args, resolveBaseUrl: resolveBaseUrlPort }, optionalArgs = {}) {
75
79
  const deps = { resolveBaseUrl: resolveBaseUrlPort, ...optionalArgs };
76
- if (args.includes('--help') || args.includes('-h')) {
77
- (deps.write ?? defaultWrite)({ text: `${RUN_START_USAGE}\n` });
80
+ if (printUsageIfHelp({ args, usage: RUN_START_USAGE }, deps))
78
81
  return;
79
- }
80
82
  const flags = parseFlags({ argv: args }, { string: new Set(['context-ref', 'agent-id', 'idempotency-key']) });
81
83
  const contextRef = flags['context-ref'];
82
84
  if (typeof contextRef !== 'string' || contextRef.length === 0) {
@@ -87,48 +89,42 @@ export async function runStartCommand({ args, resolveBaseUrl: resolveBaseUrlPort
87
89
  body.agentId = flags['agent-id'];
88
90
  if (typeof flags['idempotency-key'] === 'string')
89
91
  body.idempotencyKey = flags['idempotency-key'];
90
- const baseUrl = await resolveBaseUrl(deps);
92
+ const baseUrl = await deps.resolveBaseUrl();
91
93
  const result = await postJsonToDaemon({ base: baseUrl, route: '/api/runs', body }, transportOptions(deps));
92
94
  printJsonResult(deps, result);
93
95
  }
94
96
  /** `run list [--context-ref <ref>]` */
95
97
  export async function runListCommand({ args, resolveBaseUrl: resolveBaseUrlPort }, optionalArgs = {}) {
96
98
  const deps = { resolveBaseUrl: resolveBaseUrlPort, ...optionalArgs };
97
- if (args.includes('--help') || args.includes('-h')) {
98
- (deps.write ?? defaultWrite)({ text: `${RUN_LIST_USAGE}\n` });
99
+ if (printUsageIfHelp({ args, usage: RUN_LIST_USAGE }, deps))
99
100
  return;
100
- }
101
101
  const flags = parseFlags({ argv: args }, { string: new Set(['context-ref']) });
102
102
  const contextRef = flags['context-ref'];
103
103
  const route = typeof contextRef === 'string' && contextRef.length > 0
104
104
  ? `/api/runs?contextRef=${encodeURIComponent(contextRef)}`
105
105
  : '/api/runs';
106
- const baseUrl = await resolveBaseUrl(deps);
106
+ const baseUrl = await deps.resolveBaseUrl();
107
107
  const result = await getJsonFromDaemon({ base: baseUrl, route }, transportOptions(deps));
108
108
  printJsonResult(deps, result);
109
109
  }
110
110
  /** `run get <runId>` */
111
111
  export async function runGetCommand({ args, resolveBaseUrl: resolveBaseUrlPort }, optionalArgs = {}) {
112
112
  const deps = { resolveBaseUrl: resolveBaseUrlPort, ...optionalArgs };
113
- if (args.includes('--help') || args.includes('-h')) {
114
- (deps.write ?? defaultWrite)({ text: `${RUN_GET_USAGE}\n` });
113
+ if (printUsageIfHelp({ args, usage: RUN_GET_USAGE }, deps))
115
114
  return;
116
- }
117
115
  const runId = positionalArgs({ argv: args })[0];
118
116
  if (typeof runId !== 'string' || runId.length === 0) {
119
117
  missingInput(deps, 'runId is required: run get <runId>');
120
118
  }
121
- const baseUrl = await resolveBaseUrl(deps);
119
+ const baseUrl = await deps.resolveBaseUrl();
122
120
  const result = await getJsonFromDaemon({ base: baseUrl, route: `/api/runs/${encodeURIComponent(runId)}` }, transportOptions(deps));
123
121
  printJsonResult(deps, result);
124
122
  }
125
123
  /** `run cancel <runId> [--reason <text>]` */
126
124
  export async function runCancelCommand({ args, resolveBaseUrl: resolveBaseUrlPort }, optionalArgs = {}) {
127
125
  const deps = { resolveBaseUrl: resolveBaseUrlPort, ...optionalArgs };
128
- if (args.includes('--help') || args.includes('-h')) {
129
- (deps.write ?? defaultWrite)({ text: `${RUN_CANCEL_USAGE}\n` });
126
+ if (printUsageIfHelp({ args, usage: RUN_CANCEL_USAGE }, deps))
130
127
  return;
131
- }
132
128
  const flags = parseFlags({ argv: args }, { string: new Set(['reason']) });
133
129
  const runId = positionalArgs({ argv: args }, { string: new Set(['reason']) })[0];
134
130
  if (typeof runId !== 'string' || runId.length === 0) {
@@ -137,7 +133,7 @@ export async function runCancelCommand({ args, resolveBaseUrl: resolveBaseUrlPor
137
133
  const body = {};
138
134
  if (typeof flags.reason === 'string')
139
135
  body.reason = flags.reason;
140
- const baseUrl = await resolveBaseUrl(deps);
136
+ const baseUrl = await deps.resolveBaseUrl();
141
137
  const result = await postJsonToDaemon({ base: baseUrl, route: `/api/runs/${encodeURIComponent(runId)}/cancel`, body }, transportOptions(deps));
142
138
  printJsonResult(deps, result);
143
139
  }
@@ -189,23 +185,15 @@ function isTerminalEventFrame(frame) {
189
185
  * error detail.
190
186
  */
191
187
  export async function watchRunEvents({ baseUrl, runId, args }, deps = {}) {
192
- const flags = parseFlags({ argv: args }, { string: new Set(['after-cursor']) });
193
- const afterCursor = flags['after-cursor'];
188
+ const headers = resumeHeaders(args);
194
189
  const fetchImpl = deps.fetchImpl ?? fetch;
195
190
  const write = deps.write ?? defaultWrite;
196
- const headers = {};
197
- if (typeof afterCursor === 'string' && afterCursor.length > 0)
198
- headers['last-event-id'] = afterCursor;
199
191
  const resp = await fetchImpl(`${baseUrl}/api/runs/${encodeURIComponent(runId)}/events`, { headers });
200
192
  if (!resp.ok || resp.body === null) {
201
193
  return structuredHttpFailure({ resp }, { fallbackCode: 'daemon-not-running', ...errorOptions(deps) });
202
194
  }
203
195
  try {
204
- for await (const frame of readSseFrames(resp.body)) {
205
- write({ text: `${stripControlSequences({ text: frame.data })}\n` });
206
- if (isTerminalEventFrame(frame))
207
- return;
208
- }
196
+ await writeEventsUntilEnd({ body: resp.body, write });
209
197
  }
210
198
  catch (error) {
211
199
  // A broken/hostile stream (e.g. the unbounded-buffer guard above) must exit through the same
@@ -214,18 +202,29 @@ export async function watchRunEvents({ baseUrl, runId, args }, deps = {}) {
214
202
  return exitWithStructuredError({ code: 'daemon-not-running', message: sanitizeUntrustedText({ text: message }) }, errorOptions(deps));
215
203
  }
216
204
  }
205
+ /** `last-event-id` from a non-empty `--after-cursor`, so a reconnect resumes after that cursor; no header otherwise. */
206
+ function resumeHeaders(args) {
207
+ const afterCursor = parseFlags({ argv: args }, { string: new Set(['after-cursor']) })['after-cursor'];
208
+ return typeof afterCursor === 'string' && afterCursor.length > 0 ? { 'last-event-id': afterCursor } : {};
209
+ }
210
+ /** Writes one control-sequence-stripped NDJSON line per SSE frame, stopping after the terminal `'end'` event or when the stream closes. */
211
+ async function writeEventsUntilEnd({ body, write }) {
212
+ for await (const frame of readSseFrames(body)) {
213
+ write({ text: `${stripControlSequences({ text: frame.data })}\n` });
214
+ if (isTerminalEventFrame(frame))
215
+ return;
216
+ }
217
+ }
217
218
  /** `run watch <runId> [--after-cursor <cursor>]` */
218
219
  export async function runWatchCommand({ args, resolveBaseUrl: resolveBaseUrlPort }, optionalArgs = {}) {
219
220
  const deps = { resolveBaseUrl: resolveBaseUrlPort, ...optionalArgs };
220
- if (args.includes('--help') || args.includes('-h')) {
221
- (deps.write ?? defaultWrite)({ text: `${RUN_WATCH_USAGE}\n` });
221
+ if (printUsageIfHelp({ args, usage: RUN_WATCH_USAGE }, deps))
222
222
  return;
223
- }
224
223
  const runId = positionalArgs({ argv: args }, { string: new Set(['after-cursor']) })[0];
225
224
  if (typeof runId !== 'string' || runId.length === 0) {
226
225
  missingInput(deps, 'runId is required: run watch <runId>');
227
226
  }
228
- const baseUrl = await resolveBaseUrl(deps);
227
+ const baseUrl = await deps.resolveBaseUrl();
229
228
  await watchRunEvents({ baseUrl, runId, args }, deps);
230
229
  }
231
230
  /** Registers `run` (dispatching `start`/`list`/`cancel`/`watch` on the first remaining token) against `registry`. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jini-ai/cli",
3
- "version": "0.4.1",
3
+ "version": "0.6.10",
4
4
  "description": "CLI transport shell for driving a Jini daemon over HTTP: flag parsing, daemon-URL resolution, local daemon discovery, and a pluggable command registry.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -54,16 +54,16 @@
54
54
  },
55
55
  "devDependencies": {
56
56
  "commander": "^15.0.0",
57
- "@jini-ai/core": "0.4.2",
58
- "@jini-ai/sidecar": "0.4.1",
59
- "@jini-ai/platform": "0.5.2"
57
+ "@jini-ai/core": "0.6.10",
58
+ "@jini-ai/sidecar": "0.6.10",
59
+ "@jini-ai/platform": "0.6.10"
60
60
  },
61
61
  "dependencies": {
62
- "@jini-ai/core": "^0.4.2"
62
+ "@jini-ai/core": "^0.6.10"
63
63
  },
64
64
  "peerDependencies": {
65
- "@jini-ai/platform": "^0.5.1",
66
- "@jini-ai/sidecar": "^0.4.0"
65
+ "@jini-ai/platform": "^0.6.10",
66
+ "@jini-ai/sidecar": "^0.6.10"
67
67
  },
68
68
  "peerDependenciesMeta": {
69
69
  "@jini-ai/platform": {