@bli-cockpit/cli 0.2.67 → 0.2.69

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.
@@ -0,0 +1,165 @@
1
+ /**
2
+ * `cockpit cal` argument parsing (BLI-3709) — the calendar surface's half of
3
+ * `local-args-tower.ts`, a sibling of `local-args-tower-mail.ts` for the same
4
+ * reason that file is one: a calendar has its own vocabulary (a window, a
5
+ * zone, a series, a secret address) and folding it into another family's
6
+ * parser would stretch that family's doc comment past the truth.
7
+ *
8
+ * ONE SECRET NEVER TRAVELS ON ARGV, and it is refused here rather than at the
9
+ * door: **the secret iCal address**. `cockpit cal add-ical` reads it from
10
+ * STDIN, always, and there is no `--url` flag to forget about. That address is
11
+ * a permanent, unauthenticated, read-anything-on-that-calendar credential;
12
+ * argv is world-readable on a shared machine (`ps`), lands in shell history,
13
+ * and is captured by this very product's own session harvester. Same rule as
14
+ * `mail add-imap`, same reasoning, and the same reason neither has an MCP
15
+ * twin.
16
+ *
17
+ * THE ZONE IS ALWAYS SENT. Every read verb passes `--tz` (or the machine's own
18
+ * zone, read from `Intl`) to the door, because a window computed on the server
19
+ * would be computed in UTC — "today" would end at 5pm in Vancouver.
20
+ */
21
+ import { optionalNonEmpty, optionalPositiveInteger, optionalUrl, parseNamedArgs, } from "./local-arg-values.js";
22
+ const CAL_ACTIONS = new Set([
23
+ "today",
24
+ "week",
25
+ "next",
26
+ "find",
27
+ "calendars",
28
+ "add-ical",
29
+ "create",
30
+ "share",
31
+ "detach",
32
+ "sync",
33
+ ]);
34
+ /** Every verb that names ONE calendar first. */
35
+ const CAL_ACTIONS_NEEDING_A_SUBJECT = new Set(["sync", "detach", "share"]);
36
+ export function parseCalArgs(args) {
37
+ const values = parseNamedArgs(args, {
38
+ allowedFlags: [
39
+ "--home",
40
+ "--dashboard-url",
41
+ "--calendar",
42
+ "--tz",
43
+ "--offset",
44
+ "--hours",
45
+ "--from",
46
+ "--to",
47
+ "--limit",
48
+ "--all",
49
+ "--name",
50
+ "--title",
51
+ "--at",
52
+ "--until",
53
+ "--location",
54
+ "--attendee",
55
+ "--all-day",
56
+ "--org-visible",
57
+ "--private",
58
+ "--full",
59
+ "--json",
60
+ ],
61
+ valueFlags: [
62
+ "--home",
63
+ "--dashboard-url",
64
+ "--calendar",
65
+ "--tz",
66
+ "--offset",
67
+ "--hours",
68
+ "--from",
69
+ "--to",
70
+ "--limit",
71
+ "--name",
72
+ "--title",
73
+ "--at",
74
+ "--until",
75
+ "--location",
76
+ "--attendee",
77
+ ],
78
+ });
79
+ const first = values.positionals[0];
80
+ const action = (first === undefined ? "today" : first);
81
+ if (!CAL_ACTIONS.has(action)) {
82
+ throw new Error(`Unknown cal command: ${first}. Try today, week, next, find, calendars, add-ical, create, share, detach, or sync.`);
83
+ }
84
+ const rest = values.positionals.slice(first === undefined ? 0 : 1);
85
+ let subject;
86
+ let query;
87
+ if (CAL_ACTIONS_NEEDING_A_SUBJECT.has(action)) {
88
+ subject = optionalNonEmpty(rest[0]);
89
+ if (!subject)
90
+ throw new Error(`cal ${action} needs a calendar id — take one from \`cockpit cal calendars\`.`);
91
+ if (rest.length > 1)
92
+ throw new Error(`cal ${action} takes one id, not ${rest.length}.`);
93
+ }
94
+ else if (action === "find") {
95
+ query = optionalNonEmpty(rest.join(" "));
96
+ if (!query)
97
+ throw new Error('cal find needs words: cockpit cal find "standup".');
98
+ }
99
+ else if (rest.length > 0) {
100
+ throw new Error(`cal ${action} does not take "${rest[0]}".`);
101
+ }
102
+ const title = optionalNonEmpty(values.flags.get("--title"));
103
+ const startsAt = optionalNonEmpty(values.flags.get("--at"));
104
+ const endsAt = optionalNonEmpty(values.flags.get("--until"));
105
+ if (action === "create") {
106
+ if (!optionalNonEmpty(values.flags.get("--calendar"))) {
107
+ throw new Error("cal create needs --calendar <id>: with several calendars attached, which one this goes on is yours to say. `cockpit cal calendars` lists them.");
108
+ }
109
+ if (!title)
110
+ throw new Error('cal create needs --title "<what it is>".');
111
+ if (!startsAt)
112
+ throw new Error("cal create needs --at <when it starts> (an ISO instant, or a date with --all-day).");
113
+ if (!endsAt)
114
+ throw new Error("cal create needs --until <when it ends>.");
115
+ }
116
+ if (action === "share" && !values.booleans.has("--org-visible") && !values.booleans.has("--private")) {
117
+ throw new Error("cal share needs --org-visible (every member may read it) or --private (only you). Sharing is a decision, not a default.");
118
+ }
119
+ const limit = optionalPositiveInteger(values.flags.get("--limit"), "--limit");
120
+ const offsetRaw = optionalNonEmpty(values.flags.get("--offset"));
121
+ const offset = offsetRaw === undefined ? undefined : Number.parseInt(offsetRaw, 10);
122
+ if (offset !== undefined && !Number.isFinite(offset)) {
123
+ throw new Error("--offset takes a whole number of days (today/next) or weeks (week).");
124
+ }
125
+ return {
126
+ kind: "cal",
127
+ action,
128
+ homeDir: optionalNonEmpty(values.flags.get("--home")),
129
+ dashboardUrl: optionalUrl(values.flags.get("--dashboard-url")),
130
+ ...(subject ? { subjectId: subject } : {}),
131
+ ...(query ? { query } : {}),
132
+ calendarId: optionalNonEmpty(values.flags.get("--calendar")),
133
+ // An absent `--tz` means "this machine's zone", resolved at run time in
134
+ // `commands/cal.ts` rather than here, so the parser stays pure.
135
+ timeZone: optionalNonEmpty(values.flags.get("--tz")),
136
+ ...(offset === undefined ? {} : { offset }),
137
+ ...(optionalPositiveInteger(values.flags.get("--hours"), "--hours") === undefined
138
+ ? {}
139
+ : { hours: optionalPositiveInteger(values.flags.get("--hours"), "--hours") }),
140
+ from: optionalNonEmpty(values.flags.get("--from")),
141
+ to: optionalNonEmpty(values.flags.get("--to")),
142
+ ...(limit === undefined ? {} : { limit }),
143
+ includeDeselected: values.booleans.has("--all"),
144
+ name: optionalNonEmpty(values.flags.get("--name")),
145
+ title,
146
+ startsAt,
147
+ endsAt,
148
+ location: optionalNonEmpty(values.flags.get("--location")),
149
+ attendees: splitAddresses(values.flags.get("--attendee")),
150
+ allDay: values.booleans.has("--all-day"),
151
+ orgVisible: values.booleans.has("--org-visible"),
152
+ makePrivate: values.booleans.has("--private"),
153
+ full: values.booleans.has("--full"),
154
+ json: values.booleans.has("--json"),
155
+ };
156
+ }
157
+ /** `a@x.com,b@y.com` → two attendees. Exact, never fuzzy; empties dropped. */
158
+ function splitAddresses(raw) {
159
+ if (!raw)
160
+ return [];
161
+ return raw
162
+ .split(",")
163
+ .map((value) => value.trim())
164
+ .filter((value) => value !== "");
165
+ }
@@ -21,6 +21,7 @@
21
21
  * local-args-tower-work.ts issue, project — the issue tracker
