diff --git a/.gitignore b/.gitignore
index 71f7943df..c9736ad18 100644
--- a/.gitignore
+++ b/.gitignore
@@ -7,6 +7,7 @@ bin/gstack-global-discover
.claude/skills/
.agents/
.factory/
+.opencode/
.context/
extension/.auth.json
.gstack-worktrees/
diff --git a/README.md b/README.md
index 5057d12bc..57a6916b0 100644
--- a/README.md
+++ b/README.md
@@ -40,7 +40,7 @@ Fork it. Improve it. Make it yours. And if you want to hate on free open source
## Install — 30 seconds
-**Requirements:** [Claude Code](https://docs.anthropic.com/en/docs/claude-code), [Git](https://git-scm.com/), [Bun](https://bun.sh/) v1.0+, [Node.js](https://nodejs.org/) (Windows only)
+**Requirements:** [Claude Code](https://docs.anthropic.com/en/docs/claude-code) or [OpenCode](https://opencode.ai/), [Git](https://git-scm.com/), [Bun](https://bun.sh/) v1.0+, [Node.js](https://nodejs.org/) (Windows only)
### Step 1: Install on your machine
@@ -92,6 +92,28 @@ cd ~/gstack && ./setup --host auto
For Codex-compatible hosts, setup now supports both repo-local installs from `.agents/skills/gstack` and user-global installs from `~/.codex/skills/gstack`. All 31 skills work across all supported agents. Hook-based safety skills (careful, freeze, guard) use inline safety advisory prose on non-Claude hosts.
+### OpenCode
+
+OpenCode natively reads `SKILL.md` skills from `.opencode/skills/` in a repo or `~/.config/opencode/skills/` globally. gstack generates a native OpenCode skill layout, keeps `AGENTS.md` references correct, and preserves the runtime root at `.opencode/skills/gstack` for shared assets.
+
+Install to one repo:
+
+```bash
+git clone --single-branch --depth 1 https://github.com/garrytan/gstack.git .opencode/skills/gstack
+cd .opencode/skills/gstack && ./setup --host opencode
+```
+
+Install once for your user account:
+
+```bash
+git clone --single-branch --depth 1 https://github.com/garrytan/gstack.git ~/gstack
+cd ~/gstack && ./setup --host opencode
+```
+
+Global installs create the OpenCode runtime root at `~/.config/opencode/skills/gstack` and link each generated skill into `~/.config/opencode/skills/`. Repo-local installs keep everything inside `.opencode/skills/` so teammates get the same setup from git.
+
+If you use project instructions, put gstack routing rules in `AGENTS.md` instead of `CLAUDE.md`.
+
### Factory Droid
gstack works with [Factory Droid](https://factory.ai). Skills install to `.factory/skills/` and are discovered automatically. Sensitive skills (ship, land-and-deploy, guard) use `disable-model-invocation: true` so Droids don't auto-invoke them.
diff --git a/bin/gstack-platform-detect b/bin/gstack-platform-detect
index 4fef7331f..ad3d29ec3 100755
--- a/bin/gstack-platform-detect
+++ b/bin/gstack-platform-detect
@@ -4,17 +4,18 @@ set -euo pipefail
# gstack-platform-detect: show which AI coding agents are installed and gstack status
printf "%-16s %-10s %-40s %s\n" "Agent" "Version" "Skill Path" "gstack"
printf "%-16s %-10s %-40s %s\n" "-----" "-------" "----------" "------"
-for entry in "claude:claude" "codex:codex" "droid:factory" "kiro-cli:kiro"; do
+for entry in "claude:claude" "opencode:opencode" "codex:codex" "droid:factory" "kiro-cli:kiro"; do
bin="${entry%%:*}"; label="${entry##*:}"
if command -v "$bin" >/dev/null 2>&1; then
ver=$("$bin" --version 2>/dev/null | head -1 || echo "unknown")
case "$label" in
claude) spath="$HOME/.claude/skills/gstack" ;;
+ opencode) spath="$HOME/.config/opencode/skills/gstack" ;;
codex) spath="$HOME/.codex/skills/gstack" ;;
factory) spath="$HOME/.factory/skills/gstack" ;;
kiro) spath="$HOME/.kiro/skills/gstack" ;;
esac
- status=$([ -d "$spath" ] && echo "INSTALLED" || echo "NOT INSTALLED")
+ status=$([ -d "$spath" ] || [ -L "$spath" ] && echo "INSTALLED" || echo "NOT INSTALLED")
printf "%-16s %-10s %-40s %s\n" "$label" "$ver" "$spath" "$status"
fi
done
diff --git a/bin/gstack-uninstall b/bin/gstack-uninstall
index 2cf3d5288..e6d23ab2a 100755
--- a/bin/gstack-uninstall
+++ b/bin/gstack-uninstall
@@ -9,12 +9,15 @@
# What gets REMOVED:
# ~/.claude/skills/gstack — global Claude skill install (git clone or vendored)
# ~/.claude/skills/{skill} — per-skill symlinks created by setup
+# ~/.config/opencode/skills/gstack — global OpenCode runtime root
+# ~/.config/opencode/skills/{skill} — OpenCode per-skill links created by setup
# ~/.codex/skills/gstack* — Codex skill install + per-skill symlinks
# ~/.factory/skills/gstack* — Factory Droid skill install + per-skill symlinks
# ~/.kiro/skills/gstack* — Kiro skill install + per-skill symlinks
# ~/.gstack/ — global state (config, analytics, sessions, projects,
# repos, installation-id, browse error logs)
# .claude/skills/gstack* — project-local skill install (--local installs)
+# .opencode/skills/gstack* — project-local OpenCode install (--local installs)
# .gstack/ — per-project browse state (in current git repo)
# .gstack-worktrees/ — per-project test worktrees (in current git repo)
# .agents/skills/gstack* — Codex/Gemini/Cursor sidecar (in current git repo)
@@ -39,6 +42,16 @@ fi
GSTACK_DIR="${GSTACK_DIR:-$(cd "$(dirname "$0")/.." && pwd)}"
STATE_DIR="${GSTACK_STATE_DIR:-$HOME/.gstack}"
_GIT_ROOT="$(git rev-parse --show-toplevel 2>/dev/null || true)"
+_LOCAL_INSTALL_ROOT=""
+case "$GSTACK_DIR" in
+ */.claude/skills/gstack) _LOCAL_INSTALL_ROOT="${GSTACK_DIR%/.claude/skills/gstack}" ;;
+ */.opencode/skills/gstack) _LOCAL_INSTALL_ROOT="${GSTACK_DIR%/.opencode/skills/gstack}" ;;
+ */.agents/skills/gstack) _LOCAL_INSTALL_ROOT="${GSTACK_DIR%/.agents/skills/gstack}" ;;
+ */.factory/skills/gstack) _LOCAL_INSTALL_ROOT="${GSTACK_DIR%/.factory/skills/gstack}" ;;
+esac
+if [ -n "$_LOCAL_INSTALL_ROOT" ] && { [ -d "$_LOCAL_INSTALL_ROOT/.git" ] || [ -f "$_LOCAL_INSTALL_ROOT/.git" ]; }; then
+ _GIT_ROOT="$_LOCAL_INSTALL_ROOT"
+fi
# ─── Parse flags ─────────────────────────────────────────────
FORCE=0
@@ -63,6 +76,7 @@ done
if [ "$FORCE" -eq 0 ]; then
echo "This will remove gstack from your system:"
{ [ -d "$HOME/.claude/skills/gstack" ] || [ -L "$HOME/.claude/skills/gstack" ]; } && echo " ~/.claude/skills/gstack (+ per-skill symlinks)"
+ { [ -d "$HOME/.config/opencode/skills/gstack" ] || [ -L "$HOME/.config/opencode/skills/gstack" ]; } && echo " ~/.config/opencode/skills/gstack (+ per-skill links)"
[ -d "$HOME/.codex/skills" ] && echo " ~/.codex/skills/gstack*"
[ -d "$HOME/.factory/skills" ] && echo " ~/.factory/skills/gstack*"
[ -d "$HOME/.kiro/skills" ] && echo " ~/.kiro/skills/gstack*"
@@ -70,6 +84,7 @@ if [ "$FORCE" -eq 0 ]; then
if [ -n "$_GIT_ROOT" ]; then
[ -d "$_GIT_ROOT/.claude/skills/gstack" ] && echo " $_GIT_ROOT/.claude/skills/gstack (project-local)"
+ [ -d "$_GIT_ROOT/.opencode/skills/gstack" ] && echo " $_GIT_ROOT/.opencode/skills/gstack (project-local)"
[ -d "$_GIT_ROOT/.gstack" ] && echo " $_GIT_ROOT/.gstack/ (browse state + reports)"
[ -d "$_GIT_ROOT/.gstack-worktrees" ] && echo " $_GIT_ROOT/.gstack-worktrees/"
[ -d "$_GIT_ROOT/.agents/skills" ] && echo " $_GIT_ROOT/.agents/skills/gstack*"
@@ -161,6 +176,57 @@ if [ -n "$_GIT_ROOT" ] && [ -d "$_GIT_ROOT/.claude/skills" ]; then
fi
fi
+# ─── Remove global OpenCode skills ───────────────────────────
+OPENCODE_SKILLS="$HOME/.config/opencode/skills"
+OPENCODE_SOURCE_ROOT=""
+if [ -L "$OPENCODE_SKILLS/gstack/SKILL.md" ]; then
+ _OPENCODE_GSTACK_SKILL="$(readlink "$OPENCODE_SKILLS/gstack/SKILL.md" 2>/dev/null || true)"
+ case "$_OPENCODE_GSTACK_SKILL" in
+ */.opencode/skills/gstack/SKILL.md) OPENCODE_SOURCE_ROOT="${_OPENCODE_GSTACK_SKILL%/gstack/SKILL.md}" ;;
+ esac
+fi
+if [ -z "$OPENCODE_SOURCE_ROOT" ]; then
+ case "$GSTACK_DIR" in
+ */.opencode/skills/gstack) OPENCODE_SOURCE_ROOT="${GSTACK_DIR%/gstack}" ;;
+ esac
+fi
+if [ -d "$OPENCODE_SKILLS" ]; then
+ if [ -n "$OPENCODE_SOURCE_ROOT" ]; then
+ for _LINK in "$OPENCODE_SKILLS"/*; do
+ [ -L "$_LINK" ] || continue
+ _TARGET="$(readlink "$_LINK" 2>/dev/null || true)"
+ case "$_TARGET" in
+ "$OPENCODE_SOURCE_ROOT"/*) rm -f "$_LINK"; REMOVED+=("opencode/$(basename "$_LINK")") ;;
+ esac
+ done
+ fi
+
+ if [ -d "$OPENCODE_SKILLS/gstack" ] || [ -L "$OPENCODE_SKILLS/gstack" ]; then
+ rm -rf "$OPENCODE_SKILLS/gstack"
+ REMOVED+=("~/.config/opencode/skills/gstack")
+ fi
+fi
+
+# ─── Remove project-local OpenCode skills ────────────────────
+if [ -n "$_GIT_ROOT" ] && [ -d "$_GIT_ROOT/.opencode/skills" ]; then
+ [ -z "$OPENCODE_SOURCE_ROOT" ] && OPENCODE_SOURCE_ROOT="$_GIT_ROOT/.opencode/skills"
+ if [ -n "$OPENCODE_SOURCE_ROOT" ]; then
+ for _LINK in "$_GIT_ROOT/.opencode/skills"/*; do
+ [ -L "$_LINK" ] || continue
+ _TARGET="$(readlink "$_LINK" 2>/dev/null || true)"
+ case "$_TARGET" in
+ "$OPENCODE_SOURCE_ROOT"/*) rm -f "$_LINK"; REMOVED+=("local opencode/$(basename "$_LINK")") ;;
+ esac
+ done
+ fi
+ if [ -d "$_GIT_ROOT/.opencode/skills/gstack" ] || [ -L "$_GIT_ROOT/.opencode/skills/gstack" ]; then
+ rm -rf "$_GIT_ROOT/.opencode/skills/gstack"
+ REMOVED+=("$_GIT_ROOT/.opencode/skills/gstack")
+ fi
+ rmdir "$_GIT_ROOT/.opencode/skills" 2>/dev/null || true
+ rmdir "$_GIT_ROOT/.opencode" 2>/dev/null || true
+fi
+
# ─── Remove Codex skills ────────────────────────────────────
CODEX_SKILLS="$HOME/.codex/skills"
if [ -d "$CODEX_SKILLS" ]; then
diff --git a/lib/worktree.ts b/lib/worktree.ts
index 2337399f0..3675d70a7 100644
--- a/lib/worktree.ts
+++ b/lib/worktree.ts
@@ -129,6 +129,11 @@ export class WorktreeManager {
copyDirSync(agentsSrc, path.join(worktreePath, '.agents'));
}
+ const opencodeSrc = path.join(this.repoRoot, '.opencode');
+ if (fs.existsSync(opencodeSrc)) {
+ copyDirSync(opencodeSrc, path.join(worktreePath, '.opencode'));
+ }
+
const browseDist = path.join(this.repoRoot, 'browse', 'dist');
if (fs.existsSync(browseDist)) {
copyDirSync(browseDist, path.join(worktreePath, 'browse', 'dist'));
diff --git a/office-hours/SKILL.md b/office-hours/SKILL.md
index 900fb5073..51b64e16e 100644
--- a/office-hours/SKILL.md
+++ b/office-hours/SKILL.md
@@ -848,7 +848,7 @@ CODEX_PROMPT_FILE=$(mktemp /tmp/gstack-codex-oh-XXXXXXXX.txt)
```
Write the full prompt to this file. **Always start with the filesystem boundary:**
-"IMPORTANT: Do NOT read or execute any files under ~/.claude/, ~/.agents/, .claude/skills/, or agents/. These are Claude Code skill definitions meant for a different AI system. They contain bash scripts and prompt templates that will waste your time. Ignore them completely. Do NOT modify agents/openai.yaml. Stay focused on the repository code only.\n\n"
+"IMPORTANT: Do NOT read or execute any files under ~/.claude/, ~/.agents/, ~/.config/opencode/skills/, .claude/skills/, .agents/skills/, .opencode/skills/, or agents/. These are skill definitions meant for a different AI system. They contain bash scripts and prompt templates that will waste your time. Ignore them completely. Do NOT modify agents/openai.yaml. Stay focused on the repository code only.\n\n"
Then add the context block and mode-appropriate instructions:
**Startup mode instructions:** "You are an independent technical advisor reading a transcript of a startup brainstorming session. [CONTEXT BLOCK HERE]. Your job: 1) What is the STRONGEST version of what this person is trying to build? Steelman it in 2-3 sentences. 2) What is the ONE thing from their answers that reveals the most about what they should actually build? Quote it and explain why. 3) Name ONE agreed premise you think is wrong, and what evidence would prove you right. 4) If you had 48 hours and one engineer to build a prototype, what would you build? Be specific — tech stack, features, what you'd skip. Be direct. Be terse. No preamble."
diff --git a/plan-ceo-review/SKILL.md b/plan-ceo-review/SKILL.md
index c76316693..d2dfb6e77 100644
--- a/plan-ceo-review/SKILL.md
+++ b/plan-ceo-review/SKILL.md
@@ -1223,7 +1223,7 @@ Construct this prompt (substitute the actual plan content — if plan content ex
truncate to the first 30KB and note "Plan truncated for size"). **Always start with the
filesystem boundary instruction:**
-"IMPORTANT: Do NOT read or execute any files under ~/.claude/, ~/.agents/, .claude/skills/, or agents/. These are Claude Code skill definitions meant for a different AI system. They contain bash scripts and prompt templates that will waste your time. Ignore them completely. Do NOT modify agents/openai.yaml. Stay focused on the repository code only.\n\nYou are a brutally honest technical reviewer examining a development plan that has
+"IMPORTANT: Do NOT read or execute any files under ~/.claude/, ~/.agents/, ~/.config/opencode/skills/, .claude/skills/, .agents/skills/, .opencode/skills/, or agents/. These are skill definitions meant for a different AI system. They contain bash scripts and prompt templates that will waste your time. Ignore them completely. Do NOT modify agents/openai.yaml. Stay focused on the repository code only.\n\nYou are a brutally honest technical reviewer examining a development plan that has
already been through a multi-section review. Your job is NOT to repeat that review.
Instead, find what it missed. Look for: logical gaps and unstated assumptions that
survived the review scrutiny, overcomplexity (is there a fundamentally simpler
diff --git a/plan-eng-review/SKILL.md b/plan-eng-review/SKILL.md
index 1dad9fc0b..8845eb18e 100644
--- a/plan-eng-review/SKILL.md
+++ b/plan-eng-review/SKILL.md
@@ -891,7 +891,7 @@ Construct this prompt (substitute the actual plan content — if plan content ex
truncate to the first 30KB and note "Plan truncated for size"). **Always start with the
filesystem boundary instruction:**
-"IMPORTANT: Do NOT read or execute any files under ~/.claude/, ~/.agents/, .claude/skills/, or agents/. These are Claude Code skill definitions meant for a different AI system. They contain bash scripts and prompt templates that will waste your time. Ignore them completely. Do NOT modify agents/openai.yaml. Stay focused on the repository code only.\n\nYou are a brutally honest technical reviewer examining a development plan that has
+"IMPORTANT: Do NOT read or execute any files under ~/.claude/, ~/.agents/, ~/.config/opencode/skills/, .claude/skills/, .agents/skills/, .opencode/skills/, or agents/. These are skill definitions meant for a different AI system. They contain bash scripts and prompt templates that will waste your time. Ignore them completely. Do NOT modify agents/openai.yaml. Stay focused on the repository code only.\n\nYou are a brutally honest technical reviewer examining a development plan that has
already been through a multi-section review. Your job is NOT to repeat that review.
Instead, find what it missed. Look for: logical gaps and unstated assumptions that
survived the review scrutiny, overcomplexity (is there a fundamentally simpler
diff --git a/review/SKILL.md b/review/SKILL.md
index 3f492d21f..7d0b86b70 100644
--- a/review/SKILL.md
+++ b/review/SKILL.md
@@ -1135,7 +1135,7 @@ Claude's structured review already ran. Now add a **cross-model adversarial chal
```bash
TMPERR_ADV=$(mktemp /tmp/codex-adv-XXXXXXXX)
_REPO_ROOT=$(git rev-parse --show-toplevel) || { echo "ERROR: not in a git repo" >&2; exit 1; }
-codex exec "IMPORTANT: Do NOT read or execute any files under ~/.claude/, ~/.agents/, .claude/skills/, or agents/. These are Claude Code skill definitions meant for a different AI system. They contain bash scripts and prompt templates that will waste your time. Ignore them completely. Do NOT modify agents/openai.yaml. Stay focused on the repository code only.\n\nReview the changes on this branch against the base branch. Run git diff origin/ to see the diff. Your job is to find ways this code will fail in production. Think like an attacker and a chaos engineer. Find edge cases, race conditions, security holes, resource leaks, failure modes, and silent data corruption paths. Be adversarial. Be thorough. No compliments — just the problems." -C "$_REPO_ROOT" -s read-only -c 'model_reasoning_effort="high"' --enable web_search_cached 2>"$TMPERR_ADV"
+codex exec "IMPORTANT: Do NOT read or execute any files under ~/.claude/, ~/.agents/, ~/.config/opencode/skills/, .claude/skills/, .agents/skills/, .opencode/skills/, or agents/. These are skill definitions meant for a different AI system. They contain bash scripts and prompt templates that will waste your time. Ignore them completely. Do NOT modify agents/openai.yaml. Stay focused on the repository code only.\n\nReview the changes on this branch against the base branch. Run git diff origin/ to see the diff. Your job is to find ways this code will fail in production. Think like an attacker and a chaos engineer. Find edge cases, race conditions, security holes, resource leaks, failure modes, and silent data corruption paths. Be adversarial. Be thorough. No compliments — just the problems." -C "$_REPO_ROOT" -s read-only -c 'model_reasoning_effort="high"' --enable web_search_cached 2>"$TMPERR_ADV"
```
Set the Bash tool's `timeout` parameter to `300000` (5 minutes). Do NOT use the `timeout` shell command — it doesn't exist on macOS. After the command completes, read stderr:
@@ -1182,7 +1182,7 @@ Claude's structured review already ran. Now run **all three remaining passes** f
TMPERR=$(mktemp /tmp/codex-review-XXXXXXXX)
_REPO_ROOT=$(git rev-parse --show-toplevel) || { echo "ERROR: not in a git repo" >&2; exit 1; }
cd "$_REPO_ROOT"
-codex review "IMPORTANT: Do NOT read or execute any files under ~/.claude/, ~/.agents/, .claude/skills/, or agents/. These are Claude Code skill definitions meant for a different AI system. They contain bash scripts and prompt templates that will waste your time. Ignore them completely. Do NOT modify agents/openai.yaml. Stay focused on the repository code only.\n\nReview the diff against the base branch." --base -c 'model_reasoning_effort="high"' --enable web_search_cached 2>"$TMPERR"
+codex review "IMPORTANT: Do NOT read or execute any files under ~/.claude/, ~/.agents/, ~/.config/opencode/skills/, .claude/skills/, .agents/skills/, .opencode/skills/, or agents/. These are skill definitions meant for a different AI system. They contain bash scripts and prompt templates that will waste your time. Ignore them completely. Do NOT modify agents/openai.yaml. Stay focused on the repository code only.\n\nReview the diff against the base branch." --base -c 'model_reasoning_effort="high"' --enable web_search_cached 2>"$TMPERR"
```
Set the Bash tool's `timeout` parameter to `300000` (5 minutes). Do NOT use the `timeout` shell command — it doesn't exist on macOS. Present output under `CODEX SAYS (code review):` header.
diff --git a/scripts/gen-skill-docs.ts b/scripts/gen-skill-docs.ts
index 94f39101c..b8783be9b 100644
--- a/scripts/gen-skill-docs.ts
+++ b/scripts/gen-skill-docs.ts
@@ -32,9 +32,10 @@ const HOST_ARG_VAL: HostArg = (() => {
const val = HOST_ARG.includes('=') ? HOST_ARG.split('=')[1] : process.argv[process.argv.indexOf(HOST_ARG) + 1];
if (val === 'codex' || val === 'agents') return 'codex';
if (val === 'factory' || val === 'droid') return 'factory';
+ if (val === 'opencode' || val === 'open-code') return 'opencode';
if (val === 'claude') return 'claude';
if (val === 'all') return 'all';
- throw new Error(`Unknown host: ${val}. Use claude, codex, factory, droid, agents, or all.`);
+ throw new Error(`Unknown host: ${val}. Use claude, codex, agents, factory, droid, opencode, or all.`);
})();
// For single-host mode, HOST is the host. For --host all, it's set per iteration below.
@@ -242,6 +243,7 @@ interface ExternalHostConfig {
const EXTERNAL_HOST_CONFIG: Record = {
codex: { hostSubdir: '.agents', generateMetadata: true, descriptionLimit: 1024 },
factory: { hostSubdir: '.factory', generateMetadata: false },
+ opencode:{ hostSubdir: '.opencode', generateMetadata: false },
};
// ─── Template Processing ────────────────────────────────────
@@ -265,7 +267,10 @@ function processExternalHost(
if (!config) throw new Error(`No external host config for: ${host}`);
const name = externalSkillName(skillDir === '.' ? '' : skillDir, frontmatterName);
- const outputDir = path.join(ROOT, config.hostSubdir, 'skills', name);
+ const externalName = host === 'opencode'
+ ? (skillDir === '.' || skillDir === '' ? 'gstack' : (frontmatterName || skillDir))
+ : name;
+ const outputDir = path.join(ROOT, config.hostSubdir, 'skills', externalName);
fs.mkdirSync(outputDir, { recursive: true });
const outputPath = path.join(outputDir, 'SKILL.md');
@@ -288,6 +293,14 @@ function processExternalHost(
// Transform frontmatter (host-aware)
let result = transformFrontmatter(content, host);
+ if (host === 'opencode') {
+ const { description } = extractNameAndDescription(content);
+ const opencodeName = frontmatterName || (skillDir === '.' ? 'gstack' : skillDir);
+ const indentedDesc = description.split('\n').map(l => ` ${l}`).join('\n');
+ const bodyStart = result.indexOf('\n---') + 4;
+ result = `---\nname: ${opencodeName}\ndescription: |\n${indentedDesc}\n---` + result.slice(bodyStart);
+ }
+
// Insert safety advisory at the top of the body (after frontmatter)
if (safetyProse) {
const bodyStart = result.indexOf('\n---') + 4;
@@ -300,6 +313,18 @@ function processExternalHost(
result = result.replace(/\.claude\/skills\/review/g, `${config.hostSubdir}/skills/gstack/review`);
result = result.replace(/\.claude\/skills/g, `${config.hostSubdir}/skills`);
+ if (host === 'opencode') {
+ result = result.replace(/\$GSTACK_ROOT\/([a-z0-9-]+)\/SKILL\.md/g, '$GSTACK_ROOT/$1/SKILL.md');
+ result = result.replace(/\$GSTACK_ROOT\/ETHOS\.md/g, '$GSTACK_ROOT/gstack/ETHOS.md');
+ result = result.replace(/CLAUDE\.md/g, 'AGENTS.md');
+ result = result.replace(/This tells Claude to use specialized workflows/g, 'This tells OpenCode to use specialized workflows');
+ result = result.replace(/project's CLAUDE\.md includes skill routing rules/g, "project's AGENTS.md includes skill routing rules");
+ result = result.replace(/Add routing rules to CLAUDE\.md/g, 'Add routing rules to AGENTS.md');
+ result = result.replace(/Append this section to the end of CLAUDE\.md/g, 'Append this section to the end of AGENTS.md');
+ result = result.replace(/git add CLAUDE\.md && git commit -m "chore: add gstack skill routing rules to CLAUDE\.md"/g, 'git add AGENTS.md && git commit -m "chore: add gstack skill routing rules to AGENTS.md"');
+ result = result.replace(/Check if a CLAUDE\.md file exists in the project root\. If it does not exist, create it\./g, 'Check if an AGENTS.md file exists in the project root. If it does not exist, create it.');
+ }
+
// Factory-only: translate Claude Code tool names to generic phrasing
if (host === 'factory') {
result = result.replace(/use the Bash tool/g, 'run this command');
@@ -395,7 +420,7 @@ function findTemplates(): string[] {
return discoverTemplates(ROOT).map(t => path.join(ROOT, t.tmpl));
}
-const ALL_HOSTS: Host[] = ['claude', 'codex', 'factory'];
+const ALL_HOSTS: Host[] = ['claude', 'codex', 'factory', 'opencode'];
const hostsToRun: Host[] = HOST_ARG_VAL === 'all' ? ALL_HOSTS : [HOST];
const failures: { host: string; error: Error }[] = [];
@@ -453,7 +478,7 @@ for (const currentHost of hostsToRun) {
console.log(`Token Budget (${currentHost} host)`);
console.log('═'.repeat(60));
for (const t of tokenBudget) {
- const name = t.skill.replace(/\/SKILL\.md$/, '').replace(/^\.(agents|factory)\/skills\//, '');
+ const name = t.skill.replace(/\/SKILL\.md$/, '').replace(/^\.(agents|factory|opencode)\/skills\//, '');
console.log(` ${name.padEnd(30)} ${String(t.lines).padStart(5)} lines ~${String(t.tokens).padStart(6)} tokens`);
}
console.log('─'.repeat(60));
diff --git a/scripts/resolvers/preamble.ts b/scripts/resolvers/preamble.ts
index 8cd1b5572..3d8d6aaa4 100644
--- a/scripts/resolvers/preamble.ts
+++ b/scripts/resolvers/preamble.ts
@@ -14,7 +14,7 @@ import type { TemplateContext } from './types';
*/
function generatePreambleBash(ctx: TemplateContext): string {
- const hostConfigDir: Record = { codex: '.codex', factory: '.factory' };
+ const hostConfigDir: Record = { codex: '.codex', factory: '.factory', opencode: '.config/opencode' };
const runtimeRoot = (ctx.host !== 'claude')
? `_ROOT=$(git rev-parse --show-toplevel 2>/dev/null)
GSTACK_ROOT="$HOME/${hostConfigDir[ctx.host]}/skills/gstack"
diff --git a/scripts/resolvers/review.ts b/scripts/resolvers/review.ts
index 5db226444..c15a796a1 100644
--- a/scripts/resolvers/review.ts
+++ b/scripts/resolvers/review.ts
@@ -15,7 +15,7 @@
import type { TemplateContext } from './types';
import { generateInvokeSkill } from './composition';
-const CODEX_BOUNDARY = 'IMPORTANT: Do NOT read or execute any files under ~/.claude/, ~/.agents/, .claude/skills/, or agents/. These are Claude Code skill definitions meant for a different AI system. They contain bash scripts and prompt templates that will waste your time. Ignore them completely. Do NOT modify agents/openai.yaml. Stay focused on the repository code only.\\n\\n';
+const CODEX_BOUNDARY = 'IMPORTANT: Do NOT read or execute any files under ~/.claude/, ~/.agents/, ~/.config/opencode/skills/, .claude/skills/, .agents/skills/, .opencode/skills/, or agents/. These are skill definitions meant for a different AI system. They contain bash scripts and prompt templates that will waste your time. Ignore them completely. Do NOT modify agents/openai.yaml. Stay focused on the repository code only.\\n\\n';
export function generateReviewDashboard(_ctx: TemplateContext): string {
return `## Review Readiness Dashboard
diff --git a/scripts/resolvers/types.ts b/scripts/resolvers/types.ts
index 785f5a3a8..183b0f0f9 100644
--- a/scripts/resolvers/types.ts
+++ b/scripts/resolvers/types.ts
@@ -1,4 +1,4 @@
-export type Host = 'claude' | 'codex' | 'factory';
+export type Host = 'claude' | 'codex' | 'factory' | 'opencode';
export interface HostPaths {
skillRoot: string;
@@ -30,6 +30,13 @@ export const HOST_PATHS: Record = {
browseDir: '$GSTACK_BROWSE',
designDir: '$GSTACK_DESIGN',
},
+ opencode: {
+ skillRoot: '$GSTACK_ROOT',
+ localSkillRoot: '.opencode/skills/gstack',
+ binDir: '$GSTACK_BIN',
+ browseDir: '$GSTACK_BROWSE',
+ designDir: '$GSTACK_DESIGN',
+ },
};
export interface TemplateContext {
diff --git a/scripts/skill-check.ts b/scripts/skill-check.ts
index e859d9b59..5c8fcb404 100644
--- a/scripts/skill-check.ts
+++ b/scripts/skill-check.ts
@@ -142,6 +142,37 @@ if (fs.existsSync(FACTORY_DIR)) {
console.log('\n Factory Skills: .factory/skills/ not found (run: bun run gen:skill-docs --host factory)');
}
+// ─── OpenCode Skills ────────────────────────────────────────
+
+const OPENCODE_DIR = path.join(ROOT, '.opencode', 'skills');
+if (fs.existsSync(OPENCODE_DIR)) {
+ console.log('\n OpenCode Skills (.opencode/skills/):');
+ const opencodeDirs = fs.readdirSync(OPENCODE_DIR).sort();
+ let opencodeCount = 0;
+ let opencodeMissing = 0;
+ for (const dir of opencodeDirs) {
+ const skillMd = path.join(OPENCODE_DIR, dir, 'SKILL.md');
+ if (fs.existsSync(skillMd)) {
+ opencodeCount++;
+ const content = fs.readFileSync(skillMd, 'utf-8');
+ const hasClaude = content.includes('.claude/skills') || content.includes('CLAUDE.md');
+ if (hasClaude) {
+ hasErrors = true;
+ console.log(` ❌ ${dir.padEnd(30)} — contains Claude-specific reference`);
+ } else {
+ console.log(` ✅ ${dir.padEnd(30)} — OK`);
+ }
+ } else {
+ opencodeMissing++;
+ hasErrors = true;
+ console.log(` ❌ ${dir.padEnd(30)} — SKILL.md missing`);
+ }
+ }
+ console.log(` Total: ${opencodeCount} skills, ${opencodeMissing} missing`);
+} else {
+ console.log('\n OpenCode Skills: .opencode/skills/ not found (run: bun run gen:skill-docs --host opencode)');
+}
+
// ─── Freshness ──────────────────────────────────────────────
console.log('\n Freshness (Claude):');
@@ -186,5 +217,19 @@ try {
console.log(' Run: bun run gen:skill-docs --host factory');
}
+console.log('\n Freshness (OpenCode):');
+try {
+ execSync('bun run scripts/gen-skill-docs.ts --host opencode --dry-run', { cwd: ROOT, stdio: 'pipe' });
+ console.log(' ✅ All OpenCode generated files are fresh');
+} catch (err: any) {
+ hasErrors = true;
+ const output = err.stdout?.toString() || '';
+ console.log(' ❌ OpenCode generated files are stale:');
+ for (const line of output.split('\n').filter((l: string) => l.startsWith('STALE'))) {
+ console.log(` ${line}`);
+ }
+ console.log(' Run: bun run gen:skill-docs --host opencode');
+}
+
console.log('');
process.exit(hasErrors ? 1 : 0);
diff --git a/setup b/setup
index d2836245b..76c29a708 100755
--- a/setup
+++ b/setup
@@ -1,5 +1,5 @@
#!/usr/bin/env bash
-# gstack setup — build browser binary + register skills with Claude Code / Codex
+# gstack setup — build browser binary + register skills with Claude Code / OpenCode / Codex
set -e
if ! command -v bun >/dev/null 2>&1; then
@@ -21,6 +21,8 @@ CODEX_SKILLS="$HOME/.codex/skills"
CODEX_GSTACK="$CODEX_SKILLS/gstack"
FACTORY_SKILLS="$HOME/.factory/skills"
FACTORY_GSTACK="$FACTORY_SKILLS/gstack"
+OPENCODE_SKILLS="$HOME/.config/opencode/skills"
+OPENCODE_GSTACK="$OPENCODE_SKILLS/gstack"
IS_WINDOWS=0
case "$(uname -s)" in
@@ -34,7 +36,7 @@ SKILL_PREFIX=1
SKILL_PREFIX_FLAG=0
while [ $# -gt 0 ]; do
case "$1" in
- --host) [ -z "$2" ] && echo "Missing value for --host (expected claude, codex, kiro, or auto)" >&2 && exit 1; HOST="$2"; shift 2 ;;
+ --host) [ -z "$2" ] && echo "Missing value for --host (expected claude, opencode, codex, kiro, factory, or auto)" >&2 && exit 1; HOST="$2"; shift 2 ;;
--host=*) HOST="${1#--host=}"; shift ;;
--local) LOCAL_INSTALL=1; shift ;;
--prefix) SKILL_PREFIX=1; SKILL_PREFIX_FLAG=1; shift ;;
@@ -44,8 +46,8 @@ while [ $# -gt 0 ]; do
done
case "$HOST" in
- claude|codex|kiro|factory|auto) ;;
- *) echo "Unknown --host value: $HOST (expected claude, codex, kiro, factory, or auto)" >&2; exit 1 ;;
+ claude|opencode|codex|kiro|factory|auto) ;;
+ *) echo "Unknown --host value: $HOST (expected claude, opencode, codex, kiro, factory, or auto)" >&2; exit 1 ;;
esac
# ─── Resolve skill prefix preference ─────────────────────────
@@ -87,34 +89,42 @@ else
"$GSTACK_CONFIG" set skill_prefix "$([ "$SKILL_PREFIX" -eq 1 ] && echo true || echo false)" 2>/dev/null || true
fi
-# --local: install to .claude/skills/ in the current working directory
+# --local: install to host-native skills dir in the current working directory
if [ "$LOCAL_INSTALL" -eq 1 ]; then
- if [ "$HOST" = "codex" ]; then
- echo "Error: --local is only supported for Claude Code (not Codex)." >&2
+ if [ "$HOST" = "codex" ] || [ "$HOST" = "factory" ] || [ "$HOST" = "kiro" ]; then
+ echo "Error: --local is only supported for Claude Code or OpenCode." >&2
exit 1
fi
- INSTALL_SKILLS_DIR="$(pwd)/.claude/skills"
+ if [ "$HOST" = "opencode" ]; then
+ INSTALL_SKILLS_DIR="$(pwd)/.opencode/skills"
+ else
+ INSTALL_SKILLS_DIR="$(pwd)/.claude/skills"
+ HOST="claude"
+ fi
mkdir -p "$INSTALL_SKILLS_DIR"
- HOST="claude"
INSTALL_CODEX=0
fi
# For auto: detect which agents are installed
INSTALL_CLAUDE=0
+INSTALL_OPENCODE=0
INSTALL_CODEX=0
INSTALL_KIRO=0
INSTALL_FACTORY=0
if [ "$HOST" = "auto" ]; then
command -v claude >/dev/null 2>&1 && INSTALL_CLAUDE=1
+ command -v opencode >/dev/null 2>&1 && INSTALL_OPENCODE=1
command -v codex >/dev/null 2>&1 && INSTALL_CODEX=1
command -v kiro-cli >/dev/null 2>&1 && INSTALL_KIRO=1
command -v droid >/dev/null 2>&1 && INSTALL_FACTORY=1
# If none found, default to claude
- if [ "$INSTALL_CLAUDE" -eq 0 ] && [ "$INSTALL_CODEX" -eq 0 ] && [ "$INSTALL_KIRO" -eq 0 ] && [ "$INSTALL_FACTORY" -eq 0 ]; then
+ if [ "$INSTALL_CLAUDE" -eq 0 ] && [ "$INSTALL_OPENCODE" -eq 0 ] && [ "$INSTALL_CODEX" -eq 0 ] && [ "$INSTALL_KIRO" -eq 0 ] && [ "$INSTALL_FACTORY" -eq 0 ]; then
INSTALL_CLAUDE=1
fi
elif [ "$HOST" = "claude" ]; then
INSTALL_CLAUDE=1
+elif [ "$HOST" = "opencode" ]; then
+ INSTALL_OPENCODE=1
elif [ "$HOST" = "codex" ]; then
INSTALL_CODEX=1
elif [ "$HOST" = "kiro" ]; then
@@ -213,6 +223,16 @@ if [ "$NEEDS_AGENTS_GEN" -eq 1 ] && [ "$NEEDS_BUILD" -eq 0 ]; then
)
fi
+# 1d. Generate .opencode/ OpenCode skill docs
+if [ "$INSTALL_OPENCODE" -eq 1 ] && [ "$NEEDS_BUILD" -eq 0 ]; then
+ echo "Generating .opencode/ skill docs..."
+ (
+ cd "$SOURCE_GSTACK_DIR"
+ bun install --frozen-lockfile 2>/dev/null || bun install
+ bun run gen:skill-docs --host opencode
+ )
+fi
+
# 1c. Generate .factory/ Factory Droid skill docs
if [ "$INSTALL_FACTORY" -eq 1 ] && [ "$NEEDS_BUILD" -eq 0 ]; then
echo "Generating .factory/ skill docs..."
@@ -518,6 +538,55 @@ create_factory_runtime_root() {
fi
}
+create_opencode_runtime_root() {
+ local gstack_dir="$1"
+ local opencode_gstack="$2"
+ local opencode_dir="$gstack_dir/.opencode/skills"
+
+ if [ -L "$opencode_gstack" ]; then
+ rm -f "$opencode_gstack"
+ elif [ -d "$opencode_gstack" ] && [ "$opencode_gstack" != "$gstack_dir" ]; then
+ rm -rf "$opencode_gstack"
+ fi
+
+ mkdir -p "$opencode_gstack" "$opencode_gstack/browse" "$opencode_gstack/design" "$opencode_gstack/gstack-upgrade" "$opencode_gstack/review"
+
+ if [ -f "$opencode_dir/gstack/SKILL.md" ]; then
+ ln -snf "$opencode_dir/gstack/SKILL.md" "$opencode_gstack/SKILL.md"
+ fi
+ if [ -d "$gstack_dir/bin" ]; then
+ ln -snf "$gstack_dir/bin" "$opencode_gstack/bin"
+ fi
+ if [ -d "$gstack_dir/browse/dist" ]; then
+ ln -snf "$gstack_dir/browse/dist" "$opencode_gstack/browse/dist"
+ fi
+ if [ -d "$gstack_dir/browse/bin" ]; then
+ ln -snf "$gstack_dir/browse/bin" "$opencode_gstack/browse/bin"
+ fi
+ if [ -d "$gstack_dir/design/dist" ]; then
+ ln -snf "$gstack_dir/design/dist" "$opencode_gstack/design/dist"
+ fi
+ if [ -f "$opencode_dir/gstack-upgrade/SKILL.md" ]; then
+ ln -snf "$opencode_dir/gstack-upgrade/SKILL.md" "$opencode_gstack/gstack-upgrade/SKILL.md"
+ fi
+ for f in checklist.md design-checklist.md greptile-triage.md TODOS-format.md; do
+ if [ -f "$gstack_dir/review/$f" ]; then
+ ln -snf "$gstack_dir/review/$f" "$opencode_gstack/review/$f"
+ fi
+ done
+ if [ -f "$gstack_dir/ETHOS.md" ]; then
+ ln -snf "$gstack_dir/ETHOS.md" "$opencode_gstack/ETHOS.md"
+ fi
+
+ for skill_dir in "$opencode_dir"/*/; do
+ [ -f "$skill_dir/SKILL.md" ] || continue
+ skill_name="$(basename "$skill_dir")"
+ [ "$skill_name" = "gstack" ] && continue
+ mkdir -p "$opencode_gstack/$skill_name"
+ ln -snf "$skill_dir/SKILL.md" "$opencode_gstack/$skill_name/SKILL.md"
+ done
+}
+
link_factory_skill_dirs() {
local gstack_dir="$1"
local skills_dir="$2"
@@ -550,6 +619,38 @@ link_factory_skill_dirs() {
fi
}
+link_opencode_skill_dirs() {
+ local gstack_dir="$1"
+ local skills_dir="$2"
+ local opencode_dir="$gstack_dir/.opencode/skills"
+ local linked=()
+
+ if [ ! -d "$opencode_dir" ]; then
+ echo " Generating .opencode/ skill docs..."
+ ( cd "$gstack_dir" && bun run gen:skill-docs --host opencode )
+ fi
+
+ if [ ! -d "$opencode_dir" ]; then
+ echo " warning: .opencode/skills/ generation failed — run 'bun run gen:skill-docs --host opencode' manually" >&2
+ return 1
+ fi
+
+ for skill_dir in "$opencode_dir"/*/; do
+ if [ -f "$skill_dir/SKILL.md" ]; then
+ skill_name="$(basename "$skill_dir")"
+ [ "$skill_name" = "gstack" ] && continue
+ target="$skills_dir/$skill_name"
+ if [ -L "$target" ] || [ ! -e "$target" ]; then
+ ln -snf "$skill_dir" "$target"
+ linked+=("$skill_name")
+ fi
+ fi
+ done
+ if [ ${#linked[@]} -gt 0 ]; then
+ echo " linked skills: ${linked[*]}"
+ fi
+}
+
# 4. Install for Claude (default)
SKILLS_BASENAME="$(basename "$INSTALL_SKILLS_DIR")"
SKILLS_PARENT_BASENAME="$(basename "$(dirname "$INSTALL_SKILLS_DIR")")"
@@ -557,6 +658,10 @@ CODEX_REPO_LOCAL=0
if [ "$SKILLS_BASENAME" = "skills" ] && [ "$SKILLS_PARENT_BASENAME" = ".agents" ]; then
CODEX_REPO_LOCAL=1
fi
+OPENCODE_REPO_LOCAL=0
+if [ "$SKILLS_BASENAME" = "skills" ] && [ "$SKILLS_PARENT_BASENAME" = ".opencode" ]; then
+ OPENCODE_REPO_LOCAL=1
+fi
if [ "$INSTALL_CLAUDE" -eq 1 ]; then
if [ "$SKILLS_BASENAME" = "skills" ]; then
@@ -581,6 +686,24 @@ if [ "$INSTALL_CLAUDE" -eq 1 ]; then
fi
fi
+# 4b. Install for OpenCode
+if [ "$INSTALL_OPENCODE" -eq 1 ]; then
+ if [ "$OPENCODE_REPO_LOCAL" -eq 1 ]; then
+ OPENCODE_SKILLS="$INSTALL_SKILLS_DIR"
+ OPENCODE_GSTACK="$INSTALL_GSTACK_DIR"
+ fi
+ mkdir -p "$OPENCODE_SKILLS"
+
+ if [ "$OPENCODE_REPO_LOCAL" -eq 0 ]; then
+ create_opencode_runtime_root "$SOURCE_GSTACK_DIR" "$OPENCODE_GSTACK"
+ fi
+ link_opencode_skill_dirs "$SOURCE_GSTACK_DIR" "$OPENCODE_SKILLS"
+
+ echo "gstack ready (opencode)."
+ echo " browse: $BROWSE_BIN"
+ echo " opencode skills: $OPENCODE_SKILLS"
+fi
+
# 5. Install for Codex
if [ "$INSTALL_CODEX" -eq 1 ]; then
if [ "$CODEX_REPO_LOCAL" -eq 1 ]; then
diff --git a/ship/SKILL.md b/ship/SKILL.md
index 4519b6e2d..ca344dc0a 100644
--- a/ship/SKILL.md
+++ b/ship/SKILL.md
@@ -1629,7 +1629,7 @@ Claude's structured review already ran. Now add a **cross-model adversarial chal
```bash
TMPERR_ADV=$(mktemp /tmp/codex-adv-XXXXXXXX)
_REPO_ROOT=$(git rev-parse --show-toplevel) || { echo "ERROR: not in a git repo" >&2; exit 1; }
-codex exec "IMPORTANT: Do NOT read or execute any files under ~/.claude/, ~/.agents/, .claude/skills/, or agents/. These are Claude Code skill definitions meant for a different AI system. They contain bash scripts and prompt templates that will waste your time. Ignore them completely. Do NOT modify agents/openai.yaml. Stay focused on the repository code only.\n\nReview the changes on this branch against the base branch. Run git diff origin/ to see the diff. Your job is to find ways this code will fail in production. Think like an attacker and a chaos engineer. Find edge cases, race conditions, security holes, resource leaks, failure modes, and silent data corruption paths. Be adversarial. Be thorough. No compliments — just the problems." -C "$_REPO_ROOT" -s read-only -c 'model_reasoning_effort="high"' --enable web_search_cached 2>"$TMPERR_ADV"
+codex exec "IMPORTANT: Do NOT read or execute any files under ~/.claude/, ~/.agents/, ~/.config/opencode/skills/, .claude/skills/, .agents/skills/, .opencode/skills/, or agents/. These are skill definitions meant for a different AI system. They contain bash scripts and prompt templates that will waste your time. Ignore them completely. Do NOT modify agents/openai.yaml. Stay focused on the repository code only.\n\nReview the changes on this branch against the base branch. Run git diff origin/ to see the diff. Your job is to find ways this code will fail in production. Think like an attacker and a chaos engineer. Find edge cases, race conditions, security holes, resource leaks, failure modes, and silent data corruption paths. Be adversarial. Be thorough. No compliments — just the problems." -C "$_REPO_ROOT" -s read-only -c 'model_reasoning_effort="high"' --enable web_search_cached 2>"$TMPERR_ADV"
```
Set the Bash tool's `timeout` parameter to `300000` (5 minutes). Do NOT use the `timeout` shell command — it doesn't exist on macOS. After the command completes, read stderr:
@@ -1676,7 +1676,7 @@ Claude's structured review already ran. Now run **all three remaining passes** f
TMPERR=$(mktemp /tmp/codex-review-XXXXXXXX)
_REPO_ROOT=$(git rev-parse --show-toplevel) || { echo "ERROR: not in a git repo" >&2; exit 1; }
cd "$_REPO_ROOT"
-codex review "IMPORTANT: Do NOT read or execute any files under ~/.claude/, ~/.agents/, .claude/skills/, or agents/. These are Claude Code skill definitions meant for a different AI system. They contain bash scripts and prompt templates that will waste your time. Ignore them completely. Do NOT modify agents/openai.yaml. Stay focused on the repository code only.\n\nReview the diff against the base branch." --base -c 'model_reasoning_effort="high"' --enable web_search_cached 2>"$TMPERR"
+codex review "IMPORTANT: Do NOT read or execute any files under ~/.claude/, ~/.agents/, ~/.config/opencode/skills/, .claude/skills/, .agents/skills/, .opencode/skills/, or agents/. These are skill definitions meant for a different AI system. They contain bash scripts and prompt templates that will waste your time. Ignore them completely. Do NOT modify agents/openai.yaml. Stay focused on the repository code only.\n\nReview the diff against the base branch." --base -c 'model_reasoning_effort="high"' --enable web_search_cached 2>"$TMPERR"
```
Set the Bash tool's `timeout` parameter to `300000` (5 minutes). Do NOT use the `timeout` shell command — it doesn't exist on macOS. Present output under `CODEX SAYS (code review):` header.
diff --git a/test/gen-skill-docs.test.ts b/test/gen-skill-docs.test.ts
index 95f4bc9c9..154b9c554 100644
--- a/test/gen-skill-docs.test.ts
+++ b/test/gen-skill-docs.test.ts
@@ -8,6 +8,26 @@ import * as os from 'os';
const ROOT = path.resolve(import.meta.dir, '..');
const MAX_SKILL_DESCRIPTION_LENGTH = 1024;
+function ensureGeneratedHost(host: 'codex' | 'factory' | 'opencode'): void {
+ const marker = host === 'codex'
+ ? path.join(ROOT, '.agents', 'skills', 'gstack', 'SKILL.md')
+ : host === 'factory'
+ ? path.join(ROOT, '.factory', 'skills', 'gstack', 'SKILL.md')
+ : path.join(ROOT, '.opencode', 'skills', 'gstack', 'SKILL.md');
+
+ if (fs.existsSync(marker)) return;
+
+ const result = Bun.spawnSync(['bun', 'run', 'scripts/gen-skill-docs.ts', '--host', host], {
+ cwd: ROOT,
+ stdout: 'pipe',
+ stderr: 'pipe',
+ });
+
+ if (result.exitCode !== 0) {
+ throw new Error(`Failed to generate ${host} skills: ${result.stderr.toString() || result.stdout.toString()}`);
+ }
+}
+
function extractDescription(content: string): string {
const fmEnd = content.indexOf('\n---', 4);
expect(fmEnd).toBeGreaterThan(0);
@@ -1014,6 +1034,8 @@ describe('DESIGN_SKETCH resolver', () => {
describe('CODEX_SECOND_OPINION resolver', () => {
const content = fs.readFileSync(path.join(ROOT, 'office-hours', 'SKILL.md'), 'utf-8');
+
+ ensureGeneratedHost('codex');
const codexContent = fs.readFileSync(path.join(ROOT, '.agents', 'skills', 'gstack-office-hours', 'SKILL.md'), 'utf-8');
test('Phase 3.5 section appears in office-hours SKILL.md', () => {
@@ -1669,26 +1691,23 @@ describe('Codex generation (--host codex)', () => {
const content = fs.readFileSync(path.join(ROOT, 'review', 'SKILL.md'), 'utf-8');
expect(content).toContain('.claude/skills/review/checklist.md');
expect(content).toContain('~/.claude/skills/gstack');
- // Must NOT contain Codex paths
- expect(content).not.toContain('.agents/skills');
+ // Must NOT contain Codex runtime paths or rewritten sidecar paths
expect(content).not.toContain('~/.codex/');
+ expect(content).not.toContain('.agents/skills/gstack/review');
});
test('Claude output unchanged: ship skill still uses .claude/skills/ paths', () => {
const content = fs.readFileSync(path.join(ROOT, 'ship', 'SKILL.md'), 'utf-8');
expect(content).toContain('~/.claude/skills/gstack');
- expect(content).not.toContain('.agents/skills');
expect(content).not.toContain('~/.codex/');
+ expect(content).not.toContain('.agents/skills/gstack/review');
});
test('Claude output unchanged: all Claude skills have zero Codex paths', () => {
for (const skill of ALL_SKILLS) {
const content = fs.readFileSync(path.join(ROOT, skill.dir, 'SKILL.md'), 'utf-8');
expect(content).not.toContain('~/.codex/');
- // gstack-upgrade legitimately references .agents/skills for cross-platform detection
- if (skill.dir !== 'gstack-upgrade') {
- expect(content).not.toContain('.agents/skills');
- }
+ expect(content).not.toContain('.agents/skills/gstack/review');
}
});
@@ -1843,19 +1862,127 @@ describe('Factory generation (--host factory)', () => {
});
});
+// ─── OpenCode generation tests ───────────────────────────────
+
+describe('OpenCode generation (--host opencode)', () => {
+ const OPENCODE_DIR = path.join(ROOT, '.opencode', 'skills');
+
+ Bun.spawnSync(['bun', 'run', 'scripts/gen-skill-docs.ts', '--host', 'opencode'], {
+ cwd: ROOT, stdout: 'pipe', stderr: 'pipe',
+ });
+
+ const OPENCODE_SKILLS = (() => {
+ const skills: Array<{ dir: string; opencodeName: string }> = [];
+ const isSymlinkLoop = (opencodeName: string): boolean => {
+ const opencodeSkillDir = path.join(ROOT, '.opencode', 'skills', opencodeName);
+ try { return fs.realpathSync(opencodeSkillDir) === fs.realpathSync(ROOT); }
+ catch { return false; }
+ };
+ if (fs.existsSync(path.join(ROOT, 'SKILL.md.tmpl'))) {
+ if (!isSymlinkLoop('gstack')) skills.push({ dir: '.', opencodeName: 'gstack' });
+ }
+ for (const entry of fs.readdirSync(ROOT, { withFileTypes: true })) {
+ if (!entry.isDirectory() || entry.name.startsWith('.') || entry.name === 'node_modules') continue;
+ if (entry.name === 'codex') continue;
+ if (!fs.existsSync(path.join(ROOT, entry.name, 'SKILL.md.tmpl'))) continue;
+ if (isSymlinkLoop(entry.name)) continue;
+ skills.push({ dir: entry.name, opencodeName: entry.name });
+ }
+ return skills;
+ })();
+
+ test('--host opencode generates correct output paths', () => {
+ for (const skill of OPENCODE_SKILLS) {
+ const skillMd = path.join(OPENCODE_DIR, skill.opencodeName, 'SKILL.md');
+ expect(fs.existsSync(skillMd)).toBe(true);
+ }
+ });
+
+ test('OpenCode output keeps root as gstack and subdirectories unprefixed', () => {
+ expect(fs.existsSync(path.join(OPENCODE_DIR, 'gstack', 'SKILL.md'))).toBe(true);
+ expect(fs.existsSync(path.join(OPENCODE_DIR, 'review', 'SKILL.md'))).toBe(true);
+ expect(fs.existsSync(path.join(OPENCODE_DIR, 'ship', 'SKILL.md'))).toBe(true);
+ expect(fs.existsSync(path.join(OPENCODE_DIR, 'gstack-upgrade', 'SKILL.md'))).toBe(true);
+ expect(fs.existsSync(path.join(OPENCODE_DIR, 'gstack-review', 'SKILL.md'))).toBe(false);
+ });
+
+ test('OpenCode frontmatter has name + description only', () => {
+ for (const skill of OPENCODE_SKILLS) {
+ const content = fs.readFileSync(path.join(OPENCODE_DIR, skill.opencodeName, 'SKILL.md'), 'utf-8');
+ expect(content.startsWith('---\n')).toBe(true);
+ const fmEnd = content.indexOf('\n---', 4);
+ expect(fmEnd).toBeGreaterThan(0);
+ const frontmatter = content.slice(4, fmEnd);
+ expect(frontmatter).toContain(`name: ${skill.opencodeName}`);
+ expect(frontmatter).toContain('description:');
+ expect(frontmatter).not.toContain('allowed-tools:');
+ expect(frontmatter).not.toContain('version:');
+ expect(frontmatter).not.toContain('hooks:');
+ }
+ });
+
+ test('no Claude-specific paths remain in OpenCode output', () => {
+ for (const skill of OPENCODE_SKILLS) {
+ const content = fs.readFileSync(path.join(OPENCODE_DIR, skill.opencodeName, 'SKILL.md'), 'utf-8');
+ expect(content).not.toContain('.claude/skills');
+ expect(content).not.toContain('~/.claude/skills');
+ }
+ });
+
+ test('OpenCode output rewrites AGENTS.md references', () => {
+ const content = fs.readFileSync(path.join(OPENCODE_DIR, 'ship', 'SKILL.md'), 'utf-8');
+ expect(content).toContain('AGENTS.md');
+ expect(content).not.toContain('CLAUDE.md');
+ expect(content).toContain('Add routing rules to AGENTS.md');
+ });
+
+ test('OpenCode preamble resolves runtime assets from repo-local or global gstack roots', () => {
+ const content = fs.readFileSync(path.join(OPENCODE_DIR, 'review', 'SKILL.md'), 'utf-8');
+ expect(content).toContain('GSTACK_ROOT');
+ expect(content).toContain('$_ROOT/.opencode/skills/gstack');
+ expect(content).toContain('$GSTACK_BIN/gstack-config');
+ expect(content).toContain('$GSTACK_ROOT/gstack-upgrade/SKILL.md');
+ });
+
+ test('OpenCode sidecar paths point to .opencode/skills/gstack/review/', () => {
+ const content = fs.readFileSync(path.join(OPENCODE_DIR, 'review', 'SKILL.md'), 'utf-8');
+ expect(content).toContain('.opencode/skills/gstack/review/checklist.md');
+ expect(content).toContain('.opencode/skills/gstack/review/design-checklist.md');
+ expect(content).not.toContain('.opencode/skills/review/checklist.md');
+ });
+
+ test('/codex skill excluded from OpenCode output', () => {
+ expect(fs.existsSync(path.join(OPENCODE_DIR, 'codex', 'SKILL.md'))).toBe(false);
+ expect(fs.existsSync(path.join(OPENCODE_DIR, 'codex'))).toBe(false);
+ });
+
+ test('--host opencode --dry-run freshness', () => {
+ const result = Bun.spawnSync(['bun', 'run', 'scripts/gen-skill-docs.ts', '--host', 'opencode', '--dry-run'], {
+ cwd: ROOT, stdout: 'pipe', stderr: 'pipe',
+ });
+ expect(result.exitCode).toBe(0);
+ const output = result.stdout.toString();
+ for (const skill of OPENCODE_SKILLS) {
+ expect(output).toContain(`FRESH: .opencode/skills/${skill.opencodeName}/SKILL.md`);
+ }
+ expect(output).not.toContain('STALE');
+ });
+});
+
// ─── --host all tests ────────────────────────────────────────
describe('--host all', () => {
- test('--host all generates for claude, codex, and factory', () => {
+ test('--host all generates for claude, codex, factory, and opencode', () => {
const result = Bun.spawnSync(['bun', 'run', 'scripts/gen-skill-docs.ts', '--host', 'all', '--dry-run'], {
cwd: ROOT, stdout: 'pipe', stderr: 'pipe',
});
expect(result.exitCode).toBe(0);
const output = result.stdout.toString();
- // All three hosts should appear in output
+ // All hosts should appear in output
expect(output).toContain('FRESH: SKILL.md'); // claude
expect(output).toContain('FRESH: .agents/skills/'); // codex
expect(output).toContain('FRESH: .factory/skills/'); // factory
+ expect(output).toContain('FRESH: .opencode/skills/'); // opencode
});
});
@@ -1878,17 +2005,40 @@ describe('setup script validation', () => {
// The Claude install section (section 4) should use the Claude function
const claudeSection = setupContent.slice(
setupContent.indexOf('# 4. Install for Claude'),
- setupContent.indexOf('# 5. Install for Codex')
+ setupContent.indexOf('# 4b. Install for OpenCode')
);
expect(claudeSection).toContain('link_claude_skill_dirs');
expect(claudeSection).not.toContain('link_codex_skill_dirs');
});
+ test('OpenCode install uses OpenCode runtime root and link functions', () => {
+ const opencodeSection = setupContent.slice(
+ setupContent.indexOf('# 4b. Install for OpenCode'),
+ setupContent.indexOf('# 5. Install for Codex')
+ );
+ expect(opencodeSection).toContain('create_opencode_runtime_root');
+ expect(opencodeSection).toContain('link_opencode_skill_dirs');
+ expect(opencodeSection).toContain('OPENCODE_REPO_LOCAL');
+ });
+
+ test('create_opencode_runtime_root exposes required runtime assets', () => {
+ const fnStart = setupContent.indexOf('create_opencode_runtime_root()');
+ const fnEnd = setupContent.indexOf('}', setupContent.indexOf('ln -snf "$skill_dir/SKILL.md" "$opencode_gstack/$skill_name/SKILL.md"', fnStart));
+ const fnBody = setupContent.slice(fnStart, fnEnd);
+ expect(fnBody).toContain('mkdir -p "$opencode_gstack" "$opencode_gstack/browse" "$opencode_gstack/design"');
+ expect(fnBody).toContain('browse/dist');
+ expect(fnBody).toContain('browse/bin');
+ expect(fnBody).toContain('design/dist');
+ expect(fnBody).toContain('gstack-upgrade/SKILL.md');
+ expect(fnBody).toContain('checklist.md');
+ expect(fnBody).toContain('design-checklist.md');
+ });
+
test('Codex install uses link_codex_skill_dirs', () => {
// The Codex install section (section 5) should use the Codex function
const codexSection = setupContent.slice(
setupContent.indexOf('# 5. Install for Codex'),
- setupContent.indexOf('# 6. Create')
+ setupContent.indexOf('# 6. Install for Kiro CLI')
);
expect(codexSection).toContain('create_codex_runtime_root');
expect(codexSection).toContain('link_codex_skill_dirs');
@@ -1935,13 +2085,14 @@ describe('setup script validation', () => {
expect(fnBody).toContain('ln -snf "gstack/$dir_name"');
});
- test('setup supports --host auto|claude|codex|kiro', () => {
+ test('setup supports --host auto|claude|opencode|codex|kiro|factory', () => {
expect(setupContent).toContain('--host');
- expect(setupContent).toContain('claude|codex|kiro|factory|auto');
+ expect(setupContent).toContain('claude|opencode|codex|kiro|factory|auto');
});
- test('auto mode detects claude, codex, and kiro binaries', () => {
+ test('auto mode detects claude, opencode, codex, and kiro binaries', () => {
expect(setupContent).toContain('command -v claude');
+ expect(setupContent).toContain('command -v opencode');
expect(setupContent).toContain('command -v codex');
expect(setupContent).toContain('command -v kiro-cli');
});
diff --git a/test/platform-detect.test.ts b/test/platform-detect.test.ts
new file mode 100644
index 000000000..6a777f3a0
--- /dev/null
+++ b/test/platform-detect.test.ts
@@ -0,0 +1,21 @@
+import { describe, test, expect } from 'bun:test';
+import { spawnSync } from 'child_process';
+import * as path from 'path';
+
+const ROOT = path.resolve(import.meta.dir, '..');
+const DETECT = path.join(ROOT, 'bin', 'gstack-platform-detect');
+
+describe('gstack-platform-detect', () => {
+ test('syntax check passes', () => {
+ const result = spawnSync('bash', ['-n', DETECT], { stdio: 'pipe' });
+ expect(result.status).toBe(0);
+ });
+
+ test('lists OpenCode install path in script', () => {
+ const result = Bun.file(DETECT).text();
+ return result.then(content => {
+ expect(content).toContain('opencode:opencode');
+ expect(content).toContain('$HOME/.config/opencode/skills/gstack');
+ });
+ });
+});
diff --git a/test/uninstall.test.ts b/test/uninstall.test.ts
index a7208e877..b986b0205 100644
--- a/test/uninstall.test.ts
+++ b/test/uninstall.test.ts
@@ -44,6 +44,15 @@ describe('gstack-uninstall', () => {
// Create mock gstack install layout
fs.mkdirSync(path.join(mockHome, '.claude', 'skills', 'gstack'), { recursive: true });
fs.writeFileSync(path.join(mockHome, '.claude', 'skills', 'gstack', 'SKILL.md'), 'test');
+ const opencodeSourceRoot = path.join(tmpDir, 'opencode-source', '.opencode', 'skills');
+ fs.mkdirSync(path.join(opencodeSourceRoot, 'gstack'), { recursive: true });
+ fs.mkdirSync(path.join(opencodeSourceRoot, 'review'), { recursive: true });
+ fs.writeFileSync(path.join(opencodeSourceRoot, 'gstack', 'SKILL.md'), 'test');
+ fs.writeFileSync(path.join(opencodeSourceRoot, 'review', 'SKILL.md'), 'test');
+ fs.mkdirSync(path.join(mockHome, '.config', 'opencode', 'skills', 'gstack', 'review'), { recursive: true });
+ fs.symlinkSync(path.join(opencodeSourceRoot, 'gstack', 'SKILL.md'), path.join(mockHome, '.config', 'opencode', 'skills', 'gstack', 'SKILL.md'));
+ fs.symlinkSync(path.join(opencodeSourceRoot, 'review', 'SKILL.md'), path.join(mockHome, '.config', 'opencode', 'skills', 'gstack', 'review', 'SKILL.md'));
+ fs.symlinkSync(path.join(opencodeSourceRoot, 'review'), path.join(mockHome, '.config', 'opencode', 'skills', 'review'));
// Create per-skill symlinks (both old unprefixed and new prefixed)
fs.symlinkSync('gstack/review', path.join(mockHome, '.claude', 'skills', 'review'));
@@ -83,10 +92,12 @@ describe('gstack-uninstall', () => {
// Global skill dir should be removed
expect(fs.existsSync(path.join(mockHome, '.claude', 'skills', 'gstack'))).toBe(false);
+ expect(fs.existsSync(path.join(mockHome, '.config', 'opencode', 'skills', 'gstack'))).toBe(false);
// Per-skill symlinks pointing into gstack/ should be removed
expect(fs.existsSync(path.join(mockHome, '.claude', 'skills', 'review'))).toBe(false);
expect(fs.existsSync(path.join(mockHome, '.claude', 'skills', 'gstack-ship'))).toBe(false);
+ expect(fs.existsSync(path.join(mockHome, '.config', 'opencode', 'skills', 'review'))).toBe(false);
// Non-gstack tool should still exist
expect(fs.existsSync(path.join(mockHome, '.claude', 'skills', 'other-tool'))).toBe(true);
diff --git a/test/worktree.test.ts b/test/worktree.test.ts
index be1533ae7..58e70d252 100644
--- a/test/worktree.test.ts
+++ b/test/worktree.test.ts
@@ -23,10 +23,12 @@ function createTestRepo(): string {
// Create initial commit so HEAD exists
fs.writeFileSync(path.join(dir, 'README.md'), '# Test repo\n');
// Add .gitignore matching real repo (so copied build artifacts don't appear as changes)
- fs.writeFileSync(path.join(dir, '.gitignore'), '.agents/\nbrowse/dist/\n.gstack-worktrees/\n');
+ fs.writeFileSync(path.join(dir, '.gitignore'), '.agents/\n.opencode/\nbrowse/dist/\n.gstack-worktrees/\n');
// Create a .agents directory (simulating gitignored build artifacts)
fs.mkdirSync(path.join(dir, '.agents', 'skills'), { recursive: true });
fs.writeFileSync(path.join(dir, '.agents', 'skills', 'test-skill.md'), '# Test skill\n');
+ fs.mkdirSync(path.join(dir, '.opencode', 'skills', 'test-skill'), { recursive: true });
+ fs.writeFileSync(path.join(dir, '.opencode', 'skills', 'test-skill', 'SKILL.md'), '# Test skill\n');
// Create browse/dist (simulating build artifacts)
fs.mkdirSync(path.join(dir, 'browse', 'dist'), { recursive: true });
fs.writeFileSync(path.join(dir, 'browse', 'dist', 'browse'), '#!/bin/sh\necho browse\n');
@@ -76,7 +78,7 @@ describe('WorktreeManager', () => {
mgr.cleanup('test-1');
});
- test('create() worktree has .agents/skills/ (gitignored artifacts copied)', () => {
+ test('create() worktree has .agents/skills/ and .opencode/skills/ (gitignored artifacts copied)', () => {
const repo = createTestRepo();
repos.push(repo);
const mgr = new WorktreeManager(repo);
@@ -84,6 +86,7 @@ describe('WorktreeManager', () => {
const worktreePath = mgr.create('test-agents');
expect(fs.existsSync(path.join(worktreePath, '.agents', 'skills', 'test-skill.md'))).toBe(true);
+ expect(fs.existsSync(path.join(worktreePath, '.opencode', 'skills', 'test-skill', 'SKILL.md'))).toBe(true);
expect(fs.existsSync(path.join(worktreePath, 'browse', 'dist', 'browse'))).toBe(true);
mgr.cleanup('test-agents');