@fro.bot/systematic 2.32.1 → 2.33.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-wjkgb2gb.js";
9
+ } from "./index-bwrrk4a8.js";
10
10
 
11
11
  // src/cli.ts
12
12
  import fs from "fs";
@@ -485,6 +485,36 @@ function walkDir(rootDir, options = {}) {
485
485
  }
486
486
 
487
487
  // src/lib/skills.ts
488
+ function parseMetadata(data) {
489
+ const metadataRaw = data.metadata;
490
+ if (!isRecord(metadataRaw)) {
491
+ return;
492
+ }
493
+ const entries = Object.entries(metadataRaw);
494
+ if (!entries.every(([, v]) => typeof v === "string")) {
495
+ return;
496
+ }
497
+ return Object.fromEntries(entries);
498
+ }
499
+ function parseDeprecated(data) {
500
+ const deprecatedRaw = data.deprecated;
501
+ if (!isRecord(deprecatedRaw)) {
502
+ return;
503
+ }
504
+ const since = typeof deprecatedRaw.since === "string" && deprecatedRaw.since !== "" ? deprecatedRaw.since : undefined;
505
+ const removal = typeof deprecatedRaw.removal === "string" && deprecatedRaw.removal !== "" ? deprecatedRaw.removal : undefined;
506
+ if (since === undefined || removal === undefined) {
507
+ return;
508
+ }
509
+ const deprecated = { since, removal };
510
+ if (typeof deprecatedRaw.replacement === "string") {
511
+ deprecated.replacement = deprecatedRaw.replacement;
512
+ }
513
+ if (typeof deprecatedRaw.reason === "string") {
514
+ deprecated.reason = deprecatedRaw.reason;
515
+ }
516
+ return deprecated;
517
+ }
488
518
  function extractFrontmatter(filePath) {
489
519
  try {
490
520
  const content = fs3.readFileSync(filePath, "utf8");
@@ -492,29 +522,8 @@ function extractFrontmatter(filePath) {
492
522
  if (parseError) {
493
523
  return { name: "", description: "" };
494
524
  }
495
- const metadataRaw = data.metadata;
496
- let metadata;
497
- if (isRecord(metadataRaw)) {
498
- const entries = Object.entries(metadataRaw);
499
- if (entries.every(([, v]) => typeof v === "string")) {
500
- metadata = Object.fromEntries(entries);
501
- }
502
- }
503
- const deprecatedRaw = data.deprecated;
504
- let deprecated;
505
- if (isRecord(deprecatedRaw)) {
506
- const since = typeof deprecatedRaw.since === "string" && deprecatedRaw.since !== "" ? deprecatedRaw.since : undefined;
507
- const removal = typeof deprecatedRaw.removal === "string" && deprecatedRaw.removal !== "" ? deprecatedRaw.removal : undefined;
508
- if (since !== undefined && removal !== undefined) {
509
- deprecated = { since, removal };
510
- if (typeof deprecatedRaw.replacement === "string") {
511
- deprecated.replacement = deprecatedRaw.replacement;
512
- }
513
- if (typeof deprecatedRaw.reason === "string") {
514
- deprecated.reason = deprecatedRaw.reason;
515
- }
516
- }
517
- }
525
+ const metadata = parseMetadata(data);
526
+ const deprecated = parseDeprecated(data);
518
527
  const argumentHintRaw = extractNonEmptyString(data, "argument-hint");
519
528
  const argumentHint = argumentHintRaw?.replace(/^["']|["']$/g, "") || undefined;
520
529
  return {
@@ -16040,6 +16049,10 @@ function createSystematicConfigSchema(opts) {
16040
16049
  { enabled: true },
16041
16050
  { enabled: false, file: ".opencode/custom-prompt.md" }
16042
16051
  ]
16052
+ }),
16053
+ skills_as_commands: exports_external.boolean().default(true).meta({
16054
+ description: "Register skills discovered from user/project skill directories (OpenCode config and other agent-harness-standard locations) as slash commands. Default true.",
16055
+ examples: [true, false]
16043
16056
  })
16044
16057
  }).strict().meta({
16045
16058
  description: "Systematic user configuration file (systematic.json / systematic.jsonc)",
@@ -16071,7 +16084,8 @@ var DEFAULT_CONFIG = {
16071
16084
  enabled: true
16072
16085
  },
16073
16086
  agents: {},
16074
- categories: {}
16087
+ categories: {},
16088
+ skills_as_commands: true
16075
16089
  };
16076
16090
  var SECURITY_OVERLAY_FIELDS2 = new Set(SECURITY_OVERLAY_FIELDS);
16077
16091
  var CURRENT_SKILL_NAMES_SET = new Set(BUNDLED_SKILL_NAMES);
@@ -16240,7 +16254,8 @@ function loadConfigWithSources(projectDir) {
16240
16254
  ...customConfig?.bootstrap
16241
16255
  },
16242
16256
  agents: overlayValues(overlays.agents),
16243
- categories: overlayValues(overlays.categories)
16257
+ categories: overlayValues(overlays.categories),
16258
+ skills_as_commands: customConfig?.skills_as_commands ?? projectConfig?.skills_as_commands ?? userConfig?.skills_as_commands ?? DEFAULT_CONFIG.skills_as_commands
16244
16259
  };
16245
16260
  const warned = new Set;
16246
16261
  const droppedSkills = computeDroppedNames(result.disabled_skills, CURRENT_SKILL_NAMES_SET);
@@ -16407,4 +16422,4 @@ function extractCommandFrontmatter(content) {
16407
16422
  };
16408
16423
  }
16409
16424
 
16410
- export { parseFrontmatter, isRecord, convertContent, convertFileWithCache, findSkillsInDir, exports_external, AgentOverlaySchema, CategoryOverlaySchema, loadConfig, loadConfigWithSources, getConfigPaths, findAgentsInDir, extractAgentFrontmatter, findCommandsInDir, extractCommandFrontmatter };
16425
+ export { parseFrontmatter, isRecord, convertContent, convertFileWithCache, walkDir, extractFrontmatter, findSkillsInDir, exports_external, AgentOverlaySchema, CategoryOverlaySchema, loadConfig, loadConfigWithSources, getConfigPaths, findAgentsInDir, extractAgentFrontmatter, findCommandsInDir, extractCommandFrontmatter };
package/dist/index.js CHANGED
@@ -6,18 +6,20 @@ import {
6
6
  exports_external,
7
7
  extractAgentFrontmatter,
8
8
  extractCommandFrontmatter,
9
+ extractFrontmatter,
9
10
  findAgentsInDir,
10
11
  findCommandsInDir,
11
12
  findSkillsInDir,
12
13
  isRecord,
13
14
  loadConfig,
14
15
  loadConfigWithSources,
15
- parseFrontmatter
16
- } from "./index-wjkgb2gb.js";
16
+ parseFrontmatter,
17
+ walkDir
18
+ } from "./index-bwrrk4a8.js";
17
19
 
