@bli-cockpit/cli 0.2.222 → 0.2.236

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 (75) hide show
  1. package/dist/agent-plugins-converge.js +182 -0
  2. package/dist/agent-plugins.js +116 -0
  3. package/dist/autostart-darwin-plist.js +2 -2
  4. package/dist/autostart-windows-scripts.js +2 -2
  5. package/dist/commands/agent-door.js +35 -0
  6. package/dist/commands/approvals.js +3 -1
  7. package/dist/commands/brain.js +23 -0
  8. package/dist/commands/ci.js +23 -0
  9. package/dist/commands/docs-export.js +62 -0
  10. package/dist/commands/docs.js +3 -0
  11. package/dist/commands/doctor-access.js +2 -2
  12. package/dist/commands/doctor-agent-plugins.js +82 -0
  13. package/dist/commands/doctor-pipeline.js +2 -2
  14. package/dist/commands/doctor-report.js +1 -0
  15. package/dist/commands/doctor-update.js +2 -2
  16. package/dist/commands/doctor.js +6 -0
  17. package/dist/commands/drills-render.js +63 -0
  18. package/dist/commands/drills.js +152 -0
  19. package/dist/commands/hits.js +83 -0
  20. package/dist/commands/intentions.js +49 -0
  21. package/dist/commands/local-args-docs-export.js +23 -0
  22. package/dist/commands/local-args-tower-admin.js +8 -0
  23. package/dist/commands/local-args-tower-brain-ladder.js +90 -0
  24. package/dist/commands/local-args-tower-brain.js +11 -1
  25. package/dist/commands/local-args-tower-docs-msg.js +9 -3
  26. package/dist/commands/local-args-tower-drills.js +87 -0
  27. package/dist/commands/local-args-tower-hits.js +93 -0
  28. package/dist/commands/local-args-tower-intentions.js +70 -0
  29. package/dist/commands/local-args-tower-logs-search.js +51 -0
  30. package/dist/commands/local-args-tower-logs.js +3 -0
  31. package/dist/commands/local-args-tower-memory.js +67 -1
  32. package/dist/commands/local-args-tower-pages.js +3 -1
  33. package/dist/commands/local-args-tower-people.js +25 -0
  34. package/dist/commands/local-args-tower-usage.js +4 -4
  35. package/dist/commands/local-args-tower-who.js +36 -0
  36. package/dist/commands/local-args-tower.js +8 -1
  37. package/dist/commands/local-args.js +15 -1
  38. package/dist/commands/local-auth.js +5 -2
  39. package/dist/commands/local-command-shapes-auth.js +1 -0
  40. package/dist/commands/local-command-shapes-drills.js +7 -0
  41. package/dist/commands/local-command-shapes-hits.js +5 -0
  42. package/dist/commands/local-help-commands-tower-ops.js +123 -1
  43. package/dist/commands/local-help-commands-tower.js +19 -7
  44. package/dist/commands/local-help-commands.js +3 -0
  45. package/dist/commands/local-help.js +24 -4
  46. package/dist/commands/local-run-ops.js +11 -0
  47. package/dist/commands/local.js +16 -8
  48. package/dist/commands/logs-search.js +45 -0
  49. package/dist/commands/logs.js +3 -0
  50. package/dist/commands/memory-exam.js +47 -0
  51. package/dist/commands/memory-repeats.js +44 -0
  52. package/dist/commands/memory-search-print.js +95 -0
  53. package/dist/commands/memory-source.js +39 -0
  54. package/dist/commands/memory-store.js +18 -30
  55. package/dist/commands/memory-verb-list.js +7 -2
  56. package/dist/commands/notes-reads.js +20 -0
  57. package/dist/commands/notes-writes.js +24 -0
  58. package/dist/commands/notes.js +4 -2
  59. package/dist/commands/people.js +29 -0
  60. package/dist/commands/public-root.js +1 -1
  61. package/dist/commands/spend.js +79 -0
  62. package/dist/commands/sync-heal-agent-plugins.js +46 -0
  63. package/dist/commands/sync-heal.js +6 -0
  64. package/dist/commands/team.js +35 -0
  65. package/dist/commands/usage.js +9 -0
  66. package/dist/commands/who.js +36 -0
  67. package/dist/evidence-upload-direct.js +44 -0
  68. package/dist/evidence-upload-object.js +18 -4
  69. package/dist/evidence-upload-plan.js +1 -0
  70. package/dist/evidence-upload-transport.js +2 -0
  71. package/dist/local-state-config.js +21 -1
  72. package/dist/local-state.js +1 -1
  73. package/dist/process-runner.js +7 -3
  74. package/dist/state-dir-audit.js +1 -0
  75. package/package.json +4 -4
