From 61986a5e3bbfdd030383d5b1d8789329523ecee6 Mon Sep 17 00:00:00 2001 From: Herman Meerlo Date: Fri, 3 Jul 2026 09:17:57 +0200 Subject: [PATCH 1/3] Adds AI-powered app review command Introduces a new CLI command that runs an AI review of a Homey app against the App Store guidelines, helping developers catch issues before submission. Supports both new and update submission types, configurable AI providers (OpenAI and Anthropic), and outputs results as either human-readable terminal output or machine-readable JSON. Collects app manifest, images, and optional custom instructions to provide comprehensive feedback with severity-based findings and a final verdict. --- bin/cmds/app/review.mjs | 236 ++++++++++++++++++++++++++++++++++++++++ package-lock.json | 49 +++++++++ package.json | 1 + 3 files changed, 286 insertions(+) create mode 100644 bin/cmds/app/review.mjs diff --git a/bin/cmds/app/review.mjs b/bin/cmds/app/review.mjs new file mode 100644 index 00000000..fb891b8d --- /dev/null +++ b/bin/cmds/app/review.mjs @@ -0,0 +1,236 @@ +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, + appId: manifest.id, + 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..43022566 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", @@ -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", @@ -3552,6 +3582,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 +5477,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..7522ab85 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", From d3f436b0ffff64b24b45e0d9b6c0b39dd2cedfea Mon Sep 17 00:00:00 2001 From: Herman Meerlo Date: Mon, 6 Jul 2026 14:06:53 +0200 Subject: [PATCH 2/3] Removes redundant appId parameter from review call The appId is already accessible through the manifest object being passed, eliminating the need to pass it as a separate parameter. --- bin/cmds/app/review.mjs | 1 - 1 file changed, 1 deletion(-) diff --git a/bin/cmds/app/review.mjs b/bin/cmds/app/review.mjs index fb891b8d..d6814604 100644 --- a/bin/cmds/app/review.mjs +++ b/bin/cmds/app/review.mjs @@ -103,7 +103,6 @@ export const handler = async (yargs) => { const result = await reviewer.review({ appPath, manifest, - appId: manifest.id, brandColor: manifest.brandColor, submissionType: yargs.type, images, From 6e4d9e4aac91ec653f6a08abc74ab58cde5410a3 Mon Sep 17 00:00:00 2001 From: Herman Meerlo Date: Mon, 6 Jul 2026 14:49:14 +0200 Subject: [PATCH 3/3] Bumps homey-lib to ^2.51.1 Updates the homey-lib dependency to pull in the latest fixes and improvements from the upstream library. --- package-lock.json | 44 ++++++++++++++++++++++++++++++++++++++++---- package.json | 2 +- 2 files changed, 41 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index 43022566..9487bf39 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,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", @@ -3185,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": { @@ -3223,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", diff --git a/package.json b/package.json index 7522ab85..273ee17d 100644 --- a/package.json +++ b/package.json @@ -43,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",