opencode-dynamic-skills 1.2.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -143,6 +143,10 @@ Example:
143
143
  "model": "openai/gpt-5.2",
144
144
  "systemPrompt": "You are selecting the most relevant dynamic skills for a user request. Return strict JSON only with this shape: {\"recommendations\":[{\"name\":\"skill_tool_name\",\"reason\":\"why it matches\"}]}. Only recommend skills from the provided catalog. Prefer the smallest set of high-confidence matches.",
145
145
  },
146
+ "tuiCommandMirror": {
147
+ "enabled": true,
148
+ "directory": "~/.config/opencode/commands",
149
+ },
146
150
  }
147
151
  ```
148
152
 
@@ -155,6 +159,9 @@ Fields:
155
159
  - `reservedSlashCommands`
156
160
  - `notifications`
157
161
  - `skillRecommend`
162
+ - `tuiCommandMirror`
163
+
164
+ `tuiCommandMirror` is enabled by default. The plugin mirrors dynamic skills into proxy command files so TUI slash autocomplete can surface `/<skill-name>` after an OpenCode restart.
158
165
 
159
166
  Injected skill content now always uses a single XML renderer. The previous custom renderer selection feature was removed.
160
167
 
@@ -0,0 +1,15 @@
1
+ import type { SkillRegistry } from '../types';
2
+ type HookPart = {
3
+ type: string;
4
+ text?: string;
5
+ } & Record<string, unknown>;
6
+ export declare function rewriteMirroredSkillCommandParts(args: {
7
+ commandName: string;
8
+ commandArguments: string;
9
+ mirroredCommands: Set<string>;
10
+ registry: SkillRegistry;
11
+ output: {
12
+ parts: HookPart[];
13
+ };
14
+ }): Promise<boolean>;
15
+ export {};
@@ -0,0 +1 @@
1
+ export {};
@@ -12,7 +12,7 @@ export declare function renderSlashSkillPrompt(args: {
12
12
  invocationName: string;
13
13
  skill: Skill;
14
14
  userPrompt: string;
15
- }): string;
15
+ }): Promise<string>;
16
16
  export declare function renderRecommendSlashPrompt(task: string): string;
17
17
  export declare function rewriteRecommendSlashCommandText(text: string): Promise<string | null>;
18
18
  export declare function rewriteSlashCommandText(args: {
package/dist/config.d.ts CHANGED
@@ -1,13 +1,15 @@
1
1
  import type { PluginInput } from '@opencode-ai/plugin';
2
- import type { NotificationConfig, PluginConfig, SkillRecommendConfig } from './types';
2
+ import type { NotificationConfig, PluginConfig, SkillRecommendConfig, TuiCommandMirrorConfig } from './types';
3
3
  export declare const DEFAULT_RESERVED_SLASH_COMMANDS: readonly ["agent", "agents", "compact", "connect", "details", "editor", "exit", "export", "fork", "help", "init", "mcp", "model", "models", "new", "open", "redo", "sessions", "share", "skills", "terminal", "themes", "thinking", "undo", "unshare"];
4
4
  export declare const DEFAULT_SKILL_RECOMMEND_SYSTEM_PROMPT: string;
5
5
  export declare const MANAGED_PLUGIN_CONFIG_DIRECTORY: string;
6
+ export declare const MANAGED_TUI_COMMANDS_DIRECTORY: string;
6
7
  export declare const MANAGED_PLUGIN_JSONC_FILENAME = "opencode-dynamic-skills.config.jsonc";
7
8
  export declare const MANAGED_PLUGIN_JSON_FILENAME = "opencode-dynamic-skills.config.json";
8
9
  type PartialPluginConfig = Partial<Omit<PluginConfig, 'skillRecommend' | 'notifications'>> & {
9
10
  skillRecommend?: Partial<SkillRecommendConfig>;
10
11
  notifications?: Partial<NotificationConfig>;
12
+ tuiCommandMirror?: Partial<TuiCommandMirrorConfig>;
11
13
  };
12
14
  /**
13
15
  * Gets OpenCode-compatible config paths for the current platform.
@@ -55,6 +57,7 @@ export declare function normalizeBasePaths(basePaths: string[], projectDirectory
55
57
  export declare function getProjectSkillBasePaths(directory: string, worktree?: string): string[];
56
58
  export declare function createDefaultSkillRecommendConfig(): SkillRecommendConfig;
57
59
  export declare function createDefaultNotificationConfig(): NotificationConfig;
60
+ export declare function createDefaultTuiCommandMirrorConfig(): TuiCommandMirrorConfig;
58
61
  export declare function createDefaultPluginConfig(): PluginConfig;
59
62
  export declare function stripJsonComments(input: string): string;
60
63
  export declare function removeTrailingJsonCommas(input: string): string;
package/dist/index.js CHANGED
@@ -18059,6 +18059,9 @@ function createSkillResourceReader(provider) {
18059
18059
  };
18060
18060
  }
18061
18061
 
18062
+ // src/lib/formatLoadedSkill.ts
18063
+ import path3 from "path";
18064
+
18062
18065
  // src/lib/SkillLinks.ts
18063
18066
  import path2 from "path";
18064
18067
  var SCHEME_PATTERN = /^[a-z][a-z0-9+.-]*:/i;
@@ -18112,11 +18115,116 @@ function extractSkillLinks(content) {
18112
18115
  }
18113
18116
 
18114
18117
  // src/lib/formatLoadedSkill.ts
18115
- function formatLoadedSkill(args) {
18116
- const linkedResources = extractSkillLinks(args.skill.content);
18118
+ var MAX_REFERENCE_DEPTH = 3;
18119
+ function normalizeSkillResourcePath2(inputPath) {
18120
+ const unixPath = inputPath.replace(/\\/g, "/").trim();
18121
+ if (unixPath.length === 0 || unixPath.startsWith("/")) {
18122
+ return null;
18123
+ }
18124
+ const normalizedPath = path3.posix.normalize(unixPath).replace(/^\.\//, "");
18125
+ if (normalizedPath.length === 0 || normalizedPath === "." || normalizedPath.startsWith("../") || normalizedPath.includes("/../")) {
18126
+ return null;
18127
+ }
18128
+ return normalizedPath;
18129
+ }
18130
+ function buildSkillResourceIndex(skill) {
18131
+ const resources = new Map;
18132
+ const basenameIndex = new Map;
18133
+ for (const resourceMap of [skill.files, skill.references, skill.scripts, skill.assets]) {
18134
+ for (const [relativePath, resource] of resourceMap.entries()) {
18135
+ const normalizedPath = normalizeSkillResourcePath2(relativePath);
18136
+ if (!normalizedPath) {
18137
+ continue;
18138
+ }
18139
+ const resolvedResource = {
18140
+ relativePath: normalizedPath,
18141
+ absolutePath: resource.absolutePath,
18142
+ mimeType: resource.mimeType
18143
+ };
18144
+ resources.set(normalizedPath, resolvedResource);
18145
+ const basename2 = path3.posix.basename(normalizedPath);
18146
+ const basenameMatches = basenameIndex.get(basename2) ?? [];
18147
+ basenameMatches.push(resolvedResource);
18148
+ basenameIndex.set(basename2, basenameMatches);
18149
+ }
18150
+ }
18151
+ return {
18152
+ resources,
18153
+ basenameIndex
18154
+ };
18155
+ }
18156
+ function resolveReferencedSkillResource(resourceIndex, resourcePath) {
18157
+ const normalizedPath = normalizeSkillResourcePath2(resourcePath);
18158
+ if (!normalizedPath) {
18159
+ return null;
18160
+ }
18161
+ const { resources, basenameIndex } = resourceIndex;
18162
+ const exactMatch = resources.get(normalizedPath);
18163
+ if (exactMatch) {
18164
+ return exactMatch;
18165
+ }
18166
+ const basenameMatches = basenameIndex.get(path3.posix.basename(normalizedPath)) ?? [];
18167
+ return basenameMatches.length === 1 ? basenameMatches[0] : null;
18168
+ }
18169
+ async function collectReferencedSkillFiles(args) {
18170
+ const depth = args.depth ?? 0;
18171
+ const visited = args.visited ?? new Set;
18172
+ if (depth >= MAX_REFERENCE_DEPTH) {
18173
+ return [];
18174
+ }
18175
+ const referencedFiles = [];
18176
+ for (const link of extractSkillLinks(args.content)) {
18177
+ const resolvedResource = resolveReferencedSkillResource(args.resourceIndex, link.resourcePath);
18178
+ if (!resolvedResource || visited.has(resolvedResource.relativePath)) {
18179
+ continue;
18180
+ }
18181
+ visited.add(resolvedResource.relativePath);
18182
+ const fileContent = await readSkillFile(resolvedResource.absolutePath);
18183
+ referencedFiles.push({
18184
+ ...resolvedResource,
18185
+ content: fileContent
18186
+ });
18187
+ const nestedReferences = await collectReferencedSkillFiles({
18188
+ skill: args.skill,
18189
+ content: fileContent,
18190
+ resourceIndex: args.resourceIndex,
18191
+ depth: depth + 1,
18192
+ visited
18193
+ });
18194
+ referencedFiles.push(...nestedReferences);
18195
+ }
18196
+ return referencedFiles;
18197
+ }
18198
+ function formatExpandedReferences(references) {
18199
+ if (references.length === 0) {
18200
+ return [];
18201
+ }
18202
+ const output = ["", "### Expanded referenced skill files", ""];
18203
+ for (const reference of references) {
18204
+ output.push(`#### ${reference.relativePath}`);
18205
+ output.push(`**Absolute path**: ${reference.absolutePath}`);
18206
+ output.push(`**MIME type**: ${reference.mimeType}`);
18207
+ output.push("");
18208
+ output.push("````text");
18209
+ output.push(reference.content.trim());
18210
+ output.push("````");
18211
+ output.push("");
18212
+ }
18213
+ return output;
18214
+ }
18215
+ async function formatLoadedSkill(args) {
18216
+ const invocationName = args.invocationName ?? args.skill.name;
18217
+ const normalizedSkillRoot = `${args.skill.fullPath.replace(/\\/g, "/")}/`;
18218
+ const resourceIndex = buildSkillResourceIndex(args.skill);
18219
+ const referencedFiles = await collectReferencedSkillFiles({
18220
+ skill: args.skill,
18221
+ content: args.skill.content,
18222
+ resourceIndex
18223
+ });
18117
18224
  const output = [
18118
- `## Skill: ${args.skill.name}`,
18225
+ `# /${invocationName} Command`,
18119
18226
  "",
18227
+ `**Description**: ${args.skill.description}`,
18120
18228
  `**Skill identifier**: ${args.skill.toolName}`,
18121
18229
  `**Base directory**: ${args.skill.fullPath}`
18122
18230
  ];
@@ -18126,21 +18234,22 @@ function formatLoadedSkill(args) {
18126
18234
  if (args.userMessage?.trim()) {
18127
18235
  output.push(`**User request**: ${args.userMessage.trim()}`);
18128
18236
  }
18129
- output.push("", "Relative file references in this skill resolve from the skill root directory.");
18130
- output.push("Use skill_resource with the exact root-relative path when you need a linked file.");
18237
+ output.push("", "---", "", "## Command Instructions", "");
18238
+ output.push("<skill-instruction>");
18239
+ output.push(`Base directory for this skill: ${normalizedSkillRoot}`);
18240
+ output.push("File references (@path), markdown links, and relative file mentions in this skill resolve from the skill root.");
18241
+ output.push("Use skill_resource with the exact root-relative path if you need additional files beyond the expanded references below.");
18242
+ output.push("", args.skill.content, "</skill-instruction>");
18131
18243
  if (args.skill.files.size > 0) {
18132
18244
  output.push("", "### Available files", "");
18133
18245
  for (const relativePath of Array.from(args.skill.files.keys()).sort()) {
18134
18246
  output.push(`- ${relativePath}`);
18135
18247
  }
18136
18248
  }
18137
- if (linkedResources.length > 0) {
18138
- output.push("", "### Linked files", "");
18139
- for (const link of linkedResources) {
18140
- output.push(`- ${link.originalPath} -> ${link.resourcePath}`);
18141
- }
18249
+ output.push(...formatExpandedReferences(referencedFiles));
18250
+ if (args.userMessage?.trim()) {
18251
+ output.push("", "<user-request>", args.userMessage.trim(), "</user-request>");
18142
18252
  }
18143
- output.push("", args.skill.content);
18144
18253
  return output.join(`
18145
18254
  `);
18146
18255
  }
@@ -18176,7 +18285,7 @@ function createSkillTool(registry2) {
18176
18285
  const available = registry2.controller.skills.map((entry) => entry.name).join(", ");
18177
18286
  throw new Error(`Skill "${args.name}" not found. Available: ${available || "none"}`);
18178
18287
  }
18179
- return formatLoadedSkill({
18288
+ return await formatLoadedSkill({
18180
18289
  skill,
18181
18290
  invocationName: args.name.replace(/^\//, ""),
18182
18291
  userMessage: args.user_message
@@ -18693,10 +18802,10 @@ async function loadConfig({
18693
18802
  var defaultConfigDir = resolve(process3.cwd(), "config");
18694
18803
  var defaultGeneratedDir = resolve(process3.cwd(), "src/generated");
18695
18804
  function getProjectRoot(filePath, options2 = {}) {
18696
- let path3 = process2.cwd();
18697
- while (path3.includes("storage"))
18698
- path3 = resolve2(path3, "..");
18699
- const finalPath = resolve2(path3, filePath || "");
18805
+ let path4 = process2.cwd();
18806
+ while (path4.includes("storage"))
18807
+ path4 = resolve2(path4, "..");
18808
+ const finalPath = resolve2(path4, filePath || "");
18700
18809
  if (options2?.relative)
18701
18810
  return relative3(process2.cwd(), finalPath);
18702
18811
  return finalPath;
@@ -20143,10 +20252,10 @@ function applyEnvVarsToConfig(name, config3, verbose = false) {
20143
20252
  return config3;
20144
20253
  const envPrefix = name.toUpperCase().replace(/-/g, "_");
20145
20254
  const result = { ...config3 };
20146
- function processObject(obj, path3 = []) {
20255
+ function processObject(obj, path4 = []) {
20147
20256
  const result2 = { ...obj };
20148
20257
  for (const [key, value] of Object.entries(obj)) {
20149
- const envPath = [...path3, key];
20258
+ const envPath = [...path4, key];
20150
20259
  const formatKey = (k) => k.replace(/([A-Z])/g, "_$1").toUpperCase();
20151
20260
  const envKey = `${envPrefix}_${envPath.map(formatKey).join("_")}`;
20152
20261
  const oldEnvKey = `${envPrefix}_${envPath.map((p) => p.toUpperCase()).join("_")}`;
@@ -20328,10 +20437,10 @@ async function loadConfig3({
20328
20437
  var defaultConfigDir2 = resolve3(process6.cwd(), "config");
20329
20438
  var defaultGeneratedDir2 = resolve3(process6.cwd(), "src/generated");
20330
20439
  function getProjectRoot2(filePath, options2 = {}) {
20331
- let path3 = process7.cwd();
20332
- while (path3.includes("storage"))
20333
- path3 = resolve4(path3, "..");
20334
- const finalPath = resolve4(path3, filePath || "");
20440
+ let path4 = process7.cwd();
20441
+ while (path4.includes("storage"))
20442
+ path4 = resolve4(path4, "..");
20443
+ const finalPath = resolve4(path4, filePath || "");
20335
20444
  if (options2?.relative)
20336
20445
  return relative2(process7.cwd(), finalPath);
20337
20446
  return finalPath;
@@ -21768,10 +21877,10 @@ class EnvVarError extends BunfigError {
21768
21877
 
21769
21878
  class FileSystemError extends BunfigError {
21770
21879
  code = "FILE_SYSTEM_ERROR";
21771
- constructor(operation, path3, cause) {
21772
- super(`File system ${operation} failed for "${path3}": ${cause.message}`, {
21880
+ constructor(operation, path4, cause) {
21881
+ super(`File system ${operation} failed for "${path4}": ${cause.message}`, {
21773
21882
  operation,
21774
- path: path3,
21883
+ path: path4,
21775
21884
  originalError: cause.name,
21776
21885
  originalMessage: cause.message
21777
21886
  });
@@ -21844,8 +21953,8 @@ var ErrorFactory = {
21844
21953
  envVar(envKey, envValue, expectedType, configName) {
21845
21954
  return new EnvVarError(envKey, envValue, expectedType, configName);
21846
21955
  },
21847
- fileSystem(operation, path3, cause) {
21848
- return new FileSystemError(operation, path3, cause);
21956
+ fileSystem(operation, path4, cause) {
21957
+ return new FileSystemError(operation, path4, cause);
21849
21958
  },
21850
21959
  typeGeneration(configDir, outputPath, cause) {
21851
21960
  return new TypeGenerationError(configDir, outputPath, cause);
@@ -21981,9 +22090,9 @@ class EnvProcessor {
21981
22090
  }
21982
22091
  return key.replace(/([A-Z])/g, "_$1").toUpperCase();
21983
22092
  }
21984
- processObject(obj, path3, envPrefix, options2) {
22093
+ processObject(obj, path4, envPrefix, options2) {
21985
22094
  for (const [key, value] of Object.entries(obj)) {
21986
- const envPath = [...path3, key];
22095
+ const envPath = [...path4, key];
21987
22096
  const formattedKeys = envPath.map((k) => this.formatEnvKey(k, options2.useCamelCase));
21988
22097
  const envKey = `${envPrefix}_${formattedKeys.join("_")}`;
21989
22098
  const oldEnvKey = options2.useBackwardCompatibility ? `${envPrefix}_${envPath.map((p) => p.toUpperCase()).join("_")}` : null;
@@ -22068,9 +22177,9 @@ class EnvProcessor {
22068
22177
  return this.formatAsText(envVars, configName);
22069
22178
  }
22070
22179
  }
22071
- extractEnvVarInfo(obj, path3, prefix, envVars) {
22180
+ extractEnvVarInfo(obj, path4, prefix, envVars) {
22072
22181
  for (const [key, value] of Object.entries(obj)) {
22073
- const envPath = [...path3, key];
22182
+ const envPath = [...path4, key];
22074
22183
  const envKey = `${prefix}_${envPath.map((k) => this.formatEnvKey(k, true)).join("_")}`;
22075
22184
  if (typeof value === "object" && value !== null && !Array.isArray(value)) {
22076
22185
  this.extractEnvVarInfo(value, envPath, prefix, envVars);
@@ -22485,15 +22594,15 @@ class ConfigFileLoader {
22485
22594
  }
22486
22595
  async preloadConfigurations(configPaths, options2 = {}) {
22487
22596
  const preloaded = new Map;
22488
- await Promise.allSettled(configPaths.map(async (path3) => {
22597
+ await Promise.allSettled(configPaths.map(async (path4) => {
22489
22598
  try {
22490
- const result = await this.loadFromPath(path3, {}, options2);
22599
+ const result = await this.loadFromPath(path4, {}, options2);
22491
22600
  if (result) {
22492
- preloaded.set(path3, result.config);
22601
+ preloaded.set(path4, result.config);
22493
22602
  }
22494
22603
  } catch (error45) {
22495
22604
  if (options2.verbose) {
22496
- console.warn(`Failed to preload ${path3}:`, error45);
22605
+ console.warn(`Failed to preload ${path4}:`, error45);
22497
22606
  }
22498
22607
  }
22499
22608
  }));
@@ -22572,13 +22681,13 @@ class ConfigValidator {
22572
22681
  warnings
22573
22682
  };
22574
22683
  }
22575
- validateObjectAgainstSchema(value, schema, path3, errors3, warnings, options2) {
22684
+ validateObjectAgainstSchema(value, schema, path4, errors3, warnings, options2) {
22576
22685
  if (options2.validateTypes && schema.type) {
22577
22686
  const actualType = Array.isArray(value) ? "array" : typeof value;
22578
22687
  const expectedTypes = Array.isArray(schema.type) ? schema.type : [schema.type];
22579
22688
  if (!expectedTypes.includes(actualType)) {
22580
22689
  errors3.push({
22581
- path: path3,
22690
+ path: path4,
22582
22691
  message: `Expected type ${expectedTypes.join(" or ")}, got ${actualType}`,
22583
22692
  expected: expectedTypes.join(" or "),
22584
22693
  actual: actualType,
@@ -22590,7 +22699,7 @@ class ConfigValidator {
22590
22699
  }
22591
22700
  if (schema.enum && !schema.enum.includes(value)) {
22592
22701
  errors3.push({
22593
- path: path3,
22702
+ path: path4,
22594
22703
  message: `Value must be one of: ${schema.enum.join(", ")}`,
22595
22704
  expected: schema.enum.join(", "),
22596
22705
  actual: value,
@@ -22602,7 +22711,7 @@ class ConfigValidator {
22602
22711
  if (typeof value === "string") {
22603
22712
  if (schema.minLength !== undefined && value.length < schema.minLength) {
22604
22713
  errors3.push({
22605
- path: path3,
22714
+ path: path4,
22606
22715
  message: `String length must be at least ${schema.minLength}`,
22607
22716
  expected: `>= ${schema.minLength}`,
22608
22717
  actual: value.length,
@@ -22611,7 +22720,7 @@ class ConfigValidator {
22611
22720
  }
22612
22721
  if (schema.maxLength !== undefined && value.length > schema.maxLength) {
22613
22722
  errors3.push({
22614
- path: path3,
22723
+ path: path4,
22615
22724
  message: `String length must not exceed ${schema.maxLength}`,
22616
22725
  expected: `<= ${schema.maxLength}`,
22617
22726
  actual: value.length,
@@ -22622,7 +22731,7 @@ class ConfigValidator {
22622
22731
  const regex = new RegExp(schema.pattern);
22623
22732
  if (!regex.test(value)) {
22624
22733
  errors3.push({
22625
- path: path3,
22734
+ path: path4,
22626
22735
  message: `String does not match pattern ${schema.pattern}`,
22627
22736
  expected: schema.pattern,
22628
22737
  actual: value,
@@ -22634,7 +22743,7 @@ class ConfigValidator {
22634
22743
  if (typeof value === "number") {
22635
22744
  if (schema.minimum !== undefined && value < schema.minimum) {
22636
22745
  errors3.push({
22637
- path: path3,
22746
+ path: path4,
22638
22747
  message: `Value must be at least ${schema.minimum}`,
22639
22748
  expected: `>= ${schema.minimum}`,
22640
22749
  actual: value,
@@ -22643,7 +22752,7 @@ class ConfigValidator {
22643
22752
  }
22644
22753
  if (schema.maximum !== undefined && value > schema.maximum) {
22645
22754
  errors3.push({
22646
- path: path3,
22755
+ path: path4,
22647
22756
  message: `Value must not exceed ${schema.maximum}`,
22648
22757
  expected: `<= ${schema.maximum}`,
22649
22758
  actual: value,
@@ -22653,7 +22762,7 @@ class ConfigValidator {
22653
22762
  }
22654
22763
  if (Array.isArray(value) && schema.items) {
22655
22764
  for (let i = 0;i < value.length; i++) {
22656
- const itemPath = path3 ? `${path3}[${i}]` : `[${i}]`;
22765
+ const itemPath = path4 ? `${path4}[${i}]` : `[${i}]`;
22657
22766
  this.validateObjectAgainstSchema(value[i], schema.items, itemPath, errors3, warnings, options2);
22658
22767
  if (options2.stopOnFirstError && errors3.length > 0)
22659
22768
  return;
@@ -22665,7 +22774,7 @@ class ConfigValidator {
22665
22774
  for (const requiredProp of schema.required) {
22666
22775
  if (!(requiredProp in obj)) {
22667
22776
  errors3.push({
22668
- path: path3 ? `${path3}.${requiredProp}` : requiredProp,
22777
+ path: path4 ? `${path4}.${requiredProp}` : requiredProp,
22669
22778
  message: `Missing required property '${requiredProp}'`,
22670
22779
  expected: "required",
22671
22780
  rule: "required"
@@ -22678,7 +22787,7 @@ class ConfigValidator {
22678
22787
  if (schema.properties) {
22679
22788
  for (const [propName, propSchema] of Object.entries(schema.properties)) {
22680
22789
  if (propName in obj) {
22681
- const propPath = path3 ? `${path3}.${propName}` : propName;
22790
+ const propPath = path4 ? `${path4}.${propName}` : propName;
22682
22791
  this.validateObjectAgainstSchema(obj[propName], propSchema, propPath, errors3, warnings, options2);
22683
22792
  if (options2.stopOnFirstError && errors3.length > 0)
22684
22793
  return;
@@ -22690,7 +22799,7 @@ class ConfigValidator {
22690
22799
  for (const propName of Object.keys(obj)) {
22691
22800
  if (!allowedProps.has(propName)) {
22692
22801
  warnings.push({
22693
- path: path3 ? `${path3}.${propName}` : propName,
22802
+ path: path4 ? `${path4}.${propName}` : propName,
22694
22803
  message: `Additional property '${propName}' is not allowed`,
22695
22804
  rule: "additionalProperties"
22696
22805
  });
@@ -22724,12 +22833,12 @@ class ConfigValidator {
22724
22833
  warnings
22725
22834
  };
22726
22835
  }
22727
- validateWithRule(value, rule, path3) {
22836
+ validateWithRule(value, rule, path4) {
22728
22837
  const errors3 = [];
22729
22838
  if (rule.required && (value === undefined || value === null)) {
22730
22839
  errors3.push({
22731
- path: path3,
22732
- message: rule.message || `Property '${path3}' is required`,
22840
+ path: path4,
22841
+ message: rule.message || `Property '${path4}' is required`,
22733
22842
  expected: "required",
22734
22843
  rule: "required"
22735
22844
  });
@@ -22742,7 +22851,7 @@ class ConfigValidator {
22742
22851
  const actualType = Array.isArray(value) ? "array" : typeof value;
22743
22852
  if (actualType !== rule.type) {
22744
22853
  errors3.push({
22745
- path: path3,
22854
+ path: path4,
22746
22855
  message: rule.message || `Expected type ${rule.type}, got ${actualType}`,
22747
22856
  expected: rule.type,
22748
22857
  actual: actualType,
@@ -22754,7 +22863,7 @@ class ConfigValidator {
22754
22863
  const length = Array.isArray(value) ? value.length : typeof value === "string" ? value.length : typeof value === "number" ? value : 0;
22755
22864
  if (length < rule.min) {
22756
22865
  errors3.push({
22757
- path: path3,
22866
+ path: path4,
22758
22867
  message: rule.message || `Value must be at least ${rule.min}`,
22759
22868
  expected: `>= ${rule.min}`,
22760
22869
  actual: length,
@@ -22766,7 +22875,7 @@ class ConfigValidator {
22766
22875
  const length = Array.isArray(value) ? value.length : typeof value === "string" ? value.length : typeof value === "number" ? value : 0;
22767
22876
  if (length > rule.max) {
22768
22877
  errors3.push({
22769
- path: path3,
22878
+ path: path4,
22770
22879
  message: rule.message || `Value must not exceed ${rule.max}`,
22771
22880
  expected: `<= ${rule.max}`,
22772
22881
  actual: length,
@@ -22777,7 +22886,7 @@ class ConfigValidator {
22777
22886
  if (rule.pattern && typeof value === "string") {
22778
22887
  if (!rule.pattern.test(value)) {
22779
22888
  errors3.push({
22780
- path: path3,
22889
+ path: path4,
22781
22890
  message: rule.message || `Value does not match pattern ${rule.pattern}`,
22782
22891
  expected: rule.pattern.toString(),
22783
22892
  actual: value,
@@ -22787,7 +22896,7 @@ class ConfigValidator {
22787
22896
  }
22788
22897
  if (rule.enum && !rule.enum.includes(value)) {
22789
22898
  errors3.push({
22790
- path: path3,
22899
+ path: path4,
22791
22900
  message: rule.message || `Value must be one of: ${rule.enum.join(", ")}`,
22792
22901
  expected: rule.enum.join(", "),
22793
22902
  actual: value,
@@ -22798,7 +22907,7 @@ class ConfigValidator {
22798
22907
  const customError = rule.validator(value);
22799
22908
  if (customError) {
22800
22909
  errors3.push({
22801
- path: path3,
22910
+ path: path4,
22802
22911
  message: rule.message || customError,
22803
22912
  rule: "custom"
22804
22913
  });
@@ -22806,10 +22915,10 @@ class ConfigValidator {
22806
22915
  }
22807
22916
  return errors3;
22808
22917
  }
22809
- getValueByPath(obj, path3) {
22810
- if (!path3)
22918
+ getValueByPath(obj, path4) {
22919
+ if (!path4)
22811
22920
  return obj;
22812
- const keys = path3.split(".");
22921
+ const keys = path4.split(".");
22813
22922
  let current = obj;
22814
22923
  for (const key of keys) {
22815
22924
  if (current && typeof current === "object" && key in current) {
@@ -23235,10 +23344,10 @@ async function loadConfig5(options2) {
23235
23344
  function applyEnvVarsToConfig2(name, config4, verbose = false) {
23236
23345
  const _envProcessor = new EnvProcessor;
23237
23346
  const envPrefix = name.toUpperCase().replace(/[^A-Z0-9]/g, "_");
23238
- function processConfigLevel(obj, path3 = []) {
23347
+ function processConfigLevel(obj, path4 = []) {
23239
23348
  const result = { ...obj };
23240
23349
  for (const [key, value] of Object.entries(obj)) {
23241
- const currentPath = [...path3, key];
23350
+ const currentPath = [...path4, key];
23242
23351
  const envKeys = [
23243
23352
  `${envPrefix}_${currentPath.join("_").toUpperCase()}`,
23244
23353
  `${envPrefix}_${currentPath.map((k) => k.toUpperCase()).join("")}`,
@@ -23321,6 +23430,7 @@ var DEFAULT_SKILL_RECOMMEND_SYSTEM_PROMPT = [
23321
23430
  "Prefer the smallest set of high-confidence matches."
23322
23431
  ].join(" ");
23323
23432
  var MANAGED_PLUGIN_CONFIG_DIRECTORY = join4(homedir3(), ".config", "opencode");
23433
+ var MANAGED_TUI_COMMANDS_DIRECTORY = join4(MANAGED_PLUGIN_CONFIG_DIRECTORY, "commands");
23324
23434
  var MANAGED_PLUGIN_JSONC_FILENAME = "opencode-dynamic-skills.config.jsonc";
23325
23435
  var MANAGED_PLUGIN_JSON_FILENAME = "opencode-dynamic-skills.config.json";
23326
23436
  function getOpenCodeConfigPaths() {
@@ -23342,14 +23452,14 @@ function getOpenCodeConfigPaths() {
23342
23452
  paths.push(join4(home, ".opencode"));
23343
23453
  return paths;
23344
23454
  }
23345
- function expandTildePath(path3) {
23346
- if (path3 === "~") {
23455
+ function expandTildePath(path4) {
23456
+ if (path4 === "~") {
23347
23457
  return homedir3();
23348
23458
  }
23349
- if (path3.startsWith("~/")) {
23350
- return join4(homedir3(), path3.slice(2));
23459
+ if (path4.startsWith("~/")) {
23460
+ return join4(homedir3(), path4.slice(2));
23351
23461
  }
23352
- return path3;
23462
+ return path4;
23353
23463
  }
23354
23464
  var createPathKey = (absolutePath) => {
23355
23465
  const normalizedPath = normalize(absolutePath);
@@ -23427,6 +23537,12 @@ function createDefaultNotificationConfig() {
23427
23537
  errors: true
23428
23538
  };
23429
23539
  }
23540
+ function createDefaultTuiCommandMirrorConfig() {
23541
+ return {
23542
+ enabled: true,
23543
+ directory: MANAGED_TUI_COMMANDS_DIRECTORY
23544
+ };
23545
+ }
23430
23546
  function createDefaultPluginConfig() {
23431
23547
  return {
23432
23548
  debug: false,
@@ -23435,7 +23551,8 @@ function createDefaultPluginConfig() {
23435
23551
  enableSkillAliases: true,
23436
23552
  reservedSlashCommands: [...DEFAULT_RESERVED_SLASH_COMMANDS],
23437
23553
  notifications: createDefaultNotificationConfig(),
23438
- skillRecommend: createDefaultSkillRecommendConfig()
23554
+ skillRecommend: createDefaultSkillRecommendConfig(),
23555
+ tuiCommandMirror: createDefaultTuiCommandMirrorConfig()
23439
23556
  };
23440
23557
  }
23441
23558
  function createConfigOptions(defaultConfig3) {
@@ -23561,6 +23678,10 @@ function mergePluginConfig(baseConfig, override) {
23561
23678
  ...baseConfig.notifications,
23562
23679
  ...override.notifications
23563
23680
  };
23681
+ const tuiCommandMirror = {
23682
+ ...baseConfig.tuiCommandMirror,
23683
+ ...override.tuiCommandMirror
23684
+ };
23564
23685
  const overrideReservedSlashCommands = override.reservedSlashCommands?.map((command) => trimString(command)).filter(Boolean) ?? [];
23565
23686
  return {
23566
23687
  ...baseConfig,
@@ -23575,6 +23696,10 @@ function mergePluginConfig(baseConfig, override) {
23575
23696
  enabled: notifications.enabled === true,
23576
23697
  success: notifications.success !== false,
23577
23698
  errors: notifications.errors !== false
23699
+ },
23700
+ tuiCommandMirror: {
23701
+ enabled: tuiCommandMirror.enabled === true,
23702
+ directory: trimString(tuiCommandMirror.directory) || MANAGED_TUI_COMMANDS_DIRECTORY
23578
23703
  }
23579
23704
  };
23580
23705
  }
@@ -23631,6 +23756,12 @@ function renderManagedPluginConfigJsonc(config3 = createDefaultPluginConfig()) {
23631
23756
  ` "model": ${JSON.stringify(config3.skillRecommend.model)},`,
23632
23757
  ' // The model must return strict JSON: {"recommendations":[{"name":"skill_tool_name","reason":"why it matches"}]}',
23633
23758
  ` "systemPrompt": ${JSON.stringify(config3.skillRecommend.systemPrompt)}`,
23759
+ " },",
23760
+ "",
23761
+ " // TUI compatibility layer. Enabled by default so dynamic skills are mirrored as proxy command files and can appear in TUI slash autocomplete after restart.",
23762
+ ' "tuiCommandMirror": {',
23763
+ ` "enabled": ${JSON.stringify(config3.tuiCommandMirror.enabled)},`,
23764
+ ` "directory": ${JSON.stringify(config3.tuiCommandMirror.directory)}`,
23634
23765
  " }",
23635
23766
  "}",
23636
23767
  ""
@@ -23672,6 +23803,10 @@ async function getPluginConfig(ctx) {
23672
23803
  ...getProjectSkillBasePaths(ctx.directory, ctx.worktree)
23673
23804
  ];
23674
23805
  mergedConfig.basePaths = normalizeBasePaths(configuredBasePaths, ctx.directory);
23806
+ mergedConfig.tuiCommandMirror = {
23807
+ enabled: mergedConfig.tuiCommandMirror.enabled,
23808
+ directory: resolveBasePath(mergedConfig.tuiCommandMirror.directory, ctx.directory)
23809
+ };
23675
23810
  return mergedConfig;
23676
23811
  }
23677
23812
 
@@ -23730,10 +23865,10 @@ function findSkillBySelector(registry2, selector) {
23730
23865
  }
23731
23866
  return null;
23732
23867
  }
23733
- function renderSlashSkillPrompt(args) {
23868
+ async function renderSlashSkillPrompt(args) {
23734
23869
  return [
23735
23870
  SLASH_COMMAND_SENTINEL,
23736
- formatLoadedSkill({
23871
+ await formatLoadedSkill({
23737
23872
  invocationName: args.invocationName,
23738
23873
  skill: args.skill,
23739
23874
  userMessage: args.userPrompt || "Apply this skill to the current request."
@@ -23787,13 +23922,33 @@ async function rewriteSlashCommandText(args) {
23787
23922
  if (!skill) {
23788
23923
  return null;
23789
23924
  }
23790
- return renderSlashSkillPrompt({
23925
+ return await renderSlashSkillPrompt({
23791
23926
  invocationName: parsedCommand.invocationName,
23792
23927
  skill,
23793
23928
  userPrompt: parsedCommand.userPrompt
23794
23929
  });
23795
23930
  }
23796
23931
 
23932
+ // src/commands/MirroredSkillCommand.ts
23933
+ async function rewriteMirroredSkillCommandParts(args) {
23934
+ if (!args.mirroredCommands.has(args.commandName)) {
23935
+ return false;
23936
+ }
23937
+ await args.registry.controller.ready.whenReady();
23938
+ const skill = findSkillBySelector(args.registry, args.commandName);
23939
+ if (!skill) {
23940
+ return false;
23941
+ }
23942
+ const preservedParts = args.output.parts.filter((part) => part.type !== "text");
23943
+ const text = await formatLoadedSkill({
23944
+ skill,
23945
+ invocationName: args.commandName,
23946
+ userMessage: args.commandArguments
23947
+ });
23948
+ args.output.parts = [{ type: "text", text }, ...preservedParts];
23949
+ return true;
23950
+ }
23951
+
23797
23952
  // src/lib/xml.ts
23798
23953
  function escapeXml(str2) {
23799
23954
  return String(str2).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&apos;");
@@ -23979,17 +24134,141 @@ function createNotifier(args) {
23979
24134
  };
23980
24135
  }
23981
24136
 
24137
+ // src/services/TuiCommandMirror.ts
24138
+ import { mkdir as mkdir5, readdir as readdir3, readFile as readFile2, rm, writeFile as writeFile5 } from "fs/promises";
24139
+ import { join as join6 } from "path";
24140
+ var SHADOW_COMMAND_MARKER = "<!-- opencode-dynamic-skills:tui-command-mirror -->";
24141
+ function normalizeName(name) {
24142
+ return name.trim().toLowerCase();
24143
+ }
24144
+ function isManagedShadowCommand(content) {
24145
+ return content.includes(SHADOW_COMMAND_MARKER);
24146
+ }
24147
+ function renderShadowCommandFile(skill) {
24148
+ return [
24149
+ "---",
24150
+ `description: ${JSON.stringify(skill.description)}`,
24151
+ "---",
24152
+ "",
24153
+ SHADOW_COMMAND_MARKER,
24154
+ `Dynamic skill proxy for ${skill.name}.`,
24155
+ "This file is managed by opencode-dynamic-skills so OpenCode TUI can surface the skill as a slash command.",
24156
+ "The plugin replaces this template before execution.",
24157
+ "",
24158
+ "$ARGUMENTS",
24159
+ ""
24160
+ ].join(`
24161
+ `);
24162
+ }
24163
+ async function syncTuiCommandMirror(args) {
24164
+ if (!args.enabled) {
24165
+ return {
24166
+ mirrored: [],
24167
+ written: [],
24168
+ skipped: [],
24169
+ removed: []
24170
+ };
24171
+ }
24172
+ await mkdir5(args.directory, { recursive: true });
24173
+ const reservedCommands = new Set(args.reservedSlashCommands.map(normalizeName));
24174
+ const mirrored = [];
24175
+ const written = [];
24176
+ const skipped = [];
24177
+ const removed = [];
24178
+ const desiredNames = new Set;
24179
+ for (const skill of args.skills) {
24180
+ const normalizedSkillName = normalizeName(skill.name);
24181
+ if (reservedCommands.has(normalizedSkillName)) {
24182
+ skipped.push(skill.name);
24183
+ continue;
24184
+ }
24185
+ desiredNames.add(normalizedSkillName);
24186
+ const commandPath = join6(args.directory, `${skill.name}.md`);
24187
+ const renderedCommand = renderShadowCommandFile(skill);
24188
+ try {
24189
+ const existingContent = await readFile2(commandPath, "utf8");
24190
+ if (!isManagedShadowCommand(existingContent)) {
24191
+ skipped.push(skill.name);
24192
+ continue;
24193
+ }
24194
+ if (existingContent === renderedCommand) {
24195
+ mirrored.push(skill.name);
24196
+ continue;
24197
+ }
24198
+ } catch {}
24199
+ await writeFile5(commandPath, renderedCommand, "utf8");
24200
+ mirrored.push(skill.name);
24201
+ written.push(skill.name);
24202
+ }
24203
+ for (const entry of await readdir3(args.directory, { withFileTypes: true })) {
24204
+ if (!entry.isFile() || !entry.name.endsWith(".md")) {
24205
+ continue;
24206
+ }
24207
+ const commandPath = join6(args.directory, entry.name);
24208
+ const content = await readFile2(commandPath, "utf8").catch(() => "");
24209
+ if (!isManagedShadowCommand(content)) {
24210
+ continue;
24211
+ }
24212
+ const name = entry.name.replace(/\.md$/i, "");
24213
+ if (desiredNames.has(normalizeName(name))) {
24214
+ continue;
24215
+ }
24216
+ await rm(commandPath, { force: true });
24217
+ removed.push(name);
24218
+ }
24219
+ args.logger.debug("[OpenCodeDynamicSkills] TUI command mirror synced", {
24220
+ directory: args.directory,
24221
+ mirrored,
24222
+ written,
24223
+ removed,
24224
+ skipped
24225
+ });
24226
+ return {
24227
+ mirrored,
24228
+ written,
24229
+ skipped,
24230
+ removed
24231
+ };
24232
+ }
24233
+
23982
24234
  // src/index.ts
23983
24235
  var SkillsPlugin = async (ctx) => {
23984
24236
  const config3 = await getPluginConfig(ctx);
23985
24237
  const api2 = await createApi(config3, ctx.client);
23986
24238
  const sendPrompt = createInstructionInjector(ctx);
23987
- const renderer = createXmlPromptRenderer().render;
24239
+ const renderXmlPrompt = createXmlPromptRenderer().render;
23988
24240
  const notifier = createNotifier({
23989
24241
  config: config3.notifications,
23990
24242
  logger: api2.logger
23991
24243
  });
23992
- api2.registry.initialise();
24244
+ const mirroredSkillCommands = new Set;
24245
+ const registryInitialisation = api2.registry.initialise();
24246
+ if (config3.tuiCommandMirror.enabled) {
24247
+ try {
24248
+ await registryInitialisation;
24249
+ const mirrorResult = await syncTuiCommandMirror({
24250
+ skills: api2.registry.controller.skills,
24251
+ directory: config3.tuiCommandMirror.directory,
24252
+ enabled: config3.tuiCommandMirror.enabled,
24253
+ logger: api2.logger,
24254
+ reservedSlashCommands: config3.reservedSlashCommands
24255
+ });
24256
+ for (const commandName of mirrorResult.mirrored) {
24257
+ mirroredSkillCommands.add(commandName);
24258
+ }
24259
+ if (mirrorResult.written.length > 0 || mirrorResult.removed.length > 0) {
24260
+ api2.logger.warn("[OpenCodeDynamicSkills] TUI command mirror updated. Restart OpenCode to refresh slash autocomplete.", {
24261
+ directory: config3.tuiCommandMirror.directory,
24262
+ mirrored: mirrorResult.mirrored,
24263
+ written: mirrorResult.written,
24264
+ removed: mirrorResult.removed,
24265
+ skipped: mirrorResult.skipped
24266
+ });
24267
+ }
24268
+ } catch (error45) {
24269
+ api2.logger.warn("[OpenCodeDynamicSkills] Failed to sync TUI command mirror. Dynamic skills will still work after submit, but TUI slash autocomplete may miss them.", error45 instanceof Error ? error45.message : String(error45));
24270
+ }
24271
+ }
23993
24272
  return {
23994
24273
  "chat.message": async (_input, output) => {
23995
24274
  for (const part of output.parts) {
@@ -24019,6 +24298,22 @@ var SkillsPlugin = async (ctx) => {
24019
24298
  break;
24020
24299
  }
24021
24300
  },
24301
+ "command.execute.before": async (input, output) => {
24302
+ const rewritten = await rewriteMirroredSkillCommandParts({
24303
+ commandName: input.command,
24304
+ commandArguments: input.arguments,
24305
+ mirroredCommands: mirroredSkillCommands,
24306
+ registry: api2.registry,
24307
+ output
24308
+ });
24309
+ if (!rewritten) {
24310
+ return;
24311
+ }
24312
+ const skill = api2.registry.controller.skills.find((entry) => entry.name === input.command);
24313
+ if (skill) {
24314
+ await notifier.skillLoaded([skill.toolName]);
24315
+ }
24316
+ },
24022
24317
  async event(args) {
24023
24318
  switch (args.event.type) {
24024
24319
  case "session.error":
@@ -24037,7 +24332,7 @@ var SkillsPlugin = async (ctx) => {
24037
24332
  try {
24038
24333
  const results = await api2.loadSkill(args.skill_names);
24039
24334
  for await (const skill of results.loaded) {
24040
- await sendPrompt(renderer({ data: skill, type: "Skill" }), {
24335
+ await sendPrompt(await formatLoadedSkill({ skill }), {
24041
24336
  sessionId: toolCtx.sessionID,
24042
24337
  agent: toolCtx.agent
24043
24338
  });
@@ -24060,7 +24355,7 @@ var SkillsPlugin = async (ctx) => {
24060
24355
  },
24061
24356
  execute: async (args) => {
24062
24357
  const results = await api2.findSkills(args);
24063
- const output = renderer({
24358
+ const output = renderXmlPrompt({
24064
24359
  data: results,
24065
24360
  type: "SkillSearchResults"
24066
24361
  });
@@ -24075,7 +24370,7 @@ var SkillsPlugin = async (ctx) => {
24075
24370
  },
24076
24371
  execute: async (args) => {
24077
24372
  const results = await api2.recommendSkills(args);
24078
- return renderer({
24373
+ return renderXmlPrompt({
24079
24374
  data: results,
24080
24375
  type: "SkillSearchResults"
24081
24376
  });
@@ -24093,7 +24388,7 @@ var SkillsPlugin = async (ctx) => {
24093
24388
  if (!result.injection) {
24094
24389
  throw new Error("Failed to read resource");
24095
24390
  }
24096
- await sendPrompt(renderer({ data: result.injection, type: "SkillResource" }), {
24391
+ await sendPrompt(renderXmlPrompt({ data: result.injection, type: "SkillResource" }), {
24097
24392
  sessionId: toolCtx.sessionID,
24098
24393
  agent: toolCtx.agent
24099
24394
  });
@@ -3,4 +3,4 @@ export declare function formatLoadedSkill(args: {
3
3
  skill: Skill;
4
4
  invocationName?: string;
5
5
  userMessage?: string;
6
- }): string;
6
+ }): Promise<string>;
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,15 @@
1
+ import type { PluginLogger, Skill } from '../types';
2
+ export declare const SHADOW_COMMAND_MARKER = "<!-- opencode-dynamic-skills:tui-command-mirror -->";
3
+ export declare function renderShadowCommandFile(skill: Skill): string;
4
+ export declare function syncTuiCommandMirror(args: {
5
+ skills: Skill[];
6
+ directory: string;
7
+ enabled: boolean;
8
+ logger: PluginLogger;
9
+ reservedSlashCommands: string[];
10
+ }): Promise<{
11
+ mirrored: string[];
12
+ written: string[];
13
+ skipped: string[];
14
+ removed: string[];
15
+ }>;
@@ -0,0 +1 @@
1
+ export {};
package/dist/types.d.ts CHANGED
@@ -159,6 +159,7 @@ export type PluginConfig = {
159
159
  reservedSlashCommands: string[];
160
160
  notifications: NotificationConfig;
161
161
  skillRecommend: SkillRecommendConfig;
162
+ tuiCommandMirror: TuiCommandMirrorConfig;
162
163
  };
163
164
  export type NotificationConfig = {
164
165
  enabled: boolean;
@@ -170,6 +171,10 @@ export type SkillRecommendConfig = {
170
171
  model: string;
171
172
  systemPrompt: string;
172
173
  };
174
+ export type TuiCommandMirrorConfig = {
175
+ enabled: boolean;
176
+ directory: string;
177
+ };
173
178
  export type LogType = 'log' | 'debug' | 'error' | 'warn';
174
179
  export type PluginLogger = Record<LogType, (...message: unknown[]) => void>;
175
180
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-dynamic-skills",
3
- "version": "1.2.0",
3
+ "version": "1.3.0",
4
4
  "description": "OpenCode plugin for dynamic skill loading, slash skill commands, and skill-root-relative resources",
5
5
  "homepage": "https://github.com/Wu-H-Y/opencode-dynamic-skills#readme",
6
6
  "bugs": {