@wirecat/cli-core 0.448.0 → 0.450.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 (43) hide show
  1. package/CHANGELOG.md +50 -1
  2. package/dist/cli/command-availability.d.ts +2 -1
  3. package/dist/cli/command-availability.js +8 -5
  4. package/dist/cli/config-command.d.ts +3 -3
  5. package/dist/cli/config-command.js +2 -2
  6. package/dist/cli/early-refusal.d.ts +13 -0
  7. package/dist/cli/early-refusal.js +44 -2
  8. package/dist/cli/index.d.ts +1 -1
  9. package/dist/cli/index.js +1 -1
  10. package/dist/cli/mcp-server-command.js +9 -5
  11. package/dist/cli/messenger/mcp-command.d.ts +4 -1
  12. package/dist/cli/messenger/mcp-command.js +108 -12
  13. package/dist/cli/permission-keys.d.ts +21 -4
  14. package/dist/cli/permission-keys.js +31 -1
  15. package/dist/cli/preview.d.ts +2 -1
  16. package/dist/cli/preview.js +8 -6
  17. package/dist/cli/program.d.ts +3 -0
  18. package/dist/cli/program.js +10 -3
  19. package/dist/cli/settings.d.ts +9 -2
  20. package/dist/cli/settings.js +21 -6
  21. package/dist/cli/shared-command-exports.d.ts +7 -5
  22. package/dist/http/public-url.d.ts +5 -1
  23. package/dist/http/public-url.js +11 -3
  24. package/dist/mcp/agent.d.ts +5 -0
  25. package/dist/mcp/agent.js +7 -4
  26. package/dist/mcp/all-profiles.d.ts +47 -0
  27. package/dist/mcp/all-profiles.js +257 -0
  28. package/dist/mcp/http/oauth.d.ts +2 -2
  29. package/dist/mcp/http/oauth.js +8 -7
  30. package/dist/mcp/http/serve.d.ts +6 -1
  31. package/dist/mcp/http/serve.js +24 -7
  32. package/dist/mcp/index.d.ts +2 -1
  33. package/dist/mcp/index.js +2 -1
  34. package/dist/mcp/instructions.d.ts +2 -1
  35. package/dist/mcp/instructions.js +35 -29
  36. package/dist/mcp/server.js +3 -73
  37. package/dist/mcp/stdio-wire.d.ts +14 -0
  38. package/dist/mcp/stdio-wire.js +72 -0
  39. package/dist/sends/index.d.ts +1 -1
  40. package/dist/sends/index.js +1 -1
  41. package/dist/sends/permissions.d.ts +10 -2
  42. package/dist/sends/permissions.js +15 -7
  43. package/package.json +3 -3
package/CHANGELOG.md CHANGED
@@ -5,7 +5,56 @@ 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.450.0 — 27.10.2026
9
+
10
+ ### Added
11
+
12
+ - **`<tool> mcp` with no profile serves every profile**, on stdio and with `--http`: one connector per tool in an AI
13
+ app instead of one per profile. Its tools are the ones `<tool> <profile> mcp` shows, each with a required `profile`
14
+ (every profile on this computer, no default), and the instructions tell the agent to ask when the owner did not name
15
+ the account. A call runs in that profile's own `<tool> <profile> mcp`, started as a child on first use and closed
16
+ after 10 idle minutes, so its permissions, keyring, store and audit log apply as before; `--permission` reaches
17
+ every child. `mcp --revoke`, `mcp config` and `mcp server start|stop|status` work without a profile too, with their
18
+ own login (`mcp-http/_all.json`). `<tool> <profile> mcp` is unchanged. drive and zoom wire the same module from
19
+ `@wirecat/cli-core/mcp`: `serveAllProfilesOverStdio`, `serveAllProfilesOverHttp`.
20
+ - **`mcp --http --public-url` takes a path**, `https://<name>.ts.net/tg`, so tools share one tunnel address with a
21
+ path each. The issuer, the login routes and the connector address (`…/tg/mcp`) carry the path; the issuer's
22
+ metadata is at `/.well-known/oauth-authorization-server/tg` and the resource's at
23
+ `/.well-known/oauth-protected-resource/tg/mcp`, where RFC 8414 and RFC 9728 put them, so the tunnel routes those
24
+ two paths to the server as well as `/tg` (with Tailscale: `--set-path <path> http://127.0.0.1:<port><path>` for
25
+ each). Without a path nothing changes.
26
+
27
+ - **A CLI that is not a messenger can use the shared `permissions`, keyed by its own command paths.** It passes
28
+ `settingsFor(app, { permissions: commandPermissions({ extra, defaults }) })` and hands that `configuration` to
29
+ `run`. Then `config set permissions.<key>` and an MCP server's `--permission` take its command paths, the groups
30
+ above them and its `extra` keys, such as `api.calendar.events.insert`, and refuse every other key. Before, they took
31
+ only a messenger's resources. Each command is refused before it runs when its key is `deny`, when it writes under
32
+ `readonly`, and when it writes to the service under the read-only lock. `ask` stays with the write guard
33
+ (`permissionDefaults`: `configuration.permissions.defaults`). MCP finds and refuses the command by the same key
34
+ when the server passes `keyOf: configuration.permissions.keyOf`. Messenger CLIs are unchanged. A CLI with a program
35
+ of its own, such as drive, uses the parts: `permissionOverrides(entries, COMMAND_PATH_KEY)`,
36
+ `assertOverridesTighten(overrides, saved, defaults)` from `./sends` and `knownPermissionKeys(program, permissions)`.
37
+ - **MCP judges a declared CLI's command as the check before a run does.** A server passes
38
+ `permissions: configuration.permissions` to `toolsFromCommands` and to the agent context, in place of `keyOf`. Then
39
+ a write to the service is unavailable under the read-only lock (`readOnly`), and the CLI's own `deny` or `readonly`
40
+ defaults make a command unavailable and refuse its call. Before, MCP offered both.
41
+
42
+ ### Changed — may break callers
43
+
44
+ - **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
45
+ CLI that pins cli-base moves its pin with this release. cli-base 0.63 adds the shared Google Discovery reader
46
+ (`adaptDiscovery`, `syncDiscovery`, `readDiscovery` in `@wirecat/cli-base/codegen`) that drive and gcal generate their
47
+ API code with; nothing cli-core uses changed.
48
+
49
+ ## 0.449.0 — 26.10.2026
50
+
51
+ ### Changed — may break callers
52
+
53
+ - **cli-core needs `@wirecat/cli-base` 0.62.** The peer range moves from `>=0.59.0 <0.60.0` to `>=0.62.0 <0.63.0`. A
54
+ CLI that pins cli-base moves its pin with this release. cli-base 0.62 checks shared commands about twice as fast,
55
+ which shortens every command's setup: in tg a warm `run(["--version"])` went from 49 to 29 ms. cli-base 0.61 says in
56
+ its handbook that a run record keeps the command line as typed, with secrets still hidden; a CLI whose tests or
57
+ documents say otherwise follows its changelog.
9
58
 
