clixad 0.0.1-beta.47 → 0.0.1-beta.48

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/README.md CHANGED
@@ -63,7 +63,11 @@ before it changes anything. `shift+tab` cycles through the permission modes. Bey
63
63
  - **Keeps a task list** on multi-step work, and **asks you** when guessing wrong would waste real
64
64
  work.
65
65
  - **Uses MCP servers** you configure in `.mcp.json` or `~/.clixad/mcp.json`; `/mcp` shows their
66
- status. Every tool a server offers is put to you before it runs.
66
+ status. Every tool a server offers is put to you before it runs. A server from a project's
67
+ `.mcp.json` or `.clixad/mcp.json` starts only after you approve it (asked once per project, and
68
+ again if its entry changes; `/mcp approve` starts one later). A server gets `PATH`, `HOME` and the
69
+ locale from your environment and nothing else, so pass what it needs in its `env`, for example
70
+ `"env": { "GITHUB_TOKEN": "${GITHUB_TOKEN}" }`.
67
71
 
68
72
  Type `/help` for the commands, `@` to attach a file, and `!` to run a shell command yourself.
69
73
 
@@ -95,8 +99,8 @@ github.com/clixad/cli is an automatic one-way copy of the CLI's part of it, so a
95
99
  nowhere to be merged and is closed automatically. If you have a fix in mind, describe it in an
96
100
  issue.
97
101
 
