@bli-cockpit/cli 0.2.35 → 0.2.37

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.
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Reads a local file for `cockpit jarvis --image <path>` (BLI-3414), before
3
+ * anything is uploaded.
4
+ *
5
+ * The dashboard's `/api/jarvis/cli` route is the ONE authority on whether an
6
+ * attached image is acceptable — it runs the exact same
7
+ * `checkAttachedImage`/`attachedImageRefusalSentence` gate the JARVIS panel
8
+ * uses (`apps/dashboard/src/lib/jarvis/chat-v2/attached-image.ts`), and this
9
+ * package cannot import from `apps/dashboard` (separate workspace, no
10
+ * dependency edge). So this module only catches what is cheap and purely
11
+ * local to check BEFORE spending a network round trip: does the path exist
12
+ * and is it readable, and does its extension even look like an image. Byte
13
+ * size and decoded-dimension refusals still come from the server and are
14
+ * relayed to the terminal verbatim (`readReply`/`writeFailure` in
15
+ * `jarvis.ts`) — this module's `ATTACHED_IMAGE_MAX_BYTES` constant is a local
16
+ * courtesy check to avoid uploading something the server is certain to
17
+ * refuse, not a second source of truth; if the two ever drift, the server's
18
+ * check still wins.
19
+ */
20
+ import { readFile, stat } from "node:fs/promises";
21
+ import path from "node:path";
22
+ import { errorMessage } from "./cli-io.js";
23
+ /** Mirrors `ATTACHED_IMAGE_MAX_BYTES` in `apps/dashboard/src/lib/jarvis/chat-v2/attached-image.ts`. */
24
+ export const ATTACHED_IMAGE_MAX_BYTES = 10 * 1024 * 1024;
25
+ const EXTENSION_MIME_TYPES = {
26
+ ".png": "image/png",
27
+ ".jpg": "image/jpeg",
28
+ ".jpeg": "image/jpeg",
29
+ ".webp": "image/webp",
30
+ };
31
+ /** The sentence the terminal prints for a refusal caught locally, before any request goes out. */
32
+ export function attachedFileRefusalSentence(refusal, filePath) {
33
+ switch (refusal) {
34
+ case "file_not_found":
35
+ return `I could not find that file: ${filePath}`;
36
+ case "file_unreadable":
37
+ return `I could not read that file: ${filePath}`;
38
+ case "unsupported_file_type":
39
+ // Same wording as the panel's own refusal for this case (BLI-3170).
40
+ return "That is not an image I can read — attach a PNG, JPEG, or WEBP.";
41
+ case "file_too_big":
42
+ // Same wording as the panel's own refusal for this case (BLI-3170).
43
+ return "That image is too big to attach — keep it under 10 MB.";
44
+ }
45
+ }
46
+ /**
47
+ * Reads and locally screens one attached file. Never throws — every failure
48
+ * mode comes back as a named refusal so the caller can print a plain
49
+ * sentence instead of a stack trace.
50
+ */
51
+ export async function readAttachedImage(filePath) {
52
+ const extension = path.extname(filePath).toLowerCase();
53
+ const mimeType = EXTENSION_MIME_TYPES[extension];
54
+ if (!mimeType) {
55
+ return { ok: false, refusal: "unsupported_file_type", detail: extension || "no_extension" };
56
+ }
57
+ let size;
58
+ try {
59
+ const info = await stat(filePath);
60
+ if (!info.isFile()) {
61
+ return { ok: false, refusal: "file_unreadable", detail: "not_a_regular_file" };
62
+ }
63
+ size = info.size;
64
+ }
65
+ catch (error) {
66
+ const code = error.code;
67
+ if (code === "ENOENT")
68
+ return { ok: false, refusal: "file_not_found", detail: "enoent" };
69
+ return { ok: false, refusal: "file_unreadable", detail: errorMessage(error) };
70
+ }
71
+ if (size > ATTACHED_IMAGE_MAX_BYTES) {
72
+ return { ok: false, refusal: "file_too_big", detail: `byte_size_${size}` };
73
+ }
74
+ let bytes;
75
+ try {
76
+ bytes = await readFile(filePath);
77
+ }
78
+ catch (error) {
79
+ return { ok: false, refusal: "file_unreadable", detail: errorMessage(error) };
80
+ }
81
+ return { ok: true, bytes, mimeType, fileName: path.basename(filePath) };
82
+ }
@@ -6,6 +6,7 @@
6
6
  * through the same JARVIS runtime used by web chat and Slack.
7
7
  */
8
8
  import { errorMessage, isInteractiveStdin, readLine, writeLine } from "./cli-io.js";
9
+ import { attachedFileRefusalSentence, readAttachedImage, } from "./jarvis-attachment.js";
9
10
  import { getCollectorRuntimePaths, readLocalCollectorSessionFile, } from "../local-state.js";
10
11
  const TRACE_DIM = "\x1b[2m";
11
12
  const TRACE_RESET = "\x1b[0m";
