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/AGENTS.md b/AGENTS.md
new file mode 100644
index 0000000..6172e0a
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1,219 @@
+# 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 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 the shared runtime contracts over command-local improvisation.
+
+## Repo Shape
+
+- `packages/cali`: standalone CLI role platform
+- `packages/tools`: reusable Cali tools for other runtimes
+
+## Cali Runtime Shape
+
+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/*.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`
+- Roles:
+ - `packages/cali/src/roles/*.ts`
+- Tool packs:
+ - `packages/cali/src/tools/*.ts`
+- Reports:
+ - `packages/cali/src/report/types.ts`
+ - `packages/cali/src/report/render.ts`
+ - `packages/cali/src/report/publishers/*.ts`
+
+## Public Commands
+
+Implemented first-class commands:
+
+- `qa`
+- `review`
+- `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
+
+### Local Mode
+
+Use `--local android|ios` for local mobile runs.
+
+CI metadata is detected automatically in GitHub Actions and EAS. Use `--ci github-actions|eas` only when you need to override detection.
+
+### Context
+
+All commands use one shared `cali-context.json` contract.
+
+Keep the shared context focused on:
+
+- `workspaceRoot`
+- `repository`
+- `task`
+- `pullRequest`
+- `mobile`
+- `build`
+- `output`
+- role-specific optional sections:
+ - `qa`
+ - `review`
+ - `perfReview`
+ - `dev`
+
+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.
+
+### Tool Packs
+
+Built-in pack ids:
+
+- `skills`
+- `agent-device`
+- `repo-read`
+- `repo-write`
+- `react-devtools`
+
+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`
+
+- Bootstrap stays outside the role in the command module.
+- The role inspects the app and writes a structured QA report.
+- 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 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:
+ - `context.qa.acceptanceCriteria`
+ - `context.pullRequest.body`
+ - `context.task.body`
+ - additive CLI prompt
+
+### `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`.
+
+## Validation
+
+- 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
+- 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.
+
+## Handy Scripts
+
+Built bundle:
+
+- `bun run qa -- --help`
+- `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 export-ci -- --report ./artifacts/qa/report.json`
+
+Source/dev loop:
+
+- `bun run dev:qa -- --help`
+- `bun run dev:review -- --help`
+- `bun run dev:perf-review -- --help`
+- `bun run dev:dev-command -- --help`
+
+## Extending Cali
+
+When adding a new command:
+
+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.
+
+Prefer small, explicit contracts:
+
+- 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 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 deb1aa1..a61957a 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,17 +26,20 @@ 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) - 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)
-- **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).
## What can it do?
Cali is still in the early stages of development, but it already supports:
+- **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
-- **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).
@@ -69,4 +72,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/bun.lockb b/bun.lockb
index 843bde3..6786d8c 100755
Binary files a/bun.lockb and b/bun.lockb differ
diff --git a/package.json b/package.json
index bc946c2..930a11c 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "@cali/root",
- "version": "0.3.1",
+ "version": "0.4.0-6",
"devDependencies": {
"@release-it-plugins/workspaces": "^4.2.0",
"@release-it/conventional-changelog": "^9.0.3",
@@ -20,14 +20,10 @@
"vitest": "^2.1.1"
},
"license": "MIT",
- "patchedDependencies": {
- "ai@4.0.3": "patches/ai@4.0.3.patch"
- },
"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/cali/README.md b/packages/cali/README.md
index 877e0b8..884fd68 100644
--- a/packages/cali/README.md
+++ b/packages/cali/README.md
@@ -1,17 +1,455 @@
# cali
-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 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:
-## Learn more
+- commands: `qa`, `review`, `perf-review`, `dev`
+- 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
+- additive `--prompt`
-Learn more about Cali on [GitHub](https://github.com/callstackincubator/cali).
+## Core Concepts
-## Special thanks
+- command: the user-facing role entrypoint such as `cali qa` or `cali review`
+- 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`
-Special thanks to [@jedirandy](https://github.com/jedirandy) for donating the name `cali` on `npm`!
+## Commands
-## Made with β€οΈ at Callstack
+- `cali qa`
+ - mobile QA pass with `agent-device`
+- `cali review`
+ - findings-first PR/repository review (experimental)
+- `cali perf-review`
+ - runtime performance review with `agent-device` and `react-devtools` (experimental)
+- `cali dev`
+ - repository-backed implementation flow (experimental)
-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!
+## Shared Context
-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! π₯
+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",
+ "webUrl": "https://github.com/acme/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"
+ }
+}
+```
+
+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
+
+```bash
+cali qa \
+ --local ios \
+ --artifact ./artifacts/MyApp.app \
+ --prompt "verify the onboarding copy on Screen B"
+```
+
+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 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
+
+```bash
+cali qa --platform ios --artifact ./artifacts/MyApp.app
+cali qa --platform android --artifact ./artifacts/app.apk
+cali review --context ./cali-context.json
+```
+
+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`.
+
+### Runtime performance review
+
+```bash
+cali perf-review \
+ --context ./cali-context.json \
+ --platform android \
+ --artifact ./artifacts/app.apk \
+ --prompt "profile the checkout flow"
+```
+
+### Repo-backed implementation
+
+```bash
+cali dev --context ./cali-context.json --prompt "implement issue 123"
+```
+
+## Provider Setup
+
+Cali supports two model auth paths:
+
+### AI Gateway
+
+```bash
+export AI_GATEWAY_API_KEY="your-ai-gateway-key"
+export QA_MODEL="openai/gpt-5.4-mini"
+```
+
+### Anthropic Direct
+
+```bash
+export ANTHROPIC_API_KEY="your-anthropic-api-key"
+export QA_MODEL="anthropic/claude-sonnet-4.6"
+```
+
+### `.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.
+
+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`
+
+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`, 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.
+
+## Required Skills
+
+Cali discovers local skills from:
+
+- `~/.cali/skills`
+- `./.cali/skills`
+- `./.agents/skills`
+- `~/.agents/skills`
+
+Required role skills:
+
+- `qa`: `agent-device`
+- `perf-review`: `agent-device`, `react-devtools`
+
+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.
+
+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
+npx skills add callstackincubator/agent-skills --agent codex --skill react-devtools --copy -y
+```
+
+## CI Providers
+
+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 directly inside the command.
+
+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
+
+Core CI command:
+
+```bash
+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
+cali export-ci --report ./artifacts/qa/report.json
+cali export-ci --android ./artifacts/android/report.json --ios ./artifacts/ios/report.json
+```
+
+### GitHub Actions
+
+Minimal GitHub Actions example:
+
+```yaml
+- name: Install required CLIs
+ run: npm i -g agent-device
+
+- 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 qa --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/ci-comment.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)
+
+### EAS Workflows
+
+Minimal EAS example:
+
+```yaml
+- id: install_agent_device
+ run: npm i -g agent-device
+
+- 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 qa --quiet
+
+- id: export_cali_ci
+ run: node ./packages/cali/dist/index.js export-ci --report ./artifacts/qa/report.json
+```
+
+Reference wrapper:
+- [`packages/cali/examples/eas-workflows/run-qa.sh`](./examples/eas-workflows/run-qa.sh)
+
+For multi-platform PR comments, export once from both platform reports:
+
+```bash
+cali export-ci \
+ --android ./artifacts/android/report.json \
+ --ios ./artifacts/ios/report.json \
+ --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/combined-comment/ci-comment.md
+```
+
+## Config
+
+Create `cali.config.ts` in the project root:
+
+```ts
+export default {
+ defaultCommand: 'qa',
+ workspaceRoot: '.',
+ skillPaths: ['.agents/skills'],
+ commands: {
+ qa: {
+ contextPath: './cali-context.json',
+ mobileDefaults: {
+ platform: 'android',
+ },
+ 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.
+
+## Tool Packs
+
+Built-in tool pack ids:
+
+- `skills`
+- `agent-device`
+- `repo-read`
+- `repo-write`
+- `react-devtools`
+
+Command defaults:
+
+- `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
+
+Built bundle:
+
+- `bun run qa -- --help`
+- `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 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:
+
+- `bun run dev:qa -- --help`
+- `bun run dev:review -- --help`
+- `bun run dev:perf-review -- --help`
+- `bun run dev:dev-command -- --help`
+
+## Outputs
+
+The file publisher writes:
+
+- `report.json`
+- `section.md`
+- `status.txt`
+- `summary.txt`
+- `top-issue.txt`
+- `screenshots.md`
+- `screenshots.json`
+- `publisher-manifest.json`
+
+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 contract:
+
+- `ci-comment.md`
+- `ci-output.json`
+
+Single-platform `ci-output.json` combines:
+
+- `kind`
+- `status`
+- `summary`
+- `topIssue`
+- `screenshots`
+
+Multi-platform `ci-output.json` combines:
+
+- `kind`
+- `status`
+- `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.
+
+## Repo Guide
+
+For implementation details, runtime contracts, and guidance for extending Cali with new commands, see [`AGENTS.md`](../../AGENTS.md).
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..1ba6a1a
--- /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 --quiet "$@"
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..1ba6a1a
--- /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 --quiet "$@"
diff --git a/packages/cali/package.json b/packages/cali/package.json
index 541ee19..463a92e 100644
--- a/packages/cali/package.json
+++ b/packages/cali/package.json
@@ -7,18 +7,30 @@
"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: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",
+ "perf-review": "node ./dist/index.js perf-review",
+ "dev:command": "node ./dist/index.js dev",
+ "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",
+ "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",
+ "export-ci": "node ./dist/index.js export-ci"
},
"dependencies": {
- "@ai-sdk/openai": "^1.0.2",
- "@clack/prompts": "^0.8.1",
- "ai": "^4.0.3",
- "cali-tools": "0.3.1",
- "chalk": "^5.3.0",
- "dedent": "^1.5.3",
+ "@ai-sdk/anthropic": "^3.0.64",
+ "@vercel/blob": "^0.27.0",
+ "ai": "^6.0.138",
+ "cac": "^7.0.0",
+ "cosmiconfig": "^9.0.1",
"dotenv": "^16.4.5",
"gradient-string": "^3.0.0",
- "zod": "^3.23.8"
+ "zod": "^4.3.6"
},
"bugs": {
"url": "https://github.com/callstackincubator/cali/issues"
@@ -36,6 +48,8 @@
"react-native",
"ai",
"agent",
+ "qa",
+ "mobile qa",
"vercel ai"
],
"files": [
@@ -44,7 +58,7 @@
"README.md"
],
"license": "MIT",
- "version": "0.3.1",
+ "version": "0.4.0-6",
"engines": {
"node": ">=22"
}
diff --git a/packages/cali/rslib.config.ts b/packages/cali/rslib.config.ts
index 9639e61..39d1a50 100644
--- a/packages/cali/rslib.config.ts
+++ b/packages/cali/rslib.config.ts
@@ -3,8 +3,8 @@ import { defineConfig } from '@rslib/core'
import { dependencies } from './package.json'
/**
- * We need to bundle `ai` dependency with the CLI, because we have custom patch for it.
- * We delete `ai` from dependencies that are passed as `externals`.
+ * Bundle `ai` with the CLI so the shipped binary uses the exact agent implementation
+ * that Cali was built and validated against.
*/
// @ts-ignore
delete dependencies.ai
diff --git a/packages/cali/src/cli.ts b/packages/cali/src/cli.ts
old mode 100755
new mode 100644
index f164c63..cd0725c
--- a/packages/cali/src/cli.ts
+++ b/packages/cali/src/cli.ts
@@ -2,184 +2,17 @@
import 'dotenv/config'
-import { createOpenAI } from '@ai-sdk/openai'
-import { log, outro, select, spinner, text } from '@clack/prompts'
-import { CoreMessage, generateText } from 'ai'
-import * as tools from 'cali-tools'
-import chalk from 'chalk'
-import dedent from 'dedent'
-import { retro } from 'gradient-string'
-import { z } from 'zod'
+import { runCli } from './cli/app.js'
-import { systemPrompt } from './prompt.js'
-import { getApiKey } from './utils.js'
-
-const MessageSchema = z.union([
- z.object({ type: z.literal('select'), content: z.string(), options: z.array(z.string()) }),
- z.object({ type: z.literal('question'), content: z.string() }),
- z.object({ type: z.literal('end'), content: z.string() }),
-])
-
-console.clear()
-
-process.on('uncaughtException', (error) => {
- console.error(chalk.red(error.message))
- console.log(chalk.gray(error.stack))
-})
-
-console.log(
- retro(`
- βββββββ ββββββ βββ βββ
- βββββββββββββββββββ βββ
- βββ βββββββββββ βββ
- βββ βββββββββββ βββ
- βββββββββββ ββββββββββββββ
- ββββββββββ ββββββββββββββ
-`)
-)
-
-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)
- }
-
- return [
- {
- role: 'system',
- content: 'What do you want to do today?',
- },
- {
- role: 'user',
- content: question,
- },
- ]
+async function main() {
+ await runCli()
}
-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
- }
- }
-
- spinner(message)
- }
- },
- })
-
- 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 })
- }
+main().catch((error) => {
+ const message = error instanceof Error ? error : new Error(String(error))
+ console.error(message.message)
+ if (message.stack) {
+ console.error(message.stack)
}
-
- // tbd: handle parsing errors
- const data = MessageSchema.parse(JSON.parse(response.text))
-
- 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 (typeof answer !== 'string') {
- messages = await startSession()
- continue
- }
-
- messages.push({
- role: 'user',
- content: answer as string,
- })
-}
+ process.exitCode = 1
+})
diff --git a/packages/cali/src/cli/app.ts b/packages/cali/src/cli/app.ts
new file mode 100644
index 0000000..e7ab1df
--- /dev/null
+++ b/packages/cali/src/cli/app.ts
@@ -0,0 +1,72 @@
+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 { qaCommandDefinition } from './qa.js'
+import { reviewCommandDefinition } from './review.js'
+
+function shouldPrintBanner(args: string[]) {
+ if (
+ args.includes('--quiet') ||
+ args.includes('--help') ||
+ args.includes('-h') ||
+ 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,
+ exportCiCommandDefinition,
+ ]) {
+ commandDefinition.register(cli)
+ }
+ cli.help()
+
+ return cli
+}
+
+export async function runCli(argv = process.argv) {
+ const cli = createCli()
+ const args = argv.slice(2)
+ const printBanner = shouldPrintBanner(args)
+ if (args.length === 0) {
+ const config = await loadCaliConfigFile(process.cwd())
+ if (config.defaultCommand) {
+ if (printBanner) {
+ printRetroBanner()
+ }
+ cli.parse([argv[0] ?? 'node', argv[1] ?? 'cali', config.defaultCommand])
+ return
+ }
+
+ if (printBanner) {
+ printRetroBanner()
+ }
+ }
+
+ if (args.length > 0 && printBanner) {
+ printRetroBanner()
+ }
+
+ 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..b0d3851
--- /dev/null
+++ b/packages/cali/src/cli/banner.ts
@@ -0,0 +1,15 @@
+import { retro } from 'gradient-string'
+
+const CALI_TEXT = `
+ βββββββ ββββββ βββ βββ
+ βββββββββββββββββββ βββ
+ βββ βββββββββββ βββ
+ βββ βββββββββββ βββ
+ βββββββββββ ββββββββββββββ
+ ββββββββββ ββββββββββββββ
+`
+
+export function printRetroBanner() {
+ console.log(retro(CALI_TEXT))
+ 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..382459f
--- /dev/null
+++ b/packages/cali/src/cli/dev.ts
@@ -0,0 +1,20 @@
+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) {
+ registerCommonCommandOptions(
+ cli.command('dev', 'Run the repository development role (experimental)')
+ )
+ .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/export-ci.ts b/packages/cali/src/cli/export-ci.ts
new file mode 100644
index 0000000..6bc0b87
--- /dev/null
+++ b/packages/cali/src/cli/export-ci.ts
@@ -0,0 +1,39 @@
+import type { CAC } from 'cac'
+
+import { exportCi } from '../commands/export-ci.js'
+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 outputs from one or more Cali reports')
+ .option('--report ', 'Path to report.json')
+ .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)
+ 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/perf-review.ts b/packages/cali/src/cli/perf-review.ts
new file mode 100644
index 0000000..680d6b6
--- /dev/null
+++ b/packages/cali/src/cli/perf-review.ts
@@ -0,0 +1,26 @@
+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) {
+ registerCommonMobileOptions(
+ cli.command('perf-review', 'Run the mobile performance review role (experimental)'),
+ 'Local mobile mode: android or ios'
+ )
+ .example(
+ '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
new file mode 100644
index 0000000..7c6ebbe
--- /dev/null
+++ b/packages/cali/src/cli/qa.ts
@@ -0,0 +1,25 @@
+import type { CAC } from 'cac'
+
+import { runQaCommand } from '../commands/qa.js'
+import {
+ type BaseCommandOptions,
+ normalizeBaseCommandCliOptions,
+ registerCommonMobileOptions,
+} from './shared.js'
+
+export const qaCommandDefinition = {
+ register(cli: CAC) {
+ registerCommonMobileOptions(
+ cli.command('qa', 'Run the mobile QA role'),
+ 'Local mobile mode: android or ios'
+ )
+ .example(
+ 'qa --local ios --artifact ./artifacts/MyApp.app --prompt "verify the onboarding copy on Screen B"'
+ )
+ .example('qa --platform ios --artifact ./artifacts/MyApp.app')
+ .example('qa --platform android --artifact ./artifacts/app.apk')
+ .action(async (options: unknown) => {
+ 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..f10f5dc
--- /dev/null
+++ b/packages/cali/src/cli/review.ts
@@ -0,0 +1,20 @@
+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) {
+ registerCommonCommandOptions(
+ cli.command('review', 'Run the repository review role (experimental)')
+ )
+ .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
new file mode 100644
index 0000000..188a94a
--- /dev/null
+++ b/packages/cali/src/cli/shared.ts
@@ -0,0 +1,134 @@
+import { CaliPlatformSchema } from '../config/schema.js'
+import type { CommandCliOptions } from '../runtime/types.js'
+import { normalizePlatform } from '../utils.js'
+
+export type BaseCommandOptions = {
+ ci?: string
+ local?: 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
+ 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,
+ localPlatform,
+ 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('--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')
+ .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, 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/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/export-ci.ts b/packages/cali/src/commands/export-ci.ts
new file mode 100644
index 0000000..805dc69
--- /dev/null
+++ b/packages/cali/src/commands/export-ci.ts
@@ -0,0 +1,309 @@
+import { readFile, writeFile } from 'node:fs/promises'
+import path from 'node:path'
+
+import { z } from 'zod'
+
+import {
+ buildScreenshotsMetadata,
+ getTopIssue,
+ renderGithubComment,
+ renderGithubMultiPlatformComment,
+} from '../report/ci.js'
+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
+ iosReportPath?: string
+ outputDir?: string
+}
+
+type SinglePlatformCiOutput = {
+ kind: 'single-platform'
+ status: CommandReport['overallStatus']
+ summary: string
+ topIssue: string
+ screenshots: ReturnType
+}
+
+type PlatformCiOutput = {
+ status: CommandReport['overallStatus']
+ summary: string
+ topIssue: string
+ screenshots: ReturnType
+}
+
+type MultiPlatformCiOutput = {
+ kind: 'multi-platform'
+ status: CommandReport['overallStatus'] | 'mixed'
+ summary: string
+ topIssue: string
+ platforms: {
+ android?: PlatformCiOutput
+ ios?: PlatformCiOutput
+ }
+}
+
+export async function exportCi(options: ExportCiOptions) {
+ const cwd = process.cwd()
+ const reports = await loadReports(cwd, options)
+ const outputDir = resolveOutputDirectory(cwd, options)
+
+ await ensureDirectory(outputDir)
+
+ 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,
+ summary: report.summary,
+ topIssue: getDefaultTopIssue(report),
+ screenshots: buildScreenshotsMetadata(report),
+ }
+}
+
+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('. ')
+
+ return {
+ kind: 'multi-platform',
+ 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,
+ 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 ExportableReportSchema.parse(JSON.parse(content)) as CommandReport
+}
diff --git a/packages/cali/src/commands/perf-review.ts b/packages/cali/src/commands/perf-review.ts
new file mode 100644
index 0000000..77cc749
--- /dev/null
+++ b/packages/cali/src/commands/perf-review.ts
@@ -0,0 +1,99 @@
+import type { PerfReviewReport, ScreenshotInfo } from '../report/types.js'
+import { runPerfReviewRole } from '../roles/perf-review.js'
+import { listScreenshots } from '../runtime/mobile.js'
+import type { CommandCliOptions } from '../runtime/types.js'
+import { humanizeScreenshotLabel } from '../utils.js'
+import { runMobileStructuredCommand } 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
+): Awaited>['reportInput'] {
+ 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) {
+ 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) : []
+
+ return composePerfReviewReport(
+ model,
+ context,
+ reportInput,
+ screenshots,
+ traces.agentDeviceTrace,
+ traces.reactDevtoolsTrace
+ )
+ },
+ runRole: async ({
+ context,
+ modelId,
+ tools,
+ availableSkillsPrompt,
+ preloadedSkillsPrompt,
+ extraInstructions,
+ prompt,
+ onAgentStep,
+ onAgentFinish,
+ }) =>
+ runPerfReviewRole({
+ context,
+ modelId,
+ tools,
+ availableSkillsPrompt,
+ preloadedSkillsPrompt,
+ extraInstructions,
+ prompt,
+ onAgentStep,
+ onAgentFinish,
+ }),
+ })
+}
diff --git a/packages/cali/src/commands/qa.ts b/packages/cali/src/commands/qa.ts
new file mode 100644
index 0000000..6abf834
--- /dev/null
+++ b/packages/cali/src/commands/qa.ts
@@ -0,0 +1,118 @@
+import type { QaReport, QaReportInput, ScreenshotInfo } from '../report/types.js'
+import { runQaMobileRole } from '../roles/qa-mobile.js'
+import { listScreenshots } from '../runtime/mobile.js'
+import type { CommandCliOptions } from '../runtime/types.js'
+import { humanizeScreenshotLabel } from '../utils.js'
+import { runMobileStructuredCommand } from './shared.js'
+
+function resolveAcceptanceCriteria(
+ context: Parameters[0]['context'],
+ prompt?: string
+) {
+ if ((context.qa?.acceptanceCriteria ?? []).length > 0) {
+ return context.qa?.acceptanceCriteria ?? []
+ }
+
+ if (context.pullRequest?.body?.trim()) {
+ return [context.pullRequest.body.trim()]
+ }
+
+ if (context.task?.body?.trim()) {
+ return [context.task.body.trim()]
+ }
+
+ if (prompt?.trim()) {
+ return [prompt.trim()]
+ }
+
+ return ['Run a lightweight mobile QA pass and report the observed result.']
+}
+
+function composeQaReport(
+ model: string,
+ context: Parameters[0]['context'],
+ reportInput: QaReportInput,
+ screenshots: Array>,
+ agentDeviceTrace: QaReport['agentDeviceTrace'],
+ acceptanceCriteriaUsed: string[]
+): QaReport {
+ return {
+ command: 'qa',
+ generatedAt: new Date().toISOString(),
+ model,
+ context,
+ overallStatus: reportInput.overallStatus,
+ summary: reportInput.summary,
+ checked: reportInput.checked ?? [],
+ issues: reportInput.issues ?? [],
+ nextSteps: reportInput.nextSteps ?? [],
+ screenshots: screenshots.map((screenshot) => ({
+ ...screenshot,
+ label: humanizeScreenshotLabel(screenshot.fileName),
+ })),
+ acceptanceCriteriaUsed,
+ environmentNotes: reportInput.environmentNotes ?? [],
+ 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.'],
+ environmentNotes: [summary],
+ }
+}
+
+export async function runQaCommand(cli: CommandCliOptions) {
+ let acceptanceCriteriaUsed: string[] | undefined
+
+ return runMobileStructuredCommand({
+ commandId: 'qa',
+ cli,
+ roleLabel: 'QA',
+ reportLabel: 'QA report',
+ createBlockedReport,
+ composeReport: async ({ model, context, reportInput, mobileContext, traces }) => {
+ const screenshots = mobileContext ? await listScreenshots(mobileContext.screenshotsDir) : []
+
+ return composeQaReport(
+ model,
+ context,
+ reportInput,
+ screenshots,
+ traces.agentDeviceTrace,
+ acceptanceCriteriaUsed ?? resolveAcceptanceCriteria(context, cli.prompt)
+ )
+ },
+ runRole: async ({
+ context,
+ modelId,
+ tools,
+ availableSkillsPrompt,
+ preloadedSkillsPrompt,
+ extraInstructions,
+ prompt,
+ onAgentStep,
+ onAgentFinish,
+ }) => {
+ acceptanceCriteriaUsed = resolveAcceptanceCriteria(context, cli.prompt)
+
+ return runQaMobileRole({
+ context,
+ modelId,
+ tools,
+ availableSkillsPrompt,
+ preloadedSkillsPrompt,
+ extraInstructions,
+ prompt,
+ acceptanceCriteriaUsed,
+ onAgentStep,
+ onAgentFinish,
+ })
+ },
+ })
+}
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..45c1f23
--- /dev/null
+++ b/packages/cali/src/commands/shared.ts
@@ -0,0 +1,459 @@
+import path from 'node:path'
+
+import { tool } from 'ai'
+import { z } from 'zod'
+
+import { loadCommandConfig, resolveDefaultLocalPlatform } from '../config/load.js'
+import type { ToolPackName } from '../config/schema.js'
+import type { CommandReport } from '../report/types.js'
+import { buildCiContext, detectCiProvider } from '../runtime/ci-context.js'
+import { resolveCommandContext } from '../runtime/context.js'
+import {
+ bootstrapMobileApp,
+ closeAgentDeviceSession,
+ createAgentDeviceSessionName,
+ prepareMobileOutputDirectories,
+ resolveMobileRuntimeContext,
+} from '../runtime/mobile.js'
+import { publishReport } from '../runtime/publishers.js'
+import { prepareToolPacks } from '../runtime/tool-packs.js'
+import type {
+ CaliContext,
+ CommandCliOptions,
+ CommandId,
+ CommandResolvedConfig,
+ MobileCommandRuntimeContext,
+ ToolTraceEntry,
+} 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()
+ const ciProvider = cli.localPlatform ? undefined : (cli.ciProvider ?? detectCiProvider())
+ printPhase('Resolving config')
+
+ const config = await loadCommandConfig({
+ commandId,
+ cwd,
+ configPath: cli.configPath,
+ localPlatform: cli.localPlatform,
+ ciProvider,
+ model: cli.model,
+ })
+ const injectedContext = ciProvider
+ ? await buildCiContext(commandId, cwd, 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,
+ ciProvider,
+ config,
+ context,
+ }
+}
+
+function createFallbackConfig(
+ commandId: CommandId,
+ cwd: string,
+ cli: CommandCliOptions
+): CommandResolvedConfig {
+ const ciProvider = cli.localPlatform ? undefined : (cli.ciProvider ?? detectCiProvider())
+ return {
+ 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: {
+ platform: ciProvider
+ ? undefined
+ : (cli.localPlatform ?? resolveDefaultLocalPlatform(commandId)),
+ },
+ }
+}
+
+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 ?? cli.localPlatform
+
+ 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.`,
+ 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)
+}
+
+type MobileRoleRunArgs = 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 = process.cwd()
+ let config: CommandResolvedConfig | undefined
+ let context: CaliContext | undefined
+
+ let reportInput: TReportInput
+ let mobileContext: MobileCommandRuntimeContext | undefined
+ let sessionName: string | undefined
+ let traces: {
+ agentDeviceTrace: ToolTraceEntry[]
+ reactDevtoolsTrace: ToolTraceEntry[]
+ } = {
+ agentDeviceTrace: [],
+ reactDevtoolsTrace: [],
+ }
+
+ try {
+ const loaded = await loadRunContext(commandId, cli)
+ const localMode = !loaded.ciProvider
+ config = loaded.config
+ context = loaded.context
+
+ mobileContext = await resolveMobileRuntimeContext(commandId, localMode, 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, localMode, 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 resolvedConfig = config ?? createFallbackConfig(commandId, cwd, cli)
+ const resolvedContext = context ?? createFallbackContext(commandId, cwd, resolvedConfig, cli)
+ const report = await composeReport({
+ model: resolvedConfig.model,
+ context: resolvedContext,
+ reportInput,
+ mobileContext,
+ traces,
+ })
+
+ const outputPublishers = Array.from(
+ new Set([
+ 'file',
+ ...resolvedConfig.outputPublishers,
+ ])
+ )
+ printPhase('Publishing report', outputPublishers.join(', '))
+ const publishedReport = await publishReport({
+ report,
+ publishers: outputPublishers,
+ })
+
+ printFinalReport(cwd, commandId, reportLabel, publishedReport)
+}
diff --git a/packages/cali/src/config/load.ts b/packages/cali/src/config/load.ts
new file mode 100644
index 0000000..5a621fa
--- /dev/null
+++ b/packages/cali/src/config/load.ts
@@ -0,0 +1,208 @@
+import { existsSync } from 'node:fs'
+import { homedir } from 'node:os'
+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'
+import type {
+ CaliCommandConfig,
+ CaliConfig,
+ CaliPlatform,
+ CommandId,
+ PublisherName,
+} from './schema.js'
+import { CaliConfigSchema } from './schema.js'
+
+type LoadCommandConfigOptions = {
+ commandId: CommandId
+ cwd: string
+ configPath?: string
+ localPlatform?: CaliPlatform
+ ciProvider?: CiProvider
+ model?: string
+}
+
+export function resolveDefaultLocalPlatform(commandId: CommandId): CaliPlatform | undefined {
+ switch (commandId) {
+ case 'qa':
+ case 'perf-review':
+ return 'android'
+ case 'review':
+ case 'dev':
+ default:
+ return undefined
+ }
+}
+
+function getBuiltInSkillPaths(cwd: string) {
+ return [path.join(cwd, '.agents', 'skills'), path.join(homedir(), '.agents', 'skills')]
+}
+
+const MOBILE_CI_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.',
+ ],
+}
+
+function createLocalQaDefaults(platform: CaliPlatform): CaliCommandConfig {
+ return {
+ enabledToolPacks: ['skills', 'agent-device'],
+ outputPublishers: ['blob', 'file'],
+ mobileDefaults: {
+ platform,
+ },
+ extraInstructions: [
+ `This is a local ${platform} QA run. Keep the flow lightweight and focus on the highest-signal UI paths.`,
+ ],
+ }
+}
+
+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':
+ 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':
+ 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 {
+ enabledToolPacks: ['repo-read', 'skills'],
+ outputPublishers: commonOutputPublishers,
+ }
+ case 'dev':
+ return {
+ 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] ?? {}
+}
+
+function mergeCommandConfig(
+ base: CaliCommandConfig,
+ override: CaliCommandConfig
+): CaliCommandConfig {
+ return {
+ contextPath: override.contextPath ?? base.contextPath,
+ 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,
+ },
+ }
+}
+
+export async function loadCaliConfigFile(cwd: string, explicitPath?: string): Promise {
+ 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 CaliConfigSchema.parse(loaded?.config ?? {})
+ }
+
+ const loaded = await explorer.search(cwd)
+
+ return CaliConfigSchema.parse(loaded?.config ?? {})
+}
+
+export async function loadCommandConfig(
+ options: LoadCommandConfigOptions
+): Promise {
+ const { commandId, cwd, configPath, localPlatform, ciProvider, model } = options
+ const fileConfig = await loadCaliConfigFile(cwd, configPath)
+ 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 {
+ workspaceRoot: fileConfig.workspaceRoot
+ ? resolveFromCwd(cwd, fileConfig.workspaceRoot)
+ : undefined,
+ contextPath: merged.contextPath ? resolveFromCwd(cwd, merged.contextPath) : undefined,
+ skillPaths: uniqueStrings([...(fileConfig.skillPaths ?? []), ...getBuiltInSkillPaths(cwd)]),
+ enabledToolPacks: merged.enabledToolPacks ?? [],
+ outputPublishers: merged.outputPublishers ?? ['file'],
+ extraInstructions: asArray(merged.extraInstructions),
+ 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
new file mode 100644
index 0000000..c1b1d39
--- /dev/null
+++ b/packages/cali/src/config/schema.ts
@@ -0,0 +1,61 @@
+import { z } from 'zod'
+
+const ToolPackNameSchema = z.enum([
+ 'skills',
+ 'agent-device',
+ 'repo-read',
+ 'repo-write',
+ 'react-devtools',
+])
+const PublisherNameSchema = z.enum(['file', 'blob'])
+export const COMMAND_IDS = ['qa', 'review', 'perf-review', 'dev'] as const
+const CommandIdSchema = z.enum(COMMAND_IDS)
+export const CaliPlatformSchema = z.enum(['android', 'ios'])
+
+const StringArraySchema = z.union([z.string(), z.array(z.string())]).optional()
+
+const MobileDefaultsSchema = z
+ .object({
+ platform: CaliPlatformSchema.optional(),
+ 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,
+ })
+ .strict()
+
+export const CaliConfigSchema = z
+ .object({
+ defaultCommand: CommandIdSchema.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 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/docs.ts b/packages/cali/src/docs.ts
new file mode 100644
index 0000000..11cf95f
--- /dev/null
+++ b/packages/cali/src/docs.ts
@@ -0,0 +1,10 @@
+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`,
+ requiredSkills: `${README_URL}#required-skills`,
+ ciProviders: `${README_URL}#ci-providers`,
+ ciHelpers: `${README_URL}#ci-helpers`,
+} as const
diff --git a/packages/cali/src/model.ts b/packages/cali/src/model.ts
new file mode 100644
index 0000000..38dc788
--- /dev/null
+++ b/packages/cali/src/model.ts
@@ -0,0 +1,34 @@
+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) {
+ return modelId.startsWith('anthropic/') ? modelId.slice('anthropic/'.length) : modelId
+}
+
+export function createQaAgentModel(modelId = process.env.QA_MODEL ?? DEFAULT_QA_MODEL_ID) {
+ if (process.env.AI_GATEWAY_API_KEY) {
+ return modelId
+ }
+
+ const apiKey = process.env.ANTHROPIC_API_KEY
+ const authToken = process.env.ANTHROPIC_AUTH_TOKEN
+ if (apiKey || authToken) {
+ const anthropic = createAnthropic({
+ ...(apiKey ? { apiKey } : {}),
+ ...(authToken ? { authToken } : {}),
+ })
+
+ return anthropic(stripAnthropicPrefix(modelId))
+ }
+
+ throw new Error(
+ [
+ 'Missing AI credentials.',
+ '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/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/ci.ts b/packages/cali/src/report/ci.ts
new file mode 100644
index 0000000..5a74cee
--- /dev/null
+++ b/packages/cali/src/report/ci.ts
@@ -0,0 +1,319 @@
+import { sanitizeUrl } from '../runtime/context-repo.js'
+import { renderCommandSection } from './render.js'
+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') {
+ 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') {
+ 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) =>
+ sanitizeUrl(screenshot.blobUrl)
+ ? `- [${screenshot.label}](${sanitizeUrl(screenshot.blobUrl)})`
+ : `- ${screenshot.label}: ${screenshot.fileName}`
+ )
+ .join('\n')}\n`
+}
+
+export function renderScreenshotsCell(report?: CommandReport) {
+ if (!report || !hasScreenshots(report) || report.screenshots.length === 0) {
+ return 'N/A'
+ }
+
+ return report.screenshots
+ .map((screenshot) => renderScreenshotCellItem(screenshot))
+ .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 : []
+}
+
+function formatStatusForTable(report?: CommandReport) {
+ return report?.overallStatus ?? 'N/A'
+}
+
+function formatTopIssueForTable(report?: CommandReport) {
+ 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) {
+ 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,
+ }
+}
+
+function escapeHtml(value: string) {
+ return value
+ .replaceAll('&', '&')
+ .replaceAll('<', '<')
+ .replaceAll('>', '>')
+ .replaceAll('"', '"')
+}
+
+function renderScreenshotCellItem(
+ screenshot: ScreenshotInfo,
+ options: { showLabel?: boolean } = {}
+) {
+ const safeLabel = escapeHtml(screenshot.label)
+ const safeUrl = sanitizeUrl(screenshot.blobUrl)
+
+ if (!safeUrl) {
+ return options.showLabel
+ ? `**${safeLabel}**
${escapeHtml(screenshot.fileName)}`
+ : escapeHtml(screenshot.fileName)
+ }
+
+ const image = `
`
+ return options.showLabel ? `**${safeLabel}**
${image}` : image
+}
+
+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 createScreenshotColumns(
+ reports: Array<{ platformLabel: string; report?: CommandReport }>
+) {
+ const columns = new Map<
+ string,
+ {
+ label: string
+ byPlatform: Map
+ order: number
+ }
+ >()
+ const platformLabels = reports.map((report) => report.platformLabel)
+
+ for (const { platformLabel, report } of reports) {
+ const screenshots = getScreenshots(report)
+ for (const screenshot of screenshots) {
+ const key = getScreenshotGroupLabel(screenshot)
+ const column = columns.get(key) ?? {
+ label: screenshot.label,
+ byPlatform: new Map(),
+ order: columns.size,
+ }
+
+ const platformScreenshots = column.byPlatform.get(platformLabel) ?? []
+ platformScreenshots.push(screenshot)
+ column.byPlatform.set(platformLabel, platformScreenshots)
+ columns.set(key, column)
+ }
+ }
+
+ return {
+ platformLabels,
+ columns: [...columns.values()].sort((left, right) => left.order - right.order),
+ }
+}
+
+function renderScreenshotsTable(reports: Array<{ platformLabel: string; report?: CommandReport }>) {
+ const { platformLabels, columns } = createScreenshotColumns(reports)
+ if (columns.length === 0) {
+ return 'No screenshots recorded.'
+ }
+
+ return [
+ `| 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')
+}
+
+function renderScreenshotGroupCell(screenshots: ScreenshotInfo[]) {
+ if (screenshots.length === 0) {
+ return 'N/A'
+ }
+
+ return screenshots
+ .map((screenshot) =>
+ renderScreenshotCellItem(screenshot, { showLabel: screenshots.length > 1 })
+ )
+ .join('
')
+}
+
+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',
+ '',
+ renderScreenshotsTable([{ platformLabel: getPlatformLabel(report), report }])
+ )
+ }
+
+ 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`
+}
+
+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',
+ '',
+ renderScreenshotsTable([
+ { platformLabel: 'Android', report: android },
+ { platformLabel: 'iOS', report: ios },
+ ])
+ )
+
+ if (android) {
+ lines.push(
+ '',
+ '',
+ '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/blob.ts b/packages/cali/src/report/publishers/blob.ts
new file mode 100644
index 0000000..6f0fb8e
--- /dev/null
+++ b/packages/cali/src/report/publishers/blob.ts
@@ -0,0 +1,116 @@
+import { readFile } from 'node:fs/promises'
+
+import { put } from '@vercel/blob'
+
+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 {
+ const token = process.env.BLOB_READ_WRITE_TOKEN
+ 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(
+ 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 = [
+ 'cali',
+ 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, {
+ 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,
+ }
+ }
+ })
+ )
+
+ 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: {
+ ...report,
+ screenshots,
+ } satisfies CommandReport,
+ publisherResult,
+ }
+}
diff --git a/packages/cali/src/report/publishers/file.ts b/packages/cali/src/report/publishers/file.ts
new file mode 100644
index 0000000..f1444ea
--- /dev/null
+++ b/packages/cali/src/report/publishers/file.ts
@@ -0,0 +1,142 @@
+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, renderScreenshotsMarkdown } from '../ci.js'
+import { renderCommandSection } from '../render.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
+ publisherResults: ReportPublisherResult[]
+}
+
+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 = createSafePublishedReport(report, publisherResults)
+ const topIssue =
+ getTopIssue(finalReport) ?? (finalReport.overallStatus === 'passed' ? '' : finalReport.summary)
+
+ await ensureDirectory(outputDir)
+ await writeFile(
+ 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, '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, '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, 'publisher-manifest.json'),
+ `${JSON.stringify(publisherResults, null, 2)}\n`,
+ 'utf8'
+ )
+
+ return {
+ ...report,
+ publisherResults,
+ } satisfies CommandReport
+}
diff --git a/packages/cali/src/report/render.ts b/packages/cali/src/report/render.ts
new file mode 100644
index 0000000..ee49d1d
--- /dev/null
+++ b/packages/cali/src/report/render.ts
@@ -0,0 +1,199 @@
+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}`)
+ }
+}
+
+function appendMetadata(lines: string[], report: CommandReport) {
+ lines.push(
+ '',
+ '### Metadata',
+ `- Command: \`${report.command}\``,
+ `- Workspace: \`${report.context.workspaceRoot}\``
+ )
+
+ if (report.context.repository?.name) {
+ lines.push(
+ `- Repository: \`${report.context.repository.owner ?? 'unknown'}/${report.context.repository.name}\``
+ )
+ }
+
+ 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 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 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) {
+ lines.push(
+ screenshot.blobUrl
+ ? `- [${screenshot.label}](${screenshot.blobUrl})`
+ : `- ${screenshot.label}: ${screenshot.fileName}`
+ )
+ }
+ }
+}
+
+function renderReviewDetails(lines: string[], report: ReviewReport) {
+ lines.push('', '### Findings')
+
+ if (report.findings.length === 0) {
+ lines.push('- No concrete findings.')
+ return
+ }
+
+ 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.'
+ )
+ appendPublishers(lines, report)
+ appendMetadata(lines, report)
+
+ 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..f7dd27c
--- /dev/null
+++ b/packages/cali/src/report/types.ts
@@ -0,0 +1,143 @@
+import type { CaliContext, CommandId, ToolTraceEntry } from '../runtime/types.js'
+
+export type ResultStatus = 'passed' | 'failed' | 'blocked' | 'not_tested' | 'unsure'
+
+export type ScreenshotInfo = {
+ fileName: string
+ absolutePath?: string
+ bytes: number
+ label: string
+ blobUrl?: string
+ blobDownloadUrl?: string
+ blobPathname?: string
+ uploadError?: string
+}
+
+export type ReportPublisherResult = {
+ publisher: string
+ status: 'ok' | 'skipped' | 'failed'
+ 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 = {
+ overallStatus: ResultStatus
+ summary: string
+ checked?: string[]
+ issues?: string[]
+ nextSteps?: string[]
+ environmentNotes?: string[]
+}
+
+export type QaReport = BaseCommandReport & {
+ command: 'qa'
+ checked: string[]
+ issues: string[]
+ nextSteps?: string[]
+ screenshots: ScreenshotInfo[]
+ acceptanceCriteriaUsed: string[]
+ environmentNotes?: 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 & {
+ command: 'review'
+ findings: ReviewFinding[]
+ strengths: string[]
+ validationGaps: string[]
+ nextSteps?: string[]
+ environmentNotes?: 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 & {
+ 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
+ summary: string
+ filesChanged?: string[]
+ validationsRun?: string[]
+ followUps?: string[]
+ patchStatus?: 'applied' | 'planned' | 'blocked' | 'partial'
+ nextSteps?: string[]
+ environmentNotes?: string[]
+}
+
+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/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
new file mode 100644
index 0000000..ffe6ef1
--- /dev/null
+++ b/packages/cali/src/roles/qa-mobile.ts
@@ -0,0 +1,165 @@
+import { z } from 'zod'
+
+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: CaliContext
+ modelId: string
+ tools: Record
+ availableSkillsPrompt: string
+ preloadedSkillsPrompt: string
+ extraInstructions: string[]
+ prompt?: string
+ acceptanceCriteriaUsed: string[]
+ onAgentStep?: (event: {
+ stepNumber: number
+ finishReason: string
+ toolNames: string[]
+ totalTokens?: number
+ }) => void
+ onAgentFinish?: (event: { stepCount: number; finishReason: string; totalTokens?: number }) => void
+}
+
+type QaMobileRoleResult = {
+ reportInput: QaReportInput
+}
+
+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(),
+ checked: z.array(z.string()).optional(),
+ issues: z.array(z.string()).optional(),
+ nextSteps: z.array(z.string()).optional(),
+ environmentNotes: z.array(z.string()).optional(),
+})
+
+function buildPrompt(
+ context: CaliContext,
+ acceptanceCriteriaUsed: string[],
+ availableSkillsPrompt: string,
+ preloadedSkillsPrompt: string,
+ extraInstructions: string[],
+ prompt?: string
+) {
+ 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.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.',
+ '',
+ 'Acceptance criteria used:',
+ ...acceptanceCriteriaUsed.map((criterion) => `- ${criterion}`),
+ '',
+ preloadedSkillsPrompt,
+ '',
+ availableSkillsPrompt,
+ ]
+
+ if (extraInstructions.length > 0) {
+ lines.push('', 'Extra instructions:')
+ for (const instruction of extraInstructions) {
+ lines.push(`- ${instruction}`)
+ }
+ }
+
+ if (prompt?.trim()) {
+ lines.push('', 'Task-specific focus:')
+ lines.push(prompt.trim())
+ }
+
+ lines.push(
+ '',
+ `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.',
+ '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 lines.join('\n')
+}
+
+export async function runQaMobileRole(
+ options: RunQaMobileRoleOptions
+): Promise {
+ const {
+ context,
+ modelId,
+ tools,
+ availableSkillsPrompt,
+ preloadedSkillsPrompt,
+ extraInstructions,
+ prompt,
+ acceptanceCriteriaUsed,
+ onAgentStep,
+ onAgentFinish,
+ } = options
+
+ const instructions = [
+ `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, 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.',
+ ]
+ .concat(extraInstructions)
+ .join('\n')
+
+ const result = await runToolLoopRole({
+ modelId,
+ instructions,
+ prompt: buildPrompt(
+ context,
+ acceptanceCriteriaUsed,
+ availableSkillsPrompt,
+ preloadedSkillsPrompt,
+ extraInstructions,
+ prompt
+ ),
+ tools,
+ reportSchema: WRITE_REPORT_INPUT_SCHEMA,
+ reportDescription: 'Persist the final QA summary, findings, and environment notes.',
+ reserveReportAfterTool: 'agent_device',
+ createMissingReport: createMissingQaReport,
+ onAgentStep,
+ onAgentFinish,
+ })
+
+ return {
+ 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/ci-context.ts b/packages/cali/src/runtime/ci-context.ts
new file mode 100644
index 0000000..1b5d811
--- /dev/null
+++ b/packages/cali/src/runtime/ci-context.ts
@@ -0,0 +1,261 @@
+import { readFile } from 'node:fs/promises'
+
+import { DOCS_URLS } from '../docs.js'
+import { detectRepositoryContext, sanitizeUrl } from './context-repo.js'
+import type { CaliContext, CaliPlatform, CommandId } from './types.js'
+
+export type CiProvider = 'github-actions' | 'eas'
+
+type BuildCiContextOptions = {
+ platform?: CaliPlatform
+ 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
+}
+
+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
+}
+
+function createCiContextError(message: string) {
+ return new Error([message, `Docs: ${DOCS_URLS.ciProviders}`].join('\n\n'))
+}
+
+async function loadJsonFile(filePath: string) {
+ const content = await readFile(filePath, 'utf8')
+ return JSON.parse(content)
+}
+
+function normalizePullRequest(pullRequest: any): CaliContext['pullRequest'] {
+ 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 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,
+ }
+}
+
+function readPullRequestJson(rawPrJson: string | undefined) {
+ if (!rawPrJson) {
+ return undefined
+ }
+
+ try {
+ return JSON.parse(rawPrJson)
+ } catch {
+ throw createCiContextError('Failed to parse PR_JSON as JSON.')
+ }
+}
+
+function createCommonContext(options: {
+ commandId: CommandId
+ workspaceRoot: string
+ repository?: CaliContext['repository']
+ pullRequest?: CaliContext['pullRequest']
+ platform?: CaliPlatform
+ artifactPath?: string
+ appId?: string
+ deviceName?: string
+ outputDir: string
+ buildId?: string
+ workflowUrl?: string
+ logsUrl?: string
+}): Partial {
+ return {
+ workspaceRoot: options.workspaceRoot,
+ repository: options.repository,
+ pullRequest: options.pullRequest,
+ 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
+ ? {
+ id: options.buildId,
+ workflowUrl: sanitizeUrl(options.workflowUrl, { stripQuery: true }),
+ logsUrl: sanitizeUrl(options.logsUrl, { stripQuery: true }),
+ }
+ : undefined,
+ output: {
+ outputDir: options.outputDir,
+ },
+ }
+}
+
+async function buildGithubActionsContext(
+ commandId: CommandId,
+ cwd: string,
+ options: BuildCiContextOptions
+): Promise> {
+ const eventPath = readOptionalEnv('GITHUB_EVENT_PATH')
+ if (!eventPath) {
+ throw createCiContextError('GitHub Actions CI mode 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/${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')
+ 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 ((commandId === 'qa' || commandId === 'perf-review') && !platform) {
+ throw createCiContextError(
+ 'GitHub Actions CI mode requires CALI_PLATFORM or --platform for mobile commands.'
+ )
+ }
+
+ 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,
+ ...githubRepository,
+ },
+ pullRequest: normalizePullRequest(event?.pull_request),
+ platform,
+ artifactPath,
+ appId: options.appId ?? readOptionalEnv('CALI_APP_ID'),
+ deviceName: options.deviceName ?? readOptionalEnv('CALI_DEVICE_NAME'),
+ outputDir,
+ buildId,
+ workflowUrl,
+ logsUrl: options.logsUrl,
+ })
+}
+
+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/${commandId}`
+ const artifactPath = options.artifactPath ?? readOptionalEnv('APP_PATH')
+ const platform = options.platform ?? normalizePlatform(readOptionalEnv('QA_PLATFORM'))
+
+ if ((commandId === 'qa' || commandId === 'perf-review') && !artifactPath) {
+ throw createCiContextError('EAS CI mode requires APP_PATH or --artifact for mobile commands.')
+ }
+
+ 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,
+ ...githubRepository,
+ },
+ pullRequest: normalizePullRequest(readPullRequestJson(readOptionalEnv('PR_JSON'))),
+ platform,
+ artifactPath,
+ appId: options.appId ?? readOptionalEnv('APPLICATION_ID'),
+ deviceName: options.deviceName ?? readOptionalEnv('CALI_DEVICE_NAME'),
+ outputDir,
+ buildId: options.buildId ?? readOptionalEnv('BUILD_ID'),
+ workflowUrl: options.workflowUrl ?? readOptionalEnv('WORKFLOW_URL'),
+ logsUrl: options.logsUrl ?? readOptionalEnv('LOGS_URL'),
+ })
+}
+
+export async function buildCiContext(
+ commandId: CommandId,
+ cwd: string,
+ provider: CiProvider,
+ options: BuildCiContextOptions
+): Promise> {
+ if (provider === 'github-actions') {
+ return buildGithubActionsContext(commandId, cwd, options)
+ }
+
+ return buildEasContext(commandId, cwd, options)
+}
diff --git a/packages/cali/src/runtime/context-file.ts b/packages/cali/src/runtime/context-file.ts
new file mode 100644
index 0000000..2c3aa91
--- /dev/null
+++ b/packages/cali/src/runtime/context-file.ts
@@ -0,0 +1,207 @@
+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()
+
+const RepositorySchema = z
+ .object({
+ provider: z.string().optional(),
+ 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(),
+ })
+ .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()
+
+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
+}
+
+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
+ ? (() => {
+ 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,
+ }
+ : undefined,
+ mobile: parsed.mobile
+ ? {
+ ...parsed.mobile,
+ artifactPath: resolveOptionalPath(cwd, parsed.mobile.artifactPath),
+ }
+ : undefined,
+ 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),
+ },
+ 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
+): Promise> {
+ if (!contextPath) {
+ return {}
+ }
+
+ const absolutePath = resolveFromCwd(cwd, contextPath)
+ const content = await readFile(absolutePath, 'utf8')
+ return createContextFileSchema(cwd).parse(JSON.parse(content))
+}
diff --git a/packages/cali/src/runtime/context-repo.ts b/packages/cali/src/runtime/context-repo.ts
new file mode 100644
index 0000000..3473ea5
--- /dev/null
+++ b/packages/cali/src/runtime/context-repo.ts
@@ -0,0 +1,116 @@
+import { runCommand } from '../utils.js'
+import type { RepositoryContext } from './types.js'
+
+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 {
+ const strippedCredentials = rawUrl
+ .replace(/^(https?:\/\/)[^@]+@/, '$1')
+ .replace(/^(ssh:\/\/)[^@]+@/, '$1')
+ if (options.stripQuery) {
+ return strippedCredentials.replace(/[?#].*$/, '')
+ }
+ return strippedCredentials
+ }
+}
+
+export function parseRepositoryUrl(
+ remoteUrl: string | undefined
+): Pick {
+ if (!remoteUrl) {
+ return {}
+ }
+
+ const httpsMatch = remoteUrl.match(/^https?:\/\/([^/]+)\/([^/]+)\/([^/]+?)(?:\.git)?$/)
+ if (httpsMatch) {
+ return {
+ provider: httpsMatch[1],
+ owner: httpsMatch[2],
+ name: httpsMatch[3],
+ webUrl: `https://${httpsMatch[1]}/${httpsMatch[2]}/${httpsMatch[3]}`,
+ }
+ }
+
+ const sshMatch = remoteUrl.match(/^git@([^:]+):([^/]+)\/(.+?)(?:\.git)?$/)
+ if (sshMatch) {
+ return {
+ provider: sshMatch[1],
+ owner: sshMatch[2],
+ name: sshMatch[3],
+ webUrl: `https://${sshMatch[1]}/${sshMatch[2]}/${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
+}
+
+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 = sanitizeUrl(await readGitValue(workspaceRoot, ['remote', 'get-url', 'origin']))
+ const repository = {
+ ...parseRepositoryUrl(remoteUrl),
+ defaultBranch: await readDefaultBranch(workspaceRoot),
+ currentBranch: await readGitValue(workspaceRoot, ['branch', '--show-current']),
+ commitSha: await readGitValue(workspaceRoot, ['rev-parse', 'HEAD']),
+ }
+
+ if (!repository.currentBranch && !repository.commitSha && !repository.owner && !repository.name) {
+ return { workspaceRoot }
+ }
+
+ 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..fe8b1b4
--- /dev/null
+++ b/packages/cali/src/runtime/context.ts
@@ -0,0 +1,229 @@
+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,
+ 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:
+ 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,
+ }
+}
+
+export async function resolveCommandContext(
+ commandId: CommandId,
+ cwd: string,
+ config: CommandResolvedConfig,
+ 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
+
+ const merged = mergeContext(
+ {
+ workspaceRoot,
+ repository: repositoryInfo.repository,
+ output: {},
+ },
+ 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
new file mode 100644
index 0000000..fffac20
--- /dev/null
+++ b/packages/cali/src/runtime/mobile.ts
@@ -0,0 +1,744 @@
+import { createHash } from 'node:crypto'
+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 { 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'
+
+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
+
+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]
+
+ if (context.deviceName) {
+ args.push('--device', context.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}.`
+}
+
+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] = {}
+) {
+ return runCommand('agent-device', [command, ...args], options)
+}
+
+async function runAgentDeviceSessionCommand(
+ sessionName: string,
+ command: string,
+ args: string[],
+ options: Parameters[2] = {}
+) {
+ 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 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,
+ binaryStdout: true,
+ })
+ if (!result.ok || !result.stdoutBuffer || result.stdoutBuffer.length === 0) {
+ return undefined
+ }
+
+ 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]
+}
+
+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) {
+ 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(aaptPath, ['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 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) {
+ 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 findAppBundleAtDepth(path.join(directory, entry.name), depth + 1)
+ 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)
+ }
+
+ 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 mode requires --device when more than one Android target is booted.\n\nBooted targets:\n- ${bootedDevices.join('\n- ')}`
+ )
+ }
+
+ throw new Error(
+ 'Local Android mode 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 mode requires --device when more than one iOS simulator is booted.\n\nBooted simulators:\n- ${bootedSimulators.join('\n- ')}`
+ )
+ }
+
+ throw new Error('Local iOS mode 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', buildSessionOpenArgs(context), 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.`
+ )
+}
+
+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,
+ localMode: boolean,
+ 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
+
+ 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.`)
+ }
+
+ 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(normalizedArtifactPath)}.`
+ )
+ }
+
+ let deviceName = context.mobile?.deviceName
+ if (localMode && platform === 'ios') {
+ deviceName = await resolveLocalIosDeviceName(deviceName)
+ } else if (localMode && platform === 'android') {
+ deviceName = await resolveLocalAndroidDeviceName(deviceName)
+ }
+
+ return {
+ platform,
+ artifactPath: normalizedArtifactPath,
+ appId,
+ deviceName,
+ outputDir,
+ screenshotsDir: context.output.screenshotsDir ?? path.join(outputDir, 'screenshots'),
+ }
+}
+
+export async function bootstrapMobileApp(
+ commandId: 'qa' | 'perf-review',
+ localMode: boolean,
+ context: MobileCommandRuntimeContext,
+ sessionName: string
+) {
+ await ensureTargetReady(context)
+
+ if (localMode) {
+ 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 & { mtimeMs: number }> = []
+ 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,
+ mtimeMs: fileStat.mtimeMs,
+ })
+ }
+
+ 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/runtime/publishers.ts b/packages/cali/src/runtime/publishers.ts
new file mode 100644
index 0000000..59bc9f5
--- /dev/null
+++ b/packages/cali/src/runtime/publishers.ts
@@ -0,0 +1,62 @@
+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') {
+ const blobResult = await publishBlobReport({ report: currentReport })
+ currentReport = blobResult.report
+ publisherResults.push(blobResult.publisherResult)
+ continue
+ }
+
+ publisherResults.push({
+ publisher,
+ status: 'ok',
+ })
+ } catch (unknownError) {
+ const error = unknownError instanceof Error ? unknownError : new Error(String(unknownError))
+ publisherResults.push({
+ publisher,
+ status: 'failed',
+ detail: error.message,
+ })
+ }
+ }
+
+ if (publishers.includes('file')) {
+ currentReport = await publishFileReport({
+ report: currentReport,
+ publisherResults: [
+ ...publisherResults,
+ {
+ publisher: 'file',
+ status: 'ok',
+ },
+ ],
+ })
+ } 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..8adf64a
--- /dev/null
+++ b/packages/cali/src/runtime/tool-loop-role.ts
@@ -0,0 +1,115 @@
+import { hasToolCall, stepCountIs, tool, ToolLoopAgent } from 'ai'
+import type { ZodType } from 'zod'
+import { z } 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 ({ stepNumber }) => {
+ if (!reserveReportAfterTool || reportInput) {
+ return {}
+ }
+
+ if (!usedReservedTool || stepNumber < maxSteps - reportBufferSteps) {
+ return {}
+ }
+
+ return {
+ activeTools: ['write_report'],
+ toolChoice: { type: 'tool', toolName: 'write_report' as const },
+ }
+ },
+ })
+
+ 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,
+ })
+ },
+ })
+
+ if (!reportInput) {
+ reportInput = createMissingReport()
+ }
+
+ return {
+ reportInput,
+ }
+}
diff --git a/packages/cali/src/runtime/tool-packs.ts b/packages/cali/src/runtime/tool-packs.ts
new file mode 100644
index 0000000..63ea22e
--- /dev/null
+++ b/packages/cali/src/runtime/tool-packs.ts
@@ -0,0 +1,149 @@
+import type { ToolPackName } from '../config/schema.js'
+import { createAgentDeviceToolPack } from '../tools/agent-device.js'
+import { createReactDevtoolsToolPack } from '../tools/react-devtools.js'
+import { createRepoReadToolPack, createRepoWriteToolPack } from '../tools/repo.js'
+import {
+ buildPreloadedSkillsPrompt,
+ buildSkillsPrompt,
+ createSkillsToolPack,
+ discoverSkills,
+ ensureRequiredSkillsInstalled,
+ getManagedSkillPaths,
+ preloadSkillDocuments,
+ type RequiredSkillDocument,
+ type SkillMetadata,
+} from '../tools/skills.js'
+import { ensureCommandExists } from '../utils.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[]
+ ensureAvailable?: () => Promise
+ 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': {
+ ensureAvailable: () => ensureCommandExists('agent-device', 'npm i -g agent-device'),
+ requiredSkills: [
+ {
+ name: 'agent-device',
+ preloadPaths: ['SKILL.md', 'references/bootstrap-install.md', 'references/exploration.md'],
+ },
+ ],
+ createTools: ({ context, state, sessionName }) =>
+ createAgentDeviceToolPack({
+ trace: state.agentDeviceTrace,
+ sessionName: sessionName!,
+ screenshotsDir: context.output.screenshotsDir ?? 'screenshots',
+ }),
+ },
+ '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 ?? [],
+ }),
+ },
+ 'react-devtools': {
+ ensureAvailable: () =>
+ ensureCommandExists('agent-react-devtools', 'npm i -g agent-react-devtools'),
+ requiredSkills: [
+ {
+ name: 'react-devtools',
+ preloadPaths: ['SKILL.md'],
+ },
+ ],
+ createTools: ({ state }) => createReactDevtoolsToolPack({ trace: state.reactDevtoolsTrace }),
+ },
+}
+
+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 discoveredSkillPaths = [...getManagedSkillPaths(process.cwd()), ...skillPaths]
+ await Promise.all(
+ enabledToolPacks.map(async (toolPackName) => {
+ await TOOL_PACK_DEFINITIONS[toolPackName].ensureAvailable?.()
+ })
+ )
+ const state: ToolPackState = {
+ agentDeviceTrace: [],
+ reactDevtoolsTrace: [],
+ }
+ 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)),
+ ]
+ 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..53a8f25
--- /dev/null
+++ b/packages/cali/src/runtime/types.ts
@@ -0,0 +1,149 @@
+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 RepositoryContext = {
+ provider?: string
+ owner?: string
+ name?: string
+ webUrl?: 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 = {
+ ciProvider?: CiProvider
+ localPlatform?: CaliPlatform
+ 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 = {
+ 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
new file mode 100644
index 0000000..57c80a0
--- /dev/null
+++ b/packages/cali/src/tools/agent-device.ts
@@ -0,0 +1,93 @@
+import path from 'node:path'
+
+import type { ToolTraceEntry } from '../runtime/types.js'
+import { createCliTool } from './cli-tool.js'
+
+const DEFAULT_AGENT_DEVICE_SESSION_LOCK = 'reject'
+
+type CreateAgentDeviceToolPackOptions = {
+ trace: ToolTraceEntry[]
+ sessionName: string
+ screenshotsDir: string
+}
+
+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 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 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
+ }
+
+ 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 && /\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 normalizedArgs =
+ trimmedCommand === 'screenshot' ? normalizeScreenshotArgs(args, screenshotsDir) : args
+ return {
+ command: trimmedCommand,
+ args: normalizedArgs,
+ }
+}
+
+export function createAgentDeviceToolPack(options: CreateAgentDeviceToolPackOptions) {
+ const { trace, sessionName, screenshotsDir } = options
+ const sessionArgs = getAgentDeviceSessionArgs(sessionName, { lockTarget: true })
+
+ 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, screenshotsDir)
+ 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/react-devtools.ts b/packages/cali/src/tools/react-devtools.ts
new file mode 100644
index 0000000..833625c
--- /dev/null
+++ b/packages/cali/src/tools/react-devtools.ts
@@ -0,0 +1,18 @@
+import type { ToolTraceEntry } from '../runtime/types.js'
+import { createCliTool } from './cli-tool.js'
+
+type CreateReactDevtoolsToolPackOptions = {
+ trace: ToolTraceEntry[]
+}
+
+export function createReactDevtoolsToolPack(options: CreateReactDevtoolsToolPackOptions) {
+ const { trace } = options
+
+ 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
new file mode 100644
index 0000000..7a9ca97
--- /dev/null
+++ b/packages/cali/src/tools/repo.ts
@@ -0,0 +1,251 @@
+import { readFile, rm, writeFile } from 'node:fs/promises'
+import path from 'node:path'
+
+import { tool } from 'ai'
+import { z } from 'zod'
+
+import { ensureDirectory, resolveFromCwd, runCommand } from '../utils.js'
+
+type RepoReadToolPackOptions = {
+ workspaceRoot: string
+}
+
+type RepoWriteToolPackOptions = {
+ workspaceRoot: string
+ 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
+
+ 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 }) => {
+ 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 }) => {
+ 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 = resolveWorkspacePath(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 () => {
+ 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 }) => {
+ 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
+
+ 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 = resolveWorkspacePath(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 = resolveWorkspacePath(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 }) => {
+ 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,
+ 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
new file mode 100644
index 0000000..be059a5
--- /dev/null
+++ b/packages/cali/src/tools/skills.ts
@@ -0,0 +1,397 @@
+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
+ description: string
+ directoryPath: string
+ skillFilePath: string
+}
+
+type PreloadedSkillDocument = {
+ skillName: string
+ relativePath: string
+ absolutePath: string
+ content: string
+}
+
+type RequiredSkillDocument = {
+ name: string
+ preloadPaths: string[]
+}
+
+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) {
+ const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)/)
+ 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,
+ body: (match[2] ?? '').trim(),
+ }
+}
+
+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) {
+ const installHint = buildSkillInstallCommand(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
+}
+
+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()
+
+ 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 skillFile = parseSkillFile(content)
+ const key = skillFile.name.toLowerCase()
+
+ if (seenNames.has(key)) {
+ continue
+ }
+
+ seenNames.add(key)
+ skills.push({
+ name: skillFile.name,
+ description: skillFile.description,
+ directoryPath: skillDirectoryPath,
+ skillFilePath,
+ })
+ } catch {
+ continue
+ }
+ }
+ }
+
+ return skills.sort((left, right) => left.name.localeCompare(right.name))
+}
+
+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:',
+ ...availableSkills.map((skill) => `- ${skill.name}: ${skill.description}`),
+ '',
+ '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 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 ''
+ }
+
+ 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({
+ 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')
+ const skillFile = parseSkillFile(content)
+
+ return {
+ name: skill.name,
+ description: skill.description,
+ skillDirectory: skill.directoryPath,
+ skillFilePath: skill.skillFilePath,
+ content: skillFile.body,
+ }
+ },
+ }),
+ 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 { PreloadedSkillDocument, RequiredSkillDocument, SkillMetadata }
diff --git a/packages/cali/src/utils.ts b/packages/cali/src/utils.ts
index ab2d30f..da593b7 100644
--- a/packages/cali/src/utils.ts
+++ b/packages/cali/src/utils.ts
@@ -1,60 +1,191 @@
-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 { DOCS_URLS } from './docs.js'
+import type { CaliPlatform } from './runtime/types.js'
+
+const execFile = promisify(execFileCallback)
+
+type CommandResult = {
+ ok: boolean
+ exitCode: number
+ stdout: string
+ stderr: string
+ stdoutBuffer?: Buffer
+}
+
+type CommandOptions = {
+ cwd?: string
+ env?: NodeJS.ProcessEnv
+ allowFailure?: boolean
+ binaryStdout?: boolean
+}
+
+type ExecFileError = Error & {
+ stdout?: string | Buffer
+ stderr?: string | Buffer
+ status?: number | null
+ code?: number | string
+}
- const save = await confirm({
- message: `Do you want to save it for future runs in .env.local?`,
+const commandExistsCache = new Map()
+
+export async function runCommand(
+ file: string,
+ args: string[],
+ options: CommandOptions = {}
+): Promise {
+ 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,
+ stderr,
+ stdoutBuffer,
+ }
+ } catch (unknownError) {
+ const error = unknownError as ExecFileError
+ 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
+ : typeof error.code === 'number'
+ ? error.code
+ : 1
+
+ if (!allowFailure) {
+ throw new Error(
+ [`Command failed: ${file} ${args.join(' ')}`, stderr || stdout].filter(Boolean).join('\n\n')
+ )
+ }
- if (save) {
- execSync(`echo "${key}=${apiKey}" >> .env.local`)
- execSync(`echo ".env.local" >> .gitignore`)
+ return {
+ ok: false,
+ exitCode,
+ stdout,
+ stderr,
+ stdoutBuffer,
}
+ }
+}
+
+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}`,
+ 'Install it before running Cali:',
+ installHint,
+ `Docs: ${DOCS_URLS.requiredClis}`,
+ ].join('\n\n')
+ )
+}
+
+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): CaliPlatform | 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/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 0df3a4e..a50ffcd 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,20 +19,16 @@
"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",
- "@types/diff": "^6.0.0"
+ "@react-native-community/cli-types": "^15.1.2"
},
"author": "Mike Grabowski ",
"repository": {
@@ -55,7 +51,7 @@
"README.md"
],
"license": "MIT",
- "version": "0.3.1",
+ "version": "0.4.0-6",
"engines": {
"node": ">=22"
}
diff --git a/packages/tools/src/android.ts b/packages/tools/src/android.ts
index 4eec05f..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'
@@ -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(),
@@ -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
@@ -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(),
@@ -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 c772c49..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 {
@@ -16,7 +17,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,9 +28,9 @@ 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' })
+ execFileSync('bundle', ['install', '--path', 'vendor/bundle'], { stdio: 'inherit' })
return {
success: true,
}
@@ -38,12 +39,12 @@ export const installRubyGems = tool({
export const bootAppleSimulator = tool({
description: 'Boots iOS simulator',
- parameters: z.object({
+ inputSchema: z.object({
deviceId: z.string(),
}),
execute: async ({ deviceId }) => {
try {
- execSync(`xcrun simctl boot ${deviceId}`, { stdio: 'inherit' })
+ execFileSync('xcrun', ['simctl', 'boot', deviceId], { stdio: 'inherit' })
return {
success: `Device ${deviceId} booted successfully.`,
}
@@ -58,7 +59,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 +92,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 +123,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(),
@@ -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,
@@ -171,7 +169,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
deleted file mode 100644
index 227e896..0000000
--- a/packages/tools/src/fs.ts
+++ /dev/null
@@ -1,80 +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')
-
-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() }),
- execute: async ({ path }) => {
- return readdir(path)
- },
-})
-
-export const currentDirectory = tool({
- description: 'Get the current working directory',
- parameters: z.object({}),
- execute: async () => {
- return process.cwd()
- },
-})
-
-export const makeDirectory = tool({
- description: 'Create a new directory',
- parameters: z.object({ path: z.string() }),
- execute: async ({ path }) => {
- return mkdir(path)
- },
-})
-
-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 }) => {
- const file = await readFileNode(path, { encoding })
- if (is_image) {
- return {
- data: file,
- mimeType: `image/${extname(path).toLowerCase().replace('.', '')}`,
- }
- } else {
- return file
- }
- },
- experimental_toToolResultContent(result) {
- return typeof result === 'string'
- ? [{ type: 'text', text: result }]
- : [{ type: 'image', data: result.data, mimeType: result.mimeType }]
- },
-})
-
-export const saveFile = tool({
- description: 'Save a file at a given path',
- parameters: 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 4a17098..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',
- parameters: 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 518b7e9..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',
- parameters: 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',
- parameters: 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 5c577b3..2bc1a17 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 }) => {
@@ -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'
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/skills/cali/SKILL.md b/skills/cali/SKILL.md
new file mode 100644
index 0000000..3854a62
--- /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, local mode 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.
+- 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
+
+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 + local/CI mode + 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
+
+## Runtime modes
+
+- local mobile: `--local android|ios`
+- CI: implicit provider detection in GitHub Actions and EAS, with optional `--ci github-actions|eas` override
+
+## 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..a812a14
--- /dev/null
+++ b/skills/cali/references/running-cali.md
@@ -0,0 +1,125 @@
+# 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
+
+- 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
+
+Use `--ci github-actions|eas` only when you need to override provider detection.
+
+## 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.
+
+## 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
+# 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 \
+ --local ios \
+ --artifact ./artifacts/MyApp.app \
+ --prompt "verify onboarding copy on Screen B"
+
+# Local Android QA
+node packages/cali/dist/index.js qa \
+ --local android \
+ --artifact ./artifacts/app.apk \
+ --prompt "verify onboarding copy on Screen B"
+
+# CI-style QA
+node packages/cali/dist/index.js qa \
+ --platform ios \
+ --artifact ./artifacts/MyApp.app
+
+# EAS-style QA
+node packages/cali/dist/index.js qa \
+ --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
+
+# 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
+
+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
+
+- 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`
+- 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)
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,