@wirecat/cli-core 0.586.0 → 0.587.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.
package/CHANGELOG.md CHANGED
@@ -5,7 +5,23 @@ 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.587.0 — 06.11.2026
9
+
10
+ ### Added
11
+
12
+ - **`answerLocally` and `wallClock` in `@wirecat/cli-core/cli`.** `answerLocally` answers as `renderAnswer` does, and a
13
+ person reads each UTC time as `YYYY-MM-DD HH:MM` in the zone in use, with one `times in <zone>` line on stderr; a
14
+ `view` gets the formatter to build its text with. JSON, JSONL, `--compact` and agents keep UTC. `hitText` takes the
15
+ formatter as an optional third argument. mail can drop its own copy (`src/commands/times.ts`).
16
+ - **cli-core accepts `@wirecat/cli-base` 0.80 (peer range `>=0.76.0 <0.81.0`) and builds with 0.80.0**, which
17
+ adds the `--write <id>` flag mail's `messages restore` takes. The range was `<0.80.0`.
18
+
19
+ ### Fixed
20
+
21
+ - **`search messages`, `messages show`, `conversations list|show`, mail `review`, `runs list` and `runs show` show a
22
+ person times in the local zone** (WC-TIME-4), with a last line `times in <zone>`. They printed UTC ISO strings
23
+ such as `2026-10-03T09:00:00.000Z`. `search messages --timezone` decides the zone shown. JSON and storage keep UTC.
24
+ `runs list|show` is shared by every tool on cli-core; the mail screens reach mail and memo at their next bump.
9
25
 
10
26
  ## 0.586.0 — 06.11.2026
11
27
 
@@ -1,26 +1,16 @@
1
1
  import { renderPretty } from "@wirecat/cli-base";
2
- const UTC_TIME = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z$/u;
2
+ import { UTC_TIME, wallClock } from "../../cli/local-times.js";
3
3
  const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
4
4
  /**
5
5
  * A calendar answer as a person reads it at a terminal (WC-TIME-4): each UTC time on the wall clock of `zone`, an
6
6
  * all-day event's times as its local dates, and a last line naming the zone. JSON keeps the UTC times.
7
7
  */
