@bli-cockpit/cli 0.2.135 → 0.2.140
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/dist/commands/docs-notion.js +90 -0
- package/dist/commands/docs-read.js +197 -0
- package/dist/commands/docs.js +32 -33
- package/dist/commands/issue-directory.js +63 -0
- package/dist/commands/issue-show.js +147 -0
- package/dist/commands/issue.js +54 -71
- package/dist/commands/local-args-tower-admin.js +25 -0
- package/dist/commands/local-args-tower-docs-msg.js +130 -13
- package/dist/commands/local-args-tower-work.js +17 -5
- package/dist/commands/local-help-commands-tower.js +22 -14
- package/dist/commands/local-help-commands.js +14 -2
- package/dist/commands/local-help.js +3 -2
- package/dist/commands/local.js +8 -0
- package/dist/commands/mail.js +5 -1
- package/dist/commands/memory-store.js +11 -1
- package/dist/commands/msg.js +14 -3
- package/dist/commands/notes-reads.js +29 -0
- package/dist/commands/public-root.js +1 -1
- package/dist/commands/slack.js +59 -15
- package/dist/tower-client.js +29 -7
- package/package.json +3 -3
package/dist/commands/issue.js
CHANGED
|
@@ -25,9 +25,10 @@
|
|
|
25
25
|
* (`agent-door.ts`) — `issue_not_found_or_unreadable`, `invalid_state`,
|
|
26
26
|
* `needs_rls_client`, `comment_too_long`, and so on.
|
|
27
27
|
*/
|
|
28
|
-
import { mirroredTicketIdInTitle } from "@bli-cockpit/telemetry-core";
|
|
29
28
|
import { askAgentDoor, emitAgentDoor, failAgentDoor, openAgentDoor, } from "./agent-door.js";
|
|
30
29
|
import { writeLine } from "./cli-io.js";
|
|
30
|
+
import { assigneeLabel, resolveAssigneeDirectory, withAssigneeIdentity } from "./issue-directory.js";
|
|
31
|
+
import { showIssue } from "./issue-show.js";
|
|
31
32
|
import { READ_DEADLINE_MS, TAG, resolveProjectId, } from "./issue-contracts.js";
|
|
32
33
|
import { commentIssue, createIssue, moveIssue, updateIssue } from "./issue-write.js";
|
|
33
34
|
export async function runIssue(command, io) {
|
|
@@ -49,8 +50,40 @@ export async function runIssue(command, io) {
|
|
|
49
50
|
return issueHistory(command, door);
|
|
50
51
|
}
|
|
51
52
|
}
|
|
52
|
-
function issueLine(issue) {
|
|
53
|
-
|
|
53
|
+
function issueLine(issue, directory) {
|
|
54
|
+
const assignee = issue.assignee_id ? ` [${assigneeLabel(issue.assignee_id, directory)}]` : "";
|
|
55
|
+
return `${issue.identifier.padEnd(9)} ${issue.state.padEnd(11)} ${issue.title}${assignee}`;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* What the list did not show (BLI-10447): a capped window of a larger set,
|
|
59
|
+
* and sub-issues of the listed issues that live outside the named project.
|
|
60
|
+
* A door too old to say anything is judged by whether the read filled its limit.
|
|
61
|
+
*/
|
|
62
|
+
export function listGapLines(body, requestedLimit) {
|
|
63
|
+
const lines = [];
|
|
64
|
+
const returned = body.issues?.length ?? 0;
|
|
65
|
+
const completeness = body.completeness;
|
|
66
|
+
if (completeness?.status === "partial") {
|
|
67
|
+
lines.push(`${TAG} partial list: ${completeness.returned} of ${completeness.total} matching issues; narrow the filter or pass --limit (at most 1000).`);
|
|
68
|
+
}
|
|
69
|
+
else if (completeness?.status === "unknown") {
|
|
70
|
+
lines.push(`${TAG} list may be partial: ${completeness.returned} issues filled the limit and Tower could not count the rest.`);
|
|
71
|
+
}
|
|
72
|
+
else if (!completeness && returned >= requestedLimit) {
|
|
73
|
+
lines.push(`${TAG} list may be partial: ${returned} issues filled the limit and this Tower did not say whether more exist.`);
|
|
74
|
+
}
|
|
75
|
+
const outside = body.outside_project;
|
|
76
|
+
if (outside && outside.issues.length > 0) {
|
|
77
|
+
lines.push(`${TAG} ${outside.issues.length} sub-issue(s) of these issues sit outside the project:`);
|
|
78
|
+
for (const issue of outside.issues) {
|
|
79
|
+
lines.push(` ${issue.identifier} (under ${issue.parent_identifier ?? "?"}, ${issue.project_id ? `project ${issue.project_id}` : "no project"}) ${issue.title}`);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
if (outside?.status === "capped")
|
|
83
|
+
lines.push(`${TAG} the sub-issue check was capped; more may sit outside the project.`);
|
|
84
|
+
if (outside?.status === "failed")
|
|
85
|
+
lines.push(`${TAG} could not check for sub-issues outside the project (read_failed).`);
|
|
86
|
+
return lines;
|
|
54
87
|
}
|
|
55
88
|
async function listIssues(command, door) {
|
|
56
89
|
const query = new URLSearchParams();
|
|
@@ -78,83 +111,33 @@ async function listIssues(command, door) {
|
|
|
78
111
|
});
|
|
79
112
|
if (!answer.ok)
|
|
80
113
|
return failAgentDoor(door, TAG, answer.reason, answer.detail);
|
|
81
|
-
const
|
|
82
|
-
|
|
83
|
-
|
|
114
|
+
const body = answer.body;
|
|
115
|
+
const issues = body.issues ?? [];
|
|
116
|
+
// BLI-10447: the gaps go to stderr in both modes, so `--json` stdout stays one object.
|
|
117
|
+
for (const line of listGapLines(body, command.limit ?? 1000))
|
|
118
|
+
writeLine(door.io.stderr, line);
|
|
119
|
+
// BLI-10450: one extra call for the whole list, never one per row — the
|
|
120
|
+
// uuid `assignee_id` alone forced a second `cockpit team members` call and
|
|
121
|
+
// a join done by hand to answer "who owns what".
|
|
122
|
+
const directory = await resolveAssigneeDirectory(door, issues.map((issue) => issue.assignee_id));
|
|
123
|
+
if (door.json) {
|
|
124
|
+
return emitAgentDoor(door, {
|
|
125
|
+
ok: true,
|
|
126
|
+
issues: issues.map((issue) => withAssigneeIdentity(issue, directory)),
|
|
127
|
+
...(body.completeness ? { completeness: body.completeness } : {}),
|
|
128
|
+
...(body.outside_project ? { outside_project: body.outside_project } : {}),
|
|
129
|
+
});
|
|
130
|
+
}
|
|
84
131
|
if (issues.length === 0) {
|
|
85
132
|
writeLine(door.io.stdout, "No issues match that.");
|
|
86
133
|
return 0;
|
|
87
134
|
}
|
|
88
135
|
for (const issue of issues)
|
|
89
|
-
writeLine(door.io.stdout, issueLine(issue));
|
|
136
|
+
writeLine(door.io.stdout, issueLine(issue, directory));
|
|
90
137
|
writeLine(door.io.stdout, "");
|
|
91
138
|
writeLine(door.io.stdout, `${issues.length} issue(s).`);
|
|
92
139
|
return 0;
|
|
93
140
|
}
|
|
94
|
-
async function showIssue(command, door) {
|
|
95
|
-
const ref = command.issueRef ?? "";
|
|
96
|
-
const answer = await askAgentDoor(door, {
|
|
97
|
-
path: `/api/work/issues/${encodeURIComponent(ref)}`,
|
|
98
|
-
method: "GET",
|
|
99
|
-
label: "issue show",
|
|
100
|
-
timeoutMs: READ_DEADLINE_MS,
|
|
101
|
-
});
|
|
102
|
-
if (!answer.ok)
|
|
103
|
-
return failAgentDoor(door, TAG, answer.reason, answer.detail);
|
|
104
|
-
const issue = answer.body.issue;
|
|
105
|
-
if (!issue) {
|
|
106
|
-
return failAgentDoor(door, TAG, "issue_not_found_or_unreadable", `That issue does not exist, or you cannot read it: ${ref}`);
|
|
107
|
-
}
|
|
108
|
-
// A comment read that fails does NOT fail the whole `show` — the issue is
|
|
109
|
-
// already in hand, and a named gap beats losing what was read.
|
|
110
|
-
const commentsAnswer = await askAgentDoor(door, {
|
|
111
|
-
path: `/api/work/issues/${encodeURIComponent(issue.id)}/comments`,
|
|
112
|
-
method: "GET",
|
|
113
|
-
label: "issue show comments",
|
|
114
|
-
timeoutMs: READ_DEADLINE_MS,
|
|
115
|
-
});
|
|
116
|
-
const comments = commentsAnswer.ok
|
|
117
|
-
? (commentsAnswer.body.comments ?? [])
|
|
118
|
-
: [];
|
|
119
|
-
if (!commentsAnswer.ok) {
|
|
120
|
-
writeLine(door.io.stderr, `${TAG} comments unread ${JSON.stringify({ reason: commentsAnswer.reason, issue_id: issue.id })}`);
|
|
121
|
-
}
|
|
122
|
-
// BLI-3779: during the dual-run a Tower-native row stands for a Linear
|
|
123
|
-
// ticket, and says so in its own title (`… [BLI-3779]`). Read it out rather
|
|
124
|
-
// than making every caller parse the title — `cockpit start --ticket` binds
|
|
125
|
-
// a session to both ids, and this is where a person checks which they are.
|
|
126
|
-
const linearTicketId = mirroredTicketIdInTitle(issue.title ?? "");
|
|
127
|
-
if (door.json) {
|
|
128
|
-
return emitAgentDoor(door, {
|
|
129
|
-
ok: true,
|
|
130
|
-
issue,
|
|
131
|
-
comments,
|
|
132
|
-
...(linearTicketId ? { linear_ticket_id: linearTicketId } : {}),
|
|
133
|
-
...(commentsAnswer.ok ? {} : { comments_unread_reason: commentsAnswer.reason }),
|
|
134
|
-
});
|
|
135
|
-
}
|
|
136
|
-
writeLine(door.io.stdout, `${issue.identifier} ${issue.title}`);
|
|
137
|
-
writeLine(door.io.stdout, `${issue.state} · priority ${issue.priority ?? 0} · assignee ${issue.assignee_id ?? "(nobody)"} · updated ${issue.updated_at}`);
|
|
138
|
-
writeLine(door.io.stdout, `id ${issue.id}${issue.parent_id ? ` · parent ${issue.parent_id}` : ""}`);
|
|
139
|
-
if (linearTicketId)
|
|
140
|
-
writeLine(door.io.stdout, `Linear: ${linearTicketId}`);
|
|
141
|
-
writeLine(door.io.stdout, "");
|
|
142
|
-
writeLine(door.io.stdout, issue.description ?? "(no description)");
|
|
143
|
-
if (comments.length > 0) {
|
|
144
|
-
writeLine(door.io.stdout, "");
|
|
145
|
-
writeLine(door.io.stdout, `--- ${comments.length} comment(s) ---`);
|
|
146
|
-
for (const comment of comments) {
|
|
147
|
-
writeLine(door.io.stdout, "");
|
|
148
|
-
writeLine(door.io.stdout, `[${comment.created_at}] ${comment.author_name ?? comment.author_id ?? "unknown"}`);
|
|
149
|
-
writeLine(door.io.stdout, comment.body_markdown);
|
|
150
|
-
}
|
|
151
|
-
}
|
|
152
|
-
else if (commentsAnswer.ok) {
|
|
153
|
-
writeLine(door.io.stdout, "");
|
|
154
|
-
writeLine(door.io.stdout, "No comments.");
|
|
155
|
-
}
|
|
156
|
-
return 0;
|
|
157
|
-
}
|
|
158
141
|
/**
|
|
159
142
|
* What a history row actually says. A row imported from Linear's issue
|
|
160
143
|
* TIMESTAMPS (created/started/completed/canceled) carries no state names at
|
|
@@ -157,6 +157,7 @@ export const SLACK_WORKSPACE_KEYS = ["bli", "blue_pearl"];
|
|
|
157
157
|
* `cockpit slack read [--person|--channel|--query|--since|--until|--limit]`
|
|
158
158
|
* (BLI-3462).
|
|
159
159
|
*/
|
|
160
|
+
const SLACK_READ_FORMATS = ["compact", "full"];
|
|
160
161
|
export function parseSlackArgs(args) {
|
|
161
162
|
const values = parseNamedArgs(args, {
|
|
162
163
|
allowedFlags: [
|
|
@@ -164,12 +165,15 @@ export function parseSlackArgs(args) {
|
|
|
164
165
|
"--dashboard-url",
|
|
165
166
|
"--workspace",
|
|
166
167
|
"--stale-only",
|
|
168
|
+
"--list",
|
|
167
169
|
"--person",
|
|
168
170
|
"--channel",
|
|
169
171
|
"--query",
|
|
172
|
+
"--word",
|
|
170
173
|
"--since",
|
|
171
174
|
"--until",
|
|
172
175
|
"--limit",
|
|
176
|
+
"--format",
|
|
173
177
|
"--json",
|
|
174
178
|
],
|
|
175
179
|
valueFlags: [
|
|
@@ -182,6 +186,7 @@ export function parseSlackArgs(args) {
|
|
|
182
186
|
"--since",
|
|
183
187
|
"--until",
|
|
184
188
|
"--limit",
|
|
189
|
+
"--format",
|
|
185
190
|
],
|
|
186
191
|
});
|
|
187
192
|
if (values.positionals.length > 1) {
|
|
@@ -196,6 +201,12 @@ export function parseSlackArgs(args) {
|
|
|
196
201
|
dashboardUrl: optionalUrl(values.flags.get("--dashboard-url")),
|
|
197
202
|
json: values.booleans.has("--json"),
|
|
198
203
|
};
|
|
204
|
+
if (values.flags.has("--format") && rawAction !== "read") {
|
|
205
|
+
throw new Error("--format belongs to `cockpit slack read`.");
|
|
206
|
+
}
|
|
207
|
+
if (values.booleans.has("--list") && rawAction !== "coverage") {
|
|
208
|
+
throw new Error("--list belongs to `cockpit slack coverage`.");
|
|
209
|
+
}
|
|
199
210
|
if (rawAction === "coverage") {
|
|
200
211
|
// `--workspace` here is a Slack workspace key, NOT the collection-root
|
|
201
212
|
// `--workspace <path>` every setup command takes. Named at the point of use
|
|
@@ -209,6 +220,8 @@ export function parseSlackArgs(args) {
|
|
|
209
220
|
action: "coverage",
|
|
210
221
|
workspace,
|
|
211
222
|
staleOnly: values.booleans.has("--stale-only"),
|
|
223
|
+
// BLI-10450: counts alone could not answer "is there a Kaiben channel?".
|
|
224
|
+
list: values.booleans.has("--list"),
|
|
212
225
|
...base,
|
|
213
226
|
};
|
|
214
227
|
}
|
|
@@ -219,15 +232,27 @@ export function parseSlackArgs(args) {
|
|
|
219
232
|
throw new Error("slack read needs at least one of --person, --channel or --query. An unfiltered dump of every " +
|
|
220
233
|
"message is not something this command offers.");
|
|
221
234
|
}
|
|
235
|
+
const word = values.booleans.has("--word");
|
|
236
|
+
if (word && !query) {
|
|
237
|
+
throw new Error("--word needs --query: it says how the query text is matched.");
|
|
238
|
+
}
|
|
239
|
+
const format = optionalNonEmpty(values.flags.get("--format"));
|
|
240
|
+
if (format && !SLACK_READ_FORMATS.includes(format)) {
|
|
241
|
+
throw new Error(`slack read --format must be one of ${SLACK_READ_FORMATS.join(", ")}; got ${format}.`);
|
|
242
|
+
}
|
|
222
243
|
return {
|
|
223
244
|
kind: "slack",
|
|
224
245
|
action: "read",
|
|
225
246
|
person,
|
|
226
247
|
channel,
|
|
227
248
|
query,
|
|
249
|
+
...(word ? { word } : {}),
|
|
228
250
|
since: optionalNonEmpty(values.flags.get("--since")),
|
|
229
251
|
until: optionalNonEmpty(values.flags.get("--until")),
|
|
230
252
|
limit: optionalPositiveInteger(values.flags.get("--limit"), "--limit"),
|
|
253
|
+
// BLI-10450: every result printed twice (a summary, then blocks) — one
|
|
254
|
+
// rendering now, never both. `full` (default) keeps the detailed blocks.
|
|
255
|
+
format: format ?? "full",
|
|
231
256
|
...base,
|
|
232
257
|
};
|
|
233
258
|
}
|
|
@@ -7,8 +7,11 @@
|
|
|
7
7
|
* added both without stretching either existing family's own doc comment.
|
|
8
8
|
*/
|
|
9
9
|
import { optionalNonEmpty, optionalPositiveInteger, optionalUrl, parseNamedArgs, } from "./local-arg-values.js";
|
|
10
|
-
const DOCS_ACTIONS = new Set(["list", "tree", "read", "create", "update"]);
|
|
11
|
-
|
|
10
|
+
const DOCS_ACTIONS = new Set(["list", "tree", "read", "create", "update", "link", "sync-status"]);
|
|
11
|
+
// BLI-10450: `read` moved to its own branch in `parseDocsDocumentReference` —
|
|
12
|
+
// it takes SEVERAL references now, so it no longer shares the "exactly one"
|
|
13
|
+
// rule `update`/`link` still enforce.
|
|
14
|
+
const DOCS_ACTIONS_NEEDING_A_DOC = new Set(["update", "link"]);
|
|
12
15
|
export function parseDocsArgs(args) {
|
|
13
16
|
const values = parseNamedArgs(args, {
|
|
14
17
|
allowedFlags: [
|
|
@@ -22,36 +25,115 @@ export function parseDocsArgs(args) {
|
|
|
22
25
|
"--file",
|
|
23
26
|
"--body-stdin",
|
|
24
27
|
"--query",
|
|
28
|
+
"--word",
|
|
25
29
|
"--limit",
|
|
30
|
+
"--clear",
|
|
31
|
+
"--bodies",
|
|
32
|
+
"--section",
|
|
33
|
+
"--offset",
|
|
26
34
|
"--json",
|
|
27
35
|
],
|
|
28
|
-
valueFlags: [
|
|
36
|
+
valueFlags: [
|
|
37
|
+
"--home",
|
|
38
|
+
"--dashboard-url",
|
|
39
|
+
"--title",
|
|
40
|
+
"--parent",
|
|
41
|
+
"--visibility",
|
|
42
|
+
"--file",
|
|
43
|
+
"--query",
|
|
44
|
+
"--limit",
|
|
45
|
+
"--section",
|
|
46
|
+
"--offset",
|
|
47
|
+
],
|
|
29
48
|
});
|
|
30
49
|
const { action, rest } = parseDocsAction(values.positionals);
|
|
31
|
-
const
|
|
32
|
-
const {
|
|
33
|
-
|
|
50
|
+
const link = parseDocsLink(action, rest, values.booleans.has("--clear"));
|
|
51
|
+
const { docRef, docRefs } = parseDocsDocumentReference(action, link.rest);
|
|
52
|
+
const { visibility, clearParent, parentId, allowEmpty, title, query, wholeWord, limit, bodies, section, offset } = parseDocsFlags(action, values);
|
|
53
|
+
if (action === "read")
|
|
54
|
+
validateDocsReadTargets(docRefs, parentId, bodies);
|
|
55
|
+
return buildDocsCommand(values, action, docRef, docRefs, visibility, clearParent, parentId, allowEmpty, title, query, wholeWord, limit, bodies, section, offset, link);
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* BLI-10450: `read` takes either several document references OR
|
|
59
|
+
* `--parent <id> --bodies` (every document filed directly under that
|
|
60
|
+
* parent, with bodies) — never both, and never neither. Checked once flags
|
|
61
|
+
* are known, not in `parseDocsDocumentReference`, because "was --bodies
|
|
62
|
+
* given" decides which of the two empty-input refusals is the right one.
|
|
63
|
+
*/
|
|
64
|
+
function validateDocsReadTargets(docRefs, parentId, bodies) {
|
|
65
|
+
const hasRefs = Boolean(docRefs && docRefs.length > 0);
|
|
66
|
+
if (hasRefs && parentId) {
|
|
67
|
+
throw new Error("docs read takes either document references or --parent --bodies, not both.");
|
|
68
|
+
}
|
|
69
|
+
if (bodies && !parentId) {
|
|
70
|
+
throw new Error("--bodies needs --parent, e.g. `cockpit docs read --parent <id> --bodies`.");
|
|
71
|
+
}
|
|
72
|
+
if (parentId && !bodies) {
|
|
73
|
+
throw new Error("docs read --parent needs --bodies too (or use `cockpit docs list --parent <id>` for metadata only).");
|
|
74
|
+
}
|
|
75
|
+
if (!hasRefs && !(parentId && bodies)) {
|
|
76
|
+
throw new Error("docs read needs one or more document ids/slugs, or --parent <id> --bodies.");
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* BLI-10430: `docs link <id|slug> <notion-url|page-id>` ties a document to a
|
|
81
|
+
* Notion page; `docs link <id|slug> --clear` unties it. Exactly one of the two:
|
|
82
|
+
* a link with neither says nothing, and one with both says two things.
|
|
83
|
+
*/
|
|
84
|
+
function parseDocsLink(action, rest, clear) {
|
|
85
|
+
if (action !== "link") {
|
|
86
|
+
if (clear)
|
|
87
|
+
throw new Error("--clear belongs to `cockpit docs link`.");
|
|
88
|
+
return { rest };
|
|
89
|
+
}
|
|
90
|
+
// No document at all: the document-reference check says so, first.
|
|
91
|
+
if (rest.length === 0)
|
|
92
|
+
return { rest };
|
|
93
|
+
const notionPage = optionalNonEmpty(rest[1]);
|
|
94
|
+
if (clear && notionPage)
|
|
95
|
+
throw new Error("docs link takes a Notion page OR --clear, not both.");
|
|
96
|
+
if (!clear && !notionPage) {
|
|
97
|
+
throw new Error("docs link needs a Notion page URL or id after the document, or --clear to unlink it.");
|
|
98
|
+
}
|
|
99
|
+
if (rest.length > 2)
|
|
100
|
+
throw new Error(`docs link takes a document and a Notion page, not ${rest.length} arguments.`);
|
|
101
|
+
return { rest: rest.slice(0, 1), ...(notionPage ? { notionPage } : {}), ...(clear ? { clearLink: true } : {}) };
|
|
34
102
|
}
|
|
35
103
|
function parseDocsAction(positionals) {
|
|
36
104
|
const first = positionals[0];
|
|
37
105
|
const action = (first === undefined ? "list" : first);
|
|
38
106
|
if (!DOCS_ACTIONS.has(action)) {
|
|
39
|
-
throw new Error(`Unknown docs command: ${first}. Try list, tree, read, create, or
|
|
107
|
+
throw new Error(`Unknown docs command: ${first}. Try list, tree, read, create, update, link, or sync-status.`);
|
|
40
108
|
}
|
|
41
109
|
return { action, rest: positionals.slice(first === undefined ? 0 : 1) };
|
|
42
110
|
}
|
|
43
111
|
function parseDocsDocumentReference(action, rest) {
|
|
112
|
+
// BLI-10450: `read` alone takes several references in one call — an agent
|
|
113
|
+
// reading a folder's worth of documents used to pay a resolve-then-read
|
|
114
|
+
// pair PER document. Emptiness is validated later, once `--parent`/
|
|
115
|
+
// `--bodies` are known (`validateDocsReadTargets`).
|
|
116
|
+
if (action === "read") {
|
|
117
|
+
const refs = rest.map((token) => optionalNonEmpty(token)).filter((token) => Boolean(token));
|
|
118
|
+
return { docRef: refs[0], docRefs: refs.length > 0 ? refs : undefined };
|
|
119
|
+
}
|
|
44
120
|
if (DOCS_ACTIONS_NEEDING_A_DOC.has(action)) {
|
|
45
121
|
const docRef = optionalNonEmpty(rest[0]);
|
|
46
122
|
if (!docRef)
|
|
47
123
|
throw new Error(`docs ${action} needs a document id or slug.`);
|
|
48
124
|
if (rest.length > 1)
|
|
49
125
|
throw new Error(`docs ${action} takes one document reference, not ${rest.length}.`);
|
|
50
|
-
return docRef;
|
|
126
|
+
return { docRef, docRefs: undefined };
|
|
127
|
+
}
|
|
128
|
+
// BLI-10430: `sync-status` reports the whole linked library, or one document.
|
|
129
|
+
if (action === "sync-status") {
|
|
130
|
+
if (rest.length > 1)
|
|
131
|
+
throw new Error(`docs sync-status takes at most one document reference, not ${rest.length}.`);
|
|
132
|
+
return { docRef: optionalNonEmpty(rest[0]), docRefs: undefined };
|
|
51
133
|
}
|
|
52
134
|
if (rest.length > 0)
|
|
53
135
|
throw new Error(`docs ${action} does not take "${rest[0]}".`);
|
|
54
|
-
return undefined;
|
|
136
|
+
return { docRef: undefined, docRefs: undefined };
|
|
55
137
|
}
|
|
56
138
|
function parseDocsFlags(action, values) {
|
|
57
139
|
const visibility = optionalNonEmpty(values.flags.get("--visibility"));
|
|
@@ -86,19 +168,48 @@ function parseDocsFlags(action, values) {
|
|
|
86
168
|
if (query && action !== "list") {
|
|
87
169
|
throw new Error("--query belongs to `cockpit docs list`.");
|
|
88
170
|
}
|
|
171
|
+
const wholeWord = values.booleans.has("--word");
|
|
172
|
+
if (wholeWord && !query) {
|
|
173
|
+
throw new Error("--word needs --query: it says how the query text is matched.");
|
|
174
|
+
}
|
|
175
|
+
// BLI-10450: `--limit` now also belongs to `read`, where it means two
|
|
176
|
+
// different things depending on the mode it is used in — a row cap in
|
|
177
|
+
// `--parent --bodies` mode (matching `list`'s own meaning), or a character
|
|
178
|
+
// cap when slicing an explicit reference's body. The two modes are mutually
|
|
179
|
+
// exclusive (`validateDocsReadTargets`), so the flag is never ambiguous in
|
|
180
|
+
// any one call, only overloaded across the command's two shapes.
|
|
89
181
|
const limit = optionalPositiveInteger(values.flags.get("--limit"), "--limit");
|
|
90
|
-
if (limit !== undefined && action !== "list") {
|
|
91
|
-
throw new Error("--limit belongs to `cockpit docs list`.");
|
|
182
|
+
if (limit !== undefined && action !== "list" && action !== "read") {
|
|
183
|
+
throw new Error("--limit belongs to `cockpit docs list` or `cockpit docs read`.");
|
|
184
|
+
}
|
|
185
|
+
// BLI-10450: `--bodies` pairs with `--parent` to bulk-read a folder;
|
|
186
|
+
// `--section`/`--offset` slice an explicit reference's body, mirroring
|
|
187
|
+
// `cockpit workbook --section`.
|
|
188
|
+
const bodies = values.booleans.has("--bodies");
|
|
189
|
+
if (bodies && action !== "read")
|
|
190
|
+
throw new Error("--bodies belongs to `cockpit docs read`.");
|
|
191
|
+
const section = optionalNonEmpty(values.flags.get("--section"));
|
|
192
|
+
if (section && action !== "read")
|
|
193
|
+
throw new Error("--section belongs to `cockpit docs read`.");
|
|
194
|
+
const offsetRaw = optionalNonEmpty(values.flags.get("--offset"));
|
|
195
|
+
let offset;
|
|
196
|
+
if (offsetRaw !== undefined) {
|
|
197
|
+
offset = Number(offsetRaw);
|
|
198
|
+
if (!Number.isInteger(offset) || offset < 0)
|
|
199
|
+
throw new Error("--offset must be a whole number 0 or greater.");
|
|
92
200
|
}
|
|
93
|
-
|
|
201
|
+
if (offset !== undefined && action !== "read")
|
|
202
|
+
throw new Error("--offset belongs to `cockpit docs read`.");
|
|
203
|
+
return { visibility, clearParent, parentId, allowEmpty, title, query, wholeWord, limit, bodies, section, offset };
|
|
94
204
|
}
|
|
95
|
-
function buildDocsCommand(values, action, docRef, visibility, clearParent, parentId, allowEmpty, title, query, limit) {
|
|
205
|
+
function buildDocsCommand(values, action, docRef, docRefs, visibility, clearParent, parentId, allowEmpty, title, query, wholeWord, limit, bodies, section, offset, link) {
|
|
96
206
|
return {
|
|
97
207
|
kind: "docs",
|
|
98
208
|
action,
|
|
99
209
|
homeDir: optionalNonEmpty(values.flags.get("--home")),
|
|
100
210
|
dashboardUrl: optionalUrl(values.flags.get("--dashboard-url")),
|
|
101
211
|
...(docRef ? { docRef } : {}),
|
|
212
|
+
...(docRefs ? { docRefs } : {}),
|
|
102
213
|
title,
|
|
103
214
|
...(parentId ? { parentId } : {}),
|
|
104
215
|
clearParent,
|
|
@@ -107,7 +218,13 @@ function buildDocsCommand(values, action, docRef, visibility, clearParent, paren
|
|
|
107
218
|
filePath: optionalNonEmpty(values.flags.get("--file")),
|
|
108
219
|
bodyStdin: values.booleans.has("--body-stdin"),
|
|
109
220
|
...(query ? { query } : {}),
|
|
221
|
+
...(wholeWord ? { wholeWord } : {}),
|
|
110
222
|
...(limit === undefined ? {} : { limit }),
|
|
223
|
+
...(bodies ? { bodies } : {}),
|
|
224
|
+
...(section ? { section } : {}),
|
|
225
|
+
...(offset === undefined ? {} : { offset }),
|
|
226
|
+
...(link.notionPage ? { notionPage: link.notionPage } : {}),
|
|
227
|
+
...(link.clearLink ? { clearLink: true } : {}),
|
|
111
228
|
json: values.booleans.has("--json"),
|
|
112
229
|
};
|
|
113
230
|
}
|
|
@@ -73,14 +73,14 @@ export function parseIssueArgs(args) {
|
|
|
73
73
|
],
|
|
74
74
|
});
|
|
75
75
|
const { action, rest } = readIssueAction(values);
|
|
76
|
-
const { issueRef, moveState } = readIssueReference(action, rest);
|
|
76
|
+
const { issueRef, issueRefs, moveState } = readIssueReference(action, rest);
|
|
77
77
|
validateIssueState(moveState, "issue move state must be one of: ");
|
|
78
78
|
const stateFilter = readIssueStateFilter(action, values);
|
|
79
79
|
const title = readIssueTitle(action, values);
|
|
80
80
|
const priority = readIssuePriority(values);
|
|
81
81
|
const assignee = readIssueAssignee(action, values);
|
|
82
82
|
const limit = readIssueLimit(action, values);
|
|
83
|
-
return buildIssueCommand(values, action, issueRef, moveState, stateFilter, title, priority, assignee, limit);
|
|
83
|
+
return buildIssueCommand(values, action, issueRef, issueRefs, moveState, stateFilter, title, priority, assignee, limit);
|
|
84
84
|
}
|
|
85
85
|
function readIssueAction(values) {
|
|
86
86
|
const first = values.positionals[0];
|
|
@@ -92,8 +92,19 @@ function readIssueAction(values) {
|
|
|
92
92
|
}
|
|
93
93
|
function readIssueReference(action, rest) {
|
|
94
94
|
let issueRef;
|
|
95
|
+
let issueRefs;
|
|
95
96
|
let moveState;
|
|
96
|
-
|
|
97
|
+
// BLI-10450: `show` alone takes several references in one call — an agent
|
|
98
|
+
// reading a sprint's worth of tickets used to pay one process per issue.
|
|
99
|
+
// Every other verb here still names exactly one issue.
|
|
100
|
+
if (action === "show") {
|
|
101
|
+
const refs = rest.map((token) => optionalNonEmpty(token)).filter((token) => Boolean(token));
|
|
102
|
+
if (refs.length === 0)
|
|
103
|
+
throw new Error("issue show needs one or more issue ids or identifiers, e.g. BLI-3654.");
|
|
104
|
+
issueRefs = refs;
|
|
105
|
+
issueRef = refs[0];
|
|
106
|
+
}
|
|
107
|
+
else if (ISSUE_ACTIONS_NEEDING_AN_ISSUE.has(action)) {
|
|
97
108
|
issueRef = optionalNonEmpty(rest[0]);
|
|
98
109
|
if (!issueRef)
|
|
99
110
|
throw new Error(`issue ${action} needs an issue id or identifier, e.g. BLI-3654.`);
|
|
@@ -112,7 +123,7 @@ function readIssueReference(action, rest) {
|
|
|
112
123
|
else if (rest.length > 0) {
|
|
113
124
|
throw new Error(`issue ${action} does not take "${rest[0]}".`);
|
|
114
125
|
}
|
|
115
|
-
return { issueRef, moveState };
|
|
126
|
+
return { issueRef, issueRefs, moveState };
|
|
116
127
|
}
|
|
117
128
|
function validateIssueState(state, errorPrefix) {
|
|
118
129
|
if (state && !ISSUE_STATES.includes(state)) {
|
|
@@ -166,13 +177,14 @@ function readIssueLimit(action, values) {
|
|
|
166
177
|
}
|
|
167
178
|
return limit;
|
|
168
179
|
}
|
|
169
|
-
function buildIssueCommand(values, action, issueRef, moveState, stateFilter, title, priority, assignee, limit) {
|
|
180
|
+
function buildIssueCommand(values, action, issueRef, issueRefs, moveState, stateFilter, title, priority, assignee, limit) {
|
|
170
181
|
return {
|
|
171
182
|
kind: "issue",
|
|
172
183
|
action,
|
|
173
184
|
homeDir: optionalNonEmpty(values.flags.get("--home")),
|
|
174
185
|
dashboardUrl: optionalUrl(values.flags.get("--dashboard-url")),
|
|
175
186
|
...(issueRef ? { issueRef } : {}),
|
|
187
|
+
...(issueRefs ? { issueRefs } : {}),
|
|
176
188
|
...(moveState ? { moveState } : {}),
|
|
177
189
|
...(stateFilter ? { stateFilter } : {}),
|
|
178
190
|
...(assignee ? { assignee } : {}),
|
|
@@ -42,18 +42,25 @@ export const TOWER_COMMAND_HELP = [
|
|
|
42
42
|
[
|
|
43
43
|
"docs",
|
|
44
44
|
[
|
|
45
|
-
"Usage: cockpit docs [list|tree|read <id|slug
|
|
45
|
+
"Usage: cockpit docs [list|tree|read <id|slug> [<id|slug>...]|read --parent <id> --bodies|create|update <id>|link <id|slug> <notion-page>|sync-status [<id|slug>]] [flags]",
|
|
46
46
|
"",
|
|
47
47
|
"The Tower document library, typed. Bare `cockpit docs` lists every document you may read.",
|
|
48
|
-
"list [--parent <id>|root] [--query <text>] [--limit <n>] — every document you may see: id, visibility, body size, slug, title. Metadata only — a list never carries a body, so it stays cheap enough for an agent to call (--json was 604 KB for 155 documents before BLI-3737, and is now a few tens of KB).",
|
|
48
|
+
"list [--parent <id>|root] [--query <text> [--word]] [--limit <n>] — every document you may see: id, visibility, body size, slug, title. Metadata only — a list never carries a body, so it stays cheap enough for an agent to call (--json was 604 KB for 155 documents before BLI-3737, and is now a few tens of KB). --json shape: { documents: [{ id, slug, title, visibility, parent_id, source, updated_at, body_chars }] }.",
|
|
49
49
|
" --parent <id> lists the documents filed directly under that document; --parent root lists the top of the tree.",
|
|
50
50
|
" --query <text> keeps the documents whose title OR body contains that text, matched case-insensitively in the database — never fuzzy, and the body still never comes back.",
|
|
51
|
+
" --word matches --query as whole words: `--query ott --word` finds \"Ott\" but not \"bottom\" or \"Otter\".",
|
|
51
52
|
" --limit <n> caps the rows (1-1000). Narrow with these before reading: `cockpit docs list --query onboarding --json` then `cockpit docs read <slug>`.",
|
|
52
53
|
"tree — the same documents nested under their parent, for a sidebar-shaped view.",
|
|
53
|
-
"read <id|slug> — one
|
|
54
|
-
"
|
|
55
|
-
"
|
|
54
|
+
"read <id|slug> [<id|slug>...] — one or more documents' title, slug, visibility, and body. This is the ONLY verb that returns a body. Several refs resolve against ONE shared list call, then one GET each — one bad ref never loses the rest.",
|
|
55
|
+
" --section \"<heading>\" — one section only, from that heading (any # level, case-insensitive) to the next heading at the same or shallower level. Mirrors `cockpit workbook --section`. A miss refuses (document_section_not_found) and names the headings that ARE there.",
|
|
56
|
+
" --offset <n> --limit <n> — a character range of the body, instead of the whole thing.",
|
|
57
|
+
" --json shape: one ref answers { document }, unchanged from before BLI-10450. Several refs answer { documents: [{ ref, ok, document } | { ref, ok: false, error, detail }] }.",
|
|
58
|
+
"read --parent <id>|root --bodies [--limit <n>] — every document filed directly under that parent, bodies included, in ONE call (GET /api/docs/documents?parent_id=...&bodies=true). --limit here caps the DOCUMENT COUNT (1-50), not characters. --json shape: { documents: [{ id, ..., body_markdown }] }.",
|
|
59
|
+
"create --title \"<title>\" [--parent <id>] [--visibility org|private] [--file <path>|--body-stdin] — the body comes from --file, or stdin (`cat body.md | cockpit docs create --title \"...\"`); neither means an empty body, matching the browser's own default. --json shape: { document }.",
|
|
60
|
+
"update <id|slug> [--title \"<t>\"] [--visibility org|private] [--parent <id>|--clear-parent] [--file <path>|--body-stdin] [--allow-empty] — only the fields you pass change; a `--parent` change IS a move, there is no separate move verb. --json shape: { document }.",
|
|
56
61
|
" --allow-empty clears the page on purpose. A body that would empty a document that holds text is refused (refused_empty_body) unless you say so, because far more often it is a surface that lost the content than a person who meant it.",
|
|
62
|
+
"link <id|slug> <notion-url|page-id> — ties the document to a Notion page so the two-way sync moves it both ways. The page is the truth from then on: the next sync pulls it over this copy and keeps the copy as a version. `link <id|slug> --clear` unties it. Refusals: invalid_notion_page, notion_page_already_linked, document_not_writable.",
|
|
63
|
+
"sync-status [<id|slug>] — every linked document's Notion sync state, or one's: synced (the body is what both sides last agreed on), tower_changed (edited here since; the next write-back pushes it), never_synced (linked, no baseline yet). Tower's half only: a Notion edit shows up at the next pull, not here. Metadata only, never a body.",
|
|
57
64
|
"A document body is never accepted on the command line — --file (safest on Windows) or a pipe only, same discipline as `cockpit notes paste`.",
|
|
58
65
|
"--json writes one machine-readable object to stdout; every reason and receipt line stays on stderr.",
|
|
59
66
|
"A refusal keeps Tower's own reason label — needs_rls_client, document_not_found_or_unreadable, document_not_writable, slug_taken, circular_parent, and so on.",
|
|
@@ -80,13 +87,14 @@ export const TOWER_COMMAND_HELP = [
|
|
|
80
87
|
[
|
|
81
88
|
"issue",
|
|
82
89
|
[
|
|
83
|
-
"Usage: cockpit issue [list|show <id
|
|
90
|
+
"Usage: cockpit issue [list|show <id> [<id>...]|create|update <id>|move <id> <state>|comment <id>|history <id>] [flags]",
|
|
84
91
|
"",
|
|
85
92
|
"Tower's issue tracker. <id> is a BLI-#### identifier (BLI-3654) or an issue's uuid — both work everywhere an issue is named.",
|
|
86
|
-
"list [--state <s>] [--assignee me|unassigned|<uuid>] [--project <name|id>] [--limit <n>] — the issues you may see, most recently updated first.",
|
|
87
|
-
"show <id> — one
|
|
88
|
-
"
|
|
89
|
-
"
|
|
93
|
+
"list [--state <s>] [--assignee me|unassigned|<uuid>] [--project <name|id>] [--limit <n>] — the issues you may see, most recently updated first. Each row carries assignee_name/assignee_email alongside the bare assignee_id (BLI-10450, one extra `cockpit team members` call for the whole page, never per row). --json shape: { issues: [{ id, identifier, title, state, priority, assignee_id, assignee_name, assignee_email, project_id, parent_id, updated_at }] }.",
|
|
94
|
+
"show <id> [<id>...] — one or more issues: title, state, priority, assignee (name and email, not just its id), description, and every comment on each. Several ids read in one call — one bad id never loses the rest.",
|
|
95
|
+
" --json shape: one id answers { issue, comments }, unchanged from before BLI-10450. Several ids answer { issues: [{ ref, ok, issue, comments } | { ref, ok: false, error, detail }] }.",
|
|
96
|
+
"create --title \"<title>\" [--project <name|id>] [--priority 0-4] [--assignee me|<uuid>] [--parent <id>] [--file <path>] — the DESCRIPTION comes from --file or stdin (`cat plan.md | cockpit issue create --title \"...\"`); neither means no description. --json shape: { issue }.",
|
|
97
|
+
"update <id> [--title \"<t>\"] [--priority 0-4] [--assignee me|<uuid>] [--project <name|id>] [--parent <id>] [--file <path>|--body-stdin] — only the fields you pass change. State does NOT move here; use move. --json shape: { issue }.",
|
|
90
98
|
"move <id> <state> — moves an issue and records the move. States: backlog, todo, in_progress, in_review, done, canceled.",
|
|
91
99
|
"comment <id> — posts a comment. The body is never taken on the command line: `echo \"shipped\" | cockpit issue comment BLI-3654`.",
|
|
92
100
|
"history <id> [--limit <n>] — every recorded state move and reassignment, oldest first.",
|
|
@@ -107,14 +115,14 @@ export const TOWER_COMMAND_HELP = [
|
|
|
107
115
|
" macOS: printf \"%s\" \"abcd efgh ijkl mnop\" | cockpit mail add-imap --address you@gmail.com",
|
|
108
116
|
" PowerShell: \"abcd efgh ijkl mnop\" | cockpit mail add-imap --address you@gmail.com",
|
|
109
117
|
" Make one at myaccount.google.com/apppasswords (2-Step Verification has to be on). A work @buildlaunchiterate.ca address connects in the browser instead.",
|
|
110
|
-
"inbox [--account <id>] [--unread] [--label <l>] [--limit <n>] [--before <iso>] — everything across every mailbox, newest first. * is unread, @ has an attachment.",
|
|
111
|
-
"read <thread> — one conversation with its bodies. Thread ids come from `cockpit mail inbox --json
|
|
112
|
-
"search \"<words>\" [--account <id>] [--limit <n>] — full text over subject and body. Quotes and OR work the way they do in a search box.",
|
|
118
|
+
"inbox [--account <id>] [--unread] [--label <l>] [--limit <n>] [--before <iso>] — everything across every mailbox, newest first. * is unread, @ has an attachment. Every row (text or --json) carries the thread id — `[thread <id>]` in text — so `mail read <thread>` never costs a second --json call to learn it (BLI-10450).",
|
|
119
|
+
"read <thread> — one conversation with its bodies. Thread ids come from `cockpit mail inbox` or `cockpit mail search`, in either --json or plain text.",
|
|
120
|
+
"search \"<words>\" [--account <id>] [--limit <n>] — full text over subject and body. Quotes and OR work the way they do in a search box. Every row (text or --json) carries the thread id.",
|
|
113
121
|
"send --account <id> --to <a[,b]> [--cc <c>] --subject \"<s>\" [--reply-to <message id>] [--file <path>] — the BODY comes from --file or stdin. --account is required: which address this goes out from is yours to say.",
|
|
114
122
|
"attachment <id> --out <path> — downloads one attachment to a file. Attachments are pointers until you ask; nothing is stored in Tower.",
|
|
115
123
|
"sync <account> — reads that mailbox now instead of waiting for the cron. Prints what landed, or the reason it did not.",
|
|
116
124
|
"detach <account> — removes the mailbox, its stored mail and its credential.",
|
|
117
|
-
"--json writes one machine-readable object to stdout; every reason and receipt line stays on stderr.",
|
|
125
|
+
"--json writes one machine-readable object to stdout; every reason and receipt line stays on stderr. Shapes differ by verb: inbox/read answer { messages: [...] }, search answers { matches: [...] } — same MessageRow shape either name, always carrying thread_id.",
|
|
118
126
|
"A refusal keeps Tower's own reason label — needs_rls_client, account_not_found_or_unreadable, account_not_active, google_oauth_not_configured, credential_unavailable, and so on.",
|
|
119
127
|
"The command uses the existing paired device identity. Run `cockpit login` first if this machine is not paired.",
|
|
120
128
|
],
|
|
@@ -372,7 +372,7 @@ export function localSubcommandHelp(command) {
|
|
|
372
372
|
[
|
|
373
373
|
"slack",
|
|
374
374
|
[
|
|
375
|
-
"Usage: cockpit slack [coverage [--workspace bli|blue_pearl] [--stale-only] | read [filters]] [--json]",
|
|
375
|
+
"Usage: cockpit slack [coverage [--workspace bli|blue_pearl] [--stale-only] [--list] | read [filters] [--format compact|full]] [--json]",
|
|
376
376
|
"",
|
|
377
377
|
" cockpit slack coverage",
|
|
378
378
|
" What Cockpit's Slack bot can see: how many channels it is in, what each",
|
|
@@ -380,13 +380,23 @@ export function localSubcommandHelp(command) {
|
|
|
380
380
|
" are open. No message text at all, so anybody signed in can run it.",
|
|
381
381
|
" This is the command that tells an empty search apart from a coverage gap.",
|
|
382
382
|
" --stale-only drops the per-state breakdown and shows just what is behind.",
|
|
383
|
-
"
|
|
383
|
+
" --list names every channel and its readable state — counts alone could not",
|
|
384
|
+
" answer \"is there a Kaiben channel?\". --json's shape: { coverage: { workspaces: [",
|
|
385
|
+
" { workspace, channelsKnown, covered, byVisibility, staleChannels, openSkips,",
|
|
386
|
+
" channels? } ] } } — channels is present only with --list.",
|
|
387
|
+
" cockpit slack read --person <p> | --channel <c> | --query <text> [--word]",
|
|
384
388
|
" The messages themselves, through the same reader JARVIS uses. At least one",
|
|
385
389
|
" filter is required. --since/--until are YYYY-MM-DD; --limit caps the rows.",
|
|
390
|
+
" --query matches a substring; add --word to match whole words, so `excel`",
|
|
391
|
+
" stops matching \"Excellent\".",
|
|
386
392
|
" Reading Slack reads across the team, so this is for the people who do that",
|
|
387
393
|
" job; being refused it never costs you `cockpit slack coverage`.",
|
|
388
394
|
" An empty or partial answer prints Tower's own sentence about WHY, word for",
|
|
389
395
|
" word — none of those reasons means nothing was said.",
|
|
396
|
+
" --format full (default): each message as its own block (channel/author/time,",
|
|
397
|
+
" text, permalink). --format compact: one line per message. Always one rendering,",
|
|
398
|
+
" never both — a result used to print as a summary AND as blocks (BLI-10450).",
|
|
399
|
+
" --json's shape: { result: { status, reason, messages: [...], coverage, note } }.",
|
|
390
400
|
],
|
|
391
401
|
],
|
|
392
402
|
[
|
|
@@ -585,6 +595,8 @@ export function localSubcommandHelp(command) {
|
|
|
585
595
|
"The STORE, over the same /api/memory/* doors the bli-memory MCP server calls:",
|
|
586
596
|
" search \"<question>\" [--container <tag>] [--limit 1..20] [--no-recent]",
|
|
587
597
|
" repeat --container to search more than one space; omit it for this repository's own.",
|
|
598
|
+
" --limit caps the memories (default 5); up to 3 Tower pages and tickets ride beside",
|
|
599
|
+
" them whatever it says. For more Tower rows, use `cockpit search`.",
|
|
588
600
|
" save [--container <tag>] [--custom-id <id>] [--extract] [--file <path>]",
|
|
589
601
|
" the memory comes from stdin or --file, never from the command line:",
|
|
590
602
|
" `echo \"Edward decided X on 2026-09-10\" | cockpit memory save`.",
|