18
20
  // src/index.ts
19
- import fs5 from "fs";
20
- import path7 from "path";
21
+ import fs7 from "fs";
22
+ import path9 from "path";
21
23
  import { fileURLToPath as fileURLToPath2 } from "url";
22
24
 
23
25
  // src/lib/bootstrap.ts
@@ -237,6 +239,11 @@ ${skillUsage}${catalogSection}
237
239
  </SYSTEMATIC_WORKFLOWS>`;
238
240
  }
239
241
 
242
+ // src/lib/config-handler.ts
243
+ import fs5 from "fs";
244
+ import os3 from "os";
245
+ import path7 from "path";
246
+
240
247
  // src/lib/agent-overlays.ts
241
248
  import fs2 from "fs";
242
249
  import path4 from "path";
@@ -624,11 +631,137 @@ function throwConfigError(sourcePath, keyPath, message) {
624
631
  throw new Error(`Invalid Systematic config in ${sourcePath}: ${keyPath} ${message}`);
625
632
  }
626
633
 
634
+ // src/lib/discovered-skills.ts
635
+ import fs3 from "fs";
636
+ import path5 from "path";
637
+ var SKILL_NAME_REGEX = /^[a-z0-9]+(-[a-z0-9]+)*$/;
638
+ function isValidSkillName(name) {
639
+ return name.length >= 1 && name.length <= 64 && SKILL_NAME_REGEX.test(name);
640
+ }
641
+ function findGitWorktreeRoot(startDir) {
642
+ let current = path5.resolve(startDir);
643
+ while (true) {
644
+ try {
645
+ if (fs3.existsSync(path5.join(current, ".git"))) {
646
+ return current;
647
+ }
648
+ } catch {
649
+ return null;
650
+ }
651
+ const parent = path5.dirname(current);
652
+ if (parent === current)
653
+ return null;
654
+ current = parent;
655
+ }
656
+ }
657
+ function upWalk(targets, start, stop) {
658
+ const results = [];
659
+ let current = path5.resolve(start);
660
+ const resolvedStop = stop === undefined ? undefined : path5.resolve(stop);
661
+ while (true) {
662
+ for (const target of targets) {
663
+ const candidate = path5.join(current, target);
664
+ try {
665
+ if (fs3.existsSync(candidate)) {
666
+ results.push(candidate);
667
+ }
668
+ } catch {}
669
+ }
670
+ if (resolvedStop !== undefined && current === resolvedStop)
671
+ break;
672
+ const parent = path5.dirname(current);
673
+ if (parent === current)
674
+ break;
675
+ current = parent;
676
+ }
677
+ return results;
678
+ }
679
+ function uniqueStrings(values) {
680
+ return Array.from(new Set(values));
681
+ }
682
+ function buildOpencodeConfigDirs(startDir, homeDir, gitRoot, globalConfigDir, opencodeConfigDirOverride) {
683
+ const dirs = [globalConfigDir];
684
+ dirs.push(...upWalk([".opencode"], startDir, gitRoot ?? startDir));
685
+ dirs.push(...upWalk([".opencode"], homeDir, homeDir));
686
+ if (opencodeConfigDirOverride !== undefined) {
687
+ dirs.push(opencodeConfigDirOverride);
688
+ }
689
+ return uniqueStrings(dirs);
690
+ }
691
+ function globSkillFiles(rootDir, subdirNames) {
692
+ const results = [];
693
+ for (const subdirName of subdirNames) {
694
+ const scanRoot = path5.join(rootDir, subdirName);
695
+ try {
696
+ if (!fs3.existsSync(scanRoot))
697
+ continue;
698
+ const entries = walkDir(scanRoot, {
699
+ maxDepth: 10,
700
+ filter: (entry) => !entry.isDirectory && entry.name === "SKILL.md"
701
+ });
702
+ for (const entry of entries) {
703
+ results.push(entry.path);
704
+ }
705
+ } catch {}
706
+ }
707
+ return results;
708
+ }
709
+ function toDiscoveredSkill(skillPath, rootId) {
710
+ let stat;
711
+ try {
712
+ stat = fs3.statSync(skillPath);
713
+ } catch {
714
+ return;
715
+ }
716
+ if (!stat.isFile())
717
+ return;
718
+ const frontmatter = extractFrontmatter(skillPath);
719
+ const name = frontmatter.name;
720
+ if (!name || !isValidSkillName(name))
721
+ return;
722
+ return {
723
+ name,
724
+ description: frontmatter.description,
725
+ frontmatter,
726
+ skillPath,
727
+ root: rootId
728
+ };
729
+ }
730
+ function discoverSkills(options) {
731
+ const { startDir, homeDir, configDir, opencodeConfigDirOverride } = options;
732
+ const globalConfigDir = configDir ?? path5.join(homeDir, ".config/opencode");
733
+ const gitRoot = findGitWorktreeRoot(startDir);
734
+ const byName = new Map;
735
+ function upsertAll(skillPaths, rootId) {
736
+ for (const skillPath of skillPaths) {
737
+ const skill = toDiscoveredSkill(skillPath, rootId);
738
+ if (skill)
739
+ byName.set(skill.name, skill);
740
+ }
741
+ }
742
+ upsertAll(globSkillFiles(homeDir, [".claude/skills"]), "global-claude");
743
+ upsertAll(globSkillFiles(homeDir, [".agents/skills"]), "global-agents");
744
+ const externalLevels = upWalk([".claude", ".agents"], startDir, gitRoot ?? startDir);
745
+ for (const levelDir of externalLevels) {
746
+ const isClaudeDir = path5.basename(levelDir) === ".claude";
747
+ const parentDir = path5.dirname(levelDir);
748
+ const subdirGlob = isClaudeDir ? ".claude/skills" : ".agents/skills";
749
+ const rootId = isClaudeDir ? "project-claude" : "project-agents";
750
+ upsertAll(globSkillFiles(parentDir, [subdirGlob]), rootId);
751
+ }
752
+ const configDirs = buildOpencodeConfigDirs(startDir, homeDir, gitRoot, globalConfigDir, opencodeConfigDirOverride);
753
+ for (const dir of configDirs) {
754
+ const rootId = dir === globalConfigDir ? "global-opencode-config" : "project-opencode";
755
+ upsertAll(globSkillFiles(dir, ["skill", "skills"]), rootId);
756
+ }
757
+ return Array.from(byName.values());
758
+ }
759
+
627
760
  // src/lib/model-availability.ts
628
761
  import { createHash } from "crypto";
629
- import fs3 from "fs";
762
+ import fs4 from "fs";
630
763
  import os2 from "os";
631
- import path5 from "path";
764
+ import path6 from "path";
632
765
  function emptyAvailability() {
633
766
  return { status: "unknown", models: new Set };
634
767
  }
@@ -638,8 +771,8 @@ var MODELS_JSON_FILENAME = "models.json";
638
771
  var availabilityCache = new WeakMap;
639
772
  function resolveCacheDir() {
640
773
  const xdgCacheHome = process.env.XDG_CACHE_HOME?.trim();
641
- const cacheBase = xdgCacheHome && path5.isAbsolute(xdgCacheHome) ? xdgCacheHome : path5.join(os2.homedir(), ".cache");
642
- return path5.join(cacheBase, "opencode");
774
+ const cacheBase = xdgCacheHome && path6.isAbsolute(xdgCacheHome) ? xdgCacheHome : path6.join(os2.homedir(), ".cache");
775
+ return path6.join(cacheBase, "opencode");
643
776
  }
644
777
  function fastHash(input) {
645
778
  return createHash("sha1").update(input).digest("hex");
@@ -658,7 +791,7 @@ function isProviderRecord(value) {
658
791
  function readModelsFromCache(filePath) {
659
792
  let fd;
660
793
  try {
661
- fd = fs3.openSync(filePath, "r");
794
+ fd = fs4.openSync(filePath, "r");
662
795
  } catch {
663
796
  return null;
664
797
  }
@@ -666,7 +799,7 @@ function readModelsFromCache(filePath) {
666
799
  try {
667
800
  let stat;
668
801
  try {
669
- stat = fs3.fstatSync(fd);
802
+ stat = fs4.fstatSync(fd);
670
803
  } catch {
671
804
  return null;
672
805
  }
@@ -681,7 +814,7 @@ function readModelsFromCache(filePath) {
681
814
  const buffer = Buffer.alloc(stat.size);
682
815
  let bytesRead;
683
816
  try {
684
- bytesRead = fs3.readSync(fd, buffer, 0, stat.size, 0);
817
+ bytesRead = fs4.readSync(fd, buffer, 0, stat.size, 0);
685
818
  } catch {
686
819
  return null;
687
820
  }
@@ -690,7 +823,7 @@ function readModelsFromCache(filePath) {
690
823
  raw = buffer.toString("utf8");
691
824
  } finally {
692
825
  try {
693
- fs3.closeSync(fd);
826
+ fs4.closeSync(fd);
694
827
  } catch {}
695
828
  }
696
829
  if (raw.trim().length === 0)
@@ -717,14 +850,14 @@ function readFallbackCache() {
717
850
  const cacheDir = resolveCacheDir();
718
851
  const openCodeModelsUrl = process.env.OPENCODE_MODELS_URL?.trim();
719
852
  if (openCodeModelsUrl) {
720
- const urlDerivedPath = path5.join(cacheDir, `models-${fastHash(openCodeModelsUrl)}.json`);
853
+ const urlDerivedPath = path6.join(cacheDir, `models-${fastHash(openCodeModelsUrl)}.json`);
721
854
  const urlResult = readModelsFromCache(urlDerivedPath);
722
855
  if (urlResult !== null && urlResult.size > 0) {
723
856
  return { status: "cache", models: urlResult };
724
857
  }
725
858
  return emptyAvailability();
726
859
  }
727
- const defaultPath = path5.join(cacheDir, MODELS_JSON_FILENAME);
860
+ const defaultPath = path6.join(cacheDir, MODELS_JSON_FILENAME);
728
861
  const defaultResult = readModelsFromCache(defaultPath);
729
862
  if (defaultResult !== null && defaultResult.size > 0) {
730
863
  return { status: "cache", models: defaultResult };
@@ -1090,12 +1223,59 @@ function collectSkillsAsCommands(dir, disabledSkills) {
1090
1223
  }
1091
1224
  return commands;
1092
1225
  }
1226
+ function loadDiscoveredSkillAsCommand(skill) {
1227
+ const description = formatSkillDescription(skill.description, skill.name);
1228
+ if (skill.frontmatter.disableModelInvocation === true) {
1229
+ const content = fs5.readFileSync(skill.skillPath, "utf8");
1230
+ const { body } = parseFrontmatter(content);
1231
+ return {
1232
+ template: wrapSkillTemplate(skill.skillPath, body),
1233
+ description
1234
+ };
1235
+ }
1236
+ return {
1237
+ template: buildDiscoveredSkillShimTemplate(skill.name),
1238
+ description
1239
+ };
1240
+ }
1241
+ function buildDiscoveredSkillShimTemplate(skillName) {
1242
+ return `Load the "${skillName}" skill using the skill tool, then follow its instructions to address this request:
1243
+
1244
+ <user-request>
1245
+ $ARGUMENTS
1246
+ </user-request>`;
1247
+ }
1248
+ function collectDiscoveredSkillsAsCommands(startDir, homeDir, configDir, opencodeConfigDirOverride) {
1249
+ const commands = {};
1250
+ let discovered;
1251
+ try {
1252
+ discovered = discoverSkills({
1253
+ startDir,
1254
+ homeDir,
1255
+ configDir,
1256
+ opencodeConfigDirOverride
1257
+ });
1258
+ } catch {
1259
+ return commands;
1260
+ }
1261
+ for (const skill of discovered) {
1262
+ if (skill.frontmatter.userInvocable === false)
1263
+ continue;
1264
+ try {
1265
+ commands[skill.name] = loadDiscoveredSkillAsCommand(skill);
1266
+ } catch {}
1267
+ }
1268
+ return commands;
1269
+ }
1093
1270
  function collectEnabledSkillNames(dir, disabledSkills) {
1094
1271
  const disabledSet = new Set(disabledSkills);
1095
1272
  return findSkillsInDir(dir).filter((skillInfo) => !disabledSet.has(skillInfo.name)).map((skillInfo) => skillInfo.name);
1096
1273
  }
1097
1274
  function createConfigHandler(deps) {
1098
1275
  const { directory, bundledSkillsDir, bundledAgentsDir: bundledAgentsDir2, bundledCommandsDir } = deps;
1276
+ const homeDir = deps.homeDir ?? os3.homedir();
1277
+ const opencodeConfigDir = deps.opencodeConfigDir ?? path7.join(homeDir, ".config/opencode");
1278
+ const opencodeConfigDirOverride = process.env.OPENCODE_CONFIG_DIR?.trim() ? process.env.OPENCODE_CONFIG_DIR : undefined;
1099
1279
  return async (config) => {
1100
1280
  const { config: systematicConfig, overlays } = loadConfigWithSources(directory);
1101
1281
  const existingAgents = { ...config.agent ?? {} };
@@ -1116,11 +1296,15 @@ function createConfigHandler(deps) {
1116
1296
  const resolvedOverlays = resolveAgentOverlaySet(validatedOverlays);
1117
1297
  const bundledAgents = collectAgents(bundledAgentsDir2, systematicConfig.disabled_agents, nativeAgents, resolvedOverlays, availabilitySet);
1118
1298
  const bundledCommands = collectCommands(bundledCommandsDir, systematicConfig.disabled_commands);
1299
+ const discoveredSkillCommands = systematicConfig.skills_as_commands !== false ? collectDiscoveredSkillsAsCommands(directory, homeDir, opencodeConfigDir, opencodeConfigDirOverride) : {};
1119
1300
  const bundledAgentKeys = new Set(Object.keys(bundledAgents));
1120
1301
  config.agent = mergeSystematicEntries(existingAgents, bundledAgents, (key, agent) => bundledAgentKeys.has(key) && isSystematicAgentConfig(agent));
1121
- const emittedCommands = { ...bundledCommands, ...bundledSkills };
1122
- const emittedCommandKeys = new Set(Object.keys(emittedCommands));
1123
- config.command = mergeSystematicEntries(existingCommands, emittedCommands, (key, command) => isSystematicCommandConfig(command) && (emittedCommandKeys.has(key) || isSystematicOwnedCommandKey(key)));
1302
+ const emittedCommands = {
1303
+ ...bundledCommands,
1304
+ ...bundledSkills,
1305
+ ...discoveredSkillCommands
1306
+ };
1307
+ config.command = mergeSystematicEntries(existingCommands, emittedCommands, (_key, command) => isSystematicCommandConfig(command));
1124
1308
  registerSkillsPaths(config, bundledSkillsDir);
1125
1309
  };
1126
1310
  }
@@ -1136,22 +1320,19 @@ function registerSkillsPaths(config, skillsDir) {
1136
1320
  };
1137
1321
  }
1138
1322
  function removeSystematicSkillPaths(paths) {
1139
- return paths.filter((path6) => !isSystematicSkillPath(path6));
1323
+ return paths.filter((path8) => !isSystematicSkillPath(path8));
1140
1324
  }
1141
- function isSystematicSkillPath(path6) {
1142
- const normalizedPath = normalizePath(path6);
1325
+ function isSystematicSkillPath(path8) {
1326
+ const normalizedPath = normalizePath(path8);
1143
1327
  return normalizedPath.endsWith("/.config/opencode/systematic/skills") || normalizedPath.endsWith("/.cache/opencode/systematic/skills") || normalizedPath.endsWith("/.local/share/opencode/systematic/skills") || normalizedPath.endsWith("/.opencode/systematic/skills") || /(?:^|\/)\.cache\/opencode\/packages\/@fro\.bot\/systematic@[^/]+\/node_modules\/@fro\.bot\/systematic\/skills(?:$|\/)/u.test(normalizedPath);
1144
1328
  }
1145
- function normalizePath(path6) {
1146
- return path6.replaceAll("\\", "/").replace(/\/+$/u, "");
1147
- }
1148
- function isSystematicOwnedCommandKey(key) {
1149
- return key.startsWith("systematic:") || key.startsWith("ce:");
1329
+ function normalizePath(path8) {
1330
+ return path8.replaceAll("\\", "/").replace(/\/+$/u, "");
1150
1331
  }
1151
1332
 
1152
1333
  // src/lib/skill-tool.ts
1153
- import fs4 from "fs";
1154
- import path6 from "path";
1334
+ import fs6 from "fs";
1335
+ import path8 from "path";
1155
1336
  import { pathToFileURL as pathToFileURL2 } from "url";
1156
1337
  import { tool } from "@opencode-ai/plugin/tool";
1157
1338
  function discoverSkillFiles(dir, limit = 10) {
@@ -1165,17 +1346,17 @@ function discoverSkillFiles(dir, limit = 10) {
1165
1346
  function handleEntry(entry, currentDir) {
1166
1347
  if (entry.isDirectory()) {
1167
1348
  if (!shouldSkipDirectory(entry.name)) {
1168
- recurse(path6.resolve(currentDir, entry.name));
1349
+ recurse(path8.resolve(currentDir, entry.name));
1169
1350
  }
1170
1351
  } else if (shouldIncludeFile(entry.name)) {
1171
- files.push(path6.resolve(currentDir, entry.name));
1352
+ files.push(path8.resolve(currentDir, entry.name));
1172
1353
  }
1173
1354
  }
1174
1355
  function recurse(currentDir) {
1175
1356
  if (files.length >= limit)
1176
1357
  return;
1177
1358
  try {
1178
- const entries = fs4.readdirSync(currentDir, { withFileTypes: true });
1359
+ const entries = fs6.readdirSync(currentDir, { withFileTypes: true });
1179
1360
  for (const entry of entries) {
1180
1361
  if (files.length >= limit)
1181
1362
  break;
@@ -1261,7 +1442,7 @@ ${catalog}`;
1261
1442
  warnedSkills.add(matchedSkill.name);
