@fro.bot/systematic 2.32.2 → 2.33.1

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-vyzhzvap.js";
10
10
 
11
11
  // src/cli.ts
12
12
  import fs from "fs";
@@ -485,53 +485,65 @@ 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
+ }
518
+ function extractFrontmatterFromContent(content) {
519
+ const { data, parseError } = parseFrontmatter(content);
520
+ if (parseError) {
521
+ return { name: "", description: "" };
522
+ }
523
+ const metadata = parseMetadata(data);
524
+ const deprecated = parseDeprecated(data);
525
+ const argumentHintRaw = extractNonEmptyString(data, "argument-hint");
526
+ const argumentHint = argumentHintRaw?.replace(/^["']|["']$/g, "") || undefined;
527
+ return {
528
+ name: extractString(data, "name"),
529
+ description: extractString(data, "description"),
530
+ license: extractNonEmptyString(data, "license"),
531
+ compatibility: extractNonEmptyString(data, "compatibility"),
532
+ metadata,
533
+ deprecated,
534
+ disableModelInvocation: extractBoolean(data, "disable-model-invocation"),
535
+ userInvocable: extractBoolean(data, "user-invocable"),
536
+ subtask: data.context === "fork" ? true : extractBoolean(data, "subtask") ?? undefined,
537
+ agent: extractNonEmptyString(data, "agent"),
538
+ model: extractNonEmptyString(data, "model"),
539
+ argumentHint: argumentHint !== "" ? argumentHint : undefined,
540
+ allowedTools: extractNonEmptyString(data, "allowed-tools")
541
+ };
542
+ }
488
543
  function extractFrontmatter(filePath) {
489
544
  try {
490
545
  const content = fs3.readFileSync(filePath, "utf8");
491
- const { data, parseError } = parseFrontmatter(content);
492
- if (parseError) {
493
- return { name: "", description: "" };
494
- }
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
- }
518
- const argumentHintRaw = extractNonEmptyString(data, "argument-hint");
519
- const argumentHint = argumentHintRaw?.replace(/^["']|["']$/g, "") || undefined;
520
- return {
521
- name: extractString(data, "name"),
522
- description: extractString(data, "description"),
523
- license: extractNonEmptyString(data, "license"),
524
- compatibility: extractNonEmptyString(data, "compatibility"),
525
- metadata,
526
- deprecated,
527
- disableModelInvocation: extractBoolean(data, "disable-model-invocation"),
528
- userInvocable: extractBoolean(data, "user-invocable"),
529
- subtask: data.context === "fork" ? true : extractBoolean(data, "subtask") ?? undefined,
530
- agent: extractNonEmptyString(data, "agent"),
531
- model: extractNonEmptyString(data, "model"),
532
- argumentHint: argumentHint !== "" ? argumentHint : undefined,
533
- allowedTools: extractNonEmptyString(data, "allowed-tools")
534
- };
546
+ return extractFrontmatterFromContent(content);
535
547
  } catch {
536
548
  return { name: "", description: "" };
537
549
  }
@@ -16040,6 +16052,10 @@ function createSystematicConfigSchema(opts) {
16040
16052
  { enabled: true },
16041
16053
  { enabled: false, file: ".opencode/custom-prompt.md" }
16042
16054
  ]
16055
+ }),
16056
+ skills_as_commands: exports_external.boolean().default(true).meta({
16057
+ description: "Register skills discovered from user/project skill directories (OpenCode config and other agent-harness-standard locations) as slash commands. Default true.",
16058
+ examples: [true, false]
16043
16059
  })
16044
16060
  }).strict().meta({
16045
16061
  description: "Systematic user configuration file (systematic.json / systematic.jsonc)",
@@ -16071,7 +16087,8 @@ var DEFAULT_CONFIG = {
16071
16087
  enabled: true
16072
16088
  },
16073
16089
  agents: {},
16074
- categories: {}
16090
+ categories: {},
16091
+ skills_as_commands: true
16075
16092
  };
16076
16093
  var SECURITY_OVERLAY_FIELDS2 = new Set(SECURITY_OVERLAY_FIELDS);
16077
16094
  var CURRENT_SKILL_NAMES_SET = new Set(BUNDLED_SKILL_NAMES);
@@ -16240,7 +16257,8 @@ function loadConfigWithSources(projectDir) {
16240
16257
  ...customConfig?.bootstrap
16241
16258
  },
16242
16259
  agents: overlayValues(overlays.agents),
16243
- categories: overlayValues(overlays.categories)
16260
+ categories: overlayValues(overlays.categories),
16261
+ skills_as_commands: customConfig?.skills_as_commands ?? projectConfig?.skills_as_commands ?? userConfig?.skills_as_commands ?? DEFAULT_CONFIG.skills_as_commands
16244
16262
  };
