@wirecat/cli-core 0.529.0 → 0.531.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 (54) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/dist/cli/config-command.d.ts +11 -1
  3. package/dist/cli/config-command.js +12 -10
  4. package/dist/cli/index.d.ts +2 -2
  5. package/dist/cli/index.js +1 -1
  6. package/dist/cli/list-query.d.ts +0 -4
  7. package/dist/cli/list-query.js +19 -1
  8. package/dist/cli/messenger/download-command.d.ts +2 -1
  9. package/dist/cli/messenger/download-command.js +2 -1
  10. package/dist/cli/messenger/schedule-command.d.ts +2 -19
  11. package/dist/cli/messenger/schedule-command.js +2 -231
  12. package/dist/cli/messenger/topics-command.js +22 -5
  13. package/dist/cli/schedule/schedule-command.d.ts +19 -0
  14. package/dist/cli/schedule/schedule-command.js +261 -0
  15. package/dist/cli/shared-command-exports.d.ts +2 -1
  16. package/dist/cli/shared-command-exports.js +2 -1
  17. package/dist/domain/models.d.ts +2 -0
  18. package/dist/entities/index.d.ts +41 -2
  19. package/dist/entities/index.js +3 -2
  20. package/dist/entities/list-results.d.ts +3 -0
  21. package/dist/entities/list-results.js +1 -0
  22. package/dist/entities/task-results.d.ts +30 -0
  23. package/dist/entities/task-results.js +1 -0
  24. package/dist/entities/task.d.ts +7 -0
  25. package/dist/entities/task.js +2 -0
  26. package/dist/errors/reasons.js +1 -0
  27. package/dist/files/index.d.ts +1 -0
  28. package/dist/files/index.js +1 -0
  29. package/dist/files/safe-name.d.ts +5 -0
  30. package/dist/files/safe-name.js +18 -0
  31. package/dist/mail/account-key.d.ts +2 -0
  32. package/dist/mail/account-key.js +1 -0
  33. package/dist/mail/cli/index.js +10 -10
  34. package/dist/mail/index.d.ts +1 -0
  35. package/dist/mail/index.js +1 -0
  36. package/dist/mail/retry.js +2 -1
  37. package/dist/mail/rule-stored.js +2 -1
  38. package/dist/mail/sender-scores.js +2 -1
  39. package/dist/mail/sync.js +3 -2
  40. package/dist/ports/messenger.d.ts +3 -0
  41. package/dist/services/file-download.d.ts +2 -5
  42. package/dist/services/file-download.js +3 -18
  43. package/dist/services/tasks.d.ts +4 -2
  44. package/dist/services/tasks.js +2 -2
  45. package/dist/services/topics.d.ts +3 -0
  46. package/dist/services/topics.js +20 -6
  47. package/dist/store/index.d.ts +1 -0
  48. package/dist/store/sqlite/task-board.js +3 -1
  49. package/dist/store/sqlite/task-comments.d.ts +23 -0
  50. package/dist/store/sqlite/task-comments.js +41 -0
  51. package/dist/store/sqlite/things.js +8 -1
  52. package/dist/store/sqlite/work-tasks.d.ts +13 -2
  53. package/dist/store/sqlite/work-tasks.js +52 -1
  54. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -5,6 +5,51 @@ 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
