@wirecat/cli-core 0.396.0 → 0.398.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (102) hide show
  1. package/CHANGELOG.md +74 -1
  2. package/README.md +13 -9
  3. package/dist/background/daily-archive-timer.d.ts +40 -0
  4. package/dist/background/daily-archive-timer.js +181 -0
  5. package/dist/background/index.d.ts +2 -0
  6. package/dist/background/index.js +1 -0
  7. package/dist/cli/bot/admins.js +2 -2
  8. package/dist/cli/bot/command.js +2 -2
  9. package/dist/cli/bot/setup.js +3 -3
  10. package/dist/cli/commands-command.js +1 -1
  11. package/dist/cli/config-command.js +1 -1
  12. package/dist/cli/guide.js +0 -1
  13. package/dist/cli/help/command-help.d.ts +9 -2
  14. package/dist/cli/help/command-help.js +22 -4
  15. package/dist/cli/help/first-screen.js +3 -3
  16. package/dist/cli/messenger/account-command.js +3 -1
  17. package/dist/cli/messenger/account-sessions-command.js +3 -1
  18. package/dist/cli/messenger/admin-commands.js +1 -0
  19. package/dist/cli/messenger/admin-contacts-command.js +9 -3
  20. package/dist/cli/messenger/archive-commands.js +5 -3
  21. package/dist/cli/messenger/attachments-command.js +17 -7
  22. package/dist/cli/messenger/backfill-command.js +7 -3
  23. package/dist/cli/messenger/catch-up-answers.d.ts +2 -0
  24. package/dist/cli/messenger/catch-up-answers.js +5 -1
  25. package/dist/cli/messenger/chats-command.js +3 -1
  26. package/dist/cli/messenger/chats-members-command.js +4 -0
  27. package/dist/cli/messenger/chats-stats-command.js +1 -0
  28. package/dist/cli/messenger/chats-tracking-command.js +4 -1
  29. package/dist/cli/messenger/complete-command.js +6 -2
  30. package/dist/cli/messenger/contacts-command.js +11 -4
  31. package/dist/cli/messenger/context.d.ts +5 -0
  32. package/dist/cli/messenger/conversations-command.js +18 -1
  33. package/dist/cli/messenger/counters-command.js +1 -0
  34. package/dist/cli/messenger/doctor-command.js +2 -2
  35. package/dist/cli/messenger/download-command.js +1 -0
  36. package/dist/cli/messenger/flood-command.js +2 -1
  37. package/dist/cli/messenger/guard-commands.js +5 -4
  38. package/dist/cli/messenger/inbox.js +2 -0
  39. package/dist/cli/messenger/lacks.d.ts +7 -0
  40. package/dist/cli/messenger/lacks.js +18 -1
  41. package/dist/cli/messenger/mcp-command.js +6 -2
  42. package/dist/cli/messenger/media-archive-command.d.ts +3 -0
  43. package/dist/cli/messenger/media-archive-command.js +72 -0
  44. package/dist/cli/messenger/media-archive-move-command.d.ts +3 -0
  45. package/dist/cli/messenger/media-archive-move-command.js +39 -0
  46. package/dist/cli/messenger/media-archive-timer-command.d.ts +3 -0
  47. package/dist/cli/messenger/media-archive-timer-command.js +70 -0
  48. package/dist/cli/messenger/messages-command.js +2 -1
  49. package/dist/cli/messenger/messages-comments-command.js +1 -0
  50. package/dist/cli/messenger/messages-delete-command.js +1 -1
  51. package/dist/cli/messenger/messages-search-command.js +1 -0
  52. package/dist/cli/messenger/messages-stats-command.js +1 -0
  53. package/dist/cli/messenger/models-command.js +8 -2
  54. package/dist/cli/messenger/private-people-command.js +12 -10
  55. package/dist/cli/messenger/rankings-command.js +2 -0
  56. package/dist/cli/messenger/reactions-command.js +8 -2
  57. package/dist/cli/messenger/replies-command.js +1 -1
  58. package/dist/cli/messenger/retention-command.js +1 -0
  59. package/dist/cli/messenger/review.js +8 -2
  60. package/dist/cli/messenger/schedule-command.js +3 -3
  61. package/dist/cli/messenger/search-command.js +7 -3
  62. package/dist/cli/messenger/searches-command.js +24 -7
  63. package/dist/cli/messenger/serve-command.js +5 -1
  64. package/dist/cli/messenger/serve-relay.d.ts +1 -0
  65. package/dist/cli/messenger/server-command.js +17 -3
  66. package/dist/cli/messenger/store-maintenance-command.d.ts +1 -1
  67. package/dist/cli/messenger/store-maintenance-command.js +10 -3
  68. package/dist/cli/messenger/tags-command.js +6 -2
  69. package/dist/cli/messenger/tasks-command.js +10 -7
  70. package/dist/cli/messenger/transcribe-command.js +1 -0
  71. package/dist/cli/messenger/watch-command.js +6 -1
  72. package/dist/cli/runs/command.js +2 -3
  73. package/dist/cli/skill-command.js +1 -1
  74. package/dist/domain/models.d.ts +2 -0
  75. package/dist/entities/contact-read.d.ts +119 -0
  76. package/dist/entities/contact-read.js +50 -0
  77. package/dist/entities/index.d.ts +120 -1
  78. package/dist/entities/index.js +5 -1
  79. package/dist/meetings/mcp/index.d.ts +15 -15
  80. package/dist/parity/catalogue.js +1 -1
  81. package/dist/parity/manifest.d.ts +3 -1
  82. package/dist/parity/manifest.js +2 -0
  83. package/dist/parity/wording.js +2 -0
  84. package/dist/sends/permissions.js +7 -0
  85. package/dist/services/deps.d.ts +1 -1
  86. package/dist/services/deps.js +1 -1
  87. package/dist/services/inbox.js +5 -5
  88. package/dist/services/index.d.ts +5 -0
  89. package/dist/services/index.js +2 -0
  90. package/dist/services/media-archive-files.d.ts +10 -0
  91. package/dist/services/media-archive-files.js +110 -0
  92. package/dist/services/media-archive-locks.d.ts +6 -0
  93. package/dist/services/media-archive-locks.js +71 -0
  94. package/dist/services/media-archive-manifest.d.ts +29 -0
  95. package/dist/services/media-archive-manifest.js +116 -0
  96. package/dist/services/media-archive-move.d.ts +21 -0
  97. package/dist/services/media-archive-move.js +163 -0
  98. package/dist/services/media-archive.d.ts +34 -0
  99. package/dist/services/media-archive.js +165 -0
  100. package/dist/services/search-kind.js +1 -1
  101. package/package.json +2 -2
  102. package/parity.json +116 -193
