@tiens.nguyen/gu-cli 1.0.738 → 1.0.740

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.
@@ -4764,6 +4764,9 @@ _RAG_SKIP_DIRS = {
4764
4764
  # .github, .vscode or .claude — those carry CI config, editor settings and project
4765
4765
  # instructions a coding agent legitimately benefits from reading.
4766
4766
  ".claude-memory", ".specstory", ".aider.tags.cache.v3", ".goose",
4767
+ # gu's OWN memory store (the memory skill's .gu-memory/, task #269): the agent reaches it
4768
+ # through the memory MCP tools, which query it — not by grepping raw checkpoint markdown.
4769
+ ".gu-memory",
4767
4770
  }
4768
4771
  # Machine-generated files that are TEXT but never worth reading. A lockfile is the whole
4769
4772
  # problem in miniature: in the reported Next.js project package-lock.json is 239KB while every
@@ -6695,6 +6698,9 @@ def _ws_request_approval(api_base, worker_key, job_id, command, reason, deadline
6695
6698
  _emit({"type": "step", "text": "Awaiting your choice: compact the context?"})
6696
6699
  elif command.startswith("__S3SETUP__::"):
6697
6700
  _emit({"type": "step", "text": "Awaiting your choice: set up S3, or save on this computer?"})
6701
+ elif command.startswith("__MCPTOOL__::"):
6702
+ _srv, _, _rest = command[len("__MCPTOOL__::"):].partition("\t")
6703
+ _emit({"type": "step", "text": f"Awaiting your approval: MCP tool {_srv}/{_rest.split(chr(9))[0]}"})
6698
6704
  else:
6699
6705
  _emit({"type": "step", "text": f"Awaiting your approval to run: {command[:70]}"})
6700
6706
  deadline = _t.time() + max(30, int(deadline_s)) # per-call; risky=180s, __MAXSTEP__ long
@@ -7073,6 +7079,12 @@ def run_agent_chat(cfg):
7073
7079
  # risky command and asks the user via the API instead of hard-blocking it.
7074
7080
  _job_id = (cfg.get("jobId") or "").strip()
7075
7081
  _interactive_approval = bool(cfg.get("interactiveApproval"))
7082
+ # Skills and MCP servers (task #269). The worker scans ~/.gonext/skills, ~/.gonext/mcp.json and
7083
+ # this folder's .gonext/skills + .mcp.json for every turn; none present = neither appears
7084
+ # anywhere in the prompt. Names only here — a skill's body is read when it is used.
7085
+ import gu_skills as _skills_mod
7086
+ _skills = _skills_mod.clean_list(cfg.get("skills"))
7087
+ _mcp_cfg = cfg.get("mcp") if isinstance(cfg.get("mcp"), dict) else {}
7076
7088
 
7077
7089
  def _ask_s3_setup() -> bool:
7078
7090
  """Private mode, a file to hand over, no S3 of the user's own (task #252): ask in the
@@ -7230,6 +7242,14 @@ def run_agent_chat(cfg):
7230
7242
  # below. Otherwise the keyword router matches URLs/"api"/"GET" from prior turns
7231
7243
  # and fires the agent on trivial replies like "thanks" or "good".
7232
7244
  latest_user_text = task_text
7245
+ # `/<skill> request` typed in the terminal (task #269): the skill's instructions go in with
7246
+ # the request, for this turn only — the history keeps the short line the user typed.
7247
+ _skill_call = _skills_mod.invocation(task_text, _skills)
7248
+ if _skill_call:
7249
+ _sk, _sk_body, _sk_req = _skill_call
7250
+ task_text = (_skills_mod.render(_sk, _sk_body) + "\n\nThe user's request: "
7251
+ + (_sk_req or "(nothing more — do what the skill describes)"))
7252
+ _log(f"skill invoked: {_sk['name']}")
7233
7253
 
7234
7254
  # Walk prior turns newest-first, keeping condensed lines until the budget is
7235
7255
  # spent, then restore chronological (oldest→newest) order.
@@ -7862,6 +7882,11 @@ def run_agent_chat(cfg):
7862
7882
  needs_agent = True
7863
7883
  else:
7864
7884
  needs_agent = _route(latest_user_text, agent_base_url, agent_api_key, agent_model_id)
7885
+ # A skill the user typed is work to DO with tools — plain chat cannot follow one.
7886
+ if not needs_agent and _skill_call:
7887
+ _log("router → YES (skill invoked)")
7888
+ _emit({"type": "step", "text": "→ Agent mode (skill)"})
7889
+ needs_agent = True
7865
7890
  # A pending email awaiting 'confirm' must reach the agent even though a bare
7866
7891
  # 'confirm' / 'send it' is not a network keyword — otherwise the send never fires.
7867
7892
  if not needs_agent and _email_confirm_pending():
@@ -8270,6 +8295,7 @@ def run_agent_chat(cfg):
8270
8295
  "When you have enough to answer, call final_answer(<your answer>) in a code block. "
8271
8296
  "Prefer FEWER steps, and never repeat a tool call that already succeeded.\n\n"
8272
8297
  "{{TOOL_LIST}}\n" # filled in below, once from the REAL registered tool objects
8298
+ + _skills_mod.prompt_block(_skills)
8273
8299
  + _rag_tool_block + _ws_tool_block + _auto_test_block +
8274
8300
  "\n"
8275
8301
  "http_request RETURN FORMAT: 'HTTP 200\\n{body}' — first line is 'HTTP <code>', body follows.\n"
@@ -12405,6 +12431,62 @@ def run_agent_chat(cfg):
12405
12431
  # (S3 indexing) isn't configured.
12406
12432
  if not _RAG_AVAILABLE:
12407
12433
  agent_tools += [list_dir, read_text_file]
12434
+ # SKILLS (task #269): only offered when at least one exists, like every tool above.
12435
+ if _skills:
12436
+ @tool
12437
+ def use_skill(name: str) -> str:
12438
+ """Load a skill's full instructions by its name from the SKILLS list. Call it FIRST when the task matches a skill, then follow what it returns.
12439
+
12440
+ Args:
12441
+ name: the skill's name, exactly as listed under SKILLS
12442
+ """
12443
+ return _skills_mod.use_skill_text(_skills, name)
12444
+
12445
+ agent_tools.append(use_skill)
12446
+ # MCP SERVERS (task #269): every tool of every server that starts, named
12447
+ # mcp__<server>__<tool>. A server that does not start is a note for this turn, not a
12448
+ # failure of it. Each call passes _mcp_approve first. Not for the between-turns index job,
12449
+ # which calls no model and would start the servers for nothing.
12450
+ _mcp_servers = [s for s in (_mcp_cfg.get("servers") or [])
12451
+ if isinstance(s, dict) and s.get("name") and s.get("command")]
12452
+ if _mcp_servers and not cfg.get("ragIndexOnly"):
12453
+ import atexit as _atexit
12454
+ import gu_mcp as _mcp_mod
12455
+ from smolagents import Tool as _SmolTool
12456
+ _mcp_turn_allowed: set = set()
12457
+
12458
+ def _mcp_approve(server: str, tool_name: str, args: dict):
12459
+ """deny → allow → ask (Claude Code's order). The rules are re-read from the file on
12460
+ every call: an "Always allow" answered a moment ago was written there."""
12461
+ allow, deny = _mcp_mod.read_rules(_mcp_cfg.get("configPath"),
12462
+ _mcp_cfg.get("allow") or [], _mcp_cfg.get("deny") or [])
12463
+ verdict = _mcp_mod.decide(allow, deny, server, tool_name)
12464
+ if verdict == "deny":
12465
+ return False, (f"MCP tool {server}/{tool_name} is blocked by a deny rule in "
12466
+ "~/.gonext/mcp.json. Do not call it again; carry on without it.")
12467
+ if verdict == "allow" or (server, tool_name) in _mcp_turn_allowed:
12468
+ return True, ""
12469
+ if not (_interactive_approval and _job_id and worker_api_base and worker_api_key):
12470
+ return False, (f"MCP tool {server}/{tool_name} needs the user's approval and this "
12471
+ "session cannot ask. Tell the user to allow it in the gu terminal "
12472
+ f"(/mcp allow {server}), then carry on without it.")
12473
+ preview = json.dumps(args, ensure_ascii=False, default=str)
12474
+ if len(preview) > 240:
12475
+ preview = preview[:240] + "…"
12476
+ ok = _ws_request_approval(worker_api_base, worker_api_key, _job_id,
12477
+ f"__MCPTOOL__::{server}\t{tool_name}\t{preview}", "")
12478
+ if ok:
12479
+ _mcp_turn_allowed.add((server, tool_name))
12480
+ return True, ""
12481
+ return False, (f"The user declined MCP tool {server}/{tool_name}. Do not call it again "
12482
+ "this turn; carry on without it or ask the user.")
12483
+
12484
+ _emit({"type": "step", "text": f"Starting {len(_mcp_servers)} MCP server(s)…"})
12485
+ _mcp_conn, _mcp_notes = _mcp_mod.connect_all(_mcp_servers, _WS_ACTIVE or None, log=_log)
12486
+ _atexit.register(lambda: [c.close() for c, _t in _mcp_conn])
12487
+ for _note in _mcp_notes:
12488
+ _emit({"type": "step", "text": _note})
12489
+ agent_tools += _mcp_mod.build_tools(_mcp_conn, _SmolTool, _mcp_approve)
12408
12490
  # Fill in the numbered tool list now that the REAL tool objects exist — see
12409
12491
  # _render_numbered_tool_list and the {{TOOL_LIST}} placeholder left in tool_hint.
12410
12492
  task_with_hint = task_with_hint.replace(
package/gu-cli.mjs CHANGED
@@ -33,6 +33,27 @@ import { createRequire } from "node:module";
33
33
  import dotenv from "dotenv";
34
34
  import OpenAI from "openai";
35
35
  import { resolvePython } from "./platform-tools.mjs";
36
+ import { skillsForTurn } from "./skills.mjs";
37
+ import { mcpForTurn } from "./mcp-config.mjs";
38
+
39
+ /** The `skills` / `mcp` fields of one job's agent config (task #269). A broken config is a log
40
+ * line, never a failed turn — the turn runs without them. */
41
+ function skillsAndMcpForJob(cwd) {
42
+ const out = {};
43
+ try {
44
+ const skills = skillsForTurn({ cwd });
45
+ if (skills.length) out.skills = skills;
46
+ } catch (e) {
47
+ console.log(`[gu-worker] skills skipped: ${e?.message || e}`);
48
+ }
49
+ try {
50
+ const mcp = mcpForTurn({ cwd });
51
+ if (mcp.servers.length) out.mcp = mcp;
52
+ } catch (e) {
53
+ console.log(`[gu-worker] MCP servers skipped: ${e?.message || e}`);
54
+ }
55
+ return out;
56
+ }
36
57
 
37
58
  const WORKER_DIR = dirname(fileURLToPath(import.meta.url));
38
59
 
@@ -223,6 +244,16 @@ if (args[0] === "token") {
223
244
  process.exit(0);
224
245
  }
225
246
 
247
+ if (args[0] === "mcp") {
248
+ // #269. MCP servers from a shell — `claude mcp add`'s shape. Same code as /mcp in the terminal UI.
249
+ const { runMcpCommand } = await import("./mcp-command.mjs");
250
+ const code = await runMcpCommand(args.slice(1), {
251
+ cwd: process.cwd(),
252
+ print: (line, kind) => (kind === "warn" ? console.error(line) : console.log(line)),
253
+ });
254
+ process.exit(code);
255
+ }
256
+
226
257
  if (args[0] === "full-log") {
227
258
  // #250. An administrator switches Enable Full Log (#249) from a terminal — after typing an admin
228
259
  // token minted on the web admin page. Same code as /full-log in the terminal UI.
@@ -3836,6 +3867,10 @@ async function runAgentChatJob(job) {
3836
3867
  // Task #134: set when the terminal's folder is on ANOTHER machine. The agent surfaces
3837
3868
  // it to the user — a silent downgrade to "no file tools" would just look broken.
3838
3869
  ...(remoteWorkspace ? { remoteWorkspace } : {}),
3870
+ // Skills and MCP servers (task #269), read from disk for EVERY job so an edited, added or
3871
+ // deleted SKILL.md / server takes effect on the next turn with no restart. The project's
3872
+ // own ones only when its folder is on this machine. None = the keys are absent.
3873
+ ...skillsAndMcpForJob(terminalActiveWs && wsExistsHere ? terminalActiveWs : null),
3839
3874
  });
3840
3875
  // Agent run time budget (task #81). Default 60 min; user-configurable in web Settings
3841
3876
  // (agentTimeoutMinutes → payload.agentTimeoutMs). Multi-step ReAct on a 14B/31B with a
package/gu-repl.mjs CHANGED
@@ -119,6 +119,9 @@ import {
119
119
  shouldCancelPrompt,
120
120
  } from "./terminal-layout.mjs";
121
121
  import { completedFlush } from "./completed-flush.mjs";
122
+ import { discoverSkills, matchSkillCommand } from "./skills.mjs";
123
+ import { addAllowRule, listServers as listMcpServers } from "./mcp-config.mjs";
124
+ import { unapprovedProjectServers } from "./mcp-command.mjs";
122
125
  import { codingBannerLabel, isOllamaCloudUrl, SECRET_KEY_HINT } from "./coding-key-prompt.mjs";
123
126
  import { codingChoiceTags, coderDisplayName, coderWhere, whereOfActiveCoder } from "./coding-choice-tags.mjs";
124
127
  import {
@@ -532,7 +535,7 @@ const argv = process.argv.slice(2);
532
535
  // process.argv and exits inside its own subcommand branch, before it ever starts polling.
533
536
  const CLI_SUBCOMMANDS = [
534
537
  "status", "start", "stop", "restart", "logs", "doctor", "login", "set", "rag",
535
- "clear", "reset", "api", "token-usage", "tokens", "token", "full-log",
538
+ "clear", "reset", "api", "token-usage", "tokens", "token", "full-log", "mcp",
536
539
  ];
537
540
  // `gu logz` USED TO START THE REPL (task #181). Silently: an unrecognised word was not in
538
541
  // the list, so it fell through to "no subcommand given" and a full agent session opened in that
@@ -807,6 +810,7 @@ const COMMANDS = [
807
810
  { name: "/doctor", desc: "check every configured model ANSWERS — especially the embedder, which a turn only reaches after several steps" },
808
811
  { name: "/tokens", desc: "break down the last prompt sent to the coding model — how much of it was RAG" },
809
812
  { name: "/full-log", usage: "/full-log [on|off|status] [account]", desc: "administrators: Enable Full Log for an account — asks for an admin token from the web admin page first" },
813
+ { name: "/mcp", usage: "/mcp [list|tools|add|remove|enable|disable|approve|allow|disallow|reconnect]", desc: "MCP servers: see which connect, add one (asks step by step), choose which tools run without asking" },
810
814
  { name: "/help", desc: "list these commands" },
811
815
  { name: "/exit", aliases: ["/quit"], desc: "quit" },
812
816
  ];
@@ -814,22 +818,50 @@ const COMMANDS = [
814
818
  // terminal-layout.mjs — their handlers are below, above the unknown-command guard. Not
815
819
  // imported here: nothing in the REPL reads the list, the tests do (task #153).
816
820
  const COMMAND_NAMES = COMMANDS.flatMap((c) => [c.name, ...(c.aliases ?? [])]);
821
+ // SKILLS ARE COMMANDS TOO (task #269): each ~/.gonext/skills/<name>/SKILL.md (and this folder's
822
+ // .gonext/skills) is /<name>, in Tab-completion and /help like any other. Scanned from disk — kept
823
+ // for 2s only, because the overlay asks on every keystroke — so a skill added, edited or DELETED
824
+ // while gu runs shows up (or quietly disappears) without a restart. A built-in name always wins.
825
+ let _skillScan = { at: 0, skills: [] };
826
+ function currentSkills() {
827
+ if (Date.now() - _skillScan.at > 2000) {
828
+ try {
829
+ _skillScan = { at: Date.now(), skills: discoverSkills({ cwd: process.cwd(), reserved: COMMAND_NAMES }).skills };
830
+ } catch {
831
+ _skillScan = { at: Date.now(), skills: [] };
832
+ }
833
+ }
834
+ return _skillScan.skills;
835
+ }
836
+ const skillCommands = () =>
837
+ currentSkills()
838
+ .filter((s) => s.slash)
839
+ .map((s) => ({ name: `/${s.name}`, usage: `/${s.name} [request]`, desc: `skill — ${firstSentence(s.description)}`, skill: true }));
840
+ /** A skill's description is written for the MODEL and often runs a paragraph; /help shows one line. */
841
+ function firstSentence(text, max = 100) {
842
+ const one = String(text ?? "").split(/(?<=[.!?])\s/)[0].trim();
843
+ return one.length > max ? one.slice(0, max - 1).trimEnd() + "…" : one;
844
+ }
845
+ const allCommands = () => [...COMMANDS, ...skillCommands()];
846
+ const allCommandNames = () => [...COMMAND_NAMES, ...skillCommands().map((c) => c.name)];
817
847
  // Tab-completion: when the line starts with "/", complete against the command names.
818
848
  function slashCompleter(line) {
819
849
  if (!line.startsWith("/")) return [[], line];
820
- const hits = COMMAND_NAMES.filter((n) => n.startsWith(line));
821
- return [hits.length ? hits : COMMAND_NAMES, line];
850
+ const names = allCommandNames();
851
+ const hits = names.filter((n) => n.startsWith(line));
852
+ return [hits.length ? hits : names, line];
822
853
  }
823
854
  // Commands whose name/alias matches the typed prefix (all when prefix is "" or "/").
824
855
  function filteredCommands(prefix = "") {
825
856
  const p = (prefix ?? "").trim();
826
- const m = COMMANDS.filter(
857
+ const cmds = allCommands();
858
+ const m = cmds.filter(
827
859
  (c) =>
828
860
  !p ||
829
861
  p === "/" ||
830
862
  [c.name, ...(c.aliases ?? [])].some((n) => n.startsWith(p))
831
863
  );
832
- return m.length ? m : COMMANDS;
864
+ return m.length ? m : cmds;
833
865
  }
834
866
  // Display rows for a filtered command set; row `sel` (if >=0) is highlighted (❯ + bold).
835
867
  function hintRows(cmds, sel) {
@@ -7185,6 +7217,21 @@ async function runFullLogCommand(line) {
7185
7217
  console.log("");
7186
7218
  }
7187
7219
 
7220
+ /**
7221
+ * /mcp (#269) — the same code as `gu mcp`. `/mcp add` alone asks step by step; its questions go
7222
+ * through ask(), whose label is drawn in the frozen row (a readline prompt alone is invisible there).
7223
+ */
7224
+ async function runMcpSlashCommand(line) {
7225
+ const { runMcpCommand, splitWords } = await import("./mcp-command.mjs");
7226
+ const style = { ok: accent, warn: yellow, dim };
7227
+ await runMcpCommand(splitWords(line).slice(1), {
7228
+ cwd: process.cwd(),
7229
+ ask: (label) => ask(label + " "),
7230
+ print: (l, kind) => console.log(GUTTER + (style[kind] ?? ((x) => x))(l)),
7231
+ });
7232
+ console.log("");
7233
+ }
7234
+
7188
7235
  async function runProxyCommand(line) {
7189
7236
  const cmd = await import("./proxy-command.mjs");
7190
7237
  const store = await import("./proxy-store.mjs");
@@ -8250,6 +8297,10 @@ const COMPACT_PREFIX = "__COMPACT__::";
8250
8297
  // Private mode, a file to hand over, and no S3 of the user's own (task #252): set it up now, or
8251
8298
  // save the file on this computer. Allow = S3 is now set up AND passed its test.
8252
8299
  const S3SETUP_PREFIX = "__S3SETUP__::";
8300
+ // An MCP tool about to run for the first time (task #269): "<server>\t<tool>\t<args preview>".
8301
+ // "Always" writes an allow rule to ~/.gonext/mcp.json — the agent re-reads that file before every
8302
+ // call, so it counts from the next call of this same turn, and for every turn after.
8303
+ const MCPTOOL_PREFIX = "__MCPTOOL__::";
8253
8304
  async function approvalPrompt(command) {
8254
8305
  // Wipe the live "thinking" status block BEFORE any picker draws, and stop the ticker from
8255
8306
  // redrawing it (the tick early-returns while a picker is active) — otherwise the block
@@ -8307,6 +8358,29 @@ async function approvalPrompt(command) {
8307
8358
  );
8308
8359
  return { allow: idx === 0, dontAsk: false };
8309
8360
  }
8361
+ if (command.startsWith(MCPTOOL_PREFIX)) {
8362
+ const [server = "", tool = "", preview = ""] = command.slice(MCPTOOL_PREFIX.length).split("\t");
8363
+ if (!process.stdin.isTTY) return { allow: false, dontAsk: false };
8364
+ process.stdout.write(
8365
+ "\n" + yellow(`⚙ Allow the MCP tool ${server}/${tool}?`) + dim(" (no answer in ~3 min = No)") + "\n" +
8366
+ (preview ? dim(" " + preview) + "\n" : "")
8367
+ );
8368
+ const idx = await pickFromList(
8369
+ ["Yes", `Yes, and always allow ${server}/${tool}`, `Yes, and always allow every ${server} tool`, "No"],
8370
+ 0,
8371
+ { timeoutMs: 170000, timeoutIndex: 3 }
8372
+ );
8373
+ if (idx === 1 || idx === 2) {
8374
+ const rule = idx === 1 ? `mcp__${server}__${tool}` : `mcp__${server}__*`;
8375
+ try {
8376
+ addAllowRule({ rule });
8377
+ process.stdout.write(dim(` (won't ask again: ${rule} — undo with /mcp disallow ${server})\n`));
8378
+ } catch (e) {
8379
+ process.stdout.write(yellow(` could not save that (${e?.message || e}) — allowed this once\n`));
8380
+ }
8381
+ }
8382
+ return { allow: idx >= 0 && idx <= 2, dontAsk: false };
8383
+ }
8310
8384
  const isMax = command.startsWith(MAXSTEP_PREFIX);
8311
8385
  if (isMax) {
8312
8386
  const label = command.slice(MAXSTEP_PREFIX.length);
@@ -10665,8 +10739,33 @@ async function main() {
10665
10739
  const plan = acct.plan ? (acct.pro ? accent(acct.plan) : dim(acct.plan)) : "";
10666
10740
  statusRows.push(acct.email ? dim(`account ${acct.email}`) + (plan ? dim(" · ") + plan : "") : dim("plan ") + plan);
10667
10741
  }
10742
+ // Skills and MCP servers (task #269) — one row, only when there are any.
10743
+ try {
10744
+ const nSkills = currentSkills().length;
10745
+ const nMcp = listMcpServers({ cwd: process.cwd() }).servers.filter((x) => x.runnable).length;
10746
+ const bits = [nSkills ? `${nSkills} skill${nSkills === 1 ? "" : "s"}` : "", nMcp ? `${nMcp} MCP server${nMcp === 1 ? "" : "s"}` : ""].filter(Boolean);
10747
+ if (bits.length) statusRows.push(dim(bits.join(" · ") + " (/help · /mcp)"));
10748
+ } catch {
10749
+ /* a broken file is reported below; the banner is not the place to fail */
10750
+ }
10668
10751
  console.log(renderBanner(statusRows));
10669
10752
  }
10753
+ // Skills and MCP (task #269): say what is BROKEN or WAITING, and nothing when all is well. A
10754
+ // deleted skill is not broken — it is simply gone, and says nothing.
10755
+ try {
10756
+ for (const w of discoverSkills({ cwd: process.cwd(), reserved: COMMAND_NAMES }).warnings) {
10757
+ console.log(GUTTER + yellow(w));
10758
+ }
10759
+ const waiting = unapprovedProjectServers(process.cwd());
10760
+ if (waiting.length) {
10761
+ console.log(
10762
+ GUTTER + yellow(`This folder's .mcp.json names ${waiting.length} MCP server(s) that will not start until you approve them: ${waiting.map((x) => x.name).join(", ")}`) +
10763
+ "\n" + GUTTER + dim(" /mcp shows the command each one runs · /mcp approve <name>")
10764
+ );
10765
+ }
10766
+ } catch {
10767
+ /* never block startup on this */
10768
+ }
10670
10769
  // Task #127, Phase 3: if the base chat model server isn't answering, offer to set it up
10671
10770
  // (download + start mlx_lm.server). BC3: only runs when NOTHING answers — an already-up
10672
10771
  // model is left untouched, and this is skipped entirely. Interactive TTYs only.
@@ -11220,6 +11319,10 @@ async function main() {
11220
11319
  await runFullLogCommand(line);
11221
11320
  continue;
11222
11321
  }
11322
+ if (line === "/mcp" || line.startsWith("/mcp ")) {
11323
+ await runMcpSlashCommand(line);
11324
+ continue;
11325
+ }
11223
11326
  if (line === "/proxy" || line.startsWith("/proxy ")) {
11224
11327
  await runProxyCommand(line);
11225
11328
  continue;
@@ -11381,7 +11484,10 @@ async function main() {
11381
11484
  }
11382
11485
  // Any OTHER "/…" is a typo/unknown command — don't send it to the agent as a
11383
11486
  // question; point the user at /help (all real commands were handled above).
11384
- if (line.startsWith("/")) {
11487
+ // …EXCEPT a skill (task #269): "/<skill> request" goes to the agent as typed, and the agent
11488
+ // loads that skill's SKILL.md for the turn. Checked against the disk NOW, so a skill deleted a
11489
+ // moment ago is an unknown command like any other, not an error.
11490
+ if (line.startsWith("/") && !matchSkillCommand(line, currentSkills())) {
11385
11491
  const cmd = line.split(/\s+/)[0];
11386
11492
  console.log(GUTTER + dim(`unknown command: ${cmd} — type /help (or / then Tab) to list commands\n`));
11387
11493
  continue;