@bli-cockpit/mcp 0.1.1 → 0.1.3
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.
- package/README.md +190 -9
- package/dist/brief-tools.d.ts +26 -0
- package/dist/brief-tools.js +160 -0
- package/dist/docs-msg-tools.d.ts +13 -7
- package/dist/docs-msg-tools.js +130 -24
- package/dist/jarvis-answer-envelope.d.ts +107 -0
- package/dist/jarvis-answer-envelope.js +82 -0
- package/dist/jarvis-door.d.ts +95 -0
- package/dist/jarvis-door.js +163 -0
- package/dist/jarvis-tools.d.ts +50 -0
- package/dist/jarvis-tools.js +248 -0
- package/dist/jarvis-turn-bookmark.d.ts +25 -0
- package/dist/jarvis-turn-bookmark.js +43 -0
- package/dist/notes-tools.d.ts +33 -0
- package/dist/notes-tools.js +143 -0
- package/dist/ops-tools.d.ts +27 -0
- package/dist/ops-tools.js +151 -0
- package/dist/pages-tools.d.ts +24 -0
- package/dist/pages-tools.js +123 -0
- package/dist/readme-census.d.ts +18 -0
- package/dist/readme-census.js +77 -0
- package/dist/search-tool.d.ts +54 -0
- package/dist/search-tool.js +134 -0
- package/dist/server.d.ts +1 -1
- package/dist/server.js +25 -1
- package/dist/settings-tools.d.ts +29 -0
- package/dist/settings-tools.js +151 -0
- package/dist/tool-result.d.ts +70 -0
- package/dist/tool-result.js +79 -0
- package/dist/verb-census.d.ts +53 -0
- package/dist/verb-census.js +201 -0
- package/dist/work-tools.d.ts +2 -7
- package/dist/work-tools.js +35 -25
- package/package.json +5 -4
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `settings_*` / `team_*` / `model_show` MCP tools (BLI-3756) — the settings
|
|
3
|
+
* surface read by an agent, over the same `/api/settings/*` and `/api/team/*`
|
|
4
|
+
* doors `cockpit settings`, `cockpit team` and `cockpit model` call with the
|
|
5
|
+
* same device token.
|
|
6
|
+
*
|
|
7
|
+
* One file because the dashboard has one owner for all of it (`lib/settings/`
|
|
8
|
+
* behind `settings-gate.ts`, which the team directory and the model shorthand
|
|
9
|
+
* both sit behind), and because the reads share the rule below.
|
|
10
|
+
*
|
|
11
|
+
* **"Admin only" is not an error.** A 403 on a section is the system working;
|
|
12
|
+
* `settings_show` renders it as one line saying so and keeps every other
|
|
13
|
+
* section, exactly as the CLI does. Returning `isError` for the whole call
|
|
14
|
+
* would teach an agent that the surface is broken when in fact it is simply
|
|
15
|
+
* not this caller's to see.
|
|
16
|
+
*
|
|
17
|
+
* **A secret never comes back.** `settings_list` lists env-blob NAMES, sizes
|
|
18
|
+
* and stamps — the server cannot return a value and nothing here asks for one.
|
|
19
|
+
* Writing content is stdin-only by rule and stays in batch 2.
|
|
20
|
+
*/
|
|
21
|
+
import { type ToolDeps } from "./tool-result.js";
|
|
22
|
+
export type SettingsDeps = ToolDeps;
|
|
23
|
+
/** The sections `cockpit settings [section]` reads, plus the all-at-once view. */
|
|
24
|
+
export declare const SETTINGS_SECTIONS: readonly ["overview", "personal", "switches", "models", "cli-floor"];
|
|
25
|
+
export declare function registerSettingsTools(server: {
|
|
26
|
+
registerTool: (...args: never[]) => unknown;
|
|
27
|
+
}, deps: SettingsDeps): void;
|
|
28
|
+
/** `provider:model`, or `unset`. The same rendering `cockpit model show` prints. */
|
|
29
|
+
export declare function modelKeyOf(value: unknown): string;
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `settings_*` / `team_*` / `model_show` MCP tools (BLI-3756) — the settings
|
|
3
|
+
* surface read by an agent, over the same `/api/settings/*` and `/api/team/*`
|
|
4
|
+
* doors `cockpit settings`, `cockpit team` and `cockpit model` call with the
|
|
5
|
+
* same device token.
|
|
6
|
+
*
|
|
7
|
+
* One file because the dashboard has one owner for all of it (`lib/settings/`
|
|
8
|
+
* behind `settings-gate.ts`, which the team directory and the model shorthand
|
|
9
|
+
* both sit behind), and because the reads share the rule below.
|
|
10
|
+
*
|
|
11
|
+
* **"Admin only" is not an error.** A 403 on a section is the system working;
|
|
12
|
+
* `settings_show` renders it as one line saying so and keeps every other
|
|
13
|
+
* section, exactly as the CLI does. Returning `isError` for the whole call
|
|
14
|
+
* would teach an agent that the surface is broken when in fact it is simply
|
|
15
|
+
* not this caller's to see.
|
|
16
|
+
*
|
|
17
|
+
* **A secret never comes back.** `settings_list` lists env-blob NAMES, sizes
|
|
18
|
+
* and stamps — the server cannot return a value and nothing here asks for one.
|
|
19
|
+
* Writing content is stdin-only by rule and stays in batch 2.
|
|
20
|
+
*/
|
|
21
|
+
import { z } from "zod";
|
|
22
|
+
import { callAgentDoor } from "./agent-door.js";
|
|
23
|
+
import { doorFailureText, errorResult, registrarFor, textResult, withSession, } from "./tool-result.js";
|
|
24
|
+
/** The sections `cockpit settings [section]` reads, plus the all-at-once view. */
|
|
25
|
+
export const SETTINGS_SECTIONS = ["overview", "personal", "switches", "models", "cli-floor"];
|
|
26
|
+
/** Which door each section is behind. The overview reads all of them at once. */
|
|
27
|
+
const SECTION_DOORS = {
|
|
28
|
+
personal: "/api/settings/personal",
|
|
29
|
+
switches: "/api/settings/switches",
|
|
30
|
+
models: "/api/settings/model-routing",
|
|
31
|
+
"cli-floor": "/api/settings/cli-floor",
|
|
32
|
+
env: "/api/settings/env-blobs",
|
|
33
|
+
team: "/api/team/members",
|
|
34
|
+
};
|
|
35
|
+
/**
|
|
36
|
+
* A section's answer, or the reason it is absent. `admin_only` is deliberately
|
|
37
|
+
* its own label and not an error — see the header.
|
|
38
|
+
*/
|
|
39
|
+
function sectionPayload(response, door) {
|
|
40
|
+
if (response.ok)
|
|
41
|
+
return response.body;
|
|
42
|
+
if (response.status === 403)
|
|
43
|
+
return { visible: false, reason: "admin_only" };
|
|
44
|
+
return { visible: false, reason: doorFailureText(door, response) };
|
|
45
|
+
}
|
|
46
|
+
function sectionWord(response) {
|
|
47
|
+
if (response.ok)
|
|
48
|
+
return "ok";
|
|
49
|
+
return response.status === 403 ? "admin only" : "unavailable";
|
|
50
|
+
}
|
|
51
|
+
async function readSection(deps, session, path) {
|
|
52
|
+
return callAgentDoor(session, deps.fetchImpl, "GET", path);
|
|
53
|
+
}
|
|
54
|
+
async function oneSection(deps, session, tool, path) {
|
|
55
|
+
const response = await readSection(deps, session, path);
|
|
56
|
+
if (!response.ok)
|
|
57
|
+
return errorResult(doorFailureText(tool, response));
|
|
58
|
+
return textResult(JSON.stringify(response.body, null, 2), response.body);
|
|
59
|
+
}
|
|
60
|
+
export function registerSettingsTools(server, deps) {
|
|
61
|
+
const register = registrarFor(server);
|
|
62
|
+
register("settings_show", {
|
|
63
|
+
title: "Read Tower settings",
|
|
64
|
+
description: "Settings as this caller may see them. `overview` (the default) reads all six sections at once and names any "
|
|
65
|
+
+ "you may not see as \"admin only\" rather than failing; a named section reads just that one. Never a secret's "
|
|
66
|
+
+ "value — settings_list lists env files by name only.",
|
|
67
|
+
inputSchema: {
|
|
68
|
+
section: z.enum(SETTINGS_SECTIONS).optional().describe("Defaults to overview — everything at once."),
|
|
69
|
+
},
|
|
70
|
+
}, async (args) => withSession(deps, async (session) => {
|
|
71
|
+
const section = String(args.section ?? "overview");
|
|
72
|
+
if (section !== "overview") {
|
|
73
|
+
const path = SECTION_DOORS[section];
|
|
74
|
+
if (!path)
|
|
75
|
+
return errorResult(`settings_show has no section "${section}".`);
|
|
76
|
+
return oneSection(deps, session, "settings_show", path);
|
|
77
|
+
}
|
|
78
|
+
// The six reads go out together: they are independent, and six
|
|
79
|
+
// sequential round trips would read as a hang.
|
|
80
|
+
const keys = ["personal", "team", "switches", "models", "env", "cli-floor"];
|
|
81
|
+
const responses = await Promise.all(keys.map((key) => readSection(deps, session, SECTION_DOORS[key])));
|
|
82
|
+
const byKey = Object.fromEntries(keys.map((key, index) => [key, responses[index]]));
|
|
83
|
+
// The one read every signed-in person is entitled to. If THAT is
|
|
84
|
+
// refused for any reason other than permission, nothing else here is
|
|
85
|
+
// going to work either, so say so once and stop.
|
|
86
|
+
const personal = byKey.personal;
|
|
87
|
+
if (!personal.ok && personal.status !== 403) {
|
|
88
|
+
return errorResult(doorFailureText("settings_show", personal));
|
|
89
|
+
}
|
|
90
|
+
const lines = keys
|
|
91
|
+
.map((key) => `${key.padEnd(10)} ${sectionWord(byKey[key])}`)
|
|
92
|
+
.join("\n");
|
|
93
|
+
const payload = Object.fromEntries(keys.map((key) => [key, sectionPayload(byKey[key], `settings ${key}`)]));
|
|
94
|
+
return textResult(`TOWER SETTINGS\n${lines}\n\n${JSON.stringify(payload, null, 2)}`, payload);
|
|
95
|
+
}));
|
|
96
|
+
register("settings_list", {
|
|
97
|
+
title: "List Tower env files",
|
|
98
|
+
description: "The env files Tower holds: name, size and when each was last written. The server cannot return a value and "
|
|
99
|
+
+ "this tool has no way to ask for one — content is write-only, on stdin, through the CLI.",
|
|
100
|
+
inputSchema: {},
|
|
101
|
+
}, async () => withSession(deps, async (session) => oneSection(deps, session, "settings_list", SECTION_DOORS.env)));
|
|
102
|
+
register("model_show", {
|
|
103
|
+
title: "Which model is answering",
|
|
104
|
+
description: "The chat and brief models in force for this account — the short way to read `settings personal`, because "
|
|
105
|
+
+ "\"which model am I talking to?\" is the question people actually ask.",
|
|
106
|
+
inputSchema: {},
|
|
107
|
+
}, async () => withSession(deps, async (session) => {
|
|
108
|
+
const response = await readSection(deps, session, "/api/settings/jarvis-model");
|
|
109
|
+
if (!response.ok)
|
|
110
|
+
return errorResult(doorFailureText("model_show", response));
|
|
111
|
+
return textResult(`chat model: ${modelKeyOf(response.body.chatModel)}\nbrief model: ${modelKeyOf(response.body.briefModel)}`, response.body);
|
|
112
|
+
}));
|
|
113
|
+
register("team_members", {
|
|
114
|
+
title: "Read the Tower team directory",
|
|
115
|
+
description: "Who is on this team, their roles, and any invitations still open.",
|
|
116
|
+
inputSchema: {},
|
|
117
|
+
}, async () => withSession(deps, async (session) => {
|
|
118
|
+
const response = await readSection(deps, session, "/api/team/members");
|
|
119
|
+
if (!response.ok)
|
|
120
|
+
return errorResult(doorFailureText("team_members", response));
|
|
121
|
+
const members = (Array.isArray(response.body.members) ? response.body.members : []);
|
|
122
|
+
const lines = members
|
|
123
|
+
.map((member) => `${String(member.role ?? "?").padEnd(12)} ${String(member.email ?? member.userId ?? "?")}`)
|
|
124
|
+
.join("\n");
|
|
125
|
+
return textResult(`${members.length} member(s).${lines ? `\n${lines}` : ""}`, response.body);
|
|
126
|
+
}));
|
|
127
|
+
register("team_device_list", {
|
|
128
|
+
title: "List the collector devices Tower knows",
|
|
129
|
+
description: "Every paired collector device: id, name, owner, version and when it last checked in. Read only — revoking "
|
|
130
|
+
+ "one is irreversible for that token and stays a deliberate act.",
|
|
131
|
+
inputSchema: {},
|
|
132
|
+
}, async () => withSession(deps, async (session) => {
|
|
133
|
+
const response = await readSection(deps, session, "/api/team/devices");
|
|
134
|
+
if (!response.ok)
|
|
135
|
+
return errorResult(doorFailureText("team_device_list", response));
|
|
136
|
+
const devices = (Array.isArray(response.body.devices) ? response.body.devices : []);
|
|
137
|
+
const lines = devices
|
|
138
|
+
.map((device) => `${String(device.device_id ?? "?")} ${String(device.device_name ?? "?")} `
|
|
139
|
+
+ `${String(device.cli_version ?? "?")} last seen ${String(device.last_seen_at ?? "never")}`)
|
|
140
|
+
.join("\n");
|
|
141
|
+
return textResult(`${devices.length} device(s).${lines ? `\n${lines}` : ""}`, response.body);
|
|
142
|
+
}));
|
|
143
|
+
}
|
|
144
|
+
/** `provider:model`, or `unset`. The same rendering `cockpit model show` prints. */
|
|
145
|
+
export function modelKeyOf(value) {
|
|
146
|
+
const pair = value && typeof value === "object" ? value : {};
|
|
147
|
+
if (typeof pair.provider === "string" && typeof pair.model === "string") {
|
|
148
|
+
return `${pair.provider}:${pair.model}`;
|
|
149
|
+
}
|
|
150
|
+
return "unset";
|
|
151
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What every agent-door tool on this server needs, written once (BLI-3756).
|
|
3
|
+
*
|
|
4
|
+
* `docs-msg-tools.ts` and `work-tools.ts` each carried their own copy of these
|
|
5
|
+
* four helpers. Batch 1 of the MCP twin build added five more surfaces, and
|
|
6
|
+
* seven copies of "what does a refused door say?" is seven places for the
|
|
7
|
+
* answer to drift — so the copies became this module and nothing else decides
|
|
8
|
+
* how a tool names a refusal.
|
|
9
|
+
*
|
|
10
|
+
* The one rule worth stating out loud: **a refusal keeps the DOOR's own
|
|
11
|
+
* words.** The reason label and the sentence are both computed on the server,
|
|
12
|
+
* where the outcome is known, and relayed here verbatim. A second wording in
|
|
13
|
+
* this package would be a second thing to keep in step, and the agent reading
|
|
14
|
+
* it would have no way to tell which one was true.
|
|
15
|
+
*/
|
|
16
|
+
import { loadAgentDoorSession } from "./agent-door-session.js";
|
|
17
|
+
import type { AgentDoorResponse, FetchImpl } from "./agent-door.js";
|
|
18
|
+
/** Everything a tool family needs to reach Tower. Injectable for the suites. */
|
|
19
|
+
export interface ToolDeps {
|
|
20
|
+
fetchImpl: FetchImpl;
|
|
21
|
+
/** Injectable for tests; defaults to reading `~/.config/bli-cockpit/session.json`. */
|
|
22
|
+
loadSession?: typeof loadAgentDoorSession;
|
|
23
|
+
}
|
|
24
|
+
/** What an MCP tool hands back. Narrower than the SDK's type, on purpose. */
|
|
25
|
+
export interface ToolResult {
|
|
26
|
+
content: Array<{
|
|
27
|
+
type: "text";
|
|
28
|
+
text: string;
|
|
29
|
+
}>;
|
|
30
|
+
structuredContent?: Record<string, unknown>;
|
|
31
|
+
isError?: boolean;
|
|
32
|
+
}
|
|
33
|
+
export interface DoorSession {
|
|
34
|
+
dashboardUrl: string;
|
|
35
|
+
deviceToken: string;
|
|
36
|
+
}
|
|
37
|
+
export declare function textResult(text: string, structured?: Record<string, unknown>): ToolResult;
|
|
38
|
+
export declare function errorResult(text: string): ToolResult;
|
|
39
|
+
/**
|
|
40
|
+
* The session is loaded fresh per call (a cheap file read) rather than once at
|
|
41
|
+
* server startup: this package still serves the legacy event-stream tools on a
|
|
42
|
+
* machine with no collector pairing at all, so a missing session must fail the
|
|
43
|
+
* ONE call that needed it, by name, and never the whole process.
|
|
44
|
+
*/
|
|
45
|
+
export declare function withSession(deps: ToolDeps, run: (session: DoorSession) => Promise<ToolResult>): Promise<ToolResult>;
|
|
46
|
+
/**
|
|
47
|
+
* The door's own reason label and its own sentence.
|
|
48
|
+
*
|
|
49
|
+
* Two answer shapes are read because two exist on the server and both are
|
|
50
|
+
* deliberate: the ingest-style routes answer `{reason, message}`, while every
|
|
51
|
+
* `/api/notes/**` door answers in the shape the BROWSER renders — a `headline`
|
|
52
|
+
* plus `lines` — because the page and the terminal read the same body
|
|
53
|
+
* (`commands/notes-door.ts` says why). Reading both here rather than
|
|
54
|
+
* rephrasing either keeps one meaning per field.
|
|
55
|
+
*/
|
|
56
|
+
export declare function doorFailureText(door: string, response: AgentDoorResponse): string;
|
|
57
|
+
/** The label a script should branch on. `unknown_error` only when the door named none. */
|
|
58
|
+
export declare function doorReason(body: Record<string, unknown>): string;
|
|
59
|
+
/** The `registerTool` signature this package's tools actually use. */
|
|
60
|
+
export type RegisterTool = (name: string, config: {
|
|
61
|
+
title: string;
|
|
62
|
+
description: string;
|
|
63
|
+
inputSchema: Record<string, unknown>;
|
|
64
|
+
}, handler: (args: Record<string, unknown>) => Promise<ToolResult>) => unknown;
|
|
65
|
+
/** The one cast, so no tool family repeats it. */
|
|
66
|
+
export declare function registrarFor(server: {
|
|
67
|
+
registerTool: (...args: never[]) => unknown;
|
|
68
|
+
}): RegisterTool;
|
|
69
|
+
/** `?a=1&b=2`, or "" when nothing was asked for. Never a bare `?`. */
|
|
70
|
+
export declare function queryString(params: URLSearchParams): string;
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What every agent-door tool on this server needs, written once (BLI-3756).
|
|
3
|
+
*
|
|
4
|
+
* `docs-msg-tools.ts` and `work-tools.ts` each carried their own copy of these
|
|
5
|
+
* four helpers. Batch 1 of the MCP twin build added five more surfaces, and
|
|
6
|
+
* seven copies of "what does a refused door say?" is seven places for the
|
|
7
|
+
* answer to drift — so the copies became this module and nothing else decides
|
|
8
|
+
* how a tool names a refusal.
|
|
9
|
+
*
|
|
10
|
+
* The one rule worth stating out loud: **a refusal keeps the DOOR's own
|
|
11
|
+
* words.** The reason label and the sentence are both computed on the server,
|
|
12
|
+
* where the outcome is known, and relayed here verbatim. A second wording in
|
|
13
|
+
* this package would be a second thing to keep in step, and the agent reading
|
|
14
|
+
* it would have no way to tell which one was true.
|
|
15
|
+
*/
|
|
16
|
+
import { loadAgentDoorSession } from "./agent-door-session.js";
|
|
17
|
+
export function textResult(text, structured) {
|
|
18
|
+
return { content: [{ type: "text", text }], ...(structured ? { structuredContent: structured } : {}) };
|
|
19
|
+
}
|
|
20
|
+
export function errorResult(text) {
|
|
21
|
+
return { isError: true, content: [{ type: "text", text }] };
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* The session is loaded fresh per call (a cheap file read) rather than once at
|
|
25
|
+
* server startup: this package still serves the legacy event-stream tools on a
|
|
26
|
+
* machine with no collector pairing at all, so a missing session must fail the
|
|
27
|
+
* ONE call that needed it, by name, and never the whole process.
|
|
28
|
+
*/
|
|
29
|
+
export async function withSession(deps, run) {
|
|
30
|
+
const loadSession = deps.loadSession ?? loadAgentDoorSession;
|
|
31
|
+
const loaded = loadSession();
|
|
32
|
+
if (!loaded.ok) {
|
|
33
|
+
return errorResult(`This machine is not paired with Tower (${loaded.reason}). ${loaded.message}`);
|
|
34
|
+
}
|
|
35
|
+
return run(loaded.session);
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* The door's own reason label and its own sentence.
|
|
39
|
+
*
|
|
40
|
+
* Two answer shapes are read because two exist on the server and both are
|
|
41
|
+
* deliberate: the ingest-style routes answer `{reason, message}`, while every
|
|
42
|
+
* `/api/notes/**` door answers in the shape the BROWSER renders — a `headline`
|
|
43
|
+
* plus `lines` — because the page and the terminal read the same body
|
|
44
|
+
* (`commands/notes-door.ts` says why). Reading both here rather than
|
|
45
|
+
* rephrasing either keeps one meaning per field.
|
|
46
|
+
*/
|
|
47
|
+
export function doorFailureText(door, response) {
|
|
48
|
+
if (response.transportError) {
|
|
49
|
+
return `Tower could not be reached for ${door} (${response.transportError}). Nothing was read or written.`;
|
|
50
|
+
}
|
|
51
|
+
return `Tower refused ${door} (${doorReason(response.body)}): ${doorSentence(response.body, response.status)}`;
|
|
52
|
+
}
|
|
53
|
+
/** The label a script should branch on. `unknown_error` only when the door named none. */
|
|
54
|
+
export function doorReason(body) {
|
|
55
|
+
for (const key of ["reason", "error", "code"]) {
|
|
56
|
+
const value = body[key];
|
|
57
|
+
if (typeof value === "string" && value.trim() !== "")
|
|
58
|
+
return value;
|
|
59
|
+
}
|
|
60
|
+
return "unknown_error";
|
|
61
|
+
}
|
|
62
|
+
function doorSentence(body, status) {
|
|
63
|
+
const headline = typeof body["headline"] === "string" ? body["headline"] : null;
|
|
64
|
+
const message = typeof body["message"] === "string" ? body["message"] : null;
|
|
65
|
+
const lines = Array.isArray(body["lines"])
|
|
66
|
+
? body["lines"].filter((line) => typeof line === "string")
|
|
67
|
+
: [];
|
|
68
|
+
const said = [headline ?? message, ...lines].filter(Boolean);
|
|
69
|
+
return said.length > 0 ? said.join(" ") : `Tower answered ${status}.`;
|
|
70
|
+
}
|
|
71
|
+
/** The one cast, so no tool family repeats it. */
|
|
72
|
+
export function registrarFor(server) {
|
|
73
|
+
return server.registerTool.bind(server);
|
|
74
|
+
}
|
|
75
|
+
/** `?a=1&b=2`, or "" when nothing was asked for. Never a bare `?`. */
|
|
76
|
+
export function queryString(params) {
|
|
77
|
+
const rendered = params.toString();
|
|
78
|
+
return rendered === "" ? "" : `?${rendered}`;
|
|
79
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Every `cockpit <noun> <verb>` that talks to Tower, read off the collector's
|
|
3
|
+
* own decision tables (BLI-3732).
|
|
4
|
+
*
|
|
5
|
+
* This is a test helper with no runtime consumer, kept out of the suite file
|
|
6
|
+
* so the census itself — the three lists a person actually reviews — reads as
|
|
7
|
+
* a list and not as a parser. It reads SOURCE TEXT rather than importing the
|
|
8
|
+
* collector, because this package declares no dependency on it (the same
|
|
9
|
+
* reason `agent-door-session.ts` copies a session reader instead of importing
|
|
10
|
+
* one).
|
|
11
|
+
*
|
|
12
|
+
* ## Where each half comes from
|
|
13
|
+
*
|
|
14
|
+
* - The NOUNS are the `parse<Noun>Args` functions `local-args-tower.ts`
|
|
15
|
+
* re-exports. That file is the authoritative "these are the commands that
|
|
16
|
+
* talk to Tower" table — its siblings are a filing decision, not a contract.
|
|
17
|
+
* - The VERBS are each noun's `action:` union in `LocalCommand`
|
|
18
|
+
* (`local-args.ts`), with an `XAction` alias resolved from whichever
|
|
19
|
+
* `local-args-tower-*.ts` declares it. A noun with no `action` field is one
|
|
20
|
+
* verb wearing the noun's own name (`jarvis`, `correct`, `workbook`).
|
|
21
|
+
*
|
|
22
|
+
* ## What it deliberately does NOT see
|
|
23
|
+
*
|
|
24
|
+
* Mode FLAGS. `cockpit jarvis --trace`, `--threads` and `--history` are three
|
|
25
|
+
* different acts behind one noun, and no decision table calls them verbs, so
|
|
26
|
+
* the mechanical rule cannot find them. They are covered by hand in the
|
|
27
|
+
* census's own twin map (`jarvis --trace` → `jarvis_trace`); a flag that grows
|
|
28
|
+
* into a verb will show up here the moment it becomes an `action`.
|
|
29
|
+
*/
|
|
30
|
+
export declare const COLLECTOR_COMMANDS_DIR: string;
|
|
31
|
+
export interface TowerVerb {
|
|
32
|
+
noun: string;
|
|
33
|
+
/** The action word, or "" for a noun that is its own single verb. */
|
|
34
|
+
action: string;
|
|
35
|
+
/** How a person types it: `docs read`, or just `jarvis`. */
|
|
36
|
+
spelling: string;
|
|
37
|
+
}
|
|
38
|
+
/** `parseJarvisArgs` → `jarvis`. The re-export table IS the noun list. */
|
|
39
|
+
export declare function towerNouns(): string[];
|
|
40
|
+
/** Every Tower verb a person may type today. */
|
|
41
|
+
export declare function towerVerbs(): TowerVerb[];
|
|
42
|
+
export interface McpTwin {
|
|
43
|
+
/** The MCP tool name. Must actually be registered — the suite checks. */
|
|
44
|
+
tool: string;
|
|
45
|
+
/** The API door it goes through: the SAME one the CLI verb calls. */
|
|
46
|
+
door: string;
|
|
47
|
+
}
|
|
48
|
+
/** A CLI verb and the MCP tool that does the same thing, over the same door. */
|
|
49
|
+
export declare const MCP_TWINS: Record<string, McpTwin>;
|
|
50
|
+
/** Verbs that can never have an MCP twin, and why. A claim, not a backlog. */
|
|
51
|
+
export declare const TERMINAL_ONLY: Record<string, string>;
|
|
52
|
+
/** Verbs owed a twin, with who owes it. This list should only ever shrink. */
|
|
53
|
+
export declare const AWAITING_TWIN: Record<string, string>;
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Every `cockpit <noun> <verb>` that talks to Tower, read off the collector's
|
|
3
|
+
* own decision tables (BLI-3732).
|
|
4
|
+
*
|
|
5
|
+
* This is a test helper with no runtime consumer, kept out of the suite file
|
|
6
|
+
* so the census itself — the three lists a person actually reviews — reads as
|
|
7
|
+
* a list and not as a parser. It reads SOURCE TEXT rather than importing the
|
|
8
|
+
* collector, because this package declares no dependency on it (the same
|
|
9
|
+
* reason `agent-door-session.ts` copies a session reader instead of importing
|
|
10
|
+
* one).
|
|
11
|
+
*
|
|
12
|
+
* ## Where each half comes from
|
|
13
|
+
*
|
|
14
|
+
* - The NOUNS are the `parse<Noun>Args` functions `local-args-tower.ts`
|
|
15
|
+
* re-exports. That file is the authoritative "these are the commands that
|
|
16
|
+
* talk to Tower" table — its siblings are a filing decision, not a contract.
|
|
17
|
+
* - The VERBS are each noun's `action:` union in `LocalCommand`
|
|
18
|
+
* (`local-args.ts`), with an `XAction` alias resolved from whichever
|
|
19
|
+
* `local-args-tower-*.ts` declares it. A noun with no `action` field is one
|
|
20
|
+
* verb wearing the noun's own name (`jarvis`, `correct`, `workbook`).
|
|
21
|
+
*
|
|
22
|
+
* ## What it deliberately does NOT see
|
|
23
|
+
*
|
|
24
|
+
* Mode FLAGS. `cockpit jarvis --trace`, `--threads` and `--history` are three
|
|
25
|
+
* different acts behind one noun, and no decision table calls them verbs, so
|
|
26
|
+
* the mechanical rule cannot find them. They are covered by hand in the
|
|
27
|
+
* census's own twin map (`jarvis --trace` → `jarvis_trace`); a flag that grows
|
|
28
|
+
* into a verb will show up here the moment it becomes an `action`.
|
|
29
|
+
*/
|
|
30
|
+
import { readFileSync, readdirSync } from "node:fs";
|
|
31
|
+
import { dirname, join, resolve } from "node:path";
|
|
32
|
+
import { fileURLToPath } from "node:url";
|
|
33
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
34
|
+
export const COLLECTOR_COMMANDS_DIR = resolve(HERE, "../../cockpit-local-collector/src/commands");
|
|
35
|
+
function read(file) {
|
|
36
|
+
return readFileSync(join(COLLECTOR_COMMANDS_DIR, file), "utf8");
|
|
37
|
+
}
|
|
38
|
+
/** Every `local-args-tower*.ts` in the collector, so a new sibling is read too. */
|
|
39
|
+
function towerFiles() {
|
|
40
|
+
return readdirSync(COLLECTOR_COMMANDS_DIR).filter((name) => name.startsWith("local-args-tower") && name.endsWith(".ts") && !name.includes(".test."));
|
|
41
|
+
}
|
|
42
|
+
/** `parseJarvisArgs` → `jarvis`. The re-export table IS the noun list. */
|
|
43
|
+
export function towerNouns() {
|
|
44
|
+
const source = read("local-args-tower.ts");
|
|
45
|
+
const nouns = new Set();
|
|
46
|
+
for (const match of source.matchAll(/\bparse([A-Z][A-Za-z]*)Args\b/g)) {
|
|
47
|
+
nouns.add(match[1].replace(/([a-z])([A-Z])/g, "$1-$2").toLowerCase());
|
|
48
|
+
}
|
|
49
|
+
return [...nouns].sort();
|
|
50
|
+
}
|
|
51
|
+
/** `export type NotesAction = "list" | "show" | …` across every tower sibling. */
|
|
52
|
+
function actionAliases() {
|
|
53
|
+
const aliases = new Map();
|
|
54
|
+
for (const file of towerFiles()) {
|
|
55
|
+
const source = read(file);
|
|
56
|
+
for (const match of source.matchAll(/export type (\w+Action)\s*=\s*([\s\S]*?);/g)) {
|
|
57
|
+
aliases.set(match[1], [...match[2].matchAll(/"([a-z-]+)"/g)].map((m) => m[1]));
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return aliases;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* The `LocalCommand` variants, split the way the union is written: one chunk
|
|
64
|
+
* per ` | {` at the top level of the type.
|
|
65
|
+
*/
|
|
66
|
+
/**
|
|
67
|
+
* The `LocalCommand` union, wherever it currently lives.
|
|
68
|
+
*
|
|
69
|
+
* It moved from `local-args.ts` to `local-command-shapes.ts` in BLI-3728, when
|
|
70
|
+
* `local-args.ts` crossed the 700-line readability ceiling and was split into
|
|
71
|
+
* "which parser does this argv go to" (there) and "what does a parsed command
|
|
72
|
+
* look like" (the sibling). Both files are read rather than one, because a
|
|
73
|
+
* census that silently finds fewer verbs than exist is worse than no census —
|
|
74
|
+
* this function throwing when it can find the union in NEITHER file is the
|
|
75
|
+
* whole point, and hardcoding one filename is how it would stop throwing while
|
|
76
|
+
* being wrong.
|
|
77
|
+
*/
|
|
78
|
+
function commandVariants() {
|
|
79
|
+
for (const file of ["local-command-shapes.ts", "local-args.ts"]) {
|
|
80
|
+
let source;
|
|
81
|
+
try {
|
|
82
|
+
source = readFileSync(join(COLLECTOR_COMMANDS_DIR, file), "utf8");
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
const start = source.indexOf("export type LocalCommand =");
|
|
88
|
+
if (start < 0)
|
|
89
|
+
continue;
|
|
90
|
+
const end = source.indexOf("\nexport ", start + 10);
|
|
91
|
+
return source.slice(start, end < 0 ? undefined : end).split(/\n\s{2}\|\s/);
|
|
92
|
+
}
|
|
93
|
+
throw new Error("LocalCommand union not found in local-command-shapes.ts or local-args.ts");
|
|
94
|
+
}
|
|
95
|
+
/** Every Tower verb a person may type today. */
|
|
96
|
+
export function towerVerbs() {
|
|
97
|
+
const nouns = new Set(towerNouns());
|
|
98
|
+
const aliases = actionAliases();
|
|
99
|
+
const verbs = [];
|
|
100
|
+
const seenNouns = new Set();
|
|
101
|
+
for (const variant of commandVariants()) {
|
|
102
|
+
const kind = /kind:\s*"([a-z-]+)"/.exec(variant)?.[1];
|
|
103
|
+
if (!kind || !nouns.has(kind) || seenNouns.has(kind))
|
|
104
|
+
continue;
|
|
105
|
+
seenNouns.add(kind);
|
|
106
|
+
const action = /\n\s*action:\s*([^;]+);/.exec(variant)?.[1]?.trim();
|
|
107
|
+
if (!action) {
|
|
108
|
+
verbs.push({ noun: kind, action: "", spelling: kind });
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
const words = action.endsWith("Action")
|
|
112
|
+
? (aliases.get(action) ?? [])
|
|
113
|
+
: [...action.matchAll(/"([a-z-]+)"/g)].map((m) => m[1]);
|
|
114
|
+
if (words.length === 0) {
|
|
115
|
+
throw new Error(`No action vocabulary found for cockpit ${kind} (read "${action}")`);
|
|
116
|
+
}
|
|
117
|
+
for (const word of words) {
|
|
118
|
+
verbs.push({ noun: kind, action: word, spelling: `${kind} ${word}` });
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
const missing = [...nouns].filter((noun) => !seenNouns.has(noun));
|
|
122
|
+
if (missing.length > 0) {
|
|
123
|
+
// A parser with no `LocalCommand` variant means the extraction has drifted
|
|
124
|
+
// from the source, and a census that quietly reads fewer verbs than exist
|
|
125
|
+
// is worse than no census at all.
|
|
126
|
+
throw new Error(`Tower nouns with no LocalCommand variant: ${missing.join(", ")}`);
|
|
127
|
+
}
|
|
128
|
+
return verbs.sort((a, b) => a.spelling.localeCompare(b.spelling));
|
|
129
|
+
}
|
|
130
|
+
/** A CLI verb and the MCP tool that does the same thing, over the same door. */
|
|
131
|
+
export const MCP_TWINS = {
|
|
132
|
+
"docs list": { tool: "docs_list", door: "GET /api/docs/documents" },
|
|
133
|
+
"docs tree": { tool: "docs_tree", door: "GET /api/docs/tree" },
|
|
134
|
+
"docs read": { tool: "docs_read", door: "GET /api/docs/documents/[id]" },
|
|
135
|
+
"docs create": { tool: "docs_create", door: "POST /api/docs/documents" },
|
|
136
|
+
"docs update": { tool: "docs_update", door: "PATCH /api/docs/documents/[id]" },
|
|
137
|
+
"msg channels": { tool: "msg_channels", door: "GET /api/msg/channels" },
|
|
138
|
+
"msg create": { tool: "msg_create_channel", door: "POST /api/msg/channels" },
|
|
139
|
+
"msg dm": { tool: "msg_dm", door: "POST /api/msg/channels (dm)" },
|
|
140
|
+
"msg read": { tool: "msg_read", door: "GET /api/msg/channels/[id]/messages" },
|
|
141
|
+
"msg thread": { tool: "msg_thread", door: "GET /api/msg/channels/[id]/messages?thread_parent_id=" },
|
|
142
|
+
"msg send": { tool: "msg_send", door: "POST /api/msg/channels/[id]/messages" },
|
|
143
|
+
"issue list": { tool: "work_list_issues", door: "GET /api/work/issues" },
|
|
144
|
+
"issue show": { tool: "work_get_issue", door: "GET /api/work/issues/[id]" },
|
|
145
|
+
"issue create": { tool: "work_create_issue", door: "POST /api/work/issues" },
|
|
146
|
+
"issue update": { tool: "work_update_issue", door: "PATCH /api/work/issues/[id]" },
|
|
147
|
+
"issue move": { tool: "work_move_issue", door: "POST /api/work/issues/[id]/state" },
|
|
148
|
+
"issue comment": { tool: "work_comment_issue", door: "POST /api/work/issues/[id]/comments" },
|
|
149
|
+
"issue history": { tool: "work_issue_history", door: "GET /api/work/issues/[id]/history" },
|
|
150
|
+
"project list": { tool: "work_list_projects", door: "GET /api/work/projects" },
|
|
151
|
+
"brief read": { tool: "brief_read", door: "GET /api/jarvis/brief" },
|
|
152
|
+
"brief history": { tool: "brief_history", door: "GET /api/jarvis/brief?history=1" },
|
|
153
|
+
"brief status": { tool: "brief_status", door: "GET /api/ops/brief-status" },
|
|
154
|
+
"notes list": { tool: "notes_list", door: "GET /api/notes/library" },
|
|
155
|
+
"notes show": { tool: "notes_show", door: "GET /api/notes/library/[id]" },
|
|
156
|
+
"notes shelf": { tool: "notes_shelf", door: "GET /api/notes/shelf" },
|
|
157
|
+
"notes shelves": { tool: "notes_shelves", door: "GET /api/notes/library" },
|
|
158
|
+
"ops status": { tool: "ops_status", door: "GET /api/ops/status" },
|
|
159
|
+
"slack coverage": { tool: "slack_coverage", door: "GET /api/ops/slack/coverage" },
|
|
160
|
+
"slack read": { tool: "slack_read", door: "POST /api/ops/slack/read" },
|
|
161
|
+
"settings show": { tool: "settings_show", door: "GET /api/settings/* + /api/team/members" },
|
|
162
|
+
"settings list": { tool: "settings_list", door: "GET /api/settings/env-blobs" },
|
|
163
|
+
"team members": { tool: "team_members", door: "GET /api/team/members" },
|
|
164
|
+
"team device-list": { tool: "team_device_list", door: "GET /api/team/devices" },
|
|
165
|
+
"model show": { tool: "model_show", door: "GET /api/settings/jarvis-model" },
|
|
166
|
+
"scout board": { tool: "scout_board", door: "GET /api/cockpit/scout" },
|
|
167
|
+
workbook: { tool: "workbook_read", door: "GET /api/cockpit/workbook" },
|
|
168
|
+
// BLI-3728: one search over documents, messages, issues, meeting notes and
|
|
169
|
+
// memory. `cockpit search` takes no action word — the whole verb is the noun
|
|
170
|
+
// — so it is keyed on the noun alone, the same way `jarvis` and `workbook`
|
|
171
|
+
// are.
|
|
172
|
+
search: { tool: "tower_search", door: "GET /api/search" },
|
|
173
|
+
jarvis: { tool: "jarvis_ask", door: "POST /api/jarvis/cli" },
|
|
174
|
+
// Entered by hand: a mode flag, not an `action` the parser names (see the
|
|
175
|
+
// header of this file).
|
|
176
|
+
"jarvis --trace": { tool: "jarvis_trace", door: "GET /api/ops/trace/[id]" },
|
|
177
|
+
};
|
|
178
|
+
/** Verbs that can never have an MCP twin, and why. A claim, not a backlog. */
|
|
179
|
+
export const TERMINAL_ONLY = {
|
|
180
|
+
"brief edit": "opens the person's own $EDITOR on this machine and files what they changed; an agent has no editor to open (commands/editor.ts)",
|
|
181
|
+
};
|
|
182
|
+
/** Verbs owed a twin, with who owes it. This list should only ever shrink. */
|
|
183
|
+
export const AWAITING_TWIN = {
|
|
184
|
+
"brief rewrite": "BLI-3756 batch 2 — spends a model call; wants its own gate on this surface",
|
|
185
|
+
correct: "BLI-3756 batch 2 — filing a correction against a claim has no tool",
|
|
186
|
+
"notes move": "BLI-3756 batch 2 — needs a signed-in session server-side; reachable, just unwritten",
|
|
187
|
+
"notes share": "BLI-3756 batch 2 — a deliberate act; wants an explicit confirmation on this surface",
|
|
188
|
+
"notes unshare": "BLI-3756 batch 2 — same gate as notes share",
|
|
189
|
+
"notes paste": "BLI-3756 batch 2 — the CLI takes the body on stdin; a tool would take it as an argument",
|
|
190
|
+
"notes upload": "BLI-3756 batch 2 — takes local file paths, which an MCP server on the same machine can also read",
|
|
191
|
+
"ops recompile": "BLI-3756 batch 2 — spends a model call; wants its own gate on this surface",
|
|
192
|
+
"scout start": "BLI-3756 batch 2 — super_admin server-side; a tool would relay, not decide",
|
|
193
|
+
"scout dismiss": "BLI-3756 batch 2 — super_admin server-side; a tool would relay, not decide",
|
|
194
|
+
"scout undo": "BLI-3756 batch 2 — super_admin server-side; a tool would relay, not decide",
|
|
195
|
+
"settings set": "BLI-3756 batch 2 — mostly reachable, but env CONTENT is stdin-only by rule; a tool must keep that",
|
|
196
|
+
"settings delete": "BLI-3756 batch 2 — destructive; wants an explicit confirmation on this surface",
|
|
197
|
+
"team invite": "BLI-3756 batch 2 — super_admin; a tool would relay, not decide",
|
|
198
|
+
"team role": "BLI-3756 batch 2 — super_admin; a tool would relay, not decide",
|
|
199
|
+
"team device-revoke": "BLI-3756 batch 2 — destructive; wants an explicit confirmation on this surface",
|
|
200
|
+
"model set": "BLI-3756 batch 2 — a shorthand over settings personal; follows settings",
|
|
201
|
+
};
|
package/dist/work-tools.d.ts
CHANGED
|
@@ -14,13 +14,8 @@
|
|
|
14
14
|
* per call, so a machine with no `cockpit login` pairing still serves this
|
|
15
15
|
* server's other tools and only a `work_` call fails, by name.
|
|
16
16
|
*/
|
|
17
|
-
import {
|
|
18
|
-
|
|
19
|
-
export interface WorkDeps {
|
|
20
|
-
fetchImpl: FetchImpl;
|
|
21
|
-
/** Injectable for tests; defaults to reading `~/.config/bli-cockpit/session.json`. */
|
|
22
|
-
loadSession?: typeof loadAgentDoorSession;
|
|
23
|
-
}
|
|
17
|
+
import { type ToolDeps } from "./tool-result.js";
|
|
18
|
+
export type WorkDeps = ToolDeps;
|
|
24
19
|
/** The closed state vocabulary, verbatim from `lib/work/api-doors.ts` `WORK_ISSUE_STATES`. */
|
|
25
20
|
export declare const WORK_ISSUE_STATES: readonly ["backlog", "todo", "in_progress", "in_review", "done", "canceled"];
|
|
26
21
|
export declare function registerWorkTools(server: {
|