@fro.bot/systematic 1.0.3 → 1.2.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
@@ -52,9 +52,9 @@ Quick shortcuts to invoke workflows:
52
52
  - `/deepen-plan` - Add detail to existing plans
53
53
  - `/lfg` - Let's go - start working immediately
54
54
 
55
- ### Review Agents
55
+ ### Agents
56
56
 
57
- Specialized code review agents organized by category:
57
+ Specialized agents organized by category:
58
58
 
59
59
  **Review:**
60
60
 
@@ -82,9 +82,9 @@ The plugin provides these tools to OpenCode:
82
82
 
83
83
  | Tool | Description |
84
84
  |------|-------------|
85
- | `systematic_find_skills` | List available skills |
86
- | `systematic_find_agents` | List available agents |
87
- | `systematic_find_commands` | List available commands |
85
+ | `systematic_skill` | Load Systematic bundled skills |
86
+
87
+ The bootstrap skill instructs OpenCode to use the native `skill` tool to load non-Systematic skills.
88
88
 
89
89
  ## Configuration
90
90
 
@@ -98,42 +98,6 @@ Create `~/.config/opencode/systematic.json` or `.opencode/systematic.json` to di
98
98
  }
99
99
  ```
100
100
 
101
- ## Converting CEP Content
102
-
103
- The CLI includes a converter for adapting Claude Code agents, skills, and commands from Compound Engineering Plugin (CEP) to OpenCode.
104
-
105
- ### Convert a Skill
106
-
107
- Skills are directories containing `SKILL.md` and supporting files:
108
-
109
- ```bash
110
- npx @fro.bot/systematic convert skill /path/to/cep/skills/my-skill -o ./skills/my-skill
111
- ```
112
-
113
- ### Convert an Agent
114
-
115
- Agents are markdown files that get OpenCode-compatible YAML frontmatter:
116
-
117
- ```bash
118
- npx @fro.bot/systematic convert agent /path/to/cep/agents/review/my-agent.md -o ./agents/review/my-agent.md
119
- ```
120
-
121
- ### Convert a Command
122
-
123
- Commands are markdown templates:
124
-
125
- ```bash
126
- npx @fro.bot/systematic convert command /path/to/cep/commands/my-command.md -o ./commands/my-command.md
127
- ```
128
-
129
- ### Dry Run
130
-
131
- Preview conversion without writing files:
132
-
133
- ```bash
134
- npx @fro.bot/systematic convert skill /path/to/skill --dry-run
135
- ```
136
-
137
101
  ## Development
138
102
 
139
103
  ```bash
package/dist/cli.js CHANGED
@@ -1,191 +1,14 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
+ convertContent,
3
4
  findAgentsInDir,
4
5
  findCommandsInDir,
5
6
  findSkillsInDir
6
- } from "./index-v8dhd5s2.js";
7
+ } from "./index-33zyxync.js";
7
8
 
8
9
  // src/cli.ts
9
- import fs2 from "node:fs";
10
- import path2 from "node:path";
11
-
12
- // src/lib/converter.ts
13
10
  import fs from "node:fs";
14
11
  import path from "node:path";
