From 7b95ad6aa79e07a9c47d65fa33342c909b5fc41d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 26 Mar 2026 14:21:40 +0100 Subject: [PATCH 01/48] feat: rewrite cali as qa v2 --- bun.lockb | Bin 312664 -> 310656 bytes package.json | 3 - packages/cali/README.md | 59 +++- packages/cali/package.json | 10 +- packages/cali/rslib.config.ts | 4 +- packages/cali/src/cli.ts | 312 +++++++++----------- packages/cali/src/commands/qa.ts | 224 ++++++++++++++ packages/cali/src/config/load.ts | 151 ++++++++++ packages/cali/src/config/schema.ts | 34 +++ packages/cali/src/env/eas.ts | 74 +++++ packages/cali/src/env/json-file.ts | 78 +++++ packages/cali/src/env/local.ts | 54 ++++ packages/cali/src/env/types.ts | 70 +++++ packages/cali/src/prompt.ts | 118 -------- packages/cali/src/report/publishers/blob.ts | 58 ++++ packages/cali/src/report/publishers/file.ts | 23 ++ packages/cali/src/report/render.ts | 77 +++++ packages/cali/src/report/types.ts | 44 +++ packages/cali/src/roles/qa-mobile.ts | 234 +++++++++++++++ packages/cali/src/tools/agent-device.ts | 52 ++++ packages/cali/src/tools/skills.ts | 186 ++++++++++++ packages/cali/src/utils.ts | 174 +++++++---- packages/tools/package.json | 5 +- packages/tools/src/android.ts | 12 +- packages/tools/src/apple.ts | 14 +- packages/tools/src/fs.ts | 32 +- packages/tools/src/git.ts | 2 +- packages/tools/src/npm.ts | 4 +- packages/tools/src/react-native.ts | 6 +- patches/ai@4.0.3.patch | 55 ---- tsconfig.json | 1 + 31 files changed, 1728 insertions(+), 442 deletions(-) mode change 100755 => 100644 packages/cali/src/cli.ts create mode 100644 packages/cali/src/commands/qa.ts create mode 100644 packages/cali/src/config/load.ts create mode 100644 packages/cali/src/config/schema.ts create mode 100644 packages/cali/src/env/eas.ts create mode 100644 packages/cali/src/env/json-file.ts create mode 100644 packages/cali/src/env/local.ts create mode 100644 packages/cali/src/env/types.ts delete mode 100644 packages/cali/src/prompt.ts create mode 100644 packages/cali/src/report/publishers/blob.ts create mode 100644 packages/cali/src/report/publishers/file.ts create mode 100644 packages/cali/src/report/render.ts create mode 100644 packages/cali/src/report/types.ts create mode 100644 packages/cali/src/roles/qa-mobile.ts create mode 100644 packages/cali/src/tools/agent-device.ts create mode 100644 packages/cali/src/tools/skills.ts delete mode 100644 patches/ai@4.0.3.patch diff --git a/bun.lockb b/bun.lockb index 843bde3b66d0a5bb84dd24e4096136f7c07d886d..d9585bfa90713dd56be351ba639517f20251ddc2 100755 GIT binary patch delta 17047 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 delta 18581 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 { - console.error(chalk.red(error.message)) - console.log(chalk.gray(error.stack)) -}) - -console.log( - retro(` - ██████╗ █████╗ ██╗ ██╗ - ██╔════╝██╔══██╗██║ ██║ - ██║ ███████║██║ ██║ - ██║ ██╔══██║██║ ██║ - ╚██████╗██║ ██║███████╗██║ - ╚═════╝╚═╝ ╚═╝╚══════╝╚═╝ +import { runQaCommand } from './commands/qa.js' +import type { QaCliOptions } from './env/types.js' +import { normalizePlatform } from './utils.js' + +function printHelp() { + console.log(`cali v2 + +Usage: + cali qa [options] + +Options: + --preset Built-in preset: eas-mobile-pr, local-android, local-ios + --config Path to cali.config.ts + --prompt Add task-specific QA intent + --json Load normalized environment context from JSON + --platform android or ios + --artifact App artifact path (.apk, .aab, .app, .ipa) + --app-id Application identifier / package name + --device Simulator or emulator name to provision + --output-dir Output directory for artifacts + --build-id Build identifier + --workflow-url Workflow or build link + --pr-number Pull request number + --pr-title Pull request title + --pr-body Pull request body + --task-id Task identifier + --task-title Task title + --task-body Task body + --model Override the QA model + --help Show this help `) -) - -console.log( - chalk.gray(dedent` - AI agent for building React Native apps. - - Powered by: ${chalk.bold('Vercel AI SDK')} & ${chalk.bold('React Native CLI')} - `) -) - -console.log() - -const AI_MODEL = process.env.AI_MODEL || 'gpt-4o' - -const openai = createOpenAI({ - apiKey: await getApiKey('OpenAI', 'OPENAI_API_KEY'), -}) - -async function startSession(): Promise { - const question = await text({ - message: 'What do you want to do today?', - placeholder: 'e.g. "Build the app" or "See available simulators"', - validate: (value) => (value.length > 0 ? undefined : 'Please provide a valid answer.'), - }) +} - if (typeof question === 'symbol') { - outro(chalk.gray('Bye!')) - process.exit(0) +function readFlagValue(argv: string[], index: number) { + const value = argv[index + 1] + if (!value || value.startsWith('--')) { + throw new Error(`Missing value for ${argv[index]}`) } - return [ - { - role: 'system', - content: 'What do you want to do today?', - }, - { - role: 'user', - content: question, - }, - ] + return value } -let messages = await startSession() - -const s = spinner() - -// eslint-disable-next-line no-constant-condition -while (true) { - s.start(chalk.gray('Thinking...')) - - const response = await generateText({ - model: openai(AI_MODEL), - system: systemPrompt, - tools, - maxSteps: 10, - messages, - onStepStart(toolCalls) { - if (toolCalls.length > 0) { - const message = `Executing: ${chalk.gray(toolCalls.map((toolCall) => toolCall.toolName).join(', '))}` - - let spinner = s.message - for (const toolCall of toolCalls) { - /** - * Certain tools call external helpers outside of our control that pipe output to our stdout. - * In such case, we stop the spinner to avoid glitches and display the output instead. - */ - if ( - [ - 'buildAndroidApp', - 'launchAndroidAppOnDevice', - 'installNpmPackage', - 'uninstallNpmPackage', - ].includes(toolCall.toolName) - ) { - spinner = s.stop - break - } +function parseQaArgs(argv: string[]): QaCliOptions { + const options: QaCliOptions = {} + + for (let index = 0; index < argv.length; index += 1) { + const argument = argv[index] + + switch (argument) { + case '--preset': + options.presetName = readFlagValue(argv, index) as QaCliOptions['presetName'] + index += 1 + break + case '--config': + options.configPath = readFlagValue(argv, index) + index += 1 + break + case '--prompt': + options.prompt = readFlagValue(argv, index) + index += 1 + break + case '--json': + options.jsonPath = readFlagValue(argv, index) + index += 1 + break + case '--platform': { + const platform = normalizePlatform(readFlagValue(argv, index)) + if (!platform) { + throw new Error('`--platform` must be `android` or `ios`.') } - - spinner(message) + options.platform = platform + index += 1 + break } - }, - }) - - const toolCalls = response.steps.flatMap((step) => - step.toolCalls.map((toolCall) => toolCall.toolName) - ) - - if (toolCalls.length > 0) { - s.stop(`Tools called: ${chalk.gray(toolCalls.join(', '))}`) - } else { - s.stop(chalk.gray('Done.')) - } - - for (const step of response.steps) { - if (step.text.length > 0) { - messages.push({ role: 'assistant', content: step.text }) - } - if (step.toolCalls.length > 0) { - messages.push({ role: 'assistant', content: step.toolCalls }) - } - if (step.toolResults.length > 0) { - // tbd: fix this upstream. for some reason, the tool does not include the type, - // against the spec. - for (const toolResult of step.toolResults) { - if (!toolResult.type) { - toolResult.type = 'tool-result' - } - } - messages.push({ role: 'tool', content: step.toolResults }) + case '--artifact': + options.artifactPath = readFlagValue(argv, index) + index += 1 + break + case '--app-id': + options.appId = readFlagValue(argv, index) + index += 1 + break + case '--device': + options.deviceName = readFlagValue(argv, index) + index += 1 + break + case '--output-dir': + options.outputDir = readFlagValue(argv, index) + index += 1 + break + case '--build-id': + options.buildId = readFlagValue(argv, index) + index += 1 + break + case '--workflow-url': + options.workflowUrl = readFlagValue(argv, index) + index += 1 + break + case '--pr-number': + options.prNumber = Number(readFlagValue(argv, index)) + index += 1 + break + case '--pr-title': + options.prTitle = readFlagValue(argv, index) + index += 1 + break + case '--pr-body': + options.prBody = readFlagValue(argv, index) + index += 1 + break + case '--task-id': + options.taskId = readFlagValue(argv, index) + index += 1 + break + case '--task-title': + options.taskTitle = readFlagValue(argv, index) + index += 1 + break + case '--task-body': + options.taskBody = readFlagValue(argv, index) + index += 1 + break + case '--model': + options.model = readFlagValue(argv, index) + index += 1 + break + case '--help': + case '-h': + printHelp() + process.exit(0) + default: + throw new Error(`Unknown argument: ${argument}`) } } - // tbd: handle parsing errors - const data = MessageSchema.parse(JSON.parse(response.text)) + return options +} + +async function main() { + const [command, ...rest] = process.argv.slice(2) - const answer = await (() => { - switch (data.type) { - case 'select': - return select({ - message: data.content, - options: data.options.map((option) => ({ value: option, label: option })), - }) - case 'question': - return text({ - message: data.content, - validate: (value) => (value.length > 0 ? undefined : 'Please provide a valid answer.'), - }) - case 'end': - log.info(data.content) - return text({ - message: 'What do you want to do next?', - validate: (value) => (value.length > 0 ? undefined : 'Please provide a valid answer.'), - }) - } - })() + if (!command || command === '--help' || command === '-h') { + printHelp() + return + } - if (typeof answer !== 'string') { - messages = await startSession() - continue + if (command !== 'qa') { + throw new Error(`Unsupported command: ${command}`) } - messages.push({ - role: 'user', - content: answer as string, - }) + await runQaCommand(parseQaArgs(rest)) } + +main().catch((error) => { + const message = error instanceof Error ? error : new Error(String(error)) + console.error(message.message) + if (message.stack) { + console.error(message.stack) + } + process.exitCode = 1 +}) diff --git a/packages/cali/src/commands/qa.ts b/packages/cali/src/commands/qa.ts new file mode 100644 index 0000000..86256d3 --- /dev/null +++ b/packages/cali/src/commands/qa.ts @@ -0,0 +1,224 @@ +import 'dotenv/config' + +import { readdir, stat } from 'node:fs/promises' +import path from 'node:path' + +import { loadQaConfig } from '../config/load.js' +import { fromEasEnv } from '../env/eas.js' +import { fromJsonFile } from '../env/json-file.js' +import { fromLocalFlags } from '../env/local.js' +import type { QaCliOptions, QaRuntimeContext } from '../env/types.js' +import { publishBlobReport } from '../report/publishers/blob.js' +import { publishFileReport } from '../report/publishers/file.js' +import type { QaReport, QaReportInput, ScreenshotInfo } from '../report/types.js' +import { runQaMobileRole } from '../roles/qa-mobile.js' +import { + ensureDirectory, + humanizeScreenshotLabel, + normalizePlatform, + resolveFromCwd, + runCommand, +} from '../utils.js' + +async function resolveEnvironmentContext( + cwd: string, + cli: QaCliOptions +): Promise<{ config: Awaited>; context: QaRuntimeContext }> { + const config = await loadQaConfig({ + cwd, + configPath: cli.configPath, + presetName: cli.presetName, + model: cli.model, + }) + + if (cli.jsonPath || config.environmentAdapter === 'json-file') { + return { + config: { + ...config, + environmentAdapter: 'json-file', + }, + context: await fromJsonFile(cwd, config, cli), + } + } + + if (config.environmentAdapter === 'eas-env') { + return { + config, + context: await fromEasEnv(cwd, config, cli), + } + } + + return { + config, + context: await fromLocalFlags(cwd, config, cli), + } +} + +async function bootstrapApp(context: QaRuntimeContext) { + if (context.deviceName) { + if (context.platform === 'ios') { + await runCommand('agent-device', ['ensure-simulator', '--platform', 'ios', '--device', context.deviceName, '--boot']) + } else { + await runCommand('agent-device', ['boot', '--platform', 'android', '--device', context.deviceName]) + } + } + + if (context.platform === 'android') { + let installResult = await runCommand('agent-device', ['install', context.appId, context.artifactPath], { + allowFailure: true, + }) + + if (!installResult.ok) { + installResult = await runCommand('agent-device', ['reinstall', context.appId, context.artifactPath], { + allowFailure: true, + }) + } + + if (!installResult.ok) { + throw new Error( + `Deterministic Android bootstrap failed during install or reinstall.\n\n${installResult.stderr || installResult.stdout}` + ) + } + } else { + const reinstallResult = await runCommand('agent-device', ['reinstall', context.appId, context.artifactPath], { + allowFailure: true, + }) + + if (!reinstallResult.ok) { + throw new Error(`Deterministic iOS bootstrap failed during reinstall.\n\n${reinstallResult.stderr || reinstallResult.stdout}`) + } + } + + const openResult = await runCommand('agent-device', ['open', context.appId, '--relaunch'], { + allowFailure: true, + }) + + if (!openResult.ok) { + throw new Error(`Deterministic app bootstrap failed during open.\n\n${openResult.stderr || openResult.stdout}`) + } +} + +async function listScreenshots(screenshotsDir: string) { + let entries: string[] + + try { + entries = await readdir(screenshotsDir) + } catch { + return [] + } + + const screenshots: Array> = [] + for (const entry of entries) { + if (!entry.endsWith('.png')) { + continue + } + + const absolutePath = path.join(screenshotsDir, entry) + const fileStat = await stat(absolutePath) + screenshots.push({ + fileName: entry, + absolutePath, + bytes: fileStat.size, + }) + } + + return screenshots.sort((left, right) => left.fileName.localeCompare(right.fileName)) +} + +function composeReport( + model: string, + context: QaRuntimeContext, + reportInput: QaReportInput, + screenshots: Array>, + agentDeviceTrace: QaReport['agentDeviceTrace'] +): QaReport { + const screenshotLabelMap = new Map( + (reportInput.screenshotLabels ?? []) + .filter((item) => item.fileName && item.label) + .map((item) => [item.fileName, item.label.trim()]) + ) + + return { + generatedAt: new Date().toISOString(), + model, + context, + overallStatus: reportInput.overallStatus, + summary: reportInput.summary, + checked: reportInput.checked ?? [], + issues: reportInput.issues ?? [], + nextSteps: reportInput.nextSteps ?? [], + screenshotLabels: reportInput.screenshotLabels ?? [], + screenshots: screenshots.map((screenshot) => ({ + ...screenshot, + label: screenshotLabelMap.get(screenshot.fileName) ?? humanizeScreenshotLabel(screenshot.fileName), + })), + agentDeviceTrace: agentDeviceTrace.slice(-20), + } +} + +function createBlockedReport(summary: string): QaReportInput { + return { + overallStatus: 'blocked', + summary, + checked: ['Run a mobile QA pass'], + issues: [summary], + nextSteps: ['Inspect the bootstrap and runtime logs, then retry the QA run.'], + } +} + +async function publishReport(report: QaReport, publishers: string[]) { + let currentReport = report + + for (const publisher of publishers) { + if (publisher === 'blob') { + currentReport = await publishBlobReport({ report: currentReport }) + continue + } + + if (publisher === 'file') { + currentReport = await publishFileReport({ report: currentReport }) + } + } + + return currentReport +} + +export async function runQaCommand(cli: QaCliOptions) { + const cwd = process.cwd() + const { config, context } = await resolveEnvironmentContext(cwd, cli) + + if (!normalizePlatform(context.platform)) { + throw new Error(`Unsupported platform: ${context.platform}`) + } + + await ensureDirectory(context.outputDir) + await ensureDirectory(context.screenshotsDir) + + let reportInput: QaReportInput + let agentDeviceTrace: QaReport['agentDeviceTrace'] = [] + + try { + await bootstrapApp(context) + const roleResult = await runQaMobileRole({ + context, + modelId: config.model, + skillPaths: config.skillPaths, + enabledToolPacks: config.enabledToolPacks, + extraInstructions: config.extraInstructions, + prompt: cli.prompt, + }) + + reportInput = roleResult.reportInput + agentDeviceTrace = roleResult.agentDeviceTrace + } catch (unknownError) { + const error = unknownError instanceof Error ? unknownError : new Error(String(unknownError)) + reportInput = createBlockedReport(error.message) + } + + const screenshots = await listScreenshots(context.screenshotsDir) + const report = composeReport(config.model, context, reportInput, screenshots, agentDeviceTrace) + const publishedReport = await publishReport(report, config.outputPublishers) + + console.log(`QA report written to ${resolveFromCwd(cwd, path.join(context.outputDir, 'section.md'))}`) + console.log(`Overall status: ${publishedReport.overallStatus}`) +} diff --git a/packages/cali/src/config/load.ts b/packages/cali/src/config/load.ts new file mode 100644 index 0000000..c7853e6 --- /dev/null +++ b/packages/cali/src/config/load.ts @@ -0,0 +1,151 @@ +import { existsSync } from 'node:fs' +import path from 'node:path' + +import { createJiti } from 'jiti' + +import type { QaResolvedConfig } from '../env/types.js' +import { asArray, resolveFromCwd, uniqueStrings } from '../utils.js' +import type { CaliQaConfig, PublisherName, QaPresetName, ToolPackName } from './schema.js' +import { CaliQaConfigSchema } from './schema.js' + +type LoadQaConfigOptions = { + cwd: string + configPath?: string + presetName?: QaPresetName + model?: string +} + +function hasGatewayCredentials() { + return Boolean(process.env.AI_GATEWAY_API_KEY || process.env.AI_GATEWAY_KEY) +} + +function hasAnthropicCredentials() { + return Boolean( + process.env.ANTHROPIC_API_KEY || + process.env.ANTHROPIC_AUTH_TOKEN || + process.env.CLAUDE_API_KEY || + process.env.CLAUDE_AUTH_TOKEN + ) +} + +function getDefaultModel() { + if (process.env.QA_MODEL) { + return process.env.QA_MODEL + } + + if (!hasGatewayCredentials() && hasAnthropicCredentials()) { + return 'anthropic/claude-sonnet-4.6' + } + + return 'openai/gpt-5.4' +} + +function getBuiltInSkillPaths(cwd: string) { + return [path.join(cwd, 'node_modules', 'agent-device', 'skills')] +} + +function getPresetConfig(cwd: string, presetName: QaPresetName): CaliQaConfig { + const enabledToolPacks: ToolPackName[] = ['skills', 'agent-device'] + const outputPublishers: PublisherName[] = ['blob', 'file'] + const common = { + role: 'qa' as const, + skillPaths: getBuiltInSkillPaths(cwd), + enabledToolPacks, + outputPublishers, + } + + switch (presetName) { + case 'eas-mobile-pr': + return { + ...common, + preset: presetName, + environmentAdapter: 'eas-env', + extraInstructions: [ + 'Infer concise acceptance criteria from PR metadata and prioritize user-visible flows.', + 'Treat the repository as a black box and avoid source inspection unless the config explicitly says otherwise.', + ], + } + case 'local-ios': + return { + ...common, + preset: presetName, + environmentAdapter: 'local-flags', + platformDefaults: { + platform: 'ios', + }, + extraInstructions: [ + 'This is a local iOS QA run. Keep the flow lightweight and focus on the highest-signal UI paths.', + ], + } + case 'local-android': + default: + return { + ...common, + preset: presetName, + environmentAdapter: 'local-flags', + platformDefaults: { + platform: 'android', + }, + extraInstructions: [ + 'This is a local Android QA run. Keep the flow lightweight and focus on the highest-signal UI paths.', + ], + } + } +} + +function mergeConfig(base: CaliQaConfig, override: CaliQaConfig): CaliQaConfig { + return { + role: override.role ?? base.role ?? 'qa', + preset: override.preset ?? base.preset, + environmentAdapter: override.environmentAdapter ?? base.environmentAdapter, + appId: override.appId ?? base.appId, + platformDefaults: { + ...base.platformDefaults, + ...override.platformDefaults, + }, + outputDir: override.outputDir ?? base.outputDir, + skillPaths: uniqueStrings([...(base.skillPaths ?? []), ...(override.skillPaths ?? [])]), + enabledToolPacks: override.enabledToolPacks ?? base.enabledToolPacks, + outputPublishers: override.outputPublishers ?? base.outputPublishers, + extraInstructions: [...asArray(base.extraInstructions), ...asArray(override.extraInstructions)], + model: override.model ?? base.model, + } +} + +async function loadConfigFile(cwd: string, explicitPath?: string): Promise { + const defaultPath = path.join(cwd, 'cali.config.ts') + const configFilePath = explicitPath ? resolveFromCwd(cwd, explicitPath) : defaultPath + + if (!existsSync(configFilePath)) { + return {} + } + + const jiti = createJiti(import.meta.url, { moduleCache: false, fsCache: false }) + const loaded = await jiti.import(configFilePath) + const candidate = ((loaded as { default?: unknown })?.default ?? loaded) as unknown + + return CaliQaConfigSchema.parse(candidate) +} + +export async function loadQaConfig(options: LoadQaConfigOptions): Promise { + const { cwd, configPath, presetName: cliPresetName, model } = options + const fileConfig = await loadConfigFile(cwd, configPath) + const presetName = cliPresetName ?? fileConfig.preset ?? 'local-android' + const presetConfig = getPresetConfig(cwd, presetName) + const merged = mergeConfig(presetConfig, fileConfig) + + return { + role: 'qa', + presetName, + environmentAdapter: + merged.environmentAdapter ?? (presetName === 'eas-mobile-pr' ? 'eas-env' : 'local-flags'), + appId: merged.appId, + platformDefaults: merged.platformDefaults ?? {}, + outputDir: merged.outputDir, + skillPaths: uniqueStrings(merged.skillPaths ?? []), + enabledToolPacks: merged.enabledToolPacks ?? ['skills', 'agent-device'], + outputPublishers: merged.outputPublishers ?? ['blob', 'file'], + extraInstructions: asArray(merged.extraInstructions), + model: model ?? merged.model ?? getDefaultModel(), + } +} diff --git a/packages/cali/src/config/schema.ts b/packages/cali/src/config/schema.ts new file mode 100644 index 0000000..c6cef64 --- /dev/null +++ b/packages/cali/src/config/schema.ts @@ -0,0 +1,34 @@ +import { z } from 'zod' + +export const QaPresetNameSchema = z.enum(['eas-mobile-pr', 'local-android', 'local-ios']) +export const EnvironmentAdapterNameSchema = z.enum(['eas-env', 'local-flags', 'json-file']) +export const ToolPackNameSchema = z.enum(['skills', 'agent-device']) +export const PublisherNameSchema = z.enum(['file', 'blob']) +export const QaPlatformSchema = z.enum(['android', 'ios']) + +const StringArraySchema = z.union([z.string(), z.array(z.string())]).optional() + +export const CaliQaConfigSchema = z.object({ + role: z.literal('qa').optional(), + preset: QaPresetNameSchema.optional(), + environmentAdapter: EnvironmentAdapterNameSchema.optional(), + appId: z.string().optional(), + platformDefaults: z + .object({ + platform: QaPlatformSchema.optional(), + deviceName: z.string().optional(), + }) + .optional(), + outputDir: z.string().optional(), + skillPaths: z.array(z.string()).optional(), + enabledToolPacks: z.array(ToolPackNameSchema).optional(), + outputPublishers: z.array(PublisherNameSchema).optional(), + extraInstructions: StringArraySchema, + model: z.string().optional(), +}) + +export type QaPresetName = z.infer +export type EnvironmentAdapterName = z.infer +export type ToolPackName = z.infer +export type PublisherName = z.infer +export type CaliQaConfig = z.infer diff --git a/packages/cali/src/env/eas.ts b/packages/cali/src/env/eas.ts new file mode 100644 index 0000000..cf6a09c --- /dev/null +++ b/packages/cali/src/env/eas.ts @@ -0,0 +1,74 @@ +import path from 'node:path' + +import type { QaCliOptions, QaRuntimeContext } from './types.js' +import type { QaResolvedConfig } from './types.js' +import { normalizePlatform, parseJson, resolveFromCwd } from '../utils.js' + +type ParsedPr = { + number?: number + title?: string + body?: string | null + draft?: boolean + labels?: Array<{ name?: string }> +} + +export async function fromEasEnv( + cwd: string, + config: QaResolvedConfig, + cli: QaCliOptions +): Promise { + const parsedPr = parseJson(process.env.PR_JSON, {}) + const platform = + cli.platform ?? + normalizePlatform(process.env.QA_PLATFORM) ?? + config.platformDefaults.platform + + if (!platform) { + throw new Error('EAS adapter requires QA_PLATFORM or a preset platform default.') + } + + const outputDir = resolveFromCwd( + cwd, + cli.outputDir ?? process.env.QA_OUTPUT_DIR ?? config.outputDir ?? path.join('artifacts', 'qa') + ) + + const artifactPath = cli.artifactPath ?? process.env.APP_PATH + const appId = cli.appId ?? config.appId ?? process.env.APPLICATION_ID + + if (!artifactPath) { + throw new Error('EAS adapter requires APP_PATH or --artifact.') + } + + if (!appId) { + throw new Error('EAS adapter requires APPLICATION_ID or --app-id.') + } + + return { + platform, + artifactPath: resolveFromCwd(cwd, artifactPath), + appId, + buildId: cli.buildId ?? process.env.BUILD_ID ?? process.env.EAS_BUILD_ID ?? '', + workflowUrl: cli.workflowUrl ?? process.env.WORKFLOW_URL ?? process.env.EAS_BUILD_URL ?? '', + outputDir, + screenshotsDir: path.join(outputDir, 'screenshots'), + deviceName: + cli.deviceName ?? + process.env.DEVICE_NAME ?? + (platform === 'ios' + ? process.env.AGENT_DEVICE_IOS_DEVICE + : process.env.AGENT_DEVICE_ANDROID_DEVICE), + metadata: { + prNumber: cli.prNumber ?? parsedPr.number, + prTitle: cli.prTitle ?? parsedPr.title, + prBody: cli.prBody ?? parsedPr.body, + prLabels: Array.isArray(parsedPr.labels) + ? parsedPr.labels.map((label) => label.name).filter((value): value is string => Boolean(value)) + : [], + isDraft: Boolean(parsedPr.draft), + taskId: cli.taskId ?? process.env.TASK_ID, + taskTitle: cli.taskTitle, + taskBody: cli.taskBody, + }, + source: 'eas-env', + } +} diff --git a/packages/cali/src/env/json-file.ts b/packages/cali/src/env/json-file.ts new file mode 100644 index 0000000..8205840 --- /dev/null +++ b/packages/cali/src/env/json-file.ts @@ -0,0 +1,78 @@ +import { readFile } from 'node:fs/promises' +import path from 'node:path' + +import { z } from 'zod' + +import type { QaCliOptions, QaRuntimeContext } from './types.js' +import type { QaResolvedConfig } from './types.js' +import { resolveFromCwd } from '../utils.js' + +const JsonMetadataSchema = z + .object({ + prNumber: z.number().optional(), + prTitle: z.string().optional(), + prBody: z.string().nullable().optional(), + prLabels: z.array(z.string()).optional(), + isDraft: z.boolean().optional(), + taskId: z.string().optional(), + taskTitle: z.string().optional(), + taskBody: z.string().optional(), + }) + .optional() + +const JsonContextSchema = z.object({ + platform: z.enum(['android', 'ios']), + artifactPath: z.string(), + appId: z.string().optional(), + buildId: z.string().optional(), + workflowUrl: z.string().optional(), + outputDir: z.string().optional(), + deviceName: z.string().optional(), + metadata: JsonMetadataSchema, +}) + +export async function fromJsonFile( + cwd: string, + config: QaResolvedConfig, + cli: QaCliOptions +): Promise { + if (!cli.jsonPath) { + throw new Error('JSON adapter requires --json.') + } + + const absolutePath = resolveFromCwd(cwd, cli.jsonPath) + const content = await readFile(absolutePath, 'utf8') + const parsed = JsonContextSchema.parse(JSON.parse(content)) + + const outputDir = resolveFromCwd( + cwd, + cli.outputDir ?? parsed.outputDir ?? config.outputDir ?? path.join('artifacts', 'qa') + ) + const appId = cli.appId ?? config.appId ?? parsed.appId + + if (!appId) { + throw new Error('JSON adapter requires `appId` in the JSON file, config, or --app-id.') + } + + return { + platform: cli.platform ?? parsed.platform, + artifactPath: resolveFromCwd(cwd, cli.artifactPath ?? parsed.artifactPath), + appId, + buildId: cli.buildId ?? parsed.buildId ?? 'json-build', + workflowUrl: cli.workflowUrl ?? parsed.workflowUrl ?? '', + outputDir, + screenshotsDir: path.join(outputDir, 'screenshots'), + deviceName: cli.deviceName ?? parsed.deviceName ?? config.platformDefaults.deviceName, + metadata: { + prNumber: cli.prNumber ?? parsed.metadata?.prNumber, + prTitle: cli.prTitle ?? parsed.metadata?.prTitle, + prBody: cli.prBody ?? parsed.metadata?.prBody, + prLabels: parsed.metadata?.prLabels ?? [], + isDraft: parsed.metadata?.isDraft ?? false, + taskId: cli.taskId ?? parsed.metadata?.taskId, + taskTitle: cli.taskTitle ?? parsed.metadata?.taskTitle, + taskBody: cli.taskBody ?? parsed.metadata?.taskBody, + }, + source: 'json-file', + } +} diff --git a/packages/cali/src/env/local.ts b/packages/cali/src/env/local.ts new file mode 100644 index 0000000..f0e6b69 --- /dev/null +++ b/packages/cali/src/env/local.ts @@ -0,0 +1,54 @@ +import path from 'node:path' + +import type { QaCliOptions, QaRuntimeContext } from './types.js' +import type { QaResolvedConfig } from './types.js' +import { resolveFromCwd } from '../utils.js' + +export async function fromLocalFlags( + cwd: string, + config: QaResolvedConfig, + cli: QaCliOptions +): Promise { + const platform = cli.platform ?? config.platformDefaults.platform + const artifactPath = cli.artifactPath ?? process.env.APP_PATH + const appId = cli.appId ?? config.appId ?? process.env.APPLICATION_ID + + if (!platform) { + throw new Error('Local adapter requires --platform or a preset platform default.') + } + + if (!artifactPath) { + throw new Error('Local adapter requires --artifact.') + } + + if (!appId) { + throw new Error('Local adapter requires --app-id or config.appId.') + } + + const outputDir = resolveFromCwd( + cwd, + cli.outputDir ?? config.outputDir ?? path.join('artifacts', 'qa') + ) + + return { + platform, + artifactPath: resolveFromCwd(cwd, artifactPath), + appId, + buildId: cli.buildId ?? 'local-build', + workflowUrl: cli.workflowUrl ?? '', + outputDir, + screenshotsDir: path.join(outputDir, 'screenshots'), + deviceName: cli.deviceName ?? config.platformDefaults.deviceName, + metadata: { + prNumber: cli.prNumber, + prTitle: cli.prTitle, + prBody: cli.prBody, + prLabels: [], + isDraft: false, + taskId: cli.taskId, + taskTitle: cli.taskTitle, + taskBody: cli.taskBody, + }, + source: 'local-flags', + } +} diff --git a/packages/cali/src/env/types.ts b/packages/cali/src/env/types.ts new file mode 100644 index 0000000..cb13107 --- /dev/null +++ b/packages/cali/src/env/types.ts @@ -0,0 +1,70 @@ +import type { + EnvironmentAdapterName, + PublisherName, + QaPresetName, + ToolPackName, +} from '../config/schema.js' + +export type QaPlatform = 'android' | 'ios' + +export type QaMetadata = { + prNumber?: number + prTitle?: string + prBody?: string | null + prLabels: string[] + isDraft: boolean + taskId?: string + taskTitle?: string + taskBody?: string +} + +export type QaRuntimeContext = { + platform: QaPlatform + artifactPath: string + appId: string + buildId: string + workflowUrl: string + outputDir: string + screenshotsDir: string + deviceName?: string + metadata: QaMetadata + source: EnvironmentAdapterName +} + +export type QaCliOptions = { + presetName?: QaPresetName + configPath?: string + prompt?: string + jsonPath?: string + platform?: QaPlatform + artifactPath?: string + appId?: string + deviceName?: string + outputDir?: string + buildId?: string + workflowUrl?: string + prNumber?: number + prTitle?: string + prBody?: string + taskId?: string + taskTitle?: string + taskBody?: string + model?: string +} + +export type QaResolvedConfig = { + role: 'qa' + presetName?: QaPresetName + environmentAdapter: EnvironmentAdapterName + appId?: string + platformDefaults: { + platform?: QaPlatform + deviceName?: string + } + outputDir?: string + skillPaths: string[] + enabledToolPacks: ToolPackName[] + outputPublishers: PublisherName[] + extraInstructions: string[] + model: string +} diff --git a/packages/cali/src/prompt.ts b/packages/cali/src/prompt.ts deleted file mode 100644 index 16fe70c..0000000 --- a/packages/cali/src/prompt.ts +++ /dev/null @@ -1,118 +0,0 @@ -import dedent from 'dedent' - -export const systemPrompt = dedent` - ROLE: - You are a development assistant agent with access to various tools for React Native development. - Your purpose is to help developers be more productive by: - - Understanding and executing their natural language requests - - Using available tools effectively to accomplish tasks - - Helping with development, debugging, and maintenance activities - - TOOL PARAMETERS: - - If tools require parameters, ask the user to provide them explicitly. - - If you can get required parameters by running other tools beforehand, you must run the tools instead of asking. - - TOOL RETURN VALUES: - - If tool returns an array, always ask user to select one of the options. - - Never decide for the user. - - REACT NATIVE SPECIFIC: - - You do not know what platforms are available. You must run a tool to list available platforms. - - If user selects "Debug" mode, always start Metro bundler using "startMetro" tool. - - Never build or run for multiple platforms simultaneously. - - WORKFLOW RULES: - - Ask one clear and concise question at a time. - - If you need more information, ask a follow-up question. - - ERROR HANDLING: - - If a tool call returns an error, you must explain the error to the user and ask user if they want to try again: - { - "type": "select", - "content": "", - "options": ["Retry", "Cancel"] - } - - If you have tools to fix the error, ask user to select one of them: - { - "type": "select", - "content": "", - "options": ["", "", ""] - } - - If you do not have tools to fix the error, you must ask user to fix the error manually: - { - "type": "select", - "content": "", - "options": ["I fixed it", "Cancel"] - } - - If user confirms, you must re-run the same tool. - - RESPONSE FORMAT: - - Your response must be a valid JSON object. - - Your response must not contain any other text. - - Your response must start with { and end with }. - - RESPONSE TYPES: - - If user must select an option: - { - "type": "select", - "content": "", - "options": ["", "", ""] - } - - If user must provide an answer: - { - "type": "question", - "content": "" - } - - If user must confirm an action: - { - "type": "select", - "content": "", - "options": ["", ""] - } - - When you finish processing user task, you must answer with: - { - "type": "end", - "content": "" - } - - EXAMPLES: - - If user must select an option: - - - Here are some tasks you can perform: - 1. Option 1 - 2. Option 2 - - - { - "type": "select", - "content": "Here are some tasks you can perform:", - "options": ["Option 1", "Option 2"] - } - - - - If user must provide an answer: - - - Please provide X so I can do Y. - - - { - "type": "question", - "content": "Please provide X so I can do Y." - } - - - - If you can get required parameters by running other tools beforehand, you must run the tools instead of asking: - - - { - "type": "question", - "content": "Please provide adb path so I can run your app on Android." - } - - - Run "getAdbPath" tool and use its result. - - -` diff --git a/packages/cali/src/report/publishers/blob.ts b/packages/cali/src/report/publishers/blob.ts new file mode 100644 index 0000000..0d707c3 --- /dev/null +++ b/packages/cali/src/report/publishers/blob.ts @@ -0,0 +1,58 @@ +import { readFile } from 'node:fs/promises' + +import { put } from '@vercel/blob' + +import type { QaReport } from '../types.js' + +type BlobPublishOptions = { + report: QaReport +} + +export async function publishBlobReport({ report }: BlobPublishOptions): Promise { + const token = process.env.BLOB_READ_WRITE_TOKEN + if (!token || report.screenshots.length === 0) { + return report + } + + const screenshots = await Promise.all( + report.screenshots.map(async (screenshot) => { + try { + const fileBuffer = await readFile(screenshot.absolutePath) + const pathnameParts = [ + 'cali', + 'qa', + report.context.platform, + report.context.metadata.prNumber ? `pr-${report.context.metadata.prNumber}` : 'ad-hoc', + report.context.buildId || 'local-build', + screenshot.fileName, + ] + const blob = await put(pathnameParts.join('/'), fileBuffer, { + access: 'public', + addRandomSuffix: true, + contentType: 'image/png', + token, + }) + + return { + ...screenshot, + blobUrl: blob.url, + blobDownloadUrl: blob.downloadUrl, + blobPathname: blob.pathname, + } + } catch (unknownError) { + const error = + unknownError instanceof Error ? unknownError : new Error(String(unknownError)) + + return { + ...screenshot, + uploadError: error.message, + } + } + }) + ) + + return { + ...report, + screenshots, + } +} diff --git a/packages/cali/src/report/publishers/file.ts b/packages/cali/src/report/publishers/file.ts new file mode 100644 index 0000000..ec845be --- /dev/null +++ b/packages/cali/src/report/publishers/file.ts @@ -0,0 +1,23 @@ +import path from 'node:path' +import { writeFile } from 'node:fs/promises' + +import { ensureDirectory } from '../../utils.js' +import { renderQaSection } from '../render.js' +import type { QaReport } from '../types.js' + +type FilePublishOptions = { + report: QaReport +} + +export async function publishFileReport({ report }: FilePublishOptions): Promise { + await ensureDirectory(report.context.outputDir) + await writeFile( + path.join(report.context.outputDir, 'report.json'), + `${JSON.stringify(report, null, 2)}\n`, + 'utf8' + ) + await writeFile(path.join(report.context.outputDir, 'section.md'), renderQaSection(report), 'utf8') + await writeFile(path.join(report.context.outputDir, 'status.txt'), `${report.overallStatus}\n`, 'utf8') + + return report +} diff --git a/packages/cali/src/report/render.ts b/packages/cali/src/report/render.ts new file mode 100644 index 0000000..b29fe8c --- /dev/null +++ b/packages/cali/src/report/render.ts @@ -0,0 +1,77 @@ +import type { QaReport, ResultStatus } from './types.js' + +function getStatusLabel(status: ResultStatus) { + switch (status) { + case 'passed': + return 'passed' + case 'failed': + return 'failed' + case 'blocked': + return 'blocked' + case 'unsure': + return 'unsure' + case 'not_tested': + default: + return 'not_tested' + } +} + +export function renderQaSection(report: QaReport) { + const lines = [ + `### ${report.context.platform === 'ios' ? 'iOS' : 'Android'}`, + '', + `**Status:** ${getStatusLabel(report.overallStatus)}`, + '', + report.summary || 'No summary was provided.', + '', + '### Checked', + ] + + if (report.checked?.length) { + for (const item of report.checked) { + lines.push(`- ${item}`) + } + } else { + lines.push('- No checks were recorded.') + } + + lines.push('', '### Issues') + if (report.issues?.length) { + for (const issue of report.issues) { + lines.push(`- ${issue}`) + } + } else { + lines.push('- No issues noted.') + } + + lines.push('', '### Screenshots') + if (report.screenshots.length === 0) { + lines.push('- No screenshots were saved.') + } else { + for (const screenshot of report.screenshots) { + if (screenshot.blobUrl) { + lines.push(`- [${screenshot.label}](${screenshot.blobUrl})`) + } else { + lines.push(`- ${screenshot.label}: ${screenshot.fileName}`) + } + } + } + + lines.push('', '### Next steps') + if (report.nextSteps?.length) { + for (const step of report.nextSteps) { + lines.push(`- ${step}`) + } + } else { + lines.push('- No follow-up actions were suggested.') + } + + lines.push('', '### Metadata') + lines.push(`- Platform: \`${report.context.platform}\``) + lines.push(`- App ID: \`${report.context.appId}\``) + lines.push(`- Build ID: \`${report.context.buildId || 'n/a'}\``) + lines.push(`- Workflow: ${report.context.workflowUrl || 'n/a'}`) + lines.push('', '### JSON Report', '', '```json', JSON.stringify(report, null, 2), '```') + + return `${lines.join('\n')}\n` +} diff --git a/packages/cali/src/report/types.ts b/packages/cali/src/report/types.ts new file mode 100644 index 0000000..9803346 --- /dev/null +++ b/packages/cali/src/report/types.ts @@ -0,0 +1,44 @@ +import type { QaRuntimeContext } from '../env/types.js' + +export type ResultStatus = 'passed' | 'failed' | 'blocked' | 'not_tested' | 'unsure' + +export type ScreenshotLabel = { + fileName: string + label: string +} + +export type ScreenshotInfo = { + fileName: string + absolutePath: string + bytes: number + label: string + blobUrl?: string + blobDownloadUrl?: string + blobPathname?: string + uploadError?: string +} + +export type AgentDeviceTraceEntry = { + command: string + ok: boolean + exitCode: number + stdout: string + stderr: string +} + +export type QaReportInput = { + overallStatus: ResultStatus + summary: string + checked?: string[] + issues?: string[] + nextSteps?: string[] + screenshotLabels?: ScreenshotLabel[] +} + +export type QaReport = QaReportInput & { + generatedAt: string + model: string + context: QaRuntimeContext + screenshots: ScreenshotInfo[] + agentDeviceTrace: AgentDeviceTraceEntry[] +} diff --git a/packages/cali/src/roles/qa-mobile.ts b/packages/cali/src/roles/qa-mobile.ts new file mode 100644 index 0000000..d315bda --- /dev/null +++ b/packages/cali/src/roles/qa-mobile.ts @@ -0,0 +1,234 @@ +import { createGateway, ToolLoopAgent, gateway, stepCountIs, tool } from 'ai' +import { createAnthropic } from '@ai-sdk/anthropic' +import { z } from 'zod' + +import type { QaRuntimeContext } from '../env/types.js' +import type { AgentDeviceTraceEntry, QaReportInput } from '../report/types.js' +import { createAgentDeviceToolPack } from '../tools/agent-device.js' +import { + buildSkillsPrompt, + createSkillsToolPack, + discoverSkills, + type SkillMetadata, +} from '../tools/skills.js' + +type RunQaMobileRoleOptions = { + context: QaRuntimeContext + modelId: string + skillPaths: string[] + enabledToolPacks: string[] + extraInstructions: string[] + prompt?: string +} + +type QaMobileRoleResult = { + reportInput: QaReportInput + agentDeviceTrace: AgentDeviceTraceEntry[] + skills: SkillMetadata[] +} + +const EMPTY_INPUT_SCHEMA = z.object({}) +const WRITE_REPORT_INPUT_SCHEMA = z.object({ + overallStatus: z.enum(['passed', 'failed', 'blocked', 'not_tested', 'unsure']), + summary: z.string(), + checked: z.array(z.string()).optional(), + issues: z.array(z.string()).optional(), + nextSteps: z.array(z.string()).optional(), + screenshotLabels: z + .array( + z.object({ + fileName: z.string(), + label: z.string(), + }) + ) + .optional(), +}) + +function buildModel(modelId: string) { + const gatewayApiKey = process.env.AI_GATEWAY_API_KEY ?? process.env.AI_GATEWAY_KEY + const anthropicApiKey = process.env.ANTHROPIC_API_KEY ?? process.env.CLAUDE_API_KEY + const anthropicAuthToken = process.env.ANTHROPIC_AUTH_TOKEN ?? process.env.CLAUDE_AUTH_TOKEN + const runningOnVercel = Boolean( + process.env.VERCEL || process.env.VERCEL_ENV || process.env.VERCEL_OIDC_TOKEN + ) + + if (gatewayApiKey || runningOnVercel) { + const provider = gatewayApiKey ? createGateway({ apiKey: gatewayApiKey }) : gateway + return provider(modelId) + } + + if (anthropicApiKey || anthropicAuthToken) { + const anthropic = createAnthropic({ + ...(anthropicApiKey ? { apiKey: anthropicApiKey } : {}), + ...(anthropicAuthToken ? { authToken: anthropicAuthToken } : {}), + }) + + const anthropicModelId = modelId.startsWith('anthropic/') + ? modelId.slice('anthropic/'.length) + : modelId + + return anthropic(anthropicModelId) + } + + throw new Error( + 'Missing AI credentials. Set AI_GATEWAY_API_KEY (or AI_GATEWAY_KEY), or ANTHROPIC_API_KEY / CLAUDE_API_KEY.' + ) +} + +function buildPrompt( + context: QaRuntimeContext, + skills: SkillMetadata[], + extraInstructions: string[], + prompt?: string +) { + const platformLabel = context.platform === 'ios' ? 'iOS' : 'Android' + const baseInstructions = [ + `Review this ${platformLabel} build and run a lightweight QA pass.`, + '', + 'Execution context:', + `- Platform: ${platformLabel}`, + `- Build path: ${context.artifactPath}`, + `- Application id: ${context.appId}`, + `- Build ID: ${context.buildId || 'n/a'}`, + `- Workflow URL: ${context.workflowUrl || 'n/a'}`, + `- Device: ${context.deviceName || 'currently bound device'}`, + `- Screenshot directory: ${context.screenshotsDir}`, + '', + context.metadata.prTitle + ? `PR #${context.metadata.prNumber || 'n/a'}: ${context.metadata.prTitle}` + : 'No pull request title was provided.', + context.metadata.prBody || 'No pull request body was provided.', + '', + buildSkillsPrompt(skills), + ] + + if (extraInstructions.length > 0) { + baseInstructions.push('', 'Extra instructions:') + for (const instruction of extraInstructions) { + baseInstructions.push(`- ${instruction}`) + } + } + + if (prompt?.trim()) { + baseInstructions.push('', 'Task-specific focus:') + baseInstructions.push(prompt.trim()) + } + + baseInstructions.push( + '', + `Save screenshots into ${context.screenshotsDir}/*.png with short descriptive filenames.`, + 'When text visibility matters, prefer a plain snapshot over image-heavy inspection.', + 'Treat bootstrap as already handled. Do not install, reinstall, or open the app yourself.', + 'Do not inspect repository source files or modify project code.', + 'Finish by calling write_report exactly once.' + ) + + return baseInstructions.join('\n') +} + +function hasToolActivity( + steps: Array<{ + toolCalls?: Array<{ toolName?: string }> + toolResults?: Array<{ toolName?: string }> + }>, + toolName: string +) { + return steps.some((step) => { + const hasToolCall = step.toolCalls?.some((toolCall) => toolCall.toolName === toolName) + const hasToolResult = step.toolResults?.some((toolResult) => toolResult.toolName === toolName) + + return Boolean(hasToolCall || hasToolResult) + }) +} + +export async function runQaMobileRole(options: RunQaMobileRoleOptions): Promise { + const { context, modelId, skillPaths, enabledToolPacks, extraInstructions, prompt } = options + const skills = await discoverSkills(skillPaths) + const agentDeviceTrace: AgentDeviceTraceEntry[] = [] + let reportInput: QaReportInput | undefined + + const tools: Record = { + get_run_context: tool({ + description: 'Read the normalized QA run context and metadata.', + inputSchema: EMPTY_INPUT_SCHEMA, + execute: async () => context, + }), + } + + if (enabledToolPacks.includes('skills')) { + Object.assign(tools, createSkillsToolPack(skills)) + } + + if (enabledToolPacks.includes('agent-device')) { + Object.assign(tools, createAgentDeviceToolPack({ trace: agentDeviceTrace })) + } + + tools.write_report = tool({ + description: 'Persist the final QA summary, findings, and screenshot labels.', + inputSchema: WRITE_REPORT_INPUT_SCHEMA, + execute: async (input) => { + if (reportInput) { + throw new Error('write_report has already been called for this QA run.') + } + + reportInput = input satisfies QaReportInput + return { + ok: true, + } + }, + }) + + const instructions = [ + `You are a mobile QA agent for ${context.platform === 'ios' ? 'iOS' : 'Android'} builds.`, + 'Use only the provided tool packs and evidence from their results.', + 'The CLI already handled deterministic bootstrap. Never install, reinstall, or open the app.', + 'Refresh your view with snapshot-style commands after every meaningful UI transition.', + 'Take screenshots for meaningful states and keep filenames short and descriptive.', + 'If the environment is broken or a prerequisite is missing, report blocked checks instead of guessing.', + 'If the evidence is visual but not conclusive from text automation, prefer overallStatus "unsure".', + 'Do not finish with plain text. Finish only by calling write_report exactly once.', + ] + .concat(extraInstructions) + .join(' ') + + const agent = new ToolLoopAgent({ + model: buildModel(modelId), + instructions, + tools, + toolChoice: 'required', + stopWhen: stepCountIs(12), + prepareStep: async ({ steps, stepNumber }) => { + const hasWrittenReport = hasToolActivity(steps, 'write_report') + const hasUsedDeviceTools = hasToolActivity(steps, 'agent_device') + + if (hasWrittenReport || !hasUsedDeviceTools || stepNumber < 6) { + return {} + } + + return { + activeTools: ['write_report'], + toolChoice: { type: 'tool', toolName: 'write_report' }, + } + }, + }) + + const result = await agent.generate({ + prompt: buildPrompt(context, skills, extraInstructions, prompt), + }) + + if (!reportInput) { + reportInput = { + overallStatus: 'blocked', + summary: result.text || 'The agent completed without calling write_report.', + checked: ['Produce a mobile QA report'], + issues: ['The write_report tool was not called by the agent.'], + nextSteps: ['Inspect the run logs and tighten the QA role instructions.'], + } + } + + return { + reportInput, + agentDeviceTrace, + skills, + } +} diff --git a/packages/cali/src/tools/agent-device.ts b/packages/cali/src/tools/agent-device.ts new file mode 100644 index 0000000..7ad0a83 --- /dev/null +++ b/packages/cali/src/tools/agent-device.ts @@ -0,0 +1,52 @@ +import { tool } from 'ai' +import { z } from 'zod' + +import type { AgentDeviceTraceEntry } from '../report/types.js' +import { parseJson, runCommand, trimText } from '../utils.js' + +type CreateAgentDeviceToolPackOptions = { + trace: AgentDeviceTraceEntry[] +} + +export function createAgentDeviceToolPack(options: CreateAgentDeviceToolPackOptions) { + const { trace } = options + const inputSchema = z.object({ + command: z + .string() + .describe( + 'The first agent-device subcommand to run, such as devices, open, snapshot, tap, fill, press, or screenshot.' + ), + args: z + .array(z.string()) + .optional() + .describe('Remaining CLI arguments for the subcommand.'), + }) + + return { + agent_device: tool({ + description: 'Run an agent-device command for mobile UI automation and screenshot capture.', + inputSchema, + execute: async ({ command, args = [] }) => { + const result = await runCommand('agent-device', [command, ...args], { + allowFailure: true, + }) + + trace.push({ + command: [command, ...args].join(' '), + ok: result.ok, + exitCode: result.exitCode, + stdout: trimText(result.stdout, 4000), + stderr: trimText(result.stderr, 2000), + }) + + return { + ok: result.ok, + exitCode: result.exitCode, + stdout: trimText(result.stdout, 8000), + stderr: trimText(result.stderr, 4000), + json: parseJson(result.stdout, null as unknown), + } + }, + }), + } +} diff --git a/packages/cali/src/tools/skills.ts b/packages/cali/src/tools/skills.ts new file mode 100644 index 0000000..aad4d94 --- /dev/null +++ b/packages/cali/src/tools/skills.ts @@ -0,0 +1,186 @@ +import path from 'node:path' +import { readFile, readdir } from 'node:fs/promises' + +import { tool } from 'ai' +import { z } from 'zod' + +type SkillMetadata = { + name: string + description: string + directoryPath: string + skillFilePath: string +} + +function stripFrontmatter(content: string) { + const match = content.match(/^---\r?\n[\s\S]*?\r?\n---\r?\n?/) + return match ? content.slice(match[0].length).trim() : content.trim() +} + +function parseFrontmatter(content: string) { + const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/) + if (!match?.[1]) { + throw new Error('No frontmatter found') + } + + const frontmatter = match[1] + const name = frontmatter.match(/^name:\s*(.+)$/m)?.[1]?.trim().replace(/^['"]|['"]$/g, '') + const description = frontmatter + .match(/^description:\s*(.+)$/m)?.[1] + ?.trim() + .replace(/^['"]|['"]$/g, '') + + if (!name || !description) { + throw new Error('Skill frontmatter is missing name or description') + } + + return { + name, + description, + } +} + +function resolveSkillFilePath(skill: SkillMetadata, relativeFilePath: string) { + const absolutePath = path.resolve(skill.directoryPath, relativeFilePath) + const relativePath = path.relative(skill.directoryPath, absolutePath) + const normalizedRelativePath = relativePath.split(path.sep).join('/') + + if ( + normalizedRelativePath === '' || + normalizedRelativePath.startsWith('../') || + normalizedRelativePath === '..' + ) { + throw new Error(`Refusing to read a path outside the skill directory: ${relativeFilePath}`) + } + + return absolutePath +} + +function findSkill(skills: SkillMetadata[], name: string) { + const skill = skills.find((candidate) => candidate.name.toLowerCase() === name.toLowerCase()) + if (!skill) { + throw new Error(`Skill not found: ${name}`) + } + + return skill +} + +export async function discoverSkills(directories: string[]) { + const skills: SkillMetadata[] = [] + const seenNames = new Set() + + for (const directory of directories) { + let entries + try { + entries = await readdir(directory, { withFileTypes: true }) + } catch { + continue + } + + for (const entry of entries) { + if (!entry.isDirectory()) { + continue + } + + const skillDirectoryPath = path.join(directory, entry.name) + const skillFilePath = path.join(skillDirectoryPath, 'SKILL.md') + + try { + const content = await readFile(skillFilePath, 'utf8') + const frontmatter = parseFrontmatter(content) + const key = frontmatter.name.toLowerCase() + + if (seenNames.has(key)) { + continue + } + + seenNames.add(key) + skills.push({ + name: frontmatter.name, + description: frontmatter.description, + directoryPath: skillDirectoryPath, + skillFilePath, + }) + } catch { + continue + } + } + } + + return skills.sort((left, right) => left.name.localeCompare(right.name)) +} + +export function buildSkillsPrompt(skills: SkillMetadata[]) { + if (skills.length === 0) { + return 'No local skills were discovered for this run.' + } + + return [ + 'Available local skills:', + ...skills.map((skill) => `- ${skill.name}: ${skill.description}`), + '', + 'Load a skill before relying on its instructions. Only read files inside a skill after loading it.', + ].join('\n') +} + +export function createSkillsToolPack(skills: SkillMetadata[]) { + const loadedSkills = new Set() + const loadSkillInputSchema = z.object({ + name: z.string().describe('Skill name from the available local skills list.'), + }) + const readSkillFileInputSchema = z.object({ + skillName: z.string(), + path: z.string(), + startLine: z.number().int().min(1).optional(), + maxLines: z.number().int().min(1).max(400).optional(), + }) + + return { + load_skill: tool({ + description: 'Load a local skill and return its instructions plus the skill directory path.', + inputSchema: loadSkillInputSchema, + execute: async ({ name }) => { + const skill = findSkill(skills, name) + loadedSkills.add(skill.name.toLowerCase()) + const content = await readFile(skill.skillFilePath, 'utf8') + + return { + name: skill.name, + description: skill.description, + skillDirectory: skill.directoryPath, + skillFilePath: skill.skillFilePath, + content: stripFrontmatter(content), + } + }, + }), + read_skill_file: tool({ + description: 'Read a text file inside a previously loaded skill directory.', + inputSchema: readSkillFileInputSchema, + execute: async ({ + skillName, + path: relativeFilePath, + startLine = 1, + maxLines = 200, + }) => { + const skill = findSkill(skills, skillName) + if (!loadedSkills.has(skill.name.toLowerCase())) { + throw new Error(`Skill must be loaded before reading files: ${skill.name}`) + } + + const absolutePath = resolveSkillFilePath(skill, relativeFilePath) + const content = await readFile(absolutePath, 'utf8') + const lines = content.split('\n') + const slice = lines.slice(Math.max(startLine - 1, 0), Math.max(startLine - 1, 0) + maxLines) + + return { + skillName: skill.name, + absolutePath, + startLine, + endLine: startLine + slice.length - 1, + content: slice.join('\n'), + } + }, + }), + } +} + +export type { SkillMetadata } diff --git a/packages/cali/src/utils.ts b/packages/cali/src/utils.ts index ab2d30f..bf0094b 100644 --- a/packages/cali/src/utils.ts +++ b/packages/cali/src/utils.ts @@ -1,60 +1,126 @@ -import { execSync } from 'node:child_process' - -import { confirm, outro, text } from '@clack/prompts' -import chalk from 'chalk' -import dedent from 'dedent' - -/** - * Get API key from environment variables or prompt user for it. - */ -export async function getApiKey(name: string, key: string) { - if (key in process.env) { - return process.env[key] - } - return (async () => { - let apiKey: string | symbol - do { - apiKey = await text({ - message: dedent` - ${chalk.bold(`Please provide your ${name} API key.`)} - - To skip this message, set ${chalk.bold(key)} env variable, and run again. - - You can do it in three ways: - - by creating an ${chalk.bold('.env.local')} file (make sure to ${chalk.bold('.gitignore')} it) - ${chalk.gray(`\`\`\` - ${key}= - \`\`\` - `)} - - by passing it inline: - ${chalk.gray(`\`\`\` - ${key}= npx cali - \`\`\` - `)} - - by setting it as an env variable in your shell (e.g. in ~/.zshrc or ~/.bashrc): - ${chalk.gray(`\`\`\` - export ${key}= - \`\`\` - `)}, - `, - validate: (value) => (value.length > 0 ? undefined : `Please provide a valid ${key}.`), - }) - } while (typeof apiKey === 'undefined') - - if (typeof apiKey === 'symbol') { - outro(chalk.gray('Bye!')) - process.exit(0) - } +import { execFile as execFileCallback } from 'node:child_process' +import { mkdir } from 'node:fs/promises' +import path from 'node:path' +import { promisify } from 'node:util' + +import type { QaPlatform } from './env/types.js' + +const execFile = promisify(execFileCallback) + +export type CommandResult = { + ok: boolean + exitCode: number + stdout: string + stderr: string +} + +type CommandOptions = { + cwd?: string + env?: NodeJS.ProcessEnv + allowFailure?: boolean +} + +type ExecFileError = Error & { + stdout?: string + stderr?: string + code?: number | string +} + +export async function runCommand( + file: string, + args: string[], + options: CommandOptions = {} +): Promise { + const { cwd = process.cwd(), env = process.env, allowFailure = false } = options - const save = await confirm({ - message: `Do you want to save it for future runs in .env.local?`, + try { + const result = await execFile(file, args, { + cwd, + env, + maxBuffer: 20 * 1024 * 1024, }) - if (save) { - execSync(`echo "${key}=${apiKey}" >> .env.local`) - execSync(`echo ".env.local" >> .gitignore`) + return { + ok: true, + exitCode: 0, + stdout: result.stdout ?? '', + stderr: result.stderr ?? '', } + } catch (unknownError) { + const error = unknownError as ExecFileError + const stdout = typeof error.stdout === 'string' ? error.stdout : '' + const stderr = typeof error.stderr === 'string' ? error.stderr : error.message + const exitCode = typeof error.code === 'number' ? error.code : 1 + + if (!allowFailure) { + throw new Error( + [`Command failed: ${file} ${args.join(' ')}`, stderr || stdout].filter(Boolean).join('\n\n') + ) + } + + return { + ok: false, + exitCode, + stdout, + stderr, + } + } +} + +export async function ensureDirectory(directoryPath: string) { + await mkdir(directoryPath, { recursive: true }) +} + +export function parseJson(value: string | undefined, fallback: T): T { + if (!value) { + return fallback + } + + try { + return JSON.parse(value) as T + } catch { + return fallback + } +} + +export function trimText(value: string, max = 6000) { + if (value.length <= max) { + return value + } + + return `${value.slice(0, max)}\n...` +} + +export function uniqueStrings(values: Array) { + return [...new Set(values.filter((value): value is string => Boolean(value?.trim())).map((value) => value.trim()))] +} + +export function asArray(value: string | string[] | undefined) { + if (!value) { + return [] + } + + return Array.isArray(value) ? value : [value] +} + +export function resolveFromCwd(cwd: string, targetPath: string) { + return path.isAbsolute(targetPath) ? targetPath : path.resolve(cwd, targetPath) +} + +export function normalizePlatform(value: string | undefined): QaPlatform | undefined { + if (value === 'android' || value === 'ios') { + return value + } + + return undefined +} + +export function humanizeScreenshotLabel(fileName: string) { + const stem = fileName.replace(/\.[^.]+$/, '') + const words = stem + .split(/[-_]+/g) + .filter(Boolean) + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) - return apiKey - })() + return words.join(' ') || fileName } diff --git a/packages/tools/package.json b/packages/tools/package.json index 0df3a4e..8842a6e 100644 --- a/packages/tools/package.json +++ b/packages/tools/package.json @@ -19,16 +19,15 @@ "build:types": "tsc --emitDeclarationOnly --declaration --outdir dist/types" }, "dependencies": { - "@ai-sdk/openai": "^1.0.2", "@clack/prompts": "^0.8.1", "@react-native-community/cli": "^15.1.2", "@react-native-community/cli-config": "^15.1.2", "@react-native-community/cli-platform-android": "^15.1.2", "@react-native-community/cli-platform-apple": "^15.1.2", - "ai": "4.0.3", + "ai": "^6.0.138", "dedent": "^1.5.3", "diff": "^7.0.0", - "zod": "^3.23.8" + "zod": "^4.3.6" }, "devDependencies": { "@react-native-community/cli-types": "^15.1.2", diff --git a/packages/tools/src/android.ts b/packages/tools/src/android.ts index 4eec05f..e9c4288 100644 --- a/packages/tools/src/android.ts +++ b/packages/tools/src/android.ts @@ -17,7 +17,7 @@ import { export const getAdbPath = tool({ description: 'Returns path to ADB executable', - parameters: z.object({}), + inputSchema: z.object({}), execute: async () => { return getAdbPathString() }, @@ -33,7 +33,7 @@ export const getAndroidDevices = tool({ - "type" - device type ("device" or "emulator") - "booted" - whether the device is booted `, - parameters: z.object({ + inputSchema: z.object({ adbPath: z.string(), }), execute: async ({ adbPath }) => { @@ -62,7 +62,7 @@ export const getAndroidDevices = tool({ export const bootAndroidEmulator = tool({ description: 'Boots a given Android emulator and returns its ID', - parameters: z.object({ + inputSchema: z.object({ adbPath: z.string(), androidDevice_name: z.string(), }), @@ -83,7 +83,7 @@ export const bootAndroidEmulator = tool({ export const buildAndroidApp = tool({ description: 'Builds Android application and install it on a given device', - parameters: z.object({ + inputSchema: z.object({ androidDevice_id: z.string(), metroPort: z.number(), reactNativeConfig_android_sourceDir: z.string(), @@ -120,7 +120,7 @@ export const buildAndroidApp = tool({ export const runAdbReverse = tool({ description: 'Runs "adb reverse" to forward given port to a specified Android device', - parameters: z.object({ + inputSchema: z.object({ androidDevice_id: z.string(), port: z.number(), }), @@ -143,7 +143,7 @@ export const runAdbReverse = tool({ export const launchAndroidAppOnDevice = tool({ description: 'Launches a given Android application on a specified device', - parameters: z.object({ + inputSchema: z.object({ androidDevice_id: z.string(), adbPath: z.string(), reactNativeConfig_android_packageName: z.string(), diff --git a/packages/tools/src/apple.ts b/packages/tools/src/apple.ts index c772c49..963c0f6 100644 --- a/packages/tools/src/apple.ts +++ b/packages/tools/src/apple.ts @@ -16,7 +16,7 @@ const platforms = ['ios', 'tvos', 'visionos'] as const export const getAppleSimulators = tool({ description: 'Gets available simulators', - parameters: z.object({ + inputSchema: z.object({ platform: z.enum(platforms), }), execute: async ({ platform }) => { @@ -27,7 +27,7 @@ export const getAppleSimulators = tool({ export const installRubyGems = tool({ description: 'Install Ruby gems, including CocoaPods', - parameters: z.object({}), + inputSchema: z.object({}), execute: async () => { execSync('bundle install --path vendor/bundle', { stdio: 'inherit' }) return { @@ -38,7 +38,7 @@ export const installRubyGems = tool({ export const bootAppleSimulator = tool({ description: 'Boots iOS simulator', - parameters: z.object({ + inputSchema: z.object({ deviceId: z.string(), }), execute: async ({ deviceId }) => { @@ -58,7 +58,7 @@ export const bootAppleSimulator = tool({ export const buildAppleAppWithoutStarting = tool({ description: 'Build application for Apple platforms without running it', - parameters: z.object({ + inputSchema: z.object({ platform: z.enum(platforms), configuration: z.enum(['Debug', 'Release']), mode: z.string().optional(), @@ -91,7 +91,7 @@ export const buildAppleAppWithoutStarting = tool({ export const buildStartAppleApp = tool({ description: 'Build and start Apple application on simulator or device', - parameters: z.object({ + inputSchema: z.object({ platform: z.enum(platforms), simulator: z.string().optional(), device: z.union([z.string(), z.literal(true)]).optional(), @@ -122,7 +122,7 @@ export const buildStartAppleApp = tool({ export const installPods = tool({ description: 'Install CocoaPods dependencies', - parameters: z.object({ + inputSchema: z.object({ platform: z.enum(platforms), clean: z.boolean().optional().default(false), newArchitecture: z.boolean().optional(), @@ -171,7 +171,7 @@ export const installPods = tool({ export const startAppleLogging = tool({ description: 'Start Apple gathering logs from simulator or device', - parameters: z.object({ + inputSchema: z.object({ platform: z.enum(platforms), interactive: z.boolean().optional().default(true), }), diff --git a/packages/tools/src/fs.ts b/packages/tools/src/fs.ts index 227e896..909ed40 100644 --- a/packages/tools/src/fs.ts +++ b/packages/tools/src/fs.ts @@ -21,10 +21,19 @@ const fileEncodingSchema = z ]) .default('utf-8') +const readFileInputSchema = z.object({ + path: z.string(), + is_image: z.boolean(), + encoding: fileEncodingSchema, +}) + +type ReadFileInput = z.infer +type ReadFileResult = string | { data: string; mimeType: string } + export const listFiles = tool({ description: 'List all files in a directory. If path is nested, you must call it separately for each segment', - parameters: z.object({ path: z.string() }), + inputSchema: z.object({ path: z.string() }), execute: async ({ path }) => { return readdir(path) }, @@ -32,7 +41,7 @@ export const listFiles = tool({ export const currentDirectory = tool({ description: 'Get the current working directory', - parameters: z.object({}), + inputSchema: z.object({}), execute: async () => { return process.cwd() }, @@ -40,7 +49,7 @@ export const currentDirectory = tool({ export const makeDirectory = tool({ description: 'Create a new directory', - parameters: z.object({ path: z.string() }), + inputSchema: z.object({ path: z.string() }), execute: async ({ path }) => { return mkdir(path) }, @@ -48,8 +57,8 @@ export const makeDirectory = tool({ export const readFile = tool({ description: 'Reads a file at a given path', - parameters: z.object({ path: z.string(), is_image: z.boolean(), encoding: fileEncodingSchema }), - execute: async ({ path, is_image, encoding }) => { + inputSchema: readFileInputSchema, + execute: async ({ path, is_image, encoding }: ReadFileInput): Promise => { const file = await readFileNode(path, { encoding }) if (is_image) { return { @@ -60,16 +69,19 @@ export const readFile = tool({ return file } }, - experimental_toToolResultContent(result) { - return typeof result === 'string' - ? [{ type: 'text', text: result }] - : [{ type: 'image', data: result.data, mimeType: result.mimeType }] + toModelOutput({ output }: { output: ReadFileResult }) { + return typeof output === 'string' + ? { type: 'text', value: output } + : { + type: 'content', + value: [{ type: 'file-data', data: output.data, mediaType: output.mimeType }], + } }, }) export const saveFile = tool({ description: 'Save a file at a given path', - parameters: z.object({ + inputSchema: z.object({ path: z.string(), content: z.string(), encoding: fileEncodingSchema, diff --git a/packages/tools/src/git.ts b/packages/tools/src/git.ts index 4a17098..c35ace6 100644 --- a/packages/tools/src/git.ts +++ b/packages/tools/src/git.ts @@ -6,7 +6,7 @@ import { z } from 'zod' export const applyDiff = tool({ description: 'Apply a diff/patch to a file', - parameters: z.object({ + inputSchema: z.object({ filePath: z.string(), diff: z.string(), }), diff --git a/packages/tools/src/npm.ts b/packages/tools/src/npm.ts index 518b7e9..3d91d56 100644 --- a/packages/tools/src/npm.ts +++ b/packages/tools/src/npm.ts @@ -5,7 +5,7 @@ import { install, installDev, uninstall } from './vendor/react-native-cli.js' export const installNpmPackage = tool({ description: 'Install a package from npm by name', - parameters: z.object({ + inputSchema: z.object({ packageNames: z.array(z.string()), packageManager: z.enum(['yarn', 'npm', 'bun']).optional(), dev: z.boolean().optional(), @@ -34,7 +34,7 @@ export const installNpmPackage = tool({ export const unInstallNpmPackage = tool({ description: 'Uninstall a package from npm by name', - parameters: z.object({ + inputSchema: z.object({ packageNames: z.array(z.string()), packageManager: z.enum(['yarn', 'npm', 'bun']).optional(), }), diff --git a/packages/tools/src/react-native.ts b/packages/tools/src/react-native.ts index 5c577b3..0d2e690 100644 --- a/packages/tools/src/react-native.ts +++ b/packages/tools/src/react-native.ts @@ -14,7 +14,7 @@ export const startMetroDevServer = tool({ Starts Metro development server on a given port or a different available port. Returns port Metro server started on. `, - parameters: z.object({ + inputSchema: z.object({ port: z.number().default(8081), reactNativeConfig_root: z.string(), reactNativeConfig_reactNativePath: z.string(), @@ -65,7 +65,7 @@ export const getReactNativeConfig = tool({ - "mainActivity" - Android main activity - "assets" - Android assets `, - parameters: z.object({}), + inputSchema: z.object({}), execute: async () => { try { const { @@ -102,7 +102,7 @@ export const listReactNativeLibraries = tool({ - "score" - library score - "url" - library GitHub repository URL `, - parameters: z.object({ + inputSchema: z.object({ search: z.string().optional(), }), execute: async ({ search }) => { diff --git a/patches/ai@4.0.3.patch b/patches/ai@4.0.3.patch deleted file mode 100644 index 710e283..0000000 --- a/patches/ai@4.0.3.patch +++ /dev/null @@ -1,55 +0,0 @@ -diff --git a/dist/index.d.ts b/dist/index.d.ts -index 6d9d7ffa9c78b51a208a04189d842186a34e78cd..24aede8005b530445fb0788fefe53d98bd9ab2fc 100644 ---- a/dist/index.d.ts -+++ b/dist/index.d.ts -@@ -1558,6 +1558,10 @@ changing the tool call and result types in the result. - */ - experimental_activeTools?: Array; - /** -+ Callback that is called when each step (LLM call) is started -+ */ -+ onStepStart?: (toolCalls: ToolCallArray) => Promise | void; -+ /** - Callback that is called when each step (LLM call) is finished, including intermediate steps. - */ - onStepFinish?: (event: StepResult) => Promise | void; -diff --git a/dist/index.js b/dist/index.js -index f8002b76aae8e7b915b7a16b3c9ff68063e9e78a..dff50cb864846302688f22ea8aa68965601b4144 100644 ---- a/dist/index.js -+++ b/dist/index.js -@@ -3264,6 +3264,7 @@ async function generateText({ - currentDate = () => /* @__PURE__ */ new Date() - } = {}, - onStepFinish, -+ onStepStart, - ...settings - }) { - if (maxSteps < 1) { -@@ -3424,6 +3425,7 @@ async function generateText({ - currentToolCalls = ((_a11 = currentModelResponse.toolCalls) != null ? _a11 : []).map( - (modelToolCall) => parseToolCall({ toolCall: modelToolCall, tools }) - ); -+ await (onStepStart == null ? void 0 : onStepStart(currentToolCalls)); - currentToolResults = tools == null ? [] : await executeTools({ - toolCalls: currentToolCalls, - tools, -diff --git a/dist/index.mjs b/dist/index.mjs -index 667c98e17072b65f29597277a734127f69fdc83b..586f1082a0c4bf21f04af47031b0d41dc4d5c028 100644 ---- a/dist/index.mjs -+++ b/dist/index.mjs -@@ -3230,6 +3230,7 @@ async function generateText({ - generateId: generateId3 = originalGenerateId3, - currentDate = () => /* @__PURE__ */ new Date() - } = {}, -+ onStepStart, - onStepFinish, - ...settings - }) { -@@ -3391,6 +3392,7 @@ async function generateText({ - currentToolCalls = ((_a11 = currentModelResponse.toolCalls) != null ? _a11 : []).map( - (modelToolCall) => parseToolCall({ toolCall: modelToolCall, tools }) - ); -+ await (onStepStart == null ? void 0 : onStepStart(currentToolCalls)); - currentToolResults = tools == null ? [] : await executeTools({ - toolCalls: currentToolCalls, - tools, diff --git a/tsconfig.json b/tsconfig.json index ade9808..f9302ac 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -4,6 +4,7 @@ "module": "NodeNext", "moduleResolution": "nodenext", "allowJs": true, + "allowImportingTsExtensions": true, "strict": true, "skipLibCheck": true, "isolatedModules": true, From 51c410028b6b5b34126a7067a071e7cdb80a23eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 26 Mar 2026 16:11:22 +0100 Subject: [PATCH 02/48] refactor: slim down cali cli surface --- bun.lockb | Bin 310656 -> 311024 bytes packages/cali/package.json | 6 +- packages/cali/src/cli.ts | 151 +------------------------------ packages/cali/src/cli/app.ts | 30 ++++++ packages/cali/src/cli/banner.ts | 22 +++++ packages/cali/src/cli/qa.ts | 103 +++++++++++++++++++++ packages/cali/src/config/load.ts | 32 +++++-- 7 files changed, 182 insertions(+), 162 deletions(-) create mode 100644 packages/cali/src/cli/app.ts create mode 100644 packages/cali/src/cli/banner.ts create mode 100644 packages/cali/src/cli/qa.ts diff --git a/bun.lockb b/bun.lockb index d9585bfa90713dd56be351ba639517f20251ddc2..ffc3923a5f80cbd706ac7bb491f64bc9d65eb00a 100755 GIT binary patch delta 3938 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 K%6#7N`M&{a4!kb_ diff --git a/packages/cali/package.json b/packages/cali/package.json index 3c19e52..5890819 100644 --- a/packages/cali/package.json +++ b/packages/cali/package.json @@ -11,15 +11,13 @@ }, "dependencies": { "@ai-sdk/anthropic": "^3.0.64", - "@clack/prompts": "^0.8.1", "@vercel/blob": "^0.27.0", "ai": "^6.0.138", + "cac": "^7.0.0", "cali-tools": "0.3.1", - "chalk": "^5.3.0", - "dedent": "^1.5.3", + "cosmiconfig": "^9.0.1", "dotenv": "^16.4.5", "gradient-string": "^3.0.0", - "jiti": "^2.4.2", "zod": "^4.3.6" }, "bugs": { diff --git a/packages/cali/src/cli.ts b/packages/cali/src/cli.ts index 832b640..cd0725c 100644 --- a/packages/cali/src/cli.ts +++ b/packages/cali/src/cli.ts @@ -1,156 +1,11 @@ #!/usr/bin/env node -import { runQaCommand } from './commands/qa.js' -import type { QaCliOptions } from './env/types.js' -import { normalizePlatform } from './utils.js' +import 'dotenv/config' -function printHelp() { - console.log(`cali v2 - -Usage: - cali qa [options] - -Options: - --preset Built-in preset: eas-mobile-pr, local-android, local-ios - --config Path to cali.config.ts - --prompt Add task-specific QA intent - --json Load normalized environment context from JSON - --platform android or ios - --artifact App artifact path (.apk, .aab, .app, .ipa) - --app-id Application identifier / package name - --device Simulator or emulator name to provision - --output-dir Output directory for artifacts - --build-id Build identifier - --workflow-url Workflow or build link - --pr-number Pull request number - --pr-title Pull request title - --pr-body Pull request body - --task-id Task identifier - --task-title Task title - --task-body Task body - --model Override the QA model - --help Show this help -`) -} - -function readFlagValue(argv: string[], index: number) { - const value = argv[index + 1] - if (!value || value.startsWith('--')) { - throw new Error(`Missing value for ${argv[index]}`) - } - - return value -} - -function parseQaArgs(argv: string[]): QaCliOptions { - const options: QaCliOptions = {} - - for (let index = 0; index < argv.length; index += 1) { - const argument = argv[index] - - switch (argument) { - case '--preset': - options.presetName = readFlagValue(argv, index) as QaCliOptions['presetName'] - index += 1 - break - case '--config': - options.configPath = readFlagValue(argv, index) - index += 1 - break - case '--prompt': - options.prompt = readFlagValue(argv, index) - index += 1 - break - case '--json': - options.jsonPath = readFlagValue(argv, index) - index += 1 - break - case '--platform': { - const platform = normalizePlatform(readFlagValue(argv, index)) - if (!platform) { - throw new Error('`--platform` must be `android` or `ios`.') - } - options.platform = platform - index += 1 - break - } - case '--artifact': - options.artifactPath = readFlagValue(argv, index) - index += 1 - break - case '--app-id': - options.appId = readFlagValue(argv, index) - index += 1 - break - case '--device': - options.deviceName = readFlagValue(argv, index) - index += 1 - break - case '--output-dir': - options.outputDir = readFlagValue(argv, index) - index += 1 - break - case '--build-id': - options.buildId = readFlagValue(argv, index) - index += 1 - break - case '--workflow-url': - options.workflowUrl = readFlagValue(argv, index) - index += 1 - break - case '--pr-number': - options.prNumber = Number(readFlagValue(argv, index)) - index += 1 - break - case '--pr-title': - options.prTitle = readFlagValue(argv, index) - index += 1 - break - case '--pr-body': - options.prBody = readFlagValue(argv, index) - index += 1 - break - case '--task-id': - options.taskId = readFlagValue(argv, index) - index += 1 - break - case '--task-title': - options.taskTitle = readFlagValue(argv, index) - index += 1 - break - case '--task-body': - options.taskBody = readFlagValue(argv, index) - index += 1 - break - case '--model': - options.model = readFlagValue(argv, index) - index += 1 - break - case '--help': - case '-h': - printHelp() - process.exit(0) - default: - throw new Error(`Unknown argument: ${argument}`) - } - } - - return options -} +import { runCli } from './cli/app.js' async function main() { - const [command, ...rest] = process.argv.slice(2) - - if (!command || command === '--help' || command === '-h') { - printHelp() - return - } - - if (command !== 'qa') { - throw new Error(`Unsupported command: ${command}`) - } - - await runQaCommand(parseQaArgs(rest)) + await runCli() } main().catch((error) => { diff --git a/packages/cali/src/cli/app.ts b/packages/cali/src/cli/app.ts new file mode 100644 index 0000000..d396202 --- /dev/null +++ b/packages/cali/src/cli/app.ts @@ -0,0 +1,30 @@ +import { cac } from 'cac' + +import { printRetroBanner } from './banner.js' +import { registerQaCommand } from './qa.js' + +export function createCli() { + const cli = cac('cali') + + cli.usage(' [options]') + registerQaCommand(cli, printRetroBanner) + cli.help() + + return cli +} + +export async function runCli(argv = process.argv) { + const cli = createCli() + const args = argv.slice(2) + const shouldPrintBanner = args.length === 0 || args.includes('--help') || args.includes('-h') + + if (shouldPrintBanner) { + printRetroBanner() + } + + await Promise.resolve(cli.parse(argv)) + + if (args.length === 0) { + cli.outputHelp() + } +} diff --git a/packages/cali/src/cli/banner.ts b/packages/cali/src/cli/banner.ts new file mode 100644 index 0000000..1db0fef --- /dev/null +++ b/packages/cali/src/cli/banner.ts @@ -0,0 +1,22 @@ +import { retro } from 'gradient-string' + +const CALI_TEXT = ` + ██████╗ █████╗ ██╗ ██╗ + ██╔════╝██╔══██╗██║ ██║ + ██║ ███████║██║ ██║ + ██║ ██╔══██║██║ ██║ + ╚██████╗██║ ██║███████╗██║ + ╚═════╝╚═╝ ╚═╝╚══════╝╚═╝ +` + +let bannerPrinted = false + +export function printRetroBanner() { + if (bannerPrinted) { + return + } + + bannerPrinted = true + console.log(retro(CALI_TEXT)) + console.log('Cali v2 for mobile QA.\n') +} diff --git a/packages/cali/src/cli/qa.ts b/packages/cali/src/cli/qa.ts new file mode 100644 index 0000000..242f19c --- /dev/null +++ b/packages/cali/src/cli/qa.ts @@ -0,0 +1,103 @@ +import { cac } from 'cac' + +import { runQaCommand } from '../commands/qa.js' +import type { QaCliOptions } from '../env/types.js' +import { normalizePlatform } from '../utils.js' + +type QaCommandOptions = { + preset?: string + config?: string + prompt?: string + json?: string + platform?: string + artifact?: string + appId?: string + device?: string + outputDir?: string + buildId?: string + workflowUrl?: string + prNumber?: string | number + prTitle?: string + prBody?: string + taskId?: string + taskTitle?: string + taskBody?: string + model?: string +} + +function readOptionalString(value: unknown) { + return typeof value === 'string' && value.length > 0 ? value : undefined +} + +function readOptionalNumber(value: unknown, flagName: string) { + if (value == null || value === '') { + return undefined + } + + const parsed = Number(value) + if (!Number.isFinite(parsed)) { + throw new Error(`\`${flagName}\` must be a valid number.`) + } + + return parsed +} + +export function normalizeQaCliOptions(options: QaCommandOptions): QaCliOptions { + const platformValue = readOptionalString(options.platform) + const platform = platformValue ? normalizePlatform(platformValue) : undefined + + if (platformValue && !platform) { + throw new Error('`--platform` must be `android` or `ios`.') + } + + return { + presetName: readOptionalString(options.preset) as QaCliOptions['presetName'], + configPath: readOptionalString(options.config), + prompt: readOptionalString(options.prompt), + jsonPath: readOptionalString(options.json), + platform, + artifactPath: readOptionalString(options.artifact), + appId: readOptionalString(options.appId), + deviceName: readOptionalString(options.device), + outputDir: readOptionalString(options.outputDir), + buildId: readOptionalString(options.buildId), + workflowUrl: readOptionalString(options.workflowUrl), + prNumber: readOptionalNumber(options.prNumber, '--pr-number'), + prTitle: readOptionalString(options.prTitle), + prBody: readOptionalString(options.prBody), + taskId: readOptionalString(options.taskId), + taskTitle: readOptionalString(options.taskTitle), + taskBody: readOptionalString(options.taskBody), + model: readOptionalString(options.model), + } +} + +export function registerQaCommand(cli: ReturnType, printBanner: () => void) { + cli + .command('qa', 'Run the mobile QA role') + .option('--preset ', 'Built-in preset: eas-mobile-pr, local-android, local-ios') + .option('--config ', 'Path to cali.config.ts') + .option('--prompt ', 'Add task-specific QA intent') + .option('--json ', 'Load normalized environment context from JSON') + .option('--platform ', 'android or ios') + .option('--artifact ', 'App artifact path (.apk, .aab, .app, .ipa)') + .option('--app-id ', 'Application identifier / package name') + .option('--device ', 'Simulator or emulator name to provision') + .option('--output-dir ', 'Output directory for artifacts') + .option('--build-id ', 'Build identifier') + .option('--workflow-url ', 'Workflow or build link') + .option('--pr-number ', 'Pull request number') + .option('--pr-title ', 'Pull request title') + .option('--pr-body ', 'Pull request body') + .option('--task-id ', 'Task identifier') + .option('--task-title ', 'Task title') + .option('--task-body ', 'Task body') + .option('--model ', 'Override the QA model') + .example( + 'qa --preset local-ios --artifact ./artifacts/MyApp.app --app-id com.example.myapp --prompt "verify the onboarding copy on Screen B"' + ) + .action(async (options) => { + printBanner() + await runQaCommand(normalizeQaCliOptions(options as QaCommandOptions)) + }) +} diff --git a/packages/cali/src/config/load.ts b/packages/cali/src/config/load.ts index c7853e6..f1bd7b6 100644 --- a/packages/cali/src/config/load.ts +++ b/packages/cali/src/config/load.ts @@ -1,7 +1,7 @@ import { existsSync } from 'node:fs' import path from 'node:path' -import { createJiti } from 'jiti' +import { cosmiconfig } from 'cosmiconfig' import type { QaResolvedConfig } from '../env/types.js' import { asArray, resolveFromCwd, uniqueStrings } from '../utils.js' @@ -113,18 +113,30 @@ function mergeConfig(base: CaliQaConfig, override: CaliQaConfig): CaliQaConfig { } async function loadConfigFile(cwd: string, explicitPath?: string): Promise { - const defaultPath = path.join(cwd, 'cali.config.ts') - const configFilePath = explicitPath ? resolveFromCwd(cwd, explicitPath) : defaultPath - - if (!existsSync(configFilePath)) { - return {} + const explorer = cosmiconfig('cali', { + searchPlaces: [ + 'cali.config.ts', + 'cali.config.js', + 'cali.config.mjs', + 'cali.config.cjs', + 'cali.config.json', + ], + }) + + if (explicitPath) { + const configFilePath = resolveFromCwd(cwd, explicitPath) + + if (!existsSync(configFilePath)) { + return {} + } + + const loaded = await explorer.load(configFilePath) + return CaliQaConfigSchema.parse(loaded?.config ?? {}) } - const jiti = createJiti(import.meta.url, { moduleCache: false, fsCache: false }) - const loaded = await jiti.import(configFilePath) - const candidate = ((loaded as { default?: unknown })?.default ?? loaded) as unknown + const loaded = await explorer.search(cwd) - return CaliQaConfigSchema.parse(candidate) + return CaliQaConfigSchema.parse(loaded?.config ?? {}) } export async function loadQaConfig(options: LoadQaConfigOptions): Promise { From 78e1fbee8b93958ac915b2af4d377a7e43de245c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 26 Mar 2026 16:14:30 +0100 Subject: [PATCH 03/48] chore: remove dead cali code --- bun.lockb | Bin 311024 -> 310992 bytes packages/cali/package.json | 1 - packages/cali/src/cli/app.ts | 2 +- packages/cali/src/cli/qa.ts | 2 +- packages/cali/src/commands/qa.ts | 7 ------- packages/cali/src/config/load.ts | 2 -- packages/cali/src/config/schema.ts | 2 +- packages/cali/src/env/eas.ts | 1 - packages/cali/src/env/json-file.ts | 1 - packages/cali/src/env/local.ts | 1 - packages/cali/src/env/types.ts | 10 +--------- packages/cali/src/roles/qa-mobile.ts | 2 -- packages/cali/src/utils.ts | 2 +- 13 files changed, 5 insertions(+), 28 deletions(-) diff --git a/bun.lockb b/bun.lockb index ffc3923a5f80cbd706ac7bb491f64bc9d65eb00a..23d7fc07bda9b33ea55dc2ba182006eb19af9ea5 100755 GIT binary patch delta 559 zcmezHSLniDp$UGB4jcWAF0)5?@j0_HFibWu(wJOe#IbqDWljb5N;JVApJQZmIT#oi z^nps5fV3f$HU`olfQ<%8SwQ7MG>B;ngj4H&~9d+8yFb= z@&5xkhJm4dwHD*{)mlufFBo;VZxmvl%)zS-RHXyNxP zW(?V`tI2#`WSSjw0;AIOL_216kh8Y8+cC$tO&8k6EWTa*D6=XPXTfoZa}B1u9%pu) zKH)g?0nUUI5V4Z&ktdkFzi@8Y1GFCqPE23BhsBzcV=sg|WBSKEEXTP{K)Db0PG7f& XMQQqzy(}`E8v7t>9Hw*bV|fk$ICfmd delta 570 zcmcccSLnlEp$UGB2^;;5F0)&B@jYZ=V3=%Rq%pa`h-34P%bW`Ak!XTHKF7%BaxyS5 z7_c%hFac>JC~X3yK>!;KlCp%#gJ=-b4$8+yGXmx9q4M_I4+=77OVrcVPN3b)KsPWj z{Nw)zats4Q`)V!5?W?tzSYI&eZQm%wJeh-62dGLHi1mP2e|w-bGdGhi$kh%&Yz4%& zKx_@fARoH}1J)CWL7`&`#O6S3xV=-BS)ZBFZu(9IW_eanKpJnqtHAu9h0$R9T~%g( zX2yu^x|+=AMVMNdrvI{KPGYo~9&5*}zP-baIj(Jb!eM5q=|bC>#kc=G!mP@~IpH|O z%>mQ3k2AXhO#_1I`Nx?LayFcR$V}O8d4k#d3+IJBK&Jq~i|M_4SgbiU_CmNjreEB{ ia-8c0l*_Phdfy%vrRhiZvdD0H?1QLDnErAv%X0vzP-64| diff --git a/packages/cali/package.json b/packages/cali/package.json index 5890819..29dceab 100644 --- a/packages/cali/package.json +++ b/packages/cali/package.json @@ -14,7 +14,6 @@ "@vercel/blob": "^0.27.0", "ai": "^6.0.138", "cac": "^7.0.0", - "cali-tools": "0.3.1", "cosmiconfig": "^9.0.1", "dotenv": "^16.4.5", "gradient-string": "^3.0.0", diff --git a/packages/cali/src/cli/app.ts b/packages/cali/src/cli/app.ts index d396202..8efbe74 100644 --- a/packages/cali/src/cli/app.ts +++ b/packages/cali/src/cli/app.ts @@ -3,7 +3,7 @@ import { cac } from 'cac' import { printRetroBanner } from './banner.js' import { registerQaCommand } from './qa.js' -export function createCli() { +function createCli() { const cli = cac('cali') cli.usage(' [options]') diff --git a/packages/cali/src/cli/qa.ts b/packages/cali/src/cli/qa.ts index 242f19c..c27e83b 100644 --- a/packages/cali/src/cli/qa.ts +++ b/packages/cali/src/cli/qa.ts @@ -42,7 +42,7 @@ function readOptionalNumber(value: unknown, flagName: string) { return parsed } -export function normalizeQaCliOptions(options: QaCommandOptions): QaCliOptions { +function normalizeQaCliOptions(options: QaCommandOptions): QaCliOptions { const platformValue = readOptionalString(options.platform) const platform = platformValue ? normalizePlatform(platformValue) : undefined diff --git a/packages/cali/src/commands/qa.ts b/packages/cali/src/commands/qa.ts index 86256d3..9fbc7f8 100644 --- a/packages/cali/src/commands/qa.ts +++ b/packages/cali/src/commands/qa.ts @@ -1,5 +1,3 @@ -import 'dotenv/config' - import { readdir, stat } from 'node:fs/promises' import path from 'node:path' @@ -15,7 +13,6 @@ import { runQaMobileRole } from '../roles/qa-mobile.js' import { ensureDirectory, humanizeScreenshotLabel, - normalizePlatform, resolveFromCwd, runCommand, } from '../utils.js' @@ -187,10 +184,6 @@ export async function runQaCommand(cli: QaCliOptions) { const cwd = process.cwd() const { config, context } = await resolveEnvironmentContext(cwd, cli) - if (!normalizePlatform(context.platform)) { - throw new Error(`Unsupported platform: ${context.platform}`) - } - await ensureDirectory(context.outputDir) await ensureDirectory(context.screenshotsDir) diff --git a/packages/cali/src/config/load.ts b/packages/cali/src/config/load.ts index f1bd7b6..d7388da 100644 --- a/packages/cali/src/config/load.ts +++ b/packages/cali/src/config/load.ts @@ -147,8 +147,6 @@ export async function loadQaConfig(options: LoadQaConfigOptions): Promise Date: Thu, 26 Mar 2026 16:24:49 +0100 Subject: [PATCH 04/48] refactor: centralize cali model resolution --- packages/cali/README.md | 5 ++- packages/cali/src/config/load.ts | 28 +------------- packages/cali/src/model.ts | 55 ++++++++++++++++++++++++++++ packages/cali/src/roles/qa-mobile.ts | 37 ++----------------- 4 files changed, 63 insertions(+), 62 deletions(-) create mode 100644 packages/cali/src/model.ts diff --git a/packages/cali/README.md b/packages/cali/README.md index b772aef..0fb9942 100644 --- a/packages/cali/README.md +++ b/packages/cali/README.md @@ -31,8 +31,9 @@ cali qa \ - Anthropic direct: `ANTHROPIC_API_KEY` - Anthropic alias: `CLAUDE_API_KEY` -If only Anthropic credentials are present, Cali defaults to `anthropic/claude-sonnet-4.6`. -Otherwise it defaults to `openai/gpt-5.4` through AI Gateway. +Cali defaults to `anthropic/claude-sonnet-4.6`. +If gateway credentials are present, that model is routed through AI Gateway. +Direct provider support in this package is Anthropic only. ## Config diff --git a/packages/cali/src/config/load.ts b/packages/cali/src/config/load.ts index d7388da..d4d11a6 100644 --- a/packages/cali/src/config/load.ts +++ b/packages/cali/src/config/load.ts @@ -4,6 +4,7 @@ import path from 'node:path' import { cosmiconfig } from 'cosmiconfig' import type { QaResolvedConfig } from '../env/types.js' +import { resolveQaModelId } from '../model.js' import { asArray, resolveFromCwd, uniqueStrings } from '../utils.js' import type { CaliQaConfig, PublisherName, QaPresetName, ToolPackName } from './schema.js' import { CaliQaConfigSchema } from './schema.js' @@ -15,31 +16,6 @@ type LoadQaConfigOptions = { model?: string } -function hasGatewayCredentials() { - return Boolean(process.env.AI_GATEWAY_API_KEY || process.env.AI_GATEWAY_KEY) -} - -function hasAnthropicCredentials() { - return Boolean( - process.env.ANTHROPIC_API_KEY || - process.env.ANTHROPIC_AUTH_TOKEN || - process.env.CLAUDE_API_KEY || - process.env.CLAUDE_AUTH_TOKEN - ) -} - -function getDefaultModel() { - if (process.env.QA_MODEL) { - return process.env.QA_MODEL - } - - if (!hasGatewayCredentials() && hasAnthropicCredentials()) { - return 'anthropic/claude-sonnet-4.6' - } - - return 'openai/gpt-5.4' -} - function getBuiltInSkillPaths(cwd: string) { return [path.join(cwd, 'node_modules', 'agent-device', 'skills')] } @@ -156,6 +132,6 @@ export async function loadQaConfig(options: LoadQaConfigOptions): Promise Date: Thu, 26 Mar 2026 16:25:57 +0100 Subject: [PATCH 05/48] refactor: align cali gateway usage with ai sdk --- packages/cali/src/model.ts | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/packages/cali/src/model.ts b/packages/cali/src/model.ts index db63ab1..b4e1bf8 100644 --- a/packages/cali/src/model.ts +++ b/packages/cali/src/model.ts @@ -1,5 +1,4 @@ import { createAnthropic } from '@ai-sdk/anthropic' -import { createGateway, gateway } from 'ai' const DEFAULT_QA_MODEL_ID = 'anthropic/claude-sonnet-4.6' @@ -27,16 +26,20 @@ function stripAnthropicPrefix(modelId: string) { return modelId.startsWith('anthropic/') ? modelId.slice('anthropic/'.length) : modelId } +function ensureGatewayApiKeyAlias() { + if (!process.env.AI_GATEWAY_API_KEY && process.env.AI_GATEWAY_KEY) { + process.env.AI_GATEWAY_API_KEY = process.env.AI_GATEWAY_KEY + } +} + export function resolveQaModelId(configuredModelId?: string) { return configuredModelId ?? process.env.QA_MODEL ?? DEFAULT_QA_MODEL_ID } export function createQaAgentModel(modelId = resolveQaModelId()) { - const gatewayApiKey = process.env.AI_GATEWAY_API_KEY ?? process.env.AI_GATEWAY_KEY - if (hasGatewayCredentials() || isRunningOnVercel()) { - const provider = gatewayApiKey ? createGateway({ apiKey: gatewayApiKey }) : gateway - return provider(modelId) + ensureGatewayApiKeyAlias() + return modelId } if (hasAnthropicCredentials()) { From 7a807e402d45f8ebabb9c1abc36a82f827f8ec50 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 26 Mar 2026 16:27:30 +0100 Subject: [PATCH 06/48] chore: format cali sources --- packages/cali/README.md | 4 +- packages/cali/src/commands/qa.ts | 69 ++++++++++++++------- packages/cali/src/env/eas.ts | 10 +-- packages/cali/src/env/json-file.ts | 2 +- packages/cali/src/env/local.ts | 2 +- packages/cali/src/env/types.ts | 7 ++- packages/cali/src/report/publishers/blob.ts | 3 +- packages/cali/src/report/publishers/file.ts | 14 ++++- packages/cali/src/roles/qa-mobile.ts | 6 +- packages/cali/src/tools/agent-device.ts | 5 +- packages/cali/src/tools/skills.ts | 14 ++--- packages/cali/src/utils.ts | 6 +- 12 files changed, 90 insertions(+), 52 deletions(-) diff --git a/packages/cali/README.md b/packages/cali/README.md index 0fb9942..d95582b 100644 --- a/packages/cali/README.md +++ b/packages/cali/README.md @@ -44,9 +44,7 @@ export default { role: 'qa', preset: 'local-android', skillPaths: ['./.cali/skills'], - extraInstructions: [ - 'Prioritize auth and onboarding flows.', - ], + extraInstructions: ['Prioritize auth and onboarding flows.'], } ``` diff --git a/packages/cali/src/commands/qa.ts b/packages/cali/src/commands/qa.ts index 9fbc7f8..40754a6 100644 --- a/packages/cali/src/commands/qa.ts +++ b/packages/cali/src/commands/qa.ts @@ -10,12 +10,7 @@ import { publishBlobReport } from '../report/publishers/blob.js' import { publishFileReport } from '../report/publishers/file.js' import type { QaReport, QaReportInput, ScreenshotInfo } from '../report/types.js' import { runQaMobileRole } from '../roles/qa-mobile.js' -import { - ensureDirectory, - humanizeScreenshotLabel, - resolveFromCwd, - runCommand, -} from '../utils.js' +import { ensureDirectory, humanizeScreenshotLabel, resolveFromCwd, runCommand } from '../utils.js' async function resolveEnvironmentContext( cwd: string, @@ -54,21 +49,42 @@ async function resolveEnvironmentContext( async function bootstrapApp(context: QaRuntimeContext) { if (context.deviceName) { if (context.platform === 'ios') { - await runCommand('agent-device', ['ensure-simulator', '--platform', 'ios', '--device', context.deviceName, '--boot']) + await runCommand('agent-device', [ + 'ensure-simulator', + '--platform', + 'ios', + '--device', + context.deviceName, + '--boot', + ]) } else { - await runCommand('agent-device', ['boot', '--platform', 'android', '--device', context.deviceName]) + await runCommand('agent-device', [ + 'boot', + '--platform', + 'android', + '--device', + context.deviceName, + ]) } } if (context.platform === 'android') { - let installResult = await runCommand('agent-device', ['install', context.appId, context.artifactPath], { - allowFailure: true, - }) + let installResult = await runCommand( + 'agent-device', + ['install', context.appId, context.artifactPath], + { + allowFailure: true, + } + ) if (!installResult.ok) { - installResult = await runCommand('agent-device', ['reinstall', context.appId, context.artifactPath], { - allowFailure: true, - }) + installResult = await runCommand( + 'agent-device', + ['reinstall', context.appId, context.artifactPath], + { + allowFailure: true, + } + ) } if (!installResult.ok) { @@ -77,12 +93,18 @@ async function bootstrapApp(context: QaRuntimeContext) { ) } } else { - const reinstallResult = await runCommand('agent-device', ['reinstall', context.appId, context.artifactPath], { - allowFailure: true, - }) + const reinstallResult = await runCommand( + 'agent-device', + ['reinstall', context.appId, context.artifactPath], + { + allowFailure: true, + } + ) if (!reinstallResult.ok) { - throw new Error(`Deterministic iOS bootstrap failed during reinstall.\n\n${reinstallResult.stderr || reinstallResult.stdout}`) + throw new Error( + `Deterministic iOS bootstrap failed during reinstall.\n\n${reinstallResult.stderr || reinstallResult.stdout}` + ) } } @@ -91,7 +113,9 @@ async function bootstrapApp(context: QaRuntimeContext) { }) if (!openResult.ok) { - throw new Error(`Deterministic app bootstrap failed during open.\n\n${openResult.stderr || openResult.stdout}`) + throw new Error( + `Deterministic app bootstrap failed during open.\n\n${openResult.stderr || openResult.stdout}` + ) } } @@ -147,7 +171,8 @@ function composeReport( screenshotLabels: reportInput.screenshotLabels ?? [], screenshots: screenshots.map((screenshot) => ({ ...screenshot, - label: screenshotLabelMap.get(screenshot.fileName) ?? humanizeScreenshotLabel(screenshot.fileName), + label: + screenshotLabelMap.get(screenshot.fileName) ?? humanizeScreenshotLabel(screenshot.fileName), })), agentDeviceTrace: agentDeviceTrace.slice(-20), } @@ -212,6 +237,8 @@ export async function runQaCommand(cli: QaCliOptions) { const report = composeReport(config.model, context, reportInput, screenshots, agentDeviceTrace) const publishedReport = await publishReport(report, config.outputPublishers) - console.log(`QA report written to ${resolveFromCwd(cwd, path.join(context.outputDir, 'section.md'))}`) + console.log( + `QA report written to ${resolveFromCwd(cwd, path.join(context.outputDir, 'section.md'))}` + ) console.log(`Overall status: ${publishedReport.overallStatus}`) } diff --git a/packages/cali/src/env/eas.ts b/packages/cali/src/env/eas.ts index 78917e2..c3ee812 100644 --- a/packages/cali/src/env/eas.ts +++ b/packages/cali/src/env/eas.ts @@ -1,8 +1,8 @@ import path from 'node:path' +import { normalizePlatform, parseJson, resolveFromCwd } from '../utils.js' import type { QaCliOptions, QaRuntimeContext } from './types.js' import type { QaResolvedConfig } from './types.js' -import { normalizePlatform, parseJson, resolveFromCwd } from '../utils.js' type ParsedPr = { number?: number @@ -19,9 +19,7 @@ export async function fromEasEnv( ): Promise { const parsedPr = parseJson(process.env.PR_JSON, {}) const platform = - cli.platform ?? - normalizePlatform(process.env.QA_PLATFORM) ?? - config.platformDefaults.platform + cli.platform ?? normalizePlatform(process.env.QA_PLATFORM) ?? config.platformDefaults.platform if (!platform) { throw new Error('EAS adapter requires QA_PLATFORM or a preset platform default.') @@ -62,7 +60,9 @@ export async function fromEasEnv( prTitle: cli.prTitle ?? parsedPr.title, prBody: cli.prBody ?? parsedPr.body, prLabels: Array.isArray(parsedPr.labels) - ? parsedPr.labels.map((label) => label.name).filter((value): value is string => Boolean(value)) + ? parsedPr.labels + .map((label) => label.name) + .filter((value): value is string => Boolean(value)) : [], isDraft: Boolean(parsedPr.draft), taskId: cli.taskId ?? process.env.TASK_ID, diff --git a/packages/cali/src/env/json-file.ts b/packages/cali/src/env/json-file.ts index 8f1bc38..c04577f 100644 --- a/packages/cali/src/env/json-file.ts +++ b/packages/cali/src/env/json-file.ts @@ -3,9 +3,9 @@ import path from 'node:path' import { z } from 'zod' +import { resolveFromCwd } from '../utils.js' import type { QaCliOptions, QaRuntimeContext } from './types.js' import type { QaResolvedConfig } from './types.js' -import { resolveFromCwd } from '../utils.js' const JsonMetadataSchema = z .object({ diff --git a/packages/cali/src/env/local.ts b/packages/cali/src/env/local.ts index 995d36e..864c19b 100644 --- a/packages/cali/src/env/local.ts +++ b/packages/cali/src/env/local.ts @@ -1,8 +1,8 @@ import path from 'node:path' +import { resolveFromCwd } from '../utils.js' import type { QaCliOptions, QaRuntimeContext } from './types.js' import type { QaResolvedConfig } from './types.js' -import { resolveFromCwd } from '../utils.js' export async function fromLocalFlags( cwd: string, diff --git a/packages/cali/src/env/types.ts b/packages/cali/src/env/types.ts index 6446509..9825add 100644 --- a/packages/cali/src/env/types.ts +++ b/packages/cali/src/env/types.ts @@ -1,4 +1,9 @@ -import type { EnvironmentAdapterName, PublisherName, QaPresetName, ToolPackName } from '../config/schema.js' +import type { + EnvironmentAdapterName, + PublisherName, + QaPresetName, + ToolPackName, +} from '../config/schema.js' export type QaPlatform = 'android' | 'ios' diff --git a/packages/cali/src/report/publishers/blob.ts b/packages/cali/src/report/publishers/blob.ts index 0d707c3..3e88066 100644 --- a/packages/cali/src/report/publishers/blob.ts +++ b/packages/cali/src/report/publishers/blob.ts @@ -40,8 +40,7 @@ export async function publishBlobReport({ report }: BlobPublishOptions): Promise blobPathname: blob.pathname, } } catch (unknownError) { - const error = - unknownError instanceof Error ? unknownError : new Error(String(unknownError)) + const error = unknownError instanceof Error ? unknownError : new Error(String(unknownError)) return { ...screenshot, diff --git a/packages/cali/src/report/publishers/file.ts b/packages/cali/src/report/publishers/file.ts index ec845be..9256f5d 100644 --- a/packages/cali/src/report/publishers/file.ts +++ b/packages/cali/src/report/publishers/file.ts @@ -1,5 +1,5 @@ -import path from 'node:path' import { writeFile } from 'node:fs/promises' +import path from 'node:path' import { ensureDirectory } from '../../utils.js' import { renderQaSection } from '../render.js' @@ -16,8 +16,16 @@ export async function publishFileReport({ report }: FilePublishOptions): Promise `${JSON.stringify(report, null, 2)}\n`, 'utf8' ) - await writeFile(path.join(report.context.outputDir, 'section.md'), renderQaSection(report), 'utf8') - await writeFile(path.join(report.context.outputDir, 'status.txt'), `${report.overallStatus}\n`, 'utf8') + await writeFile( + path.join(report.context.outputDir, 'section.md'), + renderQaSection(report), + 'utf8' + ) + await writeFile( + path.join(report.context.outputDir, 'status.txt'), + `${report.overallStatus}\n`, + 'utf8' + ) return report } diff --git a/packages/cali/src/roles/qa-mobile.ts b/packages/cali/src/roles/qa-mobile.ts index 45706ce..a45ef6e 100644 --- a/packages/cali/src/roles/qa-mobile.ts +++ b/packages/cali/src/roles/qa-mobile.ts @@ -1,4 +1,4 @@ -import { ToolLoopAgent, stepCountIs, tool } from 'ai' +import { stepCountIs, tool, ToolLoopAgent } from 'ai' import { z } from 'zod' import type { QaRuntimeContext } from '../env/types.js' @@ -109,7 +109,9 @@ function hasToolActivity( }) } -export async function runQaMobileRole(options: RunQaMobileRoleOptions): Promise { +export async function runQaMobileRole( + options: RunQaMobileRoleOptions +): Promise { const { context, modelId, skillPaths, enabledToolPacks, extraInstructions, prompt } = options const skills = await discoverSkills(skillPaths) const agentDeviceTrace: AgentDeviceTraceEntry[] = [] diff --git a/packages/cali/src/tools/agent-device.ts b/packages/cali/src/tools/agent-device.ts index 7ad0a83..352de54 100644 --- a/packages/cali/src/tools/agent-device.ts +++ b/packages/cali/src/tools/agent-device.ts @@ -16,10 +16,7 @@ export function createAgentDeviceToolPack(options: CreateAgentDeviceToolPackOpti .describe( 'The first agent-device subcommand to run, such as devices, open, snapshot, tap, fill, press, or screenshot.' ), - args: z - .array(z.string()) - .optional() - .describe('Remaining CLI arguments for the subcommand.'), + args: z.array(z.string()).optional().describe('Remaining CLI arguments for the subcommand.'), }) return { diff --git a/packages/cali/src/tools/skills.ts b/packages/cali/src/tools/skills.ts index aad4d94..321b0d0 100644 --- a/packages/cali/src/tools/skills.ts +++ b/packages/cali/src/tools/skills.ts @@ -1,5 +1,5 @@ +import { readdir, readFile } from 'node:fs/promises' import path from 'node:path' -import { readFile, readdir } from 'node:fs/promises' import { tool } from 'ai' import { z } from 'zod' @@ -23,7 +23,10 @@ function parseFrontmatter(content: string) { } const frontmatter = match[1] - const name = frontmatter.match(/^name:\s*(.+)$/m)?.[1]?.trim().replace(/^['"]|['"]$/g, '') + const name = frontmatter + .match(/^name:\s*(.+)$/m)?.[1] + ?.trim() + .replace(/^['"]|['"]$/g, '') const description = frontmatter .match(/^description:\s*(.+)$/m)?.[1] ?.trim() @@ -155,12 +158,7 @@ export function createSkillsToolPack(skills: SkillMetadata[]) { read_skill_file: tool({ description: 'Read a text file inside a previously loaded skill directory.', inputSchema: readSkillFileInputSchema, - execute: async ({ - skillName, - path: relativeFilePath, - startLine = 1, - maxLines = 200, - }) => { + execute: async ({ skillName, path: relativeFilePath, startLine = 1, maxLines = 200 }) => { const skill = findSkill(skills, skillName) if (!loadedSkills.has(skill.name.toLowerCase())) { throw new Error(`Skill must be loaded before reading files: ${skill.name}`) diff --git a/packages/cali/src/utils.ts b/packages/cali/src/utils.ts index 5795c5c..0e3e746 100644 --- a/packages/cali/src/utils.ts +++ b/packages/cali/src/utils.ts @@ -92,7 +92,11 @@ export function trimText(value: string, max = 6000) { } export function uniqueStrings(values: Array) { - return [...new Set(values.filter((value): value is string => Boolean(value?.trim())).map((value) => value.trim()))] + return [ + ...new Set( + values.filter((value): value is string => Boolean(value?.trim())).map((value) => value.trim()) + ), + ] } export function asArray(value: string | string[] | undefined) { From f6e13be06e798e90f9615e8af9af490c13ccae9a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 26 Mar 2026 16:32:15 +0100 Subject: [PATCH 07/48] feat: discover project-installed skills by default --- packages/cali/README.md | 19 ++++++++++++++++++- packages/cali/src/config/load.ts | 2 +- 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/packages/cali/README.md b/packages/cali/README.md index d95582b..9735430 100644 --- a/packages/cali/README.md +++ b/packages/cali/README.md @@ -43,11 +43,28 @@ Create `cali.config.ts` in the project root: export default { role: 'qa', preset: 'local-android', - skillPaths: ['./.cali/skills'], + skillPaths: ['./.agents/skills', './.cali/skills'], extraInstructions: ['Prioritize auth and onboarding flows.'], } ``` +By default, Cali discovers project skills from: + +- `./.agents/skills` +- `./.cali/skills` + +## Installing Skills + +For starter skills, use `npx skills` with the repos we trust: + +```bash +npx skills add callstackincubator/agent-device --agent codex --skill '*' -y +npx skills add callstackincubator/agent-skills --agent codex --skill '*' -y +``` + +This installs project-local skills into `./.agents/skills` and writes `skills-lock.json`. +Those skills are picked up automatically by `cali qa`. + ## Outputs By default the file publisher writes: diff --git a/packages/cali/src/config/load.ts b/packages/cali/src/config/load.ts index d4d11a6..1253b70 100644 --- a/packages/cali/src/config/load.ts +++ b/packages/cali/src/config/load.ts @@ -17,7 +17,7 @@ type LoadQaConfigOptions = { } function getBuiltInSkillPaths(cwd: string) { - return [path.join(cwd, 'node_modules', 'agent-device', 'skills')] + return [path.join(cwd, '.agents', 'skills'), path.join(cwd, '.cali', 'skills')] } function getPresetConfig(cwd: string, presetName: QaPresetName): CaliQaConfig { From 7e6ec399452bf23e1c5133c9011ec60ab4fe6e0c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Mon, 30 Mar 2026 17:52:53 +0200 Subject: [PATCH 08/48] chore: keep a single default skills path --- packages/cali/README.md | 6 +----- packages/cali/src/config/load.ts | 2 +- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/packages/cali/README.md b/packages/cali/README.md index 9735430..c4ac751 100644 --- a/packages/cali/README.md +++ b/packages/cali/README.md @@ -43,15 +43,11 @@ Create `cali.config.ts` in the project root: export default { role: 'qa', preset: 'local-android', - skillPaths: ['./.agents/skills', './.cali/skills'], extraInstructions: ['Prioritize auth and onboarding flows.'], } ``` -By default, Cali discovers project skills from: - -- `./.agents/skills` -- `./.cali/skills` +By default, Cali discovers project skills from `./.agents/skills`. ## Installing Skills diff --git a/packages/cali/src/config/load.ts b/packages/cali/src/config/load.ts index 1253b70..5e4f997 100644 --- a/packages/cali/src/config/load.ts +++ b/packages/cali/src/config/load.ts @@ -17,7 +17,7 @@ type LoadQaConfigOptions = { } function getBuiltInSkillPaths(cwd: string) { - return [path.join(cwd, '.agents', 'skills'), path.join(cwd, '.cali', 'skills')] + return [path.join(cwd, '.agents', 'skills')] } function getPresetConfig(cwd: string, presetName: QaPresetName): CaliQaConfig { From f4ab491eec3debed31f4d56b3c6812557df71e34 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Mon, 30 Mar 2026 17:56:14 +0200 Subject: [PATCH 09/48] feat: discover home directory skills --- packages/cali/README.md | 7 +++++-- packages/cali/src/config/load.ts | 3 ++- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/packages/cali/README.md b/packages/cali/README.md index c4ac751..1a59c2c 100644 --- a/packages/cali/README.md +++ b/packages/cali/README.md @@ -47,7 +47,10 @@ export default { } ``` -By default, Cali discovers project skills from `./.agents/skills`. +By default, Cali discovers skills from: + +- `./.agents/skills` +- `~/.agents/skills` ## Installing Skills @@ -59,7 +62,7 @@ npx skills add callstackincubator/agent-skills --agent codex --skill '*' -y ``` This installs project-local skills into `./.agents/skills` and writes `skills-lock.json`. -Those skills are picked up automatically by `cali qa`. +Project-local and home-directory skills are both picked up automatically by `cali qa`. ## Outputs diff --git a/packages/cali/src/config/load.ts b/packages/cali/src/config/load.ts index 5e4f997..9e0942e 100644 --- a/packages/cali/src/config/load.ts +++ b/packages/cali/src/config/load.ts @@ -1,4 +1,5 @@ import { existsSync } from 'node:fs' +import { homedir } from 'node:os' import path from 'node:path' import { cosmiconfig } from 'cosmiconfig' @@ -17,7 +18,7 @@ type LoadQaConfigOptions = { } function getBuiltInSkillPaths(cwd: string) { - return [path.join(cwd, '.agents', 'skills')] + return [path.join(cwd, '.agents', 'skills'), path.join(homedir(), '.agents', 'skills')] } function getPresetConfig(cwd: string, presetName: QaPresetName): CaliQaConfig { From 0d8ade553649485b7e3af0e16e4b8f4567070ffb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Mon, 30 Mar 2026 18:14:25 +0200 Subject: [PATCH 10/48] fix: pass device selectors during bootstrap --- packages/cali/src/commands/qa.ts | 37 ++++++++++++++------------------ 1 file changed, 16 insertions(+), 21 deletions(-) diff --git a/packages/cali/src/commands/qa.ts b/packages/cali/src/commands/qa.ts index 40754a6..5c1b2f6 100644 --- a/packages/cali/src/commands/qa.ts +++ b/packages/cali/src/commands/qa.ts @@ -47,31 +47,22 @@ async function resolveEnvironmentContext( } async function bootstrapApp(context: QaRuntimeContext) { + const deviceSelectorArgs = context.deviceName + ? ['--platform', context.platform, '--device', context.deviceName] + : ['--platform', context.platform] + if (context.deviceName) { if (context.platform === 'ios') { - await runCommand('agent-device', [ - 'ensure-simulator', - '--platform', - 'ios', - '--device', - context.deviceName, - '--boot', - ]) + await runCommand('agent-device', ['ensure-simulator', ...deviceSelectorArgs, '--boot']) } else { - await runCommand('agent-device', [ - 'boot', - '--platform', - 'android', - '--device', - context.deviceName, - ]) + await runCommand('agent-device', ['boot', ...deviceSelectorArgs]) } } if (context.platform === 'android') { let installResult = await runCommand( 'agent-device', - ['install', context.appId, context.artifactPath], + ['install', ...deviceSelectorArgs, context.appId, context.artifactPath], { allowFailure: true, } @@ -80,7 +71,7 @@ async function bootstrapApp(context: QaRuntimeContext) { if (!installResult.ok) { installResult = await runCommand( 'agent-device', - ['reinstall', context.appId, context.artifactPath], + ['reinstall', ...deviceSelectorArgs, context.appId, context.artifactPath], { allowFailure: true, } @@ -95,7 +86,7 @@ async function bootstrapApp(context: QaRuntimeContext) { } else { const reinstallResult = await runCommand( 'agent-device', - ['reinstall', context.appId, context.artifactPath], + ['reinstall', ...deviceSelectorArgs, context.appId, context.artifactPath], { allowFailure: true, } @@ -108,9 +99,13 @@ async function bootstrapApp(context: QaRuntimeContext) { } } - const openResult = await runCommand('agent-device', ['open', context.appId, '--relaunch'], { - allowFailure: true, - }) + const openResult = await runCommand( + 'agent-device', + ['open', ...deviceSelectorArgs, context.appId, '--relaunch'], + { + allowFailure: true, + } + ) if (!openResult.ok) { throw new Error( From 2aebaee97afb7b47ef1ede926c8794cfc3b79ec1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Mon, 30 Mar 2026 19:04:03 +0200 Subject: [PATCH 11/48] fix: harden cali qa reporting --- packages/cali/src/commands/qa.ts | 90 ++++++++++++++++++++----- packages/cali/src/roles/qa-mobile.ts | 80 +++++++++++++++++++--- packages/cali/src/tools/agent-device.ts | 23 +++++-- 3 files changed, 161 insertions(+), 32 deletions(-) diff --git a/packages/cali/src/commands/qa.ts b/packages/cali/src/commands/qa.ts index 5c1b2f6..01345dc 100644 --- a/packages/cali/src/commands/qa.ts +++ b/packages/cali/src/commands/qa.ts @@ -1,4 +1,4 @@ -import { readdir, stat } from 'node:fs/promises' +import { readdir, rm, stat } from 'node:fs/promises' import path from 'node:path' import { loadQaConfig } from '../config/load.js' @@ -10,8 +10,23 @@ import { publishBlobReport } from '../report/publishers/blob.js' import { publishFileReport } from '../report/publishers/file.js' import type { QaReport, QaReportInput, ScreenshotInfo } from '../report/types.js' import { runQaMobileRole } from '../roles/qa-mobile.js' +import { + DEFAULT_AGENT_DEVICE_SESSION_NAME, + getAgentDeviceSessionArgs, +} from '../tools/agent-device.js' import { ensureDirectory, humanizeScreenshotLabel, resolveFromCwd, runCommand } from '../utils.js' +function printPhase(title: string, detail?: string) { + console.log(detail ? `${title}: ${detail}` : title) +} + +function summarizeReason(text: string) { + return text + .split('\n') + .map((line) => line.trim()) + .find(Boolean) +} + async function resolveEnvironmentContext( cwd: string, cli: QaCliOptions @@ -46,32 +61,50 @@ async function resolveEnvironmentContext( } } -async function bootstrapApp(context: QaRuntimeContext) { +async function runAgentDeviceCommand( + sessionName: string, + command: string, + args: string[], + options: Parameters[2] = {} +) { + return runCommand( + 'agent-device', + [...getAgentDeviceSessionArgs(sessionName), command, ...args], + options + ) +} + +async function bootstrapApp(context: QaRuntimeContext, sessionName: string) { const deviceSelectorArgs = context.deviceName ? ['--platform', context.platform, '--device', context.deviceName] : ['--platform', context.platform] if (context.deviceName) { if (context.platform === 'ios') { - await runCommand('agent-device', ['ensure-simulator', ...deviceSelectorArgs, '--boot']) + await runAgentDeviceCommand(sessionName, 'ensure-simulator', [ + ...deviceSelectorArgs, + '--boot', + ]) } else { - await runCommand('agent-device', ['boot', ...deviceSelectorArgs]) + await runAgentDeviceCommand(sessionName, 'boot', deviceSelectorArgs) } } if (context.platform === 'android') { - let installResult = await runCommand( - 'agent-device', - ['install', ...deviceSelectorArgs, context.appId, context.artifactPath], + let installResult = await runAgentDeviceCommand( + sessionName, + 'install', + [...deviceSelectorArgs, context.appId, context.artifactPath], { allowFailure: true, } ) if (!installResult.ok) { - installResult = await runCommand( - 'agent-device', - ['reinstall', ...deviceSelectorArgs, context.appId, context.artifactPath], + installResult = await runAgentDeviceCommand( + sessionName, + 'reinstall', + [...deviceSelectorArgs, context.appId, context.artifactPath], { allowFailure: true, } @@ -84,9 +117,10 @@ async function bootstrapApp(context: QaRuntimeContext) { ) } } else { - const reinstallResult = await runCommand( - 'agent-device', - ['reinstall', ...deviceSelectorArgs, context.appId, context.artifactPath], + const reinstallResult = await runAgentDeviceCommand( + sessionName, + 'reinstall', + [...deviceSelectorArgs, context.appId, context.artifactPath], { allowFailure: true, } @@ -99,9 +133,10 @@ async function bootstrapApp(context: QaRuntimeContext) { } } - const openResult = await runCommand( - 'agent-device', - ['open', ...deviceSelectorArgs, context.appId, '--relaunch'], + const openResult = await runAgentDeviceCommand( + sessionName, + 'open', + [...deviceSelectorArgs, context.appId, '--relaunch'], { allowFailure: true, } @@ -202,19 +237,31 @@ async function publishReport(report: QaReport, publishers: string[]) { export async function runQaCommand(cli: QaCliOptions) { const cwd = process.cwd() + printPhase('Resolving config') const { config, context } = await resolveEnvironmentContext(cwd, cli) + const sessionName = process.env.AGENT_DEVICE_SESSION ?? DEFAULT_AGENT_DEVICE_SESSION_NAME + printPhase( + 'Preparing output', + `${context.platform} | ${context.deviceName ?? 'bound device'} | ${context.appId}` + ) await ensureDirectory(context.outputDir) + await rm(context.screenshotsDir, { force: true, recursive: true }) await ensureDirectory(context.screenshotsDir) let reportInput: QaReportInput let agentDeviceTrace: QaReport['agentDeviceTrace'] = [] try { - await bootstrapApp(context) + printPhase('Bootstrapping app', context.artifactPath) + await bootstrapApp(context, sessionName) + printPhase('Bootstrap complete') + + printPhase('Running QA agent', config.model) const roleResult = await runQaMobileRole({ context, modelId: config.model, + sessionName, skillPaths: config.skillPaths, enabledToolPacks: config.enabledToolPacks, extraInstructions: config.extraInstructions, @@ -225,15 +272,22 @@ export async function runQaCommand(cli: QaCliOptions) { agentDeviceTrace = roleResult.agentDeviceTrace } catch (unknownError) { const error = unknownError instanceof Error ? unknownError : new Error(String(unknownError)) + printPhase('Run blocked', summarizeReason(error.message)) reportInput = createBlockedReport(error.message) } const screenshots = await listScreenshots(context.screenshotsDir) const report = composeReport(config.model, context, reportInput, screenshots, agentDeviceTrace) + printPhase('Publishing report', config.outputPublishers.join(', ')) const publishedReport = await publishReport(report, config.outputPublishers) console.log( `QA report written to ${resolveFromCwd(cwd, path.join(context.outputDir, 'section.md'))}` ) - console.log(`Overall status: ${publishedReport.overallStatus}`) + const reason = summarizeReason(publishedReport.summary) + console.log( + reason + ? `Overall status: ${publishedReport.overallStatus} (${reason})` + : `Overall status: ${publishedReport.overallStatus}` + ) } diff --git a/packages/cali/src/roles/qa-mobile.ts b/packages/cali/src/roles/qa-mobile.ts index a45ef6e..2c78e91 100644 --- a/packages/cali/src/roles/qa-mobile.ts +++ b/packages/cali/src/roles/qa-mobile.ts @@ -1,4 +1,4 @@ -import { stepCountIs, tool, ToolLoopAgent } from 'ai' +import { generateText, Output, stepCountIs, tool, ToolLoopAgent } from 'ai' import { z } from 'zod' import type { QaRuntimeContext } from '../env/types.js' @@ -15,6 +15,7 @@ import { type RunQaMobileRoleOptions = { context: QaRuntimeContext modelId: string + sessionName: string skillPaths: string[] enabledToolPacks: string[] extraInstructions: string[] @@ -86,6 +87,7 @@ function buildPrompt( '', `Save screenshots into ${context.screenshotsDir}/*.png with short descriptive filenames.`, 'When text visibility matters, prefer a plain snapshot over image-heavy inspection.', + 'Use canonical agent-device commands like back or home directly. Do not emulate navigation with press.', 'Treat bootstrap as already handled. Do not install, reinstall, or open the app yourself.', 'Do not inspect repository source files or modify project code.', 'Finish by calling write_report exactly once.' @@ -109,10 +111,54 @@ function hasToolActivity( }) } +async function synthesizeReportInput( + modelId: string, + context: QaRuntimeContext, + agentDeviceTrace: AgentDeviceTraceEntry[], + extraInstructions: string[], + prompt?: string +) { + const evidence = { + taskPrompt: prompt ?? '', + platform: context.platform, + appId: context.appId, + buildId: context.buildId, + workflowUrl: context.workflowUrl, + screenshotsDir: context.screenshotsDir, + agentDeviceTrace, + } + + const { output } = await generateText({ + model: createQaAgentModel(modelId), + output: Output.object({ + schema: WRITE_REPORT_INPUT_SCHEMA, + name: 'qa_report', + description: 'Structured QA result for a completed mobile QA run.', + }), + prompt: [ + 'Produce the final QA report for a completed mobile QA run.', + 'Base the report only on the provided evidence. Do not invent actions, screenshots, or observations.', + 'If the evidence shows the requested behavior worked, set overallStatus to "passed".', + 'If the evidence is inconclusive, set overallStatus to "unsure".', + 'If the environment was broken, set overallStatus to "blocked".', + prompt?.trim() ? `Task-specific focus:\n${prompt.trim()}` : '', + extraInstructions.length > 0 + ? `Extra instructions:\n${extraInstructions.map((instruction) => `- ${instruction}`).join('\n')}` + : '', + `Evidence:\n${JSON.stringify(evidence, null, 2)}`, + ] + .filter(Boolean) + .join('\n\n'), + }) + + return output satisfies QaReportInput +} + export async function runQaMobileRole( options: RunQaMobileRoleOptions ): Promise { - const { context, modelId, skillPaths, enabledToolPacks, extraInstructions, prompt } = options + const { context, modelId, sessionName, skillPaths, enabledToolPacks, extraInstructions, prompt } = + options const skills = await discoverSkills(skillPaths) const agentDeviceTrace: AgentDeviceTraceEntry[] = [] let reportInput: QaReportInput | undefined @@ -130,7 +176,7 @@ export async function runQaMobileRole( } if (enabledToolPacks.includes('agent-device')) { - Object.assign(tools, createAgentDeviceToolPack({ trace: agentDeviceTrace })) + Object.assign(tools, createAgentDeviceToolPack({ trace: agentDeviceTrace, sessionName })) } tools.write_report = tool({ @@ -153,6 +199,7 @@ export async function runQaMobileRole( 'Use only the provided tool packs and evidence from their results.', 'The CLI already handled deterministic bootstrap. Never install, reinstall, or open the app.', 'Refresh your view with snapshot-style commands after every meaningful UI transition.', + 'Use canonical agent-device commands like back or home directly. Do not emulate them with press.', 'Take screenshots for meaningful states and keep filenames short and descriptive.', 'If the environment is broken or a prerequisite is missing, report blocked checks instead of guessing.', 'If the evidence is visual but not conclusive from text automation, prefer overallStatus "unsure".', @@ -187,12 +234,27 @@ export async function runQaMobileRole( }) if (!reportInput) { - reportInput = { - overallStatus: 'blocked', - summary: result.text || 'The agent completed without calling write_report.', - checked: ['Produce a mobile QA report'], - issues: ['The write_report tool was not called by the agent.'], - nextSteps: ['Inspect the run logs and tighten the QA role instructions.'], + try { + reportInput = await synthesizeReportInput( + modelId, + context, + agentDeviceTrace, + extraInstructions, + prompt + ) + } catch (unknownError) { + const error = unknownError instanceof Error ? unknownError : new Error(String(unknownError)) + + reportInput = { + overallStatus: 'blocked', + summary: result.text || 'The agent completed without calling write_report.', + checked: ['Produce a mobile QA report'], + issues: [ + 'The write_report tool was not called by the agent.', + `Fallback report synthesis failed: ${error.message}`, + ], + nextSteps: ['Inspect the run logs and tighten the QA role instructions.'], + } } } diff --git a/packages/cali/src/tools/agent-device.ts b/packages/cali/src/tools/agent-device.ts index 352de54..b84f4c6 100644 --- a/packages/cali/src/tools/agent-device.ts +++ b/packages/cali/src/tools/agent-device.ts @@ -4,32 +4,45 @@ import { z } from 'zod' import type { AgentDeviceTraceEntry } from '../report/types.js' import { parseJson, runCommand, trimText } from '../utils.js' +export const DEFAULT_AGENT_DEVICE_SESSION_NAME = 'default' +const DEFAULT_AGENT_DEVICE_SESSION_LOCK = 'strip' + type CreateAgentDeviceToolPackOptions = { trace: AgentDeviceTraceEntry[] + sessionName?: string +} + +export function getAgentDeviceSessionArgs( + sessionName = process.env.AGENT_DEVICE_SESSION ?? DEFAULT_AGENT_DEVICE_SESSION_NAME +) { + return ['--session', sessionName, '--session-lock', DEFAULT_AGENT_DEVICE_SESSION_LOCK] } export function createAgentDeviceToolPack(options: CreateAgentDeviceToolPackOptions) { - const { trace } = options + const { trace, sessionName } = options + const sessionArgs = getAgentDeviceSessionArgs(sessionName) const inputSchema = z.object({ command: z .string() .describe( - 'The first agent-device subcommand to run, such as devices, open, snapshot, tap, fill, press, or screenshot.' + 'The first agent-device subcommand to run, such as snapshot, get, press, click, fill, type, wait, back, home, or screenshot.' ), args: z.array(z.string()).optional().describe('Remaining CLI arguments for the subcommand.'), }) return { agent_device: tool({ - description: 'Run an agent-device command for mobile UI automation and screenshot capture.', + description: + 'Run an agent-device command for mobile UI automation and screenshot capture. Use canonical subcommands like back or home directly; do not emulate them with press.', inputSchema, execute: async ({ command, args = [] }) => { - const result = await runCommand('agent-device', [command, ...args], { + const fullCommand = [...sessionArgs, command, ...args] + const result = await runCommand('agent-device', fullCommand, { allowFailure: true, }) trace.push({ - command: [command, ...args].join(' '), + command: fullCommand.join(' '), ok: result.ok, exitCode: result.exitCode, stdout: trimText(result.stdout, 4000), From 01fed4a69932d0f5d6ec92f8ca3d68336a90efc2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Mon, 30 Mar 2026 19:55:35 +0200 Subject: [PATCH 12/48] chore: update model to mini --- packages/cali/README.md | 2 +- packages/cali/src/model.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/cali/README.md b/packages/cali/README.md index 1a59c2c..b759282 100644 --- a/packages/cali/README.md +++ b/packages/cali/README.md @@ -31,7 +31,7 @@ cali qa \ - Anthropic direct: `ANTHROPIC_API_KEY` - Anthropic alias: `CLAUDE_API_KEY` -Cali defaults to `anthropic/claude-sonnet-4.6`. +Cali defaults to `openai/gpt-5.4-mini`. If gateway credentials are present, that model is routed through AI Gateway. Direct provider support in this package is Anthropic only. diff --git a/packages/cali/src/model.ts b/packages/cali/src/model.ts index b4e1bf8..a4d61ca 100644 --- a/packages/cali/src/model.ts +++ b/packages/cali/src/model.ts @@ -1,6 +1,6 @@ import { createAnthropic } from '@ai-sdk/anthropic' -const DEFAULT_QA_MODEL_ID = 'anthropic/claude-sonnet-4.6' +const DEFAULT_QA_MODEL_ID = 'openai/gpt-5.4-mini' function hasGatewayCredentials() { return Boolean(process.env.AI_GATEWAY_API_KEY || process.env.AI_GATEWAY_KEY) From 7a152a4418025ca1d0ce748d2fb0237508a78dad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Tue, 31 Mar 2026 10:37:50 +0200 Subject: [PATCH 13/48] fix: clarify qa docs and report timing --- packages/cali/README.md | 25 +++++++++++++++++++++++-- packages/cali/src/roles/qa-mobile.ts | 11 +++++++++-- 2 files changed, 32 insertions(+), 4 deletions(-) diff --git a/packages/cali/README.md b/packages/cali/README.md index b759282..bc472d9 100644 --- a/packages/cali/README.md +++ b/packages/cali/README.md @@ -1,6 +1,6 @@ # cali -Cali v2 is a QA-oriented CLI for mobile app review runs. The first shipped role is `cali qa`, which splits deterministic bootstrap from the agent phase and standardizes the resulting QA report. +Cali v2 is a QA-oriented CLI for mobile app review runs. Today it ships `cali qa`, a role-based command that keeps deterministic app bootstrap outside the agent, lets the agent inspect and navigate the UI with a narrow tool surface, and publishes a reusable QA report for local and CI workflows. ## Current scope @@ -11,7 +11,20 @@ Cali v2 is a QA-oriented CLI for mobile app review runs. The first shipped role - publishers: `blob`, `file` - additive `--prompt` -## Example +The surface is intentionally role-based today. Interactive follow-up flows can be added later without changing the preset, adapter, or publisher model. + +## Core Concepts + +- preset: bundles a role with default platform settings, an environment adapter, skill paths, enabled tool packs, and publishers +- environment adapter: resolves the normalized runtime context for a run, including platform, artifact path, app id, build metadata, and output directories +- tool pack: the explicit set of tools exposed to the role, such as `skills` metadata access or `agent-device` UI automation +- publisher: decides how the QA report is exposed after the run, such as writing files locally or uploading blobs + +## Examples + +### Local preset + +For local presets, `--artifact` is required and `appId` must come from `--app-id`, `config.appId`, or `APPLICATION_ID`. Cali does not infer it from `app.json` yet. `--device` is optional. ```bash cali qa \ @@ -22,6 +35,14 @@ cali qa \ --prompt "verify the onboarding copy on Screen B" ``` +### EAS preset + +For `eas-mobile-pr`, you usually do not pass `--artifact` or `--app-id` on the command line. The EAS adapter reads them from `APP_PATH` and `APPLICATION_ID`, and it reads the platform from `QA_PLATFORM` unless you override it with `--platform`. + +```bash +cali qa --preset eas-mobile-pr +``` + ## Credentials `cali qa` supports two model auth paths: diff --git a/packages/cali/src/roles/qa-mobile.ts b/packages/cali/src/roles/qa-mobile.ts index 2c78e91..9be9693 100644 --- a/packages/cali/src/roles/qa-mobile.ts +++ b/packages/cali/src/roles/qa-mobile.ts @@ -28,6 +28,8 @@ type QaMobileRoleResult = { } const EMPTY_INPUT_SCHEMA = z.object({}) +const MAX_AGENT_STEPS = 12 +const REPORT_BUFFER_STEPS = 2 const WRITE_REPORT_INPUT_SCHEMA = z.object({ overallStatus: z.enum(['passed', 'failed', 'blocked', 'not_tested', 'unsure']), summary: z.string(), @@ -213,12 +215,17 @@ export async function runQaMobileRole( instructions, tools, toolChoice: 'required', - stopWhen: stepCountIs(12), + stopWhen: stepCountIs(MAX_AGENT_STEPS), prepareStep: async ({ steps, stepNumber }) => { const hasWrittenReport = hasToolActivity(steps, 'write_report') const hasUsedDeviceTools = hasToolActivity(steps, 'agent_device') - if (hasWrittenReport || !hasUsedDeviceTools || stepNumber < 6) { + // Reserve the final steps for report emission if the model keeps exploring. + if ( + hasWrittenReport || + !hasUsedDeviceTools || + stepNumber < MAX_AGENT_STEPS - REPORT_BUFFER_STEPS + ) { return {} } From fded9353f0584a4c54579128a7f0ecd8e3554346 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Tue, 31 Mar 2026 10:39:29 +0200 Subject: [PATCH 14/48] docs: simplify qa example --- packages/cali/README.md | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/cali/README.md b/packages/cali/README.md index bc472d9..eb5d8fc 100644 --- a/packages/cali/README.md +++ b/packages/cali/README.md @@ -31,7 +31,6 @@ cali qa \ --preset local-ios \ --artifact ./artifacts/MyApp.app \ --app-id com.example.myapp \ - --device "iPhone 16" \ --prompt "verify the onboarding copy on Screen B" ``` From 08b91e033d482b4ae9e07e1fbbfd790950af831b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Tue, 7 Apr 2026 12:20:48 +0200 Subject: [PATCH 15/48] feat: stream qa agent progress --- packages/cali/src/commands/qa.ts | 39 ++++++++++++++++++++++++++++ packages/cali/src/roles/qa-mobile.ts | 35 +++++++++++++++++++++++-- 2 files changed, 72 insertions(+), 2 deletions(-) diff --git a/packages/cali/src/commands/qa.ts b/packages/cali/src/commands/qa.ts index 01345dc..25b86a3 100644 --- a/packages/cali/src/commands/qa.ts +++ b/packages/cali/src/commands/qa.ts @@ -27,6 +27,39 @@ function summarizeReason(text: string) { .find(Boolean) } +function formatAgentStepDetail(event: { + stepNumber: number + finishReason: string + toolNames: string[] + totalTokens?: number +}) { + const details = [`step ${event.stepNumber}`, `finish=${event.finishReason}`] + + if (event.toolNames.length > 0) { + details.push(`tools=${event.toolNames.join(',')}`) + } + + if (event.totalTokens != null) { + details.push(`tokens=${event.totalTokens}`) + } + + return details.join(' | ') +} + +function formatAgentFinishDetail(event: { + stepCount: number + finishReason: string + totalTokens?: number +}) { + const details = [`steps=${event.stepCount}`, `finish=${event.finishReason}`] + + if (event.totalTokens != null) { + details.push(`tokens=${event.totalTokens}`) + } + + return details.join(' | ') +} + async function resolveEnvironmentContext( cwd: string, cli: QaCliOptions @@ -266,6 +299,12 @@ export async function runQaCommand(cli: QaCliOptions) { enabledToolPacks: config.enabledToolPacks, extraInstructions: config.extraInstructions, prompt: cli.prompt, + onAgentStep: (event) => { + printPhase('QA agent step complete', formatAgentStepDetail(event)) + }, + onAgentFinish: (event) => { + printPhase('QA agent finished', formatAgentFinishDetail(event)) + }, }) reportInput = roleResult.reportInput diff --git a/packages/cali/src/roles/qa-mobile.ts b/packages/cali/src/roles/qa-mobile.ts index 9be9693..f87d4de 100644 --- a/packages/cali/src/roles/qa-mobile.ts +++ b/packages/cali/src/roles/qa-mobile.ts @@ -20,6 +20,13 @@ type RunQaMobileRoleOptions = { enabledToolPacks: string[] extraInstructions: string[] prompt?: string + onAgentStep?: (event: { + stepNumber: number + finishReason: string + toolNames: string[] + totalTokens?: number + }) => void + onAgentFinish?: (event: { stepCount: number; finishReason: string; totalTokens?: number }) => void } type QaMobileRoleResult = { @@ -159,8 +166,17 @@ async function synthesizeReportInput( export async function runQaMobileRole( options: RunQaMobileRoleOptions ): Promise { - const { context, modelId, sessionName, skillPaths, enabledToolPacks, extraInstructions, prompt } = - options + const { + context, + modelId, + sessionName, + skillPaths, + enabledToolPacks, + extraInstructions, + prompt, + onAgentStep, + onAgentFinish, + } = options const skills = await discoverSkills(skillPaths) const agentDeviceTrace: AgentDeviceTraceEntry[] = [] let reportInput: QaReportInput | undefined @@ -216,6 +232,13 @@ export async function runQaMobileRole( tools, toolChoice: 'required', stopWhen: stepCountIs(MAX_AGENT_STEPS), + onFinish: async ({ steps, finishReason, totalUsage }) => { + onAgentFinish?.({ + stepCount: steps.length, + finishReason, + totalTokens: totalUsage.totalTokens, + }) + }, prepareStep: async ({ steps, stepNumber }) => { const hasWrittenReport = hasToolActivity(steps, 'write_report') const hasUsedDeviceTools = hasToolActivity(steps, 'agent_device') @@ -238,6 +261,14 @@ export async function runQaMobileRole( const result = await agent.generate({ prompt: buildPrompt(context, skills, extraInstructions, prompt), + onStepFinish: async ({ stepNumber, finishReason, toolCalls, usage }) => { + onAgentStep?.({ + stepNumber: stepNumber + 1, + finishReason, + toolNames: toolCalls.map((toolCall) => toolCall.toolName), + totalTokens: usage.totalTokens, + }) + }, }) if (!reportInput) { From e6fb561950c97bd454c22821db5b12a1904dc32e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Tue, 7 Apr 2026 12:29:02 +0200 Subject: [PATCH 16/48] feat: add github actions qa preset --- AGENTS.md | 243 ++++++++++++++++++++++++ README.md | 10 +- packages/cali/README.md | 18 +- packages/cali/src/cli/qa.ts | 5 +- packages/cali/src/commands/qa.ts | 8 + packages/cali/src/config/load.ts | 26 ++- packages/cali/src/config/schema.ts | 14 +- packages/cali/src/env/github-actions.ts | 120 ++++++++++++ 8 files changed, 433 insertions(+), 11 deletions(-) create mode 100644 AGENTS.md create mode 100644 packages/cali/src/env/github-actions.ts diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..ec65da6 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,243 @@ +# AGENTS.md + +Minimal operating guide for AI coding agents in this repo. + +## First 60 Seconds + +- Classify the task: + - Info-only: do not edit code or run checks unless needed. + - Code change: make the smallest scoped edit and run the lightest relevant validation. +- Read at most 3 files first: + - the owning command, role, or env adapter + - one shared config or helper file + - one relevant doc file if the task changes CLI behavior +- Define concrete success criteria before editing. +- Prefer package docs and code over guessing current behavior. + +## Repo Shape + +- `packages/cali`: the standalone CLI, currently centered on `cali qa` +- `packages/tools`: reusable Cali tools for other agent runtimes +- `packages/mcp-server`: MCP wrapper over the tools package + +## Cali v2 Architecture + +The `cali` package is role-oriented. + +- CLI entry: + - `packages/cali/src/cli.ts` + - `packages/cali/src/cli/app.ts` + - `packages/cali/src/cli/qa.ts` +- Runtime orchestration: + - `packages/cali/src/commands/qa.ts` +- Config and presets: + - `packages/cali/src/config/schema.ts` + - `packages/cali/src/config/load.ts` +- Environment adapters: + - `packages/cali/src/env/local.ts` + - `packages/cali/src/env/eas.ts` + - `packages/cali/src/env/github-actions.ts` + - `packages/cali/src/env/json-file.ts` +- Role implementation: + - `packages/cali/src/roles/qa-mobile.ts` +- Tool packs: + - `packages/cali/src/tools/agent-device.ts` + - `packages/cali/src/tools/skills.ts` +- Reports and publishers: + - `packages/cali/src/report/types.ts` + - `packages/cali/src/report/render.ts` + - `packages/cali/src/report/publishers/file.ts` + - `packages/cali/src/report/publishers/blob.ts` + +## Current Role + +- Implemented: + - `qa` +- Role contract: + - deterministic bootstrap stays in the CLI command + - the agent inspects and navigates the app only after bootstrap + - reports are written through the standard QA report schema + - publishers decide how outputs are exposed + +## Preset Model + +Presets should stay thin. + +- A preset should define: + - role + - environment adapter + - default platform settings + - enabled tool packs + - output publishers + - extra instructions +- A preset should not add one-off special logic to the command path. +- If a new remote environment is needed, prefer a new env adapter over branching inside `qa.ts`. + +Current built-in presets: + +- `local-android` +- `local-ios` +- `eas-mobile-pr` +- `github-actions-pr` + +## Adding a New Environment Adapter + +Use this order: + +1. Add the adapter name to `packages/cali/src/config/schema.ts`. +2. Implement `packages/cali/src/env/.ts`. +3. Route it in `packages/cali/src/commands/qa.ts`. +4. Add a preset in `packages/cali/src/config/load.ts`. +5. Update `packages/cali/README.md`. + +Keep adapters small. They should only normalize context: + +- platform +- artifact path +- app id +- build and workflow metadata +- output directories +- device name +- PR or task metadata + +## Adding a New Role + +Use this order: + +1. Add the role module under `packages/cali/src/roles/`. +2. Keep bootstrap outside the role. +3. Expose only explicit tool packs. +4. Define one structured output contract. +5. Document the role prompt and intended runtime in `packages/cali/README.md`. + +Avoid role-specific branching in shared helpers when a small role module will do. + +## Validation + +- For `packages/cali` TypeScript changes: + - `bunx tsc --noEmit -p packages/cali/tsconfig.json` +- For build or runtime changes: + - `bun run build` +- For CLI surface changes: + - `node packages/cali/dist/index.js qa --help` +- For env adapter or preset changes: + - run at least one preset smoke command if credentials and local tooling exist + +Do not commit generated `artifacts/` output. + +## Documentation Touch Points + +When behavior changes, review: + +- `packages/cali/README.md` +- `README.md` if package positioning changed +- this file if agent workflow or repo guidance changed +- PR body if the branch story materially changed + +## Agent Roadmap + +These roles are good next candidates for remote environments such as CI, ephemeral sandboxes, and device-backed mobile workflows. + +### `qa` (implemented) + +Use for: + +- user-visible flow verification on installed builds +- PR smoke checks in EAS or GitHub Actions +- screenshot-backed QA summaries + +Prompt shape: + +```text +You are a mobile QA agent for React Native and Expo builds. +Treat bootstrap as already handled. +Inspect the app with the provided device tools only. +Prioritize user-visible flows and concise acceptance criteria from PR or task metadata. +Capture screenshots for meaningful states. +Do not inspect source code or modify the repository. +Finish by writing one structured QA report. +``` + +### `dev-mobile` (planned) + +Use for: + +- sandboxed implementation tasks on React Native or Expo apps +- targeted bug fixes with local validation +- feature work where code inspection and editing are allowed + +Prompt shape: + +```text +You are a React Native and Expo development agent working in a sandboxed repository. +Start from the user task, repo scripts, and current project state. +Inspect only the files needed to understand the issue. +Make the smallest code change that solves the problem. +Prefer existing scripts and project conventions over ad hoc commands. +Validate with the lightest checks that prove the change. +Summarize the fix, the files changed, and the exact validation run. +``` + +### `review-mobile-pr` (planned) + +Use for: + +- PR review in CI or a sandbox without making changes +- regression and risk analysis for React Native or Expo code +- architecture, platform, and maintainability review + +Prompt shape: + +```text +You are a mobile code review agent for React Native and Expo pull requests. +Review the diff and any attached build or QA context. +Prioritize correctness risks, platform regressions, missing validation, and maintainability concerns. +Do not suggest broad rewrites when a targeted concern is enough. +Output findings first, ordered by severity, with file references and short rationale. +If there are no concrete findings, say so explicitly and note any residual risk. +``` + +### `ci-debug-mobile` (planned) + +Use for: + +- failing GitHub Actions, EAS, or other remote mobile pipelines +- broken build, install, test, or runtime automation in CI +- diagnosis-first workflows where logs and artifacts matter more than code edits + +Prompt shape: + +```text +You are a CI debugging agent for React Native and Expo workflows. +Start from the failing job, logs, and environment metadata. +Identify the first concrete failure, not downstream noise. +Explain whether the root cause is code, configuration, environment, credentials, or infrastructure. +If a code or config fix is safe and local, implement the smallest one. +Otherwise, produce a short unblock plan with the exact environment inputs required. +``` + +### `upgrade-mobile` (planned) + +Use for: + +- React Native upgrades +- Expo SDK upgrades +- library compatibility sweeps in a sandbox + +Prompt shape: + +```text +You are a React Native and Expo upgrade agent. +Treat upgrades as compatibility work, not greenfield refactoring. +Start from the requested target version and the current repo state. +Apply the minimum set of changes needed to get the project building and typechecking again. +Call out manual follow-ups separately from the automated patch. +Validate with version-appropriate build and type checks. +``` + +## Keep It Simple + +- Prefer one small adapter over branching logic inside the command. +- Prefer one role file over abstract role frameworks. +- Prefer docs that explain current behavior clearly over speculative docs for future behavior. +- If a planned role needs a different tool surface or output contract, document it first before implementing shared abstractions. diff --git a/README.md b/README.md index deb1aa1..675e197 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@

- 🪄 An AI agent for building React Native apps + 🪄 AI agents for React Native and Expo workflows

--- @@ -18,7 +18,7 @@ $ npx cali ## Wait, what? -Cali is an AI agent that helps you build React Native apps. It takes all the utilities and functions of a React Native CLI and exposes them as tools to an LLM. +Cali is a set of AI-agent surfaces for React Native and Expo workflows. It exposes mobile development and QA utilities to LLMs so they can help with deterministic setup, app inspection, debugging, and other agent-friendly tasks. Thanks to that, an LLM can help you with your React Native app development, without the need to remember commands, spending time troubleshooting errors, and in the future, much more. @@ -26,10 +26,12 @@ Thanks to that, an LLM can help you with your React Native app development, with You can use Cali in three ways: -- **standalone** - [`cali`](./packages/cali/README.md) - AI agent that runs in your terminal. Ready to use out of the box. +- **standalone** - [`cali`](./packages/cali/README.md) - QA-oriented CLI for mobile app review runs in local and CI environments. - **with Vercel AI SDK** - [`cali-tools`](./packages/tools/README.md) - Collection of tools for building React Native apps with [Vercel AI SDK](https://github.com/ai-sdk/ai) - **with Claude, Zed, and other MCP Clients** - [`cali-mcp-server`](./packages/mcp-server/README.md) - [MCP server](http://modelcontextprotocol.io) for using Cali with Claude and other compatible environments +For a repo-oriented guide to the current Cali v2 architecture and planned mobile-agent roles, see [`AGENTS.md`](./AGENTS.md). + ## What can it do? Cali is still in the early stages of development, but it already supports: @@ -69,4 +71,4 @@ Feel free to open an issue or a discussion to suggest ideas or report bugs. Happ Cali is an open source project and will always remain free to use. If you think it's cool, please star it 🌟. [Callstack](https://callstack.com) is a group of React and React Native geeks, contact us at [hello@callstack.com](mailto:hello@callstack.com) if you need any help with these or just want to say hi! -Like the project? ⚛️ [Join the team](https://callstack.com/careers/?utm_campaign=Senior_RN&utm_source=github&utm_medium=readme) who does amazing stuff for clients and drives React Native Open Source! 🔥 +Like the project? ⚛️ [Join the team](https://callstack.com/careers/?utm_campaign=Senior_RN&utm_source=github&utm_medium=readme) who does amazing stuff for clients and drives React Native Open Source! 🔥 diff --git a/packages/cali/README.md b/packages/cali/README.md index eb5d8fc..37b145e 100644 --- a/packages/cali/README.md +++ b/packages/cali/README.md @@ -5,8 +5,8 @@ Cali v2 is a QA-oriented CLI for mobile app review runs. Today it ships `cali qa ## Current scope - `cali qa` -- presets: `eas-mobile-pr`, `local-android`, `local-ios` -- environment adapters: EAS env, local flags, JSON context +- presets: `eas-mobile-pr`, `github-actions-pr`, `local-android`, `local-ios` +- environment adapters: EAS env, GitHub Actions env, local flags, JSON context - tool packs: `skills`, `agent-device` - publishers: `blob`, `file` - additive `--prompt` @@ -42,6 +42,16 @@ For `eas-mobile-pr`, you usually do not pass `--artifact` or `--app-id` on the c cali qa --preset eas-mobile-pr ``` +### GitHub Actions preset + +For `github-actions-pr`, you usually do not pass `--artifact` or `--app-id` on the command line. The adapter reads the artifact from `APP_PATH` or `QA_ARTIFACT_PATH`, reads the app id from `APPLICATION_ID`, derives the workflow URL from GitHub Actions environment variables, and reads pull request metadata from `GITHUB_EVENT_PATH`. + +```bash +cali qa --preset github-actions-pr +``` + +The GitHub Actions and EAS presets both require `QA_PLATFORM` unless you override it with `--platform`. + ## Credentials `cali qa` supports two model auth paths: @@ -84,6 +94,10 @@ npx skills add callstackincubator/agent-skills --agent codex --skill '*' -y This installs project-local skills into `./.agents/skills` and writes `skills-lock.json`. Project-local and home-directory skills are both picked up automatically by `cali qa`. +## Repo Guide + +For implementation details, preset guidance, and the roadmap for additional Cali roles in CI or sandbox environments, see [`AGENTS.md`](../../AGENTS.md). + ## Outputs By default the file publisher writes: diff --git a/packages/cali/src/cli/qa.ts b/packages/cali/src/cli/qa.ts index c27e83b..3a2d452 100644 --- a/packages/cali/src/cli/qa.ts +++ b/packages/cali/src/cli/qa.ts @@ -75,7 +75,10 @@ function normalizeQaCliOptions(options: QaCommandOptions): QaCliOptions { export function registerQaCommand(cli: ReturnType, printBanner: () => void) { cli .command('qa', 'Run the mobile QA role') - .option('--preset ', 'Built-in preset: eas-mobile-pr, local-android, local-ios') + .option( + '--preset ', + 'Built-in preset: eas-mobile-pr, github-actions-pr, local-android, local-ios' + ) .option('--config ', 'Path to cali.config.ts') .option('--prompt ', 'Add task-specific QA intent') .option('--json ', 'Load normalized environment context from JSON') diff --git a/packages/cali/src/commands/qa.ts b/packages/cali/src/commands/qa.ts index 25b86a3..88ca7a0 100644 --- a/packages/cali/src/commands/qa.ts +++ b/packages/cali/src/commands/qa.ts @@ -3,6 +3,7 @@ import path from 'node:path' import { loadQaConfig } from '../config/load.js' import { fromEasEnv } from '../env/eas.js' +import { fromGitHubActionsEnv } from '../env/github-actions.js' import { fromJsonFile } from '../env/json-file.js' import { fromLocalFlags } from '../env/local.js' import type { QaCliOptions, QaRuntimeContext } from '../env/types.js' @@ -88,6 +89,13 @@ async function resolveEnvironmentContext( } } + if (config.environmentAdapter === 'github-actions-env') { + return { + config, + context: await fromGitHubActionsEnv(cwd, config, cli), + } + } + return { config, context: await fromLocalFlags(cwd, config, cli), diff --git a/packages/cali/src/config/load.ts b/packages/cali/src/config/load.ts index 9e0942e..14f2ca9 100644 --- a/packages/cali/src/config/load.ts +++ b/packages/cali/src/config/load.ts @@ -21,6 +21,19 @@ function getBuiltInSkillPaths(cwd: string) { return [path.join(cwd, '.agents', 'skills'), path.join(homedir(), '.agents', 'skills')] } +function getDefaultEnvironmentAdapter(presetName: QaPresetName) { + switch (presetName) { + case 'eas-mobile-pr': + return 'eas-env' as const + case 'github-actions-pr': + return 'github-actions-env' as const + case 'local-ios': + case 'local-android': + default: + return 'local-flags' as const + } +} + function getPresetConfig(cwd: string, presetName: QaPresetName): CaliQaConfig { const enabledToolPacks: ToolPackName[] = ['skills', 'agent-device'] const outputPublishers: PublisherName[] = ['blob', 'file'] @@ -42,6 +55,16 @@ function getPresetConfig(cwd: string, presetName: QaPresetName): CaliQaConfig { 'Treat the repository as a black box and avoid source inspection unless the config explicitly says otherwise.', ], } + case 'github-actions-pr': + return { + ...common, + preset: presetName, + environmentAdapter: 'github-actions-env', + extraInstructions: [ + 'Infer concise acceptance criteria from GitHub pull request metadata and prioritize user-visible flows.', + 'Treat the repository as a black box and avoid source inspection unless the config explicitly says otherwise.', + ], + } case 'local-ios': return { ...common, @@ -124,8 +147,7 @@ export async function loadQaConfig(options: LoadQaConfigOptions): Promise { + const eventPath = process.env.GITHUB_EVENT_PATH + + if (!eventPath) { + return {} + } + + const content = await readFile(eventPath, 'utf8') + return JSON.parse(content) as GitHubEventPayload +} + +function createWorkflowUrl() { + const serverUrl = process.env.GITHUB_SERVER_URL + const repository = process.env.GITHUB_REPOSITORY + const runId = process.env.GITHUB_RUN_ID + + if (!serverUrl || !repository || !runId) { + return '' + } + + return `${serverUrl}/${repository}/actions/runs/${runId}` +} + +function readLabelNames(labels: GitHubLabel[] | undefined) { + return Array.isArray(labels) + ? labels.map((label) => label.name).filter((value): value is string => Boolean(value)) + : [] +} + +export async function fromGitHubActionsEnv( + cwd: string, + config: QaResolvedConfig, + cli: QaCliOptions +): Promise { + const event = await readGitHubEventPayload() + const pullRequest = event.pull_request + const issue = event.issue + const platform = + cli.platform ?? normalizePlatform(process.env.QA_PLATFORM) ?? config.platformDefaults.platform + + if (!platform) { + throw new Error('GitHub Actions adapter requires QA_PLATFORM or a preset platform default.') + } + + const outputDir = resolveFromCwd( + cwd, + cli.outputDir ?? process.env.QA_OUTPUT_DIR ?? config.outputDir ?? path.join('artifacts', 'qa') + ) + const artifactPath = cli.artifactPath ?? process.env.APP_PATH ?? process.env.QA_ARTIFACT_PATH + const appId = cli.appId ?? config.appId ?? process.env.APPLICATION_ID + + if (!artifactPath) { + throw new Error('GitHub Actions adapter requires APP_PATH, QA_ARTIFACT_PATH, or --artifact.') + } + + if (!appId) { + throw new Error('GitHub Actions adapter requires APPLICATION_ID or --app-id.') + } + + return { + platform, + artifactPath: resolveFromCwd(cwd, artifactPath), + appId, + buildId: + cli.buildId ?? + process.env.BUILD_ID ?? + process.env.GITHUB_RUN_ID ?? + process.env.GITHUB_SHA ?? + 'github-actions-run', + workflowUrl: cli.workflowUrl ?? createWorkflowUrl(), + outputDir, + screenshotsDir: path.join(outputDir, 'screenshots'), + deviceName: + cli.deviceName ?? + process.env.DEVICE_NAME ?? + (platform === 'ios' + ? process.env.AGENT_DEVICE_IOS_DEVICE + : process.env.AGENT_DEVICE_ANDROID_DEVICE), + metadata: { + prNumber: cli.prNumber ?? pullRequest?.number ?? issue?.number, + prTitle: cli.prTitle ?? pullRequest?.title ?? issue?.title, + prBody: cli.prBody ?? pullRequest?.body ?? issue?.body, + prLabels: readLabelNames(pullRequest?.labels ?? issue?.labels), + isDraft: Boolean(pullRequest?.draft), + taskId: cli.taskId ?? process.env.GITHUB_REF_NAME, + taskTitle: cli.taskTitle, + taskBody: cli.taskBody, + }, + } +} From 597be804f6b11f269c252b66f5008bcf36cf4dad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Tue, 7 Apr 2026 14:25:58 +0200 Subject: [PATCH 17/48] chore: add cali qa helper scripts --- AGENTS.md | 19 +++++++++++++++++++ README.md | 1 + packages/cali/README.md | 21 +++++++++++++++++++++ packages/cali/package.json | 14 +++++++++++++- 4 files changed, 54 insertions(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index ec65da6..9a36860 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -125,6 +125,25 @@ Avoid role-specific branching in shared helpers when a small role module will do Do not commit generated `artifacts/` output. +## Handy Scripts + +When working in `packages/cali`, prefer the package scripts over reconstructing CLI commands: + +- built bundle: + - `bun run qa -- --help` + - `bun run qa:local:android -- --artifact ./app.apk --app-id com.example.app` + - `bun run qa:local:ios -- --artifact ./MyApp.app --app-id com.example.app` + - `bun run qa:eas` + - `bun run qa:gha` + - `bun run qa:json -- ./qa-context.json` +- source/dev loop: + - `bun run dev:qa -- --help` + - `bun run dev:qa:local:android -- --artifact ./app.apk --app-id com.example.app` + - `bun run dev:qa:local:ios -- --artifact ./MyApp.app --app-id com.example.app` + - `bun run dev:qa:eas` + - `bun run dev:qa:gha` + - `bun run dev:qa:json -- ./qa-context.json` + ## Documentation Touch Points When behavior changes, review: diff --git a/README.md b/README.md index 675e197..d9f8b7e 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,7 @@ You can use Cali in three ways: - **with Claude, Zed, and other MCP Clients** - [`cali-mcp-server`](./packages/mcp-server/README.md) - [MCP server](http://modelcontextprotocol.io) for using Cali with Claude and other compatible environments For a repo-oriented guide to the current Cali v2 architecture and planned mobile-agent roles, see [`AGENTS.md`](./AGENTS.md). +For the standalone CLI’s current presets and package scripts, see [`packages/cali/README.md`](./packages/cali/README.md). ## What can it do? diff --git a/packages/cali/README.md b/packages/cali/README.md index 37b145e..ecee6ed 100644 --- a/packages/cali/README.md +++ b/packages/cali/README.md @@ -82,6 +82,27 @@ By default, Cali discovers skills from: - `./.agents/skills` - `~/.agents/skills` +## Package Scripts + +The `cali` package exposes handy scripts for the currently implemented `qa` role. +Pass additional CLI flags after `--`. + +- `bun run qa -- --help` +- `bun run qa:local:android -- --artifact ./app.apk --app-id com.example.app` +- `bun run qa:local:ios -- --artifact ./MyApp.app --app-id com.example.app` +- `bun run qa:eas` +- `bun run qa:gha` +- `bun run qa:json -- ./qa-context.json` + +For development against source instead of the built bundle: + +- `bun run dev:qa -- --help` +- `bun run dev:qa:local:android -- --artifact ./app.apk --app-id com.example.app` +- `bun run dev:qa:local:ios -- --artifact ./MyApp.app --app-id com.example.app` +- `bun run dev:qa:eas` +- `bun run dev:qa:gha` +- `bun run dev:qa:json -- ./qa-context.json` + ## Installing Skills For starter skills, use `npx skills` with the repos we trust: diff --git a/packages/cali/package.json b/packages/cali/package.json index 29dceab..dae3ae8 100644 --- a/packages/cali/package.json +++ b/packages/cali/package.json @@ -7,7 +7,19 @@ "scripts": { "build": "rslib build", "dev": "node --import=tsx ./src/cli.ts", - "start": "node ./dist/index.js" + "start": "node ./dist/index.js", + "qa": "node ./dist/index.js qa", + "qa:eas": "node ./dist/index.js qa --preset eas-mobile-pr", + "qa:gha": "node ./dist/index.js qa --preset github-actions-pr", + "qa:json": "node ./dist/index.js qa --json", + "qa:local:android": "node ./dist/index.js qa --preset local-android", + "qa:local:ios": "node ./dist/index.js qa --preset local-ios", + "dev:qa": "node --import=tsx ./src/cli.ts qa", + "dev:qa:eas": "node --import=tsx ./src/cli.ts qa --preset eas-mobile-pr", + "dev:qa:gha": "node --import=tsx ./src/cli.ts qa --preset github-actions-pr", + "dev:qa:json": "node --import=tsx ./src/cli.ts qa --json", + "dev:qa:local:android": "node --import=tsx ./src/cli.ts qa --preset local-android", + "dev:qa:local:ios": "node --import=tsx ./src/cli.ts qa --preset local-ios" }, "dependencies": { "@ai-sdk/anthropic": "^3.0.64", From d75fb3d884deb303bd10d0ff6ca58534b29bb8a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Tue, 7 Apr 2026 16:52:26 +0200 Subject: [PATCH 18/48] refactor: unify cali env and context loading --- AGENTS.md | 63 ++++----- README.md | 2 +- packages/cali/README.md | 76 +++++------ packages/cali/package.json | 16 +-- packages/cali/src/cli/qa.ts | 17 +-- packages/cali/src/commands/qa.ts | 27 +--- packages/cali/src/config/load.ts | 61 +++------ packages/cali/src/config/schema.ts | 35 +++-- .../src/env/{json-file.ts => context-file.ts} | 26 ++-- packages/cali/src/env/eas.ts | 73 ----------- packages/cali/src/env/github-actions.ts | 120 ------------------ packages/cali/src/env/local.ts | 10 +- packages/cali/src/env/types.ts | 14 +- 13 files changed, 143 insertions(+), 397 deletions(-) rename packages/cali/src/env/{json-file.ts => context-file.ts} (75%) delete mode 100644 packages/cali/src/env/eas.ts delete mode 100644 packages/cali/src/env/github-actions.ts diff --git a/AGENTS.md b/AGENTS.md index 9a36860..8c4f4b7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,7 +8,7 @@ Minimal operating guide for AI coding agents in this repo. - Info-only: do not edit code or run checks unless needed. - Code change: make the smallest scoped edit and run the lightest relevant validation. - Read at most 3 files first: - - the owning command, role, or env adapter + - the owning command, role, or context loader - one shared config or helper file - one relevant doc file if the task changes CLI behavior - Define concrete success criteria before editing. @@ -30,14 +30,12 @@ The `cali` package is role-oriented. - `packages/cali/src/cli/qa.ts` - Runtime orchestration: - `packages/cali/src/commands/qa.ts` -- Config and presets: +- Config and env defaults: - `packages/cali/src/config/schema.ts` - `packages/cali/src/config/load.ts` -- Environment adapters: +- Runtime context loaders: - `packages/cali/src/env/local.ts` - - `packages/cali/src/env/eas.ts` - - `packages/cali/src/env/github-actions.ts` - - `packages/cali/src/env/json-file.ts` + - `packages/cali/src/env/context-file.ts` - Role implementation: - `packages/cali/src/roles/qa-mobile.ts` - Tool packs: @@ -59,38 +57,37 @@ The `cali` package is role-oriented. - reports are written through the standard QA report schema - publishers decide how outputs are exposed -## Preset Model +## Env Model -Presets should stay thin. +Envs should stay thin. -- A preset should define: +- An env should define: - role - - environment adapter - default platform settings - enabled tool packs - output publishers - extra instructions -- A preset should not add one-off special logic to the command path. -- If a new remote environment is needed, prefer a new env adapter over branching inside `qa.ts`. +- An env should not add one-off special logic to the command path. +- Runtime context should come from one normalized JSON file plus CLI overrides, not from workflow-specific `process.env` scraping. +- If a new remote workflow is needed, prefer generating the same JSON context upstream rather than adding another workflow-specific loader. -Current built-in presets: +Current built-in envs: - `local-android` - `local-ios` -- `eas-mobile-pr` -- `github-actions-pr` +- `mobile-pr` -## Adding a New Environment Adapter +## Runtime Context -Use this order: +Use this order when changing the runtime context contract: -1. Add the adapter name to `packages/cali/src/config/schema.ts`. -2. Implement `packages/cali/src/env/.ts`. -3. Route it in `packages/cali/src/commands/qa.ts`. -4. Add a preset in `packages/cali/src/config/load.ts`. +1. Update the normalized schema in `packages/cali/src/env/context-file.ts`. +2. Keep CLI flags as explicit overrides for that schema. +3. Update `packages/cali/src/env/local.ts` only if local fallback behavior changes. +4. Update env defaults in `packages/cali/src/config/load.ts` if the role behavior changes. 5. Update `packages/cali/README.md`. -Keep adapters small. They should only normalize context: +Keep the context small and explicit. It should cover: - platform - artifact path @@ -120,8 +117,8 @@ Avoid role-specific branching in shared helpers when a small role module will do - `bun run build` - For CLI surface changes: - `node packages/cali/dist/index.js qa --help` -- For env adapter or preset changes: - - run at least one preset smoke command if credentials and local tooling exist +- For env or context contract changes: + - run at least one env smoke command if credentials and local tooling exist Do not commit generated `artifacts/` output. @@ -131,18 +128,14 @@ When working in `packages/cali`, prefer the package scripts over reconstructing - built bundle: - `bun run qa -- --help` - - `bun run qa:local:android -- --artifact ./app.apk --app-id com.example.app` - - `bun run qa:local:ios -- --artifact ./MyApp.app --app-id com.example.app` - - `bun run qa:eas` - - `bun run qa:gha` - - `bun run qa:json -- ./qa-context.json` + - `bun run qa:env:local:android -- --artifact ./app.apk --app-id com.example.app` + - `bun run qa:env:local:ios -- --artifact ./MyApp.app --app-id com.example.app` + - `bun run qa:env:mobile-pr -- --context ./qa-context.json` - source/dev loop: - `bun run dev:qa -- --help` - - `bun run dev:qa:local:android -- --artifact ./app.apk --app-id com.example.app` - - `bun run dev:qa:local:ios -- --artifact ./MyApp.app --app-id com.example.app` - - `bun run dev:qa:eas` - - `bun run dev:qa:gha` - - `bun run dev:qa:json -- ./qa-context.json` + - `bun run dev:qa:env:local:android -- --artifact ./app.apk --app-id com.example.app` + - `bun run dev:qa:env:local:ios -- --artifact ./MyApp.app --app-id com.example.app` + - `bun run dev:qa:env:mobile-pr -- --context ./qa-context.json` ## Documentation Touch Points @@ -256,7 +249,7 @@ Validate with version-appropriate build and type checks. ## Keep It Simple -- Prefer one small adapter over branching logic inside the command. +- Prefer one normalized context contract over multiple workflow-specific loaders. - Prefer one role file over abstract role frameworks. - Prefer docs that explain current behavior clearly over speculative docs for future behavior. - If a planned role needs a different tool surface or output contract, document it first before implementing shared abstractions. diff --git a/README.md b/README.md index d9f8b7e..4700a17 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ You can use Cali in three ways: - **with Claude, Zed, and other MCP Clients** - [`cali-mcp-server`](./packages/mcp-server/README.md) - [MCP server](http://modelcontextprotocol.io) for using Cali with Claude and other compatible environments For a repo-oriented guide to the current Cali v2 architecture and planned mobile-agent roles, see [`AGENTS.md`](./AGENTS.md). -For the standalone CLI’s current presets and package scripts, see [`packages/cali/README.md`](./packages/cali/README.md). +For the standalone CLI’s current env model, context file contract, and package scripts, see [`packages/cali/README.md`](./packages/cali/README.md). ## What can it do? diff --git a/packages/cali/README.md b/packages/cali/README.md index ecee6ed..0fd4cec 100644 --- a/packages/cali/README.md +++ b/packages/cali/README.md @@ -5,52 +5,60 @@ Cali v2 is a QA-oriented CLI for mobile app review runs. Today it ships `cali qa ## Current scope - `cali qa` -- presets: `eas-mobile-pr`, `github-actions-pr`, `local-android`, `local-ios` -- environment adapters: EAS env, GitHub Actions env, local flags, JSON context +- envs: `mobile-pr`, `local-android`, `local-ios` +- runtime context: one JSON context file plus CLI flag overrides - tool packs: `skills`, `agent-device` - publishers: `blob`, `file` - additive `--prompt` -The surface is intentionally role-based today. Interactive follow-up flows can be added later without changing the preset, adapter, or publisher model. +The surface is intentionally role-based today. Interactive follow-up flows can be added later without changing the env, context, or publisher model. ## Core Concepts -- preset: bundles a role with default platform settings, an environment adapter, skill paths, enabled tool packs, and publishers -- environment adapter: resolves the normalized runtime context for a run, including platform, artifact path, app id, build metadata, and output directories +- env: bundles a role with default platform settings, skill paths, enabled tool packs, publishers, and default instructions +- context file: the explicit JSON input that defines the normalized runtime context for a run, including platform, artifact path, app id, build metadata, and output directories - tool pack: the explicit set of tools exposed to the role, such as `skills` metadata access or `agent-device` UI automation - publisher: decides how the QA report is exposed after the run, such as writing files locally or uploading blobs ## Examples -### Local preset +### Local env -For local presets, `--artifact` is required and `appId` must come from `--app-id`, `config.appId`, or `APPLICATION_ID`. Cali does not infer it from `app.json` yet. `--device` is optional. +For local envs, `--artifact` is required and `appId` must come from `--app-id` or `config.appId`. Cali does not infer it from `app.json` yet. `--device` is optional. ```bash cali qa \ - --preset local-ios \ + --env local-ios \ --artifact ./artifacts/MyApp.app \ --app-id com.example.myapp \ --prompt "verify the onboarding copy on Screen B" ``` -### EAS preset - -For `eas-mobile-pr`, you usually do not pass `--artifact` or `--app-id` on the command line. The EAS adapter reads them from `APP_PATH` and `APPLICATION_ID`, and it reads the platform from `QA_PLATFORM` unless you override it with `--platform`. - -```bash -cali qa --preset eas-mobile-pr +### Remote env with a context file + +For remote environments such as GitHub Actions, EAS workflows, or custom sandboxes, write one normalized JSON context file and override fields only when needed. `mobile-pr` expects that file. + +```json +{ + "platform": "android", + "artifactPath": "./artifacts/app.apk", + "appId": "com.example.myapp", + "buildId": "gha-run-123", + "workflowUrl": "https://github.com/acme/mobile/actions/runs/123", + "metadata": { + "prNumber": 42, + "prTitle": "Fix onboarding CTA", + "prLabels": ["mobile", "qa"], + "isDraft": false + } +} ``` -### GitHub Actions preset - -For `github-actions-pr`, you usually do not pass `--artifact` or `--app-id` on the command line. The adapter reads the artifact from `APP_PATH` or `QA_ARTIFACT_PATH`, reads the app id from `APPLICATION_ID`, derives the workflow URL from GitHub Actions environment variables, and reads pull request metadata from `GITHUB_EVENT_PATH`. - ```bash -cali qa --preset github-actions-pr +cali qa --env mobile-pr --context ./qa-context.json ``` -The GitHub Actions and EAS presets both require `QA_PLATFORM` unless you override it with `--platform`. +Flags always win over the context file. For example, `--platform ios` or `--output-dir ./artifacts/custom` overrides the JSON value. ## Credentials @@ -61,9 +69,7 @@ The GitHub Actions and EAS presets both require `QA_PLATFORM` unless you overrid - Anthropic direct: `ANTHROPIC_API_KEY` - Anthropic alias: `CLAUDE_API_KEY` -Cali defaults to `openai/gpt-5.4-mini`. -If gateway credentials are present, that model is routed through AI Gateway. -Direct provider support in this package is Anthropic only. +Cali defaults to `openai/gpt-5.4-mini`. If gateway credentials are present, that model is routed through AI Gateway. Direct provider support in this package is Anthropic only. ## Config @@ -72,7 +78,8 @@ Create `cali.config.ts` in the project root: ```ts export default { role: 'qa', - preset: 'local-android', + env: 'local-android', + contextPath: './qa-context.json', extraInstructions: ['Prioritize auth and onboarding flows.'], } ``` @@ -84,24 +91,19 @@ By default, Cali discovers skills from: ## Package Scripts -The `cali` package exposes handy scripts for the currently implemented `qa` role. -Pass additional CLI flags after `--`. +The `cali` package exposes handy scripts for the currently implemented `qa` role. Pass additional CLI flags after `--`. - `bun run qa -- --help` -- `bun run qa:local:android -- --artifact ./app.apk --app-id com.example.app` -- `bun run qa:local:ios -- --artifact ./MyApp.app --app-id com.example.app` -- `bun run qa:eas` -- `bun run qa:gha` -- `bun run qa:json -- ./qa-context.json` +- `bun run qa:env:local:android -- --artifact ./app.apk --app-id com.example.app` +- `bun run qa:env:local:ios -- --artifact ./MyApp.app --app-id com.example.app` +- `bun run qa:env:mobile-pr -- --context ./qa-context.json` For development against source instead of the built bundle: - `bun run dev:qa -- --help` -- `bun run dev:qa:local:android -- --artifact ./app.apk --app-id com.example.app` -- `bun run dev:qa:local:ios -- --artifact ./MyApp.app --app-id com.example.app` -- `bun run dev:qa:eas` -- `bun run dev:qa:gha` -- `bun run dev:qa:json -- ./qa-context.json` +- `bun run dev:qa:env:local:android -- --artifact ./app.apk --app-id com.example.app` +- `bun run dev:qa:env:local:ios -- --artifact ./MyApp.app --app-id com.example.app` +- `bun run dev:qa:env:mobile-pr -- --context ./qa-context.json` ## Installing Skills @@ -117,7 +119,7 @@ Project-local and home-directory skills are both picked up automatically by `cal ## Repo Guide -For implementation details, preset guidance, and the roadmap for additional Cali roles in CI or sandbox environments, see [`AGENTS.md`](../../AGENTS.md). +For implementation details, env guidance, and the roadmap for additional Cali roles in CI or sandbox environments, see [`AGENTS.md`](../../AGENTS.md). ## Outputs diff --git a/packages/cali/package.json b/packages/cali/package.json index dae3ae8..f1505e3 100644 --- a/packages/cali/package.json +++ b/packages/cali/package.json @@ -9,17 +9,13 @@ "dev": "node --import=tsx ./src/cli.ts", "start": "node ./dist/index.js", "qa": "node ./dist/index.js qa", - "qa:eas": "node ./dist/index.js qa --preset eas-mobile-pr", - "qa:gha": "node ./dist/index.js qa --preset github-actions-pr", - "qa:json": "node ./dist/index.js qa --json", - "qa:local:android": "node ./dist/index.js qa --preset local-android", - "qa:local:ios": "node ./dist/index.js qa --preset local-ios", + "qa:env:local:android": "node ./dist/index.js qa --env local-android", + "qa:env:local:ios": "node ./dist/index.js qa --env local-ios", + "qa:env:mobile-pr": "node ./dist/index.js qa --env mobile-pr", "dev:qa": "node --import=tsx ./src/cli.ts qa", - "dev:qa:eas": "node --import=tsx ./src/cli.ts qa --preset eas-mobile-pr", - "dev:qa:gha": "node --import=tsx ./src/cli.ts qa --preset github-actions-pr", - "dev:qa:json": "node --import=tsx ./src/cli.ts qa --json", - "dev:qa:local:android": "node --import=tsx ./src/cli.ts qa --preset local-android", - "dev:qa:local:ios": "node --import=tsx ./src/cli.ts qa --preset local-ios" + "dev:qa:env:local:android": "node --import=tsx ./src/cli.ts qa --env local-android", + "dev:qa:env:local:ios": "node --import=tsx ./src/cli.ts qa --env local-ios", + "dev:qa:env:mobile-pr": "node --import=tsx ./src/cli.ts qa --env mobile-pr" }, "dependencies": { "@ai-sdk/anthropic": "^3.0.64", diff --git a/packages/cali/src/cli/qa.ts b/packages/cali/src/cli/qa.ts index 3a2d452..37e7edd 100644 --- a/packages/cali/src/cli/qa.ts +++ b/packages/cali/src/cli/qa.ts @@ -5,10 +5,10 @@ import type { QaCliOptions } from '../env/types.js' import { normalizePlatform } from '../utils.js' type QaCommandOptions = { - preset?: string + env?: string config?: string prompt?: string - json?: string + context?: string platform?: string artifact?: string appId?: string @@ -51,10 +51,10 @@ function normalizeQaCliOptions(options: QaCommandOptions): QaCliOptions { } return { - presetName: readOptionalString(options.preset) as QaCliOptions['presetName'], + envName: readOptionalString(options.env) as QaCliOptions['envName'], configPath: readOptionalString(options.config), prompt: readOptionalString(options.prompt), - jsonPath: readOptionalString(options.json), + contextPath: readOptionalString(options.context), platform, artifactPath: readOptionalString(options.artifact), appId: readOptionalString(options.appId), @@ -75,13 +75,10 @@ function normalizeQaCliOptions(options: QaCommandOptions): QaCliOptions { export function registerQaCommand(cli: ReturnType, printBanner: () => void) { cli .command('qa', 'Run the mobile QA role') - .option( - '--preset ', - 'Built-in preset: eas-mobile-pr, github-actions-pr, local-android, local-ios' - ) + .option('--env ', 'Built-in env: mobile-pr, local-android, local-ios') .option('--config ', 'Path to cali.config.ts') .option('--prompt ', 'Add task-specific QA intent') - .option('--json ', 'Load normalized environment context from JSON') + .option('--context ', 'Load normalized QA context from JSON') .option('--platform ', 'android or ios') .option('--artifact ', 'App artifact path (.apk, .aab, .app, .ipa)') .option('--app-id ', 'Application identifier / package name') @@ -97,7 +94,7 @@ export function registerQaCommand(cli: ReturnType, printBanner: () = .option('--task-body ', 'Task body') .option('--model ', 'Override the QA model') .example( - 'qa --preset local-ios --artifact ./artifacts/MyApp.app --app-id com.example.myapp --prompt "verify the onboarding copy on Screen B"' + 'qa --env local-ios --artifact ./artifacts/MyApp.app --app-id com.example.myapp --prompt "verify the onboarding copy on Screen B"' ) .action(async (options) => { printBanner() diff --git a/packages/cali/src/commands/qa.ts b/packages/cali/src/commands/qa.ts index 88ca7a0..eb79f45 100644 --- a/packages/cali/src/commands/qa.ts +++ b/packages/cali/src/commands/qa.ts @@ -2,9 +2,7 @@ import { readdir, rm, stat } from 'node:fs/promises' import path from 'node:path' import { loadQaConfig } from '../config/load.js' -import { fromEasEnv } from '../env/eas.js' -import { fromGitHubActionsEnv } from '../env/github-actions.js' -import { fromJsonFile } from '../env/json-file.js' +import { fromContextFile } from '../env/context-file.js' import { fromLocalFlags } from '../env/local.js' import type { QaCliOptions, QaRuntimeContext } from '../env/types.js' import { publishBlobReport } from '../report/publishers/blob.js' @@ -68,31 +66,14 @@ async function resolveEnvironmentContext( const config = await loadQaConfig({ cwd, configPath: cli.configPath, - presetName: cli.presetName, + envName: cli.envName, model: cli.model, }) - if (cli.jsonPath || config.environmentAdapter === 'json-file') { - return { - config: { - ...config, - environmentAdapter: 'json-file', - }, - context: await fromJsonFile(cwd, config, cli), - } - } - - if (config.environmentAdapter === 'eas-env') { - return { - config, - context: await fromEasEnv(cwd, config, cli), - } - } - - if (config.environmentAdapter === 'github-actions-env') { + if (cli.contextPath || config.contextPath || config.envName === 'mobile-pr') { return { config, - context: await fromGitHubActionsEnv(cwd, config, cli), + context: await fromContextFile(cwd, config, cli), } } diff --git a/packages/cali/src/config/load.ts b/packages/cali/src/config/load.ts index 14f2ca9..e935099 100644 --- a/packages/cali/src/config/load.ts +++ b/packages/cali/src/config/load.ts @@ -7,13 +7,13 @@ import { cosmiconfig } from 'cosmiconfig' import type { QaResolvedConfig } from '../env/types.js' import { resolveQaModelId } from '../model.js' import { asArray, resolveFromCwd, uniqueStrings } from '../utils.js' -import type { CaliQaConfig, PublisherName, QaPresetName, ToolPackName } from './schema.js' -import { CaliQaConfigSchema } from './schema.js' +import type { CaliQaConfig, PublisherName, QaEnvName, ToolPackName } from './schema.js' +import { CaliQaConfigSchema, normalizeQaEnvName } from './schema.js' type LoadQaConfigOptions = { cwd: string configPath?: string - presetName?: QaPresetName + envName?: QaEnvName model?: string } @@ -21,20 +21,7 @@ function getBuiltInSkillPaths(cwd: string) { return [path.join(cwd, '.agents', 'skills'), path.join(homedir(), '.agents', 'skills')] } -function getDefaultEnvironmentAdapter(presetName: QaPresetName) { - switch (presetName) { - case 'eas-mobile-pr': - return 'eas-env' as const - case 'github-actions-pr': - return 'github-actions-env' as const - case 'local-ios': - case 'local-android': - default: - return 'local-flags' as const - } -} - -function getPresetConfig(cwd: string, presetName: QaPresetName): CaliQaConfig { +function getEnvConfig(cwd: string, envName: QaEnvName): CaliQaConfig { const enabledToolPacks: ToolPackName[] = ['skills', 'agent-device'] const outputPublishers: PublisherName[] = ['blob', 'file'] const common = { @@ -44,32 +31,20 @@ function getPresetConfig(cwd: string, presetName: QaPresetName): CaliQaConfig { outputPublishers, } - switch (presetName) { - case 'eas-mobile-pr': - return { - ...common, - preset: presetName, - environmentAdapter: 'eas-env', - extraInstructions: [ - 'Infer concise acceptance criteria from PR metadata and prioritize user-visible flows.', - 'Treat the repository as a black box and avoid source inspection unless the config explicitly says otherwise.', - ], - } - case 'github-actions-pr': + switch (envName) { + case 'mobile-pr': return { ...common, - preset: presetName, - environmentAdapter: 'github-actions-env', + env: envName, extraInstructions: [ - 'Infer concise acceptance criteria from GitHub pull request metadata and prioritize user-visible flows.', + 'Infer concise acceptance criteria from pull request or task metadata and prioritize user-visible flows.', 'Treat the repository as a black box and avoid source inspection unless the config explicitly says otherwise.', ], } case 'local-ios': return { ...common, - preset: presetName, - environmentAdapter: 'local-flags', + env: envName, platformDefaults: { platform: 'ios', }, @@ -81,8 +56,7 @@ function getPresetConfig(cwd: string, presetName: QaPresetName): CaliQaConfig { default: return { ...common, - preset: presetName, - environmentAdapter: 'local-flags', + env: envName, platformDefaults: { platform: 'android', }, @@ -96,9 +70,9 @@ function getPresetConfig(cwd: string, presetName: QaPresetName): CaliQaConfig { function mergeConfig(base: CaliQaConfig, override: CaliQaConfig): CaliQaConfig { return { role: override.role ?? base.role ?? 'qa', - preset: override.preset ?? base.preset, - environmentAdapter: override.environmentAdapter ?? base.environmentAdapter, + env: override.env ?? base.env, appId: override.appId ?? base.appId, + contextPath: override.contextPath ?? base.contextPath, platformDefaults: { ...base.platformDefaults, ...override.platformDefaults, @@ -140,15 +114,16 @@ async function loadConfigFile(cwd: string, explicitPath?: string): Promise { - const { cwd, configPath, presetName: cliPresetName, model } = options + const { cwd, configPath, envName: cliEnvName, model } = options const fileConfig = await loadConfigFile(cwd, configPath) - const presetName = cliPresetName ?? fileConfig.preset ?? 'local-android' - const presetConfig = getPresetConfig(cwd, presetName) - const merged = mergeConfig(presetConfig, fileConfig) + const envName = cliEnvName ?? normalizeQaEnvName(fileConfig.env) ?? 'local-android' + const envConfig = getEnvConfig(cwd, envName) + const merged = mergeConfig(envConfig, fileConfig) return { - environmentAdapter: merged.environmentAdapter ?? getDefaultEnvironmentAdapter(presetName), + envName, appId: merged.appId, + contextPath: merged.contextPath ? resolveFromCwd(cwd, merged.contextPath) : undefined, platformDefaults: merged.platformDefaults ?? {}, outputDir: merged.outputDir, skillPaths: uniqueStrings(merged.skillPaths ?? []), diff --git a/packages/cali/src/config/schema.ts b/packages/cali/src/config/schema.ts index 8ef7bbe..95d278b 100644 --- a/packages/cali/src/config/schema.ts +++ b/packages/cali/src/config/schema.ts @@ -1,28 +1,28 @@ import { z } from 'zod' -export const QaPresetNameSchema = z.enum([ - 'eas-mobile-pr', - 'github-actions-pr', - 'local-android', - 'local-ios', -]) -export const EnvironmentAdapterNameSchema = z.enum([ - 'eas-env', - 'github-actions-env', - 'local-flags', - 'json-file', -]) -export const ToolPackNameSchema = z.enum(['skills', 'agent-device']) -export const PublisherNameSchema = z.enum(['file', 'blob']) +const QaEnvNameSchema = z.enum(['mobile-pr', 'local-android', 'local-ios']) +const ToolPackNameSchema = z.enum(['skills', 'agent-device']) +const PublisherNameSchema = z.enum(['file', 'blob']) const QaPlatformSchema = z.enum(['android', 'ios']) const StringArraySchema = z.union([z.string(), z.array(z.string())]).optional() +export function normalizeQaEnvName(value?: string): QaEnvName | undefined { + switch (value) { + case 'mobile-pr': + case 'local-android': + case 'local-ios': + return value + default: + return undefined + } +} + export const CaliQaConfigSchema = z.object({ role: z.literal('qa').optional(), - preset: QaPresetNameSchema.optional(), - environmentAdapter: EnvironmentAdapterNameSchema.optional(), + env: QaEnvNameSchema.optional(), appId: z.string().optional(), + contextPath: z.string().optional(), platformDefaults: z .object({ platform: QaPlatformSchema.optional(), @@ -37,8 +37,7 @@ export const CaliQaConfigSchema = z.object({ model: z.string().optional(), }) -export type QaPresetName = z.infer -export type EnvironmentAdapterName = z.infer +export type QaEnvName = z.infer export type ToolPackName = z.infer export type PublisherName = z.infer export type CaliQaConfig = z.infer diff --git a/packages/cali/src/env/json-file.ts b/packages/cali/src/env/context-file.ts similarity index 75% rename from packages/cali/src/env/json-file.ts rename to packages/cali/src/env/context-file.ts index c04577f..af5cc94 100644 --- a/packages/cali/src/env/json-file.ts +++ b/packages/cali/src/env/context-file.ts @@ -4,10 +4,9 @@ import path from 'node:path' import { z } from 'zod' import { resolveFromCwd } from '../utils.js' -import type { QaCliOptions, QaRuntimeContext } from './types.js' -import type { QaResolvedConfig } from './types.js' +import type { QaCliOptions, QaResolvedConfig, QaRuntimeContext } from './types.js' -const JsonMetadataSchema = z +const ContextMetadataSchema = z .object({ prNumber: z.number().optional(), prTitle: z.string().optional(), @@ -20,7 +19,7 @@ const JsonMetadataSchema = z }) .optional() -const JsonContextSchema = z.object({ +const QaContextFileSchema = z.object({ platform: z.enum(['android', 'ios']), artifactPath: z.string(), appId: z.string().optional(), @@ -28,22 +27,23 @@ const JsonContextSchema = z.object({ workflowUrl: z.string().optional(), outputDir: z.string().optional(), deviceName: z.string().optional(), - metadata: JsonMetadataSchema, + metadata: ContextMetadataSchema, }) -export async function fromJsonFile( +export async function fromContextFile( cwd: string, config: QaResolvedConfig, cli: QaCliOptions ): Promise { - if (!cli.jsonPath) { - throw new Error('JSON adapter requires --json.') + const contextPath = cli.contextPath ?? config.contextPath + + if (!contextPath) { + throw new Error('Context file mode requires --context or config.contextPath.') } - const absolutePath = resolveFromCwd(cwd, cli.jsonPath) + const absolutePath = resolveFromCwd(cwd, contextPath) const content = await readFile(absolutePath, 'utf8') - const parsed = JsonContextSchema.parse(JSON.parse(content)) - + const parsed = QaContextFileSchema.parse(JSON.parse(content)) const outputDir = resolveFromCwd( cwd, cli.outputDir ?? parsed.outputDir ?? config.outputDir ?? path.join('artifacts', 'qa') @@ -51,14 +51,14 @@ export async function fromJsonFile( const appId = cli.appId ?? config.appId ?? parsed.appId if (!appId) { - throw new Error('JSON adapter requires `appId` in the JSON file, config, or --app-id.') + throw new Error('Context file requires `appId` in the JSON file, config, or --app-id.') } return { platform: cli.platform ?? parsed.platform, artifactPath: resolveFromCwd(cwd, cli.artifactPath ?? parsed.artifactPath), appId, - buildId: cli.buildId ?? parsed.buildId ?? 'json-build', + buildId: cli.buildId ?? parsed.buildId ?? 'context-build', workflowUrl: cli.workflowUrl ?? parsed.workflowUrl ?? '', outputDir, screenshotsDir: path.join(outputDir, 'screenshots'), diff --git a/packages/cali/src/env/eas.ts b/packages/cali/src/env/eas.ts deleted file mode 100644 index c3ee812..0000000 --- a/packages/cali/src/env/eas.ts +++ /dev/null @@ -1,73 +0,0 @@ -import path from 'node:path' - -import { normalizePlatform, parseJson, resolveFromCwd } from '../utils.js' -import type { QaCliOptions, QaRuntimeContext } from './types.js' -import type { QaResolvedConfig } from './types.js' - -type ParsedPr = { - number?: number - title?: string - body?: string | null - draft?: boolean - labels?: Array<{ name?: string }> -} - -export async function fromEasEnv( - cwd: string, - config: QaResolvedConfig, - cli: QaCliOptions -): Promise { - const parsedPr = parseJson(process.env.PR_JSON, {}) - const platform = - cli.platform ?? normalizePlatform(process.env.QA_PLATFORM) ?? config.platformDefaults.platform - - if (!platform) { - throw new Error('EAS adapter requires QA_PLATFORM or a preset platform default.') - } - - const outputDir = resolveFromCwd( - cwd, - cli.outputDir ?? process.env.QA_OUTPUT_DIR ?? config.outputDir ?? path.join('artifacts', 'qa') - ) - - const artifactPath = cli.artifactPath ?? process.env.APP_PATH - const appId = cli.appId ?? config.appId ?? process.env.APPLICATION_ID - - if (!artifactPath) { - throw new Error('EAS adapter requires APP_PATH or --artifact.') - } - - if (!appId) { - throw new Error('EAS adapter requires APPLICATION_ID or --app-id.') - } - - return { - platform, - artifactPath: resolveFromCwd(cwd, artifactPath), - appId, - buildId: cli.buildId ?? process.env.BUILD_ID ?? process.env.EAS_BUILD_ID ?? '', - workflowUrl: cli.workflowUrl ?? process.env.WORKFLOW_URL ?? process.env.EAS_BUILD_URL ?? '', - outputDir, - screenshotsDir: path.join(outputDir, 'screenshots'), - deviceName: - cli.deviceName ?? - process.env.DEVICE_NAME ?? - (platform === 'ios' - ? process.env.AGENT_DEVICE_IOS_DEVICE - : process.env.AGENT_DEVICE_ANDROID_DEVICE), - metadata: { - prNumber: cli.prNumber ?? parsedPr.number, - prTitle: cli.prTitle ?? parsedPr.title, - prBody: cli.prBody ?? parsedPr.body, - prLabels: Array.isArray(parsedPr.labels) - ? parsedPr.labels - .map((label) => label.name) - .filter((value): value is string => Boolean(value)) - : [], - isDraft: Boolean(parsedPr.draft), - taskId: cli.taskId ?? process.env.TASK_ID, - taskTitle: cli.taskTitle, - taskBody: cli.taskBody, - }, - } -} diff --git a/packages/cali/src/env/github-actions.ts b/packages/cali/src/env/github-actions.ts deleted file mode 100644 index 9a9a21f..0000000 --- a/packages/cali/src/env/github-actions.ts +++ /dev/null @@ -1,120 +0,0 @@ -import { readFile } from 'node:fs/promises' -import path from 'node:path' - -import { normalizePlatform, resolveFromCwd } from '../utils.js' -import type { QaCliOptions, QaRuntimeContext, QaResolvedConfig } from './types.js' - -type GitHubLabel = { - name?: string -} - -type GitHubPullRequest = { - number?: number - title?: string - body?: string | null - draft?: boolean - labels?: GitHubLabel[] -} - -type GitHubIssue = { - number?: number - title?: string - body?: string | null - labels?: GitHubLabel[] -} - -type GitHubEventPayload = { - pull_request?: GitHubPullRequest - issue?: GitHubIssue -} - -async function readGitHubEventPayload(): Promise { - const eventPath = process.env.GITHUB_EVENT_PATH - - if (!eventPath) { - return {} - } - - const content = await readFile(eventPath, 'utf8') - return JSON.parse(content) as GitHubEventPayload -} - -function createWorkflowUrl() { - const serverUrl = process.env.GITHUB_SERVER_URL - const repository = process.env.GITHUB_REPOSITORY - const runId = process.env.GITHUB_RUN_ID - - if (!serverUrl || !repository || !runId) { - return '' - } - - return `${serverUrl}/${repository}/actions/runs/${runId}` -} - -function readLabelNames(labels: GitHubLabel[] | undefined) { - return Array.isArray(labels) - ? labels.map((label) => label.name).filter((value): value is string => Boolean(value)) - : [] -} - -export async function fromGitHubActionsEnv( - cwd: string, - config: QaResolvedConfig, - cli: QaCliOptions -): Promise { - const event = await readGitHubEventPayload() - const pullRequest = event.pull_request - const issue = event.issue - const platform = - cli.platform ?? normalizePlatform(process.env.QA_PLATFORM) ?? config.platformDefaults.platform - - if (!platform) { - throw new Error('GitHub Actions adapter requires QA_PLATFORM or a preset platform default.') - } - - const outputDir = resolveFromCwd( - cwd, - cli.outputDir ?? process.env.QA_OUTPUT_DIR ?? config.outputDir ?? path.join('artifacts', 'qa') - ) - const artifactPath = cli.artifactPath ?? process.env.APP_PATH ?? process.env.QA_ARTIFACT_PATH - const appId = cli.appId ?? config.appId ?? process.env.APPLICATION_ID - - if (!artifactPath) { - throw new Error('GitHub Actions adapter requires APP_PATH, QA_ARTIFACT_PATH, or --artifact.') - } - - if (!appId) { - throw new Error('GitHub Actions adapter requires APPLICATION_ID or --app-id.') - } - - return { - platform, - artifactPath: resolveFromCwd(cwd, artifactPath), - appId, - buildId: - cli.buildId ?? - process.env.BUILD_ID ?? - process.env.GITHUB_RUN_ID ?? - process.env.GITHUB_SHA ?? - 'github-actions-run', - workflowUrl: cli.workflowUrl ?? createWorkflowUrl(), - outputDir, - screenshotsDir: path.join(outputDir, 'screenshots'), - deviceName: - cli.deviceName ?? - process.env.DEVICE_NAME ?? - (platform === 'ios' - ? process.env.AGENT_DEVICE_IOS_DEVICE - : process.env.AGENT_DEVICE_ANDROID_DEVICE), - metadata: { - prNumber: cli.prNumber ?? pullRequest?.number ?? issue?.number, - prTitle: cli.prTitle ?? pullRequest?.title ?? issue?.title, - prBody: cli.prBody ?? pullRequest?.body ?? issue?.body, - prLabels: readLabelNames(pullRequest?.labels ?? issue?.labels), - isDraft: Boolean(pullRequest?.draft), - taskId: cli.taskId ?? process.env.GITHUB_REF_NAME, - taskTitle: cli.taskTitle, - taskBody: cli.taskBody, - }, - } -} diff --git a/packages/cali/src/env/local.ts b/packages/cali/src/env/local.ts index 864c19b..b0bc0f4 100644 --- a/packages/cali/src/env/local.ts +++ b/packages/cali/src/env/local.ts @@ -10,19 +10,19 @@ export async function fromLocalFlags( cli: QaCliOptions ): Promise { const platform = cli.platform ?? config.platformDefaults.platform - const artifactPath = cli.artifactPath ?? process.env.APP_PATH - const appId = cli.appId ?? config.appId ?? process.env.APPLICATION_ID + const artifactPath = cli.artifactPath + const appId = cli.appId ?? config.appId if (!platform) { - throw new Error('Local adapter requires --platform or a preset platform default.') + throw new Error('Local env requires --platform or an env default platform.') } if (!artifactPath) { - throw new Error('Local adapter requires --artifact.') + throw new Error('Local env requires --artifact unless you provide a context file.') } if (!appId) { - throw new Error('Local adapter requires --app-id or config.appId.') + throw new Error('Local env requires --app-id or config.appId.') } const outputDir = resolveFromCwd( diff --git a/packages/cali/src/env/types.ts b/packages/cali/src/env/types.ts index 9825add..a067cc3 100644 --- a/packages/cali/src/env/types.ts +++ b/packages/cali/src/env/types.ts @@ -1,9 +1,4 @@ -import type { - EnvironmentAdapterName, - PublisherName, - QaPresetName, - ToolPackName, -} from '../config/schema.js' +import type { QaEnvName, PublisherName, ToolPackName } from '../config/schema.js' export type QaPlatform = 'android' | 'ios' @@ -31,10 +26,10 @@ export type QaRuntimeContext = { } export type QaCliOptions = { - presetName?: QaPresetName + envName?: QaEnvName configPath?: string prompt?: string - jsonPath?: string + contextPath?: string platform?: QaPlatform artifactPath?: string appId?: string @@ -52,8 +47,9 @@ export type QaCliOptions = { } export type QaResolvedConfig = { - environmentAdapter: EnvironmentAdapterName + envName: QaEnvName appId?: string + contextPath?: string platformDefaults: { platform?: QaPlatform deviceName?: string From b5b60050a4c0c2f1c258a6d84bc610176cc5ec63 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Tue, 7 Apr 2026 20:52:51 +0200 Subject: [PATCH 19/48] feat: expand cali role platform --- AGENTS.md | 318 ++++++-------- README.md | 4 +- packages/cali/README.md | 235 +++++++--- packages/cali/package.json | 11 +- packages/cali/src/cli/app.ts | 26 +- packages/cali/src/cli/banner.ts | 9 +- packages/cali/src/cli/dev.ts | 19 + packages/cali/src/cli/perf-review.ts | 23 + packages/cali/src/cli/qa.ts | 118 +----- packages/cali/src/cli/review.ts | 19 + packages/cali/src/cli/shared.ts | 114 +++++ packages/cali/src/commands/dev.ts | 46 ++ packages/cali/src/commands/perf-review.ts | 151 +++++++ packages/cali/src/commands/qa.ts | 328 ++++---------- packages/cali/src/commands/review.ts | 40 ++ packages/cali/src/commands/shared.ts | 214 ++++++++++ packages/cali/src/config/load.ts | 190 ++++++--- packages/cali/src/config/schema.ts | 74 ++-- packages/cali/src/env/context-file.ts | 77 ---- packages/cali/src/env/local.ts | 53 --- packages/cali/src/env/types.ts | 63 --- packages/cali/src/model.ts | 47 +- packages/cali/src/report/publishers/blob.ts | 22 +- packages/cali/src/report/publishers/file.ts | 41 +- packages/cali/src/report/render.ts | 227 +++++++--- packages/cali/src/report/types.ts | 126 +++++- packages/cali/src/roles/dev.ts | 98 +++++ packages/cali/src/roles/perf-review.ts | 122 ++++++ packages/cali/src/roles/qa-mobile.ts | 282 ++++-------- packages/cali/src/roles/review.ts | 107 +++++ packages/cali/src/runtime/context-file.ts | 177 ++++++++ packages/cali/src/runtime/context-repo.ts | 80 ++++ packages/cali/src/runtime/context.ts | 220 ++++++++++ packages/cali/src/runtime/mobile.ts | 447 ++++++++++++++++++++ packages/cali/src/runtime/publishers.ts | 59 +++ packages/cali/src/runtime/tool-loop-role.ts | 120 ++++++ packages/cali/src/runtime/tool-packs.ts | 126 ++++++ packages/cali/src/runtime/types.ts | 164 +++++++ packages/cali/src/tools/agent-device.ts | 50 ++- packages/cali/src/tools/github.ts | 30 ++ packages/cali/src/tools/react-devtools.ts | 52 +++ packages/cali/src/tools/repo.ts | 226 ++++++++++ packages/cali/src/tools/skills.ts | 99 ++++- packages/cali/src/utils.ts | 31 +- 44 files changed, 3840 insertions(+), 1245 deletions(-) create mode 100644 packages/cali/src/cli/dev.ts create mode 100644 packages/cali/src/cli/perf-review.ts create mode 100644 packages/cali/src/cli/review.ts create mode 100644 packages/cali/src/cli/shared.ts create mode 100644 packages/cali/src/commands/dev.ts create mode 100644 packages/cali/src/commands/perf-review.ts create mode 100644 packages/cali/src/commands/review.ts create mode 100644 packages/cali/src/commands/shared.ts delete mode 100644 packages/cali/src/env/context-file.ts delete mode 100644 packages/cali/src/env/local.ts delete mode 100644 packages/cali/src/env/types.ts create mode 100644 packages/cali/src/roles/dev.ts create mode 100644 packages/cali/src/roles/perf-review.ts create mode 100644 packages/cali/src/roles/review.ts create mode 100644 packages/cali/src/runtime/context-file.ts create mode 100644 packages/cali/src/runtime/context-repo.ts create mode 100644 packages/cali/src/runtime/context.ts create mode 100644 packages/cali/src/runtime/mobile.ts create mode 100644 packages/cali/src/runtime/publishers.ts create mode 100644 packages/cali/src/runtime/tool-loop-role.ts create mode 100644 packages/cali/src/runtime/tool-packs.ts create mode 100644 packages/cali/src/runtime/types.ts create mode 100644 packages/cali/src/tools/github.ts create mode 100644 packages/cali/src/tools/react-devtools.ts create mode 100644 packages/cali/src/tools/repo.ts diff --git a/AGENTS.md b/AGENTS.md index 8c4f4b7..4b15756 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -7,249 +7,195 @@ Minimal operating guide for AI coding agents in this repo. - Classify the task: - Info-only: do not edit code or run checks unless needed. - Code change: make the smallest scoped edit and run the lightest relevant validation. -- Read at most 3 files first: - - the owning command, role, or context loader - - one shared config or helper file - - one relevant doc file if the task changes CLI behavior +- Read at most 4 files first: + - the owning command module + - one role module + - one shared runtime file + - one relevant doc file if the CLI or context contract changes - Define concrete success criteria before editing. -- Prefer package docs and code over guessing current behavior. +- Prefer the shared runtime contracts over command-local improvisation. ## Repo Shape -- `packages/cali`: the standalone CLI, currently centered on `cali qa` -- `packages/tools`: reusable Cali tools for other agent runtimes +- `packages/cali`: standalone CLI role platform +- `packages/tools`: reusable Cali tools for other runtimes - `packages/mcp-server`: MCP wrapper over the tools package -## Cali v2 Architecture +## Cali Runtime Shape -The `cali` package is role-oriented. +The `cali` package is now a small role platform. - CLI entry: - `packages/cali/src/cli.ts` - `packages/cali/src/cli/app.ts` - - `packages/cali/src/cli/qa.ts` -- Runtime orchestration: - - `packages/cali/src/commands/qa.ts` -- Config and env defaults: + - `packages/cali/src/cli/*.ts` +- Command orchestration: + - `packages/cali/src/commands/*.ts` +- Shared runtime: + - `packages/cali/src/runtime/types.ts` + - `packages/cali/src/runtime/context.ts` + - `packages/cali/src/runtime/tool-packs.ts` + - `packages/cali/src/runtime/tool-loop-role.ts` + - `packages/cali/src/runtime/publishers.ts` + - `packages/cali/src/runtime/mobile.ts` +- Config: - `packages/cali/src/config/schema.ts` - `packages/cali/src/config/load.ts` -- Runtime context loaders: - - `packages/cali/src/env/local.ts` - - `packages/cali/src/env/context-file.ts` -- Role implementation: - - `packages/cali/src/roles/qa-mobile.ts` +- Roles: + - `packages/cali/src/roles/*.ts` - Tool packs: - - `packages/cali/src/tools/agent-device.ts` - - `packages/cali/src/tools/skills.ts` -- Reports and publishers: + - `packages/cali/src/tools/*.ts` +- Reports: - `packages/cali/src/report/types.ts` - `packages/cali/src/report/render.ts` - - `packages/cali/src/report/publishers/file.ts` - - `packages/cali/src/report/publishers/blob.ts` + - `packages/cali/src/report/publishers/*.ts` -## Current Role +## Public Commands -- Implemented: - - `qa` -- Role contract: - - deterministic bootstrap stays in the CLI command - - the agent inspects and navigates the app only after bootstrap - - reports are written through the standard QA report schema - - publishers decide how outputs are exposed - -## Env Model - -Envs should stay thin. - -- An env should define: - - role - - default platform settings - - enabled tool packs - - output publishers - - extra instructions -- An env should not add one-off special logic to the command path. -- Runtime context should come from one normalized JSON file plus CLI overrides, not from workflow-specific `process.env` scraping. -- If a new remote workflow is needed, prefer generating the same JSON context upstream rather than adding another workflow-specific loader. - -Current built-in envs: - -- `local-android` -- `local-ios` -- `mobile-pr` +Implemented first-class commands: -## Runtime Context +- `qa` +- `review` +- `perf-review` +- `dev` -Use this order when changing the runtime context contract: +`publish` is intentionally not implemented. Release automation belongs in CI or in `dev`-driven pipeline work, not as an open-ended agent command. -1. Update the normalized schema in `packages/cali/src/env/context-file.ts`. -2. Keep CLI flags as explicit overrides for that schema. -3. Update `packages/cali/src/env/local.ts` only if local fallback behavior changes. -4. Update env defaults in `packages/cali/src/config/load.ts` if the role behavior changes. -5. Update `packages/cali/README.md`. +## Core Contracts -Keep the context small and explicit. It should cover: +### Env -- platform -- artifact path -- app id -- build and workflow metadata -- output directories -- device name -- PR or task metadata +`env` is the only preset concept. -## Adding a New Role +Built-in envs: -Use this order: - -1. Add the role module under `packages/cali/src/roles/`. -2. Keep bootstrap outside the role. -3. Expose only explicit tool packs. -4. Define one structured output contract. -5. Document the role prompt and intended runtime in `packages/cali/README.md`. - -Avoid role-specific branching in shared helpers when a small role module will do. - -## Validation - -- For `packages/cali` TypeScript changes: - - `bunx tsc --noEmit -p packages/cali/tsconfig.json` -- For build or runtime changes: - - `bun run build` -- For CLI surface changes: - - `node packages/cali/dist/index.js qa --help` -- For env or context contract changes: - - run at least one env smoke command if credentials and local tooling exist - -Do not commit generated `artifacts/` output. - -## Handy Scripts +- `mobile-pr` +- `local-android` +- `local-ios` -When working in `packages/cali`, prefer the package scripts over reconstructing CLI commands: +An env sets defaults such as tool packs, publishers, and mobile defaults. It must not introduce workflow-specific runtime scraping. -- built bundle: - - `bun run qa -- --help` - - `bun run qa:env:local:android -- --artifact ./app.apk --app-id com.example.app` - - `bun run qa:env:local:ios -- --artifact ./MyApp.app --app-id com.example.app` - - `bun run qa:env:mobile-pr -- --context ./qa-context.json` -- source/dev loop: - - `bun run dev:qa -- --help` - - `bun run dev:qa:env:local:android -- --artifact ./app.apk --app-id com.example.app` - - `bun run dev:qa:env:local:ios -- --artifact ./MyApp.app --app-id com.example.app` - - `bun run dev:qa:env:mobile-pr -- --context ./qa-context.json` +### Context -## Documentation Touch Points +All commands use one shared `cali-context.json` contract. -When behavior changes, review: +Keep the shared context focused on: -- `packages/cali/README.md` -- `README.md` if package positioning changed -- this file if agent workflow or repo guidance changed -- PR body if the branch story materially changed +- `workspaceRoot` +- `repository` +- `task` +- `pullRequest` +- `mobile` +- `build` +- `output` +- role-specific optional sections: + - `qa` + - `review` + - `perfReview` + - `dev` -## Agent Roadmap +If a new workflow needs more data, extend the shared context schema in `packages/cali/src/runtime/context.ts` instead of adding a new workflow-specific loader. -These roles are good next candidates for remote environments such as CI, ephemeral sandboxes, and device-backed mobile workflows. +### Tool Packs -### `qa` (implemented) +Built-in pack ids: -Use for: +- `skills` +- `agent-device` +- `repo-read` +- `repo-write` +- `github` +- `react-devtools` +- `report` -- user-visible flow verification on installed builds -- PR smoke checks in EAS or GitHub Actions -- screenshot-backed QA summaries +Required skill guidance should be preloaded through the tool-pack registry when a pack depends on a skill workflow. Do not push that responsibility into individual prompts by hand. -Prompt shape: +## Command Guidance -```text -You are a mobile QA agent for React Native and Expo builds. -Treat bootstrap as already handled. -Inspect the app with the provided device tools only. -Prioritize user-visible flows and concise acceptance criteria from PR or task metadata. -Capture screenshots for meaningful states. -Do not inspect source code or modify the repository. -Finish by writing one structured QA report. -``` +### `qa` -### `dev-mobile` (planned) +- Bootstrap stays outside the role in the command module. +- The role inspects the app and writes a structured QA report. +- Requires `agent-device` on `PATH`. +- Mobile runs use a unique per-run `agent-device` session. Do not reuse ambient sessions. +- Local envs are convenience-first: try `open --relaunch` before reinstalling. +- Local mobile runs can infer the app id from the artifact. Do not require `--app-id` unless inference fails. +- If `--device` is omitted, reuse the single booted local target when exactly one exists; otherwise fail clearly. +- Acceptance criteria resolve in this order: + - `context.qa.acceptanceCriteria` + - `context.pullRequest.body` + - `context.task.body` + - additive CLI prompt -Use for: +### `review` -- sandboxed implementation tasks on React Native or Expo apps -- targeted bug fixes with local validation -- feature work where code inspection and editing are allowed +- No code changes. +- Findings first. +- Prefer repository/diff evidence over generic advice. -Prompt shape: +### `perf-review` -```text -You are a React Native and Expo development agent working in a sandboxed repository. -Start from the user task, repo scripts, and current project state. -Inspect only the files needed to understand the issue. -Make the smallest code change that solves the problem. -Prefer existing scripts and project conventions over ad hoc commands. -Validate with the lightest checks that prove the change. -Summarize the fix, the files changed, and the exact validation run. -``` +- Uses both `agent-device` and `react-devtools`. +- Requires `agent-device` and `agent-react-devtools` on `PATH`. +- Focus on runtime evidence, not speculative optimizations. -### `review-mobile-pr` (planned) +### `dev` -Use for: +- Smallest code change that solves the task. +- Repository tools rely on `git`, `rg`, and `zsh` being available. +- Respect `context.dev.writePolicy` and `context.dev.pushPolicy`. -- PR review in CI or a sandbox without making changes -- regression and risk analysis for React Native or Expo code -- architecture, platform, and maintainability review +## Validation -Prompt shape: +- For `packages/cali` TypeScript changes: + - `bunx tsc --noEmit -p packages/cali/tsconfig.json` +- For build or runtime changes: + - `bun run build:cli` +- For CLI surface changes: + - `node packages/cali/dist/index.js --help` + - relevant `--help` command smoke tests +- For command/runtime changes: + - run at least one source-mode smoke command if possible -```text -You are a mobile code review agent for React Native and Expo pull requests. -Review the diff and any attached build or QA context. -Prioritize correctness risks, platform regressions, missing validation, and maintainability concerns. -Do not suggest broad rewrites when a targeted concern is enough. -Output findings first, ordered by severity, with file references and short rationale. -If there are no concrete findings, say so explicitly and note any residual risk. -``` +Do not commit generated `artifacts/` output. -### `ci-debug-mobile` (planned) +## Handy Scripts -Use for: +Built bundle: -- failing GitHub Actions, EAS, or other remote mobile pipelines -- broken build, install, test, or runtime automation in CI -- diagnosis-first workflows where logs and artifacts matter more than code edits +- `bun run qa -- --help` +- `bun run review -- --help` +- `bun run perf-review -- --help` +- `bun run dev:command -- --help` -Prompt shape: +Source/dev loop: -```text -You are a CI debugging agent for React Native and Expo workflows. -Start from the failing job, logs, and environment metadata. -Identify the first concrete failure, not downstream noise. -Explain whether the root cause is code, configuration, environment, credentials, or infrastructure. -If a code or config fix is safe and local, implement the smallest one. -Otherwise, produce a short unblock plan with the exact environment inputs required. -``` +- `bun run dev:qa -- --help` +- `bun run dev:review -- --help` +- `bun run dev:perf-review -- --help` +- `bun run dev:dev-command -- --help` -### `upgrade-mobile` (planned) +## Extending Cali -Use for: +When adding a new command: -- React Native upgrades -- Expo SDK upgrades -- library compatibility sweeps in a sandbox +1. Add the CLI command module under `packages/cali/src/cli/`. +2. Add the orchestration module under `packages/cali/src/commands/`. +3. Add the role module under `packages/cali/src/roles/`. +4. Register tool packs in `packages/cali/src/runtime/tool-packs.ts`. +5. Extend the shared report contract and renderer only as much as needed. +6. Update `packages/cali/README.md` and this file. -Prompt shape: +Prefer small, explicit contracts: -```text -You are a React Native and Expo upgrade agent. -Treat upgrades as compatibility work, not greenfield refactoring. -Start from the requested target version and the current repo state. -Apply the minimum set of changes needed to get the project building and typechecking again. -Call out manual follow-ups separately from the automated patch. -Validate with version-appropriate build and type checks. -``` +- one shared context model +- one command registry +- one publisher pipeline +- command-specific role prompts and output schemas ## Keep It Simple -- Prefer one normalized context contract over multiple workflow-specific loaders. -- Prefer one role file over abstract role frameworks. -- Prefer docs that explain current behavior clearly over speculative docs for future behavior. -- If a planned role needs a different tool surface or output contract, document it first before implementing shared abstractions. +- Prefer one normalized context contract over workflow-specific loaders. +- Prefer one small tool-pack addition over command-local shell wrappers. +- Prefer one role file per command over broad abstract “agent frameworks”. +- Prefer accurate docs for the current command surface over speculative future docs. diff --git a/README.md b/README.md index 4700a17..286f1fd 100644 --- a/README.md +++ b/README.md @@ -26,11 +26,11 @@ Thanks to that, an LLM can help you with your React Native app development, with You can use Cali in three ways: -- **standalone** - [`cali`](./packages/cali/README.md) - QA-oriented CLI for mobile app review runs in local and CI environments. +- **standalone** - [`cali`](./packages/cali/README.md) - Role-oriented CLI for mobile QA, review, perf review, and dev runs in local and CI environments. - **with Vercel AI SDK** - [`cali-tools`](./packages/tools/README.md) - Collection of tools for building React Native apps with [Vercel AI SDK](https://github.com/ai-sdk/ai) - **with Claude, Zed, and other MCP Clients** - [`cali-mcp-server`](./packages/mcp-server/README.md) - [MCP server](http://modelcontextprotocol.io) for using Cali with Claude and other compatible environments -For a repo-oriented guide to the current Cali v2 architecture and planned mobile-agent roles, see [`AGENTS.md`](./AGENTS.md). +For a repo-oriented guide to the current Cali v2 architecture, role platform, and extension points, see [`AGENTS.md`](./AGENTS.md). For the standalone CLI’s current env model, context file contract, and package scripts, see [`packages/cali/README.md`](./packages/cali/README.md). ## What can it do? diff --git a/packages/cali/README.md b/packages/cali/README.md index 0fd4cec..d92c6b2 100644 --- a/packages/cali/README.md +++ b/packages/cali/README.md @@ -1,68 +1,131 @@ # cali -Cali v2 is a QA-oriented CLI for mobile app review runs. Today it ships `cali qa`, a role-based command that keeps deterministic app bootstrap outside the agent, lets the agent inspect and navigate the UI with a narrow tool surface, and publishes a reusable QA report for local and CI workflows. +Cali v2 is a role-oriented CLI for mobile React Native and Expo workflows. It runs first-class agent commands on top of a shared runtime model: -## Current scope - -- `cali qa` +- commands: `qa`, `review`, `perf-review`, `dev` - envs: `mobile-pr`, `local-android`, `local-ios` -- runtime context: one JSON context file plus CLI flag overrides -- tool packs: `skills`, `agent-device` -- publishers: `blob`, `file` +- one shared `cali-context.json` runtime contract +- explicit tool packs per command +- publisher-based outputs - additive `--prompt` -The surface is intentionally role-based today. Interactive follow-up flows can be added later without changing the env, context, or publisher model. - ## Core Concepts -- env: bundles a role with default platform settings, skill paths, enabled tool packs, publishers, and default instructions -- context file: the explicit JSON input that defines the normalized runtime context for a run, including platform, artifact path, app id, build metadata, and output directories -- tool pack: the explicit set of tools exposed to the role, such as `skills` metadata access or `agent-device` UI automation -- publisher: decides how the QA report is exposed after the run, such as writing files locally or uploading blobs +- command: the user-facing role entrypoint such as `cali qa` or `cali review` +- env: default runtime shape for a command, such as CI-style `mobile-pr` or local mobile envs +- context file: the explicit JSON input for workspace, repository, PR/task, mobile, build, output, and role-specific sections +- tool pack: a bounded capability surface such as `agent-device`, `react-devtools`, `repo-read`, or `repo-write` +- publisher: how reports are exposed after a run, such as `file` or `blob` -## Examples +## Commands + +- `cali qa` + - mobile QA pass with `agent-device` +- `cali review` + - findings-first PR/repository review +- `cali perf-review` + - runtime performance review with `agent-device` and `react-devtools` +- `cali dev` + - repository-backed implementation flow + +## Shared Context + +All commands use one shared `cali-context.json` contract. Commands only require the sections they actually use. + +```json +{ + "workspaceRoot": ".", + "repository": { + "provider": "github.com", + "owner": "acme", + "name": "mobile-app", + "defaultBranch": "main", + "currentBranch": "feature/onboarding-copy" + }, + "pullRequest": { + "number": 42, + "title": "Fix onboarding CTA", + "body": "Acceptance criteria: the new CTA copy is visible on Screen B.", + "url": "https://github.com/acme/mobile-app/pull/42", + "labels": ["mobile", "qa"], + "isDraft": false, + "baseBranch": "main", + "headBranch": "feature/onboarding-copy" + }, + "mobile": { + "platform": "android", + "artifactPath": "./artifacts/app.apk", + "appId": "com.example.myapp", + "deviceName": "Pixel 9" + }, + "build": { + "id": "gha-run-123", + "workflowUrl": "https://github.com/acme/mobile-app/actions/runs/123", + "logsUrl": "https://github.com/acme/mobile-app/actions/runs/123/job/456" + }, + "output": { + "outputDir": "./artifacts/qa" + }, + "qa": { + "acceptanceCriteria": ["Screen B shows the updated CTA copy", "The CTA remains tappable"] + }, + "perfReview": { + "targetFlow": "Checkout", + "profilingGoals": ["rerenders", "slow interactions"] + }, + "dev": { + "allowedValidations": ["bun test", "bunx tsc --noEmit"], + "writePolicy": "workspace", + "pushPolicy": "disabled" + } +} +``` -### Local env +Flags always win over the context file. For example, `--platform`, `--artifact`, `--app-id`, `--output-dir`, `--pr-number`, or `--task-id` override the JSON values. For local mobile runs, `--app-id` is optional when Cali can infer it from the artifact. -For local envs, `--artifact` is required and `appId` must come from `--app-id` or `config.appId`. Cali does not infer it from `app.json` yet. `--device` is optional. +## Examples + +### Local QA ```bash cali qa \ --env local-ios \ --artifact ./artifacts/MyApp.app \ - --app-id com.example.myapp \ --prompt "verify the onboarding copy on Screen B" ``` -### Remote env with a context file +Local mobile behavior: -For remote environments such as GitHub Actions, EAS workflows, or custom sandboxes, write one normalized JSON context file and override fields only when needed. `mobile-pr` expects that file. +- each run gets a unique `agent-device` session name such as `ios-a1b2c` +- local Android reuses the single booted emulator/device when exactly one is available, otherwise pass `--device` +- local envs try `open --relaunch` before reinstalling +- `local-ios` reuses the single booted simulator when exactly one is available, otherwise pass `--device` -```json -{ - "platform": "android", - "artifactPath": "./artifacts/app.apk", - "appId": "com.example.myapp", - "buildId": "gha-run-123", - "workflowUrl": "https://github.com/acme/mobile/actions/runs/123", - "metadata": { - "prNumber": 42, - "prTitle": "Fix onboarding CTA", - "prLabels": ["mobile", "qa"], - "isDraft": false - } -} +### CI-style QA or review + +```bash +cali qa --env mobile-pr --context ./cali-context.json +cali review --env mobile-pr --context ./cali-context.json ``` +### Runtime performance review + ```bash -cali qa --env mobile-pr --context ./qa-context.json +cali perf-review \ + --env mobile-pr \ + --context ./cali-context.json \ + --prompt "profile the checkout flow" ``` -Flags always win over the context file. For example, `--platform ios` or `--output-dir ./artifacts/custom` overrides the JSON value. +### Repo-backed implementation + +```bash +cali dev --env mobile-pr --context ./cali-context.json --prompt "implement issue 123" +``` ## Credentials -`cali qa` supports two model auth paths: +Cali supports two model auth paths: - AI Gateway: `AI_GATEWAY_API_KEY` - AI Gateway alias: `AI_GATEWAY_KEY` @@ -71,39 +134,91 @@ Flags always win over the context file. For example, `--platform ios` or `--outp Cali defaults to `openai/gpt-5.4-mini`. If gateway credentials are present, that model is routed through AI Gateway. Direct provider support in this package is Anthropic only. +Optional publisher/runtime credentials: + +- `BLOB_READ_WRITE_TOKEN` for blob screenshot uploads + +## Required CLIs + +Some commands shell out to local binaries: + +- `qa`: requires `agent-device` +- `perf-review`: requires `agent-device` and `agent-react-devtools` +- `review`: requires `git` and `rg` +- `dev`: requires `git`, `rg`, and `zsh` + +If one of these is missing, Cali stops with an actionable error instead of trying to install it automatically. + ## Config Create `cali.config.ts` in the project root: ```ts export default { - role: 'qa', - env: 'local-android', - contextPath: './qa-context.json', - extraInstructions: ['Prioritize auth and onboarding flows.'], + defaultCommand: 'qa', + env: 'mobile-pr', + workspaceRoot: '.', + skillPaths: ['.agents/skills'], + commands: { + qa: { + contextPath: './cali-context.json', + extraInstructions: ['Prioritize auth and onboarding flows.'], + }, + review: { + outputPublishers: ['file'], + }, + perfReview: { + extraInstructions: ['Focus on rerender hotspots first.'], + }, + }, } ``` +If `defaultCommand` is set, running plain `cali` with no command will execute that default command instead of showing help. + By default, Cali discovers skills from: - `./.agents/skills` - `~/.agents/skills` +## Tool Packs + +Built-in tool pack ids: + +- `skills` +- `agent-device` +- `repo-read` +- `repo-write` +- `github` +- `react-devtools` +- `report` + +Command defaults: + +- `qa`: `skills`, `agent-device`, `report` +- `review`: `repo-read`, `github`, `skills`, `report` +- `perf-review`: `skills`, `agent-device`, `react-devtools`, `repo-read`, `report` +- `dev`: `repo-read`, `repo-write`, `github`, `skills`, `report` + ## Package Scripts -The `cali` package exposes handy scripts for the currently implemented `qa` role. Pass additional CLI flags after `--`. +Built bundle: - `bun run qa -- --help` -- `bun run qa:env:local:android -- --artifact ./app.apk --app-id com.example.app` -- `bun run qa:env:local:ios -- --artifact ./MyApp.app --app-id com.example.app` -- `bun run qa:env:mobile-pr -- --context ./qa-context.json` +- `bun run review -- --help` +- `bun run perf-review -- --help` +- `bun run dev:command -- --help` +- `bun run qa:env:mobile-pr -- --context ./cali-context.json` +- `bun run review:env:mobile-pr -- --context ./cali-context.json` +- `bun run perf-review:env:mobile-pr -- --context ./cali-context.json` +- `bun run dev:command:env:mobile-pr -- --context ./cali-context.json` -For development against source instead of the built bundle: +Source/dev loop: - `bun run dev:qa -- --help` -- `bun run dev:qa:env:local:android -- --artifact ./app.apk --app-id com.example.app` -- `bun run dev:qa:env:local:ios -- --artifact ./MyApp.app --app-id com.example.app` -- `bun run dev:qa:env:mobile-pr -- --context ./qa-context.json` +- `bun run dev:review -- --help` +- `bun run dev:perf-review -- --help` +- `bun run dev:dev-command -- --help` ## Installing Skills @@ -114,19 +229,23 @@ npx skills add callstackincubator/agent-device --agent codex --skill '*' -y npx skills add callstackincubator/agent-skills --agent codex --skill '*' -y ``` -This installs project-local skills into `./.agents/skills` and writes `skills-lock.json`. -Project-local and home-directory skills are both picked up automatically by `cali qa`. +If you want to use performance review flows, make sure the relevant skills are installed too, such as `react-devtools`. -## Repo Guide +## Outputs -For implementation details, env guidance, and the roadmap for additional Cali roles in CI or sandbox environments, see [`AGENTS.md`](../../AGENTS.md). +The file publisher writes: -## Outputs +- `report.json` +- `section.md` +- `status.txt` +- `publisher-manifest.json` + +The default output directory is `artifacts/`. -By default the file publisher writes: +For `qa` and `perf-review`, screenshots are saved under `artifacts//screenshots`. -- `artifacts/qa/report.json` -- `artifacts/qa/section.md` -- `artifacts/qa/status.txt` +If `BLOB_READ_WRITE_TOKEN` is set, the blob publisher uploads screenshots and enriches the report with blob URLs. + +## Repo Guide -If `BLOB_READ_WRITE_TOKEN` is set, the blob publisher uploads screenshots and enriches the JSON report with blob URLs. +For implementation details, runtime contracts, and guidance for extending Cali with new commands, see [`AGENTS.md`](../../AGENTS.md). diff --git a/packages/cali/package.json b/packages/cali/package.json index f1505e3..2b0a14d 100644 --- a/packages/cali/package.json +++ b/packages/cali/package.json @@ -12,10 +12,19 @@ "qa:env:local:android": "node ./dist/index.js qa --env local-android", "qa:env:local:ios": "node ./dist/index.js qa --env local-ios", "qa:env:mobile-pr": "node ./dist/index.js qa --env mobile-pr", + "review": "node ./dist/index.js review", + "review:env:mobile-pr": "node ./dist/index.js review --env mobile-pr", + "perf-review": "node ./dist/index.js perf-review", + "perf-review:env:mobile-pr": "node ./dist/index.js perf-review --env mobile-pr", + "dev:command": "node ./dist/index.js dev", + "dev:command:env:mobile-pr": "node ./dist/index.js dev --env mobile-pr", "dev:qa": "node --import=tsx ./src/cli.ts qa", "dev:qa:env:local:android": "node --import=tsx ./src/cli.ts qa --env local-android", "dev:qa:env:local:ios": "node --import=tsx ./src/cli.ts qa --env local-ios", - "dev:qa:env:mobile-pr": "node --import=tsx ./src/cli.ts qa --env mobile-pr" + "dev:qa:env:mobile-pr": "node --import=tsx ./src/cli.ts qa --env mobile-pr", + "dev:review": "node --import=tsx ./src/cli.ts review", + "dev:perf-review": "node --import=tsx ./src/cli.ts perf-review", + "dev:dev-command": "node --import=tsx ./src/cli.ts dev" }, "dependencies": { "@ai-sdk/anthropic": "^3.0.64", diff --git a/packages/cali/src/cli/app.ts b/packages/cali/src/cli/app.ts index 8efbe74..fab93a5 100644 --- a/packages/cali/src/cli/app.ts +++ b/packages/cali/src/cli/app.ts @@ -1,13 +1,24 @@ import { cac } from 'cac' +import { loadCaliConfigFile } from '../config/load.js' import { printRetroBanner } from './banner.js' -import { registerQaCommand } from './qa.js' +import { devCommandDefinition } from './dev.js' +import { perfReviewCommandDefinition } from './perf-review.js' +import { qaCommandDefinition } from './qa.js' +import { reviewCommandDefinition } from './review.js' function createCli() { const cli = cac('cali') cli.usage(' [options]') - registerQaCommand(cli, printRetroBanner) + for (const commandDefinition of [ + qaCommandDefinition, + reviewCommandDefinition, + perfReviewCommandDefinition, + devCommandDefinition, + ]) { + commandDefinition.register(cli, printRetroBanner) + } cli.help() return cli @@ -17,11 +28,22 @@ export async function runCli(argv = process.argv) { const cli = createCli() const args = argv.slice(2) const shouldPrintBanner = args.length === 0 || args.includes('--help') || args.includes('-h') + const cwd = process.cwd() if (shouldPrintBanner) { printRetroBanner() } + if (args.length === 0) { + const config = await loadCaliConfigFile(cwd) + if (config.defaultCommand) { + await Promise.resolve( + cli.parse([argv[0] ?? 'node', argv[1] ?? 'cali', config.defaultCommand]) + ) + return + } + } + await Promise.resolve(cli.parse(argv)) if (args.length === 0) { diff --git a/packages/cali/src/cli/banner.ts b/packages/cali/src/cli/banner.ts index 1db0fef..b0d3851 100644 --- a/packages/cali/src/cli/banner.ts +++ b/packages/cali/src/cli/banner.ts @@ -9,14 +9,7 @@ const CALI_TEXT = ` ╚═════╝╚═╝ ╚═╝╚══════╝╚═╝ ` -let bannerPrinted = false - export function printRetroBanner() { - if (bannerPrinted) { - return - } - - bannerPrinted = true console.log(retro(CALI_TEXT)) - console.log('Cali v2 for mobile QA.\n') + console.log('Cali v2 for mobile agent workflows.\n') } diff --git a/packages/cali/src/cli/dev.ts b/packages/cali/src/cli/dev.ts new file mode 100644 index 0000000..e50369a --- /dev/null +++ b/packages/cali/src/cli/dev.ts @@ -0,0 +1,19 @@ +import type { CAC } from 'cac' + +import { runDevCommand } from '../commands/dev.js' +import { + type BaseCommandOptions, + normalizeBaseCommandCliOptions, + registerCommonCommandOptions, +} from './shared.js' + +export const devCommandDefinition = { + register(cli: CAC, printBanner: () => void) { + registerCommonCommandOptions(cli.command('dev', 'Run the mobile development role')) + .example('dev --env mobile-pr --context ./cali-context.json --prompt "implement issue 123"') + .action(async (options: unknown) => { + printBanner() + await runDevCommand(normalizeBaseCommandCliOptions(options as BaseCommandOptions)) + }) + }, +} diff --git a/packages/cali/src/cli/perf-review.ts b/packages/cali/src/cli/perf-review.ts new file mode 100644 index 0000000..54d8e8d --- /dev/null +++ b/packages/cali/src/cli/perf-review.ts @@ -0,0 +1,23 @@ +import type { CAC } from 'cac' + +import { runPerfReviewCommand } from '../commands/perf-review.js' +import { + type BaseCommandOptions, + normalizeBaseCommandCliOptions, + registerCommonMobileOptions, +} from './shared.js' + +export const perfReviewCommandDefinition = { + register(cli: CAC, printBanner: () => void) { + registerCommonMobileOptions( + cli.command('perf-review', 'Run the mobile performance review role') + ) + .example( + 'perf-review --env mobile-pr --context ./cali-context.json --prompt "profile the checkout flow"' + ) + .action(async (options: unknown) => { + printBanner() + await runPerfReviewCommand(normalizeBaseCommandCliOptions(options as BaseCommandOptions)) + }) + }, +} diff --git a/packages/cali/src/cli/qa.ts b/packages/cali/src/cli/qa.ts index 37e7edd..fdaa4ba 100644 --- a/packages/cali/src/cli/qa.ts +++ b/packages/cali/src/cli/qa.ts @@ -1,103 +1,21 @@ -import { cac } from 'cac' +import type { CAC } from 'cac' import { runQaCommand } from '../commands/qa.js' -import type { QaCliOptions } from '../env/types.js' -import { normalizePlatform } from '../utils.js' - -type QaCommandOptions = { - env?: string - config?: string - prompt?: string - context?: string - platform?: string - artifact?: string - appId?: string - device?: string - outputDir?: string - buildId?: string - workflowUrl?: string - prNumber?: string | number - prTitle?: string - prBody?: string - taskId?: string - taskTitle?: string - taskBody?: string - model?: string -} - -function readOptionalString(value: unknown) { - return typeof value === 'string' && value.length > 0 ? value : undefined -} - -function readOptionalNumber(value: unknown, flagName: string) { - if (value == null || value === '') { - return undefined - } - - const parsed = Number(value) - if (!Number.isFinite(parsed)) { - throw new Error(`\`${flagName}\` must be a valid number.`) - } - - return parsed -} - -function normalizeQaCliOptions(options: QaCommandOptions): QaCliOptions { - const platformValue = readOptionalString(options.platform) - const platform = platformValue ? normalizePlatform(platformValue) : undefined - - if (platformValue && !platform) { - throw new Error('`--platform` must be `android` or `ios`.') - } - - return { - envName: readOptionalString(options.env) as QaCliOptions['envName'], - configPath: readOptionalString(options.config), - prompt: readOptionalString(options.prompt), - contextPath: readOptionalString(options.context), - platform, - artifactPath: readOptionalString(options.artifact), - appId: readOptionalString(options.appId), - deviceName: readOptionalString(options.device), - outputDir: readOptionalString(options.outputDir), - buildId: readOptionalString(options.buildId), - workflowUrl: readOptionalString(options.workflowUrl), - prNumber: readOptionalNumber(options.prNumber, '--pr-number'), - prTitle: readOptionalString(options.prTitle), - prBody: readOptionalString(options.prBody), - taskId: readOptionalString(options.taskId), - taskTitle: readOptionalString(options.taskTitle), - taskBody: readOptionalString(options.taskBody), - model: readOptionalString(options.model), - } -} - -export function registerQaCommand(cli: ReturnType, printBanner: () => void) { - cli - .command('qa', 'Run the mobile QA role') - .option('--env ', 'Built-in env: mobile-pr, local-android, local-ios') - .option('--config ', 'Path to cali.config.ts') - .option('--prompt ', 'Add task-specific QA intent') - .option('--context ', 'Load normalized QA context from JSON') - .option('--platform ', 'android or ios') - .option('--artifact ', 'App artifact path (.apk, .aab, .app, .ipa)') - .option('--app-id ', 'Application identifier / package name') - .option('--device ', 'Simulator or emulator name to provision') - .option('--output-dir ', 'Output directory for artifacts') - .option('--build-id ', 'Build identifier') - .option('--workflow-url ', 'Workflow or build link') - .option('--pr-number ', 'Pull request number') - .option('--pr-title ', 'Pull request title') - .option('--pr-body ', 'Pull request body') - .option('--task-id ', 'Task identifier') - .option('--task-title ', 'Task title') - .option('--task-body ', 'Task body') - .option('--model ', 'Override the QA model') - .example( - 'qa --env local-ios --artifact ./artifacts/MyApp.app --app-id com.example.myapp --prompt "verify the onboarding copy on Screen B"' - ) - .action(async (options) => { - printBanner() - await runQaCommand(normalizeQaCliOptions(options as QaCommandOptions)) - }) +import { + type BaseCommandOptions, + normalizeBaseCommandCliOptions, + registerCommonMobileOptions, +} from './shared.js' + +export const qaCommandDefinition = { + register(cli: CAC, printBanner: () => void) { + registerCommonMobileOptions(cli.command('qa', 'Run the mobile QA role')) + .example( + 'qa --env local-ios --artifact ./artifacts/MyApp.app --prompt "verify the onboarding copy on Screen B"' + ) + .action(async (options: unknown) => { + printBanner() + await runQaCommand(normalizeBaseCommandCliOptions(options as BaseCommandOptions)) + }) + }, } diff --git a/packages/cali/src/cli/review.ts b/packages/cali/src/cli/review.ts new file mode 100644 index 0000000..5f06c04 --- /dev/null +++ b/packages/cali/src/cli/review.ts @@ -0,0 +1,19 @@ +import type { CAC } from 'cac' + +import { runReviewCommand } from '../commands/review.js' +import { + type BaseCommandOptions, + normalizeBaseCommandCliOptions, + registerCommonCommandOptions, +} from './shared.js' + +export const reviewCommandDefinition = { + register(cli: CAC, printBanner: () => void) { + registerCommonCommandOptions(cli.command('review', 'Run the mobile code review role')) + .example('review --env mobile-pr --context ./cali-context.json') + .action(async (options: unknown) => { + printBanner() + await runReviewCommand(normalizeBaseCommandCliOptions(options as BaseCommandOptions)) + }) + }, +} diff --git a/packages/cali/src/cli/shared.ts b/packages/cali/src/cli/shared.ts new file mode 100644 index 0000000..080dcf2 --- /dev/null +++ b/packages/cali/src/cli/shared.ts @@ -0,0 +1,114 @@ +import type { CommandCliOptions } from '../runtime/types.js' +import { normalizePlatform } from '../utils.js' + +export type BaseCommandOptions = { + env?: string + config?: string + prompt?: string + context?: string + outputDir?: string + model?: string + workspaceRoot?: string + platform?: string + artifact?: string + appId?: string + device?: string + buildId?: string + workflowUrl?: string + logsUrl?: string + prNumber?: string | number + prTitle?: string + prBody?: string + prUrl?: string + prBaseBranch?: string + prHeadBranch?: string + taskId?: string + taskTitle?: string + taskBody?: string + taskUrl?: string +} + +export function readOptionalString(value: unknown) { + return typeof value === 'string' && value.length > 0 ? value : undefined +} + +export function readOptionalNumber(value: unknown, flagName: string) { + if (value == null || value === '') { + return undefined + } + + const parsed = Number(value) + if (!Number.isFinite(parsed)) { + throw new Error(`\`${flagName}\` must be a valid number.`) + } + + return parsed +} + +export function normalizeBaseCommandCliOptions(options: BaseCommandOptions): CommandCliOptions { + const platformValue = readOptionalString(options.platform) + const platform = platformValue ? normalizePlatform(platformValue) : undefined + + if (platformValue && !platform) { + throw new Error('`--platform` must be `android` or `ios`.') + } + + return { + envName: readOptionalString(options.env) as CommandCliOptions['envName'], + configPath: readOptionalString(options.config), + prompt: readOptionalString(options.prompt), + contextPath: readOptionalString(options.context), + outputDir: readOptionalString(options.outputDir), + model: readOptionalString(options.model), + workspaceRoot: readOptionalString(options.workspaceRoot), + platform, + artifactPath: readOptionalString(options.artifact), + appId: readOptionalString(options.appId), + deviceName: readOptionalString(options.device), + buildId: readOptionalString(options.buildId), + workflowUrl: readOptionalString(options.workflowUrl), + logsUrl: readOptionalString(options.logsUrl), + prNumber: readOptionalNumber(options.prNumber, '--pr-number'), + prTitle: readOptionalString(options.prTitle), + prBody: readOptionalString(options.prBody), + prUrl: readOptionalString(options.prUrl), + prBaseBranch: readOptionalString(options.prBaseBranch), + prHeadBranch: readOptionalString(options.prHeadBranch), + taskId: readOptionalString(options.taskId), + taskTitle: readOptionalString(options.taskTitle), + taskBody: readOptionalString(options.taskBody), + taskUrl: readOptionalString(options.taskUrl), + } +} + +export function registerCommonCommandOptions(command: any) { + return command + .option('--env ', 'Built-in env: mobile-pr, local-android, local-ios') + .option('--config ', 'Path to cali.config.ts') + .option('--prompt ', 'Add task-specific intent') + .option('--context ', 'Load shared Cali runtime context from JSON') + .option('--output-dir ', 'Output directory for artifacts') + .option('--model ', 'Override the agent model') + .option('--workspace-root ', 'Override the workspace root') + .option('--pr-number ', 'Pull request number') + .option('--pr-title ', 'Pull request title') + .option('--pr-body ', 'Pull request body') + .option('--pr-url ', 'Pull request URL') + .option('--pr-base-branch ', 'Pull request base branch') + .option('--pr-head-branch ', 'Pull request head branch') + .option('--task-id ', 'Task identifier') + .option('--task-title ', 'Task title') + .option('--task-body ', 'Task body') + .option('--task-url ', 'Task URL') + .option('--build-id ', 'Build identifier') + .option('--workflow-url ', 'Workflow or build link') + .option('--logs-url ', 'Logs URL') +} + +export function registerCommonMobileOptions(command: any) { + return registerCommonCommandOptions(command) + .option('--platform ', 'android or ios') + .option('--artifact ', 'App artifact path (.apk, .aab, .app, .ipa)') + .option('--app-id ', 'Optional application identifier / package name override') + .option('--device ', 'Simulator or emulator name to provision') +} diff --git a/packages/cali/src/commands/dev.ts b/packages/cali/src/commands/dev.ts new file mode 100644 index 0000000..fd483c1 --- /dev/null +++ b/packages/cali/src/commands/dev.ts @@ -0,0 +1,46 @@ +import type { DevReport } from '../report/types.js' +import { runDevRole } from '../roles/dev.js' +import type { CommandCliOptions } from '../runtime/types.js' +import { runStructuredCommand } from './shared.js' + +function createBlockedDevReport(summary: string) { + return { + overallStatus: 'blocked' as const, + summary, + filesChanged: [], + validationsRun: [], + followUps: [], + patchStatus: 'blocked' as const, + nextSteps: ['Inspect repository tooling and retry the dev run.'], + environmentNotes: [summary], + } +} + +export async function runDevCommand(cli: CommandCliOptions) { + return runStructuredCommand({ + commandId: 'dev', + cli, + roleLabel: 'Dev', + reportLabel: 'Dev report', + createBlockedReport: createBlockedDevReport, + composeReport: ({ model, context, reportInput }): DevReport => ({ + command: 'dev', + generatedAt: new Date().toISOString(), + model, + context, + overallStatus: reportInput.overallStatus, + summary: reportInput.summary, + filesChanged: reportInput.filesChanged ?? [], + validationsRun: reportInput.validationsRun ?? [], + followUps: reportInput.followUps ?? [], + patchStatus: reportInput.patchStatus ?? 'planned', + nextSteps: reportInput.nextSteps ?? [], + environmentNotes: reportInput.environmentNotes ?? [], + }), + getEnabledToolPacks: ({ context, config }) => + context.dev?.writePolicy === 'none' + ? config.enabledToolPacks.filter((toolPackName) => toolPackName !== 'repo-write') + : config.enabledToolPacks, + runRole: runDevRole, + }) +} diff --git a/packages/cali/src/commands/perf-review.ts b/packages/cali/src/commands/perf-review.ts new file mode 100644 index 0000000..18b5077 --- /dev/null +++ b/packages/cali/src/commands/perf-review.ts @@ -0,0 +1,151 @@ +import type { PerfReviewReport, ScreenshotInfo } from '../report/types.js' +import { runPerfReviewRole } from '../roles/perf-review.js' +import { + bootstrapMobileApp, + closeAgentDeviceSession, + createAgentDeviceSessionName, + listScreenshots, + prepareMobileOutputDirectories, + resolveMobileRuntimeContext, +} from '../runtime/mobile.js' +import { publishReport } from '../runtime/publishers.js' +import { prepareToolPacks } from '../runtime/tool-packs.js' +import type { CommandCliOptions } from '../runtime/types.js' +import { humanizeScreenshotLabel } from '../utils.js' +import { + createRunContextTool, + formatAgentFinishDetail, + formatAgentStepDetail, + loadRunContext, + printPhase, + printFinalReport, + summarizeReason, +} from './shared.js' + +function composePerfReviewReport( + model: string, + context: Parameters[0]['context'], + reportInput: Awaited>['reportInput'], + screenshots: Array>, + agentDeviceTrace: PerfReviewReport['agentDeviceTrace'], + reactDevtoolsTrace: PerfReviewReport['reactDevtoolsTrace'] +): PerfReviewReport { + return { + command: 'perf-review', + generatedAt: new Date().toISOString(), + model, + context, + overallStatus: reportInput.overallStatus, + summary: reportInput.summary, + scenario: reportInput.scenario ?? context.perfReview?.targetFlow ?? 'General runtime review', + slowComponents: reportInput.slowComponents ?? [], + rerenderHotspots: reportInput.rerenderHotspots ?? [], + suspectedCauses: reportInput.suspectedCauses ?? [], + evidence: reportInput.evidence ?? [], + recommendedFixes: reportInput.recommendedFixes ?? [], + nextSteps: reportInput.nextSteps ?? [], + environmentNotes: reportInput.environmentNotes ?? [], + screenshots: screenshots.map((screenshot) => ({ + ...screenshot, + label: humanizeScreenshotLabel(screenshot.fileName), + })), + agentDeviceTrace, + reactDevtoolsTrace, + } +} + +function createBlockedPerfReviewReport(summary: string) { + return { + overallStatus: 'blocked' as const, + summary, + scenario: 'Blocked runtime performance review', + slowComponents: [], + rerenderHotspots: [], + suspectedCauses: [], + evidence: [], + recommendedFixes: [], + nextSteps: ['Inspect bootstrap, runtime tooling, and retry the performance review run.'], + environmentNotes: [summary], + } +} + +export async function runPerfReviewCommand(cli: CommandCliOptions) { + const { cwd, config, context } = await loadRunContext('perf-review', cli) + + let reportInput: Awaited>['reportInput'] + let agentDeviceTrace: PerfReviewReport['agentDeviceTrace'] = [] + let reactDevtoolsTrace: PerfReviewReport['reactDevtoolsTrace'] = [] + let mobileContext: Awaited> | undefined + let sessionName: string | undefined + + try { + mobileContext = await resolveMobileRuntimeContext('perf-review', config.envName, context) + sessionName = createAgentDeviceSessionName(mobileContext.platform) + + printPhase( + 'Preparing output', + `${mobileContext.platform} | ${mobileContext.deviceName ?? 'bound device'} | ${mobileContext.appId}` + ) + await prepareMobileOutputDirectories(mobileContext) + printPhase('Bootstrapping app', mobileContext.artifactPath) + await bootstrapMobileApp('perf-review', config.envName, mobileContext, sessionName) + printPhase('Bootstrap complete') + + printPhase('Preparing tool packs', config.enabledToolPacks.join(', ')) + const preparedToolPacks = await prepareToolPacks({ + context, + skillPaths: config.skillPaths, + enabledToolPacks: config.enabledToolPacks, + sessionName, + }) + + printPhase('Running perf-review agent', config.model) + const roleResult = await runPerfReviewRole({ + context, + modelId: config.model, + tools: { + ...preparedToolPacks.tools, + get_run_context: createRunContextTool('perf-review', context), + }, + availableSkillsPrompt: preparedToolPacks.availableSkillsPrompt, + preloadedSkillsPrompt: preparedToolPacks.preloadedSkillsPrompt, + extraInstructions: config.extraInstructions, + prompt: cli.prompt, + onAgentStep: (event) => { + printPhase('Perf-review step complete', formatAgentStepDetail(event)) + }, + onAgentFinish: (event) => { + printPhase('Perf-review agent finished', formatAgentFinishDetail(event)) + }, + }) + + reportInput = roleResult.reportInput + agentDeviceTrace = preparedToolPacks.traces.agentDeviceTrace + reactDevtoolsTrace = preparedToolPacks.traces.reactDevtoolsTrace + } catch (unknownError) { + const error = unknownError instanceof Error ? unknownError : new Error(String(unknownError)) + printPhase('Run blocked', summarizeReason(error.message)) + reportInput = createBlockedPerfReviewReport(error.message) + } finally { + if (sessionName) { + await closeAgentDeviceSession(sessionName) + } + } + + const screenshots = mobileContext ? await listScreenshots(mobileContext.screenshotsDir) : [] + const report = composePerfReviewReport( + config.model, + context, + reportInput, + screenshots, + agentDeviceTrace, + reactDevtoolsTrace + ) + printPhase('Publishing report', config.outputPublishers.join(', ')) + const publishedReport = await publishReport({ + report, + publishers: config.outputPublishers, + }) + + printFinalReport(cwd, 'perf-review', 'Perf-review report', publishedReport) +} diff --git a/packages/cali/src/commands/qa.ts b/packages/cali/src/commands/qa.ts index eb79f45..ed2c2ea 100644 --- a/packages/cali/src/commands/qa.ts +++ b/packages/cali/src/commands/qa.ts @@ -1,209 +1,57 @@ -import { readdir, rm, stat } from 'node:fs/promises' -import path from 'node:path' - -import { loadQaConfig } from '../config/load.js' -import { fromContextFile } from '../env/context-file.js' -import { fromLocalFlags } from '../env/local.js' -import type { QaCliOptions, QaRuntimeContext } from '../env/types.js' -import { publishBlobReport } from '../report/publishers/blob.js' -import { publishFileReport } from '../report/publishers/file.js' import type { QaReport, QaReportInput, ScreenshotInfo } from '../report/types.js' import { runQaMobileRole } from '../roles/qa-mobile.js' import { - DEFAULT_AGENT_DEVICE_SESSION_NAME, - getAgentDeviceSessionArgs, -} from '../tools/agent-device.js' -import { ensureDirectory, humanizeScreenshotLabel, resolveFromCwd, runCommand } from '../utils.js' - -function printPhase(title: string, detail?: string) { - console.log(detail ? `${title}: ${detail}` : title) -} - -function summarizeReason(text: string) { - return text - .split('\n') - .map((line) => line.trim()) - .find(Boolean) -} - -function formatAgentStepDetail(event: { - stepNumber: number - finishReason: string - toolNames: string[] - totalTokens?: number -}) { - const details = [`step ${event.stepNumber}`, `finish=${event.finishReason}`] - - if (event.toolNames.length > 0) { - details.push(`tools=${event.toolNames.join(',')}`) - } - - if (event.totalTokens != null) { - details.push(`tokens=${event.totalTokens}`) - } - - return details.join(' | ') -} - -function formatAgentFinishDetail(event: { - stepCount: number - finishReason: string - totalTokens?: number -}) { - const details = [`steps=${event.stepCount}`, `finish=${event.finishReason}`] - - if (event.totalTokens != null) { - details.push(`tokens=${event.totalTokens}`) - } - - return details.join(' | ') -} - -async function resolveEnvironmentContext( - cwd: string, - cli: QaCliOptions -): Promise<{ config: Awaited>; context: QaRuntimeContext }> { - const config = await loadQaConfig({ - cwd, - configPath: cli.configPath, - envName: cli.envName, - model: cli.model, - }) - - if (cli.contextPath || config.contextPath || config.envName === 'mobile-pr') { - return { - config, - context: await fromContextFile(cwd, config, cli), - } - } - - return { - config, - context: await fromLocalFlags(cwd, config, cli), - } -} - -async function runAgentDeviceCommand( - sessionName: string, - command: string, - args: string[], - options: Parameters[2] = {} + bootstrapMobileApp, + closeAgentDeviceSession, + createAgentDeviceSessionName, + listScreenshots, + prepareMobileOutputDirectories, + resolveMobileRuntimeContext, +} from '../runtime/mobile.js' +import { publishReport } from '../runtime/publishers.js' +import { prepareToolPacks } from '../runtime/tool-packs.js' +import type { CommandCliOptions } from '../runtime/types.js' +import { humanizeScreenshotLabel } from '../utils.js' +import { + createRunContextTool, + formatAgentFinishDetail, + formatAgentStepDetail, + loadRunContext, + printPhase, + printFinalReport, + summarizeReason, +} from './shared.js' + +function resolveAcceptanceCriteria( + context: Parameters[0]['context'], + prompt?: string ) { - return runCommand( - 'agent-device', - [...getAgentDeviceSessionArgs(sessionName), command, ...args], - options - ) -} - -async function bootstrapApp(context: QaRuntimeContext, sessionName: string) { - const deviceSelectorArgs = context.deviceName - ? ['--platform', context.platform, '--device', context.deviceName] - : ['--platform', context.platform] - - if (context.deviceName) { - if (context.platform === 'ios') { - await runAgentDeviceCommand(sessionName, 'ensure-simulator', [ - ...deviceSelectorArgs, - '--boot', - ]) - } else { - await runAgentDeviceCommand(sessionName, 'boot', deviceSelectorArgs) - } - } - - if (context.platform === 'android') { - let installResult = await runAgentDeviceCommand( - sessionName, - 'install', - [...deviceSelectorArgs, context.appId, context.artifactPath], - { - allowFailure: true, - } - ) - - if (!installResult.ok) { - installResult = await runAgentDeviceCommand( - sessionName, - 'reinstall', - [...deviceSelectorArgs, context.appId, context.artifactPath], - { - allowFailure: true, - } - ) - } - - if (!installResult.ok) { - throw new Error( - `Deterministic Android bootstrap failed during install or reinstall.\n\n${installResult.stderr || installResult.stdout}` - ) - } - } else { - const reinstallResult = await runAgentDeviceCommand( - sessionName, - 'reinstall', - [...deviceSelectorArgs, context.appId, context.artifactPath], - { - allowFailure: true, - } - ) - - if (!reinstallResult.ok) { - throw new Error( - `Deterministic iOS bootstrap failed during reinstall.\n\n${reinstallResult.stderr || reinstallResult.stdout}` - ) - } + if ((context.qa?.acceptanceCriteria ?? []).length > 0) { + return context.qa?.acceptanceCriteria ?? [] } - const openResult = await runAgentDeviceCommand( - sessionName, - 'open', - [...deviceSelectorArgs, context.appId, '--relaunch'], - { - allowFailure: true, - } - ) - - if (!openResult.ok) { - throw new Error( - `Deterministic app bootstrap failed during open.\n\n${openResult.stderr || openResult.stdout}` - ) + if (context.pullRequest?.body?.trim()) { + return [context.pullRequest.body.trim()] } -} - -async function listScreenshots(screenshotsDir: string) { - let entries: string[] - try { - entries = await readdir(screenshotsDir) - } catch { - return [] + if (context.task?.body?.trim()) { + return [context.task.body.trim()] } - const screenshots: Array> = [] - for (const entry of entries) { - if (!entry.endsWith('.png')) { - continue - } - - const absolutePath = path.join(screenshotsDir, entry) - const fileStat = await stat(absolutePath) - screenshots.push({ - fileName: entry, - absolutePath, - bytes: fileStat.size, - }) + if (prompt?.trim()) { + return [prompt.trim()] } - return screenshots.sort((left, right) => left.fileName.localeCompare(right.fileName)) + return ['Run a lightweight mobile QA pass and report the observed result.'] } -function composeReport( +function composeQaReport( model: string, - context: QaRuntimeContext, + context: Parameters[0]['context'], reportInput: QaReportInput, screenshots: Array>, - agentDeviceTrace: QaReport['agentDeviceTrace'] + agentDeviceTrace: QaReport['agentDeviceTrace'], + acceptanceCriteriaUsed: string[] ): QaReport { const screenshotLabelMap = new Map( (reportInput.screenshotLabels ?? []) @@ -212,6 +60,7 @@ function composeReport( ) return { + command: 'qa', generatedAt: new Date().toISOString(), model, context, @@ -226,6 +75,8 @@ function composeReport( label: screenshotLabelMap.get(screenshot.fileName) ?? humanizeScreenshotLabel(screenshot.fileName), })), + acceptanceCriteriaUsed, + environmentNotes: reportInput.environmentNotes ?? [], agentDeviceTrace: agentDeviceTrace.slice(-20), } } @@ -237,57 +88,54 @@ function createBlockedReport(summary: string): QaReportInput { checked: ['Run a mobile QA pass'], issues: [summary], nextSteps: ['Inspect the bootstrap and runtime logs, then retry the QA run.'], + environmentNotes: [summary], } } -async function publishReport(report: QaReport, publishers: string[]) { - let currentReport = report - - for (const publisher of publishers) { - if (publisher === 'blob') { - currentReport = await publishBlobReport({ report: currentReport }) - continue - } - - if (publisher === 'file') { - currentReport = await publishFileReport({ report: currentReport }) - } - } - - return currentReport -} - -export async function runQaCommand(cli: QaCliOptions) { - const cwd = process.cwd() - printPhase('Resolving config') - const { config, context } = await resolveEnvironmentContext(cwd, cli) - const sessionName = process.env.AGENT_DEVICE_SESSION ?? DEFAULT_AGENT_DEVICE_SESSION_NAME - - printPhase( - 'Preparing output', - `${context.platform} | ${context.deviceName ?? 'bound device'} | ${context.appId}` - ) - await ensureDirectory(context.outputDir) - await rm(context.screenshotsDir, { force: true, recursive: true }) - await ensureDirectory(context.screenshotsDir) +export async function runQaCommand(cli: CommandCliOptions) { + const { cwd, config, context } = await loadRunContext('qa', cli) + const acceptanceCriteriaUsed = resolveAcceptanceCriteria(context, cli.prompt) let reportInput: QaReportInput let agentDeviceTrace: QaReport['agentDeviceTrace'] = [] + let mobileContext: Awaited> | undefined + let sessionName: string | undefined try { - printPhase('Bootstrapping app', context.artifactPath) - await bootstrapApp(context, sessionName) + mobileContext = await resolveMobileRuntimeContext('qa', config.envName, context) + sessionName = createAgentDeviceSessionName(mobileContext.platform) + + printPhase( + 'Preparing output', + `${mobileContext.platform} | ${mobileContext.deviceName ?? 'bound device'} | ${mobileContext.appId}` + ) + await prepareMobileOutputDirectories(mobileContext) + + printPhase('Bootstrapping app', mobileContext.artifactPath) + await bootstrapMobileApp('qa', config.envName, mobileContext, sessionName) printPhase('Bootstrap complete') + printPhase('Preparing tool packs', config.enabledToolPacks.join(', ')) + const preparedToolPacks = await prepareToolPacks({ + context, + skillPaths: config.skillPaths, + enabledToolPacks: config.enabledToolPacks, + sessionName, + }) + printPhase('Running QA agent', config.model) const roleResult = await runQaMobileRole({ context, modelId: config.model, - sessionName, - skillPaths: config.skillPaths, - enabledToolPacks: config.enabledToolPacks, + tools: { + ...preparedToolPacks.tools, + get_run_context: createRunContextTool('qa', context), + }, + availableSkillsPrompt: preparedToolPacks.availableSkillsPrompt, + preloadedSkillsPrompt: preparedToolPacks.preloadedSkillsPrompt, extraInstructions: config.extraInstructions, prompt: cli.prompt, + acceptanceCriteriaUsed, onAgentStep: (event) => { printPhase('QA agent step complete', formatAgentStepDetail(event)) }, @@ -297,25 +145,31 @@ export async function runQaCommand(cli: QaCliOptions) { }) reportInput = roleResult.reportInput - agentDeviceTrace = roleResult.agentDeviceTrace + agentDeviceTrace = preparedToolPacks.traces.agentDeviceTrace } catch (unknownError) { const error = unknownError instanceof Error ? unknownError : new Error(String(unknownError)) printPhase('Run blocked', summarizeReason(error.message)) reportInput = createBlockedReport(error.message) + } finally { + if (sessionName) { + await closeAgentDeviceSession(sessionName) + } } - const screenshots = await listScreenshots(context.screenshotsDir) - const report = composeReport(config.model, context, reportInput, screenshots, agentDeviceTrace) + const screenshots = mobileContext ? await listScreenshots(mobileContext.screenshotsDir) : [] + const report = composeQaReport( + config.model, + context, + reportInput, + screenshots, + agentDeviceTrace, + acceptanceCriteriaUsed + ) printPhase('Publishing report', config.outputPublishers.join(', ')) - const publishedReport = await publishReport(report, config.outputPublishers) + const publishedReport = await publishReport({ + report, + publishers: config.outputPublishers, + }) - console.log( - `QA report written to ${resolveFromCwd(cwd, path.join(context.outputDir, 'section.md'))}` - ) - const reason = summarizeReason(publishedReport.summary) - console.log( - reason - ? `Overall status: ${publishedReport.overallStatus} (${reason})` - : `Overall status: ${publishedReport.overallStatus}` - ) + printFinalReport(cwd, 'qa', 'QA report', publishedReport) } diff --git a/packages/cali/src/commands/review.ts b/packages/cali/src/commands/review.ts new file mode 100644 index 0000000..27cfdaa --- /dev/null +++ b/packages/cali/src/commands/review.ts @@ -0,0 +1,40 @@ +import type { ReviewReport } from '../report/types.js' +import { runReviewRole } from '../roles/review.js' +import type { CommandCliOptions } from '../runtime/types.js' +import { runStructuredCommand } from './shared.js' + +function createBlockedReviewReport(summary: string) { + return { + overallStatus: 'blocked' as const, + summary, + findings: [], + strengths: [], + validationGaps: [], + nextSteps: ['Inspect the repository context and retry the review run.'], + environmentNotes: [summary], + } +} + +export async function runReviewCommand(cli: CommandCliOptions) { + return runStructuredCommand({ + commandId: 'review', + cli, + roleLabel: 'Review', + reportLabel: 'Review report', + createBlockedReport: createBlockedReviewReport, + composeReport: ({ model, context, reportInput }): ReviewReport => ({ + command: 'review', + generatedAt: new Date().toISOString(), + model, + context, + overallStatus: reportInput.overallStatus, + summary: reportInput.summary, + findings: reportInput.findings ?? [], + strengths: reportInput.strengths ?? [], + validationGaps: reportInput.validationGaps ?? [], + nextSteps: reportInput.nextSteps ?? [], + environmentNotes: reportInput.environmentNotes ?? [], + }), + runRole: runReviewRole, + }) +} diff --git a/packages/cali/src/commands/shared.ts b/packages/cali/src/commands/shared.ts new file mode 100644 index 0000000..4166330 --- /dev/null +++ b/packages/cali/src/commands/shared.ts @@ -0,0 +1,214 @@ +import path from 'node:path' + +import { tool } from 'ai' +import { z } from 'zod' + +import { loadCommandConfig } from '../config/load.js' +import type { ToolPackName } from '../config/schema.js' +import type { CommandReport } from '../report/types.js' +import { resolveCommandContext } from '../runtime/context.js' +import { publishReport } from '../runtime/publishers.js' +import { prepareToolPacks } from '../runtime/tool-packs.js' +import type { + CaliContext, + CommandCliOptions, + CommandId, + CommandResolvedConfig, +} from '../runtime/types.js' +import { resolveFromCwd } from '../utils.js' + +type AgentProgressEvent = { + stepNumber: number + finishReason: string + toolNames: string[] + totalTokens?: number +} + +type AgentFinishEvent = { + stepCount: number + finishReason: string + totalTokens?: number +} + +type RoleRunArgs = { + context: CaliContext + modelId: string + tools: Record + availableSkillsPrompt: string + preloadedSkillsPrompt: string + extraInstructions: string[] + prompt?: string + onAgentStep?: (event: AgentProgressEvent) => void + onAgentFinish?: (event: AgentFinishEvent) => void +} + +type RunStructuredCommandOptions = { + commandId: CommandId + cli: CommandCliOptions + roleLabel: string + reportLabel: string + createBlockedReport: (summary: string) => TReportInput + composeReport: (args: { + model: string + context: CaliContext + reportInput: TReportInput + }) => TReport + runRole: (args: RoleRunArgs) => Promise<{ reportInput: TReportInput }> + getEnabledToolPacks?: (args: { + context: CaliContext + config: CommandResolvedConfig + }) => ToolPackName[] +} + +export function printPhase(title: string, detail?: string) { + console.log(detail ? `${title}: ${detail}` : title) +} + +export function summarizeReason(text: string) { + return text + .split('\n') + .map((line) => line.trim()) + .find(Boolean) +} + +export function formatAgentStepDetail(event: AgentProgressEvent) { + const details = [`step ${event.stepNumber}`, `finish=${event.finishReason}`] + + if (event.toolNames.length > 0) { + details.push(`tools=${event.toolNames.join(',')}`) + } + + if (event.totalTokens != null) { + details.push(`tokens=${event.totalTokens}`) + } + + return details.join(' | ') +} + +export function formatAgentFinishDetail(event: AgentFinishEvent) { + const details = [`steps=${event.stepCount}`, `finish=${event.finishReason}`] + + if (event.totalTokens != null) { + details.push(`tokens=${event.totalTokens}`) + } + + return details.join(' | ') +} + +export async function loadRunContext(commandId: CommandId, cli: CommandCliOptions) { + const cwd = process.cwd() + printPhase('Resolving config') + + const config = await loadCommandConfig({ + commandId, + cwd, + configPath: cli.configPath, + envName: cli.envName, + model: cli.model, + }) + const context = await resolveCommandContext(commandId, cwd, config, cli) + + return { + cwd, + config, + context, + } +} + +export function createRunContextTool(commandId: CommandId, context: CaliContext) { + return tool({ + description: `Read the normalized ${commandId} run context and metadata.`, + inputSchema: z.object({}), + execute: async () => context, + }) +} + +export function printFinalReport( + cwd: string, + commandId: CommandId, + reportLabel: string, + report: CommandReport +) { + console.log( + `${reportLabel} written to ${resolveFromCwd( + cwd, + path.join(report.context.output.outputDir ?? path.join('artifacts', commandId), 'section.md') + )}` + ) + + const reason = summarizeReason(report.summary) + console.log( + reason + ? `Overall status: ${report.overallStatus} (${reason})` + : `Overall status: ${report.overallStatus}` + ) +} + +export async function runStructuredCommand( + options: RunStructuredCommandOptions +) { + const { + commandId, + cli, + roleLabel, + reportLabel, + createBlockedReport, + composeReport, + runRole, + getEnabledToolPacks, + } = options + const { cwd, config, context } = await loadRunContext(commandId, cli) + + let reportInput: TReportInput + + try { + const enabledToolPacks = getEnabledToolPacks?.({ context, config }) ?? config.enabledToolPacks + + printPhase('Preparing tool packs', enabledToolPacks.join(', ')) + const toolPacks = await prepareToolPacks({ + context, + skillPaths: config.skillPaths, + enabledToolPacks, + }) + + printPhase(`Running ${roleLabel} agent`, config.model) + const result = await runRole({ + context, + modelId: config.model, + tools: { + ...toolPacks.tools, + get_run_context: createRunContextTool(commandId, context), + }, + availableSkillsPrompt: toolPacks.availableSkillsPrompt, + preloadedSkillsPrompt: toolPacks.preloadedSkillsPrompt, + extraInstructions: config.extraInstructions, + prompt: cli.prompt, + onAgentStep: (event) => { + printPhase(`${roleLabel} agent step complete`, formatAgentStepDetail(event)) + }, + onAgentFinish: (event) => { + printPhase(`${roleLabel} agent finished`, formatAgentFinishDetail(event)) + }, + }) + + reportInput = result.reportInput + } catch (unknownError) { + const error = unknownError instanceof Error ? unknownError : new Error(String(unknownError)) + printPhase('Run blocked', summarizeReason(error.message)) + reportInput = createBlockedReport(error.message) + } + + const report = composeReport({ + model: config.model, + context, + reportInput, + }) + + printPhase('Publishing report', config.outputPublishers.join(', ')) + const publishedReport = await publishReport({ + report, + publishers: config.outputPublishers, + }) + + printFinalReport(cwd, commandId, reportLabel, publishedReport) +} diff --git a/packages/cali/src/config/load.ts b/packages/cali/src/config/load.ts index e935099..2ee49e4 100644 --- a/packages/cali/src/config/load.ts +++ b/packages/cali/src/config/load.ts @@ -4,16 +4,18 @@ import path from 'node:path' import { cosmiconfig } from 'cosmiconfig' -import type { QaResolvedConfig } from '../env/types.js' -import { resolveQaModelId } from '../model.js' +import type { CommandResolvedConfig } from '../runtime/types.js' +import type { CommandConfigKey, CommandId } from '../runtime/types.js' +import { commandConfigKeyFromId } from '../runtime/types.js' import { asArray, resolveFromCwd, uniqueStrings } from '../utils.js' -import type { CaliQaConfig, PublisherName, QaEnvName, ToolPackName } from './schema.js' -import { CaliQaConfigSchema, normalizeQaEnvName } from './schema.js' +import type { CaliCommandConfig, CaliConfig, CaliEnvName, PublisherName } from './schema.js' +import { CaliConfigSchema, normalizeCaliEnvName } from './schema.js' -type LoadQaConfigOptions = { +type LoadCommandConfigOptions = { + commandId: CommandId cwd: string configPath?: string - envName?: QaEnvName + envName?: CaliEnvName model?: string } @@ -21,72 +23,119 @@ function getBuiltInSkillPaths(cwd: string) { return [path.join(cwd, '.agents', 'skills'), path.join(homedir(), '.agents', 'skills')] } -function getEnvConfig(cwd: string, envName: QaEnvName): CaliQaConfig { - const enabledToolPacks: ToolPackName[] = ['skills', 'agent-device'] - const outputPublishers: PublisherName[] = ['blob', 'file'] - const common = { - role: 'qa' as const, - skillPaths: getBuiltInSkillPaths(cwd), - enabledToolPacks, - outputPublishers, +const QA_ENV_DEFAULTS: Record = { + 'mobile-pr': { + enabledToolPacks: ['skills', 'agent-device', 'report'], + outputPublishers: ['blob', 'file'], + extraInstructions: [ + 'Infer concise acceptance criteria from pull request or task metadata and prioritize user-visible flows.', + 'Treat the repository as a black box and avoid source inspection unless the config explicitly says otherwise.', + ], + }, + 'local-ios': { + enabledToolPacks: ['skills', 'agent-device', 'report'], + outputPublishers: ['blob', 'file'], + mobileDefaults: { + platform: 'ios', + }, + extraInstructions: [ + 'This is a local iOS QA run. Keep the flow lightweight and focus on the highest-signal UI paths.', + ], + }, + 'local-android': { + enabledToolPacks: ['skills', 'agent-device', 'report'], + outputPublishers: ['blob', 'file'], + mobileDefaults: { + platform: 'android', + }, + extraInstructions: [ + 'This is a local Android QA run. Keep the flow lightweight and focus on the highest-signal UI paths.', + ], + }, +} + +function getDefaultEnvName(commandId: CommandId): CaliEnvName { + switch (commandId) { + case 'review': + case 'dev': + return 'mobile-pr' + case 'qa': + case 'perf-review': + default: + return 'local-android' } +} - switch (envName) { - case 'mobile-pr': - return { - ...common, - env: envName, - extraInstructions: [ - 'Infer concise acceptance criteria from pull request or task metadata and prioritize user-visible flows.', - 'Treat the repository as a black box and avoid source inspection unless the config explicitly says otherwise.', - ], +function getEnvCommandDefaults(commandId: CommandId, envName: CaliEnvName): CaliCommandConfig { + const commonOutputPublishers: PublisherName[] = ['file'] + const mobileOutputPublishers: PublisherName[] = ['blob', 'file'] + + switch (commandId) { + case 'qa': + return QA_ENV_DEFAULTS[envName] + case 'perf-review': + switch (envName) { + case 'mobile-pr': + return { + enabledToolPacks: ['skills', 'agent-device', 'react-devtools', 'repo-read', 'report'], + outputPublishers: mobileOutputPublishers, + extraInstructions: [ + 'Focus on high-signal runtime performance evidence such as rerenders, slow interactions, and component-level bottlenecks.', + ], + } + case 'local-ios': + return { + enabledToolPacks: ['skills', 'agent-device', 'react-devtools', 'repo-read', 'report'], + outputPublishers: mobileOutputPublishers, + mobileDefaults: { + platform: 'ios', + }, + } + case 'local-android': + default: + return { + enabledToolPacks: ['skills', 'agent-device', 'react-devtools', 'repo-read', 'report'], + outputPublishers: mobileOutputPublishers, + mobileDefaults: { + platform: 'android', + }, + } } - case 'local-ios': + case 'review': return { - ...common, - env: envName, - platformDefaults: { - platform: 'ios', - }, - extraInstructions: [ - 'This is a local iOS QA run. Keep the flow lightweight and focus on the highest-signal UI paths.', - ], + enabledToolPacks: ['repo-read', 'github', 'skills', 'report'], + outputPublishers: commonOutputPublishers, } - case 'local-android': - default: + case 'dev': return { - ...common, - env: envName, - platformDefaults: { - platform: 'android', - }, - extraInstructions: [ - 'This is a local Android QA run. Keep the flow lightweight and focus on the highest-signal UI paths.', - ], + enabledToolPacks: ['repo-read', 'repo-write', 'github', 'skills', 'report'], + outputPublishers: commonOutputPublishers, } } } -function mergeConfig(base: CaliQaConfig, override: CaliQaConfig): CaliQaConfig { +function getCommandConfig(config: CaliConfig, key: CommandConfigKey): CaliCommandConfig { + return config.commands?.[key] ?? {} +} + +function mergeCommandConfig( + base: CaliCommandConfig, + override: CaliCommandConfig +): CaliCommandConfig { return { - role: override.role ?? base.role ?? 'qa', - env: override.env ?? base.env, - appId: override.appId ?? base.appId, contextPath: override.contextPath ?? base.contextPath, - platformDefaults: { - ...base.platformDefaults, - ...override.platformDefaults, - }, - outputDir: override.outputDir ?? base.outputDir, - skillPaths: uniqueStrings([...(base.skillPaths ?? []), ...(override.skillPaths ?? [])]), enabledToolPacks: override.enabledToolPacks ?? base.enabledToolPacks, outputPublishers: override.outputPublishers ?? base.outputPublishers, extraInstructions: [...asArray(base.extraInstructions), ...asArray(override.extraInstructions)], model: override.model ?? base.model, + mobileDefaults: { + ...base.mobileDefaults, + ...override.mobileDefaults, + }, } } -async function loadConfigFile(cwd: string, explicitPath?: string): Promise { +export async function loadCaliConfigFile(cwd: string, explicitPath?: string): Promise { const explorer = cosmiconfig('cali', { searchPlaces: [ 'cali.config.ts', @@ -105,31 +154,36 @@ async function loadConfigFile(cwd: string, explicitPath?: string): Promise { - const { cwd, configPath, envName: cliEnvName, model } = options - const fileConfig = await loadConfigFile(cwd, configPath) - const envName = cliEnvName ?? normalizeQaEnvName(fileConfig.env) ?? 'local-android' - const envConfig = getEnvConfig(cwd, envName) - const merged = mergeConfig(envConfig, fileConfig) +export async function loadCommandConfig( + options: LoadCommandConfigOptions +): Promise { + const { commandId, cwd, configPath, envName: cliEnvName, model } = options + const fileConfig = await loadCaliConfigFile(cwd, configPath) + const envName = cliEnvName ?? normalizeCaliEnvName(fileConfig.env) ?? getDefaultEnvName(commandId) + const envDefaults = getEnvCommandDefaults(commandId, envName) + const commandConfig = getCommandConfig(fileConfig, commandConfigKeyFromId(commandId)) + const merged = mergeCommandConfig(envDefaults, commandConfig) return { envName, - appId: merged.appId, + workspaceRoot: fileConfig.workspaceRoot + ? resolveFromCwd(cwd, fileConfig.workspaceRoot) + : undefined, contextPath: merged.contextPath ? resolveFromCwd(cwd, merged.contextPath) : undefined, - platformDefaults: merged.platformDefaults ?? {}, - outputDir: merged.outputDir, - skillPaths: uniqueStrings(merged.skillPaths ?? []), - enabledToolPacks: merged.enabledToolPacks ?? ['skills', 'agent-device'], - outputPublishers: merged.outputPublishers ?? ['blob', 'file'], + skillPaths: uniqueStrings([...(fileConfig.skillPaths ?? []), ...getBuiltInSkillPaths(cwd)]), + enabledToolPacks: merged.enabledToolPacks ?? ['report'], + outputPublishers: merged.outputPublishers ?? ['file'], extraInstructions: asArray(merged.extraInstructions), - model: resolveQaModelId(model ?? merged.model), + model: + model ?? merged.model ?? fileConfig.model ?? process.env.QA_MODEL ?? 'openai/gpt-5.4-mini', + mobileDefaults: merged.mobileDefaults ?? {}, } } diff --git a/packages/cali/src/config/schema.ts b/packages/cali/src/config/schema.ts index 95d278b..2a4d1b4 100644 --- a/packages/cali/src/config/schema.ts +++ b/packages/cali/src/config/schema.ts @@ -1,43 +1,63 @@ import { z } from 'zod' -const QaEnvNameSchema = z.enum(['mobile-pr', 'local-android', 'local-ios']) -const ToolPackNameSchema = z.enum(['skills', 'agent-device']) +const CaliEnvNameSchema = z.enum(['mobile-pr', 'local-android', 'local-ios']) +const ToolPackNameSchema = z.enum([ + 'skills', + 'agent-device', + 'repo-read', + 'repo-write', + 'github', + 'react-devtools', + 'report', +]) const PublisherNameSchema = z.enum(['file', 'blob']) -const QaPlatformSchema = z.enum(['android', 'ios']) +const CommandIdSchema = z.enum(['qa', 'review', 'perf-review', 'dev']) +const CaliPlatformSchema = z.enum(['android', 'ios']) const StringArraySchema = z.union([z.string(), z.array(z.string())]).optional() -export function normalizeQaEnvName(value?: string): QaEnvName | undefined { - switch (value) { - case 'mobile-pr': - case 'local-android': - case 'local-ios': - return value - default: - return undefined - } -} +const MobileDefaultsSchema = z + .object({ + platform: CaliPlatformSchema.optional(), + deviceName: z.string().optional(), + appId: z.string().optional(), + }) + .optional() -export const CaliQaConfigSchema = z.object({ - role: z.literal('qa').optional(), - env: QaEnvNameSchema.optional(), - appId: z.string().optional(), +const CommandConfigSchema = z.object({ contextPath: z.string().optional(), - platformDefaults: z - .object({ - platform: QaPlatformSchema.optional(), - deviceName: z.string().optional(), - }) - .optional(), - outputDir: z.string().optional(), - skillPaths: z.array(z.string()).optional(), enabledToolPacks: z.array(ToolPackNameSchema).optional(), outputPublishers: z.array(PublisherNameSchema).optional(), extraInstructions: StringArraySchema, model: z.string().optional(), + mobileDefaults: MobileDefaultsSchema, +}) + +export function normalizeCaliEnvName(value?: string): CaliEnvName | undefined { + const result = CaliEnvNameSchema.safeParse(value) + return result.success ? result.data : undefined +} + +export const CaliConfigSchema = z.object({ + defaultCommand: CommandIdSchema.optional(), + env: CaliEnvNameSchema.optional(), + workspaceRoot: z.string().optional(), + skillPaths: z.array(z.string()).optional(), + outputPublishers: z.array(PublisherNameSchema).optional(), + model: z.string().optional(), + commands: z + .object({ + qa: CommandConfigSchema.optional(), + review: CommandConfigSchema.optional(), + perfReview: CommandConfigSchema.optional(), + dev: CommandConfigSchema.optional(), + }) + .optional(), }) -export type QaEnvName = z.infer +export type CaliEnvName = z.infer export type ToolPackName = z.infer export type PublisherName = z.infer -export type CaliQaConfig = z.infer +export type CommandId = z.infer +export type CaliConfig = z.infer +export type CaliCommandConfig = z.infer diff --git a/packages/cali/src/env/context-file.ts b/packages/cali/src/env/context-file.ts deleted file mode 100644 index af5cc94..0000000 --- a/packages/cali/src/env/context-file.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { readFile } from 'node:fs/promises' -import path from 'node:path' - -import { z } from 'zod' - -import { resolveFromCwd } from '../utils.js' -import type { QaCliOptions, QaResolvedConfig, QaRuntimeContext } from './types.js' - -const ContextMetadataSchema = z - .object({ - prNumber: z.number().optional(), - prTitle: z.string().optional(), - prBody: z.string().nullable().optional(), - prLabels: z.array(z.string()).optional(), - isDraft: z.boolean().optional(), - taskId: z.string().optional(), - taskTitle: z.string().optional(), - taskBody: z.string().optional(), - }) - .optional() - -const QaContextFileSchema = z.object({ - platform: z.enum(['android', 'ios']), - artifactPath: z.string(), - appId: z.string().optional(), - buildId: z.string().optional(), - workflowUrl: z.string().optional(), - outputDir: z.string().optional(), - deviceName: z.string().optional(), - metadata: ContextMetadataSchema, -}) - -export async function fromContextFile( - cwd: string, - config: QaResolvedConfig, - cli: QaCliOptions -): Promise { - const contextPath = cli.contextPath ?? config.contextPath - - if (!contextPath) { - throw new Error('Context file mode requires --context or config.contextPath.') - } - - const absolutePath = resolveFromCwd(cwd, contextPath) - const content = await readFile(absolutePath, 'utf8') - const parsed = QaContextFileSchema.parse(JSON.parse(content)) - const outputDir = resolveFromCwd( - cwd, - cli.outputDir ?? parsed.outputDir ?? config.outputDir ?? path.join('artifacts', 'qa') - ) - const appId = cli.appId ?? config.appId ?? parsed.appId - - if (!appId) { - throw new Error('Context file requires `appId` in the JSON file, config, or --app-id.') - } - - return { - platform: cli.platform ?? parsed.platform, - artifactPath: resolveFromCwd(cwd, cli.artifactPath ?? parsed.artifactPath), - appId, - buildId: cli.buildId ?? parsed.buildId ?? 'context-build', - workflowUrl: cli.workflowUrl ?? parsed.workflowUrl ?? '', - outputDir, - screenshotsDir: path.join(outputDir, 'screenshots'), - deviceName: cli.deviceName ?? parsed.deviceName ?? config.platformDefaults.deviceName, - metadata: { - prNumber: cli.prNumber ?? parsed.metadata?.prNumber, - prTitle: cli.prTitle ?? parsed.metadata?.prTitle, - prBody: cli.prBody ?? parsed.metadata?.prBody, - prLabels: parsed.metadata?.prLabels ?? [], - isDraft: parsed.metadata?.isDraft ?? false, - taskId: cli.taskId ?? parsed.metadata?.taskId, - taskTitle: cli.taskTitle ?? parsed.metadata?.taskTitle, - taskBody: cli.taskBody ?? parsed.metadata?.taskBody, - }, - } -} diff --git a/packages/cali/src/env/local.ts b/packages/cali/src/env/local.ts deleted file mode 100644 index b0bc0f4..0000000 --- a/packages/cali/src/env/local.ts +++ /dev/null @@ -1,53 +0,0 @@ -import path from 'node:path' - -import { resolveFromCwd } from '../utils.js' -import type { QaCliOptions, QaRuntimeContext } from './types.js' -import type { QaResolvedConfig } from './types.js' - -export async function fromLocalFlags( - cwd: string, - config: QaResolvedConfig, - cli: QaCliOptions -): Promise { - const platform = cli.platform ?? config.platformDefaults.platform - const artifactPath = cli.artifactPath - const appId = cli.appId ?? config.appId - - if (!platform) { - throw new Error('Local env requires --platform or an env default platform.') - } - - if (!artifactPath) { - throw new Error('Local env requires --artifact unless you provide a context file.') - } - - if (!appId) { - throw new Error('Local env requires --app-id or config.appId.') - } - - const outputDir = resolveFromCwd( - cwd, - cli.outputDir ?? config.outputDir ?? path.join('artifacts', 'qa') - ) - - return { - platform, - artifactPath: resolveFromCwd(cwd, artifactPath), - appId, - buildId: cli.buildId ?? 'local-build', - workflowUrl: cli.workflowUrl ?? '', - outputDir, - screenshotsDir: path.join(outputDir, 'screenshots'), - deviceName: cli.deviceName ?? config.platformDefaults.deviceName, - metadata: { - prNumber: cli.prNumber, - prTitle: cli.prTitle, - prBody: cli.prBody, - prLabels: [], - isDraft: false, - taskId: cli.taskId, - taskTitle: cli.taskTitle, - taskBody: cli.taskBody, - }, - } -} diff --git a/packages/cali/src/env/types.ts b/packages/cali/src/env/types.ts deleted file mode 100644 index a067cc3..0000000 --- a/packages/cali/src/env/types.ts +++ /dev/null @@ -1,63 +0,0 @@ -import type { QaEnvName, PublisherName, ToolPackName } from '../config/schema.js' - -export type QaPlatform = 'android' | 'ios' - -export type QaMetadata = { - prNumber?: number - prTitle?: string - prBody?: string | null - prLabels: string[] - isDraft: boolean - taskId?: string - taskTitle?: string - taskBody?: string -} - -export type QaRuntimeContext = { - platform: QaPlatform - artifactPath: string - appId: string - buildId: string - workflowUrl: string - outputDir: string - screenshotsDir: string - deviceName?: string - metadata: QaMetadata -} - -export type QaCliOptions = { - envName?: QaEnvName - configPath?: string - prompt?: string - contextPath?: string - platform?: QaPlatform - artifactPath?: string - appId?: string - deviceName?: string - outputDir?: string - buildId?: string - workflowUrl?: string - prNumber?: number - prTitle?: string - prBody?: string - taskId?: string - taskTitle?: string - taskBody?: string - model?: string -} - -export type QaResolvedConfig = { - envName: QaEnvName - appId?: string - contextPath?: string - platformDefaults: { - platform?: QaPlatform - deviceName?: string - } - outputDir?: string - skillPaths: string[] - enabledToolPacks: ToolPackName[] - outputPublishers: PublisherName[] - extraInstructions: string[] - model: string -} diff --git a/packages/cali/src/model.ts b/packages/cali/src/model.ts index a4d61ca..6736fdd 100644 --- a/packages/cali/src/model.ts +++ b/packages/cali/src/model.ts @@ -2,51 +2,26 @@ import { createAnthropic } from '@ai-sdk/anthropic' const DEFAULT_QA_MODEL_ID = 'openai/gpt-5.4-mini' -function hasGatewayCredentials() { - return Boolean(process.env.AI_GATEWAY_API_KEY || process.env.AI_GATEWAY_KEY) -} - -function isRunningOnVercel() { - return Boolean(process.env.VERCEL || process.env.VERCEL_ENV || process.env.VERCEL_OIDC_TOKEN) -} - -function getAnthropicCredentials() { - return { - apiKey: process.env.ANTHROPIC_API_KEY ?? process.env.CLAUDE_API_KEY, - authToken: process.env.ANTHROPIC_AUTH_TOKEN ?? process.env.CLAUDE_AUTH_TOKEN, - } -} - -function hasAnthropicCredentials() { - const anthropic = getAnthropicCredentials() - return Boolean(anthropic.apiKey || anthropic.authToken) -} - function stripAnthropicPrefix(modelId: string) { return modelId.startsWith('anthropic/') ? modelId.slice('anthropic/'.length) : modelId } -function ensureGatewayApiKeyAlias() { - if (!process.env.AI_GATEWAY_API_KEY && process.env.AI_GATEWAY_KEY) { - process.env.AI_GATEWAY_API_KEY = process.env.AI_GATEWAY_KEY - } -} - -export function resolveQaModelId(configuredModelId?: string) { - return configuredModelId ?? process.env.QA_MODEL ?? DEFAULT_QA_MODEL_ID -} +export function createQaAgentModel(modelId = process.env.QA_MODEL ?? DEFAULT_QA_MODEL_ID) { + const gatewayKey = process.env.AI_GATEWAY_API_KEY || process.env.AI_GATEWAY_KEY + if (gatewayKey || process.env.VERCEL || process.env.VERCEL_ENV || process.env.VERCEL_OIDC_TOKEN) { + if (!process.env.AI_GATEWAY_API_KEY && gatewayKey) { + process.env.AI_GATEWAY_API_KEY = gatewayKey + } -export function createQaAgentModel(modelId = resolveQaModelId()) { - if (hasGatewayCredentials() || isRunningOnVercel()) { - ensureGatewayApiKeyAlias() return modelId } - if (hasAnthropicCredentials()) { - const anthropicCredentials = getAnthropicCredentials() + const apiKey = process.env.ANTHROPIC_API_KEY ?? process.env.CLAUDE_API_KEY + const authToken = process.env.ANTHROPIC_AUTH_TOKEN ?? process.env.CLAUDE_AUTH_TOKEN + if (apiKey || authToken) { const anthropic = createAnthropic({ - ...(anthropicCredentials.apiKey ? { apiKey: anthropicCredentials.apiKey } : {}), - ...(anthropicCredentials.authToken ? { authToken: anthropicCredentials.authToken } : {}), + ...(apiKey ? { apiKey } : {}), + ...(authToken ? { authToken } : {}), }) return anthropic(stripAnthropicPrefix(modelId)) diff --git a/packages/cali/src/report/publishers/blob.ts b/packages/cali/src/report/publishers/blob.ts index 3e88066..2722ef7 100644 --- a/packages/cali/src/report/publishers/blob.ts +++ b/packages/cali/src/report/publishers/blob.ts @@ -2,15 +2,19 @@ import { readFile } from 'node:fs/promises' import { put } from '@vercel/blob' -import type { QaReport } from '../types.js' +import type { CommandReport, PerfReviewReport, QaReport } from '../types.js' type BlobPublishOptions = { - report: QaReport + report: CommandReport } -export async function publishBlobReport({ report }: BlobPublishOptions): Promise { +function hasScreenshots(report: CommandReport): report is QaReport | PerfReviewReport { + return 'screenshots' in report +} + +export async function publishBlobReport({ report }: BlobPublishOptions): Promise { const token = process.env.BLOB_READ_WRITE_TOKEN - if (!token || report.screenshots.length === 0) { + if (!token || !hasScreenshots(report) || report.screenshots.length === 0) { return report } @@ -20,10 +24,10 @@ export async function publishBlobReport({ report }: BlobPublishOptions): Promise const fileBuffer = await readFile(screenshot.absolutePath) const pathnameParts = [ 'cali', - 'qa', - report.context.platform, - report.context.metadata.prNumber ? `pr-${report.context.metadata.prNumber}` : 'ad-hoc', - report.context.buildId || 'local-build', + report.command, + report.context.mobile?.platform ?? 'workspace', + report.context.pullRequest?.number ? `pr-${report.context.pullRequest.number}` : 'ad-hoc', + report.context.build?.id ?? 'local-build', screenshot.fileName, ] const blob = await put(pathnameParts.join('/'), fileBuffer, { @@ -53,5 +57,5 @@ export async function publishBlobReport({ report }: BlobPublishOptions): Promise return { ...report, screenshots, - } + } satisfies CommandReport } diff --git a/packages/cali/src/report/publishers/file.ts b/packages/cali/src/report/publishers/file.ts index 9256f5d..83588c3 100644 --- a/packages/cali/src/report/publishers/file.ts +++ b/packages/cali/src/report/publishers/file.ts @@ -2,30 +2,41 @@ import { writeFile } from 'node:fs/promises' import path from 'node:path' import { ensureDirectory } from '../../utils.js' -import { renderQaSection } from '../render.js' -import type { QaReport } from '../types.js' +import { renderCommandSection } from '../render.js' +import type { CommandReport, ReportPublisherResult } from '../types.js' type FilePublishOptions = { - report: QaReport + report: CommandReport + publisherResults: ReportPublisherResult[] } -export async function publishFileReport({ report }: FilePublishOptions): Promise { - await ensureDirectory(report.context.outputDir) - await writeFile( - path.join(report.context.outputDir, 'report.json'), - `${JSON.stringify(report, null, 2)}\n`, - 'utf8' - ) +export async function publishFileReport({ + report, + publisherResults, +}: FilePublishOptions): Promise { + const outputDir = report.context.output.outputDir + if (!outputDir) { + throw new Error('File publisher requires context.output.outputDir.') + } + + const finalReport = { + ...report, + publisherResults, + } satisfies CommandReport + + await ensureDirectory(outputDir) await writeFile( - path.join(report.context.outputDir, 'section.md'), - renderQaSection(report), + path.join(outputDir, 'report.json'), + `${JSON.stringify(finalReport, null, 2)}\n`, 'utf8' ) + await writeFile(path.join(outputDir, 'section.md'), renderCommandSection(finalReport), 'utf8') + await writeFile(path.join(outputDir, 'status.txt'), `${finalReport.overallStatus}\n`, 'utf8') await writeFile( - path.join(report.context.outputDir, 'status.txt'), - `${report.overallStatus}\n`, + path.join(outputDir, 'publisher-manifest.json'), + `${JSON.stringify(publisherResults, null, 2)}\n`, 'utf8' ) - return report + return finalReport } diff --git a/packages/cali/src/report/render.ts b/packages/cali/src/report/render.ts index b29fe8c..dd3bad0 100644 --- a/packages/cali/src/report/render.ts +++ b/packages/cali/src/report/render.ts @@ -1,77 +1,194 @@ -import type { QaReport, ResultStatus } from './types.js' - -function getStatusLabel(status: ResultStatus) { - switch (status) { - case 'passed': - return 'passed' - case 'failed': - return 'failed' - case 'blocked': - return 'blocked' - case 'unsure': - return 'unsure' - case 'not_tested': - default: - return 'not_tested' +import type { + CommandReport, + DevReport, + PerfReviewReport, + QaReport, + ReviewReport, +} from './types.js' + +function appendList(lines: string[], title: string, values: string[], empty: string) { + lines.push('', title) + + if (values.length === 0) { + lines.push(empty) + return + } + + for (const value of values) { + lines.push(`- ${value}`) } } -export function renderQaSection(report: QaReport) { - const lines = [ - `### ${report.context.platform === 'ios' ? 'iOS' : 'Android'}`, - '', - `**Status:** ${getStatusLabel(report.overallStatus)}`, - '', - report.summary || 'No summary was provided.', +function appendMetadata(lines: string[], report: CommandReport) { + lines.push( '', - '### Checked', - ] + '### Metadata', + `- Command: \`${report.command}\``, + `- Workspace: \`${report.context.workspaceRoot}\`` + ) - if (report.checked?.length) { - for (const item of report.checked) { - lines.push(`- ${item}`) - } - } else { - lines.push('- No checks were recorded.') + if (report.context.repository?.name) { + lines.push( + `- Repository: \`${report.context.repository.owner ?? 'unknown'}/${report.context.repository.name}\`` + ) } - lines.push('', '### Issues') - if (report.issues?.length) { - for (const issue of report.issues) { - lines.push(`- ${issue}`) - } - } else { - lines.push('- No issues noted.') + if (report.context.pullRequest?.number) { + lines.push(`- Pull Request: \`#${report.context.pullRequest.number}\``) } + if (report.context.build?.id) { + lines.push(`- Build ID: \`${report.context.build.id}\``) + } + + if (report.context.build?.workflowUrl) { + lines.push(`- Workflow: ${report.context.build.workflowUrl}`) + } +} + +function appendJsonReport(lines: string[], report: CommandReport) { + lines.push('', '### JSON Report', '', '```json', JSON.stringify(report, null, 2), '```') +} + +function renderHeader(title: string, report: CommandReport) { + return [ + title, + '', + `**Status:** ${report.overallStatus}`, + '', + report.summary || 'No summary was provided.', + ] +} + +function renderQaDetails(lines: string[], report: QaReport) { + appendList(lines, '### Acceptance Criteria', report.acceptanceCriteriaUsed, '- None recorded.') + appendList(lines, '### Checked', report.checked, '- No checks were recorded.') + appendList(lines, '### Issues', report.issues, '- No issues noted.') + lines.push('', '### Screenshots') if (report.screenshots.length === 0) { lines.push('- No screenshots were saved.') } else { for (const screenshot of report.screenshots) { - if (screenshot.blobUrl) { - lines.push(`- [${screenshot.label}](${screenshot.blobUrl})`) - } else { - lines.push(`- ${screenshot.label}: ${screenshot.fileName}`) - } + lines.push( + screenshot.blobUrl + ? `- [${screenshot.label}](${screenshot.blobUrl})` + : `- ${screenshot.label}: ${screenshot.fileName}` + ) } } +} - lines.push('', '### Next steps') - if (report.nextSteps?.length) { - for (const step of report.nextSteps) { - lines.push(`- ${step}`) - } - } else { - lines.push('- No follow-up actions were suggested.') +function renderReviewDetails(lines: string[], report: ReviewReport) { + lines.push('', '### Findings') + + if (report.findings.length === 0) { + lines.push('- No concrete findings.') + return } - lines.push('', '### Metadata') - lines.push(`- Platform: \`${report.context.platform}\``) - lines.push(`- App ID: \`${report.context.appId}\``) - lines.push(`- Build ID: \`${report.context.buildId || 'n/a'}\``) - lines.push(`- Workflow: ${report.context.workflowUrl || 'n/a'}`) - lines.push('', '### JSON Report', '', '```json', JSON.stringify(report, null, 2), '```') + for (const finding of report.findings) { + const location = + finding.file && finding.lineStart + ? ` (${finding.file}:${finding.lineStart}${finding.lineEnd ? `-${finding.lineEnd}` : ''})` + : finding.file + ? ` (${finding.file})` + : '' + + lines.push(`- [${finding.severity}] ${finding.title}${location}: ${finding.body}`) + } +} + +function renderPerfDetails(lines: string[], report: PerfReviewReport) { + lines.push('', `**Scenario:** ${report.scenario}`) + appendList( + lines, + '### Slow Components', + report.slowComponents.map((item) => `${item.label}: ${item.detail}`), + '- No slow components recorded.' + ) + appendList( + lines, + '### Re-render Hotspots', + report.rerenderHotspots.map((item) => `${item.label}: ${item.detail}`), + '- No re-render hotspots recorded.' + ) + appendList( + lines, + '### Suspected Causes', + report.suspectedCauses, + '- No suspected causes recorded.' + ) + appendList( + lines, + '### Recommended Fixes', + report.recommendedFixes, + '- No recommended fixes recorded.' + ) + + lines.push('', '### Evidence') + if (report.evidence.length === 0) { + lines.push('- No evidence recorded.') + return + } + + for (const item of report.evidence) { + lines.push( + `- [${item.kind}] ${item.label}: ${item.detail}${item.reference ? ` (${item.reference})` : ''}` + ) + } +} + +function renderDevDetails(lines: string[], report: DevReport) { + lines.push('', `**Patch Status:** ${report.patchStatus}`) + appendList(lines, '### Files Changed', report.filesChanged, '- No files changed were recorded.') + appendList(lines, '### Validations Run', report.validationsRun, '- No validations were recorded.') + appendList(lines, '### Follow Ups', report.followUps, '- No follow-ups recorded.') +} + +export function renderCommandSection(report: CommandReport) { + const title = + report.command === 'qa' + ? `### ${report.context.mobile?.platform === 'ios' ? 'iOS' : 'Android'}` + : `### ${report.command === 'perf-review' ? 'Perf Review' : report.command[0].toUpperCase()}${report.command === 'perf-review' ? '' : report.command.slice(1)}` + const lines = renderHeader(title, report) + + switch (report.command) { + case 'qa': + renderQaDetails(lines, report) + break + case 'review': + renderReviewDetails(lines, report) + appendList(lines, '### Strengths', report.strengths, '- No strengths recorded.') + appendList( + lines, + '### Validation Gaps', + report.validationGaps, + '- No validation gaps recorded.' + ) + break + case 'perf-review': + renderPerfDetails(lines, report) + break + case 'dev': + renderDevDetails(lines, report) + break + } + + appendList( + lines, + '### Next Steps', + report.nextSteps ?? [], + '- No follow-up actions were suggested.' + ) + appendList( + lines, + '### Environment Notes', + report.environmentNotes ?? [], + '- No environment notes recorded.' + ) + appendMetadata(lines, report) + appendJsonReport(lines, report) return `${lines.join('\n')}\n` } diff --git a/packages/cali/src/report/types.ts b/packages/cali/src/report/types.ts index 9803346..1d4ef9a 100644 --- a/packages/cali/src/report/types.ts +++ b/packages/cali/src/report/types.ts @@ -1,4 +1,4 @@ -import type { QaRuntimeContext } from '../env/types.js' +import type { CaliContext, CommandId, ToolTraceEntry } from '../runtime/types.js' export type ResultStatus = 'passed' | 'failed' | 'blocked' | 'not_tested' | 'unsure' @@ -18,12 +18,22 @@ export type ScreenshotInfo = { uploadError?: string } -export type AgentDeviceTraceEntry = { - command: string +export type ReportPublisherResult = { + publisher: string ok: boolean - exitCode: number - stdout: string - stderr: string + detail?: string +} + +export type BaseCommandReport = { + command: CommandId + generatedAt: string + model: string + context: CaliContext + overallStatus: ResultStatus + summary: string + nextSteps?: string[] + environmentNotes?: string[] + publisherResults?: ReportPublisherResult[] } export type QaReportInput = { @@ -33,12 +43,104 @@ export type QaReportInput = { issues?: string[] nextSteps?: string[] screenshotLabels?: ScreenshotLabel[] + environmentNotes?: string[] } -export type QaReport = QaReportInput & { - generatedAt: string - model: string - context: QaRuntimeContext - screenshots: ScreenshotInfo[] - agentDeviceTrace: AgentDeviceTraceEntry[] +export type QaReport = BaseCommandReport & + QaReportInput & { + command: 'qa' + checked: string[] + issues: string[] + screenshotLabels: ScreenshotLabel[] + screenshots: ScreenshotInfo[] + acceptanceCriteriaUsed: string[] + agentDeviceTrace: ToolTraceEntry[] + } + +export type ReviewFinding = { + severity: 'low' | 'medium' | 'high' | 'critical' + title: string + body: string + file?: string + lineStart?: number + lineEnd?: number } + +export type ReviewReportInput = { + overallStatus: ResultStatus + summary: string + findings?: ReviewFinding[] + strengths?: string[] + validationGaps?: string[] + nextSteps?: string[] + environmentNotes?: string[] +} + +export type ReviewReport = BaseCommandReport & + ReviewReportInput & { + command: 'review' + findings: ReviewFinding[] + strengths: string[] + validationGaps: string[] + } + +export type PerfEvidence = { + kind: 'component' | 'profile' | 'screenshot' | 'note' + label: string + detail: string + reference?: string +} + +export type PerfComponentFinding = { + label: string + detail: string +} + +export type PerfReviewReportInput = { + overallStatus: ResultStatus + summary: string + scenario?: string + slowComponents?: PerfComponentFinding[] + rerenderHotspots?: PerfComponentFinding[] + suspectedCauses?: string[] + evidence?: PerfEvidence[] + recommendedFixes?: string[] + nextSteps?: string[] + environmentNotes?: string[] +} + +export type PerfReviewReport = BaseCommandReport & + PerfReviewReportInput & { + command: 'perf-review' + scenario: string + slowComponents: PerfComponentFinding[] + rerenderHotspots: PerfComponentFinding[] + suspectedCauses: string[] + evidence: PerfEvidence[] + recommendedFixes: string[] + screenshots: ScreenshotInfo[] + agentDeviceTrace: ToolTraceEntry[] + reactDevtoolsTrace: ToolTraceEntry[] + } + +export type DevReportInput = { + overallStatus: ResultStatus + summary: string + filesChanged?: string[] + validationsRun?: string[] + followUps?: string[] + patchStatus?: 'applied' | 'planned' | 'blocked' | 'partial' + nextSteps?: string[] + environmentNotes?: string[] +} + +export type DevReport = BaseCommandReport & + DevReportInput & { + command: 'dev' + filesChanged: string[] + validationsRun: string[] + followUps: string[] + patchStatus: 'applied' | 'planned' | 'blocked' | 'partial' + } + +export type CommandReport = QaReport | ReviewReport | PerfReviewReport | DevReport diff --git a/packages/cali/src/roles/dev.ts b/packages/cali/src/roles/dev.ts new file mode 100644 index 0000000..de8c0cb --- /dev/null +++ b/packages/cali/src/roles/dev.ts @@ -0,0 +1,98 @@ +import { z } from 'zod' + +import type { DevReportInput } from '../report/types.js' +import { runToolLoopRole } from '../runtime/tool-loop-role.js' +import type { CaliContext } from '../runtime/types.js' + +type RunDevRoleOptions = { + context: CaliContext + modelId: string + tools: Record + availableSkillsPrompt: string + preloadedSkillsPrompt: string + extraInstructions: string[] + prompt?: string + onAgentStep?: (event: { + stepNumber: number + finishReason: string + toolNames: string[] + totalTokens?: number + }) => void + onAgentFinish?: (event: { stepCount: number; finishReason: string; totalTokens?: number }) => void +} + +const DEV_REPORT_INPUT_SCHEMA = z.object({ + overallStatus: z.enum(['passed', 'failed', 'blocked', 'not_tested', 'unsure']), + summary: z.string(), + filesChanged: z.array(z.string()).optional(), + validationsRun: z.array(z.string()).optional(), + followUps: z.array(z.string()).optional(), + patchStatus: z.enum(['applied', 'planned', 'blocked', 'partial']).optional(), + nextSteps: z.array(z.string()).optional(), + environmentNotes: z.array(z.string()).optional(), +}) + +function createMissingDevReport(): DevReportInput { + return { + overallStatus: 'blocked', + summary: 'The dev agent completed without calling write_report.', + filesChanged: [], + validationsRun: [], + followUps: [], + patchStatus: 'blocked', + nextSteps: ['Inspect the run logs and retry the dev command.'], + environmentNotes: ['The write_report tool was not called by the agent.'], + } +} + +export async function runDevRole(options: RunDevRoleOptions) { + const { + context, + modelId, + tools, + availableSkillsPrompt, + preloadedSkillsPrompt, + extraInstructions, + prompt, + onAgentStep, + onAgentFinish, + } = options + + return runToolLoopRole({ + modelId, + instructions: [ + 'You are a React Native and Expo development agent working in a repository-backed sandbox.', + 'Inspect only the files needed to complete the task.', + 'Make the smallest code change that solves the problem.', + 'Use repository write tools carefully and validate with the lightest checks that prove the change.', + ] + .concat(extraInstructions) + .join('\n'), + prompt: [ + 'Implement the requested task in this repository.', + '', + `Task title: ${context.task?.title ?? prompt?.trim() ?? 'n/a'}`, + context.task?.body ?? 'No task body was provided.', + '', + `Pull request title: ${context.pullRequest?.title ?? 'n/a'}`, + context.pullRequest?.body ?? 'No pull request body was provided.', + '', + `Write policy: ${context.dev?.writePolicy ?? 'workspace'}`, + `Push policy: ${context.dev?.pushPolicy ?? 'disabled'}`, + `Allowed validations: ${(context.dev?.allowedValidations ?? []).join(', ') || 'n/a'}`, + '', + preloadedSkillsPrompt, + '', + availableSkillsPrompt, + '', + 'Finish by calling write_report exactly once.', + ].join('\n'), + tools, + reportSchema: DEV_REPORT_INPUT_SCHEMA, + reportDescription: + 'Persist the development summary, files changed, validations, follow-ups, patch status, and next steps.', + createMissingReport: createMissingDevReport, + onAgentStep, + onAgentFinish, + }) +} diff --git a/packages/cali/src/roles/perf-review.ts b/packages/cali/src/roles/perf-review.ts new file mode 100644 index 0000000..21d7fde --- /dev/null +++ b/packages/cali/src/roles/perf-review.ts @@ -0,0 +1,122 @@ +import { z } from 'zod' + +import type { PerfReviewReportInput } from '../report/types.js' +import { runToolLoopRole } from '../runtime/tool-loop-role.js' +import type { CaliContext } from '../runtime/types.js' + +type RunPerfReviewRoleOptions = { + context: CaliContext + modelId: string + tools: Record + availableSkillsPrompt: string + preloadedSkillsPrompt: string + extraInstructions: string[] + prompt?: string + onAgentStep?: (event: { + stepNumber: number + finishReason: string + toolNames: string[] + totalTokens?: number + }) => void + onAgentFinish?: (event: { stepCount: number; finishReason: string; totalTokens?: number }) => void +} + +const PERF_REVIEW_REPORT_SCHEMA = z.object({ + overallStatus: z.enum(['passed', 'failed', 'blocked', 'not_tested', 'unsure']), + summary: z.string(), + scenario: z.string().optional(), + slowComponents: z + .array( + z.object({ + label: z.string(), + detail: z.string(), + }) + ) + .optional(), + rerenderHotspots: z + .array( + z.object({ + label: z.string(), + detail: z.string(), + }) + ) + .optional(), + suspectedCauses: z.array(z.string()).optional(), + evidence: z + .array( + z.object({ + kind: z.enum(['component', 'profile', 'screenshot', 'note']), + label: z.string(), + detail: z.string(), + reference: z.string().optional(), + }) + ) + .optional(), + recommendedFixes: z.array(z.string()).optional(), + nextSteps: z.array(z.string()).optional(), + environmentNotes: z.array(z.string()).optional(), +}) + +function createMissingPerfReviewReport(): PerfReviewReportInput { + return { + overallStatus: 'blocked', + summary: 'The perf-review agent completed without calling write_report.', + scenario: 'Blocked runtime performance review', + slowComponents: [], + rerenderHotspots: [], + suspectedCauses: [], + evidence: [], + recommendedFixes: [], + nextSteps: ['Inspect the run logs and retry the perf-review command.'], + environmentNotes: ['The write_report tool was not called by the agent.'], + } +} + +export async function runPerfReviewRole(options: RunPerfReviewRoleOptions) { + const { + context, + modelId, + tools, + availableSkillsPrompt, + preloadedSkillsPrompt, + extraInstructions, + prompt, + onAgentStep, + onAgentFinish, + } = options + + return runToolLoopRole({ + modelId, + instructions: [ + 'You are a runtime performance review agent for React Native and Expo apps.', + 'Use agent-device to drive the app and react-devtools to inspect component tree and profile data.', + 'Prioritize re-renders, slow interactions, and evidence-backed suspected causes.', + 'Do not change the repository.', + ] + .concat(extraInstructions) + .join('\n'), + prompt: [ + `Review the runtime performance of this ${context.mobile?.platform === 'ios' ? 'iOS' : 'Android'} app.`, + '', + `Target flow: ${context.perfReview?.targetFlow ?? prompt?.trim() ?? 'n/a'}`, + `Expected interaction: ${context.perfReview?.expectedInteraction ?? 'n/a'}`, + `Profiling goals: ${(context.perfReview?.profilingGoals ?? []).join(', ') || 'n/a'}`, + `Suspected screens/components: ${(context.perfReview?.suspectedScreens ?? []).join(', ') || 'n/a'}`, + '', + preloadedSkillsPrompt, + '', + availableSkillsPrompt, + '', + 'Treat bootstrap as already handled. Use the provided performance tools only.', + 'Finish by calling write_report exactly once.', + ].join('\n'), + tools, + reportSchema: PERF_REVIEW_REPORT_SCHEMA, + reportDescription: + 'Persist the performance review summary, hotspots, suspected causes, evidence, and recommended fixes.', + reserveReportAfterTool: 'react_devtools', + createMissingReport: createMissingPerfReviewReport, + onAgentStep, + onAgentFinish, + }) +} diff --git a/packages/cali/src/roles/qa-mobile.ts b/packages/cali/src/roles/qa-mobile.ts index f87d4de..907e551 100644 --- a/packages/cali/src/roles/qa-mobile.ts +++ b/packages/cali/src/roles/qa-mobile.ts @@ -1,25 +1,18 @@ -import { generateText, Output, stepCountIs, tool, ToolLoopAgent } from 'ai' import { z } from 'zod' -import type { QaRuntimeContext } from '../env/types.js' -import { createQaAgentModel } from '../model.js' -import type { AgentDeviceTraceEntry, QaReportInput } from '../report/types.js' -import { createAgentDeviceToolPack } from '../tools/agent-device.js' -import { - buildSkillsPrompt, - createSkillsToolPack, - discoverSkills, - type SkillMetadata, -} from '../tools/skills.js' +import type { QaReportInput } from '../report/types.js' +import { runToolLoopRole } from '../runtime/tool-loop-role.js' +import type { CaliContext } from '../runtime/types.js' type RunQaMobileRoleOptions = { - context: QaRuntimeContext + context: CaliContext modelId: string - sessionName: string - skillPaths: string[] - enabledToolPacks: string[] + tools: Record + availableSkillsPrompt: string + preloadedSkillsPrompt: string extraInstructions: string[] prompt?: string + acceptanceCriteriaUsed: string[] onAgentStep?: (event: { stepNumber: number finishReason: string @@ -31,12 +24,19 @@ type RunQaMobileRoleOptions = { type QaMobileRoleResult = { reportInput: QaReportInput - agentDeviceTrace: AgentDeviceTraceEntry[] } -const EMPTY_INPUT_SCHEMA = z.object({}) -const MAX_AGENT_STEPS = 12 -const REPORT_BUFFER_STEPS = 2 +function createMissingQaReport(): QaReportInput { + return { + overallStatus: 'blocked', + summary: 'The agent completed without calling write_report.', + checked: ['Produce a mobile QA report'], + issues: ['The write_report tool was not called by the agent.'], + nextSteps: ['Inspect the run logs and tighten the QA role instructions.'], + environmentNotes: ['The write_report tool was not called by the agent.'], + } +} + const WRITE_REPORT_INPUT_SCHEMA = z.object({ overallStatus: z.enum(['passed', 'failed', 'blocked', 'not_tested', 'unsure']), summary: z.string(), @@ -51,116 +51,68 @@ const WRITE_REPORT_INPUT_SCHEMA = z.object({ }) ) .optional(), + environmentNotes: z.array(z.string()).optional(), }) function buildPrompt( - context: QaRuntimeContext, - skills: SkillMetadata[], + context: CaliContext, + acceptanceCriteriaUsed: string[], + availableSkillsPrompt: string, + preloadedSkillsPrompt: string, extraInstructions: string[], prompt?: string ) { - const platformLabel = context.platform === 'ios' ? 'iOS' : 'Android' - const baseInstructions = [ + const platformLabel = context.mobile?.platform === 'ios' ? 'iOS' : 'Android' + const lines = [ `Review this ${platformLabel} build and run a lightweight QA pass.`, '', 'Execution context:', `- Platform: ${platformLabel}`, - `- Build path: ${context.artifactPath}`, - `- Application id: ${context.appId}`, - `- Build ID: ${context.buildId || 'n/a'}`, - `- Workflow URL: ${context.workflowUrl || 'n/a'}`, - `- Device: ${context.deviceName || 'currently bound device'}`, - `- Screenshot directory: ${context.screenshotsDir}`, + `- Build path: ${context.mobile?.artifactPath ?? 'n/a'}`, + `- Application id: ${context.mobile?.appId ?? 'n/a'}`, + `- Build ID: ${context.build?.id ?? 'n/a'}`, + `- Workflow URL: ${context.build?.workflowUrl ?? 'n/a'}`, + `- Device: ${context.mobile?.deviceName ?? 'currently bound device'}`, + `- Screenshot directory: ${context.output.screenshotsDir ?? 'n/a'}`, + '', + `Pull request: ${context.pullRequest?.title ?? 'n/a'}`, + context.pullRequest?.body ?? 'No pull request body was provided.', + '', + `Task: ${context.task?.title ?? 'n/a'}`, + context.task?.body ?? 'No task body was provided.', '', - context.metadata.prTitle - ? `PR #${context.metadata.prNumber || 'n/a'}: ${context.metadata.prTitle}` - : 'No pull request title was provided.', - context.metadata.prBody || 'No pull request body was provided.', + 'Acceptance criteria used:', + ...acceptanceCriteriaUsed.map((criterion) => `- ${criterion}`), '', - buildSkillsPrompt(skills), + preloadedSkillsPrompt, + '', + availableSkillsPrompt, ] if (extraInstructions.length > 0) { - baseInstructions.push('', 'Extra instructions:') + lines.push('', 'Extra instructions:') for (const instruction of extraInstructions) { - baseInstructions.push(`- ${instruction}`) + lines.push(`- ${instruction}`) } } if (prompt?.trim()) { - baseInstructions.push('', 'Task-specific focus:') - baseInstructions.push(prompt.trim()) + lines.push('', 'Task-specific focus:') + lines.push(prompt.trim()) } - baseInstructions.push( + lines.push( '', - `Save screenshots into ${context.screenshotsDir}/*.png with short descriptive filenames.`, + `Save screenshots into ${context.output.screenshotsDir ?? 'the screenshots directory'}/*.png with short descriptive filenames.`, 'When text visibility matters, prefer a plain snapshot over image-heavy inspection.', + 'Do not use agent-device session management commands such as session list, session close, or session open.', 'Use canonical agent-device commands like back or home directly. Do not emulate navigation with press.', 'Treat bootstrap as already handled. Do not install, reinstall, or open the app yourself.', 'Do not inspect repository source files or modify project code.', 'Finish by calling write_report exactly once.' ) - return baseInstructions.join('\n') -} - -function hasToolActivity( - steps: Array<{ - toolCalls?: Array<{ toolName?: string }> - toolResults?: Array<{ toolName?: string }> - }>, - toolName: string -) { - return steps.some((step) => { - const hasToolCall = step.toolCalls?.some((toolCall) => toolCall.toolName === toolName) - const hasToolResult = step.toolResults?.some((toolResult) => toolResult.toolName === toolName) - - return Boolean(hasToolCall || hasToolResult) - }) -} - -async function synthesizeReportInput( - modelId: string, - context: QaRuntimeContext, - agentDeviceTrace: AgentDeviceTraceEntry[], - extraInstructions: string[], - prompt?: string -) { - const evidence = { - taskPrompt: prompt ?? '', - platform: context.platform, - appId: context.appId, - buildId: context.buildId, - workflowUrl: context.workflowUrl, - screenshotsDir: context.screenshotsDir, - agentDeviceTrace, - } - - const { output } = await generateText({ - model: createQaAgentModel(modelId), - output: Output.object({ - schema: WRITE_REPORT_INPUT_SCHEMA, - name: 'qa_report', - description: 'Structured QA result for a completed mobile QA run.', - }), - prompt: [ - 'Produce the final QA report for a completed mobile QA run.', - 'Base the report only on the provided evidence. Do not invent actions, screenshots, or observations.', - 'If the evidence shows the requested behavior worked, set overallStatus to "passed".', - 'If the evidence is inconclusive, set overallStatus to "unsure".', - 'If the environment was broken, set overallStatus to "blocked".', - prompt?.trim() ? `Task-specific focus:\n${prompt.trim()}` : '', - extraInstructions.length > 0 - ? `Extra instructions:\n${extraInstructions.map((instruction) => `- ${instruction}`).join('\n')}` - : '', - `Evidence:\n${JSON.stringify(evidence, null, 2)}`, - ] - .filter(Boolean) - .join('\n\n'), - }) - - return output satisfies QaReportInput + return lines.join('\n') } export async function runQaMobileRole( @@ -169,54 +121,22 @@ export async function runQaMobileRole( const { context, modelId, - sessionName, - skillPaths, - enabledToolPacks, + tools, + availableSkillsPrompt, + preloadedSkillsPrompt, extraInstructions, prompt, + acceptanceCriteriaUsed, onAgentStep, onAgentFinish, } = options - const skills = await discoverSkills(skillPaths) - const agentDeviceTrace: AgentDeviceTraceEntry[] = [] - let reportInput: QaReportInput | undefined - - const tools: Record = { - get_run_context: tool({ - description: 'Read the normalized QA run context and metadata.', - inputSchema: EMPTY_INPUT_SCHEMA, - execute: async () => context, - }), - } - - if (enabledToolPacks.includes('skills')) { - Object.assign(tools, createSkillsToolPack(skills)) - } - - if (enabledToolPacks.includes('agent-device')) { - Object.assign(tools, createAgentDeviceToolPack({ trace: agentDeviceTrace, sessionName })) - } - - tools.write_report = tool({ - description: 'Persist the final QA summary, findings, and screenshot labels.', - inputSchema: WRITE_REPORT_INPUT_SCHEMA, - execute: async (input) => { - if (reportInput) { - throw new Error('write_report has already been called for this QA run.') - } - - reportInput = input satisfies QaReportInput - return { - ok: true, - } - }, - }) const instructions = [ - `You are a mobile QA agent for ${context.platform === 'ios' ? 'iOS' : 'Android'} builds.`, + `You are a mobile QA agent for ${context.mobile?.platform === 'ios' ? 'iOS' : 'Android'} builds.`, 'Use only the provided tool packs and evidence from their results.', 'The CLI already handled deterministic bootstrap. Never install, reinstall, or open the app.', 'Refresh your view with snapshot-style commands after every meaningful UI transition.', + 'Do not spend steps on session management commands such as session list, session close, or session open.', 'Use canonical agent-device commands like back or home directly. Do not emulate them with press.', 'Take screenshots for meaningful states and keep filenames short and descriptive.', 'If the environment is broken or a prerequisite is missing, report blocked checks instead of guessing.', @@ -224,80 +144,30 @@ export async function runQaMobileRole( 'Do not finish with plain text. Finish only by calling write_report exactly once.', ] .concat(extraInstructions) - .join(' ') + .join('\n') - const agent = new ToolLoopAgent({ - model: createQaAgentModel(modelId), + const result = await runToolLoopRole({ + modelId, instructions, + prompt: buildPrompt( + context, + acceptanceCriteriaUsed, + availableSkillsPrompt, + preloadedSkillsPrompt, + extraInstructions, + prompt + ), tools, - toolChoice: 'required', - stopWhen: stepCountIs(MAX_AGENT_STEPS), - onFinish: async ({ steps, finishReason, totalUsage }) => { - onAgentFinish?.({ - stepCount: steps.length, - finishReason, - totalTokens: totalUsage.totalTokens, - }) - }, - prepareStep: async ({ steps, stepNumber }) => { - const hasWrittenReport = hasToolActivity(steps, 'write_report') - const hasUsedDeviceTools = hasToolActivity(steps, 'agent_device') - - // Reserve the final steps for report emission if the model keeps exploring. - if ( - hasWrittenReport || - !hasUsedDeviceTools || - stepNumber < MAX_AGENT_STEPS - REPORT_BUFFER_STEPS - ) { - return {} - } - - return { - activeTools: ['write_report'], - toolChoice: { type: 'tool', toolName: 'write_report' }, - } - }, - }) - - const result = await agent.generate({ - prompt: buildPrompt(context, skills, extraInstructions, prompt), - onStepFinish: async ({ stepNumber, finishReason, toolCalls, usage }) => { - onAgentStep?.({ - stepNumber: stepNumber + 1, - finishReason, - toolNames: toolCalls.map((toolCall) => toolCall.toolName), - totalTokens: usage.totalTokens, - }) - }, + reportSchema: WRITE_REPORT_INPUT_SCHEMA, + reportDescription: + 'Persist the final QA summary, findings, environment notes, and screenshot labels.', + reserveReportAfterTool: 'agent_device', + createMissingReport: createMissingQaReport, + onAgentStep, + onAgentFinish, }) - if (!reportInput) { - try { - reportInput = await synthesizeReportInput( - modelId, - context, - agentDeviceTrace, - extraInstructions, - prompt - ) - } catch (unknownError) { - const error = unknownError instanceof Error ? unknownError : new Error(String(unknownError)) - - reportInput = { - overallStatus: 'blocked', - summary: result.text || 'The agent completed without calling write_report.', - checked: ['Produce a mobile QA report'], - issues: [ - 'The write_report tool was not called by the agent.', - `Fallback report synthesis failed: ${error.message}`, - ], - nextSteps: ['Inspect the run logs and tighten the QA role instructions.'], - } - } - } - return { - reportInput, - agentDeviceTrace, + reportInput: result.reportInput, } } diff --git a/packages/cali/src/roles/review.ts b/packages/cali/src/roles/review.ts new file mode 100644 index 0000000..357d22a --- /dev/null +++ b/packages/cali/src/roles/review.ts @@ -0,0 +1,107 @@ +import { z } from 'zod' + +import type { ReviewReportInput } from '../report/types.js' +import { runToolLoopRole } from '../runtime/tool-loop-role.js' +import type { CaliContext } from '../runtime/types.js' + +type RunReviewRoleOptions = { + context: CaliContext + modelId: string + tools: Record + availableSkillsPrompt: string + preloadedSkillsPrompt: string + extraInstructions: string[] + prompt?: string + onAgentStep?: (event: { + stepNumber: number + finishReason: string + toolNames: string[] + totalTokens?: number + }) => void + onAgentFinish?: (event: { stepCount: number; finishReason: string; totalTokens?: number }) => void +} + +const REVIEW_REPORT_INPUT_SCHEMA = z.object({ + overallStatus: z.enum(['passed', 'failed', 'blocked', 'not_tested', 'unsure']), + summary: z.string(), + findings: z + .array( + z.object({ + severity: z.enum(['low', 'medium', 'high', 'critical']), + title: z.string(), + body: z.string(), + file: z.string().optional(), + lineStart: z.number().int().optional(), + lineEnd: z.number().int().optional(), + }) + ) + .optional(), + strengths: z.array(z.string()).optional(), + validationGaps: z.array(z.string()).optional(), + nextSteps: z.array(z.string()).optional(), + environmentNotes: z.array(z.string()).optional(), +}) + +function createMissingReviewReport(): ReviewReportInput { + return { + overallStatus: 'blocked', + summary: 'The review agent completed without calling write_report.', + findings: [], + strengths: [], + validationGaps: [], + nextSteps: ['Inspect the run logs and retry the review command.'], + environmentNotes: ['The write_report tool was not called by the agent.'], + } +} + +export async function runReviewRole(options: RunReviewRoleOptions) { + const { + context, + modelId, + tools, + availableSkillsPrompt, + preloadedSkillsPrompt, + extraInstructions, + prompt, + onAgentStep, + onAgentFinish, + } = options + + return runToolLoopRole({ + modelId, + instructions: [ + 'You are a mobile code review agent for React Native and Expo pull requests.', + 'Review diff and repository context only. Do not modify code.', + 'Prioritize correctness risks, platform regressions, missing validation, and maintainability concerns.', + 'Output findings first and keep them concrete.', + ] + .concat(extraInstructions) + .join('\n'), + prompt: [ + 'Review this pull request or repository snapshot.', + '', + `Pull request title: ${context.pullRequest?.title ?? 'n/a'}`, + context.pullRequest?.body ?? 'No pull request body was provided.', + '', + `Task title: ${context.task?.title ?? 'n/a'}`, + context.task?.body ?? 'No task body was provided.', + '', + preloadedSkillsPrompt, + '', + availableSkillsPrompt, + '', + prompt?.trim() ? `Task-specific focus:\n${prompt.trim()}` : '', + 'Use repository and git tools to inspect the relevant diff or file context.', + 'Finish by calling write_report exactly once.', + ] + .filter(Boolean) + .join('\n'), + tools, + reportSchema: REVIEW_REPORT_INPUT_SCHEMA, + reportDescription: + 'Persist the final review findings, strengths, validation gaps, and next steps.', + createMissingReport: createMissingReviewReport, + onAgentStep, + onAgentFinish, + }) +} diff --git a/packages/cali/src/runtime/context-file.ts b/packages/cali/src/runtime/context-file.ts new file mode 100644 index 0000000..b2b7046 --- /dev/null +++ b/packages/cali/src/runtime/context-file.ts @@ -0,0 +1,177 @@ +import { readFile } from 'node:fs/promises' + +import { z } from 'zod' + +import { resolveFromCwd } from '../utils.js' +import type { CaliContext } from './types.js' + +const LabelsSchema = z.array(z.string()).optional() + +const RepositorySchema = z + .object({ + provider: z.string().optional(), + owner: z.string().optional(), + name: z.string().optional(), + cloneUrl: z.string().optional(), + defaultBranch: z.string().optional(), + currentBranch: z.string().optional(), + commitSha: z.string().optional(), + }) + .optional() + +const TaskSchema = z + .object({ + provider: z.string().optional(), + id: z.string().optional(), + title: z.string().optional(), + body: z.string().nullable().optional(), + url: z.string().optional(), + labels: LabelsSchema, + }) + .optional() + +const PullRequestSchema = z + .object({ + number: z.number().optional(), + title: z.string().optional(), + body: z.string().nullable().optional(), + url: z.string().optional(), + labels: LabelsSchema, + isDraft: z.boolean().optional(), + baseBranch: z.string().optional(), + headBranch: z.string().optional(), + diffPath: z.string().optional(), + diffSummary: z.string().optional(), + }) + .optional() + +const MobileSchema = z + .object({ + platform: z.enum(['android', 'ios']).optional(), + artifactPath: z.string().optional(), + appId: z.string().optional(), + deviceName: z.string().optional(), + }) + .optional() + +const BuildSchema = z + .object({ + id: z.string().optional(), + workflowUrl: z.string().optional(), + logsUrl: z.string().optional(), + }) + .optional() + +const OutputSchema = z + .object({ + outputDir: z.string().optional(), + screenshotsDir: z.string().optional(), + }) + .optional() + +const CaliContextFileSchema = z.object({ + workspaceRoot: z.string().optional(), + repository: RepositorySchema, + task: TaskSchema, + pullRequest: PullRequestSchema, + mobile: MobileSchema, + build: BuildSchema, + output: OutputSchema, + qa: z + .object({ + acceptanceCriteria: z.union([z.string(), z.array(z.string())]).optional(), + }) + .optional(), + review: z.object({}).optional(), + perfReview: z + .object({ + targetFlow: z.string().optional(), + expectedInteraction: z.string().optional(), + profilingGoals: LabelsSchema, + suspectedScreens: LabelsSchema, + }) + .optional(), + dev: z + .object({ + branchStrategy: z.string().optional(), + allowedValidations: LabelsSchema, + writePolicy: z.enum(['workspace', 'none']).optional(), + pushPolicy: z.enum(['disabled', 'manual', 'auto']).optional(), + }) + .optional(), +}) + +function normalizeLabels(values: string[] | undefined) { + return values ?? [] +} + +export async function loadContextFile( + cwd: string, + contextPath?: string +): Promise> { + if (!contextPath) { + return {} + } + + const absolutePath = resolveFromCwd(cwd, contextPath) + const content = await readFile(absolutePath, 'utf8') + const parsed = CaliContextFileSchema.parse(JSON.parse(content)) + + return { + workspaceRoot: parsed.workspaceRoot ? resolveFromCwd(cwd, parsed.workspaceRoot) : undefined, + repository: parsed.repository ? { ...parsed.repository } : undefined, + task: parsed.task + ? { + ...parsed.task, + labels: normalizeLabels(parsed.task.labels), + } + : undefined, + pullRequest: parsed.pullRequest + ? { + ...parsed.pullRequest, + labels: normalizeLabels(parsed.pullRequest.labels), + isDraft: parsed.pullRequest.isDraft ?? false, + } + : undefined, + mobile: parsed.mobile + ? { + ...parsed.mobile, + artifactPath: parsed.mobile.artifactPath + ? resolveFromCwd(cwd, parsed.mobile.artifactPath) + : undefined, + } + : undefined, + build: parsed.build, + output: { + outputDir: parsed.output?.outputDir + ? resolveFromCwd(cwd, parsed.output.outputDir) + : undefined, + screenshotsDir: parsed.output?.screenshotsDir + ? resolveFromCwd(cwd, parsed.output.screenshotsDir) + : undefined, + }, + qa: parsed.qa + ? { + acceptanceCriteria: Array.isArray(parsed.qa.acceptanceCriteria) + ? parsed.qa.acceptanceCriteria + : parsed.qa.acceptanceCriteria + ? [parsed.qa.acceptanceCriteria] + : [], + } + : undefined, + review: parsed.review, + perfReview: parsed.perfReview + ? { + ...parsed.perfReview, + profilingGoals: normalizeLabels(parsed.perfReview.profilingGoals), + suspectedScreens: normalizeLabels(parsed.perfReview.suspectedScreens), + } + : undefined, + dev: parsed.dev + ? { + ...parsed.dev, + allowedValidations: normalizeLabels(parsed.dev.allowedValidations), + } + : undefined, + } +} diff --git a/packages/cali/src/runtime/context-repo.ts b/packages/cali/src/runtime/context-repo.ts new file mode 100644 index 0000000..96aae77 --- /dev/null +++ b/packages/cali/src/runtime/context-repo.ts @@ -0,0 +1,80 @@ +import { runCommand } from '../utils.js' +import type { RepositoryContext } from './types.js' + +function parseRemoteUrl(remoteUrl: string | undefined) { + if (!remoteUrl) { + return {} + } + + const httpsMatch = remoteUrl.match(/^https?:\/\/([^/]+)\/([^/]+)\/([^/]+?)(?:\.git)?$/) + if (httpsMatch) { + return { + provider: httpsMatch[1], + owner: httpsMatch[2], + name: httpsMatch[3], + } + } + + const sshMatch = remoteUrl.match(/^git@([^:]+):([^/]+)\/(.+?)(?:\.git)?$/) + if (sshMatch) { + return { + provider: sshMatch[1], + owner: sshMatch[2], + name: sshMatch[3], + } + } + + return {} +} + +async function readGitValue(cwd: string, args: string[]) { + const result = await runCommand('git', args, { + cwd, + allowFailure: true, + }) + + if (!result.ok) { + return undefined + } + + const value = result.stdout.trim() + return value.length > 0 ? value : undefined +} + +export async function detectRepositoryContext(cwd: string): Promise<{ + workspaceRoot: string + repository?: RepositoryContext +}> { + const workspaceRoot = (await readGitValue(cwd, ['rev-parse', '--show-toplevel'])) ?? cwd + const remoteUrl = await readGitValue(workspaceRoot, ['remote', 'get-url', 'origin']) + const repository = { + ...parseRemoteUrl(remoteUrl), + cloneUrl: remoteUrl, + defaultBranch: await readGitValue(workspaceRoot, ['remote', 'show', 'origin']), + currentBranch: await readGitValue(workspaceRoot, ['branch', '--show-current']), + commitSha: await readGitValue(workspaceRoot, ['rev-parse', 'HEAD']), + } + + if ( + !repository.cloneUrl && + !repository.currentBranch && + !repository.commitSha && + !repository.owner && + !repository.name + ) { + return { workspaceRoot } + } + + if (repository.defaultBranch?.includes('HEAD branch:')) { + repository.defaultBranch = repository.defaultBranch + .split('\n') + .find((line) => line.includes('HEAD branch:')) + ?.split('HEAD branch:')[1] + ?.trim() + } + + return { + workspaceRoot, + repository, + } +} diff --git a/packages/cali/src/runtime/context.ts b/packages/cali/src/runtime/context.ts new file mode 100644 index 0000000..fded413 --- /dev/null +++ b/packages/cali/src/runtime/context.ts @@ -0,0 +1,220 @@ +import path from 'node:path' + +import { resolveFromCwd } from '../utils.js' +import { loadContextFile } from './context-file.js' +import { detectRepositoryContext } from './context-repo.js' +import type { + CaliContext, + CommandCliOptions, + CommandId, + CommandResolvedConfig, + PullRequestContext, + TaskContext, +} from './types.js' + +function isMobileCommand(commandId: CommandId) { + return commandId === 'qa' || commandId === 'perf-review' +} + +function normalizeTask(task?: Partial): TaskContext | undefined { + if (!task) { + return undefined + } + + return { + ...task, + labels: task.labels ?? [], + } +} + +function normalizePullRequest( + pullRequest?: Partial +): PullRequestContext | undefined { + if (!pullRequest) { + return undefined + } + + return { + ...pullRequest, + labels: pullRequest.labels ?? [], + isDraft: pullRequest.isDraft ?? false, + } +} + +function mergeContext( + base: Partial, + override: Partial +): Partial { + return { + workspaceRoot: override.workspaceRoot ?? base.workspaceRoot, + repository: { + ...base.repository, + ...override.repository, + }, + task: normalizeTask(override.task ? { ...base.task, ...override.task } : base.task), + pullRequest: normalizePullRequest( + override.pullRequest ? { ...base.pullRequest, ...override.pullRequest } : base.pullRequest + ), + mobile: { + ...base.mobile, + ...override.mobile, + }, + build: { + ...base.build, + ...override.build, + }, + output: { + ...base.output, + ...override.output, + }, + qa: override.qa ?? base.qa, + review: override.review ?? base.review, + perfReview: override.perfReview ?? base.perfReview, + dev: override.dev ?? base.dev, + } +} + +function buildCliContext(cli: CommandCliOptions): Partial { + const context: Partial = {} + + if (cli.workspaceRoot) { + context.workspaceRoot = cli.workspaceRoot + } + + if (cli.taskId || cli.taskTitle || cli.taskBody || cli.taskUrl) { + context.task = { + id: cli.taskId, + title: cli.taskTitle, + body: cli.taskBody, + url: cli.taskUrl, + labels: [], + } + } + + if ( + cli.prNumber || + cli.prTitle || + cli.prBody || + cli.prUrl || + cli.prBaseBranch || + cli.prHeadBranch + ) { + context.pullRequest = { + number: cli.prNumber, + title: cli.prTitle, + body: cli.prBody, + url: cli.prUrl, + labels: [], + isDraft: false, + baseBranch: cli.prBaseBranch, + headBranch: cli.prHeadBranch, + } + } + + if (cli.platform || cli.artifactPath || cli.appId || cli.deviceName) { + context.mobile = { + platform: cli.platform, + artifactPath: cli.artifactPath, + appId: cli.appId, + deviceName: cli.deviceName, + } + } + + if (cli.buildId || cli.workflowUrl || cli.logsUrl) { + context.build = { + id: cli.buildId, + workflowUrl: cli.workflowUrl, + logsUrl: cli.logsUrl, + } + } + + return context +} + +function resolveOutput( + commandId: CommandId, + workspaceRoot: string, + cli: CommandCliOptions, + context: Partial +) { + const outputDir = resolveFromCwd( + workspaceRoot, + cli.outputDir ?? context.output?.outputDir ?? path.join('artifacts', commandId) + ) + + return { + outputDir, + screenshotsDir: + context.output?.screenshotsDir ?? + (isMobileCommand(commandId) ? path.join(outputDir, 'screenshots') : undefined), + } +} + +function applyDefaults( + commandId: CommandId, + context: Partial, + workspaceRoot: string, + config: CommandResolvedConfig, + cli: CommandCliOptions +): CaliContext { + const output = resolveOutput(commandId, workspaceRoot, cli, context) + + return { + workspaceRoot, + repository: context.repository, + task: normalizeTask(context.task), + pullRequest: normalizePullRequest(context.pullRequest), + mobile: isMobileCommand(commandId) + ? { + platform: context.mobile?.platform ?? config.mobileDefaults.platform, + artifactPath: context.mobile?.artifactPath, + appId: context.mobile?.appId ?? config.mobileDefaults.appId, + deviceName: context.mobile?.deviceName ?? config.mobileDefaults.deviceName, + } + : context.mobile, + build: context.build, + output, + qa: { + acceptanceCriteria: context.qa?.acceptanceCriteria ?? [], + }, + review: context.review ?? {}, + perfReview: { + profilingGoals: context.perfReview?.profilingGoals ?? [], + suspectedScreens: context.perfReview?.suspectedScreens ?? [], + targetFlow: context.perfReview?.targetFlow, + expectedInteraction: context.perfReview?.expectedInteraction, + }, + dev: { + allowedValidations: context.dev?.allowedValidations ?? [], + branchStrategy: context.dev?.branchStrategy, + writePolicy: context.dev?.writePolicy ?? 'workspace', + pushPolicy: context.dev?.pushPolicy ?? 'disabled', + }, + } +} + +export async function resolveCommandContext( + commandId: CommandId, + cwd: string, + config: CommandResolvedConfig, + cli: CommandCliOptions +): Promise { + const fileContext = await loadContextFile(cwd, cli.contextPath ?? config.contextPath) + const repositoryInfo = await detectRepositoryContext(cwd) + const workspaceRoot = + cli.workspaceRoot ?? + fileContext.workspaceRoot ?? + config.workspaceRoot ?? + repositoryInfo.workspaceRoot + + const merged = mergeContext( + { + workspaceRoot, + repository: repositoryInfo.repository, + output: {}, + }, + mergeContext(fileContext, buildCliContext(cli)) + ) + + return applyDefaults(commandId, merged, workspaceRoot, config, cli) +} diff --git a/packages/cali/src/runtime/mobile.ts b/packages/cali/src/runtime/mobile.ts new file mode 100644 index 0000000..623df81 --- /dev/null +++ b/packages/cali/src/runtime/mobile.ts @@ -0,0 +1,447 @@ +import { createHash } from 'node:crypto' +import { existsSync, readdirSync } from 'node:fs' +import { readdir, rm, stat } from 'node:fs/promises' +import { homedir } from 'node:os' +import path from 'node:path' + +import type { CaliEnvName } from '../config/schema.js' +import type { ScreenshotInfo } from '../report/types.js' +import { getAgentDeviceSessionArgs } from '../tools/agent-device.js' +import { ensureCommandExists, ensureDirectory, runCommand } from '../utils.js' +import type { CaliContext, CaliPlatform, CommandId, MobileCommandRuntimeContext } from './types.js' + +function buildDeviceSelectorArgs(context: { platform: CaliPlatform; deviceName?: string }) { + const args = ['--platform', context.platform] + + if (context.deviceName) { + args.push('--device', context.deviceName) + } + + return args +} + +function summarizeCommandFailure(result: { stdout: string; stderr: string; exitCode: number }) { + return result.stderr || result.stdout || `Command failed with exit code ${result.exitCode}.` +} + +function assertCommandSuccess( + result: { ok: boolean; stdout: string; stderr: string; exitCode: number }, + label: string +) { + if (result.ok) { + return + } + + throw new Error(`${label}\n\n${summarizeCommandFailure(result)}`) +} + +async function runAgentDeviceCommand( + command: string, + args: string[], + options: Parameters[2] = {} +) { + await ensureCommandExists('agent-device', 'npm i -g agent-device') + return runCommand('agent-device', [command, ...args], options) +} + +async function runAgentDeviceSessionCommand( + sessionName: string, + command: string, + args: string[], + options: Parameters[2] = {} +) { + await ensureCommandExists('agent-device', 'npm i -g agent-device') + return runCommand( + 'agent-device', + [...getAgentDeviceSessionArgs(sessionName), command, ...args], + options + ) +} + +async function readCommandStdout(file: string, args: string[]) { + const result = await runCommand(file, args, { allowFailure: true }) + if (!result.ok) { + return undefined + } + + const value = result.stdout.trim() + return value.length > 0 ? value : undefined +} + +function findNewestSdkToolPath(baseDirectory: string, childPath: string) { + if (!existsSync(baseDirectory)) { + return undefined + } + + const children = readdirSync(baseDirectory).sort().reverse() + for (const child of children) { + const candidate = path.join(baseDirectory, child, childPath) + if (existsSync(candidate)) { + return candidate + } + } + + return undefined +} + +function getAndroidSdkRoots() { + return [ + process.env.ANDROID_HOME, + process.env.ANDROID_SDK_ROOT, + path.join(homedir(), 'Library', 'Android', 'sdk'), + ].filter((value): value is string => Boolean(value)) +} + +function getAndroidSdkApkanalyzerCandidates() { + const candidates: string[] = [] + for (const sdkRoot of getAndroidSdkRoots()) { + const latestApkanalyzer = findNewestSdkToolPath( + path.join(sdkRoot, 'cmdline-tools'), + path.join('bin', 'apkanalyzer') + ) + if (latestApkanalyzer) { + candidates.push(latestApkanalyzer) + } + + const legacyApkanalyzer = path.join(sdkRoot, 'tools', 'bin', 'apkanalyzer') + if (existsSync(legacyApkanalyzer)) { + candidates.push(legacyApkanalyzer) + } + } + + return [...new Set(candidates)] +} + +function getAndroidSdkAaptCandidates() { + const sdkRoots = [...getAndroidSdkRoots()] + + const candidates: string[] = [] + for (const sdkRoot of sdkRoots) { + const latestAapt = findNewestSdkToolPath(path.join(sdkRoot, 'build-tools'), 'aapt') + if (latestAapt) { + candidates.push(latestAapt) + } + } + + return [...new Set(candidates)] +} + +async function inferAndroidAppId(artifactPath: string) { + const apkanalyzerCommands = ['apkanalyzer', ...getAndroidSdkApkanalyzerCandidates()] + for (const commandPath of apkanalyzerCommands) { + const apkanalyzerValue = await readCommandStdout(commandPath, [ + 'manifest', + 'application-id', + artifactPath, + ]) + if (apkanalyzerValue) { + return apkanalyzerValue.split('\n').at(-1)?.trim() + } + } + + const aaptCommands = ['aapt', ...getAndroidSdkAaptCandidates()] + for (const commandPath of aaptCommands) { + const aaptValue = await readCommandStdout(commandPath, ['dump', 'badging', artifactPath]) + const packageName = aaptValue?.match(/package: name='([^']+)'/)?.[1] + if (packageName) { + return packageName + } + } + + return undefined +} + +async function inferIosAppId(artifactPath: string) { + if (path.extname(artifactPath) !== '.app') { + return undefined + } + + const infoPlistPath = path.join(artifactPath, 'Info.plist') + const plistBuddyValue = await readCommandStdout('/usr/libexec/PlistBuddy', [ + '-c', + 'Print :CFBundleIdentifier', + infoPlistPath, + ]) + if (plistBuddyValue) { + return plistBuddyValue + } + + return readCommandStdout('plutil', [ + '-extract', + 'CFBundleIdentifier', + 'raw', + '-o', + '-', + infoPlistPath, + ]) +} + +async function inferMobileAppId(platform: CaliPlatform, artifactPath: string) { + if (platform === 'android') { + return inferAndroidAppId(artifactPath) + } + + return inferIosAppId(artifactPath) +} + +function parseBootedDeviceNames(output: string, platform: CaliPlatform, kind: 'simulator' | 'any') { + const lines = output + .split('\n') + .map((line) => line.trim()) + .filter(Boolean) + + return lines + .filter((line) => { + if ( + !line.includes(`(${platform}`) || + !line.includes('target=mobile') || + !line.includes('booted=true') + ) { + return false + } + + if (kind === 'simulator') { + return line.includes('(ios simulator ') + } + + return true + }) + .map((line) => line.replace(/\s+\([^)]*\)\s+booted=true$/, '')) +} + +async function resolveLocalAndroidDeviceName(explicitDeviceName?: string) { + if (explicitDeviceName) { + return explicitDeviceName + } + + const result = await runAgentDeviceCommand('devices', ['--platform', 'android'], { + allowFailure: true, + }) + const bootedDevices = parseBootedDeviceNames(result.stdout, 'android', 'any') + + if (bootedDevices.length === 1) { + return bootedDevices[0] + } + + if (bootedDevices.length > 1) { + throw new Error( + `local-android requires --device when more than one Android target is booted.\n\nBooted targets:\n- ${bootedDevices.join('\n- ')}` + ) + } + + throw new Error( + 'local-android requires a booted Android device or emulator. Boot one first or pass --device so Cali can provision it deterministically.' + ) +} + +async function resolveLocalIosDeviceName(explicitDeviceName?: string) { + if (explicitDeviceName) { + return explicitDeviceName + } + + const result = await runAgentDeviceCommand('devices', ['--platform', 'ios'], { + allowFailure: true, + }) + const bootedSimulators = parseBootedDeviceNames(result.stdout, 'ios', 'simulator') + + if (bootedSimulators.length === 1) { + return bootedSimulators[0] + } + + if (bootedSimulators.length > 1) { + throw new Error( + `local-ios requires --device when more than one iOS simulator is booted.\n\nBooted simulators:\n- ${bootedSimulators.join('\n- ')}` + ) + } + + throw new Error('local-ios requires --device or exactly one booted iOS simulator.') +} + +async function ensureTargetReady(context: MobileCommandRuntimeContext) { + if (!context.deviceName) { + return + } + + if (context.platform === 'ios') { + await runAgentDeviceCommand('ensure-simulator', ['--device', context.deviceName, '--boot']) + return + } + + await runAgentDeviceCommand('boot', buildDeviceSelectorArgs(context), { + allowFailure: true, + }) +} + +async function openAppSession( + sessionName: string, + context: MobileCommandRuntimeContext, + options: Parameters[3] = {} +) { + return runAgentDeviceSessionCommand( + sessionName, + 'open', + [...buildDeviceSelectorArgs(context), context.appId, '--relaunch'], + options + ) +} + +async function installFreshArtifact( + commandId: 'qa' | 'perf-review', + context: MobileCommandRuntimeContext +) { + const installArgs = [...buildDeviceSelectorArgs(context), context.appId, context.artifactPath] + + if (context.platform === 'android') { + let installResult = await runAgentDeviceCommand('install', installArgs, { + allowFailure: true, + }) + + if (!installResult.ok) { + installResult = await runAgentDeviceCommand('reinstall', installArgs, { + allowFailure: true, + }) + } + + assertCommandSuccess( + installResult, + `Deterministic ${commandId} bootstrap failed during install or reinstall.` + ) + return + } + + const reinstallResult = await runAgentDeviceCommand('reinstall', installArgs, { + allowFailure: true, + }) + assertCommandSuccess( + reinstallResult, + `Deterministic ${commandId} bootstrap failed during reinstall.` + ) +} + +function isLocalEnv(envName: CaliEnvName) { + return envName === 'local-android' || envName === 'local-ios' +} + +export function createAgentDeviceSessionName(platform: CaliPlatform) { + const hash = createHash('md5') + .update(`${platform}:${process.cwd()}:${Date.now()}:${Math.random()}`) + .digest('hex') + .slice(0, 5) + + return `${platform}-${hash}` +} + +export async function resolveMobileRuntimeContext( + commandId: CommandId, + envName: CaliEnvName, + context: CaliContext +): Promise { + const platform = context.mobile?.platform + const artifactPath = context.mobile?.artifactPath + const outputDir = context.output.outputDir + + if (!platform) { + throw new Error( + `${commandId} requires a mobile platform in context.mobile.platform or --platform.` + ) + } + + if (!artifactPath) { + throw new Error( + `${commandId} requires a mobile artifact path in context.mobile.artifactPath or --artifact.` + ) + } + + if (!outputDir) { + throw new Error(`${commandId} requires an output directory.`) + } + + const inferredAppId = await inferMobileAppId(platform, artifactPath) + const appId = context.mobile?.appId ?? inferredAppId + if (!appId) { + throw new Error( + `${commandId} requires an app id in context.mobile.appId or --app-id. Cali could not infer it from ${path.basename(artifactPath)}.` + ) + } + + let deviceName = context.mobile?.deviceName + if (envName === 'local-ios') { + deviceName = await resolveLocalIosDeviceName(deviceName) + } else if (envName === 'local-android') { + deviceName = await resolveLocalAndroidDeviceName(deviceName) + } + + return { + platform, + artifactPath, + appId, + deviceName, + outputDir, + screenshotsDir: context.output.screenshotsDir ?? path.join(outputDir, 'screenshots'), + } +} + +export async function bootstrapMobileApp( + commandId: 'qa' | 'perf-review', + envName: CaliEnvName, + context: MobileCommandRuntimeContext, + sessionName: string +) { + await ensureTargetReady(context) + + if (isLocalEnv(envName)) { + const openResult = await openAppSession(sessionName, context, { + allowFailure: true, + }) + + if (openResult.ok) { + return + } + } + + await installFreshArtifact(commandId, context) + + const openResult = await openAppSession(sessionName, context, { + allowFailure: true, + }) + assertCommandSuccess(openResult, 'Deterministic app bootstrap failed during open.') +} + +export async function closeAgentDeviceSession(sessionName: string) { + await runAgentDeviceSessionCommand(sessionName, 'close', [], { + allowFailure: true, + }) +} + +export async function prepareMobileOutputDirectories(context: MobileCommandRuntimeContext) { + await ensureDirectory(context.outputDir) + await rm(context.screenshotsDir, { force: true, recursive: true }) + await ensureDirectory(context.screenshotsDir) +} + +export async function listScreenshots(screenshotsDir: string) { + let entries: string[] + + try { + entries = await readdir(screenshotsDir) + } catch { + return [] + } + + const screenshots: Array> = [] + for (const entry of entries) { + if (!entry.endsWith('.png')) { + continue + } + + const absolutePath = path.join(screenshotsDir, entry) + const fileStat = await stat(absolutePath) + screenshots.push({ + fileName: entry, + absolutePath, + bytes: fileStat.size, + }) + } + + return screenshots.sort((left, right) => left.fileName.localeCompare(right.fileName)) +} diff --git a/packages/cali/src/runtime/publishers.ts b/packages/cali/src/runtime/publishers.ts new file mode 100644 index 0000000..0d09e77 --- /dev/null +++ b/packages/cali/src/runtime/publishers.ts @@ -0,0 +1,59 @@ +import type { PublisherName } from '../config/schema.js' +import { publishBlobReport } from '../report/publishers/blob.js' +import { publishFileReport } from '../report/publishers/file.js' +import type { CommandReport, ReportPublisherResult } from '../report/types.js' + +type PublishReportOptions = { + report: CommandReport + publishers: PublisherName[] +} + +export async function publishReport(options: PublishReportOptions) { + const { report, publishers } = options + let currentReport = report + const publisherResults: ReportPublisherResult[] = [] + + for (const publisher of publishers) { + if (publisher === 'file') { + continue + } + + try { + if (publisher === 'blob') { + currentReport = await publishBlobReport({ report: currentReport }) + } + + publisherResults.push({ + publisher, + ok: true, + }) + } catch (unknownError) { + const error = unknownError instanceof Error ? unknownError : new Error(String(unknownError)) + publisherResults.push({ + publisher, + ok: false, + detail: error.message, + }) + } + } + + if (publishers.includes('file')) { + currentReport = await publishFileReport({ + report: currentReport, + publisherResults: [ + ...publisherResults, + { + publisher: 'file', + ok: true, + }, + ], + }) + } else { + currentReport = { + ...currentReport, + publisherResults, + } + } + + return currentReport +} diff --git a/packages/cali/src/runtime/tool-loop-role.ts b/packages/cali/src/runtime/tool-loop-role.ts new file mode 100644 index 0000000..23e9658 --- /dev/null +++ b/packages/cali/src/runtime/tool-loop-role.ts @@ -0,0 +1,120 @@ +import { hasToolCall, stepCountIs, tool, ToolLoopAgent } from 'ai' +import { z } from 'zod' +import type { ZodType } from 'zod' + +import { createQaAgentModel } from '../model.js' + +type RunToolLoopRoleOptions = { + modelId: string + instructions: string + prompt: string + tools: Record + reportSchema: ZodType + reportDescription: string + maxSteps?: number + reportBufferSteps?: number + reserveReportAfterTool?: string + createMissingReport: () => TOutput + onAgentStep?: (event: { + stepNumber: number + finishReason: string + toolNames: string[] + totalTokens?: number + }) => void + onAgentFinish?: (event: { stepCount: number; finishReason: string; totalTokens?: number }) => void +} + +export async function runToolLoopRole(options: RunToolLoopRoleOptions) { + const { + modelId, + instructions, + prompt, + tools, + reportSchema, + reportDescription, + maxSteps = 12, + reportBufferSteps = 2, + reserveReportAfterTool, + createMissingReport, + onAgentStep, + onAgentFinish, + } = options + let reportInput: TOutput | undefined + let usedReservedTool = false + + const agent = new ToolLoopAgent({ + model: createQaAgentModel(modelId), + instructions, + tools: { + ...tools, + write_report: tool({ + description: reportDescription, + inputSchema: reportSchema, + outputSchema: z.string(), + execute: async (input: TOutput) => { + if (reportInput) { + return 'report already captured' + } + + reportInput = input + return 'report captured' + }, + }), + }, + toolChoice: 'required', + stopWhen: [stepCountIs(maxSteps), hasToolCall('write_report')], + onFinish: async ({ steps, finishReason, totalUsage }) => { + onAgentFinish?.({ + stepCount: steps.length, + finishReason, + totalTokens: totalUsage.totalTokens, + }) + }, + prepareStep: async ({ steps, stepNumber }) => { + if (!reserveReportAfterTool || reportInput) { + return {} + } + + if (!usedReservedTool || stepNumber < maxSteps - reportBufferSteps) { + return {} + } + + return { + activeTools: ['write_report'], + toolChoice: { type: 'tool', toolName: 'write_report' as const }, + } + }, + }) + + let resultText = '' + + const result = await agent.generate({ + prompt, + onStepFinish: async ({ stepNumber, finishReason, toolCalls, usage }) => { + if ( + reserveReportAfterTool && + toolCalls.some((toolCall) => toolCall.toolName === reserveReportAfterTool) + ) { + usedReservedTool = true + } + + onAgentStep?.({ + stepNumber: stepNumber + 1, + finishReason, + toolNames: toolCalls.map((toolCall) => toolCall.toolName), + totalTokens: usage.totalTokens, + }) + }, + }) + + resultText = result.text + + if (!reportInput) { + reportInput = createMissingReport() + } + + return { + reportInput, + resultText, + } +} diff --git a/packages/cali/src/runtime/tool-packs.ts b/packages/cali/src/runtime/tool-packs.ts new file mode 100644 index 0000000..0610fb0 --- /dev/null +++ b/packages/cali/src/runtime/tool-packs.ts @@ -0,0 +1,126 @@ +import type { ToolPackName } from '../config/schema.js' +import { createAgentDeviceToolPack } from '../tools/agent-device.js' +import { createGitHubToolPack } from '../tools/github.js' +import { createReactDevtoolsToolPack } from '../tools/react-devtools.js' +import { createRepoReadToolPack, createRepoWriteToolPack } from '../tools/repo.js' +import { + buildPreloadedSkillsPrompt, + buildSkillsPrompt, + createSkillsToolPack, + discoverSkills, + preloadSkillDocuments, + type RequiredSkillDocument, + type SkillMetadata, +} from '../tools/skills.js' +import type { CaliContext, ToolTraceEntry } from './types.js' + +type PrepareToolPacksOptions = { + context: CaliContext + skillPaths: string[] + enabledToolPacks: ToolPackName[] + sessionName?: string +} + +type ToolPackState = { + agentDeviceTrace: ToolTraceEntry[] + reactDevtoolsTrace: ToolTraceEntry[] +} + +type ToolPackDefinition = { + requiredSkills?: RequiredSkillDocument[] + createTools?: (context: { + context: CaliContext + workspaceRoot: string + sessionName?: string + skills: SkillMetadata[] + state: ToolPackState + }) => Record +} + +const TOOL_PACK_DEFINITIONS: Record = { + skills: { + createTools: ({ skills }) => createSkillsToolPack(skills), + }, + 'agent-device': { + requiredSkills: [ + { + name: 'agent-device', + preloadPaths: ['SKILL.md', 'references/bootstrap-install.md', 'references/exploration.md'], + }, + ], + createTools: ({ state, sessionName }) => + createAgentDeviceToolPack({ + trace: state.agentDeviceTrace, + sessionName: sessionName!, + }), + }, + 'repo-read': { + createTools: ({ workspaceRoot }) => createRepoReadToolPack({ workspaceRoot }), + }, + 'repo-write': { + createTools: ({ workspaceRoot, context }) => + createRepoWriteToolPack({ + workspaceRoot, + allowedCommands: context.dev?.allowedValidations ?? [], + }), + }, + github: { + createTools: ({ context }) => createGitHubToolPack({ context }), + }, + 'react-devtools': { + requiredSkills: [ + { + name: 'react-devtools', + preloadPaths: ['SKILL.md'], + }, + ], + createTools: ({ state }) => createReactDevtoolsToolPack({ trace: state.reactDevtoolsTrace }), + }, + report: {}, +} + +export async function prepareToolPacks(options: PrepareToolPacksOptions) { + const { context, skillPaths, enabledToolPacks, sessionName } = options + if (enabledToolPacks.includes('agent-device') && !sessionName) { + throw new Error('agent-device tool pack requires a bound session name.') + } + const skills = await discoverSkills(skillPaths) + const state: ToolPackState = { + agentDeviceTrace: [], + reactDevtoolsTrace: [], + } + const requiredSkills = enabledToolPacks.flatMap( + (toolPackName) => TOOL_PACK_DEFINITIONS[toolPackName].requiredSkills ?? [] + ) + const preloadedSkillDocuments = await preloadSkillDocuments(skills, requiredSkills) + const preloadedSkillNames = [ + ...new Set(requiredSkills.map((requiredSkill) => requiredSkill.name)), + ] + const tools = enabledToolPacks.reduce>((accumulator, toolPackName) => { + const toolPack = TOOL_PACK_DEFINITIONS[toolPackName] + + if (!toolPack.createTools) { + return accumulator + } + + return { + ...accumulator, + ...toolPack.createTools({ + context, + workspaceRoot: context.workspaceRoot, + sessionName, + skills, + state, + }), + } + }, {}) + + return { + tools, + skills, + preloadedSkillDocuments, + preloadedSkillsPrompt: buildPreloadedSkillsPrompt(preloadedSkillDocuments), + availableSkillsPrompt: buildSkillsPrompt(skills, { excludeSkillNames: preloadedSkillNames }), + traces: state, + } +} diff --git a/packages/cali/src/runtime/types.ts b/packages/cali/src/runtime/types.ts new file mode 100644 index 0000000..ee7f264 --- /dev/null +++ b/packages/cali/src/runtime/types.ts @@ -0,0 +1,164 @@ +import type { CaliEnvName, PublisherName, ToolPackName } from '../config/schema.js' + +export const COMMAND_IDS = ['qa', 'review', 'perf-review', 'dev'] as const + +export type CommandId = (typeof COMMAND_IDS)[number] + +export type CommandConfigKey = 'qa' | 'review' | 'perfReview' | 'dev' + +export function commandConfigKeyFromId(commandId: CommandId): CommandConfigKey { + switch (commandId) { + case 'perf-review': + return 'perfReview' + case 'qa': + case 'review': + case 'dev': + return commandId + } +} + +export type CaliPlatform = 'android' | 'ios' + +export type RepositoryContext = { + provider?: string + owner?: string + name?: string + cloneUrl?: string + defaultBranch?: string + currentBranch?: string + commitSha?: string +} + +export type TaskContext = { + provider?: string + id?: string + title?: string + body?: string | null + url?: string + labels: string[] +} + +export type PullRequestContext = { + number?: number + title?: string + body?: string | null + url?: string + labels: string[] + isDraft: boolean + baseBranch?: string + headBranch?: string + diffPath?: string + diffSummary?: string +} + +export type MobileContext = { + platform?: CaliPlatform + artifactPath?: string + appId?: string + deviceName?: string +} + +export type BuildContext = { + id?: string + workflowUrl?: string + logsUrl?: string +} + +export type OutputContext = { + outputDir?: string + screenshotsDir?: string +} + +export type QaCommandContext = { + acceptanceCriteria: string[] +} + +export type ReviewCommandContext = Record + +export type PerfReviewCommandContext = { + targetFlow?: string + expectedInteraction?: string + profilingGoals: string[] + suspectedScreens: string[] +} + +export type DevCommandContext = { + branchStrategy?: string + allowedValidations: string[] + writePolicy?: 'workspace' | 'none' + pushPolicy?: 'disabled' | 'manual' | 'auto' +} + +export type CaliContext = { + workspaceRoot: string + repository?: RepositoryContext + task?: TaskContext + pullRequest?: PullRequestContext + mobile?: MobileContext + build?: BuildContext + output: OutputContext + qa?: QaCommandContext + review?: ReviewCommandContext + perfReview?: PerfReviewCommandContext + dev?: DevCommandContext +} + +export type MobileCommandRuntimeContext = { + platform: CaliPlatform + artifactPath: string + appId: string + deviceName?: string + outputDir: string + screenshotsDir: string +} + +export type CommandCliOptions = { + envName?: CaliEnvName + configPath?: string + prompt?: string + contextPath?: string + outputDir?: string + model?: string + workspaceRoot?: string + platform?: CaliPlatform + artifactPath?: string + appId?: string + deviceName?: string + buildId?: string + workflowUrl?: string + logsUrl?: string + prNumber?: number + prTitle?: string + prBody?: string + prUrl?: string + prBaseBranch?: string + prHeadBranch?: string + taskId?: string + taskTitle?: string + taskBody?: string + taskUrl?: string +} + +export type CommandResolvedConfig = { + envName: CaliEnvName + workspaceRoot?: string + contextPath?: string + skillPaths: string[] + enabledToolPacks: ToolPackName[] + outputPublishers: PublisherName[] + extraInstructions: string[] + model: string + mobileDefaults: { + platform?: CaliPlatform + deviceName?: string + appId?: string + } +} + +export type ToolTraceEntry = { + command: string + ok: boolean + exitCode: number + stdout: string + stderr: string +} diff --git a/packages/cali/src/tools/agent-device.ts b/packages/cali/src/tools/agent-device.ts index b84f4c6..10f8a12 100644 --- a/packages/cali/src/tools/agent-device.ts +++ b/packages/cali/src/tools/agent-device.ts @@ -1,26 +1,50 @@ import { tool } from 'ai' import { z } from 'zod' -import type { AgentDeviceTraceEntry } from '../report/types.js' -import { parseJson, runCommand, trimText } from '../utils.js' +import type { ToolTraceEntry } from '../runtime/types.js' +import { ensureCommandExists, parseJson, runCommand, trimText } from '../utils.js' -export const DEFAULT_AGENT_DEVICE_SESSION_NAME = 'default' -const DEFAULT_AGENT_DEVICE_SESSION_LOCK = 'strip' +const DEFAULT_AGENT_DEVICE_SESSION_LOCK = 'reject' type CreateAgentDeviceToolPackOptions = { - trace: AgentDeviceTraceEntry[] - sessionName?: string + trace: ToolTraceEntry[] + sessionName: string } -export function getAgentDeviceSessionArgs( - sessionName = process.env.AGENT_DEVICE_SESSION ?? DEFAULT_AGENT_DEVICE_SESSION_NAME -) { - return ['--session', sessionName, '--session-lock', DEFAULT_AGENT_DEVICE_SESSION_LOCK] +type SessionArgsOptions = { + lockTarget?: boolean +} + +export function getAgentDeviceSessionArgs(sessionName: string, options: SessionArgsOptions = {}) { + const args = ['--session', sessionName] + + if (options.lockTarget) { + args.push('--session-lock', DEFAULT_AGENT_DEVICE_SESSION_LOCK) + } + + return args +} + +function normalizeCommandInvocation(command: string, args: string[]) { + const trimmedCommand = command.trim() + + if (args.length > 0 || !trimmedCommand.includes(' ')) { + return { + command: trimmedCommand, + args, + } + } + + const [normalizedCommand, ...normalizedArgs] = trimmedCommand.split(/\s+/g) + return { + command: normalizedCommand, + args: normalizedArgs, + } } export function createAgentDeviceToolPack(options: CreateAgentDeviceToolPackOptions) { const { trace, sessionName } = options - const sessionArgs = getAgentDeviceSessionArgs(sessionName) + const sessionArgs = getAgentDeviceSessionArgs(sessionName, { lockTarget: true }) const inputSchema = z.object({ command: z .string() @@ -36,7 +60,9 @@ export function createAgentDeviceToolPack(options: CreateAgentDeviceToolPackOpti 'Run an agent-device command for mobile UI automation and screenshot capture. Use canonical subcommands like back or home directly; do not emulate them with press.', inputSchema, execute: async ({ command, args = [] }) => { - const fullCommand = [...sessionArgs, command, ...args] + await ensureCommandExists('agent-device', 'npm i -g agent-device') + const normalized = normalizeCommandInvocation(command, args) + const fullCommand = [...sessionArgs, normalized.command, ...normalized.args] const result = await runCommand('agent-device', fullCommand, { allowFailure: true, }) diff --git a/packages/cali/src/tools/github.ts b/packages/cali/src/tools/github.ts new file mode 100644 index 0000000..3c57650 --- /dev/null +++ b/packages/cali/src/tools/github.ts @@ -0,0 +1,30 @@ +import { tool } from 'ai' +import { z } from 'zod' + +import type { CaliContext } from '../runtime/types.js' + +type CreateGitHubToolPackOptions = { + context: CaliContext +} + +export function createGitHubToolPack(options: CreateGitHubToolPackOptions) { + const { context } = options + + return { + get_repository_context: tool({ + description: 'Read repository metadata from the normalized Cali context.', + inputSchema: z.object({}), + execute: async () => context.repository ?? {}, + }), + get_pull_request_context: tool({ + description: 'Read pull request metadata from the normalized Cali context.', + inputSchema: z.object({}), + execute: async () => context.pullRequest ?? {}, + }), + get_task_context: tool({ + description: 'Read task metadata from the normalized Cali context.', + inputSchema: z.object({}), + execute: async () => context.task ?? {}, + }), + } +} diff --git a/packages/cali/src/tools/react-devtools.ts b/packages/cali/src/tools/react-devtools.ts new file mode 100644 index 0000000..83d3932 --- /dev/null +++ b/packages/cali/src/tools/react-devtools.ts @@ -0,0 +1,52 @@ +import { tool } from 'ai' +import { z } from 'zod' + +import type { ToolTraceEntry } from '../runtime/types.js' +import { ensureCommandExists, parseJson, runCommand, trimText } from '../utils.js' + +type CreateReactDevtoolsToolPackOptions = { + trace: ToolTraceEntry[] +} + +export function createReactDevtoolsToolPack(options: CreateReactDevtoolsToolPackOptions) { + const { trace } = options + + return { + react_devtools: tool({ + description: + 'Run an agent-react-devtools command to inspect the component tree, props, state, hooks, or profile runtime performance.', + inputSchema: z.object({ + command: z + .string() + .describe('The first subcommand, such as status, get, find, profile, wait.'), + args: z + .array(z.string()) + .optional() + .describe('Remaining CLI arguments for the subcommand.'), + }), + execute: async ({ command, args = [] }) => { + await ensureCommandExists('agent-react-devtools', 'npm i -g agent-react-devtools') + const fullCommand = [command, ...args] + const result = await runCommand('agent-react-devtools', fullCommand, { + allowFailure: true, + }) + + trace.push({ + command: fullCommand.join(' '), + ok: result.ok, + exitCode: result.exitCode, + stdout: trimText(result.stdout, 4000), + stderr: trimText(result.stderr, 2000), + }) + + return { + ok: result.ok, + exitCode: result.exitCode, + stdout: trimText(result.stdout, 8000), + stderr: trimText(result.stderr, 4000), + json: parseJson(result.stdout, null as unknown), + } + }, + }), + } +} diff --git a/packages/cali/src/tools/repo.ts b/packages/cali/src/tools/repo.ts new file mode 100644 index 0000000..965baf2 --- /dev/null +++ b/packages/cali/src/tools/repo.ts @@ -0,0 +1,226 @@ +import { readFile, rm, writeFile } from 'node:fs/promises' +import path from 'node:path' + +import { tool } from 'ai' +import { z } from 'zod' + +import { ensureCommandExists, ensureDirectory, resolveFromCwd, runCommand } from '../utils.js' + +type RepoReadToolPackOptions = { + workspaceRoot: string +} + +type RepoWriteToolPackOptions = { + workspaceRoot: string + allowedCommands?: string[] +} + +export function createRepoReadToolPack(options: RepoReadToolPackOptions) { + const { workspaceRoot } = options + const ensureRipgrep = () => + ensureCommandExists('rg', 'Install ripgrep and make sure `rg` is on PATH.') + const ensureGit = () => ensureCommandExists('git', 'Install Git and make sure `git` is on PATH.') + + return { + list_repo_files: tool({ + description: 'List repository files using ripgrep file discovery.', + inputSchema: z.object({ + pattern: z.string().optional(), + maxResults: z.number().int().min(1).max(500).optional(), + }), + execute: async ({ pattern, maxResults = 200 }) => { + await ensureRipgrep() + const args = ['--files'] + if (pattern?.trim()) { + args.push('-g', pattern.trim()) + } + + const result = await runCommand('rg', args, { + cwd: workspaceRoot, + allowFailure: true, + }) + + return { + ok: result.ok, + files: result.stdout + .split('\n') + .map((value) => value.trim()) + .filter(Boolean) + .slice(0, maxResults), + stderr: result.stderr, + } + }, + }), + search_repo: tool({ + description: 'Search repository files with ripgrep and return matching lines.', + inputSchema: z.object({ + query: z.string(), + glob: z.string().optional(), + maxResults: z.number().int().min(1).max(200).optional(), + }), + execute: async ({ query, glob, maxResults = 100 }) => { + await ensureRipgrep() + const args = ['-n', '--no-heading', query] + if (glob?.trim()) { + args.push('-g', glob.trim()) + } + + const result = await runCommand('rg', args, { + cwd: workspaceRoot, + allowFailure: true, + }) + + return { + ok: result.ok, + matches: result.stdout + .split('\n') + .map((value) => value.trim()) + .filter(Boolean) + .slice(0, maxResults), + stderr: result.stderr, + } + }, + }), + read_repo_file: tool({ + description: 'Read a repository file, optionally scoped to a line window.', + inputSchema: z.object({ + path: z.string(), + startLine: z.number().int().min(1).optional(), + maxLines: z.number().int().min(1).max(400).optional(), + }), + execute: async ({ path: relativePath, startLine = 1, maxLines = 200 }) => { + const absolutePath = resolveFromCwd(workspaceRoot, relativePath) + const content = await readFile(absolutePath, 'utf8') + const lines = content.split('\n') + const slice = lines.slice(startLine - 1, startLine - 1 + maxLines) + + return { + absolutePath, + startLine, + endLine: startLine + slice.length - 1, + content: slice.join('\n'), + } + }, + }), + git_status: tool({ + description: 'Read the current git status for the repository.', + inputSchema: z.object({}), + execute: async () => { + await ensureGit() + const result = await runCommand('git', ['status', '--short', '--branch'], { + cwd: workspaceRoot, + allowFailure: true, + }) + + return { + ok: result.ok, + stdout: result.stdout.trim(), + stderr: result.stderr.trim(), + } + }, + }), + git_diff: tool({ + description: 'Read a git diff from the repository or a specific file path.', + inputSchema: z.object({ + baseRef: z.string().optional(), + path: z.string().optional(), + maxLines: z.number().int().min(1).max(800).optional(), + }), + execute: async ({ baseRef, path: relativePath, maxLines = 400 }) => { + await ensureGit() + const args = ['diff'] + if (baseRef?.trim()) { + args.push(baseRef.trim()) + } + if (relativePath?.trim()) { + args.push('--', relativePath.trim()) + } + + const result = await runCommand('git', args, { + cwd: workspaceRoot, + allowFailure: true, + }) + + return { + ok: result.ok, + diff: result.stdout.split('\n').slice(0, maxLines).join('\n'), + stderr: result.stderr.trim(), + } + }, + }), + } +} + +export function createRepoWriteToolPack(options: RepoWriteToolPackOptions) { + const { workspaceRoot, allowedCommands = [] } = options + const ensureShell = () => + ensureCommandExists( + 'zsh', + 'Install zsh and make sure `zsh` is on PATH for repository commands.' + ) + + return { + write_repo_file: tool({ + description: 'Write a repository file with full replacement content.', + inputSchema: z.object({ + path: z.string(), + content: z.string(), + }), + execute: async ({ path: relativePath, content }) => { + const absolutePath = resolveFromCwd(workspaceRoot, relativePath) + await ensureDirectory(path.dirname(absolutePath)) + await writeFile(absolutePath, content, 'utf8') + + return { + ok: true, + absolutePath, + } + }, + }), + delete_repo_file: tool({ + description: 'Delete a repository file.', + inputSchema: z.object({ + path: z.string(), + }), + execute: async ({ path: relativePath }) => { + const absolutePath = resolveFromCwd(workspaceRoot, relativePath) + await rm(absolutePath, { force: true }) + + return { + ok: true, + absolutePath, + } + }, + }), + run_repo_command: tool({ + description: + 'Run a shell command inside the repository workspace. Use this for validation commands and project scripts.', + inputSchema: z.object({ + command: z.string(), + }), + execute: async ({ command }) => { + await ensureShell() + if (allowedCommands.length > 0 && !allowedCommands.includes(command)) { + return { + ok: false, + exitCode: 1, + stdout: '', + stderr: `Command is not allowed by the current write policy. Allowed commands: ${allowedCommands.join(', ')}`, + } + } + + const result = await runCommand('zsh', ['-lc', command], { + cwd: workspaceRoot, + allowFailure: true, + }) + + return { + ok: result.ok, + exitCode: result.exitCode, + stdout: result.stdout, + stderr: result.stderr, + } + }, + }), + } +} diff --git a/packages/cali/src/tools/skills.ts b/packages/cali/src/tools/skills.ts index 321b0d0..fa611f4 100644 --- a/packages/cali/src/tools/skills.ts +++ b/packages/cali/src/tools/skills.ts @@ -11,13 +11,20 @@ type SkillMetadata = { skillFilePath: string } -function stripFrontmatter(content: string) { - const match = content.match(/^---\r?\n[\s\S]*?\r?\n---\r?\n?/) - return match ? content.slice(match[0].length).trim() : content.trim() +type PreloadedSkillDocument = { + skillName: string + relativePath: string + absolutePath: string + content: string } -function parseFrontmatter(content: string) { - const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/) +type RequiredSkillDocument = { + name: string + preloadPaths: string[] +} + +function parseSkillFile(content: string) { + const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)/) if (!match?.[1]) { throw new Error('No frontmatter found') } @@ -39,6 +46,7 @@ function parseFrontmatter(content: string) { return { name, description, + body: (match[2] ?? '').trim(), } } @@ -67,6 +75,21 @@ function findSkill(skills: SkillMetadata[], name: string) { return skill } +async function readSkillDocument(skill: SkillMetadata, relativeFilePath: string) { + const absolutePath = + relativeFilePath === 'SKILL.md' + ? skill.skillFilePath + : resolveSkillFilePath(skill, relativeFilePath) + const content = await readFile(absolutePath, 'utf8') + + return { + skillName: skill.name, + relativePath: relativeFilePath, + absolutePath, + content: relativeFilePath === 'SKILL.md' ? parseSkillFile(content).body : content.trim(), + } satisfies PreloadedSkillDocument +} + export async function discoverSkills(directories: string[]) { const skills: SkillMetadata[] = [] const seenNames = new Set() @@ -89,8 +112,8 @@ export async function discoverSkills(directories: string[]) { try { const content = await readFile(skillFilePath, 'utf8') - const frontmatter = parseFrontmatter(content) - const key = frontmatter.name.toLowerCase() + const skillFile = parseSkillFile(content) + const key = skillFile.name.toLowerCase() if (seenNames.has(key)) { continue @@ -98,8 +121,8 @@ export async function discoverSkills(directories: string[]) { seenNames.add(key) skills.push({ - name: frontmatter.name, - description: frontmatter.description, + name: skillFile.name, + description: skillFile.description, directoryPath: skillDirectoryPath, skillFilePath, }) @@ -112,19 +135,64 @@ export async function discoverSkills(directories: string[]) { return skills.sort((left, right) => left.name.localeCompare(right.name)) } -export function buildSkillsPrompt(skills: SkillMetadata[]) { - if (skills.length === 0) { +export function buildSkillsPrompt( + skills: SkillMetadata[], + options?: { excludeSkillNames?: string[] } +) { + const excludedSkillNames = new Set( + (options?.excludeSkillNames ?? []).map((skillName) => skillName.toLowerCase()) + ) + const availableSkills = skills.filter( + (skill) => !excludedSkillNames.has(skill.name.toLowerCase()) + ) + + if (availableSkills.length === 0) { return 'No local skills were discovered for this run.' } return [ 'Available local skills:', - ...skills.map((skill) => `- ${skill.name}: ${skill.description}`), + ...availableSkills.map((skill) => `- ${skill.name}: ${skill.description}`), '', - 'Load a skill before relying on its instructions. Only read files inside a skill after loading it.', + 'These skills are not loaded yet. Call load_skill before relying on their instructions. Only read files inside a skill after loading it.', ].join('\n') } +export async function preloadSkillDocuments( + skills: SkillMetadata[], + requiredSkills: RequiredSkillDocument[] +) { + const documents: PreloadedSkillDocument[] = [] + + for (const requiredSkill of requiredSkills) { + const skill = findSkill(skills, requiredSkill.name) + + for (const preloadPath of requiredSkill.preloadPaths) { + documents.push(await readSkillDocument(skill, preloadPath)) + } + } + + return documents +} + +export function buildPreloadedSkillsPrompt(documents: PreloadedSkillDocument[]) { + if (documents.length === 0) { + return '' + } + + const preloadedSkillNames = [...new Set(documents.map((document) => document.skillName))] + const sections = [ + 'Required skill guidance loaded for this run.', + `Already loaded skills: ${preloadedSkillNames.join(', ')}`, + ] + + for (const document of documents) { + sections.push('', `## ${document.skillName} :: ${document.relativePath}`, document.content) + } + + return sections.join('\n') +} + export function createSkillsToolPack(skills: SkillMetadata[]) { const loadedSkills = new Set() const loadSkillInputSchema = z.object({ @@ -145,13 +213,14 @@ export function createSkillsToolPack(skills: SkillMetadata[]) { const skill = findSkill(skills, name) loadedSkills.add(skill.name.toLowerCase()) const content = await readFile(skill.skillFilePath, 'utf8') + const skillFile = parseSkillFile(content) return { name: skill.name, description: skill.description, skillDirectory: skill.directoryPath, skillFilePath: skill.skillFilePath, - content: stripFrontmatter(content), + content: skillFile.body, } }, }), @@ -181,4 +250,4 @@ export function createSkillsToolPack(skills: SkillMetadata[]) { } } -export type { SkillMetadata } +export type { PreloadedSkillDocument, RequiredSkillDocument, SkillMetadata } diff --git a/packages/cali/src/utils.ts b/packages/cali/src/utils.ts index 0e3e746..7bc5ef1 100644 --- a/packages/cali/src/utils.ts +++ b/packages/cali/src/utils.ts @@ -3,7 +3,7 @@ import { mkdir } from 'node:fs/promises' import path from 'node:path' import { promisify } from 'node:util' -import type { QaPlatform } from './env/types.js' +import type { CaliPlatform } from './runtime/types.js' const execFile = promisify(execFileCallback) @@ -23,9 +23,12 @@ type CommandOptions = { type ExecFileError = Error & { stdout?: string stderr?: string + status?: number | null code?: number | string } +const commandExistsCache = new Map() + export async function runCommand( file: string, args: string[], @@ -50,7 +53,12 @@ export async function runCommand( const error = unknownError as ExecFileError const stdout = typeof error.stdout === 'string' ? error.stdout : '' const stderr = typeof error.stderr === 'string' ? error.stderr : error.message - const exitCode = typeof error.code === 'number' ? error.code : 1 + const exitCode = + typeof error.status === 'number' + ? error.status + : typeof error.code === 'number' + ? error.code + : 1 if (!allowFailure) { throw new Error( @@ -67,6 +75,23 @@ export async function runCommand( } } +export async function ensureCommandExists(commandName: string, installHint: string) { + const cached = commandExistsCache.get(commandName) + if (cached === true) { + return + } + + const result = await runCommand('which', [commandName], { allowFailure: true }) + if (result.ok && result.stdout.trim()) { + commandExistsCache.set(commandName, true) + return + } + + throw new Error( + `Missing required CLI: ${commandName}\n\nInstall it before running Cali:\n${installHint}` + ) +} + export async function ensureDirectory(directoryPath: string) { await mkdir(directoryPath, { recursive: true }) } @@ -111,7 +136,7 @@ export function resolveFromCwd(cwd: string, targetPath: string) { return path.isAbsolute(targetPath) ? targetPath : path.resolve(cwd, targetPath) } -export function normalizePlatform(value: string | undefined): QaPlatform | undefined { +export function normalizePlatform(value: string | undefined): CaliPlatform | undefined { if (value === 'android' || value === 'ios') { return value } From 0a66601bf0b6e076e74e0aeb34e3e39b4a643db7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Tue, 7 Apr 2026 20:54:27 +0200 Subject: [PATCH 20/48] chore: update bun lock --- bun.lockb | Bin 310992 -> 310992 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/bun.lockb b/bun.lockb index 23d7fc07bda9b33ea55dc2ba182006eb19af9ea5..455c6f5dba4671996959f0d1bb86dc2d396ca106 100755 GIT binary patch delta 63 zcmcccSLniDp$(0f*?)Ky)G;$KOg1pmm|S4Qv3bX3P6c*JZxq4iAD`QQd}ah Date: Tue, 7 Apr 2026 21:09:21 +0200 Subject: [PATCH 21/48] chore: trim tools package and remove mcp server --- AGENTS.md | 4 +- README.md | 3 +- bun.lockb | Bin 310992 -> 306944 bytes package.json | 3 +- packages/mcp-server/README.md | 29 ----- packages/mcp-server/package.json | 39 ------ packages/mcp-server/rslib.config.ts | 19 --- packages/mcp-server/src/index.ts | 117 ------------------ packages/mcp-server/tsconfig.json | 4 - packages/tools/README.md | 13 +- packages/tools/package.json | 7 +- packages/tools/src/android.ts | 10 +- packages/tools/src/apple.ts | 36 +++--- packages/tools/src/fs.ts | 92 -------------- packages/tools/src/git.ts | 33 ----- packages/tools/src/index.ts | 3 - packages/tools/src/npm.ts | 57 --------- packages/tools/src/react-native.ts | 4 +- packages/tools/src/vendor/react-native-cli.ts | 7 +- 19 files changed, 32 insertions(+), 448 deletions(-) delete mode 100644 packages/mcp-server/README.md delete mode 100644 packages/mcp-server/package.json delete mode 100644 packages/mcp-server/rslib.config.ts delete mode 100644 packages/mcp-server/src/index.ts delete mode 100644 packages/mcp-server/tsconfig.json delete mode 100644 packages/tools/src/fs.ts delete mode 100644 packages/tools/src/git.ts delete mode 100644 packages/tools/src/npm.ts diff --git a/AGENTS.md b/AGENTS.md index 4b15756..a8e102f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -19,7 +19,6 @@ Minimal operating guide for AI coding agents in this repo. - `packages/cali`: standalone CLI role platform - `packages/tools`: reusable Cali tools for other runtimes -- `packages/mcp-server`: MCP wrapper over the tools package ## Cali Runtime Shape @@ -149,8 +148,11 @@ Required skill guidance should be preloaded through the tool-pack registry when - For `packages/cali` TypeScript changes: - `bunx tsc --noEmit -p packages/cali/tsconfig.json` +- For `packages/tools` TypeScript changes: + - `bunx tsc --noEmit -p packages/tools/tsconfig.json` - For build or runtime changes: - `bun run build:cli` + - `bun run build:tools` when `packages/tools` changes - For CLI surface changes: - `node packages/cali/dist/index.js --help` - relevant `--help` command smoke tests diff --git a/README.md b/README.md index 286f1fd..4d6d7f5 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,6 @@ You can use Cali in three ways: - **standalone** - [`cali`](./packages/cali/README.md) - Role-oriented CLI for mobile QA, review, perf review, and dev runs in local and CI environments. - **with Vercel AI SDK** - [`cali-tools`](./packages/tools/README.md) - Collection of tools for building React Native apps with [Vercel AI SDK](https://github.com/ai-sdk/ai) -- **with Claude, Zed, and other MCP Clients** - [`cali-mcp-server`](./packages/mcp-server/README.md) - [MCP server](http://modelcontextprotocol.io) for using Cali with Claude and other compatible environments For a repo-oriented guide to the current Cali v2 architecture, role platform, and extension points, see [`AGENTS.md`](./AGENTS.md). For the standalone CLI’s current env model, context file contract, and package scripts, see [`packages/cali/README.md`](./packages/cali/README.md). @@ -37,9 +36,9 @@ For the standalone CLI’s current env model, context file contract, and package Cali is still in the early stages of development, but it already supports: +- **Role-based Mobile Workflows**: QA, code review, perf review, and repo-backed dev runs through the standalone CLI - **Build Automation**: Running and building React Native apps on iOS and Android - **Device Management**: Listing and managing connected Android and iOS devices and simulators -- **Dependency Management**: Install and manage npm packages and CocoaPods dependencies. - **React Native Library Search**: Searching and listing React Native libraries from [React Native Directory](https://reactnative.directory) You can learn more about available tools [here](./packages/tools/README.md). diff --git a/bun.lockb b/bun.lockb index 455c6f5dba4671996959f0d1bb86dc2d396ca106..6786d8c10feed2e85902e35f5a870b36c718f4a0 100755 GIT binary patch delta 48350 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
', + 'Android details', + '', + renderCommandSection(android).trimEnd(), + '', + '
' + ) + } + + if (ios) { + lines.push( + '', + '
', + 'iOS details', + '', + renderCommandSection(ios).trimEnd(), + '', + '
' + ) + } + + return `${lines.join('\n')}\n` +} diff --git a/packages/cali/src/report/publishers/file.ts b/packages/cali/src/report/publishers/file.ts index a8bcdcb..7a3e68d 100644 --- a/packages/cali/src/report/publishers/file.ts +++ b/packages/cali/src/report/publishers/file.ts @@ -111,7 +111,8 @@ export async function publishFileReport({ } const finalReport = createSafePublishedReport(report, publisherResults) - const topIssue = getTopIssue(finalReport) ?? '' + const topIssue = + getTopIssue(finalReport) ?? (finalReport.overallStatus === 'passed' ? '' : finalReport.summary) await ensureDirectory(outputDir) await writeFile( diff --git a/packages/cali/src/commands/write-mobile-pr-context.ts b/packages/cali/src/runtime/ci-context.ts similarity index 60% rename from packages/cali/src/commands/write-mobile-pr-context.ts rename to packages/cali/src/runtime/ci-context.ts index 5ed5f6f..86ec7e9 100644 --- a/packages/cali/src/commands/write-mobile-pr-context.ts +++ b/packages/cali/src/runtime/ci-context.ts @@ -1,18 +1,13 @@ -import { readFile, writeFile } from 'node:fs/promises' -import path from 'node:path' +import { readFile } from 'node:fs/promises' import { DOCS_URLS } from '../docs.js' -import { detectRepositoryContext, sanitizeUrl } from '../runtime/context-repo.js' -import type { CaliContext } from '../runtime/types.js' -import { ensureDirectory, resolveFromCwd } from '../utils.js' +import { detectRepositoryContext, sanitizeUrl } from './context-repo.js' +import type { CaliContext, CaliPlatform } from './types.js' -type WriteMobilePrContextProvider = 'github-actions' | 'eas' -type MobilePlatform = 'android' | 'ios' +export type CiProvider = 'github-actions' | 'eas' -export type WriteMobilePrContextOptions = { - from: WriteMobilePrContextProvider - outputPath: string - platform?: MobilePlatform +type BuildCiContextOptions = { + platform?: CaliPlatform artifactPath?: string appId?: string deviceName?: string @@ -28,12 +23,12 @@ function readOptionalEnv(name: string) { return value && value.length > 0 ? value : undefined } -function normalizePlatform(value: string | undefined): MobilePlatform | undefined { +function normalizePlatform(value: string | undefined): CaliPlatform | undefined { return value === 'android' || value === 'ios' ? value : undefined } -function createContextWriterError(message: string) { - return new Error([message, `Docs: ${DOCS_URLS.ciHelpers}`].join('\n\n')) +function createCiContextError(message: string) { + return new Error([message, `Docs: ${DOCS_URLS.ciProviders}`].join('\n\n')) } async function loadJsonFile(filePath: string) { @@ -90,7 +85,7 @@ function readPullRequestJson(rawPrJson: string | undefined) { try { return JSON.parse(rawPrJson) } catch { - throw createContextWriterError('Failed to parse PR_JSON as JSON.') + throw createCiContextError('Failed to parse PR_JSON as JSON.') } } @@ -98,7 +93,7 @@ function createCommonContext(options: { workspaceRoot: string repository?: CaliContext['repository'] pullRequest?: CaliContext['pullRequest'] - platform: MobilePlatform + platform: CaliPlatform artifactPath: string appId?: string deviceName?: string @@ -106,11 +101,10 @@ function createCommonContext(options: { buildId?: string workflowUrl?: string logsUrl?: string -}): CaliContext { +}): Partial { return { workspaceRoot: options.workspaceRoot, repository: options.repository, - task: undefined, pullRequest: options.pullRequest, mobile: { platform: options.platform, @@ -118,11 +112,14 @@ function createCommonContext(options: { appId: options.appId, deviceName: options.deviceName, }, - build: { - id: options.buildId, - workflowUrl: sanitizeUrl(options.workflowUrl, { stripQuery: true }), - logsUrl: sanitizeUrl(options.logsUrl, { stripQuery: true }), - }, + build: + options.buildId || options.workflowUrl || options.logsUrl + ? { + id: options.buildId, + workflowUrl: sanitizeUrl(options.workflowUrl, { stripQuery: true }), + logsUrl: sanitizeUrl(options.logsUrl, { stripQuery: true }), + } + : undefined, output: { outputDir: options.outputDir, }, @@ -131,11 +128,11 @@ function createCommonContext(options: { async function buildGithubActionsContext( cwd: string, - options: WriteMobilePrContextOptions -): Promise { + options: BuildCiContextOptions +): Promise> { const eventPath = readOptionalEnv('GITHUB_EVENT_PATH') if (!eventPath) { - throw createContextWriterError('GitHub Actions context generation requires GITHUB_EVENT_PATH.') + throw createCiContextError('GitHub Actions CI mode requires GITHUB_EVENT_PATH.') } const event = await loadJsonFile(eventPath) @@ -154,32 +151,25 @@ async function buildGithubActionsContext( : undefined) if (!platform) { - throw createContextWriterError( - 'GitHub Actions context generation requires CALI_PLATFORM or --platform.' - ) + throw createCiContextError('GitHub Actions CI mode requires CALI_PLATFORM or --platform.') } if (!artifactPath) { - throw createContextWriterError( - 'GitHub Actions context generation requires CALI_ARTIFACT_PATH or --artifact.' - ) + throw createCiContextError('GitHub Actions CI mode requires CALI_ARTIFACT_PATH or --artifact.') } return createCommonContext({ - workspaceRoot: resolveFromCwd( - cwd, - options.workspaceRoot ?? readOptionalEnv('GITHUB_WORKSPACE') ?? cwd - ), + workspaceRoot: options.workspaceRoot ?? readOptionalEnv('GITHUB_WORKSPACE') ?? cwd, repository: { ...detectedRepository.repository, ...githubRepository, }, pullRequest: normalizePullRequest(event?.pull_request), platform, - artifactPath: resolveFromCwd(cwd, artifactPath), + artifactPath, appId: options.appId ?? readOptionalEnv('CALI_APP_ID'), deviceName: options.deviceName ?? readOptionalEnv('CALI_DEVICE_NAME'), - outputDir: resolveFromCwd(cwd, outputDir), + outputDir, buildId, workflowUrl, logsUrl: options.logsUrl, @@ -188,8 +178,8 @@ async function buildGithubActionsContext( async function buildEasContext( cwd: string, - options: WriteMobilePrContextOptions -): Promise { + options: BuildCiContextOptions +): Promise> { const detectedRepository = await detectRepositoryContext(cwd) const githubRepository = resolveGithubRepositoryContext() const outputDir = options.outputDir ?? readOptionalEnv('CALI_OUTPUT_DIR') ?? './artifacts/qa' @@ -197,64 +187,39 @@ async function buildEasContext( const platform = options.platform ?? normalizePlatform(readOptionalEnv('QA_PLATFORM')) if (!artifactPath) { - throw createContextWriterError('EAS context generation requires APP_PATH or --artifact.') + throw createCiContextError('EAS CI mode requires APP_PATH or --artifact.') } if (!platform) { - throw createContextWriterError('EAS context generation requires QA_PLATFORM or --platform.') + throw createCiContextError('EAS CI mode requires QA_PLATFORM or --platform.') } return createCommonContext({ - workspaceRoot: resolveFromCwd(cwd, options.workspaceRoot ?? cwd), + workspaceRoot: options.workspaceRoot ?? cwd, repository: { ...detectedRepository.repository, ...githubRepository, }, pullRequest: normalizePullRequest(readPullRequestJson(readOptionalEnv('PR_JSON'))), platform, - artifactPath: resolveFromCwd(cwd, artifactPath), + artifactPath, appId: options.appId ?? readOptionalEnv('APPLICATION_ID'), deviceName: options.deviceName ?? readOptionalEnv('CALI_DEVICE_NAME'), - outputDir: resolveFromCwd(cwd, outputDir), + outputDir, buildId: options.buildId ?? readOptionalEnv('BUILD_ID'), workflowUrl: options.workflowUrl ?? readOptionalEnv('WORKFLOW_URL'), logsUrl: options.logsUrl ?? readOptionalEnv('LOGS_URL'), }) } -function removeUndefinedValues(value: unknown): unknown { - if (Array.isArray(value)) { - return value.map(removeUndefinedValues).filter((entry) => entry !== undefined) - } - - if (value && typeof value === 'object') { - const entries = Object.entries(value) - .map(([entryKey, entryValue]) => [entryKey, removeUndefinedValues(entryValue)] as const) - .filter(([, entryValue]) => entryValue !== undefined) - if (entries.length === 0) { - return undefined - } - - return Object.fromEntries(entries) +export async function buildCiContext( + cwd: string, + provider: CiProvider, + options: BuildCiContextOptions +): Promise> { + if (provider === 'github-actions') { + return buildGithubActionsContext(cwd, options) } - return value -} - -export async function writeMobilePrContext(options: WriteMobilePrContextOptions) { - const cwd = process.cwd() - const context = - options.from === 'github-actions' - ? await buildGithubActionsContext(cwd, options) - : await buildEasContext(cwd, options) - const outputPath = resolveFromCwd(cwd, options.outputPath) - - await ensureDirectory(path.dirname(outputPath)) - await writeFile( - outputPath, - `${JSON.stringify(removeUndefinedValues(context) ?? {}, null, 2)}\n`, - 'utf8' - ) - - console.log(`Wrote ${outputPath}`) + return buildEasContext(cwd, options) } diff --git a/packages/cali/src/runtime/context.ts b/packages/cali/src/runtime/context.ts index 2777712..fe8b1b4 100644 --- a/packages/cali/src/runtime/context.ts +++ b/packages/cali/src/runtime/context.ts @@ -204,12 +204,14 @@ export async function resolveCommandContext( commandId: CommandId, cwd: string, config: CommandResolvedConfig, - cli: CommandCliOptions + cli: CommandCliOptions, + injectedContext: Partial = {} ): Promise { const fileContext = await loadContextFile(cwd, cli.contextPath ?? config.contextPath) const repositoryInfo = await detectRepositoryContext(cwd) const workspaceRoot = cli.workspaceRoot ?? + injectedContext.workspaceRoot ?? fileContext.workspaceRoot ?? config.workspaceRoot ?? repositoryInfo.workspaceRoot @@ -220,7 +222,7 @@ export async function resolveCommandContext( repository: repositoryInfo.repository, output: {}, }, - mergeContext(fileContext, buildCliContext(cli)) + mergeContext(mergeContext(fileContext, injectedContext), buildCliContext(cli)) ) return applyDefaults(commandId, merged, workspaceRoot, config, cli) diff --git a/packages/cali/src/runtime/mobile.ts b/packages/cali/src/runtime/mobile.ts index 0b1b6a9..f4d5331 100644 --- a/packages/cali/src/runtime/mobile.ts +++ b/packages/cali/src/runtime/mobile.ts @@ -20,6 +20,8 @@ const utf16Decoder = new TextDecoder('utf-16le') let aaptPathCache: string | null | undefined +const AGENT_DEVICE_STATE_FILES = ['daemon.json', 'daemon.lock', 'daemon.sock', 'daemon.sock.lock'] + function buildDeviceSelectorArgs(context: { platform: CaliPlatform; deviceName?: string }) { const args = ['--platform', context.platform] @@ -81,6 +83,58 @@ async function readCommandStdout(file: string, args: string[]) { return value.length > 0 ? value : undefined } +function looksLikeStaleAgentDeviceState(output: string) { + const normalized = output.toLowerCase() + return ( + normalized.includes('stale metadata') || + normalized.includes('daemon.json') || + normalized.includes('daemon.lock') || + normalized.includes('stale daemon') || + normalized.includes('socket') || + normalized.includes('econnrefused') + ) +} + +async function resetAgentDeviceState() { + const stateDir = path.join(homedir(), '.agent-device') + await Promise.all( + AGENT_DEVICE_STATE_FILES.map((fileName) => + rm(path.join(stateDir, fileName), { force: true, recursive: true }) + ) + ) +} + +async function ensureAgentDeviceHealthy(platform: CaliPlatform) { + const firstAttempt = await runAgentDeviceCommand('devices', ['--platform', platform], { + allowFailure: true, + }) + + if (firstAttempt.ok) { + return + } + + const firstFailureOutput = [firstAttempt.stderr, firstAttempt.stdout].filter(Boolean).join('\n') + if (!looksLikeStaleAgentDeviceState(firstFailureOutput)) { + return + } + + await resetAgentDeviceState() + + const retryAttempt = await runAgentDeviceCommand('devices', ['--platform', platform], { + allowFailure: true, + }) + if (retryAttempt.ok) { + return + } + + throw new Error( + [ + 'agent-device preflight failed after resetting stale daemon state.', + summarizeCommandFailure(retryAttempt), + ].join('\n\n') + ) +} + async function readZipEntry(archivePath: string, entry: string) { const result = await runCommand('unzip', ['-p', archivePath, entry], { allowFailure: true, @@ -93,6 +147,14 @@ async function readZipEntry(archivePath: string, entry: string) { return result.stdoutBuffer } +async function ensureArtifactExists(artifactPath: string) { + try { + await access(artifactPath) + } catch { + throw new Error(`Mobile artifact does not exist: ${artifactPath}`) + } +} + function parseTextManifestPackageName(text: string) { const match = text.match(/]*\bpackage\s*=\s*["']([^"']+)["']/i) return match?.[1] @@ -343,6 +405,57 @@ async function inferIosAppId(artifactPath: string) { ]) } +async function findAppBundle(directory: string): Promise { + const entries = await readdir(directory, { withFileTypes: true }) + + for (const entry of entries) { + const absolutePath = path.join(directory, entry.name) + if (entry.isDirectory() && entry.name.endsWith('.app')) { + return absolutePath + } + } + + for (const entry of entries) { + if (!entry.isDirectory()) { + continue + } + + const nestedPath = await findAppBundle(path.join(directory, entry.name)) + if (nestedPath) { + return nestedPath + } + } + + return undefined +} + +async function normalizeIosArtifact(artifactPath: string, outputDir: string): Promise { + if (!artifactPath.endsWith('.app.tar.gz')) { + return artifactPath + } + + const extractionDir = path.join(outputDir, '_extracted_app') + await rm(extractionDir, { recursive: true, force: true }) + await ensureDirectory(extractionDir) + + const extractResult = await runCommand('tar', ['-xzf', artifactPath, '-C', extractionDir], { + allowFailure: true, + }) + assertCommandSuccess( + extractResult, + `Failed to extract iOS artifact ${path.basename(artifactPath)}.` + ) + + const appBundlePath = await findAppBundle(extractionDir) + if (!appBundlePath) { + throw new Error( + `Failed to locate a .app bundle after extracting ${path.basename(artifactPath)}.` + ) + } + + return appBundlePath +} + async function inferMobileAppId(platform: CaliPlatform, artifactPath: string) { if (platform === 'android') { return inferAndroidAppId(artifactPath) @@ -519,11 +632,17 @@ export async function resolveMobileRuntimeContext( throw new Error(`${commandId} requires an output directory.`) } - const inferredAppId = await inferMobileAppId(platform, artifactPath) + await ensureAgentDeviceHealthy(platform) + await ensureArtifactExists(artifactPath) + + const normalizedArtifactPath = + platform === 'ios' ? await normalizeIosArtifact(artifactPath, outputDir) : artifactPath + + const inferredAppId = await inferMobileAppId(platform, normalizedArtifactPath) const appId = context.mobile?.appId ?? inferredAppId if (!appId) { throw new Error( - `${commandId} requires an app id in context.mobile.appId or --app-id. Cali could not infer it from ${path.basename(artifactPath)}.` + `${commandId} requires an app id in context.mobile.appId or --app-id. Cali could not infer it from ${path.basename(normalizedArtifactPath)}.` ) } @@ -536,7 +655,7 @@ export async function resolveMobileRuntimeContext( return { platform, - artifactPath, + artifactPath: normalizedArtifactPath, appId, deviceName, outputDir, diff --git a/packages/cali/src/runtime/types.ts b/packages/cali/src/runtime/types.ts index 8e251b1..ec944ba 100644 --- a/packages/cali/src/runtime/types.ts +++ b/packages/cali/src/runtime/types.ts @@ -1,4 +1,5 @@ import type { CaliEnvName, PublisherName, ToolPackName } from '../config/schema.js' +import type { CiProvider } from './ci-context.js' export type { CommandId } from '../config/schema.js' export type CommandConfigKey = 'qa' | 'review' | 'perfReview' | 'dev' @@ -98,6 +99,7 @@ export type MobileCommandRuntimeContext = { } export type CommandCliOptions = { + ciProvider?: CiProvider envName?: CaliEnvName configPath?: string prompt?: string diff --git a/skills/cali/references/running-cali.md b/skills/cali/references/running-cali.md index 9c3a47b..60aa8d4 100644 --- a/skills/cali/references/running-cali.md +++ b/skills/cali/references/running-cali.md @@ -56,18 +56,25 @@ node packages/cali/dist/index.js qa \ # CI-style QA node packages/cali/dist/index.js qa \ - --env mobile-pr \ - --context ./cali-context.json + --ci github-actions \ + --platform ios \ + --artifact ./artifacts/MyApp.app -# Generate CI context -node packages/cali/dist/index.js write-mobile-pr-context \ - --from eas \ - --output ./cali-context.json +# EAS-style QA +node packages/cali/dist/index.js qa \ + --ci eas \ + --platform android \ + --artifact ./artifacts/app.apk # Render a compact GitHub comment node packages/cali/dist/index.js render-comment \ --report ./artifacts/qa/report.json \ --format github + +# Export EAS helper files +node packages/cali/dist/index.js export-ci \ + --target eas \ + --report ./artifacts/qa/report.json ``` ## Provider setup @@ -90,9 +97,12 @@ QA_MODEL=anthropic/claude-sonnet-4.6 ## CI notes -- Generate `cali-context.json` before invoking Cali. -- Do not assume Cali will scrape PR/build metadata from the environment at runtime. -- Prefer the built-in `write-mobile-pr-context` command over custom `jq` wrappers. +- For CI, prefer `cali qa --ci github-actions` or `cali qa --ci eas`. +- Cali derives runtime context from provider env plus CLI overrides before the agent starts. +- Use the explicit helper commands for integration glue: + - `render-comment` + - `export-ci` + - `publish-comment` - For copy-pasteable CI examples, use: - - [`packages/cali/examples/github-actions/write-mobile-pr-context.sh`](../../../packages/cali/examples/github-actions/write-mobile-pr-context.sh) - - [`packages/cali/examples/eas-workflows/write-mobile-pr-context.sh`](../../../packages/cali/examples/eas-workflows/write-mobile-pr-context.sh) + - [`packages/cali/examples/github-actions/run-qa.sh`](../../../packages/cali/examples/github-actions/run-qa.sh) + - [`packages/cali/examples/eas-workflows/run-qa.sh`](../../../packages/cali/examples/eas-workflows/run-qa.sh) From 4672affc23ef189885e72eddb3310cf2abb911d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 10 Apr 2026 12:13:48 +0200 Subject: [PATCH 33/48] chore: drop cali github publish command --- AGENTS.md | 1 - packages/cali/README.md | 10 +- packages/cali/package.json | 3 +- packages/cali/src/cli/app.ts | 2 - packages/cali/src/cli/publish-comment.ts | 82 -------- packages/cali/src/commands/publish-comment.ts | 198 ------------------ skills/cali/references/running-cali.md | 1 - 7 files changed, 7 insertions(+), 290 deletions(-) delete mode 100644 packages/cali/src/cli/publish-comment.ts delete mode 100644 packages/cali/src/commands/publish-comment.ts diff --git a/AGENTS.md b/AGENTS.md index 779a997..6734b35 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -181,7 +181,6 @@ Built bundle: - `bun run qa:ci:eas -- --platform ios --artifact ./artifacts/MyApp.app` - `bun run render-comment -- --report ./artifacts/qa/report.json` - `bun run export-ci:eas -- --report ./artifacts/qa/report.json` -- `bun run publish-comment -- --report ./artifacts/qa/report.json` Source/dev loop: diff --git a/packages/cali/README.md b/packages/cali/README.md index 9e47e68..ffc55df 100644 --- a/packages/cali/README.md +++ b/packages/cali/README.md @@ -268,7 +268,6 @@ Optional helpers: cali export-ci --target eas --report ./artifacts/qa/report.json cali render-comment --report ./artifacts/qa/report.json --format github cali render-comment --format github-multi-platform --android ./artifacts/android/report.json --ios ./artifacts/ios/report.json -cali publish-comment --report ./artifacts/qa/report.json ``` ### GitHub Actions @@ -288,7 +287,7 @@ Minimal GitHub Actions example: run: node ./packages/cali/dist/index.js qa --ci github-actions --quiet - name: Publish PR comment - run: node ./packages/cali/dist/index.js publish-comment --report ./artifacts/qa/report.json + run: gh pr comment "${{ github.event.pull_request.number }}" --body-file ./artifacts/qa/comment-github.md ``` Reference wrapper: @@ -330,7 +329,11 @@ cali render-comment \ --output ./artifacts/comment.md ``` -`publish-comment` uses the GitHub CLI, so make sure `gh` is available and authenticated in the job where you call it. +If you want Cali to stay GitHub-agnostic, keep posting outside Cali and use the rendered output directly: + +```bash +gh pr comment "$PR_NUMBER" --body-file ./artifacts/qa/comment-github.md +``` ## Config @@ -392,7 +395,6 @@ Built bundle: - `bun run dev:command:env:mobile-pr -- --context ./cali-context.json` - `bun run render-comment -- --report ./artifacts/qa/report.json` - `bun run export-ci:eas -- --report ./artifacts/qa/report.json` -- `bun run publish-comment -- --report ./artifacts/qa/report.json` Source/dev loop: diff --git a/packages/cali/package.json b/packages/cali/package.json index cd74d6d..6fa1977 100644 --- a/packages/cali/package.json +++ b/packages/cali/package.json @@ -30,8 +30,7 @@ "dev:perf-review": "node --import=tsx ./src/cli.ts perf-review", "dev:dev-command": "node --import=tsx ./src/cli.ts dev", "render-comment": "node ./dist/index.js render-comment --format github", - "export-ci:eas": "node ./dist/index.js export-ci --target eas", - "publish-comment": "node ./dist/index.js publish-comment --format github" + "export-ci:eas": "node ./dist/index.js export-ci --target eas" }, "dependencies": { "@ai-sdk/anthropic": "^3.0.64", diff --git a/packages/cali/src/cli/app.ts b/packages/cali/src/cli/app.ts index 28294ac..77b9751 100644 --- a/packages/cali/src/cli/app.ts +++ b/packages/cali/src/cli/app.ts @@ -5,7 +5,6 @@ import { printRetroBanner } from './banner.js' import { devCommandDefinition } from './dev.js' import { exportCiCommandDefinition } from './export-ci.js' import { perfReviewCommandDefinition } from './perf-review.js' -import { publishCommentCommandDefinition } from './publish-comment.js' import { qaCommandDefinition } from './qa.js' import { renderCommentCommandDefinition } from './render-comment.js' import { reviewCommandDefinition } from './review.js' @@ -36,7 +35,6 @@ function createCli() { devCommandDefinition, renderCommentCommandDefinition, exportCiCommandDefinition, - publishCommentCommandDefinition, ]) { commandDefinition.register(cli) } diff --git a/packages/cali/src/cli/publish-comment.ts b/packages/cali/src/cli/publish-comment.ts deleted file mode 100644 index 84d94dc..0000000 --- a/packages/cali/src/cli/publish-comment.ts +++ /dev/null @@ -1,82 +0,0 @@ -import type { CAC } from 'cac' - -import { publishComment } from '../commands/publish-comment.js' -import { readOptionalNumber, readOptionalString } from './shared.js' - -type PublishCommentCliOptions = { - format?: string - report?: string - android?: string - ios?: string - body?: string - repo?: string - prNumber?: string | number - marker?: string -} - -function normalizeFormat(value: unknown) { - if (!value || value === 'github') { - return 'github' as const - } - - if (value === 'github-multi-platform') { - return 'github-multi-platform' as const - } - - throw new Error('`--format` must be `github` or `github-multi-platform`.') -} - -export const publishCommentCommandDefinition = { - register(cli: CAC) { - cli - .command('publish-comment', 'Create or update a GitHub PR comment from Cali output') - .option('--format ', 'Comment format', { - default: 'github', - }) - .option('--report ', 'Path to report.json') - .option('--android ', 'Android report.json path for multi-platform rendering') - .option('--ios ', 'iOS report.json path for multi-platform rendering') - .option('--body ', 'Path to a pre-rendered markdown comment body') - .option('--repo ', 'GitHub repository override') - .option('--pr-number ', 'Pull request number override') - .option('--marker ', 'Stable marker used to create or update the same comment') - .example('publish-comment --report ./artifacts/qa/report.json') - .example( - 'publish-comment --format github-multi-platform --android ./artifacts/android/report.json --ios ./artifacts/ios/report.json' - ) - .action(async (options: unknown) => { - const normalized = options as PublishCommentCliOptions - const format = normalizeFormat(normalized.format) - const bodyPath = readOptionalString(normalized.body) - const reportPath = readOptionalString(normalized.report) - const androidReportPath = readOptionalString(normalized.android) - const iosReportPath = readOptionalString(normalized.ios) - - if (!bodyPath && format === 'github' && !reportPath) { - throw new Error('`publish-comment` requires `--report ` or `--body `.') - } - - if ( - !bodyPath && - format === 'github-multi-platform' && - !androidReportPath && - !iosReportPath - ) { - throw new Error( - '`publish-comment --format github-multi-platform` requires `--android `, `--ios `, or `--body `.' - ) - } - - await publishComment({ - format, - reportPath, - androidReportPath, - iosReportPath, - bodyPath, - repo: readOptionalString(normalized.repo), - prNumber: readOptionalNumber(normalized.prNumber, '--pr-number'), - marker: readOptionalString(normalized.marker), - }) - }) - }, -} diff --git a/packages/cali/src/commands/publish-comment.ts b/packages/cali/src/commands/publish-comment.ts deleted file mode 100644 index 2df680c..0000000 --- a/packages/cali/src/commands/publish-comment.ts +++ /dev/null @@ -1,198 +0,0 @@ -import { readFile } from 'node:fs/promises' - -import { renderGithubComment, renderGithubMultiPlatformComment } from '../report/ci.js' -import type { CommandReport } from '../report/types.js' -import { ensureCommandExists, parseJson, runCommand, trimText } from '../utils.js' - -const DEFAULT_COMMENT_MARKER = '' - -export type PublishCommentOptions = { - format: 'github' | 'github-multi-platform' - reportPath?: string - androidReportPath?: string - iosReportPath?: string - bodyPath?: string - repo?: string - prNumber?: number - marker?: string -} - -type GithubIssueComment = { - id: number - body?: string -} - -type GithubTargetHint = { - repo?: string - prNumber?: number -} - -function readOptionalEnv(name: string) { - const value = process.env[name] - return value && value.length > 0 ? value : undefined -} - -async function loadReport(reportPath: string) { - const content = await readFile(reportPath, 'utf8') - return JSON.parse(content) as CommandReport -} - -function extractGithubTargetHint(report?: CommandReport): GithubTargetHint { - if (!report) { - return {} - } - - const owner = report.context.repository?.owner - const name = report.context.repository?.name - - return { - repo: owner && name ? `${owner}/${name}` : undefined, - prNumber: report.context.pullRequest?.number, - } -} - -async function resolveCommentBody(options: PublishCommentOptions) { - if (options.bodyPath) { - return readFile(options.bodyPath, 'utf8') - } - - if (options.format === 'github-multi-platform') { - const [android, ios] = await Promise.all([ - options.androidReportPath - ? loadReport(options.androidReportPath) - : Promise.resolve(undefined), - options.iosReportPath ? loadReport(options.iosReportPath) : Promise.resolve(undefined), - ]) - - return renderGithubMultiPlatformComment({ android, ios }) - } - - const report = await loadReport(options.reportPath!) - return renderGithubComment(report) -} - -async function detectPrNumberFromGithubActions() { - const eventPath = readOptionalEnv('GITHUB_EVENT_PATH') - if (!eventPath) { - return undefined - } - - const content = await readFile(eventPath, 'utf8') - const event = parseJson<{ pull_request?: { number?: number } }>(content, {}) - return event.pull_request?.number -} - -async function loadGithubTargetHint(options: PublishCommentOptions): Promise { - const reportPaths = - options.format === 'github-multi-platform' - ? [options.androidReportPath, options.iosReportPath].filter(Boolean) - : [options.reportPath].filter(Boolean) - - for (const reportPath of reportPaths) { - if (!reportPath) { - continue - } - - const report = await loadReport(reportPath) - const hint = extractGithubTargetHint(report) - if (hint.repo || hint.prNumber) { - return hint - } - } - - return {} -} - -async function resolveGithubTarget(options: PublishCommentOptions) { - const reportHint = await loadGithubTargetHint(options) - const repo = options.repo ?? reportHint.repo ?? readOptionalEnv('GITHUB_REPOSITORY') - const prNumber = - options.prNumber ?? reportHint.prNumber ?? (await detectPrNumberFromGithubActions()) - - if (!repo) { - throw new Error( - 'GitHub comment publishing requires `--repo `, report repository context, or GITHUB_REPOSITORY.' - ) - } - - if (!prNumber) { - throw new Error('GitHub comment publishing requires `--pr-number ` or pull request context.') - } - - return { - repo, - prNumber, - } -} - -async function listIssueComments(repo: string, prNumber: number) { - const result = await runCommand( - 'gh', - ['api', `repos/${repo}/issues/${prNumber}/comments`, '--paginate'], - { allowFailure: true } - ) - - if (!result.ok) { - throw new Error(trimText(result.stderr || result.stdout)) - } - - return parseJson(result.stdout, []) -} - -async function createIssueComment(repo: string, prNumber: number, body: string) { - const result = await runCommand( - 'gh', - [ - 'api', - `repos/${repo}/issues/${prNumber}/comments`, - '--method', - 'POST', - '--field', - `body=${body}`, - ], - { allowFailure: true } - ) - - if (!result.ok) { - throw new Error(trimText(result.stderr || result.stdout)) - } -} - -async function updateIssueComment(repo: string, commentId: number, body: string) { - const result = await runCommand( - 'gh', - [ - 'api', - `repos/${repo}/issues/comments/${commentId}`, - '--method', - 'PATCH', - '--field', - `body=${body}`, - ], - { allowFailure: true } - ) - - if (!result.ok) { - throw new Error(trimText(result.stderr || result.stdout)) - } -} - -export async function publishComment(options: PublishCommentOptions) { - await ensureCommandExists('gh', 'Install GitHub CLI and make sure `gh` is authenticated.') - - const { repo, prNumber } = await resolveGithubTarget(options) - const body = await resolveCommentBody(options) - const marker = options.marker ?? DEFAULT_COMMENT_MARKER - const finalBody = `${marker}\n${body}` - const comments = await listIssueComments(repo, prNumber) - const existingComment = comments.find((comment) => comment.body?.includes(marker)) - - if (existingComment) { - await updateIssueComment(repo, existingComment.id, finalBody) - console.log(`Updated GitHub PR comment on ${repo}#${prNumber}`) - return - } - - await createIssueComment(repo, prNumber, finalBody) - console.log(`Created GitHub PR comment on ${repo}#${prNumber}`) -} diff --git a/skills/cali/references/running-cali.md b/skills/cali/references/running-cali.md index 60aa8d4..2af9ca1 100644 --- a/skills/cali/references/running-cali.md +++ b/skills/cali/references/running-cali.md @@ -102,7 +102,6 @@ QA_MODEL=anthropic/claude-sonnet-4.6 - Use the explicit helper commands for integration glue: - `render-comment` - `export-ci` - - `publish-comment` - For copy-pasteable CI examples, use: - [`packages/cali/examples/github-actions/run-qa.sh`](../../../packages/cali/examples/github-actions/run-qa.sh) - [`packages/cali/examples/eas-workflows/run-qa.sh`](../../../packages/cali/examples/eas-workflows/run-qa.sh) From 3b318dfa25803b33fba033c81bfd67fd92de5390 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 10 Apr 2026 12:18:26 +0200 Subject: [PATCH 34/48] docs: clarify github actions gh usage --- packages/cali/README.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/cali/README.md b/packages/cali/README.md index ffc55df..91217a8 100644 --- a/packages/cali/README.md +++ b/packages/cali/README.md @@ -287,9 +287,13 @@ Minimal GitHub Actions example: run: node ./packages/cali/dist/index.js qa --ci github-actions --quiet - name: Publish PR comment + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: gh pr comment "${{ github.event.pull_request.number }}" --body-file ./artifacts/qa/comment-github.md ``` +`gh` is preinstalled on GitHub-hosted runners. For self-hosted runners or container jobs, install it explicitly and provide `GH_TOKEN`. + Reference wrapper: - [`packages/cali/examples/github-actions/run-qa.sh`](./examples/github-actions/run-qa.sh) @@ -332,6 +336,7 @@ cali render-comment \ If you want Cali to stay GitHub-agnostic, keep posting outside Cali and use the rendered output directly: ```bash +export GH_TOKEN="${GITHUB_TOKEN}" gh pr comment "$PR_NUMBER" --body-file ./artifacts/qa/comment-github.md ``` From 1008677a98384aa312e9580a54100d99adc2b32d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 10 Apr 2026 12:28:42 +0200 Subject: [PATCH 35/48] refactor: shrink cali export-ci outputs --- packages/cali/README.md | 15 ++++++++++++++ packages/cali/src/commands/export-ci.ts | 27 +++++++++++++++++-------- 2 files changed, 34 insertions(+), 8 deletions(-) diff --git a/packages/cali/README.md b/packages/cali/README.md index 91217a8..6b6d556 100644 --- a/packages/cali/README.md +++ b/packages/cali/README.md @@ -427,6 +427,21 @@ The default output directory is `artifacts/`. For `qa`, Cali writes this output contract even for blocked runs during CI/bootstrap startup, as long as the output directory itself is writable. +`export-ci --target eas` writes a smaller provider helper contract: + +- `eas-status.txt` +- `eas-section-body.md` +- `eas-output.json` + +`eas-output.json` combines: + +- `status` +- `statusLabel` +- `summary` +- `topIssue` +- `screenshotsCell` +- `screenshots` + For `qa` and `perf-review`, screenshots are saved under `artifacts//screenshots`. If `BLOB_READ_WRITE_TOKEN` is set, the blob publisher uploads screenshots and enriches the report with blob URLs. diff --git a/packages/cali/src/commands/export-ci.ts b/packages/cali/src/commands/export-ci.ts index 61cf83a..7992da8 100644 --- a/packages/cali/src/commands/export-ci.ts +++ b/packages/cali/src/commands/export-ci.ts @@ -12,6 +12,15 @@ export type ExportCiOptions = { outputDir?: string } +type EasExportSummary = { + status: CommandReport['overallStatus'] + statusLabel: string + summary: string + topIssue: string + screenshotsCell: string + screenshots: ReturnType +} + export async function exportCi(options: ExportCiOptions) { const cwd = process.cwd() const reportPath = resolveFromCwd(cwd, options.reportPath) @@ -23,18 +32,20 @@ export async function exportCi(options: ExportCiOptions) { const topIssue = getTopIssue(report) ?? (report.overallStatus === 'passed' ? 'N/A' : report.summary) + const summary: EasExportSummary = { + status: report.overallStatus, + statusLabel: report.overallStatus, + summary: report.summary, + topIssue, + screenshotsCell: renderScreenshotsCell(report), + screenshots: buildScreenshotsMetadata(report), + } await writeFile(path.join(outputDir, 'eas-status.txt'), `${report.overallStatus}\n`, 'utf8') - await writeFile(path.join(outputDir, 'eas-top-issue.txt'), `${topIssue}\n`, 'utf8') await writeFile(path.join(outputDir, 'eas-section-body.md'), renderCommandSection(report), 'utf8') await writeFile( - path.join(outputDir, 'eas-screenshots-cell.md'), - `${renderScreenshotsCell(report)}\n`, - 'utf8' - ) - await writeFile( - path.join(outputDir, 'eas-screenshots.json'), - `${JSON.stringify(buildScreenshotsMetadata(report), null, 2)}\n`, + path.join(outputDir, 'eas-output.json'), + `${JSON.stringify(summary, null, 2)}\n`, 'utf8' ) From 116574389ca1b8d0ba7d5ce34447447a330d90a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 10 Apr 2026 12:32:04 +0200 Subject: [PATCH 36/48] refactor: generalize cali export-ci contract --- AGENTS.md | 2 +- packages/cali/README.md | 16 ++++++++-------- packages/cali/package.json | 2 +- packages/cali/src/cli/export-ci.ts | 17 ++--------------- packages/cali/src/commands/export-ci.ts | 13 ++++++------- skills/cali/references/running-cali.md | 1 - 6 files changed, 18 insertions(+), 33 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 6734b35..3e296b9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -180,7 +180,7 @@ Built bundle: - `bun run qa:ci:gha -- --platform android --artifact ./artifacts/app.apk` - `bun run qa:ci:eas -- --platform ios --artifact ./artifacts/MyApp.app` - `bun run render-comment -- --report ./artifacts/qa/report.json` -- `bun run export-ci:eas -- --report ./artifacts/qa/report.json` +- `bun run export-ci -- --report ./artifacts/qa/report.json` Source/dev loop: diff --git a/packages/cali/README.md b/packages/cali/README.md index 6b6d556..0f79481 100644 --- a/packages/cali/README.md +++ b/packages/cali/README.md @@ -265,7 +265,7 @@ cali qa --ci eas --quiet --platform android --artifact ./artifacts/app.apk Optional helpers: ```bash -cali export-ci --target eas --report ./artifacts/qa/report.json +cali export-ci --report ./artifacts/qa/report.json cali render-comment --report ./artifacts/qa/report.json --format github cali render-comment --format github-multi-platform --android ./artifacts/android/report.json --ios ./artifacts/ios/report.json ``` @@ -317,7 +317,7 @@ Minimal EAS example: run: node ./packages/cali/dist/index.js qa --ci eas --quiet - id: export_cali_ci - run: node ./packages/cali/dist/index.js export-ci --target eas --report ./artifacts/qa/report.json + run: node ./packages/cali/dist/index.js export-ci --report ./artifacts/qa/report.json ``` Reference wrapper: @@ -399,7 +399,7 @@ Built bundle: - `bun run perf-review:env:mobile-pr -- --context ./cali-context.json` - `bun run dev:command:env:mobile-pr -- --context ./cali-context.json` - `bun run render-comment -- --report ./artifacts/qa/report.json` -- `bun run export-ci:eas -- --report ./artifacts/qa/report.json` +- `bun run export-ci -- --report ./artifacts/qa/report.json` Source/dev loop: @@ -427,13 +427,13 @@ The default output directory is `artifacts/`. For `qa`, Cali writes this output contract even for blocked runs during CI/bootstrap startup, as long as the output directory itself is writable. -`export-ci --target eas` writes a smaller provider helper contract: +`export-ci` writes a smaller shared CI helper contract: -- `eas-status.txt` -- `eas-section-body.md` -- `eas-output.json` +- `ci-status.txt` +- `ci-section-body.md` +- `ci-output.json` -`eas-output.json` combines: +`ci-output.json` combines: - `status` - `statusLabel` diff --git a/packages/cali/package.json b/packages/cali/package.json index 6fa1977..d6a2b39 100644 --- a/packages/cali/package.json +++ b/packages/cali/package.json @@ -30,7 +30,7 @@ "dev:perf-review": "node --import=tsx ./src/cli.ts perf-review", "dev:dev-command": "node --import=tsx ./src/cli.ts dev", "render-comment": "node ./dist/index.js render-comment --format github", - "export-ci:eas": "node ./dist/index.js export-ci --target eas" + "export-ci": "node ./dist/index.js export-ci" }, "dependencies": { "@ai-sdk/anthropic": "^3.0.64", diff --git a/packages/cali/src/cli/export-ci.ts b/packages/cali/src/cli/export-ci.ts index 258b089..97226b7 100644 --- a/packages/cali/src/cli/export-ci.ts +++ b/packages/cali/src/cli/export-ci.ts @@ -4,29 +4,17 @@ import { exportCi } from '../commands/export-ci.js' import { readOptionalString } from './shared.js' type ExportCiCliOptions = { - target?: string report?: string outputDir?: string } -function normalizeTarget(value: unknown) { - if (value === 'eas') { - return 'eas' as const - } - - throw new Error('`--target` must be `eas`.') -} - export const exportCiCommandDefinition = { register(cli: CAC) { cli - .command('export-ci', 'Export provider-specific CI helper files from a Cali report') - .option('--target ', 'CI target', { - default: 'eas', - }) + .command('export-ci', 'Export shared CI helper files from a Cali report') .option('--report ', 'Path to report.json') .option('--output-dir ', 'Output directory for exported helper files') - .example('export-ci --target eas --report ./artifacts/qa/report.json') + .example('export-ci --report ./artifacts/qa/report.json') .action(async (options: unknown) => { const normalized = options as ExportCiCliOptions const reportPath = readOptionalString(normalized.report) @@ -35,7 +23,6 @@ export const exportCiCommandDefinition = { } await exportCi({ - target: normalizeTarget(normalized.target ?? 'eas'), reportPath, outputDir: readOptionalString(normalized.outputDir), }) diff --git a/packages/cali/src/commands/export-ci.ts b/packages/cali/src/commands/export-ci.ts index 7992da8..4aeb19e 100644 --- a/packages/cali/src/commands/export-ci.ts +++ b/packages/cali/src/commands/export-ci.ts @@ -7,12 +7,11 @@ import type { CommandReport } from '../report/types.js' import { ensureDirectory, resolveFromCwd } from '../utils.js' export type ExportCiOptions = { - target: 'eas' reportPath: string outputDir?: string } -type EasExportSummary = { +type CiExportSummary = { status: CommandReport['overallStatus'] statusLabel: string summary: string @@ -32,7 +31,7 @@ export async function exportCi(options: ExportCiOptions) { const topIssue = getTopIssue(report) ?? (report.overallStatus === 'passed' ? 'N/A' : report.summary) - const summary: EasExportSummary = { + const summary: CiExportSummary = { status: report.overallStatus, statusLabel: report.overallStatus, summary: report.summary, @@ -41,13 +40,13 @@ export async function exportCi(options: ExportCiOptions) { screenshots: buildScreenshotsMetadata(report), } - await writeFile(path.join(outputDir, 'eas-status.txt'), `${report.overallStatus}\n`, 'utf8') - await writeFile(path.join(outputDir, 'eas-section-body.md'), renderCommandSection(report), 'utf8') + await writeFile(path.join(outputDir, 'ci-status.txt'), `${report.overallStatus}\n`, 'utf8') + await writeFile(path.join(outputDir, 'ci-section-body.md'), renderCommandSection(report), 'utf8') await writeFile( - path.join(outputDir, 'eas-output.json'), + path.join(outputDir, 'ci-output.json'), `${JSON.stringify(summary, null, 2)}\n`, 'utf8' ) - console.log(`Exported ${options.target} CI helpers to ${outputDir}`) + console.log(`Exported CI helpers to ${outputDir}`) } diff --git a/skills/cali/references/running-cali.md b/skills/cali/references/running-cali.md index 2af9ca1..364b774 100644 --- a/skills/cali/references/running-cali.md +++ b/skills/cali/references/running-cali.md @@ -73,7 +73,6 @@ node packages/cali/dist/index.js render-comment \ # Export EAS helper files node packages/cali/dist/index.js export-ci \ - --target eas \ --report ./artifacts/qa/report.json ``` From 4e7859ecc56906b8d8578decc3f1925dde2ffc91 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 10 Apr 2026 12:50:04 +0200 Subject: [PATCH 37/48] refactor: fold cali ci rendering into export-ci --- AGENTS.md | 1 - packages/cali/README.md | 37 ++-- packages/cali/package.json | 1 - packages/cali/src/cli/app.ts | 2 - packages/cali/src/cli/export-ci.ts | 18 +- packages/cali/src/cli/render-comment.ts | 67 ------- packages/cali/src/commands/export-ci.ts | 193 +++++++++++++++++-- packages/cali/src/commands/render-comment.ts | 54 ------ packages/cali/src/report/ci.ts | 11 +- skills/cali/references/running-cali.md | 13 +- 10 files changed, 224 insertions(+), 173 deletions(-) delete mode 100644 packages/cali/src/cli/render-comment.ts delete mode 100644 packages/cali/src/commands/render-comment.ts diff --git a/AGENTS.md b/AGENTS.md index 3e296b9..fde9a1f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -179,7 +179,6 @@ Built bundle: - `bun run dev:command -- --help` - `bun run qa:ci:gha -- --platform android --artifact ./artifacts/app.apk` - `bun run qa:ci:eas -- --platform ios --artifact ./artifacts/MyApp.app` -- `bun run render-comment -- --report ./artifacts/qa/report.json` - `bun run export-ci -- --report ./artifacts/qa/report.json` Source/dev loop: diff --git a/packages/cali/README.md b/packages/cali/README.md index 0f79481..c0af7ef 100644 --- a/packages/cali/README.md +++ b/packages/cali/README.md @@ -266,8 +266,7 @@ Optional helpers: ```bash cali export-ci --report ./artifacts/qa/report.json -cali render-comment --report ./artifacts/qa/report.json --format github -cali render-comment --format github-multi-platform --android ./artifacts/android/report.json --ios ./artifacts/ios/report.json +cali export-ci --android ./artifacts/android/report.json --ios ./artifacts/ios/report.json ``` ### GitHub Actions @@ -286,10 +285,13 @@ Minimal GitHub Actions example: CALI_APP_ID: com.example.myapp run: node ./packages/cali/dist/index.js qa --ci github-actions --quiet +- name: Export CI comment + run: node ./packages/cali/dist/index.js export-ci --report ./artifacts/qa/report.json + - name: Publish PR comment env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: gh pr comment "${{ github.event.pull_request.number }}" --body-file ./artifacts/qa/comment-github.md + run: gh pr comment "${{ github.event.pull_request.number }}" --body-file ./artifacts/qa/ci-comment.md ``` `gh` is preinstalled on GitHub-hosted runners. For self-hosted runners or container jobs, install it explicitly and provide `GH_TOKEN`. @@ -323,21 +325,20 @@ Minimal EAS example: Reference wrapper: - [`packages/cali/examples/eas-workflows/run-qa.sh`](./examples/eas-workflows/run-qa.sh) -For multi-platform PR comments, render once from both platform reports: +For multi-platform PR comments, export once from both platform reports: ```bash -cali render-comment \ - --format github-multi-platform \ +cali export-ci \ --android ./artifacts/android/report.json \ --ios ./artifacts/ios/report.json \ - --output ./artifacts/comment.md + --output-dir ./artifacts/combined-comment ``` If you want Cali to stay GitHub-agnostic, keep posting outside Cali and use the rendered output directly: ```bash export GH_TOKEN="${GITHUB_TOKEN}" -gh pr comment "$PR_NUMBER" --body-file ./artifacts/qa/comment-github.md +gh pr comment "$PR_NUMBER" --body-file ./artifacts/combined-comment/ci-comment.md ``` ## Config @@ -398,7 +399,6 @@ Built bundle: - `bun run review:env:mobile-pr -- --context ./cali-context.json` - `bun run perf-review:env:mobile-pr -- --context ./cali-context.json` - `bun run dev:command:env:mobile-pr -- --context ./cali-context.json` -- `bun run render-comment -- --report ./artifacts/qa/report.json` - `bun run export-ci -- --report ./artifacts/qa/report.json` Source/dev loop: @@ -427,21 +427,30 @@ The default output directory is `artifacts/`. For `qa`, Cali writes this output contract even for blocked runs during CI/bootstrap startup, as long as the output directory itself is writable. -`export-ci` writes a smaller shared CI helper contract: +`export-ci` writes a smaller shared CI contract: -- `ci-status.txt` -- `ci-section-body.md` +- `ci-comment.md` - `ci-output.json` -`ci-output.json` combines: +Single-platform `ci-output.json` combines: +- `kind` - `status` - `statusLabel` - `summary` - `topIssue` -- `screenshotsCell` - `screenshots` +Multi-platform `ci-output.json` combines: + +- `kind` +- `status` +- `statusLabel` +- `summary` +- `topIssue` +- `platforms.android` +- `platforms.ios` + For `qa` and `perf-review`, screenshots are saved under `artifacts//screenshots`. If `BLOB_READ_WRITE_TOKEN` is set, the blob publisher uploads screenshots and enriches the report with blob URLs. diff --git a/packages/cali/package.json b/packages/cali/package.json index d6a2b39..6ac7163 100644 --- a/packages/cali/package.json +++ b/packages/cali/package.json @@ -29,7 +29,6 @@ "dev:review": "node --import=tsx ./src/cli.ts review", "dev:perf-review": "node --import=tsx ./src/cli.ts perf-review", "dev:dev-command": "node --import=tsx ./src/cli.ts dev", - "render-comment": "node ./dist/index.js render-comment --format github", "export-ci": "node ./dist/index.js export-ci" }, "dependencies": { diff --git a/packages/cali/src/cli/app.ts b/packages/cali/src/cli/app.ts index 77b9751..1c1564c 100644 --- a/packages/cali/src/cli/app.ts +++ b/packages/cali/src/cli/app.ts @@ -6,7 +6,6 @@ import { devCommandDefinition } from './dev.js' import { exportCiCommandDefinition } from './export-ci.js' import { perfReviewCommandDefinition } from './perf-review.js' import { qaCommandDefinition } from './qa.js' -import { renderCommentCommandDefinition } from './render-comment.js' import { reviewCommandDefinition } from './review.js' function shouldPrintBanner(args: string[]) { @@ -33,7 +32,6 @@ function createCli() { reviewCommandDefinition, perfReviewCommandDefinition, devCommandDefinition, - renderCommentCommandDefinition, exportCiCommandDefinition, ]) { commandDefinition.register(cli) diff --git a/packages/cali/src/cli/export-ci.ts b/packages/cali/src/cli/export-ci.ts index 97226b7..6bc0b87 100644 --- a/packages/cali/src/cli/export-ci.ts +++ b/packages/cali/src/cli/export-ci.ts @@ -5,25 +5,33 @@ import { readOptionalString } from './shared.js' type ExportCiCliOptions = { report?: string + android?: string + ios?: string outputDir?: string } export const exportCiCommandDefinition = { register(cli: CAC) { cli - .command('export-ci', 'Export shared CI helper files from a Cali report') + .command('export-ci', 'Export shared CI outputs from one or more Cali reports') .option('--report ', 'Path to report.json') - .option('--output-dir ', 'Output directory for exported helper files') + .option('--android ', 'Path to Android report.json for a multi-platform export') + .option('--ios ', 'Path to iOS report.json for a multi-platform export') + .option('--output-dir ', 'Output directory for exported CI outputs') .example('export-ci --report ./artifacts/qa/report.json') + .example( + 'export-ci --android ./artifacts/android/report.json --ios ./artifacts/ios/report.json' + ) .action(async (options: unknown) => { const normalized = options as ExportCiCliOptions const reportPath = readOptionalString(normalized.report) - if (!reportPath) { - throw new Error('`export-ci` requires `--report `.') - } + const androidReportPath = readOptionalString(normalized.android) + const iosReportPath = readOptionalString(normalized.ios) await exportCi({ reportPath, + androidReportPath, + iosReportPath, outputDir: readOptionalString(normalized.outputDir), }) }) diff --git a/packages/cali/src/cli/render-comment.ts b/packages/cali/src/cli/render-comment.ts deleted file mode 100644 index 652c98c..0000000 --- a/packages/cali/src/cli/render-comment.ts +++ /dev/null @@ -1,67 +0,0 @@ -import type { CAC } from 'cac' - -import { renderComment } from '../commands/render-comment.js' -import { readOptionalString } from './shared.js' - -type RenderCommentCliOptions = { - report?: string - android?: string - ios?: string - format?: string - output?: string -} - -function normalizeFormat(value: unknown) { - if (!value || value === 'github') { - return 'github' as const - } - - if (value === 'github-multi-platform') { - return 'github-multi-platform' as const - } - - throw new Error('`--format` must be `github` or `github-multi-platform`.') -} - -export const renderCommentCommandDefinition = { - register(cli: CAC) { - cli - .command('render-comment', 'Render a compact comment from a Cali report') - .option('--report ', 'Path to report.json') - .option('--android ', 'Android report.json path for multi-platform rendering') - .option('--ios ', 'iOS report.json path for multi-platform rendering') - .option('--format ', 'Comment format', { - default: 'github', - }) - .option('--output ', 'Write the rendered comment to a file instead of stdout') - .example('render-comment --report ./artifacts/qa/report.json --format github') - .example( - 'render-comment --format github-multi-platform --android ./artifacts/android/report.json --ios ./artifacts/ios/report.json' - ) - .action(async (options: unknown) => { - const normalized = options as RenderCommentCliOptions - const format = normalizeFormat(normalized.format) - const reportPath = readOptionalString(normalized.report) - const androidReportPath = readOptionalString(normalized.android) - const iosReportPath = readOptionalString(normalized.ios) - - if (format === 'github' && !reportPath) { - throw new Error('`render-comment` requires `--report `.') - } - - if (format === 'github-multi-platform' && !androidReportPath && !iosReportPath) { - throw new Error( - '`render-comment --format github-multi-platform` requires `--android `, `--ios `, or both.' - ) - } - - await renderComment({ - reportPath, - androidReportPath, - iosReportPath, - format, - outputPath: readOptionalString(normalized.output), - }) - }) - }, -} diff --git a/packages/cali/src/commands/export-ci.ts b/packages/cali/src/commands/export-ci.ts index 4aeb19e..04e276b 100644 --- a/packages/cali/src/commands/export-ci.ts +++ b/packages/cali/src/commands/export-ci.ts @@ -1,52 +1,203 @@ import { readFile, writeFile } from 'node:fs/promises' import path from 'node:path' -import { buildScreenshotsMetadata, getTopIssue, renderScreenshotsCell } from '../report/ci.js' -import { renderCommandSection } from '../report/render.js' +import { + buildScreenshotsMetadata, + getTopIssue, + renderGithubComment, + renderGithubMultiPlatformComment, +} from '../report/ci.js' import type { CommandReport } from '../report/types.js' import { ensureDirectory, resolveFromCwd } from '../utils.js' export type ExportCiOptions = { - reportPath: string + reportPath?: string + androidReportPath?: string + iosReportPath?: string outputDir?: string } -type CiExportSummary = { +type SinglePlatformCiOutput = { + kind: 'single-platform' status: CommandReport['overallStatus'] statusLabel: string summary: string topIssue: string - screenshotsCell: string screenshots: ReturnType } +type PlatformCiOutput = { + status: CommandReport['overallStatus'] + statusLabel: string + summary: string + topIssue: string + screenshots: ReturnType +} + +type MultiPlatformCiOutput = { + kind: 'multi-platform' + status: CommandReport['overallStatus'] | 'mixed' + statusLabel: string + summary: string + topIssue: string + platforms: { + android?: PlatformCiOutput + ios?: PlatformCiOutput + } +} + export async function exportCi(options: ExportCiOptions) { const cwd = process.cwd() - const reportPath = resolveFromCwd(cwd, options.reportPath) - const content = await readFile(reportPath, 'utf8') - const report = JSON.parse(content) as CommandReport - const outputDir = resolveFromCwd(cwd, options.outputDir ?? path.dirname(reportPath)) + const reports = await loadReports(cwd, options) + const outputDir = resolveOutputDirectory(cwd, options) await ensureDirectory(outputDir) - const topIssue = - getTopIssue(report) ?? (report.overallStatus === 'passed' ? 'N/A' : report.summary) - const summary: CiExportSummary = { + const output = reports.report + ? createSinglePlatformOutput(reports.report) + : createMultiPlatformOutput(reports) + const comment = reports.report + ? renderGithubComment(reports.report) + : renderGithubMultiPlatformComment({ + android: reports.android, + ios: reports.ios, + }) + + await writeFile(path.join(outputDir, 'ci-comment.md'), comment, 'utf8') + await writeFile( + path.join(outputDir, 'ci-output.json'), + `${JSON.stringify(output, null, 2)}\n`, + 'utf8' + ) + + console.log(`Exported CI outputs to ${outputDir}`) +} + +async function loadReports(cwd: string, options: ExportCiOptions) { + if (options.reportPath) { + if (options.androidReportPath || options.iosReportPath) { + throw new Error( + '`export-ci` accepts either `--report ` or multi-platform `--android ` / `--ios `, not both.' + ) + } + + return { + report: await readReport(resolveFromCwd(cwd, options.reportPath)), + android: undefined, + ios: undefined, + } + } + + if (!options.androidReportPath && !options.iosReportPath) { + throw new Error( + '`export-ci` requires `--report ` or at least one of `--android ` / `--ios `.' + ) + } + + const [android, ios] = await Promise.all([ + options.androidReportPath + ? readReport(resolveFromCwd(cwd, options.androidReportPath)) + : Promise.resolve(undefined), + options.iosReportPath + ? readReport(resolveFromCwd(cwd, options.iosReportPath)) + : Promise.resolve(undefined), + ]) + + return { + report: undefined, + android, + ios, + } +} + +function resolveOutputDirectory(cwd: string, options: ExportCiOptions) { + if (options.outputDir) { + return resolveFromCwd(cwd, options.outputDir) + } + + const basePath = options.reportPath ?? options.androidReportPath ?? options.iosReportPath + if (!basePath) { + throw new Error('Unable to resolve output directory for exported CI files.') + } + + return resolveFromCwd(cwd, path.dirname(basePath)) +} + +function createSinglePlatformOutput(report: CommandReport): SinglePlatformCiOutput { + return { + kind: 'single-platform', status: report.overallStatus, statusLabel: report.overallStatus, summary: report.summary, - topIssue, - screenshotsCell: renderScreenshotsCell(report), + topIssue: getDefaultTopIssue(report), screenshots: buildScreenshotsMetadata(report), } +} - await writeFile(path.join(outputDir, 'ci-status.txt'), `${report.overallStatus}\n`, 'utf8') - await writeFile(path.join(outputDir, 'ci-section-body.md'), renderCommandSection(report), 'utf8') - await writeFile( - path.join(outputDir, 'ci-output.json'), - `${JSON.stringify(summary, null, 2)}\n`, - 'utf8' +function createMultiPlatformOutput(reports: { + android?: CommandReport + ios?: CommandReport +}): MultiPlatformCiOutput { + const platforms = { + android: reports.android ? createPlatformOutput(reports.android) : undefined, + ios: reports.ios ? createPlatformOutput(reports.ios) : undefined, + } + + const statuses = [platforms.android?.status, platforms.ios?.status].filter( + (status): status is CommandReport['overallStatus'] => Boolean(status) ) + const status = summarizeStatuses(statuses) + const summary = [ + platforms.android ? `Android: ${platforms.android.status}` : undefined, + platforms.ios ? `iOS: ${platforms.ios.status}` : undefined, + ] + .filter(Boolean) + .join('. ') - console.log(`Exported CI helpers to ${outputDir}`) + return { + kind: 'multi-platform', + status, + statusLabel: status, + summary: summary || 'No platform reports provided.', + topIssue: + platforms.android?.topIssue !== 'N/A' + ? `Android: ${platforms.android?.topIssue}` + : platforms.ios?.topIssue !== 'N/A' + ? `iOS: ${platforms.ios?.topIssue}` + : 'N/A', + platforms, + } +} + +function createPlatformOutput(report: CommandReport): PlatformCiOutput { + return { + status: report.overallStatus, + statusLabel: report.overallStatus, + summary: report.summary, + topIssue: getDefaultTopIssue(report), + screenshots: buildScreenshotsMetadata(report), + } +} + +function summarizeStatuses( + statuses: CommandReport['overallStatus'][] +): CommandReport['overallStatus'] | 'mixed' { + if (statuses.length === 0) { + return 'blocked' + } + + if (statuses.every((status) => status === statuses[0])) { + return statuses[0]! + } + + return 'mixed' +} + +function getDefaultTopIssue(report: CommandReport) { + return getTopIssue(report) ?? (report.overallStatus === 'passed' ? 'N/A' : report.summary) +} + +async function readReport(reportPath: string) { + const content = await readFile(reportPath, 'utf8') + return JSON.parse(content) as CommandReport } diff --git a/packages/cali/src/commands/render-comment.ts b/packages/cali/src/commands/render-comment.ts deleted file mode 100644 index 753c5a0..0000000 --- a/packages/cali/src/commands/render-comment.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { readFile, writeFile } from 'node:fs/promises' -import path from 'node:path' - -import { renderGithubComment, renderGithubMultiPlatformComment } from '../report/ci.js' -import type { CommandReport } from '../report/types.js' -import { ensureDirectory, resolveFromCwd } from '../utils.js' - -export type RenderCommentOptions = { - reportPath?: string - androidReportPath?: string - iosReportPath?: string - format: 'github' | 'github-multi-platform' - outputPath?: string -} - -export async function renderComment(options: RenderCommentOptions) { - const cwd = process.cwd() - let rendered: string - - if (options.format === 'github-multi-platform') { - const [android, ios] = await Promise.all([ - options.androidReportPath - ? readFile(resolveFromCwd(cwd, options.androidReportPath), 'utf8').then( - (content) => JSON.parse(content) as CommandReport - ) - : Promise.resolve(undefined), - options.iosReportPath - ? readFile(resolveFromCwd(cwd, options.iosReportPath), 'utf8').then( - (content) => JSON.parse(content) as CommandReport - ) - : Promise.resolve(undefined), - ]) - - rendered = renderGithubMultiPlatformComment({ - android, - ios, - }) - } else { - const reportPath = resolveFromCwd(cwd, options.reportPath!) - const content = await readFile(reportPath, 'utf8') - const report = JSON.parse(content) as CommandReport - rendered = renderGithubComment(report) - } - - if (options.outputPath) { - const outputPath = resolveFromCwd(cwd, options.outputPath) - await ensureDirectory(path.dirname(outputPath)) - await writeFile(outputPath, rendered, 'utf8') - console.log(`Wrote ${outputPath}`) - return - } - - process.stdout.write(rendered) -} diff --git a/packages/cali/src/report/ci.ts b/packages/cali/src/report/ci.ts index 9424401..070a8eb 100644 --- a/packages/cali/src/report/ci.ts +++ b/packages/cali/src/report/ci.ts @@ -81,7 +81,16 @@ function formatStatusForTable(report?: CommandReport) { } function formatTopIssueForTable(report?: CommandReport) { - return report ? (getTopIssue(report) ?? 'N/A') : 'N/A' + return report ? toInlineTableCell(getTopIssue(report) ?? 'N/A') : 'N/A' +} + +function toInlineTableCell(value: string) { + return value + .split('\n') + .map((part) => part.trim()) + .filter(Boolean) + .join('
') + .replaceAll('|', '\\|') } export function buildScreenshotsMetadata(report: CommandReport) { diff --git a/skills/cali/references/running-cali.md b/skills/cali/references/running-cali.md index 364b774..6bfefc0 100644 --- a/skills/cali/references/running-cali.md +++ b/skills/cali/references/running-cali.md @@ -66,14 +66,14 @@ node packages/cali/dist/index.js qa \ --platform android \ --artifact ./artifacts/app.apk -# Render a compact GitHub comment -node packages/cali/dist/index.js render-comment \ - --report ./artifacts/qa/report.json \ - --format github - -# Export EAS helper files +# Export CI helper files from one report node packages/cali/dist/index.js export-ci \ --report ./artifacts/qa/report.json + +# Export one combined CI comment from two platform reports +node packages/cali/dist/index.js export-ci \ + --android ./artifacts/android/report.json \ + --ios ./artifacts/ios/report.json ``` ## Provider setup @@ -99,7 +99,6 @@ QA_MODEL=anthropic/claude-sonnet-4.6 - For CI, prefer `cali qa --ci github-actions` or `cali qa --ci eas`. - Cali derives runtime context from provider env plus CLI overrides before the agent starts. - Use the explicit helper commands for integration glue: - - `render-comment` - `export-ci` - For copy-pasteable CI examples, use: - [`packages/cali/examples/github-actions/run-qa.sh`](../../../packages/cali/examples/github-actions/run-qa.sh) From 1154f12d0ac9e9f34b69bb1dabf957bf0dab8d28 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 10 Apr 2026 12:54:45 +0200 Subject: [PATCH 38/48] refactor: trim redundant cali outputs and aliases --- packages/cali/README.md | 16 ---------------- packages/cali/src/cli/app.ts | 9 +++------ packages/cali/src/commands/export-ci.ts | 6 ------ packages/cali/src/model.ts | 13 ++++--------- packages/cali/src/report/publishers/file.ts | 17 +---------------- 5 files changed, 8 insertions(+), 53 deletions(-) diff --git a/packages/cali/README.md b/packages/cali/README.md index c0af7ef..7f36cc0 100644 --- a/packages/cali/README.md +++ b/packages/cali/README.md @@ -141,12 +141,6 @@ export AI_GATEWAY_API_KEY="your-ai-gateway-key" export QA_MODEL="openai/gpt-5.4-mini" ``` -Gateway also accepts the compatibility alias: - -```bash -export AI_GATEWAY_KEY="your-ai-gateway-key" -``` - ### Anthropic Direct ```bash @@ -154,12 +148,6 @@ export ANTHROPIC_API_KEY="your-anthropic-api-key" export QA_MODEL="anthropic/claude-sonnet-4.6" ``` -Anthropic also accepts the compatibility alias: - -```bash -export CLAUDE_API_KEY="your-anthropic-api-key" -``` - ### `.env` example ```dotenv @@ -415,12 +403,10 @@ The file publisher writes: - `report.json` - `section.md` - `status.txt` -- `status-label.txt` - `summary.txt` - `top-issue.txt` - `screenshots.md` - `screenshots.json` -- `comment-github.md` - `publisher-manifest.json` The default output directory is `artifacts/`. @@ -436,7 +422,6 @@ Single-platform `ci-output.json` combines: - `kind` - `status` -- `statusLabel` - `summary` - `topIssue` - `screenshots` @@ -445,7 +430,6 @@ Multi-platform `ci-output.json` combines: - `kind` - `status` -- `statusLabel` - `summary` - `topIssue` - `platforms.android` diff --git a/packages/cali/src/cli/app.ts b/packages/cali/src/cli/app.ts index 1c1564c..e7ab1df 100644 --- a/packages/cali/src/cli/app.ts +++ b/packages/cali/src/cli/app.ts @@ -44,17 +44,14 @@ function createCli() { export async function runCli(argv = process.argv) { const cli = createCli() const args = argv.slice(2) - const cwd = process.cwd() const printBanner = shouldPrintBanner(args) if (args.length === 0) { - const config = await loadCaliConfigFile(cwd) + const config = await loadCaliConfigFile(process.cwd()) if (config.defaultCommand) { if (printBanner) { printRetroBanner() } - await Promise.resolve( - cli.parse([argv[0] ?? 'node', argv[1] ?? 'cali', config.defaultCommand]) - ) + cli.parse([argv[0] ?? 'node', argv[1] ?? 'cali', config.defaultCommand]) return } @@ -67,7 +64,7 @@ export async function runCli(argv = process.argv) { printRetroBanner() } - await Promise.resolve(cli.parse(argv)) + cli.parse(argv) if (args.length === 0) { cli.outputHelp() diff --git a/packages/cali/src/commands/export-ci.ts b/packages/cali/src/commands/export-ci.ts index 04e276b..638001e 100644 --- a/packages/cali/src/commands/export-ci.ts +++ b/packages/cali/src/commands/export-ci.ts @@ -20,7 +20,6 @@ export type ExportCiOptions = { type SinglePlatformCiOutput = { kind: 'single-platform' status: CommandReport['overallStatus'] - statusLabel: string summary: string topIssue: string screenshots: ReturnType @@ -28,7 +27,6 @@ type SinglePlatformCiOutput = { type PlatformCiOutput = { status: CommandReport['overallStatus'] - statusLabel: string summary: string topIssue: string screenshots: ReturnType @@ -37,7 +35,6 @@ type PlatformCiOutput = { type MultiPlatformCiOutput = { kind: 'multi-platform' status: CommandReport['overallStatus'] | 'mixed' - statusLabel: string summary: string topIssue: string platforms: { @@ -127,7 +124,6 @@ function createSinglePlatformOutput(report: CommandReport): SinglePlatformCiOutp return { kind: 'single-platform', status: report.overallStatus, - statusLabel: report.overallStatus, summary: report.summary, topIssue: getDefaultTopIssue(report), screenshots: buildScreenshotsMetadata(report), @@ -157,7 +153,6 @@ function createMultiPlatformOutput(reports: { return { kind: 'multi-platform', status, - statusLabel: status, summary: summary || 'No platform reports provided.', topIssue: platforms.android?.topIssue !== 'N/A' @@ -172,7 +167,6 @@ function createMultiPlatformOutput(reports: { function createPlatformOutput(report: CommandReport): PlatformCiOutput { return { status: report.overallStatus, - statusLabel: report.overallStatus, summary: report.summary, topIssue: getDefaultTopIssue(report), screenshots: buildScreenshotsMetadata(report), diff --git a/packages/cali/src/model.ts b/packages/cali/src/model.ts index 8f98212..38dc788 100644 --- a/packages/cali/src/model.ts +++ b/packages/cali/src/model.ts @@ -9,17 +9,12 @@ function stripAnthropicPrefix(modelId: string) { } export function createQaAgentModel(modelId = process.env.QA_MODEL ?? DEFAULT_QA_MODEL_ID) { - const gatewayKey = process.env.AI_GATEWAY_API_KEY || process.env.AI_GATEWAY_KEY - if (gatewayKey || process.env.VERCEL || process.env.VERCEL_ENV || process.env.VERCEL_OIDC_TOKEN) { - if (!process.env.AI_GATEWAY_API_KEY && gatewayKey) { - process.env.AI_GATEWAY_API_KEY = gatewayKey - } - + if (process.env.AI_GATEWAY_API_KEY) { return modelId } - const apiKey = process.env.ANTHROPIC_API_KEY ?? process.env.CLAUDE_API_KEY - const authToken = process.env.ANTHROPIC_AUTH_TOKEN ?? process.env.CLAUDE_AUTH_TOKEN + const apiKey = process.env.ANTHROPIC_API_KEY + const authToken = process.env.ANTHROPIC_AUTH_TOKEN if (apiKey || authToken) { const anthropic = createAnthropic({ ...(apiKey ? { apiKey } : {}), @@ -32,7 +27,7 @@ export function createQaAgentModel(modelId = process.env.QA_MODEL ?? DEFAULT_QA_ throw new Error( [ 'Missing AI credentials.', - 'Set AI_GATEWAY_API_KEY (or AI_GATEWAY_KEY) for gateway access, or ANTHROPIC_API_KEY / CLAUDE_API_KEY for direct Anthropic access.', + 'Set AI_GATEWAY_API_KEY for gateway access, or ANTHROPIC_API_KEY / ANTHROPIC_AUTH_TOKEN for direct Anthropic access.', `Docs: ${DOCS_URLS.providerSetup}`, ].join('\n\n') ) diff --git a/packages/cali/src/report/publishers/file.ts b/packages/cali/src/report/publishers/file.ts index 7a3e68d..f1444ea 100644 --- a/packages/cali/src/report/publishers/file.ts +++ b/packages/cali/src/report/publishers/file.ts @@ -3,12 +3,7 @@ import path from 'node:path' import { sanitizeUrl } from '../../runtime/context-repo.js' import { ensureDirectory } from '../../utils.js' -import { - buildScreenshotsMetadata, - getTopIssue, - renderGithubComment, - renderScreenshotsMarkdown, -} from '../ci.js' +import { buildScreenshotsMetadata, getTopIssue, renderScreenshotsMarkdown } from '../ci.js' import { renderCommandSection } from '../render.js' import type { CommandReport, PerfReviewReport, QaReport, ReportPublisherResult } from '../types.js' @@ -124,11 +119,6 @@ export async function publishFileReport({ await writeFile(path.join(outputDir, 'summary.txt'), `${finalReport.summary}\n`, 'utf8') await writeFile(path.join(outputDir, 'top-issue.txt'), `${topIssue}\n`, 'utf8') await writeFile(path.join(outputDir, 'status.txt'), `${finalReport.overallStatus}\n`, 'utf8') - await writeFile( - path.join(outputDir, 'status-label.txt'), - `${finalReport.overallStatus}\n`, - 'utf8' - ) await writeFile( path.join(outputDir, 'screenshots.md'), renderScreenshotsMarkdown(finalReport), @@ -139,11 +129,6 @@ export async function publishFileReport({ `${JSON.stringify(buildScreenshotsMetadata(finalReport), null, 2)}\n`, 'utf8' ) - await writeFile( - path.join(outputDir, 'comment-github.md'), - renderGithubComment(finalReport), - 'utf8' - ) await writeFile( path.join(outputDir, 'publisher-manifest.json'), `${JSON.stringify(publisherResults, null, 2)}\n`, From 93aaa2e067704900513f40c41c8ea9143f99ea7b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 10 Apr 2026 13:04:43 +0200 Subject: [PATCH 39/48] fix: harden cali repo and ci boundaries --- packages/cali/src/commands/export-ci.ts | 114 +++++++++++++++++++++- packages/cali/src/commands/qa.ts | 11 +-- packages/cali/src/commands/shared.ts | 20 +--- packages/cali/src/config/load.ts | 38 +++++--- packages/cali/src/config/schema.ts | 54 +++++----- packages/cali/src/report/ci.ts | 30 ++++-- packages/cali/src/report/types.ts | 80 +++++++-------- packages/cali/src/runtime/context-repo.ts | 8 +- packages/cali/src/runtime/mobile.ts | 14 ++- packages/cali/src/tools/agent-device.ts | 22 ++--- packages/cali/src/tools/repo.ts | 46 ++++++++- 11 files changed, 310 insertions(+), 127 deletions(-) diff --git a/packages/cali/src/commands/export-ci.ts b/packages/cali/src/commands/export-ci.ts index 638001e..805dc69 100644 --- a/packages/cali/src/commands/export-ci.ts +++ b/packages/cali/src/commands/export-ci.ts @@ -1,6 +1,8 @@ import { readFile, writeFile } from 'node:fs/promises' import path from 'node:path' +import { z } from 'zod' + import { buildScreenshotsMetadata, getTopIssue, @@ -10,6 +12,116 @@ import { import type { CommandReport } from '../report/types.js' import { ensureDirectory, resolveFromCwd } from '../utils.js' +const ResultStatusSchema = z.enum(['passed', 'failed', 'blocked', 'not_tested', 'unsure']) + +const BaseReportSchema = z + .object({ + command: z.enum(['qa', 'review', 'perf-review', 'dev']), + overallStatus: ResultStatusSchema, + summary: z.string(), + nextSteps: z.array(z.string()).optional(), + environmentNotes: z.array(z.string()).optional(), + publisherResults: z + .array( + z.object({ + publisher: z.string(), + status: z.enum(['ok', 'skipped', 'failed']), + detail: z.string().optional(), + }) + ) + .optional(), + context: z.object({ + workspaceRoot: z.string(), + repository: z + .object({ + owner: z.string().optional(), + name: z.string().optional(), + }) + .passthrough() + .optional(), + pullRequest: z + .object({ + number: z.number().optional(), + }) + .passthrough() + .optional(), + build: z + .object({ + id: z.string().optional(), + workflowUrl: z.string().optional(), + }) + .passthrough() + .optional(), + mobile: z + .object({ + platform: z.enum(['android', 'ios']).optional(), + }) + .passthrough() + .optional(), + }), + }) + .passthrough() + +const ScreenshotInfoSchema = z + .object({ + fileName: z.string(), + label: z.string(), + blobUrl: z.string().optional(), + blobDownloadUrl: z.string().optional(), + blobPathname: z.string().optional(), + uploadError: z.string().optional(), + }) + .passthrough() + +const ExportableReportSchema = z.discriminatedUnion('command', [ + BaseReportSchema.extend({ + command: z.literal('qa'), + checked: z.array(z.string()), + issues: z.array(z.string()), + acceptanceCriteriaUsed: z.array(z.string()), + screenshots: z.array(ScreenshotInfoSchema), + }), + BaseReportSchema.extend({ + command: z.literal('review'), + findings: z.array( + z.object({ + severity: z.enum(['low', 'medium', 'high', 'critical']), + title: z.string(), + body: z.string(), + file: z.string().optional(), + lineStart: z.number().optional(), + lineEnd: z.number().optional(), + }) + ), + strengths: z.array(z.string()), + validationGaps: z.array(z.string()), + }), + BaseReportSchema.extend({ + command: z.literal('perf-review'), + scenario: z.string(), + slowComponents: z.array(z.object({ label: z.string(), detail: z.string() })), + rerenderHotspots: z.array(z.object({ label: z.string(), detail: z.string() })), + suspectedCauses: z.array(z.string()), + evidence: z.array( + z.object({ + kind: z.enum(['component', 'profile', 'screenshot', 'note']), + label: z.string(), + detail: z.string(), + reference: z.string().optional(), + }) + ), + recommendedFixes: z.array(z.string()), + screenshots: z.array(ScreenshotInfoSchema), + }), + BaseReportSchema.extend({ + command: z.literal('dev'), + filesChanged: z.array(z.string()), + validationsRun: z.array(z.string()), + followUps: z.array(z.string()), + patchStatus: z.enum(['applied', 'planned', 'blocked', 'partial']), + }), +]) + export type ExportCiOptions = { reportPath?: string androidReportPath?: string @@ -193,5 +305,5 @@ function getDefaultTopIssue(report: CommandReport) { async function readReport(reportPath: string) { const content = await readFile(reportPath, 'utf8') - return JSON.parse(content) as CommandReport + return ExportableReportSchema.parse(JSON.parse(content)) as CommandReport } diff --git a/packages/cali/src/commands/qa.ts b/packages/cali/src/commands/qa.ts index 06c0832..e22338d 100644 --- a/packages/cali/src/commands/qa.ts +++ b/packages/cali/src/commands/qa.ts @@ -5,8 +5,6 @@ import type { CommandCliOptions } from '../runtime/types.js' import { humanizeScreenshotLabel } from '../utils.js' import { runMobileStructuredCommand } from './shared.js' -const MAX_QA_TRACE_ENTRIES = 20 - function resolveAcceptanceCriteria( context: Parameters[0]['context'], prompt?: string @@ -82,6 +80,8 @@ export async function runQaCommand(cli: CommandCliOptions) { cli.envName = cli.ciProvider === 'eas' ? 'eas-mobile-pr' : 'mobile-pr' } + let acceptanceCriteriaUsed: string[] | undefined + return runMobileStructuredCommand({ commandId: 'qa', cli, @@ -89,7 +89,6 @@ export async function runQaCommand(cli: CommandCliOptions) { reportLabel: 'QA report', createBlockedReport, composeReport: async ({ model, context, reportInput, mobileContext, traces }) => { - const acceptanceCriteriaUsed = resolveAcceptanceCriteria(context, cli.prompt) const screenshots = mobileContext ? await listScreenshots(mobileContext.screenshotsDir) : [] return composeQaReport( @@ -97,8 +96,8 @@ export async function runQaCommand(cli: CommandCliOptions) { context, reportInput, screenshots, - traces.agentDeviceTrace.slice(-MAX_QA_TRACE_ENTRIES), - acceptanceCriteriaUsed + traces.agentDeviceTrace, + acceptanceCriteriaUsed ?? resolveAcceptanceCriteria(context, cli.prompt) ) }, runRole: async ({ @@ -112,7 +111,7 @@ export async function runQaCommand(cli: CommandCliOptions) { onAgentStep, onAgentFinish, }) => { - const acceptanceCriteriaUsed = resolveAcceptanceCriteria(context, cli.prompt) + acceptanceCriteriaUsed = resolveAcceptanceCriteria(context, cli.prompt) return runQaMobileRole({ context, diff --git a/packages/cali/src/commands/shared.ts b/packages/cali/src/commands/shared.ts index 0c71948..18e4b21 100644 --- a/packages/cali/src/commands/shared.ts +++ b/packages/cali/src/commands/shared.ts @@ -3,7 +3,7 @@ import path from 'node:path' import { tool } from 'ai' import { z } from 'zod' -import { loadCommandConfig } from '../config/load.js' +import { loadCommandConfig, resolveDefaultEnvName } from '../config/load.js' import type { ToolPackName } from '../config/schema.js' import type { CommandReport } from '../report/types.js' import { buildCiContext } from '../runtime/ci-context.js' @@ -138,29 +138,13 @@ export async function loadRunContext(commandId: CommandId, cli: CommandCliOption } } -function getDefaultEnvName(commandId: CommandId, cli: CommandCliOptions) { - if (cli.envName) { - return cli.envName - } - - if (cli.ciProvider === 'eas') { - return 'eas-mobile-pr' - } - - if (cli.ciProvider === 'github-actions') { - return 'mobile-pr' - } - - return commandId === 'qa' || commandId === 'perf-review' ? 'local-android' : 'mobile-pr' -} - function createFallbackConfig( commandId: CommandId, cwd: string, cli: CommandCliOptions ): CommandResolvedConfig { return { - envName: getDefaultEnvName(commandId, cli), + envName: cli.envName ?? resolveDefaultEnvName(commandId, cli.ciProvider), workspaceRoot: cli.workspaceRoot ? resolveFromCwd(cwd, cli.workspaceRoot) : cwd, contextPath: undefined, skillPaths: [], diff --git a/packages/cali/src/config/load.ts b/packages/cali/src/config/load.ts index 867218e..500862d 100644 --- a/packages/cali/src/config/load.ts +++ b/packages/cali/src/config/load.ts @@ -24,6 +24,29 @@ type LoadCommandConfigOptions = { model?: string } +export function resolveDefaultEnvName( + commandId: CommandId, + ciProvider?: 'github-actions' | 'eas' +): CaliEnvName { + if (ciProvider === 'eas') { + return 'eas-mobile-pr' + } + + if (ciProvider === 'github-actions') { + return 'mobile-pr' + } + + switch (commandId) { + case 'review': + case 'dev': + return 'mobile-pr' + case 'qa': + case 'perf-review': + default: + return 'local-android' + } +} + function getBuiltInSkillPaths(cwd: string) { return [path.join(cwd, '.agents', 'skills'), path.join(homedir(), '.agents', 'skills')] } @@ -70,18 +93,6 @@ const QA_ENV_DEFAULTS: Record = { }, } -function getDefaultEnvName(commandId: CommandId): CaliEnvName { - switch (commandId) { - case 'review': - case 'dev': - return 'mobile-pr' - case 'qa': - case 'perf-review': - default: - return 'local-android' - } -} - function getEnvCommandDefaults(commandId: CommandId, envName: CaliEnvName): CaliCommandConfig { const commonOutputPublishers: PublisherName[] = ['file'] const mobileOutputPublishers: PublisherName[] = ['blob', 'file'] @@ -191,7 +202,8 @@ export async function loadCommandConfig( ): Promise { const { commandId, cwd, configPath, envName: cliEnvName, model } = options const fileConfig = await loadCaliConfigFile(cwd, configPath) - const envName = cliEnvName ?? normalizeCaliEnvName(fileConfig.env) ?? getDefaultEnvName(commandId) + const envName = + cliEnvName ?? normalizeCaliEnvName(fileConfig.env) ?? resolveDefaultEnvName(commandId) const envDefaults = getEnvCommandDefaults(commandId, envName) const commandConfig = getCommandConfig(fileConfig, COMMAND_CONFIG_KEYS[commandId]) const merged = mergeCommandConfig(envDefaults, commandConfig) diff --git a/packages/cali/src/config/schema.ts b/packages/cali/src/config/schema.ts index 7febfd7..a14d7e4 100644 --- a/packages/cali/src/config/schema.ts +++ b/packages/cali/src/config/schema.ts @@ -21,38 +21,44 @@ const MobileDefaultsSchema = z deviceName: z.string().optional(), appId: z.string().optional(), }) + .strict() .optional() -const CommandConfigSchema = z.object({ - contextPath: z.string().optional(), - enabledToolPacks: z.array(ToolPackNameSchema).optional(), - outputPublishers: z.array(PublisherNameSchema).optional(), - extraInstructions: StringArraySchema, - model: z.string().optional(), - mobileDefaults: MobileDefaultsSchema, -}) +const CommandConfigSchema = z + .object({ + contextPath: z.string().optional(), + enabledToolPacks: z.array(ToolPackNameSchema).optional(), + outputPublishers: z.array(PublisherNameSchema).optional(), + extraInstructions: StringArraySchema, + model: z.string().optional(), + mobileDefaults: MobileDefaultsSchema, + }) + .strict() export function normalizeCaliEnvName(value?: string): CaliEnvName | undefined { const result = CaliEnvNameSchema.safeParse(value) return result.success ? result.data : undefined } -export const CaliConfigSchema = z.object({ - defaultCommand: CommandIdSchema.optional(), - env: CaliEnvNameSchema.optional(), - workspaceRoot: z.string().optional(), - skillPaths: z.array(z.string()).optional(), - outputPublishers: z.array(PublisherNameSchema).optional(), - model: z.string().optional(), - commands: z - .object({ - qa: CommandConfigSchema.optional(), - review: CommandConfigSchema.optional(), - perfReview: CommandConfigSchema.optional(), - dev: CommandConfigSchema.optional(), - }) - .optional(), -}) +export const CaliConfigSchema = z + .object({ + defaultCommand: CommandIdSchema.optional(), + env: CaliEnvNameSchema.optional(), + workspaceRoot: z.string().optional(), + skillPaths: z.array(z.string()).optional(), + outputPublishers: z.array(PublisherNameSchema).optional(), + model: z.string().optional(), + commands: z + .object({ + qa: CommandConfigSchema.optional(), + review: CommandConfigSchema.optional(), + perfReview: CommandConfigSchema.optional(), + dev: CommandConfigSchema.optional(), + }) + .strict() + .optional(), + }) + .strict() export type CaliEnvName = z.infer export type ToolPackName = z.infer diff --git a/packages/cali/src/report/ci.ts b/packages/cali/src/report/ci.ts index 070a8eb..956166f 100644 --- a/packages/cali/src/report/ci.ts +++ b/packages/cali/src/report/ci.ts @@ -1,3 +1,4 @@ +import { sanitizeUrl } from '../runtime/context-repo.js' import { renderCommandSection } from './render.js' import type { CommandReport, PerfReviewReport, QaReport, ScreenshotInfo } from './types.js' @@ -55,8 +56,8 @@ export function renderScreenshotsMarkdown(report: CommandReport) { return `${report.screenshots .map((screenshot) => - screenshot.blobUrl - ? `- [${screenshot.label}](${screenshot.blobUrl})` + sanitizeUrl(screenshot.blobUrl) + ? `- [${screenshot.label}](${sanitizeUrl(screenshot.blobUrl)})` : `- ${screenshot.label}: ${screenshot.fileName}` ) .join('\n')}\n` @@ -68,11 +69,7 @@ export function renderScreenshotsCell(report?: CommandReport) { } return report.screenshots - .map((screenshot) => - screenshot.blobUrl - ? `**${screenshot.label}**
${screenshot.label}` - : `**${screenshot.label}**
${screenshot.fileName}` - ) + .map((screenshot) => renderScreenshotCellItem(screenshot)) .join('

') } @@ -115,6 +112,25 @@ function createScreenshotMetadata(screenshot: ScreenshotInfo, index: number) { } } +function escapeHtml(value: string) { + return value + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') +} + +function renderScreenshotCellItem(screenshot: ScreenshotInfo) { + const safeLabel = escapeHtml(screenshot.label) + const safeUrl = sanitizeUrl(screenshot.blobUrl) + + if (!safeUrl) { + return `**${safeLabel}**
${escapeHtml(screenshot.fileName)}` + } + + return `**${safeLabel}**
${safeLabel}` +} + export function renderGithubComment(report: CommandReport) { const lines = [ `### ${getTitle(report)}`, diff --git a/packages/cali/src/report/types.ts b/packages/cali/src/report/types.ts index 42c5cb8..f67fd19 100644 --- a/packages/cali/src/report/types.ts +++ b/packages/cali/src/report/types.ts @@ -46,16 +46,17 @@ export type QaReportInput = { environmentNotes?: string[] } -export type QaReport = BaseCommandReport & - QaReportInput & { - command: 'qa' - checked: string[] - issues: string[] - screenshotLabels: ScreenshotLabel[] - screenshots: ScreenshotInfo[] - acceptanceCriteriaUsed: string[] - agentDeviceTrace: ToolTraceEntry[] - } +export type QaReport = BaseCommandReport & { + command: 'qa' + checked: string[] + issues: string[] + nextSteps?: string[] + screenshotLabels: ScreenshotLabel[] + screenshots: ScreenshotInfo[] + acceptanceCriteriaUsed: string[] + environmentNotes?: string[] + agentDeviceTrace: ToolTraceEntry[] +} export type ReviewFinding = { severity: 'low' | 'medium' | 'high' | 'critical' @@ -76,13 +77,14 @@ export type ReviewReportInput = { environmentNotes?: string[] } -export type ReviewReport = BaseCommandReport & - ReviewReportInput & { - command: 'review' - findings: ReviewFinding[] - strengths: string[] - validationGaps: string[] - } +export type ReviewReport = BaseCommandReport & { + command: 'review' + findings: ReviewFinding[] + strengths: string[] + validationGaps: string[] + nextSteps?: string[] + environmentNotes?: string[] +} export type PerfEvidence = { kind: 'component' | 'profile' | 'screenshot' | 'note' @@ -109,19 +111,20 @@ export type PerfReviewReportInput = { environmentNotes?: string[] } -export type PerfReviewReport = BaseCommandReport & - PerfReviewReportInput & { - command: 'perf-review' - scenario: string - slowComponents: PerfComponentFinding[] - rerenderHotspots: PerfComponentFinding[] - suspectedCauses: string[] - evidence: PerfEvidence[] - recommendedFixes: string[] - screenshots: ScreenshotInfo[] - agentDeviceTrace: ToolTraceEntry[] - reactDevtoolsTrace: ToolTraceEntry[] - } +export type PerfReviewReport = BaseCommandReport & { + command: 'perf-review' + scenario: string + slowComponents: PerfComponentFinding[] + rerenderHotspots: PerfComponentFinding[] + suspectedCauses: string[] + evidence: PerfEvidence[] + recommendedFixes: string[] + nextSteps?: string[] + environmentNotes?: string[] + screenshots: ScreenshotInfo[] + agentDeviceTrace: ToolTraceEntry[] + reactDevtoolsTrace: ToolTraceEntry[] +} export type DevReportInput = { overallStatus: ResultStatus @@ -134,13 +137,14 @@ export type DevReportInput = { environmentNotes?: string[] } -export type DevReport = BaseCommandReport & - DevReportInput & { - command: 'dev' - filesChanged: string[] - validationsRun: string[] - followUps: string[] - patchStatus: 'applied' | 'planned' | 'blocked' | 'partial' - } +export type DevReport = BaseCommandReport & { + command: 'dev' + filesChanged: string[] + validationsRun: string[] + followUps: string[] + patchStatus: 'applied' | 'planned' | 'blocked' | 'partial' + nextSteps?: string[] + environmentNotes?: string[] +} export type CommandReport = QaReport | ReviewReport | PerfReviewReport | DevReport diff --git a/packages/cali/src/runtime/context-repo.ts b/packages/cali/src/runtime/context-repo.ts index 52fac4d..3473ea5 100644 --- a/packages/cali/src/runtime/context-repo.ts +++ b/packages/cali/src/runtime/context-repo.ts @@ -16,7 +16,13 @@ export function sanitizeUrl(rawUrl: string | undefined, options: { stripQuery?: } return parsed.toString() } catch { - return rawUrl + const strippedCredentials = rawUrl + .replace(/^(https?:\/\/)[^@]+@/, '$1') + .replace(/^(ssh:\/\/)[^@]+@/, '$1') + if (options.stripQuery) { + return strippedCredentials.replace(/[?#].*$/, '') + } + return strippedCredentials } } diff --git a/packages/cali/src/runtime/mobile.ts b/packages/cali/src/runtime/mobile.ts index f4d5331..7762ccf 100644 --- a/packages/cali/src/runtime/mobile.ts +++ b/packages/cali/src/runtime/mobile.ts @@ -406,6 +406,18 @@ async function inferIosAppId(artifactPath: string) { } async function findAppBundle(directory: string): Promise { + return findAppBundleAtDepth(directory, 0) +} + +async function findAppBundleAtDepth( + directory: string, + depth: number, + maxDepth = 3 +): Promise { + if (depth > maxDepth) { + return undefined + } + const entries = await readdir(directory, { withFileTypes: true }) for (const entry of entries) { @@ -420,7 +432,7 @@ async function findAppBundle(directory: string): Promise { continue } - const nestedPath = await findAppBundle(path.join(directory, entry.name)) + const nestedPath = await findAppBundleAtDepth(path.join(directory, entry.name), depth + 1) if (nestedPath) { return nestedPath } diff --git a/packages/cali/src/tools/agent-device.ts b/packages/cali/src/tools/agent-device.ts index c257ac6..d597345 100644 --- a/packages/cali/src/tools/agent-device.ts +++ b/packages/cali/src/tools/agent-device.ts @@ -47,23 +47,17 @@ function normalizeScreenshotArgs(args: string[], screenshotsDir: string) { function normalizeCommandInvocation(command: string, args: string[], screenshotsDir: string) { const trimmedCommand = command.trim() - - if (args.length > 0 || !trimmedCommand.includes(' ')) { - const normalizedArgs = - trimmedCommand === 'screenshot' ? normalizeScreenshotArgs(args, screenshotsDir) : args - return { - command: trimmedCommand, - args: normalizedArgs, - } + if (args.length === 0 && /\s/.test(trimmedCommand)) { + throw new Error( + 'agent_device expects the subcommand in `command` and each argument in `args`. Do not pass a shell-style command string.' + ) } - const [normalizedCommand, ...normalizedArgs] = trimmedCommand.split(/\s+/g) + const normalizedArgs = + trimmedCommand === 'screenshot' ? normalizeScreenshotArgs(args, screenshotsDir) : args return { - command: normalizedCommand, - args: - normalizedCommand === 'screenshot' - ? normalizeScreenshotArgs(normalizedArgs, screenshotsDir) - : normalizedArgs, + command: trimmedCommand, + args: normalizedArgs, } } diff --git a/packages/cali/src/tools/repo.ts b/packages/cali/src/tools/repo.ts index a4a0933..7a9ca97 100644 --- a/packages/cali/src/tools/repo.ts +++ b/packages/cali/src/tools/repo.ts @@ -15,6 +15,26 @@ type RepoWriteToolPackOptions = { allowedCommands?: string[] } +const SHELL_METACHARACTERS = [';', '&&', '||', '|', '&', '`', '$(', '>', '<', '\n', '\r'] + +function resolveWorkspacePath(workspaceRoot: string, relativePath: string) { + const normalizedWorkspaceRoot = path.resolve(workspaceRoot) + const absolutePath = resolveFromCwd(normalizedWorkspaceRoot, relativePath) + + if ( + absolutePath !== normalizedWorkspaceRoot && + !absolutePath.startsWith(`${normalizedWorkspaceRoot}${path.sep}`) + ) { + throw new Error(`Path must stay within the repository workspace: ${relativePath}`) + } + + return absolutePath +} + +function hasShellMetacharacters(command: string) { + return SHELL_METACHARACTERS.some((token) => command.includes(token)) +} + export function createRepoReadToolPack(options: RepoReadToolPackOptions) { const { workspaceRoot } = options @@ -84,7 +104,7 @@ export function createRepoReadToolPack(options: RepoReadToolPackOptions) { maxLines: z.number().int().min(1).max(400).optional(), }), execute: async ({ path: relativePath, startLine = 1, maxLines = 200 }) => { - const absolutePath = resolveFromCwd(workspaceRoot, relativePath) + const absolutePath = resolveWorkspacePath(workspaceRoot, relativePath) const content = await readFile(absolutePath, 'utf8') const lines = content.split('\n') const slice = lines.slice(startLine - 1, startLine - 1 + maxLines) @@ -155,7 +175,7 @@ export function createRepoWriteToolPack(options: RepoWriteToolPackOptions) { content: z.string(), }), execute: async ({ path: relativePath, content }) => { - const absolutePath = resolveFromCwd(workspaceRoot, relativePath) + const absolutePath = resolveWorkspacePath(workspaceRoot, relativePath) await ensureDirectory(path.dirname(absolutePath)) await writeFile(absolutePath, content, 'utf8') @@ -171,7 +191,7 @@ export function createRepoWriteToolPack(options: RepoWriteToolPackOptions) { path: z.string(), }), execute: async ({ path: relativePath }) => { - const absolutePath = resolveFromCwd(workspaceRoot, relativePath) + const absolutePath = resolveWorkspacePath(workspaceRoot, relativePath) await rm(absolutePath, { force: true }) return { @@ -187,7 +207,25 @@ export function createRepoWriteToolPack(options: RepoWriteToolPackOptions) { command: z.string(), }), execute: async ({ command }) => { - if (allowedCommands.length > 0 && !allowedCommands.includes(command)) { + if (allowedCommands.length === 0) { + return { + ok: false, + exitCode: 1, + stdout: '', + stderr: 'No repository commands are allowed by the current write policy.', + } + } + + if (hasShellMetacharacters(command)) { + return { + ok: false, + exitCode: 1, + stdout: '', + stderr: 'Shell metacharacters are not allowed in repository commands.', + } + } + + if (!allowedCommands.includes(command)) { return { ok: false, exitCode: 1, From 4445afe20bb67c74da98a02142eebe1956b54f97 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 10 Apr 2026 13:09:40 +0200 Subject: [PATCH 40/48] chore: release v0.4.0-4 --- package.json | 2 +- packages/cali/package.json | 2 +- packages/tools/package.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package.json b/package.json index 7027e42..d91239d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@cali/root", - "version": "0.4.0-3", + "version": "0.4.0-4", "devDependencies": { "@release-it-plugins/workspaces": "^4.2.0", "@release-it/conventional-changelog": "^9.0.3", diff --git a/packages/cali/package.json b/packages/cali/package.json index 6ac7163..6f68302 100644 --- a/packages/cali/package.json +++ b/packages/cali/package.json @@ -67,7 +67,7 @@ "README.md" ], "license": "MIT", - "version": "0.4.0-3", + "version": "0.4.0-4", "engines": { "node": ">=22" } diff --git a/packages/tools/package.json b/packages/tools/package.json index 0247733..4963afc 100644 --- a/packages/tools/package.json +++ b/packages/tools/package.json @@ -51,7 +51,7 @@ "README.md" ], "license": "MIT", - "version": "0.4.0-3", + "version": "0.4.0-4", "engines": { "node": ">=22" } From 3b041f2aa9e80cb13a5034ff81d521aecd893b6e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 10 Apr 2026 13:45:07 +0200 Subject: [PATCH 41/48] refactor: drop legacy qa ci envs --- AGENTS.md | 2 ++ packages/cali/README.md | 6 +++--- packages/cali/package.json | 4 ---- packages/cali/src/cli/qa.ts | 5 ++++- packages/cali/src/cli/shared.ts | 11 +++++++---- packages/cali/src/commands/qa.ts | 6 ++++-- packages/cali/src/commands/shared.ts | 1 + packages/cali/src/config/load.ts | 8 ++++++-- skills/cali/references/running-cali.md | 5 +++++ 9 files changed, 32 insertions(+), 16 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index fde9a1f..247f3ca 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -121,6 +121,8 @@ Required skill guidance should be preloaded through the tool-pack registry when - Bootstrap stays outside the role in the command module. - The role inspects the app and writes a structured QA report. +- Use `--ci github-actions|eas` for CI runs. +- Use `--env local-android|local-ios` for local runs. - Requires `agent-device` on `PATH`. - Mobile runs use a unique per-run `agent-device` session. Do not reuse ambient sessions. - Local envs are convenience-first: try `open --relaunch` before reinstalling. diff --git a/packages/cali/README.md b/packages/cali/README.md index 7f36cc0..88a3d41 100644 --- a/packages/cali/README.md +++ b/packages/cali/README.md @@ -12,7 +12,7 @@ Cali v2 is a role-oriented CLI for mobile React Native and Expo workflows. It ru ## Core Concepts - command: the user-facing role entrypoint such as `cali qa` or `cali review` -- env: default runtime shape for a command, such as CI-style `mobile-pr` or local mobile envs +- env: default runtime shape for a command; for `qa`, use local envs only and prefer `--ci` in CI - context file: the optional explicit JSON input for workspace, repository, PR/task, mobile, build, output, and role-specific sections - tool pack: a bounded capability surface such as `agent-device`, `react-devtools`, `repo-read`, or `repo-write` - publisher: how reports are exposed after a run, such as `file` or `blob` @@ -109,10 +109,11 @@ Local mobile behavior: ```bash cali qa --ci github-actions --platform ios --artifact ./artifacts/MyApp.app cali qa --ci eas --platform android --artifact ./artifacts/app.apk -cali qa --env mobile-pr --context ./cali-context.json cali review --env mobile-pr --context ./cali-context.json ``` +`cali qa` no longer supports `--env mobile-pr` or `--env eas-mobile-pr`. Use `--ci github-actions|eas` for CI runs. + Use `--quiet` to suppress the retro banner in scripted environments. Cali also suppresses the banner automatically when `CI=true`. ### Runtime performance review @@ -381,7 +382,6 @@ Built bundle: - `bun run review -- --help` - `bun run perf-review -- --help` - `bun run dev:command -- --help` -- `bun run qa:env:mobile-pr -- --context ./cali-context.json` - `bun run qa:ci:gha -- --platform android --artifact ./artifacts/app.apk` - `bun run qa:ci:eas -- --platform ios --artifact ./artifacts/MyApp.app` - `bun run review:env:mobile-pr -- --context ./cali-context.json` diff --git a/packages/cali/package.json b/packages/cali/package.json index 6f68302..c5e4c97 100644 --- a/packages/cali/package.json +++ b/packages/cali/package.json @@ -11,8 +11,6 @@ "qa": "node ./dist/index.js qa", "qa:env:local:android": "node ./dist/index.js qa --env local-android", "qa:env:local:ios": "node ./dist/index.js qa --env local-ios", - "qa:env:mobile-pr": "node ./dist/index.js qa --env mobile-pr", - "qa:env:eas-mobile-pr": "node ./dist/index.js qa --env eas-mobile-pr", "review": "node ./dist/index.js review", "review:env:mobile-pr": "node ./dist/index.js review --env mobile-pr", "perf-review": "node ./dist/index.js perf-review", @@ -22,8 +20,6 @@ "dev:qa": "node --import=tsx ./src/cli.ts qa", "dev:qa:env:local:android": "node --import=tsx ./src/cli.ts qa --env local-android", "dev:qa:env:local:ios": "node --import=tsx ./src/cli.ts qa --env local-ios", - "dev:qa:env:mobile-pr": "node --import=tsx ./src/cli.ts qa --env mobile-pr", - "dev:qa:env:eas-mobile-pr": "node --import=tsx ./src/cli.ts qa --env eas-mobile-pr", "qa:ci:gha": "node ./dist/index.js qa --ci github-actions --quiet", "qa:ci:eas": "node ./dist/index.js qa --ci eas --quiet", "dev:review": "node --import=tsx ./src/cli.ts review", diff --git a/packages/cali/src/cli/qa.ts b/packages/cali/src/cli/qa.ts index 705c6a4..61c750f 100644 --- a/packages/cali/src/cli/qa.ts +++ b/packages/cali/src/cli/qa.ts @@ -22,7 +22,10 @@ function normalizeCiProvider(value: unknown): CiProvider | undefined { export const qaCommandDefinition = { register(cli: CAC) { - registerCommonMobileOptions(cli.command('qa', 'Run the mobile QA role')) + registerCommonMobileOptions( + cli.command('qa', 'Run the mobile QA role'), + 'Built-in env: local-android, local-ios' + ) .option('--ci ', 'CI provider context: github-actions or eas') .example( 'qa --env local-ios --artifact ./artifacts/MyApp.app --prompt "verify the onboarding copy on Screen B"' diff --git a/packages/cali/src/cli/shared.ts b/packages/cali/src/cli/shared.ts index 7aa1c7e..6434ebb 100644 --- a/packages/cali/src/cli/shared.ts +++ b/packages/cali/src/cli/shared.ts @@ -83,9 +83,12 @@ export function normalizeBaseCommandCliOptions(options: BaseCommandOptions): Com } } -export function registerCommonCommandOptions(command: any) { +export function registerCommonCommandOptions(command: any, envDescription?: string) { return command - .option('--env ', 'Built-in env: mobile-pr, eas-mobile-pr, local-android, local-ios') + .option( + '--env ', + envDescription ?? 'Built-in env: mobile-pr, eas-mobile-pr, local-android, local-ios' + ) .option('--config ', 'Path to cali.config.ts') .option('--prompt ', 'Add task-specific intent') .option('--context ', 'Load shared Cali runtime context from JSON') @@ -107,8 +110,8 @@ export function registerCommonCommandOptions(command: any) { .option('--logs-url ', 'Logs URL') } -export function registerCommonMobileOptions(command: any) { - return registerCommonCommandOptions(command) +export function registerCommonMobileOptions(command: any, envDescription?: string) { + return registerCommonCommandOptions(command, envDescription) .option('--platform ', 'android or ios') .option('--artifact ', 'App artifact path (.apk, .aab, .app, .ipa)') .option('--app-id ', 'Optional application identifier / package name override') diff --git a/packages/cali/src/commands/qa.ts b/packages/cali/src/commands/qa.ts index e22338d..7d8d931 100644 --- a/packages/cali/src/commands/qa.ts +++ b/packages/cali/src/commands/qa.ts @@ -76,8 +76,10 @@ function createBlockedReport(summary: string): QaReportInput { } export async function runQaCommand(cli: CommandCliOptions) { - if (cli.ciProvider && !cli.envName) { - cli.envName = cli.ciProvider === 'eas' ? 'eas-mobile-pr' : 'mobile-pr' + if (cli.envName === 'mobile-pr' || cli.envName === 'eas-mobile-pr') { + throw new Error( + '`cali qa` no longer supports `--env mobile-pr` or `--env eas-mobile-pr`. Use `--ci github-actions` or `--ci eas` for CI runs, or `--env local-android` / `--env local-ios` for local runs.' + ) } let acceptanceCriteriaUsed: string[] | undefined diff --git a/packages/cali/src/commands/shared.ts b/packages/cali/src/commands/shared.ts index 18e4b21..bb6e947 100644 --- a/packages/cali/src/commands/shared.ts +++ b/packages/cali/src/commands/shared.ts @@ -114,6 +114,7 @@ export async function loadRunContext(commandId: CommandId, cli: CommandCliOption cwd, configPath: cli.configPath, envName: cli.envName, + ciProvider: cli.ciProvider, model: cli.model, }) const injectedContext = cli.ciProvider diff --git a/packages/cali/src/config/load.ts b/packages/cali/src/config/load.ts index 500862d..b61c0fb 100644 --- a/packages/cali/src/config/load.ts +++ b/packages/cali/src/config/load.ts @@ -4,6 +4,7 @@ import path from 'node:path' import { cosmiconfig } from 'cosmiconfig' +import type { CiProvider } from '../runtime/ci-context.js' import type { CommandResolvedConfig } from '../runtime/types.js' import type { CommandConfigKey } from '../runtime/types.js' import { asArray, resolveFromCwd, uniqueStrings } from '../utils.js' @@ -21,6 +22,7 @@ type LoadCommandConfigOptions = { cwd: string configPath?: string envName?: CaliEnvName + ciProvider?: CiProvider model?: string } @@ -200,10 +202,12 @@ export async function loadCaliConfigFile(cwd: string, explicitPath?: string): Pr export async function loadCommandConfig( options: LoadCommandConfigOptions ): Promise { - const { commandId, cwd, configPath, envName: cliEnvName, model } = options + const { commandId, cwd, configPath, envName: cliEnvName, ciProvider, model } = options const fileConfig = await loadCaliConfigFile(cwd, configPath) const envName = - cliEnvName ?? normalizeCaliEnvName(fileConfig.env) ?? resolveDefaultEnvName(commandId) + cliEnvName ?? + normalizeCaliEnvName(fileConfig.env) ?? + resolveDefaultEnvName(commandId, ciProvider) const envDefaults = getEnvCommandDefaults(commandId, envName) const commandConfig = getCommandConfig(fileConfig, COMMAND_CONFIG_KEYS[commandId]) const merged = mergeCommandConfig(envDefaults, commandConfig) diff --git a/skills/cali/references/running-cali.md b/skills/cali/references/running-cali.md index 6bfefc0..677ca71 100644 --- a/skills/cali/references/running-cali.md +++ b/skills/cali/references/running-cali.md @@ -19,6 +19,10 @@ Use this reference for normal Cali usage, setup, and CI wiring. - all commands use one shared `cali-context.json` - flags override context values +For `qa`, use: +- `--env local-android` or `--env local-ios` for local runs +- `--ci github-actions` or `--ci eas` for CI runs + Built-in envs: - `mobile-pr` @@ -97,6 +101,7 @@ QA_MODEL=anthropic/claude-sonnet-4.6 ## CI notes - For CI, prefer `cali qa --ci github-actions` or `cali qa --ci eas`. +- `cali qa --env mobile-pr` and `cali qa --env eas-mobile-pr` are intentionally not supported. - Cali derives runtime context from provider env plus CLI overrides before the agent starts. - Use the explicit helper commands for integration glue: - `export-ci` From b415520d686f0dc20665835d9c7dec143ff63165 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 10 Apr 2026 14:12:52 +0200 Subject: [PATCH 42/48] refactor: simplify cali local and ci modes --- AGENTS.md | 23 ++- packages/cali/README.md | 50 ++++--- .../cali/examples/eas-workflows/run-qa.sh | 2 +- .../cali/examples/github-actions/run-qa.sh | 2 +- packages/cali/package.json | 11 +- packages/cali/src/cli/dev.ts | 4 +- packages/cali/src/cli/perf-review.ts | 8 +- packages/cali/src/cli/qa.ts | 26 +--- packages/cali/src/cli/review.ts | 4 +- packages/cali/src/cli/shared.ts | 37 +++-- packages/cali/src/commands/shared.ts | 32 ++-- packages/cali/src/config/load.ts | 141 ++++++++---------- packages/cali/src/config/schema.ts | 11 +- packages/cali/src/runtime/ci-context.ts | 78 +++++++--- packages/cali/src/runtime/mobile.ts | 23 ++- packages/cali/src/runtime/types.ts | 8 +- skills/cali/SKILL.md | 11 +- skills/cali/references/running-cali.md | 39 ++--- 18 files changed, 262 insertions(+), 248 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 247f3ca..47ef597 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -69,18 +69,11 @@ Current maturity: ## Core Contracts -### Env +### Local Mode -`env` is the only preset concept. +Use `--local android|ios` for local mobile runs. -Built-in envs: - -- `mobile-pr` -- `eas-mobile-pr` -- `local-android` -- `local-ios` - -An env sets defaults such as tool packs, publishers, and mobile defaults. It must not introduce workflow-specific runtime scraping. +CI metadata is detected automatically in GitHub Actions and EAS. Use `--ci github-actions|eas` only when you need to override detection. ### Context @@ -121,8 +114,8 @@ Required skill guidance should be preloaded through the tool-pack registry when - Bootstrap stays outside the role in the command module. - The role inspects the app and writes a structured QA report. -- Use `--ci github-actions|eas` for CI runs. -- Use `--env local-android|local-ios` for local runs. +- Use `--local android|ios` for local runs. +- In GitHub Actions and EAS, CI provider detection is automatic; `--ci` is only an override. - Requires `agent-device` on `PATH`. - Mobile runs use a unique per-run `agent-device` session. Do not reuse ambient sessions. - Local envs are convenience-first: try `open --relaunch` before reinstalling. @@ -137,18 +130,22 @@ Required skill guidance should be preloaded through the tool-pack registry when ### `review` - No code changes. +- In GitHub Actions and EAS, CI-derived repository and PR metadata is detected automatically. Use `--ci` only to override detection. - Findings first. - Prefer repository/diff evidence over generic advice. ### `perf-review` - Uses both `agent-device` and `react-devtools`. +- Use `--local android|ios` for local runs. +- In GitHub Actions and EAS, CI provider detection is automatic; `--ci` is only an override. - Requires `agent-device` and `agent-react-devtools` on `PATH`. - Focus on runtime evidence, not speculative optimizations. ### `dev` - Smallest code change that solves the task. +- In GitHub Actions and EAS, CI-derived repository and PR metadata is detected automatically. Use `--ci` only to override detection. - Repository tools rely on `git`, `rg`, and `zsh` being available. - Respect `context.dev.writePolicy` and `context.dev.pushPolicy`. @@ -179,6 +176,8 @@ Built bundle: - `bun run review -- --help` - `bun run perf-review -- --help` - `bun run dev:command -- --help` +- `bun run qa:local:android -- --artifact ./artifacts/app.apk` +- `bun run qa:local:ios -- --artifact ./artifacts/MyApp.app` - `bun run qa:ci:gha -- --platform android --artifact ./artifacts/app.apk` - `bun run qa:ci:eas -- --platform ios --artifact ./artifacts/MyApp.app` - `bun run export-ci -- --report ./artifacts/qa/report.json` diff --git a/packages/cali/README.md b/packages/cali/README.md index 88a3d41..6ec1022 100644 --- a/packages/cali/README.md +++ b/packages/cali/README.md @@ -3,7 +3,8 @@ Cali v2 is a role-oriented CLI for mobile React Native and Expo workflows. It runs first-class agent commands on top of a shared runtime model: - commands: `qa`, `review`, `perf-review`, `dev` -- envs: `mobile-pr`, `eas-mobile-pr`, `local-android`, `local-ios` +- local mobile mode: `--local android|ios` +- CI mode: implicit detection with optional `--ci github-actions|eas` override - one shared `cali-context.json` runtime contract - explicit tool packs per command - publisher-based outputs @@ -12,7 +13,7 @@ Cali v2 is a role-oriented CLI for mobile React Native and Expo workflows. It ru ## Core Concepts - command: the user-facing role entrypoint such as `cali qa` or `cali review` -- env: default runtime shape for a command; for `qa`, use local envs only and prefer `--ci` in CI +- local: local mobile mode selector for `qa` and `perf-review` - context file: the optional explicit JSON input for workspace, repository, PR/task, mobile, build, output, and role-specific sections - tool pack: a bounded capability surface such as `agent-device`, `react-devtools`, `repo-read`, or `repo-write` - publisher: how reports are exposed after a run, such as `file` or `blob` @@ -92,7 +93,7 @@ For safety, Cali sanitizes credential-bearing repository URLs when loading conte ```bash cali qa \ - --env local-ios \ + --local ios \ --artifact ./artifacts/MyApp.app \ --prompt "verify the onboarding copy on Screen B" ``` @@ -101,18 +102,18 @@ Local mobile behavior: - each run gets a unique `agent-device` session name such as `ios-a1b2c` - local Android reuses the single booted emulator/device when exactly one is available, otherwise pass `--device` -- local envs try `open --relaunch` before reinstalling -- `local-ios` reuses the single booted simulator when exactly one is available, otherwise pass `--device` +- local runs try `open --relaunch` before reinstalling +- local iOS reuses the single booted simulator when exactly one is available, otherwise pass `--device` -### CI-native QA or review +### CI-native commands ```bash -cali qa --ci github-actions --platform ios --artifact ./artifacts/MyApp.app -cali qa --ci eas --platform android --artifact ./artifacts/app.apk -cali review --env mobile-pr --context ./cali-context.json +cali qa --platform ios --artifact ./artifacts/MyApp.app +cali qa --platform android --artifact ./artifacts/app.apk +cali review --context ./cali-context.json ``` -`cali qa` no longer supports `--env mobile-pr` or `--env eas-mobile-pr`. Use `--ci github-actions|eas` for CI runs. +In GitHub Actions and EAS, Cali detects the provider automatically from the environment. Use `--ci` only to override detection. Use `--local android|ios` for local mobile runs. Use `--quiet` to suppress the retro banner in scripted environments. Cali also suppresses the banner automatically when `CI=true`. @@ -120,15 +121,16 @@ Use `--quiet` to suppress the retro banner in scripted environments. Cali also s ```bash cali perf-review \ - --env mobile-pr \ --context ./cali-context.json \ + --platform android \ + --artifact ./artifacts/app.apk \ --prompt "profile the checkout flow" ``` ### Repo-backed implementation ```bash -cali dev --env mobile-pr --context ./cali-context.json --prompt "implement issue 123" +cali dev --context ./cali-context.json --prompt "implement issue 123" ``` ## Provider Setup @@ -214,14 +216,14 @@ npx skills add callstackincubator/agent-skills --agent codex --skill react-devto ## CI Providers -The CI-native entrypoint is `cali qa --ci `. +The CI-native entrypoint is `cali `, with provider detection handled automatically in GitHub Actions and EAS. Use `--ci ` only to override detection. Supported providers: - `github-actions` - `eas` -For CI runs, Cali derives runtime context from provider env plus CLI overrides. You no longer need a separate `write-mobile-pr-context` step. +For CI runs, Cali derives runtime context from provider env plus CLI overrides directly inside the command. Required provider inputs: @@ -247,8 +249,8 @@ Required provider inputs: Core CI command: ```bash -cali qa --ci github-actions --quiet --platform ios --artifact ./artifacts/MyApp.app -cali qa --ci eas --quiet --platform android --artifact ./artifacts/app.apk +cali qa --quiet --platform ios --artifact ./artifacts/MyApp.app +cali qa --quiet --platform android --artifact ./artifacts/app.apk ``` Optional helpers: @@ -272,7 +274,7 @@ Minimal GitHub Actions example: CALI_PLATFORM: android CALI_ARTIFACT_PATH: ${{ steps.download_build.outputs.artifact_path }} CALI_APP_ID: com.example.myapp - run: node ./packages/cali/dist/index.js qa --ci github-actions --quiet + run: node ./packages/cali/dist/index.js qa --quiet - name: Export CI comment run: node ./packages/cali/dist/index.js export-ci --report ./artifacts/qa/report.json @@ -305,7 +307,7 @@ Minimal EAS example: BUILD_ID: ${{ env.BUILD_ID }} WORKFLOW_URL: ${{ workflow.url }} PR_JSON: ${{ toJSON(github.event.pull_request) }} - run: node ./packages/cali/dist/index.js qa --ci eas --quiet + run: node ./packages/cali/dist/index.js qa --quiet - id: export_cali_ci run: node ./packages/cali/dist/index.js export-ci --report ./artifacts/qa/report.json @@ -337,12 +339,14 @@ Create `cali.config.ts` in the project root: ```ts export default { defaultCommand: 'qa', - env: 'mobile-pr', workspaceRoot: '.', skillPaths: ['.agents/skills'], commands: { qa: { contextPath: './cali-context.json', + mobileDefaults: { + platform: 'android', + }, extraInstructions: ['Prioritize auth and onboarding flows.'], }, review: { @@ -382,11 +386,13 @@ Built bundle: - `bun run review -- --help` - `bun run perf-review -- --help` - `bun run dev:command -- --help` +- `bun run qa:local:android -- --artifact ./artifacts/app.apk` +- `bun run qa:local:ios -- --artifact ./artifacts/MyApp.app` - `bun run qa:ci:gha -- --platform android --artifact ./artifacts/app.apk` - `bun run qa:ci:eas -- --platform ios --artifact ./artifacts/MyApp.app` -- `bun run review:env:mobile-pr -- --context ./cali-context.json` -- `bun run perf-review:env:mobile-pr -- --context ./cali-context.json` -- `bun run dev:command:env:mobile-pr -- --context ./cali-context.json` +- `bun run review -- --context ./cali-context.json` +- `bun run perf-review -- --context ./cali-context.json --platform android --artifact ./artifacts/app.apk` +- `bun run dev:command -- --context ./cali-context.json` - `bun run export-ci -- --report ./artifacts/qa/report.json` Source/dev loop: diff --git a/packages/cali/examples/eas-workflows/run-qa.sh b/packages/cali/examples/eas-workflows/run-qa.sh index 180bf03..1ba6a1a 100644 --- a/packages/cali/examples/eas-workflows/run-qa.sh +++ b/packages/cali/examples/eas-workflows/run-qa.sh @@ -2,4 +2,4 @@ set -euo pipefail -node ./packages/cali/dist/index.js qa --ci eas --quiet "$@" +node ./packages/cali/dist/index.js qa --quiet "$@" diff --git a/packages/cali/examples/github-actions/run-qa.sh b/packages/cali/examples/github-actions/run-qa.sh index a041d69..1ba6a1a 100644 --- a/packages/cali/examples/github-actions/run-qa.sh +++ b/packages/cali/examples/github-actions/run-qa.sh @@ -2,4 +2,4 @@ set -euo pipefail -node ./packages/cali/dist/index.js qa --ci github-actions --quiet "$@" +node ./packages/cali/dist/index.js qa --quiet "$@" diff --git a/packages/cali/package.json b/packages/cali/package.json index c5e4c97..abf368a 100644 --- a/packages/cali/package.json +++ b/packages/cali/package.json @@ -9,17 +9,14 @@ "dev": "node --import=tsx ./src/cli.ts", "start": "node ./dist/index.js", "qa": "node ./dist/index.js qa", - "qa:env:local:android": "node ./dist/index.js qa --env local-android", - "qa:env:local:ios": "node ./dist/index.js qa --env local-ios", + "qa:local:android": "node ./dist/index.js qa --local android", + "qa:local:ios": "node ./dist/index.js qa --local ios", "review": "node ./dist/index.js review", - "review:env:mobile-pr": "node ./dist/index.js review --env mobile-pr", "perf-review": "node ./dist/index.js perf-review", - "perf-review:env:mobile-pr": "node ./dist/index.js perf-review --env mobile-pr", "dev:command": "node ./dist/index.js dev", - "dev:command:env:mobile-pr": "node ./dist/index.js dev --env mobile-pr", "dev:qa": "node --import=tsx ./src/cli.ts qa", - "dev:qa:env:local:android": "node --import=tsx ./src/cli.ts qa --env local-android", - "dev:qa:env:local:ios": "node --import=tsx ./src/cli.ts qa --env local-ios", + "dev:qa:local:android": "node --import=tsx ./src/cli.ts qa --local android", + "dev:qa:local:ios": "node --import=tsx ./src/cli.ts qa --local ios", "qa:ci:gha": "node ./dist/index.js qa --ci github-actions --quiet", "qa:ci:eas": "node ./dist/index.js qa --ci eas --quiet", "dev:review": "node --import=tsx ./src/cli.ts review", diff --git a/packages/cali/src/cli/dev.ts b/packages/cali/src/cli/dev.ts index 42846f0..382459f 100644 --- a/packages/cali/src/cli/dev.ts +++ b/packages/cali/src/cli/dev.ts @@ -10,9 +10,9 @@ import { export const devCommandDefinition = { register(cli: CAC) { registerCommonCommandOptions( - cli.command('dev', 'Run the mobile development role (experimental)') + cli.command('dev', 'Run the repository development role (experimental)') ) - .example('dev --env mobile-pr --context ./cali-context.json --prompt "implement issue 123"') + .example('dev --context ./cali-context.json --prompt "implement issue 123"') .action(async (options: unknown) => { await runDevCommand(normalizeBaseCommandCliOptions(options as BaseCommandOptions)) }) diff --git a/packages/cali/src/cli/perf-review.ts b/packages/cali/src/cli/perf-review.ts index 416ba01..680d6b6 100644 --- a/packages/cali/src/cli/perf-review.ts +++ b/packages/cali/src/cli/perf-review.ts @@ -10,10 +10,14 @@ import { export const perfReviewCommandDefinition = { register(cli: CAC) { registerCommonMobileOptions( - cli.command('perf-review', 'Run the mobile performance review role (experimental)') + cli.command('perf-review', 'Run the mobile performance review role (experimental)'), + 'Local mobile mode: android or ios' ) .example( - 'perf-review --env mobile-pr --context ./cali-context.json --prompt "profile the checkout flow"' + 'perf-review --local ios --artifact ./artifacts/MyApp.app --prompt "profile the checkout flow"' + ) + .example( + 'perf-review --context ./cali-context.json --platform android --artifact ./artifacts/app.apk --prompt "profile the checkout flow"' ) .action(async (options: unknown) => { await runPerfReviewCommand(normalizeBaseCommandCliOptions(options as BaseCommandOptions)) diff --git a/packages/cali/src/cli/qa.ts b/packages/cali/src/cli/qa.ts index 61c750f..7c6ebbe 100644 --- a/packages/cali/src/cli/qa.ts +++ b/packages/cali/src/cli/qa.ts @@ -1,41 +1,25 @@ import type { CAC } from 'cac' import { runQaCommand } from '../commands/qa.js' -import type { CiProvider } from '../runtime/ci-context.js' import { type BaseCommandOptions, normalizeBaseCommandCliOptions, registerCommonMobileOptions, } from './shared.js' -function normalizeCiProvider(value: unknown): CiProvider | undefined { - if (value == null || value === '') { - return undefined - } - - if (value === 'github-actions' || value === 'eas') { - return value - } - - throw new Error('`--ci` must be `github-actions` or `eas`.') -} - export const qaCommandDefinition = { register(cli: CAC) { registerCommonMobileOptions( cli.command('qa', 'Run the mobile QA role'), - 'Built-in env: local-android, local-ios' + 'Local mobile mode: android or ios' ) - .option('--ci ', 'CI provider context: github-actions or eas') .example( - 'qa --env local-ios --artifact ./artifacts/MyApp.app --prompt "verify the onboarding copy on Screen B"' + 'qa --local ios --artifact ./artifacts/MyApp.app --prompt "verify the onboarding copy on Screen B"' ) - .example('qa --ci github-actions --platform ios --artifact ./artifacts/MyApp.app') - .example('qa --ci eas --platform android --artifact ./artifacts/app.apk') + .example('qa --platform ios --artifact ./artifacts/MyApp.app') + .example('qa --platform android --artifact ./artifacts/app.apk') .action(async (options: unknown) => { - const normalized = normalizeBaseCommandCliOptions(options as BaseCommandOptions) - normalized.ciProvider = normalizeCiProvider((options as BaseCommandOptions).ci) - await runQaCommand(normalized) + await runQaCommand(normalizeBaseCommandCliOptions(options as BaseCommandOptions)) }) }, } diff --git a/packages/cali/src/cli/review.ts b/packages/cali/src/cli/review.ts index 65ee605..f10f5dc 100644 --- a/packages/cali/src/cli/review.ts +++ b/packages/cali/src/cli/review.ts @@ -10,9 +10,9 @@ import { export const reviewCommandDefinition = { register(cli: CAC) { registerCommonCommandOptions( - cli.command('review', 'Run the mobile code review role (experimental)') + cli.command('review', 'Run the repository review role (experimental)') ) - .example('review --env mobile-pr --context ./cali-context.json') + .example('review --context ./cali-context.json') .action(async (options: unknown) => { await runReviewCommand(normalizeBaseCommandCliOptions(options as BaseCommandOptions)) }) diff --git a/packages/cali/src/cli/shared.ts b/packages/cali/src/cli/shared.ts index 6434ebb..188a94a 100644 --- a/packages/cali/src/cli/shared.ts +++ b/packages/cali/src/cli/shared.ts @@ -1,9 +1,10 @@ +import { CaliPlatformSchema } from '../config/schema.js' import type { CommandCliOptions } from '../runtime/types.js' import { normalizePlatform } from '../utils.js' export type BaseCommandOptions = { ci?: string - env?: string + local?: string config?: string prompt?: string context?: string @@ -49,14 +50,30 @@ export function readOptionalNumber(value: unknown, flagName: string) { export function normalizeBaseCommandCliOptions(options: BaseCommandOptions): CommandCliOptions { const platformValue = readOptionalString(options.platform) const platform = platformValue ? normalizePlatform(platformValue) : undefined + const localValue = readOptionalString(options.local) + const localResult = localValue ? CaliPlatformSchema.safeParse(localValue) : undefined + const localPlatform = localResult?.success ? localResult.data : undefined + const ciProvider = readOptionalString(options.ci) as CommandCliOptions['ciProvider'] if (platformValue && !platform) { throw new Error('`--platform` must be `android` or `ios`.') } + if (localValue && !localPlatform) { + throw new Error('`--local` must be `android` or `ios`.') + } + + if (ciProvider && ciProvider !== 'github-actions' && ciProvider !== 'eas') { + throw new Error('`--ci` must be `github-actions` or `eas`.') + } + + if (localPlatform && ciProvider) { + throw new Error('Do not combine `--local` with `--ci`.') + } + return { - ciProvider: readOptionalString(options.ci) as CommandCliOptions['ciProvider'], - envName: readOptionalString(options.env) as CommandCliOptions['envName'], + ciProvider, + localPlatform, configPath: readOptionalString(options.config), prompt: readOptionalString(options.prompt), contextPath: readOptionalString(options.context), @@ -83,12 +100,9 @@ export function normalizeBaseCommandCliOptions(options: BaseCommandOptions): Com } } -export function registerCommonCommandOptions(command: any, envDescription?: string) { +export function registerCommonCommandOptions(command: any) { return command - .option( - '--env ', - envDescription ?? 'Built-in env: mobile-pr, eas-mobile-pr, local-android, local-ios' - ) + .option('--ci ', 'Override CI provider detection: github-actions or eas') .option('--config ', 'Path to cali.config.ts') .option('--prompt ', 'Add task-specific intent') .option('--context ', 'Load shared Cali runtime context from JSON') @@ -110,9 +124,10 @@ export function registerCommonCommandOptions(command: any, envDescription?: stri .option('--logs-url ', 'Logs URL') } -export function registerCommonMobileOptions(command: any, envDescription?: string) { - return registerCommonCommandOptions(command, envDescription) - .option('--platform ', 'android or ios') +export function registerCommonMobileOptions(command: any, localDescription?: string) { + return registerCommonCommandOptions(command) + .option('--local ', localDescription ?? 'Local mobile mode: android or ios') + .option('--platform ', 'Override platform: android or ios') .option('--artifact ', 'App artifact path (.apk, .aab, .app, .ipa)') .option('--app-id ', 'Optional application identifier / package name override') .option('--device ', 'Simulator or emulator name to provision') diff --git a/packages/cali/src/commands/shared.ts b/packages/cali/src/commands/shared.ts index bb6e947..45c1f23 100644 --- a/packages/cali/src/commands/shared.ts +++ b/packages/cali/src/commands/shared.ts @@ -3,10 +3,10 @@ import path from 'node:path' import { tool } from 'ai' import { z } from 'zod' -import { loadCommandConfig, resolveDefaultEnvName } from '../config/load.js' +import { loadCommandConfig, resolveDefaultLocalPlatform } from '../config/load.js' import type { ToolPackName } from '../config/schema.js' import type { CommandReport } from '../report/types.js' -import { buildCiContext } from '../runtime/ci-context.js' +import { buildCiContext, detectCiProvider } from '../runtime/ci-context.js' import { resolveCommandContext } from '../runtime/context.js' import { bootstrapMobileApp, @@ -107,18 +107,19 @@ export function formatAgentFinishDetail(event: AgentFinishEvent) { export async function loadRunContext(commandId: CommandId, cli: CommandCliOptions) { const cwd = process.cwd() + const ciProvider = cli.localPlatform ? undefined : (cli.ciProvider ?? detectCiProvider()) printPhase('Resolving config') const config = await loadCommandConfig({ commandId, cwd, configPath: cli.configPath, - envName: cli.envName, - ciProvider: cli.ciProvider, + localPlatform: cli.localPlatform, + ciProvider, model: cli.model, }) - const injectedContext = cli.ciProvider - ? await buildCiContext(cwd, cli.ciProvider, { + const injectedContext = ciProvider + ? await buildCiContext(commandId, cwd, ciProvider, { workspaceRoot: cli.workspaceRoot, platform: cli.platform, artifactPath: cli.artifactPath, @@ -134,6 +135,7 @@ export async function loadRunContext(commandId: CommandId, cli: CommandCliOption return { cwd, + ciProvider, config, context, } @@ -144,8 +146,8 @@ function createFallbackConfig( cwd: string, cli: CommandCliOptions ): CommandResolvedConfig { + const ciProvider = cli.localPlatform ? undefined : (cli.ciProvider ?? detectCiProvider()) return { - envName: cli.envName ?? resolveDefaultEnvName(commandId, cli.ciProvider), workspaceRoot: cli.workspaceRoot ? resolveFromCwd(cwd, cli.workspaceRoot) : cwd, contextPath: undefined, skillPaths: [], @@ -153,7 +155,11 @@ function createFallbackConfig( outputPublishers: ['file'], extraInstructions: [], model: cli.model ?? process.env.QA_MODEL ?? 'openai/gpt-5.4-mini', - mobileDefaults: {}, + mobileDefaults: { + platform: ciProvider + ? undefined + : (cli.localPlatform ?? resolveDefaultLocalPlatform(commandId)), + }, } } @@ -168,10 +174,7 @@ function createFallbackContext( workspaceRoot, cli.outputDir ?? path.join('artifacts', commandId) ) - const platform = - cli.platform ?? - config.mobileDefaults.platform ?? - (config.envName === 'local-ios' ? 'ios' : undefined) + const platform = cli.platform ?? config.mobileDefaults.platform ?? cli.localPlatform return { workspaceRoot, @@ -372,10 +375,11 @@ export async function runMobileStructuredCommand = { - 'mobile-pr': { - ...MOBILE_PR_QA_DEFAULTS, - }, - 'eas-mobile-pr': { - ...MOBILE_PR_QA_DEFAULTS, - extraInstructions: [ - ...asArray(MOBILE_PR_QA_DEFAULTS.extraInstructions), - 'This run is expected to execute in EAS-style CI with runtime context derived before the agent starts.', - ], - }, - 'local-ios': { - enabledToolPacks: ['skills', 'agent-device'], - outputPublishers: ['blob', 'file'], - mobileDefaults: { - platform: 'ios', - }, - extraInstructions: [ - 'This is a local iOS QA run. Keep the flow lightweight and focus on the highest-signal UI paths.', - ], - }, - 'local-android': { +function createLocalQaDefaults(platform: CaliPlatform): CaliCommandConfig { + return { enabledToolPacks: ['skills', 'agent-device'], outputPublishers: ['blob', 'file'], mobileDefaults: { - platform: 'android', + platform, }, extraInstructions: [ - 'This is a local Android QA run. Keep the flow lightweight and focus on the highest-signal UI paths.', + `This is a local ${platform} QA run. Keep the flow lightweight and focus on the highest-signal UI paths.`, ], - }, + } } -function getEnvCommandDefaults(commandId: CommandId, envName: CaliEnvName): CaliCommandConfig { +function getCommandDefaults( + commandId: CommandId, + options: { + localPlatform?: CaliPlatform + ciProvider?: CiProvider + } +): CaliCommandConfig { + const { localPlatform, ciProvider } = options const commonOutputPublishers: PublisherName[] = ['file'] const mobileOutputPublishers: PublisherName[] = ['blob', 'file'] switch (commandId) { case 'qa': - return QA_ENV_DEFAULTS[envName] + if (ciProvider === 'eas') { + return { + ...MOBILE_CI_QA_DEFAULTS, + extraInstructions: [ + ...asArray(MOBILE_CI_QA_DEFAULTS.extraInstructions), + 'This run is expected to execute in EAS-style CI with runtime context derived before the agent starts.', + ], + } + } + + if (ciProvider === 'github-actions') { + return { ...MOBILE_CI_QA_DEFAULTS } + } + + return createLocalQaDefaults(localPlatform ?? 'android') case 'perf-review': - switch (envName) { - case 'mobile-pr': - case 'eas-mobile-pr': - return { - enabledToolPacks: ['skills', 'agent-device', 'react-devtools', 'repo-read'], - outputPublishers: mobileOutputPublishers, - extraInstructions: [ - 'Focus on high-signal runtime performance evidence such as rerenders, slow interactions, and component-level bottlenecks.', - ], - } - case 'local-ios': - return { - enabledToolPacks: ['skills', 'agent-device', 'react-devtools', 'repo-read'], - outputPublishers: mobileOutputPublishers, - mobileDefaults: { - platform: 'ios', - }, - } - case 'local-android': - default: - return { - enabledToolPacks: ['skills', 'agent-device', 'react-devtools', 'repo-read'], - outputPublishers: mobileOutputPublishers, - mobileDefaults: { - platform: 'android', - }, - } + if (ciProvider) { + return { + enabledToolPacks: ['skills', 'agent-device', 'react-devtools', 'repo-read'], + outputPublishers: mobileOutputPublishers, + extraInstructions: [ + 'Focus on high-signal runtime performance evidence such as rerenders, slow interactions, and component-level bottlenecks.', + ], + } + } + + return { + enabledToolPacks: ['skills', 'agent-device', 'react-devtools', 'repo-read'], + outputPublishers: mobileOutputPublishers, + mobileDefaults: { + platform: localPlatform ?? 'android', + }, } case 'review': return { @@ -202,18 +181,18 @@ export async function loadCaliConfigFile(cwd: string, explicitPath?: string): Pr export async function loadCommandConfig( options: LoadCommandConfigOptions ): Promise { - const { commandId, cwd, configPath, envName: cliEnvName, ciProvider, model } = options + const { commandId, cwd, configPath, localPlatform, ciProvider, model } = options const fileConfig = await loadCaliConfigFile(cwd, configPath) - const envName = - cliEnvName ?? - normalizeCaliEnvName(fileConfig.env) ?? - resolveDefaultEnvName(commandId, ciProvider) - const envDefaults = getEnvCommandDefaults(commandId, envName) + const resolvedLocalPlatform = + ciProvider != null ? undefined : (localPlatform ?? resolveDefaultLocalPlatform(commandId)) + const envDefaults = getCommandDefaults(commandId, { + localPlatform: resolvedLocalPlatform, + ciProvider, + }) const commandConfig = getCommandConfig(fileConfig, COMMAND_CONFIG_KEYS[commandId]) const merged = mergeCommandConfig(envDefaults, commandConfig) return { - envName, workspaceRoot: fileConfig.workspaceRoot ? resolveFromCwd(cwd, fileConfig.workspaceRoot) : undefined, diff --git a/packages/cali/src/config/schema.ts b/packages/cali/src/config/schema.ts index a14d7e4..c1b1d39 100644 --- a/packages/cali/src/config/schema.ts +++ b/packages/cali/src/config/schema.ts @@ -1,6 +1,5 @@ import { z } from 'zod' -const CaliEnvNameSchema = z.enum(['mobile-pr', 'eas-mobile-pr', 'local-android', 'local-ios']) const ToolPackNameSchema = z.enum([ 'skills', 'agent-device', @@ -11,7 +10,7 @@ const ToolPackNameSchema = z.enum([ const PublisherNameSchema = z.enum(['file', 'blob']) export const COMMAND_IDS = ['qa', 'review', 'perf-review', 'dev'] as const const CommandIdSchema = z.enum(COMMAND_IDS) -const CaliPlatformSchema = z.enum(['android', 'ios']) +export const CaliPlatformSchema = z.enum(['android', 'ios']) const StringArraySchema = z.union([z.string(), z.array(z.string())]).optional() @@ -35,15 +34,9 @@ const CommandConfigSchema = z }) .strict() -export function normalizeCaliEnvName(value?: string): CaliEnvName | undefined { - const result = CaliEnvNameSchema.safeParse(value) - return result.success ? result.data : undefined -} - export const CaliConfigSchema = z .object({ defaultCommand: CommandIdSchema.optional(), - env: CaliEnvNameSchema.optional(), workspaceRoot: z.string().optional(), skillPaths: z.array(z.string()).optional(), outputPublishers: z.array(PublisherNameSchema).optional(), @@ -60,9 +53,9 @@ export const CaliConfigSchema = z }) .strict() -export type CaliEnvName = z.infer export type ToolPackName = z.infer export type PublisherName = z.infer export type CommandId = z.infer export type CaliConfig = z.infer export type CaliCommandConfig = z.infer +export type CaliPlatform = z.infer diff --git a/packages/cali/src/runtime/ci-context.ts b/packages/cali/src/runtime/ci-context.ts index 86ec7e9..1b5d811 100644 --- a/packages/cali/src/runtime/ci-context.ts +++ b/packages/cali/src/runtime/ci-context.ts @@ -2,7 +2,7 @@ import { readFile } from 'node:fs/promises' import { DOCS_URLS } from '../docs.js' import { detectRepositoryContext, sanitizeUrl } from './context-repo.js' -import type { CaliContext, CaliPlatform } from './types.js' +import type { CaliContext, CaliPlatform, CommandId } from './types.js' export type CiProvider = 'github-actions' | 'eas' @@ -23,6 +23,25 @@ function readOptionalEnv(name: string) { return value && value.length > 0 ? value : undefined } +export function detectCiProvider(): CiProvider | undefined { + if (process.env.GITHUB_ACTIONS === 'true' || readOptionalEnv('GITHUB_EVENT_PATH')) { + return 'github-actions' + } + + if ( + process.env.EAS_BUILD === 'true' || + readOptionalEnv('APP_PATH') || + readOptionalEnv('QA_PLATFORM') || + readOptionalEnv('PR_JSON') || + readOptionalEnv('BUILD_ID') || + readOptionalEnv('WORKFLOW_URL') + ) { + return 'eas' + } + + return undefined +} + function normalizePlatform(value: string | undefined): CaliPlatform | undefined { return value === 'android' || value === 'ios' ? value : undefined } @@ -90,11 +109,12 @@ function readPullRequestJson(rawPrJson: string | undefined) { } function createCommonContext(options: { + commandId: CommandId workspaceRoot: string repository?: CaliContext['repository'] pullRequest?: CaliContext['pullRequest'] - platform: CaliPlatform - artifactPath: string + platform?: CaliPlatform + artifactPath?: string appId?: string deviceName?: string outputDir: string @@ -106,12 +126,15 @@ function createCommonContext(options: { workspaceRoot: options.workspaceRoot, repository: options.repository, pullRequest: options.pullRequest, - mobile: { - platform: options.platform, - artifactPath: options.artifactPath, - appId: options.appId, - deviceName: options.deviceName, - }, + mobile: + options.commandId === 'qa' || options.commandId === 'perf-review' + ? { + platform: options.platform, + artifactPath: options.artifactPath, + appId: options.appId, + deviceName: options.deviceName, + } + : undefined, build: options.buildId || options.workflowUrl || options.logsUrl ? { @@ -127,6 +150,7 @@ function createCommonContext(options: { } async function buildGithubActionsContext( + commandId: CommandId, cwd: string, options: BuildCiContextOptions ): Promise> { @@ -138,7 +162,8 @@ async function buildGithubActionsContext( const event = await loadJsonFile(eventPath) const detectedRepository = await detectRepositoryContext(cwd) const githubRepository = resolveGithubRepositoryContext() - const outputDir = options.outputDir ?? readOptionalEnv('CALI_OUTPUT_DIR') ?? './artifacts/qa' + const outputDir = + options.outputDir ?? readOptionalEnv('CALI_OUTPUT_DIR') ?? `./artifacts/${commandId}` const buildId = options.buildId ?? readOptionalEnv('GITHUB_RUN_ID') const platform = options.platform ?? normalizePlatform(readOptionalEnv('CALI_PLATFORM')) const artifactPath = options.artifactPath ?? readOptionalEnv('CALI_ARTIFACT_PATH') @@ -150,15 +175,20 @@ async function buildGithubActionsContext( ? `${serverUrl}/${repositoryName}/actions/runs/${buildId}` : undefined) - if (!platform) { - throw createCiContextError('GitHub Actions CI mode requires CALI_PLATFORM or --platform.') + if ((commandId === 'qa' || commandId === 'perf-review') && !platform) { + throw createCiContextError( + 'GitHub Actions CI mode requires CALI_PLATFORM or --platform for mobile commands.' + ) } - if (!artifactPath) { - throw createCiContextError('GitHub Actions CI mode requires CALI_ARTIFACT_PATH or --artifact.') + if ((commandId === 'qa' || commandId === 'perf-review') && !artifactPath) { + throw createCiContextError( + 'GitHub Actions CI mode requires CALI_ARTIFACT_PATH or --artifact for mobile commands.' + ) } return createCommonContext({ + commandId, workspaceRoot: options.workspaceRoot ?? readOptionalEnv('GITHUB_WORKSPACE') ?? cwd, repository: { ...detectedRepository.repository, @@ -177,24 +207,29 @@ async function buildGithubActionsContext( } async function buildEasContext( + commandId: CommandId, cwd: string, options: BuildCiContextOptions ): Promise> { const detectedRepository = await detectRepositoryContext(cwd) const githubRepository = resolveGithubRepositoryContext() - const outputDir = options.outputDir ?? readOptionalEnv('CALI_OUTPUT_DIR') ?? './artifacts/qa' + const outputDir = + options.outputDir ?? readOptionalEnv('CALI_OUTPUT_DIR') ?? `./artifacts/${commandId}` const artifactPath = options.artifactPath ?? readOptionalEnv('APP_PATH') const platform = options.platform ?? normalizePlatform(readOptionalEnv('QA_PLATFORM')) - if (!artifactPath) { - throw createCiContextError('EAS CI mode requires APP_PATH or --artifact.') + if ((commandId === 'qa' || commandId === 'perf-review') && !artifactPath) { + throw createCiContextError('EAS CI mode requires APP_PATH or --artifact for mobile commands.') } - if (!platform) { - throw createCiContextError('EAS CI mode requires QA_PLATFORM or --platform.') + if ((commandId === 'qa' || commandId === 'perf-review') && !platform) { + throw createCiContextError( + 'EAS CI mode requires QA_PLATFORM or --platform for mobile commands.' + ) } return createCommonContext({ + commandId, workspaceRoot: options.workspaceRoot ?? cwd, repository: { ...detectedRepository.repository, @@ -213,13 +248,14 @@ async function buildEasContext( } export async function buildCiContext( + commandId: CommandId, cwd: string, provider: CiProvider, options: BuildCiContextOptions ): Promise> { if (provider === 'github-actions') { - return buildGithubActionsContext(cwd, options) + return buildGithubActionsContext(commandId, cwd, options) } - return buildEasContext(cwd, options) + return buildEasContext(commandId, cwd, options) } diff --git a/packages/cali/src/runtime/mobile.ts b/packages/cali/src/runtime/mobile.ts index 7762ccf..ddd53d8 100644 --- a/packages/cali/src/runtime/mobile.ts +++ b/packages/cali/src/runtime/mobile.ts @@ -4,7 +4,6 @@ import { homedir } from 'node:os' import path from 'node:path' import { TextDecoder } from 'node:util' -import type { CaliEnvName } from '../config/schema.js' import type { ScreenshotInfo } from '../report/types.js' import { getAgentDeviceSessionArgs } from '../tools/agent-device.js' import { ensureCommandExists, ensureDirectory, runCommand } from '../utils.js' @@ -517,12 +516,12 @@ async function resolveLocalAndroidDeviceName(explicitDeviceName?: string) { if (bootedDevices.length > 1) { throw new Error( - `local-android requires --device when more than one Android target is booted.\n\nBooted targets:\n- ${bootedDevices.join('\n- ')}` + `Local Android mode requires --device when more than one Android target is booted.\n\nBooted targets:\n- ${bootedDevices.join('\n- ')}` ) } throw new Error( - 'local-android requires a booted Android device or emulator. Boot one first or pass --device so Cali can provision it deterministically.' + 'Local Android mode requires a booted Android device or emulator. Boot one first or pass --device so Cali can provision it deterministically.' ) } @@ -542,11 +541,11 @@ async function resolveLocalIosDeviceName(explicitDeviceName?: string) { if (bootedSimulators.length > 1) { throw new Error( - `local-ios requires --device when more than one iOS simulator is booted.\n\nBooted simulators:\n- ${bootedSimulators.join('\n- ')}` + `Local iOS mode requires --device when more than one iOS simulator is booted.\n\nBooted simulators:\n- ${bootedSimulators.join('\n- ')}` ) } - throw new Error('local-ios requires --device or exactly one booted iOS simulator.') + throw new Error('Local iOS mode requires --device or exactly one booted iOS simulator.') } async function ensureTargetReady(context: MobileCommandRuntimeContext) { @@ -605,10 +604,6 @@ async function installFreshArtifact( ) } -function isLocalEnv(envName: CaliEnvName) { - return envName === 'local-android' || envName === 'local-ios' -} - export function createAgentDeviceSessionName(platform: CaliPlatform) { const hash = createHash('md5') .update(`${platform}:${process.cwd()}:${Date.now()}:${Math.random()}`) @@ -620,7 +615,7 @@ export function createAgentDeviceSessionName(platform: CaliPlatform) { export async function resolveMobileRuntimeContext( commandId: CommandId, - envName: CaliEnvName, + localMode: boolean, context: CaliContext ): Promise { await ensureCommandExists('agent-device', 'npm i -g agent-device') @@ -659,9 +654,9 @@ export async function resolveMobileRuntimeContext( } let deviceName = context.mobile?.deviceName - if (envName === 'local-ios') { + if (localMode && platform === 'ios') { deviceName = await resolveLocalIosDeviceName(deviceName) - } else if (envName === 'local-android') { + } else if (localMode && platform === 'android') { deviceName = await resolveLocalAndroidDeviceName(deviceName) } @@ -677,13 +672,13 @@ export async function resolveMobileRuntimeContext( export async function bootstrapMobileApp( commandId: 'qa' | 'perf-review', - envName: CaliEnvName, + localMode: boolean, context: MobileCommandRuntimeContext, sessionName: string ) { await ensureTargetReady(context) - if (isLocalEnv(envName)) { + if (localMode) { const openResult = await openAppSession(sessionName, context, { allowFailure: true, }) diff --git a/packages/cali/src/runtime/types.ts b/packages/cali/src/runtime/types.ts index ec944ba..53a8f25 100644 --- a/packages/cali/src/runtime/types.ts +++ b/packages/cali/src/runtime/types.ts @@ -1,10 +1,9 @@ -import type { CaliEnvName, PublisherName, ToolPackName } from '../config/schema.js' +import type { CaliPlatform, PublisherName, ToolPackName } from '../config/schema.js' import type { CiProvider } from './ci-context.js' export type { CommandId } from '../config/schema.js' +export type { CaliPlatform } from '../config/schema.js' export type CommandConfigKey = 'qa' | 'review' | 'perfReview' | 'dev' -export type CaliPlatform = 'android' | 'ios' - export type RepositoryContext = { provider?: string owner?: string @@ -100,7 +99,7 @@ export type MobileCommandRuntimeContext = { export type CommandCliOptions = { ciProvider?: CiProvider - envName?: CaliEnvName + localPlatform?: CaliPlatform configPath?: string prompt?: string contextPath?: string @@ -127,7 +126,6 @@ export type CommandCliOptions = { } export type CommandResolvedConfig = { - envName: CaliEnvName workspaceRoot?: string contextPath?: string skillPaths: string[] diff --git a/skills/cali/SKILL.md b/skills/cali/SKILL.md index 198741a..5d04218 100644 --- a/skills/cali/SKILL.md +++ b/skills/cali/SKILL.md @@ -1,6 +1,6 @@ --- name: cali -description: Use when working in the Cali repository or when you need to run, extend, or debug the Cali CLI for mobile React Native and Expo workflows. Covers Cali commands (`qa`, `review`, `perf-review`, `dev`), the shared `cali-context.json` contract, env selection, required local CLIs, provider setup, and CI integration patterns. +description: Use when working in the Cali repository or when you need to run, extend, or debug the Cali CLI for mobile React Native and Expo workflows. Covers Cali commands (`qa`, `review`, `perf-review`, `dev`), the shared `cali-context.json` contract, local mode selection, required local CLIs, provider setup, and CI integration patterns. --- # Cali @@ -21,7 +21,7 @@ Use this skill as a router with mandatory defaults. Read this file first. For no 1. Load [references/running-cali.md](references/running-cali.md). 2. If the task changes implementation or runtime behavior, then load [references/extending-cali.md](references/extending-cali.md). 3. Confirm which command is actually in scope before changing code or docs. -4. Keep the task aligned to the current runtime model: command + env + shared context + tool packs + publishers. +4. Keep the task aligned to the current runtime model: command + local/CI mode + shared context + tool packs + publishers. ## Command surface @@ -34,11 +34,10 @@ Use this skill as a router with mandatory defaults. Read this file first. For no - `cali dev` - experimental repository-backed implementation flow -## Built-in envs +## Runtime modes -- `mobile-pr` -- `local-android` -- `local-ios` +- local mobile: `--local android|ios` +- CI: implicit provider detection in GitHub Actions and EAS, with optional `--ci github-actions|eas` override ## Required references diff --git a/skills/cali/references/running-cali.md b/skills/cali/references/running-cali.md index 677ca71..51820f2 100644 --- a/skills/cali/references/running-cali.md +++ b/skills/cali/references/running-cali.md @@ -15,20 +15,12 @@ Use this reference for normal Cali usage, setup, and CI wiring. ## Runtime model -- `env` is the only preset concept +- local mobile runs use `--local android|ios` +- CI runs use implicit provider detection in GitHub Actions and EAS - all commands use one shared `cali-context.json` - flags override context values -For `qa`, use: -- `--env local-android` or `--env local-ios` for local runs -- `--ci github-actions` or `--ci eas` for CI runs - -Built-in envs: - -- `mobile-pr` -- `eas-mobile-pr` -- `local-android` -- `local-ios` +Use `--ci github-actions|eas` only when you need to override provider detection. ## Required local binaries @@ -48,28 +40,41 @@ node packages/cali/dist/index.js qa --help # Local iOS QA node packages/cali/dist/index.js qa \ - --env local-ios \ + --local ios \ --artifact ./artifacts/MyApp.app \ --prompt "verify onboarding copy on Screen B" # Local Android QA node packages/cali/dist/index.js qa \ - --env local-android \ + --local android \ --artifact ./artifacts/app.apk \ --prompt "verify onboarding copy on Screen B" # CI-style QA node packages/cali/dist/index.js qa \ - --ci github-actions \ --platform ios \ --artifact ./artifacts/MyApp.app # EAS-style QA node packages/cali/dist/index.js qa \ - --ci eas \ --platform android \ --artifact ./artifacts/app.apk +# CI-style review +node packages/cali/dist/index.js review \ + --context ./cali-context.json + +# CI-style performance review +node packages/cali/dist/index.js perf-review \ + --context ./cali-context.json \ + --platform android \ + --artifact ./artifacts/app.apk + +# CI-style dev command +node packages/cali/dist/index.js dev \ + --context ./cali-context.json \ + --prompt "implement issue 123" + # Export CI helper files from one report node packages/cali/dist/index.js export-ci \ --report ./artifacts/qa/report.json @@ -100,8 +105,8 @@ QA_MODEL=anthropic/claude-sonnet-4.6 ## CI notes -- For CI, prefer `cali qa --ci github-actions` or `cali qa --ci eas`. -- `cali qa --env mobile-pr` and `cali qa --env eas-mobile-pr` are intentionally not supported. +- In GitHub Actions and EAS, Cali detects the provider automatically. +- Use `--ci github-actions|eas` only to override provider detection. - Cali derives runtime context from provider env plus CLI overrides before the agent starts. - Use the explicit helper commands for integration glue: - `export-ci` From ef46ccf59dfc77427913b5c86b196725651bd328 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 10 Apr 2026 14:20:31 +0200 Subject: [PATCH 43/48] docs: align cali local and ci docs --- AGENTS.md | 4 +--- packages/cali/README.md | 4 +--- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 47ef597..e6b5a42 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -118,7 +118,7 @@ Required skill guidance should be preloaded through the tool-pack registry when - In GitHub Actions and EAS, CI provider detection is automatic; `--ci` is only an override. - Requires `agent-device` on `PATH`. - Mobile runs use a unique per-run `agent-device` session. Do not reuse ambient sessions. -- Local envs are convenience-first: try `open --relaunch` before reinstalling. +- Local runs are convenience-first: try `open --relaunch` before reinstalling. - Local mobile runs can infer the app id from the artifact. Do not require `--app-id` unless inference fails. - If `--device` is omitted, reuse the single booted local target when exactly one exists; otherwise fail clearly. - Acceptance criteria resolve in this order: @@ -178,8 +178,6 @@ Built bundle: - `bun run dev:command -- --help` - `bun run qa:local:android -- --artifact ./artifacts/app.apk` - `bun run qa:local:ios -- --artifact ./artifacts/MyApp.app` -- `bun run qa:ci:gha -- --platform android --artifact ./artifacts/app.apk` -- `bun run qa:ci:eas -- --platform ios --artifact ./artifacts/MyApp.app` - `bun run export-ci -- --report ./artifacts/qa/report.json` Source/dev loop: diff --git a/packages/cali/README.md b/packages/cali/README.md index 6ec1022..e1b71b3 100644 --- a/packages/cali/README.md +++ b/packages/cali/README.md @@ -253,7 +253,7 @@ cali qa --quiet --platform ios --artifact ./artifacts/MyApp.app cali qa --quiet --platform android --artifact ./artifacts/app.apk ``` -Optional helpers: +Optional helper: ```bash cali export-ci --report ./artifacts/qa/report.json @@ -388,8 +388,6 @@ Built bundle: - `bun run dev:command -- --help` - `bun run qa:local:android -- --artifact ./artifacts/app.apk` - `bun run qa:local:ios -- --artifact ./artifacts/MyApp.app` -- `bun run qa:ci:gha -- --platform android --artifact ./artifacts/app.apk` -- `bun run qa:ci:eas -- --platform ios --artifact ./artifacts/MyApp.app` - `bun run review -- --context ./cali-context.json` - `bun run perf-review -- --context ./cali-context.json --platform android --artifact ./artifacts/app.apk` - `bun run dev:command -- --context ./cali-context.json` From d268f2ed9cd65c0622a526d1ff21964be3ab59c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 10 Apr 2026 14:28:13 +0200 Subject: [PATCH 44/48] feat: auto-install required cali skills --- AGENTS.md | 6 + packages/cali/README.md | 10 +- packages/cali/src/runtime/tool-packs.ts | 10 +- packages/cali/src/tools/skills.ts | 139 ++++++++++++++++++++++-- skills/cali/SKILL.md | 1 + skills/cali/references/running-cali.md | 10 ++ 6 files changed, 165 insertions(+), 11 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index e6b5a42..6172e0a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -108,6 +108,12 @@ Built-in pack ids: Required skill guidance should be preloaded through the tool-pack registry when a pack depends on a skill workflow. Do not push that responsibility into individual prompts by hand. +Required role skills are Cali-managed: + +- Cali auto-installs missing required skills into `~/.cali/skills` +- if that is unavailable, Cali falls back to `./.cali/skills` +- local CLIs are still user-managed; do not blur skill bootstrap with CLI installation + ## Command Guidance ### `qa` diff --git a/packages/cali/README.md b/packages/cali/README.md index e1b71b3..1325b8e 100644 --- a/packages/cali/README.md +++ b/packages/cali/README.md @@ -199,6 +199,8 @@ If one of these is missing, Cali stops with an actionable error instead of tryin Cali discovers local skills from: +- `~/.cali/skills` +- `./.cali/skills` - `./.agents/skills` - `~/.agents/skills` @@ -207,11 +209,13 @@ Required role skills: - `qa`: `agent-device` - `perf-review`: `agent-device`, `react-devtools` -Install examples: +Cali auto-installs missing required skills with `npx skills` into `~/.cali/skills`, falling back to `./.cali/skills` when needed. CLI binaries are still not auto-installed. + +Equivalent manual install commands: ```bash -npx skills add callstackincubator/agent-device --agent codex --skill agent-device -y -npx skills add callstackincubator/agent-skills --agent codex --skill react-devtools -y +npx skills add callstackincubator/agent-device --agent codex --skill agent-device --copy -y +npx skills add callstackincubator/agent-skills --agent codex --skill react-devtools --copy -y ``` ## CI Providers diff --git a/packages/cali/src/runtime/tool-packs.ts b/packages/cali/src/runtime/tool-packs.ts index d94ce65..63ea22e 100644 --- a/packages/cali/src/runtime/tool-packs.ts +++ b/packages/cali/src/runtime/tool-packs.ts @@ -7,6 +7,8 @@ import { buildSkillsPrompt, createSkillsToolPack, discoverSkills, + ensureRequiredSkillsInstalled, + getManagedSkillPaths, preloadSkillDocuments, type RequiredSkillDocument, type SkillMetadata, @@ -94,7 +96,7 @@ export async function prepareToolPacks(options: PrepareToolPacksOptions) { if (enabledToolPacks.includes('agent-device') && !sessionName) { throw new Error('agent-device tool pack requires a bound session name.') } - const skills = await discoverSkills(skillPaths) + const discoveredSkillPaths = [...getManagedSkillPaths(process.cwd()), ...skillPaths] await Promise.all( enabledToolPacks.map(async (toolPackName) => { await TOOL_PACK_DEFINITIONS[toolPackName].ensureAvailable?.() @@ -107,6 +109,12 @@ export async function prepareToolPacks(options: PrepareToolPacksOptions) { const requiredSkills = enabledToolPacks.flatMap( (toolPackName) => TOOL_PACK_DEFINITIONS[toolPackName].requiredSkills ?? [] ) + const skills = await ensureRequiredSkillsInstalled( + process.cwd(), + discoveredSkillPaths, + requiredSkills, + await discoverSkills(discoveredSkillPaths) + ) const preloadedSkillDocuments = await preloadSkillDocuments(skills, requiredSkills) const preloadedSkillNames = [ ...new Set(requiredSkills.map((requiredSkill) => requiredSkill.name)), diff --git a/packages/cali/src/tools/skills.ts b/packages/cali/src/tools/skills.ts index 879b70c..be059a5 100644 --- a/packages/cali/src/tools/skills.ts +++ b/packages/cali/src/tools/skills.ts @@ -1,10 +1,12 @@ -import { readdir, readFile } from 'node:fs/promises' +import { access, cp, mkdtemp, readdir, readFile, rm } from 'node:fs/promises' +import os from 'node:os' import path from 'node:path' import { tool } from 'ai' import { z } from 'zod' import { DOCS_URLS } from '../docs.js' +import { ensureCommandExists, ensureDirectory, runCommand, uniqueStrings } from '../utils.js' type SkillMetadata = { name: string @@ -25,11 +27,29 @@ type RequiredSkillDocument = { preloadPaths: string[] } -const SKILL_INSTALL_HINTS: Record = { - 'agent-device': - 'npx skills add callstackincubator/agent-device --agent codex --skill agent-device -y', - 'react-devtools': - 'npx skills add callstackincubator/agent-skills --agent codex --skill react-devtools -y', +type SkillInstallSpec = { + packageSource: string + skillName: string +} + +const SKILL_INSTALL_SPECS: Record = { + 'agent-device': { + packageSource: 'callstackincubator/agent-device', + skillName: 'agent-device', + }, + 'react-devtools': { + packageSource: 'callstackincubator/agent-skills', + skillName: 'react-devtools', + }, +} + +function buildSkillInstallCommand(name: string) { + const spec = SKILL_INSTALL_SPECS[name] + if (!spec) { + return undefined + } + + return `npx skills add ${spec.packageSource} --agent codex --skill ${spec.skillName} --copy -y` } function parseSkillFile(content: string) { @@ -78,7 +98,7 @@ function resolveSkillFilePath(skill: SkillMetadata, relativeFilePath: string) { function findSkill(skills: SkillMetadata[], name: string) { const skill = skills.find((candidate) => candidate.name.toLowerCase() === name.toLowerCase()) if (!skill) { - const installHint = SKILL_INSTALL_HINTS[name] + const installHint = buildSkillInstallCommand(name) throw new Error( [ `Skill not found: ${name}`, @@ -194,6 +214,111 @@ export async function preloadSkillDocuments( return documents } +export function getManagedSkillPaths(cwd: string) { + return uniqueStrings([ + path.join(os.homedir(), '.cali', 'skills'), + path.join(cwd, '.cali', 'skills'), + ]) +} + +async function installRequiredSkill(targetDirectories: string[], skillName: string) { + const spec = SKILL_INSTALL_SPECS[skillName] + if (!spec) { + throw new Error(`No managed install spec found for required skill: ${skillName}`) + } + + await ensureCommandExists('npx', 'Install Node.js and npm so `npx skills` is available.') + + const temporaryRoot = await mkdtemp(path.join(os.tmpdir(), 'cali-skill-')) + + try { + const installResult = await runCommand( + 'npx', + [ + 'skills', + 'add', + spec.packageSource, + '--agent', + 'codex', + '--skill', + spec.skillName, + '--copy', + '-y', + ], + { cwd: temporaryRoot, allowFailure: true } + ) + + if (!installResult.ok) { + throw new Error( + [ + `Failed to install required skill: ${skillName}`, + installResult.stderr || installResult.stdout, + `Try manually: ${buildSkillInstallCommand(skillName)}`, + ] + .filter(Boolean) + .join('\n\n') + ) + } + + const sourceDirectory = path.join(temporaryRoot, '.agents', 'skills', spec.skillName) + await access(sourceDirectory) + + let lastCopyError: unknown + + for (const targetDirectory of targetDirectories) { + try { + await ensureDirectory(targetDirectory) + const targetSkillDirectory = path.join(targetDirectory, spec.skillName) + await rm(targetSkillDirectory, { recursive: true, force: true }) + await cp(sourceDirectory, targetSkillDirectory, { recursive: true }) + return targetSkillDirectory + } catch (error) { + lastCopyError = error + } + } + + throw new Error( + [ + `Installed required skill ${skillName}, but failed to place it in a managed Cali skills directory.`, + lastCopyError instanceof Error ? lastCopyError.message : String(lastCopyError), + ].join('\n\n') + ) + } finally { + await rm(temporaryRoot, { recursive: true, force: true }) + } +} + +export async function ensureRequiredSkillsInstalled( + cwd: string, + directories: string[], + requiredSkills: RequiredSkillDocument[], + discoveredSkills: SkillMetadata[] +) { + const missingSkillNames = [ + ...new Set( + requiredSkills + .map((requiredSkill) => requiredSkill.name) + .filter( + (name) => + !discoveredSkills.some((skill) => skill.name.toLowerCase() === name.toLowerCase()) + ) + ), + ] + + if (missingSkillNames.length === 0) { + return discoveredSkills + } + + const managedSkillDirectories = getManagedSkillPaths(cwd) + + for (const missingSkillName of missingSkillNames) { + console.log(`Installing required Cali skill: ${missingSkillName}`) + await installRequiredSkill(managedSkillDirectories, missingSkillName) + } + + return discoverSkills(directories) +} + export function buildPreloadedSkillsPrompt(documents: PreloadedSkillDocument[]) { if (documents.length === 0) { return '' diff --git a/skills/cali/SKILL.md b/skills/cali/SKILL.md index 5d04218..3854a62 100644 --- a/skills/cali/SKILL.md +++ b/skills/cali/SKILL.md @@ -14,6 +14,7 @@ Use this skill as a router with mandatory defaults. Read this file first. For no - Prefer the shared `cali-context.json` contract over workflow-specific runtime scraping. - Keep setup and CI instructions copy-pasteable when editing docs. - If the task is about running Cali, verify the required local CLIs and model credentials before assuming the environment is ready. +- Required role skills are Cali-managed; local CLIs are not. - If the task is about changing Cali, prefer small explicit runtime contracts over broad abstraction. ## Default flow diff --git a/skills/cali/references/running-cali.md b/skills/cali/references/running-cali.md index 51820f2..a812a14 100644 --- a/skills/cali/references/running-cali.md +++ b/skills/cali/references/running-cali.md @@ -31,6 +31,16 @@ Use `--ci github-actions|eas` only when you need to override provider detection. Do not auto-install missing CLIs. Cali should fail with actionable install guidance. +## Required skills + +- Cali auto-installs missing required skills with `npx skills` +- install target order: + - `~/.cali/skills` + - `./.cali/skills` +- additional optional skills can still be discovered from: + - `./.agents/skills` + - `~/.agents/skills` + ## Common commands ```bash From f87f62265de30f95c560d28723a6a4d87d2eb5d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 11 Apr 2026 15:39:50 +0200 Subject: [PATCH 45/48] fix: stabilize qa screenshot evidence --- package.json | 2 +- packages/cali/README.md | 5 +- packages/cali/package.json | 4 +- packages/cali/src/commands/qa.ts | 16 +---- packages/cali/src/report/ci.ts | 80 ++++++++++++++++++++++--- packages/cali/src/report/types.ts | 7 --- packages/cali/src/roles/qa-mobile.ts | 16 ++--- packages/cali/src/runtime/mobile.ts | 12 +++- packages/cali/src/tools/agent-device.ts | 14 +++++ packages/tools/package.json | 2 +- 10 files changed, 108 insertions(+), 50 deletions(-) diff --git a/package.json b/package.json index d91239d..67974fb 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@cali/root", - "version": "0.4.0-4", + "version": "0.4.0-5", "devDependencies": { "@release-it-plugins/workspaces": "^4.2.0", "@release-it/conventional-changelog": "^9.0.3", diff --git a/packages/cali/README.md b/packages/cali/README.md index 1325b8e..884fd68 100644 --- a/packages/cali/README.md +++ b/packages/cali/README.md @@ -104,6 +104,7 @@ Local mobile behavior: - local Android reuses the single booted emulator/device when exactly one is available, otherwise pass `--device` - local runs try `open --relaunch` before reinstalling - local iOS reuses the single booted simulator when exactly one is available, otherwise pass `--device` +- debug artifacts usually need Metro running for the duration of the QA run; start and stop Metro outside Cali ### CI-native commands @@ -211,7 +212,7 @@ Required role skills: Cali auto-installs missing required skills with `npx skills` into `~/.cali/skills`, falling back to `./.cali/skills` when needed. CLI binaries are still not auto-installed. -Equivalent manual install commands: +If you want to install the same skills yourself into a standard skills directory, use: ```bash npx skills add callstackincubator/agent-device --agent codex --skill agent-device --copy -y @@ -257,6 +258,8 @@ cali qa --quiet --platform ios --artifact ./artifacts/MyApp.app cali qa --quiet --platform android --artifact ./artifacts/app.apk ``` +If the artifact is a debug build, start Metro before `cali qa`, wait until it is ready, and stop it in CI cleanup. Release builds normally do not need Metro. + Optional helper: ```bash diff --git a/packages/cali/package.json b/packages/cali/package.json index abf368a..55cb8d1 100644 --- a/packages/cali/package.json +++ b/packages/cali/package.json @@ -17,8 +17,6 @@ "dev:qa": "node --import=tsx ./src/cli.ts qa", "dev:qa:local:android": "node --import=tsx ./src/cli.ts qa --local android", "dev:qa:local:ios": "node --import=tsx ./src/cli.ts qa --local ios", - "qa:ci:gha": "node ./dist/index.js qa --ci github-actions --quiet", - "qa:ci:eas": "node ./dist/index.js qa --ci eas --quiet", "dev:review": "node --import=tsx ./src/cli.ts review", "dev:perf-review": "node --import=tsx ./src/cli.ts perf-review", "dev:dev-command": "node --import=tsx ./src/cli.ts dev", @@ -60,7 +58,7 @@ "README.md" ], "license": "MIT", - "version": "0.4.0-4", + "version": "0.4.0-5", "engines": { "node": ">=22" } diff --git a/packages/cali/src/commands/qa.ts b/packages/cali/src/commands/qa.ts index 7d8d931..6abf834 100644 --- a/packages/cali/src/commands/qa.ts +++ b/packages/cali/src/commands/qa.ts @@ -36,12 +36,6 @@ function composeQaReport( agentDeviceTrace: QaReport['agentDeviceTrace'], acceptanceCriteriaUsed: string[] ): QaReport { - const screenshotLabelMap = new Map( - (reportInput.screenshotLabels ?? []) - .filter((item) => item.fileName && item.label) - .map((item) => [item.fileName, item.label.trim()]) - ) - return { command: 'qa', generatedAt: new Date().toISOString(), @@ -52,11 +46,9 @@ function composeQaReport( checked: reportInput.checked ?? [], issues: reportInput.issues ?? [], nextSteps: reportInput.nextSteps ?? [], - screenshotLabels: reportInput.screenshotLabels ?? [], screenshots: screenshots.map((screenshot) => ({ ...screenshot, - label: - screenshotLabelMap.get(screenshot.fileName) ?? humanizeScreenshotLabel(screenshot.fileName), + label: humanizeScreenshotLabel(screenshot.fileName), })), acceptanceCriteriaUsed, environmentNotes: reportInput.environmentNotes ?? [], @@ -76,12 +68,6 @@ function createBlockedReport(summary: string): QaReportInput { } export async function runQaCommand(cli: CommandCliOptions) { - if (cli.envName === 'mobile-pr' || cli.envName === 'eas-mobile-pr') { - throw new Error( - '`cali qa` no longer supports `--env mobile-pr` or `--env eas-mobile-pr`. Use `--ci github-actions` or `--ci eas` for CI runs, or `--env local-android` / `--env local-ios` for local runs.' - ) - } - let acceptanceCriteriaUsed: string[] | undefined return runMobileStructuredCommand({ diff --git a/packages/cali/src/report/ci.ts b/packages/cali/src/report/ci.ts index 956166f..b5dc9c1 100644 --- a/packages/cali/src/report/ci.ts +++ b/packages/cali/src/report/ci.ts @@ -73,6 +73,10 @@ export function renderScreenshotsCell(report?: CommandReport) { .join('

') } +function getScreenshots(report?: CommandReport) { + return report && hasScreenshots(report) ? report.screenshots : [] +} + function formatStatusForTable(report?: CommandReport) { return report?.overallStatus ?? 'N/A' } @@ -131,6 +135,73 @@ function renderScreenshotCellItem(screenshot: ScreenshotInfo) { return `**${safeLabel}**
${safeLabel}` } +function normalizeScreenshotGroupLabel(label: string) { + return label + .toLowerCase() + .replace(/\b(android|ios)\b/g, '') + .replace(/[^a-z0-9]+/g, ' ') + .trim() +} + +function getScreenshotGroupLabel(screenshot: ScreenshotInfo) { + return normalizeScreenshotGroupLabel(screenshot.label) || screenshot.label.toLowerCase() +} + +function createScreenshotRows(android?: CommandReport, ios?: CommandReport) { + const rows = new Map< + string, + { + label: string + android: ScreenshotInfo[] + ios: ScreenshotInfo[] + order: number + } + >() + + function add(platform: 'android' | 'ios', screenshots: ScreenshotInfo[]) { + for (const screenshot of screenshots) { + const key = getScreenshotGroupLabel(screenshot) + const row = rows.get(key) ?? { + label: screenshot.label, + android: [], + ios: [], + order: rows.size, + } + row[platform].push(screenshot) + rows.set(key, row) + } + } + + add('android', getScreenshots(android)) + add('ios', getScreenshots(ios)) + + return [...rows.values()].sort((left, right) => left.order - right.order) +} + +function renderScreenshotsTable(android?: CommandReport, ios?: CommandReport) { + const rows = createScreenshotRows(android, ios) + if (rows.length === 0) { + return 'No screenshots recorded.' + } + + return [ + '| Focus | Android | iOS |', + '| --- | --- | --- |', + ...rows.map( + (row) => + `| ${toInlineTableCell(row.label)} | ${renderScreenshotGroupCell(row.android)} | ${renderScreenshotGroupCell(row.ios)} |` + ), + ].join('\n') +} + +function renderScreenshotGroupCell(screenshots: ScreenshotInfo[]) { + if (screenshots.length === 0) { + return 'N/A' + } + + return screenshots.map((screenshot) => renderScreenshotCellItem(screenshot)).join('

') +} + export function renderGithubComment(report: CommandReport) { const lines = [ `### ${getTitle(report)}`, @@ -176,14 +247,7 @@ export function renderGithubMultiPlatformComment(reports: { `| iOS | ${formatStatusForTable(ios)} | ${formatTopIssueForTable(ios)} |` ) - lines.push( - '', - '#### Screenshots', - '', - '| Android | iOS |', - '| --- | --- |', - `| ${renderScreenshotsCell(android)} | ${renderScreenshotsCell(ios)} |` - ) + lines.push('', '#### Screenshots', '', renderScreenshotsTable(android, ios)) if (android) { lines.push( diff --git a/packages/cali/src/report/types.ts b/packages/cali/src/report/types.ts index f67fd19..f7dd27c 100644 --- a/packages/cali/src/report/types.ts +++ b/packages/cali/src/report/types.ts @@ -2,11 +2,6 @@ import type { CaliContext, CommandId, ToolTraceEntry } from '../runtime/types.js export type ResultStatus = 'passed' | 'failed' | 'blocked' | 'not_tested' | 'unsure' -export type ScreenshotLabel = { - fileName: string - label: string -} - export type ScreenshotInfo = { fileName: string absolutePath?: string @@ -42,7 +37,6 @@ export type QaReportInput = { checked?: string[] issues?: string[] nextSteps?: string[] - screenshotLabels?: ScreenshotLabel[] environmentNotes?: string[] } @@ -51,7 +45,6 @@ export type QaReport = BaseCommandReport & { checked: string[] issues: string[] nextSteps?: string[] - screenshotLabels: ScreenshotLabel[] screenshots: ScreenshotInfo[] acceptanceCriteriaUsed: string[] environmentNotes?: string[] diff --git a/packages/cali/src/roles/qa-mobile.ts b/packages/cali/src/roles/qa-mobile.ts index 907e551..ffe6ef1 100644 --- a/packages/cali/src/roles/qa-mobile.ts +++ b/packages/cali/src/roles/qa-mobile.ts @@ -43,14 +43,6 @@ const WRITE_REPORT_INPUT_SCHEMA = z.object({ checked: z.array(z.string()).optional(), issues: z.array(z.string()).optional(), nextSteps: z.array(z.string()).optional(), - screenshotLabels: z - .array( - z.object({ - fileName: z.string(), - label: z.string(), - }) - ) - .optional(), environmentNotes: z.array(z.string()).optional(), }) @@ -103,7 +95,8 @@ function buildPrompt( lines.push( '', - `Save screenshots into ${context.output.screenshotsDir ?? 'the screenshots directory'}/*.png with short descriptive filenames.`, + `Save screenshots into ${context.output.screenshotsDir ?? 'the screenshots directory'}/*.png with short descriptive filenames that describe the current visible state.`, + 'Name screenshot files after observed state, not intended step labels. For example, use counter-0-before-increment.png only after verifying the counter is visibly 0.', 'When text visibility matters, prefer a plain snapshot over image-heavy inspection.', 'Do not use agent-device session management commands such as session list, session close, or session open.', 'Use canonical agent-device commands like back or home directly. Do not emulate navigation with press.', @@ -138,7 +131,7 @@ export async function runQaMobileRole( 'Refresh your view with snapshot-style commands after every meaningful UI transition.', 'Do not spend steps on session management commands such as session list, session close, or session open.', 'Use canonical agent-device commands like back or home directly. Do not emulate them with press.', - 'Take screenshots for meaningful states and keep filenames short and descriptive.', + 'Take screenshots for meaningful states and keep filenames short, descriptive, and faithful to the visible state at capture time.', 'If the environment is broken or a prerequisite is missing, report blocked checks instead of guessing.', 'If the evidence is visual but not conclusive from text automation, prefer overallStatus "unsure".', 'Do not finish with plain text. Finish only by calling write_report exactly once.', @@ -159,8 +152,7 @@ export async function runQaMobileRole( ), tools, reportSchema: WRITE_REPORT_INPUT_SCHEMA, - reportDescription: - 'Persist the final QA summary, findings, environment notes, and screenshot labels.', + reportDescription: 'Persist the final QA summary, findings, and environment notes.', reserveReportAfterTool: 'agent_device', createMissingReport: createMissingQaReport, onAgentStep, diff --git a/packages/cali/src/runtime/mobile.ts b/packages/cali/src/runtime/mobile.ts index ddd53d8..fffac20 100644 --- a/packages/cali/src/runtime/mobile.ts +++ b/packages/cali/src/runtime/mobile.ts @@ -717,7 +717,7 @@ export async function listScreenshots(screenshotsDir: string) { return [] } - const screenshots: Array> = [] + const screenshots: Array & { mtimeMs: number }> = [] for (const entry of entries) { if (!entry.endsWith('.png')) { continue @@ -729,8 +729,16 @@ export async function listScreenshots(screenshotsDir: string) { fileName: entry, absolutePath, bytes: fileStat.size, + mtimeMs: fileStat.mtimeMs, }) } - return screenshots.sort((left, right) => left.fileName.localeCompare(right.fileName)) + return screenshots + .sort( + (left, right) => left.mtimeMs - right.mtimeMs || left.fileName.localeCompare(right.fileName) + ) + .map(({ mtimeMs, ...screenshot }) => { + void mtimeMs + return screenshot + }) } diff --git a/packages/cali/src/tools/agent-device.ts b/packages/cali/src/tools/agent-device.ts index d597345..57c80a0 100644 --- a/packages/cali/src/tools/agent-device.ts +++ b/packages/cali/src/tools/agent-device.ts @@ -34,6 +34,20 @@ function normalizeScreenshotArgs(args: string[], screenshotsDir: string) { return [path.join(screenshotsDir, getDefaultScreenshotFileName())] } + const outFlagIndex = args.findIndex((arg) => arg === '--out') + if (outFlagIndex >= 0) { + const outputPath = args[outFlagIndex + 1] + if (!outputPath || outputPath.startsWith('-')) { + return args + } + + const normalizedArgs = [...args] + normalizedArgs[outFlagIndex + 1] = path.isAbsolute(outputPath) + ? outputPath + : path.join(screenshotsDir, outputPath) + return normalizedArgs + } + const [candidatePath, ...rest] = args if (!candidatePath || candidatePath.startsWith('-')) { return args diff --git a/packages/tools/package.json b/packages/tools/package.json index 4963afc..9ffeba9 100644 --- a/packages/tools/package.json +++ b/packages/tools/package.json @@ -51,7 +51,7 @@ "README.md" ], "license": "MIT", - "version": "0.4.0-4", + "version": "0.4.0-5", "engines": { "node": ">=22" } From a89e9e67a7cab90f4f5dd1743ba8f9a7a46ec4d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 11 Apr 2026 18:14:45 +0200 Subject: [PATCH 46/48] fix: orient ci screenshot table by focus --- packages/cali/src/report/ci.ts | 82 +++++++++++++++++++++++----------- 1 file changed, 57 insertions(+), 25 deletions(-) diff --git a/packages/cali/src/report/ci.ts b/packages/cali/src/report/ci.ts index b5dc9c1..090cbf8 100644 --- a/packages/cali/src/report/ci.ts +++ b/packages/cali/src/report/ci.ts @@ -73,6 +73,18 @@ export function renderScreenshotsCell(report?: CommandReport) { .join('

') } +function getPlatformLabel(report: CommandReport) { + if (report.context.mobile?.platform === 'ios') { + return 'iOS' + } + + if (report.context.mobile?.platform === 'android') { + return 'Android' + } + + return 'Screenshots' +} + function getScreenshots(report?: CommandReport) { return report && hasScreenshots(report) ? report.screenshots : [] } @@ -147,49 +159,56 @@ function getScreenshotGroupLabel(screenshot: ScreenshotInfo) { return normalizeScreenshotGroupLabel(screenshot.label) || screenshot.label.toLowerCase() } -function createScreenshotRows(android?: CommandReport, ios?: CommandReport) { - const rows = new Map< +function createScreenshotColumns( + reports: Array<{ platformLabel: string; report?: CommandReport }> +) { + const columns = new Map< string, { label: string - android: ScreenshotInfo[] - ios: ScreenshotInfo[] + byPlatform: Map order: number } >() + const platformLabels = reports.map((report) => report.platformLabel) - function add(platform: 'android' | 'ios', screenshots: ScreenshotInfo[]) { + for (const { platformLabel, report } of reports) { + const screenshots = getScreenshots(report) for (const screenshot of screenshots) { const key = getScreenshotGroupLabel(screenshot) - const row = rows.get(key) ?? { + const column = columns.get(key) ?? { label: screenshot.label, - android: [], - ios: [], - order: rows.size, + byPlatform: new Map(), + order: columns.size, } - row[platform].push(screenshot) - rows.set(key, row) + + const platformScreenshots = column.byPlatform.get(platformLabel) ?? [] + platformScreenshots.push(screenshot) + column.byPlatform.set(platformLabel, platformScreenshots) + columns.set(key, column) } } - add('android', getScreenshots(android)) - add('ios', getScreenshots(ios)) - - return [...rows.values()].sort((left, right) => left.order - right.order) + return { + platformLabels, + columns: [...columns.values()].sort((left, right) => left.order - right.order), + } } -function renderScreenshotsTable(android?: CommandReport, ios?: CommandReport) { - const rows = createScreenshotRows(android, ios) - if (rows.length === 0) { +function renderScreenshotsTable(reports: Array<{ platformLabel: string; report?: CommandReport }>) { + const { platformLabels, columns } = createScreenshotColumns(reports) + if (columns.length === 0) { return 'No screenshots recorded.' } return [ - '| Focus | Android | iOS |', - '| --- | --- | --- |', - ...rows.map( - (row) => - `| ${toInlineTableCell(row.label)} | ${renderScreenshotGroupCell(row.android)} | ${renderScreenshotGroupCell(row.ios)} |` + `| Platform | ${columns.map((column) => toInlineTableCell(column.label)).join(' | ')} |`, + `| --- | ${columns.map(() => '---').join(' | ')} |`, + ...platformLabels.map( + (platformLabel) => + `| ${platformLabel} | ${columns + .map((column) => renderScreenshotGroupCell(column.byPlatform.get(platformLabel) ?? [])) + .join(' | ')} |` ), ].join('\n') } @@ -217,7 +236,12 @@ export function renderGithubComment(report: CommandReport) { } if (hasScreenshots(report) && report.screenshots.length > 0) { - lines.push('', '#### Screenshots', '', renderScreenshotsMarkdown(report).trimEnd()) + lines.push( + '', + '#### Screenshots', + '', + renderScreenshotsTable([{ platformLabel: getPlatformLabel(report), report }]) + ) } if (report.publisherResults?.length) { @@ -247,7 +271,15 @@ export function renderGithubMultiPlatformComment(reports: { `| iOS | ${formatStatusForTable(ios)} | ${formatTopIssueForTable(ios)} |` ) - lines.push('', '#### Screenshots', '', renderScreenshotsTable(android, ios)) + lines.push( + '', + '#### Screenshots', + '', + renderScreenshotsTable([ + { platformLabel: 'Android', report: android }, + { platformLabel: 'iOS', report: ios }, + ]) + ) if (android) { lines.push( From feb0506f6c9daf65ea64516b420fe3e13c5f51ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 11 Apr 2026 18:19:25 +0200 Subject: [PATCH 47/48] chore: release v0.4.0-6 --- package.json | 2 +- packages/cali/package.json | 2 +- packages/tools/package.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package.json b/package.json index 67974fb..930a11c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@cali/root", - "version": "0.4.0-5", + "version": "0.4.0-6", "devDependencies": { "@release-it-plugins/workspaces": "^4.2.0", "@release-it/conventional-changelog": "^9.0.3", diff --git a/packages/cali/package.json b/packages/cali/package.json index 55cb8d1..463a92e 100644 --- a/packages/cali/package.json +++ b/packages/cali/package.json @@ -58,7 +58,7 @@ "README.md" ], "license": "MIT", - "version": "0.4.0-5", + "version": "0.4.0-6", "engines": { "node": ">=22" } diff --git a/packages/tools/package.json b/packages/tools/package.json index 9ffeba9..a50ffcd 100644 --- a/packages/tools/package.json +++ b/packages/tools/package.json @@ -51,7 +51,7 @@ "README.md" ], "license": "MIT", - "version": "0.4.0-5", + "version": "0.4.0-6", "engines": { "node": ">=22" } From 0037a2c0a6a549c5d4b9487414667e6421618dde Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sun, 12 Apr 2026 12:01:33 +0200 Subject: [PATCH 48/48] fix: avoid duplicate screenshot labels --- packages/cali/src/report/ci.ts | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/packages/cali/src/report/ci.ts b/packages/cali/src/report/ci.ts index 090cbf8..5a74cee 100644 --- a/packages/cali/src/report/ci.ts +++ b/packages/cali/src/report/ci.ts @@ -136,15 +136,21 @@ function escapeHtml(value: string) { .replaceAll('"', '"') } -function renderScreenshotCellItem(screenshot: ScreenshotInfo) { +function renderScreenshotCellItem( + screenshot: ScreenshotInfo, + options: { showLabel?: boolean } = {} +) { const safeLabel = escapeHtml(screenshot.label) const safeUrl = sanitizeUrl(screenshot.blobUrl) if (!safeUrl) { - return `**${safeLabel}**
${escapeHtml(screenshot.fileName)}` + return options.showLabel + ? `**${safeLabel}**
${escapeHtml(screenshot.fileName)}` + : escapeHtml(screenshot.fileName) } - return `**${safeLabel}**
${safeLabel}` + const image = `${safeLabel}` + return options.showLabel ? `**${safeLabel}**
${image}` : image } function normalizeScreenshotGroupLabel(label: string) { @@ -218,7 +224,11 @@ function renderScreenshotGroupCell(screenshots: ScreenshotInfo[]) { return 'N/A' } - return screenshots.map((screenshot) => renderScreenshotCellItem(screenshot)).join('

') + return screenshots + .map((screenshot) => + renderScreenshotCellItem(screenshot, { showLabel: screenshots.length > 1 }) + ) + .join('

') } export function renderGithubComment(report: CommandReport) {

C+38sQ6;6X4Q%muRm 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 diff --git a/package.json b/package.json index 2081752..f08be0e 100644 --- a/package.json +++ b/package.json @@ -22,9 +22,8 @@ "license": "MIT", "private": true, "scripts": { - "build": "bun run build:tools && bun run build:mcp-server && bun run build:cli", + "build": "bun run build:tools && bun run build:cli", "build:tools": "cd packages/tools && bun run build", - "build:mcp-server": "cd packages/mcp-server && bun run build", "build:cli": "cd packages/cali && bun run build", "release": "release-it" }, diff --git a/packages/mcp-server/README.md b/packages/mcp-server/README.md deleted file mode 100644 index 96f5827..0000000 --- a/packages/mcp-server/README.md +++ /dev/null @@ -1,29 +0,0 @@ -# cali-mcp-server - -> [!NOTE] -> This package is not yet released. It is a work in progress. - -Model Context Protocol server that allows you to build and work with React Native apps. Under the hood it uses [@cali/tools](./tools/README.md). - -## Installation - -``` -{ - "mcpServers": { - "react-native": { - "command": "npx run cali-mcp-server@latest", - "env": { - "FILESYSTEM_ROOT": "/path/to/your/react-native-project" - } - } - } -} -``` - -## Debugging - -``` -bun run inspector -``` - -Then, from the inspector UI, set command to `bun` and arguments to `/Absolute/Path/To/mcp-server/src/index.ts` diff --git a/packages/mcp-server/package.json b/packages/mcp-server/package.json deleted file mode 100644 index b44031f..0000000 --- a/packages/mcp-server/package.json +++ /dev/null @@ -1,39 +0,0 @@ -{ - "name": "cali-mcp-server", - "description": "A MCP server with tools for application development", - "type": "module", - "bin": { - "cali-mcp-server": "./dist/index.js" - }, - "scripts": { - "build": "rslib build", - "dev": "node --import=tsx ./src/index.ts", - "inspector": "npx @modelcontextprotocol/inspector node --import=tsx ./src/index.ts" - }, - "dependencies": { - "cali-tools": "0.3.1", - "@modelcontextprotocol/sdk": "0.6.0", - "zod-to-json-schema": "^3.23.5" - }, - "author": "Mike Grabowski ", - "repository": { - "type": "git", - "url": "git+https://github.com/callstackincubator/cali.git" - }, - "keywords": [ - "react-native", - "ai", - "mcp server", - "model context protocol" - ], - "files": [ - "dist", - "src", - "README.md" - ], - "license": "MIT", - "version": "0.3.1", - "engines": { - "node": ">=22" - } -} diff --git a/packages/mcp-server/rslib.config.ts b/packages/mcp-server/rslib.config.ts deleted file mode 100644 index 149d498..0000000 --- a/packages/mcp-server/rslib.config.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { defineConfig } from '@rslib/core' - -export default defineConfig({ - lib: [ - { - source: { - entry: { - index: './src/index.ts', - }, - }, - format: 'esm', - output: { - distPath: { - root: 'dist', - }, - }, - }, - ], -}) diff --git a/packages/mcp-server/src/index.ts b/packages/mcp-server/src/index.ts deleted file mode 100644 index cbbeca3..0000000 --- a/packages/mcp-server/src/index.ts +++ /dev/null @@ -1,117 +0,0 @@ -#!/usr/bin/env node - -import { Server } from '@modelcontextprotocol/sdk/server/index.js' -import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js' -import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js' -import * as tools from 'cali-tools' -import { zodToJsonSchema } from 'zod-to-json-schema' - -const server = new Server( - { - name: 'cali-mcp-server', - version: '0.1.0', - }, - { - capabilities: { - tools: {}, - }, - } -) - -/** - * Set the working directory to the root of the filesystem. - */ -if (process.env.FILESYSTEM_ROOT) { - process.chdir(process.env.FILESYSTEM_ROOT) -} - -/** - * Handler for listing available tools - */ -server.setRequestHandler(ListToolsRequestSchema, async () => { - return { - tools: Object.entries(tools).map(([name, tool]) => ({ - name, - description: 'description' in tool ? tool.description : '', - inputSchema: zodToJsonSchema(tool.parameters), - })), - } -}) - -/** - * Handler for calling tools - */ -server.setRequestHandler(CallToolRequestSchema, async (request) => { - const tool = tools[request.params.name as keyof typeof tools] - - if (!tool) { - throw new Error(`Tool ${request.params.name} not found`) - } - - try { - const args = tool.parameters.parse(request.params.arguments) - // @ts-ignore - const result = await tool.execute(args, { - messages: [], - }) - /** - * Our convention for errors is to return an object with an `error` property. - */ - if (typeof result === 'object' && 'error' in result) { - return { - isError: true, - content: [ - { - type: 'text', - text: result.error, - }, - ], - } - } - /** - * If the tool has an experimental_toToolResultContent method, we use it to format the result. - * This is useful for tools that return images. - */ - if (tool.experimental_toToolResultContent) { - // @ts-ignore - const content = tool.experimental_toToolResultContent(result) - return { - content, - } - } - /** - * Each tool returns a JSON object, which we convert to a text block. - */ - return { - content: [ - { - type: 'text', - text: JSON.stringify(result), - }, - ], - } - } catch (error) { - return { - isError: true, - content: [ - { - type: 'text', - text: `Tool execution failed: ${error instanceof Error ? error.message : 'Unknown error'}`, - }, - ], - } - } -}) - -/** - * Start the server using stdio transport - */ -async function main() { - const transport = new StdioServerTransport() - await server.connect(transport) -} - -main().catch((error) => { - console.error('Server error:', error) - process.exit(1) -}) diff --git a/packages/mcp-server/tsconfig.json b/packages/mcp-server/tsconfig.json deleted file mode 100644 index a5cb75c..0000000 --- a/packages/mcp-server/tsconfig.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "extends": "../../tsconfig.json", - "include": ["src/**/*.ts"] -} diff --git a/packages/tools/README.md b/packages/tools/README.md index 4db6ade..7996845 100644 --- a/packages/tools/README.md +++ b/packages/tools/README.md @@ -1,6 +1,6 @@ # @callstack/cali -Collection of tools for building AI agents that work with React Native. Exported tools can be used with [ai](https://www.npmjs.com/package/ai) package. +React Native development tools for AI agents. Exported tools can be used with [ai](https://www.npmjs.com/package/ai). ## Usage @@ -43,17 +43,6 @@ await generateText({ - **getReactNativeConfig** - Get React Native configuration including root directory, path, version, platforms, and project configuration - **listReactNativeLibraries** - List React Native libraries from reactnative.directory -### NPM -- **installNpmPackage** - Install a package from npm by name -- **unInstallNpmPackage** - Uninstall a package from npm by name - -### File System -- **getFileTree** - Get user file tree, can be used to determine the package.json location, package manager, etc. -- **readFile** - Read file, can be used to read package.json, etc. - -### Git -- **applyDiff** - Apply a diff/patch to a file - ## Learn more Learn more about Cali on [GitHub](https://github.com/callstackincubator/cali). diff --git a/packages/tools/package.json b/packages/tools/package.json index 8842a6e..0646bcd 100644 --- a/packages/tools/package.json +++ b/packages/tools/package.json @@ -1,6 +1,6 @@ { "name": "cali-tools", - "description": "Tools to build your own AI agents for application development.", + "description": "React Native development tools for AI agents.", "type": "module", "exports": { ".": { @@ -19,19 +19,16 @@ "build:types": "tsc --emitDeclarationOnly --declaration --outdir dist/types" }, "dependencies": { - "@clack/prompts": "^0.8.1", "@react-native-community/cli": "^15.1.2", "@react-native-community/cli-config": "^15.1.2", "@react-native-community/cli-platform-android": "^15.1.2", "@react-native-community/cli-platform-apple": "^15.1.2", "ai": "^6.0.138", "dedent": "^1.5.3", - "diff": "^7.0.0", "zod": "^4.3.6" }, "devDependencies": { - "@react-native-community/cli-types": "^15.1.2", - "@types/diff": "^6.0.0" + "@react-native-community/cli-types": "^15.1.2" }, "author": "Mike Grabowski ", "repository": { diff --git a/packages/tools/src/android.ts b/packages/tools/src/android.ts index e9c4288..8000747 100644 --- a/packages/tools/src/android.ts +++ b/packages/tools/src/android.ts @@ -1,6 +1,6 @@ import { tryRunAdbReverse } from '@react-native-community/cli-platform-android' import { tool } from 'ai' -import { execSync } from 'child_process' +import { execFileSync } from 'node:child_process' import dedent from 'dedent' import { EOL } from 'os' import { z } from 'zod' @@ -96,7 +96,7 @@ export const buildAndroidApp = tool({ reactNativeConfig_android_sourceDir: sourceDir, metroPort, }) => { - // tbd: taks selection + // tbd: task selection // tbd: user selection // tbd: flavor selection @@ -183,7 +183,7 @@ export const launchAndroidAppOnDevice = tool({ }) function getEmulatorName(adbPath: string, deviceId: string) { - const buffer = execSync(`${adbPath} -s ${deviceId} emu avd name`) + const buffer = execFileSync(adbPath, ['-s', deviceId, 'emu', 'avd', 'name']) return buffer .toString() .split(EOL)[0] @@ -192,9 +192,7 @@ function getEmulatorName(adbPath: string, deviceId: string) { } function getPhoneName(adbPath: string, deviceId: string) { - const buffer = execSync(`${adbPath} -s ${deviceId} shell getprop | grep ro.product.model`) - return buffer + return execFileSync(adbPath, ['-s', deviceId, 'shell', 'getprop', 'ro.product.model']) .toString() - .replace(/\[ro\.product\.model\]:\s*\[(.*)\]/, '$1') .trim() } diff --git a/packages/tools/src/apple.ts b/packages/tools/src/apple.ts index 963c0f6..b9b6cc9 100644 --- a/packages/tools/src/apple.ts +++ b/packages/tools/src/apple.ts @@ -1,5 +1,6 @@ +import { rm } from 'node:fs/promises' import { tool } from 'ai' -import { execSync } from 'child_process' +import { execFileSync } from 'node:child_process' import { z } from 'zod' import { @@ -29,7 +30,7 @@ export const installRubyGems = tool({ description: 'Install Ruby gems, including CocoaPods', inputSchema: z.object({}), execute: async () => { - execSync('bundle install --path vendor/bundle', { stdio: 'inherit' }) + execFileSync('bundle', ['install', '--path', 'vendor/bundle'], { stdio: 'inherit' }) return { success: true, } @@ -43,7 +44,7 @@ export const bootAppleSimulator = tool({ }), execute: async ({ deviceId }) => { try { - execSync(`xcrun simctl boot ${deviceId}`, { stdio: 'inherit' }) + execFileSync('xcrun', ['simctl', 'boot', deviceId], { stdio: 'inherit' }) return { success: `Device ${deviceId} booted successfully.`, } @@ -139,24 +140,21 @@ export const installPods = tool({ } if (clean) { - execSync('rm -rf Pods Podfile.lock build', { - cwd: directory, - stdio: 'inherit', - }) + await Promise.all([ + rm(`${directory}/Pods`, { recursive: true, force: true }), + rm(`${directory}/Podfile.lock`, { force: true }), + rm(`${directory}/build`, { recursive: true, force: true }), + ]) } - const commands = ['bundle exec pod install'] - - for (const command of commands) { - execSync(command, { - cwd: directory, - stdio: 'inherit', - env: { - ...process.env, - ...(newArchitecture ? { RCT_NEW_ARCH_ENABLED: '1' } : {}), - }, - }) - } + execFileSync('bundle', ['exec', 'pod', 'install'], { + cwd: directory, + stdio: 'inherit', + env: { + ...process.env, + ...(newArchitecture ? { RCT_NEW_ARCH_ENABLED: '1' } : {}), + }, + }) return { success: true, diff --git a/packages/tools/src/fs.ts b/packages/tools/src/fs.ts deleted file mode 100644 index 909ed40..0000000 --- a/packages/tools/src/fs.ts +++ /dev/null @@ -1,92 +0,0 @@ -import { mkdir, readdir, readFile as readFileNode, writeFile } from 'node:fs/promises' -import { extname } from 'node:path' - -import { tool } from 'ai' -import { z } from 'zod' - -const fileEncodingSchema = z - .enum([ - 'ascii', - 'utf8', - 'utf-8', - 'utf16le', - 'utf-16le', - 'ucs2', - 'ucs-2', - 'base64', - 'base64url', - 'latin1', - 'binary', - 'hex', - ]) - .default('utf-8') - -const readFileInputSchema = z.object({ - path: z.string(), - is_image: z.boolean(), - encoding: fileEncodingSchema, -}) - -type ReadFileInput = z.infer -type ReadFileResult = string | { data: string; mimeType: string } - -export const listFiles = tool({ - description: - 'List all files in a directory. If path is nested, you must call it separately for each segment', - inputSchema: z.object({ path: z.string() }), - execute: async ({ path }) => { - return readdir(path) - }, -}) - -export const currentDirectory = tool({ - description: 'Get the current working directory', - inputSchema: z.object({}), - execute: async () => { - return process.cwd() - }, -}) - -export const makeDirectory = tool({ - description: 'Create a new directory', - inputSchema: z.object({ path: z.string() }), - execute: async ({ path }) => { - return mkdir(path) - }, -}) - -export const readFile = tool({ - description: 'Reads a file at a given path', - inputSchema: readFileInputSchema, - execute: async ({ path, is_image, encoding }: ReadFileInput): Promise => { - const file = await readFileNode(path, { encoding }) - if (is_image) { - return { - data: file, - mimeType: `image/${extname(path).toLowerCase().replace('.', '')}`, - } - } else { - return file - } - }, - toModelOutput({ output }: { output: ReadFileResult }) { - return typeof output === 'string' - ? { type: 'text', value: output } - : { - type: 'content', - value: [{ type: 'file-data', data: output.data, mediaType: output.mimeType }], - } - }, -}) - -export const saveFile = tool({ - description: 'Save a file at a given path', - inputSchema: z.object({ - path: z.string(), - content: z.string(), - encoding: fileEncodingSchema, - }), - execute: async ({ path, content, encoding }) => { - return writeFile(path, content, { encoding }) - }, -}) diff --git a/packages/tools/src/git.ts b/packages/tools/src/git.ts deleted file mode 100644 index c35ace6..0000000 --- a/packages/tools/src/git.ts +++ /dev/null @@ -1,33 +0,0 @@ -import fs from 'node:fs' - -import { tool } from 'ai' -import { applyPatch } from 'diff' -import { z } from 'zod' - -export const applyDiff = tool({ - description: 'Apply a diff/patch to a file', - inputSchema: z.object({ - filePath: z.string(), - diff: z.string(), - }), - execute: async ({ filePath, diff }) => { - try { - const originalContent = fs.readFileSync(filePath, 'utf8') - const patchedContent = applyPatch(originalContent, diff) - - if (patchedContent === false) { - throw new Error('Failed to apply patch - patch may be invalid or not applicable') - } - - fs.writeFileSync(filePath, patchedContent, 'utf8') - - return { - success: true, - } - } catch (error) { - return { - error: error instanceof Error ? error.message : 'Failed to apply diff', - } - } - }, -}) diff --git a/packages/tools/src/index.ts b/packages/tools/src/index.ts index 183d3da..5d43776 100644 --- a/packages/tools/src/index.ts +++ b/packages/tools/src/index.ts @@ -1,6 +1,3 @@ export * from './android.js' export * from './apple.js' -export * from './fs.js' -export * from './git.js' -export * from './npm.js' export * from './react-native.js' diff --git a/packages/tools/src/npm.ts b/packages/tools/src/npm.ts deleted file mode 100644 index 3d91d56..0000000 --- a/packages/tools/src/npm.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { tool } from 'ai' -import { z } from 'zod' - -import { install, installDev, uninstall } from './vendor/react-native-cli.js' - -export const installNpmPackage = tool({ - description: 'Install a package from npm by name', - inputSchema: z.object({ - packageNames: z.array(z.string()), - packageManager: z.enum(['yarn', 'npm', 'bun']).optional(), - dev: z.boolean().optional(), - }), - execute: async ({ packageNames, packageManager, dev }) => { - try { - const params = { - packageManager: packageManager || 'npm', - root: process.cwd(), - } - if (dev) { - await installDev(packageNames, params) - } else { - await install(packageNames, params) - } - return { - success: true, - } - } catch (error) { - return { - error: error instanceof Error ? error.message : 'Failed to install package', - } - } - }, -}) - -export const unInstallNpmPackage = tool({ - description: 'Uninstall a package from npm by name', - inputSchema: z.object({ - packageNames: z.array(z.string()), - packageManager: z.enum(['yarn', 'npm', 'bun']).optional(), - }), - execute: async ({ packageNames, packageManager }) => { - try { - const params = { - packageManager: packageManager || 'npm', - root: process.cwd(), - } - await uninstall(packageNames, params) - return { - success: true, - } - } catch (error) { - return { - error: error instanceof Error ? error.message : 'Failed to install package', - } - } - }, -}) diff --git a/packages/tools/src/react-native.ts b/packages/tools/src/react-native.ts index 0d2e690..2bc1a17 100644 --- a/packages/tools/src/react-native.ts +++ b/packages/tools/src/react-native.ts @@ -126,9 +126,7 @@ export const listReactNativeLibraries = tool({ Ask user to pick a library from the list. Offer user an option to try different search query. Offer user an option to cancel the operation and proceed with something else. - - For each library, you can use "installNpmPackage" tool to install it. - You can also offer to display package description or visit Github repository. + You can also offer to display package description or visit the GitHub repository. `, libraries: mappedLibraries, } diff --git a/packages/tools/src/vendor/react-native-cli.ts b/packages/tools/src/vendor/react-native-cli.ts index 9c148ba..c6d858a 100644 --- a/packages/tools/src/vendor/react-native-cli.ts +++ b/packages/tools/src/vendor/react-native-cli.ts @@ -7,11 +7,8 @@ import { execSync } from 'node:child_process' * Export private internals. We add `.js` extension manually, since Bundler will not do it for us. */ export { - install, - installDev, - uninstall, -} from '@react-native-community/cli/build/tools/packageManager.js' -export { build } from '@react-native-community/cli-platform-android/build/commands/buildAndroid/index.js' + build, +} from '@react-native-community/cli-platform-android/build/commands/buildAndroid/index.js' export { getTaskNames } from '@react-native-community/cli-platform-android/build/commands/runAndroid/getTaskNames.js' export { getEmulators } from '@react-native-community/cli-platform-android/build/commands/runAndroid/tryLaunchEmulator.js' export { getPlatformInfo } from '@react-native-community/cli-platform-apple/build/commands/runCommand/getPlatformInfo.js' From b26a8bc09e28a0fa65ae8151012f843020228e07 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Tue, 7 Apr 2026 21:50:08 +0200 Subject: [PATCH 22/48] feat: polish cali qa ship readiness --- AGENTS.md | 11 +- README.md | 3 +- packages/cali/README.md | 155 +++++++++++++-- .../eas-workflows/write-mobile-pr-context.sh | 60 ++++++ .../github-actions/write-mobile-pr-context.sh | 81 ++++++++ packages/cali/src/cli/app.ts | 29 ++- packages/cali/src/cli/dev.ts | 7 +- packages/cali/src/cli/perf-review.ts | 5 +- packages/cali/src/cli/qa.ts | 3 +- packages/cali/src/cli/review.ts | 7 +- packages/cali/src/commands/perf-review.ts | 138 ++++--------- packages/cali/src/commands/qa.ts | 144 +++++--------- packages/cali/src/commands/shared.ts | 123 ++++++++++++ packages/cali/src/config/load.ts | 38 ++-- packages/cali/src/config/schema.ts | 5 +- packages/cali/src/docs.ts | 8 + packages/cali/src/model.ts | 8 +- packages/cali/src/report/publishers/blob.ts | 62 +++++- packages/cali/src/report/render.ts | 24 ++- packages/cali/src/report/types.ts | 2 +- packages/cali/src/runtime/context-file.ts | 184 +++++++++--------- packages/cali/src/runtime/context.ts | 41 ++-- packages/cali/src/runtime/mobile.ts | 91 ++------- packages/cali/src/runtime/publishers.ts | 11 +- packages/cali/src/runtime/tool-loop-role.ts | 5 - packages/cali/src/runtime/tool-packs.ts | 24 ++- packages/cali/src/runtime/types.ts | 19 +- packages/cali/src/tools/agent-device.ts | 55 ++---- packages/cali/src/tools/cli-tool.ts | 51 +++++ packages/cali/src/tools/github.ts | 30 --- packages/cali/src/tools/react-devtools.ts | 50 +---- packages/cali/src/tools/repo.ts | 15 +- packages/cali/src/utils.ts | 8 +- 33 files changed, 891 insertions(+), 606 deletions(-) create mode 100755 packages/cali/examples/eas-workflows/write-mobile-pr-context.sh create mode 100755 packages/cali/examples/github-actions/write-mobile-pr-context.sh create mode 100644 packages/cali/src/docs.ts create mode 100644 packages/cali/src/tools/cli-tool.ts delete mode 100644 packages/cali/src/tools/github.ts diff --git a/AGENTS.md b/AGENTS.md index a8e102f..1f5222d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -58,6 +58,13 @@ Implemented first-class commands: - `perf-review` - `dev` +Current maturity: + +- `qa`: ship-ready +- `review`: experimental +- `perf-review`: experimental +- `dev`: experimental + `publish` is intentionally not implemented. Release automation belongs in CI or in `dev`-driven pipeline work, not as an open-ended agent command. ## Core Contracts @@ -103,9 +110,7 @@ Built-in pack ids: - `agent-device` - `repo-read` - `repo-write` -- `github` - `react-devtools` -- `report` Required skill guidance should be preloaded through the tool-pack registry when a pack depends on a skill workflow. Do not push that responsibility into individual prompts by hand. @@ -158,6 +163,8 @@ Required skill guidance should be preloaded through the tool-pack registry when - relevant `--help` command smoke tests - For command/runtime changes: - run at least one source-mode smoke command if possible +- For docs/setup changes: + - keep `packages/cali/README.md` copy-pasteable for provider setup and CI examples Do not commit generated `artifacts/` output. diff --git a/README.md b/README.md index 4d6d7f5..a61957a 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,7 @@ Thanks to that, an LLM can help you with your React Native app development, with You can use Cali in three ways: - **standalone** - [`cali`](./packages/cali/README.md) - Role-oriented CLI for mobile QA, review, perf review, and dev runs in local and CI environments. +- Copy-paste setup, provider envs, and CI examples for the standalone CLI live in [`packages/cali/README.md`](./packages/cali/README.md). - **with Vercel AI SDK** - [`cali-tools`](./packages/tools/README.md) - Collection of tools for building React Native apps with [Vercel AI SDK](https://github.com/ai-sdk/ai) For a repo-oriented guide to the current Cali v2 architecture, role platform, and extension points, see [`AGENTS.md`](./AGENTS.md). @@ -36,7 +37,7 @@ For the standalone CLI’s current env model, context file contract, and package Cali is still in the early stages of development, but it already supports: -- **Role-based Mobile Workflows**: QA, code review, perf review, and repo-backed dev runs through the standalone CLI +- **Role-based Mobile Workflows**: QA today, with experimental review, perf review, and repo-backed dev runs through the standalone CLI - **Build Automation**: Running and building React Native apps on iOS and Android - **Device Management**: Listing and managing connected Android and iOS devices and simulators - **React Native Library Search**: Searching and listing React Native libraries from [React Native Directory](https://reactnative.directory) diff --git a/packages/cali/README.md b/packages/cali/README.md index d92c6b2..305c447 100644 --- a/packages/cali/README.md +++ b/packages/cali/README.md @@ -22,11 +22,11 @@ Cali v2 is a role-oriented CLI for mobile React Native and Expo workflows. It ru - `cali qa` - mobile QA pass with `agent-device` - `cali review` - - findings-first PR/repository review + - findings-first PR/repository review (experimental) - `cali perf-review` - - runtime performance review with `agent-device` and `react-devtools` + - runtime performance review with `agent-device` and `react-devtools` (experimental) - `cali dev` - - repository-backed implementation flow + - repository-backed implementation flow (experimental) ## Shared Context @@ -108,6 +108,8 @@ cali qa --env mobile-pr --context ./cali-context.json cali review --env mobile-pr --context ./cali-context.json ``` +Use `--quiet` to suppress the retro banner in scripted environments. Cali also suppresses the banner automatically when `CI=true`. + ### Runtime performance review ```bash @@ -123,14 +125,51 @@ cali perf-review \ cali dev --env mobile-pr --context ./cali-context.json --prompt "implement issue 123" ``` -## Credentials +## Provider Setup Cali supports two model auth paths: -- AI Gateway: `AI_GATEWAY_API_KEY` -- AI Gateway alias: `AI_GATEWAY_KEY` -- Anthropic direct: `ANTHROPIC_API_KEY` -- Anthropic alias: `CLAUDE_API_KEY` +### AI Gateway + +```bash +export AI_GATEWAY_API_KEY="your-ai-gateway-key" +export QA_MODEL="openai/gpt-5.4-mini" +``` + +Gateway also accepts the compatibility alias: + +```bash +export AI_GATEWAY_KEY="your-ai-gateway-key" +``` + +### Anthropic Direct + +```bash +export ANTHROPIC_API_KEY="your-anthropic-api-key" +export QA_MODEL="anthropic/claude-sonnet-4.6" +``` + +Anthropic also accepts the compatibility alias: + +```bash +export CLAUDE_API_KEY="your-anthropic-api-key" +``` + +### `.env` example + +```dotenv +AI_GATEWAY_API_KEY=your-ai-gateway-key +QA_MODEL=openai/gpt-5.4-mini +``` + +or: + +```dotenv +ANTHROPIC_API_KEY=your-anthropic-api-key +QA_MODEL=anthropic/claude-sonnet-4.6 +``` + +`packages/cali` loads `.env` automatically from the current workspace before it starts a run. Cali defaults to `openai/gpt-5.4-mini`. If gateway credentials are present, that model is routed through AI Gateway. Direct provider support in this package is Anthropic only. @@ -147,8 +186,98 @@ Some commands shell out to local binaries: - `review`: requires `git` and `rg` - `dev`: requires `git`, `rg`, and `zsh` +Install examples: + +```bash +npm i -g agent-device +npm i -g agent-react-devtools +``` + +On macOS/Linux, Git and `zsh` are usually present already. Install ripgrep if `rg` is missing. + +If you want Android app id inference from an `.apk` without passing `--app-id`, make sure either `apkanalyzer` or `aapt` is on `PATH`. + If one of these is missing, Cali stops with an actionable error instead of trying to install it automatically. +## CI Providers + +The current ship-ready CI story for Cali is: + +- generate one `cali-context.json` +- run `cali qa --env mobile-pr --context ./cali-context.json` +- collect `artifacts/qa/report.json`, `section.md`, `status.txt`, and optional blob screenshot URLs + +### GitHub Actions + +Use the example context writer: + +- [`examples/github-actions/write-mobile-pr-context.sh`](./examples/github-actions/write-mobile-pr-context.sh) + +Required envs for that script: + +- `CALI_PLATFORM` +- `CALI_ARTIFACT_PATH` +- optional `CALI_APP_ID` +- optional `CALI_DEVICE_NAME` + +Copy-paste example: + +```yaml +- name: Install Cali CLIs + run: npm i -g agent-device + +- name: Write Cali context + env: + CALI_PLATFORM: android + CALI_ARTIFACT_PATH: ${{ steps.download_build.outputs.artifact_path }} + CALI_APP_ID: com.example.myapp + run: bash ./packages/cali/examples/github-actions/write-mobile-pr-context.sh ./cali-context.json + +- name: Run Cali QA + env: + AI_GATEWAY_API_KEY: ${{ secrets.AI_GATEWAY_API_KEY }} + run: node ./packages/cali/dist/index.js qa --env mobile-pr --context ./cali-context.json +``` + +### EAS Workflows + +Use the example context writer: + +- [`examples/eas-workflows/write-mobile-pr-context.sh`](./examples/eas-workflows/write-mobile-pr-context.sh) + +This matches the environment shape from the earlier `eas-agent-device` workflow pattern: + +- `QA_PLATFORM` +- `APP_PATH` +- `APPLICATION_ID` +- optional `BUILD_ID` +- optional `WORKFLOW_URL` +- optional `PR_JSON` + +Copy-paste example: + +```yaml +- id: install_agent_device + run: npm i -g agent-device + +- id: write_cali_context + env: + QA_PLATFORM: android + APP_PATH: ${{ steps.download_build.outputs.artifact_path }} + APPLICATION_ID: dev.expo.myapp + BUILD_ID: ${{ env.BUILD_ID }} + WORKFLOW_URL: ${{ workflow.url }} + PR_JSON: ${{ toJSON(github.event.pull_request) }} + run: bash ./packages/cali/examples/eas-workflows/write-mobile-pr-context.sh ./cali-context.json + +- id: run_cali_qa + env: + AI_GATEWAY_API_KEY: ${{ secrets.AI_GATEWAY_API_KEY }} + run: node ./packages/cali/dist/index.js qa --env mobile-pr --context ./cali-context.json +``` + +If you want a combined PR comment like the original Expo blog post flow, aggregate `section.md`, `status.txt`, and `report.json` from each platform job in a final workflow step. Cali intentionally leaves that aggregation to the workflow layer. + ## Config Create `cali.config.ts` in the project root: @@ -189,16 +318,14 @@ Built-in tool pack ids: - `agent-device` - `repo-read` - `repo-write` -- `github` - `react-devtools` -- `report` Command defaults: -- `qa`: `skills`, `agent-device`, `report` -- `review`: `repo-read`, `github`, `skills`, `report` -- `perf-review`: `skills`, `agent-device`, `react-devtools`, `repo-read`, `report` -- `dev`: `repo-read`, `repo-write`, `github`, `skills`, `report` +- `qa`: `skills`, `agent-device` +- `review`: `repo-read`, `skills` (experimental) +- `perf-review`: `skills`, `agent-device`, `react-devtools`, `repo-read` (experimental) +- `dev`: `repo-read`, `repo-write`, `skills` (experimental) ## Package Scripts diff --git a/packages/cali/examples/eas-workflows/write-mobile-pr-context.sh b/packages/cali/examples/eas-workflows/write-mobile-pr-context.sh new file mode 100755 index 0000000..41991b2 --- /dev/null +++ b/packages/cali/examples/eas-workflows/write-mobile-pr-context.sh @@ -0,0 +1,60 @@ +#!/usr/bin/env bash + +set -euo pipefail + +OUTPUT_PATH="${1:-./cali-context.json}" + +: "${QA_PLATFORM:?QA_PLATFORM is required}" +: "${APP_PATH:?APP_PATH is required}" +: "${APPLICATION_ID:?APPLICATION_ID is required}" + +jq -n \ + --arg workspaceRoot "${PWD}" \ + --arg platform "${QA_PLATFORM}" \ + --arg artifactPath "${APP_PATH}" \ + --arg appId "${APPLICATION_ID}" \ + --arg deviceName "${CALI_DEVICE_NAME:-}" \ + --arg outputDir "${CALI_OUTPUT_DIR:-./artifacts/qa}" \ + --arg buildId "${BUILD_ID:-}" \ + --arg workflowUrl "${WORKFLOW_URL:-}" \ + --arg logsUrl "${WORKFLOW_URL:-}" \ + --arg prJson "${PR_JSON:-}" \ + ' + ($prJson | if . == "" then null else fromjson end) as $pr + | { + workspaceRoot: $workspaceRoot, + pullRequest: ( + if $pr == null then + null + else + { + number: $pr.number, + title: $pr.title, + body: $pr.body, + url: $pr.html_url, + labels: (($pr.labels // []) | map(.name)), + isDraft: ($pr.draft // false), + baseBranch: $pr.base.ref, + headBranch: $pr.head.ref + } + end + ), + mobile: { + platform: $platform, + artifactPath: $artifactPath, + appId: $appId, + deviceName: (if $deviceName == "" then null else $deviceName end) + }, + build: { + id: $buildId, + workflowUrl: $workflowUrl, + logsUrl: $logsUrl + }, + output: { + outputDir: $outputDir + } + } + | del(.. | nulls) + ' >"${OUTPUT_PATH}" + +echo "Wrote ${OUTPUT_PATH}" diff --git a/packages/cali/examples/github-actions/write-mobile-pr-context.sh b/packages/cali/examples/github-actions/write-mobile-pr-context.sh new file mode 100755 index 0000000..db0e374 --- /dev/null +++ b/packages/cali/examples/github-actions/write-mobile-pr-context.sh @@ -0,0 +1,81 @@ +#!/usr/bin/env bash + +set -euo pipefail + +OUTPUT_PATH="${1:-./cali-context.json}" + +: "${GITHUB_WORKSPACE:?GITHUB_WORKSPACE is required}" +: "${GITHUB_REPOSITORY:?GITHUB_REPOSITORY is required}" +: "${GITHUB_SHA:?GITHUB_SHA is required}" +: "${GITHUB_REF_NAME:?GITHUB_REF_NAME is required}" +: "${GITHUB_SERVER_URL:?GITHUB_SERVER_URL is required}" +: "${GITHUB_EVENT_PATH:?GITHUB_EVENT_PATH is required}" +: "${CALI_PLATFORM:?CALI_PLATFORM is required}" +: "${CALI_ARTIFACT_PATH:?CALI_ARTIFACT_PATH is required}" + +REPO_OWNER="${GITHUB_REPOSITORY%/*}" +REPO_NAME="${GITHUB_REPOSITORY#*/}" +RUN_URL="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}" +JOB_URL="${RUN_URL}" + +jq -n \ + --arg workspaceRoot "${GITHUB_WORKSPACE}" \ + --arg repoOwner "${REPO_OWNER}" \ + --arg repoName "${REPO_NAME}" \ + --arg branch "${GITHUB_REF_NAME}" \ + --arg sha "${GITHUB_SHA}" \ + --arg platform "${CALI_PLATFORM}" \ + --arg artifactPath "${CALI_ARTIFACT_PATH}" \ + --arg appId "${CALI_APP_ID:-}" \ + --arg deviceName "${CALI_DEVICE_NAME:-}" \ + --arg outputDir "${CALI_OUTPUT_DIR:-./artifacts/qa}" \ + --arg buildId "${GITHUB_RUN_ID:-}" \ + --arg workflowUrl "${RUN_URL}" \ + --arg logsUrl "${JOB_URL}" \ + --slurpfile event "${GITHUB_EVENT_PATH}" \ + ' + ($event[0].pull_request // null) as $pr + | { + workspaceRoot: $workspaceRoot, + repository: { + provider: "github.com", + owner: $repoOwner, + name: $repoName, + currentBranch: $branch, + commitSha: $sha + }, + pullRequest: ( + if $pr == null then + null + else + { + number: $pr.number, + title: $pr.title, + body: $pr.body, + url: $pr.html_url, + labels: (($pr.labels // []) | map(.name)), + isDraft: ($pr.draft // false), + baseBranch: $pr.base.ref, + headBranch: $pr.head.ref + } + end + ), + mobile: { + platform: $platform, + artifactPath: $artifactPath, + appId: (if $appId == "" then null else $appId end), + deviceName: (if $deviceName == "" then null else $deviceName end) + }, + build: { + id: $buildId, + workflowUrl: $workflowUrl, + logsUrl: $logsUrl + }, + output: { + outputDir: $outputDir + } + } + | del(.. | nulls) + ' >"${OUTPUT_PATH}" + +echo "Wrote ${OUTPUT_PATH}" diff --git a/packages/cali/src/cli/app.ts b/packages/cali/src/cli/app.ts index fab93a5..398720b 100644 --- a/packages/cali/src/cli/app.ts +++ b/packages/cali/src/cli/app.ts @@ -7,17 +7,26 @@ import { perfReviewCommandDefinition } from './perf-review.js' import { qaCommandDefinition } from './qa.js' import { reviewCommandDefinition } from './review.js' +function shouldPrintBanner(args: string[]) { + if (args.includes('--quiet') || process.env.CI === 'true' || process.env.CI === '1') { + return false + } + + return true +} + function createCli() { const cli = cac('cali') cli.usage(' [options]') + cli.option('--quiet', 'Suppress Cali banner output') for (const commandDefinition of [ qaCommandDefinition, reviewCommandDefinition, perfReviewCommandDefinition, devCommandDefinition, ]) { - commandDefinition.register(cli, printRetroBanner) + commandDefinition.register(cli) } cli.help() @@ -27,21 +36,27 @@ function createCli() { export async function runCli(argv = process.argv) { const cli = createCli() const args = argv.slice(2) - const shouldPrintBanner = args.length === 0 || args.includes('--help') || args.includes('-h') const cwd = process.cwd() - - if (shouldPrintBanner) { - printRetroBanner() - } - + const printBanner = shouldPrintBanner(args) if (args.length === 0) { const config = await loadCaliConfigFile(cwd) if (config.defaultCommand) { + if (printBanner) { + printRetroBanner() + } await Promise.resolve( cli.parse([argv[0] ?? 'node', argv[1] ?? 'cali', config.defaultCommand]) ) return } + + if (printBanner) { + printRetroBanner() + } + } + + if (args.length > 0 && printBanner) { + printRetroBanner() } await Promise.resolve(cli.parse(argv)) diff --git a/packages/cali/src/cli/dev.ts b/packages/cali/src/cli/dev.ts index e50369a..42846f0 100644 --- a/packages/cali/src/cli/dev.ts +++ b/packages/cali/src/cli/dev.ts @@ -8,11 +8,12 @@ import { } from './shared.js' export const devCommandDefinition = { - register(cli: CAC, printBanner: () => void) { - registerCommonCommandOptions(cli.command('dev', 'Run the mobile development role')) + register(cli: CAC) { + registerCommonCommandOptions( + cli.command('dev', 'Run the mobile development role (experimental)') + ) .example('dev --env mobile-pr --context ./cali-context.json --prompt "implement issue 123"') .action(async (options: unknown) => { - printBanner() await runDevCommand(normalizeBaseCommandCliOptions(options as BaseCommandOptions)) }) }, diff --git a/packages/cali/src/cli/perf-review.ts b/packages/cali/src/cli/perf-review.ts index 54d8e8d..416ba01 100644 --- a/packages/cali/src/cli/perf-review.ts +++ b/packages/cali/src/cli/perf-review.ts @@ -8,15 +8,14 @@ import { } from './shared.js' export const perfReviewCommandDefinition = { - register(cli: CAC, printBanner: () => void) { + register(cli: CAC) { registerCommonMobileOptions( - cli.command('perf-review', 'Run the mobile performance review role') + cli.command('perf-review', 'Run the mobile performance review role (experimental)') ) .example( 'perf-review --env mobile-pr --context ./cali-context.json --prompt "profile the checkout flow"' ) .action(async (options: unknown) => { - printBanner() await runPerfReviewCommand(normalizeBaseCommandCliOptions(options as BaseCommandOptions)) }) }, diff --git a/packages/cali/src/cli/qa.ts b/packages/cali/src/cli/qa.ts index fdaa4ba..3e38919 100644 --- a/packages/cali/src/cli/qa.ts +++ b/packages/cali/src/cli/qa.ts @@ -8,13 +8,12 @@ import { } from './shared.js' export const qaCommandDefinition = { - register(cli: CAC, printBanner: () => void) { + register(cli: CAC) { registerCommonMobileOptions(cli.command('qa', 'Run the mobile QA role')) .example( 'qa --env local-ios --artifact ./artifacts/MyApp.app --prompt "verify the onboarding copy on Screen B"' ) .action(async (options: unknown) => { - printBanner() await runQaCommand(normalizeBaseCommandCliOptions(options as BaseCommandOptions)) }) }, diff --git a/packages/cali/src/cli/review.ts b/packages/cali/src/cli/review.ts index 5f06c04..65ee605 100644 --- a/packages/cali/src/cli/review.ts +++ b/packages/cali/src/cli/review.ts @@ -8,11 +8,12 @@ import { } from './shared.js' export const reviewCommandDefinition = { - register(cli: CAC, printBanner: () => void) { - registerCommonCommandOptions(cli.command('review', 'Run the mobile code review role')) + register(cli: CAC) { + registerCommonCommandOptions( + cli.command('review', 'Run the mobile code review role (experimental)') + ) .example('review --env mobile-pr --context ./cali-context.json') .action(async (options: unknown) => { - printBanner() await runReviewCommand(normalizeBaseCommandCliOptions(options as BaseCommandOptions)) }) }, diff --git a/packages/cali/src/commands/perf-review.ts b/packages/cali/src/commands/perf-review.ts index 18b5077..5655c60 100644 --- a/packages/cali/src/commands/perf-review.ts +++ b/packages/cali/src/commands/perf-review.ts @@ -1,26 +1,9 @@ import type { PerfReviewReport, ScreenshotInfo } from '../report/types.js' import { runPerfReviewRole } from '../roles/perf-review.js' -import { - bootstrapMobileApp, - closeAgentDeviceSession, - createAgentDeviceSessionName, - listScreenshots, - prepareMobileOutputDirectories, - resolveMobileRuntimeContext, -} from '../runtime/mobile.js' -import { publishReport } from '../runtime/publishers.js' -import { prepareToolPacks } from '../runtime/tool-packs.js' import type { CommandCliOptions } from '../runtime/types.js' +import { listScreenshots } from '../runtime/mobile.js' import { humanizeScreenshotLabel } from '../utils.js' -import { - createRunContextTool, - formatAgentFinishDetail, - formatAgentStepDetail, - loadRunContext, - printPhase, - printFinalReport, - summarizeReason, -} from './shared.js' +import { runMobileStructuredCommand } from './shared.js' function composePerfReviewReport( model: string, @@ -54,7 +37,9 @@ function composePerfReviewReport( } } -function createBlockedPerfReviewReport(summary: string) { +function createBlockedPerfReviewReport(summary: string): Awaited< + ReturnType +>['reportInput'] { return { overallStatus: 'blocked' as const, summary, @@ -70,82 +55,45 @@ function createBlockedPerfReviewReport(summary: string) { } export async function runPerfReviewCommand(cli: CommandCliOptions) { - const { cwd, config, context } = await loadRunContext('perf-review', cli) + return runMobileStructuredCommand({ + commandId: 'perf-review', + cli, + roleLabel: 'Perf-review', + reportLabel: 'Perf-review report', + createBlockedReport: createBlockedPerfReviewReport, + composeReport: async ({ model, context, reportInput, mobileContext, traces }) => { + const screenshots = mobileContext ? await listScreenshots(mobileContext.screenshotsDir) : [] - let reportInput: Awaited>['reportInput'] - let agentDeviceTrace: PerfReviewReport['agentDeviceTrace'] = [] - let reactDevtoolsTrace: PerfReviewReport['reactDevtoolsTrace'] = [] - let mobileContext: Awaited> | undefined - let sessionName: string | undefined - - try { - mobileContext = await resolveMobileRuntimeContext('perf-review', config.envName, context) - sessionName = createAgentDeviceSessionName(mobileContext.platform) - - printPhase( - 'Preparing output', - `${mobileContext.platform} | ${mobileContext.deviceName ?? 'bound device'} | ${mobileContext.appId}` - ) - await prepareMobileOutputDirectories(mobileContext) - printPhase('Bootstrapping app', mobileContext.artifactPath) - await bootstrapMobileApp('perf-review', config.envName, mobileContext, sessionName) - printPhase('Bootstrap complete') - - printPhase('Preparing tool packs', config.enabledToolPacks.join(', ')) - const preparedToolPacks = await prepareToolPacks({ + return composePerfReviewReport( + model, + context, + reportInput, + screenshots, + traces.agentDeviceTrace, + traces.reactDevtoolsTrace + ) + }, + runRole: async ({ context, - skillPaths: config.skillPaths, - enabledToolPacks: config.enabledToolPacks, - sessionName, - }) - - printPhase('Running perf-review agent', config.model) - const roleResult = await runPerfReviewRole({ - context, - modelId: config.model, - tools: { - ...preparedToolPacks.tools, - get_run_context: createRunContextTool('perf-review', context), - }, - availableSkillsPrompt: preparedToolPacks.availableSkillsPrompt, - preloadedSkillsPrompt: preparedToolPacks.preloadedSkillsPrompt, - extraInstructions: config.extraInstructions, - prompt: cli.prompt, - onAgentStep: (event) => { - printPhase('Perf-review step complete', formatAgentStepDetail(event)) - }, - onAgentFinish: (event) => { - printPhase('Perf-review agent finished', formatAgentFinishDetail(event)) - }, - }) - - reportInput = roleResult.reportInput - agentDeviceTrace = preparedToolPacks.traces.agentDeviceTrace - reactDevtoolsTrace = preparedToolPacks.traces.reactDevtoolsTrace - } catch (unknownError) { - const error = unknownError instanceof Error ? unknownError : new Error(String(unknownError)) - printPhase('Run blocked', summarizeReason(error.message)) - reportInput = createBlockedPerfReviewReport(error.message) - } finally { - if (sessionName) { - await closeAgentDeviceSession(sessionName) - } - } - - const screenshots = mobileContext ? await listScreenshots(mobileContext.screenshotsDir) : [] - const report = composePerfReviewReport( - config.model, - context, - reportInput, - screenshots, - agentDeviceTrace, - reactDevtoolsTrace - ) - printPhase('Publishing report', config.outputPublishers.join(', ')) - const publishedReport = await publishReport({ - report, - publishers: config.outputPublishers, + modelId, + tools, + availableSkillsPrompt, + preloadedSkillsPrompt, + extraInstructions, + prompt, + onAgentStep, + onAgentFinish, + }) => + runPerfReviewRole({ + context, + modelId, + tools, + availableSkillsPrompt, + preloadedSkillsPrompt, + extraInstructions, + prompt, + onAgentStep, + onAgentFinish, + }), }) - - printFinalReport(cwd, 'perf-review', 'Perf-review report', publishedReport) } diff --git a/packages/cali/src/commands/qa.ts b/packages/cali/src/commands/qa.ts index ed2c2ea..aacae25 100644 --- a/packages/cali/src/commands/qa.ts +++ b/packages/cali/src/commands/qa.ts @@ -1,26 +1,11 @@ import type { QaReport, QaReportInput, ScreenshotInfo } from '../report/types.js' import { runQaMobileRole } from '../roles/qa-mobile.js' -import { - bootstrapMobileApp, - closeAgentDeviceSession, - createAgentDeviceSessionName, - listScreenshots, - prepareMobileOutputDirectories, - resolveMobileRuntimeContext, -} from '../runtime/mobile.js' -import { publishReport } from '../runtime/publishers.js' -import { prepareToolPacks } from '../runtime/tool-packs.js' import type { CommandCliOptions } from '../runtime/types.js' +import { listScreenshots } from '../runtime/mobile.js' import { humanizeScreenshotLabel } from '../utils.js' -import { - createRunContextTool, - formatAgentFinishDetail, - formatAgentStepDetail, - loadRunContext, - printPhase, - printFinalReport, - summarizeReason, -} from './shared.js' +import { runMobileStructuredCommand } from './shared.js' + +const MAX_QA_TRACE_ENTRIES = 20 function resolveAcceptanceCriteria( context: Parameters[0]['context'], @@ -93,83 +78,50 @@ function createBlockedReport(summary: string): QaReportInput { } export async function runQaCommand(cli: CommandCliOptions) { - const { cwd, config, context } = await loadRunContext('qa', cli) - const acceptanceCriteriaUsed = resolveAcceptanceCriteria(context, cli.prompt) - - let reportInput: QaReportInput - let agentDeviceTrace: QaReport['agentDeviceTrace'] = [] - let mobileContext: Awaited> | undefined - let sessionName: string | undefined - - try { - mobileContext = await resolveMobileRuntimeContext('qa', config.envName, context) - sessionName = createAgentDeviceSessionName(mobileContext.platform) - - printPhase( - 'Preparing output', - `${mobileContext.platform} | ${mobileContext.deviceName ?? 'bound device'} | ${mobileContext.appId}` - ) - await prepareMobileOutputDirectories(mobileContext) - - printPhase('Bootstrapping app', mobileContext.artifactPath) - await bootstrapMobileApp('qa', config.envName, mobileContext, sessionName) - printPhase('Bootstrap complete') - - printPhase('Preparing tool packs', config.enabledToolPacks.join(', ')) - const preparedToolPacks = await prepareToolPacks({ - context, - skillPaths: config.skillPaths, - enabledToolPacks: config.enabledToolPacks, - sessionName, - }) - - printPhase('Running QA agent', config.model) - const roleResult = await runQaMobileRole({ + return runMobileStructuredCommand({ + commandId: 'qa', + cli, + roleLabel: 'QA', + reportLabel: 'QA report', + createBlockedReport, + composeReport: async ({ model, context, reportInput, mobileContext, traces }) => { + const acceptanceCriteriaUsed = resolveAcceptanceCriteria(context, cli.prompt) + const screenshots = mobileContext ? await listScreenshots(mobileContext.screenshotsDir) : [] + + return composeQaReport( + model, + context, + reportInput, + screenshots, + traces.agentDeviceTrace.slice(-MAX_QA_TRACE_ENTRIES), + acceptanceCriteriaUsed + ) + }, + runRole: async ({ context, - modelId: config.model, - tools: { - ...preparedToolPacks.tools, - get_run_context: createRunContextTool('qa', context), - }, - availableSkillsPrompt: preparedToolPacks.availableSkillsPrompt, - preloadedSkillsPrompt: preparedToolPacks.preloadedSkillsPrompt, - extraInstructions: config.extraInstructions, - prompt: cli.prompt, - acceptanceCriteriaUsed, - onAgentStep: (event) => { - printPhase('QA agent step complete', formatAgentStepDetail(event)) - }, - onAgentFinish: (event) => { - printPhase('QA agent finished', formatAgentFinishDetail(event)) - }, - }) - - reportInput = roleResult.reportInput - agentDeviceTrace = preparedToolPacks.traces.agentDeviceTrace - } catch (unknownError) { - const error = unknownError instanceof Error ? unknownError : new Error(String(unknownError)) - printPhase('Run blocked', summarizeReason(error.message)) - reportInput = createBlockedReport(error.message) - } finally { - if (sessionName) { - await closeAgentDeviceSession(sessionName) - } - } - - const screenshots = mobileContext ? await listScreenshots(mobileContext.screenshotsDir) : [] - const report = composeQaReport( - config.model, - context, - reportInput, - screenshots, - agentDeviceTrace, - acceptanceCriteriaUsed - ) - printPhase('Publishing report', config.outputPublishers.join(', ')) - const publishedReport = await publishReport({ - report, - publishers: config.outputPublishers, + modelId, + tools, + availableSkillsPrompt, + preloadedSkillsPrompt, + extraInstructions, + prompt, + onAgentStep, + onAgentFinish, + }) => { + const acceptanceCriteriaUsed = resolveAcceptanceCriteria(context, cli.prompt) + + return runQaMobileRole({ + context, + modelId, + tools, + availableSkillsPrompt, + preloadedSkillsPrompt, + extraInstructions, + prompt, + acceptanceCriteriaUsed, + onAgentStep, + onAgentFinish, + }) + }, }) - - printFinalReport(cwd, 'qa', 'QA report', publishedReport) } diff --git a/packages/cali/src/commands/shared.ts b/packages/cali/src/commands/shared.ts index 4166330..0a5c9ec 100644 --- a/packages/cali/src/commands/shared.ts +++ b/packages/cali/src/commands/shared.ts @@ -7,6 +7,14 @@ import { loadCommandConfig } from '../config/load.js' import type { ToolPackName } from '../config/schema.js' import type { CommandReport } from '../report/types.js' import { resolveCommandContext } from '../runtime/context.js' +import { + bootstrapMobileApp, + closeAgentDeviceSession, + createAgentDeviceSessionName, + listScreenshots, + prepareMobileOutputDirectories, + resolveMobileRuntimeContext, +} from '../runtime/mobile.js' import { publishReport } from '../runtime/publishers.js' import { prepareToolPacks } from '../runtime/tool-packs.js' import type { @@ -14,6 +22,8 @@ import type { CommandCliOptions, CommandId, CommandResolvedConfig, + MobileCommandRuntimeContext, + ToolTraceEntry, } from '../runtime/types.js' import { resolveFromCwd } from '../utils.js' @@ -212,3 +222,116 @@ export async function runStructuredCommand = RoleRunArgs & { + mobileContext: MobileCommandRuntimeContext +} + +type RunMobileStructuredCommandOptions = { + commandId: 'qa' | 'perf-review' + cli: CommandCliOptions + roleLabel: string + reportLabel: string + createBlockedReport: (summary: string) => TReportInput + composeReport: (args: { + model: string + context: CaliContext + reportInput: TReportInput + mobileContext?: MobileCommandRuntimeContext + traces: { + agentDeviceTrace: ToolTraceEntry[] + reactDevtoolsTrace: ToolTraceEntry[] + } + }) => Promise | TReport + runRole: (args: MobileRoleRunArgs) => Promise<{ reportInput: TReportInput }> +} + +export async function runMobileStructuredCommand( + options: RunMobileStructuredCommandOptions +) { + const { commandId, cli, roleLabel, reportLabel, createBlockedReport, composeReport, runRole } = + options + const { cwd, config, context } = await loadRunContext(commandId, cli) + + let reportInput: TReportInput + let mobileContext: MobileCommandRuntimeContext | undefined + let sessionName: string | undefined + let traces: { + agentDeviceTrace: ToolTraceEntry[] + reactDevtoolsTrace: ToolTraceEntry[] + } = { + agentDeviceTrace: [], + reactDevtoolsTrace: [], + } + + try { + mobileContext = await resolveMobileRuntimeContext(commandId, config.envName, context) + sessionName = createAgentDeviceSessionName(mobileContext.platform) + + printPhase( + 'Preparing output', + `${mobileContext.platform} | ${mobileContext.deviceName ?? 'bound device'} | ${mobileContext.appId}` + ) + await prepareMobileOutputDirectories(mobileContext) + + printPhase('Bootstrapping app', mobileContext.artifactPath) + await bootstrapMobileApp(commandId, config.envName, mobileContext, sessionName) + printPhase('Bootstrap complete') + + printPhase('Preparing tool packs', config.enabledToolPacks.join(', ')) + const preparedToolPacks = await prepareToolPacks({ + context, + skillPaths: config.skillPaths, + enabledToolPacks: config.enabledToolPacks, + sessionName, + }) + traces = preparedToolPacks.traces + + printPhase(`Running ${roleLabel} agent`, config.model) + const result = await runRole({ + context, + mobileContext, + modelId: config.model, + tools: { + ...preparedToolPacks.tools, + get_run_context: createRunContextTool(commandId, context), + }, + availableSkillsPrompt: preparedToolPacks.availableSkillsPrompt, + preloadedSkillsPrompt: preparedToolPacks.preloadedSkillsPrompt, + extraInstructions: config.extraInstructions, + prompt: cli.prompt, + onAgentStep: (event) => { + printPhase(`${roleLabel} step complete`, formatAgentStepDetail(event)) + }, + onAgentFinish: (event) => { + printPhase(`${roleLabel} agent finished`, formatAgentFinishDetail(event)) + }, + }) + + reportInput = result.reportInput + } catch (unknownError) { + const error = unknownError instanceof Error ? unknownError : new Error(String(unknownError)) + printPhase('Run blocked', summarizeReason(error.message)) + reportInput = createBlockedReport(error.message) + } finally { + if (sessionName) { + await closeAgentDeviceSession(sessionName) + } + } + + const report = await composeReport({ + model: config.model, + context, + reportInput, + mobileContext, + traces, + }) + + printPhase('Publishing report', config.outputPublishers.join(', ')) + const publishedReport = await publishReport({ + report, + publishers: config.outputPublishers, + }) + + printFinalReport(cwd, commandId, reportLabel, publishedReport) +} diff --git a/packages/cali/src/config/load.ts b/packages/cali/src/config/load.ts index 2ee49e4..dba09d4 100644 --- a/packages/cali/src/config/load.ts +++ b/packages/cali/src/config/load.ts @@ -5,10 +5,15 @@ import path from 'node:path' import { cosmiconfig } from 'cosmiconfig' import type { CommandResolvedConfig } from '../runtime/types.js' -import type { CommandConfigKey, CommandId } from '../runtime/types.js' -import { commandConfigKeyFromId } from '../runtime/types.js' +import type { CommandConfigKey } from '../runtime/types.js' import { asArray, resolveFromCwd, uniqueStrings } from '../utils.js' -import type { CaliCommandConfig, CaliConfig, CaliEnvName, PublisherName } from './schema.js' +import type { + CaliCommandConfig, + CaliConfig, + CaliEnvName, + CommandId, + PublisherName, +} from './schema.js' import { CaliConfigSchema, normalizeCaliEnvName } from './schema.js' type LoadCommandConfigOptions = { @@ -25,7 +30,7 @@ function getBuiltInSkillPaths(cwd: string) { const QA_ENV_DEFAULTS: Record = { 'mobile-pr': { - enabledToolPacks: ['skills', 'agent-device', 'report'], + enabledToolPacks: ['skills', 'agent-device'], outputPublishers: ['blob', 'file'], extraInstructions: [ 'Infer concise acceptance criteria from pull request or task metadata and prioritize user-visible flows.', @@ -33,7 +38,7 @@ const QA_ENV_DEFAULTS: Record = { ], }, 'local-ios': { - enabledToolPacks: ['skills', 'agent-device', 'report'], + enabledToolPacks: ['skills', 'agent-device'], outputPublishers: ['blob', 'file'], mobileDefaults: { platform: 'ios', @@ -43,7 +48,7 @@ const QA_ENV_DEFAULTS: Record = { ], }, 'local-android': { - enabledToolPacks: ['skills', 'agent-device', 'report'], + enabledToolPacks: ['skills', 'agent-device'], outputPublishers: ['blob', 'file'], mobileDefaults: { platform: 'android', @@ -77,7 +82,7 @@ function getEnvCommandDefaults(commandId: CommandId, envName: CaliEnvName): Cali switch (envName) { case 'mobile-pr': return { - enabledToolPacks: ['skills', 'agent-device', 'react-devtools', 'repo-read', 'report'], + enabledToolPacks: ['skills', 'agent-device', 'react-devtools', 'repo-read'], outputPublishers: mobileOutputPublishers, extraInstructions: [ 'Focus on high-signal runtime performance evidence such as rerenders, slow interactions, and component-level bottlenecks.', @@ -85,7 +90,7 @@ function getEnvCommandDefaults(commandId: CommandId, envName: CaliEnvName): Cali } case 'local-ios': return { - enabledToolPacks: ['skills', 'agent-device', 'react-devtools', 'repo-read', 'report'], + enabledToolPacks: ['skills', 'agent-device', 'react-devtools', 'repo-read'], outputPublishers: mobileOutputPublishers, mobileDefaults: { platform: 'ios', @@ -94,7 +99,7 @@ function getEnvCommandDefaults(commandId: CommandId, envName: CaliEnvName): Cali case 'local-android': default: return { - enabledToolPacks: ['skills', 'agent-device', 'react-devtools', 'repo-read', 'report'], + enabledToolPacks: ['skills', 'agent-device', 'react-devtools', 'repo-read'], outputPublishers: mobileOutputPublishers, mobileDefaults: { platform: 'android', @@ -103,17 +108,24 @@ function getEnvCommandDefaults(commandId: CommandId, envName: CaliEnvName): Cali } case 'review': return { - enabledToolPacks: ['repo-read', 'github', 'skills', 'report'], + enabledToolPacks: ['repo-read', 'skills'], outputPublishers: commonOutputPublishers, } case 'dev': return { - enabledToolPacks: ['repo-read', 'repo-write', 'github', 'skills', 'report'], + enabledToolPacks: ['repo-read', 'repo-write', 'skills'], outputPublishers: commonOutputPublishers, } } } +const COMMAND_CONFIG_KEYS: Record = { + qa: 'qa', + review: 'review', + 'perf-review': 'perfReview', + dev: 'dev', +} + function getCommandConfig(config: CaliConfig, key: CommandConfigKey): CaliCommandConfig { return config.commands?.[key] ?? {} } @@ -169,7 +181,7 @@ export async function loadCommandConfig( const fileConfig = await loadCaliConfigFile(cwd, configPath) const envName = cliEnvName ?? normalizeCaliEnvName(fileConfig.env) ?? getDefaultEnvName(commandId) const envDefaults = getEnvCommandDefaults(commandId, envName) - const commandConfig = getCommandConfig(fileConfig, commandConfigKeyFromId(commandId)) + const commandConfig = getCommandConfig(fileConfig, COMMAND_CONFIG_KEYS[commandId]) const merged = mergeCommandConfig(envDefaults, commandConfig) return { @@ -179,7 +191,7 @@ export async function loadCommandConfig( : undefined, contextPath: merged.contextPath ? resolveFromCwd(cwd, merged.contextPath) : undefined, skillPaths: uniqueStrings([...(fileConfig.skillPaths ?? []), ...getBuiltInSkillPaths(cwd)]), - enabledToolPacks: merged.enabledToolPacks ?? ['report'], + enabledToolPacks: merged.enabledToolPacks ?? [], outputPublishers: merged.outputPublishers ?? ['file'], extraInstructions: asArray(merged.extraInstructions), model: diff --git a/packages/cali/src/config/schema.ts b/packages/cali/src/config/schema.ts index 2a4d1b4..8851edb 100644 --- a/packages/cali/src/config/schema.ts +++ b/packages/cali/src/config/schema.ts @@ -6,12 +6,11 @@ const ToolPackNameSchema = z.enum([ 'agent-device', 'repo-read', 'repo-write', - 'github', 'react-devtools', - 'report', ]) const PublisherNameSchema = z.enum(['file', 'blob']) -const CommandIdSchema = z.enum(['qa', 'review', 'perf-review', 'dev']) +export const COMMAND_IDS = ['qa', 'review', 'perf-review', 'dev'] as const +const CommandIdSchema = z.enum(COMMAND_IDS) const CaliPlatformSchema = z.enum(['android', 'ios']) const StringArraySchema = z.union([z.string(), z.array(z.string())]).optional() diff --git a/packages/cali/src/docs.ts b/packages/cali/src/docs.ts new file mode 100644 index 0000000..525b95d --- /dev/null +++ b/packages/cali/src/docs.ts @@ -0,0 +1,8 @@ +const README_URL = 'https://github.com/callstackincubator/cali/tree/v2/packages/cali/README.md' + +export const DOCS_URLS = { + readme: README_URL, + providerSetup: `${README_URL}#provider-setup`, + requiredClis: `${README_URL}#required-clis`, + ciProviders: `${README_URL}#ci-providers`, +} as const diff --git a/packages/cali/src/model.ts b/packages/cali/src/model.ts index 6736fdd..8f98212 100644 --- a/packages/cali/src/model.ts +++ b/packages/cali/src/model.ts @@ -1,5 +1,7 @@ import { createAnthropic } from '@ai-sdk/anthropic' +import { DOCS_URLS } from './docs.js' + const DEFAULT_QA_MODEL_ID = 'openai/gpt-5.4-mini' function stripAnthropicPrefix(modelId: string) { @@ -28,6 +30,10 @@ export function createQaAgentModel(modelId = process.env.QA_MODEL ?? DEFAULT_QA_ } throw new Error( - 'Missing AI credentials. Set AI_GATEWAY_API_KEY (or AI_GATEWAY_KEY) for gateway access, or ANTHROPIC_API_KEY / CLAUDE_API_KEY for direct Anthropic access.' + [ + 'Missing AI credentials.', + 'Set AI_GATEWAY_API_KEY (or AI_GATEWAY_KEY) for gateway access, or ANTHROPIC_API_KEY / CLAUDE_API_KEY for direct Anthropic access.', + `Docs: ${DOCS_URLS.providerSetup}`, + ].join('\n\n') ) } diff --git a/packages/cali/src/report/publishers/blob.ts b/packages/cali/src/report/publishers/blob.ts index 2722ef7..bed3d5c 100644 --- a/packages/cali/src/report/publishers/blob.ts +++ b/packages/cali/src/report/publishers/blob.ts @@ -2,20 +2,45 @@ import { readFile } from 'node:fs/promises' import { put } from '@vercel/blob' -import type { CommandReport, PerfReviewReport, QaReport } from '../types.js' +import type { CommandReport, PerfReviewReport, QaReport, ReportPublisherResult } from '../types.js' type BlobPublishOptions = { report: CommandReport } +type BlobPublishResult = { + report: CommandReport + publisherResult: ReportPublisherResult +} + function hasScreenshots(report: CommandReport): report is QaReport | PerfReviewReport { return 'screenshots' in report } -export async function publishBlobReport({ report }: BlobPublishOptions): Promise { +export async function publishBlobReport({ + report, +}: BlobPublishOptions): Promise { const token = process.env.BLOB_READ_WRITE_TOKEN - if (!token || !hasScreenshots(report) || report.screenshots.length === 0) { - return report + if (!token) { + return { + report, + publisherResult: { + publisher: 'blob', + status: 'skipped', + detail: 'BLOB_READ_WRITE_TOKEN is not set.', + }, + } + } + + if (!hasScreenshots(report) || report.screenshots.length === 0) { + return { + report, + publisherResult: { + publisher: 'blob', + status: 'skipped', + detail: 'No screenshots were recorded for this report.', + }, + } } const screenshots = await Promise.all( @@ -54,8 +79,31 @@ export async function publishBlobReport({ report }: BlobPublishOptions): Promise }) ) + const failedUploads = screenshots.filter((screenshot) => Boolean(screenshot.uploadError)) + const publisherResult: ReportPublisherResult = + failedUploads.length === screenshots.length + ? { + publisher: 'blob', + status: 'failed', + detail: 'Blob uploads failed for every screenshot.', + } + : failedUploads.length > 0 + ? { + publisher: 'blob', + status: 'ok', + detail: `Uploaded ${screenshots.length - failedUploads.length}/${screenshots.length} screenshots.`, + } + : { + publisher: 'blob', + status: 'ok', + detail: `Uploaded ${screenshots.length} screenshot${screenshots.length === 1 ? '' : 's'}.`, + } + return { - ...report, - screenshots, - } satisfies CommandReport + report: { + ...report, + screenshots, + } satisfies CommandReport, + publisherResult, + } } diff --git a/packages/cali/src/report/render.ts b/packages/cali/src/report/render.ts index dd3bad0..de2a2b2 100644 --- a/packages/cali/src/report/render.ts +++ b/packages/cali/src/report/render.ts @@ -1,10 +1,4 @@ -import type { - CommandReport, - DevReport, - PerfReviewReport, - QaReport, - ReviewReport, -} from './types.js' +import type { CommandReport, DevReport, PerfReviewReport, QaReport, ReviewReport } from './types.js' function appendList(lines: string[], title: string, values: string[], empty: string) { lines.push('', title) @@ -46,6 +40,21 @@ function appendMetadata(lines: string[], report: CommandReport) { } } +function appendPublishers(lines: string[], report: CommandReport) { + lines.push('', '### Publishers') + + if (!report.publisherResults || report.publisherResults.length === 0) { + lines.push('- No publisher results recorded.') + return + } + + for (const publisherResult of report.publisherResults) { + lines.push( + `- ${publisherResult.publisher}: ${publisherResult.status}${publisherResult.detail ? ` (${publisherResult.detail})` : ''}` + ) + } +} + function appendJsonReport(lines: string[], report: CommandReport) { lines.push('', '### JSON Report', '', '```json', JSON.stringify(report, null, 2), '```') } @@ -187,6 +196,7 @@ export function renderCommandSection(report: CommandReport) { report.environmentNotes ?? [], '- No environment notes recorded.' ) + appendPublishers(lines, report) appendMetadata(lines, report) appendJsonReport(lines, report) diff --git a/packages/cali/src/report/types.ts b/packages/cali/src/report/types.ts index 1d4ef9a..89b1482 100644 --- a/packages/cali/src/report/types.ts +++ b/packages/cali/src/report/types.ts @@ -20,7 +20,7 @@ export type ScreenshotInfo = { export type ReportPublisherResult = { publisher: string - ok: boolean + status: 'ok' | 'skipped' | 'failed' detail?: string } diff --git a/packages/cali/src/runtime/context-file.ts b/packages/cali/src/runtime/context-file.ts index b2b7046..a78c998 100644 --- a/packages/cali/src/runtime/context-file.ts +++ b/packages/cali/src/runtime/context-file.ts @@ -69,42 +69,102 @@ const OutputSchema = z }) .optional() -const CaliContextFileSchema = z.object({ - workspaceRoot: z.string().optional(), - repository: RepositorySchema, - task: TaskSchema, - pullRequest: PullRequestSchema, - mobile: MobileSchema, - build: BuildSchema, - output: OutputSchema, - qa: z - .object({ - acceptanceCriteria: z.union([z.string(), z.array(z.string())]).optional(), - }) - .optional(), - review: z.object({}).optional(), - perfReview: z - .object({ - targetFlow: z.string().optional(), - expectedInteraction: z.string().optional(), - profilingGoals: LabelsSchema, - suspectedScreens: LabelsSchema, - }) - .optional(), - dev: z - .object({ - branchStrategy: z.string().optional(), - allowedValidations: LabelsSchema, - writePolicy: z.enum(['workspace', 'none']).optional(), - pushPolicy: z.enum(['disabled', 'manual', 'auto']).optional(), - }) - .optional(), -}) - function normalizeLabels(values: string[] | undefined) { return values ?? [] } +function resolveOptionalPath(cwd: string, value?: string) { + return value ? resolveFromCwd(cwd, value) : undefined +} + +function createContextFileSchema(cwd: string) { + return z + .object({ + workspaceRoot: z.string().optional(), + repository: RepositorySchema, + task: TaskSchema, + pullRequest: PullRequestSchema, + mobile: MobileSchema, + build: BuildSchema, + output: OutputSchema, + qa: z + .object({ + acceptanceCriteria: z.union([z.string(), z.array(z.string())]).optional(), + }) + .optional(), + review: z.object({}).optional(), + perfReview: z + .object({ + targetFlow: z.string().optional(), + expectedInteraction: z.string().optional(), + profilingGoals: LabelsSchema, + suspectedScreens: LabelsSchema, + }) + .optional(), + dev: z + .object({ + branchStrategy: z.string().optional(), + allowedValidations: LabelsSchema, + writePolicy: z.enum(['workspace', 'none']).optional(), + pushPolicy: z.enum(['disabled', 'manual', 'auto']).optional(), + }) + .optional(), + }) + .transform( + (parsed): Partial => ({ + workspaceRoot: resolveOptionalPath(cwd, parsed.workspaceRoot), + repository: parsed.repository, + task: parsed.task + ? { + ...parsed.task, + labels: normalizeLabels(parsed.task.labels), + } + : undefined, + pullRequest: parsed.pullRequest + ? { + ...parsed.pullRequest, + labels: normalizeLabels(parsed.pullRequest.labels), + isDraft: parsed.pullRequest.isDraft ?? false, + } + : undefined, + mobile: parsed.mobile + ? { + ...parsed.mobile, + artifactPath: resolveOptionalPath(cwd, parsed.mobile.artifactPath), + } + : undefined, + build: parsed.build, + output: { + outputDir: resolveOptionalPath(cwd, parsed.output?.outputDir), + screenshotsDir: resolveOptionalPath(cwd, parsed.output?.screenshotsDir), + }, + qa: parsed.qa + ? { + acceptanceCriteria: Array.isArray(parsed.qa.acceptanceCriteria) + ? parsed.qa.acceptanceCriteria + : parsed.qa.acceptanceCriteria + ? [parsed.qa.acceptanceCriteria] + : [], + } + : undefined, + review: parsed.review, + perfReview: parsed.perfReview + ? { + ...parsed.perfReview, + profilingGoals: normalizeLabels(parsed.perfReview.profilingGoals), + suspectedScreens: normalizeLabels(parsed.perfReview.suspectedScreens), + } + : undefined, + dev: parsed.dev + ? { + ...parsed.dev, + allowedValidations: normalizeLabels(parsed.dev.allowedValidations), + } + : undefined, + }) + ) +} + export async function loadContextFile( cwd: string, contextPath?: string @@ -115,63 +175,5 @@ export async function loadContextFile( const absolutePath = resolveFromCwd(cwd, contextPath) const content = await readFile(absolutePath, 'utf8') - const parsed = CaliContextFileSchema.parse(JSON.parse(content)) - - return { - workspaceRoot: parsed.workspaceRoot ? resolveFromCwd(cwd, parsed.workspaceRoot) : undefined, - repository: parsed.repository ? { ...parsed.repository } : undefined, - task: parsed.task - ? { - ...parsed.task, - labels: normalizeLabels(parsed.task.labels), - } - : undefined, - pullRequest: parsed.pullRequest - ? { - ...parsed.pullRequest, - labels: normalizeLabels(parsed.pullRequest.labels), - isDraft: parsed.pullRequest.isDraft ?? false, - } - : undefined, - mobile: parsed.mobile - ? { - ...parsed.mobile, - artifactPath: parsed.mobile.artifactPath - ? resolveFromCwd(cwd, parsed.mobile.artifactPath) - : undefined, - } - : undefined, - build: parsed.build, - output: { - outputDir: parsed.output?.outputDir - ? resolveFromCwd(cwd, parsed.output.outputDir) - : undefined, - screenshotsDir: parsed.output?.screenshotsDir - ? resolveFromCwd(cwd, parsed.output.screenshotsDir) - : undefined, - }, - qa: parsed.qa - ? { - acceptanceCriteria: Array.isArray(parsed.qa.acceptanceCriteria) - ? parsed.qa.acceptanceCriteria - : parsed.qa.acceptanceCriteria - ? [parsed.qa.acceptanceCriteria] - : [], - } - : undefined, - review: parsed.review, - perfReview: parsed.perfReview - ? { - ...parsed.perfReview, - profilingGoals: normalizeLabels(parsed.perfReview.profilingGoals), - suspectedScreens: normalizeLabels(parsed.perfReview.suspectedScreens), - } - : undefined, - dev: parsed.dev - ? { - ...parsed.dev, - allowedValidations: normalizeLabels(parsed.dev.allowedValidations), - } - : undefined, - } + return createContextFileSchema(cwd).parse(JSON.parse(content)) } diff --git a/packages/cali/src/runtime/context.ts b/packages/cali/src/runtime/context.ts index fded413..2777712 100644 --- a/packages/cali/src/runtime/context.ts +++ b/packages/cali/src/runtime/context.ts @@ -68,7 +68,6 @@ function mergeContext( ...override.output, }, qa: override.qa ?? base.qa, - review: override.review ?? base.review, perfReview: override.perfReview ?? base.perfReview, dev: override.dev ?? base.dev, } @@ -174,22 +173,30 @@ function applyDefaults( : context.mobile, build: context.build, output, - qa: { - acceptanceCriteria: context.qa?.acceptanceCriteria ?? [], - }, - review: context.review ?? {}, - perfReview: { - profilingGoals: context.perfReview?.profilingGoals ?? [], - suspectedScreens: context.perfReview?.suspectedScreens ?? [], - targetFlow: context.perfReview?.targetFlow, - expectedInteraction: context.perfReview?.expectedInteraction, - }, - dev: { - allowedValidations: context.dev?.allowedValidations ?? [], - branchStrategy: context.dev?.branchStrategy, - writePolicy: context.dev?.writePolicy ?? 'workspace', - pushPolicy: context.dev?.pushPolicy ?? 'disabled', - }, + qa: + commandId === 'qa' + ? { + acceptanceCriteria: context.qa?.acceptanceCriteria ?? [], + } + : undefined, + perfReview: + commandId === 'perf-review' + ? { + profilingGoals: context.perfReview?.profilingGoals ?? [], + suspectedScreens: context.perfReview?.suspectedScreens ?? [], + targetFlow: context.perfReview?.targetFlow, + expectedInteraction: context.perfReview?.expectedInteraction, + } + : undefined, + dev: + commandId === 'dev' + ? { + allowedValidations: context.dev?.allowedValidations ?? [], + branchStrategy: context.dev?.branchStrategy, + writePolicy: context.dev?.writePolicy ?? 'workspace', + pushPolicy: context.dev?.pushPolicy ?? 'disabled', + } + : undefined, } } diff --git a/packages/cali/src/runtime/mobile.ts b/packages/cali/src/runtime/mobile.ts index 623df81..0491733 100644 --- a/packages/cali/src/runtime/mobile.ts +++ b/packages/cali/src/runtime/mobile.ts @@ -1,7 +1,5 @@ import { createHash } from 'node:crypto' -import { existsSync, readdirSync } from 'node:fs' import { readdir, rm, stat } from 'node:fs/promises' -import { homedir } from 'node:os' import path from 'node:path' import type { CaliEnvName } from '../config/schema.js' @@ -40,7 +38,6 @@ async function runAgentDeviceCommand( args: string[], options: Parameters[2] = {} ) { - await ensureCommandExists('agent-device', 'npm i -g agent-device') return runCommand('agent-device', [command, ...args], options) } @@ -50,7 +47,6 @@ async function runAgentDeviceSessionCommand( args: string[], options: Parameters[2] = {} ) { - await ensureCommandExists('agent-device', 'npm i -g agent-device') return runCommand( 'agent-device', [...getAgentDeviceSessionArgs(sessionName), command, ...args], @@ -68,84 +64,20 @@ async function readCommandStdout(file: string, args: string[]) { return value.length > 0 ? value : undefined } -function findNewestSdkToolPath(baseDirectory: string, childPath: string) { - if (!existsSync(baseDirectory)) { - return undefined - } - - const children = readdirSync(baseDirectory).sort().reverse() - for (const child of children) { - const candidate = path.join(baseDirectory, child, childPath) - if (existsSync(candidate)) { - return candidate - } - } - - return undefined -} - -function getAndroidSdkRoots() { - return [ - process.env.ANDROID_HOME, - process.env.ANDROID_SDK_ROOT, - path.join(homedir(), 'Library', 'Android', 'sdk'), - ].filter((value): value is string => Boolean(value)) -} - -function getAndroidSdkApkanalyzerCandidates() { - const candidates: string[] = [] - for (const sdkRoot of getAndroidSdkRoots()) { - const latestApkanalyzer = findNewestSdkToolPath( - path.join(sdkRoot, 'cmdline-tools'), - path.join('bin', 'apkanalyzer') - ) - if (latestApkanalyzer) { - candidates.push(latestApkanalyzer) - } - - const legacyApkanalyzer = path.join(sdkRoot, 'tools', 'bin', 'apkanalyzer') - if (existsSync(legacyApkanalyzer)) { - candidates.push(legacyApkanalyzer) - } - } - - return [...new Set(candidates)] -} - -function getAndroidSdkAaptCandidates() { - const sdkRoots = [...getAndroidSdkRoots()] - - const candidates: string[] = [] - for (const sdkRoot of sdkRoots) { - const latestAapt = findNewestSdkToolPath(path.join(sdkRoot, 'build-tools'), 'aapt') - if (latestAapt) { - candidates.push(latestAapt) - } - } - - return [...new Set(candidates)] -} - async function inferAndroidAppId(artifactPath: string) { - const apkanalyzerCommands = ['apkanalyzer', ...getAndroidSdkApkanalyzerCandidates()] - for (const commandPath of apkanalyzerCommands) { - const apkanalyzerValue = await readCommandStdout(commandPath, [ - 'manifest', - 'application-id', - artifactPath, - ]) - if (apkanalyzerValue) { - return apkanalyzerValue.split('\n').at(-1)?.trim() - } + const apkanalyzerValue = await readCommandStdout('apkanalyzer', [ + 'manifest', + 'application-id', + artifactPath, + ]) + if (apkanalyzerValue) { + return apkanalyzerValue.split('\n').at(-1)?.trim() } - const aaptCommands = ['aapt', ...getAndroidSdkAaptCandidates()] - for (const commandPath of aaptCommands) { - const aaptValue = await readCommandStdout(commandPath, ['dump', 'badging', artifactPath]) - const packageName = aaptValue?.match(/package: name='([^']+)'/)?.[1] - if (packageName) { - return packageName - } + const aaptValue = await readCommandStdout('aapt', ['dump', 'badging', artifactPath]) + const packageName = aaptValue?.match(/package: name='([^']+)'/)?.[1] + if (packageName) { + return packageName } return undefined @@ -336,6 +268,7 @@ export async function resolveMobileRuntimeContext( envName: CaliEnvName, context: CaliContext ): Promise { + await ensureCommandExists('agent-device', 'npm i -g agent-device') const platform = context.mobile?.platform const artifactPath = context.mobile?.artifactPath const outputDir = context.output.outputDir diff --git a/packages/cali/src/runtime/publishers.ts b/packages/cali/src/runtime/publishers.ts index 0d09e77..59bc9f5 100644 --- a/packages/cali/src/runtime/publishers.ts +++ b/packages/cali/src/runtime/publishers.ts @@ -20,18 +20,21 @@ export async function publishReport(options: PublishReportOptions) { try { if (publisher === 'blob') { - currentReport = await publishBlobReport({ report: currentReport }) + const blobResult = await publishBlobReport({ report: currentReport }) + currentReport = blobResult.report + publisherResults.push(blobResult.publisherResult) + continue } publisherResults.push({ publisher, - ok: true, + status: 'ok', }) } catch (unknownError) { const error = unknownError instanceof Error ? unknownError : new Error(String(unknownError)) publisherResults.push({ publisher, - ok: false, + status: 'failed', detail: error.message, }) } @@ -44,7 +47,7 @@ export async function publishReport(options: PublishReportOptions) { ...publisherResults, { publisher: 'file', - ok: true, + status: 'ok', }, ], }) diff --git a/packages/cali/src/runtime/tool-loop-role.ts b/packages/cali/src/runtime/tool-loop-role.ts index 23e9658..d37230b 100644 --- a/packages/cali/src/runtime/tool-loop-role.ts +++ b/packages/cali/src/runtime/tool-loop-role.ts @@ -86,8 +86,6 @@ export async function runToolLoopRole(options: RunToolLoopRoleOptions { @@ -107,14 +105,11 @@ export async function runToolLoopRole(options: RunToolLoopRoleOptions Promise createTools?: (context: { context: CaliContext workspaceRoot: string @@ -42,6 +43,7 @@ const TOOL_PACK_DEFINITIONS: Record = { createTools: ({ skills }) => createSkillsToolPack(skills), }, 'agent-device': { + ensureAvailable: () => ensureCommandExists('agent-device', 'npm i -g agent-device'), requiredSkills: [ { name: 'agent-device', @@ -55,19 +57,27 @@ const TOOL_PACK_DEFINITIONS: Record = { }), }, 'repo-read': { + ensureAvailable: async () => { + await ensureCommandExists('git', 'Install Git and make sure `git` is on PATH.') + await ensureCommandExists('rg', 'Install ripgrep and make sure `rg` is on PATH.') + }, createTools: ({ workspaceRoot }) => createRepoReadToolPack({ workspaceRoot }), }, 'repo-write': { + ensureAvailable: () => + ensureCommandExists( + 'zsh', + 'Install zsh and make sure `zsh` is on PATH for repository commands.' + ), createTools: ({ workspaceRoot, context }) => createRepoWriteToolPack({ workspaceRoot, allowedCommands: context.dev?.allowedValidations ?? [], }), }, - github: { - createTools: ({ context }) => createGitHubToolPack({ context }), - }, 'react-devtools': { + ensureAvailable: () => + ensureCommandExists('agent-react-devtools', 'npm i -g agent-react-devtools'), requiredSkills: [ { name: 'react-devtools', @@ -76,7 +86,6 @@ const TOOL_PACK_DEFINITIONS: Record = { ], createTools: ({ state }) => createReactDevtoolsToolPack({ trace: state.reactDevtoolsTrace }), }, - report: {}, } export async function prepareToolPacks(options: PrepareToolPacksOptions) { @@ -85,6 +94,11 @@ export async function prepareToolPacks(options: PrepareToolPacksOptions) { throw new Error('agent-device tool pack requires a bound session name.') } const skills = await discoverSkills(skillPaths) + await Promise.all( + enabledToolPacks.map(async (toolPackName) => { + await TOOL_PACK_DEFINITIONS[toolPackName].ensureAvailable?.() + }) + ) const state: ToolPackState = { agentDeviceTrace: [], reactDevtoolsTrace: [], diff --git a/packages/cali/src/runtime/types.ts b/packages/cali/src/runtime/types.ts index ee7f264..4b2e170 100644 --- a/packages/cali/src/runtime/types.ts +++ b/packages/cali/src/runtime/types.ts @@ -1,22 +1,7 @@ -import type { CaliEnvName, PublisherName, ToolPackName } from '../config/schema.js' - -export const COMMAND_IDS = ['qa', 'review', 'perf-review', 'dev'] as const - -export type CommandId = (typeof COMMAND_IDS)[number] - +import type { CaliEnvName, CommandId, PublisherName, ToolPackName } from '../config/schema.js' +export type { CommandId } from '../config/schema.js' export type CommandConfigKey = 'qa' | 'review' | 'perfReview' | 'dev' -export function commandConfigKeyFromId(commandId: CommandId): CommandConfigKey { - switch (commandId) { - case 'perf-review': - return 'perfReview' - case 'qa': - case 'review': - case 'dev': - return commandId - } -} - export type CaliPlatform = 'android' | 'ios' export type RepositoryContext = { diff --git a/packages/cali/src/tools/agent-device.ts b/packages/cali/src/tools/agent-device.ts index 10f8a12..4ff7718 100644 --- a/packages/cali/src/tools/agent-device.ts +++ b/packages/cali/src/tools/agent-device.ts @@ -1,8 +1,5 @@ -import { tool } from 'ai' -import { z } from 'zod' - import type { ToolTraceEntry } from '../runtime/types.js' -import { ensureCommandExists, parseJson, runCommand, trimText } from '../utils.js' +import { createCliTool } from './cli-tool.js' const DEFAULT_AGENT_DEVICE_SESSION_LOCK = 'reject' @@ -45,44 +42,16 @@ function normalizeCommandInvocation(command: string, args: string[]) { export function createAgentDeviceToolPack(options: CreateAgentDeviceToolPackOptions) { const { trace, sessionName } = options const sessionArgs = getAgentDeviceSessionArgs(sessionName, { lockTarget: true }) - const inputSchema = z.object({ - command: z - .string() - .describe( - 'The first agent-device subcommand to run, such as snapshot, get, press, click, fill, type, wait, back, home, or screenshot.' - ), - args: z.array(z.string()).optional().describe('Remaining CLI arguments for the subcommand.'), - }) - - return { - agent_device: tool({ - description: - 'Run an agent-device command for mobile UI automation and screenshot capture. Use canonical subcommands like back or home directly; do not emulate them with press.', - inputSchema, - execute: async ({ command, args = [] }) => { - await ensureCommandExists('agent-device', 'npm i -g agent-device') - const normalized = normalizeCommandInvocation(command, args) - const fullCommand = [...sessionArgs, normalized.command, ...normalized.args] - const result = await runCommand('agent-device', fullCommand, { - allowFailure: true, - }) - trace.push({ - command: fullCommand.join(' '), - ok: result.ok, - exitCode: result.exitCode, - stdout: trimText(result.stdout, 4000), - stderr: trimText(result.stderr, 2000), - }) - - return { - ok: result.ok, - exitCode: result.exitCode, - stdout: trimText(result.stdout, 8000), - stderr: trimText(result.stderr, 4000), - json: parseJson(result.stdout, null as unknown), - } - }, - }), - } + return createCliTool({ + toolName: 'agent_device', + binaryName: 'agent-device', + description: + 'Run an agent-device command for mobile UI automation and screenshot capture. Use canonical subcommands like back or home directly; do not emulate them with press.', + trace, + buildArgs: ({ command, args }) => { + const normalized = normalizeCommandInvocation(command, args) + return [...sessionArgs, normalized.command, ...normalized.args] + }, + }) } diff --git a/packages/cali/src/tools/cli-tool.ts b/packages/cali/src/tools/cli-tool.ts new file mode 100644 index 0000000..98a04cd --- /dev/null +++ b/packages/cali/src/tools/cli-tool.ts @@ -0,0 +1,51 @@ +import { tool } from 'ai' +import { z } from 'zod' + +import type { ToolTraceEntry } from '../runtime/types.js' +import { parseJson, runCommand, trimText } from '../utils.js' + +type CreateCliToolOptions = { + toolName: string + binaryName: string + description: string + trace: ToolTraceEntry[] + buildArgs?: (args: { command: string; args: string[] }) => string[] +} + +const inputSchema = z.object({ + command: z.string(), + args: z.array(z.string()).optional(), +}) + +export function createCliTool(options: CreateCliToolOptions) { + const { toolName, binaryName, description, trace, buildArgs } = options + + return { + [toolName]: tool({ + description, + inputSchema, + execute: async ({ command, args = [] }) => { + const fullCommand = buildArgs ? buildArgs({ command, args }) : [command, ...args] + const result = await runCommand(binaryName, fullCommand, { + allowFailure: true, + }) + + trace.push({ + command: fullCommand.join(' '), + ok: result.ok, + exitCode: result.exitCode, + stdout: trimText(result.stdout, 4000), + stderr: trimText(result.stderr, 2000), + }) + + return { + ok: result.ok, + exitCode: result.exitCode, + stdout: trimText(result.stdout, 8000), + stderr: trimText(result.stderr, 4000), + json: parseJson(result.stdout, null as unknown), + } + }, + }), + } +} diff --git a/packages/cali/src/tools/github.ts b/packages/cali/src/tools/github.ts deleted file mode 100644 index 3c57650..0000000 --- a/packages/cali/src/tools/github.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { tool } from 'ai' -import { z } from 'zod' - -import type { CaliContext } from '../runtime/types.js' - -type CreateGitHubToolPackOptions = { - context: CaliContext -} - -export function createGitHubToolPack(options: CreateGitHubToolPackOptions) { - const { context } = options - - return { - get_repository_context: tool({ - description: 'Read repository metadata from the normalized Cali context.', - inputSchema: z.object({}), - execute: async () => context.repository ?? {}, - }), - get_pull_request_context: tool({ - description: 'Read pull request metadata from the normalized Cali context.', - inputSchema: z.object({}), - execute: async () => context.pullRequest ?? {}, - }), - get_task_context: tool({ - description: 'Read task metadata from the normalized Cali context.', - inputSchema: z.object({}), - execute: async () => context.task ?? {}, - }), - } -} diff --git a/packages/cali/src/tools/react-devtools.ts b/packages/cali/src/tools/react-devtools.ts index 83d3932..833625c 100644 --- a/packages/cali/src/tools/react-devtools.ts +++ b/packages/cali/src/tools/react-devtools.ts @@ -1,8 +1,5 @@ -import { tool } from 'ai' -import { z } from 'zod' - import type { ToolTraceEntry } from '../runtime/types.js' -import { ensureCommandExists, parseJson, runCommand, trimText } from '../utils.js' +import { createCliTool } from './cli-tool.js' type CreateReactDevtoolsToolPackOptions = { trace: ToolTraceEntry[] @@ -11,42 +8,11 @@ type CreateReactDevtoolsToolPackOptions = { export function createReactDevtoolsToolPack(options: CreateReactDevtoolsToolPackOptions) { const { trace } = options - return { - react_devtools: tool({ - description: - 'Run an agent-react-devtools command to inspect the component tree, props, state, hooks, or profile runtime performance.', - inputSchema: z.object({ - command: z - .string() - .describe('The first subcommand, such as status, get, find, profile, wait.'), - args: z - .array(z.string()) - .optional() - .describe('Remaining CLI arguments for the subcommand.'), - }), - execute: async ({ command, args = [] }) => { - await ensureCommandExists('agent-react-devtools', 'npm i -g agent-react-devtools') - const fullCommand = [command, ...args] - const result = await runCommand('agent-react-devtools', fullCommand, { - allowFailure: true, - }) - - trace.push({ - command: fullCommand.join(' '), - ok: result.ok, - exitCode: result.exitCode, - stdout: trimText(result.stdout, 4000), - stderr: trimText(result.stderr, 2000), - }) - - return { - ok: result.ok, - exitCode: result.exitCode, - stdout: trimText(result.stdout, 8000), - stderr: trimText(result.stderr, 4000), - json: parseJson(result.stdout, null as unknown), - } - }, - }), - } + return createCliTool({ + toolName: 'react_devtools', + binaryName: 'agent-react-devtools', + description: + 'Run an agent-react-devtools command to inspect the component tree, props, state, hooks, or profile runtime performance.', + trace, + }) } diff --git a/packages/cali/src/tools/repo.ts b/packages/cali/src/tools/repo.ts index 965baf2..a4a0933 100644 --- a/packages/cali/src/tools/repo.ts +++ b/packages/cali/src/tools/repo.ts @@ -4,7 +4,7 @@ import path from 'node:path' import { tool } from 'ai' import { z } from 'zod' -import { ensureCommandExists, ensureDirectory, resolveFromCwd, runCommand } from '../utils.js' +import { ensureDirectory, resolveFromCwd, runCommand } from '../utils.js' type RepoReadToolPackOptions = { workspaceRoot: string @@ -17,9 +17,6 @@ type RepoWriteToolPackOptions = { export function createRepoReadToolPack(options: RepoReadToolPackOptions) { const { workspaceRoot } = options - const ensureRipgrep = () => - ensureCommandExists('rg', 'Install ripgrep and make sure `rg` is on PATH.') - const ensureGit = () => ensureCommandExists('git', 'Install Git and make sure `git` is on PATH.') return { list_repo_files: tool({ @@ -29,7 +26,6 @@ export function createRepoReadToolPack(options: RepoReadToolPackOptions) { maxResults: z.number().int().min(1).max(500).optional(), }), execute: async ({ pattern, maxResults = 200 }) => { - await ensureRipgrep() const args = ['--files'] if (pattern?.trim()) { args.push('-g', pattern.trim()) @@ -59,7 +55,6 @@ export function createRepoReadToolPack(options: RepoReadToolPackOptions) { maxResults: z.number().int().min(1).max(200).optional(), }), execute: async ({ query, glob, maxResults = 100 }) => { - await ensureRipgrep() const args = ['-n', '--no-heading', query] if (glob?.trim()) { args.push('-g', glob.trim()) @@ -106,7 +101,6 @@ export function createRepoReadToolPack(options: RepoReadToolPackOptions) { description: 'Read the current git status for the repository.', inputSchema: z.object({}), execute: async () => { - await ensureGit() const result = await runCommand('git', ['status', '--short', '--branch'], { cwd: workspaceRoot, allowFailure: true, @@ -127,7 +121,6 @@ export function createRepoReadToolPack(options: RepoReadToolPackOptions) { maxLines: z.number().int().min(1).max(800).optional(), }), execute: async ({ baseRef, path: relativePath, maxLines = 400 }) => { - await ensureGit() const args = ['diff'] if (baseRef?.trim()) { args.push(baseRef.trim()) @@ -153,11 +146,6 @@ export function createRepoReadToolPack(options: RepoReadToolPackOptions) { export function createRepoWriteToolPack(options: RepoWriteToolPackOptions) { const { workspaceRoot, allowedCommands = [] } = options - const ensureShell = () => - ensureCommandExists( - 'zsh', - 'Install zsh and make sure `zsh` is on PATH for repository commands.' - ) return { write_repo_file: tool({ @@ -199,7 +187,6 @@ export function createRepoWriteToolPack(options: RepoWriteToolPackOptions) { command: z.string(), }), execute: async ({ command }) => { - await ensureShell() if (allowedCommands.length > 0 && !allowedCommands.includes(command)) { return { ok: false, diff --git a/packages/cali/src/utils.ts b/packages/cali/src/utils.ts index 7bc5ef1..e020bd7 100644 --- a/packages/cali/src/utils.ts +++ b/packages/cali/src/utils.ts @@ -3,6 +3,7 @@ import { mkdir } from 'node:fs/promises' import path from 'node:path' import { promisify } from 'node:util' +import { DOCS_URLS } from './docs.js' import type { CaliPlatform } from './runtime/types.js' const execFile = promisify(execFileCallback) @@ -88,7 +89,12 @@ export async function ensureCommandExists(commandName: string, installHint: stri } throw new Error( - `Missing required CLI: ${commandName}\n\nInstall it before running Cali:\n${installHint}` + [ + `Missing required CLI: ${commandName}`, + 'Install it before running Cali:', + installHint, + `Docs: ${DOCS_URLS.requiredClis}`, + ].join('\n\n') ) } From 3c24bbb75458986d1af559187c8f8b5cd1a3e1d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Wed, 8 Apr 2026 13:05:51 +0200 Subject: [PATCH 23/48] fix: tighten cali qa runtime polish --- .eslintrc.cjs | 1 + packages/cali/README.md | 2 +- packages/cali/src/commands/perf-review.ts | 8 +- packages/cali/src/commands/qa.ts | 2 +- packages/cali/src/commands/shared.ts | 5 +- packages/cali/src/runtime/mobile.ts | 248 +++++++++++++++++++- packages/cali/src/runtime/tool-loop-role.ts | 6 +- packages/cali/src/runtime/types.ts | 2 +- packages/cali/src/utils.ts | 44 +++- 9 files changed, 289 insertions(+), 29 deletions(-) diff --git a/.eslintrc.cjs b/.eslintrc.cjs index fda6101..af54aec 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -3,6 +3,7 @@ module.exports = { es6: true, node: true, }, + ignorePatterns: ['dist/**', 'packages/*/dist/**'], plugins: ['import', 'simple-import-sort'], extends: ['eslint:recommended'], parserOptions: { diff --git a/packages/cali/README.md b/packages/cali/README.md index 305c447..568f248 100644 --- a/packages/cali/README.md +++ b/packages/cali/README.md @@ -195,7 +195,7 @@ npm i -g agent-react-devtools On macOS/Linux, Git and `zsh` are usually present already. Install ripgrep if `rg` is missing. -If you want Android app id inference from an `.apk` without passing `--app-id`, make sure either `apkanalyzer` or `aapt` is on `PATH`. +If you want Android app id inference from an `.apk` without passing `--app-id`, Cali now reads `AndroidManifest.xml` directly from the archive. It can also fall back to SDK `aapt` when the manifest is not readable. If one of these is missing, Cali stops with an actionable error instead of trying to install it automatically. diff --git a/packages/cali/src/commands/perf-review.ts b/packages/cali/src/commands/perf-review.ts index 5655c60..77cc749 100644 --- a/packages/cali/src/commands/perf-review.ts +++ b/packages/cali/src/commands/perf-review.ts @@ -1,7 +1,7 @@ import type { PerfReviewReport, ScreenshotInfo } from '../report/types.js' import { runPerfReviewRole } from '../roles/perf-review.js' -import type { CommandCliOptions } from '../runtime/types.js' import { listScreenshots } from '../runtime/mobile.js' +import type { CommandCliOptions } from '../runtime/types.js' import { humanizeScreenshotLabel } from '../utils.js' import { runMobileStructuredCommand } from './shared.js' @@ -37,9 +37,9 @@ function composePerfReviewReport( } } -function createBlockedPerfReviewReport(summary: string): Awaited< - ReturnType ->['reportInput'] { +function createBlockedPerfReviewReport( + summary: string +): Awaited>['reportInput'] { return { overallStatus: 'blocked' as const, summary, diff --git a/packages/cali/src/commands/qa.ts b/packages/cali/src/commands/qa.ts index aacae25..b428d92 100644 --- a/packages/cali/src/commands/qa.ts +++ b/packages/cali/src/commands/qa.ts @@ -1,7 +1,7 @@ import type { QaReport, QaReportInput, ScreenshotInfo } from '../report/types.js' import { runQaMobileRole } from '../roles/qa-mobile.js' -import type { CommandCliOptions } from '../runtime/types.js' import { listScreenshots } from '../runtime/mobile.js' +import type { CommandCliOptions } from '../runtime/types.js' import { humanizeScreenshotLabel } from '../utils.js' import { runMobileStructuredCommand } from './shared.js' diff --git a/packages/cali/src/commands/shared.ts b/packages/cali/src/commands/shared.ts index 0a5c9ec..656e33e 100644 --- a/packages/cali/src/commands/shared.ts +++ b/packages/cali/src/commands/shared.ts @@ -11,7 +11,6 @@ import { bootstrapMobileApp, closeAgentDeviceSession, createAgentDeviceSessionName, - listScreenshots, prepareMobileOutputDirectories, resolveMobileRuntimeContext, } from '../runtime/mobile.js' @@ -223,7 +222,7 @@ export async function runStructuredCommand = RoleRunArgs & { +type MobileRoleRunArgs = RoleRunArgs & { mobileContext: MobileCommandRuntimeContext } @@ -243,7 +242,7 @@ type RunMobileStructuredCommandOptions Promise | TReport - runRole: (args: MobileRoleRunArgs) => Promise<{ reportInput: TReportInput }> + runRole: (args: MobileRoleRunArgs) => Promise<{ reportInput: TReportInput }> } export async function runMobileStructuredCommand( diff --git a/packages/cali/src/runtime/mobile.ts b/packages/cali/src/runtime/mobile.ts index 0491733..251e9ff 100644 --- a/packages/cali/src/runtime/mobile.ts +++ b/packages/cali/src/runtime/mobile.ts @@ -1,6 +1,8 @@ import { createHash } from 'node:crypto' -import { readdir, rm, stat } from 'node:fs/promises' +import { access, readdir, rm, stat } from 'node:fs/promises' +import { homedir } from 'node:os' import path from 'node:path' +import { TextDecoder } from 'node:util' import type { CaliEnvName } from '../config/schema.js' import type { ScreenshotInfo } from '../report/types.js' @@ -8,6 +10,16 @@ import { getAgentDeviceSessionArgs } from '../tools/agent-device.js' import { ensureCommandExists, ensureDirectory, runCommand } from '../utils.js' import type { CaliContext, CaliPlatform, CommandId, MobileCommandRuntimeContext } from './types.js' +const RES_XML_TYPE = 0x0003 +const RES_STRING_POOL_TYPE = 0x0001 +const RES_XML_START_ELEMENT_TYPE = 0x0102 +const UTF8_FLAG = 0x100 +const TYPE_STRING = 0x03 +const NO_INDEX = 0xffffffff +const utf16Decoder = new TextDecoder('utf-16le') + +let aaptPathCache: string | null | undefined + function buildDeviceSelectorArgs(context: { platform: CaliPlatform; deviceName?: string }) { const args = ['--platform', context.platform] @@ -64,17 +76,235 @@ async function readCommandStdout(file: string, args: string[]) { return value.length > 0 ? value : undefined } +async function readZipEntry(archivePath: string, entry: string) { + const result = await runCommand('unzip', ['-p', archivePath, entry], { + allowFailure: true, + binaryStdout: true, + }) + if (!result.ok || !result.stdoutBuffer || result.stdoutBuffer.length === 0) { + return undefined + } + + return result.stdoutBuffer +} + +function parseTextManifestPackageName(text: string) { + const match = text.match(/]*\bpackage\s*=\s*["']([^"']+)["']/i) + return match?.[1] +} + +function readLength8(chunk: Buffer, offset: number): [number, number] { + const first = chunk.readUInt8(offset) + if ((first & 0x80) === 0) { + return [first, 1] + } + + const second = chunk.readUInt8(offset + 1) + return [((first & 0x7f) << 8) | second, 2] +} + +function readLength16(chunk: Buffer, offset: number): [number, number] { + const first = chunk.readUInt16LE(offset) + if ((first & 0x8000) === 0) { + return [first, 2] + } + + const second = chunk.readUInt16LE(offset + 2) + return [((first & 0x7fff) << 16) | second, 4] +} + +function readUtf8String(chunk: Buffer, offset: number) { + const [, utf16LengthBytes] = readLength8(chunk, offset) + const [byteLength, byteLengthBytes] = readLength8(chunk, offset + utf16LengthBytes) + const start = offset + utf16LengthBytes + byteLengthBytes + + return chunk.subarray(start, start + byteLength).toString('utf8') +} + +function readUtf16String(chunk: Buffer, offset: number) { + const [charLength, lengthBytes] = readLength16(chunk, offset) + const start = offset + lengthBytes + + return utf16Decoder.decode(chunk.subarray(start, start + charLength * 2)) +} + +function parseStringPool(chunk: Buffer) { + if (chunk.length < 28) { + return [] + } + + const stringCount = chunk.readUInt32LE(8) + const flags = chunk.readUInt32LE(16) + const stringsStart = chunk.readUInt32LE(20) + const isUtf8 = (flags & UTF8_FLAG) !== 0 + const strings: string[] = [] + + for (let index = 0; index < stringCount; index += 1) { + const offsetPosition = 28 + index * 4 + if (offsetPosition + 4 > chunk.length) { + return strings + } + + const stringOffset = chunk.readUInt32LE(offsetPosition) + const absoluteOffset = stringsStart + stringOffset + strings.push( + isUtf8 ? readUtf8String(chunk, absoluteOffset) : readUtf16String(chunk, absoluteOffset) + ) + } + + return strings +} + +function parseStartElementPackageName( + buffer: Buffer, + chunkOffset: number, + headerSize: number, + strings: string[] +) { + if (headerSize < 16 || chunkOffset + headerSize + 20 > buffer.length) { + return undefined + } + + const nameIndex = buffer.readUInt32LE(chunkOffset + 20) + if (strings[nameIndex] !== 'manifest') { + return undefined + } + + const attributeStart = buffer.readUInt16LE(chunkOffset + 24) + const attributeSize = buffer.readUInt16LE(chunkOffset + 26) + const attributeCount = buffer.readUInt16LE(chunkOffset + 28) + const firstAttributeOffset = chunkOffset + headerSize + attributeStart + + for (let index = 0; index < attributeCount; index += 1) { + const attributeOffset = firstAttributeOffset + index * attributeSize + if (attributeOffset + 20 > buffer.length) { + return undefined + } + + const attributeName = strings[buffer.readUInt32LE(attributeOffset + 4)] + if (attributeName !== 'package') { + continue + } + + const rawValueIndex = buffer.readUInt32LE(attributeOffset + 8) + if (rawValueIndex !== NO_INDEX) { + return strings[rawValueIndex] + } + + const dataType = buffer.readUInt8(attributeOffset + 15) + const data = buffer.readUInt32LE(attributeOffset + 16) + if (dataType === TYPE_STRING) { + return strings[data] + } + + return undefined + } + + return undefined +} + +function parseBinaryManifestPackageName(buffer: Buffer) { + if (buffer.length < 8 || buffer.readUInt16LE(0) !== RES_XML_TYPE) { + return undefined + } + + let strings: string[] | undefined + for (let offset = buffer.readUInt16LE(2); offset + 8 <= buffer.length; ) { + const type = buffer.readUInt16LE(offset) + const headerSize = buffer.readUInt16LE(offset + 2) + const chunkSize = buffer.readUInt32LE(offset + 4) + if (chunkSize <= 0 || offset + chunkSize > buffer.length) { + return undefined + } + + if (type === RES_STRING_POOL_TYPE) { + strings = parseStringPool(buffer.subarray(offset, offset + chunkSize)) + } else if (type === RES_XML_START_ELEMENT_TYPE && strings) { + const packageName = parseStartElementPackageName(buffer, offset, headerSize, strings) + if (packageName) { + return packageName + } + } + + offset += chunkSize + } + + return undefined +} + +function parseAndroidManifestPackageName(manifest: Buffer) { + const textCandidate = manifest + .subarray(0, Math.min(manifest.length, 128)) + .toString('utf8') + .trimStart() + + if (textCandidate.startsWith('<')) { + return parseTextManifestPackageName(manifest.toString('utf8')) + } + + return parseBinaryManifestPackageName(manifest) +} + +async function resolveAaptPath() { + if (aaptPathCache !== undefined) { + return aaptPathCache ?? undefined + } + + const sdkRoots = [ + process.env.ANDROID_SDK_ROOT, + process.env.ANDROID_HOME, + path.join(homedir(), 'Library', 'Android', 'sdk'), + path.join(homedir(), 'Android', 'Sdk'), + ].filter((value): value is string => Boolean(value)) + + for (const sdkRoot of sdkRoots) { + const buildToolsDir = path.join(sdkRoot, 'build-tools') + + try { + const versions = await readdir(buildToolsDir) + const sortedVersions = versions.sort((left, right) => + right.localeCompare(left, undefined, { numeric: true }) + ) + + for (const version of sortedVersions) { + const candidate = path.join(buildToolsDir, version, 'aapt') + + try { + await access(candidate) + aaptPathCache = candidate + return candidate + } catch { + continue + } + } + } catch { + continue + } + } + + aaptPathCache = null + return undefined +} + async function inferAndroidAppId(artifactPath: string) { - const apkanalyzerValue = await readCommandStdout('apkanalyzer', [ - 'manifest', - 'application-id', - artifactPath, - ]) - if (apkanalyzerValue) { - return apkanalyzerValue.split('\n').at(-1)?.trim() + for (const entry of ['AndroidManifest.xml', 'base/manifest/AndroidManifest.xml']) { + const manifest = await readZipEntry(artifactPath, entry) + if (!manifest) { + continue + } + + const packageName = parseAndroidManifestPackageName(manifest) + if (packageName) { + return packageName + } + } + + const aaptPath = await resolveAaptPath() + if (!aaptPath) { + return undefined } - const aaptValue = await readCommandStdout('aapt', ['dump', 'badging', artifactPath]) + const aaptValue = await readCommandStdout(aaptPath, ['dump', 'badging', artifactPath]) const packageName = aaptValue?.match(/package: name='([^']+)'/)?.[1] if (packageName) { return packageName diff --git a/packages/cali/src/runtime/tool-loop-role.ts b/packages/cali/src/runtime/tool-loop-role.ts index d37230b..8adf64a 100644 --- a/packages/cali/src/runtime/tool-loop-role.ts +++ b/packages/cali/src/runtime/tool-loop-role.ts @@ -1,6 +1,6 @@ import { hasToolCall, stepCountIs, tool, ToolLoopAgent } from 'ai' -import { z } from 'zod' import type { ZodType } from 'zod' +import { z } from 'zod' import { createQaAgentModel } from '../model.js' @@ -70,7 +70,7 @@ export async function runToolLoopRole(options: RunToolLoopRoleOptions { + prepareStep: async ({ stepNumber }) => { if (!reserveReportAfterTool || reportInput) { return {} } @@ -86,7 +86,7 @@ export async function runToolLoopRole(options: RunToolLoopRoleOptions { if ( diff --git a/packages/cali/src/runtime/types.ts b/packages/cali/src/runtime/types.ts index 4b2e170..672c8a2 100644 --- a/packages/cali/src/runtime/types.ts +++ b/packages/cali/src/runtime/types.ts @@ -1,4 +1,4 @@ -import type { CaliEnvName, CommandId, PublisherName, ToolPackName } from '../config/schema.js' +import type { CaliEnvName, PublisherName, ToolPackName } from '../config/schema.js' export type { CommandId } from '../config/schema.js' export type CommandConfigKey = 'qa' | 'review' | 'perfReview' | 'dev' diff --git a/packages/cali/src/utils.ts b/packages/cali/src/utils.ts index e020bd7..da593b7 100644 --- a/packages/cali/src/utils.ts +++ b/packages/cali/src/utils.ts @@ -13,17 +13,19 @@ type CommandResult = { exitCode: number stdout: string stderr: string + stdoutBuffer?: Buffer } type CommandOptions = { cwd?: string env?: NodeJS.ProcessEnv allowFailure?: boolean + binaryStdout?: boolean } type ExecFileError = Error & { - stdout?: string - stderr?: string + stdout?: string | Buffer + stderr?: string | Buffer status?: number | null code?: number | string } @@ -35,25 +37,52 @@ export async function runCommand( args: string[], options: CommandOptions = {} ): Promise { - const { cwd = process.cwd(), env = process.env, allowFailure = false } = options + const { + cwd = process.cwd(), + env = process.env, + allowFailure = false, + binaryStdout = false, + } = options try { const result = await execFile(file, args, { cwd, env, maxBuffer: 20 * 1024 * 1024, + encoding: binaryStdout ? null : 'utf8', }) + const stdoutBuffer = Buffer.isBuffer(result.stdout) + ? result.stdout + : Buffer.from(result.stdout ?? '', 'utf8') + const stdout = Buffer.isBuffer(result.stdout) + ? stdoutBuffer.toString('utf8') + : (result.stdout ?? '') + const stderr = Buffer.isBuffer(result.stderr) + ? result.stderr.toString('utf8') + : (result.stderr ?? '') return { ok: true, exitCode: 0, - stdout: result.stdout ?? '', - stderr: result.stderr ?? '', + stdout, + stderr, + stdoutBuffer, } } catch (unknownError) { const error = unknownError as ExecFileError - const stdout = typeof error.stdout === 'string' ? error.stdout : '' - const stderr = typeof error.stderr === 'string' ? error.stderr : error.message + const stdoutBuffer = Buffer.isBuffer(error.stdout) ? error.stdout : undefined + const stdout = + typeof error.stdout === 'string' + ? error.stdout + : stdoutBuffer + ? stdoutBuffer.toString('utf8') + : '' + const stderr = + typeof error.stderr === 'string' + ? error.stderr + : Buffer.isBuffer(error.stderr) + ? error.stderr.toString('utf8') + : error.message const exitCode = typeof error.status === 'number' ? error.status @@ -72,6 +101,7 @@ export async function runCommand( exitCode, stdout, stderr, + stdoutBuffer, } } } From 83432999937074cb692f01bb8066b1cc3924dc60 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Wed, 8 Apr 2026 15:06:48 +0200 Subject: [PATCH 24/48] chore: release v0.4.0-0 --- package.json | 2 +- packages/cali/package.json | 2 +- packages/tools/package.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package.json b/package.json index f08be0e..20e3268 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@cali/root", - "version": "0.3.1", + "version": "0.4.0-0", "devDependencies": { "@release-it-plugins/workspaces": "^4.2.0", "@release-it/conventional-changelog": "^9.0.3", diff --git a/packages/cali/package.json b/packages/cali/package.json index 2b0a14d..77f54d8 100644 --- a/packages/cali/package.json +++ b/packages/cali/package.json @@ -62,7 +62,7 @@ "README.md" ], "license": "MIT", - "version": "0.3.1", + "version": "0.4.0-0", "engines": { "node": ">=22" } diff --git a/packages/tools/package.json b/packages/tools/package.json index 0646bcd..4a0148a 100644 --- a/packages/tools/package.json +++ b/packages/tools/package.json @@ -51,7 +51,7 @@ "README.md" ], "license": "MIT", - "version": "0.3.1", + "version": "0.4.0-0", "engines": { "node": ">=22" } From 0b86b610ccc48925e6a7b2f509e3fa105a17d0a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Wed, 8 Apr 2026 19:19:42 +0200 Subject: [PATCH 25/48] fix: avoid device selector on session-bound open --- packages/cali/src/runtime/mobile.ts | 7 +- skills/cali/SKILL.md | 52 ++++++++++++++ skills/cali/agents/openai.yaml | 4 ++ skills/cali/references/extending-cali.md | 42 ++++++++++++ skills/cali/references/running-cali.md | 86 ++++++++++++++++++++++++ 5 files changed, 190 insertions(+), 1 deletion(-) create mode 100644 skills/cali/SKILL.md create mode 100644 skills/cali/agents/openai.yaml create mode 100644 skills/cali/references/extending-cali.md create mode 100644 skills/cali/references/running-cali.md diff --git a/packages/cali/src/runtime/mobile.ts b/packages/cali/src/runtime/mobile.ts index 251e9ff..3de7c65 100644 --- a/packages/cali/src/runtime/mobile.ts +++ b/packages/cali/src/runtime/mobile.ts @@ -30,6 +30,11 @@ function buildDeviceSelectorArgs(context: { platform: CaliPlatform; deviceName?: return args } +function buildSessionOpenArgs(context: { platform: CaliPlatform; appId: string }) { + // Session-bound open must not re-specify the device selector once bootstrap chose the target. + return ['--platform', context.platform, context.appId, '--relaunch'] +} + function summarizeCommandFailure(result: { stdout: string; stderr: string; exitCode: number }) { return result.stderr || result.stdout || `Command failed with exit code ${result.exitCode}.` } @@ -442,7 +447,7 @@ async function openAppSession( return runAgentDeviceSessionCommand( sessionName, 'open', - [...buildDeviceSelectorArgs(context), context.appId, '--relaunch'], + buildSessionOpenArgs(context), options ) } diff --git a/skills/cali/SKILL.md b/skills/cali/SKILL.md new file mode 100644 index 0000000..198741a --- /dev/null +++ b/skills/cali/SKILL.md @@ -0,0 +1,52 @@ +--- +name: cali +description: Use when working in the Cali repository or when you need to run, extend, or debug the Cali CLI for mobile React Native and Expo workflows. Covers Cali commands (`qa`, `review`, `perf-review`, `dev`), the shared `cali-context.json` contract, env selection, required local CLIs, provider setup, and CI integration patterns. +--- + +# Cali + +Use this skill as a router with mandatory defaults. Read this file first. For normal Cali tasks, always load [references/running-cali.md](references/running-cali.md) before acting. If the task changes Cali itself, also load [references/extending-cali.md](references/extending-cali.md). + +## Default operating rules + +- Start with the shipped command surface and docs. Do not invent new Cali commands, envs, or config shapes. +- Treat `qa` as the stable command. Treat `review`, `perf-review`, and `dev` as experimental unless the task explicitly expands them. +- Prefer the shared `cali-context.json` contract over workflow-specific runtime scraping. +- Keep setup and CI instructions copy-pasteable when editing docs. +- If the task is about running Cali, verify the required local CLIs and model credentials before assuming the environment is ready. +- If the task is about changing Cali, prefer small explicit runtime contracts over broad abstraction. + +## Default flow + +1. Load [references/running-cali.md](references/running-cali.md). +2. If the task changes implementation or runtime behavior, then load [references/extending-cali.md](references/extending-cali.md). +3. Confirm which command is actually in scope before changing code or docs. +4. Keep the task aligned to the current runtime model: command + env + shared context + tool packs + publishers. + +## Command surface + +- `cali qa` + - ship-ready mobile QA with `agent-device` +- `cali review` + - experimental findings-first repository review +- `cali perf-review` + - experimental runtime performance review with `agent-device` and `agent-react-devtools` +- `cali dev` + - experimental repository-backed implementation flow + +## Built-in envs + +- `mobile-pr` +- `local-android` +- `local-ios` + +## Required references + +- For every normal Cali task, after reading this file, load [references/running-cali.md](references/running-cali.md) first. +- If the task changes code, runtime behavior, or extension points, also load [references/extending-cali.md](references/extending-cali.md). +- Load additional repo files only after you identify the owning command, role, or runtime module. + +## Additional references + +- Public CLI, provider setup, required CLIs, and copy-pasteable CI examples: [`packages/cali/README.md`](../../packages/cali/README.md) +- Repo implementation guidance and validation expectations: [`AGENTS.md`](../../AGENTS.md) diff --git a/skills/cali/agents/openai.yaml b/skills/cali/agents/openai.yaml new file mode 100644 index 0000000..ea17b58 --- /dev/null +++ b/skills/cali/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Cali CLI" + short_description: "Use and extend the Cali mobile QA and agent CLI" + default_prompt: "Use $cali to work with the Cali CLI, its runtime contracts, and its mobile QA workflows." diff --git a/skills/cali/references/extending-cali.md b/skills/cali/references/extending-cali.md new file mode 100644 index 0000000..8897cf8 --- /dev/null +++ b/skills/cali/references/extending-cali.md @@ -0,0 +1,42 @@ +# Extending Cali + +Use this reference when changing the Cali CLI, runtime contracts, or docs. + +## Start files + +- CLI registry: [`packages/cali/src/cli/app.ts`](../../../packages/cali/src/cli/app.ts) +- Commands: `packages/cali/src/commands/*.ts` +- Roles: `packages/cali/src/roles/*.ts` +- Shared runtime: + - [`packages/cali/src/runtime/context.ts`](../../../packages/cali/src/runtime/context.ts) + - [`packages/cali/src/runtime/tool-packs.ts`](../../../packages/cali/src/runtime/tool-packs.ts) + - [`packages/cali/src/runtime/tool-loop-role.ts`](../../../packages/cali/src/runtime/tool-loop-role.ts) + - [`packages/cali/src/runtime/mobile.ts`](../../../packages/cali/src/runtime/mobile.ts) +- Config: + - [`packages/cali/src/config/schema.ts`](../../../packages/cali/src/config/schema.ts) + - [`packages/cali/src/config/load.ts`](../../../packages/cali/src/config/load.ts) +- Reports: + - [`packages/cali/src/report/types.ts`](../../../packages/cali/src/report/types.ts) + - [`packages/cali/src/report/render.ts`](../../../packages/cali/src/report/render.ts) + +## Repo rules + +- Prefer one shared context model over workflow-specific loaders. +- Keep command surfaces explicit. Avoid broad generic agent frameworks. +- Keep `qa` behavior reliable first. +- Keep setup and CI docs copy-pasteable. +- Do not commit generated `artifacts/`. + +## Validation + +For `packages/cali` changes: + +```bash +bunx tsc --noEmit -p packages/cali/tsconfig.json +bun run build:cli +node packages/cali/dist/index.js --help +``` + +For CLI surface changes, also run the relevant command help checks. + +For runtime changes, run at least one real command if the environment is available. diff --git a/skills/cali/references/running-cali.md b/skills/cali/references/running-cali.md new file mode 100644 index 0000000..50a6197 --- /dev/null +++ b/skills/cali/references/running-cali.md @@ -0,0 +1,86 @@ +# Running Cali + +Use this reference for normal Cali usage, setup, and CI wiring. + +## Public commands + +- `cali qa` + - ship-ready mobile QA with `agent-device` +- `cali review` + - experimental findings-first repository review +- `cali perf-review` + - experimental runtime performance review with `agent-device` and `agent-react-devtools` +- `cali dev` + - experimental repository-backed implementation flow + +## Runtime model + +- `env` is the only preset concept +- all commands use one shared `cali-context.json` +- flags override context values + +Built-in envs: + +- `mobile-pr` +- `local-android` +- `local-ios` + +## Required local binaries + +- `qa`: `agent-device` +- `perf-review`: `agent-device`, `agent-react-devtools` +- `review`: `git`, `rg` +- `dev`: `git`, `rg`, `zsh` + +Do not auto-install missing CLIs. Cali should fail with actionable install guidance. + +## Common commands + +```bash +# Help +node packages/cali/dist/index.js --help +node packages/cali/dist/index.js qa --help + +# Local iOS QA +node packages/cali/dist/index.js qa \ + --env local-ios \ + --artifact ./artifacts/MyApp.app \ + --prompt "verify onboarding copy on Screen B" + +# Local Android QA +node packages/cali/dist/index.js qa \ + --env local-android \ + --artifact ./artifacts/app.apk \ + --prompt "verify onboarding copy on Screen B" + +# CI-style QA +node packages/cali/dist/index.js qa \ + --env mobile-pr \ + --context ./cali-context.json +``` + +## Provider setup + +Gateway: + +```dotenv +AI_GATEWAY_API_KEY=your-ai-gateway-key +QA_MODEL=openai/gpt-5.4-mini +``` + +Anthropic direct: + +```dotenv +ANTHROPIC_API_KEY=your-anthropic-api-key +QA_MODEL=anthropic/claude-sonnet-4.6 +``` + +`packages/cali` loads `.env` automatically from the current workspace before a run. + +## CI notes + +- Generate `cali-context.json` before invoking Cali. +- Do not assume Cali will scrape PR/build metadata from the environment at runtime. +- For copy-pasteable CI examples, use: + - [`packages/cali/examples/github-actions/write-mobile-pr-context.sh`](../../../packages/cali/examples/github-actions/write-mobile-pr-context.sh) + - [`packages/cali/examples/eas-workflows/write-mobile-pr-context.sh`](../../../packages/cali/examples/eas-workflows/write-mobile-pr-context.sh) From dec275d7785f6a869d829d75c2a6f5af0968cbd7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Wed, 8 Apr 2026 19:20:56 +0200 Subject: [PATCH 26/48] chore: release v0.4.0-1 --- package.json | 2 +- packages/cali/package.json | 2 +- packages/tools/package.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package.json b/package.json index 20e3268..5753ef1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@cali/root", - "version": "0.4.0-0", + "version": "0.4.0-1", "devDependencies": { "@release-it-plugins/workspaces": "^4.2.0", "@release-it/conventional-changelog": "^9.0.3", diff --git a/packages/cali/package.json b/packages/cali/package.json index 77f54d8..61f7410 100644 --- a/packages/cali/package.json +++ b/packages/cali/package.json @@ -62,7 +62,7 @@ "README.md" ], "license": "MIT", - "version": "0.4.0-0", + "version": "0.4.0-1", "engines": { "node": ">=22" } diff --git a/packages/tools/package.json b/packages/tools/package.json index 4a0148a..ddb431b 100644 --- a/packages/tools/package.json +++ b/packages/tools/package.json @@ -51,7 +51,7 @@ "README.md" ], "license": "MIT", - "version": "0.4.0-0", + "version": "0.4.0-1", "engines": { "node": ">=22" } From ee211d051202bb7c16585467039e275f65ee2366 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Wed, 8 Apr 2026 21:15:02 +0200 Subject: [PATCH 27/48] feat: add cali ci helpers and safe reports --- AGENTS.md | 4 + packages/cali/README.md | 87 +++--- .../eas-workflows/write-mobile-pr-context.sh | 58 +--- .../github-actions/write-mobile-pr-context.sh | 79 +----- packages/cali/package.json | 7 +- packages/cali/src/cli/app.ts | 12 +- packages/cali/src/cli/render-comment.ts | 44 +++ packages/cali/src/cli/shared.ts | 2 +- .../cali/src/cli/write-mobile-pr-context.ts | 71 +++++ packages/cali/src/commands/render-comment.ts | 32 +++ .../src/commands/write-mobile-pr-context.ts | 252 ++++++++++++++++++ packages/cali/src/config/load.ts | 20 +- packages/cali/src/config/schema.ts | 2 +- packages/cali/src/docs.ts | 2 + packages/cali/src/report/ci.ts | 106 ++++++++ packages/cali/src/report/publishers/blob.ts | 7 + packages/cali/src/report/publishers/file.ts | 126 ++++++++- packages/cali/src/report/render.ts | 5 - packages/cali/src/report/types.ts | 2 +- packages/cali/src/runtime/context-file.ts | 32 ++- packages/cali/src/runtime/context-repo.ts | 70 +++-- packages/cali/src/runtime/mobile.ts | 7 +- packages/cali/src/runtime/types.ts | 2 +- packages/cali/src/tools/skills.ts | 21 +- skills/cali/references/running-cali.md | 12 + 25 files changed, 851 insertions(+), 211 deletions(-) create mode 100644 packages/cali/src/cli/render-comment.ts create mode 100644 packages/cali/src/cli/write-mobile-pr-context.ts create mode 100644 packages/cali/src/commands/render-comment.ts create mode 100644 packages/cali/src/commands/write-mobile-pr-context.ts create mode 100644 packages/cali/src/report/ci.ts diff --git a/AGENTS.md b/AGENTS.md index 1f5222d..b053f15 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -76,6 +76,7 @@ Current maturity: Built-in envs: - `mobile-pr` +- `eas-mobile-pr` - `local-android` - `local-ios` @@ -176,6 +177,9 @@ Built bundle: - `bun run review -- --help` - `bun run perf-review -- --help` - `bun run dev:command -- --help` +- `bun run write-context:gha -- --output ./cali-context.json` +- `bun run write-context:eas -- --output ./cali-context.json` +- `bun run render-comment -- --report ./artifacts/qa/report.json` Source/dev loop: diff --git a/packages/cali/README.md b/packages/cali/README.md index 568f248..f20eacc 100644 --- a/packages/cali/README.md +++ b/packages/cali/README.md @@ -3,7 +3,7 @@ Cali v2 is a role-oriented CLI for mobile React Native and Expo workflows. It runs first-class agent commands on top of a shared runtime model: - commands: `qa`, `review`, `perf-review`, `dev` -- envs: `mobile-pr`, `local-android`, `local-ios` +- envs: `mobile-pr`, `eas-mobile-pr`, `local-android`, `local-ios` - one shared `cali-context.json` runtime contract - explicit tool packs per command - publisher-based outputs @@ -39,6 +39,7 @@ All commands use one shared `cali-context.json` contract. Commands only require "provider": "github.com", "owner": "acme", "name": "mobile-app", + "webUrl": "https://github.com/acme/mobile-app", "defaultBranch": "main", "currentBranch": "feature/onboarding-copy" }, @@ -83,6 +84,8 @@ All commands use one shared `cali-context.json` contract. Commands only require Flags always win over the context file. For example, `--platform`, `--artifact`, `--app-id`, `--output-dir`, `--pr-number`, or `--task-id` override the JSON values. For local mobile runs, `--app-id` is optional when Cali can infer it from the artifact. +For safety, Cali sanitizes credential-bearing repository URLs when loading context and publishes a reduced safe context in `report.json` by default. + ## Examples ### Local QA @@ -105,6 +108,7 @@ Local mobile behavior: ```bash cali qa --env mobile-pr --context ./cali-context.json +cali qa --env eas-mobile-pr --context ./cali-context.json cali review --env mobile-pr --context ./cali-context.json ``` @@ -199,21 +203,51 @@ If you want Android app id inference from an `.apk` without passing `--app-id`, If one of these is missing, Cali stops with an actionable error instead of trying to install it automatically. +## Required Skills + +Cali discovers local skills from: + +- `./.agents/skills` +- `~/.agents/skills` + +Required role skills: + +- `qa`: `agent-device` +- `perf-review`: `agent-device`, `react-devtools` + +Install examples: + +```bash +npx skills add callstackincubator/agent-device --agent codex --skill agent-device -y +npx skills add callstackincubator/agent-skills --agent codex --skill react-devtools -y +``` + ## CI Providers The current ship-ready CI story for Cali is: - generate one `cali-context.json` - run `cali qa --env mobile-pr --context ./cali-context.json` -- collect `artifacts/qa/report.json`, `section.md`, `status.txt`, and optional blob screenshot URLs +- collect the compact CI outputs from the file publisher -### GitHub Actions +## CI Helpers + +Use the built-in helper command instead of hand-writing `cali-context.json` in shell: + +```bash +cali write-mobile-pr-context --from github-actions --output ./cali-context.json +cali write-mobile-pr-context --from eas --output ./cali-context.json +``` -Use the example context writer: +Render a compact GitHub-ready comment from a generated report: -- [`examples/github-actions/write-mobile-pr-context.sh`](./examples/github-actions/write-mobile-pr-context.sh) +```bash +cali render-comment --report ./artifacts/qa/report.json --format github +``` -Required envs for that script: +### GitHub Actions + +Required envs for the built-in context writer: - `CALI_PLATFORM` - `CALI_ARTIFACT_PATH` @@ -231,20 +265,19 @@ Copy-paste example: CALI_PLATFORM: android CALI_ARTIFACT_PATH: ${{ steps.download_build.outputs.artifact_path }} CALI_APP_ID: com.example.myapp - run: bash ./packages/cali/examples/github-actions/write-mobile-pr-context.sh ./cali-context.json + run: node ./packages/cali/dist/index.js write-mobile-pr-context --from github-actions --output ./cali-context.json - name: Run Cali QA env: AI_GATEWAY_API_KEY: ${{ secrets.AI_GATEWAY_API_KEY }} run: node ./packages/cali/dist/index.js qa --env mobile-pr --context ./cali-context.json + +- name: Render PR comment body + run: node ./packages/cali/dist/index.js render-comment --report ./artifacts/qa/report.json --format github --output ./artifacts/qa/comment-github.md ``` ### EAS Workflows -Use the example context writer: - -- [`examples/eas-workflows/write-mobile-pr-context.sh`](./examples/eas-workflows/write-mobile-pr-context.sh) - This matches the environment shape from the earlier `eas-agent-device` workflow pattern: - `QA_PLATFORM` @@ -268,15 +301,15 @@ Copy-paste example: BUILD_ID: ${{ env.BUILD_ID }} WORKFLOW_URL: ${{ workflow.url }} PR_JSON: ${{ toJSON(github.event.pull_request) }} - run: bash ./packages/cali/examples/eas-workflows/write-mobile-pr-context.sh ./cali-context.json + run: node ./packages/cali/dist/index.js write-mobile-pr-context --from eas --output ./cali-context.json - id: run_cali_qa env: AI_GATEWAY_API_KEY: ${{ secrets.AI_GATEWAY_API_KEY }} - run: node ./packages/cali/dist/index.js qa --env mobile-pr --context ./cali-context.json + run: node ./packages/cali/dist/index.js qa --env eas-mobile-pr --context ./cali-context.json ``` -If you want a combined PR comment like the original Expo blog post flow, aggregate `section.md`, `status.txt`, and `report.json` from each platform job in a final workflow step. Cali intentionally leaves that aggregation to the workflow layer. +If you want a combined PR comment like the original Expo blog post flow, aggregate `comment-github.md`, `status-label.txt`, and `screenshots.json` from each platform job in a final workflow step. Cali still leaves the final multi-job aggregation to the workflow layer. ## Config @@ -305,11 +338,6 @@ export default { If `defaultCommand` is set, running plain `cali` with no command will execute that default command instead of showing help. -By default, Cali discovers skills from: - -- `./.agents/skills` -- `~/.agents/skills` - ## Tool Packs Built-in tool pack ids: @@ -336,9 +364,13 @@ Built bundle: - `bun run perf-review -- --help` - `bun run dev:command -- --help` - `bun run qa:env:mobile-pr -- --context ./cali-context.json` +- `bun run qa:env:eas-mobile-pr -- --context ./cali-context.json` - `bun run review:env:mobile-pr -- --context ./cali-context.json` - `bun run perf-review:env:mobile-pr -- --context ./cali-context.json` - `bun run dev:command:env:mobile-pr -- --context ./cali-context.json` +- `bun run write-context:gha -- --output ./cali-context.json` +- `bun run write-context:eas -- --output ./cali-context.json` +- `bun run render-comment -- --report ./artifacts/qa/report.json` Source/dev loop: @@ -347,17 +379,6 @@ Source/dev loop: - `bun run dev:perf-review -- --help` - `bun run dev:dev-command -- --help` -## Installing Skills - -For starter skills, use `npx skills` with the repos we trust: - -```bash -npx skills add callstackincubator/agent-device --agent codex --skill '*' -y -npx skills add callstackincubator/agent-skills --agent codex --skill '*' -y -``` - -If you want to use performance review flows, make sure the relevant skills are installed too, such as `react-devtools`. - ## Outputs The file publisher writes: @@ -365,6 +386,12 @@ The file publisher writes: - `report.json` - `section.md` - `status.txt` +- `status-label.txt` +- `summary.txt` +- `top-issue.txt` +- `screenshots.md` +- `screenshots.json` +- `comment-github.md` - `publisher-manifest.json` The default output directory is `artifacts/`. diff --git a/packages/cali/examples/eas-workflows/write-mobile-pr-context.sh b/packages/cali/examples/eas-workflows/write-mobile-pr-context.sh index 41991b2..78954f7 100755 --- a/packages/cali/examples/eas-workflows/write-mobile-pr-context.sh +++ b/packages/cali/examples/eas-workflows/write-mobile-pr-context.sh @@ -3,58 +3,6 @@ set -euo pipefail OUTPUT_PATH="${1:-./cali-context.json}" - -: "${QA_PLATFORM:?QA_PLATFORM is required}" -: "${APP_PATH:?APP_PATH is required}" -: "${APPLICATION_ID:?APPLICATION_ID is required}" - -jq -n \ - --arg workspaceRoot "${PWD}" \ - --arg platform "${QA_PLATFORM}" \ - --arg artifactPath "${APP_PATH}" \ - --arg appId "${APPLICATION_ID}" \ - --arg deviceName "${CALI_DEVICE_NAME:-}" \ - --arg outputDir "${CALI_OUTPUT_DIR:-./artifacts/qa}" \ - --arg buildId "${BUILD_ID:-}" \ - --arg workflowUrl "${WORKFLOW_URL:-}" \ - --arg logsUrl "${WORKFLOW_URL:-}" \ - --arg prJson "${PR_JSON:-}" \ - ' - ($prJson | if . == "" then null else fromjson end) as $pr - | { - workspaceRoot: $workspaceRoot, - pullRequest: ( - if $pr == null then - null - else - { - number: $pr.number, - title: $pr.title, - body: $pr.body, - url: $pr.html_url, - labels: (($pr.labels // []) | map(.name)), - isDraft: ($pr.draft // false), - baseBranch: $pr.base.ref, - headBranch: $pr.head.ref - } - end - ), - mobile: { - platform: $platform, - artifactPath: $artifactPath, - appId: $appId, - deviceName: (if $deviceName == "" then null else $deviceName end) - }, - build: { - id: $buildId, - workflowUrl: $workflowUrl, - logsUrl: $logsUrl - }, - output: { - outputDir: $outputDir - } - } - | del(.. | nulls) - ' >"${OUTPUT_PATH}" - -echo "Wrote ${OUTPUT_PATH}" +node ./packages/cali/dist/index.js write-mobile-pr-context \ + --from eas \ + --output "${OUTPUT_PATH}" diff --git a/packages/cali/examples/github-actions/write-mobile-pr-context.sh b/packages/cali/examples/github-actions/write-mobile-pr-context.sh index db0e374..4585d91 100755 --- a/packages/cali/examples/github-actions/write-mobile-pr-context.sh +++ b/packages/cali/examples/github-actions/write-mobile-pr-context.sh @@ -3,79 +3,6 @@ set -euo pipefail OUTPUT_PATH="${1:-./cali-context.json}" - -: "${GITHUB_WORKSPACE:?GITHUB_WORKSPACE is required}" -: "${GITHUB_REPOSITORY:?GITHUB_REPOSITORY is required}" -: "${GITHUB_SHA:?GITHUB_SHA is required}" -: "${GITHUB_REF_NAME:?GITHUB_REF_NAME is required}" -: "${GITHUB_SERVER_URL:?GITHUB_SERVER_URL is required}" -: "${GITHUB_EVENT_PATH:?GITHUB_EVENT_PATH is required}" -: "${CALI_PLATFORM:?CALI_PLATFORM is required}" -: "${CALI_ARTIFACT_PATH:?CALI_ARTIFACT_PATH is required}" - -REPO_OWNER="${GITHUB_REPOSITORY%/*}" -REPO_NAME="${GITHUB_REPOSITORY#*/}" -RUN_URL="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}" -JOB_URL="${RUN_URL}" - -jq -n \ - --arg workspaceRoot "${GITHUB_WORKSPACE}" \ - --arg repoOwner "${REPO_OWNER}" \ - --arg repoName "${REPO_NAME}" \ - --arg branch "${GITHUB_REF_NAME}" \ - --arg sha "${GITHUB_SHA}" \ - --arg platform "${CALI_PLATFORM}" \ - --arg artifactPath "${CALI_ARTIFACT_PATH}" \ - --arg appId "${CALI_APP_ID:-}" \ - --arg deviceName "${CALI_DEVICE_NAME:-}" \ - --arg outputDir "${CALI_OUTPUT_DIR:-./artifacts/qa}" \ - --arg buildId "${GITHUB_RUN_ID:-}" \ - --arg workflowUrl "${RUN_URL}" \ - --arg logsUrl "${JOB_URL}" \ - --slurpfile event "${GITHUB_EVENT_PATH}" \ - ' - ($event[0].pull_request // null) as $pr - | { - workspaceRoot: $workspaceRoot, - repository: { - provider: "github.com", - owner: $repoOwner, - name: $repoName, - currentBranch: $branch, - commitSha: $sha - }, - pullRequest: ( - if $pr == null then - null - else - { - number: $pr.number, - title: $pr.title, - body: $pr.body, - url: $pr.html_url, - labels: (($pr.labels // []) | map(.name)), - isDraft: ($pr.draft // false), - baseBranch: $pr.base.ref, - headBranch: $pr.head.ref - } - end - ), - mobile: { - platform: $platform, - artifactPath: $artifactPath, - appId: (if $appId == "" then null else $appId end), - deviceName: (if $deviceName == "" then null else $deviceName end) - }, - build: { - id: $buildId, - workflowUrl: $workflowUrl, - logsUrl: $logsUrl - }, - output: { - outputDir: $outputDir - } - } - | del(.. | nulls) - ' >"${OUTPUT_PATH}" - -echo "Wrote ${OUTPUT_PATH}" +node ./packages/cali/dist/index.js write-mobile-pr-context \ + --from github-actions \ + --output "${OUTPUT_PATH}" diff --git a/packages/cali/package.json b/packages/cali/package.json index 61f7410..bd7c921 100644 --- a/packages/cali/package.json +++ b/packages/cali/package.json @@ -12,6 +12,7 @@ "qa:env:local:android": "node ./dist/index.js qa --env local-android", "qa:env:local:ios": "node ./dist/index.js qa --env local-ios", "qa:env:mobile-pr": "node ./dist/index.js qa --env mobile-pr", + "qa:env:eas-mobile-pr": "node ./dist/index.js qa --env eas-mobile-pr", "review": "node ./dist/index.js review", "review:env:mobile-pr": "node ./dist/index.js review --env mobile-pr", "perf-review": "node ./dist/index.js perf-review", @@ -22,9 +23,13 @@ "dev:qa:env:local:android": "node --import=tsx ./src/cli.ts qa --env local-android", "dev:qa:env:local:ios": "node --import=tsx ./src/cli.ts qa --env local-ios", "dev:qa:env:mobile-pr": "node --import=tsx ./src/cli.ts qa --env mobile-pr", + "dev:qa:env:eas-mobile-pr": "node --import=tsx ./src/cli.ts qa --env eas-mobile-pr", "dev:review": "node --import=tsx ./src/cli.ts review", "dev:perf-review": "node --import=tsx ./src/cli.ts perf-review", - "dev:dev-command": "node --import=tsx ./src/cli.ts dev" + "dev:dev-command": "node --import=tsx ./src/cli.ts dev", + "write-context:eas": "node ./dist/index.js write-mobile-pr-context --from eas", + "write-context:gha": "node ./dist/index.js write-mobile-pr-context --from github-actions", + "render-comment": "node ./dist/index.js render-comment --format github" }, "dependencies": { "@ai-sdk/anthropic": "^3.0.64", diff --git a/packages/cali/src/cli/app.ts b/packages/cali/src/cli/app.ts index 398720b..c4afb14 100644 --- a/packages/cali/src/cli/app.ts +++ b/packages/cali/src/cli/app.ts @@ -5,10 +5,18 @@ import { printRetroBanner } from './banner.js' import { devCommandDefinition } from './dev.js' import { perfReviewCommandDefinition } from './perf-review.js' import { qaCommandDefinition } from './qa.js' +import { renderCommentCommandDefinition } from './render-comment.js' import { reviewCommandDefinition } from './review.js' +import { writeMobilePrContextCommandDefinition } from './write-mobile-pr-context.js' function shouldPrintBanner(args: string[]) { - if (args.includes('--quiet') || process.env.CI === 'true' || process.env.CI === '1') { + if ( + args.includes('--quiet') || + args.includes('--help') || + args.includes('-h') || + process.env.CI === 'true' || + process.env.CI === '1' + ) { return false } @@ -25,6 +33,8 @@ function createCli() { reviewCommandDefinition, perfReviewCommandDefinition, devCommandDefinition, + writeMobilePrContextCommandDefinition, + renderCommentCommandDefinition, ]) { commandDefinition.register(cli) } diff --git a/packages/cali/src/cli/render-comment.ts b/packages/cali/src/cli/render-comment.ts new file mode 100644 index 0000000..620e06e --- /dev/null +++ b/packages/cali/src/cli/render-comment.ts @@ -0,0 +1,44 @@ +import type { CAC } from 'cac' + +import { renderComment } from '../commands/render-comment.js' +import { readOptionalString } from './shared.js' + +type RenderCommentCliOptions = { + report?: string + format?: string + output?: string +} + +function normalizeFormat(value: unknown) { + if (!value || value === 'github') { + return 'github' as const + } + + throw new Error('`--format` must be `github`.') +} + +export const renderCommentCommandDefinition = { + register(cli: CAC) { + cli + .command('render-comment', 'Render a compact comment from a Cali report') + .option('--report ', 'Path to report.json') + .option('--format ', 'Comment format', { + default: 'github', + }) + .option('--output ', 'Write the rendered comment to a file instead of stdout') + .example('render-comment --report ./artifacts/qa/report.json --format github') + .action(async (options: unknown) => { + const normalized = options as RenderCommentCliOptions + const reportPath = readOptionalString(normalized.report) + if (!reportPath) { + throw new Error('`render-comment` requires `--report `.') + } + + await renderComment({ + reportPath, + format: normalizeFormat(normalized.format), + outputPath: readOptionalString(normalized.output), + }) + }) + }, +} diff --git a/packages/cali/src/cli/shared.ts b/packages/cali/src/cli/shared.ts index 080dcf2..7952415 100644 --- a/packages/cali/src/cli/shared.ts +++ b/packages/cali/src/cli/shared.ts @@ -83,7 +83,7 @@ export function normalizeBaseCommandCliOptions(options: BaseCommandOptions): Com export function registerCommonCommandOptions(command: any) { return command - .option('--env ', 'Built-in env: mobile-pr, local-android, local-ios') + .option('--env ', 'Built-in env: mobile-pr, eas-mobile-pr, local-android, local-ios') .option('--config ', 'Path to cali.config.ts') .option('--prompt ', 'Add task-specific intent') .option('--context ', 'Load shared Cali runtime context from JSON') diff --git a/packages/cali/src/cli/write-mobile-pr-context.ts b/packages/cali/src/cli/write-mobile-pr-context.ts new file mode 100644 index 0000000..33d84a2 --- /dev/null +++ b/packages/cali/src/cli/write-mobile-pr-context.ts @@ -0,0 +1,71 @@ +import type { CAC } from 'cac' + +import { writeMobilePrContext } from '../commands/write-mobile-pr-context.js' +import { readOptionalString } from './shared.js' + +type WriteMobilePrContextCliOptions = { + from?: string + output?: string + platform?: string + artifact?: string + appId?: string + device?: string + outputDir?: string + workspaceRoot?: string + buildId?: string + workflowUrl?: string + logsUrl?: string +} + +function normalizeProvider(value: unknown) { + if (value === 'github-actions' || value === 'eas') { + return value + } + + throw new Error('`--from` must be `github-actions` or `eas`.') +} + +export const writeMobilePrContextCommandDefinition = { + register(cli: CAC) { + cli + .command( + 'write-mobile-pr-context', + 'Write a normalized cali-context.json from GitHub Actions or EAS environment variables' + ) + .option('--from ', 'Context source: github-actions or eas') + .option('--output ', 'Output file path', { + default: './cali-context.json', + }) + .option('--platform ', 'Override platform: android or ios') + .option('--artifact ', 'Override artifact path') + .option('--app-id ', 'Override application identifier') + .option('--device ', 'Override simulator or emulator name') + .option('--output-dir ', 'Override report output directory') + .option('--workspace-root ', 'Override workspace root') + .option('--build-id ', 'Override build identifier') + .option('--workflow-url ', 'Override workflow URL') + .option('--logs-url ', 'Override logs URL') + .example('write-mobile-pr-context --from eas --output ./cali-context.json') + .example('write-mobile-pr-context --from github-actions --output ./cali-context.json') + .action(async (options: unknown) => { + const normalized = options as WriteMobilePrContextCliOptions + if (!normalized.from) { + throw new Error('`write-mobile-pr-context` requires `--from `.') + } + + await writeMobilePrContext({ + from: normalizeProvider(normalized.from), + outputPath: readOptionalString(normalized.output) ?? './cali-context.json', + platform: readOptionalString(normalized.platform) as 'android' | 'ios' | undefined, + artifactPath: readOptionalString(normalized.artifact), + appId: readOptionalString(normalized.appId), + deviceName: readOptionalString(normalized.device), + outputDir: readOptionalString(normalized.outputDir), + workspaceRoot: readOptionalString(normalized.workspaceRoot), + buildId: readOptionalString(normalized.buildId), + workflowUrl: readOptionalString(normalized.workflowUrl), + logsUrl: readOptionalString(normalized.logsUrl), + }) + }) + }, +} diff --git a/packages/cali/src/commands/render-comment.ts b/packages/cali/src/commands/render-comment.ts new file mode 100644 index 0000000..81948f6 --- /dev/null +++ b/packages/cali/src/commands/render-comment.ts @@ -0,0 +1,32 @@ +import { readFile, writeFile } from 'node:fs/promises' +import path from 'node:path' + +import { renderGithubComment } from '../report/ci.js' +import type { CommandReport } from '../report/types.js' +import { ensureDirectory, resolveFromCwd } from '../utils.js' + +export type RenderCommentOptions = { + reportPath: string + format: 'github' + outputPath?: string +} + +export async function renderComment(options: RenderCommentOptions) { + const cwd = process.cwd() + const reportPath = resolveFromCwd(cwd, options.reportPath) + const content = await readFile(reportPath, 'utf8') + const report = JSON.parse(content) as CommandReport + + const rendered = + options.format === 'github' ? renderGithubComment(report) : renderGithubComment(report) + + if (options.outputPath) { + const outputPath = resolveFromCwd(cwd, options.outputPath) + await ensureDirectory(path.dirname(outputPath)) + await writeFile(outputPath, rendered, 'utf8') + console.log(`Wrote ${outputPath}`) + return + } + + process.stdout.write(rendered) +} diff --git a/packages/cali/src/commands/write-mobile-pr-context.ts b/packages/cali/src/commands/write-mobile-pr-context.ts new file mode 100644 index 0000000..5e035ae --- /dev/null +++ b/packages/cali/src/commands/write-mobile-pr-context.ts @@ -0,0 +1,252 @@ +import { readFile, writeFile } from 'node:fs/promises' +import path from 'node:path' + +import { DOCS_URLS } from '../docs.js' +import { detectRepositoryContext, sanitizeUrl } from '../runtime/context-repo.js' +import type { CaliContext } from '../runtime/types.js' +import { ensureDirectory, resolveFromCwd } from '../utils.js' + +type WriteMobilePrContextProvider = 'github-actions' | 'eas' +type MobilePlatform = 'android' | 'ios' + +export type WriteMobilePrContextOptions = { + from: WriteMobilePrContextProvider + outputPath: string + platform?: MobilePlatform + artifactPath?: string + appId?: string + deviceName?: string + outputDir?: string + workspaceRoot?: string + buildId?: string + workflowUrl?: string + logsUrl?: string +} + +function readOptionalEnv(name: string) { + const value = process.env[name] + return value && value.length > 0 ? value : undefined +} + +function normalizePlatform(value: string | undefined): MobilePlatform | undefined { + return value === 'android' || value === 'ios' ? value : undefined +} + +function createContextWriterError(message: string) { + return new Error([message, `Docs: ${DOCS_URLS.ciHelpers}`].join('\n\n')) +} + +async function loadJsonFile(filePath: string) { + const content = await readFile(filePath, 'utf8') + return JSON.parse(content) +} + +function normalizeGithubPullRequest(event: any): CaliContext['pullRequest'] { + const pullRequest = event?.pull_request + if (!pullRequest) { + return undefined + } + + return { + number: pullRequest.number, + title: pullRequest.title, + body: pullRequest.body, + url: sanitizeUrl(pullRequest.html_url, { stripQuery: true }), + labels: (pullRequest.labels ?? []).map((label: any) => label.name).filter(Boolean), + isDraft: pullRequest.draft ?? false, + baseBranch: pullRequest.base?.ref, + headBranch: pullRequest.head?.ref, + } +} + +function normalizeEasPullRequest(rawPrJson: string | undefined): CaliContext['pullRequest'] { + if (!rawPrJson) { + return undefined + } + + const pullRequest = JSON.parse(rawPrJson) + return { + number: pullRequest.number, + title: pullRequest.title, + body: pullRequest.body, + url: sanitizeUrl(pullRequest.html_url, { stripQuery: true }), + labels: (pullRequest.labels ?? []).map((label: any) => label.name).filter(Boolean), + isDraft: pullRequest.draft ?? false, + baseBranch: pullRequest.base?.ref, + headBranch: pullRequest.head?.ref, + } +} + +function resolveGithubRepositoryContext(): CaliContext['repository'] { + const repositoryName = readOptionalEnv('GITHUB_REPOSITORY') + const currentBranch = readOptionalEnv('GITHUB_REF_NAME') + const commitSha = readOptionalEnv('GITHUB_SHA') + const serverUrl = readOptionalEnv('GITHUB_SERVER_URL') + + if (!repositoryName) { + return undefined + } + + const [owner, name] = repositoryName.split('/') + return { + provider: 'github.com', + owner, + name, + webUrl: + serverUrl && owner && name + ? sanitizeUrl(`${serverUrl}/${owner}/${name}`, { stripQuery: true }) + : undefined, + currentBranch, + commitSha, + } +} + +async function buildGithubActionsContext( + cwd: string, + options: WriteMobilePrContextOptions +): Promise { + const eventPath = readOptionalEnv('GITHUB_EVENT_PATH') + if (!eventPath) { + throw createContextWriterError('GitHub Actions context generation requires GITHUB_EVENT_PATH.') + } + + const event = await loadJsonFile(eventPath) + const detectedRepository = await detectRepositoryContext(cwd) + const githubRepository = resolveGithubRepositoryContext() + const outputDir = options.outputDir ?? readOptionalEnv('CALI_OUTPUT_DIR') ?? './artifacts/qa' + const buildId = options.buildId ?? readOptionalEnv('GITHUB_RUN_ID') + const platform = options.platform ?? normalizePlatform(readOptionalEnv('CALI_PLATFORM')) + const artifactPath = options.artifactPath ?? readOptionalEnv('CALI_ARTIFACT_PATH') + const serverUrl = readOptionalEnv('GITHUB_SERVER_URL') + const repositoryName = readOptionalEnv('GITHUB_REPOSITORY') + const workflowUrl = + options.workflowUrl ?? + (serverUrl && repositoryName && buildId + ? `${serverUrl}/${repositoryName}/actions/runs/${buildId}` + : undefined) + + if (!platform) { + throw createContextWriterError( + 'GitHub Actions context generation requires CALI_PLATFORM or --platform.' + ) + } + + if (!artifactPath) { + throw createContextWriterError( + 'GitHub Actions context generation requires CALI_ARTIFACT_PATH or --artifact.' + ) + } + + return { + workspaceRoot: resolveFromCwd( + cwd, + options.workspaceRoot ?? readOptionalEnv('GITHUB_WORKSPACE') ?? cwd + ), + repository: { + ...detectedRepository.repository, + ...githubRepository, + }, + task: undefined, + pullRequest: normalizeGithubPullRequest(event), + mobile: { + platform, + artifactPath: resolveFromCwd(cwd, artifactPath), + appId: options.appId ?? readOptionalEnv('CALI_APP_ID'), + deviceName: options.deviceName ?? readOptionalEnv('CALI_DEVICE_NAME'), + }, + build: { + id: buildId, + workflowUrl: sanitizeUrl(workflowUrl, { stripQuery: true }), + logsUrl: sanitizeUrl(options.logsUrl, { stripQuery: true }), + }, + output: { + outputDir: resolveFromCwd(cwd, outputDir), + }, + qa: undefined, + review: undefined, + perfReview: undefined, + dev: undefined, + } +} + +async function buildEasContext( + cwd: string, + options: WriteMobilePrContextOptions +): Promise { + const detectedRepository = await detectRepositoryContext(cwd) + const outputDir = options.outputDir ?? readOptionalEnv('CALI_OUTPUT_DIR') ?? './artifacts/qa' + const artifactPath = options.artifactPath ?? readOptionalEnv('APP_PATH') + const platform = options.platform ?? normalizePlatform(readOptionalEnv('QA_PLATFORM')) + + if (!artifactPath) { + throw createContextWriterError('EAS context generation requires APP_PATH or --artifact.') + } + + if (!platform) { + throw createContextWriterError('EAS context generation requires QA_PLATFORM or --platform.') + } + + return { + workspaceRoot: resolveFromCwd(cwd, options.workspaceRoot ?? cwd), + repository: detectedRepository.repository, + task: undefined, + pullRequest: normalizeEasPullRequest(readOptionalEnv('PR_JSON')), + mobile: { + platform, + artifactPath: resolveFromCwd(cwd, artifactPath), + appId: options.appId ?? readOptionalEnv('APPLICATION_ID'), + deviceName: options.deviceName ?? readOptionalEnv('CALI_DEVICE_NAME'), + }, + build: { + id: options.buildId ?? readOptionalEnv('BUILD_ID'), + workflowUrl: sanitizeUrl(options.workflowUrl ?? readOptionalEnv('WORKFLOW_URL'), { + stripQuery: true, + }), + logsUrl: sanitizeUrl(options.logsUrl ?? readOptionalEnv('LOGS_URL'), { stripQuery: true }), + }, + output: { + outputDir: resolveFromCwd(cwd, outputDir), + }, + qa: undefined, + review: undefined, + perfReview: undefined, + dev: undefined, + } +} + +function removeUndefinedValues(value: unknown): unknown { + if (Array.isArray(value)) { + return value.map(removeUndefinedValues).filter((entry) => entry !== undefined) + } + + if (value && typeof value === 'object') { + const entries = Object.entries(value) + .map(([entryKey, entryValue]) => [entryKey, removeUndefinedValues(entryValue)] as const) + .filter(([, entryValue]) => entryValue !== undefined) + if (entries.length === 0) { + return undefined + } + + return Object.fromEntries(entries) + } + + return value +} + +export async function writeMobilePrContext(options: WriteMobilePrContextOptions) { + const cwd = process.cwd() + const context = + options.from === 'github-actions' + ? await buildGithubActionsContext(cwd, options) + : await buildEasContext(cwd, options) + const outputPath = resolveFromCwd(cwd, options.outputPath) + + await ensureDirectory(path.dirname(outputPath)) + await writeFile( + outputPath, + `${JSON.stringify(removeUndefinedValues(context) ?? {}, null, 2)}\n`, + 'utf8' + ) + + console.log(`Wrote ${outputPath}`) +} diff --git a/packages/cali/src/config/load.ts b/packages/cali/src/config/load.ts index dba09d4..d270d7a 100644 --- a/packages/cali/src/config/load.ts +++ b/packages/cali/src/config/load.ts @@ -28,13 +28,24 @@ function getBuiltInSkillPaths(cwd: string) { return [path.join(cwd, '.agents', 'skills'), path.join(homedir(), '.agents', 'skills')] } +const MOBILE_PR_QA_DEFAULTS: CaliCommandConfig = { + enabledToolPacks: ['skills', 'agent-device'], + outputPublishers: ['blob', 'file'], + extraInstructions: [ + 'Infer concise acceptance criteria from pull request or task metadata and prioritize user-visible flows.', + 'Treat the repository as a black box and avoid source inspection unless the config explicitly says otherwise.', + ], +} + const QA_ENV_DEFAULTS: Record = { 'mobile-pr': { - enabledToolPacks: ['skills', 'agent-device'], - outputPublishers: ['blob', 'file'], + ...MOBILE_PR_QA_DEFAULTS, + }, + 'eas-mobile-pr': { + ...MOBILE_PR_QA_DEFAULTS, extraInstructions: [ - 'Infer concise acceptance criteria from pull request or task metadata and prioritize user-visible flows.', - 'Treat the repository as a black box and avoid source inspection unless the config explicitly says otherwise.', + ...asArray(MOBILE_PR_QA_DEFAULTS.extraInstructions), + 'This run is expected to execute in EAS-style CI with explicit context generated before Cali starts.', ], }, 'local-ios': { @@ -81,6 +92,7 @@ function getEnvCommandDefaults(commandId: CommandId, envName: CaliEnvName): Cali case 'perf-review': switch (envName) { case 'mobile-pr': + case 'eas-mobile-pr': return { enabledToolPacks: ['skills', 'agent-device', 'react-devtools', 'repo-read'], outputPublishers: mobileOutputPublishers, diff --git a/packages/cali/src/config/schema.ts b/packages/cali/src/config/schema.ts index 8851edb..7febfd7 100644 --- a/packages/cali/src/config/schema.ts +++ b/packages/cali/src/config/schema.ts @@ -1,6 +1,6 @@ import { z } from 'zod' -const CaliEnvNameSchema = z.enum(['mobile-pr', 'local-android', 'local-ios']) +const CaliEnvNameSchema = z.enum(['mobile-pr', 'eas-mobile-pr', 'local-android', 'local-ios']) const ToolPackNameSchema = z.enum([ 'skills', 'agent-device', diff --git a/packages/cali/src/docs.ts b/packages/cali/src/docs.ts index 525b95d..11cf95f 100644 --- a/packages/cali/src/docs.ts +++ b/packages/cali/src/docs.ts @@ -4,5 +4,7 @@ export const DOCS_URLS = { readme: README_URL, providerSetup: `${README_URL}#provider-setup`, requiredClis: `${README_URL}#required-clis`, + requiredSkills: `${README_URL}#required-skills`, ciProviders: `${README_URL}#ci-providers`, + ciHelpers: `${README_URL}#ci-helpers`, } as const diff --git a/packages/cali/src/report/ci.ts b/packages/cali/src/report/ci.ts new file mode 100644 index 0000000..2b5033f --- /dev/null +++ b/packages/cali/src/report/ci.ts @@ -0,0 +1,106 @@ +import type { CommandReport, PerfReviewReport, QaReport, ScreenshotInfo } from './types.js' + +function hasScreenshots(report: CommandReport): report is QaReport | PerfReviewReport { + return 'screenshots' in report +} + +function getTitle(report: CommandReport) { + if (report.command === 'qa') { + return `${report.context.mobile?.platform === 'ios' ? 'iOS' : 'Android'} QA` + } + + if (report.command === 'perf-review') { + return 'Perf Review' + } + + if (report.command === 'review') { + return 'Code Review' + } + + return 'Dev' +} + +export function getTopIssue(report: CommandReport) { + switch (report.command) { + case 'qa': + return report.issues[0] + case 'review': + return report.findings[0] + ? `[${report.findings[0].severity}] ${report.findings[0].title}: ${report.findings[0].body}` + : undefined + case 'perf-review': + return ( + report.suspectedCauses[0] ?? + report.slowComponents[0]?.detail ?? + report.rerenderHotspots[0]?.detail + ) + case 'dev': + return report.followUps[0] + } +} + +export function renderScreenshotsMarkdown(report: CommandReport) { + if (!hasScreenshots(report) || report.screenshots.length === 0) { + return '- No screenshots recorded.\n' + } + + return `${report.screenshots + .map((screenshot) => + screenshot.blobUrl + ? `- [${screenshot.label}](${screenshot.blobUrl})` + : `- ${screenshot.label}: ${screenshot.fileName}` + ) + .join('\n')}\n` +} + +export function buildScreenshotsMetadata(report: CommandReport) { + return { + command: report.command, + platform: report.context.mobile?.platform, + screenshots: hasScreenshots(report) + ? report.screenshots.map((screenshot, index) => createScreenshotMetadata(screenshot, index)) + : [], + } +} + +function createScreenshotMetadata(screenshot: ScreenshotInfo, index: number) { + return { + order: index, + label: screenshot.label, + fileName: screenshot.fileName, + blobUrl: screenshot.blobUrl, + blobDownloadUrl: screenshot.blobDownloadUrl, + blobPathname: screenshot.blobPathname, + uploadError: screenshot.uploadError, + } +} + +export function renderGithubComment(report: CommandReport) { + const lines = [ + `### ${getTitle(report)}`, + '', + `**Status:** ${report.overallStatus}`, + '', + report.summary || 'No summary was provided.', + ] + + const topIssue = getTopIssue(report) + if (topIssue) { + lines.push('', `**Top issue:** ${topIssue}`) + } + + if (hasScreenshots(report) && report.screenshots.length > 0) { + lines.push('', '#### Screenshots', '', renderScreenshotsMarkdown(report).trimEnd()) + } + + if (report.publisherResults?.length) { + lines.push('', '#### Publishers') + for (const publisherResult of report.publisherResults) { + lines.push( + `- ${publisherResult.publisher}: ${publisherResult.status}${publisherResult.detail ? ` (${publisherResult.detail})` : ''}` + ) + } + } + + return `${lines.join('\n')}\n` +} diff --git a/packages/cali/src/report/publishers/blob.ts b/packages/cali/src/report/publishers/blob.ts index bed3d5c..6f0fb8e 100644 --- a/packages/cali/src/report/publishers/blob.ts +++ b/packages/cali/src/report/publishers/blob.ts @@ -45,6 +45,13 @@ export async function publishBlobReport({ const screenshots = await Promise.all( report.screenshots.map(async (screenshot) => { + if (!screenshot.absolutePath) { + return { + ...screenshot, + uploadError: 'Screenshot path is not available for blob upload.', + } + } + try { const fileBuffer = await readFile(screenshot.absolutePath) const pathnameParts = [ diff --git a/packages/cali/src/report/publishers/file.ts b/packages/cali/src/report/publishers/file.ts index 83588c3..a8bcdcb 100644 --- a/packages/cali/src/report/publishers/file.ts +++ b/packages/cali/src/report/publishers/file.ts @@ -1,9 +1,100 @@ import { writeFile } from 'node:fs/promises' import path from 'node:path' +import { sanitizeUrl } from '../../runtime/context-repo.js' import { ensureDirectory } from '../../utils.js' +import { + buildScreenshotsMetadata, + getTopIssue, + renderGithubComment, + renderScreenshotsMarkdown, +} from '../ci.js' import { renderCommandSection } from '../render.js' -import type { CommandReport, ReportPublisherResult } from '../types.js' +import type { CommandReport, PerfReviewReport, QaReport, ReportPublisherResult } from '../types.js' + +function stripScreenshotAbsolutePath(screenshot: T) { + const { absolutePath, ...safeScreenshot } = screenshot + void absolutePath + return safeScreenshot +} + +function createPublishedContext(report: CommandReport) { + return { + workspaceRoot: '.', + repository: report.context.repository + ? { + provider: report.context.repository.provider, + owner: report.context.repository.owner, + name: report.context.repository.name, + webUrl: sanitizeUrl(report.context.repository.webUrl), + defaultBranch: report.context.repository.defaultBranch, + currentBranch: report.context.repository.currentBranch, + commitSha: report.context.repository.commitSha, + } + : undefined, + task: report.context.task + ? { + ...report.context.task, + url: sanitizeUrl(report.context.task.url, { stripQuery: true }), + } + : undefined, + pullRequest: report.context.pullRequest + ? { + ...report.context.pullRequest, + url: sanitizeUrl(report.context.pullRequest.url, { stripQuery: true }), + diffPath: undefined, + } + : undefined, + mobile: report.context.mobile + ? { + platform: report.context.mobile.platform, + appId: report.context.mobile.appId, + deviceName: report.context.mobile.deviceName, + } + : undefined, + build: report.context.build + ? { + id: report.context.build.id, + workflowUrl: sanitizeUrl(report.context.build.workflowUrl, { stripQuery: true }), + } + : undefined, + output: {}, + qa: report.context.qa, + review: report.context.review, + perfReview: report.context.perfReview, + dev: report.context.dev, + } satisfies CommandReport['context'] +} + +function createSafePublishedReport( + report: CommandReport, + publisherResults: ReportPublisherResult[] +) { + const baseReport = { + ...report, + context: createPublishedContext(report), + publisherResults, + } satisfies CommandReport + + if (baseReport.command === 'qa') { + return { + ...baseReport, + screenshots: baseReport.screenshots.map(stripScreenshotAbsolutePath), + agentDeviceTrace: [], + } satisfies QaReport + } + + if (baseReport.command === 'perf-review') { + return { + ...baseReport, + screenshots: baseReport.screenshots.map(stripScreenshotAbsolutePath), + agentDeviceTrace: [], + reactDevtoolsTrace: [], + } satisfies PerfReviewReport + } + + return baseReport +} type FilePublishOptions = { report: CommandReport @@ -19,10 +110,8 @@ export async function publishFileReport({ throw new Error('File publisher requires context.output.outputDir.') } - const finalReport = { - ...report, - publisherResults, - } satisfies CommandReport + const finalReport = createSafePublishedReport(report, publisherResults) + const topIssue = getTopIssue(finalReport) ?? '' await ensureDirectory(outputDir) await writeFile( @@ -31,12 +120,37 @@ export async function publishFileReport({ 'utf8' ) await writeFile(path.join(outputDir, 'section.md'), renderCommandSection(finalReport), 'utf8') + await writeFile(path.join(outputDir, 'summary.txt'), `${finalReport.summary}\n`, 'utf8') + await writeFile(path.join(outputDir, 'top-issue.txt'), `${topIssue}\n`, 'utf8') await writeFile(path.join(outputDir, 'status.txt'), `${finalReport.overallStatus}\n`, 'utf8') + await writeFile( + path.join(outputDir, 'status-label.txt'), + `${finalReport.overallStatus}\n`, + 'utf8' + ) + await writeFile( + path.join(outputDir, 'screenshots.md'), + renderScreenshotsMarkdown(finalReport), + 'utf8' + ) + await writeFile( + path.join(outputDir, 'screenshots.json'), + `${JSON.stringify(buildScreenshotsMetadata(finalReport), null, 2)}\n`, + 'utf8' + ) + await writeFile( + path.join(outputDir, 'comment-github.md'), + renderGithubComment(finalReport), + 'utf8' + ) await writeFile( path.join(outputDir, 'publisher-manifest.json'), `${JSON.stringify(publisherResults, null, 2)}\n`, 'utf8' ) - return finalReport + return { + ...report, + publisherResults, + } satisfies CommandReport } diff --git a/packages/cali/src/report/render.ts b/packages/cali/src/report/render.ts index de2a2b2..ee49d1d 100644 --- a/packages/cali/src/report/render.ts +++ b/packages/cali/src/report/render.ts @@ -55,10 +55,6 @@ function appendPublishers(lines: string[], report: CommandReport) { } } -function appendJsonReport(lines: string[], report: CommandReport) { - lines.push('', '### JSON Report', '', '```json', JSON.stringify(report, null, 2), '```') -} - function renderHeader(title: string, report: CommandReport) { return [ title, @@ -198,7 +194,6 @@ export function renderCommandSection(report: CommandReport) { ) appendPublishers(lines, report) appendMetadata(lines, report) - appendJsonReport(lines, report) return `${lines.join('\n')}\n` } diff --git a/packages/cali/src/report/types.ts b/packages/cali/src/report/types.ts index 89b1482..42c5cb8 100644 --- a/packages/cali/src/report/types.ts +++ b/packages/cali/src/report/types.ts @@ -9,7 +9,7 @@ export type ScreenshotLabel = { export type ScreenshotInfo = { fileName: string - absolutePath: string + absolutePath?: string bytes: number label: string blobUrl?: string diff --git a/packages/cali/src/runtime/context-file.ts b/packages/cali/src/runtime/context-file.ts index a78c998..2c3aa91 100644 --- a/packages/cali/src/runtime/context-file.ts +++ b/packages/cali/src/runtime/context-file.ts @@ -3,6 +3,7 @@ import { readFile } from 'node:fs/promises' import { z } from 'zod' import { resolveFromCwd } from '../utils.js' +import { parseRepositoryUrl, sanitizeUrl } from './context-repo.js' import type { CaliContext } from './types.js' const LabelsSchema = z.array(z.string()).optional() @@ -13,6 +14,7 @@ const RepositorySchema = z owner: z.string().optional(), name: z.string().optional(), cloneUrl: z.string().optional(), + webUrl: z.string().optional(), defaultBranch: z.string().optional(), currentBranch: z.string().optional(), commitSha: z.string().optional(), @@ -73,6 +75,10 @@ function normalizeLabels(values: string[] | undefined) { return values ?? [] } +function sanitizeContextUrl(value: string | undefined) { + return sanitizeUrl(value, { stripQuery: true }) +} + function resolveOptionalPath(cwd: string, value?: string) { return value ? resolveFromCwd(cwd, value) : undefined } @@ -113,16 +119,32 @@ function createContextFileSchema(cwd: string) { .transform( (parsed): Partial => ({ workspaceRoot: resolveOptionalPath(cwd, parsed.workspaceRoot), - repository: parsed.repository, + repository: parsed.repository + ? (() => { + const parsedFromCloneUrl = parseRepositoryUrl(sanitizeUrl(parsed.repository.cloneUrl)) + + return { + provider: parsed.repository.provider ?? parsedFromCloneUrl.provider, + owner: parsed.repository.owner ?? parsedFromCloneUrl.owner, + name: parsed.repository.name ?? parsedFromCloneUrl.name, + webUrl: sanitizeUrl(parsed.repository.webUrl ?? parsedFromCloneUrl.webUrl), + defaultBranch: parsed.repository.defaultBranch, + currentBranch: parsed.repository.currentBranch, + commitSha: parsed.repository.commitSha, + } + })() + : undefined, task: parsed.task ? { ...parsed.task, + url: sanitizeContextUrl(parsed.task.url), labels: normalizeLabels(parsed.task.labels), } : undefined, pullRequest: parsed.pullRequest ? { ...parsed.pullRequest, + url: sanitizeContextUrl(parsed.pullRequest.url), labels: normalizeLabels(parsed.pullRequest.labels), isDraft: parsed.pullRequest.isDraft ?? false, } @@ -133,7 +155,13 @@ function createContextFileSchema(cwd: string) { artifactPath: resolveOptionalPath(cwd, parsed.mobile.artifactPath), } : undefined, - build: parsed.build, + build: parsed.build + ? { + id: parsed.build.id, + workflowUrl: sanitizeContextUrl(parsed.build.workflowUrl), + logsUrl: sanitizeContextUrl(parsed.build.logsUrl), + } + : undefined, output: { outputDir: resolveOptionalPath(cwd, parsed.output?.outputDir), screenshotsDir: resolveOptionalPath(cwd, parsed.output?.screenshotsDir), diff --git a/packages/cali/src/runtime/context-repo.ts b/packages/cali/src/runtime/context-repo.ts index 96aae77..52fac4d 100644 --- a/packages/cali/src/runtime/context-repo.ts +++ b/packages/cali/src/runtime/context-repo.ts @@ -1,7 +1,28 @@ import { runCommand } from '../utils.js' import type { RepositoryContext } from './types.js' -function parseRemoteUrl(remoteUrl: string | undefined) { +export function sanitizeUrl(rawUrl: string | undefined, options: { stripQuery?: boolean } = {}) { + if (!rawUrl) { + return undefined + } + + try { + const parsed = new URL(rawUrl) + parsed.username = '' + parsed.password = '' + if (options.stripQuery) { + parsed.search = '' + parsed.hash = '' + } + return parsed.toString() + } catch { + return rawUrl + } +} + +export function parseRepositoryUrl( + remoteUrl: string | undefined +): Pick { if (!remoteUrl) { return {} } @@ -12,6 +33,7 @@ function parseRemoteUrl(remoteUrl: string | undefined) { provider: httpsMatch[1], owner: httpsMatch[2], name: httpsMatch[3], + webUrl: `https://${httpsMatch[1]}/${httpsMatch[2]}/${httpsMatch[3]}`, } } @@ -21,6 +43,7 @@ function parseRemoteUrl(remoteUrl: string | undefined) { provider: sshMatch[1], owner: sshMatch[2], name: sshMatch[3], + webUrl: `https://${sshMatch[1]}/${sshMatch[2]}/${sshMatch[3]}`, } } @@ -41,38 +64,45 @@ async function readGitValue(cwd: string, args: string[]) { return value.length > 0 ? value : undefined } +async function readDefaultBranch(cwd: string) { + const symbolicRef = await readGitValue(cwd, [ + 'symbolic-ref', + '--short', + 'refs/remotes/origin/HEAD', + ]) + if (symbolicRef) { + return symbolicRef.replace(/^origin\//, '') + } + + const remoteShow = await readGitValue(cwd, ['remote', 'show', 'origin']) + if (!remoteShow?.includes('HEAD branch:')) { + return undefined + } + + return remoteShow + .split('\n') + .find((line) => line.includes('HEAD branch:')) + ?.split('HEAD branch:')[1] + ?.trim() +} + export async function detectRepositoryContext(cwd: string): Promise<{ workspaceRoot: string repository?: RepositoryContext }> { const workspaceRoot = (await readGitValue(cwd, ['rev-parse', '--show-toplevel'])) ?? cwd - const remoteUrl = await readGitValue(workspaceRoot, ['remote', 'get-url', 'origin']) + const remoteUrl = sanitizeUrl(await readGitValue(workspaceRoot, ['remote', 'get-url', 'origin'])) const repository = { - ...parseRemoteUrl(remoteUrl), - cloneUrl: remoteUrl, - defaultBranch: await readGitValue(workspaceRoot, ['remote', 'show', 'origin']), + ...parseRepositoryUrl(remoteUrl), + defaultBranch: await readDefaultBranch(workspaceRoot), currentBranch: await readGitValue(workspaceRoot, ['branch', '--show-current']), commitSha: await readGitValue(workspaceRoot, ['rev-parse', 'HEAD']), } - if ( - !repository.cloneUrl && - !repository.currentBranch && - !repository.commitSha && - !repository.owner && - !repository.name - ) { + if (!repository.currentBranch && !repository.commitSha && !repository.owner && !repository.name) { return { workspaceRoot } } - if (repository.defaultBranch?.includes('HEAD branch:')) { - repository.defaultBranch = repository.defaultBranch - .split('\n') - .find((line) => line.includes('HEAD branch:')) - ?.split('HEAD branch:')[1] - ?.trim() - } - return { workspaceRoot, repository, diff --git a/packages/cali/src/runtime/mobile.ts b/packages/cali/src/runtime/mobile.ts index 3de7c65..0b1b6a9 100644 --- a/packages/cali/src/runtime/mobile.ts +++ b/packages/cali/src/runtime/mobile.ts @@ -444,12 +444,7 @@ async function openAppSession( context: MobileCommandRuntimeContext, options: Parameters[3] = {} ) { - return runAgentDeviceSessionCommand( - sessionName, - 'open', - buildSessionOpenArgs(context), - options - ) + return runAgentDeviceSessionCommand(sessionName, 'open', buildSessionOpenArgs(context), options) } async function installFreshArtifact( diff --git a/packages/cali/src/runtime/types.ts b/packages/cali/src/runtime/types.ts index 672c8a2..8e251b1 100644 --- a/packages/cali/src/runtime/types.ts +++ b/packages/cali/src/runtime/types.ts @@ -8,7 +8,7 @@ export type RepositoryContext = { provider?: string owner?: string name?: string - cloneUrl?: string + webUrl?: string defaultBranch?: string currentBranch?: string commitSha?: string diff --git a/packages/cali/src/tools/skills.ts b/packages/cali/src/tools/skills.ts index fa611f4..879b70c 100644 --- a/packages/cali/src/tools/skills.ts +++ b/packages/cali/src/tools/skills.ts @@ -4,6 +4,8 @@ import path from 'node:path' import { tool } from 'ai' import { z } from 'zod' +import { DOCS_URLS } from '../docs.js' + type SkillMetadata = { name: string description: string @@ -23,6 +25,13 @@ type RequiredSkillDocument = { preloadPaths: string[] } +const SKILL_INSTALL_HINTS: Record = { + 'agent-device': + 'npx skills add callstackincubator/agent-device --agent codex --skill agent-device -y', + 'react-devtools': + 'npx skills add callstackincubator/agent-skills --agent codex --skill react-devtools -y', +} + function parseSkillFile(content: string) { const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)/) if (!match?.[1]) { @@ -69,7 +78,17 @@ function resolveSkillFilePath(skill: SkillMetadata, relativeFilePath: string) { function findSkill(skills: SkillMetadata[], name: string) { const skill = skills.find((candidate) => candidate.name.toLowerCase() === name.toLowerCase()) if (!skill) { - throw new Error(`Skill not found: ${name}`) + const installHint = SKILL_INSTALL_HINTS[name] + throw new Error( + [ + `Skill not found: ${name}`, + installHint ? 'Install it before running Cali:' : undefined, + installHint, + `Docs: ${DOCS_URLS.requiredSkills}`, + ] + .filter(Boolean) + .join('\n\n') + ) } return skill diff --git a/skills/cali/references/running-cali.md b/skills/cali/references/running-cali.md index 50a6197..9c3a47b 100644 --- a/skills/cali/references/running-cali.md +++ b/skills/cali/references/running-cali.md @@ -22,6 +22,7 @@ Use this reference for normal Cali usage, setup, and CI wiring. Built-in envs: - `mobile-pr` +- `eas-mobile-pr` - `local-android` - `local-ios` @@ -57,6 +58,16 @@ node packages/cali/dist/index.js qa \ node packages/cali/dist/index.js qa \ --env mobile-pr \ --context ./cali-context.json + +# Generate CI context +node packages/cali/dist/index.js write-mobile-pr-context \ + --from eas \ + --output ./cali-context.json + +# Render a compact GitHub comment +node packages/cali/dist/index.js render-comment \ + --report ./artifacts/qa/report.json \ + --format github ``` ## Provider setup @@ -81,6 +92,7 @@ QA_MODEL=anthropic/claude-sonnet-4.6 - Generate `cali-context.json` before invoking Cali. - Do not assume Cali will scrape PR/build metadata from the environment at runtime. +- Prefer the built-in `write-mobile-pr-context` command over custom `jq` wrappers. - For copy-pasteable CI examples, use: - [`packages/cali/examples/github-actions/write-mobile-pr-context.sh`](../../../packages/cali/examples/github-actions/write-mobile-pr-context.sh) - [`packages/cali/examples/eas-workflows/write-mobile-pr-context.sh`](../../../packages/cali/examples/eas-workflows/write-mobile-pr-context.sh) From 6a0a57031bb64ee991ec6bdb863a94cda4db493d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Wed, 8 Apr 2026 21:18:20 +0200 Subject: [PATCH 28/48] refactor: simplify cali ci helpers --- packages/cali/src/commands/render-comment.ts | 11 +- .../src/commands/write-mobile-pr-context.ts | 140 +++++++++--------- packages/cali/src/report/ci.ts | 10 +- 3 files changed, 92 insertions(+), 69 deletions(-) diff --git a/packages/cali/src/commands/render-comment.ts b/packages/cali/src/commands/render-comment.ts index 81948f6..dfc80ad 100644 --- a/packages/cali/src/commands/render-comment.ts +++ b/packages/cali/src/commands/render-comment.ts @@ -11,14 +11,21 @@ export type RenderCommentOptions = { outputPath?: string } +function renderCommentText(report: CommandReport, format: RenderCommentOptions['format']) { + if (format === 'github') { + return renderGithubComment(report) + } + + return renderGithubComment(report) +} + export async function renderComment(options: RenderCommentOptions) { const cwd = process.cwd() const reportPath = resolveFromCwd(cwd, options.reportPath) const content = await readFile(reportPath, 'utf8') const report = JSON.parse(content) as CommandReport - const rendered = - options.format === 'github' ? renderGithubComment(report) : renderGithubComment(report) + const rendered = renderCommentText(report, options.format) if (options.outputPath) { const outputPath = resolveFromCwd(cwd, options.outputPath) diff --git a/packages/cali/src/commands/write-mobile-pr-context.ts b/packages/cali/src/commands/write-mobile-pr-context.ts index 5e035ae..5ed5f6f 100644 --- a/packages/cali/src/commands/write-mobile-pr-context.ts +++ b/packages/cali/src/commands/write-mobile-pr-context.ts @@ -41,8 +41,7 @@ async function loadJsonFile(filePath: string) { return JSON.parse(content) } -function normalizeGithubPullRequest(event: any): CaliContext['pullRequest'] { - const pullRequest = event?.pull_request +function normalizePullRequest(pullRequest: any): CaliContext['pullRequest'] { if (!pullRequest) { return undefined } @@ -59,24 +58,6 @@ function normalizeGithubPullRequest(event: any): CaliContext['pullRequest'] { } } -function normalizeEasPullRequest(rawPrJson: string | undefined): CaliContext['pullRequest'] { - if (!rawPrJson) { - return undefined - } - - const pullRequest = JSON.parse(rawPrJson) - return { - number: pullRequest.number, - title: pullRequest.title, - body: pullRequest.body, - url: sanitizeUrl(pullRequest.html_url, { stripQuery: true }), - labels: (pullRequest.labels ?? []).map((label: any) => label.name).filter(Boolean), - isDraft: pullRequest.draft ?? false, - baseBranch: pullRequest.base?.ref, - headBranch: pullRequest.head?.ref, - } -} - function resolveGithubRepositoryContext(): CaliContext['repository'] { const repositoryName = readOptionalEnv('GITHUB_REPOSITORY') const currentBranch = readOptionalEnv('GITHUB_REF_NAME') @@ -101,6 +82,53 @@ function resolveGithubRepositoryContext(): CaliContext['repository'] { } } +function readPullRequestJson(rawPrJson: string | undefined) { + if (!rawPrJson) { + return undefined + } + + try { + return JSON.parse(rawPrJson) + } catch { + throw createContextWriterError('Failed to parse PR_JSON as JSON.') + } +} + +function createCommonContext(options: { + workspaceRoot: string + repository?: CaliContext['repository'] + pullRequest?: CaliContext['pullRequest'] + platform: MobilePlatform + artifactPath: string + appId?: string + deviceName?: string + outputDir: string + buildId?: string + workflowUrl?: string + logsUrl?: string +}): CaliContext { + return { + workspaceRoot: options.workspaceRoot, + repository: options.repository, + task: undefined, + pullRequest: options.pullRequest, + mobile: { + platform: options.platform, + artifactPath: options.artifactPath, + appId: options.appId, + deviceName: options.deviceName, + }, + build: { + id: options.buildId, + workflowUrl: sanitizeUrl(options.workflowUrl, { stripQuery: true }), + logsUrl: sanitizeUrl(options.logsUrl, { stripQuery: true }), + }, + output: { + outputDir: options.outputDir, + }, + } +} + async function buildGithubActionsContext( cwd: string, options: WriteMobilePrContextOptions @@ -137,7 +165,7 @@ async function buildGithubActionsContext( ) } - return { + return createCommonContext({ workspaceRoot: resolveFromCwd( cwd, options.workspaceRoot ?? readOptionalEnv('GITHUB_WORKSPACE') ?? cwd @@ -146,27 +174,16 @@ async function buildGithubActionsContext( ...detectedRepository.repository, ...githubRepository, }, - task: undefined, - pullRequest: normalizeGithubPullRequest(event), - mobile: { - platform, - artifactPath: resolveFromCwd(cwd, artifactPath), - appId: options.appId ?? readOptionalEnv('CALI_APP_ID'), - deviceName: options.deviceName ?? readOptionalEnv('CALI_DEVICE_NAME'), - }, - build: { - id: buildId, - workflowUrl: sanitizeUrl(workflowUrl, { stripQuery: true }), - logsUrl: sanitizeUrl(options.logsUrl, { stripQuery: true }), - }, - output: { - outputDir: resolveFromCwd(cwd, outputDir), - }, - qa: undefined, - review: undefined, - perfReview: undefined, - dev: undefined, - } + pullRequest: normalizePullRequest(event?.pull_request), + platform, + artifactPath: resolveFromCwd(cwd, artifactPath), + appId: options.appId ?? readOptionalEnv('CALI_APP_ID'), + deviceName: options.deviceName ?? readOptionalEnv('CALI_DEVICE_NAME'), + outputDir: resolveFromCwd(cwd, outputDir), + buildId, + workflowUrl, + logsUrl: options.logsUrl, + }) } async function buildEasContext( @@ -174,6 +191,7 @@ async function buildEasContext( options: WriteMobilePrContextOptions ): Promise { const detectedRepository = await detectRepositoryContext(cwd) + const githubRepository = resolveGithubRepositoryContext() const outputDir = options.outputDir ?? readOptionalEnv('CALI_OUTPUT_DIR') ?? './artifacts/qa' const artifactPath = options.artifactPath ?? readOptionalEnv('APP_PATH') const platform = options.platform ?? normalizePlatform(readOptionalEnv('QA_PLATFORM')) @@ -186,32 +204,22 @@ async function buildEasContext( throw createContextWriterError('EAS context generation requires QA_PLATFORM or --platform.') } - return { + return createCommonContext({ workspaceRoot: resolveFromCwd(cwd, options.workspaceRoot ?? cwd), - repository: detectedRepository.repository, - task: undefined, - pullRequest: normalizeEasPullRequest(readOptionalEnv('PR_JSON')), - mobile: { - platform, - artifactPath: resolveFromCwd(cwd, artifactPath), - appId: options.appId ?? readOptionalEnv('APPLICATION_ID'), - deviceName: options.deviceName ?? readOptionalEnv('CALI_DEVICE_NAME'), - }, - build: { - id: options.buildId ?? readOptionalEnv('BUILD_ID'), - workflowUrl: sanitizeUrl(options.workflowUrl ?? readOptionalEnv('WORKFLOW_URL'), { - stripQuery: true, - }), - logsUrl: sanitizeUrl(options.logsUrl ?? readOptionalEnv('LOGS_URL'), { stripQuery: true }), - }, - output: { - outputDir: resolveFromCwd(cwd, outputDir), + repository: { + ...detectedRepository.repository, + ...githubRepository, }, - qa: undefined, - review: undefined, - perfReview: undefined, - dev: undefined, - } + pullRequest: normalizePullRequest(readPullRequestJson(readOptionalEnv('PR_JSON'))), + platform, + artifactPath: resolveFromCwd(cwd, artifactPath), + appId: options.appId ?? readOptionalEnv('APPLICATION_ID'), + deviceName: options.deviceName ?? readOptionalEnv('CALI_DEVICE_NAME'), + outputDir: resolveFromCwd(cwd, outputDir), + buildId: options.buildId ?? readOptionalEnv('BUILD_ID'), + workflowUrl: options.workflowUrl ?? readOptionalEnv('WORKFLOW_URL'), + logsUrl: options.logsUrl ?? readOptionalEnv('LOGS_URL'), + }) } function removeUndefinedValues(value: unknown): unknown { diff --git a/packages/cali/src/report/ci.ts b/packages/cali/src/report/ci.ts index 2b5033f..ba6fff5 100644 --- a/packages/cali/src/report/ci.ts +++ b/packages/cali/src/report/ci.ts @@ -6,7 +6,15 @@ function hasScreenshots(report: CommandReport): report is QaReport | PerfReviewR function getTitle(report: CommandReport) { if (report.command === 'qa') { - return `${report.context.mobile?.platform === 'ios' ? 'iOS' : 'Android'} QA` + if (report.context.mobile?.platform === 'ios') { + return 'iOS QA' + } + + if (report.context.mobile?.platform === 'android') { + return 'Android QA' + } + + return 'Mobile QA' } if (report.command === 'perf-review') { From 66bd005f66976068a9b0a758f3eb00641d3384b6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Wed, 8 Apr 2026 21:22:10 +0200 Subject: [PATCH 29/48] chore: release v0.4.0-2 --- package.json | 2 +- packages/cali/package.json | 2 +- packages/tools/package.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package.json b/package.json index 5753ef1..c4e47b2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@cali/root", - "version": "0.4.0-1", + "version": "0.4.0-2", "devDependencies": { "@release-it-plugins/workspaces": "^4.2.0", "@release-it/conventional-changelog": "^9.0.3", diff --git a/packages/cali/package.json b/packages/cali/package.json index bd7c921..0e102b0 100644 --- a/packages/cali/package.json +++ b/packages/cali/package.json @@ -67,7 +67,7 @@ "README.md" ], "license": "MIT", - "version": "0.4.0-1", + "version": "0.4.0-2", "engines": { "node": ">=22" } diff --git a/packages/tools/package.json b/packages/tools/package.json index ddb431b..def3c97 100644 --- a/packages/tools/package.json +++ b/packages/tools/package.json @@ -51,7 +51,7 @@ "README.md" ], "license": "MIT", - "version": "0.4.0-1", + "version": "0.4.0-2", "engines": { "node": ">=22" } From eb0f29ea8b9e24d467de5473633e7c827437e495 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 9 Apr 2026 15:25:24 +0200 Subject: [PATCH 30/48] fix: normalize cali screenshot paths --- packages/cali/src/runtime/tool-packs.ts | 3 +- packages/cali/src/tools/agent-device.ts | 38 +++++++++++++++++++++---- 2 files changed, 35 insertions(+), 6 deletions(-) diff --git a/packages/cali/src/runtime/tool-packs.ts b/packages/cali/src/runtime/tool-packs.ts index ac31fa8..d94ce65 100644 --- a/packages/cali/src/runtime/tool-packs.ts +++ b/packages/cali/src/runtime/tool-packs.ts @@ -50,10 +50,11 @@ const TOOL_PACK_DEFINITIONS: Record = { preloadPaths: ['SKILL.md', 'references/bootstrap-install.md', 'references/exploration.md'], }, ], - createTools: ({ state, sessionName }) => + createTools: ({ context, state, sessionName }) => createAgentDeviceToolPack({ trace: state.agentDeviceTrace, sessionName: sessionName!, + screenshotsDir: context.output.screenshotsDir ?? 'screenshots', }), }, 'repo-read': { diff --git a/packages/cali/src/tools/agent-device.ts b/packages/cali/src/tools/agent-device.ts index 4ff7718..c257ac6 100644 --- a/packages/cali/src/tools/agent-device.ts +++ b/packages/cali/src/tools/agent-device.ts @@ -1,3 +1,5 @@ +import path from 'node:path' + import type { ToolTraceEntry } from '../runtime/types.js' import { createCliTool } from './cli-tool.js' @@ -6,6 +8,7 @@ const DEFAULT_AGENT_DEVICE_SESSION_LOCK = 'reject' type CreateAgentDeviceToolPackOptions = { trace: ToolTraceEntry[] sessionName: string + screenshotsDir: string } type SessionArgsOptions = { @@ -22,25 +25,50 @@ export function getAgentDeviceSessionArgs(sessionName: string, options: SessionA return args } -function normalizeCommandInvocation(command: string, args: string[]) { +function getDefaultScreenshotFileName() { + return `screenshot-${new Date().toISOString().replace(/[:.]/g, '-').toLowerCase()}.png` +} + +function normalizeScreenshotArgs(args: string[], screenshotsDir: string) { + if (args.length === 0) { + return [path.join(screenshotsDir, getDefaultScreenshotFileName())] + } + + const [candidatePath, ...rest] = args + if (!candidatePath || candidatePath.startsWith('-')) { + return args + } + + return [ + path.isAbsolute(candidatePath) ? candidatePath : path.join(screenshotsDir, candidatePath), + ...rest, + ] +} + +function normalizeCommandInvocation(command: string, args: string[], screenshotsDir: string) { const trimmedCommand = command.trim() if (args.length > 0 || !trimmedCommand.includes(' ')) { + const normalizedArgs = + trimmedCommand === 'screenshot' ? normalizeScreenshotArgs(args, screenshotsDir) : args return { command: trimmedCommand, - args, + args: normalizedArgs, } } const [normalizedCommand, ...normalizedArgs] = trimmedCommand.split(/\s+/g) return { command: normalizedCommand, - args: normalizedArgs, + args: + normalizedCommand === 'screenshot' + ? normalizeScreenshotArgs(normalizedArgs, screenshotsDir) + : normalizedArgs, } } export function createAgentDeviceToolPack(options: CreateAgentDeviceToolPackOptions) { - const { trace, sessionName } = options + const { trace, sessionName, screenshotsDir } = options const sessionArgs = getAgentDeviceSessionArgs(sessionName, { lockTarget: true }) return createCliTool({ @@ -50,7 +78,7 @@ export function createAgentDeviceToolPack(options: CreateAgentDeviceToolPackOpti 'Run an agent-device command for mobile UI automation and screenshot capture. Use canonical subcommands like back or home directly; do not emulate them with press.', trace, buildArgs: ({ command, args }) => { - const normalized = normalizeCommandInvocation(command, args) + const normalized = normalizeCommandInvocation(command, args, screenshotsDir) return [...sessionArgs, normalized.command, ...normalized.args] }, }) From e5a3edd8a2796b5409e459c71b1ce793c5a3d83b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 9 Apr 2026 15:28:38 +0200 Subject: [PATCH 31/48] chore: release v0.4.0-3 --- package.json | 2 +- packages/cali/package.json | 2 +- packages/tools/package.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package.json b/package.json index c4e47b2..7027e42 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@cali/root", - "version": "0.4.0-2", + "version": "0.4.0-3", "devDependencies": { "@release-it-plugins/workspaces": "^4.2.0", "@release-it/conventional-changelog": "^9.0.3", diff --git a/packages/cali/package.json b/packages/cali/package.json index 0e102b0..f00da2e 100644 --- a/packages/cali/package.json +++ b/packages/cali/package.json @@ -67,7 +67,7 @@ "README.md" ], "license": "MIT", - "version": "0.4.0-2", + "version": "0.4.0-3", "engines": { "node": ">=22" } diff --git a/packages/tools/package.json b/packages/tools/package.json index def3c97..0247733 100644 --- a/packages/tools/package.json +++ b/packages/tools/package.json @@ -51,7 +51,7 @@ "README.md" ], "license": "MIT", - "version": "0.4.0-2", + "version": "0.4.0-3", "engines": { "node": ">=22" } From d7b1a4400a5b43365b40fd5e386f807984883273 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 10 Apr 2026 12:09:50 +0200 Subject: [PATCH 32/48] feat: make cali qa CI-native --- AGENTS.md | 6 +- packages/cali/README.md | 122 ++++++----- .../cali/examples/eas-workflows/run-qa.sh | 5 + .../eas-workflows/write-mobile-pr-context.sh | 8 - .../cali/examples/github-actions/run-qa.sh | 5 + .../github-actions/write-mobile-pr-context.sh | 8 - packages/cali/package.json | 8 +- packages/cali/src/cli/app.ts | 6 +- packages/cali/src/cli/export-ci.ts | 44 ++++ packages/cali/src/cli/publish-comment.ts | 82 ++++++++ packages/cali/src/cli/qa.ts | 20 +- packages/cali/src/cli/render-comment.ts | 29 ++- packages/cali/src/cli/shared.ts | 2 + .../cali/src/cli/write-mobile-pr-context.ts | 71 ------- packages/cali/src/commands/export-ci.ts | 42 ++++ packages/cali/src/commands/publish-comment.ts | 198 ++++++++++++++++++ packages/cali/src/commands/qa.ts | 4 + packages/cali/src/commands/render-comment.ts | 47 +++-- packages/cali/src/commands/shared.ts | 146 ++++++++++++- packages/cali/src/config/load.ts | 2 +- packages/cali/src/report/ci.ts | 74 +++++++ packages/cali/src/report/publishers/file.ts | 3 +- .../ci-context.ts} | 121 ++++------- packages/cali/src/runtime/context.ts | 6 +- packages/cali/src/runtime/mobile.ts | 125 ++++++++++- packages/cali/src/runtime/types.ts | 2 + skills/cali/references/running-cali.md | 32 ++- 27 files changed, 953 insertions(+), 265 deletions(-) create mode 100644 packages/cali/examples/eas-workflows/run-qa.sh delete mode 100755 packages/cali/examples/eas-workflows/write-mobile-pr-context.sh create mode 100644 packages/cali/examples/github-actions/run-qa.sh delete mode 100755 packages/cali/examples/github-actions/write-mobile-pr-context.sh create mode 100644 packages/cali/src/cli/export-ci.ts create mode 100644 packages/cali/src/cli/publish-comment.ts delete mode 100644 packages/cali/src/cli/write-mobile-pr-context.ts create mode 100644 packages/cali/src/commands/export-ci.ts create mode 100644 packages/cali/src/commands/publish-comment.ts rename packages/cali/src/{commands/write-mobile-pr-context.ts => runtime/ci-context.ts} (60%) diff --git a/AGENTS.md b/AGENTS.md index b053f15..779a997 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -177,9 +177,11 @@ Built bundle: - `bun run review -- --help` - `bun run perf-review -- --help` - `bun run dev:command -- --help` -- `bun run write-context:gha -- --output ./cali-context.json` -- `bun run write-context:eas -- --output ./cali-context.json` +- `bun run qa:ci:gha -- --platform android --artifact ./artifacts/app.apk` +- `bun run qa:ci:eas -- --platform ios --artifact ./artifacts/MyApp.app` - `bun run render-comment -- --report ./artifacts/qa/report.json` +- `bun run export-ci:eas -- --report ./artifacts/qa/report.json` +- `bun run publish-comment -- --report ./artifacts/qa/report.json` Source/dev loop: diff --git a/packages/cali/README.md b/packages/cali/README.md index f20eacc..9e47e68 100644 --- a/packages/cali/README.md +++ b/packages/cali/README.md @@ -13,7 +13,7 @@ Cali v2 is a role-oriented CLI for mobile React Native and Expo workflows. It ru - command: the user-facing role entrypoint such as `cali qa` or `cali review` - env: default runtime shape for a command, such as CI-style `mobile-pr` or local mobile envs -- context file: the explicit JSON input for workspace, repository, PR/task, mobile, build, output, and role-specific sections +- context file: the optional explicit JSON input for workspace, repository, PR/task, mobile, build, output, and role-specific sections - tool pack: a bounded capability surface such as `agent-device`, `react-devtools`, `repo-read`, or `repo-write` - publisher: how reports are exposed after a run, such as `file` or `blob` @@ -104,11 +104,12 @@ Local mobile behavior: - local envs try `open --relaunch` before reinstalling - `local-ios` reuses the single booted simulator when exactly one is available, otherwise pass `--device` -### CI-style QA or review +### CI-native QA or review ```bash +cali qa --ci github-actions --platform ios --artifact ./artifacts/MyApp.app +cali qa --ci eas --platform android --artifact ./artifacts/app.apk cali qa --env mobile-pr --context ./cali-context.json -cali qa --env eas-mobile-pr --context ./cali-context.json cali review --env mobile-pr --context ./cali-context.json ``` @@ -224,92 +225,112 @@ npx skills add callstackincubator/agent-skills --agent codex --skill react-devto ## CI Providers -The current ship-ready CI story for Cali is: +The CI-native entrypoint is `cali qa --ci `. -- generate one `cali-context.json` -- run `cali qa --env mobile-pr --context ./cali-context.json` -- collect the compact CI outputs from the file publisher +Supported providers: + +- `github-actions` +- `eas` + +For CI runs, Cali derives runtime context from provider env plus CLI overrides. You no longer need a separate `write-mobile-pr-context` step. + +Required provider inputs: + +- GitHub Actions: + - `GITHUB_EVENT_PATH` + - `CALI_PLATFORM` or `--platform` + - `CALI_ARTIFACT_PATH` or `--artifact` + - optional `CALI_APP_ID` + - optional `CALI_DEVICE_NAME` + - optional `CALI_OUTPUT_DIR` +- EAS: + - `QA_PLATFORM` or `--platform` + - `APP_PATH` or `--artifact` + - optional `APPLICATION_ID` + - optional `CALI_DEVICE_NAME` + - optional `BUILD_ID` + - optional `WORKFLOW_URL` + - optional `LOGS_URL` + - optional `PR_JSON` ## CI Helpers -Use the built-in helper command instead of hand-writing `cali-context.json` in shell: +Core CI command: ```bash -cali write-mobile-pr-context --from github-actions --output ./cali-context.json -cali write-mobile-pr-context --from eas --output ./cali-context.json +cali qa --ci github-actions --quiet --platform ios --artifact ./artifacts/MyApp.app +cali qa --ci eas --quiet --platform android --artifact ./artifacts/app.apk ``` -Render a compact GitHub-ready comment from a generated report: +Optional helpers: ```bash +cali export-ci --target eas --report ./artifacts/qa/report.json cali render-comment --report ./artifacts/qa/report.json --format github +cali render-comment --format github-multi-platform --android ./artifacts/android/report.json --ios ./artifacts/ios/report.json +cali publish-comment --report ./artifacts/qa/report.json ``` ### GitHub Actions -Required envs for the built-in context writer: - -- `CALI_PLATFORM` -- `CALI_ARTIFACT_PATH` -- optional `CALI_APP_ID` -- optional `CALI_DEVICE_NAME` - -Copy-paste example: +Minimal GitHub Actions example: ```yaml -- name: Install Cali CLIs +- name: Install required CLIs run: npm i -g agent-device -- name: Write Cali context +- name: Run Cali QA env: + AI_GATEWAY_API_KEY: ${{ secrets.AI_GATEWAY_API_KEY }} CALI_PLATFORM: android CALI_ARTIFACT_PATH: ${{ steps.download_build.outputs.artifact_path }} CALI_APP_ID: com.example.myapp - run: node ./packages/cali/dist/index.js write-mobile-pr-context --from github-actions --output ./cali-context.json - -- name: Run Cali QA - env: - AI_GATEWAY_API_KEY: ${{ secrets.AI_GATEWAY_API_KEY }} - run: node ./packages/cali/dist/index.js qa --env mobile-pr --context ./cali-context.json + run: node ./packages/cali/dist/index.js qa --ci github-actions --quiet -- name: Render PR comment body - run: node ./packages/cali/dist/index.js render-comment --report ./artifacts/qa/report.json --format github --output ./artifacts/qa/comment-github.md +- name: Publish PR comment + run: node ./packages/cali/dist/index.js publish-comment --report ./artifacts/qa/report.json ``` -### EAS Workflows - -This matches the environment shape from the earlier `eas-agent-device` workflow pattern: +Reference wrapper: +- [`packages/cali/examples/github-actions/run-qa.sh`](./examples/github-actions/run-qa.sh) -- `QA_PLATFORM` -- `APP_PATH` -- `APPLICATION_ID` -- optional `BUILD_ID` -- optional `WORKFLOW_URL` -- optional `PR_JSON` +### EAS Workflows -Copy-paste example: +Minimal EAS example: ```yaml - id: install_agent_device run: npm i -g agent-device -- id: write_cali_context +- id: run_cali_qa env: + AI_GATEWAY_API_KEY: ${{ secrets.AI_GATEWAY_API_KEY }} QA_PLATFORM: android APP_PATH: ${{ steps.download_build.outputs.artifact_path }} APPLICATION_ID: dev.expo.myapp BUILD_ID: ${{ env.BUILD_ID }} WORKFLOW_URL: ${{ workflow.url }} PR_JSON: ${{ toJSON(github.event.pull_request) }} - run: node ./packages/cali/dist/index.js write-mobile-pr-context --from eas --output ./cali-context.json + run: node ./packages/cali/dist/index.js qa --ci eas --quiet -- id: run_cali_qa - env: - AI_GATEWAY_API_KEY: ${{ secrets.AI_GATEWAY_API_KEY }} - run: node ./packages/cali/dist/index.js qa --env eas-mobile-pr --context ./cali-context.json +- id: export_cali_ci + run: node ./packages/cali/dist/index.js export-ci --target eas --report ./artifacts/qa/report.json ``` -If you want a combined PR comment like the original Expo blog post flow, aggregate `comment-github.md`, `status-label.txt`, and `screenshots.json` from each platform job in a final workflow step. Cali still leaves the final multi-job aggregation to the workflow layer. +Reference wrapper: +- [`packages/cali/examples/eas-workflows/run-qa.sh`](./examples/eas-workflows/run-qa.sh) + +For multi-platform PR comments, render once from both platform reports: + +```bash +cali render-comment \ + --format github-multi-platform \ + --android ./artifacts/android/report.json \ + --ios ./artifacts/ios/report.json \ + --output ./artifacts/comment.md +``` + +`publish-comment` uses the GitHub CLI, so make sure `gh` is available and authenticated in the job where you call it. ## Config @@ -364,13 +385,14 @@ Built bundle: - `bun run perf-review -- --help` - `bun run dev:command -- --help` - `bun run qa:env:mobile-pr -- --context ./cali-context.json` -- `bun run qa:env:eas-mobile-pr -- --context ./cali-context.json` +- `bun run qa:ci:gha -- --platform android --artifact ./artifacts/app.apk` +- `bun run qa:ci:eas -- --platform ios --artifact ./artifacts/MyApp.app` - `bun run review:env:mobile-pr -- --context ./cali-context.json` - `bun run perf-review:env:mobile-pr -- --context ./cali-context.json` - `bun run dev:command:env:mobile-pr -- --context ./cali-context.json` -- `bun run write-context:gha -- --output ./cali-context.json` -- `bun run write-context:eas -- --output ./cali-context.json` - `bun run render-comment -- --report ./artifacts/qa/report.json` +- `bun run export-ci:eas -- --report ./artifacts/qa/report.json` +- `bun run publish-comment -- --report ./artifacts/qa/report.json` Source/dev loop: @@ -396,6 +418,8 @@ The file publisher writes: The default output directory is `artifacts/`. +For `qa`, Cali writes this output contract even for blocked runs during CI/bootstrap startup, as long as the output directory itself is writable. + For `qa` and `perf-review`, screenshots are saved under `artifacts//screenshots`. If `BLOB_READ_WRITE_TOKEN` is set, the blob publisher uploads screenshots and enriches the report with blob URLs. diff --git a/packages/cali/examples/eas-workflows/run-qa.sh b/packages/cali/examples/eas-workflows/run-qa.sh new file mode 100644 index 0000000..180bf03 --- /dev/null +++ b/packages/cali/examples/eas-workflows/run-qa.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash + +set -euo pipefail + +node ./packages/cali/dist/index.js qa --ci eas --quiet "$@" diff --git a/packages/cali/examples/eas-workflows/write-mobile-pr-context.sh b/packages/cali/examples/eas-workflows/write-mobile-pr-context.sh deleted file mode 100755 index 78954f7..0000000 --- a/packages/cali/examples/eas-workflows/write-mobile-pr-context.sh +++ /dev/null @@ -1,8 +0,0 @@ -#!/usr/bin/env bash - -set -euo pipefail - -OUTPUT_PATH="${1:-./cali-context.json}" -node ./packages/cali/dist/index.js write-mobile-pr-context \ - --from eas \ - --output "${OUTPUT_PATH}" diff --git a/packages/cali/examples/github-actions/run-qa.sh b/packages/cali/examples/github-actions/run-qa.sh new file mode 100644 index 0000000..a041d69 --- /dev/null +++ b/packages/cali/examples/github-actions/run-qa.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash + +set -euo pipefail + +node ./packages/cali/dist/index.js qa --ci github-actions --quiet "$@" diff --git a/packages/cali/examples/github-actions/write-mobile-pr-context.sh b/packages/cali/examples/github-actions/write-mobile-pr-context.sh deleted file mode 100755 index 4585d91..0000000 --- a/packages/cali/examples/github-actions/write-mobile-pr-context.sh +++ /dev/null @@ -1,8 +0,0 @@ -#!/usr/bin/env bash - -set -euo pipefail - -OUTPUT_PATH="${1:-./cali-context.json}" -node ./packages/cali/dist/index.js write-mobile-pr-context \ - --from github-actions \ - --output "${OUTPUT_PATH}" diff --git a/packages/cali/package.json b/packages/cali/package.json index f00da2e..cd74d6d 100644 --- a/packages/cali/package.json +++ b/packages/cali/package.json @@ -24,12 +24,14 @@ "dev:qa:env:local:ios": "node --import=tsx ./src/cli.ts qa --env local-ios", "dev:qa:env:mobile-pr": "node --import=tsx ./src/cli.ts qa --env mobile-pr", "dev:qa:env:eas-mobile-pr": "node --import=tsx ./src/cli.ts qa --env eas-mobile-pr", + "qa:ci:gha": "node ./dist/index.js qa --ci github-actions --quiet", + "qa:ci:eas": "node ./dist/index.js qa --ci eas --quiet", "dev:review": "node --import=tsx ./src/cli.ts review", "dev:perf-review": "node --import=tsx ./src/cli.ts perf-review", "dev:dev-command": "node --import=tsx ./src/cli.ts dev", - "write-context:eas": "node ./dist/index.js write-mobile-pr-context --from eas", - "write-context:gha": "node ./dist/index.js write-mobile-pr-context --from github-actions", - "render-comment": "node ./dist/index.js render-comment --format github" + "render-comment": "node ./dist/index.js render-comment --format github", + "export-ci:eas": "node ./dist/index.js export-ci --target eas", + "publish-comment": "node ./dist/index.js publish-comment --format github" }, "dependencies": { "@ai-sdk/anthropic": "^3.0.64", diff --git a/packages/cali/src/cli/app.ts b/packages/cali/src/cli/app.ts index c4afb14..28294ac 100644 --- a/packages/cali/src/cli/app.ts +++ b/packages/cali/src/cli/app.ts @@ -3,11 +3,12 @@ import { cac } from 'cac' import { loadCaliConfigFile } from '../config/load.js' import { printRetroBanner } from './banner.js' import { devCommandDefinition } from './dev.js' +import { exportCiCommandDefinition } from './export-ci.js' import { perfReviewCommandDefinition } from './perf-review.js' +import { publishCommentCommandDefinition } from './publish-comment.js' import { qaCommandDefinition } from './qa.js' import { renderCommentCommandDefinition } from './render-comment.js' import { reviewCommandDefinition } from './review.js' -import { writeMobilePrContextCommandDefinition } from './write-mobile-pr-context.js' function shouldPrintBanner(args: string[]) { if ( @@ -33,8 +34,9 @@ function createCli() { reviewCommandDefinition, perfReviewCommandDefinition, devCommandDefinition, - writeMobilePrContextCommandDefinition, renderCommentCommandDefinition, + exportCiCommandDefinition, + publishCommentCommandDefinition, ]) { commandDefinition.register(cli) } diff --git a/packages/cali/src/cli/export-ci.ts b/packages/cali/src/cli/export-ci.ts new file mode 100644 index 0000000..258b089 --- /dev/null +++ b/packages/cali/src/cli/export-ci.ts @@ -0,0 +1,44 @@ +import type { CAC } from 'cac' + +import { exportCi } from '../commands/export-ci.js' +import { readOptionalString } from './shared.js' + +type ExportCiCliOptions = { + target?: string + report?: string + outputDir?: string +} + +function normalizeTarget(value: unknown) { + if (value === 'eas') { + return 'eas' as const + } + + throw new Error('`--target` must be `eas`.') +} + +export const exportCiCommandDefinition = { + register(cli: CAC) { + cli + .command('export-ci', 'Export provider-specific CI helper files from a Cali report') + .option('--target ', 'CI target', { + default: 'eas', + }) + .option('--report ', 'Path to report.json') + .option('--output-dir ', 'Output directory for exported helper files') + .example('export-ci --target eas --report ./artifacts/qa/report.json') + .action(async (options: unknown) => { + const normalized = options as ExportCiCliOptions + const reportPath = readOptionalString(normalized.report) + if (!reportPath) { + throw new Error('`export-ci` requires `--report `.') + } + + await exportCi({ + target: normalizeTarget(normalized.target ?? 'eas'), + reportPath, + outputDir: readOptionalString(normalized.outputDir), + }) + }) + }, +} diff --git a/packages/cali/src/cli/publish-comment.ts b/packages/cali/src/cli/publish-comment.ts new file mode 100644 index 0000000..84d94dc --- /dev/null +++ b/packages/cali/src/cli/publish-comment.ts @@ -0,0 +1,82 @@ +import type { CAC } from 'cac' + +import { publishComment } from '../commands/publish-comment.js' +import { readOptionalNumber, readOptionalString } from './shared.js' + +type PublishCommentCliOptions = { + format?: string + report?: string + android?: string + ios?: string + body?: string + repo?: string + prNumber?: string | number + marker?: string +} + +function normalizeFormat(value: unknown) { + if (!value || value === 'github') { + return 'github' as const + } + + if (value === 'github-multi-platform') { + return 'github-multi-platform' as const + } + + throw new Error('`--format` must be `github` or `github-multi-platform`.') +} + +export const publishCommentCommandDefinition = { + register(cli: CAC) { + cli + .command('publish-comment', 'Create or update a GitHub PR comment from Cali output') + .option('--format ', 'Comment format', { + default: 'github', + }) + .option('--report ', 'Path to report.json') + .option('--android ', 'Android report.json path for multi-platform rendering') + .option('--ios ', 'iOS report.json path for multi-platform rendering') + .option('--body ', 'Path to a pre-rendered markdown comment body') + .option('--repo ', 'GitHub repository override') + .option('--pr-number ', 'Pull request number override') + .option('--marker ', 'Stable marker used to create or update the same comment') + .example('publish-comment --report ./artifacts/qa/report.json') + .example( + 'publish-comment --format github-multi-platform --android ./artifacts/android/report.json --ios ./artifacts/ios/report.json' + ) + .action(async (options: unknown) => { + const normalized = options as PublishCommentCliOptions + const format = normalizeFormat(normalized.format) + const bodyPath = readOptionalString(normalized.body) + const reportPath = readOptionalString(normalized.report) + const androidReportPath = readOptionalString(normalized.android) + const iosReportPath = readOptionalString(normalized.ios) + + if (!bodyPath && format === 'github' && !reportPath) { + throw new Error('`publish-comment` requires `--report ` or `--body `.') + } + + if ( + !bodyPath && + format === 'github-multi-platform' && + !androidReportPath && + !iosReportPath + ) { + throw new Error( + '`publish-comment --format github-multi-platform` requires `--android `, `--ios `, or `--body `.' + ) + } + + await publishComment({ + format, + reportPath, + androidReportPath, + iosReportPath, + bodyPath, + repo: readOptionalString(normalized.repo), + prNumber: readOptionalNumber(normalized.prNumber, '--pr-number'), + marker: readOptionalString(normalized.marker), + }) + }) + }, +} diff --git a/packages/cali/src/cli/qa.ts b/packages/cali/src/cli/qa.ts index 3e38919..705c6a4 100644 --- a/packages/cali/src/cli/qa.ts +++ b/packages/cali/src/cli/qa.ts @@ -1,20 +1,38 @@ import type { CAC } from 'cac' import { runQaCommand } from '../commands/qa.js' +import type { CiProvider } from '../runtime/ci-context.js' import { type BaseCommandOptions, normalizeBaseCommandCliOptions, registerCommonMobileOptions, } from './shared.js' +function normalizeCiProvider(value: unknown): CiProvider | undefined { + if (value == null || value === '') { + return undefined + } + + if (value === 'github-actions' || value === 'eas') { + return value + } + + throw new Error('`--ci` must be `github-actions` or `eas`.') +} + export const qaCommandDefinition = { register(cli: CAC) { registerCommonMobileOptions(cli.command('qa', 'Run the mobile QA role')) + .option('--ci ', 'CI provider context: github-actions or eas') .example( 'qa --env local-ios --artifact ./artifacts/MyApp.app --prompt "verify the onboarding copy on Screen B"' ) + .example('qa --ci github-actions --platform ios --artifact ./artifacts/MyApp.app') + .example('qa --ci eas --platform android --artifact ./artifacts/app.apk') .action(async (options: unknown) => { - await runQaCommand(normalizeBaseCommandCliOptions(options as BaseCommandOptions)) + const normalized = normalizeBaseCommandCliOptions(options as BaseCommandOptions) + normalized.ciProvider = normalizeCiProvider((options as BaseCommandOptions).ci) + await runQaCommand(normalized) }) }, } diff --git a/packages/cali/src/cli/render-comment.ts b/packages/cali/src/cli/render-comment.ts index 620e06e..652c98c 100644 --- a/packages/cali/src/cli/render-comment.ts +++ b/packages/cali/src/cli/render-comment.ts @@ -5,6 +5,8 @@ import { readOptionalString } from './shared.js' type RenderCommentCliOptions = { report?: string + android?: string + ios?: string format?: string output?: string } @@ -14,7 +16,11 @@ function normalizeFormat(value: unknown) { return 'github' as const } - throw new Error('`--format` must be `github`.') + if (value === 'github-multi-platform') { + return 'github-multi-platform' as const + } + + throw new Error('`--format` must be `github` or `github-multi-platform`.') } export const renderCommentCommandDefinition = { @@ -22,21 +28,38 @@ export const renderCommentCommandDefinition = { cli .command('render-comment', 'Render a compact comment from a Cali report') .option('--report ', 'Path to report.json') + .option('--android ', 'Android report.json path for multi-platform rendering') + .option('--ios ', 'iOS report.json path for multi-platform rendering') .option('--format ', 'Comment format', { default: 'github', }) .option('--output ', 'Write the rendered comment to a file instead of stdout') .example('render-comment --report ./artifacts/qa/report.json --format github') + .example( + 'render-comment --format github-multi-platform --android ./artifacts/android/report.json --ios ./artifacts/ios/report.json' + ) .action(async (options: unknown) => { const normalized = options as RenderCommentCliOptions + const format = normalizeFormat(normalized.format) const reportPath = readOptionalString(normalized.report) - if (!reportPath) { + const androidReportPath = readOptionalString(normalized.android) + const iosReportPath = readOptionalString(normalized.ios) + + if (format === 'github' && !reportPath) { throw new Error('`render-comment` requires `--report `.') } + if (format === 'github-multi-platform' && !androidReportPath && !iosReportPath) { + throw new Error( + '`render-comment --format github-multi-platform` requires `--android `, `--ios `, or both.' + ) + } + await renderComment({ reportPath, - format: normalizeFormat(normalized.format), + androidReportPath, + iosReportPath, + format, outputPath: readOptionalString(normalized.output), }) }) diff --git a/packages/cali/src/cli/shared.ts b/packages/cali/src/cli/shared.ts index 7952415..7aa1c7e 100644 --- a/packages/cali/src/cli/shared.ts +++ b/packages/cali/src/cli/shared.ts @@ -2,6 +2,7 @@ import type { CommandCliOptions } from '../runtime/types.js' import { normalizePlatform } from '../utils.js' export type BaseCommandOptions = { + ci?: string env?: string config?: string prompt?: string @@ -54,6 +55,7 @@ export function normalizeBaseCommandCliOptions(options: BaseCommandOptions): Com } return { + ciProvider: readOptionalString(options.ci) as CommandCliOptions['ciProvider'], envName: readOptionalString(options.env) as CommandCliOptions['envName'], configPath: readOptionalString(options.config), prompt: readOptionalString(options.prompt), diff --git a/packages/cali/src/cli/write-mobile-pr-context.ts b/packages/cali/src/cli/write-mobile-pr-context.ts deleted file mode 100644 index 33d84a2..0000000 --- a/packages/cali/src/cli/write-mobile-pr-context.ts +++ /dev/null @@ -1,71 +0,0 @@ -import type { CAC } from 'cac' - -import { writeMobilePrContext } from '../commands/write-mobile-pr-context.js' -import { readOptionalString } from './shared.js' - -type WriteMobilePrContextCliOptions = { - from?: string - output?: string - platform?: string - artifact?: string - appId?: string - device?: string - outputDir?: string - workspaceRoot?: string - buildId?: string - workflowUrl?: string - logsUrl?: string -} - -function normalizeProvider(value: unknown) { - if (value === 'github-actions' || value === 'eas') { - return value - } - - throw new Error('`--from` must be `github-actions` or `eas`.') -} - -export const writeMobilePrContextCommandDefinition = { - register(cli: CAC) { - cli - .command( - 'write-mobile-pr-context', - 'Write a normalized cali-context.json from GitHub Actions or EAS environment variables' - ) - .option('--from ', 'Context source: github-actions or eas') - .option('--output ', 'Output file path', { - default: './cali-context.json', - }) - .option('--platform ', 'Override platform: android or ios') - .option('--artifact ', 'Override artifact path') - .option('--app-id ', 'Override application identifier') - .option('--device ', 'Override simulator or emulator name') - .option('--output-dir ', 'Override report output directory') - .option('--workspace-root ', 'Override workspace root') - .option('--build-id ', 'Override build identifier') - .option('--workflow-url ', 'Override workflow URL') - .option('--logs-url ', 'Override logs URL') - .example('write-mobile-pr-context --from eas --output ./cali-context.json') - .example('write-mobile-pr-context --from github-actions --output ./cali-context.json') - .action(async (options: unknown) => { - const normalized = options as WriteMobilePrContextCliOptions - if (!normalized.from) { - throw new Error('`write-mobile-pr-context` requires `--from `.') - } - - await writeMobilePrContext({ - from: normalizeProvider(normalized.from), - outputPath: readOptionalString(normalized.output) ?? './cali-context.json', - platform: readOptionalString(normalized.platform) as 'android' | 'ios' | undefined, - artifactPath: readOptionalString(normalized.artifact), - appId: readOptionalString(normalized.appId), - deviceName: readOptionalString(normalized.device), - outputDir: readOptionalString(normalized.outputDir), - workspaceRoot: readOptionalString(normalized.workspaceRoot), - buildId: readOptionalString(normalized.buildId), - workflowUrl: readOptionalString(normalized.workflowUrl), - logsUrl: readOptionalString(normalized.logsUrl), - }) - }) - }, -} diff --git a/packages/cali/src/commands/export-ci.ts b/packages/cali/src/commands/export-ci.ts new file mode 100644 index 0000000..61cf83a --- /dev/null +++ b/packages/cali/src/commands/export-ci.ts @@ -0,0 +1,42 @@ +import { readFile, writeFile } from 'node:fs/promises' +import path from 'node:path' + +import { buildScreenshotsMetadata, getTopIssue, renderScreenshotsCell } from '../report/ci.js' +import { renderCommandSection } from '../report/render.js' +import type { CommandReport } from '../report/types.js' +import { ensureDirectory, resolveFromCwd } from '../utils.js' + +export type ExportCiOptions = { + target: 'eas' + reportPath: string + outputDir?: string +} + +export async function exportCi(options: ExportCiOptions) { + const cwd = process.cwd() + const reportPath = resolveFromCwd(cwd, options.reportPath) + const content = await readFile(reportPath, 'utf8') + const report = JSON.parse(content) as CommandReport + const outputDir = resolveFromCwd(cwd, options.outputDir ?? path.dirname(reportPath)) + + await ensureDirectory(outputDir) + + const topIssue = + getTopIssue(report) ?? (report.overallStatus === 'passed' ? 'N/A' : report.summary) + + await writeFile(path.join(outputDir, 'eas-status.txt'), `${report.overallStatus}\n`, 'utf8') + await writeFile(path.join(outputDir, 'eas-top-issue.txt'), `${topIssue}\n`, 'utf8') + await writeFile(path.join(outputDir, 'eas-section-body.md'), renderCommandSection(report), 'utf8') + await writeFile( + path.join(outputDir, 'eas-screenshots-cell.md'), + `${renderScreenshotsCell(report)}\n`, + 'utf8' + ) + await writeFile( + path.join(outputDir, 'eas-screenshots.json'), + `${JSON.stringify(buildScreenshotsMetadata(report), null, 2)}\n`, + 'utf8' + ) + + console.log(`Exported ${options.target} CI helpers to ${outputDir}`) +} diff --git a/packages/cali/src/commands/publish-comment.ts b/packages/cali/src/commands/publish-comment.ts new file mode 100644 index 0000000..2df680c --- /dev/null +++ b/packages/cali/src/commands/publish-comment.ts @@ -0,0 +1,198 @@ +import { readFile } from 'node:fs/promises' + +import { renderGithubComment, renderGithubMultiPlatformComment } from '../report/ci.js' +import type { CommandReport } from '../report/types.js' +import { ensureCommandExists, parseJson, runCommand, trimText } from '../utils.js' + +const DEFAULT_COMMENT_MARKER = '' + +export type PublishCommentOptions = { + format: 'github' | 'github-multi-platform' + reportPath?: string + androidReportPath?: string + iosReportPath?: string + bodyPath?: string + repo?: string + prNumber?: number + marker?: string +} + +type GithubIssueComment = { + id: number + body?: string +} + +type GithubTargetHint = { + repo?: string + prNumber?: number +} + +function readOptionalEnv(name: string) { + const value = process.env[name] + return value && value.length > 0 ? value : undefined +} + +async function loadReport(reportPath: string) { + const content = await readFile(reportPath, 'utf8') + return JSON.parse(content) as CommandReport +} + +function extractGithubTargetHint(report?: CommandReport): GithubTargetHint { + if (!report) { + return {} + } + + const owner = report.context.repository?.owner + const name = report.context.repository?.name + + return { + repo: owner && name ? `${owner}/${name}` : undefined, + prNumber: report.context.pullRequest?.number, + } +} + +async function resolveCommentBody(options: PublishCommentOptions) { + if (options.bodyPath) { + return readFile(options.bodyPath, 'utf8') + } + + if (options.format === 'github-multi-platform') { + const [android, ios] = await Promise.all([ + options.androidReportPath + ? loadReport(options.androidReportPath) + : Promise.resolve(undefined), + options.iosReportPath ? loadReport(options.iosReportPath) : Promise.resolve(undefined), + ]) + + return renderGithubMultiPlatformComment({ android, ios }) + } + + const report = await loadReport(options.reportPath!) + return renderGithubComment(report) +} + +async function detectPrNumberFromGithubActions() { + const eventPath = readOptionalEnv('GITHUB_EVENT_PATH') + if (!eventPath) { + return undefined + } + + const content = await readFile(eventPath, 'utf8') + const event = parseJson<{ pull_request?: { number?: number } }>(content, {}) + return event.pull_request?.number +} + +async function loadGithubTargetHint(options: PublishCommentOptions): Promise { + const reportPaths = + options.format === 'github-multi-platform' + ? [options.androidReportPath, options.iosReportPath].filter(Boolean) + : [options.reportPath].filter(Boolean) + + for (const reportPath of reportPaths) { + if (!reportPath) { + continue + } + + const report = await loadReport(reportPath) + const hint = extractGithubTargetHint(report) + if (hint.repo || hint.prNumber) { + return hint + } + } + + return {} +} + +async function resolveGithubTarget(options: PublishCommentOptions) { + const reportHint = await loadGithubTargetHint(options) + const repo = options.repo ?? reportHint.repo ?? readOptionalEnv('GITHUB_REPOSITORY') + const prNumber = + options.prNumber ?? reportHint.prNumber ?? (await detectPrNumberFromGithubActions()) + + if (!repo) { + throw new Error( + 'GitHub comment publishing requires `--repo `, report repository context, or GITHUB_REPOSITORY.' + ) + } + + if (!prNumber) { + throw new Error('GitHub comment publishing requires `--pr-number ` or pull request context.') + } + + return { + repo, + prNumber, + } +} + +async function listIssueComments(repo: string, prNumber: number) { + const result = await runCommand( + 'gh', + ['api', `repos/${repo}/issues/${prNumber}/comments`, '--paginate'], + { allowFailure: true } + ) + + if (!result.ok) { + throw new Error(trimText(result.stderr || result.stdout)) + } + + return parseJson(result.stdout, []) +} + +async function createIssueComment(repo: string, prNumber: number, body: string) { + const result = await runCommand( + 'gh', + [ + 'api', + `repos/${repo}/issues/${prNumber}/comments`, + '--method', + 'POST', + '--field', + `body=${body}`, + ], + { allowFailure: true } + ) + + if (!result.ok) { + throw new Error(trimText(result.stderr || result.stdout)) + } +} + +async function updateIssueComment(repo: string, commentId: number, body: string) { + const result = await runCommand( + 'gh', + [ + 'api', + `repos/${repo}/issues/comments/${commentId}`, + '--method', + 'PATCH', + '--field', + `body=${body}`, + ], + { allowFailure: true } + ) + + if (!result.ok) { + throw new Error(trimText(result.stderr || result.stdout)) + } +} + +export async function publishComment(options: PublishCommentOptions) { + await ensureCommandExists('gh', 'Install GitHub CLI and make sure `gh` is authenticated.') + + const { repo, prNumber } = await resolveGithubTarget(options) + const body = await resolveCommentBody(options) + const marker = options.marker ?? DEFAULT_COMMENT_MARKER + const finalBody = `${marker}\n${body}` + const comments = await listIssueComments(repo, prNumber) + const existingComment = comments.find((comment) => comment.body?.includes(marker)) + + if (existingComment) { + await updateIssueComment(repo, existingComment.id, finalBody) + console.log(`Updated GitHub PR comment on ${repo}#${prNumber}`) + return + } + + await createIssueComment(repo, prNumber, finalBody) + console.log(`Created GitHub PR comment on ${repo}#${prNumber}`) +} diff --git a/packages/cali/src/commands/qa.ts b/packages/cali/src/commands/qa.ts index b428d92..06c0832 100644 --- a/packages/cali/src/commands/qa.ts +++ b/packages/cali/src/commands/qa.ts @@ -78,6 +78,10 @@ function createBlockedReport(summary: string): QaReportInput { } export async function runQaCommand(cli: CommandCliOptions) { + if (cli.ciProvider && !cli.envName) { + cli.envName = cli.ciProvider === 'eas' ? 'eas-mobile-pr' : 'mobile-pr' + } + return runMobileStructuredCommand({ commandId: 'qa', cli, diff --git a/packages/cali/src/commands/render-comment.ts b/packages/cali/src/commands/render-comment.ts index dfc80ad..753c5a0 100644 --- a/packages/cali/src/commands/render-comment.ts +++ b/packages/cali/src/commands/render-comment.ts @@ -1,31 +1,46 @@ import { readFile, writeFile } from 'node:fs/promises' import path from 'node:path' -import { renderGithubComment } from '../report/ci.js' +import { renderGithubComment, renderGithubMultiPlatformComment } from '../report/ci.js' import type { CommandReport } from '../report/types.js' import { ensureDirectory, resolveFromCwd } from '../utils.js' export type RenderCommentOptions = { - reportPath: string - format: 'github' + reportPath?: string + androidReportPath?: string + iosReportPath?: string + format: 'github' | 'github-multi-platform' outputPath?: string } -function renderCommentText(report: CommandReport, format: RenderCommentOptions['format']) { - if (format === 'github') { - return renderGithubComment(report) - } - - return renderGithubComment(report) -} - export async function renderComment(options: RenderCommentOptions) { const cwd = process.cwd() - const reportPath = resolveFromCwd(cwd, options.reportPath) - const content = await readFile(reportPath, 'utf8') - const report = JSON.parse(content) as CommandReport - - const rendered = renderCommentText(report, options.format) + let rendered: string + + if (options.format === 'github-multi-platform') { + const [android, ios] = await Promise.all([ + options.androidReportPath + ? readFile(resolveFromCwd(cwd, options.androidReportPath), 'utf8').then( + (content) => JSON.parse(content) as CommandReport + ) + : Promise.resolve(undefined), + options.iosReportPath + ? readFile(resolveFromCwd(cwd, options.iosReportPath), 'utf8').then( + (content) => JSON.parse(content) as CommandReport + ) + : Promise.resolve(undefined), + ]) + + rendered = renderGithubMultiPlatformComment({ + android, + ios, + }) + } else { + const reportPath = resolveFromCwd(cwd, options.reportPath!) + const content = await readFile(reportPath, 'utf8') + const report = JSON.parse(content) as CommandReport + rendered = renderGithubComment(report) + } if (options.outputPath) { const outputPath = resolveFromCwd(cwd, options.outputPath) diff --git a/packages/cali/src/commands/shared.ts b/packages/cali/src/commands/shared.ts index 656e33e..0c71948 100644 --- a/packages/cali/src/commands/shared.ts +++ b/packages/cali/src/commands/shared.ts @@ -6,6 +6,7 @@ import { z } from 'zod' import { loadCommandConfig } from '../config/load.js' import type { ToolPackName } from '../config/schema.js' import type { CommandReport } from '../report/types.js' +import { buildCiContext } from '../runtime/ci-context.js' import { resolveCommandContext } from '../runtime/context.js' import { bootstrapMobileApp, @@ -115,7 +116,20 @@ export async function loadRunContext(commandId: CommandId, cli: CommandCliOption envName: cli.envName, model: cli.model, }) - const context = await resolveCommandContext(commandId, cwd, config, cli) + const injectedContext = cli.ciProvider + ? await buildCiContext(cwd, cli.ciProvider, { + workspaceRoot: cli.workspaceRoot, + platform: cli.platform, + artifactPath: cli.artifactPath, + appId: cli.appId, + deviceName: cli.deviceName, + outputDir: cli.outputDir, + buildId: cli.buildId, + workflowUrl: cli.workflowUrl, + logsUrl: cli.logsUrl, + }) + : {} + const context = await resolveCommandContext(commandId, cwd, config, cli, injectedContext) return { cwd, @@ -124,6 +138,112 @@ export async function loadRunContext(commandId: CommandId, cli: CommandCliOption } } +function getDefaultEnvName(commandId: CommandId, cli: CommandCliOptions) { + if (cli.envName) { + return cli.envName + } + + if (cli.ciProvider === 'eas') { + return 'eas-mobile-pr' + } + + if (cli.ciProvider === 'github-actions') { + return 'mobile-pr' + } + + return commandId === 'qa' || commandId === 'perf-review' ? 'local-android' : 'mobile-pr' +} + +function createFallbackConfig( + commandId: CommandId, + cwd: string, + cli: CommandCliOptions +): CommandResolvedConfig { + return { + envName: getDefaultEnvName(commandId, cli), + workspaceRoot: cli.workspaceRoot ? resolveFromCwd(cwd, cli.workspaceRoot) : cwd, + contextPath: undefined, + skillPaths: [], + enabledToolPacks: [], + outputPublishers: ['file'], + extraInstructions: [], + model: cli.model ?? process.env.QA_MODEL ?? 'openai/gpt-5.4-mini', + mobileDefaults: {}, + } +} + +function createFallbackContext( + commandId: 'qa' | 'perf-review', + cwd: string, + config: CommandResolvedConfig, + cli: CommandCliOptions +): CaliContext { + const workspaceRoot = config.workspaceRoot ?? cwd + const outputDir = resolveFromCwd( + workspaceRoot, + cli.outputDir ?? path.join('artifacts', commandId) + ) + const platform = + cli.platform ?? + config.mobileDefaults.platform ?? + (config.envName === 'local-ios' ? 'ios' : undefined) + + return { + workspaceRoot, + repository: undefined, + task: + cli.taskId || cli.taskTitle || cli.taskBody || cli.taskUrl + ? { + id: cli.taskId, + title: cli.taskTitle, + body: cli.taskBody, + url: cli.taskUrl, + labels: [], + } + : undefined, + pullRequest: + cli.prNumber || cli.prTitle || cli.prBody || cli.prUrl || cli.prBaseBranch || cli.prHeadBranch + ? { + number: cli.prNumber, + title: cli.prTitle, + body: cli.prBody, + url: cli.prUrl, + labels: [], + isDraft: false, + baseBranch: cli.prBaseBranch, + headBranch: cli.prHeadBranch, + } + : undefined, + mobile: { + platform, + artifactPath: cli.artifactPath, + appId: cli.appId, + deviceName: cli.deviceName ?? config.mobileDefaults.deviceName, + }, + build: + cli.buildId || cli.workflowUrl || cli.logsUrl + ? { + id: cli.buildId, + workflowUrl: cli.workflowUrl, + logsUrl: cli.logsUrl, + } + : undefined, + output: { + outputDir, + screenshotsDir: path.join(outputDir, 'screenshots'), + }, + qa: commandId === 'qa' ? { acceptanceCriteria: [] } : undefined, + perfReview: + commandId === 'perf-review' + ? { + profilingGoals: [], + suspectedScreens: [], + } + : undefined, + dev: undefined, + } +} + export function createRunContextTool(commandId: CommandId, context: CaliContext) { return tool({ description: `Read the normalized ${commandId} run context and metadata.`, @@ -250,7 +370,9 @@ export async function runMobileStructuredCommand([ + 'file', + ...resolvedConfig.outputPublishers, + ]) + ) + printPhase('Publishing report', outputPublishers.join(', ')) const publishedReport = await publishReport({ report, - publishers: config.outputPublishers, + publishers: outputPublishers, }) printFinalReport(cwd, commandId, reportLabel, publishedReport) diff --git a/packages/cali/src/config/load.ts b/packages/cali/src/config/load.ts index d270d7a..867218e 100644 --- a/packages/cali/src/config/load.ts +++ b/packages/cali/src/config/load.ts @@ -45,7 +45,7 @@ const QA_ENV_DEFAULTS: Record = { ...MOBILE_PR_QA_DEFAULTS, extraInstructions: [ ...asArray(MOBILE_PR_QA_DEFAULTS.extraInstructions), - 'This run is expected to execute in EAS-style CI with explicit context generated before Cali starts.', + 'This run is expected to execute in EAS-style CI with runtime context derived before the agent starts.', ], }, 'local-ios': { diff --git a/packages/cali/src/report/ci.ts b/packages/cali/src/report/ci.ts index ba6fff5..9424401 100644 --- a/packages/cali/src/report/ci.ts +++ b/packages/cali/src/report/ci.ts @@ -1,3 +1,4 @@ +import { renderCommandSection } from './render.js' import type { CommandReport, PerfReviewReport, QaReport, ScreenshotInfo } from './types.js' function hasScreenshots(report: CommandReport): report is QaReport | PerfReviewReport { @@ -61,6 +62,28 @@ export function renderScreenshotsMarkdown(report: CommandReport) { .join('\n')}\n` } +export function renderScreenshotsCell(report?: CommandReport) { + if (!report || !hasScreenshots(report) || report.screenshots.length === 0) { + return 'N/A' + } + + return report.screenshots + .map((screenshot) => + screenshot.blobUrl + ? `**${screenshot.label}**
${screenshot.label}` + : `**${screenshot.label}**
${screenshot.fileName}` + ) + .join('

') +} + +function formatStatusForTable(report?: CommandReport) { + return report?.overallStatus ?? 'N/A' +} + +function formatTopIssueForTable(report?: CommandReport) { + return report ? (getTopIssue(report) ?? 'N/A') : 'N/A' +} + export function buildScreenshotsMetadata(report: CommandReport) { return { command: report.command, @@ -112,3 +135,54 @@ export function renderGithubComment(report: CommandReport) { return `${lines.join('\n')}\n` } + +export function renderGithubMultiPlatformComment(reports: { + android?: CommandReport + ios?: CommandReport +}) { + const { android, ios } = reports + const lines = ['### Mobile QA'] + + lines.push( + '', + '| Platform | Status | Top issue |', + '| --- | --- | --- |', + `| Android | ${formatStatusForTable(android)} | ${formatTopIssueForTable(android)} |`, + `| iOS | ${formatStatusForTable(ios)} | ${formatTopIssueForTable(ios)} |` + ) + + lines.push( + '', + '#### Screenshots', + '', + '| Android | iOS |', + '| --- | --- |', + `| ${renderScreenshotsCell(android)} | ${renderScreenshotsCell(ios)} |` + ) + + if (android) { + lines.push( + '', + '