Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ bin/gstack-global-discover
.claude/skills/
.agents/
.factory/
.opencode/
.context/
extension/.auth.json
.gstack-worktrees/
Expand Down
28 changes: 25 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,9 +59,9 @@ Real files get committed to your repo (not a submodule), so `git clone` just wor
> git clone https://github.com/garrytan/gstack.git ~/.claude/skills/gstack
> ```

### Codex, Gemini CLI, or Cursor
### Codex, OpenCode, Gemini CLI, or Cursor

gstack works on any agent that supports the [SKILL.md standard](https://github.com/anthropics/claude-code). Skills live in `.agents/skills/` and are discovered automatically.
gstack works on any agent that supports the [SKILL.md standard](https://github.com/anthropics/claude-code). Generated skills live in `.agents/skills/` or `.opencode/skills/` depending on host and are discovered automatically.

Install to one repo:

Expand All @@ -83,14 +83,36 @@ cd ~/gstack && ./setup --host codex
links the generated Codex skills at the top level. This avoids duplicate skill
discovery from the source repo checkout.

### OpenCode

OpenCode has first-class gstack install support.

Project-local install:

```bash
git clone --single-branch --depth 1 https://github.com/garrytan/gstack.git .opencode/skills/gstack
cd .opencode/skills/gstack && ./setup --host opencode
```

User-global install:

```bash
git clone --single-branch --depth 1 https://github.com/garrytan/gstack.git ~/gstack
cd ~/gstack && ./setup --host opencode
```

`setup --host opencode` installs generated skills into `.opencode/skills/` for
repo-local installs or `~/.config/opencode/skills/` for global installs, with the
runtime root at `.../skills/gstack`.

Or let setup auto-detect which agents you have installed:

```bash
git clone --single-branch --depth 1 https://github.com/garrytan/gstack.git ~/gstack
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.
For Codex-compatible hosts, setup now supports repo-local installs from `.agents/skills/gstack` plus first-class OpenCode installs from `.opencode/skills/gstack` or `~/.config/opencode/skills/gstack`. Hook-based safety skills (careful, freeze, guard) use inline safety advisory prose on non-Claude hosts.

### Factory Droid

Expand Down
22 changes: 18 additions & 4 deletions scripts/gen-skill-docs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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') 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, opencode, factory, droid, agents, or all.`);
})();

// For single-host mode, HOST is the host. For --host all, it's set per iteration below.
Expand Down Expand Up @@ -188,6 +189,18 @@ function transformFrontmatter(content: string, host: Host): string {
return `---\nname: ${name}\ndescription: |\n${indentedDesc}\n---` + body;
}

if (host === 'opencode') {
const MAX_DESC = 1024;
if (description.length > MAX_DESC) {
throw new Error(
`OpenCode description for "${name}" is ${description.length} chars (max ${MAX_DESC}). ` +
`Compress the description in the .tmpl file.`
);
}
const indentedDesc = description.split('\n').map(l => ` ${l}`).join('\n');
return `---\nname: ${name}\ndescription: |\n${indentedDesc}\ncompatibility: opencode\n---` + body;
}

if (host === 'factory') {
const sensitive = /^sensitive:\s*true/m.test(frontmatter);
const indentedDesc = description.split('\n').map(l => ` ${l}`).join('\n');
Expand Down Expand Up @@ -240,8 +253,9 @@ interface ExternalHostConfig {
}

const EXTERNAL_HOST_CONFIG: Record<string, ExternalHostConfig> = {
codex: { hostSubdir: '.agents', generateMetadata: true, descriptionLimit: 1024 },
factory: { hostSubdir: '.factory', generateMetadata: false },
codex: { hostSubdir: '.agents', generateMetadata: true, descriptionLimit: 1024 },
factory: { hostSubdir: '.factory', generateMetadata: false },
opencode: { hostSubdir: '.opencode', generateMetadata: false, descriptionLimit: 1024 },
};

// ─── Template Processing ────────────────────────────────────
Expand Down Expand Up @@ -395,7 +409,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 }[] = [];

Expand Down
2 changes: 1 addition & 1 deletion scripts/resolvers/preamble.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import type { TemplateContext } from './types';
*/

function generatePreambleBash(ctx: TemplateContext): string {
const hostConfigDir: Record<string, string> = { codex: '.codex', factory: '.factory' };
const hostConfigDir: Record<string, string> = { 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"
Expand Down
9 changes: 8 additions & 1 deletion scripts/resolvers/types.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
export type Host = 'claude' | 'codex' | 'factory';
export type Host = 'claude' | 'codex' | 'factory' | 'opencode';

export interface HostPaths {
skillRoot: string;
Expand Down Expand Up @@ -30,6 +30,13 @@ export const HOST_PATHS: Record<Host, HostPaths> = {
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 {
Expand Down
45 changes: 45 additions & 0 deletions scripts/skill-check.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
if (hasClaude) {
hasErrors = true;
console.log(` \u274c ${dir.padEnd(30)} — contains .claude/skills reference`);
} else {
console.log(` \u2705 ${dir.padEnd(30)} — OK`);
}
} else {
opencodeMissing++;
hasErrors = true;
console.log(` \u274c ${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):');
Expand Down Expand Up @@ -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(' \u2705 All OpenCode generated files are fresh');
} catch (err: any) {
hasErrors = true;
const output = err.stdout?.toString() || '';
console.log(' \u274c 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);
Loading
Loading