diff --git a/bin/cmds/app/review.mjs b/bin/cmds/app/review.mjs new file mode 100644 index 00000000..d6814604 --- /dev/null +++ b/bin/cmds/app/review.mjs @@ -0,0 +1,235 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import colors from 'colors'; +import { AIReviewer } from 'homey-lib'; + +import Log from '../../../lib/Log.js'; + +const DEFAULT_MODEL = 'openai/gpt-5.4'; +const SUBMISSION_TYPES = ['new', 'update']; + +const PROVIDER_ENV = { + openai: 'OPENAI_API_KEY', + anthropic: 'ANTHROPIC_API_KEY', +}; + +const SEVERITY_STYLE = { + blocker: (t) => colors.red.bold(t), + warning: (t) => colors.yellow(t), + suggestion: (t) => colors.cyan(t), +}; + +const VERDICT_STYLE = { + approve: (t) => colors.green.bold(t), + request_changes: (t) => colors.yellow.bold(t), + reject: (t) => colors.red.bold(t), +}; + +export const desc = 'Run an AI review of the app against the Homey App Store guidelines'; + +export const builder = (yargs) => { + return yargs + .option('type', { + choices: SUBMISSION_TYPES, + default: 'new', + description: + 'Submission type — "new" for first submission, "update" if the app is already live.', + }) + .option('model', { + default: DEFAULT_MODEL, + type: 'string', + description: `Model in "/" form. Default: ${DEFAULT_MODEL}. Any other model prints a warning.`, + }) + .option('json', { + type: 'boolean', + default: false, + description: 'Emit machine-readable JSON instead of pretty terminal output.', + }) + .option('verbose', { + alias: 'v', + type: 'boolean', + default: false, + description: 'Print token counts, timings, and other diagnostics.', + }); +}; + +export const handler = async (yargs) => { + try { + const appPath = path.resolve(yargs.path); + if (!fs.existsSync(path.join(appPath, 'app.json'))) { + throw new Error( + `No app.json found in ${appPath}. Run this from a Homey app directory or pass --path.`, + ); + } + + const manifest = JSON.parse(fs.readFileSync(path.join(appPath, 'app.json'), 'utf-8')); + + const { model } = yargs; + const slash = model.indexOf('/'); + if (slash < 0) throw new Error(`--model must be "/" (got "${model}")`); + const provider = model.slice(0, slash); + const envVar = PROVIDER_ENV[provider]; + if (!envVar) { + throw new Error( + `Unsupported provider "${provider}". Supported: ${Object.keys(PROVIDER_ENV).join(', ')}.`, + ); + } + if (!process.env[envVar]) { + throw new Error( + `${envVar} is not set. Create an API key and export it, e.g.:\n export ${envVar}="sk-…"\n homey app review`, + ); + } + + if (model !== DEFAULT_MODEL && !yargs.json) { + Log( + colors.yellow( + `⚠ Using "${model}" — Athom's official review uses "${DEFAULT_MODEL}". Results may differ.`, + ), + ); + } + + const customInstructions = readCustomInstructions(appPath); + const images = collectImages(appPath, manifest); + + if (!yargs.json) { + Log.info(`→ Reviewing ${manifest.id}@${manifest.version} (${yargs.type}) with ${model}`); + Log.info( + `→ ${images.length} images will be reviewed${customInstructions ? ', app-specific instructions loaded' : ''}`, + ); + } + + const reviewer = new AIReviewer({ modelString: model }); + const t0 = Date.now(); + const result = await reviewer.review({ + appPath, + manifest, + brandColor: manifest.brandColor, + submissionType: yargs.type, + images, + customInstructions, + }); + const duration = ((Date.now() - t0) / 1000).toFixed(1); + + if (yargs.json) { + process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); + } else { + renderResult(result, { duration, verbose: yargs.verbose }); + } + + process.exit(result.verdict === 'reject' ? 1 : 0); + } catch (err) { + Log.error(err); + process.exit(1); + } +}; + +function readCustomInstructions(appPath) { + const file = path.join(appPath, '.homeyreview.md'); + if (!fs.existsSync(file)) return undefined; + const content = fs.readFileSync(file, 'utf-8').trim(); + return content || undefined; +} + +function collectImages(appPath, manifest) { + const images = []; + const add = (label, rel) => { + if (!rel) return; + const abs = path.resolve(appPath, rel.replace(/^\/+/, '')); + if (fs.existsSync(abs)) images.push({ label, source: abs }); + }; + + const mImages = manifest.images || {}; + add('app imageLarge (target 500×350)', mImages.large); + add('app imageSmall (target 250×175)', mImages.small); + add('app imageXLarge (target 1000×700, optional)', mImages.xlarge); + + if (Array.isArray(manifest.drivers)) { + for (const driver of manifest.drivers) { + if (driver && driver.images && driver.images.large) { + add(`driver "${driver.id}" imageLarge (target 500×500)`, driver.images.large); + } + } + } + + if (manifest.widgets && typeof manifest.widgets === 'object') { + for (const widgetId of Object.keys(manifest.widgets)) { + add(`widget "${widgetId}" preview-light`, `/widgets/${widgetId}/preview-light.png`); + add(`widget "${widgetId}" preview-dark`, `/widgets/${widgetId}/preview-dark.png`); + } + } + + return images; +} + +function renderResult(result, { duration, verbose }) { + const reviewFindings = result.findings.filter((f) => f.kind === 'review'); + const codeFindings = result.findings.filter((f) => f.kind === 'code'); + + Log(''); + Log(colors.bold('Review findings') + colors.grey(` (${reviewFindings.length})`)); + if (reviewFindings.length === 0) { + Log(colors.grey(' (none)')); + } else { + renderFindingsByCategory(reviewFindings); + } + + if (codeFindings.length > 0) { + Log(''); + Log(colors.bold('Code findings') + colors.grey(` (${codeFindings.length}, advisory)`)); + renderFindingsByCategory(codeFindings); + } + + Log(''); + const verdictLabel = VERDICT_STYLE[result.verdict] + ? VERDICT_STYLE[result.verdict](result.verdict.toUpperCase()) + : result.verdict; + Log(`Verdict: ${verdictLabel}`); + + if (verbose) { + const t = result.tokensUsed; + Log(''); + Log(colors.grey(`Model: ${result.model}`)); + Log(colors.grey(`Duration: ${duration}s`)); + Log( + colors.grey( + `Tokens: in=${t.input} out=${t.output} cacheRead=${t.cacheRead} cacheCreate=${t.cacheCreate}`, + ), + ); + } +} + +function renderFindingsByCategory(findings) { + const byCategory = new Map(); + for (const f of findings) { + if (!byCategory.has(f.category)) byCategory.set(f.category, []); + byCategory.get(f.category).push(f); + } + for (const [category, items] of byCategory) { + Log(''); + Log(colors.grey(` [${category}]`)); + for (const f of items) { + const style = SEVERITY_STYLE[f.severity] || ((t) => t); + Log(` ${style(f.severity.padEnd(10))} ${f.title}`); + if (f.explanation) + Log(colors.grey(` ${wrap(f.explanation, 78, ' ')}`)); + if (f.evidence) Log(colors.grey(` evidence: ${f.evidence}`)); + if (f.guidelineRef) Log(colors.grey(` ref: ${f.guidelineRef}`)); + } + } +} + +function wrap(text, width, indent) { + const words = String(text).split(/\s+/); + const lines = []; + let line = ''; + for (const w of words) { + if ((line + ' ' + w).trim().length > width) { + lines.push(line.trim()); + line = w; + } else { + line = (line + ' ' + w).trim(); + } + } + if (line) lines.push(line); + return lines.join(`\n${indent}`); +} diff --git a/package-lock.json b/package-lock.json index 8c09802b..9487bf39 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "4.3.1", "license": "ISC", "dependencies": { + "@anthropic-ai/sdk": "^0.91.0", "cli-table": "^0.3.11", "colors": "^1.1.2 <=1.4.0", "deepmerge": "^4.3.1", @@ -19,7 +20,7 @@ "fs-extra": "^10.1.0", "get-port": "^5.1.1", "homey-api": "^3.17.7", - "homey-lib": "^2.50.5", + "homey-lib": "^2.51.1", "ignore-walk": "^3.0.3", "inquirer": "8.1.2", "object-path": "^0.11.4", @@ -52,6 +53,35 @@ "node": ">=22" } }, + "node_modules/@anthropic-ai/sdk": { + "version": "0.91.1", + "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.91.1.tgz", + "integrity": "sha512-LAmu761tSN9r66ixvmciswUj/ZC+1Q4iAfpedTfSVLeswRwnY3n2Nb6Tsk+cLPP28aLOPWeMgIuTuCcMC6W/iw==", + "license": "MIT", + "dependencies": { + "json-schema-to-ts": "^3.1.1" + }, + "bin": { + "anthropic-ai-sdk": "bin/cli" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + }, + "peerDependenciesMeta": { + "zod": { + "optional": true + } + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@balena/dockerignore": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/@balena/dockerignore/-/dockerignore-1.0.2.tgz", @@ -3155,19 +3185,32 @@ } }, "node_modules/homey-lib": { - "version": "2.50.5", - "resolved": "https://registry.npmjs.org/homey-lib/-/homey-lib-2.50.5.tgz", - "integrity": "sha512-R0W9nQUG8xGV9npNv9M9uuUJ4x6vTSE2RVhUkR+WRIpTrqhH5XrXL618duZMohQIZUL6ROQJZsfhWClYqWWQtA==", + "version": "2.51.1", + "resolved": "https://registry.npmjs.org/homey-lib/-/homey-lib-2.51.1.tgz", + "integrity": "sha512-VZhsT/uHWUwd9rIziht/EJVHDzIRrlWwahdSITkPiZtuNBKwYWTgn0AvNGhYJGwzXLVRdB5+RPtD99z4GYdrDQ==", "license": "ISC", "dependencies": { "ajv": "^6.1.1", "buffer": "^6.0.3", + "debug": "4.4.3", "image-size": "^0.6.2", "semver": "^5.7.0", "tinycolor2": "^1.4.1" }, "engines": { "node": ">=12.13.0" + }, + "peerDependencies": { + "@anthropic-ai/sdk": ">=0.91.0", + "openai": ">=4.0.0" + }, + "peerDependenciesMeta": { + "@anthropic-ai/sdk": { + "optional": true + }, + "openai": { + "optional": true + } } }, "node_modules/homey-lib/node_modules/buffer": { @@ -3193,6 +3236,29 @@ "ieee754": "^1.2.1" } }, + "node_modules/homey-lib/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/homey-lib/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, "node_modules/homey-lib/node_modules/semver": { "version": "5.7.2", "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", @@ -3552,6 +3618,19 @@ "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.0.tgz", "integrity": "sha512-CuUqjv0FUZIdXkHPI8MezCnFCdaTAacej1TZYulLoAg1h/PhwkdXFN4V/gzY4g+fMBCOV2xF+rp7t2XD2ns/NQ==" }, + "node_modules/json-schema-to-ts": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz", + "integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.3", + "ts-algebra": "^2.0.0" + }, + "engines": { + "node": ">=16" + } + }, "node_modules/json-schema-traverse": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", @@ -5434,6 +5513,12 @@ "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==" }, + "node_modules/ts-algebra": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz", + "integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==", + "license": "MIT" + }, "node_modules/tweetnacl": { "version": "0.14.5", "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz", diff --git a/package.json b/package.json index 6648233e..273ee17d 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ "license": "ISC", "readme": "README.md", "dependencies": { + "@anthropic-ai/sdk": "^0.91.0", "cli-table": "^0.3.11", "colors": "^1.1.2 <=1.4.0", "deepmerge": "^4.3.1", @@ -42,7 +43,7 @@ "fs-extra": "^10.1.0", "get-port": "^5.1.1", "homey-api": "^3.17.7", - "homey-lib": "^2.50.5", + "homey-lib": "^2.51.1", "ignore-walk": "^3.0.3", "inquirer": "8.1.2", "object-path": "^0.11.4",