1262
1443
  }
1263
1444
  const body = extractSkillBody(matchedSkill.wrappedTemplate);
1264
- const dir = path6.dirname(matchedSkill.skillFile);
1445
+ const dir = path8.dirname(matchedSkill.skillFile);
1265
1446
  const base = pathToFileURL2(dir).href;
1266
1447
  const files = discoverSkillFiles(dir);
1267
1448
  await context.ask({
@@ -1298,17 +1479,17 @@ ${catalog}`;
1298
1479
  }
1299
1480
 
1300
1481
  // src/index.ts
1301
- var __dirname3 = path7.dirname(fileURLToPath2(import.meta.url));
1302
- var packageRoot2 = path7.resolve(__dirname3, "..");
1303
- var bundledSkillsDir = path7.join(packageRoot2, "skills");
1304
- var bundledAgentsDir2 = path7.join(packageRoot2, "agents");
1305
- var bundledCommandsDir = path7.join(packageRoot2, "commands");
1306
- var packageJsonPath = path7.join(packageRoot2, "package.json");
1482
+ var __dirname3 = path9.dirname(fileURLToPath2(import.meta.url));
1483
+ var packageRoot2 = path9.resolve(__dirname3, "..");
1484
+ var bundledSkillsDir = path9.join(packageRoot2, "skills");
1485
+ var bundledAgentsDir2 = path9.join(packageRoot2, "agents");
1486
+ var bundledCommandsDir = path9.join(packageRoot2, "commands");
1487
+ var packageJsonPath = path9.join(packageRoot2, "package.json");
1307
1488
  var getPackageVersion = () => {
1308
1489
  try {
1309
- if (!fs5.existsSync(packageJsonPath))
1490
+ if (!fs7.existsSync(packageJsonPath))
1310
1491
  return "unknown";
1311
- const content = fs5.readFileSync(packageJsonPath, "utf8");
1492
+ const content = fs7.readFileSync(packageJsonPath, "utf8");
1312
1493
  const parsed = JSON.parse(content);
1313
1494
  return parsed.version ?? "unknown";
1314
1495
  } catch {
@@ -7,6 +7,10 @@ export interface ConfigHandlerDeps {
7
7
  bundledCommandsDir: string;
8
8
  /** OpenCode client for availability lookup. When omitted, availability falls back to empty set (last-resort resolution). */
9
9
  client?: OpencodeClientLike;
10
+ /** Home directory for discovered-skill lookups. Defaults to `os.homedir()`; inject a temp dir in tests. */
11
+ homeDir?: string;
12
+ /** OpenCode global config directory override for discovered-skill lookups. Defaults to `<homeDir>/.config/opencode`. */
13
+ opencodeConfigDir?: string;
10
14
  }
11
15
  export declare function toTitleCase(name: string): string;
12
16
  export declare function formatAgentDescription(name: string, description: string | undefined): string;
@@ -24,6 +24,7 @@ export interface SystematicConfig {
24
24
  bootstrap: BootstrapConfig;
25
25
  agents?: OverlayConfigMap;
26
26
  categories?: OverlayConfigMap;
27
+ skills_as_commands: boolean;
27
28
  }
28
29
  export declare const DEFAULT_CONFIG: SystematicConfig;
29
30
  /**
@@ -0,0 +1,67 @@
1
+ import { type SkillFrontmatter } from './skills.js';
2
+ /**
3
+ * Provenance ids for discovered skills. When the same skill `name` is found
4
+ * in multiple roots, the winner is whichever root is discovered LAST in
5
+ * upstream's sequence (mirrors upstream `skill/index.ts`'s last-write-wins
6
+ * map keyed by frontmatter name). Discovery order (earliest to latest):
7
+ * global-claude, global-agents, project-claude/project-agents (walked from
8
+ * startDir up to the worktree root, closest-first), then all
9
+ * `.opencode`-style config directories (global-opencode-config wins over
10
+ * everything above it). Do not reorder without re-verifying against
11
+ * upstream.
12
+ */
13
+ type DiscoveryRootId = 'global-claude' | 'global-agents' | 'project-claude' | 'project-agents' | 'project-opencode' | 'global-opencode-config';
14
+ export interface DiscoveredSkill {
15
+ name: string;
16
+ description: string;
17
+ frontmatter: SkillFrontmatter;
18
+ skillPath: string;
19
+ root: DiscoveryRootId;
20
+ }
21
+ export interface DiscoverSkillsOptions {
22
+ /** Directory to start the upward worktree walk from (typically the project cwd). */
23
+ startDir: string;
24
+ /** Home directory, injected so tests can use a temp dir instead of the real one. */
25
+ homeDir: string;
26
+ /**
27
+ * Override for OpenCode's global config directory (mirrors
28
+ * `$XDG_CONFIG_HOME` resolution). Defaults to `<homeDir>/.config/opencode`
29
+ * when omitted.
30
+ */
31
+ configDir?: string;
32
+ /**
33
+ * Override mirroring upstream's `OPENCODE_CONFIG_DIR` env var: an extra
34
+ * config directory appended to the end of the OpenCode config-dir list
35
+ * (so it wins over every other root, including the default global config
36
+ * dir). Injected as a param rather than read from `process.env` to keep
37
+ * discovery pure and testable.
38
+ */
39
+ opencodeConfigDirOverride?: string;
40
+ }
41
+ /**
42
+ * Discover user/project skills, replicating OpenCode v1.17.6's real
43
+ * discovery algorithm (verified from source: `skill/index.ts`,
44
+ * `config/paths.ts`, `util/filesystem.ts`). Builds ONE map keyed by
45
+ * frontmatter skill `name`; entries are scanned and upserted in upstream's
46
+ * exact sequence, and later additions overwrite earlier ones:
47
+ *
48
+ * 1. Global external: `<home>/.claude/skills/**\/SKILL.md`, then
49
+ * `<home>/.agents/skills/**\/SKILL.md`.
50
+ * 2. Project external (multi-level up-walk): from `startDir` up to the
51
+ * git worktree root (inclusive), closest-first; at each level scan
52
+ * `.claude/skills/**\/SKILL.md` then `.agents/skills/**\/SKILL.md`.
53
+ * Because of last-write-wins, the worktree-root level wins over
54
+ * deeper subdirectories.
55
+ * 3. OpenCode config dirs (`ConfigPaths.directories`-equivalent: global
56
+ * config dir, then project `.opencode` up-walk, then `<home>/.opencode`,
57
+ * then an optional `OPENCODE_CONFIG_DIR`-style override), each scanned
58
+ * for `{skill,skills}/**\/SKILL.md`. Scanned last, so these beat
59
+ * everything above.
60
+ *
61
+ * The dedup key is the frontmatter `name` (not the containing directory
62
+ * name); entries with no name, or a name failing the charset/length regex,
63
+ * are skipped. Never throws: unreadable dirs/files, missing roots, or
64
+ * malformed frontmatter cause that entry to be skipped, not an abort.
65
+ */
66
+ export declare function discoverSkills(options: DiscoverSkillsOptions): DiscoveredSkill[];
67
+ export {};
@@ -22,6 +22,9 @@
22
22
  },
23
23
  "bootstrap": {
24
24
  "$ref": "#/definitions/__schema51"
25
+ },
26
+ "skills_as_commands": {
27
+ "$ref": "#/definitions/__schema57"
25
28
  }
26
29
  },
