@bli-cockpit/cli 0.2.117 → 0.2.121

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/dist/commands/analyze.js +74 -54
  2. package/dist/commands/brief-rewrite.js +164 -101
  3. package/dist/commands/brief.js +38 -13
  4. package/dist/commands/correct.js +38 -21
  5. package/dist/commands/docs.js +13 -10
  6. package/dist/commands/editor.js +59 -30
  7. package/dist/commands/install-receipts.js +106 -91
  8. package/dist/commands/local-args-tower-admin.js +4 -0
  9. package/dist/commands/local-args-tower-cal.js +28 -3
  10. package/dist/commands/local-args-tower-chat.js +55 -28
  11. package/dist/commands/local-args-tower-docs-msg.js +39 -8
  12. package/dist/commands/local-args-tower-mail.js +27 -1
  13. package/dist/commands/local-args-tower-models.js +14 -17
  14. package/dist/commands/local-args-tower-pages.js +62 -5
  15. package/dist/commands/local-args-tower-work.js +37 -6
  16. package/dist/commands/local-help-commands.js +6 -3
  17. package/dist/commands/local-help.js +1 -1
  18. package/dist/commands/mcp-stdio-probe.js +92 -73
  19. package/dist/commands/memory-hook-performance.js +135 -101
  20. package/dist/commands/memory-install-claude.js +15 -14
  21. package/dist/commands/memory-install-codex.js +10 -6
  22. package/dist/commands/memory-install-config.js +5 -4
  23. package/dist/commands/memory-install-contract.js +56 -10
  24. package/dist/commands/memory-install-report.js +16 -11
  25. package/dist/commands/memory-install-skills.js +11 -11
  26. package/dist/commands/memory-log.js +22 -5
  27. package/dist/commands/msg.js +11 -5
  28. package/dist/commands/notes-accounts.js +96 -5
  29. package/dist/commands/notes.js +10 -3
  30. package/dist/commands/onboard-setup.js +16 -1
  31. package/dist/commands/ops-sections.js +89 -0
  32. package/dist/commands/ops.js +117 -120
  33. package/dist/commands/public-root.js +1 -1
  34. package/dist/commands/scout.js +90 -68
  35. package/dist/commands/session-sync-failures.js +19 -13
  36. package/dist/commands/session-sync-record.js +53 -52
  37. package/dist/commands/session-sync-upload.js +15 -11
  38. package/dist/commands/sessions.js +61 -51
  39. package/dist/commands/slack.js +90 -61
  40. package/dist/commands/status.js +53 -41
  41. package/dist/commands/workbook.js +23 -20
  42. package/package.json +2 -2
@@ -10,7 +10,39 @@
10
10
  */
11
11
  import { optionalNonEmpty, optionalPositiveInteger, optionalUrl, parseNamedArgs, } from "./local-arg-values.js";
