From c205b7d90c3da59a4d89a5efa1cd839552ddc356 Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Fri, 17 Apr 2026 06:18:28 +0800 Subject: [PATCH] feat: standalone methodology skill publishing via gstack-publish MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 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) --- bin/gstack-publish | 264 +++++++++++++++++++++++++++++++++++++++++++++ skills.json | 90 ++++++++++++++++ 2 files changed, 354 insertions(+) create mode 100755 bin/gstack-publish create mode 100644 skills.json diff --git a/bin/gstack-publish b/bin/gstack-publish new file mode 100755 index 00000000..0f040939 --- /dev/null +++ b/bin/gstack-publish @@ -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 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; + 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; +} + +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 { + 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 { + 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(); + 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 = {}; + 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); +}); diff --git a/skills.json b/skills.json new file mode 100644 index 00000000..eef3dc46 --- /dev/null +++ b/skills.json @@ -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" + } + } +}