Skip to content
Merged
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
35 changes: 22 additions & 13 deletions src/cli/commands/analyze.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { existsSync, mkdirSync } from "node:fs";
import { writeFile } from "node:fs/promises";
import { resolve, dirname } from "node:path";
import type { CAC } from "cac";
import { z } from "zod";

import type { RuleConfig, RuleId } from "../../core/contracts/rule.js";
import { analyzeFile } from "../../core/engine/rule-engine.js";
Expand All @@ -10,25 +11,26 @@ import {
getFigmaToken, getReportsDir, ensureReportsDir,
} from "../../core/engine/config-store.js";
import { calculateScores, formatScoreSummary, buildResultJson } from "../../core/engine/scoring.js";
import { getConfigsWithPreset, RULE_CONFIGS, type Preset } from "../../core/rules/rule-config.js";
import { getConfigsWithPreset, RULE_CONFIGS } from "../../core/rules/rule-config.js";
import { ruleRegistry } from "../../core/rules/rule-registry.js";
import { loadCustomRules } from "../../core/rules/custom/custom-rule-loader.js";
import { loadConfigFile, mergeConfigs } from "../../core/rules/custom/config-loader.js";
import { generateHtmlReport } from "../../core/report-html/index.js";
import { trackEvent, trackError, EVENTS } from "../../core/monitoring/index.js";
import { pickRandomScope, countNodes, MAX_NODES_WITHOUT_SCOPE } from "../helpers.js";

interface AnalyzeOptions {
preset?: Preset;
output?: string;
token?: string;
api?: boolean;
screenshot?: boolean;
customRules?: string;
config?: string;
noOpen?: boolean;
json?: boolean;
}
const AnalyzeOptionsSchema = z.object({
preset: z.enum(["relaxed", "dev-friendly", "ai-ready", "strict"]).optional(),
output: z.string().optional(),
token: z.string().optional(),
api: z.boolean().optional(),
screenshot: z.boolean().optional(),
customRules: z.string().optional(),
config: z.string().optional(),
noOpen: z.boolean().optional(),
json: z.boolean().optional(),
});


