reis-mobile 0.3.5

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 (105) hide show
  1. package/.claude-plugin/marketplace.json +24 -0
  2. package/.claude-plugin/plugin.json +12 -0
  3. package/CHANGELOG.md +103 -0
  4. package/LICENSE +21 -0
  5. package/README.md +631 -0
  6. package/THIRD_PARTY_NOTICES.md +105 -0
  7. package/agents/flutter-architect.md +92 -0
  8. package/agents/flutter-performance-engineer.md +103 -0
  9. package/agents/flutter-test-engineer.md +100 -0
  10. package/agents/lead-mobile.md +107 -0
  11. package/agents/mobile-code-reviewer.md +66 -0
  12. package/agents/mobile-staff-engineer.md +85 -0
  13. package/agents/plugin-native-expert.md +97 -0
  14. package/bin/reis-mobile.mjs +256 -0
  15. package/commands/debate.md +135 -0
  16. package/commands/doctor.md +23 -0
  17. package/commands/reis-mobile.md +50 -0
  18. package/commands/review.md +53 -0
  19. package/core/config/language.mjs +83 -0
  20. package/core/context/context-engine.mjs +113 -0
  21. package/core/detection/project-probe.mjs +104 -0
  22. package/core/detection/stack-detector.mjs +204 -0
  23. package/core/diagnostics/doctor.mjs +181 -0
  24. package/core/install/claude-plugin.mjs +76 -0
  25. package/core/paths.mjs +4 -0
  26. package/core/registry/frontmatter.mjs +81 -0
  27. package/core/registry/registry.mjs +140 -0
  28. package/core/router/intent-detector.mjs +75 -0
  29. package/core/router/intents.mjs +91 -0
  30. package/core/router/router.mjs +83 -0
  31. package/core/security/redact.mjs +24 -0
  32. package/package.json +35 -0
  33. package/skills/architecture-feature-first/SKILL.md +116 -0
  34. package/skills/bloc/SKILL.md +354 -0
  35. package/skills/code-review/SKILL.md +89 -0
  36. package/skills/dart-3-updates/SKILL.md +223 -0
  37. package/skills/dart-add-unit-test/SKILL.md +126 -0
  38. package/skills/dart-collect-coverage/SKILL.md +145 -0
  39. package/skills/dart-fix-runtime-errors/SKILL.md +170 -0
  40. package/skills/dart-generate-test-mocks/SKILL.md +159 -0
  41. package/skills/dart-migrate-to-checks-package/SKILL.md +532 -0
  42. package/skills/dart-resolve-package-conflicts/SKILL.md +120 -0
  43. package/skills/dart-run-static-analysis/SKILL.md +108 -0
  44. package/skills/dart-use-pattern-matching/SKILL.md +240 -0
  45. package/skills/dart-use-pattern-matching/examples/json_patterns.dart +85 -0
  46. package/skills/detect-mobile-stack/SKILL.md +33 -0
  47. package/skills/effective-dart/SKILL.md +187 -0
  48. package/skills/firebase-ai/SKILL.md +93 -0
  49. package/skills/firebase-analytics/SKILL.md +129 -0
  50. package/skills/firebase-app-check/SKILL.md +124 -0
  51. package/skills/firebase-auth/SKILL.md +224 -0
  52. package/skills/firebase-cloud-firestore/SKILL.md +165 -0
  53. package/skills/firebase-cloud-functions/SKILL.md +148 -0
  54. package/skills/firebase-crashlytics/SKILL.md +182 -0
  55. package/skills/firebase-data-connect/SKILL.md +80 -0
  56. package/skills/firebase-database/SKILL.md +224 -0
  57. package/skills/firebase-in-app-messaging/SKILL.md +111 -0
  58. package/skills/firebase-messaging/SKILL.md +201 -0
  59. package/skills/firebase-remote-config/SKILL.md +131 -0
  60. package/skills/firebase-storage/SKILL.md +158 -0
  61. package/skills/flutter-add-integration-test/SKILL.md +167 -0
  62. package/skills/flutter-add-widget-preview/SKILL.md +149 -0
  63. package/skills/flutter-add-widget-test/SKILL.md +158 -0
  64. package/skills/flutter-adding-home-screen-widgets/SKILL.md +205 -0
  65. package/skills/flutter-animating-apps/SKILL.md +185 -0
  66. package/skills/flutter-app-architecture/SKILL.md +80 -0
  67. package/skills/flutter-apply-architecture-best-practices/SKILL.md +166 -0
  68. package/skills/flutter-build-responsive-layout/SKILL.md +143 -0
  69. package/skills/flutter-building-forms/SKILL.md +125 -0
  70. package/skills/flutter-building-layouts/SKILL.md +131 -0
  71. package/skills/flutter-building-plugins/SKILL.md +209 -0
  72. package/skills/flutter-caching-data/SKILL.md +171 -0
  73. package/skills/flutter-change-notifier/SKILL.md +138 -0
  74. package/skills/flutter-embedding-native-views/SKILL.md +200 -0
  75. package/skills/flutter-errors/SKILL.md +126 -0
  76. package/skills/flutter-fix-layout-issues/SKILL.md +134 -0
  77. package/skills/flutter-handling-concurrency/SKILL.md +182 -0
  78. package/skills/flutter-implement-json-serialization/SKILL.md +157 -0
  79. package/skills/flutter-improving-accessibility/SKILL.md +113 -0
  80. package/skills/flutter-interoperating-with-native-apis/SKILL.md +130 -0
  81. package/skills/flutter-login-usecase/SKILL.md +38 -0
  82. package/skills/flutter-managing-state/SKILL.md +206 -0
  83. package/skills/flutter-project-audit/SKILL.md +66 -0
  84. package/skills/flutter-reducing-app-size/SKILL.md +103 -0
  85. package/skills/flutter-secure-token-store/SKILL.md +35 -0
  86. package/skills/flutter-setting-up-on-macos/SKILL.md +78 -0
  87. package/skills/flutter-setup-declarative-routing/SKILL.md +259 -0
  88. package/skills/flutter-setup-localization/SKILL.md +214 -0
  89. package/skills/flutter-theming-apps/SKILL.md +163 -0
  90. package/skills/flutter-use-http-package/SKILL.md +178 -0
  91. package/skills/flutter-widget-review/SKILL.md +54 -0
  92. package/skills/flutter-working-with-databases/SKILL.md +203 -0
  93. package/skills/flutterfire-configure/SKILL.md +143 -0
  94. package/skills/mobile-security-audit/SKILL.md +101 -0
  95. package/skills/mockito/SKILL.md +149 -0
  96. package/skills/mocktail/SKILL.md +122 -0
  97. package/skills/patrol-e2e-testing/SKILL.md +224 -0
  98. package/skills/provider/SKILL.md +139 -0
  99. package/skills/riverpod/SKILL.md +246 -0
  100. package/skills/testing/SKILL.md +50 -0
  101. package/stacks/android/stack.json +8 -0
  102. package/stacks/flutter/stack.json +8 -0
  103. package/stacks/ios/stack.json +8 -0
  104. package/stacks/kotlin-multiplatform/stack.json +8 -0
  105. package/stacks/react-native/stack.json +8 -0