98
- Please report security problems privately, through "Report a vulnerability" on the repository's
99
- Security tab, rather than in a public issue.
102
+ Please report security problems by email to [info@clixad.io](mailto:info@clixad.io), not in a
103
+ public GitHub issue. See [SECURITY.md](https://github.com/clixad/cli/blob/main/SECURITY.md).
100
104
 
101
105
  ## Status
102
106
 
@@ -643,16 +643,16 @@ async function httpFailure(res, what) {
643
643
  return detail ? `${what} failed: ${res.status} \u2014 ${detail}` : `${what} failed: ${res.status}`;
644
644
  }
645
645
  function sleep(ms, signal) {
646
- return new Promise((resolve5) => {
646
+ return new Promise((resolve6) => {
647
647
  if (!signal) {
648
- setTimeout(resolve5, ms);
648
+ setTimeout(resolve6, ms);
649
649
  return;
650
650
  }
651
- if (signal.aborted) return resolve5();
651
+ if (signal.aborted) return resolve6();
652
652
  const done = () => {
653
653
  clearTimeout(timer);
654
654
  signal.removeEventListener("abort", done);
655
- resolve5();
655
+ resolve6();
656
656
  };
657
657
  const timer = setTimeout(done, ms);
658
658
  signal.addEventListener("abort", done, { once: true });
@@ -2032,16 +2032,16 @@ async function devLogin(client, config, email, invite) {
2032
2032
  };
2033
2033
  }
2034
2034
  function sleep2(ms, signal) {
2035
- return new Promise((resolve5) => {
2035
+ return new Promise((resolve6) => {
2036
2036
  if (!signal) {
2037
- setTimeout(resolve5, ms);
2037
+ setTimeout(resolve6, ms);
2038
2038
  return;
2039
2039
  }
2040
- if (signal.aborted) return resolve5();
2040
+ if (signal.aborted) return resolve6();
2041
2041
  const done = () => {
2042
2042
  clearTimeout(timer);
2043
2043
  signal.removeEventListener("abort", done);
2044
- resolve5();
2044
+ resolve6();
2045
2045
  };
2046
2046
  const timer = setTimeout(done, ms);
2047
2047
  signal.addEventListener("abort", done, { once: true });
@@ -2336,8 +2336,18 @@ ${renderTodos(items)}${warning}`;
2336
2336
 
2337
2337
  // src/tools.ts
2338
2338
  import { spawn as spawn4 } from "node:child_process";
2339
- import { existsSync as existsSync2, mkdirSync as mkdirSync2, readdirSync, readFileSync as readFileSync3, statSync, writeFileSync as writeFileSync2 } from "node:fs";
2340
- import { isAbsolute, relative, resolve, dirname as dirname2, join as join3, sep } from "node:path";
2339
+ import {
2340
+ existsSync as existsSync2,
2341
+ lstatSync,
2342
+ mkdirSync as mkdirSync3,
2343
+ readdirSync,
2344
+ readFileSync as readFileSync3,
2345
+ readlinkSync,
2346
+ realpathSync as realpathSync2,
2347
+ statSync,
2348
+ writeFileSync as writeFileSync3
2349
+ } from "node:fs";
2350
+ import { basename, isAbsolute, relative, resolve as resolve2, dirname as dirname3, join as join3, sep } from "node:path";
2341
2351
 
2342
2352
  // src/diff.ts
2343
2353
  var R = "\x1B[0m";
@@ -3026,9 +3036,10 @@ function decodeEntities(text) {
3026
3036
 
3027
3037
  // src/mcp.ts
3028
3038
  import { spawn as spawn3 } from "node:child_process";
3029
- import { existsSync, readFileSync as readFileSync2 } from "node:fs";
3039
+ import { createHash } from "node:crypto";
3040
+ import { existsSync, mkdirSync as mkdirSync2, readFileSync as readFileSync2, realpathSync, renameSync as renameSync2, writeFileSync as writeFileSync2 } from "node:fs";
3030
3041
  import { homedir as homedir2 } from "node:os";
3031
- import { join as join2 } from "node:path";
3042
+ import { dirname as dirname2, join as join2, resolve } from "node:path";
3032
3043
  var CONNECT_TIMEOUT_MS = 2e4;
3033
3044
  var DEFAULT_CALL_TIMEOUT_MS = 12e4;
3034
3045
  var PROTOCOL_VERSION = "2025-06-18";
@@ -3039,14 +3050,20 @@ var McpError = class extends Error {
3039
3050
  function isHttpConfig(c) {
3040
3051
  return c.url !== void 0;
3041
3052
  }
3042
- function mcpConfigPaths(root) {
3043
- return [join2(homedir2(), ".clixad", "mcp.json"), join2(root, ".mcp.json"), join2(root, ".clixad", "mcp.json")];
3053
+ function mcpConfigFiles(root) {
3054
+ const personal = join2(homedir2(), ".clixad", "mcp.json");
3055
+ const files = [{ path: personal, scope: "user" }];
3056
+ for (const path of [join2(root, ".mcp.json"), join2(root, ".clixad", "mcp.json")]) {
3057
+ if (resolve(path) !== resolve(personal)) files.push({ path, scope: "project" });
3058
+ }
3059
+ return files;
3044
3060
  }
3045
3061
  function loadMcpConfig(root) {
3046
3062
  const servers = {};
3063
+ const origins = {};
3047
3064
  const sources = [];
3048
3065
  const errors = [];
3049
- for (const path of mcpConfigPaths(root)) {
3066
+ for (const { path, scope } of mcpConfigFiles(root)) {
3050
3067
  if (!existsSync(path)) continue;
3051
3068
  let parsed;
3052
3069
  try {
@@ -3067,13 +3084,24 @@ function loadMcpConfig(root) {
3067
3084
  sources.push(short(path));
3068
3085
  for (const [name, raw] of Object.entries(declared)) {
3069
3086
  const config = validateServer(name, raw);
3070
- if (typeof config === "string") errors.push(`${short(path)}: ${config}`);
3071
- else servers[name] = config;
3087
+ if (typeof config === "string") {
3088
+ errors.push(`${short(path)}: ${config}`);
3089
+ continue;
3090
+ }
3091
+ servers[name] = config;
3092
+ origins[name] = {
3093
+ scope,
3094
+ file: short(path),
3095
+ fingerprint: fingerprint(raw),
3096
+ display: describeEntry(raw),
3097
+ variables: [...new Set(variablesIn(raw))].sort()
3098
+ };
3072
3099
  }
3073
3100
  }
3074
- return { servers, sources, errors };
3101
+ return { servers, origins, sources, errors };
3075
3102
  }
3076
3103
  function validateServer(name, raw) {
3104
+ if (/[\u0000-\u001f\u007f-\u009f]/.test(name)) return `server ${JSON.stringify(name)} has control characters in its name`;
3077
3105
  if (typeof raw !== "object" || raw === null) return `server "${name}" is not an object`;
3078
3106
  const c = raw;
3079
3107
  if (typeof c.url === "string") {
@@ -3114,6 +3142,109 @@ function expandMap(map) {
3114
3142
  function short(path) {
3115
3143
  return path.replace(homedir2(), "~");
3116
3144
  }
3145
+ function canonicalJson(value) {
3146
+ if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
3147
+ if (value && typeof value === "object") {
3148
+ const entries = Object.keys(value).sort().map((k) => `${JSON.stringify(k)}:${canonicalJson(value[k])}`);
3149
+ return `{${entries.join(",")}}`;
3150
+ }
3151
+ return JSON.stringify(value) ?? "null";
3152
+ }
3153
+ function fingerprint(raw) {
3154
+ return createHash("sha256").update(canonicalJson(raw)).digest("hex");
3155
+ }
3156
+ function variablesIn(value) {
3157
+ if (typeof value === "string") return [...value.matchAll(/\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g)].map((m) => m[1]);
3158
+ if (Array.isArray(value)) return value.flatMap(variablesIn);
3159
+ if (value && typeof value === "object") return Object.values(value).flatMap(variablesIn);
3160
+ return [];
3161
+ }
3162
+ var MAX_DISPLAY_CHARS = 2e3;
3163
+ function shellWord(word) {
3164
+ if (/^[\x21\x23-\x5b\x5d-\x7e]+$/.test(word)) return word;
3165
+ return JSON.stringify(word).replace(/[^\x20-\x7e]/g, (ch) => `\\u${ch.charCodeAt(0).toString(16).padStart(4, "0")}`);
3166
+ }
3167
+ function describeEntry(raw) {
3168
+ const strings = (v) => Array.isArray(v) ? v.filter((x) => typeof x === "string") : [];
3169
+ const pairs = (v) => isStringMap(v) ? Object.entries(v) : [];
3170
+ const lines = [];
3171
+ if (typeof raw.url === "string") {
3172
+ lines.push(`${raw.type === "sse" ? "sse" : "http"} ${shellWord(raw.url)}`);
3173
+ for (const [k, v] of pairs(raw.headers)) lines.push(`header ${shellWord(k)}: ${shellWord(v)}`);
3174
+ } else {
3175
+ lines.push([String(raw.command ?? ""), ...strings(raw.args)].map(shellWord).join(" "));
3176
+ for (const [k, v] of pairs(raw.env)) lines.push(`env ${shellWord(k)}=${shellWord(v)}`);
3177
+ if (typeof raw.cwd === "string") lines.push(`cwd ${shellWord(raw.cwd)}`);
3178
+ }
3179
+ const text = lines.join("\n");
3180
+ return text.length > MAX_DISPLAY_CHARS ? `${text.slice(0, MAX_DISPLAY_CHARS)}\u2026 (${text.length - MAX_DISPLAY_CHARS} more characters; read the file before approving)` : text;
3181
+ }
3182
+ var INHERITED_ENV = {
3183
+ posix: ["HOME", "LOGNAME", "PATH", "SHELL", "TERM", "USER", "LANG", "LC_ALL", "LC_CTYPE", "TMPDIR", "TZ"],
3184
+ win32: [
3185
+ "APPDATA",
3186
+ "COMSPEC",
3187
+ "HOMEDRIVE",
3188
+ "HOMEPATH",
3189
+ "LOCALAPPDATA",
3190
+ "PATH",
3191
+ "PATHEXT",
3192
+ "PROCESSOR_ARCHITECTURE",
3193
+ "PROGRAMFILES",
3194
+ "SYSTEMDRIVE",
3195
+ "SYSTEMROOT",
3196
+ "TEMP",
3197
+ "TMP",
3198
+ "USERNAME",
3199
+ "USERPROFILE",
3200
+ "WINDIR"
3201
+ ]
3202
+ };
3203
+ function mcpServerEnv(own = {}, source = process.env, platform2 = process.platform) {
3204
+ const env = {};
3205
+ const wanted = INHERITED_ENV[platform2 === "win32" ? "win32" : "posix"];
3206
+ const byName = new Map(Object.keys(source).map((k) => [platform2 === "win32" ? k.toUpperCase() : k, k]));
3207
+ for (const name of wanted) {
3208
+ const key = byName.get(name);
3209
+ const value = key === void 0 ? void 0 : source[key];
3210
+ if (value !== void 0 && !value.startsWith("()")) env[name] = value;
3211
+ }
3212
+ return { ...env, ...own };
3213
+ }
3214
+ function mcpApprovalsPath() {
3215
+ return join2(homedir2(), ".clixad", "mcp-approvals.json");
3216
+ }
3217
+ function workspaceKey(root) {
3218
+ try {
3219
+ return realpathSync.native(root);
3220
+ } catch {
3221
+ return resolve(root);
3222
+ }
3223
+ }
3224
+ function readApprovals() {
3225
+ try {
3226
+ const parsed = JSON.parse(readFileSync2(mcpApprovalsPath(), "utf8"));
3227
+ if (parsed?.version === 1 && parsed.projects && typeof parsed.projects === "object") return parsed;
3228
+ } catch {
3229
+ }
3230
+ return { version: 1, projects: {} };
3231
+ }
3232
+ function mcpDecision(root, name, fingerprint2) {
3233
+ const entry = readApprovals().projects[workspaceKey(root)]?.[name];
3234
+ return entry && entry.fingerprint === fingerprint2 ? entry.decision : void 0;
3235
+ }
3236
+ function recordMcpDecision(root, servers, decision, at = /* @__PURE__ */ new Date()) {
3237
+ const file = readApprovals();
3238
+ const key = workspaceKey(root);
3239
+ const project = file.projects[key] ??= {};
3240
+ for (const server of servers) project[server.name] = { fingerprint: server.fingerprint, decision, at: at.toISOString() };
3241
+ const path = mcpApprovalsPath();
3242
+ mkdirSync2(dirname2(path), { recursive: true });
3243
+ const tmp = `${path}.${process.pid}.tmp`;
3244
+ writeFileSync2(tmp, `${JSON.stringify(file, null, 2)}
3245
+ `, { encoding: "utf8", mode: 384 });
3246
+ renameSync2(tmp, path);
3247
+ }
3117
3248
  var StdioTransport = class {
3118
3249
  child;
3119
3250
  buffer = "";
@@ -3124,10 +3255,13 @@ var StdioTransport = class {
3124
3255
  constructor(config, root) {
3125
3256
  this.child = spawn3(config.command, config.args ?? [], {
3126
3257
  cwd: config.cwd ?? root,
3127
- // The server's own env plus ours. `shell: false` on purpose: the command
3128
- // and its args are already separate, so there is nothing for a shell to
3129
- // re-split, and a path with a space in it would be split if there were.
3130
- env: { ...process.env, ...config.env ?? {} },
3258
+ // What a program needs to run plus what the entry names, and never the
3259
+ // rest of ours: this process's environment holds whatever the user
3260
+ // exported, API keys included, and a server is somebody else's program.
3261
+ // `shell: false` on purpose: the command and its args are already
3262
+ // separate, so there is nothing for a shell to re-split, and a path with
3263
+ // a space in it would be split if there were.
3264
+ env: mcpServerEnv(config.env),
3131
3265
  stdio: ["pipe", "pipe", "pipe"],
3132
3266
  windowsHide: true,
3133
3267
  // npx and friends are .cmd shims on Windows, which CreateProcess cannot
@@ -3177,7 +3311,7 @@ var StdioTransport = class {
3177
3311
  if (this.exited) return Promise.reject(new McpError(this.exited));
3178
3312
  const id = ++this.seq;
3179
3313
  const payload = JSON.stringify({ jsonrpc: "2.0", id, method, params }) + "\n";
3180
- return new Promise((resolve5, reject) => {
3314
+ return new Promise((resolve6, reject) => {
3181
3315
  const settle = (fn) => {
3182
3316
  clearTimeout(timer);
3183
3317
  signal?.removeEventListener("abort", onAbort);
@@ -3191,7 +3325,7 @@ var StdioTransport = class {
3191
3325
  const onAbort = () => settle(() => reject(new McpError("cancelled")));
3192
3326
  signal?.addEventListener("abort", onAbort, { once: true });
3193
3327
  this.pending.set(id, {
3194
- resolve: (v) => settle(() => resolve5(v)),
3328
+ resolve: (v) => settle(() => resolve6(v)),
3195
3329
  reject: (e) => settle(() => reject(e))
3196
3330
  });
3197
3331
  this.child.stdin?.write(payload, (err) => {
@@ -3315,23 +3449,42 @@ async function readSseResponse(res, id) {
3315
3449
  return null;
3316
3450
  }
3317
3451
  var McpServerConnection = class {
3318
- constructor(name, config, root) {
3452
+ constructor(name, config, root, origin, held) {
3319
3453
  this.name = name;
3320
3454
  this.config = config;
3321
- this.transport = isHttpConfig(config) ? new HttpTransport(config) : new StdioTransport(config, root);
3455
+ this.root = root;
3456
+ this.origin = origin;
3457
+ this.state = held ?? "connecting";
3322
3458
  }
3323
3459
  name;
3324
3460
  config;
3325
- state = "connecting";
3461
+ root;
3462
+ origin;
3463
+ state;
3326
3464
  error;
3327
3465
  tools = [];
3466
+ /**
3467
+ * Created by `connect()` and nowhere else, because creating a stdio
3468
+ * transport *is* starting the program. A held connection never has one.
3469
+ */
3328
3470
  transport;
3329
3471
  get kind() {
3330
3472
  return isHttpConfig(this.config) ? "http" : "stdio";
3331
3473
  }
3474
+ /** Lift the hold, after the user approved. Starts nothing by itself. */
3475
+ release() {
3476
+ if (this.state === "unapproved" || this.state === "declined") this.state = "connecting";
3477
+ }
3478
+ /** Remember a refusal, so `/mcp` can say why it is not running. */
3479
+ decline() {
3480
+ if (this.state === "unapproved") this.state = "declined";
3481
+ }
3332
3482
  async connect(timeoutMs = CONNECT_TIMEOUT_MS) {
3483
+ if (this.state !== "connecting" || this.transport) return;
3484
+ const transport = isHttpConfig(this.config) ? new HttpTransport(this.config) : new StdioTransport(this.config, this.root);
3485
+ this.transport = transport;
3333
3486
  try {
3334
- await this.transport.request(
3487
+ await transport.request(
3335
3488
  "initialize",
3336
3489
  {
3337
3490
  protocolVersion: PROTOCOL_VERSION,
@@ -3340,8 +3493,8 @@ var McpServerConnection = class {
3340
3493
  },
3341
3494
  timeoutMs
3342
3495
  );
3343
- await this.transport.notify("notifications/initialized", {});
3344
- const listed = await this.transport.request("tools/list", {}, timeoutMs);
3496
+ await transport.notify("notifications/initialized", {});
3497
+ const listed = await transport.request("tools/list", {}, timeoutMs);
3345
3498
  this.tools = (listed?.tools ?? []).filter(
3346
3499
  (t) => typeof t?.name === "string" && t.name.length > 0
3347
3500
  ).map((t) => ({
@@ -3352,17 +3505,19 @@ var McpServerConnection = class {
3352
3505
  ...typeof t.description === "string" ? { description: t.description } : {},
3353
3506
  ...t.inputSchema && typeof t.inputSchema === "object" ? { inputSchema: t.inputSchema } : {}
3354
3507
  }));
3508
+ if (this.state !== "connecting") return;
3355
3509
  this.state = "ready";
3356
3510
  } catch (err) {
3511
+ if (this.state !== "connecting") return;
3357
3512
  this.state = "failed";
3358
- const diagnostics = this.transport.diagnostics();
3513
+ const diagnostics = transport.diagnostics();
3359
3514
  this.error = err.message + (diagnostics ? `
3360
3515
  ${diagnostics.split("\n").slice(-3).join("\n ")}` : "");
3361
- await this.transport.close().catch(() => void 0);
3516
+ await transport.close().catch(() => void 0);
3362
3517
  }
3363
3518
  }
3364
3519
  async call(tool, args, timeoutMs, signal) {
3365
- if (this.state !== "ready") throw new McpError(`server "${this.name}" is ${this.state}`);
3520
+ if (this.state !== "ready" || !this.transport) throw new McpError(`server "${this.name}" is ${this.state}`);
3366
3521
  const raw = await this.transport.request(
3367
3522
  "tools/call",
3368
3523
  { name: tool, arguments: args ?? {} },
@@ -3375,7 +3530,7 @@ var McpServerConnection = class {
3375
3530
  }
3376
3531
  async close() {
3377
3532
  this.state = "closed";
3378
- await this.transport.close().catch(() => void 0);
3533
+ await this.transport?.close().catch(() => void 0);
3379
3534
  }
3380
3535
  };
3381
3536
  function renderContent(content, structured) {
@@ -3418,34 +3573,118 @@ function isMcpToolName(name) {
3418
3573
  return name.startsWith("mcp__");
3419
3574
  }
3420
3575
  var McpHub = class _McpHub {
3421
- constructor(servers) {
3576
+ constructor(servers, root) {
3422
3577
  this.servers = servers;
3578
+ this.root = root;
3423
3579
  }
3424
3580
  servers;
3581
+ root;
3425
3582
  /**
3426
- * Connect to every configured server, in parallel, and never reject.
3583
+ * Connect to every server given, in parallel, and never reject.
3427
3584
  *
3585
+ * For servers the caller has already decided may run; a config read from
3586
+ * disk goes through `fromConfig`, which holds the repository's servers.
3428
3587
  * Parallel because a slow server must not delay the others, and one that
3429
3588
  * cannot start must not delay anything: the whole point is that the REPL opens
3430
3589
  * whether or not somebody's Docker daemon is running today.
3431
3590
  */
3432
3591
  static async connect(servers, opts = { root: process.cwd() }) {
3433
3592
  const connections = Object.entries(servers).map(([name, config]) => new McpServerConnection(name, config, opts.root));
3434
- await Promise.all(connections.map((c) => c.connect(opts.timeoutMs)));
3435
- const taken = /* @__PURE__ */ new Set();
3436
- for (const conn of connections) {
3593
+ const hub = new _McpHub(connections, opts.root);
3594
+ await hub.startReleased(opts.timeoutMs);
3595
+ return hub;
3596
+ }
3597
+ /**
3598
+ * A hub for the config on disk, with nothing started yet.
3599
+ *
3600
+ * A server from `~/.clixad/mcp.json` is ready to start. A server from the
3601
+ * workspace is held unless the user approved this exact entry for this
3602
+ * workspace before, and stays held if they declined it. `startReleased`
3603
+ * then starts what may start; `approve` releases the rest.
3604
+ */
3605
+ static fromConfig(loaded, opts) {
3606
+ const connections = Object.entries(loaded.servers).map(([name, config]) => {
3607
+ const origin = loaded.origins[name];
3608
+ let held;
3609
+ if (!origin || origin.scope !== "user") {
3610
+ const decision = origin ? mcpDecision(opts.root, name, origin.fingerprint) : void 0;
3611
+ held = decision === "approved" ? void 0 : decision === "declined" ? "declined" : "unapproved";
3612
+ }
3613
+ return new McpServerConnection(name, config, opts.root, origin, held);
3614
+ });
3615
+ return new _McpHub(connections, opts.root);
3616
+ }
3617
+ /** An empty hub — what a run with no config, or a scripted run, gets. */
3618
+ static empty() {
3619
+ return new _McpHub([], process.cwd());
3620
+ }
3621
+ /**
3622
+ * Start every server that may start and has not been, in parallel. Returns
3623
+ * the ones it started, so the caller can say how each one went.
3624
+ */
3625
+ async startReleased(timeoutMs) {
3626
+ const starting = this.servers.filter((s) => s.state === "connecting");
3627
+ await Promise.all(starting.map((c) => c.connect(timeoutMs)));
3628
+ this.assignNames();
3629
+ const names = new Set(starting.map((s) => s.name));
3630
+ return this.status().filter((s) => names.has(s.name));
3631
+ }
3632
+ /**
3633
+ * Give every listed tool its model-facing name. Names already given keep
3634
+ * theirs, so a server approved later cannot rename a tool the model has
3635
+ * already been told about. A collision (two servers with the same tool, or
3636
+ * two long names that truncate together) is broken deterministically.
3637
+ */
3638
+ assignNames() {
3639
+ const taken = new Set(this.servers.flatMap((s) => s.tools.map((t) => t.qualified)).filter(Boolean));
3640
+ for (const conn of this.servers) {
3437
3641
  for (const tool of conn.tools) {
3642
+ if (tool.qualified) continue;
3438
3643
  let name = qualifiedToolName(conn.name, tool.name);
3439
3644
  for (let i = 2; taken.has(name); i++) name = `${name.slice(0, 61)}_${i}`;
3440
3645
  taken.add(name);
3441
3646
  tool.qualified = name;
3442
3647
  }
3443
3648
  }
3444
- return new _McpHub(connections);
3445
3649
  }
3446
- /** An empty hub — what a run with no config, or a scripted run, gets. */
3447
- static empty() {
3448
- return new _McpHub([]);
3650
+ /** The servers waiting for the user, with what the prompt shows. */
3651
+ pending(opts = {}) {
3652
+ return this.servers.filter((s) => s.state === "unapproved" || opts.includeDeclined && s.state === "declined").map((s) => ({
3653
+ name: s.name,
3654
+ file: s.origin?.file ?? "",
3655
+ display: s.origin?.display ?? "",
3656
+ variables: s.origin?.variables ?? [],
3657
+ fingerprint: s.origin?.fingerprint ?? ""
3658
+ }));
3659
+ }
3660
+ /**
3661
+ * The user approved these held servers: remember it (bound to each entry as
3662
+ * written) when asked to, and start them. Names that are not held are
3663
+ * ignored. Returns how each one started.
3664
+ */
3665
+ async approve(names, opts) {
3666
+ const chosen = this.held(names);
3667
+ if (opts.remember) this.remember(chosen, "approved");
3668
+ for (const s of chosen) s.release();
3669
+ return this.startReleased(opts.timeoutMs);
3670
+ }
3671
+ /** The user declined these held servers: remember it, so they are not asked about again until the entry changes. */
3672
+ decline(names) {
3673
+ const chosen = this.held(names).filter((s) => s.state === "unapproved");
3674
+ this.remember(chosen, "declined");
3675
+ for (const s of chosen) s.decline();
3676
+ }
3677
+ held(names) {
3678
+ const wanted = new Set(names);
3679
+ return this.servers.filter((s) => wanted.has(s.name) && (s.state === "unapproved" || s.state === "declined"));
3680
+ }
3681
+ remember(servers, decision) {
3682
+ const entries = servers.filter((s) => s.origin).map((s) => ({ name: s.name, fingerprint: s.origin.fingerprint }));
3683
+ if (!entries.length) return;
3684
+ try {
3685
+ recordMcpDecision(this.root, entries, decision);
3686
+ } catch {
3687
+ }
3449
3688
  }
3450
3689
  tools() {
3451
3690
  return this.servers.filter((s) => s.state === "ready").flatMap((s) => s.tools);
@@ -3509,13 +3748,60 @@ var PermissionDeniedError = class extends ToolError {
3509
3748
  }
3510
3749
  };
3511
3750
  function safeResolve(root, p) {
3512
- const abs = isAbsolute(p) ? p : resolve(root, p);
3513
- const rel2 = relative(root, abs);
3514
- if (rel2 === ".." || rel2.startsWith(".." + sep) || isAbsolute(rel2)) {
3515
- throw new ToolError(`path escapes workspace: ${p}`);
3751
+ const abs = isAbsolute(p) ? p : resolve2(root, p);
3752
+ if (escapes(root, abs)) throw new ToolError(`path escapes workspace: ${p}`);
3753
+ let real;
3754
+ let realRoot;
3755
+ try {
3756
+ real = realLocation(abs);
3757
+ realRoot = realLocation(resolve2(root));
3758
+ } catch (err) {
3759
+ const code = err.code;
3760
+ throw new ToolError(code === "ELOOP" ? `${p}: the path goes through a symlink loop` : `cannot resolve ${p}`);
3761
+ }
3762
+ if (escapes(realRoot, real)) {
3763
+ throw new ToolError(`path escapes workspace: ${p} is a symlink (or inside one) that points outside the workspace`);
3516
3764
  }
3517
3765
  return abs;
3518
3766
  }
3767
+ function escapes(root, abs) {
3768
+ const rel2 = relative(root, abs);
3769
+ return rel2 === ".." || rel2.startsWith(".." + sep) || isAbsolute(rel2);
3770
+ }
3771
+ var MAX_LINK_HOPS = 40;
3772
+ function realLocation(abs, hops = 0) {
3773
+ try {
3774
+ return realpathSync2.native(abs);
3775
+ } catch (err) {
3776
+ const code = err.code;
3777
+ if (code !== "ENOENT" && code !== "ENOTDIR") throw err;
3778
+ }
3779
+ const parent = dirname3(abs);
3780
+ if (parent === abs) return abs;
3781
+ let link;
3782
+ try {
3783
+ if (lstatSync(abs).isSymbolicLink()) link = readlinkSync(abs);
3784
+ } catch {
3785
+ }
3786
+ const realParent = realLocation(parent, hops);
3787
+ if (link === void 0) return join3(realParent, basename(abs));
3788
+ if (hops >= MAX_LINK_HOPS) throw Object.assign(new Error("too many symlinks"), { code: "ELOOP" });
3789
+ return realLocation(resolve2(realParent, link), hops + 1);
3790
+ }
3791
+ function realRootOf(root) {
3792
+ try {
3793
+ return realLocation(resolve2(root));
3794
+ } catch {
3795
+ return resolve2(root);
3796
+ }
3797
+ }
3798
+ function linkStaysInside(abs, realRoot) {
3799
+ try {
3800
+ return !escapes(realRoot, realpathSync2.native(abs));
3801
+ } catch {
3802
+ return false;
3803
+ }
3804
+ }
3519
3805
  function rel(root, abs) {
3520
3806
  return relative(root, abs).replace(/\\/g, "/") || ".";
3521
3807
  }
@@ -3589,10 +3875,11 @@ function describeValue(value) {
3589
3875
  if (typeof value === "number") return "an unusable number";
3590
3876
  return typeof value === "object" ? "an object" : `a ${typeof value}`;
3591
3877
  }
3592
- function entryKind(entry, abs) {
3878
+ function entryKind(entry, abs, realRoot) {
3593
3879
  if (entry.isDirectory()) return "dir";
3594
3880
  if (entry.isFile()) return "file";
3595
3881
  if (!entry.isSymbolicLink()) return "other";
3882
+ if (!linkStaysInside(abs, realRoot)) return "other";
3596
3883
  try {
3597
3884
  const stat = statSync(abs);
3598
3885
  return stat.isDirectory() ? "dir" : stat.isFile() ? "file" : "other";
@@ -3730,9 +4017,10 @@ ${body2}` + (pdf.text.length > MAX_FILE_BYTES ? "\n\u2026 [truncated]" : "");
3730
4017
  if (!fsCall(`cannot list ${name}`, () => statSync(abs)).isDirectory()) {
3731
4018
  throw new ToolError(`${path} is a file \u2014 use read_file`);
3732
4019
  }
4020
+ const realRoot = realRootOf(ctx.root);
3733
4021
  const entries = fsCall(`cannot list ${name}`, () => readdirSync(abs, { withFileTypes: true })).map(
3734
4022
  (entry) => {
3735
- const isDir = entryKind(entry, resolve(abs, entry.name)) === "dir";
4023
+ const isDir = entryKind(entry, resolve2(abs, entry.name), realRoot) === "dir";
3736
4024
  return { name: isDir ? `${entry.name}/` : entry.name, isDir };
3737
4025
  }
3738
4026
  );
@@ -3812,9 +4100,10 @@ ${body2}` + (pdf.text.length > MAX_FILE_BYTES ? "\n\u2026 [truncated]" : "");
3812
4100
  path: name,
3813
4101
  root: ctx.root
3814
4102
  });
4103
+ safeResolve(ctx.root, abs);
3815
4104
  fsCall(`cannot write ${name}`, () => {
3816
- mkdirSync2(dirname2(abs), { recursive: true });
3817
- writeFileSync2(abs, content, "utf8");
4105
+ mkdirSync3(dirname3(abs), { recursive: true });
4106
+ writeFileSync3(abs, content, "utf8");
3818
4107
  });
3819
4108
  ctx.present?.(view);
3820
4109
  return `wrote ${content.length} bytes to ${name}`;
@@ -3856,7 +4145,8 @@ ${body2}` + (pdf.text.length > MAX_FILE_BYTES ? "\n\u2026 [truncated]" : "");
3856
4145
  path: name,
3857
4146
  root: ctx.root
3858
4147
  });
3859
- fsCall(`cannot edit ${name}`, () => writeFileSync2(abs, after, "utf8"));
4148
+ safeResolve(ctx.root, abs);
4149
+ fsCall(`cannot edit ${name}`, () => writeFileSync3(abs, after, "utf8"));
3860
4150
  ctx.present?.(view);
3861
4151
  return `edited ${name} (${count > 1 ? `${count} occurrences` : "1 occurrence"})`;
3862
4152
  },
@@ -4262,6 +4552,7 @@ function walk(dir, root, visit) {
4262
4552
  visit(dir);
4263
4553
  return;
4264
4554
  }
4555
+ const realRoot = realRootOf(root);
4265
4556
  let seen = 0;
4266
4557
  const stack = [dir];
4267
4558
  while (stack.length > 0) {
@@ -4274,7 +4565,7 @@ function walk(dir, root, visit) {
4274
4565
  }
4275
4566
  for (const entry of entries) {
4276
4567
  const abs = join3(current, entry.name);
4277
- const kind = entryKind(entry, abs);
4568
+ const kind = entryKind(entry, abs, realRoot);
4278
4569
  if (kind === "dir") {
4279
4570
  if (entry.isSymbolicLink() || SKIP_DIRS.has(entry.name)) continue;
4280
4571
  stack.push(abs);
@@ -4285,7 +4576,6 @@ function walk(dir, root, visit) {
4285
4576
  if (!visit(abs)) return;
4286
4577
  }
4287
4578
  }
4288
- void root;
4289
4579
  }
4290
4580
  function globToRegExp(pattern) {
4291
4581
  const p = requireString(pattern, "glob", "pattern").replace(/\\/g, "/").replace(/^\.\//, "");
@@ -4590,7 +4880,7 @@ var TOOL_SCHEMA = [
4590
4880
 
4591
4881
  // src/auto-mode.ts
4592
4882
  import { homedir as homedir3 } from "node:os";
4593
- import { isAbsolute as isAbsolute2, relative as relative2, resolve as resolve2, sep as sep2 } from "node:path";
4883
+ import { isAbsolute as isAbsolute2, relative as relative2, resolve as resolve3, sep as sep2 } from "node:path";
4594
4884
  function parseCommand(src) {
4595
4885
  const pipelines = [];
4596
4886
  let pipeline = [];
@@ -4763,9 +5053,9 @@ function expandHome(word, home) {
4763
5053
  function resolveWord(word, ctx) {
4764
5054
  const w = expandHome(word, ctx.home);
4765
5055
  if (w === null) return null;
4766
- if (isAbsolute2(w)) return resolve2(w);
5056
+ if (isAbsolute2(w)) return resolve3(w);
4767
5057
  if (ctx.cwd === null) return null;
4768
- return resolve2(ctx.cwd, w);
5058
+ return resolve3(ctx.cwd, w);
4769
5059
  }
4770
5060
  function within(parent, child) {
4771
5061
  const rel2 = relative2(parent, child);
@@ -5299,7 +5589,7 @@ function classify(command, root, home, depth) {
5299
5589
  if (FORK_BOMB.test(command)) return { kind: "dangerous", reason: "it is a fork bomb" };
5300
5590
  if (FETCH_AND_RUN.some((re) => re.test(command))) return { kind: "dangerous", reason: "it downloads code and runs it" };
5301
5591
  const parsed = parseCommand(command);
5302
- const ctx = { root: resolve2(root), home: resolve2(home), cwd: resolve2(root) };
5592
+ const ctx = { root: resolve3(root), home: resolve3(home), cwd: resolve3(root) };
5303
5593
  for (const pipeline of parsed.pipelines) {
5304
5594
  let fetched = false;
5305
5595
  for (const cmd of pipeline) {
@@ -5816,7 +6106,7 @@ async function dispatch(ctx, name, args) {
5816
6106
  import { spawnSync } from "node:child_process";
5817
6107
  import { existsSync as existsSync3, readdirSync as readdirSync2, readFileSync as readFileSync4 } from "node:fs";
5818
6108
  import { homedir as homedir4, platform } from "node:os";
5819
- import { join as join4, parse, resolve as resolve3 } from "node:path";
6109
+ import { join as join4, parse, resolve as resolve4 } from "node:path";
5820
6110
  var MAX_CONTEXT_FILE_CHARS = 2e4;
5821
6111
  function contextFileCandidates(root) {
5822
6112
  return [join4(homedir4(), ".clixad", "CLIXAD.md"), join4(root, "AGENTS.md"), join4(root, "CLIXAD.md")];
@@ -5824,10 +6114,12 @@ function contextFileCandidates(root) {
5824
6114
  function collectContext(root, base = SYSTEM_PROMPT) {
5825
6115
  const parts = [base, environmentBlock(root)];
5826
6116
  const files = [];
6117
+ const personal = join4(homedir4(), ".clixad", "CLIXAD.md");
5827
6118
  for (const path of contextFileCandidates(root)) {
5828
6119
  if (!existsSync3(path)) continue;
5829
6120
  let text;
5830
6121
  try {
6122
+ if (path !== personal) safeResolve(root, path);
5831
6123
  text = readFileSync4(path, "utf8").trim();
5832
6124
  } catch {
5833
6125
  continue;
@@ -5882,8 +6174,8 @@ var INIT_PROMPT = "Create a CLIXAD.md file in the workspace root that tells a fu
5882
6174
  var INIT_HINT = "No CLIXAD.md in this project yet. /init writes one, and every later session here starts out knowing the layout.";
5883
6175
  var PROJECT_MARKERS = [".git", "package.json", "pyproject.toml", "Cargo.toml", "go.mod", "pom.xml", "Gemfile"];
5884
6176
  function shouldSuggestInit(root) {
5885
- const dir = resolve3(root);
5886
- if (dir === resolve3(homedir4()) || dir === parse(dir).root) return false;
6177
+ const dir = resolve4(root);
6178
+ if (dir === resolve4(homedir4()) || dir === parse(dir).root) return false;
5887
6179
  if (existsSync3(join4(dir, "CLIXAD.md")) || existsSync3(join4(dir, "AGENTS.md"))) return false;
5888
6180
  return PROJECT_MARKERS.some((name) => existsSync3(join4(dir, name)));
5889
6181
  }
@@ -6055,17 +6347,17 @@ var denyAll = async (req) => ({
6055
6347
  import {
6056
6348
  closeSync as closeSync2,
6057
6349
  existsSync as existsSync4,
6058
- mkdirSync as mkdirSync3,
6350
+ mkdirSync as mkdirSync4,
6059
6351
  openSync as openSync2,
6060
6352
  readdirSync as readdirSync3,
6061
6353
  readFileSync as readFileSync5,
6062
6354
  readSync,
6063
6355
  rmSync,
6064
6356
  statSync as statSync2,
6065
- writeFileSync as writeFileSync3
6357
+ writeFileSync as writeFileSync4
6066
6358
  } from "node:fs";
6067
6359
  import { homedir as homedir5 } from "node:os";
6068
- import { join as join5, resolve as resolve4 } from "node:path";
6360
+ import { join as join5, resolve as resolve5 } from "node:path";
6069
6361
  var ROOT = join5(homedir5(), ".clixad");
6070
6362
  var SESSION_DIR = join5(ROOT, "sessions");
6071
6363
  var HISTORY_FILE = join5(ROOT, "history");
@@ -6076,7 +6368,7 @@ function isSessionId(id) {
6076
6368
  return SESSION_ID.test(id);
6077
6369
  }
6078
6370
  function sameDirectory(a, b) {
6079
- const norm = (p) => process.platform === "win32" ? resolve4(p).toLowerCase() : resolve4(p);
6371
+ const norm = (p) => process.platform === "win32" ? resolve5(p).toLowerCase() : resolve5(p);
6080
6372
  return norm(a) === norm(b);
6081
6373
  }
6082
6374
  function newSessionId() {
@@ -6085,9 +6377,9 @@ function newSessionId() {
6085
6377
  }
6086
6378
  function saveSession(session) {
6087
6379
  try {
6088
- mkdirSync3(SESSION_DIR, { recursive: true });
6380
+ mkdirSync4(SESSION_DIR, { recursive: true });
6089
6381
  const stored = { ...session, messages: withoutImages(session.messages) };
6090
- writeFileSync3(join5(SESSION_DIR, `${session.id}.json`), JSON.stringify(stored), "utf8");
6382
+ writeFileSync4(join5(SESSION_DIR, `${session.id}.json`), JSON.stringify(stored), "utf8");
6091
6383
  prune();
6092
6384
  } catch {
6093
6385
  }
@@ -6175,17 +6467,17 @@ function loadHistory() {
6175
6467
  }
6176
6468
  function saveHistory(entries) {
6177
6469
  try {
6178
- mkdirSync3(ROOT, { recursive: true });
6179
- writeFileSync3(HISTORY_FILE, entries.slice(0, MAX_HISTORY).reverse().join("\n") + "\n", "utf8");
6470
+ mkdirSync4(ROOT, { recursive: true });
6471
+ writeFileSync4(HISTORY_FILE, entries.slice(0, MAX_HISTORY).reverse().join("\n") + "\n", "utf8");
6180
6472
  } catch {
6181
6473
  }
6182
6474
  }
6183
6475
 
6184
6476
  // src/kimi.ts
6185
6477
  import { spawn as spawn5, spawnSync as spawnSync2 } from "node:child_process";
6186
- import { existsSync as existsSync5, mkdirSync as mkdirSync4, writeFileSync as writeFileSync4 } from "node:fs";
6478
+ import { existsSync as existsSync5, mkdirSync as mkdirSync5, writeFileSync as writeFileSync5 } from "node:fs";
6187
6479
  import { homedir as homedir6 } from "node:os";
6188
- import { dirname as dirname3, join as join6 } from "node:path";
6480
+ import { dirname as dirname4, join as join6 } from "node:path";
6189
6481
  var CONTEXT_TOKENS = {
6190
6482
  "gpt-5-nano": 4e5,
6191
6483
  "gemini-2.5-flash-lite": 1048576,
@@ -6265,8 +6557,8 @@ function runKimiCode(config, task, opts = {}) {
6265
6557
  return Promise.resolve(1);
6266
6558
  }
6267
6559
  const path = kimiConfigPath();
6268
- mkdirSync4(dirname3(path), { recursive: true });
6269
- writeFileSync4(path, buildKimiConfig({ gatewayUrl: config.gatewayUrl, token: config.token, modelId: config.model }), "utf8");
6560
+ mkdirSync5(dirname4(path), { recursive: true });
6561
+ writeFileSync5(path, buildKimiConfig({ gatewayUrl: config.gatewayUrl, token: config.token, modelId: config.model }), "utf8");
6270
6562
  const bin = process.env.CLIXAD_KIMI_BIN || "kimi";
6271
6563
  if (!binExists(bin)) {
6272
6564
  console.log("\n" + INSTALL_HELP);
@@ -6280,14 +6572,14 @@ function runKimiCode(config, task, opts = {}) {
6280
6572
  OPENAI_BASE_URL: `${config.gatewayUrl.replace(/\/+$/, "")}/v1`,
6281
6573
  OPENAI_API_KEY: config.token
6282
6574
  };
6283
- return new Promise((resolve5) => {
6575
+ return new Promise((resolve6) => {
6284
6576
  const child = process.platform === "win32" ? spawn5([bin, ...args].map(quoteWin).join(" "), { stdio: "inherit", env, shell: true }) : spawn5(bin, args, { stdio: "inherit", env });
6285
6577
  child.on("error", (err) => {
6286
6578
  if (err.code === "ENOENT") console.log("\n" + INSTALL_HELP);
6287
6579
  else console.error(`failed to launch Kimi CLI: ${err.message}`);
6288
- resolve5(127);
6580
+ resolve6(127);
6289
6581
  });
6290
- child.on("exit", (code) => resolve5(code ?? 0));
6582
+ child.on("exit", (code) => resolve6(code ?? 0));
6291
6583
  });
6292
6584
  }
6293
6585
  function quoteWin(s) {
package/dist/clixad.mjs CHANGED
@@ -54,7 +54,7 @@ import {
54
54
  takeConfigNotices,
55
55
  toolSchemaFor,
56
56
  usd
57
- } from "./chunk-TKGMAY4C.mjs";
57
+ } from "./chunk-CNLFGTYS.mjs";
58
58
 
59
59
  // src/tui/load.ts
60
60
  import * as nodeModule from "node:module";
@@ -133,7 +133,7 @@ async function withProductionReact(load) {
133
133
  }
134
134
  function loadTui() {
135
135
  boundInkCaches();
136
- return withProductionReact(() => import("./tui-US36BPP3.mjs"));
136
+ return withProductionReact(() => import("./tui-M5SJRCHM.mjs"));
137
137
  }
138
138
 
139
139
  // src/title.ts
@@ -105,7 +105,7 @@ import {
105
105
  visibleLength,
106
106
  webSearchCostLine,
107
107
  welcomeBanner
108
- } from "./chunk-TKGMAY4C.mjs";
108
+ } from "./chunk-CNLFGTYS.mjs";
109
109
 
110
110
  // src/tui/index.tsx
111
111
  import "react";
@@ -486,7 +486,7 @@ var COMMANDS = [
486
486
  // enter complete the command instead of running it, and listing is what both
487
487
  // are for. `/bg kill <id>` still works when typed in full.
488
488
  { name: "bg", desc: "background commands the agent started" },
489
- { name: "mcp", desc: "connected MCP servers and their tools" },
489
+ { name: "mcp", desc: "MCP servers and their tools (approve: start one from this project)" },
490
490
  { name: "exit", desc: "quit clixad" }
491
491
  ];
492
492
  function commandLabel(c) {
@@ -2271,17 +2271,10 @@ function App({
2271
2271
  }
2272
2272
  }).catch(() => void 0);
2273
2273
  }, []);
2274
- useEffect(() => {
2275
- const { servers, errors } = loadMcpConfig(root);
2276
- for (const problem of errors) push({ kind: "notice", tone: "warn", text: ` mcp config \u2014 ${problem}` });
2277
- const names = Object.keys(servers);
2278
- if (names.length === 0) return;
2279
- let cancelled = false;
2280
- void McpHub.connect(servers, { root }).then((hub) => {
2281
- if (cancelled) return void hub.close();
2282
- mcpRef.current = hub;
2283
- const ready = hub.status().filter((s) => s.state === "ready");
2284
- const failed2 = hub.status().filter((s) => s.state === "failed");
2274
+ const reportMcpStarted = useCallback(
2275
+ (started) => {
2276
+ const ready = started.filter((s) => s.state === "ready");
2277
+ const failed2 = started.filter((s) => s.state === "failed");
2285
2278
  if (ready.length) {
2286
2279
  push({
2287
2280
  kind: "notice",
@@ -2291,10 +2284,63 @@ function App({
2291
2284
  for (const server of failed2) {
2292
2285
  push({ kind: "notice", tone: "warn", text: ` mcp: ${server.name} did not start \u2014 ${server.error}` });
2293
2286
  }
2287
+ },
2288
+ [push]
2289
+ );
2290
+ const askMcpApproval = useCallback(
2291
+ (hub, pending) => {
2292
+ if (!pending.length) return;
2293
+ const files = [...new Set(pending.map((p) => p.file))].join(" and ");
2294
+ push({
2295
+ kind: "notice",
2296
+ tone: "warn",
2297
+ text: ` ${files} in this project ${pending.length === 1 ? "wants to start an MCP server" : `wants to start ${pending.length} MCP servers`}. A server is a program that runs on this machine; nothing has been started.
2298
+ ` + pending.map(
2299
+ (p) => ` ${p.name}
2300
+ ` + p.display.split("\n").map((line2) => ` ${line2}`).join("\n") + `
2301
+ environment it gets: ${p.variables.length ? p.variables.join(", ") : "none beyond PATH, HOME and the locale"}`
2302
+ ).join("\n")
2303
+ });
2304
+ const names = pending.map((p) => p.name);
2305
+ setPickerSel(1);
2306
+ setPicker({
2307
+ title: pending.length === 1 ? `Start the MCP server "${names[0]}"?` : `Start these ${pending.length} MCP servers?`,
2308
+ subtitle: "Only if you trust this project \xB7 esc: not now",
2309
+ items: [
2310
+ { value: "approve", label: "Yes, start", hint: "and remember it for this project until the config changes" },
2311
+ { value: "later", label: "Not now", hint: "ask again next time \xB7 /mcp approve starts them" },
2312
+ { value: "decline", label: "No", hint: "and don't ask again until the config changes" }
2313
+ ],
2314
+ onPick: (choice) => {
2315
+ if (choice.value === "approve") {
2316
+ push({ kind: "notice", text: ` mcp: starting ${names.join(", ")}` });
2317
+ void hub.approve(names, { remember: true }).then(reportMcpStarted);
2318
+ } else if (choice.value === "decline") {
2319
+ hub.decline(names);
2320
+ push({ kind: "notice", text: ` mcp: not starting ${names.join(", ")} \xB7 /mcp approve starts them` });
2321
+ } else {
2322
+ push({ kind: "notice", text: " mcp: not started \xB7 /mcp approve starts them" });
2323
+ }
2324
+ },
2325
+ onCancel: () => push({ kind: "notice", text: " mcp: not started \xB7 /mcp approve starts them" })
2326
+ });
2327
+ },
2328
+ [push, reportMcpStarted]
2329
+ );
2330
+ useEffect(() => {
2331
+ const loaded = loadMcpConfig(root);
2332
+ for (const problem of loaded.errors) push({ kind: "notice", tone: "warn", text: ` mcp config \u2014 ${problem}` });
2333
+ if (Object.keys(loaded.servers).length === 0) return;
2334
+ const hub = McpHub.fromConfig(loaded, { root });
2335
+ mcpRef.current = hub;
2336
+ let cancelled = false;
2337
+ void hub.startReleased().then((started) => {
2338
+ if (!cancelled) reportMcpStarted(started);
2294
2339
  });
2340
+ askMcpApproval(hub, hub.pending());
2295
2341
  return () => {
2296
2342
  cancelled = true;
2297
- void mcpRef.current.close();
2343
+ void hub.close();
2298
2344
  };
2299
2345
  }, []);
2300
2346
  useEffect(() => {
@@ -3644,7 +3690,27 @@ ${preferencesText}` : ""),
3644
3690
  return;
3645
3691
  }
3646
3692
  case "mcp": {
3647
- const status = mcpRef.current.status();
3693
+ const hub = mcpRef.current;
3694
+ const [verb, ...targets] = arg.split(/\s+/).filter(Boolean);
3695
+ if (verb === "approve") {
3696
+ const held = hub.pending({ includeDeclined: true });
3697
+ if (!held.length) {
3698
+ push({ kind: "notice", text: " no MCP servers waiting for approval" });
3699
+ return;
3700
+ }
3701
+ if (!targets.length || targets.includes("all")) return askMcpApproval(hub, held);
3702
+ const unknown = targets.filter((t) => !held.some((h) => h.name === t));
3703
+ if (unknown.length) {
3704
+ push({
3705
+ kind: "notice",
3706
+ tone: "warn",
3707
+ text: ` not waiting for approval: ${unknown.join(", ")} \xB7 waiting: ${held.map((h) => h.name).join(", ")}`
3708
+ });
3709
+ return;
3710
+ }
3711
+ return askMcpApproval(hub, held.filter((h) => targets.includes(h.name)));
3712
+ }
3713
+ const status = hub.status();
3648
3714
  if (!status.length) {
3649
3715
  push({
3650
3716
  kind: "notice",
@@ -3652,14 +3718,16 @@ ${preferencesText}` : ""),
3652
3718
  });
3653
3719
  return;
3654
3720
  }
3721
+ const label = { unapproved: "not approved", declined: "declined" };
3722
+ const anyHeld = status.some((s) => s.state === "unapproved" || s.state === "declined");
3655
3723
  push({
3656
3724
  kind: "notice",
3657
3725
  text: status.map((s) => {
3658
- const head = ` ${s.name.padEnd(16)} ${s.state.padEnd(10)} ${s.transport}`;
3726
+ const head = ` ${s.name.padEnd(16)} ${(label[s.state] ?? s.state).padEnd(12)} ${s.transport}`;
3659
3727
  if (s.state === "ready") return `${head} ${s.toolCount} tool${s.toolCount === 1 ? "" : "s"}`;
3660
3728
  return s.error ? `${head}
3661
3729
  ${s.error.split("\n").join("\n ")}` : head;
3662
- }).join("\n")
3730
+ }).join("\n") + (anyHeld ? "\n /mcp approve shows what a server runs and starts it" : "")
3663
3731
  });
3664
3732
  return;
3665
3733
  }
@@ -3702,7 +3770,7 @@ ${preferencesText}` : ""),
3702
3770
  push({ kind: "notice", tone: "warn", text: ` unknown command: /${cmd} \u2014 try /help` });
3703
3771
  }
3704
3772
  },
3705
- [client, cols, config, cycleMode, exit, model, modelTaskHints, premium, push, replayConversation, root, runAdWall, runBusy, runBuy, runLogin, runTurn, setPremium, writeToStdout]
3773
+ [askMcpApproval, client, cols, config, cycleMode, exit, model, modelTaskHints, premium, push, replayConversation, root, runAdWall, runBusy, runBuy, runLogin, runTurn, setPremium, writeToStdout]
3706
3774
  );
3707
3775
  const runShell = useCallback(
3708
3776
  async (command2) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clixad",
3
- "version": "0.0.1-beta.47",
3
+ "version": "0.0.1-beta.48",
4
4
  "description": "Free AI coding agent in your terminal, funded by rewarded ads.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",