reis-mobile 0.8.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -5,14 +5,14 @@
5
5
  },
6
6
  "metadata": {
7
7
  "description": "AI agents for mobile engineering",
8
- "version": "0.8.0"
8
+ "version": "0.9.0"
9
9
  },
10
10
  "plugins": [
11
11
  {
12
12
  "name": "reis-mobile",
13
13
  "source": "./",
14
14
  "description": "AI agents, skills and workflows for Flutter, Android, iOS and React Native development.",
15
- "version": "0.8.0",
15
+ "version": "0.9.0",
16
16
  "author": {
17
17
  "name": "Warleson Reis"
18
18
  },
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "reis-mobile",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "description": "AI agents, skills and workflows for Flutter, Android, iOS and React Native development. Use /reis-mobile:doctor, /reis-mobile:review and /reis-mobile:test.",
5
5
  "author": {
6
6
  "name": "Warleson Reis"
package/CHANGELOG.md CHANGED
@@ -4,6 +4,29 @@ Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). The pr
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [0.9.0] - 2026-10-13
8
+
9
+ ### Added
10
+
11
+ - `core/providers/`: the multi-provider layer. Six external providers — the `codex` and `gemini` CLIs plus the OpenAI, Gemini, OpenRouter and Ollama HTTP APIs — described as data (endpoint, credential variables, request shape, model catalog), so adding one touches no command. Status, model and allowlist resolution make no network call: a CLI is found by scanning `PATH`, an HTTP provider by its credential variable.
12
+ - `reis-mobile providers [--all] [--json]` lists every provider with the evidence behind its status (`ready`, `local`, `missing`, `disabled`, `blocked`), its endpoint and the resolved model. `reis-mobile providers --models` asks each provider which models it serves, so no model id is ever shipped as a default.
13
+ - `reis-mobile consult [--providers a,b] [--model <id>] [--file <path>] [--timeout <ms>] <question>` asks the selected providers the same question in parallel. The core performs the call, so no API key passes through the conversation; the question is redacted on the way out and every answer on the way in. Each requested provider is reported with its own typed status — `ok`, `unavailable`, `no-model`, `timeout`, `error` — and the exit code is 1 only when nobody answered.
14
+ - `/reis-mobile:consult [options] <question>`, also reachable as `/reis-mobile consult ...`: routes the question through the stack's specialist, requires its own position first, builds a self-contained briefing in `.reis-mobile/consults/`, consults the providers and then reconciles every outside claim against the code as confirmed with `file:line`, unverifiable or wrong.
15
+ - `providers:` in `.reis-mobile/config.yaml`: the allowlist of external providers a repository accepts, committed with the repository. An absent key allows all, an empty list allows none, and a blocked provider says so instead of failing.
16
+ - `providers.<id>.model` and `providers.<id>.enabled` in `~/.config/reis-mobile/config.json`, with precedence `--model`, then the provider's model variable (`OPENAI_MODEL`, `GEMINI_MODEL`, `OPENROUTER_MODEL`, `OLLAMA_MODEL`), then the config file.
17
+ - `reis-mobile doctor` lists the configured providers under Integrations, with a single informational row when none is configured. It still makes no network call.
18
+
19
+ ### Changed
20
+
21
+ - `/reis-mobile:debate --external` selects providers through the core instead of probing with `command -v`, accepts `--providers a,b`, and no longer needs `Bash(codex:*)` or `Bash(gemini:*)`: every provider call goes through the CLI. Behavior without a ready provider is unchanged — the debate carries on with the agents alone.
22
+ - CLI providers are asked for an opinion, not for work: `codex exec` now runs with `--sandbox read-only` and `gemini` with `--approval-mode plan`, both in a temporary working directory that is removed afterwards. The debate's previous invocation granted write access and the user's repository as a workspace, neither of which a second opinion needs.
23
+ - `/reis-mobile:review` points at `/reis-mobile:consult` when a finding should be cross-checked outside the session, and still never sends project context itself.
24
+
25
+ ### Fixed
26
+
27
+ - `/reis-mobile:debate --external` could not reach Codex at all on current CLI versions: `codex exec --full-auto` is rejected with `unexpected argument '--full-auto' found`, and the call ran outside a Git repository without `--skip-git-repo-check`. The provider catalog now carries the invocation verified against `codex exec --help`.
28
+ - A failing CLI provider reported its opening banner (`YOLO mode is enabled...`) instead of the cause. The reported line is now the first one that names a failure, falling back to the last line of stderr.
29
+
7
30
  ## [0.8.0] - 2026-09-23
8
31
 
9
32
  ### Added
package/README.md CHANGED
@@ -44,21 +44,28 @@ A mobile app is not a generic project. A code review that does not know about `B
44
44
  ## What's new
45
45
 
46
46
  <!-- reis-mobile:latest-release:start -->
47
- ### v0.8.0
47
+ ### v0.9.0
48
48
 
49
49
  #### Added
50
50
 
51
- - `mobile-performance` topic skill: measurement before optimization, budgets for startup, rendering, memory, battery and app size, and regression detection in CI, with a reference per stack (Flutter DevTools and `integration_test` summaries, Android Macrobenchmark, Baseline Profiles and vitals, iOS Instruments, MetricKit and `XCTMetric` baselines, React Native Hermes profiling and list tuning, KMP shared-versus-host isolation) plus a cross-stack guide to background execution and battery.
52
- - `mobile-offline-sync` topic skill: local store as the source of truth, a durable outbox with client-generated idempotency keys, ordering and operation collapsing, retry with backoff, conflict strategies per entity, reconnection triggers, offline UX and nine failure cases to test, with a reference per stack (drift and workmanager, Room and WorkManager, Core Data/SwiftData and `BGTaskScheduler`, SQLite with NetInfo and OTA-aware drains, SQLDelight and Ktor in `commonMain`).
53
- - `mobile-observability` topic skill: the signals that explain a production failure, mandatory segmentation by version and cohort, error context and grouping, correlation ids shared with the backend, rate-based alerting per version, sampling and cost, and what must never be collected, with a reference per stack (Flutter's three capture layers and Dart symbol upload, Android vitals and `ApplicationExitInfo`, MetricKit and Organizer, React Native source maps and OTA update ids, KMP `Telemetry` boundary and Kotlin/Native crash hooks).
54
- - `offline` and `observability` intents in the router. `offline` goes to the stack's architect and falls back to `mobile-staff-engineer`; `observability` goes to `mobile-release-engineer`.
55
- - `mobile-security/references/auth.md`: OAuth 2.0 and OIDC for native apps following RFC 8252 — authorization code with PKCE (`S256`), no client secret, system browser instead of a WebView, redirect URI choices, token rotation with reuse detection, and biometrics gating a key rather than a boolean.
56
- - `mobile-release/references/billing.md`: in-app purchase and subscription lifecycle — server-side entitlement, store server notifications, the states between active and expired, acknowledgement deadlines and automatic refunds, restore and account mapping, and the per-stack client libraries.
57
- - `mobile-accessibility/references/localization.md`: strings, CLDR plurals, locale-aware formatting, right-to-left layout and icon mirroring, translation growth, per-app language settings and pseudolocalization.
51
+ - `core/providers/`: the multi-provider layer. Six external providers — the `codex` and `gemini` CLIs plus the OpenAI, Gemini, OpenRouter and Ollama HTTP APIs — described as data (endpoint, credential variables, request shape, model catalog), so adding one touches no command. Status, model and allowlist resolution make no network call: a CLI is found by scanning `PATH`, an HTTP provider by its credential variable.
52
+ - `reis-mobile providers [--all] [--json]` lists every provider with the evidence behind its status (`ready`, `local`, `missing`, `disabled`, `blocked`), its endpoint and the resolved model. `reis-mobile providers --models` asks each provider which models it serves, so no model id is ever shipped as a default.
53
+ - `reis-mobile consult [--providers a,b] [--model <id>] [--file <path>] [--timeout <ms>] <question>` asks the selected providers the same question in parallel. The core performs the call, so no API key passes through the conversation; the question is redacted on the way out and every answer on the way in. Each requested provider is reported with its own typed status — `ok`, `unavailable`, `no-model`, `timeout`, `error` — and the exit code is 1 only when nobody answered.
54
+ - `/reis-mobile:consult [options] <question>`, also reachable as `/reis-mobile consult ...`: routes the question through the stack's specialist, requires its own position first, builds a self-contained briefing in `.reis-mobile/consults/`, consults the providers and then reconciles every outside claim against the code as confirmed with `file:line`, unverifiable or wrong.
55
+ - `providers:` in `.reis-mobile/config.yaml`: the allowlist of external providers a repository accepts, committed with the repository. An absent key allows all, an empty list allows none, and a blocked provider says so instead of failing.
56
+ - `providers.<id>.model` and `providers.<id>.enabled` in `~/.config/reis-mobile/config.json`, with precedence `--model`, then the provider's model variable (`OPENAI_MODEL`, `GEMINI_MODEL`, `OPENROUTER_MODEL`, `OLLAMA_MODEL`), then the config file.
57
+ - `reis-mobile doctor` lists the configured providers under Integrations, with a single informational row when none is configured. It still makes no network call.
58
58
 
59
59
  #### Changed
60
60
 
61
- - The five `*-architect` agents also serve the `offline` intent, and `mobile-release-engineer` also serves `observability`; `mobile-staff-engineer` covers both when no stack is detected.
61
+ - `/reis-mobile:debate --external` selects providers through the core instead of probing with `command -v`, accepts `--providers a,b`, and no longer needs `Bash(codex:*)` or `Bash(gemini:*)`: every provider call goes through the CLI. Behavior without a ready provider is unchanged — the debate carries on with the agents alone.
62
+ - CLI providers are asked for an opinion, not for work: `codex exec` now runs with `--sandbox read-only` and `gemini` with `--approval-mode plan`, both in a temporary working directory that is removed afterwards. The debate's previous invocation granted write access and the user's repository as a workspace, neither of which a second opinion needs.
63
+ - `/reis-mobile:review` points at `/reis-mobile:consult` when a finding should be cross-checked outside the session, and still never sends project context itself.
64
+
65
+ #### Fixed
66
+
67
+ - `/reis-mobile:debate --external` could not reach Codex at all on current CLI versions: `codex exec --full-auto` is rejected with `unexpected argument '--full-auto' found`, and the call ran outside a Git repository without `--skip-git-repo-check`. The provider catalog now carries the invocation verified against `codex exec --help`.
68
+ - A failing CLI provider reported its opening banner (`YOLO mode is enabled...`) instead of the cause. The reported line is now the first one that names a failure, falling back to the last line of stderr.
62
69
 
63
70
  <!-- reis-mobile:latest-release:end -->
64
71
 
@@ -247,7 +254,8 @@ After editing agents, skills or commands, restart Claude Code or Codex.
247
254
  /reis-mobile:test audit Espresso checkout tests # Reviews assertions and synchronization without editing
248
255
  /reis-mobile:release audit both stores for the production variant # Evidence-backed readiness, read-only
249
256
  /reis-mobile:debate Riverpod or BLoC in this app? # Debate between the specialists, with a decision by lead-mobile
250
- /reis-mobile:debate --rounds 3 --external ... # More rounds; --external adds Codex and Gemini, if installed
257
+ /reis-mobile:debate --rounds 3 --external ... # More rounds; --external adds every configured external provider
258
+ /reis-mobile:consult should we move this to a native module? # Second opinion from OpenAI, Gemini, OpenRouter, Ollama or the CLIs
251
259
  ```
252
260
 
253
261
  ### Debug
@@ -299,7 +307,50 @@ Participants vary along two axes at once. The **role** gives each one a differen
299
307
 
300
308
  It is expensive: several agents over two rounds. Use it for architecture decisions with a real trade-off — *migrate to Riverpod*, *offline-first with Firestore or a local cache*, *native plugin or an off-the-shelf package* — and not for questions with a single answer, where `/reis-mobile <request>` solves it for less.
301
309
 
302
- With `--external`, the command checks whether the `codex` and `gemini` CLIs exist and carries on without them if they are missing. External providers do not know the project and do not have the plugin skills: they come in as an outside opinion, checked against the code before the synthesis.
310
+ With `--external`, the command asks the core which providers are ready — the `codex` and `gemini` CLIs, OpenAI, Gemini, OpenRouter, Ollama — and carries on without them if none is. `--providers a,b` picks them explicitly. External providers do not know the project and do not have the plugin skills: they come in as an outside opinion, checked against the code before the synthesis.
311
+
312
+ ### External providers
313
+
314
+ `/reis-mobile:consult <question>` is the debate's external axis on its own: it routes the question, forms its own position, builds a self-contained briefing with the relevant code excerpts, sends it to the configured providers and then reconciles every outside claim against your code — confirmed with `file:line`, unverifiable, or wrong with the evidence. Rounds and agents cost nothing here, so it fits a question a single outside opinion settles.
315
+
316
+ The core makes the call, never the model, so **no API key passes through the conversation**. The question is masked by [`redact.mjs`](core/security/redact.mjs) on the way out and every answer on the way in.
317
+
318
+ | Provider | Needs | Model from |
319
+ |---|---|---|
320
+ | `codex` | the `codex` CLI on `PATH` | its own configuration |
321
+ | `gemini-cli` | the `gemini` CLI on `PATH` | its own configuration |
322
+ | `openai` | `OPENAI_API_KEY` | `OPENAI_MODEL` |
323
+ | `gemini` | `GEMINI_API_KEY` or `GOOGLE_API_KEY` | `GEMINI_MODEL` |
324
+ | `openrouter` | `OPENROUTER_API_KEY` | `OPENROUTER_MODEL` |
325
+ | `ollama` | a server at `OLLAMA_HOST` (default `http://127.0.0.1:11434`) | `OLLAMA_MODEL` |
326
+
327
+ ```bash
328
+ reis-mobile providers --all # What each provider needs, with no network call
329
+ reis-mobile providers --models --providers openai # Ask the provider which models it serves
330
+ reis-mobile consult --providers openai,ollama -- "is a native module justified here?"
331
+ reis-mobile consult --file briefing.md --timeout 300000
332
+ ```
333
+
334
+ reis-mobile ships **no default model id**: a hardcoded one is wrong the day the provider retires it. Set `OPENAI_MODEL` and friends, pass `--model`, or write it once in `~/.config/reis-mobile/config.json`:
335
+
336
+ ```json
337
+ {
338
+ "providers": {
339
+ "openai": { "model": "<a model from reis-mobile providers --models>" },
340
+ "openrouter": { "enabled": false }
341
+ }
342
+ }
343
+ ```
344
+
345
+ A team decides which providers may receive repository context, committed with the repository:
346
+
347
+ ```yaml
348
+ # .reis-mobile/config.yaml
349
+ providers:
350
+ - ollama # only this one; an empty list allows none, no key allows all
351
+ ```
352
+
353
+ `reis-mobile providers` and `/reis-mobile:doctor` make no network call: a provider is `ready` because its executable is on `PATH` or its credential is in the environment, never because it answered. `ollama` shows as `local` until a request proves the server is up.
303
354
 
304
355
  Not sure what the router will pick? Ask the CLI:
305
356
 
@@ -326,6 +377,7 @@ The router never invents a specialist: if no agent serves the intent, it warns y
326
377
  | I want to... | Use | Status |
327
378
  |----------|-----|--------|
328
379
  | Know if my environment is ready to build | `/reis-mobile:doctor` | ✅ |
380
+ | Cross-check a decision with a model outside this session | `/reis-mobile:consult` | ✅ |
329
381
  | Review a PR or my changes | `/reis-mobile:review` | ✅ |
330
382
  | Audit an entire Flutter project | `/reis-mobile:review` with no pending changes | ✅ |
331
383
  | Find out a project's stack | `reis-mobile detect` | ✅ |
@@ -454,13 +506,13 @@ About 85 guides come from [flutter/skills](https://github.com/flutter/skills), [
454
506
 
455
507
  ## Trust, security and limits
456
508
 
457
- **Read-only CLI context.** `doctor`, `detect`, `route`, `review`, `test` and `release` do not modify your project. `/reis-mobile:release` inspects existing release evidence and remains read-only unless execution is explicitly requested. The `/reis-mobile:test` slash command can write or fix tests and execute runners when requested. `/reis-mobile:project` writes only the optional project profile. The `/reis-mobile:review` and `/reis-mobile:debate` commands instruct the model not to edit app code; the debate writes its rounds to `.reis-mobile/debates/`.
509
+ **Read-only CLI context.** `doctor`, `detect`, `route`, `review`, `test`, `release`, `providers` and `consult` do not modify your project. `/reis-mobile:release` inspects existing release evidence and remains read-only unless execution is explicitly requested. The `/reis-mobile:test` slash command can write or fix tests and execute runners when requested. `/reis-mobile:project` writes only the optional project profile. The `/reis-mobile:review` and `/reis-mobile:debate` commands instruct the model not to edit app code; the debate writes its rounds to `.reis-mobile/debates/` and a consult its briefing to `.reis-mobile/consults/`.
458
510
 
459
511
  **Secrets masked.** The diff goes through [`core/security/redact.mjs`](core/security/redact.mjs) before reaching the model. Lock files and generated code (`*.g.dart`, `*.freezed.dart`, `*.pbxproj`) stay out of the diff. Redaction is a protection layer, not a guarantee. See [SECURITY.md](SECURITY.md).
460
512
 
461
- **No telemetry.** `detect`, `doctor`, `route`, `review`, `debug` and `test` make no network calls. The update hook and `update-check` request the public npm package metadata; they send no project files or identifiers. Installation downloads the package and registers the plugin. The Google Play policy guide in `mobile-android` runs Python scripts that download your app's public Google Play listing when the model uses it. The AI analysis uses the model of the session you are working in, such as Claude Code or Codex.
513
+ **No telemetry.** `detect`, `doctor`, `route`, `review`, `debug`, `test` and `providers` make no network calls (`providers --models` and `consult` do, by request). The update hook and `update-check` request the public npm package metadata; they send no project files or identifiers. Installation downloads the package and registers the plugin. The Google Play policy guide in `mobile-android` runs Python scripts that download your app's public Google Play listing when the model uses it. The AI analysis uses the model of the session you are working in, such as Claude Code or Codex.
462
514
 
463
- **Exception: `/reis-mobile:debate --external`.** This flag, and only this flag, sends the debate context — including excerpts of the files mentioned in the question — to the `codex` and `gemini` CLIs, which are third-party and have their own data policies. Without the flag, the debate context stays in your session. The `review` diff is masked by `redact.mjs`, but the context you cite in a debate question does not go through that layer: check what you are sending before using `--external`.
515
+ **Exception: external providers.** Two workflows, and only these two, send project context to a third party: `/reis-mobile:debate --external` and `/reis-mobile:consult`. Both state which providers receive it, and both are opt-in — no other command calls a provider. What leaves is the briefing, including the code excerpts you include; each provider has its own data policy. The question and every answer pass through `redact.mjs`, which is a protection layer and not a guarantee: read the briefing before sending it. API keys are read from the environment by the CLI and never enter the conversation, and `providers:` in `.reis-mobile/config.yaml` lets a team restrict which providers a repository allows. CLI providers (`codex`, `gemini-cli`) are asked for an opinion, not for work: they run in read-only mode (`codex exec --sandbox read-only`, `gemini --approval-mode plan`) inside a temporary directory, never in your repository.
464
516
 
465
517
  **Context cost.** Claude Code loads the descriptions of 21 agents and 17 skills; the full content of each skill is loaded only when it is used. The exact description cost depends on the host and plugin version.
466
518
 
@@ -491,8 +543,8 @@ About 85 guides come from [flutter/skills](https://github.com/flutter/skills), [
491
543
  | v0.7.1 | Update notices, single-source version and release automation | ✅ |
492
544
  | v0.7.3 | `/reis-mobile:release` with per-target evidence, required gates and deterministic readiness verdicts (previously planned for v0.8.0) | ✅ |
493
545
  | v0.8.0 | `mobile-performance`, `mobile-offline-sync` and `mobile-observability` topic skills with a reference per stack, plus background execution, OAuth/PKCE, billing lifecycle and localization references | ✅ |
494
- | v0.9.0 | MCP server | ⏳ |
495
- | v0.10.0 | Multi-provider (OpenAI, Gemini, OpenRouter, Ollama) beyond the debate's `--external` | ⏳ |
546
+ | v0.9.0 | Multi-provider layer (OpenAI, Gemini, OpenRouter, Ollama and the Codex/Gemini CLIs), `reis-mobile providers`, `reis-mobile consult` and `/reis-mobile:consult` — previously planned for v0.10.0 | ✅ |
547
+ | v0.10.0 | MCP server | ⏳ |
496
548
  | v1.0.0 | First stable version: Flutter, Android, iOS and React Native | ⏳ |
497
549
 
498
550
  ---
@@ -502,6 +554,9 @@ About 85 guides come from [flutter/skills](https://github.com/flutter/skills), [
502
554
  **Do I need Flutter, Xcode or the Android SDK installed?**
503
555
  Not for the review, which only reads code. `/reis-mobile:doctor` shows what is missing in case you want to build.
504
556
 
557
+ **Do I need an API key?**
558
+ No. Everything except `/reis-mobile:consult` and `/reis-mobile:debate --external` runs on the model of your session. Those two use whatever you have configured — a `codex` or `gemini` CLI already on your machine counts, and `ollama` keeps the question on localhost. `reis-mobile providers --all` shows what each one still needs.
559
+
505
560
  **My app lives inside a monorepo.**
506
561
  Commit a `.reis-mobile/config.yaml` at the repository root pointing at the app:
507
562
 
@@ -546,6 +601,7 @@ Questions: [Discussions](https://github.com/wrsilva/reis-mobile/discussions). Wh
546
601
  - [Architecture](ARCHITECTURE.md): modules, detection, routing and the agent and skill contract
547
602
  - [AGENTS.md](AGENTS.md): rules for whoever (or whichever AI) contributes to the repository
548
603
  - [Contributing](CONTRIBUTING.md)
604
+ - [Releasing](docs/releasing.md): how a version is cut, what is generated and what to do when the pipeline fails
549
605
  - [Code of Conduct](CODE_OF_CONDUCT.md)
550
606
  - [Support](SUPPORT.md)
551
607
  - [Security](SECURITY.md)
@@ -10,6 +10,8 @@ import { detectStack } from '../core/detection/stack-detector.mjs';
10
10
  import { runDoctor } from '../core/diagnostics/doctor.mjs';
11
11
  import { runInit } from '../core/install/init.mjs';
12
12
  import { PLUGIN_ROOT } from '../core/paths.mjs';
13
+ import { DEFAULT_TIMEOUT_MS, consult, listProviderModels } from '../core/providers/consult.mjs';
14
+ import { parseProviderList, providerAllowlist, resolveProviders, selectProviders } from '../core/providers/providers.mjs';
13
15
  import { loadRegistry, validateRegistry } from '../core/registry/registry.mjs';
14
16
  import { releaseContext } from '../core/release/context.mjs';
15
17
  import { INTENT_IDS } from '../core/router/intents.mjs';
@@ -30,6 +32,8 @@ Commands:
30
32
  debug [problem...] Route a build or runtime failure and report the toolchain it runs on
31
33
  test [request...] Prepare test routing, git context and toolchain checks (does not run tests)
32
34
  release [request...] Prepare release evidence context (does not build or publish)
35
+ providers List the external model providers and what each one needs
36
+ consult [question...] Ask the configured external providers the same question
33
37
  agents | skills | stacks List registered components
34
38
  validate Validate the plugin's agents, skills and stacks
35
39
  update-check Check npm for a newer reis-mobile release
@@ -44,6 +48,11 @@ Options:
44
48
  --scope <scope> init: Claude Code scope, user (default), project or local
45
49
  --uninstall init: remove the plugin and its marketplace from Claude Code and Codex, and the saved language
46
50
  --force update-check: ignore the cached registry result
51
+ --providers <ids> providers/consult: comma-separated provider ids (default: every usable one)
52
+ --model <id> consult: model for the selected HTTP providers, overriding the configuration
53
+ --models providers: ask each provider which models it serves (network request)
54
+ --file <path> consult: read the question from a file instead of the arguments
55
+ --timeout <ms> consult: give up on a provider after this long (default 120000)
47
56
  --lang <en|pt> Override the saved language for this run
48
57
  --json Machine-readable output
49
58
  -v, --version Print the version
@@ -59,6 +68,11 @@ const OPTIONS = {
59
68
  scope: { type: 'string' },
60
69
  uninstall: { type: 'boolean' },
61
70
  force: { type: 'boolean' },
71
+ providers: { type: 'string' },
72
+ model: { type: 'string' },
73
+ models: { type: 'boolean' },
74
+ file: { type: 'string' },
75
+ timeout: { type: 'string' },
62
76
  lang: { type: 'string' },
63
77
  json: { type: 'boolean' },
64
78
  version: { type: 'boolean', short: 'v' },
@@ -75,6 +89,8 @@ const COMMANDS = {
75
89
  debug: commandDebug,
76
90
  test: commandTest,
77
91
  release: commandRelease,
92
+ providers: commandProviders,
93
+ consult: commandConsult,
78
94
  agents: (options) => commandList('agents', options),
79
95
  skills: (options) => commandList('skills', options),
80
96
  stacks: (options) => commandList('stacks', options),
@@ -109,7 +125,7 @@ async function main(argv) {
109
125
  return (await command({ ...options, dir: project.projectDir, project, prompt: rest.join(' ') })) ?? 0;
110
126
  }
111
127
 
112
- const PROJECT_COMMANDS = new Set(['detect', 'doctor', 'route', 'review', 'debug', 'test', 'release']);
128
+ const PROJECT_COMMANDS = new Set(['detect', 'doctor', 'route', 'review', 'debug', 'test', 'release', 'providers', 'consult']);
113
129
 
114
130
  async function commandUpdateCheck({ force, json }) {
115
131
  const result = await checkForUpdate({ force });
@@ -300,6 +316,112 @@ async function commandRelease({ dir, project, prompt, json, lang }) {
300
316
  return 0;
301
317
  }
302
318
 
319
+ /**
320
+ * External providers and what each one still needs. No network call: an executable on PATH
321
+ * or a credential in the environment is what decides `ready`, so `providers` and `doctor`
322
+ * stay offline. `--models` is the one exception, and asks the provider itself rather than
323
+ * shipping a model id that goes stale.
324
+ */
325
+ async function commandProviders({ project, all, json, models, providers: requested, model, timeout }) {
326
+ const allow = providerAllowlist(project.config);
327
+ const resolution = await resolveProviders({ allow, model });
328
+ const requestedIds = parseProviderList(requested);
329
+ for (const warning of resolution.warnings) console.error(`! ${warning}`);
330
+
331
+ if (models) {
332
+ const selected = selectProviders(resolution.providers, requestedIds, { requireModel: false });
333
+ const catalogs = await listProviderModels({ providers: selected, timeoutMs: parseTimeout(timeout, 30_000) });
334
+ if (json) return print({ models: catalogs, configPath: resolution.configPath });
335
+ for (const catalog of catalogs) {
336
+ const summary = catalog.status === 'ok' ? `${catalog.models.length} model(s)` : `${catalog.status} · ${catalog.error}`;
337
+ console.log(`${catalog.provider.padEnd(14)}${summary}`);
338
+ for (const id of catalog.models) console.log(` · ${id}`);
339
+ }
340
+ return catalogs.some((catalog) => catalog.status === 'ok') ? 0 : 1;
341
+ }
342
+
343
+ if (json) return print({ providers: resolution.providers, configPath: resolution.configPath, projectConfig: project.config?.path ?? null });
344
+
345
+ printProject(project);
346
+ const shown = all ? resolution.providers : resolution.providers.filter((provider) => provider.selectable);
347
+ for (const provider of shown) {
348
+ const model = provider.model ? `model ${provider.model} (${provider.modelSource})` : provider.kind === 'cli' ? 'own model' : 'no model';
349
+ console.log(`${provider.id.padEnd(14)}${PROVIDER_SYMBOLS[provider.status]} ${provider.status.padEnd(9)}${model.padEnd(34)}${provider.detail}`);
350
+ }
351
+ const usable = resolution.providers.filter((provider) => provider.runnable);
352
+ console.log(
353
+ usable.length
354
+ ? `\n${usable.length} provider(s) ready: reis-mobile consult --providers ${usable.map((provider) => provider.id).join(',')} -- "<question>"`
355
+ : '\nNo provider is ready. Install the Codex or Gemini CLI, or set an API key and a model (reis-mobile providers --all).',
356
+ );
357
+ console.log(`Config ${resolution.configPath}`);
358
+ return 0;
359
+ }
360
+
361
+ const PROVIDER_SYMBOLS = { ready: '✓', local: '-', missing: '-', disabled: '-', blocked: '✗' };
362
+
363
+ /**
364
+ * Asks the selected providers the same question. The core performs the call so no API key
365
+ * reaches the conversation, and both the question and every answer go through the redaction
366
+ * layer on the way through.
367
+ */
368
+ async function commandConsult({ dir, project, json, providers: requested, model, timeout, file, prompt, lang }) {
369
+ const question = file ? await readFile(file, 'utf8') : prompt || (await readStdin());
370
+ if (!question.trim()) {
371
+ console.error('consult needs a question. Pass it as an argument, with --file <path>, or on stdin.');
372
+ return 1;
373
+ }
374
+
375
+ const allow = providerAllowlist(project.config);
376
+ const resolution = await resolveProviders({ allow, model });
377
+ for (const warning of resolution.warnings) console.error(`! ${warning}`);
378
+ const selected = selectProviders(resolution.providers, parseProviderList(requested));
379
+ if (!selected.length) {
380
+ console.error('No external provider is ready. Run `reis-mobile providers --all` to see what each one needs.');
381
+ return 1;
382
+ }
383
+
384
+ const detection = await detectStack(dir);
385
+ const language = await resolveLanguage({ flag: lang });
386
+ const answers = await consult({ prompt: question, providers: selected, timeoutMs: parseTimeout(timeout, DEFAULT_TIMEOUT_MS) });
387
+ const answered = answers.results.filter((result) => result.status === 'ok');
388
+
389
+ if (json) {
390
+ print({ stack: detection.stack, language, projectConfig: project.config?.path ?? null, ...answers });
391
+ return answered.length ? 0 : 1;
392
+ }
393
+
394
+ printProject(project);
395
+ console.log(`Stack ${detection.stack}`);
396
+ printLanguage(language);
397
+ console.log(`Question ${answers.prompt.chars} chars${answers.prompt.redacted ? ' (secrets masked)' : ''}`);
398
+ for (const result of answers.results) {
399
+ const heading = `${result.provider}${result.model ? ` · ${result.model}` : ''} · ${result.status} · ${(result.durationMs / 1000).toFixed(1)}s`;
400
+ console.log(`\n--- ${heading} ---`);
401
+ console.log(result.status === 'ok' ? result.text : `! ${result.error}`);
402
+ }
403
+ console.log(
404
+ answered.length
405
+ ? '\nExternal answers are an outside opinion: they do not know this project. Verify every claim in the code.'
406
+ : '\nNo provider answered.',
407
+ );
408
+ return answered.length ? 0 : 1;
409
+ }
410
+
411
+ function parseTimeout(value, fallback) {
412
+ if (value === undefined) return fallback;
413
+ const timeout = Number(value);
414
+ if (!Number.isFinite(timeout) || timeout <= 0) throw new Error(`--timeout must be a positive number of milliseconds, got ${JSON.stringify(value)}`);
415
+ return timeout;
416
+ }
417
+
418
+ async function readStdin() {
419
+ if (process.stdin.isTTY) return '';
420
+ const chunks = [];
421
+ for await (const chunk of process.stdin) chunks.push(chunk);
422
+ return Buffer.concat(chunks).toString('utf8');
423
+ }
424
+
303
425
  async function commandList(kind, { json }) {
304
426
  const registry = await loadRegistry();
305
427
  const items = registry[kind];
@@ -0,0 +1,95 @@
1
+ ---
2
+ description: Second opinion from external providers — builds a stack-aware briefing, sends it through the provider layer and reconciles every outside claim against the code
3
+ argument-hint: "[--providers a,b] [--model <id>] [--files a,b] <question>"
4
+ allowed-tools: ["Bash(node:*)", "Read", "Grep", "Glob", "Write"]
5
+ ---
6
+
7
+ # reis-mobile consult
8
+
9
+ Arguments received: `$ARGUMENTS`
10
+
11
+ Use this command when a mobile decision or finding deserves a model that does not share this session's blind spots: a migration you are unsure about, a diagnosis you cannot confirm, a trade-off where you suspect you are anchored. For a question the code answers on its own, answer it directly and say why a consult was not worth it.
12
+
13
+ **This sends project context to a third party.** Everything in the briefing leaves the session, including the excerpts of the files you include. Name the providers that will receive it before sending, and never include a credential, a store key or a `.env` value.
14
+
15
+ ## 1. Parse the arguments
16
+
17
+ | Flag | Default | Effect |
18
+ |---|---|---|
19
+ | `--providers a,b` | every ready provider | Explicit provider ids, from step 2. |
20
+ | `--model <id>` | per-provider configuration | Model for the selected HTTP providers. |
21
+ | `--files a,b` | files named in the question | Extra files to include in the briefing. |
22
+ | `--timeout <ms>` | 120000 | How long to wait for each provider. |
23
+
24
+ Whatever remains is the question, as free text. Without a question, explain the usage and stop.
25
+
26
+ ## 2. Check who can answer
27
+
28
+ ```bash
29
+ node "${CLAUDE_PLUGIN_ROOT}/bin/reis-mobile.mjs" providers --dir "$PWD" --all
30
+ ```
31
+
32
+ `ready` providers can be used now. `missing` ones state the executable or the credential they need, `local` needs a server running, `disabled` is off in the user config and `blocked` is not in the `providers:` allowlist of `.reis-mobile/config.yaml` — a team decision, which you do not work around. With no ready provider, show what each one needs and stop.
33
+
34
+ ## 3. Route the question and gather the evidence
35
+
36
+ ```bash
37
+ node "${CLAUDE_PLUGIN_ROOT}/bin/reis-mobile.mjs" route --dir "$PWD" -- "<question>"
38
+ ```
39
+
40
+ Use the `Language` line for everything the user reads (`en` English, `pt` Brazilian Portuguese; `-` means the language of the request). Read the selected agent and skills as `/reis-mobile:review` does, then resolve the paths in the question and in `--files` and read them. External providers receive content, never paths: they cannot open your repository.
41
+
42
+ **Form your own position first.** Write it down before sending anything. A consult that starts without one turns into agreement with whatever comes back.
43
+
44
+ ## 4. Write the briefing
45
+
46
+ ```bash
47
+ mkdir -p ".reis-mobile/consults/<NNN-question-slug>"
48
+ ```
49
+
50
+ `NNN` is sequential within `.reis-mobile/consults/`. Write `briefing.md` in that folder with the Write tool, self-contained:
51
+
52
+ ```text
53
+ QUESTION: <question>
54
+ STACK: <detected stack, variant and platforms>
55
+ LANGUAGE: <language — answer in it>
56
+ CODE: <the relevant excerpts, with file:line for each>
57
+ CONSTRAINTS: <versions, SDK targets, deadlines or rules that limit the answer>
58
+
59
+ You do not have access to this repository: everything you need is above.
60
+ Answer in at most 500 words: your recommendation, the evidence in the excerpts that
61
+ supports it, what it costs, and what would change your mind. Say explicitly when the
62
+ excerpts are not enough to decide.
63
+ ```
64
+
65
+ ## 5. Consult
66
+
67
+ ```bash
68
+ node "${CLAUDE_PLUGIN_ROOT}/bin/reis-mobile.mjs" consult --dir "$PWD" \
69
+ --file ".reis-mobile/consults/<id>/briefing.md" \
70
+ [--providers <ids>] [--model <id>] [--timeout <ms>]
71
+ ```
72
+
73
+ The CLI performs the call, so no API key passes through this conversation; the question is masked by the redaction layer on the way out and every answer on the way in. Each block is headed by the provider, its model, its status and its duration. A `no-model`, `timeout`, `unavailable` or `error` status is information to report, not a failure to retry blindly — retry once at most, and only for `timeout`.
74
+
75
+ ## 6. Reconcile against the code
76
+
77
+ External answers do not know this project and have no reis-mobile skills. For every claim that matters:
78
+
79
+ - confirm it in the code and cite `file:line`;
80
+ - mark as **unverifiable** what the excerpts cannot settle;
81
+ - mark as **wrong** what the code contradicts, with the evidence.
82
+
83
+ A claim that only repeats the briefing back adds nothing. Do not adopt a recommendation because several providers agree: they may share a training bias that your code does not.
84
+
85
+ ## 7. Report
86
+
87
+ In this order:
88
+
89
+ 1. **Decision** — your recommendation, with the reason, in one paragraph.
90
+ 2. **Where the providers agreed with you** and where they did not, naming each one.
91
+ 3. **Confirmed claims** with `file:line`, then **unverifiable** and **wrong** ones.
92
+ 4. **Action plan**, if the decision implies work.
93
+ 5. The briefing path and the providers that received it.
94
+
95
+ Do not apply any code change: a consult ends in a recommendation. Implementing it is a new request.
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  description: Structured debate between the mobile specialists — each agent defends its perspective, rebuts the others, and lead-mobile decides
3
- argument-hint: "[--rounds N] [--agents a,b,c] [--external] <question>"
4
- allowed-tools: ["Bash(node:*)", "Bash(command:*)", "Bash(mkdir:*)", "Bash(codex:*)", "Bash(gemini:*)", "Agent", "Read", "Write", "Grep", "Glob"]
3
+ argument-hint: "[--rounds N] [--agents a,b,c] [--external] [--providers a,b] <question>"
4
+ allowed-tools: ["Bash(node:*)", "Bash(mkdir:*)", "Agent", "Read", "Write", "Grep", "Glob"]
5
5
  ---
6
6
 
7
7
  # reis-mobile debate
@@ -16,7 +16,8 @@ Use this command for architecture decisions with real trade-offs, not for routin
16
16
  |---|---|---|
17
17
  | `--rounds N` | 2 | Number of rounds, from 1 to 3. Above 3, warn and use 3. |
18
18
  | `--agents a,b,c` | automatic | Explicit participants, using the names from the table in step 3. |
19
- | `--external` | off | Adds Codex and Gemini to the debate, when installed. |
19
+ | `--external` | off | Adds every ready external provider to the debate. |
20
+ | `--providers a,b` | every ready one | Explicit provider ids; implies `--external`. |
20
21
 
21
22
  Whatever remains is the question, as free text. Without a question, explain the usage and stop.
22
23
 
@@ -98,23 +99,26 @@ Ground every claim in file:line. Do not write what you have not verified.
98
99
  Write your position to: .reis-mobile/debates/<id>/rounds/r1_<agent>.md
99
100
  ```
100
101
 
101
- ### 5.1 External providers (only with `--external`)
102
+ ### 5.1 External providers (only with `--external` or `--providers`)
102
103
 
103
- Check before calling, and carry on without them if they are missing — their absence is informative, not an error:
104
+ Ask the core which providers can answer, instead of probing for executables yourself:
104
105
 
105
106
  ```bash
106
- command -v codex >/dev/null 2>&1 && echo codex
107
- command -v gemini >/dev/null 2>&1 && echo gemini
107
+ node "${CLAUDE_PLUGIN_ROOT}/bin/reis-mobile.mjs" providers --dir "$PWD" --all
108
108
  ```
109
109
 
110
- If present, call them in parallel with the agents, writing to `r1_codex.md` and `r1_gemini.md`:
110
+ `ready` providers can be used now; `missing`, `local`, `disabled` and `blocked` state what each one needs, and `blocked` means the repository's `providers:` allowlist excludes it — a team decision you do not work around. With no ready provider, carry on with the agents alone: their absence is informative, not an error. State at the opening who joined and who was missing.
111
+
112
+ Write the same briefing you gave the agents to `rounds/r<N>_briefing.md` with the Write tool — a file, so nothing depends on shell quoting — and send it:
111
113
 
112
114
  ```bash
113
- codex exec --full-auto "<briefing>"
114
- printf '%s' "<briefing>" | gemini -p "" -o text --approval-mode yolo
115
+ node "${CLAUDE_PLUGIN_ROOT}/bin/reis-mobile.mjs" consult --dir "$PWD" \
116
+ --file ".reis-mobile/debates/<id>/rounds/r<N>_briefing.md" [--providers <ids>]
115
117
  ```
116
118
 
117
- They do not have the plugin skills and do not know the project: give the entire context in the briefing and treat the output as an outside opinion, to be checked against the code before it enters the synthesis. State at the opening who joined and who was missing.
119
+ The CLI performs the call, so no API key passes through this conversation, and the briefing is masked by the redaction layer before it leaves the process. Write each answer to `rounds/r<N>_<provider>.md` with the Write tool, keeping the provider id and the status from the output header. Treat a `timeout`, `error` or `no-model` status as a participant that did not answer this round.
120
+
121
+ They do not have the plugin skills and do not know the project: the briefing carries the entire context, and the output is an outside opinion to be checked against the code before it enters the synthesis. In round 2 they receive the others' positions exactly as the agents do.
118
122
 
119
123
  ## 6. Round 2 — rebuttal
120
124
 
@@ -1,6 +1,6 @@
1
1
  ---
2
- description: reis-mobile entry point — lists the commands or forwards to doctor, project, review, debug, test, release, debate or the right agent from a free-form request
3
- argument-hint: "[doctor | project | review | debug | test | release | debate | free-form request]"
2
+ description: reis-mobile entry point — lists the commands or forwards to doctor, project, review, debug, test, release, debate, consult or the right agent from a free-form request
3
+ argument-hint: "[doctor | project | review | debug | test | release | debate | consult | free-form request]"
4
4
  allowed-tools: ["Bash(node:*)", "Bash(git:*)", "Read", "Grep", "Glob", "Edit", "Write"]
5
5
  ---
6
6
 
@@ -26,6 +26,7 @@ Reply only with this list. The only thing to run is `node "${CLAUDE_PLUGIN_ROOT}
26
26
  /reis-mobile:test [request] Runs, writes, audits or fixes tests using the project's test setup
27
27
  /reis-mobile:release [request] Audits store readiness with evidence and a go/no-go verdict
28
28
  /reis-mobile:debate <question> Debate between the specialists, with a decision by lead-mobile
29
+ /reis-mobile:consult <question> Second opinion from the configured external providers, checked against the code
29
30
  /reis-mobile <free-form request> Forwards the request to the agent and skills for the detected stack
30
31
  ```
31
32
 
@@ -52,6 +53,10 @@ Read `${CLAUDE_PLUGIN_ROOT}/commands/debug.md` and follow all of it, treating th
52
53
 
53
54
  Read `${CLAUDE_PLUGIN_ROOT}/commands/debate.md` and follow all of it, treating the rest of the arguments (without the word `debate`) as `$ARGUMENTS`.
54
55
 
56
+ ## `consult [options] <question>`
57
+
58
+ Read `${CLAUDE_PLUGIN_ROOT}/commands/consult.md` and follow all of it, treating the rest of the arguments (without the word `consult`) as `$ARGUMENTS`. This entry point has the same behavior as `/reis-mobile:consult`.
59
+
55
60
  ## `test [request]`
56
61
 
57
62
  Read `${CLAUDE_PLUGIN_ROOT}/commands/test.md` and follow all of it, treating the rest of the arguments (without the word `test`) as `$ARGUMENTS`. This entry point has the same behavior as `/reis-mobile:test`.
@@ -51,3 +51,5 @@ Confirm every finding in the code before reporting it. Do not modify files: this
51
51
  Answer in the format defined by the agent (Summary, Critical, Bugs, Architecture, Security, Performance, Maintainability, Suggested changes), translating the headings when the language is not English. On the first line of the Summary, state the stack, the agent and the skills used, so the routing is visible.
52
52
 
53
53
  If the router emitted warnings (lines starting with `!`), mention them in the Summary.
54
+
55
+ When the user wants a finding cross-checked by a model outside this session, point them at `/reis-mobile:consult <question>`: it builds the briefing, sends it to the configured providers and reconciles every outside claim against the code. A review never sends project context to a third party on its own.
@@ -5,7 +5,7 @@ import { parseYamlSubset } from '../registry/frontmatter.mjs';
5
5
 
6
6
  export const PROJECT_CONFIG = join('.reis-mobile', 'config.yaml');
7
7
 
8
- const KNOWN_KEYS = new Set(['app']);
8
+ const KNOWN_KEYS = new Set(['app', 'providers']);
9
9
 
10
10
  /**
11
11
  * Finds the nearest `.reis-mobile/config.yaml`, starting at `dir` and walking up, so a
@@ -4,8 +4,10 @@ import { homedir } from 'node:os';
4
4
  import { join } from 'node:path';
5
5
  import { promisify } from 'node:util';
6
6
 
7
+ import { findProjectConfig } from '../config/project.mjs';
7
8
  import { createProbe } from '../detection/project-probe.mjs';
8
9
  import { detectStack } from '../detection/stack-detector.mjs';
10
+ import { providerAllowlist, resolveProviders } from '../providers/providers.mjs';
9
11
 
10
12
  const run = promisify(execFile);
11
13
  const TOOL_TIMEOUT_MS = 30_000;
@@ -47,8 +49,9 @@ export async function runDoctor({ projectDir = process.cwd(), all = false, platf
47
49
  const tools = TOOLS.filter((tool) => all || tool.relevant(context));
48
50
  const toolChecks = await Promise.all(tools.map((tool) => checkTool(tool, platform)));
49
51
  const projectChecks = await checkProject(probe, detection, files);
52
+ const providerChecks = await checkProviders(detection.projectDir, all);
50
53
 
51
- const checks = [...toolChecks, ...projectChecks];
54
+ const checks = [...toolChecks, ...projectChecks, ...providerChecks];
52
55
  const warnings = checks.filter((check) => check.status === 'warning').length;
53
56
 
54
57
  return {
@@ -85,6 +88,25 @@ async function checkTool(tool, platform) {
85
88
  }
86
89
  }
87
90
 
91
+ /**
92
+ * External model providers, resolved from PATH and the environment only: `doctor` makes no
93
+ * network call, so a reachable provider is never confirmed here, just a configured one.
94
+ */
95
+ async function checkProviders(projectDir, all) {
96
+ const { providers } = await resolveProviders({ allow: providerAllowlist(findProjectConfig(projectDir)) });
97
+ const shown = all ? providers : providers.filter((provider) => provider.status === 'ready');
98
+ if (!shown.length) {
99
+ return [{ id: 'providers', label: 'Providers', section: 'integrations', status: 'info', detail: 'none configured (reis-mobile providers --all)' }];
100
+ }
101
+ return shown.map((provider) => ({
102
+ id: `provider-${provider.id}`,
103
+ label: provider.label,
104
+ section: 'integrations',
105
+ status: provider.status === 'ready' ? 'ok' : 'info',
106
+ detail: `${provider.status} · ${provider.model ? `model ${provider.model}` : provider.kind === 'cli' ? 'own model' : 'no model'}`,
107
+ }));
108
+ }
109
+
88
110
  async function checkAndroidSdk() {
89
111
  const candidates = [
90
112
  process.env.ANDROID_HOME,
@@ -0,0 +1,168 @@
1
+ // External model providers reis-mobile can ask for a second opinion.
2
+ //
3
+ // A provider is data, not code paths spread across commands: the catalog says how to reach
4
+ // it, which credential proves it is usable and where its model catalog lives. Nothing here
5
+ // carries a default model id — a provider is the only authority on its own catalog, and a
6
+ // hardcoded id becomes wrong the day it is retired. `providers --models` asks instead.
7
+
8
+ const JSON_HEADERS = { 'content-type': 'application/json' };
9
+
10
+ /** OpenAI, OpenRouter and any other `/chat/completions` endpoint share this shape. */
11
+ function openAiCompatible({ id, label, docs, keys, modelEnv, baseEnv, defaultBase, headers = {} }) {
12
+ const base = (env) => trimTrailingSlash(env[baseEnv] || defaultBase);
13
+ const auth = (env) => ({ authorization: `Bearer ${credential(env, keys)}`, ...headers });
14
+ return {
15
+ id,
16
+ label,
17
+ kind: 'http',
18
+ docs,
19
+ keys,
20
+ modelEnv,
21
+ needsModel: true,
22
+ base,
23
+ chat: ({ prompt, model, env }) => ({
24
+ url: `${base(env)}/chat/completions`,
25
+ init: {
26
+ method: 'POST',
27
+ headers: { ...JSON_HEADERS, ...auth(env) },
28
+ body: JSON.stringify({ model, messages: [{ role: 'user', content: prompt }] }),
29
+ },
30
+ }),
31
+ readChat: (body) => body?.choices?.[0]?.message?.content ?? '',
32
+ catalog: ({ env }) => ({ url: `${base(env)}/models`, init: { headers: auth(env) } }),
33
+ readCatalog: (body) => (body?.data ?? []).map((item) => item?.id).filter(Boolean),
34
+ };
35
+ }
36
+
37
+ export const PROVIDERS = [
38
+ {
39
+ id: 'codex',
40
+ label: 'Codex CLI',
41
+ kind: 'cli',
42
+ docs: 'https://developers.openai.com/codex/cli',
43
+ command: 'codex',
44
+ // `--sandbox read-only` because a second opinion never needs to write, and
45
+ // `--skip-git-repo-check` because the call runs in a temporary directory, not a repository.
46
+ // Verified against `codex exec --help` (codex-cli 0.155.1); the prompt is an argument.
47
+ argv: ({ prompt }) => ['exec', '--sandbox', 'read-only', '--skip-git-repo-check', prompt],
48
+ stdin: () => undefined,
49
+ version: ['--version'],
50
+ },
51
+ {
52
+ id: 'gemini-cli',
53
+ label: 'Gemini CLI',
54
+ kind: 'cli',
55
+ docs: 'https://github.com/google-gemini/gemini-cli',
56
+ command: 'gemini',
57
+ // `-p ""` plus the briefing on stdin, as `--prompt` is appended to stdin input, and
58
+ // `--approval-mode plan`, the read-only mode (`gemini --help`, gemini-cli 0.34.0).
59
+ argv: () => ['-p', '', '-o', 'text', '--approval-mode', 'plan'],
60
+ stdin: ({ prompt }) => prompt,
61
+ version: ['--version'],
62
+ },
63
+ openAiCompatible({
64
+ id: 'openai',
65
+ label: 'OpenAI',
66
+ docs: 'https://platform.openai.com/docs/api-reference',
67
+ keys: ['OPENAI_API_KEY'],
68
+ modelEnv: 'OPENAI_MODEL',
69
+ baseEnv: 'OPENAI_BASE_URL',
70
+ defaultBase: 'https://api.openai.com/v1',
71
+ }),
72
+ openAiCompatible({
73
+ id: 'openrouter',
74
+ label: 'OpenRouter',
75
+ docs: 'https://openrouter.ai/docs',
76
+ keys: ['OPENROUTER_API_KEY'],
77
+ modelEnv: 'OPENROUTER_MODEL',
78
+ baseEnv: 'OPENROUTER_BASE_URL',
79
+ defaultBase: 'https://openrouter.ai/api/v1',
80
+ headers: { 'x-title': 'reis-mobile' },
81
+ }),
82
+ {
83
+ id: 'gemini',
84
+ label: 'Gemini API',
85
+ kind: 'http',
86
+ docs: 'https://ai.google.dev/gemini-api/docs',
87
+ keys: ['GEMINI_API_KEY', 'GOOGLE_API_KEY'],
88
+ modelEnv: 'GEMINI_MODEL',
89
+ needsModel: true,
90
+ base: geminiBase,
91
+ chat: ({ prompt, model, env }) => ({
92
+ url: `${geminiBase(env)}/models/${encodeURIComponent(model)}:generateContent`,
93
+ init: {
94
+ method: 'POST',
95
+ headers: { ...JSON_HEADERS, 'x-goog-api-key': credential(env, ['GEMINI_API_KEY', 'GOOGLE_API_KEY']) },
96
+ body: JSON.stringify({ contents: [{ role: 'user', parts: [{ text: prompt }] }] }),
97
+ },
98
+ }),
99
+ readChat: (body) =>
100
+ (body?.candidates?.[0]?.content?.parts ?? [])
101
+ .map((part) => part?.text)
102
+ .filter(Boolean)
103
+ .join(''),
104
+ catalog: ({ env }) => ({
105
+ url: `${geminiBase(env)}/models`,
106
+ init: { headers: { 'x-goog-api-key': credential(env, ['GEMINI_API_KEY', 'GOOGLE_API_KEY']) } },
107
+ }),
108
+ readCatalog: (body) => (body?.models ?? []).map((item) => String(item?.name ?? '').replace(/^models\//, '')).filter(Boolean),
109
+ },
110
+ {
111
+ id: 'ollama',
112
+ label: 'Ollama',
113
+ kind: 'http',
114
+ docs: 'https://github.com/ollama/ollama/blob/main/docs/api.md',
115
+ // No credential: a local server is either running or it is not, and only a request tells.
116
+ keys: [],
117
+ local: true,
118
+ modelEnv: 'OLLAMA_MODEL',
119
+ needsModel: true,
120
+ base: ollamaBase,
121
+ chat: ({ prompt, model, env }) => ({
122
+ url: `${ollamaBase(env)}/api/chat`,
123
+ init: {
124
+ method: 'POST',
125
+ headers: JSON_HEADERS,
126
+ body: JSON.stringify({ model, messages: [{ role: 'user', content: prompt }], stream: false }),
127
+ },
128
+ }),
129
+ readChat: (body) => body?.message?.content ?? '',
130
+ catalog: ({ env }) => ({ url: `${ollamaBase(env)}/api/tags`, init: {} }),
131
+ readCatalog: (body) => (body?.models ?? []).map((item) => item?.name).filter(Boolean),
132
+ },
133
+ ];
134
+
135
+ export const PROVIDER_IDS = PROVIDERS.map((provider) => provider.id);
136
+
137
+ export function findProvider(id) {
138
+ return PROVIDERS.find((provider) => provider.id === id) ?? null;
139
+ }
140
+
141
+ export function unknownProviderMessage(id) {
142
+ return `Unknown provider "${id}". Use one of: ${PROVIDER_IDS.join(', ')}`;
143
+ }
144
+
145
+ /** The endpoint a provider is reached at, so `providers` can show where a call would go. */
146
+ export function providerEndpoint(provider, env = process.env) {
147
+ return provider.kind === 'cli' ? provider.command : provider.base(env);
148
+ }
149
+
150
+ function geminiBase(env) {
151
+ return trimTrailingSlash(env.GEMINI_BASE_URL || 'https://generativelanguage.googleapis.com/v1beta');
152
+ }
153
+
154
+ /** `OLLAMA_HOST` is commonly set without a scheme (`127.0.0.1:11434`). */
155
+ function ollamaBase(env) {
156
+ const host = (env.OLLAMA_HOST || '').trim();
157
+ if (!host) return 'http://127.0.0.1:11434';
158
+ return trimTrailingSlash(/^https?:\/\//.test(host) ? host : `http://${host}`);
159
+ }
160
+
161
+ function credential(env, keys) {
162
+ for (const key of keys) if (env[key]) return env[key];
163
+ return '';
164
+ }
165
+
166
+ function trimTrailingSlash(value) {
167
+ return String(value).replace(/\/+$/, '');
168
+ }
@@ -0,0 +1,220 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { mkdtemp, rm } from 'node:fs/promises';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+
6
+ import { redactSecrets } from '../security/redact.mjs';
7
+ import { findProvider } from './catalog.mjs';
8
+
9
+ export const DEFAULT_TIMEOUT_MS = 120_000;
10
+ const MAX_OUTPUT_BYTES = 2_000_000;
11
+ const ERROR_BODY_CHARS = 300;
12
+
13
+ /**
14
+ * Asks each provider the same question, in parallel, and returns one result per requested
15
+ * provider.
16
+ *
17
+ * The core makes the call instead of handing the model a command to run: a credential must
18
+ * never reach a transcript or a tool log. The prompt is redacted on the way out — a
19
+ * third-party provider is exactly the boundary `redactSecrets` exists for — and every answer
20
+ * is redacted on the way in, because a provider can echo back what it was given.
21
+ *
22
+ * Statuses: `ok`, `unavailable` (the provider cannot be reached at all), `no-model`,
23
+ * `timeout`, `error`.
24
+ */
25
+ export async function consult({
26
+ prompt,
27
+ providers,
28
+ timeoutMs = DEFAULT_TIMEOUT_MS,
29
+ env = process.env,
30
+ fetchImpl = globalThis.fetch,
31
+ runCommand = spawnCommand,
32
+ } = {}) {
33
+ const text = redactSecrets(String(prompt ?? ''));
34
+ if (!text.trim()) throw new Error('consult needs a prompt. Pass it as an argument, with --file, or on stdin.');
35
+
36
+ const results = await Promise.all(
37
+ providers.map((resolved) => askOne({ resolved, prompt: text, timeoutMs, env, fetchImpl, runCommand })),
38
+ );
39
+ return {
40
+ prompt: { chars: text.length, redacted: text !== String(prompt ?? '') },
41
+ timeoutMs,
42
+ results,
43
+ };
44
+ }
45
+
46
+ async function askOne({ resolved, prompt, timeoutMs, env, fetchImpl, runCommand }) {
47
+ const base = {
48
+ provider: resolved.id,
49
+ label: resolved.label,
50
+ kind: resolved.kind,
51
+ model: resolved.model,
52
+ status: 'error',
53
+ text: '',
54
+ error: null,
55
+ durationMs: 0,
56
+ };
57
+ if (!resolved.selectable) return { ...base, status: 'unavailable', error: resolved.detail };
58
+ if (resolved.needsModel && !resolved.model) {
59
+ const provider = findProvider(resolved.id);
60
+ return {
61
+ ...base,
62
+ status: 'no-model',
63
+ error: `no model configured. Set ${provider.modelEnv}, or providers.${resolved.id}.model in the config file, or pass --model. Run \`reis-mobile providers --models --providers ${resolved.id}\` to see what it serves.`,
64
+ };
65
+ }
66
+
67
+ const started = Date.now();
68
+ const outcome = await (resolved.kind === 'cli'
69
+ ? askCli({ resolved, prompt, timeoutMs, env, runCommand })
70
+ : askHttp({ resolved, prompt, timeoutMs, env, fetchImpl }));
71
+ return { ...base, ...outcome, durationMs: Date.now() - started };
72
+ }
73
+
74
+ async function askHttp({ resolved, prompt, timeoutMs, env, fetchImpl }) {
75
+ const provider = findProvider(resolved.id);
76
+ const { url, init } = provider.chat({ prompt, model: resolved.model, env });
77
+ const response = await withTimeout(timeoutMs, (signal) => fetchImpl(url, { ...init, signal }));
78
+ if (response.timedOut) return { status: 'timeout', error: `no answer in ${timeoutMs} ms` };
79
+ if (response.failed) return { status: 'error', error: safe(response.error) };
80
+
81
+ if (!response.value.ok) {
82
+ const body = await readBody(response.value);
83
+ return { status: 'error', error: safe(`HTTP ${response.value.status}${body ? `: ${body.slice(0, ERROR_BODY_CHARS)}` : ''}`) };
84
+ }
85
+ let payload;
86
+ try {
87
+ payload = await response.value.json();
88
+ } catch {
89
+ return { status: 'error', error: 'the answer was not JSON' };
90
+ }
91
+ const text = safe(provider.readChat(payload));
92
+ return text.trim() ? { status: 'ok', text } : { status: 'error', error: 'the provider returned no text' };
93
+ }
94
+
95
+ /**
96
+ * CLI providers run in a throwaway directory. `codex exec --full-auto` and `gemini
97
+ * --approval-mode yolo` may write files, and the briefing already carries every file they
98
+ * need to read, so there is no reason to give them the user's repository as a workspace.
99
+ */
100
+ async function askCli({ resolved, prompt, timeoutMs, env, runCommand }) {
101
+ const provider = findProvider(resolved.id);
102
+ const workdir = await mkdtemp(join(tmpdir(), 'reis-mobile-consult-'));
103
+ try {
104
+ const result = await runCommand({
105
+ command: provider.command,
106
+ args: provider.argv({ prompt }),
107
+ stdin: provider.stdin({ prompt }),
108
+ cwd: workdir,
109
+ timeoutMs,
110
+ env,
111
+ });
112
+ if (result.status === 'timeout') return { status: 'timeout', error: `no answer in ${timeoutMs} ms` };
113
+ if (result.status === 'error') return { status: 'error', error: safe(result.error) };
114
+ const text = safe(result.stdout).trim();
115
+ return text ? { status: 'ok', text } : { status: 'error', error: 'the provider returned no text' };
116
+ } finally {
117
+ await rm(workdir, { recursive: true, force: true });
118
+ }
119
+ }
120
+
121
+ /** What each selected provider says it serves, so reis-mobile never invents a model id. */
122
+ export async function listProviderModels({ providers, env = process.env, fetchImpl = globalThis.fetch, timeoutMs = 30_000 } = {}) {
123
+ return Promise.all(
124
+ providers.map(async (resolved) => {
125
+ const base = { provider: resolved.id, label: resolved.label, kind: resolved.kind, models: [], status: 'error', error: null };
126
+ if (resolved.kind === 'cli') return { ...base, status: 'unsupported', error: 'a CLI provider uses the model it is configured with' };
127
+ if (!resolved.selectable) return { ...base, status: 'unavailable', error: resolved.detail };
128
+
129
+ const provider = findProvider(resolved.id);
130
+ const { url, init } = provider.catalog({ env });
131
+ const response = await withTimeout(timeoutMs, (signal) => fetchImpl(url, { ...init, signal }));
132
+ if (response.timedOut) return { ...base, status: 'timeout', error: `no answer in ${timeoutMs} ms` };
133
+ if (response.failed) return { ...base, status: 'error', error: safe(response.error) };
134
+ if (!response.value.ok) return { ...base, status: 'error', error: safe(`HTTP ${response.value.status}`) };
135
+ try {
136
+ return { ...base, status: 'ok', models: provider.readCatalog(await response.value.json()) };
137
+ } catch {
138
+ return { ...base, status: 'error', error: 'the answer was not JSON' };
139
+ }
140
+ }),
141
+ );
142
+ }
143
+
144
+ async function withTimeout(timeoutMs, call) {
145
+ const controller = new AbortController();
146
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
147
+ try {
148
+ return { value: await call(controller.signal) };
149
+ } catch (error) {
150
+ return controller.signal.aborted ? { timedOut: true } : { failed: true, error: error.message };
151
+ } finally {
152
+ clearTimeout(timer);
153
+ }
154
+ }
155
+
156
+ async function readBody(response) {
157
+ try {
158
+ return (await response.text()).trim().replace(/\s+/g, ' ');
159
+ } catch {
160
+ return '';
161
+ }
162
+ }
163
+
164
+ /** Collects stdout instead of using execFile, which cannot write to stdin. */
165
+ export function spawnCommand({ command, args, stdin, cwd, timeoutMs, env }) {
166
+ return new Promise((resolve) => {
167
+ let child;
168
+ try {
169
+ child = spawn(command, args, { cwd, env, stdio: ['pipe', 'pipe', 'pipe'] });
170
+ } catch (error) {
171
+ resolve({ status: 'error', error: error.message });
172
+ return;
173
+ }
174
+ let stdout = '';
175
+ let stderr = '';
176
+ let timedOut = false;
177
+ const timer = setTimeout(() => {
178
+ timedOut = true;
179
+ child.kill('SIGKILL');
180
+ }, timeoutMs);
181
+
182
+ child.stdout.on('data', (chunk) => {
183
+ if (stdout.length < MAX_OUTPUT_BYTES) stdout += chunk;
184
+ });
185
+ child.stderr.on('data', (chunk) => {
186
+ if (stderr.length < MAX_OUTPUT_BYTES) stderr += chunk;
187
+ });
188
+ child.stdin.on('error', () => {
189
+ // The provider closed stdin before reading the prompt; the exit code reports it.
190
+ });
191
+ child.on('error', (error) => {
192
+ clearTimeout(timer);
193
+ resolve({ status: 'error', error: error.code === 'ENOENT' ? `${command} not found in PATH` : error.message });
194
+ });
195
+ child.on('close', (code) => {
196
+ clearTimeout(timer);
197
+ if (timedOut) resolve({ status: 'timeout' });
198
+ else if (code === 0) resolve({ status: 'ok', stdout });
199
+ else resolve({ status: 'error', error: failureLine(stderr) || `${command} exited with code ${code}` });
200
+ });
201
+ child.stdin.end(stdin ?? '');
202
+ });
203
+ }
204
+
205
+ /** Nothing leaves this module without passing the redaction layer. */
206
+ function safe(value) {
207
+ return redactSecrets(String(value ?? ''));
208
+ }
209
+
210
+ /**
211
+ * The line of stderr that explains the failure. Agentic CLIs open with banners and notices,
212
+ * so the first line is usually noise and the cause is further down.
213
+ */
214
+ function failureLine(text) {
215
+ const lines = String(text ?? '')
216
+ .split('\n')
217
+ .map((line) => line.trim())
218
+ .filter(Boolean);
219
+ return lines.find((line) => /\b(error|failed|failure|unauthorized|forbidden|quota|unexpected|not found|invalid|denied)\b/i.test(line)) ?? lines.at(-1) ?? '';
220
+ }
@@ -0,0 +1,156 @@
1
+ import { accessSync, constants } from 'node:fs';
2
+ import { join } from 'node:path';
3
+
4
+ import { configPath, readConfig } from '../config/language.mjs';
5
+ import { PROVIDERS, PROVIDER_IDS, findProvider, providerEndpoint, unknownProviderMessage } from './catalog.mjs';
6
+
7
+ export { PROVIDERS, PROVIDER_IDS, findProvider, unknownProviderMessage } from './catalog.mjs';
8
+
9
+ /**
10
+ * Status of a provider, decided without a single network call so `doctor` and `providers`
11
+ * stay offline:
12
+ *
13
+ * - `ready` the executable is on PATH, or the credential is set
14
+ * - `local` keyless local server: selectable, proven only by an actual request
15
+ * - `missing` no executable and no credential
16
+ * - `disabled` turned off in the user config
17
+ * - `blocked` not in the `providers:` allowlist of .reis-mobile/config.yaml
18
+ */
19
+ const SELECTABLE = new Set(['ready', 'local']);
20
+
21
+ /**
22
+ * Finds an executable by scanning PATH instead of shelling out to `which`: one less
23
+ * subprocess, and a platform the tests can inject.
24
+ */
25
+ export function findExecutable(command, { env = process.env, platform = process.platform } = {}) {
26
+ const separator = platform === 'win32' ? ';' : ':';
27
+ const extensions = platform === 'win32' ? (env.PATHEXT || '.COM;.EXE;.BAT;.CMD').split(';') : [''];
28
+ for (const directory of String(env.PATH || '').split(separator).filter(Boolean)) {
29
+ for (const extension of extensions) {
30
+ const candidate = join(directory, `${command}${extension}`);
31
+ try {
32
+ accessSync(candidate, platform === 'win32' ? constants.F_OK : constants.X_OK);
33
+ return candidate;
34
+ } catch {
35
+ // Not here; keep scanning.
36
+ }
37
+ }
38
+ }
39
+ return null;
40
+ }
41
+
42
+ /** The allowlist a team commits in `.reis-mobile/config.yaml`, or null when absent. */
43
+ export function providerAllowlist(projectConfig) {
44
+ const value = projectConfig?.data?.providers;
45
+ if (value === undefined) return null;
46
+ const list = (Array.isArray(value) ? value : [value]).map((item) => String(item).trim()).filter(Boolean);
47
+ const unknown = list.filter((id) => !PROVIDER_IDS.includes(id));
48
+ if (unknown.length) {
49
+ return { ids: list.filter((id) => PROVIDER_IDS.includes(id)), warnings: unknown.map((id) => `${projectConfig.path}: ${unknownProviderMessage(id)}`) };
50
+ }
51
+ return { ids: list, warnings: [] };
52
+ }
53
+
54
+ /**
55
+ * Every provider with its status, resolved model and the evidence that decided both.
56
+ * `model` precedence: the `--model` flag, then the provider's model variable, then
57
+ * `providers.<id>.model` in the user config. CLI providers resolve no model: the spelling
58
+ * of their model flag is not verified here, so they keep the one they are configured with.
59
+ */
60
+ export async function resolveProviders({
61
+ env = process.env,
62
+ platform = process.platform,
63
+ model: flagModel,
64
+ allow = null,
65
+ config,
66
+ path = configPath({ env }),
67
+ } = {}) {
68
+ const settings = config ?? (await readConfig(path));
69
+ const providerSettings = settings.providers ?? {};
70
+ const warnings = [...(allow?.warnings ?? [])];
71
+
72
+ const providers = PROVIDERS.map((provider) => {
73
+ const setting = providerSettings[provider.id] ?? {};
74
+ const resolved = {
75
+ id: provider.id,
76
+ label: provider.label,
77
+ kind: provider.kind,
78
+ endpoint: providerEndpoint(provider, env),
79
+ docs: provider.docs,
80
+ needsModel: provider.needsModel === true,
81
+ model: null,
82
+ modelSource: null,
83
+ status: 'missing',
84
+ detail: '',
85
+ };
86
+
87
+ if (provider.kind === 'cli') {
88
+ const executable = findExecutable(provider.command, { env, platform });
89
+ resolved.status = executable ? 'ready' : 'missing';
90
+ resolved.detail = executable ?? `${provider.command} not found in PATH`;
91
+ resolved.model = null;
92
+ resolved.modelSource = 'provider';
93
+ if (setting.model || (flagModel && executable)) {
94
+ warnings.push(`${provider.id}: reis-mobile does not set the model of a CLI provider; it uses its own configuration`);
95
+ }
96
+ } else {
97
+ const key = provider.keys.find((candidate) => env[candidate]);
98
+ if (provider.local) {
99
+ resolved.status = 'local';
100
+ resolved.detail = `requires a server at ${resolved.endpoint}`;
101
+ } else {
102
+ resolved.status = key ? 'ready' : 'missing';
103
+ resolved.detail = key ? `${key} set` : `set ${provider.keys.join(' or ')}`;
104
+ }
105
+ const [value, source] = flagModel
106
+ ? [flagModel, '--model']
107
+ : env[provider.modelEnv]
108
+ ? [env[provider.modelEnv], provider.modelEnv]
109
+ : setting.model
110
+ ? [setting.model, `providers.${provider.id}.model`]
111
+ : [null, null];
112
+ resolved.model = value;
113
+ resolved.modelSource = source;
114
+ }
115
+
116
+ if (setting.enabled === false) {
117
+ resolved.status = 'disabled';
118
+ resolved.detail = `providers.${provider.id}.enabled is false`;
119
+ } else if (allow && !allow.ids.includes(provider.id)) {
120
+ resolved.status = 'blocked';
121
+ resolved.detail = 'not listed in the project providers allowlist';
122
+ }
123
+
124
+ resolved.selectable = SELECTABLE.has(resolved.status);
125
+ // A provider that needs a model and has none is selectable but not runnable: asking for
126
+ // it explicitly reports `no-model`, while the default selection leaves it out.
127
+ resolved.runnable = resolved.selectable && (!resolved.needsModel || Boolean(resolved.model));
128
+ if (resolved.selectable && !resolved.runnable) resolved.detail = `${resolved.detail} · no model configured`;
129
+ return resolved;
130
+ });
131
+
132
+ return { providers, warnings, configPath: path };
133
+ }
134
+
135
+ /**
136
+ * The providers a run should talk to. Requested ids that are not usable are kept in the
137
+ * result with their status instead of being dropped: a missing provider is information the
138
+ * caller has to report, not an error to swallow.
139
+ */
140
+ export function selectProviders(providers, requested, { requireModel = true } = {}) {
141
+ if (!requested || requested.length === 0) {
142
+ return providers.filter((provider) => (requireModel ? provider.runnable : provider.selectable));
143
+ }
144
+ const unknown = requested.filter((id) => !PROVIDER_IDS.includes(id));
145
+ if (unknown.length) throw new Error(unknownProviderMessage(unknown[0]));
146
+ return requested.map((id) => providers.find((provider) => provider.id === id));
147
+ }
148
+
149
+ /** `--providers codex,openai` */
150
+ export function parseProviderList(value) {
151
+ if (value === undefined || value === null) return null;
152
+ return String(value)
153
+ .split(',')
154
+ .map((item) => item.trim())
155
+ .filter(Boolean);
156
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "reis-mobile",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "description": "AI agents and skills for Flutter, Android, iOS, React Native and Kotlin Multiplatform — a Claude Code and Codex plugin for reviews, debugging, tests, architecture, security and Firebase.",
5
5
  "type": "module",
6
6
  "license": "MIT",