27
30
  "additionalProperties": false,
@@ -1504,6 +1507,19 @@
1504
1507
  },
1505
1508
  "__schema56": {
1506
1509
  "type": "string"
1510
+ },
1511
+ "__schema57": {
1512
+ "default": true,
1513
+ "description": "Register skills discovered from user/project skill directories (OpenCode config and other agent-harness-standard locations) as slash commands. Default true.",
1514
+ "examples": [true, false],
1515
+ "allOf": [
1516
+ {
1517
+ "$ref": "#/definitions/__schema58"
1518
+ }
1519
+ ]
1520
+ },
1521
+ "__schema58": {
1522
+ "type": "boolean"
1507
1523
  }
1508
1524
  }
1509
1525
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fro.bot/systematic",
3
- "version": "2.32.1",
3
+ "version": "2.33.0",
4
4
  "description": "Structured engineering workflows for OpenCode",
5
5
  "type": "module",
6
6
  "homepage": "https://fro.bot/systematic",
@@ -65,9 +65,9 @@
65
65
  "@opencode-ai/plugin": "^1.1.30"
66
66
  },
67
67
  "devDependencies": {
68
- "@biomejs/biome": "2.4.16",
69
- "@opencode-ai/plugin": "1.17.9",
70
- "@opencode-ai/sdk": "1.17.9",
68
+ "@biomejs/biome": "2.5.2",
69
+ "@opencode-ai/plugin": "1.17.13",
70
+ "@opencode-ai/sdk": "1.17.13",
71
71
  "@semantic-release/exec": "7.1.0",
72
72
  "@types/bun": "latest",
73
73
  "@types/js-yaml": "4.0.9",
@@ -1,2 +1,2 @@
1
1
  google-genai>=1.0.0
2
- Pillow>=10.0.0
2
+ Pillow>=12.2.0