@bli-cockpit/cli 0.2.31 → 0.2.33

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.
@@ -1,4 +1,4 @@
1
- import { containsSecretLikeContent, redactSecretLikeContent, } from "@bli-cockpit/telemetry-core";
1
+ import { containsSecretLikeContent, redactSecretLikeContent, scannedCleanRedactionMetadata, } from "@bli-cockpit/telemetry-core";
2
2
  import { sha256 } from "./raw-evidence-keys.js";
3
3
  import { describeError } from "../health-detail.js";
4
4
  /** BLI-3238: a crashed redactor has to say so; see the catch below. */
@@ -165,32 +165,6 @@ function lineEndingOf(segment) {
165
165
  return "\n";
166
166
  return "";
167
167
  }
168
- /**
169
- * The receipt for a file the scan cleared (BLI-3277).
170
- *
171
- * Same schema, same `mode` — the deterministic ruleset is what ran — with the
172
- * verdict `scanned_clean` and zero of everything else. The content fields are
173
- * the point: an evidence ref is only readable downstream when its redaction
174
- * record hashes the bytes that are actually in the bucket, and for a clean file
175
- * those are the original bytes, so both halves carry the same digest and size.
176
- */
177
- function scannedCleanRedactionMetadata(originalBytes) {
178
- const digest = sha256(originalBytes);
179
- return {
180
- schema_version: "raw-evidence-redaction.v1",
181
- status: "scanned_clean",
182
- mode: "deterministic_text_replacement",
183
- applied_by: ["local_collector"],
184
- rule_counts: [],
185
- secret_like_match_count: 0,
186
- redacted_fields: [],
187
- redacted_ranges: [],
188
- original_content_hash_sha256: digest,
189
- sanitized_content_hash_sha256: digest,
190
- original_byte_size: originalBytes.byteLength,
191
- sanitized_byte_size: originalBytes.byteLength,
192
- };
193
- }
194
168
  function fallbackRedactionMetadata(options) {
195
169
  const fullContentRedacted = options.fullContentRedacted !== false;
196
170
  return {
@@ -0,0 +1,143 @@
1
+ /**
2
+ * Terminal adapter for the shared JARVIS conversation gateway.
3
+ *
4
+ * This file owns terminal input and output only. Identity comes from the
5
+ * existing paired device session, and every turn is executed by the dashboard
6
+ * through the same JARVIS runtime used by web chat and Slack.
7
+ */
8
+ import { errorMessage, isInteractiveStdin, readLine, writeLine } from "./cli-io.js";
9
+ import { getCollectorRuntimePaths, readLocalCollectorSessionFile, } from "../local-state.js";
10
+ export async function runJarvis(command, io) {
11
+ const session = await loadPairedSession(command.homeDir);
12
+ const dashboardUrl = command.dashboardUrl ?? session.dashboard_url;
13
+ const oneShotPrompt = await resolveOneShotPrompt(command, io);
14
+ if (oneShotPrompt !== null) {
15
+ return sendOneTurn({ command, dashboardUrl, deviceToken: session.device_token }, oneShotPrompt, io);
16
+ }
17
+ if (command.json) {
18
+ throw new Error("cockpit jarvis --json needs --prompt, positional text, or piped stdin.");
19
+ }
20
+ writeLine(io.stdout, "JARVIS terminal chat. Type /exit to leave.");
21
+ while (true) {
22
+ const prompt = (await readLine(io, "you> ")).trim();
23
+ if (!prompt)
24
+ continue;
25
+ if (prompt === "/exit" || prompt === "/quit")
26
+ return 0;
27
+ const exitCode = await sendOneTurn({ command, dashboardUrl, deviceToken: session.device_token }, prompt, io);
28
+ if (exitCode !== 0)
29
+ return exitCode;
30
+ }
31
+ }
32
+ async function loadPairedSession(homeDir) {
33
+ const paths = getCollectorRuntimePaths(homeDir);
34
+ try {
35
+ const session = await readLocalCollectorSessionFile(paths);
36
+ if (session.session_state !== "valid") {
37
+ throw new Error(`session_${session.session_state}`);
38
+ }
39
+ return session;
40
+ }
41
+ catch (error) {
42
+ throw new Error(`JARVIS needs a valid paired Cockpit session. Run \`cockpit login\`, then try again (${errorMessage(error)}).`);
43
+ }
44
+ }
45
+ async function resolveOneShotPrompt(command, io) {
46
+ if (command.prompt)
47
+ return validatePrompt(command.prompt);
48
+ if (isInteractiveStdin(io))
49
+ return null;
50
+ const piped = await readAll(io.stdin);
51
+ return validatePrompt(piped);
52
+ }
53
+ async function sendOneTurn(context, prompt, io) {
54
+ const startedAt = Date.now();
55
+ let response;
56
+ try {
57
+ response = await io.fetch(`${context.dashboardUrl}/api/jarvis/cli`, {
58
+ method: "POST",
59
+ headers: {
60
+ authorization: `Bearer ${context.deviceToken}`,
61
+ "content-type": "application/json",
62
+ },
63
+ body: JSON.stringify({
64
+ question: prompt,
65
+ thread: context.command.thread,
66
+ subject: context.command.subject,
67
+ }),
68
+ });
69
+ }
70
+ catch (error) {
71
+ writeFailure(context.command, io, "gateway_unreachable", errorMessage(error));
72
+ return 1;
73
+ }
74
+ const body = await readReply(response);
75
+ if (!response.ok || !body.ok || !body.reply) {
76
+ const reason = body.error ?? body.reply ?? `http_${response.status}`;
77
+ writeFailure(context.command, io, "turn_failed", reason);
78
+ return 1;
79
+ }
80
+ if (context.command.json) {
81
+ writeLine(io.stdout, JSON.stringify({
82
+ ok: true,
83
+ reply: body.reply,
84
+ thread: body.thread ?? context.command.thread,
85
+ model: body.model ?? null,
86
+ subject: body.subject ?? null,
87
+ }));
88
+ }
89
+ else {
90
+ const subject = body.subject?.displayName ? ` (${body.subject.displayName})` : "";
91
+ writeLine(io.stdout, `jarvis${subject}> ${body.reply}`);
92
+ }
93
+ writeLine(io.stderr, `[jarvis cli] answered ${JSON.stringify({
94
+ prompt_length: prompt.length,
95
+ reply_length: body.reply.length,
96
+ elapsed_ms: Date.now() - startedAt,
97
+ thread: context.command.thread === "main" ? "default" : "named",
98
+ subject: context.command.subject ? "selected" : "caller",
99
+ })}`);
100
+ return 0;
101
+ }
102
+ async function readReply(response) {
103
+ const text = await response.text();
104
+ if (!text)
105
+ return { ok: false, error: "empty_response" };
106
+ try {
107
+ const value = JSON.parse(text);
108
+ if (!value || typeof value !== "object") {
109
+ return { ok: false, error: "response_body_not_object" };
110
+ }
111
+ return value;
112
+ }
113
+ catch {
114
+ return { ok: false, error: "response_body_not_json" };
115
+ }
116
+ }
117
+ function writeFailure(command, io, reason, detail) {
118
+ if (command.json) {
119
+ writeLine(io.stdout, JSON.stringify({ ok: false, error: reason, detail }));
120
+ return;
121
+ }
122
+ writeLine(io.stderr, `JARVIS could not answer: ${detail}`);
123
+ }
124
+ function validatePrompt(raw) {
125
+ const prompt = raw.trim();
126
+ if (!prompt)
127
+ throw new Error("JARVIS needs a non-empty question.");
128
+ if (prompt.length > 4000) {
129
+ throw new Error("JARVIS questions are limited to 4000 characters.");
130
+ }
131
+ return prompt;
132
+ }
133
+ async function readAll(stream) {
134
+ stream.setEncoding("utf8");
135
+ let text = "";
136
+ for await (const chunk of stream) {
137
+ text += chunk;
138
+ if (text.length > 4000) {
139
+ throw new Error("JARVIS questions are limited to 4000 characters.");
140
+ }
141
+ }
142
+ return text;
143
+ }
@@ -47,6 +47,8 @@ export function parseLocalArgs(argv) {
47
47
  return parseSyncArgs(argv.slice(1));
48
48
  case "analyze":
49
49
  return parseAnalyzeArgs(argv.slice(1));
50
+ case "jarvis":
51
+ return parseJarvisArgs(argv.slice(1));
50
52
  case "backfill":
51
53
  return parseBackfillArgs(argv.slice(1));
52
54
  case "status":
@@ -607,6 +609,30 @@ function parseAgentRulesHost(value) {
607
609
  return host;
608
610
  throw new Error("agent-rules --host must be codex, claude, or all.");
609
611
  }
612
+ function parseJarvisArgs(args) {
613
+ const values = parseNamedArgs(args, {
614
+ allowedFlags: ["--home", "--dashboard-url", "--prompt", "--as", "--thread", "--json"],
615
+ valueFlags: ["--home", "--dashboard-url", "--prompt", "--as", "--thread"],
616
+ });
617
+ const flaggedPrompt = optionalNonEmpty(values.flags.get("--prompt"));
618
+ const positionalPrompt = optionalNonEmpty(values.positionals.join(" "));
619
+ if (flaggedPrompt && positionalPrompt) {
620
+ throw new Error("jarvis accepts either --prompt or positional text, not both.");
621
+ }
622
+ const thread = optionalNonEmpty(values.flags.get("--thread")) ?? "main";
623
+ if (!/^[A-Za-z0-9_-]{1,40}$/.test(thread)) {
624
+ throw new Error("jarvis --thread must use 1 to 40 letters, numbers, underscores, or hyphens.");
625
+ }
626
+ return {
627
+ kind: "jarvis",
628
+ homeDir: optionalNonEmpty(values.flags.get("--home")),
629
+ dashboardUrl: optionalUrl(values.flags.get("--dashboard-url")),
630
+ prompt: flaggedPrompt ?? positionalPrompt,
631
+ subject: optionalNonEmpty(values.flags.get("--as")),
632
+ thread,
633
+ json: values.booleans.has("--json"),
634
+ };
635
+ }
610
636
  function parseNamedArgs(args, options) {
611
637
  const allowed = new Set(options.allowedFlags);
612
638
  const valueFlags = new Set(options.valueFlags);
@@ -21,6 +21,7 @@ export const rootCommandNames = new Set([
21
21
  "start",
22
22
  "sync",
23
23
  "analyze",
24
+ "jarvis",
24
25
  "backfill",
25
26
  "status",
26
27
  "sessions",
@@ -45,6 +46,7 @@ export function localCommandHelp(command) {
45
46
  " cockpit start [--ticket <id>|--clear-ticket] [--topic <label>] [--intent <intent>] [--phase <phase>] [--workspace <path>] [--branch <name>] [--max-depth <n>] [--max-repos <n>] [--json]",
46
47
  " cockpit sync [--workspace <path>] [--dashboard-url <url>] [--max-depth <n>] [--max-repos <n>] [--json]",
47
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>] [--dashboard-url <url>] [--json]",
48
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]",
49
51
  " cockpit status [--workspace <path>] [--max-depth <n>] [--max-repos <n>] [--json]",
50
52
  " cockpit sessions [--source codex|claude] [--since-days <n>|--all] [--workspace <path>] [--max-depth <n>] [--max-repos <n>] [--json]",
@@ -185,6 +187,20 @@ function localSubcommandHelp(command) {
185
187
  "`--repo <path>` remains supported as a backward-compatible alias.",
186
188
  ],
187
189
  ],
190
+ [
191
+ "jarvis",
192
+ [
193
+ "Usage: cockpit jarvis [question] [--prompt <question>] [--as <person>] [--thread <name>] [--dashboard-url <url>] [--json]",
194
+ "",
195
+ "Chats with the same JARVIS used by Cockpit web chat and the BLI Slack DM.",
196
+ "--as selects the existing website person space; it changes who the chat is about, never who is authenticated.",
197
+ "Run with no question for an interactive terminal conversation.",
198
+ "Agents can pass --prompt, positional text, or pipe one question on stdin.",
199
+ "--json writes one machine-readable response to stdout; operational metadata stays on stderr.",
200
+ "The command uses the existing paired device identity. It cannot override the caller, team, role, or person scope.",
201
+ "Run `cockpit login` first if this machine is not paired.",
202
+ ],
203
+ ],
188
204
  [
189
205
  "backfill",
190
206
  [
@@ -16,6 +16,7 @@
16
16
  * install-update.ts install, update, self-update, release
17
17
  * status.ts `cockpit status`
18
18
  * sessions.ts `cockpit sessions`
19
+ * jarvis.ts terminal adapter for the shared JARVIS gateway
19
20
  *
20
21
  * What stays here is orchestration: onboard, login/logout/start, the sync tick,
21
22
  * analyze, serve, autostart and agent-rules.
@@ -32,6 +33,7 @@ import { attributedSyncRunStatus, cursorStatusLine, displayTicketId, rawEvidence
32
33
  import { runInstall, runRelease, runSelfUpdate, runUpdate, SelfUpdateError, } from "./install-update.js";
33
34
  import { runStatus } from "./status.js";
34
35
  import { runSessions } from "./sessions.js";
36
+ import { runJarvis } from "./jarvis.js";
35
37
  import { createCollectorServer } from "../server.js";
36
38
  import { inspectAgentRules, installAgentRules, uninstallAgentRules, } from "../agent-rules.js";
37
39
  import { backfillRetryCommand, runBackfill, runBackfillCommand, } from "./backfill.js";
@@ -98,6 +100,8 @@ export async function runLocalCockpitCli(argv, io = defaultIo()) {
98
100
  return await runSync(command, io);
99
101
  case "analyze":
100
102
  return await runAnalyze(command, io);
103
+ case "jarvis":
104
+ return await runJarvis(command, io);
101
105
  case "backfill":
102
106
  return await runBackfillCommand(command, io);
103
107
  case "status":
@@ -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.31");
18
+ writeLine(io?.stdout ?? process.stdout, "0.2.33");
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.31",
3
+ "version": "0.2.33",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "bin": {
@@ -26,6 +26,6 @@
26
26
  "test": "node dist/cli.js --help && node ../../scripts/assert-public-cli-routing.mjs && node ../../scripts/assert-public-package-pack.mjs --workspace=@bli-cockpit/cli"
27
27
  },
28
28
  "dependencies": {
29
- "@bli-cockpit/telemetry-core": "0.1.23"
29
+ "@bli-cockpit/telemetry-core": "0.1.24"
30
30
  }
31
31
  }