12
12
  export function parseJarvisArgs(args) {
13
- const values = parseNamedArgs(args, {
13
+ const values = parseJarvisNamedArgs(args);
14
+ const prompt = parseJarvisPrompt(values);
15
+ const thread = parseJarvisThread(values);
16
+ const imagePath = parseJarvisAttachment(values);
17
+ const { threads, history } = parseJarvisReadback(values, prompt, imagePath);
18
+ const date = parseJarvisDate(values, threads, history);
19
+ const trace = parseJarvisTrace(values, threads, history, prompt, imagePath);
20
+ return {
21
+ kind: "jarvis",
22
+ homeDir: optionalNonEmpty(values.flags.get("--home")),
23
+ dashboardUrl: optionalUrl(values.flags.get("--dashboard-url")),
24
+ prompt,
25
+ subject: optionalNonEmpty(values.flags.get("--as")),
26
+ thread,
27
+ threads,
28
+ history,
29
+ ...(trace ? { trace } : {}),
30
+ limit: optionalPositiveInteger(values.flags.get("--limit"), "--limit"),
31
+ // BLI-3381: no client-side allowlist — the dashboard forwards this key
32
+ // to the inference server's own allowlist and relays its refusal.
33
+ model: optionalNonEmpty(values.flags.get("--model")),
34
+ ...(date ? { date } : {}),
35
+ imagePath,
36
+ // BLI-3457: streaming is on unless a caller opts out. A dashboard that
37
+ // does not stream yet still answers plain JSON, so this flag is for
38
+ // callers that want the single-body shape on purpose, not a compat knob.
39
+ stream: !values.booleans.has("--no-stream"),
40
+ json: values.booleans.has("--json"),
41
+ showApprovalCode: values.booleans.has("--show-approval-code"),
42
+ };
43
+ }
44
+ function parseJarvisNamedArgs(args) {
45
+ return parseNamedArgs(args, {
14
46
  allowedFlags: [
15
47
  "--home",
16
48
  "--dashboard-url",
@@ -50,15 +82,23 @@ export function parseJarvisArgs(args) {
50
82
  "--limit",
51
83
  ],
52
84
  });
85
+ }
86
+ function parseJarvisPrompt(values) {
53
87
  const flaggedPrompt = optionalNonEmpty(values.flags.get("--prompt"));
54
88
  const positionalPrompt = optionalNonEmpty(values.positionals.join(" "));
55
89
  if (flaggedPrompt && positionalPrompt) {
56
90
  throw new Error("jarvis accepts either --prompt or positional text, not both.");
57
91
  }
92
+ return flaggedPrompt ?? positionalPrompt;
93
+ }
94
+ function parseJarvisThread(values) {
58
95
  const thread = optionalNonEmpty(values.flags.get("--thread")) ?? "main";
59
96
  if (!/^[A-Za-z0-9_-]{1,40}$/.test(thread)) {
60
97
  throw new Error("jarvis --thread must use 1 to 40 letters, numbers, underscores, or hyphens.");
61
98
  }
99
+ return thread;
100
+ }
101
+ function parseJarvisAttachment(values) {
62
102
  // BLI-3414: `--file` is a plain alias for `--image` — same flag, whichever
63
103
  // word a person reaches for first.
64
104
  const image = optionalNonEmpty(values.flags.get("--image"));
@@ -66,6 +106,9 @@ export function parseJarvisArgs(args) {
66
106
  if (image && file) {
67
107
  throw new Error("jarvis accepts either --image or --file, not both — they are the same flag.");
68
108
  }
109
+ return image ?? file;
110
+ }
111
+ function parseJarvisReadback(values, prompt, imagePath) {
69
112
  // BLI-3458. Reading history and asking a question are different acts, and a
70
113
  // command that quietly did one while you asked for the other would be worse
71
114
  // than a refusal — `--threads` with a question would silently drop the
@@ -75,12 +118,15 @@ export function parseJarvisArgs(args) {
75
118
  if (threads && history) {
76
119
  throw new Error("jarvis --threads lists every thread; --history replays one. Pass one, not both.");
77
120
  }
78
- if ((threads || history) && (flaggedPrompt || positionalPrompt)) {
121
+ if ((threads || history) && prompt) {
79
122
  throw new Error("jarvis --threads and --history read back what was already said; they do not take a question.");
80
123
  }
81
- if ((threads || history) && image) {
124
+ if ((threads || history) && imagePath) {
82
125
  throw new Error("jarvis --threads and --history do not take an attachment.");
83
126
  }
127
+ return { threads, history };
128
+ }
129
+ function parseJarvisDate(values, threads, history) {
84
130
  // BLI-3484. `--date` binds the page that was live on one of the subject's
85
131
  // days, so a turn can be about Sunday's page. Refused on the two reading
86
132
  // commands for the same reason an attachment is: they replay what was said
@@ -89,6 +135,9 @@ export function parseJarvisArgs(args) {
89
135
  if ((threads || history) && date) {
90
136
  throw new Error("jarvis --threads and --history replay what was said; they bind no page.");
91
137
  }
138
+ return date;
139
+ }
140
+ function parseJarvisTrace(values, threads, history, prompt, imagePath) {
92
141
  // BLI-3560. `--trace` reads back what a turn DID, the same class of act as
93
142
  // `--threads` and `--history`, and refused alongside them for the same
94
143
  // reason: a command that quietly asked a question while you asked to see one
@@ -97,38 +146,16 @@ export function parseJarvisArgs(args) {
97
146
  if (trace && (threads || history)) {
98
147
  throw new Error("jarvis --trace shows one turn's steps; --threads and --history replay what was said. Pass one.");
99
148
  }
100
- if (trace && (flaggedPrompt || positionalPrompt)) {
149
+ if (trace && prompt) {
101
150
  throw new Error("jarvis --trace shows a turn that already ran; it does not take a question.");
102
151
  }
103
- if (trace && image) {
152
+ if (trace && imagePath) {
104
153
  throw new Error("jarvis --trace does not take an attachment.");
105
154
  }
106
155
  if (trace !== undefined && !/^(last|[A-Za-z0-9_-]{1,64})$/.test(trace)) {
107
156
  throw new Error("jarvis --trace takes `last` or a trace id (up to 64 letters, numbers, underscores or hyphens).");
108
157
  }
109
- return {
110
- kind: "jarvis",
111
- homeDir: optionalNonEmpty(values.flags.get("--home")),
112
- dashboardUrl: optionalUrl(values.flags.get("--dashboard-url")),
113
- prompt: flaggedPrompt ?? positionalPrompt,
114
- subject: optionalNonEmpty(values.flags.get("--as")),
115
- thread,
116
- threads,
117
- history,
118
- ...(trace ? { trace } : {}),
119
- limit: optionalPositiveInteger(values.flags.get("--limit"), "--limit"),
120
- // BLI-3381: no client-side allowlist — the dashboard forwards this key
121
- // to the inference server's own allowlist and relays its refusal.
122
- model: optionalNonEmpty(values.flags.get("--model")),
123
- ...(date ? { date } : {}),
124
- imagePath: image ?? file,
125
- // BLI-3457: streaming is on unless a caller opts out. A dashboard that
126
- // does not stream yet still answers plain JSON, so this flag is for
127
- // callers that want the single-body shape on purpose, not a compat knob.
128
- stream: !values.booleans.has("--no-stream"),
129
- json: values.booleans.has("--json"),
130
- showApprovalCode: values.booleans.has("--show-approval-code"),
131
- };
158
+ return trace;
132
159
  }
133
160
  /**
134
161
  * `cockpit correct` (BLI-3458) — say that one line on the page is wrong.
@@ -27,23 +27,33 @@ export function parseDocsArgs(args) {
27
27
  ],
28
28
  valueFlags: ["--home", "--dashboard-url", "--title", "--parent", "--visibility", "--file", "--query", "--limit"],
29
29
  });
30
- const first = values.positionals[0];
30
+ const { action, rest } = parseDocsAction(values.positionals);
31
+ const docRef = parseDocsDocumentReference(action, rest);
32
+ const { visibility, clearParent, parentId, allowEmpty, title, query, limit } = parseDocsFlags(action, values);
33
+ return buildDocsCommand(values, action, docRef, visibility, clearParent, parentId, allowEmpty, title, query, limit);
34
+ }
35
+ function parseDocsAction(positionals) {
36
+ const first = positionals[0];
31
37
  const action = (first === undefined ? "list" : first);
32
38
  if (!DOCS_ACTIONS.has(action)) {
33
39
  throw new Error(`Unknown docs command: ${first}. Try list, tree, read, create, or update.`);
34
40
  }
35
- const rest = values.positionals.slice(first === undefined ? 0 : 1);
36
- let docRef;
41
+ return { action, rest: positionals.slice(first === undefined ? 0 : 1) };
42
+ }
43
+ function parseDocsDocumentReference(action, rest) {
37
44
  if (DOCS_ACTIONS_NEEDING_A_DOC.has(action)) {
38
- docRef = optionalNonEmpty(rest[0]);
45
+ const docRef = optionalNonEmpty(rest[0]);
39
46
  if (!docRef)
40
47
  throw new Error(`docs ${action} needs a document id or slug.`);
41
48
  if (rest.length > 1)
42
49
  throw new Error(`docs ${action} takes one document reference, not ${rest.length}.`);
50
+ return docRef;
43
51
  }
44
- else if (rest.length > 0) {
52
+ if (rest.length > 0)
45
53
  throw new Error(`docs ${action} does not take "${rest[0]}".`);
46
- }
54
+ return undefined;
55
+ }
56
+ function parseDocsFlags(action, values) {
47
57
  const visibility = optionalNonEmpty(values.flags.get("--visibility"));
48
58
  if (visibility && visibility !== "org" && visibility !== "private") {
49
59
  throw new Error('docs --visibility must be "org" or "private".');
@@ -80,6 +90,9 @@ export function parseDocsArgs(args) {
80
90
  if (limit !== undefined && action !== "list") {
81
91
  throw new Error("--limit belongs to `cockpit docs list`.");
82
92
  }
93
+ return { visibility, clearParent, parentId, allowEmpty, title, query, limit };
94
+ }
95
+ function buildDocsCommand(values, action, docRef, visibility, clearParent, parentId, allowEmpty, title, query, limit) {
83
96
  return {
84
97
  kind: "docs",
85
98
  action,
@@ -137,12 +150,21 @@ export function parseMsgArgs(args) {
137
150
  ],
138
151
  valueFlags: ["--home", "--dashboard-url", "--channel", "--thread", "--limit", "--members", "--description"],
139
152
  });
140
- const first = values.positionals[0];
153
+ const { action, rest } = parseMsgAction(values.positionals);
154
+ const { channelName, dmEmail } = parseMsgCreateTarget(action, rest);
155
+ const { isPrivate, memberEmails, description } = parseMsgCreateFlags(action, values);
156
+ const { channelRef, threadId, limit } = parseMsgReadTarget(action, rest, values);
157
+ return buildMsgCommand(values, action, channelRef, channelName, dmEmail, memberEmails, description, isPrivate, threadId, limit);
158
+ }
159
+ function parseMsgAction(positionals) {
160
+ const first = positionals[0];
141
161
  const action = (first === undefined ? "channels" : first);
142
162
  if (!MSG_ACTIONS.has(action)) {
143
163
  throw new Error(`Unknown msg command: ${first}. Try channels, read, send, thread, create, or dm.`);
144
164
  }
145
- const rest = values.positionals.slice(first === undefined ? 0 : 1);
165
+ return { action, rest: positionals.slice(first === undefined ? 0 : 1) };
166
+ }
167
+ function parseMsgCreateTarget(action, rest) {
146
168
  let channelName;
147
169
  let dmEmail;
148
170
  if (action === "create") {
@@ -167,6 +189,9 @@ export function parseMsgArgs(args) {
167
189
  if (!dmEmail.includes("@"))
168
190
  throw new Error(`msg dm takes an email address; "${dmEmail}" is not one.`);
169
191
  }
192
+ return { channelName, dmEmail };
193
+ }
194
+ function parseMsgCreateFlags(action, values) {
170
195
  const isPrivate = values.booleans.has("--private");
171
196
  if (isPrivate && action !== "create") {
172
197
  throw new Error("--private belongs to `cockpit msg create`.");
@@ -179,6 +204,9 @@ export function parseMsgArgs(args) {
179
204
  if (description && action !== "create") {
180
205
  throw new Error("--description belongs to `cockpit msg create`.");
181
206
  }
207
+ return { isPrivate, memberEmails, description };
208
+ }
209
+ function parseMsgReadTarget(action, rest, values) {
182
210
  let channelRef;
183
211
  if (MSG_ACTIONS_NEEDING_A_CHANNEL.has(action)) {
184
212
  channelRef = optionalNonEmpty(rest[0]);
@@ -207,6 +235,9 @@ export function parseMsgArgs(args) {
207
235
  if (limit !== undefined && action !== "read" && action !== "thread") {
208
236
  throw new Error("--limit belongs to `cockpit msg read` or `cockpit msg thread`.");
209
237
  }
238
+ return { channelRef, threadId, limit };
239
+ }
240
+ function buildMsgCommand(values, action, channelRef, channelName, dmEmail, memberEmails, description, isPrivate, threadId, limit) {
210
241
  return {
211
242
  kind: "msg",
212
243
  action,
@@ -70,12 +70,23 @@ export function parseMailArgs(args) {
70
70
  "--out",
71
71
  ],
72
72
  });
73
+ const { action, rest } = readMailAction(values);
74
+ const { subject, query } = readMailSubjectOrQuery(action, rest);
75
+ const address = readImapAddress(action, values);
76
+ const { to, cc } = readMailRecipients(action, values);
77
+ const outPath = readAttachmentPath(action, values);
78
+ const limit = readMailLimit(action, values);
79
+ return buildMailCommand(values, action, subject, query, address, to, cc, outPath, limit);
80
+ }
81
+ function readMailAction(values) {
73
82
  const first = values.positionals[0];
74
83
  const action = (first === undefined ? "inbox" : first);
75
84
  if (!MAIL_ACTIONS.has(action)) {
76
85
  throw new Error(`Unknown mail command: ${first}. Try accounts, add-imap, detach, inbox, read, search, send, attachment, or sync.`);
77
86
  }
78
- const rest = values.positionals.slice(first === undefined ? 0 : 1);
87
+ return { action, rest: values.positionals.slice(first === undefined ? 0 : 1) };
88
+ }
89
+ function readMailSubjectOrQuery(action, rest) {
79
90
  let subject;
80
91
  let query;
81
92
  if (MAIL_ACTIONS_NEEDING_A_SUBJECT.has(action)) {
@@ -96,10 +107,16 @@ export function parseMailArgs(args) {
96
107
  else if (rest.length > 0) {
97
108
  throw new Error(`mail ${action} does not take "${rest[0]}".`);
98
109
  }
110
+ return { subject, query };
111
+ }
112
+ function readImapAddress(action, values) {
99
113
  const address = optionalNonEmpty(values.flags.get("--address"));
100
114
  if (action === "add-imap" && !address) {
101
115
  throw new Error("mail add-imap needs --address, and reads the app password from stdin.");
102
116
  }
117
+ return address;
118
+ }
119
+ function readMailRecipients(action, values) {
103
120
  const to = splitAddresses(values.flags.get("--to"));
104
121
  const cc = splitAddresses(values.flags.get("--cc"));
105
122
  if (action === "send" && to.length === 0) {
@@ -108,14 +125,23 @@ export function parseMailArgs(args) {
108
125
  if (action === "send" && !optionalNonEmpty(values.flags.get("--account"))) {
109
126
  throw new Error("mail send needs --account <id>: with several mailboxes attached, which address this goes out from is yours to say. `cockpit mail accounts` lists them.");
110
127
  }
128
+ return { to, cc };
129
+ }
130
+ function readAttachmentPath(action, values) {
111
131
  const outPath = optionalNonEmpty(values.flags.get("--out"));
112
132
  if (action === "attachment" && !outPath) {
113
133
  throw new Error("mail attachment needs --out <path>: the file is written to disk, never to stdout.");
114
134
  }
135
+ return outPath;
136
+ }
137
+ function readMailLimit(action, values) {
115
138
  const limit = optionalPositiveInteger(values.flags.get("--limit"), "--limit");
116
139
  if (limit !== undefined && action !== "inbox" && action !== "search") {
117
140
  throw new Error("--limit belongs to `cockpit mail inbox` or `cockpit mail search`.");
118
141
  }
142
+ return limit;
143
+ }
144
+ function buildMailCommand(values, action, subject, query, address, to, cc, outPath, limit) {
119
145
  return {
120
146
  kind: "mail",
121
147
  action,
@@ -28,23 +28,7 @@ export function parseModelsArgs(args) {
28
28
  // `compare` is the one verb that takes several ids, so it is parsed before
29
29
  // the at-most-two rule the other two live under (BLI-3919).
30
30
  if (values.positionals[0] === "compare") {
31
- const modelIds = values.positionals.slice(1).map((id) => id.trim()).filter((id) => id.length > 0);
32
- const unique = [...new Set(modelIds)];
33
- if (unique.length < COMPARE_MIN) {
34
- throw new Error(`models compare needs at least ${COMPARE_MIN} model ids, e.g. ` +
35
- "`models compare openai:gpt-5.6-luna openai:gpt-5.6-terra`.");
36
- }
37
- if (unique.length > COMPARE_MAX) {
38
- throw new Error(`models compare holds at most ${COMPARE_MAX} models; ${unique.length} named. ` +
39
- "A fifth column stops being readable.");
40
- }
41
- return {
42
- kind: "models",
43
- action: "compare",
44
- modelIds: unique,
45
- highlight: values.booleans.has("--highlight"),
46
- ...base,
47
- };
31
+ return parseModelComparison(values.positionals, values.booleans.has("--highlight"), base);
48
32
  }
49
33
  if (values.booleans.has("--highlight")) {
50
34
  throw new Error("--highlight only means something for `models compare`.");
@@ -69,4 +53,17 @@ export function parseModelsArgs(args) {
69
53
  throw new Error("models show needs a model id, e.g. `models show openai:gpt-5.6-terra`.");
70
54
  }
71
55
  return { kind: "models", action: "show", modelId, ...base };
56
+ }
57
+ function parseModelComparison(positionals, highlight, base) {
58
+ const modelIds = positionals.slice(1).map((id) => id.trim()).filter((id) => id.length > 0);
59
+ const unique = [...new Set(modelIds)];
60
+ if (unique.length < COMPARE_MIN) {
61
+ throw new Error(`models compare needs at least ${COMPARE_MIN} model ids, e.g. ` +
62
+ "`models compare openai:gpt-5.6-luna openai:gpt-5.6-terra`.");
63
+ }
64
+ if (unique.length > COMPARE_MAX) {
65
+ throw new Error(`models compare holds at most ${COMPARE_MAX} models; ${unique.length} named. ` +
66
+ "A fifth column stops being readable.");
67
+ }
68
+ return { kind: "models", action: "compare", modelIds: unique, highlight, ...base };
72
69
  }
@@ -152,6 +152,18 @@ export function parseBriefArgs(args) {
152
152
  json: values.booleans.has("--json"),
153
153
  };
154
154
  }
155
+ /**
156
+ * The notetakers `cockpit notes connect` will actually seal a key for
157
+ * (BLI-4391). It mirrors `CONNECTABLE_NOTES_PROVIDERS` in the dashboard, and
158
+ * the door is still the authority — this list only saves a round trip and a
159
+ * sealed key for a provider with no reader.
160
+ *
161
+ * `otter` and `granola` are deliberately absent: Otter's public API is
162
+ * Enterprise-only and Granola still runs on its own server-side cron.
163
+ */
164
+ const CONNECTABLE_PROVIDERS = ["fellow", "circleback"];
165
+ /** The provider whose API is per workspace, so its account needs a subdomain. */
166
+ const PROVIDERS_NEEDING_A_WORKSPACE = ["fellow"];
155
167
  const NOTES_ACTIONS = new Set([
156
168
  "folders", "mkdir", "rmdir", "rename",
157
169
  "list",
@@ -169,6 +181,8 @@ const NOTES_ACTIONS = new Set([
169
181
  "connect",
170
182
  "detach",
171
183
  "sync",
184
+ // BLI-4394: arm that notetaker's webhook with the vendor's signing secret.
185
+ "webhook",
172
186
  ]);
173
187
  /** Actions whose first positional is the note it acts on. */
174
188
  const NOTES_ACTIONS_NEEDING_A_NOTE = new Set([
@@ -183,7 +197,7 @@ const NOTES_ACTIONS_NEEDING_A_NOTE = new Set([
183
197
  * note id. `sync` takes one too, and is handled beside these rather than in
184
198
  * this set because its id is checked at the door instead of here.
185
199
  */
186
- const NOTES_ACTIONS_NEEDING_AN_ACCOUNT = new Set(["detach"]);
200
+ const NOTES_ACTIONS_NEEDING_AN_ACCOUNT = new Set(["detach", "webhook"]);
187
201
  export function parseNotesArgs(args) {
188
202
  const values = parseNamedArgs(args, {
189
203
  allowedFlags: [
@@ -209,6 +223,13 @@ export function parseNotesArgs(args) {
209
223
  // read from stdin and never from argv.
210
224
  "--provider",
211
225
  "--key-stdin",
226
+ // BLI-4391. Fellow's API is per WORKSPACE, and a subdomain is a label,
227
+ // not a secret, so this one carries a value while the key never does.
228
+ "--workspace",
229
+ // BLI-4394. `--secret-stdin` is a FLAG and not a value, for the same
230
+ // reason `--key-stdin` is: the signing secret never touches argv.
231
+ "--secret-stdin",
232
+ "--show-webhook",
212
233
  ],
213
234
  valueFlags: [
214
235
  "--home",
@@ -224,6 +245,8 @@ export function parseNotesArgs(args) {
224
245
  "--until",
225
246
  "--limit",
226
247
  "--provider",
248
+ "--workspace",
249
+ "--show-webhook",
227
250
  ],
228
251
  });
229
252
  // Bare `cockpit notes` is the list, which is the thing a person typing it
@@ -231,7 +254,7 @@ export function parseNotesArgs(args) {
231
254
  const first = values.positionals[0];
232
255
  const action = (first === undefined ? "list" : first);
233
256
  if (!NOTES_ACTIONS.has(action)) {
234
- throw new Error(`Unknown notes command: ${first}. Try list, show, shelf, shelves, upload, paste, share, unshare, move, place, accounts, connect, detach, or sync.`);
257
+ throw new Error(`Unknown notes command: ${first}. Try list, show, shelf, shelves, upload, paste, share, unshare, move, place, accounts, connect, detach, sync, or webhook.`);
235
258
  }
236
259
  const rest = values.positionals.slice(first === undefined ? 0 : 1);
237
260
  const json = values.booleans.has("--json");
@@ -258,14 +281,46 @@ export function parseNotesArgs(args) {
258
281
  throw new Error(`notes ${action} takes one account id, not ${rest.length}.`);
259
282
  }
260
283
  }
284
+ // BLI-4394. `--show-webhook <id>` is a flag on `accounts` rather than a verb
285
+ // of its own, because "what is this one's webhook URL?" is the question a
286
+ // person asks WHILE looking at the list.
287
+ const showWebhook = optionalNonEmpty(values.flags.get("--show-webhook"));
288
+ if (showWebhook !== undefined) {
289
+ if (action !== "accounts") {
290
+ throw new Error("--show-webhook belongs to `cockpit notes accounts --show-webhook <account-id>`.");
291
+ }
292
+ accountId = showWebhook;
293
+ }
294
+ if (action === "webhook") {
295
+ if (!values.booleans.has("--secret-stdin")) {
296
+ // Naming the flag is the point: it says out loud that the signing secret
297
+ // is not an argument, so nobody goes looking for a `--secret` that will
298
+ // never exist.
299
+ throw new Error('notes webhook reads the signing secret from stdin. Say so: printf "%s" "<secret>" | cockpit notes webhook <account-id> --secret-stdin');
300
+ }
301
+ }
302
+ else if (values.booleans.has("--secret-stdin")) {
303
+ throw new Error("--secret-stdin belongs to `cockpit notes webhook <account-id> --secret-stdin`.");
304
+ }
261
305
  const provider = optionalNonEmpty(values.flags.get("--provider"));
306
+ const workspace = optionalNonEmpty(values.flags.get("--workspace"));
262
307
  if (action === "connect") {
263
- if (!provider)
264
- throw new Error("notes connect needs --provider fathom.");
308
+ if (!provider) {
309
+ throw new Error(`notes connect needs --provider ${CONNECTABLE_PROVIDERS.join("|")}.`);
310
+ }
311
+ if (!CONNECTABLE_PROVIDERS.includes(provider)) {
312
+ throw new Error(`notes connect does not know the provider "${provider}". Tower reads ${CONNECTABLE_PROVIDERS.join(" and ")}.`);
313
+ }
314
+ if (PROVIDERS_NEEDING_A_WORKSPACE.includes(provider) && !workspace) {
315
+ throw new Error(`notes connect --provider ${provider} needs --workspace <subdomain>: its API lives at https://<subdomain>.fellow.app, so a key alone cannot address anything.`);
316
+ }
317
+ if (workspace !== undefined && !/^[a-z0-9-]{1,63}$/.test(workspace)) {
318
+ throw new Error("--workspace is a workspace subdomain: lowercase letters, digits and hyphens, 1 to 63 characters.");
319
+ }
265
320
  if (!values.booleans.has("--key-stdin")) {
266
321
  // Naming the flag is the point: it says out loud that the key is not an
267
322
  // argument, so nobody goes looking for a `--key` that will never exist.
268
- throw new Error('notes connect reads the key from stdin. Say so: printf "%s" "<key>" | cockpit notes connect --provider fathom --key-stdin');
323
+ throw new Error('notes connect reads the key from stdin. Say so: printf "%s" "<key>" | cockpit notes connect --provider fellow --workspace <subdomain> --key-stdin');
269
324
  }
270
325
  if (rest.length > 0)
271
326
  throw new Error(`notes connect does not take "${rest[0]}".`);
@@ -316,6 +371,8 @@ export function parseNotesArgs(args) {
316
371
  ...(noteId ? { noteId } : {}),
317
372
  ...(accountId ? { accountId } : {}),
318
373
  ...(provider ? { provider } : {}),
374
+ ...(workspace ? { workspace } : {}),
375
+ ...(showWebhook ? { showWebhook: true } : {}),
319
376
  ...(paths ? { paths } : {}),
320
377
  filePath: optionalNonEmpty(values.flags.get("--file")),
321
378
  name: optionalNonEmpty(values.flags.get("--name")),
@@ -72,12 +72,25 @@ export function parseIssueArgs(args) {
72
72
  "--file",
73
73
  ],
74
74
  });
75
+ const { action, rest } = readIssueAction(values);
76
+ const { issueRef, moveState } = readIssueReference(action, rest);
77
+ validateIssueState(moveState, "issue move state must be one of: ");
78
+ const stateFilter = readIssueStateFilter(action, values);
79
+ const title = readIssueTitle(action, values);
80
+ const priority = readIssuePriority(values);
81
+ const assignee = readIssueAssignee(action, values);
82
+ const limit = readIssueLimit(action, values);
83
+ return buildIssueCommand(values, action, issueRef, moveState, stateFilter, title, priority, assignee, limit);
84
+ }
85
+ function readIssueAction(values) {
75
86
  const first = values.positionals[0];
76
87
  const action = (first === undefined ? "list" : first);
77
88
  if (!ISSUE_ACTIONS.has(action)) {
78
89
  throw new Error(`Unknown issue command: ${first}. Try list, show, create, update, move, comment, or history.`);
79
90
  }
80
- const rest = values.positionals.slice(first === undefined ? 0 : 1);
91
+ return { action, rest: values.positionals.slice(first === undefined ? 0 : 1) };
92
+ }
93
+ function readIssueReference(action, rest) {
81
94
  let issueRef;
82
95
  let moveState;
83
96
  if (ISSUE_ACTIONS_NEEDING_AN_ISSUE.has(action)) {
@@ -99,19 +112,28 @@ export function parseIssueArgs(args) {
99
112
  else if (rest.length > 0) {
100
113
  throw new Error(`issue ${action} does not take "${rest[0]}".`);
101
114
  }
102
- if (moveState && !ISSUE_STATES.includes(moveState)) {
103
- throw new Error(`issue move state must be one of: ${ISSUE_STATES.join(", ")}.`);
115
+ return { issueRef, moveState };
116
+ }
117
+ function validateIssueState(state, errorPrefix) {
118
+ if (state && !ISSUE_STATES.includes(state)) {
119
+ throw new Error(`${errorPrefix}${ISSUE_STATES.join(", ")}.`);
104
120
  }
121
+ }
122
+ function readIssueStateFilter(action, values) {
105
123
  const stateFilter = optionalNonEmpty(values.flags.get("--state"));
106
- if (stateFilter !== undefined && !ISSUE_STATES.includes(stateFilter)) {
107
- throw new Error(`--state must be one of: ${ISSUE_STATES.join(", ")}.`);
108
- }
124
+ validateIssueState(stateFilter, "--state must be one of: ");
109
125
  if (stateFilter !== undefined && action !== "list") {
110
126
  throw new Error("--state filters `cockpit issue list`; to move an issue use `cockpit issue move <id> <state>`.");
111
127
  }
128
+ return stateFilter;
129
+ }
130
+ function readIssueTitle(action, values) {
112
131
  const title = optionalNonEmpty(values.flags.get("--title"));
113
132
  if (action === "create" && !title)
114
133
  throw new Error("issue create needs --title.");
134
+ return title;
135
+ }
136
+ function readIssuePriority(values) {
115
137
  // Not `optionalPositiveInteger`: 0 is a legitimate priority ("none", the
116
138
  // column default), and that helper refuses it as non-positive.
117
139
  const priorityRaw = optionalNonEmpty(values.flags.get("--priority"));
@@ -122,6 +144,9 @@ export function parseIssueArgs(args) {
122
144
  throw new Error("--priority must be 0-4 (0 none, 1 urgent, 2 high, 3 medium, 4 low).");
123
145
  }
124
146
  }
147
+ return priority;
148
+ }
149
+ function readIssueAssignee(action, values) {
125
150
  // An assignee is `me`, `unassigned`, or a person's uuid. A name or an
126
151
  // email is refused HERE rather than travelling to Postgres as a malformed
127
152
  // uuid, which would come back as a 500 that names nothing useful.
@@ -132,10 +157,16 @@ export function parseIssueArgs(args) {
132
157
  if (assignee === "unassigned" && action !== "list") {
133
158
  throw new Error('--assignee unassigned filters `cockpit issue list`; to clear an assignee pass --assignee "" is not supported yet.');
134
159
  }
160
+ return assignee;
161
+ }
162
+ function readIssueLimit(action, values) {
135
163
  const limit = optionalPositiveInteger(values.flags.get("--limit"), "--limit");
136
164
  if (limit !== undefined && action !== "list" && action !== "history") {
137
165
  throw new Error("--limit belongs to `cockpit issue list` or `cockpit issue history`.");
138
166
  }
167
+ return limit;
168
+ }
169
+ function buildIssueCommand(values, action, issueRef, moveState, stateFilter, title, priority, assignee, limit) {
139
170
  return {
140
171
  kind: "issue",
141
172
  action,
@@ -327,12 +327,13 @@ export function localSubcommandHelp(command) {
327
327
  [
328
328
  "ops",
329
329
  [
330
- "Usage: cockpit ops [status [--job <id>] [--coverage] [--skips] [--memory [--memory-days N]] [--models] | recompile --person <email|name|id> [--dry-run]] [--json]",
330
+ "Usage: cockpit ops [status [--job <id>] [--coverage] [--skips] [--memory [--memory-days N]] [--models] [--turns] [--tool-router] | recompile --person <email|name|id> [--dry-run]] [--json]",
331
331
  "",
332
332
  " cockpit ops status",
333
333
  " One line per scheduled job: when it last produced something, and whether that",
334
334
  " is late FOR THAT JOB. A once-daily job quiet for 18 hours reads ok; a",
335
335
  " quarter-hourly one does not. Every line names the schedule it is judged against.",
336
+ " --turns adds 30-day JARVIS turn aggregates; --tool-router adds the shadow-router agreement board.",
336
337
  " A job that is not ok also prints what its artifact does and does not prove —",
337
338
  " several only write a row when there is something new, so quiet can mean a quiet",
338
339
  " week rather than a broken cron.",
@@ -472,9 +473,11 @@ export function localSubcommandHelp(command) {
472
473
  "move <id> (--to \"<shelf>\"|--clear-shelf) — put it on a different shelf, or take the shelf off.",
473
474
  "place <id> [--apply] — say which shelf this note belongs on and why, from the meeting it is part of, who was in the room and its own name. Moves nothing without --apply, and never overrules a shelf somebody typed.",
474
475
  "",
475
- "Your own notetaker (BLI-4383). Fathom today; Granola uses the same account model.",
476
+ "Your own notetaker (BLI-4383). Fellow and Circleback today; Otter needs an Enterprise plan and Granola still runs on its own cron.",
476
477
  "accounts [--json] — the notetakers you have connected, with health and when each was last read.",
477
- "connect --provider fathom --key-stdin [--name <n>]connect one. THE API KEY IS READ FROM STDIN, never a flag: printf \"%s\" \"<key>\" | cockpit notes connect --provider fathom --key-stdin",
478
+ "accounts --show-webhook <account-id> — that account's webhook URL, where to paste it at the vendor, and whether a signing secret is stored (BLI-4394). Until one is, every request to that URL is refused, which is the right way to be off.",
479
+ "webhook <account-id> --secret-stdin — store the VENDOR's webhook signing secret. THE SECRET IS READ FROM STDIN, never a flag: printf \"%s\" \"<signing secret>\" | cockpit notes webhook <account-id> --secret-stdin",
480
+ "connect --provider fellow|circleback [--workspace <subdomain>] --key-stdin [--name <n>] — connect one. Fellow needs --workspace, its API is per workspace. THE API KEY IS READ FROM STDIN, never a flag: printf \"%s\" \"<key>\" | cockpit notes connect --provider fellow --workspace <subdomain> --key-stdin",
478
481
  "detach <account-id> — take it off. The stored key goes with it; the meetings it already filed stay.",
479
482
  "sync <account-id> — read that notetaker now. Prints the run's own counts and reason label, and exits 1 when the pass failed.",
480
483
  "A large note is read by a model on the server and can take a couple of minutes; the terminal says so before it waits.",
@@ -87,7 +87,7 @@ export function localCommandHelp(command) {
87
87
  " cockpit brief status [--who <person>] [--render] [--dashboard-url <url>] [--json]",
88
88
  " cockpit correct --claim <claimId> --text \"<what is wrong>\" [--for <person>] [--version <pageId>] [--supersedes <id>] [--dashboard-url <url>] [--json]",
89
89
  " cockpit notes [list|show <id>|shelf|shelves|folders|mkdir <path>|rmdir <path>|rename <path>|upload <paths...> [--wait]|paste|share <id>|unshare <id>|move <id>|place <id>] [--folder <path>] [--series <shelf>] [--kind <kind>] [--since <YYYY-MM-DD>] [--until <YYYY-MM-DD>] [--limit <n>] [--file <path>] [--name <n>] [--exclude \"<sentence>\"] [--to \"<shelf>\"|--clear-shelf] [--apply] [--yes] [--dashboard-url <url>] [--json]",
90
- " cockpit notes [accounts|connect --provider fathom --key-stdin [--name <n>]|detach <account-id>|sync <account-id>] [--json] (the key is read from stdin, never a flag)",
90
+ " cockpit notes [accounts [--show-webhook <account-id>]|connect --provider fellow --workspace <sub> --key-stdin [--name <n>]|webhook <account-id> --secret-stdin|detach <account-id>|sync <account-id>] [--json] (the key and the signing secret are read from stdin, never a flag)",
91
91
  " cockpit backfill (--since-days <n>|--all) [--source codex|claude] [--dry-run] [--max-files <n>] [--max-depth <n>] [--max-repos <n>] [--yes] [--workspace <path>] [--json]",
92
92
  " cockpit status [--workspace <path>] [--max-depth <n>] [--max-repos <n>] [--json]",
93
93
  " cockpit sessions [--source codex|claude] [--since-days <n>|--all] [--workspace <path>] [--max-depth <n>] [--max-repos <n>] [--json]",