reis-mobile 0.10.1 â 1.0.1
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.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +22 -0
- package/README.md +62 -23
- package/agents/mobile-code-reviewer.md +2 -0
- package/bin/reis-mobile.mjs +58 -58
- package/commands/review.md +2 -0
- package/core/commands/payloads.mjs +63 -0
- package/core/mcp/server.mjs +188 -0
- package/core/mcp/tools.mjs +226 -0
- package/package.json +7 -3
- package/skills/mobile-code-review/SKILL.md +5 -1
- package/skills/mobile-security/references/ios/keychain.md +105 -0
- package/skills/mobile-security/references/ios.md +1 -1
|
@@ -5,14 +5,14 @@
|
|
|
5
5
|
},
|
|
6
6
|
"metadata": {
|
|
7
7
|
"description": "AI agents for mobile engineering",
|
|
8
|
-
"version": "0.
|
|
8
|
+
"version": "1.0.1"
|
|
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.
|
|
15
|
+
"version": "1.0.1",
|
|
16
16
|
"author": {
|
|
17
17
|
"name": "Warleson Reis"
|
|
18
18
|
},
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "reis-mobile",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "1.0.1",
|
|
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,28 @@ Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). The pr
|
|
|
4
4
|
|
|
5
5
|
## [Unreleased]
|
|
6
6
|
|
|
7
|
+
## [1.0.1] - 2026-11-11
|
|
8
|
+
|
|
9
|
+
### Fixed
|
|
10
|
+
|
|
11
|
+
- Review instructions now prioritize applicable analyzer, linter and test results, separate confirmed tool diagnostics from additional findings, and disclose missing checks. Recurring defects with precise conditions should become regression tests or lint rules.
|
|
12
|
+
- README positioning now explains how AI review complements deterministic checks, replaces the lint-covered opening example, and distinguishes illustrative investigation targets from demonstrated detections.
|
|
13
|
+
|
|
14
|
+
## [1.0.0] - 2026-11-04
|
|
15
|
+
|
|
16
|
+
### Added
|
|
17
|
+
|
|
18
|
+
- `reis-mobile mcp`, a stdio MCP server for editors that cannot load the Claude Code plugin, such as Cursor. Seven tools (`reis_mobile_route`, `reis_mobile_review`, `reis_mobile_debug`, `reis_mobile_test`, `reis_mobile_release`, `reis_mobile_detect` and `reis_mobile_doctor`) return the same JSON as the matching `--json` command, and `reis_mobile_read_instructions` serves the agent, skill and reference files a route points to.
|
|
19
|
+
- Both MCP eras on one process: per-request `_meta` and `server/discover` from the 2026-07-28 revision, and the `initialize` handshake from 2025-11-25 back to 2024-11-05. Validated end to end with Claude Code 2.1.267; the Cursor and Codex configurations in the README follow each client's documented format.
|
|
20
|
+
- `--dir` on `reis-mobile mcp` sets the app directory for tool calls that pass none, so a client can pass its workspace folder.
|
|
21
|
+
- Every MCP tool is read-only and offline. `consult` and the commands that write are not exposed, and the instruction reader resolves symlinks and serves only `agents/`, `skills/` and `docs/agent-context.md`, up to 512 KB.
|
|
22
|
+
- iOS Keychain token storage guide in `mobile-security` (`references/ios/keychain.md`): accessibility classes and backups, biometric access control, access groups, reinstall persistence, migration out of `UserDefaults`, and a Swift token store with `OSStatus` handling. Contributed by @jay23606 in #21.
|
|
23
|
+
|
|
24
|
+
### Changed
|
|
25
|
+
|
|
26
|
+
- 1.0.0 is the first stable release for Flutter, native Android, native iOS and React Native, as planned in the roadmap, and ships the MCP server previously planned for v0.11.0.
|
|
27
|
+
- The read-only commands build their `--json` output in `core/commands/payloads.mjs`, shared by the CLI and the MCP server. The output itself is unchanged.
|
|
28
|
+
|
|
7
29
|
## [0.10.1] - 2026-11-04
|
|
8
30
|
|
|
9
31
|
### Added
|
package/README.md
CHANGED
|
@@ -1,9 +1,17 @@
|
|
|
1
1
|
# ðą reis-mobile
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
**reis-mobile** gives AI assistants mobile-specific context and workflows for code review, debugging, tests and architecture. It detects your project's stack, picks the right specialist and loads only the skills that apply to Flutter, Android, iOS, React Native or Kotlin Multiplatform.
|
|
4
4
|
|
|
5
5
|
**AI agents and skills for mobile development â Flutter, Android, iOS, React Native and Kotlin Multiplatform.** A plugin for Claude Code and Codex with specialist agents and skills for code review, debugging, tests, architecture, security and Firebase.
|
|
6
6
|
|
|
7
|
+
### How review fits with linters and tests
|
|
8
|
+
|
|
9
|
+
Use configured analyzers, linters and tests first. For example, Dart's [`use_build_context_synchronously`](https://dart.dev/tools/linter-rules/use_build_context_synchronously) checks `BuildContext` use across asynchronous gaps; detecting that pattern is not a reason to spend tokens on a review.
|
|
10
|
+
|
|
11
|
+
The review instructions ask the assistant to reuse available check results, distinguish tool diagnostics from additional findings, and trace behavior across files and lifecycle transitions. Investigation targets include a pending request repopulating user data after logout, an older search response replacing newer results, or retries conflicting with an API's idempotency contract. These are illustrative targets, not demonstrated detections or guarantees: each finding needs a concrete trigger and code evidence. The CLI gathers context; it does not automatically run the app's analyzers or tests, and missing check results must be disclosed.
|
|
12
|
+
|
|
13
|
+
When a recurring defect has a precise, enforceable condition, capture it in a regression test or lint rule. AI can help write that check. A passing analyzer does not prove an app correct, and an AI review does not replace deterministic checks. This project does not yet provide a reproducible benchmark demonstrating additional defect detection beyond those checks.
|
|
14
|
+
|
|
7
15
|
<p align="center">
|
|
8
16
|
<a href="https://github.com/wrsilva/reis-mobile/actions/workflows/ci.yml"><img src="https://github.com/wrsilva/reis-mobile/actions/workflows/ci.yml/badge.svg" alt="CI"></a>
|
|
9
17
|
<img src="https://img.shields.io/npm/v/reis-mobile" alt="Latest npm version">
|
|
@@ -37,6 +45,8 @@ A mobile app is not a generic project. A code review that does not know about `B
|
|
|
37
45
|
|
|
38
46
|
ð **Secrets never reach the model.** The diff sent for review goes through a layer that masks API keys, tokens, JWTs, private keys and keystore passwords.
|
|
39
47
|
|
|
48
|
+
ð **Works in any MCP client.** `reis-mobile mcp` serves stack detection, routing and the review, debug, test and release context to Cursor and other editors, with the agent and skill instructions behind them. See [MCP server](#mcp-server).
|
|
49
|
+
|
|
40
50
|
ðŠķ **Two commands to install.** `npm install -g reis-mobile` and `reis-mobile init eng` set it up in Claude Code and Codex, with zero npm dependencies and no external provider.
|
|
41
51
|
|
|
42
52
|
---
|
|
@@ -44,22 +54,12 @@ A mobile app is not a generic project. A code review that does not know about `B
|
|
|
44
54
|
## What's new
|
|
45
55
|
|
|
46
56
|
<!-- reis-mobile:latest-release:start -->
|
|
47
|
-
###
|
|
48
|
-
|
|
49
|
-
#### Added
|
|
50
|
-
|
|
51
|
-
- `mobile-ui-ux-designer` agent and `mobile-ui-ux` topic skill for mobile journeys, wireframes, design tokens, component states and evidence-based interface reviews across all five stacks.
|
|
52
|
-
- English and Portuguese `design` routing with platform skill selection and regression coverage for architecture, accessibility, tests and unrelated terms.
|
|
53
|
-
- Apple design reading workflow linked to `dickwu/apple-design-skill` at a recorded revision and to official HIG topics. Upstream content is linked, not vendored, because the inspected revision declares no redistribution license.
|
|
54
|
-
|
|
55
|
-
#### Changed
|
|
56
|
-
|
|
57
|
-
- The debate now invokes `lead-mobile` for participant selection before the rounds and again for final synthesis. The candidate table includes every specialist, including UI/UX, accessibility and release, with explicit participant validation and a persisted selection rationale.
|
|
58
|
-
- All five platform skills now route design work and link the UI/UX workflow and Apple-target reference.
|
|
57
|
+
### v1.0.1
|
|
59
58
|
|
|
60
59
|
#### Fixed
|
|
61
60
|
|
|
62
|
-
-
|
|
61
|
+
- Review instructions now prioritize applicable analyzer, linter and test results, separate confirmed tool diagnostics from additional findings, and disclose missing checks. Recurring defects with precise conditions should become regression tests or lint rules.
|
|
62
|
+
- README positioning now explains how AI review complements deterministic checks, replaces the lint-covered opening example, and distinguishes illustrative investigation targets from demonstrated detections.
|
|
63
63
|
|
|
64
64
|
<!-- reis-mobile:latest-release:end -->
|
|
65
65
|
|
|
@@ -197,7 +197,7 @@ reis-mobile route "review architecture" --json
|
|
|
197
197
|
reis-mobile review --json
|
|
198
198
|
```
|
|
199
199
|
|
|
200
|
-
You can also ask Codex: *"Run `reis-mobile review --json`, read the instructions in the returned files and review the changes using that context."* The CLI selects the instructions and gathers the context; Codex does the analysis.
|
|
200
|
+
You can also ask Codex: *"Run `reis-mobile review --json`, read the instructions in the returned files and review the changes using that context."* The CLI selects the instructions and gathers the context; Codex does the analysis. To let Codex call these commands as tools instead, register the [MCP server](#mcp-server).
|
|
201
201
|
</details>
|
|
202
202
|
|
|
203
203
|
<details>
|
|
@@ -366,6 +366,46 @@ The router never invents a specialist: if no agent serves the intent, it warns y
|
|
|
366
366
|
|
|
367
367
|
---
|
|
368
368
|
|
|
369
|
+
## MCP server
|
|
370
|
+
|
|
371
|
+
Editors that cannot load the Claude Code plugin, such as Cursor, can use reis-mobile through MCP. `reis-mobile mcp` is a stdio server shipped in the npm package, so install it with `npm install -g reis-mobile` first.
|
|
372
|
+
|
|
373
|
+
| Client | Register the server |
|
|
374
|
+
|---|---|
|
|
375
|
+
| Cursor | `.cursor/mcp.json` in the project, or `~/.cursor/mcp.json` for every project, as below |
|
|
376
|
+
| Claude Code | `claude mcp add reis-mobile -- reis-mobile mcp` |
|
|
377
|
+
| Codex | `codex mcp add reis-mobile -- reis-mobile mcp` |
|
|
378
|
+
|
|
379
|
+
```json
|
|
380
|
+
{
|
|
381
|
+
"mcpServers": {
|
|
382
|
+
"reis-mobile": {
|
|
383
|
+
"command": "reis-mobile",
|
|
384
|
+
"args": ["mcp", "--dir", "${workspaceFolder}"]
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
```
|
|
389
|
+
|
|
390
|
+
`--dir` is the app directory a tool uses when the call does not pass `dir`; Cursor replaces `${workspaceFolder}` with the project folder. In Claude Code the plugin already provides the agents and commands, so the server matters most in the other clients.
|
|
391
|
+
|
|
392
|
+
| Tool | Returns |
|
|
393
|
+
|---|---|
|
|
394
|
+
| `reis_mobile_route` | Intent, stack, agent and skill paths for a request |
|
|
395
|
+
| `reis_mobile_review` | The review route and the git diff, lock files out and secrets masked |
|
|
396
|
+
| `reis_mobile_debug` | The debug route and the installed toolchain versions |
|
|
397
|
+
| `reis_mobile_test` | The test route, the diff and the toolchain checks; runs no tests |
|
|
398
|
+
| `reis_mobile_release` | Candidate release targets and references; builds and uploads nothing |
|
|
399
|
+
| `reis_mobile_detect` | Stack, languages, platforms and the evidence for each |
|
|
400
|
+
| `reis_mobile_doctor` | SDK and project file checks for the detected stack |
|
|
401
|
+
| `reis_mobile_read_instructions` | The agent, skill or reference file a route points to |
|
|
402
|
+
|
|
403
|
+
The model calls a routing tool, reads the instructions it returns and follows them, the same flow the slash commands use. Each tool returns the same JSON as its `--json` command. Every tool is read-only and offline, so `consult`, which sends context to a third party, is not exposed. The server speaks both MCP eras: the 2026-07-28 revision with per-request metadata, and the `initialize` handshake of 2025-11-25 back to 2024-11-05.
|
|
404
|
+
|
|
405
|
+
Validated end to end with Claude Code 2.1.267. The Cursor and Codex entries follow each client's documented configuration but have not been run against those clients yet; if you try one, tell us in an issue.
|
|
406
|
+
|
|
407
|
+
---
|
|
408
|
+
|
|
369
409
|
## Choose by goal
|
|
370
410
|
|
|
371
411
|
| I want to... | Use | Status |
|
|
@@ -400,7 +440,7 @@ The router never invents a specialist: if no agent serves the intent, it warns y
|
|
|
400
440
|
|---|---|---|
|
|
401
441
|
| **Domain** | Generic | Flutter, Android, iOS, React Native, KMP |
|
|
402
442
|
| **Project context** | You explain the stack | Deterministic detection of stack, platform and variant |
|
|
403
|
-
| **Review** |
|
|
443
|
+
| **Review** | General review instructions | Mobile workflows: existing check results, lifecycle and cross-file behavior, findings with code evidence |
|
|
404
444
|
| **Secrets in the diff** | Sent as they are | Masked before reaching the model |
|
|
405
445
|
| **Specialists** | None | 22 agents and 27 mobile skills |
|
|
406
446
|
| **Context cost** | Zero | ~5,500 fixed tokens (agent and skill descriptions), no hooks |
|
|
@@ -491,7 +531,7 @@ Every skill starts with `mobile-`. **Topic** skills cover every stack with one r
|
|
|
491
531
|
| `mobile-code-review` | Review process and checklists | review, performance | Flutter (PR review, widgets, project audit, static analysis), Android, iOS, React Native, KMP |
|
|
492
532
|
| `mobile-debug` | Failing builds and runtime errors | debug, build, dependency, migration | Flutter (build, runtime, layout, packages), Android (Gradle, crashes, ANRs, memory leaks), iOS (Xcode, CocoaPods, crash logs), React Native (Metro, native modules), KMP, deep links |
|
|
493
533
|
| `mobile-test` | Unit, UI, integration and end-to-end tests | test | Flutter (widget, integration, Patrol, mocks, coverage), Android, iOS, React Native, KMP |
|
|
494
|
-
| `mobile-security` | OWASP MASVS security checks | review, security, release | Flutter (token storage), Android (intents), iOS, React Native, KMP, OAuth/OIDC and PKCE |
|
|
534
|
+
| `mobile-security` | OWASP MASVS security checks | review, security, release | Flutter (token storage), Android (intents), iOS (Keychain token storage), React Native, KMP, OAuth/OIDC and PKCE |
|
|
495
535
|
| `mobile-ui-ux` | Journeys, wireframes, visual hierarchy, tokens, component states and Apple HIG reference workflow | design | Flutter, Android, iOS, React Native, KMP and external Apple design reading |
|
|
496
536
|
| `mobile-accessibility` | Screen readers, touch targets, text scaling, contrast, localization | accessibility | Flutter (Semantics), Android (Compose and Views), iOS (SwiftUI and UIKit), React Native, KMP, localization and RTL |
|
|
497
537
|
| `mobile-release` | Release readiness, store copy, billing and CI/CD | release, deployment | Flutter, Android, iOS, React Native, KMP, release notes and store listing, in-app purchases, CI/CD |
|
|
@@ -539,11 +579,11 @@ About 85 guides come from [flutter/skills](https://github.com/flutter/skills), [
|
|
|
539
579
|
|
|
540
580
|
## Trust, security and limits
|
|
541
581
|
|
|
542
|
-
**Read-only CLI context.** `doctor`, `detect`, `route`, `review`, `test`, `release`, `providers` and `
|
|
582
|
+
**Read-only CLI context.** `doctor`, `detect`, `route`, `review`, `test`, `release`, `providers`, `consult` and the `mcp` server's tools 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/`.
|
|
543
583
|
|
|
544
584
|
**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).
|
|
545
585
|
|
|
546
|
-
**No telemetry.** `detect`, `doctor`, `route`, `review`, `debug`, `test` and `
|
|
586
|
+
**No telemetry.** `detect`, `doctor`, `route`, `review`, `debug`, `test`, `providers` and the `mcp` server 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.
|
|
547
587
|
|
|
548
588
|
**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.
|
|
549
589
|
|
|
@@ -578,8 +618,7 @@ About 85 guides come from [flutter/skills](https://github.com/flutter/skills), [
|
|
|
578
618
|
| 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 | â
|
|
|
579
619
|
| 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 | â
|
|
|
580
620
|
| v0.10.0 | `mobile-privacy`, `mobile-networking`, `mobile-auth` and `mobile-notifications` topic skills with a reference per stack, plus the `privacy`, `networking`, `auth` and `notifications` intents | â
|
|
|
581
|
-
|
|
|
582
|
-
| v1.0.0 | First stable version: Flutter, Android, iOS and React Native | âģ |
|
|
621
|
+
| v1.0.0 | First stable version: Flutter, Android, iOS and React Native, with the MCP server previously planned for v0.11.0 | â
|
|
|
583
622
|
|
|
584
623
|
---
|
|
585
624
|
|
|
@@ -607,10 +646,10 @@ Yes. Native Android, native iOS and React Native each have architect, performanc
|
|
|
607
646
|
The plugin's skills live in the `reis-mobile:` namespace and do not conflict, but Claude Code loads both descriptions. To save context, remove the global copies the plugin already covers.
|
|
608
647
|
|
|
609
648
|
**Does it work in Codex?**
|
|
610
|
-
Yes: `reis-mobile init eng` installs the plugin in Codex too. See [installation](#install). Installation does not confirm parity with the Claude Code agents and commands; the `reis-mobile` CLI can also be run by Codex to get diagnostics and context.
|
|
649
|
+
Yes: `reis-mobile init eng` installs the plugin in Codex too. See [installation](#install). Installation does not confirm parity with the Claude Code agents and commands; the `reis-mobile` CLI can also be run by Codex to get diagnostics and context, and the [MCP server](#mcp-server) exposes the same commands as tools.
|
|
611
650
|
|
|
612
651
|
**Does it work in Cursor?**
|
|
613
|
-
The Cursor
|
|
652
|
+
Through the [MCP server](#mcp-server): register `reis-mobile mcp` in `.cursor/mcp.json`. The configuration follows Cursor's documented format but has not been run against Cursor yet. Cursor gets the tools and the instructions, not the Claude Code agents, slash commands or hooks.
|
|
614
653
|
|
|
615
654
|
**What does reis-mobile send outside my machine?**
|
|
616
655
|
The project analysis commands do not make network requests. The update hook and `reis-mobile update-check` request the public npm package metadata without sending project files or identifiers. What the model reads during `/reis-mobile:review` follows the same rules as any Claude Code session.
|
|
@@ -18,6 +18,8 @@ Resolve the requested base/head or working-tree scope, including staged, unstage
|
|
|
18
18
|
|
|
19
19
|
## Prove a finding
|
|
20
20
|
|
|
21
|
+
Start with configured analyzer, linter and test results for the reviewed revision and scope. Reuse applicable results or run existing checks when available and permitted; disclose missing or stale evidence. Summarize confirmed tool diagnostics separately from additional review findings, including correctness and security diagnostics. A potentially applicable rule alone is not evidence that a defect is covered. Focus additional analysis on behavior across files, lifecycle transitions and API contracts.
|
|
22
|
+
|
|
21
23
|
1. Read the complete changed function/type and its callers, tests and platform configuration. Identify the input, lifecycle transition or configuration that activates the suspected defect.
|
|
22
24
|
2. Trace the consequence across the changed boundary: lost data, crash, stale state, permission failure, exposed secret or broken platform behavior. For async changes, identify who owns and cancels the work and whether a late result can still reach the UI.
|
|
23
25
|
3. Compare with the base behavior and surrounding conventions. Separate newly introduced regressions from pre-existing issues, and check whether a wrapper, validation layer or existing test already handles the case.
|
package/bin/reis-mobile.mjs
CHANGED
|
@@ -3,19 +3,25 @@ import { readFile } from 'node:fs/promises';
|
|
|
3
3
|
import { join } from 'node:path';
|
|
4
4
|
import { parseArgs } from 'node:util';
|
|
5
5
|
|
|
6
|
+
import {
|
|
7
|
+
debugPayload,
|
|
8
|
+
detectPayload,
|
|
9
|
+
doctorPayload,
|
|
10
|
+
releasePayload,
|
|
11
|
+
reviewPayload,
|
|
12
|
+
routePayload,
|
|
13
|
+
testPayload,
|
|
14
|
+
} from '../core/commands/payloads.mjs';
|
|
6
15
|
import { LANGUAGES, configPath, removeConfig, resolveLanguage, saveLanguage } from '../core/config/language.mjs';
|
|
7
16
|
import { resolveProjectDir } from '../core/config/project.mjs';
|
|
8
|
-
import { collectReviewContext } from '../core/context/context-engine.mjs';
|
|
9
17
|
import { detectStack } from '../core/detection/stack-detector.mjs';
|
|
10
|
-
import { runDoctor } from '../core/diagnostics/doctor.mjs';
|
|
11
18
|
import { runInit } from '../core/install/init.mjs';
|
|
19
|
+
import { serveStdio } from '../core/mcp/server.mjs';
|
|
12
20
|
import { PLUGIN_ROOT } from '../core/paths.mjs';
|
|
13
21
|
import { DEFAULT_TIMEOUT_MS, consult, listProviderModels } from '../core/providers/consult.mjs';
|
|
14
22
|
import { parseProviderList, providerAllowlist, resolveProviders, selectProviders } from '../core/providers/providers.mjs';
|
|
15
23
|
import { loadRegistry, validateRegistry } from '../core/registry/registry.mjs';
|
|
16
|
-
import { releaseContext } from '../core/release/context.mjs';
|
|
17
24
|
import { INTENT_IDS } from '../core/router/intents.mjs';
|
|
18
|
-
import { route } from '../core/router/router.mjs';
|
|
19
25
|
import { checkForUpdate, clearUpdateCache } from '../core/update/check.mjs';
|
|
20
26
|
|
|
21
27
|
const USAGE = `reis-mobile â AI agents for mobile engineering
|
|
@@ -34,12 +40,13 @@ Commands:
|
|
|
34
40
|
release [request...] Prepare release evidence context (does not build or publish)
|
|
35
41
|
providers List the external model providers and what each one needs
|
|
36
42
|
consult [question...] Ask the configured external providers the same question
|
|
43
|
+
mcp Serve the read-only commands to MCP clients over stdio (Cursor, Claude Code, Codex...)
|
|
37
44
|
agents | skills | stacks List registered components
|
|
38
45
|
validate Validate the plugin's agents, skills and stacks
|
|
39
46
|
update-check Check npm for a newer reis-mobile release
|
|
40
47
|
|
|
41
48
|
Options:
|
|
42
|
-
--dir <path> Project directory (default: current directory, or "app" in .reis-mobile/config.yaml)
|
|
49
|
+
--dir <path> Project directory (default: current directory, or "app" in .reis-mobile/config.yaml); mcp: default for tool calls
|
|
43
50
|
--intent <id> Force an intent (${INTENT_IDS.join(', ')})
|
|
44
51
|
--base <ref> review/test: compare <ref>...HEAD instead of the working tree
|
|
45
52
|
--all doctor: check every tool, not only the relevant ones
|
|
@@ -91,6 +98,7 @@ const COMMANDS = {
|
|
|
91
98
|
release: commandRelease,
|
|
92
99
|
providers: commandProviders,
|
|
93
100
|
consult: commandConsult,
|
|
101
|
+
mcp: commandMcp,
|
|
94
102
|
agents: (options) => commandList('agents', options),
|
|
95
103
|
skills: (options) => commandList('skills', options),
|
|
96
104
|
stacks: (options) => commandList('stacks', options),
|
|
@@ -182,10 +190,9 @@ async function commandLang({ prompt, json }) {
|
|
|
182
190
|
return 0;
|
|
183
191
|
}
|
|
184
192
|
|
|
185
|
-
async function commandDetect({
|
|
186
|
-
const detection = await
|
|
187
|
-
|
|
188
|
-
if (json) return print({ ...detection, language, projectConfig: project.config?.path ?? null });
|
|
193
|
+
async function commandDetect({ project, json, lang }) {
|
|
194
|
+
const detection = await detectPayload({ project, lang });
|
|
195
|
+
if (json) return print(detection);
|
|
189
196
|
|
|
190
197
|
printProject(project);
|
|
191
198
|
|
|
@@ -195,21 +202,20 @@ async function commandDetect({ dir, project, json, lang }) {
|
|
|
195
202
|
for (const evidence of detection.evidence) console.log(` · ${evidence}`);
|
|
196
203
|
const others = detection.candidates.slice(1).map((candidate) => candidate.stack);
|
|
197
204
|
if (others.length) console.log(`Also matches ${others.join(', ')}`);
|
|
198
|
-
printLanguage(language);
|
|
205
|
+
printLanguage(detection.language);
|
|
199
206
|
return detection.stack === 'unknown' ? 2 : 0;
|
|
200
207
|
}
|
|
201
208
|
|
|
202
|
-
async function commandDoctor({
|
|
203
|
-
const report = await
|
|
204
|
-
const language = await resolveLanguage({ flag: lang });
|
|
209
|
+
async function commandDoctor({ project, all, strict, json, lang }) {
|
|
210
|
+
const report = await doctorPayload({ project, all, lang });
|
|
205
211
|
if (json) {
|
|
206
|
-
print(
|
|
212
|
+
print(report);
|
|
207
213
|
} else {
|
|
208
214
|
console.log('reis-mobile doctor\n');
|
|
209
215
|
printProject(project);
|
|
210
216
|
printDoctorChecks(report);
|
|
211
217
|
console.log(report.warnings ? `${report.warnings} warning(s)` : 'No issues found');
|
|
212
|
-
printLanguage(language);
|
|
218
|
+
printLanguage(report.language);
|
|
213
219
|
}
|
|
214
220
|
return strict && report.warnings ? 1 : 0;
|
|
215
221
|
}
|
|
@@ -226,30 +232,27 @@ function printDoctorChecks(report) {
|
|
|
226
232
|
|
|
227
233
|
const SYMBOLS = { ok: 'â', warning: 'â', info: '-', skipped: '-' };
|
|
228
234
|
|
|
229
|
-
async function commandRoute({
|
|
235
|
+
async function commandRoute({ project, intent, prompt, json, lang }) {
|
|
230
236
|
if (!prompt && !intent) {
|
|
231
237
|
console.error('route needs a prompt or --intent.');
|
|
232
238
|
return 1;
|
|
233
239
|
}
|
|
234
|
-
const result = await
|
|
235
|
-
|
|
236
|
-
if (json) return print({ ...summarizeRoute(result), language, projectConfig: project.config?.path ?? null });
|
|
240
|
+
const result = await routePayload({ project, prompt, intent, lang });
|
|
241
|
+
if (json) return print(result);
|
|
237
242
|
printProject(project);
|
|
238
243
|
printRoute(result);
|
|
239
|
-
printLanguage(language);
|
|
244
|
+
printLanguage(result.language);
|
|
240
245
|
return 0;
|
|
241
246
|
}
|
|
242
247
|
|
|
243
|
-
async function commandReview({
|
|
244
|
-
const result = await
|
|
245
|
-
|
|
246
|
-
const language = await resolveLanguage({ flag: lang });
|
|
247
|
-
if (json) return print({ ...summarizeRoute(result), language, projectConfig: project.config?.path ?? null, context });
|
|
248
|
+
async function commandReview({ project, base, prompt, json, lang }) {
|
|
249
|
+
const result = await reviewPayload({ project, base, prompt, lang });
|
|
250
|
+
if (json) return print(result);
|
|
248
251
|
|
|
249
252
|
printProject(project);
|
|
250
253
|
printRoute(result);
|
|
251
|
-
printLanguage(language);
|
|
252
|
-
printContext(context);
|
|
254
|
+
printLanguage(result.language);
|
|
255
|
+
printContext(result.context);
|
|
253
256
|
return 0;
|
|
254
257
|
}
|
|
255
258
|
|
|
@@ -264,55 +267,57 @@ function printContext(context) {
|
|
|
264
267
|
* Routes a failure to the debug agent and skills, and adds the doctor report: most build
|
|
265
268
|
* failures are toolchain mismatches (JDK, Gradle, Xcode, CocoaPods), which the report shows.
|
|
266
269
|
*/
|
|
267
|
-
async function commandDebug({
|
|
268
|
-
const result = await
|
|
269
|
-
|
|
270
|
-
const language = await resolveLanguage({ flag: lang });
|
|
271
|
-
if (json) return print({ ...summarizeRoute(result), language, projectConfig: project.config?.path ?? null, doctor });
|
|
270
|
+
async function commandDebug({ project, prompt, json, lang }) {
|
|
271
|
+
const result = await debugPayload({ project, prompt, lang });
|
|
272
|
+
if (json) return print(result);
|
|
272
273
|
|
|
273
274
|
printProject(project);
|
|
274
275
|
printRoute(result);
|
|
275
|
-
printLanguage(language);
|
|
276
|
+
printLanguage(result.language);
|
|
276
277
|
console.log('');
|
|
277
|
-
printDoctorChecks(doctor);
|
|
278
|
+
printDoctorChecks(result.doctor);
|
|
278
279
|
return 0;
|
|
279
280
|
}
|
|
280
281
|
|
|
281
282
|
/** Prepare evidence for the test agent; running the selected suite is the agent's job. */
|
|
282
|
-
async function commandTest({
|
|
283
|
-
const result = await
|
|
284
|
-
|
|
285
|
-
const doctor = await runDoctor({ projectDir: dir });
|
|
286
|
-
const language = await resolveLanguage({ flag: lang });
|
|
287
|
-
if (json) return print({ ...summarizeRoute(result), language, projectConfig: project.config?.path ?? null, context, doctor });
|
|
283
|
+
async function commandTest({ project, base, prompt, json, lang }) {
|
|
284
|
+
const result = await testPayload({ project, base, prompt, lang });
|
|
285
|
+
if (json) return print(result);
|
|
288
286
|
|
|
289
287
|
// A test runner needs an explicit working directory even without a monorepo config.
|
|
290
288
|
printProject(project, true);
|
|
291
289
|
printRoute(result);
|
|
292
|
-
printLanguage(language);
|
|
293
|
-
printContext(context);
|
|
290
|
+
printLanguage(result.language);
|
|
291
|
+
printContext(result.context);
|
|
294
292
|
console.log('\nTest context prepared; no test suites were executed.\n');
|
|
295
|
-
printDoctorChecks(doctor);
|
|
293
|
+
printDoctorChecks(result.doctor);
|
|
296
294
|
return 0;
|
|
297
295
|
}
|
|
298
296
|
|
|
299
297
|
/** Prepare release evidence; the specialist audits it before assigning a verdict. */
|
|
300
|
-
async function commandRelease({
|
|
301
|
-
const result = await
|
|
302
|
-
|
|
303
|
-
const doctor = await runDoctor({ projectDir: dir });
|
|
304
|
-
const language = await resolveLanguage({ flag: lang });
|
|
305
|
-
const release = releaseContext(result.detection);
|
|
306
|
-
if (json) return print({ ...summarizeRoute(result), language, projectConfig: project.config?.path ?? null, context, doctor, release });
|
|
298
|
+
async function commandRelease({ project, prompt, json, lang }) {
|
|
299
|
+
const result = await releasePayload({ project, prompt, lang });
|
|
300
|
+
if (json) return print(result);
|
|
307
301
|
|
|
302
|
+
const { release } = result;
|
|
308
303
|
printProject(project, true);
|
|
309
304
|
printRoute(result);
|
|
310
|
-
printLanguage(language);
|
|
311
|
-
printContext(context);
|
|
305
|
+
printLanguage(result.language);
|
|
306
|
+
printContext(result.context);
|
|
312
307
|
console.log(`\nRelease platforms (verify) ${release.platforms.join(', ') || 'unknown'}`);
|
|
313
308
|
for (const reference of release.references) console.log(` Reference ${reference}`);
|
|
314
309
|
console.log('\nRelease context prepared; readiness has not been evaluated. No builds, tests or uploads were executed.\n');
|
|
315
|
-
printDoctorChecks(doctor);
|
|
310
|
+
printDoctorChecks(result.doctor);
|
|
311
|
+
return 0;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Serves the read-only commands to MCP clients over stdio. Nothing but protocol messages may
|
|
316
|
+
* reach stdout, so this command prints nothing itself; `--dir` is the default for tool calls.
|
|
317
|
+
*/
|
|
318
|
+
async function commandMcp({ dir }) {
|
|
319
|
+
const pkg = JSON.parse(await readFile(join(PLUGIN_ROOT, 'package.json'), 'utf8'));
|
|
320
|
+
await serveStdio({ version: pkg.version, defaultDir: dir });
|
|
316
321
|
return 0;
|
|
317
322
|
}
|
|
318
323
|
|
|
@@ -450,11 +455,6 @@ async function commandValidate({ json }) {
|
|
|
450
455
|
return errors.length ? 1 : 0;
|
|
451
456
|
}
|
|
452
457
|
|
|
453
|
-
function summarizeRoute(result) {
|
|
454
|
-
const component = (item) => item && { name: item.name, description: item.description, path: item.path };
|
|
455
|
-
return { ...result, agent: component(result.agent), skills: result.skills.map(component) };
|
|
456
|
-
}
|
|
457
|
-
|
|
458
458
|
function printRoute(result) {
|
|
459
459
|
const confidence = result.intentSource === 'explicit' ? 'explicit' : `confidence ${result.confidence}`;
|
|
460
460
|
console.log(`Intent ${result.intent ?? '-'} (${confidence})${result.area ? ` · area ${result.area}` : ''}`);
|
package/commands/review.md
CHANGED
|
@@ -41,6 +41,8 @@ Read with the Read tool, in this order:
|
|
|
41
41
|
|
|
42
42
|
Follow the agent's process, applying the skills' checklists:
|
|
43
43
|
|
|
44
|
+
- First inspect available analyzer, linter and test results for this revision and scope. Run existing checks only when available and permitted; otherwise state what was not checked. The CLI gathers context and does not execute these checks automatically. Summarize confirmed tool diagnostics separately from additional review findings.
|
|
45
|
+
|
|
44
46
|
- **working-tree / range:** review the changed files. Read the full file when the diff does not give enough context. `untracked` files do not appear in the diff: read them directly. If the diff was truncated, read the remaining files.
|
|
45
47
|
- **project:** there are no changes; audit the project starting from the entry points and the data layer.
|
|
46
48
|
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { resolveLanguage } from '../config/language.mjs';
|
|
2
|
+
import { collectReviewContext } from '../context/context-engine.mjs';
|
|
3
|
+
import { detectStack } from '../detection/stack-detector.mjs';
|
|
4
|
+
import { runDoctor } from '../diagnostics/doctor.mjs';
|
|
5
|
+
import { releaseContext } from '../release/context.mjs';
|
|
6
|
+
import { route } from '../router/router.mjs';
|
|
7
|
+
|
|
8
|
+
// The `--json` output of the read-only commands. The CLI prints it and the MCP server returns
|
|
9
|
+
// it, so both surfaces describe a project the same way. `project` is resolveProjectDir's result.
|
|
10
|
+
|
|
11
|
+
export async function detectPayload({ project, lang }) {
|
|
12
|
+
const detection = await detectStack(project.projectDir);
|
|
13
|
+
return { ...detection, ...(await common(project, lang)) };
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export async function doctorPayload({ project, all, lang }) {
|
|
17
|
+
const report = await runDoctor({ projectDir: project.projectDir, all });
|
|
18
|
+
return { ...report, ...(await common(project, lang)) };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export async function routePayload({ project, prompt, intent, lang }) {
|
|
22
|
+
const result = await route({ prompt, intent, projectDir: project.projectDir });
|
|
23
|
+
return { ...summarizeRoute(result), ...(await common(project, lang)) };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export async function reviewPayload({ project, base, prompt, lang }) {
|
|
27
|
+
const result = await route({ prompt, intent: 'review', projectDir: project.projectDir });
|
|
28
|
+
const context = collectReviewContext({ projectDir: project.projectDir, base });
|
|
29
|
+
return { ...summarizeRoute(result), ...(await common(project, lang)), context };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Most build failures are toolchain mismatches, so the doctor report travels with the route. */
|
|
33
|
+
export async function debugPayload({ project, prompt, lang }) {
|
|
34
|
+
const result = await route({ prompt, intent: 'debug', projectDir: project.projectDir });
|
|
35
|
+
const doctor = await runDoctor({ projectDir: project.projectDir });
|
|
36
|
+
return { ...summarizeRoute(result), ...(await common(project, lang)), doctor };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Evidence for the test agent; running the selected suite is the agent's job. */
|
|
40
|
+
export async function testPayload({ project, base, prompt, lang }) {
|
|
41
|
+
const result = await route({ prompt, intent: 'test', projectDir: project.projectDir });
|
|
42
|
+
const context = collectReviewContext({ projectDir: project.projectDir, base });
|
|
43
|
+
const doctor = await runDoctor({ projectDir: project.projectDir });
|
|
44
|
+
return { ...summarizeRoute(result), ...(await common(project, lang)), context, doctor };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Release evidence; the specialist audits it before assigning a verdict. */
|
|
48
|
+
export async function releasePayload({ project, prompt, lang }) {
|
|
49
|
+
const result = await route({ prompt, intent: 'release', projectDir: project.projectDir });
|
|
50
|
+
const context = collectReviewContext({ projectDir: project.projectDir });
|
|
51
|
+
const doctor = await runDoctor({ projectDir: project.projectDir });
|
|
52
|
+
const release = releaseContext(result.detection);
|
|
53
|
+
return { ...summarizeRoute(result), ...(await common(project, lang)), context, doctor, release };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function common(project, lang) {
|
|
57
|
+
return { language: await resolveLanguage({ flag: lang }), projectConfig: project.config?.path ?? null };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function summarizeRoute(result) {
|
|
61
|
+
const component = (item) => item && { name: item.name, description: item.description, path: item.path };
|
|
62
|
+
return { ...result, agent: component(result.agent), skills: result.skills.map(component) };
|
|
63
|
+
}
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import { once } from 'node:events';
|
|
2
|
+
import { createInterface } from 'node:readline';
|
|
3
|
+
|
|
4
|
+
import { callTool, hasTool, listTools } from './tools.mjs';
|
|
5
|
+
|
|
6
|
+
// A dual-era MCP server. Modern clients (2026-07-28) send the protocol version and their
|
|
7
|
+
// capabilities in every request's `_meta` and may probe with `server/discover`; legacy clients
|
|
8
|
+
// (2025-11-25 and earlier) open with an `initialize` handshake. Both are served on one process.
|
|
9
|
+
export const MODERN_VERSIONS = ['2026-07-28'];
|
|
10
|
+
export const LEGACY_VERSIONS = ['2025-11-25', '2025-06-18', '2025-03-26', '2024-11-05'];
|
|
11
|
+
const SUPPORTED_VERSIONS = [...MODERN_VERSIONS, ...LEGACY_VERSIONS];
|
|
12
|
+
|
|
13
|
+
const META_VERSION = 'io.modelcontextprotocol/protocolVersion';
|
|
14
|
+
const META_CAPABILITIES = 'io.modelcontextprotocol/clientCapabilities';
|
|
15
|
+
const META_SERVER_INFO = 'io.modelcontextprotocol/serverInfo';
|
|
16
|
+
|
|
17
|
+
export const ERROR_CODES = {
|
|
18
|
+
parse: -32700,
|
|
19
|
+
invalidRequest: -32600,
|
|
20
|
+
methodNotFound: -32601,
|
|
21
|
+
invalidParams: -32602,
|
|
22
|
+
internal: -32603,
|
|
23
|
+
unsupportedVersion: -32022,
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const CAPABILITIES = { tools: {} };
|
|
27
|
+
|
|
28
|
+
const INSTRUCTIONS =
|
|
29
|
+
'reis-mobile prepares context for Flutter, native Android, native iOS, React Native/Expo and Kotlin Multiplatform apps. ' +
|
|
30
|
+
'Call reis_mobile_route, or reis_mobile_review, reis_mobile_debug, reis_mobile_test or reis_mobile_release for those tasks, ' +
|
|
31
|
+
'with the app directory as "dir". Then read the returned agent and skill files with reis_mobile_read_instructions and follow them; ' +
|
|
32
|
+
'open only the references that match the stack and the task. Every tool is read-only and offline: none edits the project, ' +
|
|
33
|
+
'builds, runs tests or calls a network service.';
|
|
34
|
+
|
|
35
|
+
class RpcError extends Error {
|
|
36
|
+
constructor(code, message, data) {
|
|
37
|
+
super(message);
|
|
38
|
+
this.code = code;
|
|
39
|
+
this.data = data;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Handles one decoded JSON-RPC message and resolves to the response, or to null for a
|
|
45
|
+
* notification. It never rejects: every failure becomes a JSON-RPC error or a tool error.
|
|
46
|
+
*/
|
|
47
|
+
export function createMcpServer({ version, defaultDir = process.cwd() }) {
|
|
48
|
+
const serverInfo = { name: 'reis-mobile', title: 'reis-mobile', version };
|
|
49
|
+
// Set by `initialize`: from then on, requests without `_meta` follow that legacy revision.
|
|
50
|
+
let legacyVersion = null;
|
|
51
|
+
|
|
52
|
+
async function handle(message) {
|
|
53
|
+
if (typeof message !== 'object' || message === null || Array.isArray(message)) {
|
|
54
|
+
return failure(null, new RpcError(ERROR_CODES.invalidRequest, 'Expected one JSON-RPC 2.0 message object; batches are not supported.'));
|
|
55
|
+
}
|
|
56
|
+
const isRequest = Object.hasOwn(message, 'id');
|
|
57
|
+
if (message.jsonrpc !== '2.0' || typeof message.method !== 'string') {
|
|
58
|
+
return isRequest ? failure(requestId(message.id), new RpcError(ERROR_CODES.invalidRequest, 'Expected a JSON-RPC 2.0 request with a method.')) : null;
|
|
59
|
+
}
|
|
60
|
+
// notifications/initialized needs no reply; cancellation is handled by the transport.
|
|
61
|
+
if (!isRequest) return null;
|
|
62
|
+
|
|
63
|
+
const { id, method, params } = message;
|
|
64
|
+
if (requestId(id) === null) return failure(null, new RpcError(ERROR_CODES.invalidRequest, 'The request id must be a string or a number.'));
|
|
65
|
+
try {
|
|
66
|
+
if (params !== undefined && (typeof params !== 'object' || params === null || Array.isArray(params))) {
|
|
67
|
+
throw new RpcError(ERROR_CODES.invalidParams, 'params must be an object.');
|
|
68
|
+
}
|
|
69
|
+
if (method === 'initialize') return success(id, initialize(params ?? {}));
|
|
70
|
+
|
|
71
|
+
const modern = isModernRequest(method, params);
|
|
72
|
+
const result = await dispatch(method, params ?? {}, modern);
|
|
73
|
+
return success(id, modern ? { resultType: 'complete', ...result, _meta: { [META_SERVER_INFO]: serverInfo } } : result);
|
|
74
|
+
} catch (error) {
|
|
75
|
+
return failure(id, error instanceof RpcError ? error : new RpcError(ERROR_CODES.internal, `Internal error: ${error.message}`));
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function initialize(params) {
|
|
80
|
+
const requested = params.protocolVersion;
|
|
81
|
+
legacyVersion = LEGACY_VERSIONS.includes(requested) ? requested : LEGACY_VERSIONS[0];
|
|
82
|
+
return { protocolVersion: legacyVersion, capabilities: CAPABILITIES, serverInfo, instructions: INSTRUCTIONS };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** A request is modern when it carries a protocol version in `_meta`; legacy once initialized. */
|
|
86
|
+
function isModernRequest(method, params) {
|
|
87
|
+
const meta = params?._meta;
|
|
88
|
+
const requested = meta?.[META_VERSION];
|
|
89
|
+
if (requested !== undefined) {
|
|
90
|
+
if (!MODERN_VERSIONS.includes(requested)) {
|
|
91
|
+
throw new RpcError(ERROR_CODES.unsupportedVersion, 'Unsupported protocol version', { supported: SUPPORTED_VERSIONS, requested });
|
|
92
|
+
}
|
|
93
|
+
const capabilities = meta[META_CAPABILITIES];
|
|
94
|
+
if (typeof capabilities !== 'object' || capabilities === null || Array.isArray(capabilities)) {
|
|
95
|
+
throw new RpcError(ERROR_CODES.invalidParams, `Missing _meta["${META_CAPABILITIES}"]: every ${requested} request carries the client capabilities.`);
|
|
96
|
+
}
|
|
97
|
+
return true;
|
|
98
|
+
}
|
|
99
|
+
if (legacyVersion || method === 'ping') return false;
|
|
100
|
+
throw new RpcError(
|
|
101
|
+
ERROR_CODES.invalidParams,
|
|
102
|
+
`Missing _meta["${META_VERSION}"]. Send initialize first (protocol ${LEGACY_VERSIONS[0]} and earlier) or include the per-request _meta of ${MODERN_VERSIONS[0]}.`,
|
|
103
|
+
{ supported: SUPPORTED_VERSIONS },
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async function dispatch(method, params, modern) {
|
|
108
|
+
switch (method) {
|
|
109
|
+
case 'server/discover':
|
|
110
|
+
if (!modern) break;
|
|
111
|
+
return { supportedVersions: MODERN_VERSIONS, capabilities: CAPABILITIES, instructions: INSTRUCTIONS };
|
|
112
|
+
case 'ping':
|
|
113
|
+
return {};
|
|
114
|
+
case 'tools/list':
|
|
115
|
+
return { tools: listTools() };
|
|
116
|
+
case 'tools/call': {
|
|
117
|
+
const { name, arguments: args } = params;
|
|
118
|
+
if (typeof name !== 'string' || !hasTool(name)) throw new RpcError(ERROR_CODES.invalidParams, `Unknown tool: ${name}`);
|
|
119
|
+
if (args !== undefined && (typeof args !== 'object' || args === null || Array.isArray(args))) {
|
|
120
|
+
throw new RpcError(ERROR_CODES.invalidParams, 'tools/call arguments must be an object.');
|
|
121
|
+
}
|
|
122
|
+
return callTool(name, args ?? {}, { defaultDir });
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
throw new RpcError(ERROR_CODES.methodNotFound, `Method not found: ${method}`);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return { handle };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Serves newline-delimited JSON-RPC over stdio until the input closes. Only protocol messages
|
|
133
|
+
* are written to `output`; a cancelled request gets no reply, as the transport requires.
|
|
134
|
+
*/
|
|
135
|
+
export async function serveStdio({ version, defaultDir, input = process.stdin, output = process.stdout }) {
|
|
136
|
+
const server = createMcpServer({ version, defaultDir });
|
|
137
|
+
const inFlight = new Set();
|
|
138
|
+
const cancelled = new Set();
|
|
139
|
+
const pending = new Set();
|
|
140
|
+
const lines = createInterface({ input, crlfDelay: Infinity });
|
|
141
|
+
|
|
142
|
+
const write = (message) => {
|
|
143
|
+
if (!output.writableEnded && !output.destroyed) output.write(`${JSON.stringify(message)}\n`);
|
|
144
|
+
};
|
|
145
|
+
// The client closing its end mid-reply is a shutdown, not a crash.
|
|
146
|
+
output.on('error', () => lines.close());
|
|
147
|
+
|
|
148
|
+
lines.on('line', (line) => {
|
|
149
|
+
if (!line.trim()) return;
|
|
150
|
+
let message;
|
|
151
|
+
try {
|
|
152
|
+
message = JSON.parse(line);
|
|
153
|
+
} catch {
|
|
154
|
+
write(failure(null, new RpcError(ERROR_CODES.parse, 'Parse error: each line must be one JSON-RPC message.')));
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
if (message?.method === 'notifications/cancelled') {
|
|
158
|
+
const target = message.params?.requestId;
|
|
159
|
+
if (inFlight.has(target)) cancelled.add(target);
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const id = Object.hasOwn(message ?? {}, 'id') ? message.id : undefined;
|
|
164
|
+
if (id !== undefined) inFlight.add(id);
|
|
165
|
+
const task = server.handle(message).then((response) => {
|
|
166
|
+
inFlight.delete(id);
|
|
167
|
+
if (cancelled.delete(id) || !response) return;
|
|
168
|
+
write(response);
|
|
169
|
+
});
|
|
170
|
+
pending.add(task);
|
|
171
|
+
task.finally(() => pending.delete(task));
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
await once(lines, 'close');
|
|
175
|
+
await Promise.allSettled([...pending]);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function requestId(id) {
|
|
179
|
+
return typeof id === 'string' || typeof id === 'number' ? id : null;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function success(id, result) {
|
|
183
|
+
return { jsonrpc: '2.0', id, result };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function failure(id, error) {
|
|
187
|
+
return { jsonrpc: '2.0', id, error: { code: error.code, message: error.message, ...(error.data !== undefined && { data: error.data }) } };
|
|
188
|
+
}
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
import { existsSync, realpathSync, statSync } from 'node:fs';
|
|
2
|
+
import { readFile } from 'node:fs/promises';
|
|
3
|
+
import { isAbsolute, join, relative, resolve, sep } from 'node:path';
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
debugPayload,
|
|
7
|
+
detectPayload,
|
|
8
|
+
doctorPayload,
|
|
9
|
+
releasePayload,
|
|
10
|
+
reviewPayload,
|
|
11
|
+
routePayload,
|
|
12
|
+
testPayload,
|
|
13
|
+
} from '../commands/payloads.mjs';
|
|
14
|
+
import { resolveProjectDir } from '../config/project.mjs';
|
|
15
|
+
import { PLUGIN_ROOT } from '../paths.mjs';
|
|
16
|
+
import { INTENT_IDS } from '../router/intents.mjs';
|
|
17
|
+
|
|
18
|
+
/** Agents, skills and the brief contract the agents link to; nothing else in the package. */
|
|
19
|
+
const READABLE = ['agents', 'skills', join('docs', 'agent-context.md')];
|
|
20
|
+
const MAX_READ_BYTES = 512 * 1024;
|
|
21
|
+
|
|
22
|
+
const DIR = {
|
|
23
|
+
type: 'string',
|
|
24
|
+
description:
|
|
25
|
+
'Absolute path of the mobile app, or of a repository whose .reis-mobile/config.yaml points at it. ' +
|
|
26
|
+
'Defaults to the --dir given to `reis-mobile mcp`, then to the server working directory.',
|
|
27
|
+
};
|
|
28
|
+
const BASE = { type: 'string', description: 'Git ref to compare as <base>...HEAD, such as main. Omit to use uncommitted changes.' };
|
|
29
|
+
|
|
30
|
+
// Every tool reads the project or the package and runs only local, read-only commands (git,
|
|
31
|
+
// toolchain --version). None edits files, builds, runs tests or calls a network service.
|
|
32
|
+
const READ_ONLY = { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false };
|
|
33
|
+
|
|
34
|
+
const TOOLS = [
|
|
35
|
+
{
|
|
36
|
+
name: 'reis_mobile_route',
|
|
37
|
+
title: 'Route a mobile request',
|
|
38
|
+
description:
|
|
39
|
+
'Resolve a request to the reis-mobile specialist agent and skills for the detected stack. ' +
|
|
40
|
+
'Returns the intent, agent and skill instruction paths: read them with reis_mobile_read_instructions and follow them. ' +
|
|
41
|
+
'Pass a prompt, an intent, or both.',
|
|
42
|
+
properties: {
|
|
43
|
+
prompt: { type: 'string', description: 'The request in English or Portuguese, such as "the Android build of my Flutter app broke".' },
|
|
44
|
+
intent: { type: 'string', enum: INTENT_IDS, description: 'Force an intent instead of detecting it from the prompt.' },
|
|
45
|
+
dir: DIR,
|
|
46
|
+
},
|
|
47
|
+
run: ({ project, prompt, intent }) => {
|
|
48
|
+
if (!prompt && !intent) throw new ToolInputError('Pass a prompt, an intent, or both.');
|
|
49
|
+
return routePayload({ project, prompt, intent });
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
name: 'reis_mobile_review',
|
|
54
|
+
title: 'Prepare a mobile code review',
|
|
55
|
+
description:
|
|
56
|
+
'Route a code review to the review agent and skills and collect the git diff to review: uncommitted changes, ' +
|
|
57
|
+
'or <base>...HEAD. Lock files and generated code are excluded and secrets are masked. Does not review by itself.',
|
|
58
|
+
properties: {
|
|
59
|
+
prompt: { type: 'string', description: 'Optional focus, such as "focus on security".' },
|
|
60
|
+
base: BASE,
|
|
61
|
+
dir: DIR,
|
|
62
|
+
},
|
|
63
|
+
run: ({ project, prompt, base }) => reviewPayload({ project, prompt, base }),
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
name: 'reis_mobile_debug',
|
|
67
|
+
title: 'Prepare a build or runtime debug',
|
|
68
|
+
description:
|
|
69
|
+
'Route a build or runtime failure (Gradle, Xcode, CocoaPods, Metro, pub, crashes) to the debug agent and skills, ' +
|
|
70
|
+
'with the doctor report of the installed toolchain versions. Does not change the project or its caches.',
|
|
71
|
+
properties: {
|
|
72
|
+
problem: { type: 'string', description: 'The error, failing command or symptom, such as "pod install fails on CI".' },
|
|
73
|
+
dir: DIR,
|
|
74
|
+
},
|
|
75
|
+
run: ({ project, problem }) => debugPayload({ project, prompt: problem }),
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
name: 'reis_mobile_test',
|
|
79
|
+
title: 'Prepare mobile test work',
|
|
80
|
+
description:
|
|
81
|
+
'Route a test request to the test agent and skills for the stack, with the git diff and toolchain checks. ' +
|
|
82
|
+
'Does not run tests: the agent runs, writes or audits them after reading the instructions.',
|
|
83
|
+
properties: {
|
|
84
|
+
request: { type: 'string', description: 'What to test, such as "write regression tests for the checkout cubit".' },
|
|
85
|
+
base: BASE,
|
|
86
|
+
dir: DIR,
|
|
87
|
+
},
|
|
88
|
+
run: ({ project, request, base }) => testPayload({ project, prompt: request, base }),
|
|
89
|
+
},
|
|
90
|
+
{
|
|
91
|
+
name: 'reis_mobile_release',
|
|
92
|
+
title: 'Prepare release readiness evidence',
|
|
93
|
+
description:
|
|
94
|
+
'Route a release readiness audit to the release engineer with the candidate platforms, reference paths, ' +
|
|
95
|
+
'git context and toolchain checks. Does not build, sign, upload or evaluate readiness: the verdict is null.',
|
|
96
|
+
properties: {
|
|
97
|
+
request: { type: 'string', description: 'What to audit, such as "Android production and iOS App Store".' },
|
|
98
|
+
dir: DIR,
|
|
99
|
+
},
|
|
100
|
+
run: ({ project, request }) => releasePayload({ project, prompt: request }),
|
|
101
|
+
},
|
|
102
|
+
{
|
|
103
|
+
name: 'reis_mobile_detect',
|
|
104
|
+
title: 'Detect the mobile stack',
|
|
105
|
+
description:
|
|
106
|
+
'Detect the stack of a project from its build files: Flutter, React Native or Expo, Kotlin Multiplatform, ' +
|
|
107
|
+
'native Android or native iOS, with languages, target platforms and the evidence for each.',
|
|
108
|
+
properties: { dir: DIR },
|
|
109
|
+
run: ({ project }) => detectPayload({ project }),
|
|
110
|
+
},
|
|
111
|
+
{
|
|
112
|
+
name: 'reis_mobile_doctor',
|
|
113
|
+
title: 'Diagnose the mobile environment',
|
|
114
|
+
description:
|
|
115
|
+
'Check the SDKs and project files that matter for the detected stack: Flutter, Dart, Java, Android SDK, Xcode, ' +
|
|
116
|
+
'CocoaPods, the Gradle wrapper and lock files. Reads versions only; installs nothing.',
|
|
117
|
+
properties: {
|
|
118
|
+
all: { type: 'boolean', description: 'Check every tool, not only those for the detected stack.' },
|
|
119
|
+
dir: DIR,
|
|
120
|
+
},
|
|
121
|
+
run: ({ project, all }) => doctorPayload({ project, all }),
|
|
122
|
+
},
|
|
123
|
+
{
|
|
124
|
+
name: 'reis_mobile_read_instructions',
|
|
125
|
+
title: 'Read reis-mobile instructions',
|
|
126
|
+
description:
|
|
127
|
+
'Read an agent, skill or reference file shipped with reis-mobile: the agent and skill paths the other tools ' +
|
|
128
|
+
'return, or a link inside one of those files resolved against that file\'s own path. Accepts an absolute path ' +
|
|
129
|
+
'inside the package or a path relative to it, such as skills/mobile-debug/SKILL.md.',
|
|
130
|
+
properties: {
|
|
131
|
+
path: { type: 'string', description: 'The file to read, such as skills/mobile-debug/references/ios.md.' },
|
|
132
|
+
},
|
|
133
|
+
required: ['path'],
|
|
134
|
+
readsProject: false,
|
|
135
|
+
run: ({ path }) => readInstructions(path),
|
|
136
|
+
},
|
|
137
|
+
];
|
|
138
|
+
|
|
139
|
+
const BY_NAME = new Map(TOOLS.map((tool) => [tool.name, tool]));
|
|
140
|
+
|
|
141
|
+
/** Thrown for arguments a model can correct; reported as a tool result with isError. */
|
|
142
|
+
class ToolInputError extends Error {}
|
|
143
|
+
|
|
144
|
+
export function listTools() {
|
|
145
|
+
return TOOLS.map(({ name, title, description, properties, required }) => ({
|
|
146
|
+
name,
|
|
147
|
+
title,
|
|
148
|
+
description,
|
|
149
|
+
inputSchema: { type: 'object', properties, ...(required && { required }), additionalProperties: false },
|
|
150
|
+
annotations: { title, ...READ_ONLY },
|
|
151
|
+
}));
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export function hasTool(name) {
|
|
155
|
+
return BY_NAME.has(name);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Runs a tool and returns its MCP result. Mistakes the model can fix (wrong arguments, a missing
|
|
160
|
+
* directory) and failures inside the core come back with isError, so the model sees them.
|
|
161
|
+
*/
|
|
162
|
+
export async function callTool(name, args = {}, { defaultDir = process.cwd() } = {}) {
|
|
163
|
+
const tool = BY_NAME.get(name);
|
|
164
|
+
if (!tool) return { content: [{ type: 'text', text: `Unknown tool: ${name}` }], isError: true };
|
|
165
|
+
try {
|
|
166
|
+
validateArguments(tool, args);
|
|
167
|
+
if (tool.readsProject === false) return { content: [{ type: 'text', text: await tool.run(args) }] };
|
|
168
|
+
|
|
169
|
+
const project = resolveToolProject(args.dir, defaultDir);
|
|
170
|
+
const payload = await tool.run({ ...args, project });
|
|
171
|
+
const warnings = project.warnings.map((warning) => ({ type: 'text', text: `! ${warning}` }));
|
|
172
|
+
return {
|
|
173
|
+
content: [...warnings, { type: 'text', text: JSON.stringify(payload, null, 2) }],
|
|
174
|
+
structuredContent: payload,
|
|
175
|
+
};
|
|
176
|
+
} catch (error) {
|
|
177
|
+
return { content: [{ type: 'text', text: error.message }], isError: true };
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function validateArguments(tool, args) {
|
|
182
|
+
if (typeof args !== 'object' || args === null || Array.isArray(args)) {
|
|
183
|
+
throw new ToolInputError(`${tool.name} takes an object of arguments.`);
|
|
184
|
+
}
|
|
185
|
+
const allowed = Object.keys(tool.properties);
|
|
186
|
+
for (const [key, value] of Object.entries(args)) {
|
|
187
|
+
const schema = tool.properties[key];
|
|
188
|
+
if (!schema) throw new ToolInputError(`Unknown argument "${key}" for ${tool.name}. Allowed: ${allowed.join(', ')}.`);
|
|
189
|
+
if (value === undefined || value === null) continue;
|
|
190
|
+
if (typeof value !== schema.type) throw new ToolInputError(`"${key}" must be a ${schema.type}, got ${typeof value}.`);
|
|
191
|
+
if (schema.enum && !schema.enum.includes(value)) {
|
|
192
|
+
throw new ToolInputError(`"${key}" must be one of: ${schema.enum.join(', ')}. Got "${value}".`);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
for (const key of tool.required ?? []) {
|
|
196
|
+
if (typeof args[key] !== 'string' || !args[key].trim()) throw new ToolInputError(`"${key}" is required.`);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function resolveToolProject(dir, defaultDir) {
|
|
201
|
+
const requested = resolve(defaultDir, dir ?? '.');
|
|
202
|
+
if (!existsSync(requested) || !statSync(requested).isDirectory()) {
|
|
203
|
+
throw new ToolInputError(`Directory not found: ${requested}. Pass the absolute path of the mobile app as "dir".`);
|
|
204
|
+
}
|
|
205
|
+
return resolveProjectDir(requested);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** Resolves symlinks before the containment check, so a link cannot point outside the package. */
|
|
209
|
+
async function readInstructions(path) {
|
|
210
|
+
const requested = isAbsolute(path) ? path : join(PLUGIN_ROOT, path);
|
|
211
|
+
if (!existsSync(requested) || !statSync(requested).isFile()) {
|
|
212
|
+
throw new ToolInputError(`No file at ${requested}. Use a path returned by another reis-mobile tool, or one relative to ${PLUGIN_ROOT}.`);
|
|
213
|
+
}
|
|
214
|
+
const root = realpathSync(PLUGIN_ROOT);
|
|
215
|
+
const real = realpathSync(requested);
|
|
216
|
+
const inside = relative(root, real);
|
|
217
|
+
const readable = READABLE.some((entry) => inside === entry || inside.startsWith(`${entry}${sep}`));
|
|
218
|
+
if (!readable || inside.startsWith('..') || isAbsolute(inside)) {
|
|
219
|
+
throw new ToolInputError(`${path} is outside the readable instructions: ${READABLE.join(', ')} in ${root}.`);
|
|
220
|
+
}
|
|
221
|
+
const { size } = statSync(real);
|
|
222
|
+
if (size > MAX_READ_BYTES) throw new ToolInputError(`${inside} is ${size} bytes, above the ${MAX_READ_BYTES}-byte limit.`);
|
|
223
|
+
|
|
224
|
+
// Commands and skills written for Claude Code refer to the package as ${CLAUDE_PLUGIN_ROOT}.
|
|
225
|
+
return `File: ${real}\nPackage root (\${CLAUDE_PLUGIN_ROOT}): ${root}\n\n${await readFile(real, 'utf8')}`;
|
|
226
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "reis-mobile",
|
|
3
|
-
"version": "0.
|
|
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.",
|
|
3
|
+
"version": "1.0.1",
|
|
4
|
+
"description": "AI agents and skills for Flutter, Android, iOS, React Native and Kotlin Multiplatform â a Claude Code and Codex plugin and an MCP server for reviews, debugging, tests, architecture, security and Firebase.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"author": "Warleson Reis",
|
|
@@ -55,6 +55,10 @@
|
|
|
55
55
|
"agent-skills",
|
|
56
56
|
"skills",
|
|
57
57
|
"ai-agents",
|
|
58
|
-
"code-review"
|
|
58
|
+
"code-review",
|
|
59
|
+
"mcp",
|
|
60
|
+
"mcp-server",
|
|
61
|
+
"model-context-protocol",
|
|
62
|
+
"cursor"
|
|
59
63
|
]
|
|
60
64
|
}
|
|
@@ -32,7 +32,9 @@ In a cross-platform app, native code in `android/`, `ios/` or native modules get
|
|
|
32
32
|
|
|
33
33
|
- Open the code and confirm every suspicion. A finding without evidence at `file:line` does not go in the report.
|
|
34
34
|
- Do not invent APIs, versions or lint rules. Check the lock files and build files (`pubspec.lock`, version catalog, `Package.resolved`, `package.json`) before claiming something is missing, deprecated or unavailable.
|
|
35
|
-
-
|
|
35
|
+
- Start with the project's configured analyzers, linters and relevant tests. Reuse results that match the reviewed revision and scope; run existing checks when available and permitted. If results are unavailable, stale or checks cannot run, disclose that limitation instead of assuming they passed.
|
|
36
|
+
- Keep confirmed tool diagnostics (including correctness and security diagnostics, not just style) in the checks summary; do not present them again as independent AI findings. Do not suppress a concrete defect merely because a rule might cover it: verify the rule is enabled and its output applies to the reviewed code. Trace any additional consequence beyond a diagnostic before claiming added value.
|
|
37
|
+
- Spend review effort on concrete behavior across files, lifecycle transitions and API contracts. Require a trigger and code evidence; illustrative scenarios are not findings. When a recurring defect has a precise condition, recommend a regression test or lint rule to enforce it.
|
|
36
38
|
- When the project deliberately does something differently and it is coherent, say so instead of flagging it.
|
|
37
39
|
|
|
38
40
|
## 4. What always matters, on any stack
|
|
@@ -69,3 +71,5 @@ Prioritized list. For each item: file:line, problem, why it matters, fix.
|
|
|
69
71
|
```
|
|
70
72
|
|
|
71
73
|
Write "No findings." in a section with nothing to report.
|
|
74
|
+
|
|
75
|
+
Include checks executed or reused, their revision/scope and results, confirmed tool diagnostics, and checks not run in the Summary. Keep additional review findings separate from those diagnostics; never imply that a clean review guarantees correctness.
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
# Store tokens in the iOS Keychain
|
|
2
|
+
|
|
3
|
+
Use Keychain Services for small secrets such as access and refresh tokens. Choose access and sharing rules from the app's actual lifecycle and threat model; a token in the Keychain is still a bearer credential and must be handled carefully after reading it.
|
|
4
|
+
|
|
5
|
+
## Review checklist
|
|
6
|
+
|
|
7
|
+
1. **Remove plaintext token copies.** Do not store tokens in `UserDefaults`, property lists, logs, analytics, crash reports or URLs. Find and delete the old copy after a successful migration.
|
|
8
|
+
2. **Choose the accessibility class.** Use `kSecAttrAccessibleWhenUnlockedThisDeviceOnly` for foreground-only secrets that should not migrate to a different device. If background work needs the token after the first unlock, consider `kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly`. Select a class based on when the app needs access; avoid the deprecated `kSecAttrAccessibleAlways*` classes. Verify current behavior in Apple's documentation.
|
|
9
|
+
3. **Decide backup and device migration behavior.** A `ThisDeviceOnly` class prevents migration when restoring a backup to another device; it can still be restored to the device that created the backup. Non-`ThisDeviceOnly` classes may migrate with encrypted backups. Do not assume this is equivalent to deleting an item during backup or restore.
|
|
10
|
+
4. **Gate access with biometrics only when required.** A biometric prompt that returns a Boolean does not protect the stored token. When the product requires biometric gating, attach a `SecAccessControl` created with an appropriate flag such as `.biometryCurrentSet` to the item. Handle authentication cancellation and enrollment changes by requiring reauthentication. Do not add a prompt to every token read unless that is the intended user experience.
|
|
11
|
+
5. **Share only with intended targets.** Use a Keychain access group only when an app extension or companion target must read the item. Configure the entitlement for each target and use the same explicit access-group identifier in the query. Keep unrelated targets out of the group.
|
|
12
|
+
6. **Handle reinstall and logout deliberately.** Keychain credentials can remain available after an app is removed and reinstalled, so do not treat reinstall as logout. On logout, revoke the server-side session as appropriate and delete the item's Keychain entry. On launch, validate any recovered session with the server and provide an explicit sign-out path.
|
|
13
|
+
|
|
14
|
+
## Basic token store
|
|
15
|
+
|
|
16
|
+
This example stores one generic-password item per service and account. It updates an existing item, returns `nil` when no token exists, treats deleting a missing item as success, and surfaces other `OSStatus` failures to the caller.
|
|
17
|
+
|
|
18
|
+
```swift
|
|
19
|
+
import Foundation
|
|
20
|
+
import Security
|
|
21
|
+
|
|
22
|
+
enum TokenStoreError: Error {
|
|
23
|
+
case keychain(OSStatus)
|
|
24
|
+
case invalidData
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
struct TokenStore {
|
|
28
|
+
private let service = "com.example.app.auth"
|
|
29
|
+
private let account = "refresh-token"
|
|
30
|
+
|
|
31
|
+
private var identity: [String: Any] {
|
|
32
|
+
[
|
|
33
|
+
kSecClass as String: kSecClassGenericPassword,
|
|
34
|
+
kSecAttrService as String: service,
|
|
35
|
+
kSecAttrAccount as String: account
|
|
36
|
+
]
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
func save(_ token: String) throws {
|
|
40
|
+
guard let data = token.data(using: .utf8) else {
|
|
41
|
+
throw TokenStoreError.invalidData
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
let query = identity
|
|
45
|
+
let update: [String: Any] = [kSecValueData as String: data]
|
|
46
|
+
let updateStatus = SecItemUpdate(query as CFDictionary, update as CFDictionary)
|
|
47
|
+
|
|
48
|
+
if updateStatus == errSecItemNotFound {
|
|
49
|
+
var item = query
|
|
50
|
+
item[kSecValueData as String] = data
|
|
51
|
+
item[kSecAttrAccessible as String] = kSecAttrAccessibleWhenUnlockedThisDeviceOnly
|
|
52
|
+
let addStatus = SecItemAdd(item as CFDictionary, nil)
|
|
53
|
+
guard addStatus == errSecSuccess else {
|
|
54
|
+
throw TokenStoreError.keychain(addStatus)
|
|
55
|
+
}
|
|
56
|
+
} else if updateStatus != errSecSuccess {
|
|
57
|
+
throw TokenStoreError.keychain(updateStatus)
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
func read() throws -> String? {
|
|
62
|
+
var query = identity
|
|
63
|
+
query[kSecReturnData as String] = true
|
|
64
|
+
query[kSecMatchLimit as String] = kSecMatchLimitOne
|
|
65
|
+
|
|
66
|
+
var result: CFTypeRef?
|
|
67
|
+
let status = SecItemCopyMatching(query as CFDictionary, &result)
|
|
68
|
+
if status == errSecItemNotFound {
|
|
69
|
+
return nil
|
|
70
|
+
}
|
|
71
|
+
guard status == errSecSuccess else {
|
|
72
|
+
throw TokenStoreError.keychain(status)
|
|
73
|
+
}
|
|
74
|
+
guard let data = result as? Data,
|
|
75
|
+
let token = String(data: data, encoding: .utf8) else {
|
|
76
|
+
throw TokenStoreError.invalidData
|
|
77
|
+
}
|
|
78
|
+
return token
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
func delete() throws {
|
|
82
|
+
let status = SecItemDelete(identity as CFDictionary)
|
|
83
|
+
guard status == errSecSuccess || status == errSecItemNotFound else {
|
|
84
|
+
throw TokenStoreError.keychain(status)
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
When biometrics are required, create a `SecAccessControl` with the chosen accessibility and flags, then provide it as `kSecAttrAccessControl` when adding the item. Do not combine it with `kSecAttrAccessible`; the access-control object carries the protection class.
|
|
91
|
+
|
|
92
|
+
## Migrate from `UserDefaults`
|
|
93
|
+
|
|
94
|
+
1. Read the legacy value only if present.
|
|
95
|
+
2. Save it to the Keychain and confirm the Keychain operation succeeded.
|
|
96
|
+
3. Remove the legacy value and persist that removal.
|
|
97
|
+
4. Keep the migration idempotent: if the Keychain already has a token, prefer it and remove any stale legacy copy. If migration fails, do not silently discard the only credential; prompt for sign-in if recovery is not safe.
|
|
98
|
+
|
|
99
|
+
## Apple documentation
|
|
100
|
+
|
|
101
|
+
- [Restricting Keychain item accessibility](https://developer.apple.com/documentation/security/restricting-keychain-item-accessibility)
|
|
102
|
+
- [Sharing Keychain items among a collection of apps](https://developer.apple.com/documentation/security/sharing-access-to-keychain-items-among-a-collection-of-apps)
|
|
103
|
+
- [SecAccessControlCreateWithFlags](https://developer.apple.com/documentation/security/secaccesscontrolcreatewithflags(_:_:_:_:))
|
|
104
|
+
- [Updating and deleting Keychain items](https://developer.apple.com/documentation/security/updating-and-deleting-keychain-items)
|
|
105
|
+
- [Using the Keychain to manage user secrets](https://developer.apple.com/documentation/security/using-the-keychain-to-manage-user-secrets)
|
|
@@ -9,7 +9,7 @@ Apply after the general checks in `SKILL.md`, to native iOS apps and to the `ios
|
|
|
9
9
|
|
|
10
10
|
## Storage and credentials
|
|
11
11
|
|
|
12
|
-
- [ ] Tokens in `UserDefaults`, property lists or files instead of the Keychain.
|
|
12
|
+
- [ ] Tokens in `UserDefaults`, property lists or files instead of the Keychain. Store and migrate them as in [ios/keychain.md](ios/keychain.md).
|
|
13
13
|
- [ ] Keychain items with permissive accessibility (`kSecAttrAccessibleAlways*`, deprecated) instead of `...WhenUnlocked` or `...AfterFirstUnlock`; `ThisDeviceOnly` variants for secrets that must not migrate to other devices through backups.
|
|
14
14
|
- [ ] Files with sensitive data written without an appropriate Data Protection class.
|
|
15
15
|
- [ ] Biometrics checked with `LAContext.evaluatePolicy` only as a boolean when they guard credentials; protect the Keychain item with `SecAccessControl` and biometry instead.
|