package/CHANGELOG.md CHANGED
@@ -5,7 +5,80 @@ Notable changes to `@wirecat/cli-core` (`@wirecat/cli-messaging` up to 0.261.0,
5
5
  minor version may break callers, and says how under "Changed — may break callers". `pnpm docs:check` checks the
6
6
  shape of this file.
7
7
 
8
- ## Unreleased
8
+ ## 0.398.0 — 23.10.2026
9
+
10
+ ### Added
11
+
12
+ - Top-level summaries, listeners and shell completion include examples in help and command discovery.
13
+
14
+ - Clearing a remembered flood hold includes an example in help and command discovery.
15
+
16
+ - **`Messenger.never`**: the adapter methods a messenger can never have, as opposed to `lacks`, what it does not have
17
+ yet. Their commands refuse as before and are also left out of help, `commands --json` and MCP. A command a
18
+ messenger only `lacks` stays in sight. For WhatsApp: `endOtherSessions` (only the phone ends other devices) and
19
+ `removeContact`.
20
+
21
+ - **`review --offline` reads the local store** instead of refusing, for every messenger. It shows only what was
22
+ already synced and says so (`missing` in JSON, a warning on the terminal); its next step starts from the same
23
+ point, and `--new` keeps its saved point, so an online review still sees what was never synced.
24
+
25
+ - **MCP offers more commands**, each limited by the profile's permissions: `runs list|show|path`, `store status|info|check|export|reindex`,
26
+ `store jobs cancel|retry|clear`, `attachments move`, `attachments jobs status`, `config show`, `commands schema`, `guide`,
27
+ `skill show`, `doctor report`, the `models` lists, `replies status|test|consents show`, `schedule list`,
28
+ `server status|logs`, and on the bot server `list`, `chats leave`, `chats admins add|remove`, `commands set|clear` and
29
+ `webhooks list|set|delete`. What stays off and why is in [the messenger rules](docs/dev/STANDARD.md#mcp).
30
+
31
+ ### Security
32
+
33
+ - `@modelcontextprotocol/server` 2.3.0 clears the three high-severity advisories `npm audit` reported for every
34
+ WireCat CLI.
35
+
36
+ ### Fixed
37
+
38
+ - **A pasted help example runs.** The `Example:` line of an account command's `--help`, generated or written, names
39
+ the profile `personal` (`tg personal messages list Team`), so it no longer fails with "name a profile".
40
+
41
+ - `search mail` with no mail in the store points at `memo sync --only mail`; it named a `--mail` option memo does not have.
42
+
43
+ - `parity.json` matches tg and max: `guide`, `mentions`, `runs search`, `tasks reject` and `--brief`/`--full` are in
44
+ both, and each gap planned for tg names its tg-cli backlog id.
45
+
46
+ - `search all --help` says the messenger's server is asked by default and names `--backend archive` and `--offline`
47
+ to keep it to the local store.
48
+
49
+ ## 0.397.0 — 23.10.2026
50
+
51
+ ### Changed — may break callers
52
+
53
+ - MCP argument names follow singular CLI placeholders: `messages delete` takes `message`, chat export takes `chat`, and duplicate-task operations take `task` (with `other_task` for a pair). Update saved MCP requests using `messages`, `chats` or `dup`. CLI positional values and their order are unchanged.
54
+
55
+ ### Fixed
56
+
57
+ - The option catalogue records WhatsApp's JPEG-only profile photos. Wording differences can be justified for one command without exempting that option everywhere or hiding a missing option.
58
+
59
+ - **Short command summaries and argument help.** Messenger commands keep their detailed descriptions while help and discovery show concise summaries. Contact notes, aliases, contact surnames and shell completion explain their positional arguments.
60
+
61
+ - Message deletion, chat export and duplicate-task commands use singular argument names in help and discovery. Variadic arguments still accept several values, and task argument help identifies each side of a pair.
62
+
63
+ - Contact profiles, phone lookups and address-book sync summaries declare their existing answer fields. Profile schemas retain masked phones, incomplete activity counts, partial provider flags and the source and precision of account-age estimates.
64
+
65
+ ### Added
66
+
67
+ - Reaction commands include concrete examples in help and command discovery.
68
+
69
+ - Recipient-list commands include concrete examples in help and command discovery.
70
+
71
+ - Saved-search commands include concrete examples in help and command discovery.
72
+
73
+ - **Independent media archives for messenger CLIs.** `attachments download <dir>` saves files by account,
74
+ chat, message and position, verifies size and SHA-256 on reruns, and preserves completed downloads after
75
+ interruption. `attachments check <dir>` verifies copies offline. `attachments jobs` installs and manages
76
+ opt-in Linux daily timers. Archive files stay independent of message-store deletion; interrupted streams
77
+ restart from byte zero because messenger downloads expose no range validators.
78
+ Download help clarifies that receiving messages saves no media copies and names the command to stop a daily timer.
79
+ `attachments move <dir> <archive-dir> --before-time <time>` moves older images to verified cold storage;
80
+ `--dry-run` previews counts and bytes. Relocation records preserve offline verification and prevent duplicate
81
+ downloads. New manifests retain message dates and image MIME types; undated draft images are left in place.
9
82
 
10
83
  ## 0.396.0 — 23.10.2026
11
84
 
package/README.md CHANGED
@@ -4,7 +4,8 @@
4
4
 
5
5
  # @wirecat/cli-core
6
6
 
7
- The engine behind the WireCat command-line tools for Telegram, MAX, mail, notes and meetings. Everything that
7
+ The engine behind the WireCat command-line tools for Telegram, MAX, WhatsApp, mail, notes, meetings and Google
8
+ Drive. Everything that
8
9
  does not depend on one messenger lives here: the data model, a local SQLite store with full-text and semantic
9
10
  search, a Lucene query language, the shared commands, an MCP server for AI agents, and the guard around every
10
11
  write. A messenger plugs in as one adapter and gets all of it.
@@ -25,6 +26,7 @@ write. A messenger plugs in as one adapter and gets all of it.
25
26
  |---|---|
26
27
  | [Architecture](docs/dev/ARCHITECTURE.md) | The layers, the module map, the store internals and migrations, who consumes what |
27
28
  | [Document workflows](docs/dev/DOCUMENTS.md) | Shared file ingestion, sync, scoped knowledge and extraction; the ports a file provider supplies |
29
+ | [Media archives](docs/dev/MEDIA-ARCHIVE.md) | Independent verified file copies, interruption recovery and opt-in daily timers |
28
30
  | [Write an adapter](docs/dev/ADAPTERS.md) | How to add a messenger: the port, the `Messenger` description, the contract cases |
29
31
  | [Messenger rules](docs/dev/STANDARD.md) | What binds tg, max and wa beyond the [CLI standards handbook](https://github.com/WireCatLabs/cli-base/blob/main/docs/standards/README.md): parity, the option catalogue, `bot` |
30
32
  | [Glossary](docs/dev/GLOSSARY.md) | One word, one meaning across the tools |
@@ -35,6 +37,7 @@ write. A messenger plugs in as one adapter and gets all of it.
35
37
  | [Testing](docs/dev/TESTING.md) · [Parity audit](docs/dev/PARITY-AUDIT.md) | The sandbox, the coverage floor, the checks across tg and max |
36
38
  | [Tool boundaries](docs/dev/TOOL-BOUNDARIES.md) | Which tool owns which command |
37
39
  | [API reference](docs/REFERENCE.md) | Every entry point and the detailed contract of each feature |
40
+ | [Roadmap](docs/roadmap.md) | What is being built next for developers on this package |
38
41
  | [Changelog](CHANGELOG.md) | What each version changed, and what may break callers |
39
42
 
40
43
  ## What it is
@@ -71,7 +74,7 @@ It runs on Node 22.16+ and 24+, and on Bun.
71
74
 
72
75
  ```mermaid
73
76
  flowchart TB
74
- tools["tg · max · memo · zm · drive"] --> interface
77
+ tools["tg · max · wa · memo · zm · drive"] --> interface
75
78
  subgraph core ["@wirecat/cli-core"]
76
79
  interface["Interface: CLI commands and MCP tools"] --> services["Services: one use case each"]
77
80
  services --> ports["Ports: MessengerAdapter · MeetingSource · TaskStore"]
@@ -87,7 +90,7 @@ Five layers, and each calls only the layers below it: domain, adapters behind a
87
90
 
88
91
  - **The CLI and the MCP server are two adapters over the same services.** A command and its MCP tool answer with
89
92
  the same data, because one service produces it.
90
- - **A port with one required core and optional groups.** `MessengerCore` is what every messenger must do. Seventeen
93
+ - **A port with one required core and optional groups.** `MessengerCore` is what every messenger must do. The other
91
94
  groups — server reads, polls, forums, reactions and the rest — are optional and reached through `capability()`.
92
95
  A messenger without a group refuses the command before connecting; it never crashes and never silently drops an
93
96
  option.
@@ -115,8 +118,9 @@ code and its document must agree. A search plan that reads an index once per mes
115
118
  - **One query language for every list and every source.** A TypeScript port of the Apache Lucene 9.12.3 parsers,
116
119
  with no runtime parser dependency. Phrases, `AND` / `OR` / `NOT`, `+required`, grouping, field groups like
117
120
  `from:(alice OR bob)`, ranges `date:[2026-01-01 TO 2026-02-01}`, comparisons `size>10MB`, wildcards and regular
118
- expressions. 27 fields: sender, chat, date, file name, MIME type, transcript, subject, label and more. Fuzzy
119
- and proximity operators are refused with an error that names the alternative.
121
+ expressions. 27 fields: sender, chat, date, file name, MIME type, transcript, subject, label and more.
122
+ Proximity `"invoice paid"~3` finds the words close together in one message; the fuzzy operator `word~1` is
123
+ refused with an error that names the alternative.
120
124
  [Syntax](docs/search/query-language.md) · [grammar](docs/search/query-language-spec.md)
121
125
  - **Any three letters inside a word.** An FTS5 trigram index finds `port` inside `report`. A
122
126
  second, word index ranks by BM25.
@@ -210,8 +214,9 @@ they deliberately differ. A rule changes in its own pull request, before the cod
210
214
  - **Built for agents.** `--no-input` never prompts. Input and output are bounded (16 MiB in, 4 MiB out) and fail
211
215
  visibly instead of truncating. One-shot commands time out after 30 seconds and close what they hold. `--dry-run`
212
216
  previews any write.
213
- - **The tool never picks an account.** The profile is the first word of every account command; without one, the
214
- command stops and lists the choices — even when there is only one.
217
+ - **The tool never picks an account.** Every account command names its profile — the first word in tg, max and
218
+ wa, an option such as `--account` or `--profile` in the others; without one, the command stops and lists the
219
+ choices — even when there is only one.
215
220
  - **Run records keep ids and timings, never content.** A failure is kept by default, so `runs show` can explain it
216
221
  without anyone sending a log full of messages.
217
222
  - **Tests that cannot touch real data.** A sandbox keeps every test away from the owner's store, and no test may
@@ -252,8 +257,7 @@ describe("the adapter keeps the port's promises", () => {
252
257
  **Before 1.0, any release may break a caller.** Releases are frequent, sometimes several a day, and any of them
253
258
  may change any entry point. Every such change is listed in the [changelog](CHANGELOG.md) under "Changed — may break
254
259
  callers", with what to change. tg-cli and max-cli pin an exact version and move to a new one in their own pull request.
255
- Pin an exact version too: as of 18.10.2026, 132 of the 321 versions in the changelog break callers, and no patch
256
- release has.
260
+ Pin an exact version too.
257
261
  The full list of entry points and what each holds is in the [API reference](docs/REFERENCE.md).
258
262
 
259
263
  ⚠ **Errors are recognised by shape, not by class** (`isCliFailure`). A package linked during development brings its
@@ -0,0 +1,40 @@
1
+ import type { AppIdentity } from "../ports/app.js";
2
+ import type { ServerSystem } from "./system.js";
3
+ export declare const runArchiveTimerCommand: (args: string[], env: NodeJS.ProcessEnv, signal?: AbortSignal) => Promise<{
4
+ code: number;
5
+ stdout: string;
6
+ stderr: string;
7
+ }>;
8
+ export interface DailyArchiveTimer {
9
+ name: string;
10
+ unitDirectory: string;
11
+ service: string;
12
+ timer: string;
13
+ }
14
+ export interface DailyArchiveTimerInput {
15
+ app: AppIdentity;
16
+ profile: string;
17
+ directory: string;
18
+ entry: readonly string[];
19
+ env: NodeJS.ProcessEnv;
20
+ chats?: readonly string[];
21
+ sinceTime?: string;
22
+ at?: string;
23
+ timeZone?: string;
24
+ }
25
+ export declare const dailyArchiveTimer: (input: DailyArchiveTimerInput) => DailyArchiveTimer;
26
+ export declare const manageDailyArchiveTimer: (plan: DailyArchiveTimer, action: "install" | "start" | "stop" | "status" | "uninstall", system: Pick<ServerSystem, "platform" | "run">, env: NodeJS.ProcessEnv, signal?: AbortSignal) => Promise<{
27
+ name: string;
28
+ installed: boolean;
29
+ active: boolean;
30
+ servicePath: string;
31
+ timerPath: string;
32
+ action?: undefined;
33
+ } | {
34
+ active?: undefined;
35
+ name: string;
36
+ installed: boolean;
37
+ action: "install" | "start" | "stop" | "uninstall";
38
+ servicePath: string;
39
+ timerPath: string;
40
+ }>;
@@ -0,0 +1,181 @@
1
+ import { createHash } from "node:crypto";
2
+ import { mkdir, realpath, unlink } from "node:fs/promises";
3
+ import { isAbsolute, join, resolve } from "node:path";
4
+ import { CliError } from "@wirecat/cli-base";
5
+ import { acquireLock, readBoundedUtf8File, syncDirectory, writeFileAtomic } from "../files/index.js";
6
+ import { locationVariables } from "./units.js";
7
+ export const runArchiveTimerCommand = (args, env, signal) => new Promise((resolve, reject) => {
8
+ execFile(args[0] ?? "systemctl", args.slice(1), {
9
+ env,
10
+ signal,
11
+ timeout: 30000,
12
+ maxBuffer: 65536,
13
+ encoding: "utf8",
14
+ shell: false,
15
+ }, (error, stdout, stderr) => {
16
+ if (!error)
17
+ return resolve({ code: 0, stdout, stderr });
18
+ if (typeof error.code === "number")
19
+ return resolve({ code: error.code, stdout, stderr });
20
+ reject(new CliError("configuration_error", "archive timer command did not complete; inspect its systemctl status"));
21
+ });
22
+ });
23
+ const missingMediaFile = (error) => typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT";
24
+ const digest = (value) => createHash("sha256").update(value).digest("hex");
25
+ const quote = (value, expandDollar) => `"${value
26
+ .replaceAll("\\", "\\\\")
27
+ .replaceAll('"', '\\"')
28
+ .replaceAll("%", "%%")
29
+ .replaceAll("$", () => (expandDollar ? "$$" : "$"))}"`;
30
+ const unitFolder = (env) => {
31
+ const folder = env.XDG_CONFIG_HOME ?? (env.HOME ? join(env.HOME, ".config") : "");
32
+ if (!isAbsolute(folder) || /[\p{Cc}]/u.test(folder))
33
+ throw new CliError("configuration_error", "a timer needs an absolute HOME or XDG_CONFIG_HOME");
34
+ return join(folder, "systemd", "user");
35
+ };
36
+ export const dailyArchiveTimer = (input) => {
37
+ const at = input.at ?? "03:00";
38
+ const timeZone = input.timeZone ?? "UTC";
39
+ const environment = locationVariables(input.app, input.env);
40
+ let zone;
41
+ try {
42
+ zone = new Intl.DateTimeFormat("en", { timeZone }).resolvedOptions().timeZone;
43
+ }
44
+ catch {
45
+ throw new CliError("validation_error", "--timezone needs an IANA time zone");
46
+ }
47
+ if (!/^(?:[01]\d|2[0-3]):[0-5]\d$/.test(at) ||
48
+ !/^[A-Za-z0-9_+/-]+$/.test(zone) ||
49
+ !/^[a-z][a-z0-9-]*$/.test(input.app.command) ||
50
+ !input.profile ||
51
+ !isAbsolute(input.directory) ||
52
+ input.entry.length < 1 ||
53
+ input.entry.some((one) => !isAbsolute(one)) ||
54
+ [
55
+ input.profile,
56
+ input.directory,
57
+ ...input.entry,
58
+ ...Object.values(environment),
59
+ ...(input.chats ?? []),
60
+ input.sinceTime ?? "",
61
+ ].some((one) => /[\p{Cc}]/u.test(one)))
62
+ throw new CliError("validation_error", "invalid daily archive timer arguments");
63
+ const scope = digest(JSON.stringify([
64
+ input.app.command,
65
+ input.profile,
66
+ input.directory,
67
+ Object.entries(environment).sort(([a], [b]) => a.localeCompare(b)),
68
+ ]));
69
+ const name = `${input.app.command}-media-archive-${scope.slice(0, 32)}`;
70
+ const owner = `# WireCat daily media archive ${scope}`;
71
+ const command = [
72
+ ...input.entry,
73
+ input.profile,
74
+ "attachments",
75
+ "download",
76
+ input.directory,
77
+ "--json",
78
+ "--no-input",
79
+ "--timeout",
80
+ "6h",
81
+ ...(input.sinceTime ? ["--since-time", input.sinceTime] : []),
82
+ ...(input.chats ?? []).flatMap((chat) => ["--chat", chat]),
83
+ ];
84
+ const seal = (text) => `${text}# SHA-256 ${digest(text)}\n`;
85
+ return {
86
+ name,
87
+ unitDirectory: unitFolder(input.env),
88
+ service: seal(`${owner}\n[Unit]\nDescription=WireCat media archive\n\n[Service]\nType=oneshot\nUMask=0077\nTimeoutStartSec=6h\n${Object.entries(environment)
89
+ .map(([key, value]) => `Environment=${quote(`${key}=${value}`, false)}\n`)
90
+ .join("")}ExecStart=${command.map((one) => quote(one, true)).join(" ")}\n`),
91
+ timer: seal(`${owner}\n[Unit]\nDescription=WireCat daily media archive\n\n[Timer]\nOnCalendar=*-*-* ${at}:00 ${zone}\nPersistent=true\nUnit=${name}.service\n\n[Install]\nWantedBy=timers.target\n`),
92
+ };
93
+ };
94
+ const ownedUnit = async (path, expectedOwner) => {
95
+ let text;
96
+ try {
97
+ text = await readBoundedUtf8File(path, { maxBytes: 65536 });
98
+ }
99
+ catch (error) {
100
+ if (missingMediaFile(error))
101
+ return null;
102
+ throw error;
103
+ }
104
+ const sealAt = text.lastIndexOf("# SHA-256 ");
105
+ if (!text.startsWith(`${expectedOwner}\n`) ||
106
+ sealAt < 0 ||
107
+ text.slice(sealAt) !== `# SHA-256 ${digest(text.slice(0, sealAt))}\n`)
108
+ throw new CliError("validation_error", "archive timer unit is foreign or edited; it was preserved");
109
+ return text;
110
+ };
111
+ export const manageDailyArchiveTimer = async (plan, action, system, env, signal) => {
112
+ if (system.platform !== "linux")
113
+ throw new CliError("configuration_error", "daily media archive timers require Linux systemd user services");
114
+ if (!isAbsolute(plan.unitDirectory) || !/^[a-z][a-z0-9-]*-media-archive-[a-f0-9]{32}$/.test(plan.name))
115
+ throw new CliError("validation_error", "invalid archive timer paths");
116
+ signal?.throwIfAborted();
117
+ await mkdir(plan.unitDirectory, { recursive: true, mode: 0o700 });
118
+ const folder = await realpath(plan.unitDirectory);
119
+ const lease = await acquireLock(join(folder, `.${plan.name}.lock`), { signal });
120
+ const beforeWrite = async () => {
121
+ signal?.throwIfAborted();
122
+ if (!(await lease.held()))
123
+ throw new CliError("validation_error", "archive timer lost its writer lock");
124
+ };
125
+ const servicePath = resolve(folder, `${plan.name}.service`);
126
+ const timerPath = resolve(folder, `${plan.name}.timer`);
127
+ const owner = plan.service.split("\n")[0] ?? "";
128
+ const run = async (...args) => {
129
+ await beforeWrite();
130
+ const result = await system.run(["systemctl", "--user", ...args], env);
131
+ if (result.code !== 0)
132
+ throw new CliError("configuration_error", "systemctl could not change the archive timer; inspect its status");
133
+ };
134
+ try {
135
+ const service = await ownedUnit(servicePath, owner);
136
+ const timer = await ownedUnit(timerPath, owner);
137
+ const installed = service !== null && timer !== null;
138
+ if (action === "status") {
139
+ const active = installed
140
+ ? await system.run(["systemctl", "--user", "is-active", `${plan.name}.timer`], env)
141
+ : undefined;
142
+ return { name: plan.name, installed, active: active?.code === 0, servicePath, timerPath };
143
+ }
144
+ if (action === "install") {
145
+ if ((service !== null && service !== plan.service) || (timer !== null && timer !== plan.timer))
146
+ throw new CliError("validation_error", "this destination already has a different schedule; stop and uninstall it first");
147
+ await beforeWrite();
148
+ if (service === null)
149
+ await writeFileAtomic(servicePath, plan.service, { overwrite: false, beforePublish: beforeWrite });
150
+ if (timer === null)
151
+ await writeFileAtomic(timerPath, plan.timer, { overwrite: false, beforePublish: beforeWrite });
152
+ await run("daemon-reload");
153
+ }
154
+ else {
155
+ if (!installed)
156
+ throw new CliError("not_found", "archive timer is not installed");
157
+ if (action === "start")
158
+ await run("enable", "--now", `${plan.name}.timer`);
159
+ if (action === "stop")
160
+ await run("disable", "--now", `${plan.name}.timer`);
161
+ if (action === "uninstall") {
162
+ await beforeWrite();
163
+ const active = await system.run(["systemctl", "--user", "is-active", `${plan.name}.timer`], env);
164
+ const enabled = await system.run(["systemctl", "--user", "is-enabled", `${plan.name}.timer`], env);
165
+ const running = await system.run(["systemctl", "--user", "is-active", `${plan.name}.service`], env);
166
+ if (active.code !== 3 || enabled.code !== 1 || running.code !== 3)
167
+ throw new CliError("validation_error", "stop the archive timer and wait for its service to finish before uninstalling");
168
+ await beforeWrite();
169
+ await unlink(servicePath);
170
+ await unlink(timerPath);
171
+ await syncDirectory(folder);
172
+ await run("daemon-reload");
173
+ }
174
+ }
175
+ return { name: plan.name, installed: action !== "uninstall", action, servicePath, timerPath };
176
+ }
177
+ finally {
178
+ await lease.release();
179
+ }
180
+ };
181
+ import { execFile } from "node:child_process";
@@ -1,4 +1,6 @@
1
1
  export { type Checkpoint, checkpointPath, clearCheckpoint, type Progress, progressLine, progressOf, readCheckpoint, saveCheckpoint, } from "./checkpoints.js";
2
+ export type { DailyArchiveTimer, DailyArchiveTimerInput } from "./daily-archive-timer.js";
3
+ export { dailyArchiveTimer, manageDailyArchiveTimer } from "./daily-archive-timer.js";
2
4
  export { holdLock, type Lock, lockPath, readLock, releaseLock, servingProfiles, takeLock } from "./lock.js";
3
5
  export { alive, carries, holdersOf } from "./processes.js";
4
6
  export { listenRelay, RELAY_PROTOCOL, type RelayAddress, type RelayIo, type RelayRequest, relayCommand, relayPath, } from "./relay.js";
@@ -1,4 +1,5 @@
1
1
  export { checkpointPath, clearCheckpoint, progressLine, progressOf, readCheckpoint, saveCheckpoint, } from "./checkpoints.js";
2
+ export { dailyArchiveTimer, manageDailyArchiveTimer } from "./daily-archive-timer.js";
2
3
  export { holdLock, lockPath, readLock, releaseLock, servingProfiles, takeLock } from "./lock.js";
3
4
  export { alive, carries, holdersOf } from "./processes.js";
4
5
  export { listenRelay, RELAY_PROTOCOL, relayCommand, relayPath, } from "./relay.js";
@@ -35,7 +35,7 @@ export const botAdminsCommand = (bot) => {
35
35
  renderAnswer(this, context, wholeList("bot chats admins list", admins.map(snakeKeys)));
36
36
  });
37
37
  });
