Skip to content
Open
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
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

102 changes: 100 additions & 2 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,35 @@ import {
showUltraPlaywrightError,
showResults,
runInteractivePrompts,
promptLoginCredentials,
showLoginSuccess,
showLoginFailed,
} from './ui.js';
import { detectLoginPage, detectLoginPageWithPlaywright, performLogin, extractCookieHeader, StorageState } from './login.js';
import { loadPlaywright } from './playwright-loader.js';

const program = new Command();

// Ensure Ctrl+C kills the process even when Playwright browsers are running.
// Playwright child processes (Chromium) can keep the event loop alive and
// prevent a clean process.exit(). We use SIGKILL as a fallback.
process.on('SIGINT', () => {
process.stderr.write('\n Interrupted — shutting down...\n');
// Give 3s for graceful cleanup, then force-kill
const forceTimer = setTimeout(() => {
process.kill(process.pid, 'SIGKILL');
}, 3000);
forceTimer.unref();
process.exit(130);
});
process.on('SIGTERM', () => {
const forceTimer = setTimeout(() => {
process.kill(process.pid, 'SIGKILL');
}, 3000);
forceTimer.unref();
process.exit(143);
});

program
.name('skillui')
.description('Reverse-engineer design systems from any project. Pure static analysis — no AI, no API keys.')
Expand All @@ -36,6 +61,9 @@ program
.option('--format <format>', 'Output format: design-md | skill | both', 'both')
.option('--mode <mode>', 'Extraction mode: default | ultra', 'default')
.option('--screens <number>', 'Ultra mode: max pages to crawl (default: 5)', '5')
.option('--user <string>', 'Login username or email (for authenticated sites)')
.option('--pass <string>', 'Login password (for authenticated sites)')
.option('--login', 'Force login prompt (skip auto-detection)')
.action(async (opts: CLIOptions) => {
// Always show the logo on every command
await showLogo();
Expand Down Expand Up @@ -63,6 +91,7 @@ program
try {
let profile: DesignProfile;
let screenshotPath: string | null = null;
let storageState: StorageState | null = null;

const outputDir = path.resolve(opts.out);
fs.mkdirSync(outputDir, { recursive: true });
Expand Down Expand Up @@ -101,10 +130,76 @@ program
const skillDir = path.join(outputDir, `${safeName}-design`);
fs.mkdirSync(path.join(skillDir, 'screenshots'), { recursive: true });

// ── Login detection + auth (BEFORE spinner) ──────────────────
// Support SKILLUI_USER / SKILLUI_PASSWORD env vars (avoids exposing creds in ps)
const authUser = opts.user || process.env.SKILLUI_USER;
const authPass = opts.pass || process.env.SKILLUI_PASSWORD;
const preAuthCreds = authUser && authPass ? { username: authUser, password: authPass } : undefined;

if (opts.login) {
// --login flag: skip detection, prompt immediately
const creds = preAuthCreds || await promptLoginCredentials();
if (creds) {
const spLogin = startSpinner('Authenticating...');
storageState = await performLogin(opts.url!, creds);
if (storageState) {
succeedSpinner(spLogin, 'Login', 'authenticated successfully');
showLoginSuccess(opts.url!);
} else {
failSpinner(spLogin, 'Login', 'authentication failed — continuing without auth');
showLoginFailed();
}
}
} else {
// Auto-detect login page
let loginUrl: string | null = null;

const spDetect = startSpinner('Checking for login page...');
try {
// Pass 1: HTTP fetch
const checkRes = await fetch(opts.url!, {
headers: { 'User-Agent': 'Mozilla/5.0 (compatible; skillui/1.0)', 'Accept': 'text/html' },
redirect: 'follow',
signal: AbortSignal.timeout(10000),
});
if (checkRes.ok) {
const checkHtml = await checkRes.text();
if (detectLoginPage(checkHtml, checkRes.url)) {
loginUrl = checkRes.url;
}
}
} catch { /* pre-flight failed */ }

// Pass 2: Playwright-based SPA detection
if (!loginUrl && loadPlaywright()) {
loginUrl = await detectLoginPageWithPlaywright(opts.url!);
}

if (loginUrl) {
succeedSpinner(spDetect, 'Login detected', loginUrl);
// Prompt for credentials (no spinner running — input fields visible)
const creds = preAuthCreds || await promptLoginCredentials();
if (creds) {
const spLogin = startSpinner('Authenticating...');
storageState = await performLogin(loginUrl, creds);
if (storageState) {
succeedSpinner(spLogin, 'Login', 'authenticated successfully');
showLoginSuccess(opts.url!);
} else {
failSpinner(spLogin, 'Login', 'authentication failed — continuing without auth');
showLoginFailed();
}
}
} else {
succeedSpinner(spDetect, 'Login check', 'no login page detected');
}
}

// ── Extraction (with auth if available) ──────────────────────
const sp1 = startSpinner('Fetching HTML + CSS...');
let urlResult: Awaited<ReturnType<typeof runUrlMode>>;
try {
urlResult = await runUrlMode(opts.url!, opts.name, skillDir);
urlResult = await runUrlMode(opts.url!, opts.name, skillDir, storageState);
const { cssColorCount, cssFontCount, computedColorCount, hadPlaywright } = urlResult;
const detail = hadPlaywright
? `${cssColorCount} CSS colors · ${computedColorCount} computed · ${cssFontCount} fonts`
Expand All @@ -120,6 +215,7 @@ program
}
profile = urlResult.profile;
screenshotPath = urlResult.screenshotPath;
storageState = urlResult.storageState || storageState;
}

// ── Ultra mode (URL only) ──────────────────────────────────────
Expand All @@ -140,7 +236,9 @@ program
} else {
const spAnim = startSpinner('Capturing scroll journey + animations...');
try {
const ultraResult = await runUltraMode(opts.url!, profile, skillDir, { screens: ultraScreens });
const ultraResult = await runUltraMode(opts.url!, profile, skillDir, { screens: ultraScreens }, storageState, (step) => {
spAnim.text = step;
});
ultraAnimations = ultraResult.animations;
const kf = ultraAnimations.keyframes.length;
const sf = ultraAnimations.scrollFrames.length;
Expand Down
16 changes: 13 additions & 3 deletions src/extractors/tokens/computed.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import { RawTokens } from '../../types';
import { RawTokens, StorageState } from '../../types';
import { loadPlaywright } from '../../playwright-loader';

/**
* URL mode: Extract computed styles from live DOM using Playwright.
* Playwright is an optional peer dependency.
*/
export async function extractComputedTokens(url: string, maxPages = 5): Promise<RawTokens> {
export async function extractComputedTokens(url: string, maxPages = 5, storageState?: StorageState | null): Promise<RawTokens> {
const tokens: RawTokens = {
colors: [],
fonts: [],
Expand Down Expand Up @@ -36,6 +36,7 @@ export async function extractComputedTokens(url: string, maxPages = 5): Promise<
const context = await browser.newContext({
viewport: { width: 1440, height: 900 },
userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
...(storageState ? { storageState } : {}),
});

try {
Expand Down Expand Up @@ -239,7 +240,16 @@ export async function extractComputedTokens(url: string, maxPages = 5): Promise<
return '';
}
})
.filter(href => href.startsWith(origin) && !href.includes('#'));
.filter(href => {
if (!href.startsWith(origin)) return false;
// Allow hash routes (#/path) but skip same-page anchors
const hashIdx = href.indexOf('#');
if (hashIdx !== -1) {
const hash = href.slice(hashIdx);
if (!hash.startsWith('#/')) return false;
}
return true;
});
}, currentUrl);

for (const link of links.slice(0, 10)) {
Expand Down
26 changes: 16 additions & 10 deletions src/extractors/tokens/http-css.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ export interface HttpExtractionResult {
components: ComponentInfo[];
}

export async function extractHttpCSSTokens(url: string, maxPages = 3): Promise<HttpExtractionResult> {
export async function extractHttpCSSTokens(url: string, maxPages = 3, cookies?: string): Promise<HttpExtractionResult> {
const tokens: RawTokens = {
colors: [],
fonts: [],
Expand Down Expand Up @@ -53,7 +53,7 @@ export async function extractHttpCSSTokens(url: string, maxPages = 3): Promise<H
visited.add(currentUrl);

try {
const html = await fetchText(currentUrl);
const html = await fetchText(currentUrl, cookies);
if (!html) continue;
allHtml.push(html);

Expand Down Expand Up @@ -94,7 +94,7 @@ export async function extractHttpCSSTokens(url: string, maxPages = 3): Promise<H
if (fetchedCss.has(cssUrl)) continue;
fetchedCss.add(cssUrl);
try {
const cssContent = await fetchText(cssUrl);
const cssContent = await fetchText(cssUrl, cookies);
if (cssContent) {
allCssContent.push(cssContent);
parseCSS(cssContent, tokens, cssUrl);
Expand Down Expand Up @@ -823,11 +823,14 @@ function extractTransitionParts(value: string, tokens: RawTokens): void {

function extractPageLinks(html: string, baseUrl: string, origin: string): string[] {
const links: string[] = [];
const hrefMatches = html.matchAll(/<a[^>]+href\s*=\s*["']([^"'#]+)["']/gi);
const hrefMatches = html.matchAll(/<a[^>]+href\s*=\s*["']([^"']+)["']/gi);
for (const m of hrefMatches) {
try {
const resolved = new URL(m[1], baseUrl).href;
if (resolved.startsWith(origin) && !resolved.includes('#') && !links.includes(resolved)) {
// Allow hash routes (#/path) but skip same-page anchors
const hashIdx = resolved.indexOf('#');
const hasBlockingHash = hashIdx !== -1 && !resolved.slice(hashIdx).startsWith('#/');
if (resolved.startsWith(origin) && !hasBlockingHash && !links.includes(resolved)) {
links.push(resolved);
}
} catch {
Expand Down Expand Up @@ -1596,17 +1599,20 @@ function buildDarkModePairs(tokens: RawTokens): void {

// ── Utility Helpers ───────────────────────────────────────────────────

async function fetchText(url: string): Promise<string | null> {
async function fetchText(url: string, cookies?: string): Promise<string | null> {
try {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 15000);

const headers: Record<string, string> = {
'User-Agent': 'Mozilla/5.0 (compatible; skillui/1.0; +https://github.com/amaanbuilds/skillui)',
'Accept': 'text/html,text/css,application/xhtml+xml,*/*',
};
if (cookies) headers['Cookie'] = cookies;

const response = await fetch(url, {
signal: controller.signal,
headers: {
'User-Agent': 'Mozilla/5.0 (compatible; skillui/1.0; +https://github.com/amaanbuilds/skillui)',
'Accept': 'text/html,text/css,application/xhtml+xml,*/*',
},
headers,
redirect: 'follow',
});

Expand Down
10 changes: 9 additions & 1 deletion src/extractors/ultra/animations.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import * as fs from 'fs';
import * as path from 'path';
import { StorageState } from '../../types';
import { loadPlaywright } from '../../playwright-loader';
import {
FullAnimationResult,
Expand Down Expand Up @@ -31,7 +32,9 @@ import {
*/
export async function captureAnimations(
url: string,
skillDir: string
skillDir: string,
storageState?: StorageState | null,
onProgress?: (step: string) => void
): Promise<FullAnimationResult> {
const empty: FullAnimationResult = {
keyframes: [],
Expand All @@ -46,6 +49,7 @@ export async function captureAnimations(
lottieCount: 0,
};

const log = onProgress || (() => {});
const playwright = loadPlaywright();
if (!playwright) return empty;

Expand All @@ -58,6 +62,7 @@ export async function captureAnimations(
viewport: { width: 1440, height: 900 },
userAgent:
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
...(storageState ? { storageState } : {}),
});

const page = await context.newPage();
Expand All @@ -73,6 +78,7 @@ export async function captureAnimations(
await page.waitForTimeout(2000);

// ── Phase 1: Extract CSS keyframes from document.styleSheets ──────────
log('Animations — extracting keyframes + libraries...');
const keyframesRaw = await page.evaluate(() => {
const result: Array<{
name: string;
Expand Down Expand Up @@ -288,6 +294,7 @@ export async function captureAnimations(
const libraries: DetectedLibrary[] = librariesRaw as DetectedLibrary[];

// ── Phase 5: Video detection + first-frame capture ────────────────────
log('Animations — detecting videos + scroll patterns...');
const videosRaw = await page.evaluate(() => {
return Array.from(document.querySelectorAll('video')).map((v, i) => ({
index: i + 1,
Expand Down Expand Up @@ -474,6 +481,7 @@ export async function captureAnimations(
});

// ── Phase 9: Scroll Journey Screenshots ───────────────────────────────
log('Animations — capturing scroll journey screenshots...');
const scrollFrames: ScrollFrame[] = [];
const scrollPercents = [0, 17, 33, 50, 67, 83, 100];

Expand Down
Loading