@@ -0,0 +1,182 @@
1
+ /**
2
+ * Runs the host commands that put the company plugin on a machine (BLI-18200),
3
+ * and remembers what happened so the 15-minute tick never hammers a failure.
4
+ *
5
+ * Claude Code claude plugin marketplace add BLI-INC/bli-agents-marketplace
6
+ * claude plugin install engineering-standards@bli-agents
7
+ * daily: claude plugin marketplace update bli-agents
8
+ * claude plugin update engineering-standards@bli-agents
9
+ * Codex codex plugin marketplace add BLI-INC/bli-agents-marketplace
10
+ * codex plugin add engineering-standards@bli-agents
11
+ * daily: codex plugin marketplace upgrade bli-agents
12
+ *
13
+ * The marketplace follows the repository default branch (no ref is passed).
14
+ * These are the host programs the machine already runs (agent-setup.ts spawns
15
+ * them for `--version` the same way), never a person's own program, so this does
16
+ * not belong in `commands/editor.ts`.
17
+ *
18
+ * Never throws and never blocks: a missing program, a private-repo sign-in gap,
19
+ * a dead network or a program too old for `plugin` is a named reason in the
20
+ * returned outcome, the log and `agent-plugins.json`. The tick retries a failed
21
+ * host only after six hours; a person running `cockpit doctor` always retries.
22
+ * Logs carry reason labels, exit codes and timings only, never command output.
23
+ */
24
+ import fs from "node:fs/promises";
25
+ import path from "node:path";
26
+ import { AGENT_PLUGIN_ID, AGENT_PLUGIN_MARKETPLACE, AGENT_PLUGIN_SOURCE, PLUGIN_HOSTS, classifyPluginFailure, inspectAgentPlugins, locateHostBinary, } from "./agent-plugins.js";
27
+ import { envWithNodeRuntimeOnPath } from "./scheduled-self-update.js";
28
+ export const AGENT_PLUGINS_STATE_FILE = "agent-plugins.json";
29
+ export const PLUGIN_RETRY_AFTER_FAILURE_MS = 6 * 60 * 60 * 1000;
30
+ export const PLUGIN_REFRESH_EVERY_MS = 24 * 60 * 60 * 1000;
31
+ /** A first marketplace clone is small; anything slower is a stuck prompt or a dead link. */
32
+ export const PLUGIN_COMMAND_TIMEOUT_MS = 120_000;
33
+ export async function readPluginState(stateDir) {
34
+ try {
35
+ const parsed = JSON.parse(await fs.readFile(path.join(stateDir, AGENT_PLUGINS_STATE_FILE), "utf8"));
36
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
37
+ }
38
+ catch {
39
+ return {};
40
+ }
41
+ }
42
+ async function writePluginState(stateDir, state) {
43
+ try {
44
+ await fs.mkdir(stateDir, { recursive: true });
45
+ await fs.writeFile(path.join(stateDir, AGENT_PLUGINS_STATE_FILE), JSON.stringify(state), { mode: 0o600 });
46
+ }
47
+ catch (error) {
48
+ console.error("[agent-plugins] state not saved", JSON.stringify({ reason: "state_write_failed", error_code: error.code ?? null }));
49
+ }
50
+ }
51
+ const msSince = (iso, now) => {
52
+ const at = iso ? Date.parse(iso) : Number.NaN;
53
+ return Number.isFinite(at) ? now.getTime() - at : Number.POSITIVE_INFINITY;
54
+ };
55
+ function spawnEnv(options) {
56
+ const env = envWithNodeRuntimeOnPath({ ...(options.env ?? process.env) }, options.nodeExecutable);
57
+ return {
58
+ ...env,
59
+ // git must fail, not ask: a prompt on a scheduled tick hangs until the timeout.
60
+ GIT_TERMINAL_PROMPT: "0",
61
+ GCM_INTERACTIVE: "never",
62
+ ...(options.unattended && !env["GIT_SSH_COMMAND"] ? { GIT_SSH_COMMAND: "ssh -o BatchMode=yes" } : {}),
63
+ };
64
+ }
65
+ function stepsFor(host, reading, refresh) {
66
+ const steps = [];
67
+ if (!reading.marketplace)
68
+ steps.push({ name: "marketplace_add", args: ["plugin", "marketplace", "add", AGENT_PLUGIN_SOURCE] });
69
+ if (!reading.installed)
70
+ steps.push({ name: "plugin_install", args: host === "claude" ? ["plugin", "install", AGENT_PLUGIN_ID] : ["plugin", "add", AGENT_PLUGIN_ID] });
71
+ if (reading.installed && refresh) {
72
+ steps.push(host === "claude"
73
+ ? { name: "marketplace_update", args: ["plugin", "marketplace", "update", AGENT_PLUGIN_MARKETPLACE] }
74
+ : { name: "marketplace_update", args: ["plugin", "marketplace", "upgrade", AGENT_PLUGIN_MARKETPLACE] });
75
+ if (host === "claude")
76
+ steps.push({ name: "plugin_update", args: ["plugin", "update", AGENT_PLUGIN_ID] });
77
+ }
78
+ return steps;
79
+ }
80
+ export async function convergeAgentPlugins(options) {
81
+ const now = options.now ?? new Date();
82
+ const state = await readPluginState(options.stateDir);
83
+ const before = JSON.stringify(state);
84
+ const outcomes = [];
85
+ let readings;
86
+ try {
87
+ readings = await inspectAgentPlugins(options.homeDir, options);
88
+ }
89
+ catch (error) {
90
+ console.error("[agent-plugins] host state unreadable", JSON.stringify({ reason: "inspect_threw", error_name: error instanceof Error ? error.name : typeof error }));
91
+ return PLUGIN_HOSTS.map((host) => ({ host, status: "failed", reason: "command_failed", quiet: false }));
92
+ }
93
+ for (const host of PLUGIN_HOSTS) {
94
+ const reading = readings.find((entry) => entry.host === host);
95
+ const entry = { ...state[host] };
96
+ const outcome = await convergeHost(host, reading, entry, options, now).catch((error) => {
97
+ console.error("[agent-plugins] host step threw", JSON.stringify({ reason: "unexpected_error", host, error_name: error instanceof Error ? error.name : typeof error }));
98
+ return { host, status: "failed", reason: "command_failed", quiet: false };
99
+ });
100
+ if (Object.keys(entry).length > 0)
101
+ state[host] = entry;
102
+ else
103
+ delete state[host];
104
+ outcomes.push(outcome);
105
+ }
106
+ if (JSON.stringify(state) !== before)
107
+ await writePluginState(options.stateDir, state);
108
+ return outcomes;
109
+ }
110
+ async function convergeHost(host, reading, entry, options, now) {
111
+ if (!reading.present)
112
+ return { host, status: "skipped", reason: "host_not_present", quiet: true };
113
+ if (reading.installed) {
114
+ // Installed by us or by hand: a failure remembered from before is spent.
115
+ delete entry.last_reason;
116
+ const due = options.refresh && msSince(entry.last_refresh_at, now) >= PLUGIN_REFRESH_EVERY_MS;
117
+ if (!due)
118
+ return { host, status: "already_installed", reason: "installed", quiet: true };
119
+ return refreshHost(host, reading, entry, options, now);
120
+ }
121
+ if (options.unattended && entry.last_reason !== undefined && msSince(entry.last_attempt_at, now) < PLUGIN_RETRY_AFTER_FAILURE_MS) {
122
+ console.error("[agent-plugins] retry waits; the last attempt failed under six hours ago", JSON.stringify({ reason: "retry_throttled", host, last_reason: entry.last_reason }));
123
+ return { host, status: entry.last_reason === "binary_missing" ? "skipped" : "failed", reason: entry.last_reason, quiet: true };
124
+ }
125
+ return installHost(host, reading, entry, options, now);
126
+ }
127
+ async function runSteps(host, binary, steps, options) {
128
+ const env = spawnEnv(options);
129
+ for (const step of steps) {
130
+ const started = Date.now();
131
+ // The runner contract never rejects; a runner that does is still just a failed command.
132
+ const result = await options.exec(binary, step.args, { env, timeoutMs: options.timeoutMs ?? PLUGIN_COMMAND_TIMEOUT_MS })
133
+ .catch(() => ({ code: 1, stdout: "", stderr: "" }));
134
+ const elapsed_ms = Date.now() - started;
135
+ if (result.code !== 0) {
136
+ const reason = classifyPluginFailure(result);
137
+ console.error("[agent-plugins] host command failed", JSON.stringify({ reason, host, step: step.name, exit_code: result.code, elapsed_ms }));
138
+ return reason;
139
+ }
140
+ console.error("[agent-plugins] host command ran", JSON.stringify({ reason: "step_ok", host, step: step.name, exit_code: 0, elapsed_ms }));
141
+ }
142
+ return null;
143
+ }
144
+ async function installHost(host, reading, entry, options, now) {
145
+ entry.last_attempt_at = now.toISOString();
146
+ const binary = await locateHostBinary(host, options.homeDir, options);
147
+ if (!binary) {
148
+ entry.last_reason = "binary_missing";
149
+ console.error("[agent-plugins] host program not found", JSON.stringify({ reason: "binary_missing", host }));
150
+ return { host, status: "skipped", reason: "binary_missing", quiet: false };
151
+ }
152
+ const failure = await runSteps(host, binary, stepsFor(host, reading, false), options);
153
+ if (failure) {
154
+ entry.last_reason = failure;
155
+ return { host, status: "failed", reason: failure, quiet: false };
156
+ }
157
+ // Verify against what the host recorded, not against the exit code we got.
158
+ const after = (await inspectAgentPlugins(options.homeDir, options)).find((candidate) => candidate.host === host);
159
+ if (!after?.installed) {
160
+ entry.last_reason = "install_not_recorded";
161
+ console.error("[agent-plugins] install ran but the host did not record it", JSON.stringify({ reason: "install_not_recorded", host }));
162
+ return { host, status: "failed", reason: "install_not_recorded", quiet: false };
163
+ }
164
+ delete entry.last_reason;
165
+ entry.last_refresh_at = now.toISOString();
166
+ console.error("[agent-plugins] plugin installed", JSON.stringify({ reason: "installed", host }));
167
+ return { host, status: "installed", reason: "installed", quiet: false };
168
+ }
169
+ /** The daily refresh. A failure here never un-installs anything; the next try is tomorrow's. */
170
+ async function refreshHost(host, reading, entry, options, now) {
171
+ entry.last_refresh_at = now.toISOString();
172
+ const binary = await locateHostBinary(host, options.homeDir, options);
173
+ if (!binary) {
174
+ console.error("[agent-plugins] host program not found for the daily refresh", JSON.stringify({ reason: "binary_missing", host }));
175
+ return { host, status: "already_installed", reason: "refresh_skipped_binary_missing", quiet: true };
176
+ }
177
+ const failure = await runSteps(host, binary, stepsFor(host, reading, true), options);
178
+ if (failure)
179
+ return { host, status: "already_installed", reason: `refresh_failed_${failure}`, quiet: false };
180
+ console.error("[agent-plugins] plugin refreshed", JSON.stringify({ reason: "refreshed", host }));
181
+ return { host, status: "refreshed", reason: "refreshed", quiet: false };
182
+ }
@@ -0,0 +1,116 @@
1
+ /**
2
+ * The company `engineering-standards` plugin on an agent machine (BLI-18200).
3
+ *
4
+ * Every intern machine should carry the plugin from the private marketplace
5
+ * `BLI-INC/bli-agents-marketplace` in BOTH Claude Code and Codex. This module is
6
+ * the reading half: what the two hosts' own state files say, where each host's
7
+ * program lives, and how a failed host command is named. It spawns nothing;
8
+ * `agent-plugins-converge.ts` runs the commands, `commands/doctor-agent-plugins.ts`
9
+ * is the doctor row, `commands/sync-heal-agent-plugins.ts` is the 15-minute tick.
10
+ *
11
+ * Reads only the hosts' own files, rooted at the person's home, so the same code
12
+ * answers on macOS and native Windows:
13
+ * Claude Code ~/.claude/plugins/known_marketplaces.json (marketplace added)
14
+ * ~/.claude/plugins/installed_plugins.json (plugin installed)
15
+ * Codex ~/.codex/config.toml [marketplaces.bli-agents] / [plugins."<id>"]
16
+ *
17
+ * A host counts as PRESENT when its config directory exists (`~/.claude`,
18
+ * `~/.codex`). That is a file read, so a doctor check never spawns a program, and
19
+ * a machine whose agent was never started has nothing to install into yet.
20
+ */
21
+ import fs from "node:fs/promises";
22
+ import path from "node:path";
23
+ export const AGENT_PLUGIN_SOURCE = "BLI-INC/bli-agents-marketplace";
24
+ export const AGENT_PLUGIN_MARKETPLACE = "bli-agents";
25
+ export const AGENT_PLUGIN_NAME = "engineering-standards";
26
+ export const AGENT_PLUGIN_ID = `${AGENT_PLUGIN_NAME}@${AGENT_PLUGIN_MARKETPLACE}`;
27
+ export const PLUGIN_HOSTS = ["claude", "codex"];
28
+ export const realPluginFiles = {
29
+ read: (file) => fs.readFile(file, "utf8").catch(() => null),
30
+ exists: (file) => fs.access(file).then(() => true, () => false),
31
+ };
32
+ const pathApi = (platform) => (platform === "win32" ? path.win32 : path.posix);
33
+ function parseObject(text) {
34
+ if (text === null)
35
+ return null;
36
+ try {
37
+ const value = JSON.parse(text);
38
+ return value && typeof value === "object" && !Array.isArray(value) ? value : null;
39
+ }
40
+ catch {
41
+ return null;
42
+ }
43
+ }
44
+ export async function inspectAgentPlugins(homeDir, runtime = {}) {
45
+ const platform = runtime.platform ?? process.platform;
46
+ const files = runtime.files ?? realPluginFiles;
47
+ const join = (...parts) => pathApi(platform).join(homeDir, ...parts);
48
+ const claudePresent = await files.exists(join(".claude"));
49
+ const marketplaces = parseObject(await files.read(join(".claude", "plugins", "known_marketplaces.json")));
50
+ const installedFile = parseObject(await files.read(join(".claude", "plugins", "installed_plugins.json")));
51
+ const installations = installedFile?.["plugins"]?.[AGENT_PLUGIN_ID];
52
+ const codexPresent = await files.exists(join(".codex"));
53
+ const toml = (await files.read(join(".codex", "config.toml"))) ?? "";
54
+ const escapeRegExp = (value) => value.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
55
+ const marketplace = escapeRegExp(AGENT_PLUGIN_MARKETPLACE);
56
+ return [
57
+ {
58
+ host: "claude",
59
+ present: claudePresent,
60
+ marketplace: Boolean(marketplaces && Object.keys(marketplaces).includes(AGENT_PLUGIN_MARKETPLACE)),
61
+ // A plugin the person turned off by hand is still installed; it is not ours to switch back on.
62
+ installed: Array.isArray(installations) && installations.length > 0,
63
+ },
64
+ {
65
+ host: "codex",
66
+ present: codexPresent,
67
+ marketplace: new RegExp(`^\\s*\\[marketplaces\\.(?:"${marketplace}"|${marketplace})\\]\\s*$`, "mu").test(toml),
68
+ installed: new RegExp(`^\\s*\\[plugins\\."${escapeRegExp(AGENT_PLUGIN_ID)}"\\]\\s*$`, "mu").test(toml),
69
+ },
70
+ ];
71
+ }
72
+ /**
73
+ * Where a host's program lives, as an absolute path or null. A scheduled tick
74
+ * runs with a stripped PATH, so the person's PATH is tried first and then the
75
+ * places the host's installers use on each family. No exec: a file check.
76
+ */
77
+ export async function locateHostBinary(host, homeDir, runtime = {}) {
78
+ const platform = runtime.platform ?? process.platform;
79
+ const files = runtime.files ?? realPluginFiles;
80
+ const api = pathApi(platform);
81
+ const env = runtime.env ?? process.env;
82
+ const windows = platform === "win32";
83
+ const fromPath = (env["PATH"] ?? env["Path"] ?? "").split(windows ? ";" : ":").filter(Boolean);
84
+ const known = windows
85
+ ? [api.join(homeDir, ".local", "bin"), api.join(homeDir, "AppData", "Roaming", "npm"), ...(host === "claude" ? [api.join(homeDir, ".claude", "local")] : [])]
86
+ : [api.join(homeDir, ".local", "bin"), api.join(homeDir, ".npm-global", "bin"), api.join(homeDir, ".bun", "bin"), "/opt/homebrew/bin", "/usr/local/bin", ...(host === "claude" ? [api.join(homeDir, ".claude", "local")] : [])];
87
+ const nodeDir = api.dirname(runtime.nodeExecutable ?? process.execPath);
88
+ const names = windows ? [`${host}.exe`, `${host}.cmd`] : [host];
89
+ for (const directory of [...new Set([...fromPath, ...known, nodeDir])]) {
90
+ for (const name of names) {
91
+ const candidate = api.join(directory, name);
92
+ if (await files.exists(candidate))
93
+ return candidate;
94
+ }
95
+ }
96
+ return null;
97
+ }
98
+ /**
99
+ * Names a failed host command from its exit code and output. The output is read
100
+ * for the label and then dropped: only the label ever leaves this function, so a
101
+ * git error that echoed a URL with a credential in it cannot reach a log.
102
+ */
103
+ export function classifyPluginFailure(result) {
104
+ if (result.code === 124)
105
+ return "timed_out";
106
+ const text = `${result.stderr}\n${result.stdout}`.toLowerCase();
107
+ if (/unknown (sub)?command|unrecognized (sub)?command|unexpected argument|no such (sub)?command|invalid subcommand|unknown option/u.test(text))
108
+ return "cli_too_old";
109
+ if (/authentication failed|could not read (username|password)|permission denied \(publickey|repository not found|terminal prompts disabled|invalid username or password|bad credentials|not logged in|returned error: (401|403|404)|access denied|saml|single sign-on/u.test(text))
110
+ return "private_repo_auth";
111
+ if (/could not resolve host|network is unreachable|failed to connect|connection (timed out|refused|reset)|econn(reset|refused)|enotfound|etimedout|eai_again|temporary failure in name resolution|ssl|tls|early eof|remote end hung up/u.test(text))
112
+ return "network_unreachable";
113
+ if (/plugin .*(not found|does not exist)|not found in (any )?marketplace|no plugin named|marketplace .*not found/u.test(text))
114
+ return "plugin_not_in_marketplace";
115
+ return "command_failed";
116
+ }
@@ -1,7 +1,7 @@
1
1
  import { mkdir, readFile, writeFile } from "node:fs/promises";
2
2
  import os from "node:os";
3
3
  import path from "node:path";
4
- import { DEFAULT_DASHBOARD_URL, getCollectorRuntimePaths } from "./local-state.js";
4
+ import { isDefaultDashboardUrl, getCollectorRuntimePaths } from "./local-state.js";
5
5
  import { savedDiscoveryLimitArgs } from "./discovery-limits.js";
6
6
  import { DARWIN_LOGIN_SHELL } from "./autostart-node-path.js";
7
7
  import { AUTOSTART_LABEL, fileExists, plistPathFor, resolveAutostartSettings, schedulerNodeExecutable, } from "./autostart-contract.js";
@@ -211,7 +211,7 @@ function watchPathsBlock(watchPaths) {
211
211
  ];
212
212
  }