export function registerAnalyze(cli: CAC): void {
cli
Expand All @@ -47,7 +49,14 @@ export function registerAnalyze(cli: CAC): void {
.example(" canicode analyze ./fixtures/my-design --output report.html")
.example(" canicode analyze ./fixtures/my-design --custom-rules ./my-rules.json")
.example(" canicode analyze ./fixtures/my-design --config ./my-config.json")
.action(async (input: string, options: AnalyzeOptions) => {
.action(async (input: string, rawOptions: Record<string, unknown>) => {
const parseResult = AnalyzeOptionsSchema.safeParse(rawOptions);
if (!parseResult.success) {
const msg = parseResult.error.issues.map(i => `--${i.path.join(".")}: ${i.message}`).join("\n");
console.error(`\nInvalid options:\n${msg}`);
process.exit(1);
}
const options = parseResult.data;
const analysisStart = Date.now();
trackEvent(EVENTS.ANALYSIS_STARTED, { source: isJsonFile(input) || isFixtureDir(input) ? "fixture" : "figma" });
// In --json mode, send progress messages to stderr so stdout contains only valid JSON
Expand Down
42 changes: 31 additions & 11 deletions src/cli/commands/implement.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { existsSync, mkdirSync } from "node:fs";
import { writeFile } from "node:fs/promises";
import { resolve, dirname } from "node:path";
import type { CAC } from "cac";
import { z } from "zod";

import { parseFigmaUrl } from "../../core/adapters/figma-url-parser.js";
import { analyzeFile } from "../../core/engine/rule-engine.js";
Expand All @@ -10,12 +11,13 @@ import { getFigmaToken } from "../../core/engine/config-store.js";
import { calculateScores, buildResultJson } from "../../core/engine/scoring.js";
import { collectVectorNodeIds, collectImageNodes, sanitizeFilename } from "../helpers.js";

interface ImplementOptions {
token?: string;
output?: string;
prompt?: string;
imageScale?: string;
}
const ImplementOptionsSchema = z.object({
token: z.string().optional(),
output: z.string().optional(),
prompt: z.string().optional(),
imageScale: z.string().optional(),
});


export function registerImplement(cli: CAC): void {
cli
Expand All @@ -29,8 +31,30 @@ export function registerImplement(cli: CAC): void {
.option("--image-scale <n>", "Image export scale: 2 for PC (default), 3 for mobile")
.example(" canicode implement ./fixtures/my-design")
.example(" canicode implement ./fixtures/my-design --prompt ./my-react-prompt.md --image-scale 3")
.action(async (input: string, options: ImplementOptions) => {
.action(async (input: string, rawOptions: Record<string, unknown>) => {
try {
const parseResult = ImplementOptionsSchema.safeParse(rawOptions);
if (!parseResult.success) {
const msg = parseResult.error.issues.map(i => `--${i.path.join(".")}: ${i.message}`).join("\n");
console.error(`\nInvalid options:\n${msg}`);
process.exit(1);
}
const options = parseResult.data;

// Validate --image-scale early
if (options.imageScale !== undefined) {
const scale = Number(options.imageScale);
if (!Number.isFinite(scale) || scale < 1 || scale > 4) {
console.error("Error: --image-scale must be 1-4 (2 for PC, 3 for mobile)");
process.exit(1);
}
}

// Warn for unscoped Figma URL
if (isFigmaUrl(input) && !parseFigmaUrl(input).nodeId) {
console.warn("Warning: No node-id in Figma URL. Implementation package will cover the entire file.");
console.warn("Tip: Add ?node-id=XXX to target a specific section.\n");
}

const outputDir = resolve(options.output ?? "canicode-implement");
mkdirSync(outputDir, { recursive: true });
Expand Down Expand Up @@ -87,10 +111,6 @@ export function registerImplement(cli: CAC): void {
const figmaToken = options.token ?? getFigmaToken();
if (figmaToken) {
const imgScale = options.imageScale !== undefined ? Number(options.imageScale) : 2;
if (!Number.isFinite(imgScale) || imgScale < 1 || imgScale > 4) {
console.error("Error: --image-scale must be 1-4 (2 for PC, 3 for mobile)");
process.exitCode = 1; return;
}

const { FigmaClient } = await import("../../core/adapters/figma-client.js");
const client = new FigmaClient({ token: figmaToken });
Expand Down
22 changes: 17 additions & 5 deletions src/cli/commands/init.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,33 @@
import type { CAC } from "cac";
import { z } from "zod";

import {
initAiready, getConfigPath, getReportsDir,
} from "../../core/engine/config-store.js";

interface InitOptions {
token?: string;
mcp?: boolean;
}
const InitOptionsSchema = z.object({
token: z.string().optional(),
mcp: z.boolean().optional(),
}).refine(
(opts) => !(opts.token && opts.mcp),
{ message: "--token and --mcp are mutually exclusive. Choose one." }
);

export function registerInit(cli: CAC): void {
cli
.command("init", "Set up canicode (Figma token or MCP)")
.option("--token <token>", "Save Figma API token to ~/.canicode/")
.option("--mcp", "Show Figma MCP setup instructions")
.action((options: InitOptions) => {
.action((rawOptions: Record<string, unknown>) => {
try {
const parseResult = InitOptionsSchema.safeParse(rawOptions);
if (!parseResult.success) {
const msg = parseResult.error.issues.map(i => i.message).join("\n");
console.error(`\nInvalid options:\n${msg}`);
process.exit(1);
}
const options = parseResult.data;

if (options.token) {
initAiready(options.token);

Expand Down
39 changes: 27 additions & 12 deletions src/cli/commands/save-fixture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,21 @@ import { mkdirSync } from "node:fs";
import { writeFile } from "node:fs/promises";
import { resolve } from "node:path";
import type { CAC } from "cac";
import { z } from "zod";

import { parseFigmaUrl } from "../../core/adapters/figma-url-parser.js";
import { loadFile, isFigmaUrl } from "../../core/engine/loader.js";
import { getFigmaToken } from "../../core/engine/config-store.js";
import { collectVectorNodeIds, collectImageNodes, sanitizeFilename, countNodes } from "../helpers.js";

interface SaveFixtureOptions {
output?: string;
api?: boolean;
token?: string;
imageScale?: string;
name?: string;
}
const SaveFixtureOptionsSchema = z.object({
output: z.string().optional(),
api: z.boolean().optional(),
token: z.string().optional(),
imageScale: z.string().optional(),
name: z.string().optional(),
});


export function registerSaveFixture(cli: CAC): void {
cli
Expand All @@ -28,12 +30,29 @@ export function registerSaveFixture(cli: CAC): void {
.option("--image-scale <n>", "Image export scale: 2 for PC (default), 3 for mobile")
.example(" canicode save-fixture https://www.figma.com/design/ABC123/MyDesign?node-id=1-234")
.example(" canicode save-fixture https://www.figma.com/design/ABC123/MyDesign?node-id=1-234 --image-scale 3")
.action(async (input: string, options: SaveFixtureOptions) => {
.action(async (input: string, rawOptions: Record<string, unknown>) => {
try {
const parseResult = SaveFixtureOptionsSchema.safeParse(rawOptions);
if (!parseResult.success) {
const msg = parseResult.error.issues.map(i => `--${i.path.join(".")}: ${i.message}`).join("\n");
console.error(`\nInvalid options:\n${msg}`);
process.exit(1);
}
const options = parseResult.data;

if (!isFigmaUrl(input)) {
throw new Error("save-fixture requires a Figma URL as input.");
}

// Validate --image-scale early (before any file I/O)
if (options.imageScale !== undefined) {
const scale = Number(options.imageScale);
if (!Number.isFinite(scale) || scale < 1 || scale > 4) {
console.error("Error: --image-scale must be 1-4 (2 for PC, 3 for mobile)");
process.exit(1);
}
}

if (!parseFigmaUrl(input).nodeId) {
console.warn("\nWarning: No node-id specified. Saving entire file as fixture.");
console.warn("Tip: Add ?node-id=XXX to save a specific section.\n");
Expand Down Expand Up @@ -123,10 +142,6 @@ export function registerSaveFixture(cli: CAC): void {
const imageNodes = collectImageNodes(file.document);
if (imageNodes.length > 0) {
const imgScale = options.imageScale !== undefined ? Number(options.imageScale) : 2;
if (!Number.isFinite(imgScale) || imgScale < 1 || imgScale > 4) {
console.error("Error: --image-scale must be 1-4 (2 for PC, 3 for mobile)");
process.exitCode = 1; return;
}

const imageDir = resolve(fixtureDir, "images");
mkdirSync(imageDir, { recursive: true });
Expand Down
35 changes: 26 additions & 9 deletions src/cli/commands/visual-compare.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,19 @@
import { resolve } from "node:path";
import type { CAC } from "cac";
import { z } from "zod";

import { parseFigmaUrl } from "../../core/adapters/figma-url-parser.js";
import { getFigmaToken } from "../../core/engine/config-store.js";

interface VisualCompareOptions {
figmaUrl: string;
token?: string;
output?: string;
width?: number;
height?: number;
figmaScale?: string;
}
const VisualCompareOptionsSchema = z.object({
figmaUrl: z.string().optional(),
token: z.string().optional(),
output: z.string().optional(),
width: z.union([z.string(), z.number()]).optional(),
height: z.union([z.string(), z.number()]).optional(),
figmaScale: z.string().optional(),
});


export function registerVisualCompare(cli: CAC): void {
cli
Expand All @@ -25,13 +28,27 @@ export function registerVisualCompare(cli: CAC): void {
.option("--height <px>", "Logical viewport height in CSS px (default: infer from Figma PNG ÷ export scale)")
.option("--figma-scale <n>", "Figma export scale (default: 2, matches save-fixture / @2x PNGs)")
.example(" canicode visual-compare ./generated/index.html --figma-url 'https://www.figma.com/design/ABC/File?node-id=1-234'")
.action(async (codePath: string, options: VisualCompareOptions) => {
.action(async (codePath: string, rawOptions: Record<string, unknown>) => {
try {
const parseResult = VisualCompareOptionsSchema.safeParse(rawOptions);
if (!parseResult.success) {
const msg = parseResult.error.issues.map(i => `--${i.path.join(".")}: ${i.message}`).join("\n");
console.error(`\nInvalid options:\n${msg}`);
process.exit(1);
}
const options = parseResult.data;

if (!options.figmaUrl) {
console.error("Error: --figma-url is required");
process.exitCode = 1; return;
}

// Warn if --figma-url has no node-id
if (!parseFigmaUrl(options.figmaUrl).nodeId) {
console.warn("Warning: --figma-url has no node-id. Results may be inaccurate for full files.");
console.warn("Tip: Add ?node-id=XXX to target a specific section.\n");
}

const token = options.token ?? getFigmaToken();
if (!token) {
console.error("Error: Figma token required. Use --token or set FIGMA_TOKEN env var.");
Expand Down
Loading