Skip to content
Draft
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
20 changes: 20 additions & 0 deletions .agents/plugins/marketplace.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
{
"name": "gstack-local",
"interface": {
"displayName": "gstack Local Plugins"
},
"plugins": [
{
"name": "gstack",
"source": {
"source": "local",
"path": "./plugins/gstack"
},
"policy": {
"installation": "AVAILABLE",
"authentication": "ON_INSTALL"
},
"category": "Coding"
}
]
}
5 changes: 4 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,10 @@ bin/gstack-global-discover
.claude/skills/
.claude/scheduled_tasks.lock
.claude/*.lock
.agents/
.agents/*
.agents/skills/
!.agents/plugins/
!.agents/plugins/**
.factory/
.kiro/
.opencode/
Expand Down
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,20 @@ Or target a specific agent with `./setup --host <name>`:
**Want to add support for another agent?** See [docs/ADDING_A_HOST.md](docs/ADDING_A_HOST.md).
It's one TypeScript config file, zero code changes.

### Codex plugin marketplace (repo-local)

If you want Codex to install gstack from this repository through a local plugin
marketplace instead of `~/.codex/skills`, run:

```bash
bun run plugin:codex:prepare
```

That command regenerates the Codex-formatted skills in `.agents/skills/`,
refreshes the repo-local runtime sidecar under `.agents/skills/gstack/`, and
keeps `plugins/gstack` pointed at the generated skill tree. Codex can then load
the plugin from `.agents/plugins/marketplace.json`.

## See it work

```
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
"eval:watch": "bun run scripts/eval-watch.ts",
"eval:select": "bun run scripts/eval-select.ts",
"analytics": "bun run scripts/analytics.ts",
"plugin:codex:prepare": "bun run scripts/prepare-codex-plugin.ts",
"test:audit": "bun test test/audit-compliance.test.ts",
"slop": "npx slop-scan scan . 2>/dev/null || echo 'slop-scan not available (install with: npm i -g slop-scan)'",
"slop:diff": "bun run scripts/slop-diff.ts"
Expand Down
43 changes: 43 additions & 0 deletions plugins/gstack/.codex-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
{
"name": "gstack",
"version": "1.21.1.0",
"description": "Garry's Stack \u2014 AI engineering workflows, QA, planning, and browser automation skills for Codex.",
"author": {
"name": "Garry Tan",
"email": "opensource@garrytan.com",
"url": "https://github.com/garrytan"
},
"homepage": "https://github.com/garrytan/gstack",
"repository": "https://github.com/garrytan/gstack",
"license": "MIT",
"keywords": [
"skills",
"codex",
"browser-automation",
"qa",
"planning",
"workflow"
],
"skills": "./skills/",
"interface": {
"displayName": "gstack",
"shortDescription": "AI engineering workflows for Codex",
"longDescription": "Use gstack to add planning, QA, browser automation, review, deployment, and product workflow skills to Codex from this repository.",
"developerName": "Garry Tan",
"category": "Coding",
"capabilities": [
"Interactive",
"Read",
"Write"
],
"websiteURL": "https://github.com/garrytan/gstack",
"privacyPolicyURL": "https://docs.github.com/en/site-policy/privacy-policies/github-general-privacy-statement",
"termsOfServiceURL": "https://docs.github.com/en/site-policy/github-terms/github-terms-of-service",
"defaultPrompt": [
"Run /gstack-qa against my local app and fix the bugs it finds.",
"Review this branch before I open a PR.",
"Help me plan a new feature before I start coding."
],
"brandColor": "#2563EB"
}
}
1 change: 1 addition & 0 deletions plugins/gstack/skills
80 changes: 80 additions & 0 deletions scripts/prepare-codex-plugin.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { spawnSync } from 'node:child_process';

const repoRoot = path.resolve(fileURLToPath(new URL('..', import.meta.url)));
const agentsSkillsRoot = path.join(repoRoot, '.agents', 'skills');
const runtimeRoot = path.join(agentsSkillsRoot, 'gstack');
const pluginSkillsPath = path.join(repoRoot, 'plugins', 'gstack', 'skills');

function run(command: string[], cwd = repoRoot): void {
const result = spawnSync(command[0], command.slice(1), {
cwd,
stdio: 'inherit',
});
if (result.status !== 0) {
process.exit(result.status ?? 1);
}
}

function ensureRelativeSymlink(targetPath: string, linkPath: string): void {
const relativeTarget = path.relative(path.dirname(linkPath), targetPath);
const existing = fs.existsSync(linkPath) || fs.lstatSync(linkPath, { throwIfNoEntry: false }) !== undefined;

if (existing) {
const stat = fs.lstatSync(linkPath);
if (stat.isSymbolicLink() && fs.readlinkSync(linkPath) === relativeTarget) {
return;
}
if (stat.isDirectory() && !stat.isSymbolicLink()) {
const entries = fs.readdirSync(linkPath);
if (entries.length > 0) {
throw new Error(`Refusing to replace non-empty directory: ${path.relative(repoRoot, linkPath)}`);
}
fs.rmdirSync(linkPath);
} else {
fs.rmSync(linkPath, { force: true, recursive: true });
}
}

fs.mkdirSync(path.dirname(linkPath), { recursive: true });
fs.symlinkSync(relativeTarget, linkPath);
}

function topLevelSkillDirs(): string[] {
return fs
.readdirSync(repoRoot, { withFileTypes: true })
.filter((entry) => entry.isDirectory())
.map((entry) => entry.name)
.filter((name) => !name.startsWith('.'))
.filter((name) => fs.existsSync(path.join(repoRoot, name, 'SKILL.md')))
.filter((name) => name !== 'plugins');
}

run(['bun', 'run', 'scripts/gen-skill-docs.ts', '--host', 'codex']);

fs.mkdirSync(runtimeRoot, { recursive: true });

const runtimeEntries = new Set<string>([
'bin',
'browse',
'design',
'extension',
'review',
'qa',
'make-pdf',
'ETHOS.md',
'VERSION',
...topLevelSkillDirs(),
]);

for (const entry of runtimeEntries) {
const source = path.join(repoRoot, entry);
if (!fs.existsSync(source)) continue;
const dest = path.join(runtimeRoot, entry);
if (entry === 'SKILL.md' || entry === 'agents') continue;
ensureRelativeSymlink(source, dest);
}

ensureRelativeSymlink(agentsSkillsRoot, pluginSkillsPath);
45 changes: 45 additions & 0 deletions test/codex-plugin-compat.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import fs from 'node:fs';
import path from 'node:path';

const ROOT = path.resolve(import.meta.dir, '..');

test('gstack plugin manifest points Codex at the generated skills bundle', () => {
const pluginJson = JSON.parse(
fs.readFileSync(path.join(ROOT, 'plugins', 'gstack', '.codex-plugin', 'plugin.json'), 'utf8'),
);

expect(pluginJson.name).toBe('gstack');
expect(pluginJson.skills).toBe('./skills/');
expect(pluginJson.interface.displayName).toBe('gstack');
expect(pluginJson.interface.category).toBe('Coding');
});

test('repo-local marketplace publishes the gstack plugin entry', () => {
const marketplace = JSON.parse(
fs.readFileSync(path.join(ROOT, '.agents', 'plugins', 'marketplace.json'), 'utf8'),
);

expect(marketplace.name).toBe('gstack-local');
expect(marketplace.plugins).toEqual([
{
name: 'gstack',
source: {
source: 'local',
path: './plugins/gstack',
},
policy: {
installation: 'AVAILABLE',
authentication: 'ON_INSTALL',
},
category: 'Coding',
},
]);
});

test('plugin skills directory resolves to the generated Codex skill tree', () => {
const skillsPath = path.join(ROOT, 'plugins', 'gstack', 'skills');
const stat = fs.lstatSync(skillsPath);

expect(stat.isSymbolicLink()).toBe(true);
expect(fs.readlinkSync(skillsPath)).toBe('../../.agents/skills');
});