@wirecat/cli-core 0.413.0 → 0.414.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,22 @@ 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.414.0 — 25.10.2026
9
+
10
+ ### Added
11
+
12
+ - `AppIdentity.contentLanguages`: ISO 639-1 codes of the languages a CLI's content is in. On a new store the first run pre-ticks the ones search can stem in place of English and the system's language; the person still confirms the shared choice. Unset, nothing changes.
13
+ - Export `publicUrlOf` and `portOf` through `/mcp`, so non-messenger consumers reuse the same HTTP listener option validation as the messenger commands.
14
+ - **A contact saved with an address joins the mail contact of that exact address.** `savePeople` takes an optional
15
+ `email`; the provider's contact and the mail contact become one person (`same-email`), whichever is saved first.
16
+ A name never joins anyone, and two identities the owner unlinked stay apart.
17
+ - **The first screen of `tg --help`, `max --help` and `wa --help` points at `account list`.** A row under "Start here" names `<tool> account list`, which lists every profile on this computer without a profile in front, so a reader who does not know their profile names finds them. A tool without `account list` shows no row.
18
+
19
+ ### Fixed
20
+
21
+ - Generated MCP commands accept a positional argument or its same-named flag alternative once. A single `text` input no longer emits both the argument and `--text`, which made Memo note creation fail. Positional requiredness is retained.
22
+ - Public listener origins refuse embedded credentials before startup diagnostics can expose them.
23
+ - **"Returns" lists the fields of commands whose output has several shapes.** A command's help and the generated `docs/commands.md` showed `message send — ; every field: …` with no fields for every output declared as a union (`message_send`, `store_info`, `chat_group_change` and others; 41 lines in tg-cli's page). The line now lists the fields of every shape, required first, as for any other output.
9
24
 
10
25
  ## 0.413.0 — 25.10.2026
11
26
 
