mirror of
https://github.com/garrytan/gstack.git
synced 2026-05-02 03:35:09 +02:00
feat: standalone methodology skill publishing via gstack-publish
Ships the marketplace-distribution half of Item 5 (reframed): publish
the existing standalone OpenClaw methodology skills to multiple
marketplaces with one command.
Codex review caught that the original plan assumed raw generated
multi-host skills could be published directly. They can't — those
depend on gstack binaries, generated host paths, tool names, and
telemetry. The correct artifact class is hand-crafted standalone
skills in openclaw/skills/gstack-openclaw-* (already exist and work
without gstack runtime). This commit adds the wrapper that publishes
them to ClawHub + SkillsMP + Vercel Skills.sh with per-marketplace
error isolation and dry-run validation.
Changes:
- skills.json: root manifest with 4 skills (office-hours, ceo-review,
investigate, retro) each pointing at its openclaw/skills source.
Each skill declares per-marketplace targets with a slug, a publish
flag, and a compatible-hosts list. Marketplace configs include CLI
name, login command, publish command template (with placeholder
substitution), docs URL, and auth_check command.
- bin/gstack-publish: new CLI. Subcommands:
gstack-publish Publish all skills
gstack-publish <slug> Publish one skill
gstack-publish --dry-run Validate + auth-check without publishing
gstack-publish --list List skills + marketplace targets
Features:
* Manifest validation (missing source files, missing slugs, empty
marketplace list all reported).
* Per-marketplace auth check before any publish attempt.
* Per-skill / per-marketplace error isolation: one failure doesn't
abort the batch.
* Idempotent — re-running with the same version is safe; markets
that reject duplicate versions report it as a failure for that
single target without affecting others.
* --dry-run walks the full pipeline but skips execSync; useful in
CI to validate manifest before bumping version.
Tested locally: clawhub auth detected, skillsmp/vercel CLIs not
installed (marked NOT READY and skipped cleanly in dry-run).
Follow-up work (tracked in TODOS.md later):
- Version-bump helper that reads openclaw/skills/*/SKILL.md frontmatter
and updates skills.json in lockstep.
- CI workflow that runs gstack-publish --dry-run on every PR and
gstack-publish on tags.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Executable
+264
@@ -0,0 +1,264 @@
|
||||
#!/usr/bin/env bun
|
||||
/**
|
||||
* gstack-publish — publish gstack standalone methodology skills to
|
||||
* configured marketplaces (ClawHub, SkillsMP, Vercel Skills.sh).
|
||||
*
|
||||
* Usage:
|
||||
* gstack-publish Publish all skills listed in skills.json
|
||||
* gstack-publish <slug> Publish a single skill by slug
|
||||
* gstack-publish --dry-run Validate manifest + resolve auth, don't publish
|
||||
* gstack-publish --list List skills + marketplace targets
|
||||
* gstack-publish --changelog "description"
|
||||
* Pass a changelog message (required by some CLIs)
|
||||
*
|
||||
* Per-skill error isolation: if publishing to one marketplace fails for one skill,
|
||||
* the batch continues. Aggregate failures are reported at the end with exit 1.
|
||||
*
|
||||
* Idempotent: publishing the same version twice is a no-op for marketplaces that
|
||||
* reject duplicate versions; for others, gstack-publish bumps the version check
|
||||
* via their auth_check command and reports if the version already exists.
|
||||
*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { execSync, spawnSync } from 'child_process';
|
||||
|
||||
const ROOT = path.resolve(import.meta.dir, '..');
|
||||
const MANIFEST_PATH = path.join(ROOT, 'skills.json');
|
||||
|
||||
interface SkillEntry {
|
||||
slug: string;
|
||||
source: string;
|
||||
name: string;
|
||||
version: string;
|
||||
category: string;
|
||||
description: string;
|
||||
marketplaces: Record<string, { slug: string; publish: boolean }>;
|
||||
standalone: boolean;
|
||||
compatible_hosts: string[];
|
||||
}
|
||||
|
||||
interface MarketplaceConfig {
|
||||
cli: string;
|
||||
login_cmd: string;
|
||||
publish_cmd_template: string;
|
||||
docs: string;
|
||||
auth_check: string;
|
||||
}
|
||||
|
||||
interface Manifest {
|
||||
version: string;
|
||||
description: string;
|
||||
skills: SkillEntry[];
|
||||
marketplaces: Record<string, MarketplaceConfig>;
|
||||
}
|
||||
|
||||
function arg(name: string, def?: string): string | undefined {
|
||||
const idx = process.argv.findIndex(a => a === name || a.startsWith(name + '='));
|
||||
if (idx < 0) return def;
|
||||
const eqIdx = process.argv[idx].indexOf('=');
|
||||
if (eqIdx >= 0) return process.argv[idx].slice(eqIdx + 1);
|
||||
return process.argv[idx + 1];
|
||||
}
|
||||
|
||||
function flag(name: string): boolean {
|
||||
return process.argv.includes(name);
|
||||
}
|
||||
|
||||
function loadManifest(): Manifest {
|
||||
if (!fs.existsSync(MANIFEST_PATH)) {
|
||||
console.error(`ERROR: ${MANIFEST_PATH} not found.`);
|
||||
process.exit(1);
|
||||
}
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(MANIFEST_PATH, 'utf-8'));
|
||||
} catch (err) {
|
||||
console.error(`ERROR: could not parse ${MANIFEST_PATH}:`, (err as Error).message);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
function validateSkill(skill: SkillEntry): string[] {
|
||||
const errs: string[] = [];
|
||||
if (!skill.slug) errs.push('missing slug');
|
||||
if (!skill.version) errs.push('missing version');
|
||||
if (!skill.source) errs.push('missing source');
|
||||
if (skill.source) {
|
||||
const fullPath = path.join(ROOT, skill.source);
|
||||
if (!fs.existsSync(fullPath)) errs.push(`source file missing: ${skill.source}`);
|
||||
}
|
||||
if (!Object.keys(skill.marketplaces || {}).length) errs.push('no marketplaces configured');
|
||||
return errs;
|
||||
}
|
||||
|
||||
function checkAuth(marketplace: string, cfg: MarketplaceConfig): { ok: boolean; reason?: string } {
|
||||
// Check binary exists
|
||||
const binCheck = spawnSync('sh', ['-c', `command -v ${cfg.cli}`], { timeout: 2000 });
|
||||
if (binCheck.status !== 0) {
|
||||
return { ok: false, reason: `${cfg.cli} CLI not on PATH. Docs: ${cfg.docs}` };
|
||||
}
|
||||
// Run auth check. Success = ok. Any non-zero = not authenticated.
|
||||
const authCheck = spawnSync('sh', ['-c', cfg.auth_check], { timeout: 5000 });
|
||||
if (authCheck.status !== 0) {
|
||||
return { ok: false, reason: `not authenticated. Run: ${cfg.login_cmd}` };
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
interface PublishResult {
|
||||
skill: string;
|
||||
marketplace: string;
|
||||
ok: boolean;
|
||||
reason?: string;
|
||||
cmd?: string;
|
||||
}
|
||||
|
||||
function buildCommand(template: string, subs: Record<string, string>): string {
|
||||
return template.replace(/\{(\w+)\}/g, (_, key) => subs[key] ?? `{${key}}`);
|
||||
}
|
||||
|
||||
function publishOne(skill: SkillEntry, marketplace: string, cfg: MarketplaceConfig, changelog: string, dryRun: boolean): PublishResult {
|
||||
const sourceDir = path.dirname(path.join(ROOT, skill.source));
|
||||
const target = skill.marketplaces[marketplace];
|
||||
if (!target || !target.publish) {
|
||||
return { skill: skill.slug, marketplace, ok: true, reason: 'not configured for this marketplace' };
|
||||
}
|
||||
const cmd = buildCommand(cfg.publish_cmd_template, {
|
||||
source_dir: sourceDir,
|
||||
slug: target.slug,
|
||||
name: skill.name,
|
||||
version: skill.version,
|
||||
changelog,
|
||||
});
|
||||
if (dryRun) {
|
||||
return { skill: skill.slug, marketplace, ok: true, reason: 'DRY-RUN (not executed)', cmd };
|
||||
}
|
||||
try {
|
||||
const out = execSync(cmd, { stdio: 'pipe', timeout: 120_000, encoding: 'utf-8' });
|
||||
return { skill: skill.slug, marketplace, ok: true, cmd, reason: out.slice(0, 200) };
|
||||
} catch (err: unknown) {
|
||||
const e = err as { stderr?: Buffer; message?: string };
|
||||
const reason = e.stderr?.toString()?.slice(0, 400) ?? e.message ?? 'unknown error';
|
||||
return { skill: skill.slug, marketplace, ok: false, cmd, reason };
|
||||
}
|
||||
}
|
||||
|
||||
function cmdList(m: Manifest): void {
|
||||
console.log(`gstack-publish manifest v${m.version}: ${m.skills.length} skills\n`);
|
||||
for (const s of m.skills) {
|
||||
console.log(` ${s.slug} v${s.version} (${s.category})`);
|
||||
console.log(` source: ${s.source}`);
|
||||
const targets = Object.entries(s.marketplaces).filter(([, v]) => v.publish).map(([k]) => k);
|
||||
console.log(` publish to: ${targets.join(', ') || '(none)'}`);
|
||||
console.log();
|
||||
}
|
||||
console.log('Configured marketplaces:');
|
||||
for (const [name, cfg] of Object.entries(m.marketplaces)) {
|
||||
console.log(` ${name}: ${cfg.cli} (docs: ${cfg.docs})`);
|
||||
}
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const manifest = loadManifest();
|
||||
|
||||
if (flag('--list')) {
|
||||
cmdList(manifest);
|
||||
return;
|
||||
}
|
||||
|
||||
const dryRun = flag('--dry-run');
|
||||
const changelog = arg('--changelog') ?? `gstack-publish ${new Date().toISOString().slice(0, 10)}`;
|
||||
const targetSlug = process.argv.slice(2).find(a => !a.startsWith('--'));
|
||||
|
||||
// 1. Validate manifest entries
|
||||
console.log(`== Validating manifest (${manifest.skills.length} skills) ==`);
|
||||
const validationErrors: Array<{ slug: string; errors: string[] }> = [];
|
||||
for (const s of manifest.skills) {
|
||||
const errs = validateSkill(s);
|
||||
if (errs.length) validationErrors.push({ slug: s.slug, errors: errs });
|
||||
}
|
||||
if (validationErrors.length) {
|
||||
console.error('Manifest validation failed:');
|
||||
for (const { slug, errors } of validationErrors) {
|
||||
console.error(` ${slug}: ${errors.join(', ')}`);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(' OK\n');
|
||||
|
||||
// 2. Filter skills (single slug or all)
|
||||
const skills = targetSlug
|
||||
? manifest.skills.filter(s => s.slug === targetSlug)
|
||||
: manifest.skills;
|
||||
if (!skills.length) {
|
||||
console.error(`ERROR: skill not found: ${targetSlug}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// 3. Check marketplace auth (only for marketplaces we'll actually use)
|
||||
const activeMarketplaces = new Set<string>();
|
||||
for (const s of skills) {
|
||||
for (const [name, cfg] of Object.entries(s.marketplaces)) {
|
||||
if (cfg.publish) activeMarketplaces.add(name);
|
||||
}
|
||||
}
|
||||
console.log(`== Checking auth for ${activeMarketplaces.size} marketplace(s) ==`);
|
||||
const authStatus: Record<string, { ok: boolean; reason?: string }> = {};
|
||||
for (const mp of activeMarketplaces) {
|
||||
const cfg = manifest.marketplaces[mp];
|
||||
if (!cfg) {
|
||||
authStatus[mp] = { ok: false, reason: `no config for marketplace ${mp}` };
|
||||
continue;
|
||||
}
|
||||
const res = checkAuth(mp, cfg);
|
||||
authStatus[mp] = res;
|
||||
console.log(` ${mp}: ${res.ok ? 'OK' : 'NOT READY — ' + res.reason}`);
|
||||
}
|
||||
console.log();
|
||||
|
||||
// 4. Publish
|
||||
const results: PublishResult[] = [];
|
||||
for (const skill of skills) {
|
||||
console.log(`== Publishing ${skill.slug} v${skill.version} ==`);
|
||||
for (const [mp, target] of Object.entries(skill.marketplaces)) {
|
||||
if (!target.publish) continue;
|
||||
if (!authStatus[mp]?.ok && !dryRun) {
|
||||
results.push({ skill: skill.slug, marketplace: mp, ok: false, reason: `auth not ready: ${authStatus[mp]?.reason}` });
|
||||
console.log(` ${mp}: SKIPPED (auth not ready)`);
|
||||
continue;
|
||||
}
|
||||
const cfg = manifest.marketplaces[mp];
|
||||
if (!cfg) {
|
||||
results.push({ skill: skill.slug, marketplace: mp, ok: false, reason: 'no marketplace config' });
|
||||
continue;
|
||||
}
|
||||
const r = publishOne(skill, mp, cfg, changelog, dryRun);
|
||||
results.push(r);
|
||||
const tag = r.ok ? (dryRun ? 'DRY-RUN' : 'OK') : 'FAIL';
|
||||
console.log(` ${mp}: ${tag}${r.reason ? ' — ' + r.reason.slice(0, 80) : ''}`);
|
||||
}
|
||||
console.log();
|
||||
}
|
||||
|
||||
// 5. Summary
|
||||
const successes = results.filter(r => r.ok).length;
|
||||
const failures = results.filter(r => !r.ok);
|
||||
console.log(`== Summary ==`);
|
||||
console.log(` Published: ${successes}`);
|
||||
console.log(` Failed: ${failures.length}`);
|
||||
if (failures.length) {
|
||||
console.log();
|
||||
for (const f of failures) {
|
||||
console.log(` ${f.skill} → ${f.marketplace}: ${f.reason}`);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
if (dryRun) {
|
||||
console.log(`\n(--dry-run — no actual publish happened)`);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch(err => {
|
||||
console.error('FATAL:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
+90
@@ -0,0 +1,90 @@
|
||||
{
|
||||
"$schema": "https://gstack.dev/schemas/skills-manifest.v1.json",
|
||||
"version": "1.0.0",
|
||||
"description": "Manifest for publishing gstack standalone methodology skills to marketplaces (ClawHub, Vercel Skills.sh, SkillsMP). These skills have NO gstack runtime dependencies — they work as pure Claude Code / OpenClaw skills.",
|
||||
"skills": [
|
||||
{
|
||||
"slug": "gstack-office-hours",
|
||||
"source": "openclaw/skills/gstack-openclaw-office-hours/SKILL.md",
|
||||
"name": "YC Office Hours",
|
||||
"version": "1.0.0",
|
||||
"category": "strategy",
|
||||
"description": "Product interrogation with six forcing questions. Two modes: startup diagnostic and builder brainstorm. Produces a design doc, not code.",
|
||||
"marketplaces": {
|
||||
"clawhub": { "slug": "gstack-office-hours", "publish": true },
|
||||
"skillsmp": { "slug": "gstack-office-hours", "publish": true },
|
||||
"vercel": { "slug": "gstack-office-hours", "publish": true }
|
||||
},
|
||||
"standalone": true,
|
||||
"compatible_hosts": ["claude-code", "openclaw", "cursor"]
|
||||
},
|
||||
{
|
||||
"slug": "gstack-ceo-review",
|
||||
"source": "openclaw/skills/gstack-openclaw-ceo-review/SKILL.md",
|
||||
"name": "CEO Plan Review",
|
||||
"version": "1.0.0",
|
||||
"category": "strategy",
|
||||
"description": "Founder-mode plan review. Rethink the problem, find the 10x product, challenge premises, expand scope when it creates a better product.",
|
||||
"marketplaces": {
|
||||
"clawhub": { "slug": "gstack-ceo-review", "publish": true },
|
||||
"skillsmp": { "slug": "gstack-ceo-review", "publish": true },
|
||||
"vercel": { "slug": "gstack-ceo-review", "publish": true }
|
||||
},
|
||||
"standalone": true,
|
||||
"compatible_hosts": ["claude-code", "openclaw", "cursor"]
|
||||
},
|
||||
{
|
||||
"slug": "gstack-investigate",
|
||||
"source": "openclaw/skills/gstack-openclaw-investigate/SKILL.md",
|
||||
"name": "Root Cause Investigation",
|
||||
"version": "1.0.0",
|
||||
"category": "debugging",
|
||||
"description": "Systematic debugging with root cause investigation. Four phases: investigate, analyze, hypothesize, implement. Iron Law: no fixes without root cause.",
|
||||
"marketplaces": {
|
||||
"clawhub": { "slug": "gstack-investigate", "publish": true },
|
||||
"skillsmp": { "slug": "gstack-investigate", "publish": true },
|
||||
"vercel": { "slug": "gstack-investigate", "publish": true }
|
||||
},
|
||||
"standalone": true,
|
||||
"compatible_hosts": ["claude-code", "openclaw", "cursor"]
|
||||
},
|
||||
{
|
||||
"slug": "gstack-retro",
|
||||
"source": "openclaw/skills/gstack-openclaw-retro/SKILL.md",
|
||||
"name": "Engineering Retrospective",
|
||||
"version": "1.0.0",
|
||||
"category": "process",
|
||||
"description": "Weekly engineering retrospective. Analyzes commit history and work patterns with per-person breakdown.",
|
||||
"marketplaces": {
|
||||
"clawhub": { "slug": "gstack-retro", "publish": true },
|
||||
"skillsmp": { "slug": "gstack-retro", "publish": true },
|
||||
"vercel": { "slug": "gstack-retro", "publish": true }
|
||||
},
|
||||
"standalone": true,
|
||||
"compatible_hosts": ["claude-code", "openclaw", "cursor"]
|
||||
}
|
||||
],
|
||||
"marketplaces": {
|
||||
"clawhub": {
|
||||
"cli": "clawhub",
|
||||
"login_cmd": "clawhub login",
|
||||
"publish_cmd_template": "clawhub publish {source_dir} --slug {slug} --name \"{name}\" --version {version} --changelog \"{changelog}\"",
|
||||
"docs": "https://clawhub.dev/docs/publishing",
|
||||
"auth_check": "clawhub whoami"
|
||||
},
|
||||
"skillsmp": {
|
||||
"cli": "skillsmp",
|
||||
"login_cmd": "skillsmp login",
|
||||
"publish_cmd_template": "skillsmp publish {source_dir} --name {slug} --version {version}",
|
||||
"docs": "https://skillsmp.com/docs/publish",
|
||||
"auth_check": "skillsmp whoami"
|
||||
},
|
||||
"vercel": {
|
||||
"cli": "skills",
|
||||
"login_cmd": "skills login",
|
||||
"publish_cmd_template": "skills publish {source_dir} --package {slug} --version {version}",
|
||||
"docs": "https://skills.sh/docs",
|
||||
"auth_check": "skills whoami"
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user