@wirecat/cli-core 0.449.0 → 0.451.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 (49) hide show
  1. package/CHANGELOG.md +64 -0
  2. package/README.md +7 -5
  3. package/dist/cli/command-availability.d.ts +2 -1
  4. package/dist/cli/command-availability.js +8 -5
  5. package/dist/cli/config-command.d.ts +3 -3
  6. package/dist/cli/config-command.js +2 -2
  7. package/dist/cli/early-refusal.d.ts +13 -0
  8. package/dist/cli/early-refusal.js +44 -2
  9. package/dist/cli/index.d.ts +1 -1
  10. package/dist/cli/index.js +1 -1
  11. package/dist/cli/mcp-server-command.js +9 -5
  12. package/dist/cli/messenger/mcp-command.d.ts +4 -1
  13. package/dist/cli/messenger/mcp-command.js +108 -12
  14. package/dist/cli/permission-keys.d.ts +21 -4
  15. package/dist/cli/permission-keys.js +31 -1
  16. package/dist/cli/preview.d.ts +2 -1
  17. package/dist/cli/preview.js +8 -6
  18. package/dist/cli/program.d.ts +3 -0
  19. package/dist/cli/program.js +10 -3
  20. package/dist/cli/settings.d.ts +9 -2
  21. package/dist/cli/settings.js +21 -6
  22. package/dist/cli/shared-command-exports.d.ts +7 -5
  23. package/dist/embeddings/embed.js +4 -21
  24. package/dist/embeddings/onnx-runtime.d.ts +14 -1
  25. package/dist/embeddings/onnx-runtime.js +50 -2
  26. package/dist/http/public-url.d.ts +5 -1
  27. package/dist/http/public-url.js +11 -3
  28. package/dist/images/picture/embedder.js +9 -8
  29. package/dist/mcp/agent.d.ts +5 -0
  30. package/dist/mcp/agent.js +7 -4
  31. package/dist/mcp/all-profiles.d.ts +47 -0
  32. package/dist/mcp/all-profiles.js +257 -0
  33. package/dist/mcp/http/oauth.d.ts +2 -2
  34. package/dist/mcp/http/oauth.js +8 -7
  35. package/dist/mcp/http/serve.d.ts +6 -1
  36. package/dist/mcp/http/serve.js +24 -7
  37. package/dist/mcp/index.d.ts +2 -1
  38. package/dist/mcp/index.js +2 -1
  39. package/dist/mcp/instructions.d.ts +2 -1
  40. package/dist/mcp/instructions.js +35 -29
  41. package/dist/mcp/server.js +3 -73
  42. package/dist/mcp/stdio-wire.d.ts +14 -0
  43. package/dist/mcp/stdio-wire.js +72 -0
  44. package/dist/sends/index.d.ts +1 -1
  45. package/dist/sends/index.js +1 -1
  46. package/dist/sends/permissions.d.ts +10 -2
  47. package/dist/sends/permissions.js +15 -7
  48. package/dist/store/sqlite/note-search.js +66 -22
  49. package/package.json +4 -4
package/CHANGELOG.md CHANGED
@@ -5,6 +5,70 @@ 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.451.0 — 27.10.2026
11
+
12
+ ### Added
13
+
14
+ - **Picture models run on the native ONNX Runtime too**, where text models do: on one machine CLIP embedded 12
15
+ pictures in 0.5 s against 1.1 s through WebAssembly, and text queries in 0.15 s against 0.26 s, with vectors at
16
+ cosine 0.99 or more to WebAssembly's, so pictures embedded before still match. `openSession` in
17
+ `src/embeddings/onnx-runtime.ts` is now the one place that picks the runtime and falls back per model.
18
+ - **Text models and rerankers run natively on Windows too** (x64 and arm64): `models download` fetches
19
+ `onnxruntime.dll` out of `onnxruntime-node`'s npm tarball (113.5 MB once, checked by sha256), because the Windows
20
+ binding needs its DirectML build, which Microsoft's release archive lacks. Needs `@wirecat/cli-core-onnx` 1.2.0.
21
+ A new native-runtime workflow downloads and runs the tiny test models natively on Linux x64 and arm64, macOS,
22
+ and Windows x64 and arm64, and checks that Alpine stays on WebAssembly.
23
+
24
+ ### Fixed
25
+
26
+ - **Meaning search over documents and notes uses the vector index**, as message search already did: it scanned every
27
+ chunk of the store in pages (on one archive 600,000 chunks, mostly mail, for 23,000 document chunks), 7–18 s a
28
+ query; now about 0.05 s, with the same results, ties included. It still scans when the index is not ready or cannot
29
+ give an exact answer. `memo search all` gets most of the gain.
30
+
31
+ ## 0.450.0 — 27.10.2026
32
+
33
+ ### Added
34
+
35
+ - **`<tool> mcp` with no profile serves every profile**, on stdio and with `--http`: one connector per tool in an AI
36
+ app instead of one per profile. Its tools are the ones `<tool> <profile> mcp` shows, each with a required `profile`
37
+ (every profile on this computer, no default), and the instructions tell the agent to ask when the owner did not name
38
+ the account. A call runs in that profile's own `<tool> <profile> mcp`, started as a child on first use and closed
39
+ after 10 idle minutes, so its permissions, keyring, store and audit log apply as before; `--permission` reaches
40
+ every child. `mcp --revoke`, `mcp config` and `mcp server start|stop|status` work without a profile too, with their
41
+ own login (`mcp-http/_all.json`). `<tool> <profile> mcp` is unchanged. drive and zoom wire the same module from
42
+ `@wirecat/cli-core/mcp`: `serveAllProfilesOverStdio`, `serveAllProfilesOverHttp`.
43
+ - **`mcp --http --public-url` takes a path**, `https://<name>.ts.net/tg`, so tools share one tunnel address with a
44
+ path each. The issuer, the login routes and the connector address (`…/tg/mcp`) carry the path; the issuer's
45
+ metadata is at `/.well-known/oauth-authorization-server/tg` and the resource's at
46
+ `/.well-known/oauth-protected-resource/tg/mcp`, where RFC 8414 and RFC 9728 put them, so the tunnel routes those
47
+ two paths to the server as well as `/tg` (with Tailscale: `--set-path <path> http://127.0.0.1:<port><path>` for
48
+ each). Without a path nothing changes.
49
+
50
+ - **A CLI that is not a messenger can use the shared `permissions`, keyed by its own command paths.** It passes
51
+ `settingsFor(app, { permissions: commandPermissions({ extra, defaults }) })` and hands that `configuration` to
52
+ `run`. Then `config set permissions.<key>` and an MCP server's `--permission` take its command paths, the groups
53
+ above them and its `extra` keys, such as `api.calendar.events.insert`, and refuse every other key. Before, they took
54
+ only a messenger's resources. Each command is refused before it runs when its key is `deny`, when it writes under
55
+ `readonly`, and when it writes to the service under the read-only lock. `ask` stays with the write guard
56
+ (`permissionDefaults`: `configuration.permissions.defaults`). MCP finds and refuses the command by the same key
57
+ when the server passes `keyOf: configuration.permissions.keyOf`. Messenger CLIs are unchanged. A CLI with a program
58
+ of its own, such as drive, uses the parts: `permissionOverrides(entries, COMMAND_PATH_KEY)`,
59
+ `assertOverridesTighten(overrides, saved, defaults)` from `./sends` and `knownPermissionKeys(program, permissions)`.
60
+ - **MCP judges a declared CLI's command as the check before a run does.** A server passes
61
+ `permissions: configuration.permissions` to `toolsFromCommands` and to the agent context, in place of `keyOf`. Then
62
+ a write to the service is unavailable under the read-only lock (`readOnly`), and the CLI's own `deny` or `readonly`
63
+ defaults make a command unavailable and refuse its call. Before, MCP offered both.
64
+
65
+ ### Changed — may break callers
66
+
67
+ - **cli-core needs `@wirecat/cli-base` 0.63.** The peer range moves from `>=0.62.0 <0.63.0` to `>=0.63.0 <0.64.0`. A
68
+ CLI that pins cli-base moves its pin with this release. cli-base 0.63 adds the shared Google Discovery reader
69
+ (`adaptDiscovery`, `syncDiscovery`, `readDiscovery` in `@wirecat/cli-base/codegen`) that drive and gcal generate their
70
+ API code with; nothing cli-core uses changed.
71
+
8
72
  ## 0.449.0 — 26.10.2026
