@tiens.nguyen/gu-cli 1.0.738 → 1.0.740

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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
+ }