22
22
  * (BLI-3716)
23
23
  * local-args-tower-mail.ts mail — the mailboxes (BLI-3708)
24
+ * local-args-tower-cal.ts cal — the calendars (BLI-3709)
24
25
  * local-args-tower-search.ts search — one bar over all five
25
26
  * corpora (BLI-3728)
26
27
  *
@@ -32,4 +33,5 @@ export { SCOUT_MIN_PREFIX_LENGTH, parseScoutArgs, parseOpsArgs, SLACK_WORKSPACE_
32
33
  export { parseDocsArgs, parseMsgArgs, } from "./local-args-tower-docs-msg.js";
33
34
  export { ISSUE_STATES, parseIssueArgs, parseProjectArgs, } from "./local-args-tower-work.js";
34
35
  export { SEARCH_KINDS, parseSearchArgs } from "./local-args-tower-search.js";
35
- export { parseMailArgs } from "./local-args-tower-mail.js";
36
+ export { parseMailArgs } from "./local-args-tower-mail.js";
37
+ export { parseCalArgs } from "./local-args-tower-cal.js";
@@ -16,7 +16,7 @@
16
16
  * verbatim, no logic change.
17
17
  */
18
18
  import { parseAgentRulesArgs, parseAnalyzeArgs, parseAutostartArgs, parseBackfillArgs, parseCleanArgs, parseDoctorArgs, parseInstallArgs, parseLoginArgs, parseMemoryArgs, parseLogoutArgs, parseOnboardArgs, parseReleaseArgs, parseServeArgs, parseSessionsArgs, parseStartArgs, parseStatusArgs, parseSyncArgs, parseUpdateArgs, } from "./local-args-collector.js";
19
- import { parseBriefArgs, parseCorrectArgs, parseDocsArgs, parseIssueArgs, parseJarvisArgs, parseMailArgs, parseModelArgs, parseMsgArgs, parseNotesArgs, parseOpsArgs, parseProjectArgs, parseScoutArgs, parseSearchArgs, parseSettingsArgs, parseSlackArgs, parseTeamArgs, parseWorkbookArgs, } from "./local-args-tower.js";
19
+ import { parseBriefArgs, parseCorrectArgs, parseDocsArgs, parseIssueArgs, parseJarvisArgs, parseMailArgs, parseCalArgs, parseModelArgs, parseMsgArgs, parseNotesArgs, parseOpsArgs, parseProjectArgs, parseScoutArgs, parseSearchArgs, parseSettingsArgs, parseSlackArgs, parseTeamArgs, parseWorkbookArgs, } from "./local-args-tower.js";
20
20
  // `normalizeUrl` has always been part of this module's surface — `local.ts` and
21
21
  // `local-auth.ts` import it from here — so it stays exported from this address
22
22
  // even though it now lives next door. The same goes for the four names the
@@ -105,6 +105,8 @@ export function parseLocalArgs(argv) {
105
105
  return parseIssueArgs(argv.slice(1));
106
106
  case "mail":
107
107
  return parseMailArgs(argv.slice(1));
108
+ case "cal":
109
+ return parseCalArgs(argv.slice(1));
108
110
  case "project":
109
111
  return parseProjectArgs(argv.slice(1));
110
112
  case "search":
@@ -0,0 +1,163 @@
1
+ /**
2
+ * The long-form `cockpit <command> --help` text for the TOWER surfaces —
3
+ * documents, messages, issues, projects, mail, calendar and search (BLI-3709).
4
+ *
5
+ * Split out of `local-help-commands.ts` when that file crossed the 700-line
6
+ * readability ceiling, and split along the same seam the ARGUMENT parsers
7
+ * already use (`local-args-tower*.ts`): the collector's own commands — onboard,
8
+ * sync, backfill, autostart, clean — change for one set of reasons, and the
9
+ * Tower nouns a person reads and writes through change for another.
10
+ *
11
+ * Every string moved verbatim. Help output is what an intern pastes back when
12
+ * something breaks, so it is a user-visible contract like any other.
13
+ */
14
+ /** One entry per Tower noun, in the order `cockpit --help` lists them. */
15
+ export const TOWER_COMMAND_HELP = [
16
+ [
17
+ "docs",
18
+ [
19
+ "Usage: cockpit docs [list|tree|read <id|slug>|create|update <id>] [flags]",
20
+ "",
21
+ "The Tower document library, typed. Bare `cockpit docs` lists every document you may read.",
22
+ "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).",
23
+ " --parent <id> lists the documents filed directly under that document; --parent root lists the top of the tree.",
24
+ " --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.",
25
+ " --limit <n> caps the rows (1-1000). Narrow with these before reading: `cockpit docs list --query onboarding --json` then `cockpit docs read <slug>`.",
26
+ "tree — the same documents nested under their parent, for a sidebar-shaped view.",
27
+ "read <id|slug> — one document's title, slug, visibility, and its body. This is the ONLY verb that returns a body.",
28
+ "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.",
29
+ "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.",
30
+ " --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.",
31
+ "A document body is never accepted on the command line — --file (safest on Windows) or a pipe only, same discipline as `cockpit notes paste`.",
32
+ "--json writes one machine-readable object to stdout; every reason and receipt line stays on stderr.",
33
+ "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.",
34
+ "The command uses the existing paired device identity. Run `cockpit login` first if this machine is not paired.",
35
+ ],
36
+ ],
37
+ [
38
+ "msg",
39
+ [
40
+ "Usage: cockpit msg [channels|create <name>|dm <email>|read <channel>|send <channel>|thread <id> --channel <channel>] [flags]",
41
+ "",
42
+ "Channels and messages, typed. <channel> is a channel id, or its name with or without a leading #.",
43
+ "channels — every channel you are a member of (or, as a super_admin, every channel).",
44
+ "create <name> [--private] [--members a@x.test,b@y.test] [--description \"<text>\"] — makes a channel and prints its id. A leading # is fine; --private means membership decides who may read it, and --members names who joins at birth BY EMAIL. An address Tower does not carry refuses the whole create — no half-built channel.",
45
+ "dm <email> — opens (or re-opens) the direct message with one person. Idempotent: the same address always resolves to the same channel, and you never have to name yourself.",
46
+ "read <channel> [--limit <n>] [--thread <id>] — the channel's most recent top-level messages, oldest first; --thread <id> reads one thread's replies instead.",
47
+ "send <channel> [--thread <id>] — posts a message. The content is never accepted on the command line: pipe it in, e.g. `echo \"hello\" | cockpit msg send general`.",
48
+ "thread <id> --channel <channel> — one thread's replies by the parent message's id.",
49
+ "--json writes one machine-readable object to stdout; every reason and receipt line stays on stderr.",
50
+ "A refusal keeps Tower's own reason label — needs_rls_client, channel_not_found_or_unreadable, content_too_long, and so on.",
51
+ "The command uses the existing paired device identity. Run `cockpit login` first if this machine is not paired.",
52
+ ],
53
+ ],
54
+ [
55
+ "issue",
56
+ [
57
+ "Usage: cockpit issue [list|show <id>|create|update <id>|move <id> <state>|comment <id>|history <id>] [flags]",
58
+ "",
59
+ "Tower's issue tracker. <id> is a BLI-#### identifier (BLI-3654) or an issue's uuid — both work everywhere an issue is named.",
60
+ "list [--state <s>] [--assignee me|unassigned|<uuid>] [--project <name|id>] [--limit <n>] — the issues you may see, most recently updated first.",
61
+ "show <id> — one issue: title, state, priority, assignee, description, and every comment on it.",
62
+ "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.",
63
+ "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.",
64
+ "move <id> <state> — moves an issue and records the move. States: backlog, todo, in_progress, in_review, done, canceled.",
65
+ "comment <id> — posts a comment. The body is never taken on the command line: `echo \"shipped\" | cockpit issue comment BLI-3654`.",
66
+ "history <id> [--limit <n>] — every recorded state move and reassignment, oldest first.",
67
+ "A project may be named instead of id'd; the name is matched exactly (case-insensitively) against `cockpit project list`.",
68
+ "--json writes one machine-readable object to stdout; every reason and receipt line stays on stderr.",
69
+ "A refusal keeps Tower's own reason label — needs_rls_client, issue_not_found_or_unreadable, issue_not_writable, invalid_state, comment_too_long, and so on.",
70
+ "The command uses the existing paired device identity. Run `cockpit login` first if this machine is not paired.",
71
+ ],
72
+ ],
73
+ [
74
+ "mail",
75
+ [
76
+ "Usage: cockpit mail [accounts|add-imap|inbox|read <thread>|search \"<words>\"|send|attachment <id>|sync <account>|detach <account>] [flags]",
77
+ "",
78
+ "Every mailbox you attached, in one place. One person, several addresses — nothing here has a \"current\" mailbox.",
79
+ "accounts — every mailbox, its provider (gmail_oauth or imap), its health and when it last synced. The ids other verbs take are on the last line.",
80
+ "add-imap --address <you@gmail.com> [--name \"<display name>\"] — attaches a personal Google account over IMAP + SMTP. The APP PASSWORD is read from stdin and there is no flag for it:",
81
+ " macOS: printf \"%s\" \"abcd efgh ijkl mnop\" | cockpit mail add-imap --address you@gmail.com",
82
+ " PowerShell: \"abcd efgh ijkl mnop\" | cockpit mail add-imap --address you@gmail.com",
83
+ " Make one at myaccount.google.com/apppasswords (2-Step Verification has to be on). A work @buildlaunchiterate.ca address connects in the browser instead.",
84
+ "inbox [--account <id>] [--unread] [--label <l>] [--limit <n>] [--before <iso>] — everything across every mailbox, newest first. * is unread, @ has an attachment.",
85
+ "read <thread> — one conversation with its bodies. Thread ids come from `cockpit mail inbox --json`.",
86
+ "search \"<words>\" [--account <id>] [--limit <n>] — full text over subject and body. Quotes and OR work the way they do in a search box.",
87
+ "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.",
88
+ "attachment <id> --out <path> — downloads one attachment to a file. Attachments are pointers until you ask; nothing is stored in Tower.",
89
+ "sync <account> — reads that mailbox now instead of waiting for the cron. Prints what landed, or the reason it did not.",
90
+ "detach <account> — removes the mailbox, its stored mail and its credential.",
91
+ "--json writes one machine-readable object to stdout; every reason and receipt line stays on stderr.",
92
+ "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.",
93
+ "The command uses the existing paired device identity. Run `cockpit login` first if this machine is not paired.",
94
+ ],
95
+ ],
96
+ [
97
+ "cal",
98
+ [
99
+ 'Usage: cockpit cal [today|week|next|find "<words>"|calendars|add-ical|create|share <id>|sync <id>|detach <id>] [flags]',
100
+ "",
101
+ "Every calendar you attached, in one place — plus any calendar a colleague marked shared.",
102
+ "today [--offset 1] [--tz <zone>] — what is on today, in YOUR zone (this machine's, unless --tz says otherwise). --offset 1 is tomorrow, -1 yesterday.",
103
+ "week [--offset 1] — Monday to Sunday. The week starts on Monday here.",
104
+ "next [--hours 12] — what is coming, from RIGHT NOW rather than from midnight. Defaults to the next 72 hours, so a Friday evening still answers.",
105
+ 'find "<words>" — full text over titles, locations and descriptions. A repeating event is answered with the NEXT time it happens, not its first one in 2024.',
106
+ "calendars — every calendar, its provider (google_oauth or ical_url), whether it is shared, and when it last synced. The ids other verbs take are on the last line.",
107
+ "add-ical [--name \"<what to call it>\"] — attaches a personal calendar by its SECRET iCal ADDRESS, read from stdin. There is no flag for it:",
108
+ ' macOS: printf "%s" "https://calendar.google.com/calendar/ical/…/basic.ics" | cockpit cal add-ical --name "Personal"',
109
+ ' PowerShell: "https://calendar.google.com/calendar/ical/…/basic.ics" | cockpit cal add-ical --name "Personal"',
110
+ " Find it at calendar.google.com -> the calendar's Settings -> \"Secret address in iCal format\". Anybody who has that link can read the calendar, which is why it never goes on a command line.",
111
+ " A work @buildlaunchiterate.ca calendar does not need this: connect Google in the browser and `cockpit cal calendars` will list them.",
112
+ 'create --calendar <id> --title "<what>" --at <iso> --until <iso> [--location "<where>"] [--attendee a@x,b@y] [--all-day] — creates the event AT GOOGLE, then here. Attendees are invited by Google.',
113
+ " Only a Google calendar can be written to: an iCal secret address is a read address, and `write_not_supported_for_provider` says so by name.",
114
+ "share <id> --org-visible|--private — makes one of YOUR calendars readable by everyone at BLI, or private again. Sharing never lets anybody else change it.",
115
+ "sync <id> [--full] — reads that calendar now instead of waiting for the cron. --full ignores the sync token / ETag and re-reads everything.",
116
+ "detach <id> — removes the calendar and its stored events from Tower. The calendar itself is untouched at Google.",
117
+ "--json writes one machine-readable object to stdout; every reason and receipt line stays on stderr.",
118
+ "A refusal keeps Tower's own reason label — needs_rls_client, calendar_not_found_or_unreadable, calendar_not_yours, ical_url_invalid, google_oauth_not_configured, write_not_supported_for_provider, and so on.",
119
+ "The command uses the existing paired device identity. Run `cockpit login` first if this machine is not paired.",
120
+ ],
121
+ ],
122
+ [
123
+ "project",
124
+ [
125
+ "Usage: cockpit project [list] [--archived] [--json]",
126
+ "",
127
+ "The projects issues are filed under. Bare `cockpit project` lists them.",
128
+ "list [--archived] — id, active/archived, name. --archived includes archived projects.",
129
+ "There is no create/update/delete verb: `GET /api/work/projects` is the whole of Tower's project door today.",
130
+ "--json writes one machine-readable object to stdout; every reason and receipt line stays on stderr.",
131
+ ],
132
+ ],
133
+ [
134
+ "search",
135
+ [
136
+ 'Usage: cockpit search "<words>" [--kind doc,msg,issue,note,memory] [--limit <n>] [--json]',
137
+ "",
138
+ "One bar over five corpora: documents, messages, issues, meeting notes and memory.",
139
+ "It presses the SAME door the browser's search bar presses (GET /api/search), so what you",
140
+ "read here is the same row, the same ranking and the same snippet a person sees in Tower.",
141
+ "",
142
+ "The words are positional and do not need quoting unless they contain shell metacharacters:",
143
+ '`cockpit search storage ceiling` and `cockpit search "storage ceiling"` are the same search.',
144
+ "Quoted phrases and -word work inside the query itself — the query goes to Postgres's",
145
+ "websearch parser, which shrugs at anything a person can type instead of raising.",
146
+ "",
147
+ "--kind narrows to one or more corpora, comma separated. Omit it and all five are searched.",
148
+ "--limit caps the number of results (default 20, maximum 50).",
149
+ "--json writes the door's whole answer to stdout, hits, per-kind counts, failures and all.",
150
+ "",
151
+ "Every result is scoped by what YOU may read: four of the five corpora are searched on your",
152
+ "own database session, so a document or a channel you cannot open is not in the list.",
153
+ "",
154
+ "A corpus that could not ANSWER gets its own line, separately from \"nothing matched\" —",
155
+ "those are different facts and folding them together would let a broken search read as silence.",
156
+ "A memory result has no page to open; the text printed under it is the whole record.",
157
+ "",
158
+ "A refusal keeps Tower's own reason label — needs_rls_client, query_too_short, query_too_long,",
159
+ "unknown_kind, read_failed.",
160
+ "The command uses the existing paired device identity. Run `cockpit login` first if this machine is not paired.",
161
+ ],
162
+ ],
163
+ ];
@@ -1,6 +1,11 @@
1
1
  /**
2
2
  * The long-form `cockpit <command> --help` text — one entry per command.
3
3
  *
4
+ * The collector's OWN commands live here; the Tower nouns (docs, msg, issue,
5
+ * project, mail, cal, search) live in `local-help-commands-tower.ts`, split off
6
+ * by BLI-3709 when this file crossed the same ceiling — along the seam
7
+ * `local-args-tower*.ts` already draws.
8
+ *
4
9
  * Split out of `local-help.ts` (BLI-3728) when that file crossed the 700-line
5
10
  * readability ceiling. The split is by RESPONSIBILITY, not by size: this file
6
11
  * is the per-command manual, `local-help.ts` is the command NAME registry plus
@@ -13,8 +18,12 @@
13
18
  */
14
19
  import { DEFAULT_DASHBOARD_URL } from "../local-state.js";
15
20
  import { localCommandHelp } from "./local-help.js";
21
+ import { TOWER_COMMAND_HELP } from "./local-help-commands-tower.js";
16
22
  export function localSubcommandHelp(command) {
17
23
  const helpByCommand = new Map([
24
+ // BLI-3709: the Tower nouns live next door, split along the same seam the
25
+ // argument parsers already use — see `local-help-commands-tower.ts`.
26
+ ...TOWER_COMMAND_HELP,
18
27
  [
19
28
  "onboard",
20
29
  [
@@ -544,127 +553,6 @@ export function localSubcommandHelp(command) {
544
553
  "right now.",
545
554
  ],
546
555
  ],
547
- [
548
- "docs",
549
- [
550
- "Usage: cockpit docs [list|tree|read <id|slug>|create|update <id>] [flags]",
551
- "",
552
- "The Tower document library, typed. Bare `cockpit docs` lists every document you may read.",
553
- "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).",
554
- " --parent <id> lists the documents filed directly under that document; --parent root lists the top of the tree.",
555
- " --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.",
556
- " --limit <n> caps the rows (1-1000). Narrow with these before reading: `cockpit docs list --query onboarding --json` then `cockpit docs read <slug>`.",
557
- "tree — the same documents nested under their parent, for a sidebar-shaped view.",
558
- "read <id|slug> — one document's title, slug, visibility, and its body. This is the ONLY verb that returns a body.",
559
- "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.",
560
- "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.",
561
- " --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.",
562
- "A document body is never accepted on the command line — --file (safest on Windows) or a pipe only, same discipline as `cockpit notes paste`.",
563
- "--json writes one machine-readable object to stdout; every reason and receipt line stays on stderr.",
564
- "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.",
565
- "The command uses the existing paired device identity. Run `cockpit login` first if this machine is not paired.",
566
- ],
567
- ],
568
- [
569
- "msg",
570
- [
571
- "Usage: cockpit msg [channels|create <name>|dm <email>|read <channel>|send <channel>|thread <id> --channel <channel>] [flags]",
572
- "",
573
- "Channels and messages, typed. <channel> is a channel id, or its name with or without a leading #.",
574
- "channels — every channel you are a member of (or, as a super_admin, every channel).",
575
- "create <name> [--private] [--members a@x.test,b@y.test] [--description \"<text>\"] — makes a channel and prints its id. A leading # is fine; --private means membership decides who may read it, and --members names who joins at birth BY EMAIL. An address Tower does not carry refuses the whole create — no half-built channel.",
576
- "dm <email> — opens (or re-opens) the direct message with one person. Idempotent: the same address always resolves to the same channel, and you never have to name yourself.",
577
- "read <channel> [--limit <n>] [--thread <id>] — the channel's most recent top-level messages, oldest first; --thread <id> reads one thread's replies instead.",
578
- "send <channel> [--thread <id>] — posts a message. The content is never accepted on the command line: pipe it in, e.g. `echo \"hello\" | cockpit msg send general`.",
579
- "thread <id> --channel <channel> — one thread's replies by the parent message's id.",
580
- "--json writes one machine-readable object to stdout; every reason and receipt line stays on stderr.",
581
- "A refusal keeps Tower's own reason label — needs_rls_client, channel_not_found_or_unreadable, content_too_long, and so on.",
582
- "The command uses the existing paired device identity. Run `cockpit login` first if this machine is not paired.",
583
- ],
584
- ],
585
- [
586
- "issue",
587
- [
588
- "Usage: cockpit issue [list|show <id>|create|update <id>|move <id> <state>|comment <id>|history <id>] [flags]",
589
- "",
590
- "Tower's issue tracker. <id> is a BLI-#### identifier (BLI-3654) or an issue's uuid — both work everywhere an issue is named.",
591
- "list [--state <s>] [--assignee me|unassigned|<uuid>] [--project <name|id>] [--limit <n>] — the issues you may see, most recently updated first.",
592
- "show <id> — one issue: title, state, priority, assignee, description, and every comment on it.",
593
- "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.",
594
- "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.",
595
- "move <id> <state> — moves an issue and records the move. States: backlog, todo, in_progress, in_review, done, canceled.",
596
- "comment <id> — posts a comment. The body is never taken on the command line: `echo \"shipped\" | cockpit issue comment BLI-3654`.",
597
- "history <id> [--limit <n>] — every recorded state move and reassignment, oldest first.",
598
- "A project may be named instead of id'd; the name is matched exactly (case-insensitively) against `cockpit project list`.",
599
- "--json writes one machine-readable object to stdout; every reason and receipt line stays on stderr.",
600
- "A refusal keeps Tower's own reason label — needs_rls_client, issue_not_found_or_unreadable, issue_not_writable, invalid_state, comment_too_long, and so on.",
601
- "The command uses the existing paired device identity. Run `cockpit login` first if this machine is not paired.",
602
- ],
603
- ],
604
- [
605
- "mail",
606
- [
607
- "Usage: cockpit mail [accounts|add-imap|inbox|read <thread>|search \"<words>\"|send|attachment <id>|sync <account>|detach <account>] [flags]",
608
- "",
609
- "Every mailbox you attached, in one place. One person, several addresses — nothing here has a \"current\" mailbox.",
610
- "accounts — every mailbox, its provider (gmail_oauth or imap), its health and when it last synced. The ids other verbs take are on the last line.",
611
- "add-imap --address <you@gmail.com> [--name \"<display name>\"] — attaches a personal Google account over IMAP + SMTP. The APP PASSWORD is read from stdin and there is no flag for it:",
612
- " macOS: printf \"%s\" \"abcd efgh ijkl mnop\" | cockpit mail add-imap --address you@gmail.com",
613
- " PowerShell: \"abcd efgh ijkl mnop\" | cockpit mail add-imap --address you@gmail.com",
614
- " Make one at myaccount.google.com/apppasswords (2-Step Verification has to be on). A work @buildlaunchiterate.ca address connects in the browser instead.",
615
- "inbox [--account <id>] [--unread] [--label <l>] [--limit <n>] [--before <iso>] — everything across every mailbox, newest first. * is unread, @ has an attachment.",
616
- "read <thread> — one conversation with its bodies. Thread ids come from `cockpit mail inbox --json`.",
617
- "search \"<words>\" [--account <id>] [--limit <n>] — full text over subject and body. Quotes and OR work the way they do in a search box.",
618
- "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.",
619
- "attachment <id> --out <path> — downloads one attachment to a file. Attachments are pointers until you ask; nothing is stored in Tower.",
620
- "sync <account> — reads that mailbox now instead of waiting for the cron. Prints what landed, or the reason it did not.",
621
- "detach <account> — removes the mailbox, its stored mail and its credential.",
622
- "--json writes one machine-readable object to stdout; every reason and receipt line stays on stderr.",
623
- "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.",
624
- "The command uses the existing paired device identity. Run `cockpit login` first if this machine is not paired.",
625
- ],
626
- ],
627
- [
628
- "project",
629
- [
630
- "Usage: cockpit project [list] [--archived] [--json]",
631
- "",
632
- "The projects issues are filed under. Bare `cockpit project` lists them.",
633
- "list [--archived] — id, active/archived, name. --archived includes archived projects.",
634
- "There is no create/update/delete verb: `GET /api/work/projects` is the whole of Tower's project door today.",
635
- "--json writes one machine-readable object to stdout; every reason and receipt line stays on stderr.",
636
- ],
637
- ],
638
- [
639
- "search",
640
- [
641
- 'Usage: cockpit search "<words>" [--kind doc,msg,issue,note,memory] [--limit <n>] [--json]',
642
- "",
643
- "One bar over five corpora: documents, messages, issues, meeting notes and memory.",
644
- "It presses the SAME door the browser's search bar presses (GET /api/search), so what you",
645
- "read here is the same row, the same ranking and the same snippet a person sees in Tower.",
646
- "",
647
- "The words are positional and do not need quoting unless they contain shell metacharacters:",
648
- '`cockpit search storage ceiling` and `cockpit search "storage ceiling"` are the same search.',
649
- "Quoted phrases and -word work inside the query itself — the query goes to Postgres's",
650
- "websearch parser, which shrugs at anything a person can type instead of raising.",
651
- "",
652
- "--kind narrows to one or more corpora, comma separated. Omit it and all five are searched.",
653
- "--limit caps the number of results (default 20, maximum 50).",
654
- "--json writes the door's whole answer to stdout, hits, per-kind counts, failures and all.",
655
- "",
656
- "Every result is scoped by what YOU may read: four of the five corpora are searched on your",
657
- "own database session, so a document or a channel you cannot open is not in the list.",
658
- "",
659
- "A corpus that could not ANSWER gets its own line, separately from \"nothing matched\" —",
660
- "those are different facts and folding them together would let a broken search read as silence.",
661
- "A memory result has no page to open; the text printed under it is the whole record.",
662
- "",
663
- "A refusal keeps Tower's own reason label — needs_rls_client, query_too_short, query_too_long,",
664
- "unknown_kind, read_failed.",
665
- "The command uses the existing paired device identity. Run `cockpit login` first if this machine is not paired.",
666
- ],
667
- ],
668
556
  [
669
557
  "release",
670
558
  [
@@ -46,6 +46,7 @@ export const rootCommandNames = new Set([
46
46
  "msg",
47
47
  "issue",
48
48
  "mail",
49
+ "cal",
49
50
  "project",
50
51
  "search",
51
52
  "release",
@@ -91,6 +92,7 @@ export function localCommandHelp(command) {
91
92
  " cockpit msg [channels|create <name>|dm <email>|read <channel>|send <channel>|thread <id> --channel <channel>] [--private] [--members a@x,b@y] [--description <text>] [--thread <id>] [--limit <n>] [--dashboard-url <url>] [--json]",
92
93
  " cockpit issue [list|show <BLI-id>|create --title <t>|update <BLI-id>|move <BLI-id> <state>|comment <BLI-id>|history <BLI-id>] [--state <s>] [--assignee me|unassigned|<uuid>] [--project <name|id>] [--limit <n>] [--priority 0-4] [--parent <BLI-id>] [--file <path>|--body-stdin] [--dashboard-url <url>] [--json]",
93
94
  " cockpit mail [accounts|add-imap --address <a>|inbox|read <thread>|search \"<words>\"|send --account <id> --to <a> --subject <s>|attachment <id> --out <path>|sync <account>|detach <account>] [--account <id>] [--limit <n>] [--unread] [--label <l>] [--file <path>] [--dashboard-url <url>] [--json]",
95
+ " cockpit cal [today|week|next|find \"<words>\"|calendars|add-ical|create --calendar <id> --title <t> --at <iso> --until <iso>|share <id> --org-visible|--private|sync <id> [--full]|detach <id>] [--tz <zone>] [--offset <n>] [--hours <n>] [--from <d> --to <d>] [--calendar <id>] [--limit <n>] [--all] [--dashboard-url <url>] [--json]",
94
96
  " cockpit project [list] [--archived] [--dashboard-url <url>] [--json]",
95
97
  " cockpit search \"<words>\" [--kind doc,msg,issue,note,memory] [--limit <n>] [--dashboard-url <url>] [--json]",
96
98
  " cockpit release [--dry-run] [--skip-checks] [--no-floor] [--tag <tag>] [--access <public|restricted>] [--otp <code>]",
@@ -35,6 +35,7 @@ import { runClean } from "./clean.js";
35
35
  import { runDocs } from "./docs.js";
36
36
  import { runMsg } from "./msg.js";
37
37
  import { runIssue } from "./issue.js";
38
+ import { runCal } from "./cal.js";
38
39
  import { runMail } from "./mail.js";
39
40
  import { runProject } from "./project.js";
40
41
  import { runSearch } from "./search.js";
@@ -143,6 +144,8 @@ export async function runLocalCockpitCli(argv, io = defaultIo()) {
143
144
  return await runMsg(command, io);
144
145
  case "issue":
145
146
  return await runIssue(command, io);
147
+ case "cal":
148
+ return runCal(command, io);
146
149
  case "mail":
147
150
  return await runMail(command, io);
148
151
  case "project":
@@ -15,7 +15,7 @@ export async function runCockpitCli(argv, io) {
15
15
  }
16
16
 
17
17
  if (command === "--version" || command === "-V" || command === "version") {
18
- writeLine(io?.stdout ?? process.stdout, "0.2.67");
18
+ writeLine(io?.stdout ?? process.stdout, "0.2.69");
19
19
  return 0;
20
20
  }
21
21