@vincemakes/kiso-skills-ext 0.39.1 → 0.40.0

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/README.md CHANGED
@@ -17,6 +17,27 @@ Skill directories: `~/.kiso/skills/<name>/SKILL.md` (or the project-level
17
17
  `.kiso/skills/` after the trust gate). No configuration file — the
18
18
  extension scans the skills dir at startup.
19
19
 
20
+ ## Invoking a skill yourself
21
+
22
+ `/skill <name> [args]` sends a skill as your turn: its SKILL.md body, then
23
+ your args after a blank line. `/<name> [args]` does the same when no
24
+ built-in command has that name — a built-in always wins. `/skills` lists
25
+ what is installed, where each skill lives, and any that cannot load with
26
+ the reason.
27
+
28
+ The frontmatter keys read are `name`, `description` and `user-invocable`.
29
+ Values may be plain, quoted (`"…"` or `'…'`), or YAML block scalars (`>` or
30
+ `|`, with the text on the following indented lines); the index shows the
31
+ description on one line. A `>` or `|` with nothing under it is reported as
32
+ a skill that cannot load.
33
+
34
+ A skill whose frontmatter says `user-invocable: false` stays in the
35
+ model's index and out of your reach. Only the literal `false` counts; a
36
+ skill without the key is invocable.
37
+
38
+ The body is sent as written — there is no placeholder substitution; your
39
+ args follow it. A body over 32,768 characters is refused, not cut.
40
+
20
41
  ## Versioning
21
42
 
22
43
  The version counter is this package's own. It is pinned exactly by the kiso
@@ -4,7 +4,8 @@
4
4
  *
5
5
  * Tier 1 (resident): the skills index — every ${KISO_SKILLS_DIR:-~/.kiso/
6
6
  * skills}/<name>/SKILL.md's frontmatter (a --- wrapped YAML SUBSET; only
7
- * name/description are read, by a hand-written parser — no deps) becomes
7
+ * name/description/user-invocable are read, by a hand-written parser — no
8
+ * deps) becomes
8
9
  * one line of the system prompt, sorted by directory name:
9
10
  * Available skills (load with read_skill):
10
11
  * - <name>: <description>
@@ -41,7 +42,8 @@ export default async function createSkillsExtension() {
41
42
  const { index, broken } = loadIndex(skillsDir);
42
43
  // finding #8: no persistent resources — SKILL.md files are read per call;
43
44
  // nothing is spawned or connected — no dispose is needed, explicitly.
44
- if (index.length === 0 && broken.length === 0) return { name: "skills", skills: 0, tools: [] };
45
+ const catalog = skillsCatalog(index, broken);
46
+ if (index.length === 0 && broken.length === 0) return { name: "skills", skills: 0, tools: [], catalog };
45
47
  const tools = index.length > 0 ? [readSkillTool(index, broken)] : [];
46
48
  return {
47
49
  name: "skills",
@@ -50,6 +52,10 @@ export default async function createSkillsExtension() {
50
52
  // it here rather than walking the directory again — one scan, one
51
53
  // answer, and no second count free to disagree with this one.
52
54
  skills: index.length,
55
+ // 0.40.0: the same scan, handed to the CLI for `/skill` and
56
+ // `/skills` — the person's door and the model's index cannot list
57
+ // different skills, because they are one list.
58
+ catalog,
53
59
  systemPrompt: { append: skillsPromptAppend(index, broken) },
54
60
  };
55
61
  }
@@ -73,9 +79,9 @@ function loadIndex(skillsDir) {
73
79
  } else if (d.isSymbolicLink()) {
74
80
  try {
75
81
  if (statSync(join(skillsDir, d.name)).isDirectory()) dirs.push(d.name);
76
- else brokenLinks.push(`${d.name} (symlink target is not a directory)`);
82
+ else brokenLinks.push({ dir: d.name, reason: "symlink target is not a directory" });
77
83
  } catch {
78
- brokenLinks.push(`${d.name} (broken symlink)`);
84
+ brokenLinks.push({ dir: d.name, reason: "broken symlink" });
79
85
  }
80
86
  }
81
87
  }
