@wirecat/cli-core 0.464.0 → 0.466.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,35 @@ 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.466.0 — 27.10.2026
9
+
10
+ ### Changed — may break callers
11
+
12
+ - **`account list` says whether each profile is signed in on this computer.** The `account_profile` entity gains an
13
+ optional `signed_in`, set when the messenger sets `Messenger.profileState`; a caller that checks items against an
14
+ older, strict copy of it refuses the new field.
15
+
16
+ ### Fixed
17
+
18
+ - **`<cli> mcp serve` with no profile marks the profiles that are not signed in.** A messenger sets the new optional
19
+ `Messenger.profileState(profile, env)` (`signed-in`, `signed-out` or `bot`), read from its own files without
20
+ connecting. The `profile` field keeps its `enum` and gains `oneOf` entries with a `title` per profile
21
+ (`default — not signed in`), as MCP's titled enums, and its description lists who is signed in. Bot profiles are left
22
+ out: they have their own server. A call for a profile that is not signed in is refused with how to sign it in
23
+ (`<cli> <profile> setup`, or WireCat Desktop) and starts nothing. Unset, every profile counts as signed in, as before.
24
+ - **The "no profile named" error marks the listed profiles that are not signed in** (`beta (not signed in)`), when the
25
+ messenger sets `profileState`.
26
+ - **The MCP login page says why a code that just worked is now wrong.** Each login code works once; after an app
27
+ logs in, the server shows a new one. The wrong-code message now says so and points to WireCat Desktop and
28
+ `<cli> mcp server status`, where the new code is.
29
+
30
+ ## 0.465.0 — 27.10.2026
31
+
32
+ ### Changed — may break callers
33
+
34
+ - **The calendar `sync` starts over with `--rescan`, not `--full`**, the start-over flag WC-LIFE-13 names and drive's
35
+ `sync --rescan` uses. `--full` is the shared program's output flag; `sync --full` is now refused with a message
36
+ that names `--rescan`, rather than quietly reading only the changes.
9
37
 
10
38
  ## 0.464.0 — 27.10.2026
11
39
 
@@ -75,6 +75,7 @@ export const listenRelay = async (path, run) => {
75
75
  // The caller holds the serve lock, so a socket left here belongs to a serve that is gone.
76
76
  rmSync(path, { force: true });
77
77
  const open = new Set();
78
+ const running = new Set();
78
79
  const server = createServer((socket) => {
79
80
  open.add(socket);
80
81
  const stop = new AbortController();
@@ -111,7 +112,9 @@ export const listenRelay = async (path, run) => {
111
112
  stdin.isTTY = parsed.output.stdinTty;
112
113
  if (parsed.output.stdinTty)
113
114
  stdin.end();
114
- void serve(socket, parsed.output, stdin, stop.signal, run);
115
+ const job = serve(socket, parsed.output, stdin, stop.signal, run);
116
+ running.add(job);
117
+ void job.then(() => running.delete(job), () => running.delete(job));
115
118
  return;
116
119
  }
117
120
  if (typeof message.stdin === "string" && !stdin.writableEnded)
@@ -133,6 +136,7 @@ export const listenRelay = async (path, run) => {
133
136
  for (const socket of open)
134
137
  socket.destroy();
135
138
  await new Promise((resolve) => server.close(() => resolve()));
139
+ await Promise.allSettled(running);
136
140
  rmSync(path, { force: true });
137
141
  },
138
142
  };
@@ -122,21 +122,22 @@ const addCalendarCommandsDefinition = (program, deps) => {
122
122
  offline: false,
123
123
  cooperative: true,
124
124
  partial: true,
125
- }).description("bring the synced calendars up to date: what changed since the last sync, or everything with --full; a run " +
126
- "stops before its deadline or --max-pages and the next one goes on from there"), "sync", "sync --full --embed", "sync --timeout 2m"))
127
- .option("--full", "list every synced calendar's window again, dropping a round in progress")
125
+ }).description("bring the synced calendars up to date: what changed since the last sync, or everything with --rescan; a run " +
126
+ "stops before its deadline or --max-pages and the next one goes on from there"), "sync", "sync --rescan --embed", "sync --timeout 2m"))
127
+ .option("--rescan", "list every synced calendar's window again, dropping a round in progress")
128
128
  .option("--max-pages <pages>", "at most this many requests to the provider in this run; as many as fit the deadline if not given", integer)
