reis-mobile 0.7.3 → 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.
Files changed (54) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +39 -0
  4. package/README.md +100 -26
  5. package/agents/android-architect.md +3 -3
  6. package/agents/android-performance-engineer.md +1 -1
  7. package/agents/flutter-architect.md +3 -3
  8. package/agents/flutter-performance-engineer.md +1 -1
  9. package/agents/ios-architect.md +3 -3
  10. package/agents/ios-performance-engineer.md +1 -1
  11. package/agents/kmp-architect.md +3 -3
  12. package/agents/kmp-performance-engineer.md +1 -1
  13. package/agents/lead-mobile.md +3 -0
  14. package/agents/mobile-release-engineer.md +3 -3
  15. package/agents/mobile-staff-engineer.md +1 -1
  16. package/agents/rn-architect.md +3 -3
  17. package/agents/rn-performance-engineer.md +1 -1
  18. package/bin/reis-mobile.mjs +123 -1
  19. package/commands/consult.md +95 -0
  20. package/commands/debate.md +15 -11
  21. package/commands/reis-mobile.md +7 -2
  22. package/commands/review.md +2 -0
  23. package/core/config/project.mjs +1 -1
  24. package/core/diagnostics/doctor.mjs +23 -1
  25. package/core/providers/catalog.mjs +168 -0
  26. package/core/providers/consult.mjs +220 -0
  27. package/core/providers/providers.mjs +156 -0
  28. package/core/router/intents.mjs +15 -0
  29. package/package.json +1 -1
  30. package/skills/mobile-accessibility/SKILL.md +3 -0
  31. package/skills/mobile-accessibility/references/localization.md +77 -0
  32. package/skills/mobile-observability/SKILL.md +137 -0
  33. package/skills/mobile-observability/references/android.md +105 -0
  34. package/skills/mobile-observability/references/flutter.md +86 -0
  35. package/skills/mobile-observability/references/ios.md +88 -0
  36. package/skills/mobile-observability/references/kotlin-multiplatform.md +76 -0
  37. package/skills/mobile-observability/references/react-native.md +62 -0
  38. package/skills/mobile-offline-sync/SKILL.md +132 -0
  39. package/skills/mobile-offline-sync/references/android.md +123 -0
  40. package/skills/mobile-offline-sync/references/flutter.md +103 -0
  41. package/skills/mobile-offline-sync/references/ios.md +103 -0
  42. package/skills/mobile-offline-sync/references/kotlin-multiplatform.md +114 -0
  43. package/skills/mobile-offline-sync/references/react-native.md +108 -0
  44. package/skills/mobile-performance/SKILL.md +88 -0
  45. package/skills/mobile-performance/references/android.md +96 -0
  46. package/skills/mobile-performance/references/background.md +87 -0
  47. package/skills/mobile-performance/references/flutter.md +101 -0
  48. package/skills/mobile-performance/references/ios.md +82 -0
  49. package/skills/mobile-performance/references/kotlin-multiplatform.md +62 -0
  50. package/skills/mobile-performance/references/react-native.md +85 -0
  51. package/skills/mobile-release/SKILL.md +2 -1
  52. package/skills/mobile-release/references/billing.md +92 -0
  53. package/skills/mobile-security/SKILL.md +3 -0
  54. package/skills/mobile-security/references/auth.md +92 -0
@@ -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
+ }