8
8
  export const localTimesView = (value, zone, color) => {
9
- const clock = new Intl.DateTimeFormat("en-CA", {
10
- timeZone: zone,
11
- year: "numeric",
12
- month: "2-digit",
13
- day: "2-digit",
14
- hour: "2-digit",
15
- minute: "2-digit",
16
- hourCycle: "h23",
17
- });
9
+ const clock = wallClock(zone);
18
10
  let shown = false;
19
11
  const local = (text, dateOnly) => {
20
- const part = Object.fromEntries(clock.formatToParts(Date.parse(text)).map(({ type, value }) => [type, value]));
21
12
  shown = true;
22
- const date = `${part.year}-${part.month}-${part.day}`;
23
- return dateOnly ? date : `${date} ${part.hour}:${part.minute}`;
13
+ return dateOnly ? clock.date(text) : clock.time(text);
24
14
  };
25
15
  const walk = (one, allDay = false) => {
26
16
  if (typeof one === "string")
@@ -86,6 +86,7 @@ export { type HelpSection, inSection, SECTIONS } from "./help/sections.js";
86
86
  export { bufferedInput, type InputPolicy, inputPolicy, MAX_BUFFERED_INPUT, MAX_SECRET_INPUT, provideInputPolicy, } from "./input-policy.js";
87
87
  export { answerFields, checkJson, type EntityFields, entityFields, type JsonFieldsResolver, type JsonRequest, liftJson, } from "./json-fields.js";
88
88
  export { commandPath, LIST_QUERY_GUIDE, listQuery, nativeField, nativeTermLists, nativeTerms, negatableOf, pagedList, pageOf, type QueryWords, queryArgument, queryHelp, queryTerms, searchText, termField, timeBounds, typedQuery, unfilteredQuery, withQuery, } from "./list-query.js";
89
+ export { answerLocally, type ShownTime, wallClock } from "./local-times.js";
89
90
  export { MCP_HTTP_DEFAULT_PORT, type McpServerOptions, type McpServerTarget, mcpServerCommand, } from "./mcp-server-command.js";
90
91
  export { formatMeetingBatch, prepareMeetingBatch } from "./meeting-batch.js";
91
92
  export { meetingEvidenceItem, meetingPersonContextItem } from "./meeting-evidence-answer.js";
package/dist/cli/index.js CHANGED
@@ -77,6 +77,7 @@ export { inSection, SECTIONS } from "./help/sections.js";
77
77
  export { bufferedInput, inputPolicy, MAX_BUFFERED_INPUT, MAX_SECRET_INPUT, provideInputPolicy, } from "./input-policy.js";
78
78
  export { answerFields, checkJson, entityFields, liftJson, } from "./json-fields.js";
79
79
  export { commandPath, LIST_QUERY_GUIDE, listQuery, nativeField, nativeTermLists, nativeTerms, negatableOf, pagedList, pageOf, queryArgument, queryHelp, queryTerms, searchText, termField, timeBounds, typedQuery, unfilteredQuery, withQuery, } from "./list-query.js";
80
+ export { answerLocally, wallClock } from "./local-times.js";
80
81
  export { MCP_HTTP_DEFAULT_PORT, mcpServerCommand, } from "./mcp-server-command.js";
81
82
  export { formatMeetingBatch, prepareMeetingBatch } from "./meeting-batch.js";
82
83
  export { meetingEvidenceItem, meetingPersonContextItem } from "./meeting-evidence-answer.js";
@@ -0,0 +1,16 @@
1
+ import type { Command } from "commander";
2
+ import { type AnswerOutput, type Body } from "./envelope.js";
3
+ export declare const UTC_TIME: RegExp;
4
+ /** `YYYY-MM-DD HH:MM` on the wall clock of `zone`, with no offset: the answer names the zone once. */
5
+ export declare const wallClock: (zone: string) => {
6
+ date: (iso: string) => string;
7
+ time: (iso: string) => string;
8
+ };
9
+ /** Turns a UTC time into the shown one; any other text comes back as it was. */
10
+ export type ShownTime = (value: string) => string;
11
+ /**
12
+ * Answers as `renderAnswer` does, and a person reads every UTC time in the zone in use (`zone`, else the computer's),
13
+ * with one line naming it (WC-TIME-4). A `view` builds its text with the `shown` it is given. JSON, JSONL, compact
14
+ * rows and agents keep UTC (WC-TIME-2), and `next` is left alone: its args run.
15
+ */
16
+ export declare const answerLocally: <T>(command: Command, output: AnswerOutput, body: Body<T>, view?: (shown: ShownTime) => string, zone?: string) => void;
@@ -0,0 +1,65 @@
1
+ import { timezoneOf } from "@wirecat/cli-base/query";
2
+ import { environmentOf } from "./context.js";
3
+ import { renderAnswer } from "./envelope.js";
4
+ export const UTC_TIME = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z$/u;
5
+ /** `YYYY-MM-DD HH:MM` on the wall clock of `zone`, with no offset: the answer names the zone once. */
6
+ export const wallClock = (zone) => {
7
+ const format = new Intl.DateTimeFormat("en-CA", {
8
+ timeZone: zone,
9
+ year: "numeric",
10
+ month: "2-digit",
11
+ day: "2-digit",
12
+ hour: "2-digit",
13
+ minute: "2-digit",
14
+ hourCycle: "h23",
15
+ });
16
+ const parts = (iso) => Object.fromEntries(format.formatToParts(Date.parse(iso)).map(({ type, value }) => [type, value]));
17
+ const date = (iso) => {
18
+ const part = parts(iso);
19
+ return `${part.year}-${part.month}-${part.day}`;
20
+ };
21
+ return {
22
+ date,
23
+ time: (iso) => {
24
+ const part = parts(iso);
25
+ return `${part.year}-${part.month}-${part.day} ${part.hour}:${part.minute}`;
26
+ },
27
+ };
28
+ };
29
+ const localised = (value, shown) => {
30
+ if (typeof value === "string")
31
+ return shown(value);
32
+ if (Array.isArray(value))
33
+ return value.map((one) => localised(one, shown));
34
+ if (value !== null && typeof value === "object")
35
+ return Object.fromEntries(Object.entries(value).map(([key, one]) => [key, localised(one, shown)]));
36
+ return value;
37
+ };
38
+ /**
39
+ * Answers as `renderAnswer` does, and a person reads every UTC time in the zone in use (`zone`, else the computer's),
40
+ * with one line naming it (WC-TIME-4). A `view` builds its text with the `shown` it is given. JSON, JSONL, compact
41
+ * rows and agents keep UTC (WC-TIME-2), and `next` is left alone: its args run.
42
+ */
43
+ export const answerLocally = (command, output, body, view, zone) => {
44
+ if (output.format !== "pretty" || output.compact || environmentOf(command).agent) {
45
+ renderAnswer(command, output, body, view && (() => view((value) => value)));
46
+ return;
47
+ }
48
+ const inUse = timezoneOf(zone);
49
+ const clock = wallClock(inUse);
50
+ let seen = false;
51
+ const shown = (value) => {
52
+ if (!UTC_TIME.test(value))
53
+ return value;
54
+ seen = true;
55
+ return clock.time(value);
56
+ };
57
+ if (view)
58
+ renderAnswer(command, output, body, () => view(shown));
59
+ else
60
+ renderAnswer(command, output, ("items" in body
61
+ ? { ...body, items: body.items.map((one) => localised(one, shown)) }
62
+ : { ...body, item: localised(body.item, shown) }));
63
+ if (seen)
64
+ output.renderer.note(`times in ${inUse}`);
65
+ };
@@ -3,9 +3,9 @@ import type { CacheDatabase } from "../../store/driver.js";
3
3
  import type { Messenger } from "./context.js";
4
4
  /** `doctor`'s short summary of the store. Must answer for a broken file, so an error is a field. */
5
5
  export declare const storeSummary: (env: NodeJS.ProcessEnv) => Promise<{
6
- error?: undefined;
7
6
  path: string;
8
7
  exists: boolean;
8
+ error?: undefined;
9
9
  } | {
10
10
  path: string;
11
11
  exists: boolean;
@@ -6,6 +6,7 @@ import { environmentOf, outputFor } from "../context.js";
6
6
  import { LOCAL_READ, NO_EFFECTS } from "../effects.js";
7
7
  import { itemOf, listOf, renderAnswer, snakeKeys } from "../envelope.js";
8
8
  import { typedQuery, withQuery } from "../list-query.js";
9
+ import { answerLocally } from "../local-times.js";
9
10
  import { positiveCount } from "../paging.js";
10
11
  import { lineOf } from "../steps.js";
11
12
  import { RUNS_QUERY, runListFilter } from "./query.js";
@@ -33,7 +34,7 @@ export const runsCommand = (app) => {
33
34
  const { renderer } = output;
34
35
  const runs = listRuns(dir(this), { atLeast: page * limit + 1, filter });
35
36
  const shown = runs.slice((page - 1) * limit, page * limit);
36
- renderAnswer(this, output, listOf("runs list", {
37
+ answerLocally(this, output, listOf("runs list", {
37
38
  items: shown.map(snakeKeys),
38
39
  limit,
39
40
  page,
@@ -60,7 +61,7 @@ export const runsCommand = (app) => {
60
61
  const { problems } = this.opts();
61
62
  const shown = problems ? events.filter(notable) : events;
62
63
  // Events are log records as written; only the run's own fields are renamed here.
63
- renderAnswer(this, output, itemOf({
64
+ answerLocally(this, output, itemOf({
64
65
  ...snakeKeys(found.metadata),
65
66
  directory: found.dir,
66
67
  event_count: events.length,
@@ -6,8 +6,9 @@ import * as v from "valibot";
6
6
  import { answerEntity } from "../../cli/answer-entity.js";
7
7
  import { environmentOf, outputFor } from "../../cli/context.js";
8
8
  import { FETCH, LOCAL_READ, REMOTE_READ } from "../../cli/effects.js";
9
- import { itemOf, listOf, renderAnswer, snakeKeys } from "../../cli/envelope.js";
9
+ import { itemOf, listOf, snakeKeys } from "../../cli/envelope.js";
10
10
  import { listQuery, typedQuery, withQuery } from "../../cli/list-query.js";
11
+ import { answerLocally } from "../../cli/local-times.js";
11
12
  import { messagesAnswer, offsetOf, shownOf } from "../../cli/messenger/search-answers.js";
12
13
  import { backendRequest, noteServer, serverSearchOptions } from "../../cli/messenger/search-backend-options.js";
13
14
  import { positiveCount } from "../../cli/paging.js";
@@ -193,7 +194,7 @@ Search guide: https://github.com/WireCatLabs/cli-core/blob/main/docs/search/quer
193
194
  const issues = serverIssues(found.server);
194
195
  if (issues.length)
195
196
  answer.meta = { ...answer.meta, complete: false, issues: [...(answer.meta?.issues ?? []), ...issues] };
196
- renderAnswer(this, output, answer, () => {
197
+ answerLocally(this, output, answer, (local) => {
197
198
  if (!found.wordsReady)
198
199
  output.renderer.note(`the word index is still being built — \`${mail.app.command} store migrate\` finishes it`);
199
200
  noteServer(output, found.server);
@@ -204,12 +205,12 @@ Search guide: https://github.com/WireCatLabs/cli-core/blob/main/docs/search/quer
204
205
  const shown = shownOf(found.items.length, (page - 1) * limit, found.total);
205
206
  if (shown)
206
207
  output.renderer.note(shown);
207
- return found.items.map((item) => `${hitText(item, level)}\n`).join("");
208
- });
208
+ return found.items.map((item) => `${hitText(item, level, local)}\n`).join("");
209
+ }, options.timezone);
209
210
  });
210
211
  return new Command("search").summary("search the stored mail").description("find stored emails").addCommand(leaf);
211
212
  };
212
- const shownText = (emails) => `${emails.map((email) => [hitText(email, "full"), attachmentsText(email)].filter(Boolean).join("\n")).join("\n\n")}\n`;
213
+ const shownText = (emails, local) => `${emails.map((email) => [hitText(email, "full", local), attachmentsText(email)].filter(Boolean).join("\n")).join("\n\n")}\n`;
213
214
  const messagesCommand = (mail) => {
214
215
  const messages = new Command("messages").summary("read stored emails").description("read the emails sync stored");
215
216
  annotate(messages.command("show"), {
@@ -249,9 +250,9 @@ const messagesCommand = (mail) => {
249
250
  });
250
251
  const output = outputFor(this);
251
252
  const shown = emails.map((email) => snakeKeys(atDetail(email)));
252
- renderAnswer(this, output, beforeN || afterN
253
+ answerLocally(this, output, beforeN || afterN
253
254
  ? listOf("messages show", { items: shown, limit: Math.max(1, shown.length), has_more: false })
254
- : itemOf(shown.find((_, at) => emails[at]?.anchor) ?? shown[0], unread ? { meta: { complete: false, issues: [unread] } } : undefined), () => shownText(emails));
255
+ : itemOf(shown.find((_, at) => emails[at]?.anchor) ?? shown[0], unread ? { meta: { complete: false, issues: [unread] } } : undefined), (local) => shownText(emails, local));
255
256
  });
256
257
  messages.addCommand(acknowledgeCommand(mail));
257
258
  messages.addCommand(resetMarkCommand(mail));
@@ -315,14 +316,14 @@ const conversationsCommand = (mail) => {
315
316
  last_email_at: thread.lastEmailAt === null ? null : new Date(thread.lastEmailAt).toISOString(),
316
317
  untrusted: ["subject"],
317
318
  }));
318
- renderAnswer(this, outputFor(this), listOf("conversations list", {
319
+ answerLocally(this, outputFor(this), listOf("conversations list", {
319
320
  items,
320
321
  limit,
321
322
  has_more: threads.length > limit,
322
323
  page,
323
324
  ...(typed.trim() ? { args: { query: typed } } : {}),
324
- }), () => items
325
- .map((one) => `${one.ref}\n ${one.last_email_at ?? "time unknown"} ${one.emails_count} ${one.subject ?? ""}\n`)
325
+ }), (local) => items
326
+ .map((one) => `${one.ref}\n ${one.last_email_at === null ? "time unknown" : local(one.last_email_at)} ${one.emails_count} ${one.subject ?? ""}\n`)
326
327
  .join(""));
327
328
  });
328
329
  answerEntity(list, "mail_thread", mailThread);
@@ -349,7 +350,7 @@ const conversationsCommand = (mail) => {
349
350
  : hit));
350
351
  });
351
352
  const items = emails.map((email) => snakeKeys(atDetail(email)));
352
- renderAnswer(this, outputFor(this), listOf("conversations show", { items, limit: Math.max(1, items.length), has_more: false }), () => shownText(emails));
353
+ answerLocally(this, outputFor(this), listOf("conversations show", { items, limit: Math.max(1, items.length), has_more: false }), (local) => shownText(emails, local));
353
354
  });
354
355
  return conversations;
355
356
  };
@@ -5,8 +5,9 @@ import { defineSteps, MAX_NEXT_STEPS, stepOf, } from "@wirecat/cli-base/standard
5
5
  import { Command } from "commander";
6
6
  import { environmentOf, outputFor } from "../../cli/context.js";
7
7
  import { LOCAL_READ } from "../../cli/effects.js";
8
- import { listOf, renderAnswer } from "../../cli/envelope.js";
8
+ import { listOf } from "../../cli/envelope.js";
9
9
  import { listQuery, nativeField, nativeTerms, queryHelp, typedQuery } from "../../cli/list-query.js";
10
+ import { answerLocally } from "../../cli/local-times.js";
10
11
  import { pointsOf } from "../../cli/messenger/catch-up.js";
11
12
  import { catchUpQuery, SUMMARY_UNTRUSTED, summaryOf } from "../../cli/messenger/catch-up-answers.js";
12
13
  import { messageItem } from "../../cli/messenger/message-items.js";
@@ -231,11 +232,13 @@ const holderReview = async (command, mail, holder, query) => {
231
232
  : []),
232
233
  ];
233
234
  const output = outputFor(command);
234
- renderAnswer(command, output, answer, () => {
235
+ answerLocally(command, output, answer, (local) => {
235
236
  output.renderer.note(read.mark
236
237
  ? `${holder}: past ${read.mark.throughId}${emails.length ? `; findings key ${read.findingsKey}` : ""}; acknowledge with --cursor ${read.cursor}${read.more ? "; more after these" : ""}`
237
238
  : `${holder} has no mark yet: it starts from now — acknowledge with --cursor ${read.cursor}`);
238
- return emails.map((hit) => `${hitText(hit)}${markText(marks.get(hit.id), hit.excerptOnly)}\n`).join("");
239
+ return emails
240
+ .map((hit) => `${hitText(hit, undefined, local)}${markText(marks.get(hit.id), hit.excerptOnly)}\n`)
241
+ .join("");
239
242
  });
240
243
  };
241
244
  /**
@@ -311,7 +314,7 @@ an agent over MCP, and each scheduled run.`)
311
314
  const answer = reviewAnswer(found, mailbox, saved !== undefined, stepsFor(mail.mount, mailbox));
312
315
  if (meta)
313
316
  answer.meta = { ...meta, ...answer.meta };
314
- renderAnswer(this, output, answer, () => {
317
+ answerLocally(this, output, answer, (local) => {
315
318
  const read = found.chats.flatMap((chat) => chat.messages);
316
319
  if (found.unanswered || found.waiting)
317
320
  output.renderer.note(`${[
@@ -319,16 +322,16 @@ an agent over MCP, and each scheduled run.`)
319
322
  found.waiting ? `waiting ${found.waiting.olderThanHours}h or more` : "",
320
323
  ]
321
324
  .filter(Boolean)
322
- .join(", ")}, threads active from ${found.since}`);
325
+ .join(", ")}, threads active from ${local(found.since)}`);
323
326
  else
324
- output.renderer.note(`from ${found.since} to ${found.until}${found.complete ? "" : ", incomplete"}`);
327
+ output.renderer.note(`from ${local(found.since)} to ${local(found.until)}${found.complete ? "" : ", incomplete"}`);
325
328
  if (found.quiet > 0)
326
329
  output.renderer.note(`bulk:false left out ${found.quiet} threads`);
327
330
  if (read.length === 0)
328
331
  output.renderer.note("nothing new in the stored mail");
329
332
  return read
330
333
  .sort((a, b) => Date.parse(a.timestamp) - Date.parse(b.timestamp))
331
- .map((hit) => `${hitText(hit)}${markText(hit.marks)}\n`)
334
+ .map((hit) => `${hitText(hit, undefined, local)}${markText(hit.marks)}\n`)
332
335
  .join("");
333
336
  });
334
337
  saved?.save(found.checked ?? {}, found.complete ? { through: found.until } : {});
@@ -54,7 +54,7 @@ export declare const showMail: (store: MessageStore, locator: string, { before,
54
54
  */
55
55
  export declare const hitText: (item: StoredHit & {
56
56
  excerpt?: string;
57
- }, level?: "brief" | "full") => string;
57
+ }, level?: "brief" | "full", shown?: (time: string) => string) => string;
58
58
  /** Each attachment of a shown email with its text indented under it, or why it has none. */
59
59
  export declare const attachmentsText: (hit: ShownMail) => string;
60
60
  export {};
package/dist/mail/read.js CHANGED
@@ -82,7 +82,7 @@ const line = (text) => text.replace(/\s+/g, " ").trim();
82
82
  * A found message or email for a reader: its reference first, then when and from whom, who else it went to and
83
83
  * where it is filed, its subject or chat, and an excerpt — the whole text only at `full`.
84
84
  */
85
- export const hitText = (item, level) => {
85
+ export const hitText = (item, level, shown = (time) => time) => {
86
86
  const mail = item.email;
87
87
  const names = (people) => people.map(({ name, address }) => line(name ?? address)).join(", ");
88
88
  const between = mail
@@ -97,7 +97,7 @@ export const hitText = (item, level) => {
97
97
  const who = line(item.senderName ?? item.senderId ?? "unknown");
98
98
  return [
99
99
  item.locator,
100
- ` ${item.timestamp} ${who}${level === "brief" || !between ? "" : ` ${between}`}`,
100
+ ` ${shown(item.timestamp)} ${who}${level === "brief" || !between ? "" : ` ${between}`}`,
101
101
  ...(level === "brief" || !item.chatTitle ? [] : [` ${line(item.chatTitle)}`]),
102
102
  ` ${level === "full" ? item.text.replace(/\n/g, "\n ") : line(item.excerpt ?? item.text)}`,
103
103
  ].join("\n");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wirecat/cli-core",
3
- "version": "0.586.0",
3
+ "version": "0.587.0",
4
4
  "description": "The messenger-neutral half of a messaging CLI: domain model, message locators, rendering, name resolution, the SQLite seam, the command skeleton and shared read commands, the send guard, run records and the message store",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Viacheslav Ptsarev",
@@ -258,7 +258,7 @@
258
258
  "@types/node": "^22.20.5",
259
259
  "@types/pngjs": "6.0.5",
260
260
  "@vitest/coverage-v8": "^5.0.5",
261
- "@wirecat/cli-base": "0.79.0",
261
+ "@wirecat/cli-base": "0.80.0",
262
262
  "@wirecat/cli-core-legacy-tags": "npm:@wirecat/cli-core@0.414.0",
263
263
  "commander": "15.0.0",
264
264
  "cspell": "10.4.0",
@@ -278,7 +278,7 @@
278
278
  },
279
279
  "peerDependencies": {
280
280
  "@napi-rs/canvas": ">=0.1.69 <2",
281
- "@wirecat/cli-base": ">=0.76.0 <0.80.0",
281
+ "@wirecat/cli-base": ">=0.76.0 <0.81.0",
282
282
  "commander": ">=15"
283
283
  },
284
284
  "bin": {