init
This commit is contained in:
17
.opencode/skills/stitch/scripts/package.json
Normal file
17
.opencode/skills/stitch/scripts/package.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"name": "stitch-scripts",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"description": "ClaudeKit Stitch skill SDK wrapper scripts",
|
||||
"scripts": {
|
||||
"generate": "tsx stitch-generate.ts",
|
||||
"export": "tsx stitch-export.ts",
|
||||
"quota": "tsx stitch-quota.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@google/stitch-sdk": ">=0.0.3 <1.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"tsx": "^4.0.0"
|
||||
}
|
||||
}
|
||||
173
.opencode/skills/stitch/scripts/stitch-export.ts
Normal file
173
.opencode/skills/stitch/scripts/stitch-export.ts
Normal file
@@ -0,0 +1,173 @@
|
||||
/**
|
||||
* stitch-export.ts — Export Stitch designs as HTML, image, or DESIGN.md.
|
||||
*
|
||||
* Usage:
|
||||
* npx tsx stitch-export.ts <screen-id> [--project <id>] [--format html|image|all] [--output <dir>]
|
||||
*
|
||||
* Env: STITCH_API_KEY (required), STITCH_PROJECT_ID (optional default)
|
||||
*/
|
||||
|
||||
import { stitch } from "@google/stitch-sdk";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
|
||||
// -- Argument parsing --
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
|
||||
function getFlag(name: string): string | undefined {
|
||||
const idx = args.indexOf(`--${name}`);
|
||||
if (idx === -1 || idx + 1 >= args.length) return undefined;
|
||||
return args[idx + 1];
|
||||
}
|
||||
|
||||
// Extract positional args (skip flags and their values)
|
||||
function getPositionalArgs(): string[] {
|
||||
const positional: string[] = [];
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
if (args[i].startsWith("--")) {
|
||||
i++; // skip flag value
|
||||
} else {
|
||||
positional.push(args[i]);
|
||||
}
|
||||
}
|
||||
return positional;
|
||||
}
|
||||
|
||||
const screenId = getPositionalArgs()[0];
|
||||
const projectId =
|
||||
getFlag("project") || process.env.STITCH_PROJECT_ID || "claudekit-default";
|
||||
const format = getFlag("format") || "all";
|
||||
const outputDir = getFlag("output") || "./stitch-exports";
|
||||
|
||||
if (!screenId) {
|
||||
console.error("Usage: npx tsx stitch-export.ts <screen-id> [--project <id>] [--format html|image|all] [--output <dir>]");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!process.env.STITCH_API_KEY) {
|
||||
console.error("[X] STITCH_API_KEY not set. Get one at https://stitch.withgoogle.com/settings/api");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// -- Helpers --
|
||||
|
||||
async function downloadFile(url: string, dest: string): Promise<void> {
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) throw new Error(`Download failed: ${response.status} ${response.statusText}`);
|
||||
const buffer = Buffer.from(await response.arrayBuffer());
|
||||
fs.writeFileSync(dest, buffer);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a DESIGN.md from HTML content by extracting Tailwind classes,
|
||||
* color values, typography patterns, and component structure.
|
||||
*/
|
||||
function generateDesignMd(html: string): string {
|
||||
const lines: string[] = ["# Design System", "", "Auto-generated from Google Stitch export.", ""];
|
||||
|
||||
// Extract colors from Tailwind classes and inline styles
|
||||
const colorMatches = html.match(/(?:bg|text|border)-(?:\w+)-(\d+)/g) || [];
|
||||
const uniqueColors = [...new Set(colorMatches)].slice(0, 20);
|
||||
if (uniqueColors.length > 0) {
|
||||
lines.push("## Colors", "");
|
||||
uniqueColors.forEach((c) => lines.push(`- \`${c}\``));
|
||||
lines.push("");
|
||||
}
|
||||
|
||||
// Extract typography classes
|
||||
const textMatches = html.match(/(?:text-(?:xs|sm|base|lg|xl|2xl|3xl|4xl|5xl)|font-(?:thin|light|normal|medium|semibold|bold|extrabold))/g) || [];
|
||||
const uniqueText = [...new Set(textMatches)].slice(0, 15);
|
||||
if (uniqueText.length > 0) {
|
||||
lines.push("## Typography", "");
|
||||
uniqueText.forEach((t) => lines.push(`- \`${t}\``));
|
||||
lines.push("");
|
||||
}
|
||||
|
||||
// Extract spacing patterns
|
||||
const spacingMatches = html.match(/(?:p|m|gap|space)-(?:x|y)?-?\d+/g) || [];
|
||||
const uniqueSpacing = [...new Set(spacingMatches)].slice(0, 15);
|
||||
if (uniqueSpacing.length > 0) {
|
||||
lines.push("## Spacing", "");
|
||||
uniqueSpacing.forEach((s) => lines.push(`- \`${s}\``));
|
||||
lines.push("");
|
||||
}
|
||||
|
||||
// Extract component-level structure from HTML tags
|
||||
const componentMatches = html.match(/<(section|nav|header|footer|main|aside|form|button|input|table|dialog)[^>]*>/gi) || [];
|
||||
const uniqueComponents = [...new Set(componentMatches.map((c) => c.match(/<(\w+)/)?.[1] || ""))].filter(Boolean);
|
||||
if (uniqueComponents.length > 0) {
|
||||
lines.push("## Components", "");
|
||||
uniqueComponents.forEach((c) => lines.push(`- \`<${c}>\``));
|
||||
lines.push("");
|
||||
}
|
||||
|
||||
lines.push("## Notes", "", "- Generated by Google Stitch AI", "- Tailwind CSS utility classes used throughout", "- Review and customize colors/typography for brand alignment");
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
// -- Main --
|
||||
|
||||
async function main() {
|
||||
try {
|
||||
fs.mkdirSync(outputDir, { recursive: true });
|
||||
|
||||
console.error(`[i] Exporting screen ${screenId} from project ${projectId}`);
|
||||
// Resolve project handle — for default project, find by title first
|
||||
let project;
|
||||
if (projectId === "claudekit-default") {
|
||||
const projects = await stitch.projects();
|
||||
const found = projects.find(p => p.data?.title === "claudekit-default");
|
||||
project = found || stitch.project(projectId);
|
||||
} else {
|
||||
project = stitch.project(projectId);
|
||||
}
|
||||
const screen = await project.getScreen(screenId!);
|
||||
|
||||
const exported: Record<string, string> = {};
|
||||
|
||||
// Export HTML
|
||||
if (format === "html" || format === "all") {
|
||||
const htmlUrl = await screen.getHtml();
|
||||
const htmlPath = path.join(outputDir, "design.html");
|
||||
await downloadFile(htmlUrl, htmlPath);
|
||||
exported.html = htmlPath;
|
||||
console.error(`[OK] HTML exported: ${htmlPath}`);
|
||||
|
||||
// Generate DESIGN.md from HTML
|
||||
if (format === "all") {
|
||||
const htmlContent = fs.readFileSync(htmlPath, "utf-8");
|
||||
const designMd = generateDesignMd(htmlContent);
|
||||
const designPath = path.join(outputDir, "DESIGN.md");
|
||||
fs.writeFileSync(designPath, designMd);
|
||||
exported.designMd = designPath;
|
||||
console.error(`[OK] DESIGN.md generated: ${designPath}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Export image
|
||||
if (format === "image" || format === "all") {
|
||||
const imageUrl = await screen.getImage();
|
||||
const imagePath = path.join(outputDir, "design.png");
|
||||
await downloadFile(imageUrl, imagePath);
|
||||
exported.image = imagePath;
|
||||
console.error(`[OK] Image exported: ${imagePath}`);
|
||||
}
|
||||
|
||||
// Output result JSON to stdout
|
||||
console.log(JSON.stringify({ screenId, projectId, format, exported }, null, 2));
|
||||
} catch (error: unknown) {
|
||||
const err = error as { code?: string; message?: string };
|
||||
if (err.code === "NOT_FOUND") {
|
||||
console.error(`[X] Screen "${screenId}" not found in project "${projectId}".`);
|
||||
} else if (err.code === "AUTH_FAILED") {
|
||||
console.error("[X] Authentication failed. Check STITCH_API_KEY env var.");
|
||||
} else {
|
||||
console.error(`[X] Export error: ${err.message || error}`);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
182
.opencode/skills/stitch/scripts/stitch-generate.ts
Normal file
182
.opencode/skills/stitch/scripts/stitch-generate.ts
Normal file
@@ -0,0 +1,182 @@
|
||||
/**
|
||||
* stitch-generate.ts — Generate UI designs from text prompts via Google Stitch SDK.
|
||||
*
|
||||
* Usage:
|
||||
* npx tsx stitch-generate.ts "<prompt>" [--project <id>] [--device mobile|desktop|tablet] [--variants <count>]
|
||||
*
|
||||
* Env: STITCH_API_KEY (required), STITCH_PROJECT_ID (optional default)
|
||||
*/
|
||||
|
||||
import { stitch } from "@google/stitch-sdk";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import os from "os";
|
||||
|
||||
// -- Quota helpers (inline to avoid cross-script import issues) --
|
||||
|
||||
const QUOTA_DIR = path.join(os.homedir(), ".claudekit");
|
||||
const QUOTA_FILE = path.join(QUOTA_DIR, ".stitch-quota.json");
|
||||
// Stitch free tier: 400 daily credits. No API to fetch real usage.
|
||||
const DEFAULT_LIMIT = parseInt(process.env.STITCH_QUOTA_LIMIT || "400", 10);
|
||||
|
||||
interface QuotaState { date: string; count: number; limit: number; }
|
||||
|
||||
function todayUTC(): string { return new Date().toISOString().slice(0, 10); }
|
||||
|
||||
function loadQuota(): QuotaState {
|
||||
try {
|
||||
if (fs.existsSync(QUOTA_FILE)) {
|
||||
const data = JSON.parse(fs.readFileSync(QUOTA_FILE, "utf-8"));
|
||||
if (data.date !== todayUTC()) return { date: todayUTC(), count: 0, limit: data.limit || DEFAULT_LIMIT };
|
||||
return data;
|
||||
}
|
||||
} catch { /* corrupted — start fresh */ }
|
||||
return { date: todayUTC(), count: 0, limit: DEFAULT_LIMIT };
|
||||
}
|
||||
|
||||
function saveQuota(state: QuotaState): void {
|
||||
fs.mkdirSync(QUOTA_DIR, { recursive: true });
|
||||
fs.writeFileSync(QUOTA_FILE, JSON.stringify(state, null, 2));
|
||||
}
|
||||
|
||||
// -- Argument parsing (minimal, no deps) --
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
|
||||
function getFlag(name: string): string | undefined {
|
||||
const idx = args.indexOf(`--${name}`);
|
||||
if (idx === -1 || idx + 1 >= args.length) return undefined;
|
||||
return args[idx + 1];
|
||||
}
|
||||
|
||||
// Extract positional args (skip flags and their values)
|
||||
function getPositionalArgs(): string[] {
|
||||
const positional: string[] = [];
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
if (args[i].startsWith("--")) {
|
||||
i++; // skip flag value
|
||||
} else {
|
||||
positional.push(args[i]);
|
||||
}
|
||||
}
|
||||
return positional;
|
||||
}
|
||||
|
||||
const prompt = getPositionalArgs()[0];
|
||||
const projectId =
|
||||
getFlag("project") || process.env.STITCH_PROJECT_ID || "claudekit-default";
|
||||
// SDK expects uppercase device types: MOBILE, DESKTOP, TABLET, AGNOSTIC
|
||||
const deviceFlag = getFlag("device");
|
||||
const DEVICE_MAP: Record<string, "MOBILE" | "DESKTOP" | "TABLET"> = {
|
||||
mobile: "MOBILE", desktop: "DESKTOP", tablet: "TABLET",
|
||||
};
|
||||
const deviceType = deviceFlag
|
||||
? DEVICE_MAP[deviceFlag.toLowerCase()] || (deviceFlag.toUpperCase() as "MOBILE" | "DESKTOP" | "TABLET")
|
||||
: undefined;
|
||||
const variantCount = getFlag("variants") ? parseInt(getFlag("variants")!, 10) : 0;
|
||||
|
||||
if (!prompt) {
|
||||
console.error("Usage: npx tsx stitch-generate.ts <prompt> [--project <id>] [--device mobile|desktop|tablet] [--variants <count>]");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!process.env.STITCH_API_KEY) {
|
||||
console.error("[X] STITCH_API_KEY not set. Get one at https://stitch.withgoogle.com/settings/api");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// -- Main --
|
||||
|
||||
async function main() {
|
||||
try {
|
||||
// Pre-check quota — 1 credit per generate, 1 per variant
|
||||
const creditsNeeded = 1 + variantCount;
|
||||
const quota = loadQuota();
|
||||
const remaining = quota.limit - quota.count;
|
||||
if (remaining < creditsNeeded) {
|
||||
console.error(`[X] Not enough credits: need ${creditsNeeded}, have ${remaining}/${quota.limit}.`);
|
||||
console.error("[i] Use ck:ui-ux-pro-max as fallback, or wait until midnight UTC.");
|
||||
process.exit(2);
|
||||
}
|
||||
console.error(`[i] Credits: ${remaining}/${quota.limit} remaining (this run costs ${creditsNeeded})`);
|
||||
console.error(`[i] Prompt: "${prompt}"`);
|
||||
|
||||
// Resolve project — use existing or create if "claudekit-default" doesn't exist
|
||||
const isDefaultProject = projectId === "claudekit-default";
|
||||
let resolvedProjectId = projectId;
|
||||
if (isDefaultProject) {
|
||||
const projects = await stitch.projects();
|
||||
const existing = projects.find(p => p.data?.title === "claudekit-default");
|
||||
if (existing) {
|
||||
resolvedProjectId = existing.id;
|
||||
console.error(`[i] Using project: ${resolvedProjectId}`);
|
||||
} else {
|
||||
console.error("[i] Creating default project 'claudekit-default'...");
|
||||
const created = await stitch.createProject("claudekit-default");
|
||||
resolvedProjectId = created.id;
|
||||
console.error(`[OK] Created project: ${resolvedProjectId}`);
|
||||
}
|
||||
} else {
|
||||
console.error(`[i] Using project: ${resolvedProjectId}`);
|
||||
}
|
||||
// Always use a fresh handle for generation
|
||||
const project = stitch.project(resolvedProjectId);
|
||||
|
||||
// SDK signature: generate(prompt, deviceType?, modelId?)
|
||||
const screen = await project.generate(prompt!, deviceType);
|
||||
|
||||
const imageUrl = await screen.getImage();
|
||||
|
||||
const result: Record<string, unknown> = {
|
||||
screenId: screen.id,
|
||||
projectId: resolvedProjectId,
|
||||
imageUrl,
|
||||
prompt,
|
||||
};
|
||||
|
||||
// Generate variants if requested
|
||||
if (variantCount > 0) {
|
||||
console.error(`[i] Generating ${variantCount} variant(s)...`);
|
||||
const variants = await screen.variants("Generate design variants", {
|
||||
variantCount,
|
||||
creativeRange: "medium",
|
||||
});
|
||||
|
||||
result.variants = await Promise.all(
|
||||
variants.map(async (v) => ({
|
||||
screenId: v.id,
|
||||
imageUrl: await v.getImage(),
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
// Auto-increment quota tracker
|
||||
const postQuota = loadQuota();
|
||||
postQuota.count += creditsNeeded;
|
||||
saveQuota(postQuota);
|
||||
const postRemaining = postQuota.limit - postQuota.count;
|
||||
console.error(`[OK] Quota updated: ${postQuota.count}/${postQuota.limit} used (${postRemaining} remaining)`);
|
||||
|
||||
result.creditsUsed = creditsNeeded;
|
||||
result.creditsRemaining = postRemaining;
|
||||
|
||||
// Output JSON to stdout (logs go to stderr)
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
} catch (error: unknown) {
|
||||
const err = error as { code?: string; message?: string };
|
||||
if (err.code === "RATE_LIMITED") {
|
||||
// Auto-sync local tracker — API is the source of truth
|
||||
const q = loadQuota();
|
||||
q.count = q.limit;
|
||||
saveQuota(q);
|
||||
console.error("[X] Daily quota exceeded (local tracker synced). Try tomorrow or use ck:ui-ux-pro-max.");
|
||||
} else if (err.code === "AUTH_FAILED") {
|
||||
console.error("[X] Authentication failed. Check STITCH_API_KEY env var.");
|
||||
} else {
|
||||
console.error(`[X] Stitch error: ${err.message || error}`);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
123
.opencode/skills/stitch/scripts/stitch-quota.ts
Normal file
123
.opencode/skills/stitch/scripts/stitch-quota.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* stitch-quota.ts — Local quota tracker for Google Stitch daily credits.
|
||||
*
|
||||
* Usage:
|
||||
* npx tsx stitch-quota.ts check # Show remaining credits
|
||||
* npx tsx stitch-quota.ts increment # Bump count after generation
|
||||
* npx tsx stitch-quota.ts reset # Force reset counter
|
||||
*
|
||||
* Tracks usage in ~/.claudekit/.stitch-quota.json.
|
||||
* Auto-resets when date changes (UTC midnight).
|
||||
*/
|
||||
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import os from "os";
|
||||
|
||||
// -- Config --
|
||||
|
||||
const QUOTA_DIR = path.join(os.homedir(), ".claudekit");
|
||||
const QUOTA_FILE = path.join(QUOTA_DIR, ".stitch-quota.json");
|
||||
// Stitch free tier: 400 daily credits (generate), 15 redesign credits (edit)
|
||||
// Source: stitch.withgoogle.com dashboard. No API to fetch real usage.
|
||||
const DEFAULT_LIMIT = parseInt(process.env.STITCH_QUOTA_LIMIT || "400", 10);
|
||||
const WARN_THRESHOLD = 0.2; // Warn when <20% remaining
|
||||
|
||||
interface QuotaState {
|
||||
date: string;
|
||||
count: number;
|
||||
limit: number;
|
||||
}
|
||||
|
||||
// -- Helpers --
|
||||
|
||||
function todayUTC(): string {
|
||||
return new Date().toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function loadQuota(): QuotaState {
|
||||
try {
|
||||
if (fs.existsSync(QUOTA_FILE)) {
|
||||
const data = JSON.parse(fs.readFileSync(QUOTA_FILE, "utf-8"));
|
||||
// Auto-reset if date changed
|
||||
if (data.date !== todayUTC()) {
|
||||
return { date: todayUTC(), count: 0, limit: data.limit || DEFAULT_LIMIT };
|
||||
}
|
||||
return data;
|
||||
}
|
||||
} catch {
|
||||
// Corrupted file — start fresh
|
||||
}
|
||||
return { date: todayUTC(), count: 0, limit: DEFAULT_LIMIT };
|
||||
}
|
||||
|
||||
function saveQuota(state: QuotaState): void {
|
||||
fs.mkdirSync(QUOTA_DIR, { recursive: true });
|
||||
fs.writeFileSync(QUOTA_FILE, JSON.stringify(state, null, 2));
|
||||
}
|
||||
|
||||
// -- Commands --
|
||||
|
||||
function check(): void {
|
||||
const state = loadQuota();
|
||||
saveQuota(state); // Persist auto-reset if date changed
|
||||
const remaining = state.limit - state.count;
|
||||
const pct = remaining / state.limit;
|
||||
|
||||
console.log(JSON.stringify({
|
||||
date: state.date,
|
||||
used: state.count,
|
||||
remaining,
|
||||
limit: state.limit,
|
||||
percentRemaining: Math.round(pct * 100),
|
||||
}, null, 2));
|
||||
|
||||
if (remaining <= 0) {
|
||||
console.error("[X] Daily quota exhausted. Use ck:ui-ux-pro-max as fallback.");
|
||||
process.exit(2);
|
||||
} else if (pct < WARN_THRESHOLD) {
|
||||
console.error(`[!] Low quota: ${remaining}/${state.limit} credits remaining (${Math.round(pct * 100)}%)`);
|
||||
} else {
|
||||
console.error(`[OK] ${remaining}/${state.limit} credits remaining`);
|
||||
}
|
||||
}
|
||||
|
||||
function increment(): void {
|
||||
const state = loadQuota();
|
||||
state.count += 1;
|
||||
saveQuota(state);
|
||||
|
||||
const remaining = state.limit - state.count;
|
||||
console.error(`[OK] Quota updated: ${state.count}/${state.limit} used (${remaining} remaining)`);
|
||||
|
||||
if (remaining <= 0) {
|
||||
console.error("[!] Daily quota now exhausted.");
|
||||
} else if (remaining / state.limit < WARN_THRESHOLD) {
|
||||
console.error(`[!] Low quota warning: ${remaining} credits remaining`);
|
||||
}
|
||||
}
|
||||
|
||||
function reset(): void {
|
||||
const state: QuotaState = { date: todayUTC(), count: 0, limit: DEFAULT_LIMIT };
|
||||
saveQuota(state);
|
||||
console.error(`[OK] Quota reset: 0/${state.limit} used`);
|
||||
}
|
||||
|
||||
// -- Main --
|
||||
|
||||
const command = process.argv[2];
|
||||
|
||||
switch (command) {
|
||||
case "check":
|
||||
check();
|
||||
break;
|
||||
case "increment":
|
||||
increment();
|
||||
break;
|
||||
case "reset":
|
||||
reset();
|
||||
break;
|
||||
default:
|
||||
console.error("Usage: npx tsx stitch-quota.ts <check|increment|reset>");
|
||||
process.exit(1);
|
||||
}
|
||||
Reference in New Issue
Block a user