package/dist/cli/entry.js CHANGED
@@ -61,6 +61,7 @@ export const startCli = async (entry) => {
61
61
  atStart: (command) => setupAtStart({
62
62
  command: entry.app.command,
63
63
  env: io.env,
64
+ contentLanguages: entry.app.contentLanguages,
64
65
  path: commandPathOf(command),
65
66
  headless: headless(command),
66
67
  readOnlyLock: readOnlyLockOf(entry.app, io.env),
@@ -25,6 +25,8 @@ export interface FirstRun {
25
25
  command: string;
26
26
  env: NodeJS.ProcessEnv;
27
27
  io: FirstRunIo;
28
+ /** `AppIdentity.contentLanguages`: ticked on a new store in place of English and the system's language. */
29
+ contentLanguages?: readonly string[] | undefined;
28
30
  /** Every question, as `setup` asks them; otherwise only those no WireCat tool asked yet. */
29
31
  all?: boolean;
30
32
  /** Where tools are looked for and how one is installed; injected by tests. */
@@ -58,6 +60,7 @@ export declare const askSetupQuestions: (run: FirstRun) => Promise<Question[]>;
58
60
  export declare const setupAtStart: (start: {
59
61
  command: string;
60
62
  env: NodeJS.ProcessEnv;
63
+ contentLanguages?: readonly string[] | undefined;
61
64
  path: readonly string[];
62
65
  headless: boolean;
63
66
  readOnlyLock?: string | undefined;
@@ -85,17 +85,19 @@ const installerHere = () => {
85
85
  return "unknown";
86
86
  }
87
87
  };
88
- const askLanguages = async ({ command, env, io }) => {
88
+ const suggested = (env, contentLanguages) => {
89
+ const own = LANGUAGES.filter(({ code }) => contentLanguages?.includes(code));
90
+ const system = systemLanguage(env);
91
+ return (own.length ? own : LANGUAGES.filter(({ code }) => code === "en" || code === system)).map(({ name }) => name);
92
+ };
93
+ const askLanguages = async ({ command, env, io, contentLanguages }) => {
89
94
  const store = await openStore({ env });
90
95
  let rebuild = false;
91
96
  try {
92
97
  const saved = (await store.searchIndex.stemmers()) ?? undefined;
93
98
  // A store already in use keeps the languages it was built with, so Enter changes nothing (RISK-1612).
94
99
  const inUse = saved !== undefined || (await store.accounts.accounts()).length > 0;
95
- const system = systemLanguage(env);
96
- const ticked = inUse
97
- ? languagesOf(saved ?? DEFAULT_STEMMERS)
98
- : LANGUAGES.filter(({ code }) => code === "en" || code === system).map(({ name }) => name);
100
+ const ticked = inUse ? languagesOf(saved ?? DEFAULT_STEMMERS) : suggested(env, contentLanguages);
99
101
  const numbers = ticked.map((name) => LANGUAGES.findIndex((one) => one.name === name) + 1);
100
102
  io.say("Languages you write and speak — search finds every form of their words, and spellings speech mangles:");
101
103
  LANGUAGES.forEach(({ name, label }, at) => {
@@ -213,6 +215,7 @@ export const setupAtStart = async (start) => {
213
215
  await askSetupQuestions({
214
216
  command: start.command,
215
217
  env: start.env,
218
+ contentLanguages: start.contentLanguages,
216
219
  all: start.path[0] === "setup",
217
220
  io: {
218
221
  ask: (prompt) => readSecret(prompt, { input, echo: true }),
@@ -100,16 +100,19 @@ const call = (command) => [
100
100
  ...commandPathOf(command),
101
101
  ...command.registeredArguments.map((one) => (one.required ? `<${one.name()}>` : `[${one.name()}]`)),
102
102
  ].join(" ");
103
+ const fieldsOf = (schema) => {
104
+ const variants = schema.anyOf;
105
+ if (variants)
106
+ return variants.flatMap(fieldsOf);
107
+ const required = schema.required ?? [];
108
+ return [...required, ...Object.keys(schema.properties ?? {})];
109
+ };
103
110
  const returnsOf = (command) => {
104
111
  const output = metaOf(command).output;
105
112
  const { item, coverage } = outputOf(command);
106
113
  if (!output || coverage !== "declared-entity")
107
114
  return short(command.summary() || command.description()) || "not declared";
108
- const required = item.required ?? [];
109
- const fields = [
110
- ...required,
111
- ...Object.keys(item.properties ?? {}).filter((name) => !required.includes(name)),
112
- ];
115
+ const fields = [...new Set(fieldsOf(item))];
113
116
  const noun = (output.view ? `${output.entity} ${output.view}` : output.entity).replaceAll("_", " ");
114
117
  const more = fields.length > 8 ? `, and ${fields.length - 8} more` : "";
115
118
  const root = rootOf(command);
@@ -64,6 +64,9 @@ const pointers = (program, cli, start, profile) => {
64
64
  ].filter((row) => row !== undefined);
65
65
  const rows = [
66
66
  ...(start ? [["Start here:", start, profile ? "(any profile name works)" : ""]] : []),
67
+ ...(has("account list")
68
+ ? [["", `${cli} account list`, "your profiles and their logged-in accounts"]]
69
+ : []),
67
70
  ...next.map(([command = "", does = ""], at) => [at === 0 ? "Next:" : "", command, does]),
68
71
  ...(has("skill show") ? [["For agents:", `${cli} skill show`, "rules and the discovery loop"]] : []),
69
72
  ];
@@ -267,6 +267,7 @@ const runOnce = async (argv, definition, options) => {
267
267
  await setupAtStart({
268
268
  command,
269
269
  env: commandEnv,
270
+ contentLanguages: definition.app.contentLanguages,
270
271
  path,
271
272
  headless,
272
273
  readOnlyLock: lock,
@@ -14,6 +14,8 @@ export const publicUrlOf = (given, { missing, reaches }) => {
14
14
  catch {
15
15
  throw new CliError("validation_error", `--public-url is not an address: ${EXAMPLE}`);
16
16
  }
17
+ if (url.username || url.password)
18
+ throw new CliError("validation_error", "--public-url must not contain login credentials");
17
19
  const local = url.protocol === "http:" && ["localhost", "127.0.0.1"].includes(url.hostname);
18
20
  if (url.protocol !== "https:" && !local)
19
21
  throw new CliError("validation_error", `--public-url must be https — ${reaches} through it`);
package/dist/mcp/agent.js CHANGED
@@ -111,7 +111,10 @@ const fieldsOf = (info, { omit = [], offline = false, stdin }) => {
111
111
  const long = longOf(option);
112
112
  if (!long || option.mcp === false)
113
113
  continue;
114
- fields.push(optionField(option, long, longs.has(`--no-${long.slice(2)}`)));
114
+ const field = optionField(option, long, longs.has(`--no-${long.slice(2)}`));
115
+ // A positional and its flag alternative share one MCP input; emitting both supplies the value twice.
116
+ if (!fields.some((existing) => existing.name === field.name))
117
+ fields.push(field);
115
118
  }
116
119
  if (offline && info.effects?.remote === "read" && info.offline !== false)
117
120
  fields.push(OFFLINE);
@@ -1,3 +1,4 @@
1
+ export { portOf, publicUrlOf } from "../http/public-url.js";
1
2
  export { type AgentAnswer, type AgentContext, type AgentProgram, type AgentTool, type CatalogueOptions, type CommandAvailability, runCommandForAgent, toolsFromCommands, } from "./agent.js";
2
3
  export { type BotServerOptions, createBotServer, type RunBotCommand } from "./bot/server.js";
3
4
  export { revokeAll } from "./http/oauth.js";
package/dist/mcp/index.js CHANGED
@@ -1,4 +1,5 @@
1
1
  // Apart from the `cli` entry, so a CLI loads the MCP SDK and zod only when it serves MCP.
2
+ export { portOf, publicUrlOf } from "../http/public-url.js";
2
3
  export { runCommandForAgent, toolsFromCommands, } from "./agent.js";
3
4
  export { createBotServer } from "./bot/server.js";
4
5
  export { revokeAll } from "./http/oauth.js";
@@ -14,6 +14,11 @@ export interface AppIdentity {
14
14
  issues?: string;
15
15
  /** The language of the transcript a person reads: its day headings and the word for "you". `ru-RU` when unset. */
16
16
  locale?: string;
17
+ /**
18
+ * ISO 639-1 codes of the languages its content is mostly in (`["ru"]`). The first run pre-ticks the ones search
19
+ * can stem in place of English and the system's language; the person still confirms, since the choice is shared.
20
+ */
21
+ contentLanguages?: readonly string[];
17
22
  /** Error reasons and detail keys of its own — max's `max_code`, tg's `migrated_chat_id` — kept in every error it prints. */
18
23
  errors?: AppErrors;
19
24
  }
@@ -12,8 +12,11 @@ export const accountPeopleOver = (context) => {
12
12
  return {
13
13
  savePeople: async (key, people) => write(database, () => {
14
14
  const accountKey = accountPk(key);
15
- for (const person of people)
15
+ for (const person of people) {
16
16
  identityPk(accountKey, key.provider, person.id, person.name, person);
17
+ if (person.email)
18
+ personLinks.joinByAddress(context, { provider: key.provider, id: person.id }, person.email, key.provider);
19
+ }
17
20
  }),
18
21
  contacts: async (key, options) => {
19
22
  const accountKey = findAccountPk(key);
@@ -14,6 +14,12 @@ export declare const linkIdentityToPerson: (context: StoreContext, identityPk: n
14
14
  export declare const linkIdentities: (context: StoreContext, person: IdentityRef, other: IdentityRef, options: LinkOptions) => PersonRecord;
15
15
  /** Moves every identity of `from` to `to`, as `linkIdentities` does once it has found both people. */
16
16
  export declare const mergePerson: (context: StoreContext, from: number, to: number, options: LinkOptions) => void;
17
+ /**
18
+ * Joins the identity to the person of the mail contact with this exact address, making that contact when mail has
19
+ * not seen it yet, so whichever import comes second lands on one person. The contact is seen by no account, so it is
20
+ * no one's mail correspondent. An owner's unlink wins: two identities the owner separated stay apart.
21
+ */
22
+ export declare const joinByAddress: (context: StoreContext, ref: IdentityRef, address: string, by: string) => void;
17
23
  /** Gives the identity a person of its own again; the ones left keep theirs. */
18
24
  export declare const unlinkIdentity: (context: StoreContext, ref: IdentityRef, options: LinkOptions) => PersonRecord;
19
25
  /** Messages that name the person: by id where the messenger said so, or by `@username` in the text. */
@@ -1,5 +1,6 @@
1
1
  import { CliError } from "@wirecat/cli-base";
2
2
  import { and, eq, inArray, isNull, sql } from "./drizzle/core.js";
3
+ import { identityOf } from "./identities.js";
3
4
  import { resolvePersonLinks } from "./person-resolution.js";
4
5
  import { accountIdentities, accounts, chats, identities, identityLinkEvents, identityLinks, messages, persons, } from "./schema.js";
5
6
  import { hitsWhere } from "./search.js";
@@ -135,6 +136,32 @@ export const mergePerson = (context, from, to, options) => {
135
136
  for (const { identityPk } of together)
136
137
  move(context, identityPk, from, to, options);
137
138
  };
139
+ /** The owner once moved one of the two identities away from a person the other belonged to. */
140
+ const separatedByOwner = ({ database }, one, other) => database
141
+ .prepare(`SELECT 1 FROM identity_link_events moved
142
+ JOIN identity_link_events held ON held.to_person_id = moved.from_person_id
143
+ WHERE moved.author = 'owner' AND ((moved.identity_id = ? AND held.identity_id = ?)
144
+ OR (moved.identity_id = ? AND held.identity_id = ?))
145
+ LIMIT 1`)
146
+ .get(one, other, other, one) !== undefined;
147
+ /**
148
+ * Joins the identity to the person of the mail contact with this exact address, making that contact when mail has
149
+ * not seen it yet, so whichever import comes second lands on one person. The contact is seen by no account, so it is
150
+ * no one's mail correspondent. An owner's unlink wins: two identities the owner separated stay apart.
151
+ */
152
+ export const joinByAddress = (context, ref, address, by) => {
153
+ const key = address.trim().toLowerCase();
154
+ if (!/^[^\s@]+@[^\s@]+$/.test(key) || ref.provider === "email")
155
+ return;
156
+ const found = required(context, ref);
157
+ if (!identityRow(context, { provider: "email", id: key }))
158
+ identityOf(context, "email", key, null);
159
+ const contact = required(context, { provider: "email", id: key });
160
+ if (contact.personPk === found.personPk || separatedByOwner(context, found.pk, contact.pk))
161
+ return;
162
+ mergePerson(context, found.personPk, contact.personPk, { method: "same-email", by });
163
+ resolvePersonLinks(context.database, recordOf(context, contact.personPk).identities.flatMap(({ name, username }) => [name, username]));
164
+ };
138
165
  /** Gives the identity a person of its own again; the ones left keep theirs. */
139
166
  export const unlinkIdentity = (context, ref, options) => {
140
167
  const found = required(context, ref);
@@ -32,6 +32,8 @@ export interface PersonFacts {
32
32
  username?: string | null;
33
33
  isBot?: boolean | null;
34
34
  description?: string | null;
35
+ /** An address the provider shows for them: they become one person with the mail contact of that exact address. */
36
+ email?: string | null;
35
37
  }
36
38
  /** One identity in one messenger, by the messenger's own id. `provider` is any string: `email` is one. */
37
39
  export interface IdentityRef {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wirecat/cli-core",
3
- "version": "0.413.0",
3
+ "version": "0.414.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",