@bli-cockpit/cli 0.2.241 → 0.2.242
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/local-args-tower-msg.js +59 -2
- package/dist/commands/local-args-tower.js +1 -0
- package/dist/commands/local-args.js +2 -0
- package/dist/commands/local-help-commands-tower.js +2 -2
- package/dist/commands/local-help-commands.js +1 -0
- package/dist/commands/local-help.js +2 -0
- package/dist/commands/local.js +2 -0
- package/dist/commands/msg-draft.js +193 -0
- package/dist/commands/msg.js +15 -0
- package/dist/commands/public-root.js +1 -1
- package/dist/commands/push.js +50 -0
- package/package.json +2 -2
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { optionalNonEmpty, optionalPositiveInteger, optionalUrl, parseNamedArgs } from "./local-arg-values.js";
|
|
2
2
|
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
3
|
-
const
|
|
3
|
+
const MSG_DRAFT_VERBS = new Set(["list", "show", "save", "discard", "restore", "send"]);
|
|
4
|
+
const MSG_ACTIONS = new Set(["channels", "read", "send", "thread", "create", "dm", "show", "unread", "edit", "delete", "outcome", "draft"]);
|
|
4
5
|
const MSG_ACTIONS_NEEDING_A_CHANNEL = new Set(["read", "send", "unread"]);
|
|
5
6
|
/**
|
|
6
7
|
* `--members a@x.test,b@y.test` — addresses only, split on commas, never
|
|
@@ -48,6 +49,8 @@ export function parseMsgArgs(args) {
|
|
|
48
49
|
],
|
|
49
50
|
});
|
|
50
51
|
const { action, rest } = parseMsgAction(values.positionals);
|
|
52
|
+
if (action === "draft")
|
|
53
|
+
return parseMsgDraftArgs(values, rest);
|
|
51
54
|
if (values.flags.has("--before") && action !== "read" && action !== "thread") {
|
|
52
55
|
throw new Error("--before belongs to msg read or msg thread.");
|
|
53
56
|
}
|
|
@@ -58,6 +61,60 @@ export function parseMsgArgs(args) {
|
|
|
58
61
|
const writeSafety = parseMsgWriteSafety(action, rest, values);
|
|
59
62
|
return buildMsgCommand(values, action, channelRef, channelName, dmEmail, memberEmails, description, isPrivate, threadId, limit, markRead, unreadFrom, writeSafety);
|
|
60
63
|
}
|
|
64
|
+
/**
|
|
65
|
+
* `cockpit msg draft [list|show|save|discard|restore|send] <channel> [--thread <id>] [--expected-revision <n>]`
|
|
66
|
+
* (BLI-21414). `list` takes no channel; every other verb needs one. `--request-id` belongs to `send`.
|
|
67
|
+
*/
|
|
68
|
+
function parseMsgDraftArgs(values, rest) {
|
|
69
|
+
const first = rest[0];
|
|
70
|
+
let verb = "list";
|
|
71
|
+
let positional = rest;
|
|
72
|
+
if (first !== undefined) {
|
|
73
|
+
if (!MSG_DRAFT_VERBS.has(first)) {
|
|
74
|
+
throw new Error(`Unknown msg draft command: ${first}. Try list, show, save, discard, restore or send.`);
|
|
75
|
+
}
|
|
76
|
+
verb = first;
|
|
77
|
+
positional = rest.slice(1);
|
|
78
|
+
}
|
|
79
|
+
const stray = ["--limit", "--before", "--members", "--description", "--from", "--channel"].find((flag) => values.flags.has(flag));
|
|
80
|
+
if (stray || values.booleans.has("--private") || values.booleans.has("--mark-read")) {
|
|
81
|
+
throw new Error(`${stray ?? "That flag"} does not belong to \`cockpit msg draft\`. It takes a channel, --thread <id> and --expected-revision <n>.`);
|
|
82
|
+
}
|
|
83
|
+
let channelRef;
|
|
84
|
+
if (verb === "list") {
|
|
85
|
+
if (positional.length > 0 || values.flags.has("--thread"))
|
|
86
|
+
throw new Error("msg draft list takes no channel; it lists all your unsent drafts.");
|
|
87
|
+
}
|
|
88
|
+
else {
|
|
89
|
+
channelRef = optionalNonEmpty(positional[0]);
|
|
90
|
+
if (!channelRef)
|
|
91
|
+
throw new Error(`msg draft ${verb} needs a channel id or name, e.g. \`cockpit msg draft ${verb} general\`.`);
|
|
92
|
+
if (positional.length > 1)
|
|
93
|
+
throw new Error(`msg draft ${verb} takes one channel, not ${positional.length}.`);
|
|
94
|
+
}
|
|
95
|
+
const requestId = optionalNonEmpty(values.flags.get("--request-id"));
|
|
96
|
+
if (requestId && (verb !== "send" || !UUID_PATTERN.test(requestId))) {
|
|
97
|
+
throw new Error(verb === "send" ? "--request-id must be a UUID." : "--request-id belongs to `cockpit msg draft send`.");
|
|
98
|
+
}
|
|
99
|
+
const expectedRevision = optionalPositiveInteger(values.flags.get("--expected-revision"), "--expected-revision");
|
|
100
|
+
if (expectedRevision !== undefined && (verb === "list" || verb === "show")) {
|
|
101
|
+
throw new Error("--expected-revision belongs to `msg draft save`, `discard`, `restore` or `send`.");
|
|
102
|
+
}
|
|
103
|
+
const threadId = optionalNonEmpty(values.flags.get("--thread"));
|
|
104
|
+
return {
|
|
105
|
+
kind: "msg",
|
|
106
|
+
action: "draft",
|
|
107
|
+
draftVerb: verb,
|
|
108
|
+
homeDir: optionalNonEmpty(values.flags.get("--home")),
|
|
109
|
+
dashboardUrl: optionalUrl(values.flags.get("--dashboard-url")),
|
|
110
|
+
...(channelRef ? { channelRef } : {}),
|
|
111
|
+
...(threadId ? { threadId } : {}),
|
|
112
|
+
isPrivate: false,
|
|
113
|
+
...(requestId ? { requestId } : {}),
|
|
114
|
+
...(expectedRevision === undefined ? {} : { expectedRevision }),
|
|
115
|
+
json: values.booleans.has("--json"),
|
|
116
|
+
};
|
|
117
|
+
}
|
|
61
118
|
/** `send|edit|delete --request-id`, `edit|delete --expected-revision`, and the id `edit`/`delete`/`outcome` name (BLI-21314). */
|
|
62
119
|
function parseMsgWriteSafety(action, rest, values) {
|
|
63
120
|
const requestId = optionalNonEmpty(values.flags.get("--request-id"));
|
|
@@ -102,7 +159,7 @@ function parseMsgAction(positionals) {
|
|
|
102
159
|
const first = positionals[0];
|
|
103
160
|
const action = (first === undefined ? "channels" : first);
|
|
104
161
|
if (!MSG_ACTIONS.has(action)) {
|
|
105
|
-
throw new Error(`Unknown msg command: ${first}. Try channels, read, unread, send, thread, create, dm, or show; to change a message: edit, delete or outcome.`);
|
|
162
|
+
throw new Error(`Unknown msg command: ${first}. Try channels, read, unread, send, thread, create, dm, or show; to change a message: edit, delete or outcome; for unsent text: draft.`);
|
|
106
163
|
}
|
|
107
164
|
return { action, rest: positionals.slice(first === undefined ? 0 : 1) };
|
|
108
165
|
}
|
|
@@ -54,4 +54,5 @@ export { parseCareersArgs } from './local-args-tower-careers.js';
|
|
|
54
54
|
export { parseRenameArgs } from './local-args-tower-rename.js';
|
|
55
55
|
export { parseGlossaryArgs } from "./glossary.js";
|
|
56
56
|
export { parseSpendArgs } from "./spend.js";
|
|
57
|
+
export { parsePushArgs } from "./push.js";
|
|
57
58
|
export { parseCiArgs } from "./ci.js";
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
*/
|
|
18
18
|
import { parseGlossaryArgs } from "./glossary.js";
|
|
19
19
|
import { parseSpendArgs } from "./spend.js";
|
|
20
|
+
import { parsePushArgs } from "./push.js";
|
|
20
21
|
import { parseRootsArgs } from "./roots.js";
|
|
21
22
|
import { SURFACE_ALIASES } from "./local-help.js";
|
|
22
23
|
import { parseAgentRulesArgs, parseAnalyzeArgs, parseAutostartArgs, parseBackfillArgs, parseCleanArgs, parseDoctorArgs, parseInstallArgs, parseLoginArgs, parseMemoryArgs, parseLogoutArgs, parseOnboardArgs, parseReleaseArgs, parseServeArgs, parseSessionsArgs, parseSourcesArgs, parseStartArgs, parseStatusArgs, parseSyncArgs, parseUpdateArgs, } from "./local-args-collector.js";
|
|
@@ -82,6 +83,7 @@ const COMMAND_PARSERS = new Map([
|
|
|
82
83
|
["sources", (_command, rest) => parseSourcesArgs(rest)],
|
|
83
84
|
["glossary", (_command, rest) => parseGlossaryArgs(rest)],
|
|
84
85
|
["spend", (_command, rest) => parseSpendArgs(rest)],
|
|
86
|
+
["push", (_command, rest) => parsePushArgs(rest)],
|
|
85
87
|
["docs", (_command, rest) => parseDocsArgs(rest)],
|
|
86
88
|
["msg", (_command, rest) => parseMsgArgs(rest)],
|
|
87
89
|
["issue", (_command, rest) => parseIssueArgs(rest)],
|
|
@@ -81,7 +81,7 @@ export const TOWER_COMMAND_HELP = [
|
|
|
81
81
|
[
|
|
82
82
|
"msg",
|
|
83
83
|
[
|
|
84
|
-
"Usage: cockpit msg [channels|create <name>|dm <email>|read <channel>|unread <channel> --from <message-id>|send <channel>|edit <message-id>|delete <message-id>|outcome <request-uuid>|thread <id> --channel <channel>|show <message-id>] [flags]",
|
|
84
|
+
"Usage: cockpit msg [channels|create <name>|dm <email>|read <channel>|unread <channel> --from <message-id>|send <channel>|edit <message-id>|delete <message-id>|outcome <request-uuid>|draft [list|show|save|discard|restore|send] <channel>|thread <id> --channel <channel>|show <message-id>] [flags]",
|
|
85
85
|
"",
|
|
86
86
|
"Channels and messages, typed. <channel> is a channel id, or its name with or without a leading #.",
|
|
87
87
|
"channels — every channel you are a member of (or, as a super_admin, every channel).",
|
|
@@ -90,7 +90,7 @@ export const TOWER_COMMAND_HELP = [
|
|
|
90
90
|
"read <channel> [--limit <n>] [--thread <id>] [--mark-read] — the channel's most recent top-level messages, oldest first; --thread <id> reads one thread's replies instead. The first page also says where the messages come from (for an imported Slack channel: how long ago the archive was checked, or that it is unknown) and where YOUR read line stands. Reading never moves the read line; --mark-read moves it to the newest message shown, and says plainly if that failed.",
|
|
91
91
|
"unread <channel> --from <message-id> — marks that message and everything after it unread, so the web sidebar badge and the NEW MESSAGES line start there. Opening or reading a channel never clears unseen history.",
|
|
92
92
|
"send <channel> [--thread <id>] [--request-id <uuid>] — posts a message. The content is never accepted on the command line: pipe it in, e.g. `echo \"hello\" | cockpit msg send general`. Every send carries a request id (minted when you give none); retry a send whose answer was lost with the SAME --request-id and the same text and it posts once. The same id with different text is refused request_id_reused, not applied as an edit.",
|
|
93
|
-
"edit <message-id> [--expected-revision <n>] [--request-id <uuid>] — replaces your own message's text (piped in). With --expected-revision, an edit of a message that has moved on is refused revision_conflict and nothing is saved over it; the current revision is printed. A message you did not write, or one that was deleted, is refused by name. delete <message-id> [--expected-revision <n>] [--request-id <uuid>] — deletes by tombstone: the text is cleared and every surface reads \"This message was deleted\", but the row stays so its thread, replies and reply count survive. Search and unread counts ignore it. A repeat answers already_deleted. outcome <request-uuid> — your own write receipt for a send, edit or delete: committed, not_committed (with the reason) or unknown_check_again. Run it after a timeout before retrying.",
|
|
93
|
+
"edit <message-id> [--expected-revision <n>] [--request-id <uuid>] — replaces your own message's text (piped in). With --expected-revision, an edit of a message that has moved on is refused revision_conflict and nothing is saved over it; the current revision is printed. A message you did not write, or one that was deleted, is refused by name. delete <message-id> [--expected-revision <n>] [--request-id <uuid>] — deletes by tombstone: the text is cleared and every surface reads \"This message was deleted\", but the row stays so its thread, replies and reply count survive. Search and unread counts ignore it. A repeat answers already_deleted. outcome <request-uuid> — your own write receipt for a send, edit or delete: committed, not_committed (with the reason) or unknown_check_again. Run it after a timeout before retrying. draft [list|show|save|discard|restore|send] <channel> [--thread <id>] [--expected-revision <n>] [--request-id <uuid>] — the text you have not sent yet, one draft per channel and thread, the same drafts the browser autosaves. list shows all your unsent drafts (newest first, with coverage); show prints one; save takes the body on stdin (`echo \"half a thought\" | cockpit msg draft save general`) and with --expected-revision refuses draft_revision_conflict, naming the stored revision, instead of overwriting; discard keeps the text (state discarded) and restore brings it back; send posts the stored draft with a fresh request id (--request-id to retry the same send) and its revision, prints the receipt, and marks the draft sent only if it did not change meanwhile. Only you can read or change your drafts.",
|
|
94
94
|
"thread <id> --channel <channel> [--before <cursor>] [--limit <n>] — one thread's replies by the parent message's id.",
|
|
95
95
|
"show <message-id> — exact message, thread root and bounded neighbors. read/thread --before takes the preceding nextCursor; repeat until hasMore is false.",
|
|
96
96
|
"--json writes one machine-readable object to stdout; every reason and receipt line stays on stderr.",
|
|
@@ -23,6 +23,7 @@ import { TOWER_OPS_COMMAND_HELP } from "./local-help-commands-tower-ops.js";
|
|
|
23
23
|
/** One manual per collector command; the Tower nouns are spread in from their own file. */
|
|
24
24
|
const COMMAND_HELP = new Map([
|
|
25
25
|
["glossary", ["Usage: cockpit glossary list|add <wrong> --to <name>|remove <wrong> [--json]", "Quote phrases. Removing a form prevents future corrections of it."]],
|
|
26
|
+
["push", ["Usage: cockpit push status | test [--dashboard-url <url>] [--json]", "Phone notifications. status lists your saved devices and whether the server has its push keys; test sends a test push to every saved device. Turn a phone on from Settings on the phone."]],
|
|
26
27
|
["spend", ["Usage: cockpit spend memory [--days 1|7|30] [--dashboard-url <url>] [--json]", "Spend by memory operation (write, recall, dream, update_forget, backfill, label, exam, predictor, people, who, intentions, salience, chat, other), ranked top first with list and real dollars, share, trend against the previous window, and how much of the real OpenAI bill the ledger explains. What it does not explain is shown as untagged. A super_admin reads it."]],
|
|
27
28
|
["roots", ["Usage: cockpit roots add <dir> [--allow-home-root] [--json]", "Approves a work directory, saves an approval receipt, and adds it to collection. Run cockpit doctor afterward."]],
|
|
28
29
|
// BLI-3709: the Tower nouns live next door, split along the same seam the
|
|
@@ -72,6 +72,7 @@ export const rootCommandNames = new Set([
|
|
|
72
72
|
"sources",
|
|
73
73
|
"glossary",
|
|
74
74
|
"spend",
|
|
75
|
+
"push",
|
|
75
76
|
"docs",
|
|
76
77
|
"msg",
|
|
77
78
|
"issue",
|
|
@@ -153,6 +154,7 @@ const COMMAND_SUMMARY_LINES = [
|
|
|
153
154
|
" cockpit careers [list|show <id>|rescreen <id>|send-takehome <id>|decide <id> --decision <passed_on|archived|reopen>|followup <id> [--record-sent-at <iso>]|interview <id> <--at <iso>|--clear>|grade <id> [--now]|resume <id> [--out <path>]|takehome show <role>|takehome set <role> --body-file <path>|settings show|settings set --enabled <true|false> --followup-enabled <true|false> --followup-after-days <n> --takehome-window-hours <n>] [--role <slug>] [--min-score <n>] [--since <date>] [--stage <s>] [--force] [--json]",
|
|
154
155
|
" cockpit glossary list|add <wrong> --to <name>|remove <wrong> [--dashboard-url <url>] [--json]",
|
|
155
156
|
" cockpit spend memory [--days 1|7|30] [--dashboard-url <url>] [--json]",
|
|
157
|
+
" cockpit push status|test [--dashboard-url <url>] [--json]",
|
|
156
158
|
" cockpit rename [list|resolve <id> --scope <page|everywhere|undo>] [--dashboard-url <url>] [--json]",
|
|
157
159
|
" cockpit logs <collector|dashboard|railway|railway:<service>|-> [--since 15m|1h|2d|<iso>] [--raw <template-id> [--limit <n>]] [--all-levels] [--table <file>] [--json]",
|
|
158
160
|
" cockpit intentions [list [--status open|overdue|done|dropped|all] [--project <name>] [--limit <n>]|show <id>|done <id> --evidence <text>|drop <id> --evidence <text>|reopen <id>] [--dashboard-url <url>] [--json]",
|
package/dist/commands/local.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { runOpsCommand } from "./local-run-ops.js";
|
|
2
2
|
import { runGlossary } from "./glossary.js";
|
|
3
3
|
import { runSpend } from "./spend.js";
|
|
4
|
+
import { runPush } from "./push.js";
|
|
4
5
|
import { defaultIo, writeLine } from "./cli-io.js";
|
|
5
6
|
import { isLocalHelpRequest, localCommandHelp } from "./local-help.js";
|
|
6
7
|
import { beginStage, endStage } from "../crash-guard.js";
|
|
@@ -196,6 +197,7 @@ async function runTowerCommand(command, io) {
|
|
|
196
197
|
case "notes": return await runNotes(command, io);
|
|
197
198
|
case "glossary": return await runGlossary(command, io);
|
|
198
199
|
case "spend": return await runSpend(command, io);
|
|
200
|
+
case "push": return await runPush(command, io);
|
|
199
201
|
case "docs": return await runDocs(command, io);
|
|
200
202
|
case "msg": return await runMsg(command, io);
|
|
201
203
|
case "issue": return await runIssue(command, io);
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `cockpit msg draft` (BLI-21414): the text you have not sent yet, per channel and thread, the same
|
|
3
|
+
* drafts the browser autosaves. `list`, `show`, `save` (body piped in), `discard`, `restore` and
|
|
4
|
+
* `send` (posts the stored draft with a fresh request id, then prints the receipt). Same doors the
|
|
5
|
+
* browser calls: `/api/msg/drafts`, `/api/msg/drafts/restore` and the channel post door.
|
|
6
|
+
*
|
|
7
|
+
* The channel arrives already resolved to an id by `msg.ts`. Draft text is never logged; stderr lines
|
|
8
|
+
* carry ids, counts and reason labels.
|
|
9
|
+
*/
|
|
10
|
+
import { randomUUID } from "node:crypto";
|
|
11
|
+
import { askAgentDoor, emitAgentDoor, failAgentDoor } from "./agent-door.js";
|
|
12
|
+
import { writeLine } from "./cli-io.js";
|
|
13
|
+
import { readContent, refuseMsgWrite } from "./msg-write.js";
|
|
14
|
+
const TAG = "[msg draft cli]";
|
|
15
|
+
const DEADLINE_MS = 30_000;
|
|
16
|
+
function placeQuery(channelId, command) {
|
|
17
|
+
const query = new URLSearchParams({ channel_id: channelId });
|
|
18
|
+
if (command.threadId)
|
|
19
|
+
query.set("thread_parent_id", command.threadId);
|
|
20
|
+
return query.toString();
|
|
21
|
+
}
|
|
22
|
+
/** A refusal keeps the door's reason. A conflict names the stored revision; the stored text goes to --json only, never to a log line. */
|
|
23
|
+
function refuseDraft(door, answer) {
|
|
24
|
+
const stored = answer.body?.draft ?? null;
|
|
25
|
+
writeLine(door.io.stderr, `${TAG} refused ${JSON.stringify({ reason: answer.reason, stored_revision: stored?.revision ?? null })}`);
|
|
26
|
+
if (door.json) {
|
|
27
|
+
writeLine(door.io.stdout, JSON.stringify({ ok: false, error: answer.reason, detail: answer.detail, draft: stored }));
|
|
28
|
+
}
|
|
29
|
+
else {
|
|
30
|
+
writeLine(door.io.stderr, answer.detail);
|
|
31
|
+
if (answer.reason === "draft_revision_conflict" && stored) {
|
|
32
|
+
writeLine(door.io.stderr, `The stored draft is at revision ${stored.revision}. Read it with \`cockpit msg draft show\`, then save again with --expected-revision ${stored.revision}.`);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return 1;
|
|
36
|
+
}
|
|
37
|
+
export async function runMsgDraft(command, door, channelId) {
|
|
38
|
+
switch (command.draftVerb ?? "list") {
|
|
39
|
+
case "list":
|
|
40
|
+
return listDrafts(door);
|
|
41
|
+
case "show":
|
|
42
|
+
return showDraft(command, door, channelId ?? "");
|
|
43
|
+
case "save":
|
|
44
|
+
return saveDraft(command, door, channelId ?? "");
|
|
45
|
+
case "discard":
|
|
46
|
+
return discardDraft(command, door, channelId ?? "");
|
|
47
|
+
case "restore":
|
|
48
|
+
return restoreDraft(command, door, channelId ?? "");
|
|
49
|
+
case "send":
|
|
50
|
+
return sendDraft(command, door, channelId ?? "");
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
async function listDrafts(door) {
|
|
54
|
+
const answer = await askAgentDoor(door, { path: "/api/msg/drafts", method: "GET", label: "msg draft list", timeoutMs: DEADLINE_MS });
|
|
55
|
+
if (!answer.ok)
|
|
56
|
+
return refuseDraft(door, answer);
|
|
57
|
+
const body = answer.body;
|
|
58
|
+
const drafts = body.drafts ?? [];
|
|
59
|
+
writeLine(door.io.stderr, `${TAG} listed ${JSON.stringify({ returned: drafts.length, coverage: body.coverage ?? "unknown" })}`);
|
|
60
|
+
if (door.json)
|
|
61
|
+
return emitAgentDoor(door, { ok: true, drafts, coverage: body.coverage, limit: body.limit, returned: body.returned ?? drafts.length });
|
|
62
|
+
if (drafts.length === 0) {
|
|
63
|
+
writeLine(door.io.stdout, "No unsent drafts.");
|
|
64
|
+
return 0;
|
|
65
|
+
}
|
|
66
|
+
for (const draft of drafts) {
|
|
67
|
+
writeLine(door.io.stdout, `${draft.channel_id}${draft.thread_parent_id ? ` thread ${draft.thread_parent_id}` : ""} revision ${draft.revision} ${draft.content.length} chars ${draft.updated_at}`);
|
|
68
|
+
}
|
|
69
|
+
if (body.coverage === "capped")
|
|
70
|
+
writeLine(door.io.stdout, `Showing the newest ${body.limit ?? drafts.length}; there are more.`);
|
|
71
|
+
return 0;
|
|
72
|
+
}
|
|
73
|
+
async function showDraft(command, door, channelId) {
|
|
74
|
+
const answer = await askAgentDoor(door, { path: `/api/msg/drafts?${placeQuery(channelId, command)}`, method: "GET", label: "msg draft show", timeoutMs: DEADLINE_MS });
|
|
75
|
+
if (!answer.ok)
|
|
76
|
+
return refuseDraft(door, answer);
|
|
77
|
+
const draft = answer.body.draft ?? null;
|
|
78
|
+
writeLine(door.io.stderr, `${TAG} shown ${JSON.stringify({ channel_id: channelId, found: draft !== null, state: draft?.state ?? "none", revision: draft?.revision ?? null })}`);
|
|
79
|
+
if (door.json)
|
|
80
|
+
return emitAgentDoor(door, { ok: true, draft });
|
|
81
|
+
if (!draft) {
|
|
82
|
+
writeLine(door.io.stdout, "No draft here.");
|
|
83
|
+
return 0;
|
|
84
|
+
}
|
|
85
|
+
writeLine(door.io.stderr, `revision ${draft.revision}, ${draft.state}`);
|
|
86
|
+
writeLine(door.io.stdout, draft.content);
|
|
87
|
+
return 0;
|
|
88
|
+
}
|
|
89
|
+
async function saveDraft(command, door, channelId) {
|
|
90
|
+
const content = await readContent(door.io);
|
|
91
|
+
if (!content.ok)
|
|
92
|
+
return failAgentDoor(door, TAG, content.reason, content.detail);
|
|
93
|
+
if (content.text === "") {
|
|
94
|
+
return failAgentDoor(door, TAG, "invalid_body", "There was nothing to save. Use `cockpit msg draft discard` to drop a draft. Nothing was changed.");
|
|
95
|
+
}
|
|
96
|
+
const answer = await askAgentDoor(door, {
|
|
97
|
+
path: "/api/msg/drafts",
|
|
98
|
+
method: "PUT",
|
|
99
|
+
label: "msg draft save",
|
|
100
|
+
timeoutMs: DEADLINE_MS,
|
|
101
|
+
body: {
|
|
102
|
+
channel_id: channelId,
|
|
103
|
+
thread_parent_id: command.threadId ?? null,
|
|
104
|
+
content: content.text,
|
|
105
|
+
...(command.expectedRevision === undefined ? {} : { expected_revision: command.expectedRevision }),
|
|
106
|
+
},
|
|
107
|
+
});
|
|
108
|
+
if (!answer.ok)
|
|
109
|
+
return refuseDraft(door, answer);
|
|
110
|
+
const result = answer.body;
|
|
111
|
+
writeLine(door.io.stderr, `${TAG} saved ${JSON.stringify({ channel_id: channelId, outcome: result.outcome ?? null, revision: result.draft?.revision ?? null, chars: content.text.length })}`);
|
|
112
|
+
if (door.json)
|
|
113
|
+
return emitAgentDoor(door, { ok: true, ...result });
|
|
114
|
+
writeLine(door.io.stdout, `Saved draft, revision ${result.draft?.revision ?? "?"}.`);
|
|
115
|
+
return 0;
|
|
116
|
+
}
|
|
117
|
+
async function discardDraft(command, door, channelId) {
|
|
118
|
+
const query = placeQuery(channelId, command) + (command.expectedRevision === undefined ? "" : `&expected_revision=${command.expectedRevision}`);
|
|
119
|
+
const answer = await askAgentDoor(door, { path: `/api/msg/drafts?${query}`, method: "DELETE", label: "msg draft discard", timeoutMs: DEADLINE_MS });
|
|
120
|
+
if (!answer.ok)
|
|
121
|
+
return refuseDraft(door, answer);
|
|
122
|
+
const result = answer.body;
|
|
123
|
+
writeLine(door.io.stderr, `${TAG} discarded ${JSON.stringify({ channel_id: channelId, changed: result.changed ?? null, revision: result.draft?.revision ?? null })}`);
|
|
124
|
+
if (door.json)
|
|
125
|
+
return emitAgentDoor(door, { ok: true, ...result });
|
|
126
|
+
writeLine(door.io.stdout, `Discarded the draft (revision ${result.draft?.revision ?? "?"}). Its text is kept; \`cockpit msg draft restore\` brings it back.`);
|
|
127
|
+
return 0;
|
|
128
|
+
}
|
|
129
|
+
async function restoreDraft(command, door, channelId) {
|
|
130
|
+
const answer = await askAgentDoor(door, {
|
|
131
|
+
path: "/api/msg/drafts/restore",
|
|
132
|
+
method: "POST",
|
|
133
|
+
label: "msg draft restore",
|
|
134
|
+
timeoutMs: DEADLINE_MS,
|
|
135
|
+
body: {
|
|
136
|
+
channel_id: channelId,
|
|
137
|
+
thread_parent_id: command.threadId ?? null,
|
|
138
|
+
...(command.expectedRevision === undefined ? {} : { expected_revision: command.expectedRevision }),
|
|
139
|
+
},
|
|
140
|
+
});
|
|
141
|
+
if (!answer.ok)
|
|
142
|
+
return refuseDraft(door, answer);
|
|
143
|
+
const result = answer.body;
|
|
144
|
+
writeLine(door.io.stderr, `${TAG} restored ${JSON.stringify({ channel_id: channelId, changed: result.changed ?? null, revision: result.draft?.revision ?? null })}`);
|
|
145
|
+
if (door.json)
|
|
146
|
+
return emitAgentDoor(door, { ok: true, ...result });
|
|
147
|
+
writeLine(door.io.stdout, `Restored the draft, revision ${result.draft?.revision ?? "?"}.`);
|
|
148
|
+
return 0;
|
|
149
|
+
}
|
|
150
|
+
/** Posts the stored draft with a fresh request id and the draft's revision, then prints the receipt. */
|
|
151
|
+
async function sendDraft(command, door, channelId) {
|
|
152
|
+
const read = await askAgentDoor(door, { path: `/api/msg/drafts?${placeQuery(channelId, command)}`, method: "GET", label: "msg draft send read", timeoutMs: DEADLINE_MS });
|
|
153
|
+
if (!read.ok)
|
|
154
|
+
return refuseDraft(door, read);
|
|
155
|
+
const draft = read.body.draft ?? null;
|
|
156
|
+
if (!draft || draft.state !== "active") {
|
|
157
|
+
return failAgentDoor(door, TAG, "draft_not_found", "There is no unsent draft here to send.", { state: draft?.state ?? "none" });
|
|
158
|
+
}
|
|
159
|
+
if (command.expectedRevision !== undefined && command.expectedRevision !== draft.revision) {
|
|
160
|
+
return refuseDraft(door, {
|
|
161
|
+
reason: "draft_revision_conflict",
|
|
162
|
+
detail: "This draft changed since you read it. Nothing was sent.",
|
|
163
|
+
body: { draft },
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
const requestId = command.requestId ?? randomUUID();
|
|
167
|
+
const answer = await askAgentDoor(door, {
|
|
168
|
+
path: `/api/msg/channels/${encodeURIComponent(channelId)}/messages`,
|
|
169
|
+
method: "POST",
|
|
170
|
+
label: "msg draft send",
|
|
171
|
+
timeoutMs: DEADLINE_MS,
|
|
172
|
+
body: {
|
|
173
|
+
content: draft.content,
|
|
174
|
+
request_id: requestId,
|
|
175
|
+
draft_revision: draft.revision,
|
|
176
|
+
...(command.threadId ? { thread_parent_id: command.threadId } : {}),
|
|
177
|
+
},
|
|
178
|
+
});
|
|
179
|
+
if (!answer.ok)
|
|
180
|
+
return refuseMsgWrite(door, answer, requestId, "send");
|
|
181
|
+
const { message = null, mutation = null, draft: settled = null } = answer.body;
|
|
182
|
+
writeLine(door.io.stderr, `${TAG} sent ${JSON.stringify({
|
|
183
|
+
channel_id: channelId, message_id: message?.id ?? null, request_id: requestId, draft_revision: draft.revision,
|
|
184
|
+
draft_marked: settled?.marked ?? null, draft_reason: settled?.reason ?? null,
|
|
185
|
+
})}`);
|
|
186
|
+
if (door.json)
|
|
187
|
+
return emitAgentDoor(door, { ok: true, message, mutation, draft: settled });
|
|
188
|
+
writeLine(door.io.stdout, `Sent the draft as ${message?.id ?? "?"} (request ${requestId}, ${String(mutation?.["outcome"] ?? "committed")}).`);
|
|
189
|
+
if (settled && settled.marked === false) {
|
|
190
|
+
writeLine(door.io.stdout, `The draft was not marked sent (${settled.reason ?? "unknown"}); it is still saved.`);
|
|
191
|
+
}
|
|
192
|
+
return 0;
|
|
193
|
+
}
|
package/dist/commands/msg.js
CHANGED
|
@@ -20,6 +20,7 @@ import { askAgentDoor, emitAgentDoor, failAgentDoor, openAgentDoor, } from "./ag
|
|
|
20
20
|
import { writeLine } from "./cli-io.js";
|
|
21
21
|
import { cacheAgeLabel, cacheCoverage, readThroughCache } from "./read-cache.js";
|
|
22
22
|
import { markReadThrough } from "./msg-read-line.js";
|
|
23
|
+
import { runMsgDraft } from "./msg-draft.js";
|
|
23
24
|
import { deleteMessage, editMessage, messageOutcome, messageText, readContent, refuseMsgWrite } from "./msg-write.js";
|
|
24
25
|
const TAG = "[msg cli]";
|
|
25
26
|
const READ_DEADLINE_MS = 30_000;
|
|
@@ -43,6 +44,8 @@ export async function runMsg(command, io) {
|
|
|
43
44
|
return deleteMessage(command, door);
|
|
44
45
|
case "outcome":
|
|
45
46
|
return messageOutcome(command, door);
|
|
47
|
+
case "draft":
|
|
48
|
+
return draftVerb(command, door);
|
|
46
49
|
case "thread":
|
|
47
50
|
return readThread(command, door);
|
|
48
51
|
case "create":
|
|
@@ -51,6 +54,18 @@ export async function runMsg(command, io) {
|
|
|
51
54
|
return openDm(command, door);
|
|
52
55
|
}
|
|
53
56
|
}
|
|
57
|
+
/** `msg draft`: `list` needs no channel; every other verb resolves the channel the same way read and send do. */
|
|
58
|
+
async function draftVerb(command, door) {
|
|
59
|
+
if ((command.draftVerb ?? "list") === "list")
|
|
60
|
+
return runMsgDraft(command, door, null);
|
|
61
|
+
const ref = command.channelRef ?? "";
|
|
62
|
+
const resolved = await resolveChannelId(door, ref);
|
|
63
|
+
if (resolved.status === "list_failed")
|
|
64
|
+
return failAgentDoor(door, TAG, resolved.reason, resolved.detail);
|
|
65
|
+
if (resolved.status === "not_found")
|
|
66
|
+
return channelNotFound(door, ref);
|
|
67
|
+
return runMsgDraft(command, door, resolved.id);
|
|
68
|
+
}
|
|
54
69
|
async function fetchChannels(door) {
|
|
55
70
|
const answer = await readThroughCache({
|
|
56
71
|
io: door.io,
|
|
@@ -21,7 +21,7 @@ export async function runCockpitCli(argv, io) {
|
|
|
21
21
|
}
|
|
22
22
|
|
|
23
23
|
if (command === "--version" || command === "-V" || command === "version") {
|
|
24
|
-
writeLine(io?.stdout ?? process.stdout, "0.2.
|
|
24
|
+
writeLine(io?.stdout ?? process.stdout, "0.2.242");
|
|
25
25
|
return 0;
|
|
26
26
|
}
|
|
27
27
|
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `cockpit push status` and `cockpit push test`: phone notifications from the terminal (BLI-21497).
|
|
3
|
+
*
|
|
4
|
+
* `status` reads `GET /api/push/subscriptions` (the caller's own saved devices, and whether the server has its VAPID
|
|
5
|
+
* keys); `test` posts `POST /api/push/test` (a test push to every saved device). The same doors the Settings page and the
|
|
6
|
+
* `push_status` and `push_test` MCP tools use. Turning a phone on is the browser's own permission prompt, so there is no
|
|
7
|
+
* terminal verb for it. `--json` prints the door's body untouched.
|
|
8
|
+
*/
|
|
9
|
+
import { askAgentDoor, emitAgentDoor, failAgentDoor, openAgentDoor } from "./agent-door.js";
|
|
10
|
+
import { writeLine } from "./cli-io.js";
|
|
11
|
+
import { optionalNonEmpty, optionalUrl, parseNamedArgs } from "./local-arg-values.js";
|
|
12
|
+
const USAGE = "Usage: cockpit push status | test [--dashboard-url <url>] [--json]";
|
|
13
|
+
export function parsePushArgs(args) {
|
|
14
|
+
const values = parseNamedArgs(args, { allowedFlags: ["--json", "--home", "--dashboard-url"], valueFlags: ["--home", "--dashboard-url"] });
|
|
15
|
+
const [action, ...rest] = values.positionals;
|
|
16
|
+
if ((action !== "status" && action !== "test") || rest.length > 0)
|
|
17
|
+
throw new Error(USAGE);
|
|
18
|
+
return { kind: "push", action, json: values.booleans.has("--json"), homeDir: optionalNonEmpty(values.flags.get("--home")), dashboardUrl: optionalUrl(values.flags.get("--dashboard-url")) };
|
|
19
|
+
}
|
|
20
|
+
export function renderPushStatus(body) {
|
|
21
|
+
const devices = body.devices ?? [];
|
|
22
|
+
const lines = [`Server push keys: ${body.configured ? "set" : "not set (nothing can be sent yet)"}.`, `Saved devices: ${devices.length}.`];
|
|
23
|
+
for (const device of devices)
|
|
24
|
+
lines.push(` ${device.created_at.slice(0, 16).replace("T", " ")}Z ${device.user_agent ?? "unknown browser"}`);
|
|
25
|
+
if (devices.length === 0)
|
|
26
|
+
lines.push("Turn notifications on from Settings, on the phone (add TOWER to the Home Screen first on iPhone).");
|
|
27
|
+
return lines;
|
|
28
|
+
}
|
|
29
|
+
export function renderPushTest(body) {
|
|
30
|
+
if (body.skipped === "push_not_configured")
|
|
31
|
+
return ["Nothing sent: the server has no VAPID keys yet."];
|
|
32
|
+
if (body.skipped === "no_devices")
|
|
33
|
+
return ["Nothing sent: no device is saved. Turn notifications on from Settings on the phone."];
|
|
34
|
+
if (body.skipped)
|
|
35
|
+
return [`Nothing sent: ${body.skipped}.`];
|
|
36
|
+
const reasons = Object.entries(body.reasons ?? {}).map(([reason, count]) => `${reason} x${count}`).join(", ");
|
|
37
|
+
return [`Test push: ${body.accepted ?? 0} of ${body.devices ?? 0} devices accepted, ${body.failed ?? 0} failed${reasons ? ` (${reasons})` : ""}.`];
|
|
38
|
+
}
|
|
39
|
+
export async function runPush(command, io) {
|
|
40
|
+
const door = await openAgentDoor("push", command, io);
|
|
41
|
+
const status = command.action === "status";
|
|
42
|
+
const answer = await askAgentDoor(door, { path: status ? "/api/push/subscriptions" : "/api/push/test", method: status ? "GET" : "POST", label: `push ${command.action}`, timeoutMs: 60_000, ...(status ? {} : { body: {} }) });
|
|
43
|
+
if (!answer.ok)
|
|
44
|
+
return failAgentDoor(door, "[push cli]", answer.reason, answer.detail);
|
|
45
|
+
if (door.json)
|
|
46
|
+
return emitAgentDoor(door, answer.body);
|
|
47
|
+
for (const line of status ? renderPushStatus(answer.body) : renderPushTest(answer.body))
|
|
48
|
+
writeLine(door.io.stdout, line);
|
|
49
|
+
return 0;
|
|
50
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bli-cockpit/cli",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.242",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
},
|
|
29
29
|
"dependencies": {
|
|
30
30
|
"@bli-cockpit/memory-mcp": "0.1.70",
|
|
31
|
-
"@bli-cockpit/mcp": "0.1.
|
|
31
|
+
"@bli-cockpit/mcp": "0.1.147",
|
|
32
32
|
"@bli-cockpit/telemetry-core": "0.1.77"
|
|
33
33
|
},
|
|
34
34
|
"repository": {
|