9
73
 
10
74
  ### Changed — may break callers
package/README.md CHANGED
@@ -131,8 +131,8 @@ code and its document must agree. A search plan that reads an index once per mes
131
131
  - **Presets for what people actually look for.** `preset:password`, `api-key`, `card`, `phone`, `email`, `url` and
132
132
  seven more find candidates, labelled as candidates.
133
133
  - **Search by meaning, opt in.** Conversations are split into chunks and embedded locally by ONNX Runtime — native
134
- on Linux (glibc) and Apple-silicon Macs once `models download` has fetched it, WebAssembly elsewhere, on Node and
135
- Bun alike — e5-small by default, or any OpenAI-compatible API with your own key, after
134
+ on Linux (glibc), Apple-silicon Macs and Windows once `models download` has fetched it, WebAssembly elsewhere, on
135
+ Node and Bun alike — e5-small by default, or any OpenAI-compatible API with your own key, after
136
136
  you see the cost. Results by meaning and by words are merged by reciprocal rank fusion.
137
137
  - **The archive and the server together.** `--backend both` also asks the messenger's own search; its hits are saved
138
138
  and filtered by the same query, so nothing is counted twice. `--thread` brings the reply thread around a hit.
@@ -226,9 +226,11 @@ they deliberately differ. A rule changes in its own pull request, before the cod
226
226
  over an in-memory fake or a real client.
227
227
  - **Checked across tools and runtimes.** CI runs on Linux: the test suite on Node 24, smoke checks on Bun and on
228
228
  Node 22.16, a parity workflow that builds the Telegram, MAX and WhatsApp CLIs against this repository, and
229
- gitleaks over the full history. macOS and Windows run only three workflows: the SQLite library build (macOS) and
230
- the ONNX runtime probe (macOS and Windows) run monthly and when started by hand; the scheduler test (macOS and
231
- Windows) runs only when started by hand. The rest of the suite does not run on macOS or Windows.
229
+ gitleaks over the full history. macOS and Windows run only four workflows: the SQLite library build (macOS) and
230
+ the ONNX runtime probe (macOS and Windows) run monthly and when started by hand; the native-runtime check (Linux,
231
+ macOS and Windows, x64 and arm64, and Alpine) runs monthly and on every pull request that changes how models
232
+ load; the scheduler test (macOS and Windows) runs only when started by hand. The rest of the suite does not run on
233
+ macOS or Windows.
232
234
  - **One shared toolchain.** Biome, markdownlint, CSpell and Lefthook configs come from the
