@fro.bot/systematic 1.2.2 → 1.3.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/dist/cli.js CHANGED
@@ -6,7 +6,7 @@ import {
6
6
  findCommandsInDir,
7
7
  findSkillsInDir,
8
8
  getConfigPaths
9
- } from "./index-hvkf19rd.js";
9
+ } from "./index-ymsavt2y.js";
10
10
 
11
11
  // src/cli.ts
12
12
  import fs from "fs";
@@ -1,8 +1,8 @@
1
1
  // @bun
2
2
  // src/lib/config.ts
3
3
  import fs from "fs";
4
- import path from "path";
5
4
  import os from "os";
5
+ import path from "path";
6
6
  import { parse as parseJsonc } from "jsonc-parser";
7
7
  var DEFAULT_CONFIG = {
8
8
  disabled_skills: [],
@@ -25,9 +25,11 @@ function loadJsoncFile(filePath) {
25
25
  function mergeArraysUnique(arr1, arr2) {
26
26
  const set = new Set;
27
27
  if (arr1)
28
- arr1.forEach((item) => set.add(item));
28
+ for (const item of arr1)
29
+ set.add(item);
29
30
  if (arr2)
30
- arr2.forEach((item) => set.add(item));
31
+ for (const item of arr2)
32
+ set.add(item);
31
33
  return Array.from(set);
32
34
  }
33
35
  function loadConfig(projectDir) {
@@ -58,81 +60,168 @@ function getConfigPaths(projectDir) {
58
60
  };
59
61
  }
60
62
 
61
- // src/lib/converter.ts
62
- import fs2 from "fs";
63
-
64
63
  // src/lib/frontmatter.ts
64
+ import yaml from "js-yaml";
65
65
  function parseFrontmatter(content) {
66
- const lines = content.split(/\r?\n/);
67
- if (lines.length === 0 || lines[0].trim() !== "---") {
68
- return { data: {}, body: content, raw: "" };
69
- }
70
- let endIndex = -1;
71
- for (let i = 1;i < lines.length; i++) {
72
- if (lines[i].trim() === "---") {
73
- endIndex = i;
74
- break;
75
- }
76
- }
77
- if (endIndex === -1) {
78
- return { data: {}, body: content, raw: "" };
66
+ const frontmatterRegex = /^---\r?\n([\s\S]*?)\r?\n?---\r?\n([\s\S]*)$/;
67
+ const match = content.match(frontmatterRegex);
68
+ if (!match) {
69
+ return {
70
+ data: {},
71
+ body: content,
72
+ hadFrontmatter: false,
73
+ parseError: false
74
+ };
79
75
  }
80
- const yamlLines = lines.slice(1, endIndex);
81
- const body = lines.slice(endIndex + 1).join(`
82
- `);
83
- const raw = lines.slice(0, endIndex + 1).join(`
84
- `);
85
- const data = {};
86
- for (const line of yamlLines) {
87
- const match = line.match(/^([\w-]+):\s*(.*)$/);
88
- if (match) {
89
- const [, key, value] = match;
90
- if (value === "true")
91
- data[key] = true;
92
- else if (value === "false")
93
- data[key] = false;
94
- else if (/^\d+(\.\d+)?$/.test(value))
95
- data[key] = parseFloat(value);
96
- else
97
- data[key] = value;
98
- }
76
+ const yamlContent = match[1];
77
+ const body = match[2];
78
+ try {
79
+ const parsed = yaml.load(yamlContent, { schema: yaml.JSON_SCHEMA });
80
+ const data = parsed ?? {};
81
+ return { data, body, hadFrontmatter: true, parseError: false };
82
+ } catch {
83
+ return { data: {}, body, hadFrontmatter: true, parseError: true };
99
84
  }
100
- return { data, body, raw };
101
85
  }
102
86
  function formatFrontmatter(data) {
103
- const lines = ["---"];
104
- for (const [key, value] of Object.entries(data)) {
105
- lines.push(`${key}: ${value}`);
87
+ if (Object.keys(data).length === 0) {
88
+ return ["---", "---"].join(`
89
+ `);
106
90
  }
107
- lines.push("---");
108
- return lines.join(`
91
+ const yamlContent = yaml.dump(data, {
92
+ schema: yaml.JSON_SCHEMA,
93
+ lineWidth: -1,
94
+ noRefs: true
95
+ }).trimEnd();
96
+ return ["---", yamlContent, "---"].join(`
109
97
  `);
110
98
  }
111
99
  function stripFrontmatter(content) {
112
- const lines = content.split(`
113
- `);
114
- let inFrontmatter = false;
115
- let frontmatterEnded = false;
116
- const contentLines = [];
117
- for (const line of lines) {
118
- if (line.trim() === "---") {
119
- if (inFrontmatter) {
120
- frontmatterEnded = true;
121
- continue;
100
+ const { body, hadFrontmatter } = parseFrontmatter(content);
101
+ return hadFrontmatter ? body.trim() : content.trim();
102
+ }
103
+
104
+ // src/lib/walk-dir.ts
105
+ import fs2 from "fs";
106
+ import path2 from "path";
107
+ function walkDir(rootDir, options = {}) {
108
+ const { maxDepth = 3, filter } = options;
109
+ const results = [];
110
+ if (!fs2.existsSync(rootDir))
111
+ return results;
112
+ function recurse(currentDir, depth, category) {
113
+ if (depth > maxDepth)
114
+ return;
115
+ const entries = fs2.readdirSync(currentDir, { withFileTypes: true });
116
+ for (const entry of entries) {
117
+ const fullPath = path2.join(currentDir, entry.name);
118
+ const walkEntry = {
119
+ path: fullPath,
120
+ name: entry.name,
121
+ isDirectory: entry.isDirectory(),
122
+ depth,
123
+ category
124
+ };
125
+ if (!filter || filter(walkEntry)) {
126
+ results.push(walkEntry);
127
+ }
128
+ if (entry.isDirectory()) {
129
+ recurse(fullPath, depth + 1, entry.name);
122
130
  }
123
- inFrontmatter = true;
124
- continue;
125
- }
126
- if (frontmatterEnded || !inFrontmatter) {
127
- contentLines.push(line);
128
131
  }
129
132
  }
130
- return contentLines.join(`
131
- `).trim();
133
+ recurse(rootDir, 0);
134
+ return results;
135
+ }
136
+
137
+ // src/lib/agents.ts
138
+ function findAgentsInDir(dir, maxDepth = 2) {
139
+ const entries = walkDir(dir, {
140
+ maxDepth,
141
+ filter: (e) => !e.isDirectory && e.name.endsWith(".md")
142
+ });
143
+ return entries.map((entry) => ({
144
+ name: entry.name.replace(/\.md$/, ""),
145
+ file: entry.path,
146
+ category: entry.category
147
+ }));
148
+ }
149
+ function extractAgentFrontmatter(content) {
150
+ const { data, parseError } = parseFrontmatter(content);
151
+ return {
152
+ name: !parseError && typeof data.name === "string" ? data.name : "",
153
+ description: !parseError && typeof data.description === "string" ? data.description : "",
154
+ prompt: stripFrontmatter(content)
155
+ };
156
+ }
157
+
158
+ // src/lib/commands.ts
159
+ function findCommandsInDir(dir, maxDepth = 2) {
160
+ const entries = walkDir(dir, {
161
+ maxDepth,
162
+ filter: (e) => !e.isDirectory && e.name.endsWith(".md")
163
+ });
164
+ return entries.map((entry) => {
165
+ const baseName = entry.name.replace(/\.md$/, "");
166
+ const commandName = entry.category ? `/${entry.category}:${baseName}` : `/${baseName}`;
167
+ return {
168
+ name: commandName,
169
+ file: entry.path,
170
+ category: entry.category
171
+ };
172
+ });
173
+ }
174
+ function extractCommandFrontmatter(content) {
175
+ const { data, parseError } = parseFrontmatter(content);
176
+ const argumentHintRaw = !parseError && typeof data["argument-hint"] === "string" ? data["argument-hint"] : "";
177
+ return {
178
+ name: !parseError && typeof data.name === "string" ? data.name : "",
179
+ description: !parseError && typeof data.description === "string" ? data.description : "",
180
+ argumentHint: argumentHintRaw.replace(/^["']|["']$/g, "")
181
+ };
132
182
  }
133
183
 
134
184
  // src/lib/converter.ts
185
+ import fs3 from "fs";
135
186
  var cache = new Map;
187
+ var TOOL_MAPPINGS = [
188
+ [/\bTask\s+tool\b/gi, "delegate_task tool"],
189
+ [/\bTask\s+([\w-]+)\s*\(/g, "delegate_task $1("],
190
+ [/\bTask\s*\(/g, "delegate_task("],
191
+ [/\bTask\b(?=\s+to\s+\w)/g, "delegate_task"],
192
+ [/\bTodoWrite\b/g, "todowrite"],
193
+ [/\bAskUserQuestion\b/g, "question"],
194
+ [/\bWebSearch\b/g, "google_search"],
195
+ [/\bRead\b(?=\s+tool|\s+to\s+|\()/g, "read"],
196
+ [/\bWrite\b(?=\s+tool|\s+to\s+|\()/g, "write"],
197
+ [/\bEdit\b(?=\s+tool|\s+to\s+|\()/g, "edit"],
198
+ [/\bBash\b(?=\s+tool|\s+to\s+|\()/g, "bash"],
199
+ [/\bGrep\b(?=\s+tool|\s+to\s+|\()/g, "grep"],
200
+ [/\bGlob\b(?=\s+tool|\s+to\s+|\()/g, "glob"],
201
+ [/\bWebFetch\b/g, "webfetch"],
202
+ [/\bSkill\b(?=\s+tool)/g, "skill"]
203
+ ];
204
+ var PATH_REPLACEMENTS = [
205
+ [/\.claude\/skills\//g, ".opencode/skills/"],
206
+ [/\.claude\/commands\//g, ".opencode/commands/"],
207
+ [/\.claude\/agents\//g, ".opencode/agents/"],
208
+ [/~\/\.claude\//g, "~/.config/opencode/"],
209
+ [/CLAUDE\.md/g, "AGENTS.md"],
210
+ [/\/compound-engineering:/g, "/systematic:"],
211
+ [/compound-engineering:/g, "systematic:"]
212
+ ];
213
+ var CC_ONLY_SKILL_FIELDS = [
214
+ "model",
215
+ "allowed-tools",
216
+ "allowedTools",
217
+ "disable-model-invocation",
218
+ "disableModelInvocation",
219
+ "user-invocable",
220
+ "userInvocable",
221
+ "context",
222
+ "agent"
223
+ ];
224
+ var CC_ONLY_COMMAND_FIELDS = ["argument-hint", "argumentHint"];
136
225
  function inferTemperature(name, description) {
137
226
  const sample = `${name} ${description ?? ""}`.toLowerCase();
138
227
  if (/(review|audit|security|sentinel|oracle|lint|verification|guardian)/.test(sample)) {
@@ -149,6 +238,37 @@ function inferTemperature(name, description) {
149
238
  }
150
239
  return 0.3;
151
240
  }
241
+ function transformBody(body) {
242
+ let result = body;
243
+ for (const [pattern, replacement] of TOOL_MAPPINGS) {
244
+ result = result.replace(pattern, replacement);
245
+ }
246
+ for (const [pattern, replacement] of PATH_REPLACEMENTS) {
247
+ result = result.replace(pattern, replacement);
248
+ }
249
+ return result;
250
+ }
251
+ function removeFields(data, fieldsToRemove) {
252
+ const result = {};
253
+ for (const [key, value] of Object.entries(data)) {
254
+ if (!fieldsToRemove.includes(key)) {
255
+ result[key] = value;
256
+ }
257
+ }
258
+ return result;
259
+ }
260
+ function transformSkillFrontmatter(data) {
261
+ return removeFields(data, CC_ONLY_SKILL_FIELDS);
262
+ }
263
+ function transformCommandFrontmatter(data) {
264
+ const cleaned = removeFields(data, CC_ONLY_COMMAND_FIELDS);
265
+ if (typeof cleaned.model === "string" && cleaned.model !== "inherit") {
266
+ cleaned.model = normalizeModel(cleaned.model);
267
+ } else if (cleaned.model === "inherit") {
268
+ delete cleaned.model;
269
+ }
270
+ return cleaned;
271
+ }
152
272
  function normalizeModel(model) {
153
273
  if (model.includes("/"))
154
274
  return model;
@@ -182,153 +302,46 @@ function transformAgentFrontmatter(data, agentMode) {
182
302
  function convertContent(content, type, options = {}) {
183
303
  if (content === "")
184
304
  return "";
185
- const { data, body, raw } = parseFrontmatter(content);
186
- const hasFrontmatter = raw !== "";
187
- if (!hasFrontmatter) {
188
- return content;
305
+ const { data, body, hadFrontmatter } = parseFrontmatter(content);
306
+ if (!hadFrontmatter) {
307
+ return options.skipBodyTransform ? content : transformBody(content);
189
308
  }
309
+ const shouldTransformBody = !options.skipBodyTransform;
310
+ const transformedBody = shouldTransformBody ? transformBody(body) : body;
190
311
  if (type === "agent") {
191
312
  const agentMode = options.agentMode ?? "subagent";
192
313
  const transformedData = transformAgentFrontmatter(data, agentMode);
193
314
  return `${formatFrontmatter(transformedData)}
194
- ${body}`;
315
+ ${transformedBody}`;
316
+ }
317
+ if (type === "skill") {
318
+ const transformedData = transformSkillFrontmatter(data);
319
+ return `${formatFrontmatter(transformedData)}
320
+ ${transformedBody}`;
321
+ }
322
+ if (type === "command") {
323
+ const transformedData = transformCommandFrontmatter(data);
324
+ return `${formatFrontmatter(transformedData)}
325
+ ${transformedBody}`;
195
326
  }
196
327
  return content;
197
328
  }
198
329
  function convertFileWithCache(filePath, type, options = {}) {
199
- const fd = fs2.openSync(filePath, "r");
330
+ const fd = fs3.openSync(filePath, "r");
200
331
  try {
201
- const stats = fs2.fstatSync(fd);
202
- const cacheKey = `${filePath}:${type}:${options.source ?? "bundled"}:${options.agentMode ?? "subagent"}`;
332
+ const stats = fs3.fstatSync(fd);
333
+ const cacheKey = `${filePath}:${type}:${options.source ?? "bundled"}:${options.agentMode ?? "subagent"}:${options.skipBodyTransform ?? false}`;
203
334
  const cached = cache.get(cacheKey);
204
335
  if (cached != null && cached.mtimeMs === stats.mtimeMs) {
205
336
  return cached.converted;
206
337
  }
207
- const content = fs2.readFileSync(fd, "utf8");
338
+ const content = fs3.readFileSync(fd, "utf8");
208
339
  const converted = convertContent(content, type, options);
209
340
  cache.set(cacheKey, { mtimeMs: stats.mtimeMs, converted });
210
341
  return converted;
211
342
  } finally {
212
- fs2.closeSync(fd);
213
- }
214
- }
215
-
216
- // src/lib/walk-dir.ts
217
- import fs3 from "fs";
218
- import path2 from "path";
219
- function walkDir(rootDir, options = {}) {
220
- const { maxDepth = 3, filter } = options;
221
- const results = [];
222
- if (!fs3.existsSync(rootDir))
223
- return results;
224
- function recurse(currentDir, depth, category) {
225
- if (depth > maxDepth)
226
- return;
227
- const entries = fs3.readdirSync(currentDir, { withFileTypes: true });
228
- for (const entry of entries) {
229
- const fullPath = path2.join(currentDir, entry.name);
230
- const walkEntry = {
231
- path: fullPath,
232
- name: entry.name,
233
- isDirectory: entry.isDirectory(),
234
- depth,
235
- category
236
- };
237
- if (!filter || filter(walkEntry)) {
238
- results.push(walkEntry);
239
- }
240
- if (entry.isDirectory()) {
241
- recurse(fullPath, depth + 1, entry.name);
242
- }
243
- }
343
+ fs3.closeSync(fd);
244
344
  }
245
- recurse(rootDir, 0);
246
- return results;
247
- }
248
-
249
- // src/lib/agents.ts
250
- function findAgentsInDir(dir, maxDepth = 2) {
251
- const entries = walkDir(dir, {
252
- maxDepth,
253
- filter: (e) => !e.isDirectory && e.name.endsWith(".md")
254
- });
255
- return entries.map((entry) => ({
256
- name: entry.name.replace(/\.md$/, ""),
257
- file: entry.path,
258
- category: entry.category
259
- }));
260
- }
261
- function extractAgentFrontmatter(content) {
262
- const lines = content.split(`
263
- `);
264
- let inFrontmatter = false;
265
- let name = "";
266
- let description = "";
267
- for (const line of lines) {
268
- if (line.trim() === "---") {
269
- if (inFrontmatter)
270
- break;
271
- inFrontmatter = true;
272
- continue;
273
- }
274
- if (inFrontmatter) {
275
- const match = line.match(/^(\w+(?:-\w+)*):\s*(.*)$/);
276
- if (match) {
277
- const [, key, value] = match;
278
- if (key === "name")
279
- name = value.trim();
280
- if (key === "description")
281
- description = value.trim();
282
- }
283
- }
284
- }
285
- return { name, description, prompt: stripFrontmatter(content) };
286
- }
287
-
288
- // src/lib/commands.ts
289
- function findCommandsInDir(dir, maxDepth = 2) {
290
- const entries = walkDir(dir, {
291
- maxDepth,
292
- filter: (e) => !e.isDirectory && e.name.endsWith(".md")
293
- });
294
- return entries.map((entry) => {
295
- const baseName = entry.name.replace(/\.md$/, "");
296
- const commandName = entry.category ? `/${entry.category}:${baseName}` : `/${baseName}`;
297
- return {
298
- name: commandName,
299
- file: entry.path,
300
- category: entry.category
301
- };
302
- });
303
- }
304
- function extractCommandFrontmatter(content) {
305
- const lines = content.split(`
306
- `);
307
- let inFrontmatter = false;
308
- let name = "";
309
- let description = "";
310
- let argumentHint = "";
311
- for (const line of lines) {
312
- if (line.trim() === "---") {
313
- if (inFrontmatter)
314
- break;
315
- inFrontmatter = true;
316
- continue;
317
- }
318
- if (inFrontmatter) {
319
- const match = line.match(/^(\w+(?:-\w+)*):\s*(.*)$/);
320
- if (match) {
321
- const [, key, value] = match;
322
- if (key === "name")
323
- name = value.trim();
324
- if (key === "description")
325
- description = value.trim();
326
- if (key === "argument-hint")
327
- argumentHint = value.trim().replace(/^["']|["']$/g, "");
328
- }
329
- }
330
- }
331
- return { name, description, argumentHint };
332
345
  }
333
346
 
334
347
  // src/lib/skills.ts
@@ -337,30 +350,14 @@ import path3 from "path";
337
350
  function extractFrontmatter(filePath) {
338
351
  try {
339
352
  const content = fs4.readFileSync(filePath, "utf8");
340
- const lines = content.split(`
341
- `);
342
- let inFrontmatter = false;
343
- let name = "";
344
- let description = "";
345
- for (const line of lines) {
346
- if (line.trim() === "---") {
347
- if (inFrontmatter)
348
- break;
349
- inFrontmatter = true;
350
- continue;
351
- }
352
- if (inFrontmatter) {
353
- const match = line.match(/^(\w+):\s*(.*)$/);
354
- if (match) {
355
- const [, key, value] = match;
356
- if (key === "name")
357
- name = value.trim();
358
- if (key === "description")
359
- description = value.trim();
360
- }
361
- }
353
+ const { data, parseError } = parseFrontmatter(content);
354
+ if (parseError) {
355
+ return { name: "", description: "" };
362
356
  }
363
- return { name, description };
357
+ return {
358
+ name: typeof data.name === "string" ? data.name : "",
359
+ description: typeof data.description === "string" ? data.description : ""
360
+ };
364
361
  } catch {
365
362
  return { name: "", description: "" };
366
363
  }
@@ -404,4 +401,4 @@ ${skillsXml}
404
401
  </available_skills>`;
405
402
  }
406
403
 
407
- export { stripFrontmatter, loadConfig, getConfigPaths, convertContent, convertFileWithCache, findAgentsInDir, extractAgentFrontmatter, findCommandsInDir, extractCommandFrontmatter, findSkillsInDir, formatSkillsXml };
404
+ export { stripFrontmatter, loadConfig, getConfigPaths, findAgentsInDir, extractAgentFrontmatter, findCommandsInDir, extractCommandFrontmatter, convertContent, convertFileWithCache, findSkillsInDir, formatSkillsXml };
package/dist/index.js CHANGED
@@ -1,6 +1,5 @@
1
1
  // @bun
2
2
  import {
3
- convertContent,
4
3
  convertFileWithCache,
5
4
  extractAgentFrontmatter,
6
5
  extractCommandFrontmatter,
@@ -10,11 +9,11 @@ import {
10
9
  formatSkillsXml,
11
10
  loadConfig,
12
11
  stripFrontmatter
13
- } from "./index-hvkf19rd.js";
12
+ } from "./index-ymsavt2y.js";
14
13
 
15
14
  // src/index.ts
16
- import fs3 from "fs";
17
- import path3 from "path";
15
+ import fs2 from "fs";
16
+ import path4 from "path";
18
17
  import { fileURLToPath } from "url";
19
18
 
20
19
  // src/lib/bootstrap.ts
@@ -68,6 +67,60 @@ ${toolMapping}
68
67
  </SYSTEMATIC_WORKFLOWS>`;
69
68
  }
70
69
 
70
+ // src/lib/skill-loader.ts
71
+ import path2 from "path";
72
+ var SKILL_PREFIX = "systematic:";
73
+ var SKILL_DESCRIPTION_PREFIX = "(systematic - Skill) ";
74
+ function formatSkillCommandName(name) {
75
+ if (name.startsWith(SKILL_PREFIX)) {
76
+ return name;
77
+ }
78
+ return `${SKILL_PREFIX}${name}`;
79
+ }
80
+ function formatSkillDescription(description, fallbackName) {
81
+ const desc = description || `${fallbackName} skill`;
82
+ if (desc.startsWith(SKILL_DESCRIPTION_PREFIX)) {
83
+ return desc;
84
+ }
85
+ return `${SKILL_DESCRIPTION_PREFIX}${desc}`;
86
+ }
87
+ function wrapSkillTemplate(skillPath, body) {
88
+ const skillDir = path2.dirname(skillPath);
89
+ return `<skill-instruction>
90
+ Base directory for this skill: ${skillDir}/
91
+ File references (@path) in this skill are relative to this directory.
92
+
93
+ ${body.trim()}
94
+ </skill-instruction>
95
+
96
+ <user-request>
97
+ $ARGUMENTS
98
+ </user-request>`;
99
+ }
100
+ function extractSkillBody(wrappedTemplate) {
101
+ const match = wrappedTemplate.match(/<skill-instruction>([\s\S]*?)<\/skill-instruction>/);
102
+ return match ? match[1].trim() : wrappedTemplate;
103
+ }
104
+ function loadSkill(skillInfo) {
105
+ try {
106
+ const converted = convertFileWithCache(skillInfo.skillFile, "skill", {
107
+ source: "bundled"
108
+ });
109
+ const body = stripFrontmatter(converted);
110
+ const wrappedTemplate = wrapSkillTemplate(skillInfo.skillFile, body);
111
+ return {
112
+ name: skillInfo.name,
113
+ prefixedName: formatSkillCommandName(skillInfo.name),
114
+ description: formatSkillDescription(skillInfo.description, skillInfo.name),
115
+ path: skillInfo.path,
116
+ skillFile: skillInfo.skillFile,
117
+ wrappedTemplate
118
+ };
119
+ } catch {
120
+ return null;
121
+ }
122
+ }
123
+
71
124
  // src/lib/config-handler.ts
72
125
  function loadAgentAsConfig(agentInfo) {
73
126
  try {
@@ -86,7 +139,9 @@ function loadAgentAsConfig(agentInfo) {
86
139
  }
87
140
  function loadCommandAsConfig(commandInfo) {
88
141
  try {
89
- const converted = convertFileWithCache(commandInfo.file, "command", { source: "bundled" });
142
+ const converted = convertFileWithCache(commandInfo.file, "command", {
143
+ source: "bundled"
144
+ });
90
145
  const { name, description } = extractCommandFrontmatter(converted);
91
146
  const cleanName = commandInfo.name.replace(/^\//, "");
92
147
  return {
@@ -97,16 +152,11 @@ function loadCommandAsConfig(commandInfo) {
97
152
  return null;
98
153
  }
99
154
  }
100
- function loadSkillAsCommand(skillInfo) {
101
- try {
102
- const converted = convertFileWithCache(skillInfo.skillFile, "skill", { source: "bundled" });
103
- return {
104
- template: stripFrontmatter(converted),
105
- description: skillInfo.description || `${skillInfo.name} skill`
106
- };
107
- } catch {
108
- return null;
109
- }
155
+ function loadSkillAsCommand(loaded) {
156
+ return {
157
+ template: loaded.wrappedTemplate,
158
+ description: loaded.description
159
+ };
110
160
  }
111
161
  function collectAgents(dir, disabledAgents) {
112
162
  const agents = {};
@@ -141,9 +191,9 @@ function collectSkillsAsCommands(dir, disabledSkills) {
141
191
  for (const skillInfo of skillList) {
142
192
  if (disabledSkills.includes(skillInfo.name))
143
193
  continue;
144
- const config = loadSkillAsCommand(skillInfo);
145
- if (config) {
146
- commands[skillInfo.name] = config;
194
+ const loaded = loadSkill(skillInfo);
195
+ if (loaded) {
196
+ commands[loaded.prefixedName] = loadSkillAsCommand(loaded);
147
197
  }
148
198
  }
149
199
  return commands;
@@ -170,28 +220,22 @@ function createConfigHandler(deps) {
170
220
  }
171
221
 
172
222
  // src/lib/skill-tool.ts
173
- import fs2 from "fs";
174
- import path2 from "path";
223
+ import path3 from "path";
175
224
  import { tool } from "@opencode-ai/plugin/tool";
176
- function wrapSkillContent(skillPath, content) {
177
- const skillDir = path2.dirname(skillPath);
178
- const converted = convertContent(content, "skill", { source: "bundled" });
179
- const body = stripFrontmatter(converted);
180
- return `<skill-instruction>
181
- Base directory for this skill: ${skillDir}/
182
- File references (@path) in this skill are relative to this directory.
183
-
184
- ${body.trim()}
185
- </skill-instruction>`;
186
- }
187
225
  function createSkillTool(options) {
188
226
  const { bundledSkillsDir, disabledSkills } = options;
189
227
  const getSystematicSkills = () => {
190
- return findSkillsInDir(bundledSkillsDir).filter((s) => !disabledSkills.includes(s.name)).sort((a, b) => a.name.localeCompare(b.name));
228
+ return findSkillsInDir(bundledSkillsDir).filter((s) => !disabledSkills.includes(s.name)).map((skillInfo) => loadSkill(skillInfo)).filter((s) => s !== null).sort((a, b) => a.name.localeCompare(b.name));
191
229
  };
192
230
  const buildDescription = () => {
193
231
  const skills = getSystematicSkills();
194
- const systematicXml = formatSkillsXml(skills);
232
+ const skillInfos = skills.map((s) => ({
233
+ name: s.name,
234
+ description: s.description,
235
+ path: s.path,
236
+ skillFile: s.skillFile
237
+ }));
238
+ const systematicXml = formatSkillsXml(skillInfos);
195
239
  const baseDescription = `Load a skill to get detailed instructions for a specific task.
196
240
 
197
241
  Skills provide specialized knowledge and step-by-step guidance.
@@ -217,38 +261,33 @@ ${systematicXml}`;
217
261
  const skills = getSystematicSkills();
218
262
  const matchedSkill = skills.find((s) => s.name === normalizedName);
219
263
  if (matchedSkill) {
220
- try {
221
- const content = fs2.readFileSync(matchedSkill.skillFile, "utf8");
222
- const wrapped = wrapSkillContent(matchedSkill.skillFile, content);
223
- return `## Skill: systematic:${matchedSkill.name}
264
+ const body = extractSkillBody(matchedSkill.wrappedTemplate);
265
+ const dir = path3.dirname(matchedSkill.skillFile);
266
+ return `## Skill: ${matchedSkill.prefixedName}
224
267
 
225
- **Base directory**: ${matchedSkill.path}
268
+ **Base directory**: ${dir}
226
269
 
227
- ${wrapped}`;
228
- } catch (error) {
229
- const errorMessage = error instanceof Error ? error.message : String(error);
230
- throw new Error(`Failed to load skill "${requestedName}": ${errorMessage}`);
231
- }
270
+ ${body}`;
232
271
  }
233
- const availableSystematic = skills.map((s) => `systematic:${s.name}`);
272
+ const availableSystematic = skills.map((s) => s.prefixedName);
234
273
  throw new Error(`Skill "${requestedName}" not found. Available systematic skills: ${availableSystematic.join(", ")}`);
235
274
  }
236
275
  });
237
276
  }
238
277
 
239
278
  // src/index.ts
240
- var __dirname2 = path3.dirname(fileURLToPath(import.meta.url));
241
- var packageRoot = path3.resolve(__dirname2, "..");
242
- var bundledSkillsDir = path3.join(packageRoot, "skills");
243
- var bundledAgentsDir = path3.join(packageRoot, "agents");
244
- var bundledCommandsDir = path3.join(packageRoot, "commands");
245
- var packageJsonPath = path3.join(packageRoot, "package.json");
279
+ var __dirname2 = path4.dirname(fileURLToPath(import.meta.url));
280
+ var packageRoot = path4.resolve(__dirname2, "..");
281
+ var bundledSkillsDir = path4.join(packageRoot, "skills");
282
+ var bundledAgentsDir = path4.join(packageRoot, "agents");
283
+ var bundledCommandsDir = path4.join(packageRoot, "commands");
284
+ var packageJsonPath = path4.join(packageRoot, "package.json");
246
285
  var hasLoggedInit = false;
247
286
  var getPackageVersion = () => {
248
287
  try {
249
- if (!fs3.existsSync(packageJsonPath))
288
+ if (!fs2.existsSync(packageJsonPath))
250
289
  return "unknown";
251
- const content = fs3.readFileSync(packageJsonPath, "utf8");
290
+ const content = fs2.readFileSync(packageJsonPath, "utf8");
252
291
  const parsed = JSON.parse(content);
253
292
  return parsed.version ?? "unknown";
254
293
  } catch {
@@ -4,6 +4,8 @@ export type AgentMode = 'primary' | 'subagent';
4
4
  export interface ConvertOptions {
5
5
  source?: SourceType;
6
6
  agentMode?: AgentMode;
7
+ /** Skip body content transformations (tool names, paths, etc.) */
8
+ skipBodyTransform?: boolean;
7
9
  }
8
10
  export declare function convertContent(content: string, type: ContentType, options?: ConvertOptions): string;
9
11
  export declare function convertFileWithCache(filePath: string, type: ContentType, options?: ConvertOptions): string;
@@ -1,9 +1,18 @@
1
- interface ParsedFrontmatter {
2
- data: Record<string, string | number | boolean>;
1
+ export interface FrontmatterResult<T = Record<string, unknown>> {
2
+ data: T;
3
3
  body: string;
4
- raw: string;
4
+ hadFrontmatter: boolean;
5
+ parseError: boolean;
5
6
  }
6
- export type { ParsedFrontmatter };
7
- export declare function parseFrontmatter(content: string): ParsedFrontmatter;
8
- export declare function formatFrontmatter(data: Record<string, string | number | boolean>): string;
7
+ /**
8
+ * Parses YAML frontmatter from Markdown content.
9
+ *
10
+ * Uses js-yaml with JSON_SCHEMA for security (prevents code execution via YAML tags).
11
+ * Supports all standard YAML keys including hyphenated ones (e.g., 'argument-hint').
12
+ *
13
+ * @param content - Markdown content with optional frontmatter
14
+ * @returns Parsed frontmatter data, body content, and parsing status
15
+ */
16
+ export declare function parseFrontmatter<T = Record<string, unknown>>(content: string): FrontmatterResult<T>;
17
+ export declare function formatFrontmatter(data: Record<string, unknown>): string;
9
18
  export declare function stripFrontmatter(content: string): string;
@@ -0,0 +1,14 @@
1
+ import type { SkillInfo } from './skills.js';
2
+ export interface LoadedSkill {
3
+ name: string;
4
+ prefixedName: string;
5
+ description: string;
6
+ path: string;
7
+ skillFile: string;
8
+ wrappedTemplate: string;
9
+ }
10
+ export declare function formatSkillCommandName(name: string): string;
11
+ export declare function formatSkillDescription(description: string, fallbackName: string): string;
12
+ export declare function wrapSkillTemplate(skillPath: string, body: string): string;
13
+ export declare function extractSkillBody(wrappedTemplate: string): string;
14
+ export declare function loadSkill(skillInfo: SkillInfo): LoadedSkill | null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fro.bot/systematic",
3
- "version": "1.2.2",
3
+ "version": "1.3.0",
4
4
  "description": "Structured engineering workflows for OpenCode",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -54,6 +54,7 @@
54
54
  "@biomejs/biome": "^2.0.0",
55
55
  "@opencode-ai/plugin": "^1.1.30",
56
56
  "@types/bun": "latest",
57
+ "@types/js-yaml": "^4.0.9",
57
58
  "@types/node": "^22.0.0",
58
59
  "conventional-changelog-conventionalcommits": "^9.0.0",
59
60
  "markdownlint-cli": "^0.47.0",
@@ -63,6 +64,7 @@
63
64
  "typescript": "^5.7.0"
64
65
  },
65
66
  "dependencies": {
67
+ "js-yaml": "^4.1.1",
66
68
  "jsonc-parser": "^3.3.0"
67
69
  },
68
70
  "publishConfig": {