@@ -54,21 +55,40 @@ async function resolveOneShotPrompt(command, io) {
54
55
  }
55
56
  async function sendOneTurn(context, prompt, io) {
56
57
  const startedAt = Date.now();
58
+ // BLI-3414: an attached file is read and locally screened (exists,
59
+ // readable, a supported extension, under the byte ceiling) BEFORE any
60
+ // network call — a refusal here never reaches the dashboard and is
61
+ // terminal, same discipline as the panel's own attached-image gate.
62
+ let attachment = null;
63
+ if (context.command.imagePath) {
64
+ const read = await readAttachedImage(context.command.imagePath);
65
+ if (!read.ok) {
66
+ writeAttachmentRefusal(context.command, io, read.refusal, context.command.imagePath);
67
+ return 1;
68
+ }
69
+ attachment = read;
70
+ }
57
71
  let response;
58
72
  try {
59
- response = await io.fetch(`${context.dashboardUrl}/api/jarvis/cli`, {
60
- method: "POST",
61
- headers: {
62
- authorization: `Bearer ${context.deviceToken}`,
63
- "content-type": "application/json",
64
- },
65
- body: JSON.stringify({
66
- question: prompt,
67
- thread: context.command.thread,
68
- subject: context.command.subject,
69
- model: context.command.model,
70
- }),
71
- });
73
+ response = attachment
74
+ ? await io.fetch(`${context.dashboardUrl}/api/jarvis/cli`, {
75
+ method: "POST",
76
+ headers: { authorization: `Bearer ${context.deviceToken}` },
77
+ body: buildAttachmentForm(context.command, prompt, attachment),
78
+ })
79
+ : await io.fetch(`${context.dashboardUrl}/api/jarvis/cli`, {
80
+ method: "POST",
81
+ headers: {
82
+ authorization: `Bearer ${context.deviceToken}`,
83
+ "content-type": "application/json",
84
+ },
85
+ body: JSON.stringify({
86
+ question: prompt,
87
+ thread: context.command.thread,
88
+ subject: context.command.subject,
89
+ model: context.command.model,
90
+ }),
91
+ });
72
92
  }
73
93
  catch (error) {
74
94
  writeFailure(context.command, io, "gateway_unreachable", errorMessage(error));
@@ -105,9 +125,38 @@ async function sendOneTurn(context, prompt, io) {
105
125
  elapsed_ms: Date.now() - startedAt,
106
126
  thread: context.command.thread === "main" ? "default" : "named",
107
127
  subject: context.command.subject ? "selected" : "caller",
128
+ image_attached: attachment !== null,
129
+ image_byte_size: attachment?.bytes.byteLength ?? null,
108
130
  })}`);
109
131
  return 0;
110
132
  }
133
+ /**
134
+ * The multipart body `/api/jarvis/cli` reads when a file is attached
135
+ * (BLI-3414) — same field names the request handler parses, mirroring the
136
+ * JSON body's fields plus one `image` file field.
137
+ */
138
+ function buildAttachmentForm(command, prompt, attachment) {
139
+ const form = new FormData();
140
+ form.set("question", prompt);
141
+ form.set("thread", command.thread);
142
+ if (command.subject)
143
+ form.set("subject", command.subject);
144
+ if (command.model)
145
+ form.set("model", command.model);
146
+ form.set("image", new File([attachment.bytes], attachment.fileName, { type: attachment.mimeType }));
147
+ return form;
148
+ }
149
+ /** A refusal caught locally, before any request went out — never a stack trace. */
150
+ function writeAttachmentRefusal(command, io, refusal, filePath) {
151
+ const message = attachedFileRefusalSentence(refusal, filePath);
152
+ if (command.json) {
153
+ writeLine(io.stdout, JSON.stringify({ ok: false, error: refusal, detail: message }));
154
+ }
155
+ else {
156
+ writeLine(io.stderr, `JARVIS could not attach that file: ${message}`);
157
+ }
158
+ writeLine(io.stderr, `[jarvis cli] attachment refused ${JSON.stringify({ reason: refusal })}`);
159
+ }
111
160
  /**
112
161
  * One dim line per tool the turn called (BLI-3381): what it was, how long it
113
162
  * took, and — for a failure — the reason, matching what the web thinking
@@ -611,8 +611,18 @@ function parseAgentRulesHost(value) {
611
611
  }
612
612
  function parseJarvisArgs(args) {
613
613
  const values = parseNamedArgs(args, {
614
- allowedFlags: ["--home", "--dashboard-url", "--prompt", "--as", "--thread", "--model", "--json"],
615
- valueFlags: ["--home", "--dashboard-url", "--prompt", "--as", "--thread", "--model"],
614
+ allowedFlags: [
615
+ "--home",
616
+ "--dashboard-url",
617
+ "--prompt",
618
+ "--as",
619
+ "--thread",
620
+ "--model",
621
+ "--image",
622
+ "--file",
623
+ "--json",
624
+ ],
625
+ valueFlags: ["--home", "--dashboard-url", "--prompt", "--as", "--thread", "--model", "--image", "--file"],
616
626
  });
617
627
  const flaggedPrompt = optionalNonEmpty(values.flags.get("--prompt"));
618
628
  const positionalPrompt = optionalNonEmpty(values.positionals.join(" "));
@@ -623,6 +633,13 @@ function parseJarvisArgs(args) {
623
633
  if (!/^[A-Za-z0-9_-]{1,40}$/.test(thread)) {
624
634
  throw new Error("jarvis --thread must use 1 to 40 letters, numbers, underscores, or hyphens.");
625
635
  }
636
+ // BLI-3414: `--file` is a plain alias for `--image` — same flag, whichever
637
+ // word a person reaches for first.
638
+ const image = optionalNonEmpty(values.flags.get("--image"));
639
+ const file = optionalNonEmpty(values.flags.get("--file"));
640
+ if (image && file) {
641
+ throw new Error("jarvis accepts either --image or --file, not both — they are the same flag.");
642
+ }
626
643
  return {
627
644
  kind: "jarvis",
628
645
  homeDir: optionalNonEmpty(values.flags.get("--home")),
@@ -633,6 +650,7 @@ function parseJarvisArgs(args) {
633
650
  // BLI-3381: no client-side allowlist — the dashboard forwards this key
634
651
  // to the inference server's own allowlist and relays its refusal.
635
652
  model: optionalNonEmpty(values.flags.get("--model")),
653
+ imagePath: image ?? file,
636
654
  json: values.booleans.has("--json"),
637
655
  };
638
656
  }
@@ -46,7 +46,7 @@ export function localCommandHelp(command) {
46
46
  " cockpit start [--ticket <id>|--clear-ticket] [--topic <label>] [--intent <intent>] [--phase <phase>] [--workspace <path>] [--branch <name>] [--max-depth <n>] [--max-repos <n>] [--json]",
47
47
  " cockpit sync [--workspace <path>] [--dashboard-url <url>] [--max-depth <n>] [--max-repos <n>] [--json]",
48
48
  " cockpit analyze [--workspace <path>] [--dashboard-url <url>] [--max-depth <n>] [--max-repos <n>] [--json]",
49
- " cockpit jarvis [question] [--prompt <question>] [--as <person>] [--thread <name>] [--model <key>] [--dashboard-url <url>] [--json]",
49
+ " cockpit jarvis [question] [--prompt <question>] [--as <person>] [--thread <name>] [--model <key>] [--image <path>|--file <path>] [--dashboard-url <url>] [--json]",
50
50
  " cockpit backfill (--since-days <n>|--all) [--source codex|claude] [--dry-run] [--max-files <n>] [--max-depth <n>] [--max-repos <n>] [--yes] [--workspace <path>] [--json]",
51
51
  " cockpit status [--workspace <path>] [--max-depth <n>] [--max-repos <n>] [--json]",
52
52
  " cockpit sessions [--source codex|claude] [--since-days <n>|--all] [--workspace <path>] [--max-depth <n>] [--max-repos <n>] [--json]",
@@ -190,11 +190,12 @@ function localSubcommandHelp(command) {
190
190
  [
191
191
  "jarvis",
192
192
  [
193
- "Usage: cockpit jarvis [question] [--prompt <question>] [--as <person>] [--thread <name>] [--model <key>] [--dashboard-url <url>] [--json]",
193
+ "Usage: cockpit jarvis [question] [--prompt <question>] [--as <person>] [--thread <name>] [--model <key>] [--image <path>|--file <path>] [--dashboard-url <url>] [--json]",
194
194
  "",
195
195
  "Chats with the same JARVIS used by Tower web chat and the BLI Slack DM.",
196
196
  "--as selects the existing website person space; it changes who the chat is about, never who is authenticated.",
197
197
  "--model <key> requests one provider:model pair (e.g. openai:gpt-5.6-terra); an unrecognised key is refused by the dashboard, not this command.",
198
+ "--image <path> (alias --file) attaches one local PNG, JPEG, or WEBP under 10 MB with the question, the same one-image gate the JARVIS panel uses. A bad path, an unsupported type, or an oversized file is refused with its own plain sentence — never a stack trace.",
198
199
  "Run with no question for an interactive terminal conversation.",
199
200
  "Agents can pass --prompt, positional text, or pipe one question on stdin.",
200
201
  "Each answer prints a per-tool trace line and, only on a fallback or model mismatch, one model receipt line.",
@@ -15,7 +15,7 @@ export async function runCockpitCli(argv, io) {
15
15
  }
16
16
 
17
17
  if (command === "--version" || command === "-V" || command === "version") {
18
- writeLine(io?.stdout ?? process.stdout, "0.2.35");
18
+ writeLine(io?.stdout ?? process.stdout, "0.2.37");
19
19
  return 0;
20
20
  }
21
21
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bli-cockpit/cli",
3
- "version": "0.2.35",
3
+ "version": "0.2.37",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "bin": {