38
- annotate(command.command("add"), { effects: REMOTE_WRITE, mcp: false })
38
+ annotate(command.command("add"), { effects: REMOTE_WRITE })
39
39
  .description("make a member an admin with these rights")
40
40
  .argument("<chat>", CHAT)
41
41
  .argument("<person>", PERSON)
@@ -61,7 +61,7 @@ export const botAdminsCommand = (bot) => {
61
61
  });
62
62
  });
63
63
  });
64
- annotate(command.command("remove"), { effects: REMOTE_WRITE, mcp: false })
64
+ annotate(command.command("remove"), { effects: REMOTE_WRITE })
65
65
  .description("take an admin's rights back; they stay a member")
66
66
  .argument("<chat>", CHAT)
67
67
  .argument("<person>", PERSON)
@@ -111,7 +111,7 @@ const meCommand = (bot) => annotate(new Command("me"), { effects: REMOTE_READ })
111
111
  }
112
112
  });
113
113
  });
114
- const listCommand = (bot, tokenVariable) => annotate(new Command("list"), { effects: LOCAL_READ, mcp: false })
114
+ const listCommand = (bot, tokenVariable) => annotate(new Command("list"), { effects: LOCAL_READ })
115
115
  .description(`every name on this machine that has a bot token; --check asks ${bot.name ?? "the messenger"} which bot each is`)
