134 lines
10 KiB
TypeScript
134 lines
10 KiB
TypeScript
#!/usr/bin/env bun
|
|
// Generates every AI-tool adapter from the single sources: skills/*/SKILL.md, docs/agents-core.md, package.json.
|
|
// bun tools/gen-adapters.ts write the files
|
|
// bun tools/gen-adapters.ts --check exit 1 when a committed file is stale (CI)
|
|
// Outputs are committed so each tool works straight from a git checkout.
|
|
import { existsSync, readdirSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
|
import { dirname, join } from 'node:path';
|
|
|
|
export interface Skill { name: string; description: string }
|
|
|
|
const NAME_RE = /^[a-z0-9]+(-[a-z0-9]+)*$/;
|
|
|
|
/** Frontmatter of one SKILL.md, checked against the Agent Skills spec subset we use (name + description only). */
|
|
export function parseSkill(folder: string, text: string): Skill {
|
|
const m = /^---\n([\s\S]*?)\n---\n/.exec(text);
|
|
if (!m) throw new Error(`skills/${folder}/SKILL.md: no frontmatter`);
|
|
const fields: Record<string, string> = {};
|
|
for (const line of m[1]!.split('\n')) {
|
|
const kv = /^([A-Za-z-]+):\s*(.*)$/.exec(line);
|
|
if (!kv) throw new Error(`skills/${folder}/SKILL.md: frontmatter line "${line.slice(0, 40)}" is not "key: value" on one line`);
|
|
fields[kv[1]!] = kv[2]!.trim();
|
|
}
|
|
const extra = Object.keys(fields).filter(k => k !== 'name' && k !== 'description');
|
|
if (extra.length) throw new Error(`skills/${folder}/SKILL.md: only name + description allowed in frontmatter (found ${extra.join(', ')})`);
|
|
const { name = '', description = '' } = fields;
|
|
if (!NAME_RE.test(name) || name.length > 64) throw new Error(`skills/${folder}/SKILL.md: name "${name}" must be lowercase-hyphen, max 64`);
|
|
if (name !== folder) throw new Error(`skills/${folder}/SKILL.md: name "${name}" must equal the folder name`);
|
|
if (!description || description.length > 1024) throw new Error(`skills/${folder}/SKILL.md: description must be 1-1024 characters`);
|
|
return { name, description };
|
|
}
|
|
|
|
export function readSkills(root: string): Skill[] {
|
|
const dir = join(root, 'skills');
|
|
return readdirSync(dir).filter(n => existsSync(join(dir, n, 'SKILL.md'))).sort()
|
|
.map(n => parseSkill(n, readFileSync(join(dir, n, 'SKILL.md'), 'utf8')));
|
|
}
|
|
|
|
const json = (o: unknown) => JSON.stringify(o, null, 2) + '\n';
|
|
/** First sentence-ish part of a skill description for compact indexes. */
|
|
const short = (d: string) => d.split(' - ')[0]!.split('. Use when')[0]!.replace(/\.$/, '');
|
|
|
|
/** Every generated file: path -> content. Pure apart from reading the sources. */
|
|
/** The plugin's MCP start: the plugin's launcher, else storebound on PATH, else the npm package. */
|
|
export const MCP_LAUNCH = 'r="${CLAUDE_PLUGIN_ROOT:-}"; if [ -n "$r" ] && [ -x "$r/bin/storebound" ]; then exec "$r/bin/storebound" mcp; fi; '
|
|
+ 'if command -v storebound >/dev/null 2>&1; then exec storebound mcp; fi; exec npx -y storebound@latest mcp';
|
|
|
|
export function generate(root: string): Map<string, string> {
|
|
const pkg = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')) as { version: string; description: string; license: string; keywords: string[] };
|
|
const core = readFileSync(join(root, 'docs', 'agents-core.md'), 'utf8').trim();
|
|
const coreBody = core.replace(/^# .*\n+/, '');
|
|
const skills = readSkills(root);
|
|
const out = new Map<string, string>();
|
|
const homepage = 'https://git.crt.fyi/CRT/storebound';
|
|
const repository = 'https://git.crt.fyi/CRT/storebound.git';
|
|
const description = 'Take a mobile app or game from nothing to Submit for review on Google Play and the App Store: app records, listings in every language, in-app products and subscriptions with regional prices, every questionnaire (Data safety, App access and demo accounts, account deletion, IARC, target audience, ads, App Privacy, age rating, export compliance), privacy, icons, screenshots and previews, validate, TestFlight, release management, and after launch: crash vitals, review replies, price sheets, store analytics, Product Page Optimization. Agent Skills plus a Bun CLI and MCP server (storebound); you press Submit. CRTkafa\'s own toolchain, open-sourced.';
|
|
const keywords = ['google-play', 'app-store', 'app-store-connect', 'mobile-apps', 'mobile-games', 'iap', 'subscriptions', 'screenshots', 'app-icons', 'release', 'aso', 'mcp'];
|
|
|
|
// 1. Claude Code plugin + its MCP server + marketplace
|
|
out.set('.claude-plugin/plugin.json', json({ name: 'storebound', version: pkg.version, description, author: { name: 'CRTkafa' }, homepage, repository, license: pkg.license, keywords }));
|
|
// through sh, never a direct path: a plugin root that is gone (an old cache folder after an update, or the repo opened
|
|
// as a project where CLAUDE_PLUGIN_ROOT is unset) made the client's spawn fail with ENOENT (pitfall: MCP spawn ENOENT)
|
|
out.set('.mcp.json', json({ mcpServers: { storebound: { command: 'sh', args: ['-c', MCP_LAUNCH] } } }));
|
|
out.set('.claude-plugin/marketplace.json', json({ name: 'crtkafa', description: 'CRTkafa\'s open-sourced tools.', owner: { name: 'CRTkafa' },
|
|
plugins: [{ name: 'storebound', source: '.', description: 'Take a mobile app or game from nothing to Submit for review on both stores: skills for every store step and questionnaire plus the storebound CLI and MCP server.',
|
|
version: pkg.version, category: 'development', tags: ['mobile', 'google-play', 'app-store', 'release'] }] }));
|
|
|
|
// 2. OpenAI Codex plugin (skills; MCP via `storebound install --agent codex`)
|
|
out.set('.codex-plugin/plugin.json', json({ name: 'storebound', version: pkg.version, description, author: { name: 'CRTkafa' }, homepage, repository, license: pkg.license, keywords, skills: './skills/' }));
|
|
|
|
// 3. Gemini CLI extension
|
|
out.set('gemini-extension.json', json({ name: 'storebound', version: pkg.version, description, contextFileName: 'GEMINI.md',
|
|
mcpServers: { storebound: { command: '${extensionPath}${/}bin${/}storebound', args: ['mcp'] } } }));
|
|
|
|
const index = skills.map(s => `| \`${s.name}\` | ${short(s.description)} |`).join('\n');
|
|
const indexPaths = skills.map(s => `- \`skills/${s.name}/SKILL.md\`: ${short(s.description)}`).join('\n');
|
|
const generated = '<!-- generated by tools/gen-adapters.ts from docs/agents-core.md and skills/*/SKILL.md; do not edit -->';
|
|
const cli = [
|
|
'## Tools',
|
|
'',
|
|
'- CLI: `storebound <command>` (`storebound help`; outside the plugin: `npx storebound` on Node 20+ or `bunx storebound` on Bun); config `storebound.config.ts`, credentials in env or `.storebound/`.',
|
|
'- MCP: `storebound mcp` (stdio). MCP 2026-07-28 + 2025-11-25. Write tools default to `dry_run: true`; submit / release / cancel / delete / send are not exposed.',
|
|
'- Readiness: `storebound scan` (offline rejection risks in the project files), `storebound check` (offline), `storebound validate` (read-only, both stores).',
|
|
'- Every endpoint: `storebound api apple|play <operationId>` (skill store-api). GETs run; writes are dry unless the owner adds `--yes` in a terminal; MCP has the read-only `api_get`.',
|
|
].join('\n');
|
|
|
|
// 4. AGENTS.md (Codex, Cursor, Copilot, Windsurf, OpenCode, ...)
|
|
out.set('AGENTS.md', [generated, '', '# storebound', '',
|
|
'Toolchain for taking mobile apps and games from nothing to "Submit for review" on Google Play and the App Store.',
|
|
'Before a store task, open the matching skill',
|
|
'file below and follow it step by step.', '', coreBody.replace(/^/, ''), '', cli, '', '## Skills', '', indexPaths, '',
|
|
'## Working on storebound itself', '',
|
|
'- Bun + TypeScript strict, zero runtime dependencies. `bun run ci` must pass (adapters fresh, tests, typecheck, scrub).',
|
|
'- Inner loop: `bun run ci:quick` (typecheck, the tests related to your change against origin/main, scrub; seconds).',
|
|
' Before every push: the full `bun run ci` (every check and test, in parallel shards), and check its exit code.',
|
|
'- Skills are the single source: edit `skills/*/SKILL.md` or `docs/agents-core.md`, then `bun tools/gen-adapters.ts`.',
|
|
'- Every check, rule, template line, message and desktop view covers both stores (App Store and Google Play) and every',
|
|
' surface (CLI, MCP, desktop, skills); when a store has no equivalent field, say where the same thing goes there.',
|
|
'- Never commit credentials, account / team / issuer / key / app ids or personal data; placeholders use example.com.',
|
|
''].join('\n'));
|
|
|
|
// 5. GitHub Copilot repository instructions
|
|
out.set('.github/copilot-instructions.md', [generated, '', '# storebound for GitHub Copilot', '',
|
|
'Mobile app and game store releases (Google Play, App Store). Skills live in `skills/<name>/SKILL.md` (Agent Skills',
|
|
'format; `storebound install --agent copilot` copies them to `.github/skills/` of an app or game project).', '', coreBody, '', cli, '', '## Skills', '',
|
|
'| skill | for |', '|---|---|', index, ''].join('\n'));
|
|
|
|
// 6. Cursor project rule (agent-requested)
|
|
out.set('.cursor/rules/storebound.mdc', ['---',
|
|
'description: Shipping a mobile app or game to Google Play or the App Store with storebound (listings, IAPs, subscriptions, screenshots, releases, review, compliance)',
|
|
'alwaysApply: false', '---', '', generated, '', coreBody, '', cli, '', '## Skills', '', indexPaths, ''].join('\n'));
|
|
|
|
// 7. Gemini context file
|
|
out.set('GEMINI.md', [generated, '', '# storebound', '',
|
|
'This extension ships Agent Skills (skills/) and the storebound MCP server for mobile app and game store releases.', '',
|
|
coreBody, '', cli, '', '## Skills', '', '| skill | for |', '|---|---|', index, ''].join('\n'));
|
|
return out;
|
|
}
|
|
|
|
if (import.meta.main) {
|
|
const root = join(import.meta.dir, '..');
|
|
const check = process.argv.includes('--check');
|
|
const stale: string[] = [];
|
|
for (const [rel, text] of generate(root)) {
|
|
const p = join(root, rel);
|
|
const cur = existsSync(p) ? readFileSync(p, 'utf8') : null;
|
|
if (cur === text) continue;
|
|
if (check) { stale.push(rel); continue; }
|
|
mkdirSync(dirname(p), { recursive: true });
|
|
writeFileSync(p, text);
|
|
console.log(`wrote ${rel}`);
|
|
}
|
|
if (check && stale.length) { console.error(`stale adapters (run bun tools/gen-adapters.ts): ${stale.join(', ')}`); process.exit(1); }
|
|
if (check) console.log('adapters up to date');
|
|
}
|