@tiens.nguyen/gu-cli 1.0.739 → 1.0.741

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/mcp-config.mjs ADDED
@@ -0,0 +1,329 @@
1
+ /**
2
+ * MCP SERVERS — where they are configured, and who may call their tools (task #269).
3
+ *
4
+ * ~/.gonext/mcp.json yours: { mcpServers, disabled, permissions: { allow, deny }, approvedProjects }
5
+ * <project>/.mcp.json the project's — Claude Code's own file and format, so one file serves both
6
+ *
7
+ * The shapes are Claude Code's: `mcpServers: { name: { type: "stdio", command, args, env } }`, and
8
+ * permission rules `mcp__<server>` / `mcp__<server>__*` / `mcp__<server>__<tool>`. A command copied
9
+ * from an MCP server's README (`claude mcp add …`) works as `gu mcp add …`.
10
+ *
11
+ * A PROJECT'S SERVERS DO NOT START UNTIL YOU APPROVE THEM. Starting a stdio server runs a command,
12
+ * and a project's .mcp.json came with the repository — cloning something is not agreeing to run
13
+ * what it names. Claude Code asks before connecting them for the same reason. Approval is per
14
+ * folder and per server, and lives in YOUR file, never in the project's.
15
+ *
16
+ * PERMISSIONS: deny, then ask, then allow — the first rule that matches decides, and a deny can
17
+ * never be carved open by a narrower allow (Claude Code's order). No rule = ask. A server saying a
18
+ * tool is read-only (readOnlyHint) is NOT a rule: the MCP spec says clients must treat those hints
19
+ * as untrusted, and a hostile server would simply claim it.
20
+ *
21
+ * ONE WRITER SHAPE. Both `gu mcp` and `/mcp` go through updateUserConfig, which re-reads the file,
22
+ * changes one thing and writes it back atomically — keys it does not know are kept. Rebuilding the
23
+ * object from what one side knows is how a settings screen once deleted the other side's entries.
24
+ */
25
+ import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
26
+ import { homedir } from "node:os";
27
+ import { dirname, join, resolve } from "node:path";
28
+
29
+ export const SERVER_NAME_RE = /^[A-Za-z0-9_-]{1,64}$/;
30
+
31
+ export function userConfigPath(home = homedir()) {
32
+ return join(home, ".gonext", "mcp.json");
33
+ }
34
+ export function projectConfigPath(cwd) {
35
+ return join(cwd, ".mcp.json");
36
+ }
37
+
38
+ function readJson(file) {
39
+ let text;
40
+ try {
41
+ text = readFileSync(file, "utf8");
42
+ } catch {
43
+ return { data: null }; // no file: nothing configured, not an error
44
+ }
45
+ try {
46
+ const data = JSON.parse(text);
47
+ if (!data || typeof data !== "object" || Array.isArray(data)) return { data: null, error: "not a JSON object" };
48
+ return { data };
49
+ } catch (e) {
50
+ return { data: null, error: `not valid JSON (${e.message})` };
51
+ }
52
+ }
53
+
54
+ export function readUserConfig(home = homedir()) {
55
+ const { data, error } = readJson(userConfigPath(home));
56
+ const cfg = data ?? {};
57
+ return {
58
+ raw: cfg,
59
+ error,
60
+ mcpServers: isObj(cfg.mcpServers) ? cfg.mcpServers : {},
61
+ disabled: Array.isArray(cfg.disabled) ? cfg.disabled.map(String) : [],
62
+ allow: Array.isArray(cfg.permissions?.allow) ? cfg.permissions.allow.map(String) : [],
63
+ deny: Array.isArray(cfg.permissions?.deny) ? cfg.permissions.deny.map(String) : [],
64
+ approvedProjects: isObj(cfg.approvedProjects) ? cfg.approvedProjects : {},
65
+ };
66
+ }
67
+
68
+ const isObj = (v) => !!v && typeof v === "object" && !Array.isArray(v);
69
+
70
+ /** Write ~/.gonext/mcp.json atomically: a crash mid-write leaves the old file, never half of one. */
71
+ function writeUserRaw(raw, home) {
72
+ const file = userConfigPath(home);
73
+ mkdirSync(dirname(file), { recursive: true });
74
+ const tmp = `${file}.${process.pid}.${Date.now()}.tmp`;
75
+ writeFileSync(tmp, JSON.stringify(raw, null, 2) + "\n", { encoding: "utf8", mode: 0o600 });
76
+ renameSync(tmp, file);
77
+ }
78
+
79
+ /**
80
+ * Re-read, change, write back. `mutate(raw)` edits the parsed file in place. A file that is not
81
+ * valid JSON is refused rather than overwritten — it may be hand-edited and one typo from fine.
82
+ */
83
+ export function updateUserConfig(mutate, home = homedir()) {
84
+ const { data, error } = readJson(userConfigPath(home));
85
+ if (error) throw new Error(`${userConfigPath(home)} is ${error} — fix or remove it first`);
86
+ const raw = data ?? {};
87
+ mutate(raw);
88
+ writeUserRaw(raw, home);
89
+ return raw;
90
+ }
91
+
92
+ /** Normalize one server entry → { type, command, args, env } | { error }. */
93
+ export function normalizeServer(entry) {
94
+ if (!isObj(entry)) return { error: "not an object" };
95
+ const type = String(entry.type ?? (entry.url ? "http" : "stdio"));
96
+ if (type !== "stdio") return { error: `transport "${type}" is not supported yet — only stdio` };
97
+ const command = String(entry.command ?? "").trim();
98
+ if (!command) return { error: "no command" };
99
+ const args = Array.isArray(entry.args) ? entry.args.map(String) : [];
100
+ const env = isObj(entry.env) ? Object.fromEntries(Object.entries(entry.env).map(([k, v]) => [k, String(v)])) : {};
101
+ return { type, command, args, env };
102
+ }
103
+
104
+ /**
105
+ * Every server this folder knows of, with its state.
106
+ * → { servers: [{ name, scope, type, command, args, env, disabled, approved, runnable, error? }], warnings }
107
+ */
108
+ export function listServers({ cwd = process.cwd(), home = homedir() } = {}) {
109
+ const user = readUserConfig(home);
110
+ const warnings = [];
111
+ if (user.error) warnings.push(`${userConfigPath(home)} is ${user.error} — its servers are skipped`);
112
+ const servers = [];
113
+ const seen = new Set();
114
+ const add = (name, entry, scope, approved) => {
115
+ if (!SERVER_NAME_RE.test(name)) {
116
+ warnings.push(`${scope} MCP server "${name}": the name must be letters, digits, _ or - — skipped`);
117
+ return;
118
+ }
119
+ if (seen.has(name)) return; // yours wins over the project's
120
+ seen.add(name);
121
+ const n = normalizeServer(entry);
122
+ const disabled = user.disabled.includes(name);
123
+ servers.push({
124
+ name,
125
+ scope,
126
+ ...(n.error ? { error: n.error } : n),
127
+ disabled,
128
+ approved,
129
+ runnable: !n.error && !disabled && approved,
130
+ });
131
+ };
132
+ for (const [name, entry] of Object.entries(user.mcpServers)) add(name, entry, "user", true);
133
+ if (cwd) {
134
+ const proj = readJson(projectConfigPath(cwd));
135
+ if (proj.error) warnings.push(`${projectConfigPath(cwd)} is ${proj.error} — its servers are skipped`);
136
+ const approvedHere = (user.approvedProjects[resolve(cwd)] ?? []).map(String);
137
+ for (const [name, entry] of Object.entries(isObj(proj.data?.mcpServers) ? proj.data.mcpServers : {})) {
138
+ add(name, entry, "project", approvedHere.includes(name));
139
+ }
140
+ }
141
+ return { servers, warnings };
142
+ }
143
+
144
+ /** What the worker hands the agent for one turn: the servers it may start, and the rules. */
145
+ export function mcpForTurn({ cwd, home = homedir() } = {}) {
146
+ const { servers } = listServers({ cwd, home });
147
+ const user = readUserConfig(home);
148
+ return {
149
+ servers: servers
150
+ .filter((s) => s.runnable)
151
+ .map(({ name, command, args, env, scope }) => ({ name, command, args, env, scope })),
152
+ allow: user.allow,
153
+ deny: user.deny,
154
+ configPath: userConfigPath(home),
155
+ };
156
+ }
157
+
158
+ /* ---------------------------------------------------------------- permissions ---------- */
159
+
160
+ /** Does a rule name this server's tool? `mcp__s`, `mcp__s__*`, `mcp__s__tool`, `mcp__s__get_*`, `mcp__*`. */
161
+ export function ruleMatches(rule, server, tool) {
162
+ const r = String(rule).trim();
163
+ if (r === "mcp__*" || r === "*") return true;
164
+ if (r === `mcp__${server}`) return true;
165
+ const full = `mcp__${server}__${tool}`;
166
+ if (!r.includes("*")) return r === full;
167
+ const re = new RegExp("^" + r.split("*").map((p) => p.replace(/[.+?^${}()|[\]\\]/g, "\\$&")).join(".*") + "$");
168
+ return re.test(full);
169
+ }
170
+
171
+ /** → "deny" | "allow" | "ask". Deny first, then allow; nothing matching = ask. */
172
+ export function decide({ allow = [], deny = [] }, server, tool) {
173
+ if (deny.some((r) => ruleMatches(r, server, tool))) return "deny";
174
+ if (allow.some((r) => ruleMatches(r, server, tool))) return "allow";
175
+ return "ask";
176
+ }
177
+
178
+ /* ---------------------------------------------------------------- changes -------------- */
179
+
180
+ export function addServer({ name, command, args = [], env = {}, scope = "user", cwd, home = homedir() }) {
181
+ if (!SERVER_NAME_RE.test(name ?? "")) throw new Error(`"${name}" is not a valid name — use letters, digits, _ or -`);
182
+ if (!String(command ?? "").trim()) throw new Error("no command given");
183
+ const entry = { type: "stdio", command: String(command), args: args.map(String), env };
184
+ if (scope === "project") {
185
+ if (!cwd) throw new Error("no project folder");
186
+ const file = projectConfigPath(cwd);
187
+ const { data, error } = readJson(file);
188
+ if (error) throw new Error(`${file} is ${error} — fix or remove it first`);
189
+ const raw = data ?? {};
190
+ raw.mcpServers = isObj(raw.mcpServers) ? raw.mcpServers : {};
191
+ raw.mcpServers[name] = entry;
192
+ const tmp = `${file}.${process.pid}.${Date.now()}.tmp`;
193
+ writeFileSync(tmp, JSON.stringify(raw, null, 2) + "\n", "utf8");
194
+ renameSync(tmp, file);
195
+ // You added it yourself, from this folder: that IS the approval.
196
+ approveProjectServer({ name, cwd, home });
197
+ return { file };
198
+ }
199
+ updateUserConfig((raw) => {
200
+ raw.mcpServers = isObj(raw.mcpServers) ? raw.mcpServers : {};
201
+ raw.mcpServers[name] = entry;
202
+ if (Array.isArray(raw.disabled)) raw.disabled = raw.disabled.filter((d) => d !== name);
203
+ }, home);
204
+ return { file: userConfigPath(home) };
205
+ }
206
+
207
+ /** Remove from YOUR file. A project's server cannot be removed from here — only not approved. */
208
+ export function removeServer({ name, home = homedir() }) {
209
+ let found = false;
210
+ updateUserConfig((raw) => {
211
+ if (isObj(raw.mcpServers) && name in raw.mcpServers) {
212
+ delete raw.mcpServers[name];
213
+ found = true;
214
+ }
215
+ if (Array.isArray(raw.disabled)) raw.disabled = raw.disabled.filter((d) => d !== name);
216
+ if (isObj(raw.permissions)) {
217
+ for (const k of ["allow", "deny"]) {
218
+ if (Array.isArray(raw.permissions[k])) {
219
+ raw.permissions[k] = raw.permissions[k].filter((r) => !(r === `mcp__${name}` || String(r).startsWith(`mcp__${name}__`)));
220
+ }
221
+ }
222
+ }
223
+ }, home);
224
+ return found;
225
+ }
226
+
227
+ export function setDisabled({ name, disabled, home = homedir() }) {
228
+ updateUserConfig((raw) => {
229
+ const list = Array.isArray(raw.disabled) ? raw.disabled.filter((d) => d !== name) : [];
230
+ if (disabled) list.push(name);
231
+ raw.disabled = list;
232
+ }, home);
233
+ }
234
+
235
+ export function approveProjectServer({ name, cwd, home = homedir(), approve = true }) {
236
+ const key = resolve(cwd);
237
+ updateUserConfig((raw) => {
238
+ raw.approvedProjects = isObj(raw.approvedProjects) ? raw.approvedProjects : {};
239
+ const list = (raw.approvedProjects[key] ?? []).filter((n) => n !== name);
240
+ if (approve) list.push(name);
241
+ raw.approvedProjects[key] = list;
242
+ }, home);
243
+ }
244
+
245
+ /** Add an allow rule (no duplicates). Used by "Always allow" at the approval prompt. */
246
+ export function addAllowRule({ rule, home = homedir() }) {
247
+ updateUserConfig((raw) => {
248
+ raw.permissions = isObj(raw.permissions) ? raw.permissions : {};
249
+ const allow = Array.isArray(raw.permissions.allow) ? raw.permissions.allow : [];
250
+ if (!allow.includes(rule)) allow.push(rule);
251
+ raw.permissions.allow = allow;
252
+ }, home);
253
+ }
254
+
255
+ export function removeAllowRules({ server, home = homedir() }) {
256
+ updateUserConfig((raw) => {
257
+ if (isObj(raw.permissions) && Array.isArray(raw.permissions.allow)) {
258
+ raw.permissions.allow = raw.permissions.allow.filter(
259
+ (r) => !(r === `mcp__${server}` || String(r).startsWith(`mcp__${server}__`))
260
+ );
261
+ }
262
+ }, home);
263
+ }
264
+
265
+ /* ---------------------------------------------------------------- display -------------- */
266
+
267
+ const SECRET_KEY_RE = /TOKEN|SECRET|PASSWORD|PASSWD|KEY|AUTH|CREDENTIAL/i;
268
+
269
+ /** Env for display: values of secret-looking names become "****" + last 2. */
270
+ export function maskEnv(env = {}) {
271
+ return Object.fromEntries(
272
+ Object.entries(env).map(([k, v]) => [k, SECRET_KEY_RE.test(k) ? (String(v).length > 6 ? `****${String(v).slice(-2)}` : "****") : v])
273
+ );
274
+ }
275
+
276
+ /** One line naming the command, with secret env values masked. */
277
+ export function describeServer(s) {
278
+ const env = Object.entries(maskEnv(s.env ?? {})).map(([k, v]) => `${k}=${v}`);
279
+ return [...env, s.command, ...(s.args ?? [])].map((w) => (/\s/.test(w) ? JSON.stringify(w) : w)).join(" ");
280
+ }
281
+
282
+ /* ---------------------------------------------------------------- `add` arguments ------ */
283
+
284
+ export const ADD_USAGE =
285
+ "gu mcp add [--scope user|project] [-e KEY=value …] <name> -- <command> [args…]";
286
+
287
+ /**
288
+ * `claude mcp add`'s shape: options, then the name, then `--`, then the command. Without a `--`
289
+ * the first word after the name starts the command (a common way people type it).
290
+ * → { name, command, args, env, scope } | { error }
291
+ */
292
+ export function parseAddArgs(words) {
293
+ const w = (words ?? []).map(String);
294
+ const dash = w.indexOf("--");
295
+ const head = dash >= 0 ? w.slice(0, dash) : w;
296
+ let tail = dash >= 0 ? w.slice(dash + 1) : [];
297
+ const env = {};
298
+ let scope = "user";
299
+ let name = null;
300
+ for (let i = 0; i < head.length; i++) {
301
+ const a = head[i];
302
+ if (a === "-e" || a === "--env") {
303
+ const kv = head[++i] ?? "";
304
+ const eq = kv.indexOf("=");
305
+ if (eq <= 0) return { error: `-e needs KEY=value, got "${kv}" — ${ADD_USAGE}` };
306
+ env[kv.slice(0, eq)] = kv.slice(eq + 1);
307
+ } else if (a === "-s" || a === "--scope") {
308
+ scope = head[++i] ?? "";
309
+ if (scope === "local") scope = "user"; // Claude Code's default scope; gu's user file is the same idea
310
+ if (!["user", "project"].includes(scope)) return { error: `--scope must be user or project — ${ADD_USAGE}` };
311
+ } else if (a === "-t" || a === "--transport") {
312
+ const t = head[++i] ?? "";
313
+ if (t !== "stdio") return { error: `transport "${t}" is not supported yet — only stdio` };
314
+ } else if (a.startsWith("-")) {
315
+ return { error: `unknown option ${a} — ${ADD_USAGE}` };
316
+ } else if (name === null) {
317
+ name = a;
318
+ } else if (dash < 0) {
319
+ tail = head.slice(i);
320
+ break;
321
+ } else {
322
+ return { error: `unexpected "${a}" before -- — ${ADD_USAGE}` };
323
+ }
324
+ }
325
+ if (!name) return { error: `no name — ${ADD_USAGE}` };
326
+ if (!SERVER_NAME_RE.test(name)) return { error: `"${name}" is not a valid name — use letters, digits, _ or -` };
327
+ if (!tail.length) return { error: `no command — ${ADD_USAGE}` };
328
+ return { name, command: tail[0], args: tail.slice(1), env, scope };
329
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tiens.nguyen/gu-cli",
3
- "version": "1.0.739",
3
+ "version": "1.0.741",
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",
@@ -89,8 +89,16 @@
89
89
  "add-server.mjs",
90
90
  "powershell-alias.mjs",
91
91
  "install-path.mjs",
92
+ "skills.mjs",
93
+ "mcp-config.mjs",
94
+ "mcp-command.mjs",
95
+ "hooks-config.mjs",
96
+ "hooks-command.mjs",
92
97
  "gonext_probe_agent.py",
93
98
  "gonext_agent_chat.py",
99
+ "gu_mcp.py",
100
+ "gu_skills.py",
101
+ "gu_hooks.py",
94
102
  "prompt_scaffold_check.py",
95
103
  "gonext_transcribe.py",
96
104
  "gonext_mlx_embed.py",
package/skills.mjs ADDED
@@ -0,0 +1,175 @@
1
+ /**
2
+ * SKILLS — folders with a SKILL.md, the way Claude Code does them (task #269).
3
+ *
4
+ * ~/.gonext/skills/<name>/SKILL.md yours, every folder
5
+ * <project>/.gonext/skills/<name>/SKILL.md this project only
6
+ *
7
+ * A SKILL.md is YAML frontmatter (name, description) and a body of instructions. Only the name and
8
+ * description go into every prompt; the body is read when the agent calls use_skill(name) or the
9
+ * user types /<name> — never pasted into every turn. Same split as Claude Code, and for the same
10
+ * reason: a dozen skills must not cost a dozen bodies on every step.
11
+ *
12
+ * READ FROM DISK EVERY TIME, NOTHING CACHED. The user edits these files, renames them and deletes
13
+ * them while gu is running, and each of those has to take effect on the next scan with no restart,
14
+ * no reinstall and no code change. Nothing in gu names a skill or knows what one says.
15
+ *
16
+ * A REMOVED SKILL IS NOT AN ERROR. A folder with no SKILL.md in it, a skills folder that does not
17
+ * exist, no skills at all: every one of those is "the user has no such skill", which is a normal
18
+ * state and says nothing. Only a file that EXISTS and cannot be used (no frontmatter, no
19
+ * description, a bad name) earns a warning — that one is a mistake the user would want to hear about.
20
+ *
21
+ * WHEN BOTH HAVE ONE: yours wins over the project's, which is Claude Code's order (personal over
22
+ * project) — a repository you cloned should not be able to replace a skill you wrote.
23
+ */
24
+ import { readdirSync, readFileSync, statSync } from "node:fs";
25
+ import { homedir } from "node:os";
26
+ import { join } from "node:path";
27
+
28
+ export const SKILL_FILE = "SKILL.md";
29
+ /** Claude Code's rule: lowercase letters, digits and hyphens, at most 64. It is also a slash command. */
30
+ export const SKILL_NAME_RE = /^[a-z0-9][a-z0-9-]{0,63}$/;
31
+
32
+ export function userSkillsDir(home = homedir()) {
33
+ return join(home, ".gonext", "skills");
34
+ }
35
+ export function projectSkillsDir(cwd) {
36
+ return join(cwd, ".gonext", "skills");
37
+ }
38
+
39
+ /** Strip one pair of matching quotes. */
40
+ function unquote(v) {
41
+ const s = v.trim();
42
+ if (s.length >= 2 && ((s[0] === '"' && s.at(-1) === '"') || (s[0] === "'" && s.at(-1) === "'"))) {
43
+ return s.slice(1, -1);
44
+ }
45
+ return s;
46
+ }
47
+
48
+ /**
49
+ * The frontmatter a SKILL.md actually uses: `key: value` lines, quoted or not, plus the block
50
+ * scalars `key: >` / `key: |` for a long description. Not a YAML parser — nothing here needs one,
51
+ * and a dependency for two fields is a dependency to install on every machine. Unknown keys are
52
+ * kept and ignored, so a file written for Claude Code (allowed-tools, model, …) loads unchanged.
53
+ *
54
+ * → { data, body } | { error }
55
+ */
56
+ export function parseFrontmatter(text) {
57
+ const src = String(text ?? "").replace(/^/, "").replace(/\r\n/g, "\n");
58
+ if (!src.startsWith("---\n")) return { error: "no frontmatter (the file must start with a --- line)" };
59
+ const end = src.indexOf("\n---", 4);
60
+ if (end < 0) return { error: "the frontmatter is never closed (no second --- line)" };
61
+ const lines = src.slice(4, end).split("\n");
62
+ const after = src.slice(end + 4);
63
+ const body = after.replace(/^[^\n]*\n?/, ""); // the rest of the closing --- line
64
+ const data = {};
65
+ for (let i = 0; i < lines.length; i++) {
66
+ const m = /^([A-Za-z0-9_-]+):\s?(.*)$/.exec(lines[i]);
67
+ if (!m) continue; // a comment, a blank, or a nested line of a key we do not read
68
+ const [, key, rest] = m;
69
+ const v = rest.trim();
70
+ if (v === ">" || v === "|" || v === ">-" || v === "|-") {
71
+ const block = [];
72
+ while (i + 1 < lines.length && (/^\s+\S/.test(lines[i + 1]) || lines[i + 1].trim() === "")) {
73
+ block.push(lines[++i].trim());
74
+ }
75
+ data[key] = v.startsWith(">") ? block.filter(Boolean).join(" ") : block.join("\n").trim();
76
+ } else {
77
+ data[key] = unquote(v);
78
+ }
79
+ }
80
+ return { data, body };
81
+ }
82
+
83
+ function isDir(p) {
84
+ try {
85
+ return statSync(p).isDirectory();
86
+ } catch {
87
+ return false;
88
+ }
89
+ }
90
+
91
+ /** One skills folder → { skills, warnings }. A folder that is not there is simply empty. */
92
+ function scanDir(dir, scope) {
93
+ const skills = [];
94
+ const warnings = [];
95
+ let entries;
96
+ try {
97
+ entries = readdirSync(dir, { withFileTypes: true });
98
+ } catch {
99
+ return { skills, warnings }; // no skills folder: no skills, nothing to say
100
+ }
101
+ for (const e of entries) {
102
+ if (e.name.startsWith(".")) continue; // .DS_Store and friends
103
+ const folder = join(dir, e.name);
104
+ if (!(e.isDirectory() || (e.isSymbolicLink() && isDir(folder)))) continue;
105
+ const file = join(folder, SKILL_FILE);
106
+ let text;
107
+ try {
108
+ text = readFileSync(file, "utf8");
109
+ } catch {
110
+ continue; // SKILL.md removed (or never written): the skill does not exist — silently
111
+ }
112
+ const parsed = parseFrontmatter(text);
113
+ const where = `${scope} skill "${e.name}"`;
114
+ if (parsed.error) {
115
+ warnings.push(`${where}: ${parsed.error} — skipped`);
116
+ continue;
117
+ }
118
+ // Claude Code: `name` is optional and defaults to the folder's name.
119
+ const name = (parsed.data.name || e.name).trim();
120
+ const description = (parsed.data.description || "").trim();
121
+ if (!SKILL_NAME_RE.test(name)) {
122
+ warnings.push(`${where}: name "${name}" must be lowercase letters, digits and hyphens (max 64) — skipped`);
123
+ continue;
124
+ }
125
+ if (!description) {
126
+ // Without one the agent has nothing to decide by: the skill could only ever be typed.
127
+ warnings.push(`${where}: no description in the frontmatter — skipped`);
128
+ continue;
129
+ }
130
+ skills.push({ name, description, path: file, dir: folder, scope });
131
+ }
132
+ skills.sort((a, b) => a.name.localeCompare(b.name));
133
+ return { skills, warnings };
134
+ }
135
+
136
+ /**
137
+ * Every skill that exists right now. `reserved` = names that are already commands (/help, /mcp…):
138
+ * the built-in wins, and the skill is still usable by the agent, just not as a slash command —
139
+ * that is `slash: false`.
140
+ *
141
+ * → { skills: [{ name, description, path, dir, scope, slash }], warnings: string[] }
142
+ */
143
+ export function discoverSkills({ cwd = process.cwd(), home = homedir(), reserved = [] } = {}) {
144
+ const user = scanDir(userSkillsDir(home), "user");
145
+ const project =
146
+ cwd && projectSkillsDir(cwd) !== userSkillsDir(home)
147
+ ? scanDir(projectSkillsDir(cwd), "project")
148
+ : { skills: [], warnings: [] };
149
+ const byName = new Map();
150
+ for (const s of user.skills) byName.set(s.name, s);
151
+ for (const s of project.skills) if (!byName.has(s.name)) byName.set(s.name, s);
152
+ const taken = new Set(reserved.map((r) => r.replace(/^\//, "")));
153
+ const skills = [...byName.values()]
154
+ .sort((a, b) => a.name.localeCompare(b.name))
155
+ .map((s) => ({ ...s, slash: !taken.has(s.name) }));
156
+ return { skills, warnings: [...user.warnings, ...project.warnings] };
157
+ }
158
+
159
+ /** What the worker hands the agent for one turn: enough to list them and to read one. */
160
+ export function skillsForTurn(opts) {
161
+ return discoverSkills(opts).skills.map(({ name, description, path, dir }) => ({
162
+ name,
163
+ description,
164
+ path,
165
+ dir,
166
+ }));
167
+ }
168
+
169
+ /** "/name rest" → { name, args } when `name` is one of `skills` usable as a slash command, else null. */
170
+ export function matchSkillCommand(line, skills) {
171
+ const m = /^\/([a-z0-9][a-z0-9-]*)(?:\s+([\s\S]*))?$/.exec(String(line ?? "").trim());
172
+ if (!m) return null;
173
+ const skill = skills.find((s) => s.name === m[1] && s.slash !== false);
174
+ return skill ? { name: skill.name, args: (m[2] ?? "").trim() } : null;
175
+ }
@@ -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
+ }