15
- function parseFrontmatter(raw) {
16
- const lines = raw.split(/\r?\n/);
17
- if (lines.length === 0 || lines[0].trim() !== "---") {
18
- return { data: {}, body: raw };
19
- }
20
- let endIndex = -1;
21
- for (let i = 1;i < lines.length; i++) {
22
- if (lines[i].trim() === "---") {
23
- endIndex = i;
24
- break;
25
- }
26
- }
27
- if (endIndex === -1) {
28
- return { data: {}, body: raw };
29
- }
30
- const yamlLines = lines.slice(1, endIndex);
31
- const body = lines.slice(endIndex + 1).join(`
32
- `);
33
- const data = {};
34
- for (const line of yamlLines) {
35
- const match = line.match(/^(\w+):\s*(.*)$/);
36
- if (match) {
37
- const [, key, value] = match;
38
- if (value === "true")
39
- data[key] = true;
40
- else if (value === "false")
41
- data[key] = false;
42
- else if (/^\d+(\.\d+)?$/.test(value))
43
- data[key] = parseFloat(value);
44
- else
45
- data[key] = value;
46
- }
47
- }
48
- return { data, body };
49
- }
50
- function formatFrontmatter(data, body) {
51
- const lines = [];
52
- if (data.description)
53
- lines.push(`description: ${data.description}`);
54
- if (data.mode)
55
- lines.push(`mode: ${data.mode}`);
56
- if (data.model)
57
- lines.push(`model: ${data.model}`);
58
- if (data.temperature !== undefined)
59
- lines.push(`temperature: ${data.temperature}`);
60
- if (lines.length === 0)
61
- return body;
62
- return ["---", ...lines, "---", "", body].join(`
63
- `);
64
- }
65
- function inferTemperature(name, description) {
66
- const sample = `${name} ${description || ""}`.toLowerCase();
67
- if (/(review|audit|security|sentinel|oracle|lint|verification|guardian)/.test(sample)) {
68
- return 0.1;
69
- }
70
- if (/(plan|planning|architecture|strategist|analysis|research)/.test(sample)) {
71
- return 0.2;
72
- }
73
- if (/(doc|readme|changelog|editor|writer)/.test(sample)) {
74
- return 0.3;
75
- }
76
- if (/(brainstorm|creative|ideate|design|concept)/.test(sample)) {
77
- return 0.6;
78
- }
79
- return 0.3;
80
- }
81
- function normalizeModel(model) {
82
- if (model.includes("/"))
83
- return model;
84
- if (/^claude-/.test(model))
85
- return `anthropic/${model}`;
86
- if (/^(gpt-|o1-|o3-)/.test(model))
87
- return `openai/${model}`;
88
- if (/^gemini-/.test(model))
89
- return `google/${model}`;
90
- return `anthropic/${model}`;
91
- }
92
- function convertAgent(sourcePath, options = {}) {
93
- const content = fs.readFileSync(sourcePath, "utf-8");
94
- const name = path.basename(sourcePath, ".md");
95
- const { data, body } = parseFrontmatter(content);
96
- const existingDescription = data.description;
97
- const existingModel = data.model;
98
- let description = existingDescription;
99
- if (!description) {
100
- const firstLine = body.split(`
101
- `).find((l) => l.trim() && !l.startsWith("#"));
102
- description = firstLine?.slice(0, 100) || `${name} agent`;
103
- }
104
- const frontmatter = {
105
- description,
106
- mode: "subagent",
107
- temperature: inferTemperature(name, description)
108
- };
109
- if (existingModel && existingModel !== "inherit") {
110
- frontmatter.model = normalizeModel(existingModel);
111
- }
112
- const converted = formatFrontmatter(frontmatter, body.trim());
113
- const outputPath = options.output || sourcePath;
114
- if (!options.dryRun) {
115
- fs.mkdirSync(path.dirname(outputPath), { recursive: true });
116
- fs.writeFileSync(outputPath, converted);
117
- }
118
- return {
119
- type: "agent",
120
- sourcePath,
121
- outputPath,
122
- converted: true,
123
- files: [path.basename(outputPath)]
124
- };
125
- }
126
- function convertCommand(sourcePath, options = {}) {
127
- const content = fs.readFileSync(sourcePath, "utf-8");
128
- const outputPath = options.output || sourcePath;
129
- if (!options.dryRun) {
130
- fs.mkdirSync(path.dirname(outputPath), { recursive: true });
131
- fs.writeFileSync(outputPath, content);
132
- }
133
- return {
134
- type: "command",
135
- sourcePath,
136
- outputPath,
137
- converted: true,
138
- files: [path.basename(outputPath)]
139
- };
140
- }
141
- function convertSkill(sourcePath, options = {}) {
142
- const stats = fs.statSync(sourcePath);
143
- if (!stats.isDirectory()) {
144
- throw new Error(`Skill source must be a directory: ${sourcePath}`);
145
- }
146
- const skillName = path.basename(sourcePath);
147
- const outputPath = options.output || sourcePath;
148
- const files = [];
149
- function copyDir(src, dest) {
150
- if (!options.dryRun) {
151
- fs.mkdirSync(dest, { recursive: true });
152
- }
153
- for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
154
- const srcPath = path.join(src, entry.name);
155
- const destPath = path.join(dest, entry.name);
156
- if (entry.isDirectory()) {
157
- copyDir(srcPath, destPath);
158
- } else {
159
- files.push(path.relative(outputPath, destPath));
160
- if (!options.dryRun) {
161
- fs.copyFileSync(srcPath, destPath);
162
- }
163
- }
164
- }
165
- }
166
- copyDir(sourcePath, outputPath);
167
- return {
168
- type: "skill",
169
- sourcePath,
170
- outputPath,
171
- converted: true,
172
- files
173
- };
174
- }
175
- function convert(type, sourcePath, options = {}) {
176
- switch (type) {
177
- case "agent":
178
- return convertAgent(sourcePath, options);
179
- case "command":
180
- return convertCommand(sourcePath, options);
181
- case "skill":
182
- return convertSkill(sourcePath, options);
183
- default:
184
- throw new Error(`Unknown type: ${type}`);
185
- }
186
- }
187
-
188
- // src/cli.ts
189
12
  var VERSION = "0.1.0";
190
13
  var HELP = `
191
14
  systematic - OpenCode plugin for systematic engineering workflows
@@ -195,32 +18,32 @@ Usage:
195
18
 
196
19
  Commands:
197
20
  list [type] List available skills, agents, or commands
198
- convert <type> <source> [--output <path>] [--dry-run]
199
- Convert Claude Code content to OpenCode format
200
- Types: skill, agent, command
21
+ convert <type> <file> [--mode=primary|subagent]
22
+ Convert and inspect a file (outputs to stdout)
201
23
  config [subcommand] Configuration management
202
24
  show Show configuration
203
25
  path Print config file locations
204
26
 
205
27
  Options:
206
- --output, -o Output path for convert command
207
- --dry-run Preview conversion without writing files
208
28
  -h, --help Show this help message
209
29
  -v, --version Show version
210
30
 
211
31
  Examples:
212
32
  systematic list skills
213
- systematic convert skill /path/to/cep/skills/agent-browser -o ./skills/agent-browser
214
- systematic convert agent /path/to/agent.md --dry-run
33
+ systematic list agents
34
+ systematic convert agent ./agents/my-agent.md
35
+ systematic convert agent ./agents/my-agent.md --mode=primary
36
+ systematic convert skill ./skills/my-skill/SKILL.md
37
+ systematic config show
215
38
  `;
216
39
  function getUserConfigDir() {
217
- return path2.join(process.env.HOME || process.env.USERPROFILE || ".", ".config/opencode");
40
+ return path.join(process.env.HOME || process.env.USERPROFILE || ".", ".config/opencode");
218
41
  }
219
42
  function getProjectConfigDir() {
220
- return path2.join(process.cwd(), ".opencode");
43
+ return path.join(process.cwd(), ".opencode");
221
44
  }