116
116
  .option("--check", "ask the messenger who each bot is, with its token")
117
117
  .action(async function () {
@@ -216,7 +216,7 @@ const chatsCommand = (bot) => {
216
216
  renderItem(this, context, found);
217
217
  });
218
218
  });
219
- annotate(command.command("leave"), { effects: REMOTE_WRITE, mcp: false })
219
+ annotate(command.command("leave"), { effects: REMOTE_WRITE })
220
220
  .description("take the bot out of a chat; only an admin of the chat can bring it back")
221
221
  .argument("<chat>", "a chat id, or the title of a chat this bot has seen")
222
222
  .action(async function (chat) {
@@ -48,13 +48,13 @@ export const botMenuCommand = (bot) => {
48
48
  renderAnswer(command, context, wholeList("bot commands list", entries));
49
49
  });
50
50
  };
51
- annotate(command.command("set"), { effects: REMOTE_WRITE, mcp: false })
51
+ annotate(command.command("set"), { effects: REMOTE_WRITE })
52
52
  .description("replace the whole menu: each command as name=description, e.g. start=Begin")
53
53
  .argument("<commands...>", "name=description, one per command")
54
54
  .action(async function (typed) {
55
55
  await replace(this, typed.map(entryOf), "bot.commands.set");
56
56
  });