9
+
10
+ ## 0.531.0 — 01.11.2026
11
+
12
+ ### Added
13
+
14
+ - **`topics create --emoji` and `topics edit --emoji`: a forum topic's icon.** The value is an emoji from the
15
+ messenger's topic icon set, which `topics icons list` shows; `topics edit --emoji none` removes the icon. An emoji
16
+ change is journaled as `topic-edit`. Adapters take it as `createTopic(…, { icon })` and `TopicChange.icon`
17
+ (`null` removes it), and list the set with the optional `TopicEditing.topicIcons()`; the `topic_icon` entity is the
18
+ list's item.
19
+ - **`tasks.edit(id, { project })` moves a project task to another of the owner's projects.** It takes the next key
20
+ there; `id`, `ref` and `key` change, and the old key still finds the task through a `project` event in its
21
+ history. The source project's numbers are never reused. A subtask leaves its parent; a
22
+ task with subtasks is refused (`task.has-subtasks`, with their keys). The revision goes up.
23
+ - **`tasks.edit(id, { type })` changes a project task's kind** among bug, feature and chore, as a `type` event, and
24
+ bumps the revision.
25
+ - **A task names who added it**: `TaskDetails.createdBy` (`created_by` in the `task` and `task_card` entities),
26
+ `{ type, id, name, owner }`. `tasksService.add` takes an `author`, so a task found in a message by a named agent is
27
+ that agent's, not `agent`'s.
28
+ - **Task comments**: `tasks.comment(id, text, by)` keeps a note about the task in its author's name; `tasks.comments`
29
+ lists them oldest first with `author`, `text` and `at`. A comment leaves the revision as it is.
30
+ - **`tomorrow` in any time field of a list query** (`due:tomorrow`, `due:[today TO tomorrow]`), as the owner's next
31
+ calendar day: `listQuery` reads it before validating. `today` and `yesterday` already worked.
32
+ - **`mailAccountKey(address)`** from `./mail`: the store key of a mail account, `{ provider: "email", account }` with
33
+ the address in lower case. **`positiveCount(flag)`** from `./cli`: the parser of a whole-number flag from 1 up.
34
+ - **The shared `schedule` command lives in `src/cli/schedule/`**, not under the messenger commands. `scheduleCommandFor`
35
+ and `ScheduleHost` come from `./cli` as before; the old module path re-exports them. Each `schedule` command now
36
+ has examples and described arguments, and the example profile of the shared mail read commands is `personal`.
37
+
38
+ ### Fixed
39
+
40
+ - **`schedule command` and `schedule agent` refuse a name that another profile already uses**, rather than replacing
41
+ its schedule and timer. Schedules of the same profile are still replaced. Existing timers keep their names.
42
+
43
+ ## 0.530.0 — 01.11.2026
44
+
45
+ ### Added
46
+
47
+ - **`configCommand(app, config, { ownerOnly })`**: settings and permission keys an agent with `mcp.authority` still
48
+ cannot change over MCP, beside `mcp.*` and `bot.api` — for a mail CLI, the server hosts its password goes to, and
49
+ the key that lets an agent mail other people. A permission key there covers the keys under it.
50
+ - **`safeName` from `./files`**: the file-name cleaner downloads use (no path, no leading dot, no control or direction
51
+ character; Windows' reserved names refused), for a CLI that saves a file someone else named.
52
+
8
53
  ## 0.529.0 — 01.11.2026
9
54
 
10
55
  ### Added
@@ -6,8 +6,18 @@ import { type Configuration } from "./settings.js";
6
6
  * The settings in force and where each came from, and changing them. Printed whole: no field the
7
7
  * configuration accepts can hold a secret — the schema has nowhere to put one.
8
8
  */
9
- export declare const configCommand: (app: AppIdentity, config: Configuration, { permissionKey }?: {
9
+ /**
10
+ * Settings and permission keys an agent with `mcp.authority` still may not change, beyond the ones that let it in:
11
+ * where the password goes (a server host), or a permission the app reads only from the owner's file.
12
+ */
13
+ export interface OwnerOnlySettings {
14
+ settings?: readonly string[];
15
+ /** A key here covers the keys under it too: `messages.send.others` covers `messages.send.others.x`. */
16
+ permissions?: readonly string[];
17
+ }
18
+ export declare const configCommand: (app: AppIdentity, config: Configuration, { permissionKey, ownerOnly, }?: {
10
19
  permissionKey?: PermissionKeySource | undefined;
20
+ ownerOnly?: OwnerOnlySettings;
11
21
  }) => Command;
12
22
  /**
13
23
  * A key no command or write is checked against would be saved and do nothing (BUG-137) — whether it
@@ -10,11 +10,7 @@ import { knownBeside, knownPermissionKeys, unknownPermissionIssues, } from "./pe
10
10
  import { fromFile, NONE_NAMED } from "./settings.js";
11
11
  import { nextStep } from "./steps.js";
12
12
  import { changeStoreSetting, isStoreSetting, offersStoreSetting, storeSettings, storeSettingsOf, } from "./store-settings.js";
13
- /**
14
- * The settings in force and where each came from, and changing them. Printed whole: no field the
15
- * configuration accepts can hold a secret — the schema has nowhere to put one.
16
- */
17
- export const configCommand = (app, config, { permissionKey = config.permissions } = {}) => {
13
+ export const configCommand = (app, config, { permissionKey = config.permissions, ownerOnly = {}, } = {}) => {
18
14
  const command = new Command("config").description("the settings in force, and where each one came from");
19
15
  // Only a messenger has bots, and with them the personal and bot sections of the file. Elsewhere the options stay
20
16
  // accepted, so a script that passes one still runs, but help, `commands --json` and MCP leave them out.
@@ -120,7 +116,7 @@ export const configCommand = (app, config, { permissionKey = config.permissions
120
116
  throw new CliError("permission_error", `this process is locked to profile ${settings.profile} (${lock}) — ${root ? setting : "--defaults"} changes other profiles`);
121
117
  }
122
118
  if (environmentOf(this).agent)
123
- refuseGuardedKey(setting, action === "set" ? String(given) : undefined);
119
+ refuseGuardedKey(setting, action === "set" ? String(given) : undefined, ownerOnly);
124
120
  if (action === "set")
125
121
  refuseUnknownKey(this, setting, String(given), permissionKey);
126
122
  const saved = config.changeSetting(settings.configPath, {
@@ -166,7 +162,11 @@ export const refuseUnknownKey = (command, setting, value, permissionKey) => {
166
162
  * profile, in the defaults, or by replacing `permissions` whole. Only the owner, in a terminal, turns it on or off
167
163
  * (NEED-1662, NEED-1679).
168
164
  */
169
- const refuseGuardedKey = (setting, value) => {
165
+ const refuseGuardedKey = (setting, value, ownerOnly) => {
166
+ if (ownerOnly.settings?.includes(setting))
167
+ throw new CliError("permission_error", `an agent cannot change ${setting} — the owner sets it in a terminal`, {
168
+ setting,
169
+ });
170
170
  const keys = setting.startsWith("permissions.")
171
171
  ? [setting.slice("permissions.".length)]
172
172
  : setting === "permissions"
@@ -174,11 +174,13 @@ const refuseGuardedKey = (setting, value) => {
174
174
  ? ["mcp"]
175
175
  : objectKeys(value)
176
176
  : [];
177
- const guarded = keys.some((key) => key === BOT_API_KEY || key.startsWith(`${BOT_API_KEY}.`))
177
+ const under = (key, guarded) => key === guarded || key.startsWith(`${guarded}.`);
178
+ const listed = keys.flatMap((key) => (ownerOnly.permissions ?? []).filter((guarded) => under(key, guarded)));
179
+ const guarded = keys.some((key) => under(key, BOT_API_KEY))
178
180
  ? BOT_API_KEY
179
- : keys.some((key) => key === "mcp" || key.startsWith("mcp."))
181
+ : keys.some((key) => under(key, "mcp"))
180
182
  ? AUTHORITY_KEY
181
- : undefined;
183
+ : listed[0];
182
184
  if (!guarded)
183
185
  return;
184
186
  throw new CliError("permission_error", `an agent cannot change permissions.${guarded} — the owner sets it in a terminal`, { permission: guarded });
@@ -97,7 +97,6 @@ export { httpTokenFile, type McpEnvironment, type McpHost, serverEntry } from ".
97
97
  export { type MessageItem, messageItem, type ReadMessage } from "./messenger/message-items.js";
98
98
  export { type ModeProblem, type PrivateFiles, privateFiles, withSqliteSidecars } from "./messenger/private-files.js";
99
99
  export { buildReport as buildDoctorReport, type Report as DoctorReport, type ReportContext as DoctorReportContext, reportFileName as doctorReportFileName, } from "./messenger/report.js";
100
- export type { ScheduleHost } from "./messenger/schedule-command.js";
101
100
  export { allAnswer, allHitItem, coverageGaps, type HitItem, type MessageHitItem, messageHitItem, type NoteHitItem, noteHitItem, shownOf, } from "./messenger/search-answers.js";
102
101
  export { SEARCHES_QUERY } from "./messenger/searches-command.js";
103
102
  export { servingProfiles } from "./messenger/serve-command.js";
@@ -108,12 +107,13 @@ export { storeSummary } from "./messenger/store-maintenance-command.js";
108
107
  export { type Saving, stored } from "./messenger/stored.js";
109
108
  export { dateOrNone } from "./messenger/tasks-command.js";
110
109
  export { MODEL_CATALOGUE_QUERY, type ModelCatalogueOptions } from "./model-catalogue-command.js";
111
- export { window, withPaging } from "./paging.js";
110
+ export { positiveCount, window, withPaging } from "./paging.js";
112
111
  export { type CommandPermissions, commandPathKey, commandPermissions, knownBeside, knownPermissionKeys, type PermissionKeyOf, type PermissionKeySource, refuseUnknownPermissionKeys, unknownPermissionIssues, unknownPermissionKeys, } from "./permission-keys.js";
113
112
  export { asFirstWord, commandWords, liftProfile, profileFree, refuseCommandName, rootOf, takesProfile, usableProfileName, } from "./profile.js";
114
113
  export { createProgram, type ProgramDefinition, type ProgramOptions, type RunOptions, run } from "./program.js";
115
114
  export { type ProjectTextHints, projectAnswerParts, projectCutoffTime, projectEvidenceText, projectWindowOf, withSyncCommands, } from "./project-evidence.js";
116
115
  export { fieldsOf, projectFields } from "./result-fields.js";
116
+ export type { ScheduleHost } from "./schedule/schedule-command.js";
117
117
  export { type Config, type Configuration, count, first, flag, fromFile, type GlobalFlags, NONE_NAMED, namedProfile, type ProfileKind, plain, type ResolveOptions, type Settings, type SettingsExtension, type SettingsExtensionContext, type Source, settingsFor, } from "./settings.js";
118
118
  export { type PermissionAnswer, readSetupAnswers, type SetupAnswers, setupPermissionCeiling } from "./setup-answers.js";
119
119
  export { AGENTS, type Agent, agentFor, screenFor } from "./setup-screen.js";
package/dist/cli/index.js CHANGED
@@ -97,7 +97,7 @@ export { storeSummary } from "./messenger/store-maintenance-command.js";
97
97
  export { stored } from "./messenger/stored.js";
98
98
  export { dateOrNone } from "./messenger/tasks-command.js";
99
99
  export { MODEL_CATALOGUE_QUERY } from "./model-catalogue-command.js";
100
- export { window, withPaging } from "./paging.js";
100
+ export { positiveCount, window, withPaging } from "./paging.js";
101
101
  export { commandPathKey, commandPermissions, knownBeside, knownPermissionKeys, refuseUnknownPermissionKeys, unknownPermissionIssues, unknownPermissionKeys, } from "./permission-keys.js";
102
102
  export { asFirstWord, commandWords, liftProfile, profileFree, refuseCommandName, rootOf, takesProfile, usableProfileName, } from "./profile.js";
103
103
  export { createProgram, run } from "./program.js";
@@ -16,10 +16,6 @@ export declare const withQuery: (command: Command, registry: QueryRegistry) => C
16
16
  export declare const unfilteredQuery: (entity: string, schema: Parameters<typeof queryFieldsOf>[0]) => QueryRegistry;
17
17
  /** `tasks list`, as a query error names the command. */
18
18
  export declare const commandPath: (command: Command) => string;
19
- /**
20
- * The query as typed, validated against the list's fields, with each default whose field it does not name;
21
- * `undefined` when nothing narrows the list. A bare word is refused unless the list names a `defaultField` for it. CLI and MCP read a list's query through this one function.
22
- */
23
19
  export declare const listQuery: (text: string, registry: QueryRegistry, command: string, { defaultField }?: {
24
20
  defaultField?: string;
25
21
  }) => QueryAst | undefined;
@@ -39,9 +39,27 @@ export const commandPath = (command) => {
39
39
  * The query as typed, validated against the list's fields, with each default whose field it does not name;
40
40
  * `undefined` when nothing narrows the list. A bare word is refused unless the list names a `defaultField` for it. CLI and MCP read a list's query through this one function.
41
41
  */
42
+ /**
43
+ * `tomorrow` in a time field as the owner's next calendar date: the query language knows `today` and `yesterday`
44
+ * only, and a due date is more often ahead than behind.
45
+ */
46
+ const withTomorrow = (query, registry, zone = timezoneOf(), now = Date.now()) => {
47
+ const times = new Set(registry.fields.filter(({ type }) => type === "time").map(({ name }) => name));
48
+ const date = new Date(`${new Intl.DateTimeFormat("en-CA", { timeZone: zone }).format(now)}T00:00:00Z`);
49
+ date.setUTCDate(date.getUTCDate() + 1);
50
+ const tomorrow = date.toISOString().slice(0, 10);
51
+ const day = (value) => (value.toLowerCase() === "tomorrow" ? tomorrow : value);
52
+ const map = (node) => node.kind === "predicate"
53
+ ? times.has(node.field)
54
+ ? { ...node, value: day(node.value), ...(node.upper === undefined ? {} : { upper: day(node.upper) }) }
55
+ : node
56
+ : { ...node, clauses: node.clauses.map((clause) => ({ ...clause, node: map(clause.node) })) };
57
+ return { ...query, root: map(query.root) };
58
+ };
42
59
  export const listQuery = (text, registry, command, { defaultField = "none" } = {}) => {
43
60
  const context = { command, guide: LIST_QUERY_GUIDE };
44
- const typed = text.trim() === "" ? undefined : parseLucene(text, { defaultField, ...context });
61
+ const parsed = text.trim() === "" ? undefined : parseLucene(text, { defaultField, ...context });
62
+ const typed = parsed && withTomorrow(parsed, registry);
45
63
  if (typed)
46
64
  validateQuery(typed, registry, context);
47
65
  const named = typed ? fieldsNamed(typed) : new Set();
@@ -1,6 +1,7 @@
1
1
  import type { Command } from "commander";
2
2
  import type { Id } from "../../domain/models.js";
3
- export { downloadMessage, type Saved, safeName, save } from "../../services/file-download.js";
3
+ export { safeName } from "../../files/safe-name.js";
4
+ export { downloadMessage, type Saved, save } from "../../services/file-download.js";
4
5
  import { type Messenger } from "./context.js";
5
6
  /** `messages download`: every file of one message, or with `--all` of a whole chat, never over a file already there. */
6
7
  export declare const downloadSubcommand: (messages: Command, messenger: Messenger) => Command;
@@ -7,7 +7,8 @@ import { NOT_FILES } from "../../domain/attachments.js";
7
7
  import { FETCHING, keyOf } from "../../services/archive.js";
8
8
  import { batchProgress, mergeBatches } from "../../services/batch.js";
9
9
  import { saveFiles } from "../../services/file-download.js";
10
- export { downloadMessage, safeName, save } from "../../services/file-download.js";
10
+ export { safeName } from "../../files/safe-name.js";
11
+ export { downloadMessage, save } from "../../services/file-download.js";
11
12
  import { parseDuration } from "../../domain/duration.js";
12
13
  import { OFFLINE } from "../../services/deps.js";
13
14
  import { patiently } from "../../services/patience.js";
@@ -1,21 +1,4 @@
1
- import { Command } from "commander";
2
- import { type AppIdentity } from "../../ports/app.js";
3
- import { type AnswerOutput } from "../envelope.js";
1
+ import type { Command } from "commander";
4
2
  import { type Messenger } from "./context.js";
5
- /** What the schedule command needs of a CLI: who it is and, per command, where it prints and which profile runs. */
6
- export interface ScheduleHost {
7
- app: AppIdentity;
8
- context(command: Command, resolving?: {
9
- needsProfile?: boolean;
10
- }): AnswerOutput & {
11
- env: NodeJS.ProcessEnv;
12
- profile: string;
13
- };
14
- }
15
- /**
16
- * Runs the CLI's own commands, or an agent, later or on a timer — through systemd (Linux), launchd
17
- * (macOS) or Task Scheduler (Windows). Every timer runs `schedule exec <file>`; the schedule file holds
18
- * the rest, so what runs and with which variables is the same on all three.
19
- */
20
- export declare const scheduleCommandFor: ({ app, context: contextOf }: ScheduleHost) => Command;
3
+ export { type ScheduleHost, scheduleCommandFor } from "../schedule/schedule-command.js";
21
4
  export declare const scheduleCommand: (messenger: Messenger) => Command;
@@ -1,235 +1,6 @@
1
- import { existsSync, rmSync } from "node:fs";
2
- import { dirname, join } from "node:path";
3
- import { CliError, resolvePaths, writeSecurely } from "@wirecat/cli-base";
4
- import { annotate } from "@wirecat/cli-base/commands";
5
- import { Argument, Command, Option } from "commander";
6
- import { describeWhen, findProgram, lastRun, listSchedules, runSchedule, scheduleFile, scheduleName, timerFor, } from "../../background/schedules.js";
7
- import { thisMachine } from "../../background/system.js";
8
- import { locationVariables } from "../../background/units.js";
9
- import { parseDuration } from "../../domain/duration.js";
10
- import { readOnlyLockOf } from "../../ports/app.js";
11
- import { aheadOf } from "../../services/moment.js";
12
- import { storePath } from "../../store/path.js";
13
- import { environmentOf, setExitCode } from "../context.js";
14
- import { grantsAuthority, LOCAL_READ, LOCAL_WRITE, REMOTE_WRITE } from "../effects.js";
15
- import { itemOf, listOf, renderAnswer, snakeKeys } from "../envelope.js";
16
- import { withQuery } from "../list-query.js";
17
- import { lineOf } from "../steps.js";
1
+ import { scheduleCommandFor } from "../schedule/schedule-command.js";
18
2
  import { messengerContext } from "./context.js";
19
- import { SCHEDULES_QUERY, scheduleFilter } from "./list-result-queries.js";
20
- /** Each run of an agent is a paid model call that reads chats; keeping the store current is `serve`'s job. */
21
- const AGENT_MINIMUM_MINUTES = 60;
22
- const COMMAND_MINIMUM_MINUTES = 1;
23
- const HARNESSES = ["claude", "codex"];
24
- const whenOf = ({ every, at, atTime }, minimum) => {
25
- if ([every ?? at, atTime].filter(Boolean).length !== 1) {
26
- throw new CliError("validation_error", "say when: --every <30m|2h|1d>, --at HH:MM (daily), or --at-time <30m|2d|ISO time>");
27
- }
28
- if (atTime) {
29
- const time = aheadOf(atTime, "--at-time");
30
- if (time <= Date.now())
31
- throw new CliError("validation_error", "--at-time is in the past");
32
- return { once: new Date(time).toISOString() };
33
- }
34
- if (at) {
35
- if (!/^([01]\d|2[0-3]):[0-5]\d$/.test(at))
36
- throw new CliError("validation_error", `--at takes HH:MM, not "${at}"`);
37
- if (every && parseDuration(every, "--every") !== 86_400_000) {
38
- throw new CliError("validation_error", "--at runs once a day; leave out --every, or use --every 1d");
39
- }
40
- return { daily: at };
41
- }
42
- const minutes = parseDuration(every ?? "", "--every") / 60_000;
43
- if (!Number.isInteger(minutes) || minutes < minimum) {
44
- throw new CliError("validation_error", `--every is at least ${minimum} minute${minimum === 1 ? "" : "s"} here, in whole minutes`);
45
- }
46
- return { every: minutes };
47
- };
48
- /** A schedule as answered: snake_case, but its environment keeps the variables' own names. */
49
- const scheduleAnswer = ({ environment, createdAt, ...schedule }, extra) => ({
50
- ...snakeKeys(schedule),
51
- environment,
52
- created_at: createdAt,
53
- ...snakeKeys(extra),
54
- });
55
- const withWhen = (command) => command
56
- .option("--every <duration>", "repeat: 30m, 2h, 1d")
57
- .option("--at <HH:MM>", "every day at this local time")
58
- .option("--at-time <time>", "one run: 30m, 2d from now, or an ISO time; the schedule removes itself after it");
59
- /**
60
- * Runs the CLI's own commands, or an agent, later or on a timer — through systemd (Linux), launchd
61
- * (macOS) or Task Scheduler (Windows). Every timer runs `schedule exec <file>`; the schedule file holds
62
- * the rest, so what runs and with which variables is the same on all three.
63
- */
64
- export const scheduleCommandFor = ({ app, context: contextOf }) => {
65
- const command = new Command("schedule").description(`run a ${app.command} command or an agent later or on a timer (systemd, launchd, Task Scheduler); an agent cannot send`);
66
- const prepare = (self, resolving = {}) => {
67
- const context = contextOf(self, resolving);
68
- const system = environmentOf(self).system ?? thisMachine();
69
- const state = resolvePaths({ appName: app.appName, prefix: app.envPrefix, env: context.env }).state;
70
- const say = (value, lines) => renderAnswer(self, context, itemOf(value), () => lines.join("\n"));
71
- const timer = () => timerFor(app, system, context.env);
72
- // The command line refuses a schedule under the lock; a caller that gets here still passes the lock on.
73
- const readOnly = readOnlyLockOf(app, context.env);
74
- const environment = () => ({
75
- [`${app.envPrefix}_PROFILE`]: context.profile,
76
- ...locationVariables(app, context.env),
77
- // The timer starts with almost no PATH; an agent runs `max` and its own tools by name.
78
- ...(context.env.PATH ? { PATH: context.env.PATH } : {}),
79
- ...(readOnly ? { [readOnly]: "1" } : {}),
80
- });
81
- const save = async (schedule) => {
82
- const file = scheduleFile(state, schedule.name);
83
- const replaced = existsSync(file);
84
- writeSecurely(file, `${JSON.stringify(schedule, null, 2)}\n`, 0o600);
85
- await timer().install(schedule, [...system.entry, "schedule", "exec", file]);
86
- say(scheduleAnswer(schedule, { file, timer: timer().where(schedule.name), replaced }), [
87
- `${replaced ? "Replaced" : "Added"} ${schedule.name}: ${describeWhen(schedule.when)}.`,
88
- `It runs: ${schedule.kind === "agent" ? schedule.argv.join(" ") : `${app.command} ${schedule.argv.join(" ")}`}`,
89
- `Output: ${join(dirname(file), "runs")} · now: ${lineOf(self, `schedule run ${schedule.name}`)} · stop: ${lineOf(self, `schedule remove ${schedule.name}`)}`,
90
- ]);
91
- };
92
- return { context, system, state, say, timer, environment, save };
93
- };
94
- withWhen(grantsAuthority(annotate(command.command("command"), { effects: LOCAL_WRITE }))
95
- .description(`run ${app.command} <args…> on a timer — reads save what they read, so this is how a chat's history fills the store`)
96
- .argument("<name>", "lowercase letters, digits and dashes")
97
- .argument("<args...>", `the ${app.command} command after its name, e.g. -- messages list <chat> --limit 50`)).action(async function (name, args) {
98
- const { environment, save } = prepare(this);
99
- if (args[0] === "schedule")
100
- throw new CliError("validation_error", "a schedule cannot run schedule");
101
- await save({
102
- name: scheduleName(name),
103
- kind: "command",
104
- argv: args,
105
- when: whenOf(this.opts(), COMMAND_MINIMUM_MINUTES),
106
- environment: environment(),
107
- createdAt: new Date().toISOString(),
108
- });
109
- });
110
- withWhen(grantsAuthority(annotate(command.command("agent"), { effects: LOCAL_WRITE }))
111
- .description(`start an agent (claude or codex) with a prompt on a timer; it runs with ${app.envPrefix}_READ_ONLY=1, so it reads and drafts and cannot send — its answer is kept and you are notified`)
112
- .argument("<name>", "lowercase letters, digits and dashes")
113
- .addArgument(new Argument("<harness>", "the agent to start").choices(HARNESSES))
114
- .argument("<prompt>", `what to do, e.g. "run the daily follow-up review: ${app.command} skill show follow-ups"`)
115
- .addOption(new Option("--max-cost <usd>", "claude only: stop a run that costs more than this, in US dollars").argParser(Number))).action(async function (name, harness, prompt) {
116
- const { context, system, environment, save } = prepare(this);
117
- const options = this.opts();
118
- const program = findProgram(harness, context.env, system.platform);
119
- if (!program)
120
- throw new CliError("not_found", `${harness} is not on PATH`);
121
- const paths = resolvePaths({ appName: app.appName, prefix: app.envPrefix, env: context.env });
122
- const argv = harness === "claude"
123
- ? [
124
- program,
125
- "-p",
126
- prompt,
127
- // The owner's own settings may let every tool run (bypassPermissions); here only the CLI may.
128
- "--permission-mode",
129
- "dontAsk",
130
- "--allowedTools",
131
- `Bash(${app.command}:*)`,
132
- "--no-session-persistence",
133
- ...(options.maxCost ? ["--max-budget-usd", String(options.maxCost)] : []),
134
- ]
135
- : [
136
- program,
137
- "exec",
138
- "--skip-git-repo-check",
139
- "--ephemeral",
140
- "--sandbox",
141
- "workspace-write",
142
- // The CLI reaches the messenger and writes its store and state, and nothing else.
143
- "-c",
144
- "sandbox_workspace_write.network_access=true",
145
- ...[dirname(storePath(context.env)), paths.state, paths.config, paths.cache].flatMap((dir) => [
146
- "--add-dir",
147
- dir,
148
- ]),
149
- prompt,
150
- ];
151
- await save({
152
- name: scheduleName(name),
153
- kind: "agent",
154
- argv,
155
- when: whenOf(options, AGENT_MINIMUM_MINUTES),
156
- environment: environment(),
157
- createdAt: new Date().toISOString(),
158
- });
159
- });
160
- withQuery(annotate(command.command("list"), { effects: LOCAL_READ }), SCHEDULES_QUERY)
161
- .description("the schedules, when they run and how their last run went")
162
- .action(function (words) {
163
- const filter = scheduleFilter(words);
164
- const { context, state } = prepare(this);
165
- const rows = listSchedules(state, filter).map((schedule) => ({
166
- name: schedule.name,
167
- kind: schedule.kind,
168
- when: describeWhen(schedule.when),
169
- last: lastRun(scheduleFile(state, schedule.name)) ?? null,
170
- }));
171
- const answer = listOf("schedule list", {
172
- items: rows.map((row) => snakeKeys(row)),
173
- limit: Math.max(1, rows.length),
174
- has_more: false,
175
- });
176
- renderAnswer(this, context, answer, () => {
177
- if (rows.length === 0) {
178
- context.renderer.note(`no schedules — \`${lineOf(this, "schedule command|agent")}\` adds one`);
179
- return "";
180
- }
181
- return rows
182
- .map(({ name, kind, when, last }) => [
183
- name,
184
- kind,
185
- when,
186
- last
187
- ? `last ${last.at.slice(0, 16).replace("T", " ")} exit ${last.exitCode}` +
188
- (last.failures
189
- ? ` — failing since ${(last.failingSince ?? last.at).slice(0, 16).replace("T", " ")}, ` +
190
- `${last.failures} in a row${last.reason ? `: ${last.reason}` : ""} — ${last.output}`
191
- : "")
192
- : "not run yet",
193
- ].join(" "))
194
- .join("\n");
195
- });
196
- });
197
- annotate(command.command("run"), { effects: REMOTE_WRITE })
198
- .description("run a schedule now, as its timer would, and wait for it")
199
- .argument("<name>")
200
- .action(async function (name) {
201
- const { context, system, state, say, timer } = prepare(this);
202
- const last = await runSchedule(app, system, context.env, scheduleFile(state, scheduleName(name)), {
203
- timer: timer(),
204
- });
205
- say(snakeKeys({ name, ...last }), [`${name}: exit ${last.exitCode} — ${last.output}`]);
206
- if (last.exitCode !== 0)
207
- setExitCode(this, 1);
208
- });
209
- grantsAuthority(annotate(command.command("remove"), { effects: LOCAL_WRITE }))
210
- .description("stop a schedule and remove its timer; its past output stays")
211
- .argument("<name>")
212
- .action(async function (name) {
213
- const { state, say, timer } = prepare(this);
214
- const file = scheduleFile(state, scheduleName(name));
215
- if (!existsSync(file))
216
- throw new CliError("not_found", `no schedule ${name} — \`${lineOf(this, "schedule list")}\``);
217
- await timer().remove(name);
218
- rmSync(file, { force: true });
219
- say({ name, removed: true }, [`Removed ${name}.`]);
220
- });
221
- annotate(command.command("exec"), { effects: REMOTE_WRITE })
222
- .description("what a timer runs, given the schedule's file; to run one by hand, use run")
223
- .argument("<file>")
224
- .action(async function (file) {
225
- // A timer starts it with no profile named; the schedule's own environment carries the profile.
226
- const { context, system, timer } = prepare(this, { needsProfile: false });
227
- const last = await runSchedule(app, system, context.env, file, { timer: timer() });
228
- if (last.exitCode !== 0)
229
- setExitCode(this, 1);
230
- });
231
- return command;
232
- };
3
+ export { scheduleCommandFor } from "../schedule/schedule-command.js";
233
4
  export const scheduleCommand = (messenger) => scheduleCommandFor({
234
5
  app: messenger.app,
235
6
  context: (command, resolving) => messengerContext(command, messenger, resolving),
@@ -1,12 +1,13 @@
1
1
  import { CliError } from "@wirecat/cli-base";
2
2
  import { annotate } from "@wirecat/cli-base/commands";
3
3
  import { Command } from "commander";
4
+ import { topicIcon } from "../../entities/index.js";
4
5
  import { capability } from "../../ports/messenger.js";
5
6
  import { ownerOnlyOption, REMOTE_READ, REMOTE_WRITE } from "../effects.js";
6
7
  import { itemOf, listOf, renderAnswer, snakeKeys } from "../envelope.js";
7
- import { typedQuery, withQuery } from "../list-query.js";
8
+ import { listQuery, typedQuery, unfilteredQuery, withQuery } from "../list-query.js";
8
9
  import { window, withPaging } from "../paging.js";
9
- import { lines, pageOf, topicAnswer } from "./chat-answers.js";
10
+ import { lines, pageOf, topicAnswer, wholeList } from "./chat-answers.js";
10
11
  import { messengerContext } from "./context.js";
11
12
  import { TOPICS_QUERY, topicTitle } from "./list-result-queries.js";
12
13
  const listed = (messenger) => async function (chat, value) {
@@ -47,6 +48,17 @@ export const topicsCommand = (messenger) => {
47
48
  const shown = await context.withServices((services) => services.topics.show(chat, topic));
48
49
  renderAnswer(this, context, itemOf(topicAnswer(shown)));
49
50
  });
51
+ const icons = new Command("icons").description("the emojis a forum topic can take as its icon");
52
+ topics.addCommand(icons);
53
+ icons.addCommand(withQuery(annotate(new Command("list"), {
54
+ effects: REMOTE_READ,
55
+ examples: [`${messenger.app.command} personal topics icons list`],
56
+ }).description("the emojis `--emoji` takes in topics create and topics edit"), TOPIC_ICONS_QUERY).action(async function (words) {
57
+ const context = messengerContext(this, messenger);
58
+ listQuery(typedQuery(words), TOPIC_ICONS_QUERY, "topics icons list");
59
+ const icons = await context.withServices((services) => services.topics.icons());
60
+ renderAnswer(this, context, wholeList("topics icons list", icons.map((emoji) => ({ emoji }))), lines(({ emoji }) => emoji));
61
+ }));
50
62
  annotate(topics.command("enable"), { effects: REMOTE_WRITE })
51
63
  .description("enable forum topics; only the owner, with an explicit upgrade for a basic group")
52
64
  .argument("<chat>", messenger.chatArgument)
@@ -61,22 +73,25 @@ export const topicsCommand = (messenger) => {
61
73
  .argument("<chat>", messenger.chatArgument)
62
74
  .argument("<title>", "the topic title, at most 128 UTF-8 bytes")
63
75
  .option("--operation-id <id>", "identify this creation attempt; an already sent or unknown id is refused")
76
+ .option("--emoji <emoji>", "the topic's icon, one of `topics icons list`")
64
77
  .action(async function (chat, title) {
65
78
  const context = messengerContext(this, messenger);
66
- const created = await context.withServices((services) => services.topics.create(chat, title, { sendId: this.opts().operationId }));
79
+ const { operationId, emoji } = this.opts();
80
+ const created = await context.withServices((services) => services.topics.create(chat, title, { sendId: operationId, ...(emoji === undefined ? {} : { icon: emoji }) }));
67
81
  renderAnswer(this, context, itemOf(snakeKeys(created)));
68
82
  });
69
83
  annotate(topics.command("edit"), { effects: REMOTE_WRITE })
70
- .description("rename, close or reopen a forum topic")
84
+ .description("rename, close, reopen or give an icon to a forum topic")
71
85
  .argument("<chat>", messenger.chatArgument)
72
86
  .argument("<topic>", "the topic id, from `topics list`")
73
87
  .option("--title <title>", "the new title, at most 128 UTF-8 bytes")
74
88
  .option("--closed <on|off>", "on closes the topic to new messages, off reopens it")
75
89
  .option("--pinned <on|off>", "on pins the topic at the top of the list, off unpins it")
76
90
  .option("--hidden <on|off>", "on hides the General topic from the topic list, off shows it")
91
+ .option("--emoji <emoji>", "the topic's icon, one of `topics icons list`; none removes it")
77
92
  .action(async function (chat, topic) {
78
93
  const context = messengerContext(this, messenger);
79
- const { title, closed, pinned, hidden } = this.opts();
94
+ const { title, closed, pinned, hidden, emoji } = this.opts();
80
95
  const closes = onOff("--closed", closed);
81
96
  const pins = onOff("--pinned", pinned);
82
97
  const hides = onOff("--hidden", hidden);
@@ -85,6 +100,7 @@ export const topicsCommand = (messenger) => {
85
100
  ...(closes === undefined ? {} : { closed: closes }),
86
101
  ...(pins === undefined ? {} : { pinned: pins }),
87
102
  ...(hides === undefined ? {} : { hidden: hides }),
103
+ ...(emoji === undefined ? {} : { icon: emoji === "none" ? null : emoji }),
88
104
  }));
89
105
  renderAnswer(this, context, itemOf(snakeKeys(done)));
90
106
  });
@@ -107,6 +123,7 @@ export const topicsCommand = (messenger) => {
107
123
  });
108
124
  return topics;
109
125
  };
126
+ const TOPIC_ICONS_QUERY = unfilteredQuery("topic_icon", topicIcon);
110
127
  const onOff = (flag, value) => {
111
128
  if (value === undefined)
112
129
  return undefined;
@@ -0,0 +1,19 @@
1
+ import { Command } from "commander";
2
+ import { type AppIdentity } from "../../ports/app.js";
3
+ import { type AnswerOutput } from "../envelope.js";
4
+ /** What the schedule command needs of a CLI: who it is and, per command, where it prints and which profile runs. */
5
+ export interface ScheduleHost {
6
+ app: AppIdentity;
7
+ context(command: Command, resolving?: {
8
+ needsProfile?: boolean;
9
+ }): AnswerOutput & {
10
+ env: NodeJS.ProcessEnv;
11
+ profile: string;
12
+ };
13
+ }
14
+ /**
15
+ * Runs the CLI's own commands, or an agent, later or on a timer — through systemd (Linux), launchd
16
+ * (macOS) or Task Scheduler (Windows). Every timer runs `schedule exec <file>`; the schedule file holds
17
+ * the rest, so what runs and with which variables is the same on all three.
18
+ */
19
+ export declare const scheduleCommandFor: ({ app, context: contextOf }: ScheduleHost) => Command;