129
129
  .option("--embed", "also index new events for meaning search, with a model already downloaded")
130
130
  .action(async (opts, command) => {
131
- // The shared program has a global --full of its own, which claims the flag before this command sees it.
132
- const full = command.optsWithGlobals().full === true;
131
+ // --full is the shared program's output flag; it used to start the round over here, and silently would not now.
132
+ if (command.optsWithGlobals().full)
133
+ throw new CliError("validation_error", "sync starts over with --rescan; --full only widens output");
133
134
  const { signal } = environmentOf(command);
134
135
  const report = await syncCalendars(source(), deps.store, {
135
136
  accountId: deps.accountId,
136
137
  now: now(),
137
138
  budget: runBudgetOf(command, opts.maxPages),
138
139
  ...(signal ? { signal } : {}),
139
- ...(full ? { full: true } : {}),
140
+ ...(opts.rescan ? { full: true } : {}),
140
141
  ...(deps.expand ? { expand: deps.expand } : {}),
141
142
  });
142
143
  if (opts.embed && !deps.embed)
@@ -144,7 +145,7 @@ const addCalendarCommandsDefinition = (program, deps) => {
144
145
  // A run its budget stopped has no time left for the model; the run that finishes the round embeds.
145
146
  const embeddings = opts.embed && !report.stopped ? await deps.embed?.(EMBED_MAX) : undefined;
146
147
  const timeout = command.optsWithGlobals().timeout;
147
- // The same command again, never --full: that would drop the round this run leaves in progress. A run that moved
148
+ // The same command again, never --rescan: that would drop the round this run leaves in progress. A run that moved
148
149
  // nothing on would only repeat itself, so it gets no step an agent may run unasked.
149
150
  const stuck = report.state === "partial" && report.progress.steps === 0 && !report.synced.some((one) => one.error);
150
151
  const next = report.state === "partial" && !stuck && !report.synced.some((one) => one.error !== undefined)
@@ -88,7 +88,7 @@ export { type MeetingSeriesInventoryReader, readMeetingSeriesInventory } from ".
88
88
  export { accountFileFor, profilesHere, rememberAccount } from "./messenger/accounts.js";
89
89
  export { terminalAsker } from "./messenger/ask.js";
90
90
  export type { CompletionOptions } from "./messenger/complete-command.js";
91
- export { type ConnectOptions, connectFor, type Messenger, type MessengerContext, messengerContext, type ReadConnection, type StoredMessages, } from "./messenger/context.js";
91
+ export { type ConnectOptions, connectFor, type Messenger, type MessengerContext, messengerContext, type ProfileState, type ReadConnection, type StoredMessages, } from "./messenger/context.js";
92
92
  export { COMMAND_NEEDS } from "./messenger/lacks.js";
93
93
  export { httpTokenFile, type McpEnvironment, serverEntry } from "./messenger/mcp-command.js";
94
94
  export { type MessageItem, messageItem, type ReadMessage } from "./messenger/message-items.js";
@@ -45,6 +45,9 @@ export const accountCommand = (messenger) => {
45
45
  current: profile === current,
46
46
  account: key?.account ?? null,
47
47
  name: key && store ? await store.accounts.accountName(key) : null,
48
+ ...(messenger.profileState
49
+ ? { signed_in: messenger.profileState(profile, context.env) !== "signed-out" }
50
+ : {}),
48
51
  });
49
52
  }
50
53
  renderAnswer(this, context, wholeList("account list", items));
@@ -49,6 +49,7 @@ export interface LazyStore {
49
49
  }
50
50
  export declare const lazyStore: (env: NodeJS.ProcessEnv, command: string, version?: string) => LazyStore;
51
51
  export declare const storedMessages: (provider: Provider, store: () => Promise<MessageStore>, account: () => Id | null | undefined) => StoredMessages;
52
+ export type ProfileState = "signed-in" | "signed-out" | "bot";
52
53
  /** What one messenger CLI hands the shared commands. Everything else about it stays in its own code. */
53
54
  export interface Messenger extends ServiceMessenger {
54
55
  /**
@@ -65,6 +66,12 @@ export interface Messenger extends ServiceMessenger {
65
66
  * Called inside `--timeout`; the context tracks and closes what it returns.
66
67
  */
67
68
  connect: (command: Command, context: BaseContext, options?: ConnectOptions) => Promise<MessengerAdapter>;
69
+ /**
70
+ * A profile's state, read from the messenger's own files without connecting: `signed-in` has a session, `signed-out`
71
+ * has none yet, `bot` speaks for a bot. `<tool> mcp` with no profile offers the personal ones and marks those not
72
+ * signed in; `account list` and the profile errors say so too. Unset: every profile counts as signed in.
73
+ */
74
+ profileState?: (profile: string, env: NodeJS.ProcessEnv) => ProfileState;
68
75
  /** The help for a `<chat>` argument, in this messenger's words. */
69
76
  chatArgument: string;
70
77
  /** The group settings this messenger has, as `chats update` offers them; every one when unset. */
@@ -262,6 +269,8 @@ export declare const connectedFor: (command: Command, messenger: Pick<Messenger,
262
269
  adapter: MessengerAdapter;
263
270
  close: () => Promise<void>;
264
271
  }>;
272
+ /** `default (not signed in)`: a profile as an error lists it among the choices. */
273
+ export declare const markedProfile: (messenger: Messenger, profile: string, env: NodeJS.ProcessEnv) => string;
265
274
  /**
266
275
  * A write to the local store alone. `deny` already stopped the command (`messengerContext`); `readonly`
267
276
  * stops this write too. There is no question to put to the owner here, so `ask` refuses rather than
@@ -288,9 +288,11 @@ export const connectedFor = async (command, messenger, base, options) => {
288
288
  }),
289
289
  };
290
290
  };
291
- const unrecorded = (command, messenger, profile, { offline, profilesHere }) => {
291
+ /** `default (not signed in)`: a profile as an error lists it among the choices. */
292
+ export const markedProfile = (messenger, profile, env) => messenger.profileState?.(profile, env) === "signed-out" ? `${profile} (not signed in)` : profile;
293
+ const unrecorded = (command, messenger, profile, { offline, profilesHere }, env) => {
292
294
  if (!profilesHere.includes(profile)) {
293
- return new CliError("not_found", `no profile named "${profile}" — profiles here: ${profilesHere.length > 0 ? profilesHere.join(", ") : "none"}`, { profiles: profilesHere });
295
+ return new CliError("not_found", `no profile named "${profile}" — profiles here: ${profilesHere.length > 0 ? profilesHere.map((one) => markedProfile(messenger, one, env)).join(", ") : "none"}`, { profiles: profilesHere });
294
296
  }
295
297
  const words = `${messenger.app.command} ${asFirstWord(profile)}`;
296
298
  const setup = rootOf(command).commands.some((one) => one.name() === "setup") ? `\`${words}setup\`` : undefined;
@@ -361,7 +363,7 @@ export const messengerContext = (command, messenger, resolving = {}) => {
361
363
  const accountFor = () => {
362
364
  const account = messenger.accountOf?.(command) ?? recalledAccount(app, provider, profile, base.env);
363
365
  if (!account)
364
- throw unrecorded(command, messenger, profile, base.settings);
366
+ throw unrecorded(command, messenger, profile, base.settings, base.env);
365
367
  return account;
366
368
  };
367
369
  const guard = messenger.guard?.(command, base.settings, warn) ??
@@ -222,7 +222,11 @@ const serveEveryProfile = async (self, context, messenger, flags) => {
222
222
  const { app } = messenger;
223
223
  const { env } = context;
224
224
  const tokenFile = httpTokenFile(app, ALL_PROFILES, env);
225
- const profiles = () => messenger.resolveSettings(self.optsWithGlobals(), { env, needsProfile: false, readOnly: true }).profilesHere;
225
+ const state = (profile) => messenger.profileState?.(profile, env) ?? "signed-in";
226
+ // Read again for each session, so a profile set up while the server runs appears. A bot has its own server.
227
+ const profiles = () => messenger
228
+ .resolveSettings(self.optsWithGlobals(), { env, needsProfile: false, readOnly: true })
229
+ .profilesHere.filter((profile) => state(profile) !== "bot");
226
230
  if (profiles().length === 0)
227
231
  throw new CliError("configuration_error", `no ${app.command} profile on this computer yet — set one up, then run \`${app.command} mcp serve\` again`);
228
232
  const given = environmentOf(self).mcp ?? {};
@@ -235,6 +239,9 @@ const serveEveryProfile = async (self, context, messenger, flags) => {
235
239
  version: app.version,
236
240
  name: messenger.name ?? app.command,
237
241
  profiles,
242
+ signedIn: (profile) => state(profile) === "signed-in",
243
+ signIn: (profile) => `profile ${profile} is not signed in — run \`${app.command} ${profile} setup\`, or sign in from WireCat Desktop, ` +
244
+ "then try again. If the owner did not choose this profile, ask which one.",
238
245
  child: (profile) => ({ command: execPath, args: [scriptPath, profile, "mcp", "serve", ...tightened], env }),
239
246
  instructions: instructions({
240
247
  command: app.command,
@@ -39,6 +39,7 @@ export declare const serveRelay: (command: Command, messenger: Messenger) => {
39
39
  connection?: "exclusive";
40
40
  pace?: import("../../sends/pace.js").PaceRate;
41
41
  resolveSettings: (flags: import("../settings.js").GlobalFlags, options?: import("../settings.js").ResolveOptions) => import("../settings.js").Settings;
42
+ profileState?: (profile: string, env: NodeJS.ProcessEnv) => import("./context.js").ProfileState;
42
43
  chatArgument: string;
43
44
  groupSettings?: readonly import("../../domain/models.js").GroupSettingKey[];
44
45
  pollQuiz?: boolean;
@@ -4,4 +4,6 @@ export declare const accountProfile: v.StrictObjectSchema<{
4
4
  readonly current: v.BooleanSchema<undefined>;
5
5
  readonly account: v.NullableSchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.MinLengthAction<string, 1, undefined>]>, undefined>;
6
6
  readonly name: v.NullableSchema<v.StringSchema<undefined>, undefined>;
7
+ /** False for a profile with no session on this computer; absent when the messenger does not say. */
8
+ readonly signed_in: v.OptionalSchema<v.BooleanSchema<undefined>, undefined>;
7
9
  }, undefined>;
@@ -5,4 +5,6 @@ export const accountProfile = v.strictObject({
5
5
  current: v.boolean(),
6
6
  account: v.nullable(id),
7
7
  name: v.nullable(text),
8
+ /** False for a profile with no session on this computer; absent when the messenger does not say. */
9
+ signed_in: v.optional(v.boolean()),
8
10
  });
@@ -4177,6 +4177,7 @@ export declare const ENTITIES: {
4177
4177
  readonly current: v.BooleanSchema<undefined>;
4178
4178
  readonly account: v.NullableSchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.MinLengthAction<string, 1, undefined>]>, undefined>;
4179
4179
  readonly name: v.NullableSchema<v.StringSchema<undefined>, undefined>;
4180
+ readonly signed_in: v.OptionalSchema<v.BooleanSchema<undefined>, undefined>;
4180
4181
  }, undefined>;
4181
4182
  readonly organization: v.StrictObjectSchema<{
4182
4183
  readonly id: v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.MinLengthAction<string, 1, undefined>]>;
@@ -16,14 +16,22 @@ export interface AllProfilesOptions {
16
16
  name: string;
17
17
  /** Read again for each new session, so a profile set up while serving over HTTP shows up. */
18
18
  profiles: () => readonly string[];
19
+ /** Whether a profile has a session now; unset, every one does. A call for one without is refused with `signIn`. */
20
+ signedIn?: (profile: string) => boolean;
21
+ /** How to sign a profile in, for the refusal of a call it cannot serve yet. */
22
+ signIn?: (profile: string) => string;
19
23
  child: (profile: string) => ChildSpec;
20
24
  instructions: string;
21
25
  idleMs?: number;
22
26
  /** A child's stderr, line by line; never this process's stdout, which carries the protocol. */
23
27
  note?: (line: string) => void;
24
28
  }
25
- /** The child's tool with the one field that picks the profile, required and without a default. */
26
- export declare const withProfile: (tool: Tool, profiles: readonly string[], name: string) => Tool;
29
+ /**
30
+ * The child's tool with the one field that picks the profile, required and without a default. Each profile carries
31
+ * its state as the title an app shows in its menu (`oneOf` with `const` and `title`, as MCP's titled enums), and the
32
+ * description repeats it for an app that shows only the description.
33
+ */
34
+ export declare const withProfile: (tool: Tool, profiles: readonly string[], name: string, signedIn?: (profile: string) => boolean) => Tool;
27
35
  /**
28
36
  * **One server for every profile of a tool** (TASK-26): `<tool> mcp` shows the tools `<tool> <profile> mcp` shows,
29
37
  * each with a required `profile`, and runs a call in that profile's own server, started as a child the first time
@@ -138,9 +138,17 @@ const pool = (options) => {
138
138
  };
139
139
  return { use, close };
140
140
  };
141
- /** The child's tool with the one field that picks the profile, required and without a default. */
142
- export const withProfile = (tool, profiles, name) => {
141
+ /**
142
+ * The child's tool with the one field that picks the profile, required and without a default. Each profile carries
143
+ * its state as the title an app shows in its menu (`oneOf` with `const` and `title`, as MCP's titled enums), and the
144
+ * description repeats it for an app that shows only the description.
145
+ */
146
+ export const withProfile = (tool, profiles, name, signedIn = () => true) => {
143
147
  const schema = tool.inputSchema;
148
+ const out = profiles.filter((profile) => !signedIn(profile));
149
+ const states = out.length === 0
150
+ ? ""
151
+ : ` Signed in: ${profiles.filter(signedIn).join(", ") || "none"} · Not signed in: ${out.join(", ")}.`;
144
152
  return {
145
153
  ...tool,
146
154
  inputSchema: {
@@ -149,7 +157,11 @@ export const withProfile = (tool, profiles, name) => {
149
157
  profile: {
150
158
  type: "string",
151
159
  enum: [...profiles],
152
- description: `Which ${name} account, by its profile. If the owner did not name one, ask which — never guess.`,
160
+ oneOf: profiles.map((profile) => ({
161
+ const: profile,
162
+ title: signedIn(profile) ? profile : `${profile} — not signed in`,
163
+ })),
164
+ description: `Which ${name} account, by its profile. If the owner did not name one, ask which — never guess.${states}`,
153
165
  },
154
166
  ...schema.properties,
155
167
  },
@@ -174,24 +186,29 @@ export const allProfilesServer = (options) => {
174
186
  // list comes from whichever answers first and is kept.
175
187
  const tools = (profiles) => {
176
188
  listed ??= children
177
- .use(profiles[0], (child) => child.request("tools/list", {}))
189
+ .use(profiles.find(signedIn) ?? profiles[0], (child) => child.request("tools/list", {}))
178
190
  .then((answer) => (answer.tools ?? []));
179
191
  listed.catch(() => {
180
192
  listed = undefined;
181
193
  });
182
194
  return listed;
183
195
  };
196
+ const signedIn = (profile) => options.signedIn?.(profile) ?? true;
184
197
  const build = () => {
185
198
  const profiles = [...options.profiles()].sort();
186
199
  const server = new McpServer({ name: options.command, version: options.version }, { capabilities: { tools: {} }, instructions: options.instructions });
187
200
  server.server.setRequestHandler("tools/list", async () => ({
188
- tools: profiles.length === 0 ? [] : (await tools(profiles)).map((one) => withProfile(one, profiles, options.name)),
201
+ tools: profiles.length === 0
202
+ ? []
203
+ : (await tools(profiles)).map((one) => withProfile(one, profiles, options.name, signedIn)),
189
204
  }));
190
205
  server.server.setRequestHandler("tools/call", async (request) => {
191
206
  const { name, arguments: given = {}, _meta } = request.params;
192
207
  const { profile, ...rest } = given;
193
208
  if (typeof profile !== "string" || !profiles.includes(profile))
194
209
  return refusal(`${name} needs "profile", one of: ${profiles.join(", ")}. If the owner did not say which account, ask.`);
210
+ if (!signedIn(profile))
211
+ return refusal(options.signIn?.(profile) ?? `profile ${profile} is not signed in.`);
195
212
  try {
196
213
  return (await children.use(profile, (child) => child.request("tools/call", { name, arguments: rest, ...(_meta ? { _meta } : {}) })));
197
214
  }
@@ -201,7 +201,7 @@ export const ownerLogin = ({ issuer, resource, file, appName, onCode, now = () =
201
201
  retryAt = now() + WRONG_CODE_WAIT_MS;
202
202
  return throttled();
203
203
  }
204
- return page(params, client, `Wrong code. ${WRONG_TRIES - wrong} tries left.`);
204
+ return page(params, client, `Wrong code. Each code works for one login: after an app logs in, a new one appears in WireCat Desktop and in "${appName} mcp server status". ${WRONG_TRIES - wrong} tries left.`);
205
205
  }
206
206
  wrong = 0;
207
207
  freshCode();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wirecat/cli-core",
3
- "version": "0.464.0",
3
+ "version": "0.466.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",