222
45
  function listItems(type) {
223
- const packageRoot = path2.resolve(import.meta.dirname, "..");
46
+ const packageRoot = path.resolve(import.meta.dirname, "..");
224
47
  const bundledDir = packageRoot;
225
48
  let finder;
226
49
  let subdir;
@@ -241,7 +64,7 @@ function listItems(type) {
241
64
  console.error(`Unknown type: ${type}. Use: skills, agents, commands`);
242
65
  process.exit(1);
243
66
  }
244
- const items = finder(path2.join(bundledDir, subdir), "bundled");
67
+ const items = finder(path.join(bundledDir, subdir), "bundled");
245
68
  if (items.length === 0) {
246
69
  console.log(`No ${type} found.`);
247
70
  return;
@@ -252,71 +75,57 @@ function listItems(type) {
252
75
  console.log(` ${item.name} (${item.sourceType})`);
253
76
  }
254
77
  }
78
+ function runConvert(type, filePath, modeArg) {
79
+ const validTypes = ["skill", "agent", "command"];
80
+ if (!validTypes.includes(type)) {
81
+ console.error(`Invalid type: ${type}. Must be one of: ${validTypes.join(", ")}`);
82
+ process.exit(1);
83
+ }
84
+ const resolvedPath = path.resolve(filePath);
85
+ if (!fs.existsSync(resolvedPath)) {
86
+ console.error(`File not found: ${resolvedPath}`);
87
+ process.exit(1);
88
+ }
89
+ let agentMode = "subagent";
90
+ if (modeArg) {
91
+ const modeMatch = modeArg.match(/^--mode=(primary|subagent)$/);
92
+ if (modeMatch) {
93
+ agentMode = modeMatch[1];
94
+ } else {
95
+ console.error("Invalid --mode flag. Use: --mode=primary or --mode=subagent");
96
+ process.exit(1);
97
+ }
98
+ }
99
+ const content = fs.readFileSync(resolvedPath, "utf8");
100
+ const converted = convertContent(content, type, { agentMode });
101
+ console.log(converted);
102
+ }
255
103
  function configShow() {
256
104
  const userDir = getUserConfigDir();
257
105
  const projectDir = getProjectConfigDir();
258
106
  console.log(`Configuration locations:
259
107
  `);
260
- console.log(` User config: ${path2.join(userDir, "systematic.json")}`);
261
- console.log(` Project config: ${path2.join(projectDir, "systematic.json")}`);
262
- const projectConfig = path2.join(projectDir, "systematic.json");
263
- if (fs2.existsSync(projectConfig)) {
108
+ console.log(` User config: ${path.join(userDir, "systematic.json")}`);
109
+ console.log(` Project config: ${path.join(projectDir, "systematic.json")}`);
110
+ const projectConfig = path.join(projectDir, "systematic.json");
111
+ if (fs.existsSync(projectConfig)) {
264
112
  console.log(`
265
113
  Project configuration:`);
266
- console.log(fs2.readFileSync(projectConfig, "utf-8"));
114
+ console.log(fs.readFileSync(projectConfig, "utf-8"));
267
115
  }
268
- const userConfig = path2.join(userDir, "systematic.json");
269
- if (fs2.existsSync(userConfig)) {
116
+ const userConfig = path.join(userDir, "systematic.json");
117
+ if (fs.existsSync(userConfig)) {
270
118
  console.log(`
271
119
  User configuration:`);
272
- console.log(fs2.readFileSync(userConfig, "utf-8"));
120
+ console.log(fs.readFileSync(userConfig, "utf-8"));
273
121
  }
274
122
  }
275
123
  function configPath() {
276
124
  const userDir = getUserConfigDir();
277
125
  const projectDir = getProjectConfigDir();
278
126
  console.log("Config file paths:");
279
- console.log(` User: ${path2.join(userDir, "systematic.json")}`);
280
- console.log(` Project: ${path2.join(projectDir, "systematic.json")}`);
281
- }
282
- function runConvert(args) {
283
- const typeArg = args[1];
284
- const sourceArg = args[2];
285
- if (!typeArg || !sourceArg) {
286
- console.error("Usage: systematic convert <type> <source> [--output <path>] [--dry-run]");
287
- console.error("Types: skill, agent, command");
288
- process.exit(1);
289
- }
290
- const validTypes = ["skill", "agent", "command"];
291
- if (!validTypes.includes(typeArg)) {
292
- console.error(`Invalid type: ${typeArg}. Must be one of: ${validTypes.join(", ")}`);
293
- process.exit(1);
294
- }
295
- const sourcePath = path2.resolve(sourceArg);
296
- if (!fs2.existsSync(sourcePath)) {
297
- console.error(`Source not found: ${sourcePath}`);
298
- process.exit(1);
299
- }
300
- const outputIndex = args.findIndex((a) => a === "--output" || a === "-o");
301
- const outputPath = outputIndex !== -1 ? path2.resolve(args[outputIndex + 1]) : undefined;
302
- const dryRun = args.includes("--dry-run");
303
- try {
304
- const result = convert(typeArg, sourcePath, { output: outputPath, dryRun });
305
- if (dryRun) {
306
- console.log(`[DRY RUN] Would convert ${result.type}:`);
307
- } else {
308
- console.log(`Converted ${result.type}:`);
309
- }
310
- console.log(` Source: ${result.sourcePath}`);
311
- console.log(` Output: ${result.outputPath}`);
312
- console.log(" Files:");
313
- for (const file of result.files) {
314
- console.log(` - ${file}`);
315
- }
316
- } catch (err) {
317
- console.error(`Conversion failed: ${err.message}`);
318
- process.exit(1);
319
- }
127
+ console.log(` User: ${path.join(userDir, "systematic.json")}`);
128
+ console.log(` Project: ${path.join(projectDir, "systematic.json")}`);
320
129
  }
321
130
  var args = process.argv.slice(2);
322
131
  var command = args[0];
@@ -325,7 +134,12 @@ switch (command) {
325
134
  listItems(args[1] || "skills");
326
135
  break;
327
136
  case "convert":
328
- runConvert(args);
137
+ if (!args[1] || !args[2]) {
138
+ console.error("Usage: systematic convert <type> <file> [--mode=primary|subagent]");
139
+ console.error(" type: skill, agent, or command");
140
+ process.exit(1);
141
+ }
142
+ runConvert(args[1], args[2], args[3]);
329
143
  break;
330
144
  case "config":
331
145
  switch (args[1]) {
@@ -1,9 +1,138 @@
1
- // src/lib/skills-core.ts
1
+ // src/lib/converter.ts
2
2
  import fs from "node:fs";
3
+ var cache = new Map;
4
+ function parseFrontmatter(content) {
5
+ const lines = content.split(/\r?\n/);
6
+ if (lines.length === 0 || lines[0].trim() !== "---") {
7
+ return { data: {}, body: content, raw: "" };
8
+ }
9
+ let endIndex = -1;
10
+ for (let i = 1;i < lines.length; i++) {
11
+ if (lines[i].trim() === "---") {
12
+ endIndex = i;
13
+ break;
14
+ }
15
+ }
16
+ if (endIndex === -1) {
17
+ return { data: {}, body: content, raw: "" };
18
+ }
19
+ const yamlLines = lines.slice(1, endIndex);
20
+ const body = lines.slice(endIndex + 1).join(`
21
+ `);
22
+ const raw = lines.slice(0, endIndex + 1).join(`
23
+ `);
24
+ const data = {};
25
+ for (const line of yamlLines) {
26
+ const match = line.match(/^([\w-]+):\s*(.*)$/);
27
+ if (match) {
28
+ const [, key, value] = match;
29
+ if (value === "true")
30
+ data[key] = true;
31
+ else if (value === "false")
32
+ data[key] = false;
33
+ else if (/^\d+(\.\d+)?$/.test(value))
34
+ data[key] = parseFloat(value);
35
+ else
36
+ data[key] = value;
37
+ }
38
+ }
39
+ return { data, body, raw };
40
+ }
41
+ function formatFrontmatter(data) {
42
+ const lines = ["---"];
43
+ for (const [key, value] of Object.entries(data)) {
44
+ lines.push(`${key}: ${value}`);
45
+ }
46
+ lines.push("---");
47
+ return lines.join(`
48
+ `);
49
+ }
50
+ function inferTemperature(name, description) {
51
+ const sample = `${name} ${description ?? ""}`.toLowerCase();
52
+ if (/(review|audit|security|sentinel|oracle|lint|verification|guardian)/.test(sample)) {
53
+ return 0.1;
54
+ }
55
+ if (/(plan|planning|architecture|strategist|analysis|research)/.test(sample)) {
56
+ return 0.2;
57
+ }
58
+ if (/(doc|readme|changelog|editor|writer)/.test(sample)) {
59
+ return 0.3;
60
+ }
61
+ if (/(brainstorm|creative|ideate|design|concept)/.test(sample)) {
62
+ return 0.6;
63
+ }
64
+ return 0.3;
65
+ }
66
+ function normalizeModel(model) {
67
+ if (model.includes("/"))
68
+ return model;
69
+ if (model === "inherit")
70
+ return model;
71
+ if (/^claude-/.test(model))
72
+ return `anthropic/${model}`;
73
+ if (/^(gpt-|o1-|o3-)/.test(model))
74
+ return `openai/${model}`;
75
+ if (/^gemini-/.test(model))
76
+ return `google/${model}`;
77
+ return `anthropic/${model}`;
78
+ }
79
+ function transformAgentFrontmatter(data, agentMode) {
80
+ const name = typeof data.name === "string" ? data.name : "";
81
+ const description = typeof data.description === "string" ? data.description : "";
82
+ const newData = {
83
+ description: description || `${name} agent`,
84
+ mode: agentMode
85
+ };
86
+ if (typeof data.model === "string" && data.model !== "inherit") {
87
+ newData.model = normalizeModel(data.model);
88
+ }
89
+ if (typeof data.temperature === "number") {
90
+ newData.temperature = data.temperature;
91
+ } else {
92
+ newData.temperature = inferTemperature(name, description);
93
+ }
94
+ return newData;
95
+ }
96
+ function convertContent(content, type, options = {}) {
97
+ if (content === "")
98
+ return "";
99
+ const { data, body, raw } = parseFrontmatter(content);
100
+ const hasFrontmatter = raw !== "";
101
+ if (!hasFrontmatter) {
102
+ return content;
103
+ }
104
+ if (type === "agent") {
105
+ const agentMode = options.agentMode ?? "subagent";
106
+ const transformedData = transformAgentFrontmatter(data, agentMode);
107
+ return `${formatFrontmatter(transformedData)}
108
+ ${body}`;
109
+ }
110
+ return content;
111
+ }
112
+ function convertFileWithCache(filePath, type, options = {}) {
113
+ const fd = fs.openSync(filePath, "r");
114
+ try {
115
+ const stats = fs.fstatSync(fd);
116
+ const cacheKey = `${filePath}:${type}:${options.source ?? "bundled"}:${options.agentMode ?? "subagent"}`;
117
+ const cached = cache.get(cacheKey);
118
+ if (cached != null && cached.mtimeMs === stats.mtimeMs) {
119
+ return cached.converted;
120
+ }
121
+ const content = fs.readFileSync(fd, "utf8");
122
+ const converted = convertContent(content, type, options);
123
+ cache.set(cacheKey, { mtimeMs: stats.mtimeMs, converted });
124
+ return converted;
125
+ } finally {
126
+ fs.closeSync(fd);
127
+ }
128
+ }
129
+
130
+ // src/lib/skills-core.ts
131
+ import fs2 from "node:fs";
3
132
  import path from "node:path";
4
133
  function extractFrontmatter(filePath) {
5
134
  try {
6
- const content = fs.readFileSync(filePath, "utf8");
135
+ const content = fs2.readFileSync(filePath, "utf8");
7
136
  const lines = content.split(`
8
137
  `);
9
138
  let inFrontmatter = false;
@@ -56,17 +185,17 @@ function stripFrontmatter(content) {
56
185
  }
57
186
  function findSkillsInDir(dir, sourceType, maxDepth = 3) {
58
187
  const skills = [];
59
- if (!fs.existsSync(dir))
188
+ if (!fs2.existsSync(dir))
60
189
  return skills;
61
190
  function recurse(currentDir, depth) {
62
191
  if (depth > maxDepth)
63
192
  return;
64
- const entries = fs.readdirSync(currentDir, { withFileTypes: true });
193
+ const entries = fs2.readdirSync(currentDir, { withFileTypes: true });
65
194
  for (const entry of entries) {
66
195
  const fullPath = path.join(currentDir, entry.name);
67
196
  if (entry.isDirectory()) {
68
197
  const skillFile = path.join(fullPath, "SKILL.md");
69
- if (fs.existsSync(skillFile)) {
198
+ if (fs2.existsSync(skillFile)) {
70
199
  const { name, description } = extractFrontmatter(skillFile);
71
200
  skills.push({
72
201
  path: fullPath,
@@ -85,12 +214,12 @@ function findSkillsInDir(dir, sourceType, maxDepth = 3) {
85
214
  }
86
215
  function findAgentsInDir(dir, sourceType, maxDepth = 2) {
87
216
  const agents = [];
88
- if (!fs.existsSync(dir))
217
+ if (!fs2.existsSync(dir))
89
218
  return agents;
90
219
  function recurse(currentDir, depth, category) {
91
220
  if (depth > maxDepth)
92
221
  return;
93
- const entries = fs.readdirSync(currentDir, { withFileTypes: true });
222
+ const entries = fs2.readdirSync(currentDir, { withFileTypes: true });
94
223
  for (const entry of entries) {
95
224
  const fullPath = path.join(currentDir, entry.name);
96
225
  if (entry.isDirectory()) {
@@ -110,12 +239,12 @@ function findAgentsInDir(dir, sourceType, maxDepth = 2) {
110
239
  }
111
240
  function findCommandsInDir(dir, sourceType, maxDepth = 2) {
112
241
  const commands = [];
113
- if (!fs.existsSync(dir))
242
+ if (!fs2.existsSync(dir))
114
243
  return commands;
115
244
  function recurse(currentDir, depth, category) {
116
245
  if (depth > maxDepth)
117
246
  return;
118
- const entries = fs.readdirSync(currentDir, { withFileTypes: true });
247
+ const entries = fs2.readdirSync(currentDir, { withFileTypes: true });
119
248
  for (const entry of entries) {
120
249
  const fullPath = path.join(currentDir, entry.name);
121
250
  if (entry.isDirectory()) {
@@ -191,4 +320,4 @@ function extractCommandFrontmatter(content) {
191
320
  return { name, description, argumentHint };
192
321
  }
193
322
 
194
- export { stripFrontmatter, findSkillsInDir, findAgentsInDir, findCommandsInDir, extractAgentFrontmatter, extractCommandFrontmatter };
323
+ export { convertContent, convertFileWithCache, stripFrontmatter, findSkillsInDir, findAgentsInDir, findCommandsInDir, extractAgentFrontmatter, extractCommandFrontmatter };
package/dist/index.js CHANGED
@@ -1,21 +1,19 @@
1
1
  import {
2
+ convertContent,
3
+ convertFileWithCache,
2
4
  extractAgentFrontmatter,
3
5
  extractCommandFrontmatter,
4
6
  findAgentsInDir,
5
7
  findCommandsInDir,
6
8
  findSkillsInDir,
7
9
  stripFrontmatter
8
- } from "./index-v8dhd5s2.js";
10
+ } from "./index-33zyxync.js";
9
11
 
10
12
  // src/index.ts
11
13
  import fs3 from "node:fs";
12
14
  import os2 from "node:os";
13
- import path2 from "node:path";
15
+ import path3 from "node:path";
14
16
  import { fileURLToPath } from "node:url";
15
- import { tool } from "@opencode-ai/plugin/tool";
16
-
17
- // src/lib/config-handler.ts
18
- import fs2 from "node:fs";
19
17
 
20
18
  // src/lib/config.ts
21
19
  import fs from "node:fs";
@@ -70,11 +68,14 @@ function loadConfig(projectDir) {
70
68
  // src/lib/config-handler.ts
71
69
  function loadAgentAsConfig(agentInfo) {
72
70
  try {
73
- const content = fs2.readFileSync(agentInfo.file, "utf8");
74
- const { name, description, prompt } = extractAgentFrontmatter(content);
71
+ const converted = convertFileWithCache(agentInfo.file, "agent", {
72
+ source: "bundled",
73
+ agentMode: "subagent"
74
+ });
75
+ const { description, prompt } = extractAgentFrontmatter(converted);
75
76
  return {
76
- description: description || `${name || agentInfo.name} agent`,
77
- prompt: prompt || stripFrontmatter(content)
77
+ description: description || `${agentInfo.name} agent`,
78
+ prompt: prompt || stripFrontmatter(converted)
78
79
  };
79
80
  } catch {
80
81
  return null;
@@ -82,11 +83,11 @@ function loadAgentAsConfig(agentInfo) {
82
83
  }
83
84
  function loadCommandAsConfig(commandInfo) {
84
85
  try {
85
- const content = fs2.readFileSync(commandInfo.file, "utf8");
86
- const { name, description } = extractCommandFrontmatter(content);
86
+ const converted = convertFileWithCache(commandInfo.file, "command", { source: "bundled" });
87
+ const { name, description } = extractCommandFrontmatter(converted);
87
88
  const cleanName = commandInfo.name.replace(/^\//, "");
88
89
  return {
89
- template: stripFrontmatter(content),
90
+ template: stripFrontmatter(converted),
90
91
  description: description || `${name || cleanName} command`
91
92
  };
92
93
  } catch {
@@ -95,9 +96,9 @@ function loadCommandAsConfig(commandInfo) {
95
96
  }
96
97
  function loadSkillAsCommand(skillInfo) {
97
98
  try {
98
- const content = fs2.readFileSync(skillInfo.skillFile, "utf8");
99
+ const converted = convertFileWithCache(skillInfo.skillFile, "skill", { source: "bundled" });
99
100
  return {
100
- template: stripFrontmatter(content),
101
+ template: stripFrontmatter(converted),
101
102
  description: skillInfo.description || `${skillInfo.name} skill`
102
103
  };
103
104
  } catch {
@@ -165,32 +166,168 @@ function createConfigHandler(deps) {
165
166
  };
166
167
  }
167
168
 
168
- // src/index.ts
169
- var __dirname2 = path2.dirname(fileURLToPath(import.meta.url));
170
- var packageRoot = path2.resolve(__dirname2, "..");
171
- var bundledSkillsDir = path2.join(packageRoot, "skills");
172
- var bundledAgentsDir = path2.join(packageRoot, "agents");
173
- var bundledCommandsDir = path2.join(packageRoot, "commands");
174
- function formatItemList(items, emptyMessage, header) {
175
- if (items.length === 0)
176
- return emptyMessage;
177
- let output = header;
178
- for (const item of items) {
179
- output += `- ${item.name} (${item.sourceType})
180
- `;
169
+ // src/lib/skill-tool.ts
170
+ import fs2 from "node:fs";
171
+ import path2 from "node:path";
172
+ import { tool } from "@opencode-ai/plugin/tool";
173
+ var HOOK_KEY = "systematic_skill_tool_hooked";
174
+ var SYSTEMATIC_MARKER = "__systematic_skill_tool__";
175
+ var globalStore = globalThis;
176
+ function getHookState() {
177
+ let state = globalStore[HOOK_KEY];
178
+ if (state == null) {
179
+ state = {
180
+ hookedTool: null,
181
+ hookedDescription: null,
182
+ initialized: false
183
+ };
184
+ globalStore[HOOK_KEY] = state;
185
+ }
186
+ return state;
187
+ }
188
+ function getHookedTool() {
189
+ return getHookState().hookedTool;
190
+ }
191
+ function formatSkillsXml(skills) {
192
+ if (skills.length === 0)
193
+ return "";
194
+ const skillsXml = skills.map((skill) => {
195
+ const lines = [
196
+ " <skill>",
197
+ ` <name>systematic:${skill.name}</name>`,
198
+ ` <description>${skill.description}</description>`
199
+ ];
200
+ lines.push(" </skill>");
201
+ return lines.join(`
202
+ `);
203
+ }).join(`
204
+ `);
205
+ return `<available_skills>
206
+ ${skillsXml}
207
+ </available_skills>`;
208
+ }
209
+ function mergeDescriptions(baseDescription, hookedDescription, systematicSkillsXml) {
210
+ if (hookedDescription == null || hookedDescription.trim() === "") {
211
+ return `${baseDescription}
212
+
213
+ ${systematicSkillsXml}`;
214
+ }
215
+ const availableSkillsMatch = hookedDescription.match(/<available_skills>([\s\S]*?)<\/available_skills>/);
216
+ if (availableSkillsMatch) {
217
+ const existingSkillsContent = availableSkillsMatch[1];
218
+ const systematicSkillsContent = systematicSkillsXml.replace("<available_skills>", "").replace("</available_skills>", "").trim();
219
+ const mergedContent = `<available_skills>
220
+ ${systematicSkillsContent}
221
+ ${existingSkillsContent}</available_skills>`;
222
+ return hookedDescription.replace(/<available_skills>[\s\S]*?<\/available_skills>/, mergedContent);
181
223
  }
182
- return output;
224
+ return `${hookedDescription}
225
+
226
+ ${systematicSkillsXml}`;
227
+ }
228
+ function wrapSkillContent(skillPath, content) {
229
+ const skillDir = path2.dirname(skillPath);
230
+ const converted = convertContent(content, "skill", { source: "bundled" });
231
+ const body = stripFrontmatter(converted);
232
+ return `<skill-instruction>
233
+ Base directory for this skill: ${skillDir}/
234
+ File references (@path) in this skill are relative to this directory.
235
+
236
+ ${body.trim()}
237
+ </skill-instruction>`;
238
+ }
239
+ function createSkillTool(options) {
240
+ const { bundledSkillsDir, disabledSkills } = options;
241
+ const getSystematicSkills = () => {
242
+ return findSkillsInDir(bundledSkillsDir, "bundled", 3).filter((s) => !disabledSkills.includes(s.name)).sort((a, b) => a.name.localeCompare(b.name));
243
+ };
244
+ const buildDescription = () => {
245
+ const skills = getSystematicSkills();
246
+ const systematicXml = formatSkillsXml(skills);
247
+ const baseDescription = `Load a skill to get detailed instructions for a specific task.
248
+
249
+ Skills provide specialized knowledge and step-by-step guidance.
250
+ Use this when a task matches an available skill's description.`;
251
+ const hookState = getHookState();
252
+ return mergeDescriptions(baseDescription, hookState.hookedDescription, systematicXml);
253
+ };
254
+ let cachedDescription = null;
255
+ const toolDef = tool({
256
+ get description() {
257
+ if (cachedDescription == null) {
258
+ cachedDescription = buildDescription();
259
+ }
260
+ return cachedDescription;
261
+ },
262
+ args: {
263
+ name: tool.schema.string().describe("The skill identifier from available_skills (e.g., 'systematic:brainstorming')")
264
+ },
265
+ async execute(args) {
266
+ const requestedName = args.name;
267
+ const normalizedName = requestedName.startsWith("systematic:") ? requestedName.slice("systematic:".length) : requestedName;
268
+ const skills = getSystematicSkills();
269
+ const matchedSkill = skills.find((s) => s.name === normalizedName);
270
+ if (matchedSkill) {
271
+ try {
272
+ const content = fs2.readFileSync(matchedSkill.skillFile, "utf8");
273
+ const wrapped = wrapSkillContent(matchedSkill.skillFile, content);
274
+ return `## Skill: systematic:${matchedSkill.name}
275
+
276
+ **Base directory**: ${matchedSkill.path}
277
+
278
+ ${wrapped}`;
279
+ } catch (error) {
280
+ const errorMessage = error instanceof Error ? error.message : String(error);
281
+ throw new Error(`Failed to load skill "${requestedName}": ${errorMessage}`);
282
+ }
283
+ }
284
+ const hookedTool = getHookedTool();
285
+ if (hookedTool != null && typeof hookedTool.execute === "function") {
286
+ try {
287
+ return await hookedTool.execute(args);
288
+ } catch {}
289
+ }
290
+ const availableSystematic = skills.map((s) => `systematic:${s.name}`);
291
+ throw new Error(`Skill "${requestedName}" not found. Available systematic skills: ${availableSystematic.join(", ")}`);
292
+ }
293
+ });
294
+ Object.defineProperty(toolDef, SYSTEMATIC_MARKER, {
295
+ value: true,
296
+ enumerable: false,
297
+ writable: false
298
+ });
299
+ return toolDef;
183
300
  }
301
+
302
+ // src/index.ts
303
+ var __dirname2 = path3.dirname(fileURLToPath(import.meta.url));
304
+ var packageRoot = path3.resolve(__dirname2, "..");
305
+ var bundledSkillsDir = path3.join(packageRoot, "skills");
306
+ var bundledAgentsDir = path3.join(packageRoot, "agents");
307
+ var bundledCommandsDir = path3.join(packageRoot, "commands");
308
+ var packageJsonPath = path3.join(packageRoot, "package.json");
309
+ var hasLoggedInit = false;
310
+ var getPackageVersion = () => {
311
+ try {
312
+ if (!fs3.existsSync(packageJsonPath))
313
+ return "unknown";
314
+ const content = fs3.readFileSync(packageJsonPath, "utf8");
315
+ const parsed = JSON.parse(content);
316
+ return parsed.version ?? "unknown";
317
+ } catch {
318
+ return "unknown";
319
+ }
320
+ };
184
321
  var getBootstrapContent = (config) => {
185
322
  if (!config.bootstrap.enabled)
186
323
  return null;
187
324
  if (config.bootstrap.file) {
188
- const customPath = config.bootstrap.file.startsWith("~/") ? path2.join(os2.homedir(), config.bootstrap.file.slice(2)) : config.bootstrap.file;
325
+ const customPath = config.bootstrap.file.startsWith("~/") ? path3.join(os2.homedir(), config.bootstrap.file.slice(2)) : config.bootstrap.file;
189
326
  if (fs3.existsSync(customPath)) {
190
327
  return fs3.readFileSync(customPath, "utf8");
191
328
  }
192
329
  }
193
- const usingSystematicPath = path2.join(bundledSkillsDir, "using-systematic/SKILL.md");
330
+ const usingSystematicPath = path3.join(bundledSkillsDir, "using-systematic/SKILL.md");
194
331
  if (!fs3.existsSync(usingSystematicPath))
195
332
  return null;
196
333
  const fullContent = fs3.readFileSync(usingSystematicPath, "utf8");
@@ -200,19 +337,23 @@ When skills reference tools you don't have, substitute OpenCode equivalents:
200
337
  - \`TodoWrite\` → \`update_plan\`
201
338
  - \`Task\` tool with subagents → Use OpenCode's subagent system (@mention)
202
339
  - \`Skill\` tool → OpenCode's native \`skill\` tool
340
+ - \`SystematicSkill\` tool → \`systematic_skill\` (Systematic plugin skills)
203
341
  - \`Read\`, \`Write\`, \`Edit\`, \`Bash\` → Your native tools
204
342
 
205
343
  **Skills naming:**
206
344
  - Bundled skills use the \`systematic:\` prefix (e.g., \`systematic:brainstorming\`)
207
345
  - Skills can also be invoked without prefix if unambiguous
208
346
 
347
+ **Skills usage:**
348
+ - Use \`systematic_skill\` to load Systematic bundled skills
349
+ - Use the native \`skill\` tool for non-Systematic skills
350
+
209
351
  **Skills location:**
210
- Bundled skills are in \`${bundledSkillsDir}/\`
211
- Use \`systematic_find_skills\` to list all available skills.`;
352
+ Bundled skills are in \`${bundledSkillsDir}/\``;
212
353
  return `<SYSTEMATIC_WORKFLOWS>
213
354
  You have access to structured engineering workflows via the systematic plugin.
214
355
 
215
- **IMPORTANT: The using-systematic skill content is included below. It is ALREADY LOADED - you are currently following it. Do NOT use the skill tool to load "using-systematic" again - that would be redundant.**
356
+ **IMPORTANT: The using-systematic skill content is included below. It is ALREADY LOADED - you are currently following it. Do NOT use the systematic_skill tool to load "using-systematic" again - that would be redundant.**
216
357
 
217
358
  ${content}
218
359
 
@@ -230,56 +371,37 @@ var SystematicPlugin = async ({ client, directory }) => {
230
371
  return {
231
372
  config: configHandler,
232
373
  tool: {
233
- systematic_find_skills: tool({
234
- description: "List all available skills in the bundled skill library.",
235
- args: {},
236
- execute: async () => {
237
- const bundledSkills = findSkillsInDir(bundledSkillsDir, "bundled", 3);
238
- const skills = bundledSkills.filter((s) => !config.disabled_skills.includes(s.name)).sort((a, b) => a.name.localeCompare(b.name));
239
- if (skills.length === 0) {
240
- return "No skills available. Skills are bundled with the systematic plugin.";
241
- }
242
- let output = `Available skills:
243
-
244
- `;
245
- for (const skill of skills) {
246
- output += `systematic:${skill.name}
247
- `;
248
- if (skill.description) {
249
- output += ` ${skill.description}
250
- `;
251
- }
252
- output += ` Directory: ${skill.path}
253
-
254
- `;
255
- }
256
- return output.trim();
257
- }
258
- }),
259
- systematic_find_agents: tool({
260
- description: "List all available review agents.",
261
- args: {},
262
- execute: async () => {
263
- const bundledAgents = findAgentsInDir(bundledAgentsDir, "bundled");
264
- const agents = bundledAgents.filter((a) => !config.disabled_agents.includes(a.name)).sort((a, b) => a.name.localeCompare(b.name));
265
- return formatItemList(agents, "No agents available.", `Available agents:
266
-
267
- `);
268
- }
269
- }),
270
- systematic_find_commands: tool({
271
- description: "List all available commands.",
272
- args: {},
273
- execute: async () => {
274
- const bundledCommands = findCommandsInDir(bundledCommandsDir, "bundled");
275
- const commands = bundledCommands.filter((c) => !config.disabled_commands.includes(c.name.replace(/^\//, ""))).sort((a, b) => a.name.localeCompare(b.name));
276
- return formatItemList(commands, "No commands available.", `Available commands:
277
-
278
- `);
279
- }
374
+ systematic_skill: createSkillTool({
375
+ bundledSkillsDir,
376
+ disabledSkills: config.disabled_skills
280
377
  })
281
378
  },
282
379
  "experimental.chat.system.transform": async (_input, output) => {
380
+ if (!hasLoggedInit) {
381
+ hasLoggedInit = true;
382
+ const packageVersion = getPackageVersion();
383
+ try {
384
+ await client.app.log({
385
+ body: {
386
+ service: "systematic",
387
+ level: "info",
388
+ message: "Systematic plugin initialized",
389
+ extra: {
390
+ version: packageVersion,
391
+ bootstrapEnabled: config.bootstrap.enabled,
392
+ disabledSkillsCount: config.disabled_skills.length,
393
+ disabledAgentsCount: config.disabled_agents.length,
394
+ disabledCommandsCount: config.disabled_commands.length
395
+ }
396
+ }
397
+ });
398
+ } catch {}
399
+ }
400
+ const existingSystem = output.system.join(`
401
+ `).toLowerCase();
402
+ if (existingSystem.includes("title generator") || existingSystem.includes("generate a title")) {
403
+ return;
404
+ }
283
405
  const content = getBootstrapContent(config);
284
406
  if (content) {
285
407
  if (!output.system) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fro.bot/systematic",
3
- "version": "1.0.3",
3
+ "version": "1.2.0",
4
4
  "description": "Structured engineering workflows for OpenCode",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -50,7 +50,7 @@
50
50
  "@opencode-ai/plugin": "^1.1.30"
51
51
  },
52
52
  "devDependencies": {
53
- "@biomejs/biome": "^1.9.0",
53
+ "@biomejs/biome": "^2.0.0",
54
54
  "@opencode-ai/plugin": "^1.1.30",
55
55
  "@types/bun": "latest",
56
56
  "@types/node": "^22.0.0",
@@ -13,7 +13,7 @@ This is not negotiable. This is not optional. You cannot rationalize your way ou
13
13
 
14
14
  ## How to Access Skills
15
15
 
16
- Use the `skill` tool. When you invoke a skill, its content is loaded and presented to you—follow it directly.
16
+ Use the `systematic_skill` tool for Systematic bundled skills. Use the native `skill` tool for non-Systematic skills. When you invoke a skill, its content is loaded and presented to you—follow it directly.
17
17
 
18
18
  # Using Skills
19
19
 
@@ -25,7 +25,7 @@ Use the `skill` tool. When you invoke a skill, its content is loaded and present
25
25
  digraph skill_flow {
26
26
  "User message received" [shape=doublecircle];
27
27
  "Might any skill apply?" [shape=diamond];
28
- "Invoke `skill` tool" [shape=box];
28
+ "Invoke `systematic_skill` tool" [shape=box];
29
29
  "Announce: 'Using [skill] to [purpose]'" [shape=box];
30
30
  "Has checklist?" [shape=diamond];
31
31
  "Create todo per item" [shape=box];
@@ -33,9 +33,9 @@ digraph skill_flow {
33
33
  "Respond (including clarifications)" [shape=doublecircle];
34
34
 
35
35
  "User message received" -> "Might any skill apply?";
36
- "Might any skill apply?" -> "Invoke `skill` tool" [label="yes, even 1%"];
36
+ "Might any skill apply?" -> "Invoke `systematic_skill` tool" [label="yes, even 1%"];
37
37
  "Might any skill apply?" -> "Respond (including clarifications)" [label="definitely not"];
38
- "Invoke `skill` tool" -> "Announce: 'Using [skill] to [purpose]'";
38
+ "Invoke `systematic_skill` tool" -> "Announce: 'Using [skill] to [purpose]'";
39
39
  "Announce: 'Using [skill] to [purpose]'" -> "Has checklist?";
40
40
  "Has checklist?" -> "Create todo per item" [label="yes"];
41
41
  "Has checklist?" -> "Follow skill exactly" [label="no"];
@@ -91,4 +91,4 @@ Skills are resolved in priority order:
91
91
  2. **User skills**: `~/.config/opencode/skills/`
92
92
  3. **Bundled skills**: Provided by systematic plugin
93
93
 
94
- Use `systematic_find_skills` to see all available skills and their sources.
94
+ Systematic bundled skills are listed in the `systematic_skill` tool description. Use the native `skill` tool for skills outside the Systematic plugin.