@@ -0,0 +1,76 @@
1
+ import { spawnSync } from 'node:child_process';
2
+
3
+ export const MARKETPLACE_NAME = 'reis-mobile';
4
+ export const PLUGIN_ID = `reis-mobile@${MARKETPLACE_NAME}`;
5
+ /** v0.3.2 and v0.3.3 shipped the plugin as `mobile`, with commands under /mobile:*. */
6
+ export const LEGACY_PLUGIN_IDS = [`mobile@${MARKETPLACE_NAME}`];
7
+ export const MARKETPLACE_URL = 'https://github.com/wrsilva/reis-mobile.git';
8
+ export const SCOPES = ['user', 'project', 'local'];
9
+
10
+ /**
11
+ * Registers the reis-mobile marketplace and installs the plugin through the `claude` CLI.
12
+ * Both steps are idempotent in Claude Code, so running `init` twice is safe.
13
+ *
14
+ * The marketplace is refreshed after being added: a checkout from v0.3.2 or v0.3.3 only
15
+ * knows the plugin as `mobile`. A plugin installed under that name is removed once
16
+ * reis-mobile is in place, so its commands do not show up twice.
17
+ *
18
+ * `source` is the GitHub repository by default, so `claude plugin update` keeps working;
19
+ * pass a local directory for offline or development installs.
20
+ */
21
+ export function installClaudePlugin({ source = MARKETPLACE_URL, scope = 'user', run = runClaude, list = listClaudePlugins } = {}) {
22
+ assertScope(scope);
23
+ const result = runSteps(run, [
24
+ ['plugin', 'marketplace', 'add', source, '--scope', scope],
25
+ ['plugin', 'marketplace', 'update', MARKETPLACE_NAME],
26
+ ['plugin', 'install', PLUGIN_ID, '--scope', scope],
27
+ ]);
28
+ if (!result.ok) return result;
29
+ return runSteps(run, legacyUninstallSteps(list(), scope));
30
+ }
31
+
32
+ export function uninstallClaudePlugin({ scope = 'user', run = runClaude, list = listClaudePlugins } = {}) {
33
+ assertScope(scope);
34
+ return runSteps(run, [
35
+ ['plugin', 'uninstall', PLUGIN_ID, '--scope', scope],
36
+ ...legacyUninstallSteps(list(), scope),
37
+ ['plugin', 'marketplace', 'remove', MARKETPLACE_NAME],
38
+ ]);
39
+ }
40
+
41
+ function legacyUninstallSteps(installed, scope) {
42
+ return LEGACY_PLUGIN_IDS.filter((id) => installed.includes(id)).map((id) => ['plugin', 'uninstall', id, '--scope', scope]);
43
+ }
44
+
45
+ function runSteps(run, steps) {
46
+ for (const args of steps) {
47
+ const status = run(args);
48
+ if (status !== 0) return { ok: false, failedStep: `claude ${args.join(' ')}`, status };
49
+ }
50
+ return { ok: true };
51
+ }
52
+
53
+ function assertScope(scope) {
54
+ if (!SCOPES.includes(scope)) throw new Error(`Unknown scope "${scope}". Use one of: ${SCOPES.join(', ')}`);
55
+ }
56
+
57
+ function runClaude(args) {
58
+ // npm-based Claude Code installs expose `claude.cmd` on Windows, which needs a shell.
59
+ const result = spawnSync('claude', args, { stdio: 'inherit', shell: process.platform === 'win32' });
60
+ if (result.error?.code === 'ENOENT') {
61
+ throw new Error('Claude Code CLI not found in PATH. Install it from https://claude.com/claude-code and run `reis-mobile init` again.');
62
+ }
63
+ if (result.error) throw result.error;
64
+ return result.status ?? 1;
65
+ }
66
+
67
+ /** Installed plugin ids. An unreadable list only means there is nothing to migrate. */
68
+ function listClaudePlugins() {
69
+ const result = spawnSync('claude', ['plugin', 'list', '--json'], { encoding: 'utf8', shell: process.platform === 'win32' });
70
+ if (result.status !== 0) return [];
71
+ try {
72
+ return JSON.parse(result.stdout).map((plugin) => plugin.id);
73
+ } catch {
74
+ return [];
75
+ }
76
+ }
package/core/paths.mjs ADDED
@@ -0,0 +1,4 @@
1
+ import { dirname, resolve } from 'node:path';
2
+ import { fileURLToPath } from 'node:url';
3
+
4
+ export const PLUGIN_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
@@ -0,0 +1,81 @@
1
+ // Minimal YAML frontmatter parser, dependency-free on purpose: the plugin must run
2
+ // straight from the Claude Code plugin cache without `npm install`.
3
+ //
4
+ // Supported subset: `key: scalar`, `key: [a, b]`, block lists (`key:` followed by
5
+ // ` - item` lines) and block scalars (`key: |`, `|-`, `>`, `>-`). Nested maps, such as
6
+ // the `metadata:` block of upstream skills, are skipped.
7
+
8
+ const FRONTMATTER = /^---\r?\n([\s\S]*?)\r?\n---[ \t]*(?:\r?\n|$)([\s\S]*)$/;
9
+ const BLOCK_SCALAR = /^([|>])([+-]?)$/;
10
+
11
+ export function parseFrontmatter(source) {
12
+ const match = FRONTMATTER.exec(source);
13
+ if (!match) return { data: {}, body: source };
14
+ return { data: parseYamlSubset(match[1]), body: match[2] };
15
+ }
16
+
17
+ function parseYamlSubset(text) {
18
+ const data = {};
19
+ const lines = text.split(/\r?\n/);
20
+ let listKey = null;
21
+
22
+ for (let index = 0; index < lines.length; index++) {
23
+ const line = lines[index];
24
+ if (!line.trim() || line.trimStart().startsWith('#')) continue;
25
+
26
+ const item = /^\s+-\s+(.*)$/.exec(line);
27
+ if (item && listKey) {
28
+ data[listKey].push(parseScalar(item[1]));
29
+ continue;
30
+ }
31
+
32
+ const pair = /^([A-Za-z0-9_-]+):\s*(.*)$/.exec(line);
33
+ if (!pair) continue;
34
+
35
+ const [, key, value] = pair;
36
+ listKey = null;
37
+ const block = BLOCK_SCALAR.exec(value);
38
+
39
+ if (block) {
40
+ const collected = [];
41
+ while (index + 1 < lines.length && (lines[index + 1].trim() === '' || /^\s/.test(lines[index + 1]))) {
42
+ collected.push(lines[++index]);
43
+ }
44
+ data[key] = foldBlock(collected, block[1], block[2]);
45
+ } else if (value === '') {
46
+ data[key] = [];
47
+ listKey = key;
48
+ } else {
49
+ data[key] = parseValue(value);
50
+ }
51
+ }
52
+ return data;
53
+ }
54
+
55
+ function foldBlock(lines, style, chomping) {
56
+ const indent = Math.min(...lines.filter((line) => line.trim()).map((line) => line.match(/^\s*/)[0].length));
57
+ const content = lines.map((line) => line.slice(Number.isFinite(indent) ? indent : 0));
58
+ const text = style === '|' ? content.join('\n') : content.map((line) => line.trim()).join(' ').replace(/ {2,}/g, ' ');
59
+ return chomping === '+' ? `${text}\n` : text.trimEnd();
60
+ }
61
+
62
+ function parseValue(value) {
63
+ if (value.startsWith('[') && value.endsWith(']')) {
64
+ return value
65
+ .slice(1, -1)
66
+ .split(',')
67
+ .map((part) => parseScalar(part.trim()))
68
+ .filter((part) => part !== '');
69
+ }
70
+ return parseScalar(value);
71
+ }
72
+
73
+ function parseScalar(raw) {
74
+ const value = raw.trim();
75
+ if (value.length >= 2 && value[0] === value.at(-1) && (value[0] === '"' || value[0] === "'")) {
76
+ return value.slice(1, -1).replaceAll(`\\${value[0]}`, value[0]);
77
+ }
78
+ if (value === 'true') return true;
79
+ if (value === 'false') return false;
80
+ return value;
81
+ }
@@ -0,0 +1,140 @@
1
+ import { readFile, readdir } from 'node:fs/promises';
2
+ import { basename, join } from 'node:path';
3
+
4
+ import { DETECTABLE_STACKS } from '../detection/stack-detector.mjs';
5
+ import { PLUGIN_ROOT } from '../paths.mjs';
6
+ import { INTENT_IDS } from '../router/intents.mjs';
7
+ import { parseFrontmatter } from './frontmatter.mjs';
8
+
9
+ export const ANY_STACK = '*';
10
+
11
+ /**
12
+ * Loads stacks (`stacks/<id>/stack.json`), agents (`agents/<name>.md`) and skills
13
+ * (`skills/<name>/SKILL.md`). Routing metadata lives in the same frontmatter Claude Code
14
+ * reads, so the router and the plugin can never disagree about what exists.
15
+ */
16
+ export async function loadRegistry(root = PLUGIN_ROOT) {
17
+ const [stacks, agents, skills] = await Promise.all([
18
+ loadStacks(join(root, 'stacks')),
19
+ loadAgents(join(root, 'agents')),
20
+ loadSkills(join(root, 'skills')),
21
+ ]);
22
+ return { root, stacks, agents, skills };
23
+ }
24
+
25
+ async function loadStacks(dir) {
26
+ const stacks = [];
27
+ for (const entry of await listDir(dir)) {
28
+ if (!entry.isDirectory()) continue;
29
+ const path = join(dir, entry.name, 'stack.json');
30
+ const data = JSON.parse(await readFile(path, 'utf8'));
31
+ stacks.push({ ...data, dirName: entry.name, path });
32
+ }
33
+ return sortByName(stacks, 'id');
34
+ }
35
+
36
+ async function loadAgents(dir) {
37
+ const agents = [];
38
+ for (const entry of await listDir(dir)) {
39
+ if (!entry.isFile() || !entry.name.endsWith('.md')) continue;
40
+ const path = join(dir, entry.name);
41
+ agents.push(toComponent('agent', path, basename(entry.name, '.md'), await readFile(path, 'utf8')));
42
+ }
43
+ return sortByName(agents);
44
+ }
45
+
46
+ async function loadSkills(dir) {
47
+ const skills = [];
48
+ for (const entry of await listDir(dir)) {
49
+ if (!entry.isDirectory()) continue;
50
+ const path = join(dir, entry.name, 'SKILL.md');
51
+ let source;
52
+ try {
53
+ source = await readFile(path, 'utf8');
54
+ } catch {
55
+ continue;
56
+ }
57
+ skills.push(toComponent('skill', path, entry.name, source));
58
+ }
59
+ return sortByName(skills);
60
+ }
61
+
62
+ function toComponent(kind, path, fileName, source) {
63
+ const { data } = parseFrontmatter(source);
64
+ return {
65
+ kind,
66
+ name: data.name,
67
+ fileName,
68
+ description: data.description,
69
+ intents: asList(data.intents),
70
+ stacks: asList(data.stacks),
71
+ // `routing: manual` marks helpers that Claude invokes by description only.
72
+ routed: data.routing !== 'manual',
73
+ // Third-party components declare where they came from (see THIRD_PARTY_NOTICES.md).
74
+ source: data.source ?? null,
75
+ license: data.license ?? null,
76
+ path,
77
+ };
78
+ }
79
+
80
+ /** Structural checks for everything the plugin ships. Returns human-readable errors. */
81
+ export function validateRegistry({ stacks, agents, skills }) {
82
+ const errors = [];
83
+ const stackIds = new Set(stacks.map((stack) => stack.id));
84
+
85
+ for (const stack of stacks) {
86
+ if (stack.id !== stack.dirName) errors.push(`stack ${stack.path}: id "${stack.id}" must match its folder`);
87
+ }
88
+ for (const id of DETECTABLE_STACKS) {
89
+ if (!stackIds.has(id)) errors.push(`stack "${id}" has a detector but no stacks/${id}/stack.json`);
90
+ }
91
+
92
+ const seen = new Set();
93
+ for (const component of [...agents, ...skills]) {
94
+ const label = `${component.kind} ${component.path}`;
95
+ if (!component.name) errors.push(`${label}: missing "name"`);
96
+ else if (component.name !== component.fileName) {
97
+ errors.push(`${label}: name "${component.name}" must match "${component.fileName}"`);
98
+ }
99
+ if (!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(component.name ?? '')) {
100
+ errors.push(`${label}: name must be kebab-case`);
101
+ }
102
+ if (!component.description) errors.push(`${label}: missing "description"`);
103
+ if (component.routed && !component.intents.length) {
104
+ errors.push(`${label}: declare at least one intent in "intents" or set "routing: manual"`);
105
+ }
106
+ if (!component.stacks.length) errors.push(`${label}: declare "stacks" (use "*" for any stack)`);
107
+
108
+ if (component.source && !component.license) errors.push(`${label}: "source" requires "license"`);
109
+
110
+ for (const intent of component.intents) {
111
+ if (!INTENT_IDS.includes(intent)) errors.push(`${label}: unknown intent "${intent}"`);
112
+ }
113
+ for (const stack of component.stacks) {
114
+ if (stack !== ANY_STACK && !stackIds.has(stack)) errors.push(`${label}: unknown stack "${stack}"`);
115
+ }
116
+
117
+ const key = `${component.kind}:${component.name}`;
118
+ if (seen.has(key)) errors.push(`${label}: duplicate ${component.kind} name "${component.name}"`);
119
+ seen.add(key);
120
+ }
121
+
122
+ return errors;
123
+ }
124
+
125
+ function asList(value) {
126
+ if (value === undefined || value === '') return [];
127
+ return Array.isArray(value) ? value.map(String) : [String(value)];
128
+ }
129
+
130
+ async function listDir(dir) {
131
+ try {
132
+ return await readdir(dir, { withFileTypes: true });
133
+ } catch {
134
+ return [];
135
+ }
136
+ }
137
+
138
+ function sortByName(items, key = 'name') {
139
+ return items.sort((a, b) => String(a[key] ?? a.fileName).localeCompare(String(b[key] ?? b.fileName)));
140
+ }
@@ -0,0 +1,75 @@
1
+ import { AREAS, DEBUG_DOMINATES, INTENTS, INTENT_IDS, STACK_HINTS } from './intents.mjs';
2
+
3
+ export function normalize(text) {
4
+ return String(text ?? '')
5
+ .normalize('NFD')
6
+ .replace(/\p{Diacritic}/gu, '')
7
+ .toLowerCase();
8
+ }
9
+
10
+ /**
11
+ * Detects what the user wants from a free-text prompt.
12
+ *
13
+ * @returns {{ intent: string|null, confidence: number, scores: Record<string, number>,
14
+ * stack: string|null, stackHints: string[], area: string|null }}
15
+ */
16
+ export function detectIntent(prompt) {
17
+ const text = normalize(prompt);
18
+ const scores = matchTerms(text, Object.entries(INTENTS));
19
+ const stackHints = Object.keys(matchTerms(text, STACK_HINTS));
20
+ const area = Object.keys(matchTerms(text, AREAS))[0] ?? null;
21
+
22
+ const ranked = INTENT_IDS.filter((id) => scores[id]).sort((a, b) => scores[b] - scores[a]);
23
+ let intent = ranked[0] ?? null;
24
+ if (intent && DEBUG_DOMINATES.has(intent) && scores.debug) intent = 'debug';
25
+
26
+ const total = Object.values(scores).reduce((sum, value) => sum + value, 0);
27
+ const confidence = intent ? round(scores[intent] / total) : 0;
28
+ const stack = stackHints[0] ?? null;
29
+
30
+ return {
31
+ intent,
32
+ confidence,
33
+ scores,
34
+ stack,
35
+ stackHints,
36
+ area: area ?? inferArea(intent, stack),
37
+ };
38
+ }
39
+
40
+ // Longest terms claim their span first, so "testflight" never also counts as "test"
41
+ // and "kotlin multiplatform" never also counts as "kotlin". Groups with no match are
42
+ // omitted; the returned keys keep the declaration order of `groups`.
43
+ function matchTerms(text, groups) {
44
+ const terms = groups
45
+ .flatMap(([id, words]) => words.map((word) => ({ id, word })))
46
+ .sort((a, b) => b.word.length - a.word.length);
47
+ const claimed = new Array(text.length).fill(false);
48
+ const counts = new Map();
49
+
50
+ for (const { id, word } of terms) {
51
+ const pattern = new RegExp(`(?<![\\p{L}\\p{N}])${escapeRegExp(word)}`, 'gu');
52
+ for (const match of text.matchAll(pattern)) {
53
+ const start = match.index;
54
+ const end = start + word.length;
55
+ if (claimed.slice(start, end).some(Boolean)) continue;
56
+ claimed.fill(true, start, end);
57
+ counts.set(id, (counts.get(id) ?? 0) + 1);
58
+ }
59
+ }
60
+
61
+ return Object.fromEntries(groups.filter(([id]) => counts.has(id)).map(([id]) => [id, counts.get(id)]));
62
+ }
63
+
64
+ function inferArea(intent, stack) {
65
+ if (intent !== 'debug' && intent !== 'build') return null;
66
+ return { android: 'gradle', ios: 'xcode', 'react-native': 'metro' }[stack] ?? null;
67
+ }
68
+
69
+ function escapeRegExp(value) {
70
+ return value.replace(/[.*+?^${}()|[\]\\/]/g, '\\$&');
71
+ }
72
+
73
+ function round(value) {
74
+ return Math.round(value * 100) / 100;
75
+ }
@@ -0,0 +1,91 @@
1
+ // Keyword stems are matched against normalized text (lowercase, no diacritics) at word
2
+ // boundaries, so "compil" matches "compilar", "compile" and "compilação".
3
+ // Order matters: it is the tie-break priority when two intents score the same. Specific
4
+ // concerns (security, performance...) come before the generic "review".
5
+ export const INTENTS = {
6
+ debug: [
7
+ 'debug', 'erro', 'error', 'crash', 'falh', 'fail', 'exception', 'excecao', 'bug',
8
+ 'quebr', 'parou', 'nao compila', 'nao funciona', 'not working', 'stack trace',
9
+ 'stacktrace', 'broken', 'fatal', 'anr',
10
+ ],
11
+ security: [
12
+ 'seguranca', 'security', 'vulnerab', 'owasp', 'secret', 'credencia', 'criptograf',
13
+ 'encrypt', 'ssl pinning', 'certificate pinning', 'jailbreak', 'root detection',
14
+ 'keychain', 'secure storage', 'autentica',
15
+ ],
16
+ performance: [
17
+ 'performance', 'desempenho', 'lento', 'lentidao', 'slow', 'jank', 'fps', 'memoria',
18
+ 'memory', 'leak', 'startup', 'rebuild', 'bateria', 'battery', 'app size', 'apk size',
19
+ 'ipa size', 'tamanho do app', 'tamanho do apk', 'tamanho do ipa', 'lag',
20
+ 'travando', 'engasg',
21
+ ],
22
+ test: [
23
+ 'test', 'teste', 'cobertura', 'coverage', 'golden', 'espresso', 'xctest', 'maestro',
24
+ 'appium', 'patrol', 'tdd', 'mock',
25
+ ],
26
+ architecture: [
27
+ 'arquitetura', 'architecture', 'modulariza', 'clean architecture', 'feature-first',
28
+ 'feature first', 'estrutura de pastas', 'state management', 'gerenciamento de estado',
29
+ 'injecao de dependencia', 'dependency injection', 'design pattern', 'camada',
30
+ ],
31
+ release: [
32
+ 'release', 'publica', 'publish', 'loja', 'app store', 'play store', 'testflight',
33
+ 'lancamento', 'submission', 'store review', 'rejeita', 'reject',
34
+ ],
35
+ accessibility: [
36
+ 'acessibilidade', 'accessibility', 'a11y', 'leitor de tela', 'screen reader',
37
+ 'talkback', 'voiceover', 'contraste', 'semantics',
38
+ ],
39
+ review: [
40
+ 'review', 'revis', 'code review', 'pull request', 'pr', 'analis', 'avali', 'audit',
41
+ 'code smell',
42
+ ],
43
+ migration: ['migra', 'migrate', 'upgrade', 'null safety'],
44
+ dependency: [
45
+ 'dependencia', 'dependenc', 'pacote', 'package', 'pubspec', 'podfile', 'outdated',
46
+ 'desatualizad', 'npm audit',
47
+ ],
48
+ build: [
49
+ 'build', 'compil', 'gradle', 'xcodebuild', 'archive', 'apk', 'aab', 'ipa', 'assemble',
50
+ ],
51
+ deployment: [
52
+ 'deploy', 'pipeline', 'ci/cd', 'github actions', 'fastlane', 'codemagic', 'bitrise',
53
+ 'azure devops',
54
+ ],
55
+ };
56
+
57
+ export const INTENT_IDS = Object.keys(INTENTS);
58
+
59
+ // A failing build or pipeline is a debugging task, not a build/deploy task.
60
+ export const DEBUG_DOMINATES = new Set(['build', 'deployment', 'dependency', 'migration']);
61
+
62
+ // First match wins, so the most specific stacks come first.
63
+ export const STACK_HINTS = [
64
+ ['flutter', ['flutter', 'dart', 'pubspec']],
65
+ ['react-native', ['react native', 'react-native', 'expo', 'metro']],
66
+ ['kotlin-multiplatform', ['kotlin multiplatform', 'kmp', 'compose multiplatform']],
67
+ ['android', ['android', 'kotlin', 'gradle', 'jetpack compose', 'play store', 'apk', 'aab']],
68
+ ['ios', ['ios', 'swift', 'swiftui', 'xcode', 'cocoapods', 'testflight', 'app store', 'ipa']],
69
+ ];
70
+
71
+ export const AREAS = [
72
+ ['gradle', ['gradle', 'agp', 'compiledebugkotlin', 'kapt', 'ksp']],
73
+ ['xcode', ['xcode', 'xcodebuild', 'derived data', 'deriveddata']],
74
+ ['cocoapods', ['cocoapods', 'pod install', 'podfile']],
75
+ ['spm', ['swift package', 'spm']],
76
+ ['metro', ['metro']],
77
+ ['signing', ['signing', 'assinatura', 'provisioning', 'certificado', 'certificate', 'keystore']],
78
+ ['manifest', ['androidmanifest', 'manifest']],
79
+ ['proguard', ['proguard', 'r8', 'minify']],
80
+ ['pub', ['pub get', 'pubspec', 'version solving']],
81
+ ];
82
+
83
+ // Areas that only exist on one native platform, used to focus cross-platform projects.
84
+ export const AREA_PLATFORM = {
85
+ gradle: 'android',
86
+ manifest: 'android',
87
+ proguard: 'android',
88
+ xcode: 'ios',
89
+ cocoapods: 'ios',
90
+ spm: 'ios',
91
+ };
@@ -0,0 +1,83 @@
1
+ import { detectStack } from '../detection/stack-detector.mjs';
2
+ import { ANY_STACK, loadRegistry } from '../registry/registry.mjs';
3
+ import { AREA_PLATFORM, INTENT_IDS } from './intents.mjs';
4
+ import { detectIntent } from './intent-detector.mjs';
5
+
6
+ /**
7
+ * prompt → intent → stack → agent → skills.
8
+ *
9
+ * The project on disk decides the stack. Prompt hints only fill in an unknown stack or
10
+ * narrow a cross-platform project to one of its native platforms ("the Android build of
11
+ * my Flutter app fails" loads Flutter and Android skills).
12
+ */
13
+ export async function route({ prompt = '', intent: explicitIntent, projectDir = process.cwd(), registry } = {}) {
14
+ if (explicitIntent && !INTENT_IDS.includes(explicitIntent)) {
15
+ throw new Error(`Unknown intent "${explicitIntent}". Known intents: ${INTENT_IDS.join(', ')}`);
16
+ }
17
+
18
+ const components = registry ?? (await loadRegistry());
19
+ const parsed = detectIntent(prompt);
20
+ const detection = await detectStack(projectDir);
21
+ const warnings = [];
22
+
23
+ const intent = explicitIntent ?? parsed.intent;
24
+ const stack = detection.stack !== 'unknown' ? detection.stack : (parsed.stack ?? 'unknown');
25
+ const platformFocus = [...new Set([...parsed.stackHints, AREA_PLATFORM[parsed.area]])].filter(
26
+ (hint) => hint && hint !== stack && detection.platforms.includes(hint),
27
+ );
28
+
29
+ if (detection.stack === 'unknown') {
30
+ warnings.push(
31
+ parsed.stack
32
+ ? `No mobile project found in ${detection.projectDir}; using "${parsed.stack}" from the prompt.`
33
+ : `No mobile project found in ${detection.projectDir}; only stack-agnostic skills apply.`,
34
+ );
35
+ }
36
+ if (!intent) warnings.push('Could not infer an intent from the prompt; pass --intent explicitly.');
37
+
38
+ const agent = intent ? selectAgent(components.agents, intent, stack) : null;
39
+ if (intent && !agent) warnings.push(`No agent handles intent "${intent}" yet.`);
40
+
41
+ const skills = intent ? selectSkills(components.skills, intent, [stack, ...platformFocus]) : [];
42
+
43
+ return {
44
+ intent,
45
+ intentSource: explicitIntent ? 'explicit' : 'prompt',
46
+ confidence: explicitIntent ? 1 : parsed.confidence,
47
+ area: parsed.area,
48
+ stack,
49
+ platformFocus,
50
+ detection,
51
+ agent,
52
+ skills,
53
+ warnings,
54
+ };
55
+ }
56
+
57
+ export function selectAgent(agents, intent, stack) {
58
+ return (
59
+ agents
60
+ .filter((agent) => agent.routed && agent.intents.includes(intent))
61
+ .map((agent) => ({ agent, score: stackScore(agent.stacks, [stack]) }))
62
+ .filter(({ score }) => score > 0)
63
+ .sort((a, b) => b.score - a.score || a.agent.name.localeCompare(b.agent.name))[0]?.agent ?? null
64
+ );
65
+ }
66
+
67
+ /** Skills for the primary stack first, then focused platforms, then stack-agnostic ones. */
68
+ export function selectSkills(skills, intent, stacks) {
69
+ return skills
70
+ .filter((skill) => skill.routed && skill.intents.includes(intent))
71
+ .map((skill) => ({ skill, score: stackScore(skill.stacks, stacks) }))
72
+ .filter(({ score }) => score > 0)
73
+ .sort((a, b) => b.score - a.score || a.skill.name.localeCompare(b.skill.name))
74
+ .map(({ skill }) => skill);
75
+ }
76
+
77
+ // Earlier stacks weigh more; "*" matches anything with the lowest weight.
78
+ function stackScore(declared, stacks) {
79
+ for (const [index, stack] of stacks.entries()) {
80
+ if (declared.includes(stack)) return stacks.length - index + 1;
81
+ }
82
+ return declared.includes(ANY_STACK) ? 1 : 0;
83
+ }
@@ -0,0 +1,24 @@
1
+ const MASK = '********';
2
+
3
+ // Patterns target literal secrets, not identifiers: `final token = await read()` must stay
4
+ // readable in a code review, while `apiKey = "AIza..."` must not leak to a model.
5
+ const RULES = [
6
+ [/-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g, `-----BEGIN PRIVATE KEY-----\n${MASK}\n-----END PRIVATE KEY-----`],
7
+ [/(authorization["']?\s*[:=]\s*["']?(?:bearer|basic|token)\s+)[^\s"'`]+/gi, `$1${MASK}`],
8
+ [/\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/g, MASK],
9
+ [/\bAKIA[0-9A-Z]{16}\b/g, MASK],
10
+ [/\bAIza[0-9A-Za-z_-]{35}\b/g, MASK],
11
+ [/\bgh[pousr]_[A-Za-z0-9]{36,}\b/g, MASK],
12
+ [/\bsk-[A-Za-z0-9_-]{20,}\b/g, MASK],
13
+ // apiKey = "…", "client_secret": "…", password: '…'
14
+ [/(\b[\w.-]*(?:password|passwd|secret|token|api[_-]?key|private[_-]?key|access[_-]?key)[\w.-]*["']?\s*[:=]\s*)(["'])([^"'\n]{8,})\2/gi, `$1$2${MASK}$2`],
15
+ // .env / gradle.properties / key.properties: API_KEY=…, storePassword=…
16
+ [/^([+\- ]?\s*(?:export\s+)?[A-Z0-9_]*(?:PASSWORD|SECRET|TOKEN|API_KEY|APIKEY|PRIVATE_KEY|ACCESS_KEY)[A-Z0-9_]*\s*=\s*)\S+/gm, `$1${MASK}`],
17
+ [/^([+\- ]?\s*\w*[Pp]assword\s*=\s*)\S+/gm, `$1${MASK}`],
18
+ ];
19
+
20
+ export function redactSecrets(text) {
21
+ let result = String(text ?? '');
22
+ for (const [pattern, replacement] of RULES) result = result.replace(pattern, replacement);
23
+ return result;
24
+ }
package/package.json ADDED
@@ -0,0 +1,35 @@
1
+ {
2
+ "name": "reis-mobile",
3
+ "version": "0.3.5",
4
+ "description": "AI agents, skills and workflows for Flutter, Android, iOS and React Native development.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": "Warleson Reis",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/wrsilva/reis-mobile.git"
11
+ },
12
+ "bin": {
13
+ "reis-mobile": "bin/reis-mobile.mjs"
14
+ },
15
+ "files": [
16
+ ".claude-plugin",
17
+ "agents",
18
+ "bin",
19
+ "commands",
20
+ "core",
21
+ "skills",
22
+ "stacks",
23
+ "THIRD_PARTY_NOTICES.md",
24
+ "CHANGELOG.md"
25
+ ],
26
+ "engines": {
27
+ "node": ">=22"
28
+ },
29
+ "scripts": {
30
+ "test": "node --test",
31
+ "validate": "node bin/reis-mobile.mjs validate",
32
+ "check": "npm run validate && npm test"
33
+ },
34
+ "keywords": ["mobile", "flutter", "android", "ios", "react-native", "claude-code", "plugin", "agents"]
35
+ }