16245
16263
  const warned = new Set;
16246
16264
  const droppedSkills = computeDroppedNames(result.disabled_skills, CURRENT_SKILL_NAMES_SET);
@@ -16407,4 +16425,4 @@ function extractCommandFrontmatter(content) {
16407
16425
  };
16408
16426
  }
16409
16427
 
16410
- export { parseFrontmatter, isRecord, convertContent, convertFileWithCache, findSkillsInDir, exports_external, AgentOverlaySchema, CategoryOverlaySchema, loadConfig, loadConfigWithSources, getConfigPaths, findAgentsInDir, extractAgentFrontmatter, findCommandsInDir, extractCommandFrontmatter };
16428
+ export { parseFrontmatter, isRecord, convertContent, convertFileWithCache, walkDir, extractFrontmatterFromContent, 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
+ extractFrontmatterFromContent,
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-vyzhzvap.js";
17
19
 
18
20
  // src/index.ts
19
- import fs5 from "fs";
20
- import path7 from "path";
21
+ import fs6 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,10 @@ ${skillUsage}${catalogSection}
237
239
  </SYSTEMATIC_WORKFLOWS>`;
238
240
  }
239
241
 
242
+ // src/lib/config-handler.ts
243
+ import os3 from "os";
244
+ import path7 from "path";
245
+
240
246
  // src/lib/agent-overlays.ts
241
247
  import fs2 from "fs";
242
248
  import path4 from "path";
@@ -624,11 +630,136 @@ function throwConfigError(sourcePath, keyPath, message) {
624
630
  throw new Error(`Invalid Systematic config in ${sourcePath}: ${keyPath} ${message}`);
625
631
  }
626
632
 
633
+ // src/lib/discovered-skills.ts
634
+ import fs3 from "fs";
635
+ import path5 from "path";
636
+ var SKILL_NAME_REGEX = /^[a-z0-9]+(-[a-z0-9]+)*$/;
637
+ function isValidSkillName(name) {
638
+ return name.length >= 1 && name.length <= 64 && SKILL_NAME_REGEX.test(name);
639
+ }
640
+ function findGitWorktreeRoot(startDir) {
641
+ let current = path5.resolve(startDir);
642
+ while (true) {
643
+ try {
644
+ if (fs3.existsSync(path5.join(current, ".git"))) {
645
+ return current;
646
+ }
647
+ } catch {
648
+ return null;
649
+ }
650
+ const parent = path5.dirname(current);
651
+ if (parent === current)
652
+ return null;
653
+ current = parent;
654
+ }
655
+ }
656
+ function upWalk(targets, start, stop) {
657
+ const results = [];
658
+ let current = path5.resolve(start);
659
+ const resolvedStop = stop === undefined ? undefined : path5.resolve(stop);
660
+ while (true) {
661
+ for (const target of targets) {
662
+ const candidate = path5.join(current, target);
663
+ try {
664
+ if (fs3.existsSync(candidate)) {
665
+ results.push(candidate);
666
+ }
667
+ } catch {}
668
+ }
669
+ if (resolvedStop !== undefined && current === resolvedStop)
670
+ break;
671
+ const parent = path5.dirname(current);
672
+ if (parent === current)
673
+ break;
674
+ current = parent;
675
+ }
676
+ return results;
677
+ }
678
+ function uniqueStrings(values) {
679
+ return Array.from(new Set(values));
680
+ }
681
+ function buildOpencodeConfigDirs(startDir, homeDir, gitRoot, globalConfigDir, opencodeConfigDirOverride) {
682
+ const dirs = [globalConfigDir];
683
+ dirs.push(...upWalk([".opencode"], startDir, gitRoot ?? startDir));
684
+ dirs.push(...upWalk([".opencode"], homeDir, homeDir));
685
+ if (opencodeConfigDirOverride !== undefined) {
686
+ dirs.push(opencodeConfigDirOverride);
687
+ }
688
+ return uniqueStrings(dirs);
689
+ }
690
+ function globSkillFiles(rootDir, subdirNames) {
691
+ const results = [];
692
+ for (const subdirName of subdirNames) {
693
+ const scanRoot = path5.join(rootDir, subdirName);
694
+ try {
695
+ if (!fs3.existsSync(scanRoot))
696
+ continue;
697
+ const entries = walkDir(scanRoot, {
698
+ maxDepth: 10,
699
+ filter: (entry) => !entry.isDirectory && entry.name === "SKILL.md"
700
+ });
701
+ for (const entry of entries) {
702
+ results.push(entry.path);
703
+ }
704
+ } catch {}
705
+ }
706
+ return results;
707
+ }
708
+ function toDiscoveredSkill(skillPath, rootId) {
709
+ let content;
710
+ try {
711
+ content = fs3.readFileSync(skillPath, "utf8");
712
+ } catch {
713
+ return;
714
+ }
715
+ const frontmatter = extractFrontmatterFromContent(content);
716
+ const name = frontmatter.name;
717
+ if (!name || !isValidSkillName(name))
718
+ return;
719
+ return {
720
+ name,
721
+ description: frontmatter.description,
722
+ frontmatter,
723
+ body: parseFrontmatter(content).body,
724
+ skillPath,
725
+ root: rootId
726
+ };
727
+ }
728
+ function discoverSkills(options) {
729
+ const { startDir, homeDir, configDir, opencodeConfigDirOverride } = options;
730
+ const globalConfigDir = configDir ?? path5.join(homeDir, ".config/opencode");
731
+ const gitRoot = findGitWorktreeRoot(startDir);
732
+ const byName = new Map;
733
+ function upsertAll(skillPaths, rootId) {
734
+ for (const skillPath of skillPaths) {
735
+ const skill = toDiscoveredSkill(skillPath, rootId);
736
+ if (skill)
737
+ byName.set(skill.name, skill);
738
+ }
739
+ }
740
+ upsertAll(globSkillFiles(homeDir, [".claude/skills"]), "global-claude");
741
+ upsertAll(globSkillFiles(homeDir, [".agents/skills"]), "global-agents");
742
+ const externalLevels = upWalk([".claude", ".agents"], startDir, gitRoot ?? startDir);
743
+ for (const levelDir of externalLevels) {
744
+ const isClaudeDir = path5.basename(levelDir) === ".claude";
745
+ const parentDir = path5.dirname(levelDir);
746
+ const subdirGlob = isClaudeDir ? ".claude/skills" : ".agents/skills";
747
+ const rootId = isClaudeDir ? "project-claude" : "project-agents";
748
+ upsertAll(globSkillFiles(parentDir, [subdirGlob]), rootId);
749
+ }
750
+ const configDirs = buildOpencodeConfigDirs(startDir, homeDir, gitRoot, globalConfigDir, opencodeConfigDirOverride);
751
+ for (const dir of configDirs) {
752
+ const rootId = dir === globalConfigDir ? "global-opencode-config" : "project-opencode";
753
+ upsertAll(globSkillFiles(dir, ["skill", "skills"]), rootId);
754
+ }
755
+ return Array.from(byName.values());
756
+ }
757
+
627
758
  // src/lib/model-availability.ts
628
759
  import { createHash } from "crypto";
629
- import fs3 from "fs";
760
+ import fs4 from "fs";
630
761
  import os2 from "os";
631
- import path5 from "path";
762
+ import path6 from "path";
632
763
  function emptyAvailability() {
633
764
  return { status: "unknown", models: new Set };
634
765
  }
@@ -638,8 +769,8 @@ var MODELS_JSON_FILENAME = "models.json";
638
769
  var availabilityCache = new WeakMap;
639
770
  function resolveCacheDir() {
640
771
  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");
772
+ const cacheBase = xdgCacheHome && path6.isAbsolute(xdgCacheHome) ? xdgCacheHome : path6.join(os2.homedir(), ".cache");
773
+ return path6.join(cacheBase, "opencode");
643
774
  }
644
775
  function fastHash(input) {
645
776
  return createHash("sha1").update(input).digest("hex");
@@ -658,7 +789,7 @@ function isProviderRecord(value) {
658
789
  function readModelsFromCache(filePath) {
659
790
  let fd;
660
791
  try {
661
- fd = fs3.openSync(filePath, "r");
792
+ fd = fs4.openSync(filePath, "r");
662
793
  } catch {
663
794
  return null;
664
795
  }
@@ -666,7 +797,7 @@ function readModelsFromCache(filePath) {
666
797
  try {
667
798
  let stat;
668
799
  try {
669
- stat = fs3.fstatSync(fd);
800
+ stat = fs4.fstatSync(fd);
670
801
  } catch {
671
802
  return null;
672
803
  }
@@ -681,7 +812,7 @@ function readModelsFromCache(filePath) {
681
812
  const buffer = Buffer.alloc(stat.size);
682
813
  let bytesRead;
683
814
  try {
684
- bytesRead = fs3.readSync(fd, buffer, 0, stat.size, 0);
815
+ bytesRead = fs4.readSync(fd, buffer, 0, stat.size, 0);
685
816
  } catch {
686
817
  return null;
687
818
  }
@@ -690,7 +821,7 @@ function readModelsFromCache(filePath) {
690
821
  raw = buffer.toString("utf8");
691
822
  } finally {
692
823
  try {
693
- fs3.closeSync(fd);
824
+ fs4.closeSync(fd);
694
825
  } catch {}
695
826
  }
696
827
  if (raw.trim().length === 0)
@@ -717,14 +848,14 @@ function readFallbackCache() {
717
848
  const cacheDir = resolveCacheDir();
718
849
  const openCodeModelsUrl = process.env.OPENCODE_MODELS_URL?.trim();
719
850
  if (openCodeModelsUrl) {
720
- const urlDerivedPath = path5.join(cacheDir, `models-${fastHash(openCodeModelsUrl)}.json`);
851
+ const urlDerivedPath = path6.join(cacheDir, `models-${fastHash(openCodeModelsUrl)}.json`);
721
852
  const urlResult = readModelsFromCache(urlDerivedPath);
722
853
  if (urlResult !== null && urlResult.size > 0) {
723
854
  return { status: "cache", models: urlResult };
724
855
  }
725
856
  return emptyAvailability();
726
857
  }
727
- const defaultPath = path5.join(cacheDir, MODELS_JSON_FILENAME);
858
+ const defaultPath = path6.join(cacheDir, MODELS_JSON_FILENAME);
728
859
  const defaultResult = readModelsFromCache(defaultPath);
729
860
  if (defaultResult !== null && defaultResult.size > 0) {
730
861
  return { status: "cache", models: defaultResult };
@@ -1090,12 +1221,59 @@ function collectSkillsAsCommands(dir, disabledSkills) {
1090
1221
  }
1091
1222
  return commands;
1092
1223
  }
1224
+ function loadDiscoveredSkillAsCommand(skill) {
1225
+ const description = formatSkillDescription(skill.description, skill.name);
1226
+ if (skill.frontmatter.disableModelInvocation === true) {
1227
+ return {
1228
+ template: wrapSkillTemplate(skill.skillPath, skill.body),
1229
+ description
1230
+ };
1231
+ }
1232
+ return {
1233
+ template: buildDiscoveredSkillShimTemplate(skill.name),
1234
+ description
1235
+ };
1236
+ }
1237
+ function buildDiscoveredSkillShimTemplate(skillName) {
1238
+ return `Load the "${skillName}" skill using the skill tool, then follow its instructions to address this request:
1239
+
1240
+ <user-request>
1241
+ $ARGUMENTS
1242
+ </user-request>`;
1243
+ }
1244
+ function collectDiscoveredSkillsAsCommands(startDir, homeDir, configDir, opencodeConfigDirOverride, disabledCommands) {
1245
+ const commands = {};
1246
+ let discovered;
1247
+ try {
1248
+ discovered = discoverSkills({
1249
+ startDir,
1250
+ homeDir,
1251
+ configDir,
1252
+ opencodeConfigDirOverride
1253
+ });
1254
+ } catch {
1255
+ return commands;
1256
+ }
1257
+ for (const skill of discovered) {
1258
+ if (skill.frontmatter.userInvocable === false)
1259
+ continue;
1260
+ if (disabledCommands.includes(skill.name))
1261
+ continue;
1262
+ try {
1263
+ commands[skill.name] = loadDiscoveredSkillAsCommand(skill);
1264
+ } catch {}
1265
+ }
1266
+ return commands;
1267
+ }
1093
1268
  function collectEnabledSkillNames(dir, disabledSkills) {
1094
1269
  const disabledSet = new Set(disabledSkills);
1095
1270
  return findSkillsInDir(dir).filter((skillInfo) => !disabledSet.has(skillInfo.name)).map((skillInfo) => skillInfo.name);
1096
1271
  }
1097
1272
  function createConfigHandler(deps) {
1098
1273
  const { directory, bundledSkillsDir, bundledAgentsDir: bundledAgentsDir2, bundledCommandsDir } = deps;
1274
+ const homeDir = deps.homeDir ?? os3.homedir();
1275
+ const opencodeConfigDir = deps.opencodeConfigDir ?? path7.join(homeDir, ".config/opencode");
1276
+ const opencodeConfigDirOverride = process.env.OPENCODE_CONFIG_DIR?.trim() ? process.env.OPENCODE_CONFIG_DIR : undefined;
1099
1277
  return async (config) => {
1100
1278
  const { config: systematicConfig, overlays } = loadConfigWithSources(directory);
1101
1279
  const existingAgents = { ...config.agent ?? {} };
@@ -1116,11 +1294,15 @@ function createConfigHandler(deps) {
1116
1294
  const resolvedOverlays = resolveAgentOverlaySet(validatedOverlays);
1117
1295
  const bundledAgents = collectAgents(bundledAgentsDir2, systematicConfig.disabled_agents, nativeAgents, resolvedOverlays, availabilitySet);
1118
1296
  const bundledCommands = collectCommands(bundledCommandsDir, systematicConfig.disabled_commands);
1297
+ const discoveredSkillCommands = systematicConfig.skills_as_commands !== false ? collectDiscoveredSkillsAsCommands(directory, homeDir, opencodeConfigDir, opencodeConfigDirOverride, systematicConfig.disabled_commands) : {};
1119
1298
  const bundledAgentKeys = new Set(Object.keys(bundledAgents));
1120
1299
  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)));
1300
+ const emittedCommands = {
1301
+ ...bundledCommands,
1302
+ ...bundledSkills,
1303
+ ...discoveredSkillCommands
1304
+ };
1305
+ config.command = mergeSystematicEntries(existingCommands, emittedCommands, (_key, command) => isSystematicCommandConfig(command));
1124
1306
  registerSkillsPaths(config, bundledSkillsDir);
1125
1307
  };
1126
1308
  }
@@ -1136,22 +1318,19 @@ function registerSkillsPaths(config, skillsDir) {
1136
1318
  };
1137
1319
  }
1138
1320
  function removeSystematicSkillPaths(paths) {
1139
- return paths.filter((path6) => !isSystematicSkillPath(path6));
1321
+ return paths.filter((path8) => !isSystematicSkillPath(path8));
1140
1322
  }
1141
- function isSystematicSkillPath(path6) {
1142
- const normalizedPath = normalizePath(path6);
1323
+ function isSystematicSkillPath(path8) {
1324
+ const normalizedPath = normalizePath(path8);
1143
1325
  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
1326
  }
1145
- function normalizePath(path6) {
1146
- return path6.replaceAll("\\", "/").replace(/\/+$/u, "");
1147
- }
1148
- function isSystematicOwnedCommandKey(key) {
1149
- return key.startsWith("systematic:") || key.startsWith("ce:");
1327
+ function normalizePath(path8) {
1328
+ return path8.replaceAll("\\", "/").replace(/\/+$/u, "");
1150
1329
  }
1151
1330
 
1152
1331
  // src/lib/skill-tool.ts
1153
- import fs4 from "fs";
1154
- import path6 from "path";
1332
+ import fs5 from "fs";
1333
+ import path8 from "path";
1155
1334
  import { pathToFileURL as pathToFileURL2 } from "url";
1156
1335
  import { tool } from "@opencode-ai/plugin/tool";
1157
1336
  function discoverSkillFiles(dir, limit = 10) {
@@ -1165,17 +1344,17 @@ function discoverSkillFiles(dir, limit = 10) {
1165
1344
  function handleEntry(entry, currentDir) {
1166
1345
  if (entry.isDirectory()) {
1167
1346
  if (!shouldSkipDirectory(entry.name)) {
1168
- recurse(path6.resolve(currentDir, entry.name));
1347
+ recurse(path8.resolve(currentDir, entry.name));
1169
1348
  }
1170
1349
  } else if (shouldIncludeFile(entry.name)) {
1171
- files.push(path6.resolve(currentDir, entry.name));
1350
+ files.push(path8.resolve(currentDir, entry.name));
1172
1351
  }
1173
1352
  }
1174
1353
  function recurse(currentDir) {
1175
1354
  if (files.length >= limit)
1176
1355
  return;
1177
1356
  try {
1178
- const entries = fs4.readdirSync(currentDir, { withFileTypes: true });
1357
+ const entries = fs5.readdirSync(currentDir, { withFileTypes: true });
1179
1358
  for (const entry of entries) {
1180
1359
  if (files.length >= limit)
1181
1360
  break;
@@ -1261,7 +1440,7 @@ ${catalog}`;
1261
1440
  warnedSkills.add(matchedSkill.name);
1262
1441
  }