213
213
  function renderDarwinSyncCommand(options) {
214
- const dashboardArg = options.dashboardUrl === DEFAULT_DASHBOARD_URL
214
+ const dashboardArg = isDefaultDashboardUrl(options.dashboardUrl)
215
215
  ? ""
216
216
  : ` --dashboard-url ${shellQuote(options.dashboardUrl)}`;
217
217
  const discoveryArg = options.discoveryArgs.length > 0 ? ` ${options.discoveryArgs.join(" ")}` : "";
@@ -1,7 +1,7 @@
1
1
  import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
2
2
  import os from "node:os";
3
3
  import path from "node:path";
4
- import { DEFAULT_DASHBOARD_URL, getCollectorRuntimePaths } from "./local-state.js";
4
+ import { isDefaultDashboardUrl, getCollectorRuntimePaths } from "./local-state.js";
5
5
  import { savedDiscoveryLimitArgs } from "./discovery-limits.js";
6
6
  import { fileExists, resolveAutostartSettings, schedulerNodeExecutable, WINDOWS_AUTOSTART_TASK_NAME, } from "./autostart-contract.js";
7
7
  /**
@@ -271,7 +271,7 @@ function renderWindowsLauncherScript(options) {
271
271
  ].join("\r\n");
272
272
  }
273
273
  function renderWindowsSyncScript(options) {
274
- const dashboardArgs = options.dashboardUrl === DEFAULT_DASHBOARD_URL
274
+ const dashboardArgs = isDefaultDashboardUrl(options.dashboardUrl)
275
275
  ? ""
276
276
  : ` --dashboard-url ${powershellLiteral(options.dashboardUrl)}`;
277
277
  const discoveryArgs = options.discoveryArgs.length > 0 ? ` ${options.discoveryArgs.join(" ")}` : "";
@@ -57,6 +57,41 @@ export async function askAgentDoor(door, options) {
57
57
  }
58
58
  return { ok: true, httpStatus: result.response.status, body };
59
59
  }
60
+ /**
61
+ * A GET whose answer is a FILE (ticket 20694, `cockpit docs export`): the bytes
62
+ * come back untouched, never through a JSON or text decode. A refusal keeps the
63
+ * door's own `reason` and `message`, exactly as `askAgentDoor` does.
64
+ */
65
+ export async function fetchAgentDoorFile(door, options) {
66
+ const result = await towerRequest({
67
+ dashboardUrl: door.dashboardUrl,
68
+ path: options.path,
69
+ deviceToken: door.deviceToken,
70
+ fetch: door.io.fetch,
71
+ method: "GET",
72
+ label: options.label,
73
+ timeoutMs: options.timeoutMs,
74
+ ...(door.homeDir === undefined ? {} : { homeDir: door.homeDir }),
75
+ log: (line) => writeLine(door.io.stderr, line),
76
+ });
77
+ if (!result.ok) {
78
+ const failure = result;
79
+ return { ok: false, reason: failure.reason, detail: towerFailureDetail(failure.reason, failure.detail) };
80
+ }
81
+ if (!result.response.ok) {
82
+ const status = result.response.status;
83
+ const body = await readResponseJson(result.response);
84
+ return {
85
+ ok: false,
86
+ reason: doorReason(body) ?? `http_${status}`,
87
+ detail: doorMessage(body) ?? `Tower answered ${status} and said nothing about why.`,
88
+ };
89
+ }
90
+ const bytes = Buffer.from(await result.response.arrayBuffer());
91
+ const disposition = result.response.headers.get("content-disposition") ?? "";
92
+ const filename = /filename="([^"]+)"/.exec(disposition)?.[1] ?? null;
93
+ return { ok: true, bytes, filename, contentType: result.response.headers.get("content-type") ?? "" };
94
+ }
60
95
  /** The door's own `reason` label (`document_not_found_or_unreadable`, `needs_rls_client`, …), verbatim. */