@@ -90,38 +96,126 @@ function loadIndex(skillsDir) {
90
96
  try {
91
97
  text = readFileSync(path, "utf8");
92
98
  } catch {
93
- broken.push(`${dir} (no SKILL.md)`);
99
+ broken.push({ dir, reason: "no SKILL.md" });
94
100
  continue;
95
101
  }
96
- const meta = parseFrontmatter(text);
97
- if (meta === null) {
98
- broken.push(`${dir} (no frontmatter)`);
102
+ const parsed = parseFrontmatter(text);
103
+ if (parsed === null) {
104
+ broken.push({ dir, reason: "no frontmatter" });
105
+ continue;
106
+ }
107
+ const { meta, emptyBlocks } = parsed;
108
+ // RF-2: a `>`/`|` with nothing under it is a mistake in the file, not
109
+ // an empty value — say so, rather than "no description"
110
+ if (emptyBlocks.has("name") || emptyBlocks.has("description")) {
111
+ broken.push({ dir, reason: "empty block scalar" });
99
112
  continue;
100
113
  }
101
114
  const name = (meta.name ?? dir).trim();
102
- let description = (meta.description ?? "").trim();
115
+ // RF-2: a name is matched against `/skill <name>` and printed on one
116
+ // index line — a `|` block or a quoted "\n" cannot make it two
117
+ if (/[\r\n]/.test(name)) {
118
+ broken.push({ dir, reason: "name is not one line" });
119
+ continue;
120
+ }
121
+ // the index is ONE line per skill: only a value that CARRIES a newline
122
+ // (a `|` block, a quoted "\n") is collapsed — a plain value's bytes are
123
+ // exactly what they were before RF-2, inner spacing included
124
+ const rawDescription = meta.description ?? "";
125
+ let description = (/[\r\n]/.test(rawDescription) ? rawDescription.replace(/\s+/g, " ") : rawDescription).trim();
103
126
  if (description === "") {
104
- broken.push(`${dir} (no description)`);
127
+ broken.push({ dir, reason: "no description" });
105
128
  continue;
106
129
  }
107
130
  if (description.length > MAX_DESCRIPTION) description = `${description.slice(0, MAX_DESCRIPTION)}…[truncated]`;
108
- index.push({ name, description, path });
131
+ // `user-invocable: false` (the key other harnesses use) keeps a skill
132
+ // out of the person's reach and in the model's. Only the literal
133
+ // `false` counts: absence cannot be told apart from a skill written
134
+ // before the key existed, so absence is invocable.
135
+ index.push({ name, description, dir, path, userInvocable: meta["user-invocable"] !== "false" });
109
136
  }
110
137
  return { index, broken };
111
138
  }
112
139
 
