@bli-cockpit/cli 0.2.199 → 0.2.201

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.
@@ -164,6 +164,7 @@ export function repairReceiptLines(repairs) {
164
164
  }
165
165
  export function doctorNeedsPerson(rows, receipt) {
166
166
  const defaults = {
167
+ "slack-connected": "cockpit slack connect",
167
168
  "cli-latest": "npm i -g @bli-cockpit/cli && cockpit doctor",
168
169
  "authed": "cockpit doctor",
169
170
  "roots-ok": "cockpit doctor",
@@ -0,0 +1,25 @@
1
+ import { DEFAULT_DASHBOARD_URL } from "../local-state.js";
2
+ import { asRecord, callTower, openTower } from "./tower-command.js";
3
+ export async function checkSlackConnected(context) {
4
+ const message = "Slack connected for private-channel coverage";
5
+ try {
6
+ const tower = await openTower("doctor", context.command, context.io);
7
+ const response = await callTower(tower, { path: "/api/slack/connections", label: "doctor-slack", timeoutMs: 5_000 });
8
+ if (!response.ok)
9
+ return { id: "slack-connected", status: "needs_fix", code: response.reason, message: `${message}: could not read connection state.`, nextAction: `${tower.dashboardUrl}/settings/personal` };
10
+ const connections = asRecord(response.body)["connections"];
11
+ if (!connections || connections.length !== 2)
12
+ throw new Error("connection_state_incomplete");
13
+ const missing = connections.filter(row => !row["connected"]);
14
+ const links = missing.map(row => new URL(String(row["connectPath"]), tower.dashboardUrl).toString());
15
+ console.error("[doctor slack] checked", JSON.stringify({ missing: missing.length, reason: missing.length ? "slack_connect_required" : "ok" }));
16
+ return { id: "slack-connected", status: missing.length ? "needs_fix" : "ok", code: missing.length ? "slack_connect_required" : "slack_connected",
17
+ message: missing.length ? `${message}: connect ${missing.map(row => row["workspace"]).join(", ")} at ${links.join(" or ")}` : message,
18
+ ...(missing.length ? { nextAction: links.join(" ") } : {}),
19
+ };
20
+ }
21
+ catch {
22
+ console.error("[doctor slack] refused", JSON.stringify({ reason: "connection_read_failed" }));
23
+ return { id: "slack-connected", status: "skipped", code: "connection_read_failed", message: `${message}: unavailable until this machine is signed in.`, nextAction: `${context.command.dashboardUrl ?? DEFAULT_DASHBOARD_URL}/settings/personal` };
24
+ }
25
+ }
@@ -1,3 +1,4 @@
1
+ import { checkSlackConnected } from "./doctor-slack.js";
1
2
  import { checkUsageTelemetry, fixUsageTelemetry } from "./doctor-usage-telemetry.js";
2
3
  import { LOCAL_COLLECTOR_VERSION } from "../local-state.js";
3
4
  import { isSemverBelow } from "../scheduled-self-update.js";
@@ -153,6 +154,7 @@ export function doctorInvariantTable() {
153
154
  function doctorInvariants() {
154
155
  return [
155
156
  { id: "cli-latest", check: checkCliLatest, fix: fixCliLatest },
157
+ { id: "slack-connected", check: context => context.deps.checkSlackConnected ? context.deps.checkSlackConnected(context) : Promise.resolve({ id: "slack-connected", status: "skipped", code: "not_probed", message: "Slack connection check not provided." }) },
156
158
  {
157
159
  id: "authed",
158
160
  check: (context) => context.deps.readAuth(context),
@@ -248,6 +250,7 @@ function defaultDoctorDeps(hooks) {
248
250
  readRootsCoverWork,
249
251
  resolveAndSaveRoots: (context) => hooks.resolveAndSaveRoots(context.command, context.io),
250
252
  checkSingleInstall: checkSingleInstallState,
253
+ checkSlackConnected,
251
254
  checkAutostart: checkAutostartState,
252
255
  fixAutostart: fixAutostartState,
253
256
  checkUsageTelemetry,
@@ -193,11 +193,11 @@ export function parseSlackArgs(args) {
193
193
  ],
194
194
  });
195
195
  if (values.positionals.length > 1) {
196
- throw new Error("slack accepts one action: coverage or read.");
196
+ throw new Error("slack accepts one action: coverage, read, connect or disconnect.");
197
197
  }
198
198
  const rawAction = values.positionals[0] ?? "coverage";
199
- if (rawAction !== "coverage" && rawAction !== "read") {
200
- throw new Error("slack action must be coverage or read.");
199
+ if (!["coverage", "read", "connect", "disconnect"].includes(rawAction)) {
200
+ throw new Error("slack action must be coverage, read, connect or disconnect.");
201
201
  }
202
202
  const base = {
203
203
  homeDir: optionalNonEmpty(values.flags.get("--home")),
@@ -210,6 +210,14 @@ export function parseSlackArgs(args) {
210
210
  if (values.booleans.has("--list") && rawAction !== "coverage") {
211
211
  throw new Error("--list belongs to `cockpit slack coverage`.");
212
212
  }
213
+ if (rawAction === "connect" || rawAction === "disconnect") {
214
+ const workspace = optionalNonEmpty(values.flags.get("--workspace"));
215
+ if (workspace && !SLACK_WORKSPACE_KEYS.includes(workspace))
216
+ throw new Error("Unknown Slack workspace.");
217
+ if (rawAction === "disconnect" && !workspace)
218
+ throw new Error("slack disconnect needs --workspace bli|blue_pearl.");
219
+ return { kind: "slack", action: rawAction, workspace, ...base };
220
+ }
213
221
  if (rawAction === "coverage") {
214
222
  // `--workspace` here is a Slack workspace key, NOT the collection-root
215
223
  // `--workspace <path>` every setup command takes. Named at the point of use
@@ -35,6 +35,7 @@ import { parseMemoryRulesArgs } from "./memory-rules.js";
35
35
  import { optionalNonEmpty, optionalUrl, parseNamedArgs, workRootFlagValue, } from "./local-arg-values.js";
36
36
  export const MEMORY_STORE_ACTIONS = new Set([
37
37
  "rules",
38
+ "knowledge",
38
39
  "search",
39
40
  "save",
40
41
  "update",
@@ -88,8 +89,8 @@ const VALUE_FLAGS = [
88
89
  "--source",
89
90
  ];
90
91
  export function parseMemoryStoreArgs(action, args) {
91
- if (action === "rules")
92
- return parseMemoryRulesArgs(args);
92
+ if (action === "rules" || action === "knowledge")
93
+ return parseMemoryRulesArgs(args, action);
93
94
  const values = parseNamedArgs(args, { allowedFlags: ALLOWED_FLAGS, valueFlags: VALUE_FLAGS });
94
95
  const rest = values.positionals.slice(1);
95
96
  const containers = values.flagValues.get("--container") ?? [];
@@ -262,7 +262,7 @@ export const TOWER_COMMAND_HELP = [
262
262
  [
263
263
  "slack",
264
264
  [
265
- "Usage: cockpit slack [coverage [--workspace bli|blue_pearl] [--stale-only] [--list] | read [filters] [--format compact|full]] [--json]",
265
+ "Usage: cockpit slack [connect [--workspace bli|blue_pearl] | disconnect --workspace bli|blue_pearl | coverage [--workspace bli|blue_pearl] [--stale-only] [--list] | read [filters] [--format compact|full]] [--json]",
266
266
  "",
267
267
  " cockpit slack coverage",
268
268
  " What Cockpit's Slack bot can see: how many channels it is in, what each",
@@ -111,7 +111,7 @@ export function localCommandHelp(command) {
111
111
  " cockpit models [list|show <provider:model>|compare <provider:model> <provider:model> [...]] [--highlight] [--dashboard-url <url>] [--json]",
112
112
  " cockpit scout [board|start|dismiss|undo <experiment-id>] [--days <n>] [--dashboard-url <url>] [--json]",
113
113
  " cockpit ops [status [--job <id>] [--skips] [--memory] [--models] | recompile --person <email|name|id> [--dry-run]] [--dashboard-url <url>] [--json]",
114
- " cockpit slack [coverage [--workspace bli|blue_pearl] [--stale-only] [--list] | read [--person <p>] [--channel <c>] [--query <text> [--word]] [--since <YYYY-MM-DD>] [--until <YYYY-MM-DD>] [--limit <n>] [--format compact|full]] [--json]",
114
+ " cockpit slack [connect [--workspace bli|blue_pearl] | disconnect --workspace bli|blue_pearl | coverage [--workspace bli|blue_pearl] [--stale-only] [--list] | read [--person <p>] [--channel <c>] [--query <text> [--word]] [--since <YYYY-MM-DD>] [--until <YYYY-MM-DD>] [--limit <n>] [--format compact|full]] [--json]",
115
115
  " cockpit settings [personal [--chat-model <key>] [--brief-model <key>] | switches [set <key> <value>] | models [set --chat <key>] [--memory <id>] | cli-floor [<version>] | env list|set --project <p> --file <f> --content-stdin|delete --id <uuid> [--yes]] [--json]",
116
116
  " cockpit team [members | invite <email> --role <role> [--team-id <uuid>] | role <userId> --role <role> [--yes] | device [list] | device revoke <id|name> --reason <label> [--note \"<text>\"] [--yes]] [--json]",
117
117
  " cockpit workbook [<project> [<doc>]] [--section <id>] [--markdown] [--width <n>] [--dashboard-url <url>] [--json]",
@@ -1,15 +1,15 @@
1
1
  import { askAgentDoor, emitAgentDoor, failAgentDoor } from "./agent-door.js";
2
2
  import { writeLine } from "./cli-io.js";
3
3
  import { optionalNonEmpty, optionalUrl, parseNamedArgs } from "./local-arg-values.js";
4
- export function parseMemoryRulesArgs(args) {
5
- const values = parseNamedArgs(args, { allowedFlags: ["--person", "--project", "--limit", "--json", "--home", "--dashboard-url"],
4
+ export function parseMemoryRulesArgs(args, action = "rules") {
5
+ const values = parseNamedArgs(args, { allowedFlags: ["--person", "--project", "--limit", "--json", "--by-project", "--home", "--dashboard-url"],
6
6
  valueFlags: ["--person", "--project", "--limit", "--home", "--dashboard-url"] });
7
7
  if (values.positionals.slice(1).length)
8
- throw new Error("memory rules takes no positionals; use --person and --project.");
8
+ throw new Error(`memory ${action} takes no positionals; use --person and --project.`);
9
9
  const raw = values.flags.get("--limit");
10
10
  if (raw !== undefined && (!/^\d+$/.test(raw) || Number(raw) < 1 || Number(raw) > 1000))
11
11
  throw new Error("--limit must be between 1 and 1000.");
12
- return { kind: "memory-store", action: "rules", containerTags: [],
12
+ return { kind: "memory-store", action, byProject: values.booleans.has("--by-project"), containerTags: [],
13
13
  person: optionalNonEmpty(values.flags.get("--person")), project: optionalNonEmpty(values.flags.get("--project")),
14
14
  limit: raw === undefined ? undefined : Number(raw), json: values.booleans.has("--json"),
15
15
  homeDir: optionalNonEmpty(values.flags.get("--home")), dashboardUrl: optionalUrl(values.flags.get("--dashboard-url")) };
@@ -22,21 +22,32 @@ export async function runMemoryRules(command, door) {
22
22
  params.set("project", command.project);
23
23
  if (command.limit !== undefined)
24
24
  params.set("limit", String(command.limit));
25
- const answer = await askAgentDoor(door, { path: `/api/memory/rules${params.size ? `?${params}` : ""}`,
26
- method: "GET", label: "memory rules", timeoutMs: 60_000 });
25
+ const answer = await askAgentDoor(door, { path: `/api/memory/${command.action}${params.size ? `?${params}` : ""}`,
26
+ method: "GET", label: `memory ${command.action}`, timeoutMs: 60_000 });
27
27
  if (!answer.ok)
28
28
  return failAgentDoor(door, "[memory cli]", answer.reason, answer.detail);
29
29
  const body = answer.body;
30
- writeLine(door.io.stderr, `[memory cli] rules ${JSON.stringify({ reason: body.capped ? "capped" : "exhausted", returned: body.returned, groups: body.groups.length })}`);
30
+ writeLine(door.io.stderr, `[memory cli] ${command.action} ${JSON.stringify({ reason: body.capped ? "capped" : "exhausted", returned: body.returned, groups: body.projectGroups?.length ?? body.groups?.length ?? 0 })}`);
31
31
  if (door.json)
32
32
  return emitAgentDoor(door, answer.body);
33
- for (const group of body.groups) {
34
- writeLine(door.io.stdout, group.owner);
35
- for (const rule of group.rules)
36
- writeLine(door.io.stdout, ` [${rule.created_at.slice(0, 10)}] ${rule.project ? `[${rule.project}] ` : ""}${rule.text}${group.owner === "unattributed" ? ` (space: ${rule.container})` : ""}`);
33
+ if (command.byProject || command.action === "knowledge") {
34
+ for (const group of body.projectGroups ?? []) {
35
+ writeLine(door.io.stdout, `${group.project} (${group.count}) : ${group.headline.split("\n")[0]}`);
36
+ for (const topic of group.topics) {
37
+ writeLine(door.io.stdout, ` ${topic.title}`);
38
+ for (const memory of topic.memories)
39
+ writeLine(door.io.stdout, ` ${memory.text}${memory.repetition > 1 ? ` (said ${memory.repetition} times)` : ""}${memory.importance !== null ? ` [importance ${memory.importance}/100]` : ""}`);
40
+ }
41
+ }
37
42
  }
43
+ else
44
+ for (const group of body.groups) {
45
+ writeLine(door.io.stdout, group.owner);
46
+ for (const rule of group.rules)
47
+ writeLine(door.io.stdout, ` [${rule.created_at.slice(0, 10)}] ${rule.project ? `[${rule.project}] ` : ""}${rule.text}${group.owner === "unattributed" ? ` (space: ${rule.container})` : ""}`);
48
+ }
38
49
  if (!body.returned)
39
- writeLine(door.io.stdout, "No ground rules matched.");
50
+ writeLine(door.io.stdout, `No ${command.action === "knowledge" ? "project knowledge" : "ground rules"} matched.`);
40
51
  if (body.capped)
41
52
  writeLine(door.io.stdout, "Result capped. Narrow by person or project, or increase --limit (max 1000).");
42
53
  return 0;
@@ -65,6 +65,7 @@ const FORGET_MAX_CHARS = 10_000;
65
65
  export async function runMemoryStore(command, io) {
66
66
  const door = await openAgentDoor("memory", command, io);
67
67
  switch (command.action) {
68
+ case "knowledge":
68
69
  case "rules":
69
70
  return runMemoryRules(command, door);
70
71
  case "search":
@@ -26,7 +26,7 @@ export function runMemoryVerbList(io, json) {
26
26
  ok: true,
27
27
  noun: "memory",
28
28
  machine_verbs: ["install", "status"],
29
- store_verbs: ["search", "save", "update", "forget", "private", "public", "profile", "consolidation", "requeue"],
29
+ store_verbs: ["rules", "knowledge", "search", "save", "update", "forget", "private", "public", "profile", "consolidation", "requeue"],
30
30
  log_verbs: ["log"],
31
31
  }));
32
32
  return 0;
@@ -44,7 +44,8 @@ export function runMemoryVerbList(io, json) {
44
44
  " you can forget or update only your own memories (a super admin, any)",
45
45
  " cockpit memory private <id> [--json] only you and super admins can read this memory",
46
46
  " cockpit memory public <id> [--json] every member can read it again (how every memory starts)",
47
- " cockpit memory rules [--person <name>] [--project <name>] [--limit <n>] [--json]",
47
+ " cockpit memory rules [--person <name>] [--project <name-or-slug>] [--by-project] [--limit <n>] [--json]",
48
+ " cockpit memory knowledge [--person <name>] [--project <name-or-slug>] [--by-project] [--limit <n>] [--json]",
48
49
  " cockpit memory profile [--container <tag>] [--json]",
49
50
  " the compiled one-page briefing every session opens with; a read, never a compile",
50
51
  " cockpit memory consolidation [--container <tag>] [--json]",
@@ -21,7 +21,7 @@ export async function runCockpitCli(argv, io) {
21
21
  }
22
22
 
23
23
  if (command === "--version" || command === "-V" || command === "version") {
24
- writeLine(io?.stdout ?? process.stdout, "0.2.199");
24
+ writeLine(io?.stdout ?? process.stdout, "0.2.201");
25
25
  return 0;
26
26
  }
27
27
 
@@ -24,6 +24,24 @@ import { colorEnabled, dim, writeLine } from "./cli-io.js";
24
24
  import { asRecord, callTower, openTower, writeCommandFailure } from "./tower-command.js";
25
25
  export async function runSlack(command, io) {
26
26
  const tower = await openTower("slack", command, io);
27
+ if (command.action === "connect" || command.action === "disconnect") {
28
+ const result = await callTower(tower, { path: `/api/slack/connections${command.action === "disconnect" ? `?workspace=${command.workspace}` : ""}`,
29
+ method: command.action === "disconnect" ? "DELETE" : "GET", label: "slack-connection" });
30
+ if (!result.ok)
31
+ return writeCommandFailure(io, command.json, result);
32
+ writeLine(io.stderr, `[slack cli] connection answered ${JSON.stringify({ action: command.action, workspace: command.workspace ?? "all", reason: "ok" })}`);
33
+ const body = asRecord(result.body);
34
+ if (command.json)
35
+ writeLine(io.stdout, JSON.stringify(body));
36
+ else if (command.action === "disconnect")
37
+ writeLine(io.stdout, `Disconnected Slack for ${command.workspace}.`);
38
+ else
39
+ for (const connection of (body["connections"] ?? [])) {
40
+ if (!command.workspace || command.workspace === connection["workspace"])
41
+ writeLine(io.stdout, `${connection["workspace"]}: ${connection["connected"] ? "connected" : "Connect Slack"} ${new URL(String(connection["connectPath"]), tower.dashboardUrl).toString()}`);
42
+ }
43
+ return 0;
44
+ }
27
45
  return command.action === "coverage"
28
46
  ? runCoverageAction(command, io, tower)
29
47
  : runReadAction(command, io, tower);
@@ -86,6 +104,12 @@ function coverageLines(coverage, workspaces, staleOnly, list, paint) {
86
104
  for (const workspace of workspaces) {
87
105
  writeWorkspaceCoverageLines(lines, workspace, staleAfter, staleOnly, list, paint);
88
106
  }
107
+ for (const entry of (coverage["privateCoverage"] ?? [])) {
108
+ lines.push(`${entry["workspace"]}: ${entry["botMemberCount"]}/${entry["privateChannelsKnown"]} known private channels have the bot; ${entry["peopleConnected"]} people connected; as of ${entry["asOf"] ?? "no scan yet"}`);
109
+ lines.push(` Not connected: ${(entry["peopleNotConnected"] ?? []).join(", ") || "none"}`);
110
+ }
111
+ if (coverage["privateCoverageError"])
112
+ lines.push(`Private coverage unavailable: ${coverage["privateCoverageError"]}`);
89
113
  writeCoverageFooter(lines, coverage, paint);
90
114
  return lines;
91
115
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bli-cockpit/cli",
3
- "version": "0.2.199",
3
+ "version": "0.2.201",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "bin": {
@@ -28,7 +28,7 @@
28
28
  },
29
29
  "dependencies": {
30
30
  "@bli-cockpit/memory-mcp": "0.1.60",
31
- "@bli-cockpit/mcp": "0.1.111",
31
+ "@bli-cockpit/mcp": "0.1.113",
32
32
  "@bli-cockpit/telemetry-core": "0.1.69"
33
33
  },
34
34
  "repository": {