@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/gonext_agent_chat.py +126 -3
- package/gu-cli.mjs +58 -0
- package/gu-repl.mjs +173 -9
- package/gu_hooks.py +231 -0
- package/gu_mcp.py +486 -0
- package/gu_skills.py +93 -0
- package/hooks-command.mjs +71 -0
- package/hooks-config.mjs +172 -0
- package/mcp-command.mjs +324 -0
- package/mcp-config.mjs +329 -0
- package/package.json +9 -1
- package/skills.mjs +175 -0
- package/terminal-layout.mjs +48 -0
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.
|
|
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
|
+
}
|
package/terminal-layout.mjs
CHANGED
|
@@ -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
|
+
}
|