61
96
  function doorReason(body) {
62
97
  if (!body || typeof body !== "object")
@@ -232,4 +232,6 @@ async function readJsonObject(door, verb) {
232
232
  return { ok: false, reason: "invalid_json_body", detail: "stdin must be one JSON object, like {\"text\":\"...\"}. Nothing was sent." };
233
233
  }
234
234
  return { ok: true, value: parsed };
235
- }
235
+ }
236
+ // BLI-19259: `cockpit drills` is re-exported here so `local.ts` (at the readability ceiling) imports both on one line.
237
+ export { runDrills } from "./drills.js";
@@ -2,6 +2,27 @@ import { askAgentDoor, emitAgentDoor, failAgentDoor, openAgentDoor } from "./age
2
2
  import { writeLine } from "./cli-io.js";
3
3
  const TAG = "[brain cli]";
4
4
  const READ_DEADLINE_MS = 55_000;
5
+ async function runLadder(door, ladder, json) {
6
+ const base = "/api/brain/ladder";
7
+ const request = (() => {
8
+ switch (ladder.verb) {
9
+ case "show": return { path: base, method: "GET", label: "brain ladder show" };
10
+ case "promote": return { path: `${base}/promote`, method: "POST", label: "brain ladder promote", body: { class: ladder.className, anyway: ladder.anyway, note: ladder.note, via: "cli" } };
11
+ case "demote": return { path: `${base}/demote`, method: "POST", label: "brain ladder demote", body: { class: ladder.className, note: ladder.note, via: "cli" } };
12
+ case "predict": return { path: `${base}/predictions`, method: "POST", label: "brain ladder predict", body: { question: ladder.text, context: ladder.context, asker: ladder.asker, source_kind: ladder.sourceKind, source_ref: ladder.sourceRef } };
13
+ case "answer": return { path: `${base}/answers`, method: "POST", label: "brain ladder answer", body: { answer: ladder.text, prediction_id: ladder.predictionId, source_kind: ladder.sourceKind, source_ref: ladder.sourceRef } };
14
+ }
15
+ })();
16
+ const answer = await askAgentDoor(door, { path: request.path, method: request.method, label: request.label, timeoutMs: READ_DEADLINE_MS, ...(request.body ? { body: request.body } : {}) });
17
+ if (!answer.ok)
18
+ return failAgentDoor(door, TAG, answer.reason, answer.detail);
19
+ const body = answer.body;
20
+ writeLine(door.io.stderr, `${TAG} ladder ${ladder.verb} answered ${JSON.stringify({ ok: body.ok !== false, reason: body.reason ?? "ok" })}`);
21
+ if (json)
22
+ return emitAgentDoor(door, body, body.ok === false ? 1 : 0);
23
+ writeLine(door.io.stdout, body.text ?? body.message ?? "");
24
+ return body.ok === false ? 1 : 0;
25
+ }
5
26
  export async function runBrain(command, io) {
6
27
  let door;
7
28
  try {
@@ -16,6 +37,8 @@ export async function runBrain(command, io) {
16
37
  writeLine(io.stderr, detail);
17
38
  return 1;
18
39
  }
40
+ if (command.ladder)
41
+ return runLadder(door, command.ladder, command.json);
19
42
  if (command.action === "retry") {
20
43
  const answer = await askAgentDoor(door, { path: "/api/brain/incidents/retry", method: "POST", label: "brain retry", timeoutMs: READ_DEADLINE_MS,
21
44
  body: { ids: command.ids, all_needs_human: command.allNeedsHuman, project: command.project, reason: command.reason } });
@@ -0,0 +1,23 @@
1
+ import { askAgentDoor, emitAgentDoor, failAgentDoor, openAgentDoor } from "./agent-door.js";
2
+ import { writeLine } from "./cli-io.js";
3
+ import { parseNamedArgs, optionalNonEmpty, optionalUrl } from "./local-arg-values.js";
4
+ export function parseCiArgs(args) {
5
+ const values = parseNamedArgs(args, { allowedFlags: ["--repo", "--json", "--home", "--dashboard-url"], valueFlags: ["--repo", "--home", "--dashboard-url"] });
6
+ const repo = values.flags.get("--repo");
7
+ if (values.positionals.length !== 1 || values.positionals[0] !== "report" || (repo !== undefined && !/^BLI-INC\/[A-Za-z0-9_.-]+$/.test(repo)))
8
+ throw new Error("Usage: cockpit ci report [--repo BLI-INC/name] [--json]");
9
+ return { kind: "ci", action: "report", repo, json: values.booleans.has("--json"), homeDir: optionalNonEmpty(values.flags.get("--home")), dashboardUrl: optionalUrl(values.flags.get("--dashboard-url")) };
10
+ }
11
+ export async function runCi(command, io) {
12
+ const door = await openAgentDoor("ci", command, io);
13
+ const query = command.repo ? `?${new URLSearchParams({ repo: command.repo })}` : "";
14
+ const answer = await askAgentDoor(door, { path: `/api/ops/ci/report${query}`, method: "GET", label: "ci report", timeoutMs: 30_000 });
15
+ if (!answer.ok)
16
+ return failAgentDoor(door, "[ci cli]", answer.reason, answer.detail);
17
+ if (command.json)
18
+ return emitAgentDoor(door, answer.body);
19
+ const body = answer.body;
20
+ for (const line of body.lines)
21
+ writeLine(io.stdout, line);
22
+ return 0;
23
+ }
@@ -0,0 +1,62 @@
1
+ /**
2
+ * `cockpit docs export <id|slug> [--format md|pdf|docx|txt] [--out <path>]`
3
+ * (ticket 20694). A sibling of `docs.ts`: one verb, one door.
4
+ *
5
+ * `GET /api/docs/documents/[id]/export?format=` is the same door the browser's
6
+ * Export menu calls. The bytes are written untouched; the filename is the one
7
+ * the door chose (a slug of the title plus the extension) unless `--out` names
8
+ * another. `cockpit docs read` already covers "copy": it prints the Markdown.
9
+ */
10
+ import { mkdir, stat, writeFile } from "node:fs/promises";
11
+ import path from "node:path";
12
+ import { emitAgentDoor, failAgentDoor, fetchAgentDoorFile } from "./agent-door.js";
13
+ import { writeLine } from "./cli-io.js";
14
+ import { resolveDocId } from "./docs.js";
15
+ const TAG = "[docs cli]";
16
+ const EXPORT_DEADLINE_MS = 90_000;
17
+ async function isDirectory(target) {
18
+ try {
19
+ return (await stat(target)).isDirectory();
20
+ }
21
+ catch {
22
+ return false;
23
+ }
24
+ }
25
+ export async function exportDoc(command, door) {
26
+ const ref = command.docRef ?? "";
27
+ const format = command.exportFormat ?? "md";
28
+ const resolved = await resolveDocId(door, ref);
29
+ if (resolved.status === "list_failed")
30
+ return failAgentDoor(door, TAG, resolved.reason, resolved.detail);
31
+ if (resolved.status === "not_found") {
32
+ return failAgentDoor(door, TAG, "document_not_found_or_unreadable", `That document does not exist, or you cannot read it: ${ref}`);
33
+ }
34
+ const started = Date.now();
35
+ const answer = await fetchAgentDoorFile(door, {
36
+ path: `/api/docs/documents/${encodeURIComponent(resolved.id)}/export?format=${encodeURIComponent(format)}`,
37
+ label: "docs export",
38
+ timeoutMs: EXPORT_DEADLINE_MS,
39
+ });
40
+ if (!answer.ok)
41
+ return failAgentDoor(door, TAG, answer.reason, answer.detail);
42
+ const filename = path.basename(answer.filename ?? `${ref}.${format}`);
43
+ let target = path.resolve(process.cwd(), filename);
44
+ if (command.outPath) {
45
+ const asked = path.resolve(command.outPath);
46
+ const wantsDirectory = /[\\/]$/.test(command.outPath) || (await isDirectory(asked));
47
+ target = wantsDirectory ? path.join(asked, filename) : asked;
48
+ }
49
+ try {
50
+ await mkdir(path.dirname(target), { recursive: true });
51
+ await writeFile(target, answer.bytes);
52
+ }
53
+ catch (error) {
54
+ const detail = error instanceof Error ? error.message : String(error);
55
+ return failAgentDoor(door, TAG, "export_write_failed", `Could not write ${target}: ${detail}`);
56
+ }
57
+ writeLine(door.io.stderr, `${TAG} export ${JSON.stringify({ document_id: resolved.id, format, bytes: answer.bytes.length, duration_ms: Date.now() - started })}`);
58
+ if (door.json)
59
+ return emitAgentDoor(door, { ok: true, path: target, bytes: answer.bytes.length, format });
60
+ writeLine(door.io.stdout, target);
61
+ return 0;
62
+ }
@@ -30,6 +30,7 @@
30
30
  import { askAgentDoor, emitAgentDoor, failAgentDoor, openAgentDoor, } from "./agent-door.js";
31
31
  import { isInteractiveStdin, readPipedText, writeLine } from "./cli-io.js";
32
32
  import { readDoc } from "./docs-read.js";
33
+ import { exportDoc } from "./docs-export.js";
33
34
  import { linkDoc, syncStatusDocs } from "./docs-notion.js";
34
35
  import { readNoteFile } from "./notes-file.js";
35
36
  import { cacheAgeLabel, cacheCoverage, readThroughCache } from "./read-cache.js";
@@ -56,6 +57,8 @@ export async function runDocs(command, io) {
56
57
  return linkDoc(command, door);
57
58
  case "sync-status":
58
59
  return syncStatusDocs(command, door);
60
+ case "export":
61
+ return exportDoc(command, door);
59
62
  }
60
63
  }
61
64
  /** `--parent`, `--query`, `--word`, `--limit`, `--bodies` (BLI-10450) as the door's own query parameters (BLI-3737). */
@@ -4,7 +4,7 @@ import { scanWorkDirectories } from "./local-discovery.js";
4
4
  import { sendCollectorHeartbeatBestEffort } from "./heartbeat.js";
5
5
  import { isInteractiveDoctorFix } from "./doctor-report.js";
6
6
  import { describeError } from "../health-detail.js";
7
- import { DEFAULT_DASHBOARD_URL, getCollectorRuntimePaths, readLocalCollectorConfig, readLocalCollectorSessionFile, toSessionReference, } from "../local-state.js";
7
+ import { isDefaultDashboardUrl, getCollectorRuntimePaths, readLocalCollectorConfig, readLocalCollectorSessionFile, toSessionReference, } from "../local-state.js";
8
8
  import { normalizeCollectionRoots } from "../root-normalization.js";
9
9
  import { detectSecondCockpitInstall } from "../second-install.js";
10
10
  import { hardStop, needsFix, ok, skipped } from "./doctor-report.js";
@@ -140,7 +140,7 @@ export async function savedRoots(homeDir) {
140
140
  }
141
141
  function onboardOneLiner(command) {
142
142
  const workspace = command.repoRoot ?? "$PWD";
143
- const dashboard = command.dashboardUrl === DEFAULT_DASHBOARD_URL
143
+ const dashboard = isDefaultDashboardUrl(command.dashboardUrl)
144
144
  ? ""
145
145
  : ` --dashboard-url ${shellQuote(command.dashboardUrl)}`;
146
146
  return `cockpit doctor --workspace ${shellQuote(workspace)}${dashboard}`;
@@ -0,0 +1,82 @@
1
+ /**
2
+ * The `agent-plugins` doctor row (BLI-18200): does this machine carry the
3
+ * company `engineering-standards` plugin in Claude Code and in Codex?
4
+ *
5
+ * The CHECK reads the two hosts' own files and the last attempt's remembered
6
+ * reason; it never starts a program, so `cockpit doctor --check` is free. The
7
+ * FIX runs the host commands through `agent-plugins-converge.ts` and answers by
8
+ * re-reading what the hosts recorded. A tick runs the fix too (the doctor the
9
+ * sync runs after collection), throttled to one retry per six hours; a person
10
+ * running `cockpit doctor` always retries.
11
+ *
12
+ * Never blocks anything else: a missing program is `skipped`, every other
13
+ * failure is a named reason on this row alone.
14
+ */
15
+ import os from "node:os";
16
+ import { AGENT_PLUGIN_NAME, AGENT_PLUGIN_SOURCE, inspectAgentPlugins, locateHostBinary, } from "../agent-plugins.js";
17
+ import { convergeAgentPlugins, readPluginState } from "../agent-plugins-converge.js";
18
+ import { getCollectorRuntimePaths } from "../local-state.js";
19
+ import { needsFix, ok, skipped } from "./doctor-report.js";
20
+ const ID = "agent-plugins";
21
+ /** Worst first: the row names the reason a person can act on before the one that heals itself. */
22
+ const REASON_PRIORITY = ["private_repo_auth", "cli_too_old", "plugin_not_in_marketplace", "install_not_recorded", "command_failed", "timed_out", "network_unreachable", "missing"];
23
+ const SENTENCES = {
24
+ missing: { say: (hosts) => `the company ${AGENT_PLUGIN_NAME} plugin is not installed for ${hosts}; \`cockpit doctor\` installs it`, next: "cockpit doctor" },
25
+ private_repo_auth: {
26
+ say: (hosts) => `Tower could not read the company plugin repository ${AGENT_PLUGIN_SOURCE} for ${hosts}: GitHub sign-in or BLI-INC access is missing. Run \`gh auth login\` with your company account, then \`cockpit doctor\``,
27
+ next: "gh auth login",
28
+ },
29
+ cli_too_old: { say: (hosts) => `the installed ${hosts} program is too old to manage plugins; update it, then run \`cockpit doctor\``, next: "cockpit doctor" },
30
+ plugin_not_in_marketplace: { say: (hosts) => `the company marketplace did not offer ${AGENT_PLUGIN_NAME} for ${hosts}; ask in the engineering channel, then run \`cockpit doctor\``, next: "cockpit doctor" },
31
+ install_not_recorded: { say: (hosts) => `the install for ${hosts} ran but the program did not record it; run \`cockpit doctor\` again`, next: "cockpit doctor" },
32
+ command_failed: { say: (hosts) => `the plugin install for ${hosts} failed; run \`cockpit doctor\` to retry`, next: "cockpit doctor" },
33
+ timed_out: { say: (hosts) => `the plugin install for ${hosts} timed out; Tower retries on its own, or run \`cockpit doctor\``, next: "cockpit doctor" },
34
+ network_unreachable: { say: (hosts) => `the plugin install for ${hosts} could not reach GitHub; Tower retries on its own, or run \`cockpit doctor\``, next: "cockpit doctor" },
35
+ };
36
+ function homeOf(context) {
37
+ return context.command.homeDir ?? os.homedir();
38
+ }
39
+ /** `runtime` is for tests: a Windows layout, a fake file system. */
40
+ export async function checkAgentPluginsState(context, runtime = {}) {
41
+ const homeDir = homeOf(context);
42
+ const present = (await inspectAgentPlugins(homeDir, runtime)).filter((reading) => reading.present);
43
+ if (present.length === 0) {
44
+ return skipped(ID, "no_agent_hosts", "neither Claude Code nor Codex is set up on this machine yet; Tower installs the company plugin once one is");
45
+ }
46
+ const missing = present.filter((reading) => !reading.installed);
47
+ if (missing.length === 0) {
48
+ return ok(ID, "installed", `the company ${AGENT_PLUGIN_NAME} plugin is installed for ${present.map((reading) => reading.host).join(", ")}`);
49
+ }
50
+ const remembered = await readPluginState(getCollectorRuntimePaths(context.command.homeDir).state_dir);
51
+ const reasons = new Map();
52
+ for (const reading of missing) {
53
+ const binary = await locateHostBinary(reading.host, homeDir, runtime);
54
+ reasons.set(reading.host, binary ? remembered[reading.host]?.last_reason ?? "missing" : "binary_missing");
55
+ }
56
+ // A remembered label this table does not know reads as a plain command failure.
57
+ const actionable = [...reasons].filter(([, reason]) => reason !== "binary_missing").map(([host, reason]) => ({ host, reason: REASON_PRIORITY.includes(reason) ? reason : "command_failed" }));
58
+ if (actionable.length === 0) {
59
+ return skipped(ID, "binary_missing", `${[...reasons.keys()].join(", ")} is set up but its program was not found; the plugin installs once it is`);
60
+ }
61
+ const code = REASON_PRIORITY.find((reason) => actionable.some((entry) => entry.reason === reason));
62
+ const hosts = actionable.filter((entry) => entry.reason === code).map((entry) => entry.host).join(" and ");
63
+ const sentence = SENTENCES[code];
64
+ return { ...needsFix(ID, code, sentence.say(hosts)), nextAction: sentence.next };
65
+ }
66
+ export async function fixAgentPluginsState(context, runtime = {}) {
67
+ const exec = context.io.exec;
68
+ if (!exec) {
69
+ console.error("[agent-plugins] no process runner on this io; the install could not be attempted", JSON.stringify({ reason: "runner_unavailable" }));
70
+ return needsFix(ID, "runner_unavailable", "this CLI has no process runner; reinstall it with `npm i -g @bli-cockpit/cli`, then run `cockpit doctor`");
71
+ }
72
+ const outcomes = await convergeAgentPlugins({
73
+ ...runtime,
74
+ homeDir: homeOf(context),
75
+ stateDir: getCollectorRuntimePaths(context.command.homeDir).state_dir,
76
+ exec,
77
+ unattended: Boolean(context.command.automaticTick),
78
+ refresh: false,
79
+ });
80
+ console.error("[agent-plugins] doctor repair done", JSON.stringify({ reason: "repair_done", outcomes: outcomes.map((outcome) => `${outcome.host}:${outcome.reason}`) }));
81
+ return checkAgentPluginsState(context, runtime);
82
+ }