@tiens.nguyen/gu-cli 1.0.740 → 1.0.742

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.
@@ -24,6 +24,8 @@ const identity = (s) => s;
24
24
  * and on Ctrl+K to replace one — and they were two hand-written copies of the same idea. A hint
25
25
  * added to one of them would have left the other exactly as confusing.
26
26
  */
27
+ import { keyInUse } from "./server-keys.mjs";
28
+
27
29
  export const SECRET_KEY_HINT = "type or paste the key, then press Enter — it stays masked";
28
30
 
29
31
  /**
@@ -217,13 +219,26 @@ export function codingKeyFallbackLine(kj, jobId = "") {
217
219
  * when a choice HAS been made, and when only one key exists — with one there is nothing to
218
220
  * choose, and a warning on a correct setup is how warnings stop being read.
219
221
  */
220
- export function unchosenKeyWarning({ codingKind = "", codingUrl = "", selectedKeyId = "", keys = null } = {}) {
222
+ export function unchosenKeyWarning({ codingKind = "", codingUrl = "", selectedKeyId = "", keys = null, backendId = "" } = {}) {
221
223
  const quiet = { warn: false, using: null, why: "" };
222
224
  // Local Ollama and MLX need no credential; the Ollama cloud does.
223
225
  if (!codingNeedsKey(codingKind, codingUrl)) return quiet;
224
- if (!Array.isArray(keys) || keys.length < 2) return quiet;
226
+ if (!Array.isArray(keys)) return quiet;
225
227
  if (String(selectedKeyId ?? "").trim()) return quiet;
226
- return { warn: true, using: keys[0], why: "none-chosen" };
228
+ // KEYS BELONG TO A SERVER (#245). Reported: the bar said "key not chosen" beside ollama.com while
229
+ // the key picker showed its one key, hsbc_ollama, "(in use)" — because this counted all seven keys
230
+ // on the ACCOUNT (Anthropic, OpenAI, …) where the API only ever chooses among THIS server's keys.
231
+ // With the server known: its keys, else the keys tied to no server; and a server default IS a
232
+ // choice. Without it (an older caller) the old account-wide count stands.
233
+ if (backendId) {
234
+ const own = keys.filter((k) => k && k.backendId === backendId);
235
+ const pool = own.length ? own : keys.filter((k) => k && !k.backendId);
236
+ if (own.some((k) => k.default)) return quiet;
237
+ if (pool.length < 2) return quiet;
238
+ return { warn: true, using: keyInUse(keys, backendId) ?? pool[0], why: "none-chosen", count: pool.length };
239
+ }
240
+ if (keys.length < 2) return quiet;
241
+ return { warn: true, using: keys[0], why: "none-chosen", count: keys.length };
227
242
  }
228
243
 
229
244
  /**
@@ -245,14 +260,14 @@ export function unchosenKeyWarning({ codingKind = "", codingUrl = "", selectedKe
245
260
  * qualified ("<kind>::<model>", task #123) stores the bare form and is still a valid pick.
246
261
  */
247
262
  /** The folder's coder — its kind AND its URL, which together decide whether it needs a key. */
248
- export function effectiveCoder({ sessionCodingModel = "", choices = null, defaultKind = "", defaultUrl = "" } = {}) {
263
+ export function effectiveCoder({ sessionCodingModel = "", choices = null, defaultKind = "", defaultUrl = "", defaultBackendId = "" } = {}) {
249
264
  const want = String(sessionCodingModel ?? "").trim();
250
265
  const list = Array.isArray(choices) ? choices : [];
251
266
  if (want) {
252
267
  const hit = list.find((c) => c?.id === want || c?.model === want);
253
- if (hit) return { kind: String(hit.kind ?? ""), url: String(hit.url ?? "") };
268
+ if (hit) return { kind: String(hit.kind ?? ""), url: String(hit.url ?? ""), backendId: String(hit.backendId ?? "") };
254
269
  }
255
- return { kind: String(defaultKind ?? ""), url: String(defaultUrl ?? "") };
270
+ return { kind: String(defaultKind ?? ""), url: String(defaultUrl ?? ""), backendId: String(defaultBackendId ?? "") };
256
271
  }
257
272
 
258
273
  export function effectiveCoderKind({
@@ -7251,6 +7251,32 @@ def run_agent_chat(cfg):
7251
7251
  + (_sk_req or "(nothing more — do what the skill describes)"))
7252
7252
  _log(f"skill invoked: {_sk['name']}")
7253
7253
 
7254
+ # HOOKS (task #270): onStart on the first turn of a gu session, onAsk on every question. What
7255
+ # they print goes in beside the task; exit 2 from onAsk stops the turn before any model call.
7256
+ import gu_hooks as _hooks_mod
7257
+ _hooks = _hooks_mod.from_cfg(cfg, _log)
7258
+ _hook_context = ""
7259
+ if _hooks:
7260
+ _hr_parts, _hr_warn = [], []
7261
+ if _hooks.has("onStart") and _hooks_mod.is_new_session(_hooks.cwd or "", _hooks.session_id):
7262
+ _r = _hooks.run("onStart", {"source": "startup"})
7263
+ _hr_parts.append(_r.text)
7264
+ _hr_warn += _r.warnings
7265
+ if _hooks.has("onAsk"):
7266
+ _r = _hooks.run("onAsk", {"question": latest_user_text})
7267
+ _hr_warn += _r.warnings
7268
+ if _r.blocked:
7269
+ _emit({"type": "final", "text": f"Stopped by your onAsk hook: {_r.blocked}"})
7270
+ return
7271
+ _hr_parts.append(_r.text)
7272
+ for _w in _hr_warn:
7273
+ _emit({"type": "step", "text": f"⚠ {_w}"})
7274
+ _hook_context = "\n\n".join(p for p in _hr_parts if p.strip())
7275
+ if _hook_context:
7276
+ _log(f"hooks added {len(_hook_context)} chars of context")
7277
+ task_text += ("\n\nCONTEXT FROM YOUR gu HOOKS (onStart/onAsk — background, not the "
7278
+ f"request itself):\n{_hook_context}")
7279
+
7254
7280
  # Walk prior turns newest-first, keeping condensed lines until the budget is
7255
7281
  # spent, then restore chronological (oldest→newest) order.
7256
7282
  history_lines = []
@@ -8153,7 +8179,10 @@ def run_agent_chat(cfg):
8153
8179
  # told to manage; a model that believes otherwise turns "own the environment" into
8154
8180
  # "write the user instructions for owning it" — see _machine_context.
8155
8181
  + _machine_context(_sys_platform(), _sys_arch(), _WS_ACTIVE or "")
8156
- + "WORK LIKE A SENIOR DEVELOPER — you OWN the task until it DEMONSTRABLY works:\n"
8182
+ # "the task you were GIVEN": ownership is of the request, not of everything it mentions.
8183
+ # `create task "only keep logo on flash screen"` asks for a RECORD; read as "own the logo
8184
+ # change", the agent saved the task and then went on to do it (scaffold rule 8).
8185
+ + "WORK LIKE A SENIOR DEVELOPER — you OWN the task you were GIVEN until it DEMONSTRABLY works:\n"
8157
8186
  " * OUTCOME over exit code: a command exiting 0 is NOT the goal. After any "
8158
8187
  "state-changing step (start/build/install/edit), VERIFY the intended EFFECT "
8159
8188
  "(server responds, artifact exists, test green, file contains the change) before "
@@ -8409,8 +8438,9 @@ def run_agent_chat(cfg):
8409
8438
  # Task #90: ~25-token echo of the autonomy contract at the highest-attention spot
8410
8439
  # (recency — right next to the task), reinforcing the full directive that lives in
8411
8440
  # the cached reference above. Workspace/coding mode only.
8412
- + ("Remember: FINISH it yourself — fix blockers, VERIFY the outcome, then "
8413
- "final_answer with evidence; never hand the task back.\n"
8441
+ + ("Remember: FINISH what was asked yourself — fix blockers, VERIFY the outcome, then "
8442
+ "final_answer with evidence; never hand the task back. If it only asks to RECORD work "
8443
+ "(create a task, a note, a plan), recording it IS the task — do not start the work.\n"
8414
8444
  if _WS_AVAILABLE else "")
8415
8445
  + "\nTASK (answer THIS, choose the tool that fits it):\n"
8416
8446
  f"{task_text}\n"
@@ -12487,6 +12517,11 @@ def run_agent_chat(cfg):
12487
12517
  for _note in _mcp_notes:
12488
12518
  _emit({"type": "step", "text": _note})
12489
12519
  agent_tools += _mcp_mod.build_tools(_mcp_conn, _SmolTool, _mcp_approve)
12520
+ # onToolDone (task #270): after every tool the hooks' matchers name — MCP tools included.
12521
+ if _hooks:
12522
+ _n_wrapped = _hooks_mod.wrap_tools(agent_tools, _hooks)
12523
+ if _n_wrapped:
12524
+ _log(f"onToolDone hooks on {_n_wrapped} tool(s)")
12490
12525
  # Fill in the numbered tool list now that the REAL tool objects exist — see
12491
12526
  # _render_numbered_tool_list and the {{TOOL_LIST}} placeholder left in tool_hint.
12492
12527
  task_with_hint = task_with_hint.replace(
@@ -12661,6 +12696,12 @@ def run_agent_chat(cfg):
12661
12696
  final_text = _strip_tool_tags(str(result).strip()) or "[No result]"
12662
12697
  _log(f"done (deterministic, no summarizer call): {len(final_text)} chars")
12663
12698
  _emit({"type": "final", "text": final_text})
12699
+ # onTurnEnd (task #270): after the answer is out, so a slow hook never delays it.
12700
+ if _hooks and _hooks.has("onTurnEnd"):
12701
+ _te = _hooks.run("onTurnEnd", {"question": latest_user_text, "answer": final_text,
12702
+ "partial": bool(_maxsteps_partial.get("hit"))})
12703
+ for _w in _te.warnings:
12704
+ _log(f"onTurnEnd: {_w}")
12664
12705
  # Clear the step checkpoint ONLY on a genuinely complete finish (the model's own
12665
12706
  # final_answer, or a deterministic PDF delivery) — that content is now in the
12666
12707
  # answer pushed to conversation history, so the raw trace is no longer needed.
package/gu-cli.mjs CHANGED
@@ -35,6 +35,7 @@ import OpenAI from "openai";
35
35
  import { resolvePython } from "./platform-tools.mjs";
36
36
  import { skillsForTurn } from "./skills.mjs";
37
37
  import { mcpForTurn } from "./mcp-config.mjs";
38
+ import { hooksForTurn, sessionForTurn } from "./hooks-config.mjs";
38
39
 
39
40
  /** The `skills` / `mcp` fields of one job's agent config (task #269). A broken config is a log
40
41
  * line, never a failed turn — the turn runs without them. */
@@ -52,6 +53,18 @@ function skillsAndMcpForJob(cwd) {
52
53
  } catch (e) {
53
54
  console.log(`[gu-worker] MCP servers skipped: ${e?.message || e}`);
54
55
  }
56
+ // Hooks (task #270): yours, plus the project's once approved; and the terminal session this
57
+ // turn belongs to, so the agent knows when onStart is due.
58
+ try {
59
+ const hooks = hooksForTurn({ cwd });
60
+ if (Object.keys(hooks).length) {
61
+ out.hooks = hooks;
62
+ const session = sessionForTurn(cwd);
63
+ if (session) out.hookSession = session;
64
+ }
65
+ } catch (e) {
66
+ console.log(`[gu-worker] hooks skipped: ${e?.message || e}`);
67
+ }
55
68
  return out;
56
69
  }
57
70
 
@@ -254,6 +267,16 @@ if (args[0] === "mcp") {
254
267
  process.exit(code);
255
268
  }
256
269
 
270
+ if (args[0] === "hooks") {
271
+ // #270. Hooks from a shell — same code as /hooks in the terminal UI.
272
+ const { runHooksCommand } = await import("./hooks-command.mjs");
273
+ const code = await runHooksCommand(args.slice(1), {
274
+ cwd: process.cwd(),
275
+ print: (line, kind) => (kind === "warn" ? console.error(line) : console.log(line)),
276
+ });
277
+ process.exit(code);
278
+ }
279
+
257
280
  if (args[0] === "full-log") {
258
281
  // #250. An administrator switches Enable Full Log (#249) from a terminal — after typing an admin
259
282
  // token minted on the web admin page. Same code as /full-log in the terminal UI.
package/gu-repl.mjs CHANGED
@@ -35,7 +35,7 @@ import { dirname, join, resolve, basename } from "node:path";
35
35
  import { fileURLToPath } from "node:url";
36
36
  import { PassThrough } from "node:stream";
37
37
  import { createInterface } from "node:readline";
38
- import { createHash } from "node:crypto";
38
+ import { createHash, randomUUID } from "node:crypto";
39
39
  import { createRequire } from "node:module";
40
40
  import dotenv from "dotenv";
41
41
  // Pure layout arithmetic for every in-place block (bottom bar, live status, pickers) — kept
@@ -117,11 +117,14 @@ import {
117
117
  identityRow,
118
118
  CANCEL_LINE,
119
119
  shouldCancelPrompt,
120
+ rankSlashCommands,
121
+ slashMenuWindow,
120
122
  } from "./terminal-layout.mjs";
121
123
  import { completedFlush } from "./completed-flush.mjs";
122
124
  import { discoverSkills, matchSkillCommand } from "./skills.mjs";
123
125
  import { addAllowRule, listServers as listMcpServers } from "./mcp-config.mjs";
124
126
  import { unapprovedProjectServers } from "./mcp-command.mjs";
127
+ import { readHooks } from "./hooks-config.mjs";
125
128
  import { codingBannerLabel, isOllamaCloudUrl, SECRET_KEY_HINT } from "./coding-key-prompt.mjs";
126
129
  import { codingChoiceTags, coderDisplayName, coderWhere, whereOfActiveCoder } from "./coding-choice-tags.mjs";
127
130
  import {
@@ -535,7 +538,7 @@ const argv = process.argv.slice(2);
535
538
  // process.argv and exits inside its own subcommand branch, before it ever starts polling.
536
539
  const CLI_SUBCOMMANDS = [
537
540
  "status", "start", "stop", "restart", "logs", "doctor", "login", "set", "rag",
538
- "clear", "reset", "api", "token-usage", "tokens", "token", "full-log", "mcp",
541
+ "clear", "reset", "api", "token-usage", "tokens", "token", "full-log", "mcp", "hooks",
539
542
  ];
540
543
  // `gu logz` USED TO START THE REPL (task #181). Silently: an unrecognised word was not in
541
544
  // the list, so it fell through to "no subcommand given" and a full agent session opened in that
@@ -810,6 +813,7 @@ const COMMANDS = [
810
813
  { name: "/doctor", desc: "check every configured model ANSWERS — especially the embedder, which a turn only reaches after several steps" },
811
814
  { name: "/tokens", desc: "break down the last prompt sent to the coding model — how much of it was RAG" },
812
815
  { 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" },
816
+ { name: "/hooks", usage: "/hooks [list|approve|unapprove]", desc: "hooks: commands gu runs on onStart, onAsk, onToolDone, onTurnEnd — list them, approve this project's" },
813
817
  { 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" },
814
818
  { name: "/help", desc: "list these commands" },
815
819
  { name: "/exit", aliases: ["/quit"], desc: "quit" },
@@ -1680,14 +1684,28 @@ function slashOverlaySeq(line, cursor, menu, cols) {
1680
1684
  }
1681
1685
 
1682
1686
  // Redraw the input line AND the command menu below it, fully in place.
1687
+ // A short WINDOW over the ranked matches, not the whole registry (task #271): a bare "/" used to
1688
+ // draw every command and skill at once. ↑/↓ move through ALL matches and the window follows.
1689
+ let slashTop = 0;
1690
+ function slashMenuRows(cmds, sel, win, line) {
1691
+ if (!cmds.length) {
1692
+ return [dim(` no command matches ${line.trim().split(/\s+/)[0]} — /help lists them all`)];
1693
+ }
1694
+ const rows = hintRows(cmds.slice(win.from, win.to), sel - win.from);
1695
+ if (win.above) rows.splice(1, 0, dim(` ⋯ ${win.above} more above`));
1696
+ if (win.below) rows.push(dim(` ⋯ ${win.below} more — keep typing to narrow`));
1697
+ return rows;
1698
+ }
1683
1699
  function renderSlashOverlay(sel) {
1684
1700
  const line = rl.line;
1685
- const cmds = filteredCommands(line);
1701
+ const cmds = rankSlashCommands(allCommands(), line);
1686
1702
  slashNames = cmds.map((c) => c.name);
1687
1703
  if (sel >= cmds.length) sel = cmds.length - 1;
1688
1704
  slashSel = sel;
1689
1705
  slashPrefix = line;
1690
- const menu = hintRows(cmds, sel);
1706
+ const win = slashMenuWindow(cmds.length, sel, slashTop);
1707
+ slashTop = win.top;
1708
+ const menu = slashMenuRows(cmds, sel, win, line);
1691
1709
  if (!slashOverlayActive) {
1692
1710
  slashOverlayActive = true;
1693
1711
  slashNeutralizeHistory();
@@ -1712,6 +1730,7 @@ function closeSlashOverlay() {
1712
1730
  slashOverlayActive = false;
1713
1731
  slashHintRows = 0;
1714
1732
  slashSel = -1;
1733
+ slashTop = 0;
1715
1734
  slashNames = [];
1716
1735
  slashRestoreHistory();
1717
1736
  }
@@ -4436,6 +4455,23 @@ async function loadSessionUnfinishedTask(cwd) {
4436
4455
  * `mutate` receives the parsed object and may return a replacement or mutate in place.
4437
4456
  */
4438
4457
  let _sessionWrite = Promise.resolve();
4458
+ /**
4459
+ * Mark the start of a gu session in this folder's session file (task #270). The worker reads the
4460
+ * id back (hooks-config.mjs sessionForTurn) and the agent runs onStart hooks on the first turn it
4461
+ * sees it — which is how "a session started" reaches the agent with nothing added to the API.
4462
+ */
4463
+ async function startHookSession(cwd) {
4464
+ try {
4465
+ await updateSessionFile(cwd, (s) => {
4466
+ s.hookSessionId = randomUUID();
4467
+ s.hookSessionStartedAt = new Date().toISOString();
4468
+ return s;
4469
+ });
4470
+ } catch {
4471
+ /* no session file = no onStart this time, never a failed start */
4472
+ }
4473
+ }
4474
+
4439
4475
  async function updateSessionFile(cwd, mutate) {
4440
4476
  const run = async () => {
4441
4477
  let raw = {};
@@ -6215,6 +6251,8 @@ async function chooseModel() {
6215
6251
  codingKind: "openai",
6216
6252
  selectedKeyId: sessionCodingKeyId,
6217
6253
  keys: keysNow,
6254
+ // Only THIS server's keys are a choice to make (#245) — not every key on the account.
6255
+ backendId: ks.backendId,
6218
6256
  }).warn;
6219
6257
  const line = codingKeyLine({
6220
6258
  keys: keysNow,
@@ -10756,6 +10794,15 @@ async function main() {
10756
10794
  for (const w of discoverSkills({ cwd: process.cwd(), reserved: COMMAND_NAMES }).warnings) {
10757
10795
  console.log(GUTTER + yellow(w));
10758
10796
  }
10797
+ // Hooks (task #270): a Claude Code event name, a broken file, or project hooks waiting.
10798
+ const hk = readHooks({ cwd: process.cwd() });
10799
+ for (const w of hk.warnings) console.log(GUTTER + yellow(w));
10800
+ if (hk.projectPending) {
10801
+ console.log(
10802
+ GUTTER + yellow("This folder's .gonext/settings.json has hooks that will not run until you approve them.") +
10803
+ "\n" + GUTTER + dim(" /hooks shows the commands · /hooks approve")
10804
+ );
10805
+ }
10759
10806
  const waiting = unapprovedProjectServers(process.cwd());
10760
10807
  if (waiting.length) {
10761
10808
  console.log(
@@ -10931,11 +10978,12 @@ async function main() {
10931
10978
  // machine an Ollama coder, which needs no key — while the folder was on gpt-5. Reading
10932
10979
  // it directly made this check answer about a coder nobody was using, and the mark only
10933
10980
  // appeared after /model, which re-probes WITH the override.
10934
- const { kind, url } = effectiveCoder({
10981
+ const { kind, url, backendId } = effectiveCoder({
10935
10982
  sessionCodingModel,
10936
10983
  choices: probe?.codingChoices,
10937
10984
  defaultKind: p?.codingKind,
10938
10985
  defaultUrl: probe?.codingChoices?.find?.((c) => c?.isDefault)?.url ?? "",
10986
+ defaultBackendId: probe?.codingChoices?.find?.((c) => c?.isDefault)?.backendId ?? "",
10939
10987
  });
10940
10988
  const allKeys =
10941
10989
  codingNeedsKey(kind, url) && !sessionCodingKeyId ? await fetchCodingKeys() : null;
@@ -10944,6 +10992,7 @@ async function main() {
10944
10992
  codingUrl: url,
10945
10993
  selectedKeyId: sessionCodingKeyId,
10946
10994
  keys: allKeys,
10995
+ backendId,
10947
10996
  });
10948
10997
  // THE BAR CARRIES IT TOO, from the same decision — a line printed at startup scrolls
10949
10998
  // away within a few turns, and the report was precisely that the warning has to be
@@ -10951,7 +11000,7 @@ async function main() {
10951
11000
  keyChoicePending = w.warn;
10952
11001
  if (w.warn) {
10953
11002
  console.log(
10954
- GUTTER + orange(` ${allKeys.length} API keys stored and this folder has not chosen one`) +
11003
+ GUTTER + orange(` ${w.count} API keys for this server and this folder has not chosen one`) +
10955
11004
  dim(` — using ${w.using.name} ····${w.using.tail}.`)
10956
11005
  );
10957
11006
  console.log(GUTTER + dim(" ctrl+k picks the right one; a wrong key fails mid-task.\n"));
@@ -11065,6 +11114,8 @@ async function main() {
11065
11114
 
11066
11115
  const cwd = resolve(process.cwd());
11067
11116
  const history = await loadSession(cwd);
11117
+ // A new gu session (task #270): the agent runs onStart hooks on the first turn it sees this id.
11118
+ await startHookSession(cwd);
11068
11119
  if (history.length > 0) {
11069
11120
  const turns = history.filter((m) => m.role === "user").length;
11070
11121
  console.log(
@@ -11323,6 +11374,16 @@ async function main() {
11323
11374
  await runMcpSlashCommand(line);
11324
11375
  continue;
11325
11376
  }
11377
+ if (line === "/hooks" || line.startsWith("/hooks ")) {
11378
+ const { runHooksCommand } = await import("./hooks-command.mjs");
11379
+ const style = { ok: accent, warn: yellow, dim };
11380
+ await runHooksCommand(line.split(/\s+/).slice(1), {
11381
+ cwd: process.cwd(),
11382
+ print: (l, kind) => console.log(GUTTER + (style[kind] ?? ((x) => x))(l)),
11383
+ });
11384
+ console.log("");
11385
+ continue;
11386
+ }
11326
11387
  if (line === "/proxy" || line.startsWith("/proxy ")) {
11327
11388
  await runProxyCommand(line);
11328
11389
  continue;
@@ -11415,6 +11476,7 @@ async function main() {
11415
11476
  if (line === "/reset" || line === "/new") {
11416
11477
  history.length = 0;
11417
11478
  await clearSession(cwd);
11479
+ await startHookSession(cwd); // a fresh session: onStart hooks run again (#270)
11418
11480
  console.log(GUTTER + dim("Session cleared — starting fresh.\n"));
11419
11481
  continue;
11420
11482
  }
package/gu_hooks.py ADDED
@@ -0,0 +1,231 @@
1
+ """Hooks for the gu agent (task #270): commands gu runs at fixed moments of a turn.
2
+
3
+ onStart the first turn of a gu session (gu started, or /reset)
4
+ onAsk every question, before the agent starts on it
5
+ onToolDone after a tool the matcher names (gu's tool names: edit_file, grep_repo, mcp__…)
6
+ onTurnEnd after the answer has been sent
7
+
8
+ gu's own names and gu's own tool names, deliberately not Claude Code's. The worker hands the agent
9
+ the hooks that apply (hooks-config.mjs: yours, plus a project's only once approved) and the
10
+ session this turn belongs to.
11
+
12
+ A HOOK IS A COMMAND. It gets one JSON object on stdin and may answer on stdout:
13
+ {"additionalContext": "..."} added for the model (onStart, onAsk; onToolDone: after the result)
14
+ plain text the same as additionalContext
15
+ exit code 2 stop: onAsk stops the turn, onToolDone tells the model; stderr is why
16
+ A hook that fails any other way, or runs past its timeout, is a warning — never a failed turn.
17
+
18
+ WHAT A HOOK SEES: its own env on a short whitelist of this process's (the worker's env carries gu's
19
+ credentials), plus GU_PROJECT_DIR and GU_HOOK_EVENT. It runs in the project folder.
20
+ """
21
+ from __future__ import annotations
22
+
23
+ import json
24
+ import os
25
+ import re
26
+ import subprocess
27
+ import time
28
+ from typing import Any, Callable
29
+
30
+ EVENTS = ("onStart", "onAsk", "onToolDone", "onTurnEnd")
31
+ DEFAULT_TIMEOUT_S = 30
32
+ CONTEXT_CAP_CHARS = 6000
33
+ _ENV_KEEP = ("PATH", "HOME", "LANG", "LC_ALL", "LC_CTYPE", "TMPDIR", "TEMP", "TMP", "SHELL", "USER",
34
+ "USERNAME", "LOGNAME", "TERM", "SYSTEMROOT", "APPDATA", "LOCALAPPDATA", "USERPROFILE",
35
+ "PROGRAMDATA", "PATHEXT", "COMSPEC", "VIRTUAL_ENV", "PYTHONIOENCODING")
36
+ _PLAIN_MATCHER = re.compile(r"^[A-Za-z0-9_\-| ,]*$")
37
+
38
+
39
+ def matches(matcher: str | None, value: str) -> bool:
40
+ """Empty or "*" = everything. Letters, digits, _ - and | = an exact list. Anything else = a regex."""
41
+ m = (matcher or "").strip()
42
+ if m in ("", "*"):
43
+ return True
44
+ if _PLAIN_MATCHER.match(m):
45
+ return value in {x.strip() for x in re.split(r"[|,]", m) if x.strip()}
46
+ try:
47
+ return re.search(m, value) is not None
48
+ except re.error:
49
+ return False
50
+
51
+
52
+ class HookResult:
53
+ def __init__(self):
54
+ self.context: list[str] = []
55
+ self.blocked: str | None = None
56
+ self.warnings: list[str] = []
57
+ self.ran = 0
58
+
59
+ @property
60
+ def text(self) -> str:
61
+ t = "\n\n".join(c for c in self.context if c.strip())
62
+ return t[:CONTEXT_CAP_CHARS] + ("\n… [hook output cut]" if len(t) > CONTEXT_CAP_CHARS else "")
63
+
64
+
65
+ class HookRunner:
66
+ def __init__(self, hooks: dict, cwd: str | None, session_id: str,
67
+ log: Callable[[str], None] = lambda s: None):
68
+ self.hooks = hooks if isinstance(hooks, dict) else {}
69
+ self.cwd = cwd if cwd and os.path.isdir(cwd) else None
70
+ self.session_id = session_id or "unknown"
71
+ self.log = log
72
+
73
+ def has(self, event: str) -> bool:
74
+ return bool(self.hooks.get(event))
75
+
76
+ def matcher_union(self, event: str) -> list[str]:
77
+ return [g.get("matcher", "") for g in self.hooks.get(event) or [] if isinstance(g, dict)]
78
+
79
+ def _env(self, event: str, extra: dict | None) -> dict:
80
+ env = {k: os.environ[k] for k in _ENV_KEEP if k in os.environ}
81
+ env.setdefault("PYTHONIOENCODING", "utf-8")
82
+ env["GU_PROJECT_DIR"] = self.cwd or ""
83
+ env["GU_HOOK_EVENT"] = event
84
+ for k, v in (extra or {}).items():
85
+ env[str(k)] = str(v)
86
+ return env
87
+
88
+ def run(self, event: str, payload: dict, match_value: str = "") -> HookResult:
89
+ res = HookResult()
90
+ if event not in EVENTS:
91
+ return res
92
+ body = {"event": event, "session_id": self.session_id, "cwd": self.cwd or "", **payload}
93
+ data = json.dumps(body, ensure_ascii=False, default=str)
94
+ for group in self.hooks.get(event) or []:
95
+ if not isinstance(group, dict) or not matches(group.get("matcher"), match_value):
96
+ continue
97
+ for h in group.get("hooks") or []:
98
+ if not isinstance(h, dict) or (h.get("type") or "command") != "command" or not h.get("command"):
99
+ continue
100
+ self._one(event, h, data, res)
101
+ return res
102
+
103
+ def _one(self, event: str, h: dict, data: str, res: HookResult) -> None:
104
+ cmd = str(h["command"])
105
+ args = [str(a) for a in (h.get("args") or [])]
106
+ timeout = float(h.get("timeout") or DEFAULT_TIMEOUT_S)
107
+ label = os.path.basename(args[-1] if args else cmd.split()[0])
108
+ t0 = time.time()
109
+ res.ran += 1
110
+ try:
111
+ p = subprocess.run([cmd, *args] if args else cmd, shell=not args, input=data,
112
+ capture_output=True, text=True, encoding="utf-8", errors="replace",
113
+ timeout=timeout, cwd=self.cwd, env=self._env(event, h.get("env")))
114
+ except subprocess.TimeoutExpired:
115
+ res.warnings.append(f"{event} hook {label} took longer than {timeout:.0f}s and was stopped")
116
+ self.log(f"hook {event} {label}: timeout {timeout:.0f}s")
117
+ return
118
+ except OSError as e:
119
+ res.warnings.append(f"{event} hook {label} could not start: {e.strerror or e}")
120
+ self.log(f"hook {event} {label}: could not start")
121
+ return
122
+ ms = int((time.time() - t0) * 1000)
123
+ out = (p.stdout or "").strip()
124
+ err = (p.stderr or "").strip()
125
+ # Sizes only: a hook's output is conversation content, and the log is content-free (#249).
126
+ self.log(f"hook {event} {label}: exit {p.returncode} in {ms}ms, {len(out)} chars out")
127
+ if p.returncode == 2:
128
+ res.blocked = err or f"the {event} hook {label} said no"
129
+ return
130
+ if p.returncode != 0:
131
+ tail = err.splitlines()[-1][:200] if err else ""
132
+ res.warnings.append(f"{event} hook {label} failed (exit {p.returncode})" + (f": {tail}" if tail else ""))
133
+ return
134
+ if not out:
135
+ return
136
+ try:
137
+ obj = json.loads(out)
138
+ except ValueError:
139
+ res.context.append(out) # plain text = context
140
+ return
141
+ if isinstance(obj, dict):
142
+ if str(obj.get("decision", "")).lower() == "block":
143
+ res.blocked = str(obj.get("reason") or err or f"the {event} hook {label} said no")
144
+ return
145
+ ctx = obj.get("additionalContext")
146
+ if isinstance(ctx, str) and ctx.strip():
147
+ res.context.append(ctx)
148
+
149
+
150
+ # ---- onStart: once per session ---------------------------------------------------------------
151
+
152
+ def _state_path() -> str:
153
+ return os.path.join(os.path.expanduser("~"), ".gonext", "hooks-state.json")
154
+
155
+
156
+ def is_new_session(cwd: str, session_id: str, path: str | None = None) -> bool:
157
+ """True the first time this session is seen for this folder — and records it."""
158
+ path = path or _state_path()
159
+ key = os.path.realpath(cwd or "")
160
+ try:
161
+ with open(path, encoding="utf-8") as fh:
162
+ state = json.load(fh)
163
+ if not isinstance(state, dict):
164
+ state = {}
165
+ except (OSError, ValueError):
166
+ state = {}
167
+ if state.get(key) == session_id:
168
+ return False
169
+ state[key] = session_id
170
+ try:
171
+ os.makedirs(os.path.dirname(path), exist_ok=True)
172
+ tmp = f"{path}.{os.getpid()}.tmp"
173
+ with open(tmp, "w", encoding="utf-8") as fh:
174
+ json.dump(state, fh, indent=2)
175
+ os.replace(tmp, path)
176
+ except OSError:
177
+ pass
178
+ return True
179
+
180
+
181
+ # ---- onToolDone: wrapping the agent's tools ---------------------------------------------------
182
+
183
+ def wrap_tools(tools: list, runner: HookRunner, clip: int = 4000) -> int:
184
+ """Run onToolDone after every tool a matcher names. Returns how many tools were wrapped. Hook
185
+ context is appended to the tool's result; a block (exit 2) is appended as a message."""
186
+ if not runner.has("onToolDone"):
187
+ return 0
188
+ matchers = runner.matcher_union("onToolDone")
189
+ n = 0
190
+ for t in tools:
191
+ name = getattr(t, "name", "")
192
+ if not name or name == "final_answer" or not any(matches(m, name) for m in matchers):
193
+ continue
194
+ orig = t.forward
195
+ order = list((getattr(t, "inputs", None) or {}).keys())
196
+
197
+ def forward(*pos, _orig=orig, _name=name, _order=order, **kw):
198
+ result = _orig(*pos, **kw)
199
+ args = dict(kw)
200
+ for k, v in zip(_order, pos):
201
+ args.setdefault(k, v)
202
+ text = str(result)
203
+ r = runner.run("onToolDone", {"tool_name": _name, "tool_input": args,
204
+ "tool_result": text[:clip]}, match_value=_name)
205
+ extra = []
206
+ if r.blocked:
207
+ extra.append(f"[onToolDone hook] {r.blocked}")
208
+ if r.text:
209
+ extra.append(f"[onToolDone hook]\n{r.text}")
210
+ return f"{result}\n\n" + "\n".join(extra) if extra and isinstance(result, str) else result
211
+
212
+ t.forward = forward
213
+ n += 1
214
+ return n
215
+
216
+
217
+ def from_cfg(cfg: dict, log: Callable[[str], None]) -> HookRunner | None:
218
+ hooks = cfg.get("hooks")
219
+ if not isinstance(hooks, dict) or not any(hooks.get(e) for e in EVENTS):
220
+ return None
221
+ sess = cfg.get("hookSession") if isinstance(cfg.get("hookSession"), dict) else {}
222
+ cwd = (cfg.get("activeWorkspace") or "").strip() or None
223
+ if sess.get("id"):
224
+ return HookRunner(hooks, cwd, str(sess["id"]), log)
225
+ # No terminal session (the web, or a terminal older than hooks). The job id would be a NEW
226
+ # "session" every turn — onStart on every question — so onStart is skipped, and the other
227
+ # events share one id per day, which keeps the memory hook's steps together.
228
+ import datetime
229
+ runner = HookRunner(hooks, cwd, "gu-" + datetime.date.today().isoformat(), log)
230
+ runner.hooks = {k: v for k, v in runner.hooks.items() if k != "onStart"}
231
+ return runner
@@ -0,0 +1,71 @@
1
+ /**
2
+ * `gu hooks …` and `/hooks …` — ONE code path (task #270).
3
+ *
4
+ * list every hook for this folder: yours, and the project's with whether it is approved
5
+ * approve run this project's hooks (as they are now — an edit needs approving again)
6
+ * unapprove stop running them
7
+ */
8
+ import { HOOK_EVENTS, describeHook, readHooks, approveProjectHooks, userSettingsPath } from "./hooks-config.mjs";
9
+
10
+ export const HOOKS_USAGE = "gu hooks [list|approve|unapprove]";
11
+
12
+ function printGroups(hooks, print, indent = " ") {
13
+ for (const ev of HOOK_EVENTS) {
14
+ for (const g of hooks[ev] ?? []) {
15
+ const on = g.matcher ? ` (tools: ${g.matcher})` : "";
16
+ for (const h of g.hooks) print(`${indent}${ev.padEnd(11)} ${describeHook(h)}${on}`);
17
+ }
18
+ }
19
+ }
20
+
21
+ export async function runHooksCommand(words, { cwd = process.cwd(), print = console.log } = {}) {
22
+ const sub = String((words ?? [])[0] ?? "list").toLowerCase();
23
+ try {
24
+ if (sub === "list" || sub === "ls") {
25
+ const r = readHooks({ cwd });
26
+ for (const w of r.warnings) print(w, "warn");
27
+ const nUser = Object.keys(r.user).length;
28
+ const nProj = Object.keys(r.project).length;
29
+ if (!nUser && !nProj) {
30
+ print("No hooks.");
31
+ print(`Add them under "hooks" in ${userSettingsPath()} — events: ${HOOK_EVENTS.join(", ")}.`, "dim");
32
+ return 0;
33
+ }
34
+ if (nUser) {
35
+ print(`Yours (${userSettingsPath()}):`, "ok");
36
+ printGroups(r.user, (l) => print(l, "dim"));
37
+ }
38
+ if (nProj) {
39
+ print(
40
+ r.projectApproved
41
+ ? "This project's (./.gonext/settings.json) — approved:"
42
+ : "This project's (./.gonext/settings.json) — NOT running until you approve them:",
43
+ r.projectApproved ? "ok" : "warn"
44
+ );
45
+ printGroups(r.project, (l) => print(l, "dim"));
46
+ if (!r.projectApproved) print("Check the commands above, then: /hooks approve", "dim");
47
+ }
48
+ return 0;
49
+ }
50
+ if (sub === "approve" || sub === "unapprove") {
51
+ const r = readHooks({ cwd });
52
+ if (!Object.keys(r.project).length) {
53
+ print("This folder has no hooks of its own (./.gonext/settings.json).", "warn");
54
+ return 1;
55
+ }
56
+ approveProjectHooks({ cwd, approve: sub === "approve" });
57
+ print(
58
+ sub === "approve"
59
+ ? "Approved: this project's hooks run from the next turn. Editing them needs approving again."
60
+ : "This project's hooks will not run here.",
61
+ "ok"
62
+ );
63
+ return 0;
64
+ }
65
+ print(`unknown "${sub}" — ${HOOKS_USAGE}`, "warn");
66
+ return 2;
67
+ } catch (e) {
68
+ print(e?.message || String(e), "warn");
69
+ return 1;
70
+ }
71
+ }
@@ -0,0 +1,172 @@
1
+ /**
2
+ * HOOKS — commands gu runs at fixed moments of a turn (task #270). Where they are set, and which
3
+ * ones a turn gets.
4
+ *
5
+ * ~/.gonext/settings.json yours, every folder
6
+ * <project>/.gonext/settings.json this project's — run only once you approve them here
7
+ *
8
+ * { "hooks": { "<event>": [ { "matcher": "edit_file|create_file",
9
+ * "hooks": [ { "type": "command", "command": "python3",
10
+ * "args": ["/path/hook.py"], "timeout": 30 } ] } ] } }
11
+ *
12
+ * gu's OWN event names — onStart, onAsk, onToolDone, onTurnEnd — and gu's own tool names in
13
+ * matchers (edit_file, grep_repo, mcp__<server>__<tool>). Claude Code's names (SessionStart, …) are
14
+ * recognised only to say what to write instead: a hook filed under a name gu never fires would
15
+ * otherwise fail in silence.
16
+ *
17
+ * A PROJECT'S HOOKS ARE COMMANDS THAT CAME WITH THE REPOSITORY, so they never run until you approve
18
+ * them for that folder — and the approval is of their exact content (a hash): an edited hooks
19
+ * block needs approving again. The approval lives in YOUR settings file, never the project's.
20
+ *
21
+ * THE SESSION. onStart fires on the first turn of a gu session. The terminal writes a new session
22
+ * id into the folder's session file when it starts (and on /reset); sessionForTurn reads it back so
23
+ * the agent can tell "new session" from "next turn" with nothing added to the API.
24
+ */
25
+ import { createHash } from "node:crypto";
26
+ import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
27
+ import { homedir } from "node:os";
28
+ import { dirname, join, resolve } from "node:path";
29
+
30
+ export const HOOK_EVENTS = ["onStart", "onAsk", "onToolDone", "onTurnEnd"];
31
+ /** Claude Code's names → gu's. Only used to tell the user what to rename. */
32
+ export const CLAUDE_EVENT_NAMES = {
33
+ SessionStart: "onStart",
34
+ UserPromptSubmit: "onAsk",
35
+ PostToolUse: "onToolDone",
36
+ Stop: "onTurnEnd",
37
+ };
38
+
39
+ export const userSettingsPath = (home = homedir()) => join(home, ".gonext", "settings.json");
40
+ export const projectSettingsPath = (cwd) => join(cwd, ".gonext", "settings.json");
41
+
42
+ const isObj = (v) => !!v && typeof v === "object" && !Array.isArray(v);
43
+
44
+ function readJson(file) {
45
+ let text;
46
+ try {
47
+ text = readFileSync(file, "utf8");
48
+ } catch {
49
+ return { data: null };
50
+ }
51
+ try {
52
+ const data = JSON.parse(text);
53
+ return isObj(data) ? { data } : { data: null, error: "not a JSON object" };
54
+ } catch (e) {
55
+ return { data: null, error: `not valid JSON (${e.message})` };
56
+ }
57
+ }
58
+
59
+ /** A settings file's hooks, cleaned: only gu events, only command hooks. → { hooks, warnings } */
60
+ export function cleanHooks(raw, where) {
61
+ const hooks = {};
62
+ const warnings = [];
63
+ if (!isObj(raw)) return { hooks, warnings };
64
+ for (const [event, groups] of Object.entries(raw)) {
65
+ if (!HOOK_EVENTS.includes(event)) {
66
+ const gu = CLAUDE_EVENT_NAMES[event];
67
+ warnings.push(
68
+ gu
69
+ ? `${where}: "${event}" is Claude Code's name — gu calls it "${gu}". Skipped until renamed.`
70
+ : `${where}: "${event}" is not a gu hook event (${HOOK_EVENTS.join(", ")}). Skipped.`
71
+ );
72
+ continue;
73
+ }
74
+ const kept = [];
75
+ for (const g of Array.isArray(groups) ? groups : []) {
76
+ if (!isObj(g)) continue;
77
+ const hs = (Array.isArray(g.hooks) ? g.hooks : [])
78
+ .filter((h) => isObj(h) && (h.type ?? "command") === "command" && String(h.command ?? "").trim())
79
+ .map((h) => ({
80
+ type: "command",
81
+ command: String(h.command),
82
+ ...(Array.isArray(h.args) ? { args: h.args.map(String) } : {}),
83
+ ...(Number.isFinite(h.timeout) && h.timeout > 0 ? { timeout: h.timeout } : {}),
84
+ ...(isObj(h.env) ? { env: Object.fromEntries(Object.entries(h.env).map(([k, v]) => [k, String(v)])) } : {}),
85
+ }));
86
+ if (hs.length) kept.push({ matcher: String(g.matcher ?? ""), hooks: hs });
87
+ }
88
+ if (kept.length) hooks[event] = kept;
89
+ }
90
+ return { hooks, warnings };
91
+ }
92
+
93
+ /** The exact content being approved — any edit changes it. */
94
+ export function hooksFingerprint(hooks) {
95
+ return createHash("sha256").update(JSON.stringify(hooks ?? {})).digest("hex").slice(0, 16);
96
+ }
97
+
98
+ /** → { user, project, projectApproved, projectPending, warnings } */
99
+ export function readHooks({ cwd, home = homedir() } = {}) {
100
+ const warnings = [];
101
+ const u = readJson(userSettingsPath(home));
102
+ if (u.error) warnings.push(`${userSettingsPath(home)} is ${u.error} — its hooks are skipped`);
103
+ const user = cleanHooks(u.data?.hooks, "~/.gonext/settings.json");
104
+ warnings.push(...user.warnings);
105
+ let project = { hooks: {}, warnings: [] };
106
+ let projectApproved = false;
107
+ if (cwd && resolve(projectSettingsPath(cwd)) !== resolve(userSettingsPath(home))) {
108
+ const p = readJson(projectSettingsPath(cwd));
109
+ if (p.error) warnings.push(`${projectSettingsPath(cwd)} is ${p.error} — its hooks are skipped`);
110
+ project = cleanHooks(p.data?.hooks, ".gonext/settings.json");
111
+ warnings.push(...project.warnings);
112
+ const approved = u.data?.approvedProjectHooks?.[resolve(cwd)];
113
+ projectApproved = Object.keys(project.hooks).length > 0 && approved === hooksFingerprint(project.hooks);
114
+ }
115
+ const hasProject = Object.keys(project.hooks).length > 0;
116
+ return {
117
+ user: user.hooks,
118
+ project: project.hooks,
119
+ projectApproved,
120
+ projectPending: hasProject && !projectApproved,
121
+ warnings,
122
+ };
123
+ }
124
+
125
+ /** What one turn runs: yours first, then the project's when approved. */
126
+ export function hooksForTurn({ cwd, home = homedir() } = {}) {
127
+ const r = readHooks({ cwd, home });
128
+ const out = {};
129
+ for (const ev of HOOK_EVENTS) {
130
+ const groups = [...(r.user[ev] ?? []), ...(r.projectApproved ? r.project[ev] ?? [] : [])];
131
+ if (groups.length) out[ev] = groups;
132
+ }
133
+ return out;
134
+ }
135
+
136
+ /** Approve (or withdraw) this folder's hooks, as they are right now. */
137
+ export function approveProjectHooks({ cwd, home = homedir(), approve = true }) {
138
+ const r = readHooks({ cwd, home });
139
+ const file = userSettingsPath(home);
140
+ const { data, error } = readJson(file);
141
+ if (error) throw new Error(`${file} is ${error} — fix or remove it first`);
142
+ const raw = data ?? {};
143
+ raw.approvedProjectHooks = isObj(raw.approvedProjectHooks) ? raw.approvedProjectHooks : {};
144
+ if (approve) raw.approvedProjectHooks[resolve(cwd)] = hooksFingerprint(r.project);
145
+ else delete raw.approvedProjectHooks[resolve(cwd)];
146
+ mkdirSync(dirname(file), { recursive: true });
147
+ const tmp = `${file}.${process.pid}.${Date.now()}.tmp`;
148
+ writeFileSync(tmp, JSON.stringify(raw, null, 2) + "\n", { encoding: "utf8", mode: 0o600 });
149
+ renameSync(tmp, file);
150
+ return r.project;
151
+ }
152
+
153
+ /* ---------------------------------------------------------------- the session ------------ */
154
+
155
+ /** The terminal's per-folder session file — the same name gu-repl.mjs uses. */
156
+ export function sessionFileFor(cwd, home = homedir()) {
157
+ const hash = createHash("sha256").update(cwd).digest("hex").slice(0, 32);
158
+ return join(home, ".gonext", "sessions", `${hash}.json`);
159
+ }
160
+
161
+ /** → { id, startedAt } | null. null = no terminal session (the web, or an older terminal). */
162
+ export function sessionForTurn(cwd, home = homedir()) {
163
+ if (!cwd) return null;
164
+ const { data } = readJson(sessionFileFor(cwd, home));
165
+ const id = data?.hookSessionId;
166
+ return typeof id === "string" && id ? { id, startedAt: String(data.hookSessionStartedAt ?? "") } : null;
167
+ }
168
+
169
+ /** Describe one hook for a list: the command and its args, as one line. */
170
+ export function describeHook(h) {
171
+ return [h.command, ...(h.args ?? [])].map((w) => (/\s/.test(w) ? JSON.stringify(w) : w)).join(" ");
172
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tiens.nguyen/gu-cli",
3
- "version": "1.0.740",
3
+ "version": "1.0.742",
4
4
  "description": "Gu CLI — the gu terminal plus the local worker that runs agent / OCR / PDF / embedding jobs on your Mac (Ollama / MLX / OpenAI-compatible).",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -92,10 +92,13 @@
92
92
  "skills.mjs",
93
93
  "mcp-config.mjs",
94
94
  "mcp-command.mjs",
95
+ "hooks-config.mjs",
96
+ "hooks-command.mjs",
95
97
  "gonext_probe_agent.py",
96
98
  "gonext_agent_chat.py",
97
99
  "gu_mcp.py",
98
100
  "gu_skills.py",
101
+ "gu_hooks.py",
99
102
  "prompt_scaffold_check.py",
100
103
  "gonext_transcribe.py",
101
104
  "gonext_mlx_embed.py",
@@ -2653,3 +2653,51 @@ export function shouldCancelPrompt(awaiting, key) {
2653
2653
  if (key.ctrl) return false;
2654
2654
  return key.sequence === "\u001b";
2655
2655
  }
2656
+
2657
+ /**
2658
+ * THE SLASH MENU SHOWS A FEW, AND NARROWS AS YOU TYPE (task #271), the way Claude Code's does.
2659
+ *
2660
+ * A bare "/" used to print every command at once: sixteen built-ins plus every skill, most of them
2661
+ * settings nobody touches twice, so the one you wanted was somewhere in a wall of text. Now the
2662
+ * menu is a short window (SLASH_MENU_ROWS) over the ranked matches, ↑/↓ scroll it, and each typed
2663
+ * character narrows it. /help still prints everything.
2664
+ *
2665
+ * MATCHING, best first, so the command whose name you are typing is always on top:
2666
+ * 0 the name starts with it /mo → /model
2667
+ * 1 a word of the name starts with it /model → then /agent-model, /embed-model
2668
+ * 2 the name contains it /del → /model …
2669
+ * 3 the description contains it /coding → /model ("switch the CODING model"); 3+ chars
2670
+ * Only the first word counts: "/proxy set" is /proxy with an argument. No match → [] (the menu
2671
+ * says so), never "everything", which is what it used to fall back to.
2672
+ */
2673
+ export const SLASH_MENU_ROWS = 6;
2674
+
2675
+ export function rankSlashCommands(cmds, typed) {
2676
+ const list = cmds ?? [];
2677
+ const word = String(typed ?? "").trim().split(/\s+/)[0].toLowerCase().replace(/^\//, "");
2678
+ if (!word) return list.slice();
2679
+ const scored = [];
2680
+ list.forEach((c, i) => {
2681
+ const names = [c.name, ...(c.aliases ?? [])].map((n) => String(n).toLowerCase().replace(/^\//, ""));
2682
+ let score = -1;
2683
+ if (names.some((n) => n.startsWith(word))) score = 0;
2684
+ else if (names.some((n) => n.split(/[-_]/).some((part) => part.startsWith(word)))) score = 1;
2685
+ else if (names.some((n) => n.includes(word))) score = 2;
2686
+ else if (word.length >= 3 && String(c.desc ?? "").toLowerCase().includes(word)) score = 3;
2687
+ if (score >= 0) scored.push({ c, score, i });
2688
+ });
2689
+ scored.sort((a, b) => a.score - b.score || a.i - b.i);
2690
+ return scored.map((s) => s.c);
2691
+ }
2692
+
2693
+ /**
2694
+ * Which slice of the ranked list is on screen. `sel` -1 = nothing highlighted (window at the top).
2695
+ * → { from, to, above, below, top } — above/below are how many are hidden on each side.
2696
+ */
2697
+ export function slashMenuWindow(total, sel, top, rows = SLASH_MENU_ROWS) {
2698
+ const n = Math.max(0, total);
2699
+ if (n <= rows) return { from: 0, to: n, above: 0, below: 0, top: 0 };
2700
+ // listWindow spends two of its rows on the indicators; give it room for exactly `rows` items.
2701
+ const w = listWindow(n, sel < 0 ? 0 : sel, sel < 0 ? 0 : top, rows + 2);
2702
+ return { from: w.from, to: w.to, above: w.above, below: w.below, top: w.top };
2703
+ }