10
59
  ## 0.448.0 — 26.10.2026
11
60
 
@@ -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;
@@ -1,6 +1,26 @@
1
1
  import { CliError } from "@wirecat/cli-base";
2
- import { CEILING_KEY, keyForCommand, WRITE_KEYS } from "../sends/permissions.js";
2
+ import { AUTHORITY_KEY, CEILING_KEY, DEFAULT_PERMISSIONS, keyForCommand, WRITE_KEYS, } from "../sends/permissions.js";
3
3
  import { issueMessage, permissionIssues, publicIssue } from "./config-issues.js";
4
+ const HOUSEKEEPING = new Set([
5
+ "help",
6
+ "guide",
7
+ "config",
8
+ "session",
9
+ "doctor",
10
+ "commands",
11
+ "complete",
12
+ "upgrade",
13
+ "skill",
14
+ "mcp",
15
+ "runs",
16
+ ]);
17
+ /** A command's own path is its key; the commands that look after the tool have none. */
18
+ export const commandPathKey = (path) => path[0] === undefined || HOUSEKEEPING.has(path[0]) ? null : path.join(".");
19
+ export const commandPermissions = ({ keyOf = commandPathKey, extra = [], defaults = {}, } = {}) => ({
20
+ keyOf,
21
+ extra: [AUTHORITY_KEY, ...extra],
22
+ defaults: { ...DEFAULT_PERMISSIONS, ...defaults },
23
+ });
4
24
  const pathOf = (command) => {
5
25
  const words = [];
6
26
  for (let at = command; at?.parent; at = at.parent)
@@ -24,6 +44,16 @@ export const knownPermissionKeys = (command, keyOf = (one) => keyForCommand(path
24
44
  for (let parts = key.split("."); parts.length > 0; parts = parts.slice(0, -1))
25
45
  known.add(parts.join("."));
26
46
  };
47
+ if (typeof keyOf !== "function") {
48
+ for (const one of everyCommand(rootOf(command))) {
49
+ const key = keyOf.keyOf(pathOf(one));
50
+ if (key)
51
+ add(key);
52
+ }
53
+ for (const key of [...keyOf.extra, ...extra])
54
+ add(key);
55
+ return known;
56
+ }
27
57
  for (const one of everyCommand(rootOf(command))) {
28
58
  const own = keyOf(one);
29
59
  const key = own === undefined ? keyForCommand(pathOf(one)) : own;
@@ -1,9 +1,10 @@
1
1
  import type { Command } from "commander";
2
2
  import { type PermissionKey } from "../sends/permissions.js";
3
+ import { type CommandPermissions } from "./permission-keys.js";
3
4
  import type { Settings } from "./settings.js";
4
5
  export declare class PreviewComplete extends Error {
5
6
  }
6
- export declare const preview: (command: Command, settings: Pick<Settings, "permissions" | "permissionSources" | "configPath" | "savedPermissions" | "profile">, tool: string, keyOf?: (path: readonly string[]) => PermissionKey | null | undefined, messenger?: boolean) => {
7
+ export declare const preview: (command: Command, settings: Pick<Settings, "permissions" | "permissionSources" | "configPath" | "savedPermissions" | "profile">, tool: string, keys?: ((path: readonly string[]) => PermissionKey | null | undefined) | CommandPermissions, messenger?: boolean) => {
7
8
  preview: boolean;
8
9
  path: string[];
9
10
  permission?: {