233
235
  [WireCat standards](https://github.com/WireCatLabs/community/blob/main/standards/README.md), and CI fails when a
234
236
  copy drifts.
@@ -1,5 +1,6 @@
1
1
  import type { CommandInfo } from "@wirecat/cli-base/commands";
2
2
  import { type Level, type PermissionKey } from "../sends/permissions.js";
3
+ import type { CommandPermissions } from "./permission-keys.js";
3
4
  export type AvailabilityCommand = Pick<CommandInfo, "path" | "effects" | "lifetime" | "reaches">;
4
5
  export interface DiscoveredCommand extends CommandInfo {
5
6
  availability?: CommandAvailability;
@@ -11,4 +12,4 @@ export interface CommandAvailability {
11
12
  permission?: string;
12
13
  }
13
14
  /** Discovery and execution use the same permission decision. */
14
- export declare const commandAvailability: (info: AvailabilityCommand, levels: Readonly<Record<PermissionKey, Level>>, keyOf?: (path: readonly string[]) => PermissionKey | null | undefined, authority?: (path: readonly string[]) => boolean, readOnly?: boolean, mcp?: boolean) => CommandAvailability;
15
+ export declare const commandAvailability: (info: AvailabilityCommand, levels: Readonly<Record<PermissionKey, Level>>, keyOf?: (path: readonly string[]) => PermissionKey | null | undefined, authority?: (path: readonly string[]) => boolean, readOnly?: boolean, mcp?: boolean, declared?: CommandPermissions) => CommandAvailability;
@@ -2,24 +2,27 @@ import { writes } from "@wirecat/cli-base/standards";
2
2
  import { AUTHORITY_KEY, authorityAllowed, BOT_API_KEY, botApiAllowed, isBotApi, keyForCommand, levelFor, readKeysForCommand, } from "../sends/permissions.js";
3
3
  import { changesMessenger } from "./effects.js";
4
4
  /** Discovery and execution use the same permission decision. */
5
- export const commandAvailability = (info, levels, keyOf, authority, readOnly = false, mcp = true) => {
5
+ export const commandAvailability = (info, levels, keyOf, authority, readOnly = false, mcp = true, declared) => {
6
6
  const blocked = (reason, permission) => ({
7
7
  available: false,
8
8
  reason,
9
9
  ...(permission ? { permission } : {}),
10
10
  });
11
- if (readOnly && (changesMessenger(info) || authority?.(info.path)))
11
+ const declaredKey = declared?.keyOf(info.path);
12
+ // As `refuseByCommandPermissions` judges it before the run: a keyed write to the service.
13
+ const declaredChange = Boolean(declaredKey) && info.effects?.remote === "write";
14
+ if (readOnly && (changesMessenger(info) || declaredChange || authority?.(info.path)))
12
15
  return blocked("the read-only lock blocks this change");
13
16
  if (mcp && authority?.(info.path) && !authorityAllowed(levels))
14
17
  return blocked(`the profile keeps ${AUTHORITY_KEY} off`, AUTHORITY_KEY);
15
18
  if (mcp && isBotApi(info.path) && !botApiAllowed(levels))
16
19
  return blocked(`the profile requires ${BOT_API_KEY}: allow`, BOT_API_KEY);
17
- const key = keyOf?.(info.path) ?? keyForCommand(info.path);
18
- const level = key ? levelFor(levels, key).level : "allow";
20
+ const key = declared ? declaredKey : (keyOf?.(info.path) ?? keyForCommand(info.path));
21
+ const level = key ? levelFor(levels, key, declared?.defaults).level : "allow";
19
22
  if (level === "deny")
20
23
  return blocked(`the profile denies ${key}`, key ?? undefined);
21
24
  if (level === "readonly" && writes(info.effects))
22
25
  return blocked(`${key} is readonly in this profile`, key ?? undefined);
23
- const denied = readKeysForCommand(info.path).find((read) => levelFor(levels, read).level === "deny");
26
+ const denied = (declared ? [] : readKeysForCommand(info.path)).find((read) => levelFor(levels, read).level === "deny");
24
27
  return denied ? blocked(`the profile denies ${denied}`, denied) : { available: true };
25
28
  };
@@ -1,12 +1,12 @@
1
1
  import { Command } from "commander";
2
2
  import { type AppIdentity } from "../ports/app.js";
3
- import { type PermissionKeyOf } from "./permission-keys.js";
3
+ import { type PermissionKeySource } from "./permission-keys.js";
4
4
  import { type Configuration } from "./settings.js";
5
5
  export declare const configCommand: (app: AppIdentity, config: Configuration, { permissionKey }?: {
6
- permissionKey?: PermissionKeyOf;
6
+ permissionKey?: PermissionKeySource | undefined;
7
7
  }) => Command;
8
8
  /**
9
9
  * A key no command or write is checked against would be saved and do nothing (BUG-137) — whether it
10
10
  * comes as `permissions.<key>` or inside a whole `permissions` object.
11
11
  */
12
- export declare const refuseUnknownKey: (command: Command, setting: string, value: string, permissionKey?: PermissionKeyOf) => void;
12
+ export declare const refuseUnknownKey: (command: Command, setting: string, value: string, permissionKey?: PermissionKeySource) => void;
@@ -6,7 +6,7 @@ import { AUTHORITY_KEY, BOT_API_KEY } from "../sends/permissions.js";
6
6
  import { baseContext, environmentOf } from "./context.js";
7
7
  import { grantsAuthority, LOCAL_READ, LOCAL_WRITE } from "./effects.js";
8
8
  import { itemOf, renderAnswer } from "./envelope.js";
9
- import { knownBeside, knownPermissionKeys, unknownPermissionIssues } from "./permission-keys.js";
9
+ import { knownBeside, knownPermissionKeys, unknownPermissionIssues, } from "./permission-keys.js";
10
10
  import { fromFile, NONE_NAMED } from "./settings.js";
11
11
  import { nextStep } from "./steps.js";
12
12
  import { changeStoreSetting, isStoreSetting, STORE_SETTINGS, storeSettings } from "./store-settings.js";
@@ -16,7 +16,7 @@ import { changeStoreSetting, isStoreSetting, STORE_SETTINGS, storeSettings } fro
16
16
  */
17
17
  /** `sender_colors` colours who wrote a message; a CLI without messages has nothing to colour. */
18
18
  const shownSettings = (app, config) => app.messenger === false ? config.allSettings.filter((setting) => setting !== "sender_colors") : config.allSettings;
19
- export const configCommand = (app, config, { permissionKey } = {}) => {
19
+ export const configCommand = (app, config, { permissionKey = config.permissions } = {}) => {
20
20
  const command = new Command("config").description("the settings in force, and where each one came from");
21
21
  annotate(command.command("show"), { effects: LOCAL_READ, examples: [`${app.command} personal config show`] })
22
22
  .description("the profile, the profiles that exist, and each setting with where it came from")
@@ -1,5 +1,6 @@
1
1
  import type { Command } from "commander";
2
2
  import { type AppIdentity } from "../ports/app.js";
3
+ import { type CommandPermissions } from "./permission-keys.js";
3
4
  type Resolve = (flags: object, options: {
4
5
  env: NodeJS.ProcessEnv;
5
6
  needsProfile: false;
@@ -17,4 +18,16 @@ export declare const refuseWriteBeforeConnecting: (action: Command, { app, env,
17
18
  dryRun: boolean;
18
19
  resolve?: Resolve;
19
20
  }) => void;
21
+ /**
22
+ * A command of a CLI that declares its own keys (`commandPermissions`), refused by its key before anything connects:
23
+ * `deny` stops it, `readonly` stops a write, and the read-only lock stops a write to the service. `ask` is left to
24
+ * the write guard, which can put the question. A saved or `--permission` key that names no command stops every keyed
25
+ * command; housekeeping still runs, so `config unset` can remove it.
26
+ */
27
+ export declare const refuseByCommandPermissions: (action: Command, permissions: CommandPermissions, { app, env, dryRun, resolve }: {
28
+ app: AppIdentity;
29
+ env: NodeJS.ProcessEnv;
30
+ dryRun: boolean;
31
+ resolve: Resolve;
32
+ }) => void;
20
33
  export {};
@@ -1,10 +1,15 @@
1
+ import { CliError } from "@wirecat/cli-base";
2
+ import { metaOf } from "@wirecat/cli-base/commands";
3
+ import { writes } from "@wirecat/cli-base/standards";
1
4
  import { MESSENGER_CHANGE_REFUSAL, readOnlyLockOf, readOnlyRefusal } from "../ports/app.js";
2
- import { levelRefusal } from "../sends/guard.js";
5
+ import { ceilingRefusal, levelRefusal } from "../sends/guard.js";
3
6
  import { SendJournal, sendsPathFor } from "../sends/journal.js";
4
- import { keyForCommand, levelFor, writeOfKey } from "../sends/permissions.js";
7
+ import { CEILING_KEY, keyForCommand, levelFor, writeOfKey } from "../sends/permissions.js";
5
8
  import { newOperationId } from "../sends/send-id.js";
6
9
  import { commandPathOf } from "./command-contract.js";
10
+ import { environmentOf } from "./context.js";
7
11
  import { changesMessenger } from "./effects.js";
12
+ import { refuseUnknownPermissionKeys } from "./permission-keys.js";
8
13
  import { NONE_NAMED, settingsFor } from "./settings.js";
9
14
  /**
10
15
  * Refuses a change on the messenger the read-only lock or the profile's level stops, before anything connects
@@ -70,3 +75,40 @@ export const refuseWriteBeforeConnecting = (action, { app, env, dryRun, resolve
70
75
  }
71
76
  throw refusal;
72
77
  };
78
+ /**
79
+ * A command of a CLI that declares its own keys (`commandPermissions`), refused by its key before anything connects:
80
+ * `deny` stops it, `readonly` stops a write, and the read-only lock stops a write to the service. `ask` is left to
81
+ * the write guard, which can put the question. A saved or `--permission` key that names no command stops every keyed
82
+ * command; housekeeping still runs, so `config unset` can remove it.
83
+ */
84
+ export const refuseByCommandPermissions = (action, permissions, { app, env, dryRun, resolve }) => {
85
+ const key = permissions.keyOf(commandPathOf(action));
86
+ if (!key)
87
+ return;
88
+ const { effects } = metaOf(action);
89
+ const lock = readOnlyLockOf(app, env);
90
+ if (lock && effects?.remote === "write")
91
+ throw readOnlyRefusal(lock, `run ${commandPathOf(action).join(" ")}`);
92
+ const flags = action.optsWithGlobals();
93
+ const overrides = environmentOf(action).agent?.overrides ?? [];
94
+ const settings = resolve(overrides.length > 0 ? { ...flags, permission: [...overrides, ...(flags.permission ?? [])] } : flags, { env, needsProfile: false, readOnly: true });
95
+ refuseUnknownPermissionKeys(action, settings, app.command, permissions);
96
+ if (dryRun || settings.sources?.profile === NONE_NAMED)
97
+ return;
98
+ const { level, key: named } = levelFor(settings.permissions, key, permissions.defaults);
99
+ if (level === "deny") {
100
+ if (named === CEILING_KEY)
101
+ throw ceilingRefusal(key, app.command);
102
+ throw new CliError("permission_error", `profile ${settings.profile} denies ${key} (permissions.${named} is deny, from the ` +
103
+ `${settings.permissionSources[named ?? ""] ?? "default"})`, { permission: key });
104
+ }
105
+ if (level === "readonly" && effects !== undefined && writes(effects))
106
+ throw levelRefusal({
107
+ profile: settings.profile,
108
+ command: app.command,
109
+ key,
110
+ level,
111
+ named,
112
+ permissionSources: settings.permissionSources,
113
+ });
114
+ };
@@ -103,7 +103,7 @@ export { storeSummary } from "./messenger/store-maintenance-command.js";
103
103
  export { type Saving, stored } from "./messenger/stored.js";
104
104
  export { MODEL_CATALOGUE_QUERY, type ModelCatalogueOptions } from "./model-catalogue-command.js";
105
105
  export { window, withPaging } from "./paging.js";
106
- export { knownBeside, knownPermissionKeys, type PermissionKeyOf, unknownPermissionIssues, unknownPermissionKeys, } from "./permission-keys.js";
106
+ export { type CommandPermissions, commandPathKey, commandPermissions, knownBeside, knownPermissionKeys, type PermissionKeyOf, type PermissionKeySource, unknownPermissionIssues, unknownPermissionKeys, } from "./permission-keys.js";
107
107
  export { asFirstWord, commandWords, liftProfile, profileFree, refuseCommandName, rootOf, takesProfile, usableProfileName, } from "./profile.js";
108
108
  export { createProgram, type ProgramDefinition, type ProgramOptions, type RunOptions, run } from "./program.js";
109
109
  export { fieldsOf, projectFields } from "./result-fields.js";
package/dist/cli/index.js CHANGED
@@ -94,7 +94,7 @@ export { storeSummary } from "./messenger/store-maintenance-command.js";
94
94
  export { stored } from "./messenger/stored.js";
95
95
  export { MODEL_CATALOGUE_QUERY } from "./model-catalogue-command.js";
96
96
  export { window, withPaging } from "./paging.js";
97
- export { knownBeside, knownPermissionKeys, unknownPermissionIssues, unknownPermissionKeys, } from "./permission-keys.js";
97
+ export { commandPathKey, commandPermissions, knownBeside, knownPermissionKeys, unknownPermissionIssues, unknownPermissionKeys, } from "./permission-keys.js";
98
98
  export { asFirstWord, commandWords, liftProfile, profileFree, refuseCommandName, rootOf, takesProfile, usableProfileName, } from "./profile.js";
99
99
  export { createProgram, run } from "./program.js";
100
100
  export { fieldsOf, projectFields } from "./result-fields.js";
@@ -4,7 +4,7 @@ import { Command } from "commander";
4
4
  import { alive, carries } from "../background/processes.js";
5
5
  import { thisMachine } from "../background/system.js";
6
6
  import { readableLog, tail } from "../background/units.js";
7
- import { portOf, publicUrlOf } from "../http/public-url.js";
7
+ import { mcpUrlOf, portOf, publicUrlOf } from "../http/public-url.js";
8
8
  import { httpLockFile, httpLogFile, mcpHttpMarker, readHttpLock, signedInClients, } from "../mcp/http/state.js";
9
9
  import { destroysNothing, LOCAL_READ, LOCAL_WRITE } from "./effects.js";
10
10
  export const MCP_HTTP_DEFAULT_PORT = 8765;
@@ -14,7 +14,7 @@ const clock = (iso) => {
14
14
  const time = when.toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit" });
15
15
  return when.toDateString() === new Date().toDateString() ? time : `${when.toLocaleDateString("sv-SE")} ${time}`;
16
16
  };
17
- const connectorUrl = (lock) => new URL("/mcp", lock.publicUrl).href;
17
+ const connectorUrl = (lock) => mcpUrlOf(new URL(lock.publicUrl)).href;
18
18
  const liveCode = (lock, now) => lock.loginCode && lock.loginCodeExpiresAt && Date.parse(lock.loginCodeExpiresAt) > now
19
19
  ? { code: lock.loginCode, expires_at: lock.loginCodeExpiresAt }
20
20
  : null;
@@ -58,13 +58,17 @@ export const mcpServerCommand = ({ example, defaultPort = MCP_HTTP_DEFAULT_PORT,
58
58
  .summary("start the HTTP MCP server in the background")
59
59
  .description("start `mcp --http` apart from this shell and answer once it listens, with its login code")
60
60
  .option("--port <port>", `the local port (default ${defaultPort})`)
61
- .option("--public-url <url>", "the tunnel's https address the browser apps use, e.g. https://<name>.ts.net")
61
+ .option("--public-url <url>", "the tunnel's https address the browser apps use, e.g. https://<name>.ts.net or https://<name>.ts.net/tg")
62
62
  .option("--permission <key=level>", "make a permission stricter for this server only; repeat for more keys", (value, previous = []) => [...previous, value])
63
63
  .action(async function () {
64
64
  // A CLI whose `mcp` takes the same flags reads them first, so they are looked up on both.
65
65
  const flags = this.optsWithGlobals();
66
66
  const one = target(this);
67
- const url = publicUrlOf(flags.publicUrl, { missing: "mcp server start", reaches: "the browser apps reach it" });
67
+ const url = publicUrlOf(flags.publicUrl, {
68
+ missing: "mcp server start",
69
+ reaches: "the browser apps reach it",
70
+ path: true,
71
+ });
68
72
  const port = portOf(flags.port, defaultPort);
69
73
  const lockFile = httpLockFile(one.tokenFile);
70
74
  const before = readHttpLock(lockFile);
@@ -82,7 +86,7 @@ export const mcpServerCommand = ({ example, defaultPort = MCP_HTTP_DEFAULT_PORT,
82
86
  "--port",
83
87
  String(port),
84
88
  "--public-url",
85
- url.origin,
89
+ url.pathname === "/" ? url.origin : url.href,
86
90
  ...(flags.permission ?? []).flatMap((entry) => ["--permission", entry]),
87
91
  ], { ...inherited, ...one.childEnv, [mcpHttpMarker(one.envPrefix)]: one.profile ?? "default" }, log);
88
92
  for (let look = 0; look < LOOKS; look++) {
@@ -19,6 +19,8 @@ export interface McpFlags {
19
19
  publicUrl?: string;
20
20
  revoke?: boolean;
21
21
  }
22
+ /** The token file of `<tool> mcp --http` for every profile: no profile name can start with `_`. */
23
+ export declare const ALL_PROFILES = "_all";
22
24
  /** Where `mcp --http` keeps the hashes of the tokens it issued — per profile, in the state folder. */
23
25
  export declare const httpTokenFile: (app: AppIdentity, profile: string, env: NodeJS.ProcessEnv) => string;
24
26
  export declare const mcpCommand: (messenger: Messenger) => Command;
@@ -42,7 +44,8 @@ export declare const redactTail: (text: string, home: string | undefined) => str
42
44
  * servers with a trimmed environment leaves it out (measured 2026-09-28). Credentials are never copied.
43
45
  */
44
46
  export declare const serverEntry: (app: AppIdentity, { profile, flags, execPath, scriptPath, env, }: {
45
- profile: string;
47
+ /** Left out, the entry is `<tool> mcp`, the server of every profile. */
48
+ profile?: string;
46
49
  flags?: McpFlags;
47
50
  execPath: string;
48
51
  scriptPath: string;
@@ -6,7 +6,7 @@ import { annotate } from "@wirecat/cli-base/commands";
6
6
  import { installerOf } from "@wirecat/cli-base/update";
7
7
  import { Command } from "commander";
8
8
  import { ATTACHMENT_LIMIT_ENV } from "../../attachments/limits.js";
9
- import { portOf, publicUrlOf } from "../../http/public-url.js";
9
+ import { mcpUrlOf, portOf, publicUrlOf } from "../../http/public-url.js";
10
10
  import { httpLockFile } from "../../mcp/http/state.js";
11
11
  import { envName } from "../../ports/app.js";
12
12
  import { environmentOf } from "../context.js";
@@ -14,10 +14,13 @@ import { grantsAuthority, LOCAL_READ, LOCAL_WRITE, REMOTE_WRITE } from "../effec
14
14
  import { itemOf, renderAnswer } from "../envelope.js";
15
15
  import { MCP_HTTP_DEFAULT_PORT, mcpServerCommand } from "../mcp-server-command.js";
16
16
  import { rootOf } from "../profile.js";
17
+ import { namedProfile } from "../settings.js";
17
18
  import { nextStep } from "../steps.js";
18
19
  import { recalledAccount } from "./accounts.js";
19
20
  import { messengerContext } from "./context.js";
20
21
  const DEFAULT_PORT = MCP_HTTP_DEFAULT_PORT;
22
+ /** The token file of `<tool> mcp --http` for every profile: no profile name can start with `_`. */
23
+ export const ALL_PROFILES = "_all";
21
24
  /** Where `mcp --http` keeps the hashes of the tokens it issued — per profile, in the state folder. */
22
25
  export const httpTokenFile = (app, profile, env) => join(resolvePaths({ appName: app.appName, prefix: app.envPrefix, env }).state, "mcp-http", `${profile}.json`);
23
26
  const withFlags = (command) => command.option("--permission <key=level>", "make a permission stricter for this server only, like messages.send=deny; it never loosens the profile; repeat for more keys", (value, previous = []) => [...previous, value]);
@@ -25,14 +28,16 @@ export const mcpCommand = (messenger) => {
25
28
  const { app } = messenger;
26
29
  const command = withFlags(annotate(new Command("mcp"), { effects: REMOTE_WRITE, lifetime: "persistent", mcp: false })
27
30
  .summary("serve this profile's commands to an AI agent")
28
- .description(`serve this profile to an agent over MCP, on stdin and stdout — \`claude mcp add ${app.command} -- ${app.command} <profile> mcp\``))
31
+ .description(`serve this profile to an agent over MCP, on stdin and stdout — \`claude mcp add ${app.command} -- ${app.command} <profile> mcp\`; with no profile, \`${app.command} mcp\` serves every profile and each tool asks which`))
29
32
  .option("--http", "serve over HTTP on 127.0.0.1 for ChatGPT and Claude in the browser, behind your tunnel; the profile's permissions decide")
30
33
  .option("--port <port>", `the local port for --http (default ${DEFAULT_PORT})`)
31
- .option("--public-url <url>", "the tunnel's https address the browser apps use, e.g. https://<name>.ts.net")
34
+ .option("--public-url <url>", "the tunnel's https address the browser apps use, e.g. https://<name>.ts.net or https://<name>.ts.net/tg")
32
35
  .option("--revoke", "forget every login given to a browser app; each must log in again");
33
36
  command.action(async function () {
34
37
  const flags = this.optsWithGlobals();
35
- const context = messengerContext(this, messenger);
38
+ const context = messengerContext(this, messenger, { needsProfile: false });
39
+ if (namedProfile(context.settings) === undefined)
40
+ return serveEveryProfile(this, context, messenger, flags);
36
41
  if (flags.revoke) {
37
42
  const { revokeAll } = await import("../../mcp/http/oauth.js");
38
43
  revokeAll(httpTokenFile(app, context.settings.profile, context.env));
@@ -43,6 +48,7 @@ export const mcpCommand = (messenger) => {
43
48
  const publicUrl = publicUrlOf(flags.publicUrl, {
44
49
  missing: "--http",
45
50
  reaches: `the browser apps reach ${app.command}`,
51
+ path: true,
46
52
  });
47
53
  const { serveOverHttpUntilStopped } = await import("../../mcp/server.js");
48
54
  const tokenFile = httpTokenFile(app, context.settings.profile, context.env);
@@ -62,7 +68,16 @@ export const mcpCommand = (messenger) => {
62
68
  command.addCommand(mcpServerCommand({
63
69
  example: `${app.command} personal`,
64
70
  target: (self) => {
65
- const { settings, env } = messengerContext(self, messenger);
71
+ const { settings, env } = messengerContext(self, messenger, { needsProfile: false });
72
+ if (namedProfile(settings) === undefined)
73
+ return {
74
+ invocation: app.command,
75
+ envPrefix: app.envPrefix,
76
+ env,
77
+ tokenFile: httpTokenFile(app, ALL_PROFILES, env),
78
+ argv: ["mcp", "--http"],
79
+ version: app.version,
80
+ };
66
81
  return {
67
82
  invocation: `${app.command} ${settings.profile}`,
68
83
  profile: settings.profile,
@@ -74,21 +89,22 @@ export const mcpCommand = (messenger) => {
74
89
  version: app.version,
75
90
  };
76
91
  },
77
- answer: (self, item, lines) => renderAnswer(self, messengerContext(self, messenger), itemOf(item), () => lines.join("\n")),
92
+ answer: (self, item, lines) => renderAnswer(self, messengerContext(self, messenger, { needsProfile: false }), itemOf(item), () => lines.join("\n")),
78
93
  system: (self) => environmentOf(self).system,
79
94
  }));
80
95
  command.addCommand(withFlags(annotate(new Command("config"), {
81
96
  effects: LOCAL_READ,
82
97
  examples: [`${app.command} personal mcp config`],
83
98
  })
84
- .summary("print this profile's local MCP configuration")
99
+ .summary("print the local MCP configuration, of this profile or of every profile")
85
100
  .description("print the mcpServers entry for Claude Desktop, Cursor and others, with full paths; writes nothing")).action(async function () {
86
101
  const flags = this.optsWithGlobals();
87
- const context = messengerContext(this, messenger);
102
+ const context = messengerContext(this, messenger, { needsProfile: false });
88
103
  const { settings, env } = context;
89
104
  const given = environmentOf(this).mcp ?? {};
105
+ const named = namedProfile(settings);
90
106
  const entry = serverEntry(app, {
91
- profile: settings.profile,
107
+ ...(named === undefined ? {} : { profile: named }),
92
108
  flags,
93
109
  execPath: given.execPath ?? process.execPath,
94
110
  scriptPath: given.scriptPath ?? realpathSync(process.argv[1] ?? ""),
@@ -177,6 +193,81 @@ export const mcpCommand = (messenger) => {
177
193
  }));
178
194
  return command;
179
195
  };
196
+ /**
197
+ * `<tool> mcp` with no profile: every profile on this computer, each call in that profile's own `<tool> <profile> mcp`
198
+ * (`src/mcp/all-profiles.ts`). Nothing picks a profile: each tool takes one, and the agent is told to ask.
199
+ */
200
+ const serveEveryProfile = async (self, context, messenger, flags) => {
201
+ const { app } = messenger;
202
+ const { env } = context;
203
+ const tokenFile = httpTokenFile(app, ALL_PROFILES, env);
204
+ if (flags.revoke) {
205
+ const { revokeAll } = await import("../../mcp/http/oauth.js");
206
+ revokeAll(tokenFile);
207
+ renderAnswer(self, context, itemOf({ revoked: true, profile: null }));
208
+ return;
209
+ }
210
+ const profiles = () => messenger.resolveSettings(self.optsWithGlobals(), { env, needsProfile: false, readOnly: true }).profilesHere;
211
+ if (profiles().length === 0)
212
+ throw new CliError("configuration_error", `no ${app.command} profile on this computer yet — set one up, then run \`${app.command} mcp\` again`);
213
+ const given = environmentOf(self).mcp ?? {};
214
+ const execPath = given.execPath ?? process.execPath;
215
+ const scriptPath = given.scriptPath ?? realpathSync(process.argv[1] ?? "");
216
+ const tightened = (flags.permission ?? []).flatMap((entry) => ["--permission", entry]);
217
+ const { instructions } = await import("../../mcp/instructions.js");
218
+ const options = {
219
+ command: app.command,
220
+ version: app.version,
221
+ name: messenger.name ?? app.command,
222
+ profiles,
223
+ child: (profile) => ({ command: execPath, args: [scriptPath, profile, "mcp", ...tightened], env }),
224
+ instructions: instructions({
225
+ command: app.command,
226
+ name: messenger.name ?? app.command,
227
+ profile: profiles(),
228
+ writes: [],
229
+ }),
230
+ note: (line) => context.renderer.note(line),
231
+ };
232
+ if (flags.http) {
233
+ const publicUrl = publicUrlOf(flags.publicUrl, {
234
+ missing: "--http",
235
+ reaches: `the browser apps reach ${app.command}`,
236
+ path: true,
237
+ });
238
+ const { serveAllProfilesOverHttp } = await import("../../mcp/all-profiles.js");
239
+ const { mcpHttpMarker } = await import("../../mcp/http/state.js");
240
+ const port = portOf(flags.port, DEFAULT_PORT);
241
+ const listening = await serveAllProfilesOverHttp(options, {
242
+ publicUrl,
243
+ port,
244
+ tokenFile,
245
+ lockFile: httpLockFile(tokenFile),
246
+ version: app.version,
247
+ onCode: (code, expires) => context.renderer.note(env[mcpHttpMarker(app.envPrefix)] === undefined
248
+ ? `login code for a new browser app: ${code} (until ${expires.toTimeString().slice(0, 5)}; a new one after each login)`
249
+ : `a new login code for a browser app: \`${app.command} mcp server status\``),
250
+ }).catch((error) => {
251
+ if (error.code === "EADDRINUSE")
252
+ throw new CliError("configuration_error", `port ${port} is in use — pass another with --port`);
253
+ throw error;
254
+ });
255
+ context.renderer.note(`serving every profile on ${listening.url.href} — point your tunnel at it; connectors use ${mcpUrlOf(publicUrl).href}`);
256
+ await new Promise((resolve) => {
257
+ process.once("SIGINT", resolve);
258
+ process.once("SIGTERM", resolve);
259
+ });
260
+ await listening.close();
261
+ return;
262
+ }
263
+ const { serveAllProfilesOverStdio } = await import("../../mcp/all-profiles.js");
264
+ const { stdin, stdout, signal } = environmentOf(self);
265
+ await serveAllProfilesOverStdio(options, {
266
+ ...(stdin ? { stdin: stdin } : {}),
267
+ ...(stdout ? { stdout: stdout } : {}),
268
+ ...(signal ? { signal } : {}),
269
+ });
270
+ };
180
271
  const TAIL_LINES = 20;
181
272
  const TAIL_BYTES = 2_000;
182
273
  const TAIL_WAIT_MS = 500;
@@ -230,7 +321,7 @@ const VERSION_MANAGER = /[\\/](\.nvm|nvm|\.fnm|fnm|fnm_multishells|\.volta|volta
230
321
  export const serverEntry = (app, { profile, flags = {}, execPath, scriptPath, env, }) => {
231
322
  if (installerOf(scriptPath) === "npx") {
232
323
  throw new CliError("validation_error", `this ${app.command} runs from npx's cache, which gets cleared, and the path would stop working — ` +
233
- `install it globally, then run \`${app.command} ${profile} mcp config\` again`);
324
+ `install it globally, then run \`${app.command} ${profile === undefined ? "" : `${profile} `}mcp config\` again`);
234
325
  }
235
326
  const names = [
236
327
  ...["CONFIG_DIR", "STATE_DIR", "CACHE_DIR"].map((name) => envName(app, name)),
@@ -242,11 +333,16 @@ export const serverEntry = (app, { profile, flags = {}, execPath, scriptPath, en
242
333
  const server = {
243
334
  type: "stdio",
244
335
  command: execPath,
245
- args: [scriptPath, profile, "mcp", ...(flags.permission ?? []).flatMap((entry) => ["--permission", entry])],
336
+ args: [
337
+ scriptPath,
338
+ ...(profile === undefined ? [] : [profile]),
339
+ "mcp",
340
+ ...(flags.permission ?? []).flatMap((entry) => ["--permission", entry]),
341
+ ],
246
342
  ...(Object.keys(directories).length > 0 ? { env: directories } : {}),
247
343
  };
248
344
  return {
249
- config: { mcpServers: { [`${app.command}-${profile}`]: server } },
345
+ config: { mcpServers: { [profile === undefined ? app.command : `${app.command}-${profile}`]: server } },
250
346
  /** The path belongs to one Node version of a version manager; switching Node breaks the entry. */
251
347
  nodeBound: VERSION_MANAGER.test(execPath),
252
348
  };
@@ -1,20 +1,37 @@
1
1
  import type { Command } from "commander";
2
- import { type PermissionKey } from "../sends/permissions.js";
2
+ import { type Level, type PermissionKey } from "../sends/permissions.js";
3
3
  import { type ConfigIssue } from "./config-issues.js";
4
4
  import type { Settings } from "./settings.js";
5
5
  export type PermissionKeyOf = (command: Command) => PermissionKey | null | undefined;
6
+ /**
7
+ * A CLI that is not a messenger names its own keys: each command is checked against its own path, and the
8
+ * messenger's resources and write keys mean nothing to it (drive's model). `settingsFor(app, { permissions })` takes it.
9
+ */
10
+ export interface CommandPermissions {
11
+ /** The key a command path is checked against; `null` for housekeeping, which no level stops. */
12
+ keyOf: (path: readonly string[]) => PermissionKey | null;
13
+ /** Keys a command checks inside itself, beyond its own path: `api.calendar.events.insert`. */
14
+ extra: readonly PermissionKey[];
15
+ /** cli-core's built-in levels and the CLI's own; they only ever tighten. */
16
+ defaults: Readonly<Record<PermissionKey, Level>>;
17
+ }
18
+ /** Either way of naming a program's keys: a messenger's per-command mapping, or a declared CLI's own. */
19
+ export type PermissionKeySource = PermissionKeyOf | CommandPermissions;
20
+ /** A command's own path is its key; the commands that look after the tool have none. */
21
+ export declare const commandPathKey: (path: readonly string[]) => PermissionKey | null;
22
+ export declare const commandPermissions: ({ keyOf, extra, defaults, }?: Partial<CommandPermissions>) => CommandPermissions;
6
23
  /**
7
24
  * The keys `permissions` may name: every command of the program, every write key, and each of their
8
25
  * parents. A key outside it is a typo that would otherwise do nothing (BUG-137).
9
26
  */
10
- export declare const knownPermissionKeys: (command: Command, keyOf?: PermissionKeyOf, extra?: readonly PermissionKey[]) => Set<PermissionKey>;
27
+ export declare const knownPermissionKeys: (command: Command, keyOf?: PermissionKeySource, extra?: readonly PermissionKey[]) => Set<PermissionKey>;
11
28
  /** The known keys one level under the unknown key's parent, to name in the refusal. */
12
29
  export declare const knownBeside: (key: PermissionKey, known: ReadonlySet<PermissionKey>) => PermissionKey[];
13
30
  export declare const unknownPermissionKeys: (levels: Readonly<Record<PermissionKey, unknown>>, known: ReadonlySet<PermissionKey>) => PermissionKey[];
14
31
  /** Every unknown permission key saved in the file, in every section and profile, with the unset that removes it. */
15
- export declare const unknownPermissionIssues: (command: Command, settings: Pick<Settings, "configPath" | "savedPermissions" | "permissions" | "permissionSources" | "profile">, tool: string, keyOf?: PermissionKeyOf) => ConfigIssue[];
32
+ export declare const unknownPermissionIssues: (command: Command, settings: Pick<Settings, "configPath" | "savedPermissions" | "permissions" | "permissionSources" | "profile">, tool: string, keyOf?: PermissionKeySource) => ConfigIssue[];
16
33
  /**
17
34
  * A saved key no command checks would do nothing, so the command refuses to run and names the unset that removes
18
35
  * it. A `--permission` key is checked too: it is no file's, so the flag is the fix.
19
36
  */
20
- export declare const refuseUnknownPermissionKeys: (command: Command, settings: Pick<Settings, "configPath" | "savedPermissions" | "permissions" | "permissionSources" | "profile">, tool: string, keyOf?: PermissionKeyOf) => void;
37
+ export declare const refuseUnknownPermissionKeys: (command: Command, settings: Pick<Settings, "configPath" | "savedPermissions" | "permissions" | "permissionSources" | "profile">, tool: string, keyOf?: PermissionKeySource) => void;