113
- /** The --- wrapped YAML subset: only `name:` and `description:` lines are
114
- * read (everything else is ignored). Null = no valid frontmatter. */
140
+ /** 0.40.0 — what the CLI reads to let a PERSON invoke a skill. `body` reads
141
+ * the file at call time (finding #8: nothing is held), strips the
142
+ * frontmatter, and refuses — never truncates — a body over the cap: a
143
+ * skill cut in half is a different instruction than the one written. */
144
+ function skillsCatalog(index, broken) {
145
+ return {
146
+ entries: index.map(({ name, description, dir, path, userInvocable }) => ({ name, description, dir, path, userInvocable })),
147
+ broken: broken.map(({ dir, reason }) => ({ dir, reason })),
148
+ body(name) {
149
+ const skill = index.find((s) => s.name === name);
150
+ if (skill === undefined) return { error: "not installed" };
151
+ let text;
152
+ try {
153
+ text = readFileSync(skill.path, "utf8");
154
+ } catch (err) {
155
+ return { error: `cannot read ${skill.path}: ${err instanceof Error ? err.message : String(err)}` };
156
+ }
157
+ const end = text.indexOf("\n---", 4);
158
+ const body = text.slice(end + 4).replace(/^[^\n]*\n?/, "").trim();
159
+ if (body.length > MAX_BODY) return { error: `over the ${MAX_BODY.toLocaleString("en-US")}-character skill cap (${body.length.toLocaleString("en-US")} characters)` };
160
+ return { body };
161
+ },
162
+ };
163
+ }
164
+
165
+ /** The --- wrapped YAML subset: top-level `key: value` lines; the loader
166
+ * reads `name`, `description` and `user-invocable` (everything else is
167
+ * ignored). Null = no valid frontmatter.
168
+ *
169
+ * RF-2: skills written for other harnesses use real YAML, so a value may
170
+ * be a BLOCK scalar (`>` folds its lines with spaces, `|` keeps them; an
171
+ * optional chomping `+`/`-` is accepted) whose text is the following lines
172
+ * indented deeper than the key, or a double/single-QUOTED scalar. The flat
173
+ * reader indexed `description: >` as the literal ">" and dropped the text.
174
+ * A block indicator with no indented lines records the key in
175
+ * `emptyBlocks` — the caller names it a broken entry, never an empty
176
+ * description. Anchors, flow collections and nesting stay out of scope:
177
+ * the index needs three string keys. */
115
178
  function parseFrontmatter(text) {
116
179
  if (!text.startsWith("---\n")) return null;
117
180
  const end = text.indexOf("\n---", 4);
118
181
  if (end < 0) return null;
182
+ const lines = text.slice(4, end).split("\n");
119
183
  const meta = {};
120
- for (const line of text.slice(4, end).split("\n")) {
121
- const m = /^([A-Za-z][A-Za-z0-9_-]*):\s*(.*)$/.exec(line);
122
- if (m !== null) meta[m[1]] = m[2].trim();
184
+ const emptyBlocks = new Set();
185
+ for (let i = 0; i < lines.length; i += 1) {
186
+ const m = /^([A-Za-z][A-Za-z0-9_-]*):\s*(.*)$/.exec(lines[i]);
187
+ if (m === null) continue;
188
+ const [, key, rawValue] = m;
189
+ const value = rawValue.trim();
190
+ if (/^[>|][+-]?$/.test(value)) {
191
+ // the block: every following line indented deeper than the key
192
+ // (a blank line inside the block belongs to it)
193
+ const block = [];
194
+ while (i + 1 < lines.length && (/^\s+\S/.test(lines[i + 1]) || (lines[i + 1].trim() === "" && block.length > 0))) block.push(lines[(i += 1)]);
195
+ while (block.length > 0 && block[block.length - 1].trim() === "") block.pop();
196
+ if (block.length === 0) {
197
+ emptyBlocks.add(key);
198
+ meta[key] = "";
199
+ continue;
200
+ }
201
+ const indent = Math.min(...block.filter((l) => l.trim() !== "").map((l) => /^\s*/.exec(l)[0].length));
202
+ const body = block.map((l) => l.slice(indent));
203
+ meta[key] = value.startsWith(">") ? body.map((l) => l.trim()).join(" ").replace(/ {2,}/g, " ").trim() : body.join("\n").trim();
204
+ } else {
205
+ meta[key] = unquote(value);
206
+ }
207
+ }
208
+ return { meta, emptyBlocks };
209
+ }
210
+
211
+ /** RF-2: a quoted scalar's text — `"…"` with its backslash escapes, `'…'`
212
+ * with `''` for a quote. Anything else is returned as written. */
213
+ function unquote(value) {
214
+ if (value.length >= 2 && value.startsWith('"') && value.endsWith('"')) {
215
+ return value.slice(1, -1).replace(/\\(["\\nt])/g, (_, c) => (c === "n" ? "\n" : c === "t" ? "\t" : c));
123
216
  }
124
- return meta;
217
+ if (value.length >= 2 && value.startsWith("'") && value.endsWith("'")) return value.slice(1, -1).replace(/''/g, "'");
218
+ return value;
125
219
  }
126
220
 
127
221
  /** Tier 1: the resident index — one line per skill, sorted by directory
@@ -129,14 +223,14 @@ function parseFrontmatter(text) {
129
223
  function skillsPromptAppend(index, broken) {
130
224
  const lines = index.map((s) => `- ${s.name}: ${s.description}`);
131
225
  const warning =
132
- broken.length > 0 ? `\n[skills] skipped ${broken.length} broken skill(s): ${broken.join(", ")}` : "";
226
+ broken.length > 0 ? `\n[skills] skipped ${broken.length} broken skill(s): ${broken.map((b) => `${b.dir} (${b.reason})`).join(", ")}` : "";
133
227
  return `Available skills (load with read_skill):\n${lines.join("\n")}${warning}`;
134
228
  }
135
229
 
136
230
  /** Tier 2: read_skill — the full SKILL.md (≤32KB), or an honest,
137
231
  * actionable unknown-name error listing the installed skills. */
138
232
  function readSkillTool(index, broken) {
139
- const brokenNote = broken.length > 0 ? ` (${broken.length} broken skill(s) skipped: ${broken.map((b) => b.split(" ")[0]).join(", ")})` : "";
233
+ const brokenNote = broken.length > 0 ? ` (${broken.length} broken skill(s) skipped: ${broken.map((b) => b.dir).join(", ")})` : "";
140
234
  return {
141
235
  name: "read_skill",
142
236
  description: "load a skill's SKILL.md (the available-skills list is in the system prompt)",
package/index.d.ts CHANGED
@@ -11,7 +11,25 @@ import type { KisoExtension } from "@vincemakes/kiso-core";
11
11
  * caller wanting the number does not walk the directory a second time and
12
12
  * get a second answer free to disagree with this one. Absent means the
13
13
  * load reported none — never a reason to guess. */
14
- type SkillsExtension = KisoExtension & { readonly skills?: number };
14
+ type SkillsExtension = KisoExtension & { readonly skills?: number; readonly catalog?: SkillsCatalog };
15
+
16
+ /** 0.40.0: the same scan, for the CLI's `/skill` and `/skills`. `body` reads
17
+ * the file at call time and returns the SKILL.md body without its
18
+ * frontmatter, or the reason it cannot (over the cap, unreadable). */
19
+ export interface SkillsCatalog {
20
+ readonly entries: readonly {
21
+ readonly name: string;
22
+ readonly description: string;
23
+ /** the directory under the skills root the skill was found in */
24
+ readonly dir: string;
25
+ readonly path: string;
26
+ /** false only for `user-invocable: false` — the model may still load it */
27
+ readonly userInvocable: boolean;
28
+ }[];
29
+ /** the loader's own reason per skipped entry — the words the model's warning line uses */
30
+ readonly broken: readonly { readonly dir: string; readonly reason: string }[];
31
+ body(name: string): { readonly body: string } | { readonly error: string };
32
+ }
15
33
 
16
34
  declare const createSkillsExtension: () => SkillsExtension | Promise<SkillsExtension>;
17
35
  export default createSkillsExtension;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vincemakes/kiso-skills-ext",
3
- "version": "0.39.1",
3
+ "version": "0.40.0",
4
4
  "description": "kiso official skills extension \u2014 two-tier progressive skills, kernel untouched",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -23,7 +23,7 @@
23
23
  "test": "vitest run"
24
24
  },
25
25
  "devDependencies": {
26
- "@vincemakes/kiso-core": "0.39.1",
26
+ "@vincemakes/kiso-core": "0.40.0",
27
27
  "@types/node": "^26.1.2",
28
28
  "typescript": "^5.7.2",
29
29
  "vitest": "^3.0.0"