57
- annotate(command.command("clear"), { effects: REMOTE_WRITE, mcp: false })
57
+ annotate(command.command("clear"), { effects: REMOTE_WRITE })
58
58
  .description("empty the menu")
59
59
  .action(async function () {
60
60
  await replace(this, [], "bot.commands.clear");
@@ -97,7 +97,7 @@ export const botCallbacksCommand = (bot) => new Command("callbacks").description
97
97
  }));
98
98
  /** `bot webhooks`: where the messenger pushes this bot's updates instead of being polled. */
99
99
  export const botWebhooksCommand = (bot) => {
100
- const command = annotate(new Command("webhooks"), { mcp: false }).description("where the messenger pushes this bot's updates — while one is set, `bot watch` gets nothing");
100
+ const command = new Command("webhooks").description("where the messenger pushes this bot's updates — while one is set, `bot watch` gets nothing");
101
101
  annotate(command.command("list"), { effects: REMOTE_READ })
102
102
  .description("the webhooks this bot has")
103
103
  .action(async function () {
@@ -50,7 +50,7 @@ export const commandsCommand = (app) => {
50
50
  exit_codes: { ok: 0, [CRASH_CODE]: GENERIC_FAILURE, ...EXIT_CODES },
51
51
  });
52
52
  });
53
- command.addCommand(annotate(new Command("schema"), { effects: NO_EFFECTS, mcp: false })
53
+ command.addCommand(annotate(new Command("schema"), { effects: NO_EFFECTS })
54
54
  .description("one command's argv and result schemas, effects, permissions and retry guidance")
55
55
  .argument("<path...>", "one command path, for example: stats messages show")
56
56
  .action(function (path) {
@@ -15,7 +15,7 @@ import { changeStoreSetting, isStoreSetting, STORE_SETTINGS, storeSettings } fro
15
15
  * configuration accepts can hold a secret — the schema has nowhere to put one.
16
16
  */
17
17
  export const configCommand = (app, config, { permissionKey } = {}) => {
18
- const command = annotate(new Command("config"), { mcp: false }).description("the settings in force, and where each one came from");
18
+ const command = new Command("config").description("the settings in force, and where each one came from");
19
19
  annotate(command.command("show"), { effects: LOCAL_READ, examples: [`${app.command} personal config show`] })
20
20
  .description("the profile, the profiles that exist, and each setting with where it came from")
21
21
  .option("--bot", "the settings a bot command on this profile gets, rather than the personal account's")
package/dist/cli/guide.js CHANGED
@@ -116,7 +116,6 @@ const scenarioText = (cli, scenario, profile) => [
116
116
  export const guideCommand = (app, options) => {
117
117
  const command = annotate(new Command("guide"), {
118
118
  effects: NO_EFFECTS,
119
- mcp: false,
120
119
  examples: [`${app.command} guide`, `${app.command} guide <name> --json`],
121
120
  })
122
121
  .description("scenarios: the commands for a common task, in order, and why each")
@@ -7,6 +7,8 @@ export interface NextChoice {
7
7
  }
8
8
  /** Overrides the "Next usually" a command's help derives from types. Kept off `annotate`, like its section. */
9
9
  export declare const nextUsually: <T extends Command>(command: T, choice: NextChoice) => T;
10
+ /** The profile an example names, so a pasted one runs; the name the first screen's "Start here" uses. */
11
+ export declare const EXAMPLE_PROFILE = "personal";
10
12
  export interface CommandHelp {
11
13
  returns: string;
12
14
  next: readonly string[];
@@ -20,8 +22,13 @@ export declare const commandHelp: (command: Command) => CommandHelp;
20
22
  export declare const commandHelpText: (command: Command) => string;
21
23
  /** The same, as Markdown lines for `docs/commands.md`: hand it to cli-base's `commandsPage` per command. */
22
24
  export declare const commandHelpMarkdown: (root: Command, path: readonly string[]) => string[];
23
- /** Puts Returns, Next usually and Example on every leaf command's `--help`, worked out when the help is shown. */
24
- export declare const installCommandHelp: (program: Command) => void;
25
+ /**
26
+ * Puts Returns, Next usually and Example on every leaf command's `--help`, worked out when the help is shown.
27
+ * With `profile`, an example of an account command gets the profile word it would fail without.
28
+ */
29
+ export declare const installCommandHelp: (program: Command, { profile }?: {
30
+ profile?: boolean;
31
+ }) => void;
25
32
  /** Which leaf commands still show a generated example, so the important ones get a written one. Report only. */
26
33
  export declare const exampleReport: (program: Command) => {
27
34
  written: string[];
@@ -1,6 +1,7 @@
1
1
  import { metaOf } from "@wirecat/cli-base/commands";
2
2
  import { writes } from "@wirecat/cli-base/standards";
3
3
  import { commandPathOf, outputOf } from "../command-contract.js";
4
+ import { PROFILE_FREE } from "../profile.js";
4
5
  import { commandAt } from "../steps.js";
5
6
  import { short } from "./commands-text.js";
6
7
  /** Argument names onto the kinds `domain/references.ts` names; a plural takes the same kind. */
@@ -164,16 +165,28 @@ const generatedExample = (command) => {
164
165
  return [flag];
165
166
  return [flag, option.argChoices?.[0] ?? sample(placeholder ?? option.attributeName())];
166
167
  });
167
- // No profile word, as on the first screen and in written examples: the Usage line already shows `<profile>`.
168
168
  return [rootOf(command).name(), ...commandPathOf(command), ...words, ...options].join(" ");
169
169
  };
170
+ const profiled = new WeakSet();
171
+ /** The profile an example names, so a pasted one runs; the name the first screen's "Start here" uses. */
172
+ export const EXAMPLE_PROFILE = "personal";
173
+ const withProfile = (command, example) => {
174
+ const root = rootOf(command);
175
+ const top = commandPathOf(command)[0];
176
+ if (!profiled.has(root) || top === undefined || PROFILE_FREE.has(top))
177
+ return example;
178
+ const prefix = `${root.name()} ${top}`;
179
+ return example === prefix || example.startsWith(`${prefix} `)
180
+ ? `${root.name()} ${EXAMPLE_PROFILE} ${example.slice(root.name().length + 1)}`
181
+ : example;
182
+ };
170
183
  /** Returns, Next usually and Example for one command, from its definition alone. */
171
184
  export const commandHelp = (command) => {
172
185
  const written = metaOf(command).examples ?? [];
173
186
  return {
174
187
  returns: returnsOf(command),
175
188
  next: nextOf(command).map(call),
176
- examples: written.length > 0 ? written : [generatedExample(command)],
189
+ examples: (written.length > 0 ? written : [generatedExample(command)]).map((one) => withProfile(command, one)),
177
190
  origin: written.length > 0 ? "written" : "generated",
178
191
  };
179
192
  };
@@ -197,8 +210,13 @@ export const commandHelpMarkdown = (root, path) => {
197
210
  `**Example:** ${code(examples)}`,
198
211
  ];
199
212
  };
200
- /** Puts Returns, Next usually and Example on every leaf command's `--help`, worked out when the help is shown. */
201
- export const installCommandHelp = (program) => {
213
+ /**
214
+ * Puts Returns, Next usually and Example on every leaf command's `--help`, worked out when the help is shown.
215
+ * With `profile`, an example of an account command gets the profile word it would fail without.
216
+ */
217
+ export const installCommandHelp = (program, { profile = true } = {}) => {
218
+ if (profile)
219
+ profiled.add(program);
202
220
  for (const command of leaves(program))
203
221
  command.addHelpText("after", () => commandHelpText(command));
204
222
  };
@@ -4,7 +4,7 @@ import { Command, Help, Option } from "commander";
4
4
  import wrapAnsi from "wrap-ansi";
5
5
  import { NO_EFFECTS } from "../effects.js";
6
6
  import { commandAt } from "../steps.js";
7
- import { installCommandHelp } from "./command-help.js";
7
+ import { EXAMPLE_PROFILE, installCommandHelp } from "./command-help.js";
8
8
  /** Every WireCat tool, as "Works with" names it; a tool's own entry is left out of its screen. */
9
9
  export const WIRECAT_TOOLS = [
10
10
  { command: "tg", does: "Telegram in the same local store: search, review and tasks offline" },
@@ -56,7 +56,7 @@ export const firstScreen = (program, screen = {}) => {
56
56
  const can = (screen.can ?? []).map(({ command, does }) => [command, does]);
57
57
  const works = WIRECAT_TOOLS.filter((tool) => tool.command !== cli).map(({ command, shown, does }) => [shown ?? command, does]);
58
58
  const column = Math.max(22, widest([...can, ...works].map(([left]) => left))) + 2;
59
- const start = screen.start ?? (commandAt(program, "setup") ? `${cli} personal setup` : undefined);
59
+ const start = screen.start ?? (commandAt(program, "setup") ? `${cli} ${EXAMPLE_PROFILE} setup` : undefined);
60
60
  return [
61
61
  wrapAnsi(`${cli} — ${pitch}`, WIDTH),
62
62
  "",
@@ -95,7 +95,7 @@ export const installHelp = (program, screen = {}) => {
95
95
  ? firstScreen(program, screen)
96
96
  : (own.formatHelp ?? Help.prototype.formatHelp).call(helper, command, helper),
97
97
  });
98
- installCommandHelp(program);
98
+ installCommandHelp(program, { profile: screen.profile !== false });
99
99
  program.addOption(new Option("--help-all", "the full help: every command and option").hideHelp());
100
100
  program.on("option:help-all", () => {
101
101
  full = true;
@@ -12,12 +12,14 @@ import { recalledAccount } from "./accounts.js";
12
12
  import { photoHelp, readProfilePhoto } from "./admin-commands.js";
13
13
  import { accountAnswer, renderItem, wholeList } from "./chat-answers.js";
14
14
  import { messengerContext } from "./context.js";
15
+ import { hideNever } from "./lacks.js";
15
16
  import { privacyCommand } from "./records-command.js";
16
17
  export const accountCommand = (messenger) => {
17
18
  const tool = `${messenger.app.command} personal`;
18
19
  const account = new Command("account")
19
20
  .description("the logged-in account")
20
21
  .addCommand(annotate(new Command("list"), { effects: REMOTE_READ, examples: [`${tool} account list`] })
22
+ .summary("profiles on this computer and their signed-in accounts")
21
23
  .description("every profile on this computer, and the account each is logged in as; asks the messenger nothing")
22
24
  .action(async function () {
23
25
  const context = messengerContext(this, messenger, { needsProfile: false });
@@ -79,5 +81,5 @@ export const accountCommand = (messenger) => {
79
81
  .addCommand(accountSessionsCommand(messenger));
80
82
  if (messenger.privacy === true)
81
83
  account.addCommand(privacyCommand(messenger));
82
- return account;
84
+ return hideNever(messenger, account);
83
85
  };
@@ -12,7 +12,9 @@ const SESSIONS_QUERY = unfilteredQuery("account_session", accountSession);
12
12
  /** `account sessions …` — where else the account is logged in, not this tool's own `session`. */
13
13
  export const accountSessionsCommand = (messenger) => {
14
14
  const tool = `${messenger.app.command} personal`;
15
- const sessions = new Command("sessions").description(`where else this account is logged in — not \`${messenger.app.command} session\`, which is this tool's own login`);
15
+ const sessions = new Command("sessions")
16
+ .summary("devices and apps signed in to this account")
17
+ .description(`where else this account is logged in — not \`${messenger.app.command} session\`, which is this tool's own login`);
16
18
  withQuery(annotate(sessions.command("list"), { effects: REMOTE_READ, examples: [`${tool} account sessions list`] }), SESSIONS_QUERY)
17
19
  .description("every device and app logged in to this account; nothing is ended")
18
20
  .action(async function (words) {