1263
1442
  const body = extractSkillBody(matchedSkill.wrappedTemplate);
1264
- const dir = path6.dirname(matchedSkill.skillFile);
1443
+ const dir = path8.dirname(matchedSkill.skillFile);
1265
1444
  const base = pathToFileURL2(dir).href;
1266
1445
  const files = discoverSkillFiles(dir);
1267
1446
  await context.ask({
@@ -1298,17 +1477,17 @@ ${catalog}`;
1298
1477
  }
1299
1478
 
1300
1479
  // 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");
1480
+ var __dirname3 = path9.dirname(fileURLToPath2(import.meta.url));
1481
+ var packageRoot2 = path9.resolve(__dirname3, "..");
1482
+ var bundledSkillsDir = path9.join(packageRoot2, "skills");
1483
+ var bundledAgentsDir2 = path9.join(packageRoot2, "agents");
1484
+ var bundledCommandsDir = path9.join(packageRoot2, "commands");
1485
+ var packageJsonPath = path9.join(packageRoot2, "package.json");
1307
1486
  var getPackageVersion = () => {
1308
1487
  try {
1309
- if (!fs5.existsSync(packageJsonPath))
1488
+ if (!fs6.existsSync(packageJsonPath))
1310
1489
  return "unknown";
1311
- const content = fs5.readFileSync(packageJsonPath, "utf8");
1490
+ const content = fs6.readFileSync(packageJsonPath, "utf8");
1312
1491
  const parsed = JSON.parse(content);
1313
1492
  return parsed.version ?? "unknown";
1314
1493
  } 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,70 @@
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
+ /** SKILL.md body (frontmatter stripped), read once at discovery so callers
19
+ * that inline the body don't re-read the file. */
20
+ body: string;
21
+ skillPath: string;
22
+ root: DiscoveryRootId;
23
+ }
24
+ export interface DiscoverSkillsOptions {
25
+ /** Directory to start the upward worktree walk from (typically the project cwd). */
26
+ startDir: string;
27
+ /** Home directory, injected so tests can use a temp dir instead of the real one. */
28
+ homeDir: string;
29
+ /**
30
+ * Override for OpenCode's global config directory (mirrors
31
+ * `$XDG_CONFIG_HOME` resolution). Defaults to `<homeDir>/.config/opencode`
32
+ * when omitted.
33
+ */
34
+ configDir?: string;
35
+ /**
36
+ * Override mirroring upstream's `OPENCODE_CONFIG_DIR` env var: an extra
37
+ * config directory appended to the end of the OpenCode config-dir list
38
+ * (so it wins over every other root, including the default global config
39
+ * dir). Injected as a param rather than read from `process.env` to keep
40
+ * discovery pure and testable.
41
+ */
42
+ opencodeConfigDirOverride?: string;
43
+ }
44
+ /**
45
+ * Discover user/project skills, replicating OpenCode v1.17.6's real
46
+ * discovery algorithm (verified from source: `skill/index.ts`,
47
+ * `config/paths.ts`, `util/filesystem.ts`). Builds ONE map keyed by
48
+ * frontmatter skill `name`; entries are scanned and upserted in upstream's
49
+ * exact sequence, and later additions overwrite earlier ones:
50
+ *
51
+ * 1. Global external: `<home>/.claude/skills/**\/SKILL.md`, then
52
+ * `<home>/.agents/skills/**\/SKILL.md`.
53
+ * 2. Project external (multi-level up-walk): from `startDir` up to the
54
+ * git worktree root (inclusive), closest-first; at each level scan
55
+ * `.claude/skills/**\/SKILL.md` then `.agents/skills/**\/SKILL.md`.
56
+ * Because of last-write-wins, the worktree-root level wins over
57
+ * deeper subdirectories.
58
+ * 3. OpenCode config dirs (`ConfigPaths.directories`-equivalent: global
59
+ * config dir, then project `.opencode` up-walk, then `<home>/.opencode`,
60
+ * then an optional `OPENCODE_CONFIG_DIR`-style override), each scanned
61
+ * for `{skill,skills}/**\/SKILL.md`. Scanned last, so these beat
62
+ * everything above.
63
+ *
64
+ * The dedup key is the frontmatter `name` (not the containing directory
65
+ * name); entries with no name, or a name failing the charset/length regex,
66
+ * are skipped. Never throws: unreadable dirs/files, missing roots, or
67
+ * malformed frontmatter cause that entry to be skipped, not an abort.
68
+ */
69
+ export declare function discoverSkills(options: DiscoverSkillsOptions): DiscoveredSkill[];
70
+ export {};
@@ -37,5 +37,11 @@ export interface SkillInfo {
37
37
  allowedTools?: string;
38
38
  }
39
39
  export declare const SKILL_FRONTMATTER_FIELDS: readonly ["name", "description", "argument-hint", "disable-model-invocation", "allowed-tools", "license", "compatibility", "metadata", "deprecated", "user-invocable", "agent", "model", "context", "subtask"];
40
+ /**
41
+ * Parse skill frontmatter from already-read file content. Split out from
42
+ * `extractFrontmatter` so callers that also need the body (e.g. discovered-skill
43
+ * command emission) can read the file once and derive both. Never throws.
44
+ */
45
+ export declare function extractFrontmatterFromContent(content: string): SkillFrontmatter;
40
46
  export declare function extractFrontmatter(filePath: string): SkillFrontmatter;
41
47
  export declare function findSkillsInDir(dir: string, maxDepth?: number): SkillInfo[];
@@ -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.2",
3
+ "version": "2.33.1",
4
4
  "description": "Structured engineering workflows for OpenCode",
5
5
  "type": "module",
6
6
  "homepage": "https://fro.bot/systematic",