mobile-automator 0.23.8
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/LICENSE +17 -0
- package/README.md +167 -0
- package/bin/mauto-session-daemon.js +101 -0
- package/bin/mauto.js +13 -0
- package/package.json +49 -0
- package/src/assertion/evaluator.js +285 -0
- package/src/cli.js +1722 -0
- package/src/config/coerce.js +97 -0
- package/src/config/manager.js +75 -0
- package/src/config/schema.js +124 -0
- package/src/device/action-catalog.js +99 -0
- package/src/device/bridge.js +139 -0
- package/src/device/connection.js +65 -0
- package/src/device/device-model.js +59 -0
- package/src/device/device-resolver.js +34 -0
- package/src/device/element-model.js +82 -0
- package/src/device/mobile-mcp-client.js +163 -0
- package/src/device/resolve-connection.js +156 -0
- package/src/device/selection.js +80 -0
- package/src/device/semantic-press/index.js +38 -0
- package/src/device/semantic-press/labels.js +39 -0
- package/src/device/semantic-press/resolver.js +100 -0
- package/src/device/session-client.js +173 -0
- package/src/device/session-daemon.js +526 -0
- package/src/device/session-paths.js +80 -0
- package/src/device/session-protocol.js +60 -0
- package/src/device/session-spawn.js +67 -0
- package/src/device/tool-args.js +14 -0
- package/src/guide/content/execute.agnostic.md +207 -0
- package/src/guide/content/execute.aware.md +281 -0
- package/src/guide/content/execute.invariants.md +4 -0
- package/src/guide/content/generate.agnostic.md +236 -0
- package/src/guide/content/generate.aware.md +327 -0
- package/src/guide/content/generate.invariants.md +5 -0
- package/src/guide/content/setup.agnostic.md +63 -0
- package/src/guide/content/setup.aware.md +91 -0
- package/src/guide/content/setup.invariants.md +3 -0
- package/src/guide/emitter.js +137 -0
- package/src/guide/placeholders.js +132 -0
- package/src/init/adapters.js +162 -0
- package/src/init/skill-meta.js +31 -0
- package/src/init/skill-renderer.js +60 -0
- package/src/mcp/prompts.js +55 -0
- package/src/mcp/server.js +55 -0
- package/src/memory/entries.js +61 -0
- package/src/memory/history.js +86 -0
- package/src/memory/lock.js +5 -0
- package/src/memory/paths.js +47 -0
- package/src/memory/store.js +230 -0
- package/src/output/envelope.js +106 -0
- package/src/result/capability-catalog.js +144 -0
- package/src/result/flags.js +52 -0
- package/src/result/store.js +273 -0
- package/src/scenario/validator.js +30 -0
- package/src/schemas/config_schema.json +68 -0
- package/src/schemas/format-error.js +19 -0
- package/src/schemas/result_schema.json +211 -0
- package/src/schemas/scenario_schema.json +587 -0
- package/src/setup/scaffold.js +53 -0
- package/src/util/atomic.js +41 -0
- package/src/util/lock.js +85 -0
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# Guide: setup (platform-aware)
|
|
2
|
+
|
|
3
|
+
You are configuring a mobile-QA workspace by **analyzing this project**. The mechanical part is already done: `mauto setup` has scaffolded the `mobile-automator/` tree (scenarios, screenshots, results) and written a starter `config.json`. **This guide completes the project analysis** — you inspect the codebase, infer each fact, confirm anything uncertain with the user, and PERSIST every finding with `mauto config set <key> <value>`. Read each value back with `mauto config get <key>` to confirm it landed.
|
|
4
|
+
|
|
5
|
+
You are doing LLM analysis work, not running a fixed script. Read real project files, reason about what you find, and only ask the user when a value genuinely cannot be inferred.
|
|
6
|
+
|
|
7
|
+
## How to persist a finding
|
|
8
|
+
|
|
9
|
+
Every analysis result is stored in the workspace config:
|
|
10
|
+
|
|
11
|
+
```
|
|
12
|
+
mauto config set <key> <value>
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
For list-valued keys (e.g. `protected_directories`), pass a comma-separated value. Run `mauto schema config` to see the declared type of any key. When a value is uncertain, confirm it with the user before persisting. When you truly cannot infer a value, ask the user directly and persist their answer.
|
|
16
|
+
|
|
17
|
+
## 1. Detect the platform
|
|
18
|
+
|
|
19
|
+
Inspect the project files to determine the mobile platform. First honor any `.gitignore` patterns, then run these checks in order and stop at the first match:
|
|
20
|
+
|
|
21
|
+
- **Flutter** — `pubspec.yaml` at the root with `flutter` in `dependencies`/`environment`.
|
|
22
|
+
- **React Native** — `package.json` with `react-native` in `dependencies`/`devDependencies`.
|
|
23
|
+
- **Kotlin Multiplatform** — `build.gradle.kts`/`build.gradle` declaring the `kotlin("multiplatform")` plugin, or `shared/` / `composeApp/` source sets (`commonMain/`, `androidMain/`, `iosMain/`).
|
|
24
|
+
- **Compose Multiplatform** — the `org.jetbrains.compose` plugin or a `composeApp/` `commonMain/` with `@Composable` functions.
|
|
25
|
+
- **Android Native** — `build.gradle`/`build.gradle.kts` with the `com.android.application` plugin, or `AndroidManifest.xml`.
|
|
26
|
+
- **iOS Native** — `*.xcodeproj` / `*.xcworkspace`, `*.swift`/`*.m` sources, or `Info.plist`.
|
|
27
|
+
|
|
28
|
+
If none match, ask the user which platform this is. Confirm the detected platform with the user, then persist it:
|
|
29
|
+
|
|
30
|
+
```
|
|
31
|
+
mauto config set platform <platform>
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## 2. Discover build environments
|
|
35
|
+
|
|
36
|
+
Scan the project's build configuration for named environments, scoped to the detected platform:
|
|
37
|
+
|
|
38
|
+
- **Gradle-based projects** — read the app/module build file; extract `productFlavors` (names + any `applicationIdSuffix`) and `buildTypes` (e.g. `debug`, `release`, `staging`). Compile the cross-product (e.g. `stagingDebug`, `productionRelease`).
|
|
39
|
+
- **Xcode-based projects** — list build schemes (`*.xcscheme`) and per-configuration `*.xcconfig` files; treat each configuration as an environment.
|
|
40
|
+
- **Flutter** — look for `--dart-define` patterns, flavor configs, and env files (`.env.staging`, `lib/flavors/`, `flutter_dotenv` / `envied`).
|
|
41
|
+
- **React Native** — look for `.env*` files, env-specific `package.json` scripts, and `react-native-config` / `react-native-dotenv`.
|
|
42
|
+
|
|
43
|
+
Deduplicate the result. If none are found, default to `production, staging, development`. Confirm with the user, then persist:
|
|
44
|
+
|
|
45
|
+
```
|
|
46
|
+
mauto config set environments <env1,env2,...>
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
The comma-separated value is stored as a JSON list.
|
|
50
|
+
|
|
51
|
+
## 3. Infer the app package
|
|
52
|
+
|
|
53
|
+
Read the application identifier from the build files, scoped to the platform:
|
|
54
|
+
|
|
55
|
+
- **Android module** — base `applicationId` from the app build file's `defaultConfig`, plus any flavor/buildType `applicationIdSuffix`. Persist with `mauto config set android_package <id>`.
|
|
56
|
+
- **Apple target** — `PRODUCT_BUNDLE_IDENTIFIER` from the Xcode project (`project.pbxproj`/`*.xcconfig`) or `CFBundleIdentifier` in `Info.plist`. Persist with `mauto config set ios_bundle_id <id>`.
|
|
57
|
+
- **Cross-platform projects** — persist both the Android `android_package` and the Apple `ios_bundle_id`.
|
|
58
|
+
|
|
59
|
+
If you cannot infer the identifier, ask the user for it (`e.g. com.example.myapp`), then persist. Confirm any inferred value with the user first.
|
|
60
|
+
|
|
61
|
+
## 4. Capture project knowledge
|
|
62
|
+
|
|
63
|
+
Infer each of the following from the codebase, confirm uncertain values with the user, and persist each one.
|
|
64
|
+
|
|
65
|
+
- **Project name** — from `settings.gradle*` `rootProject.name`, `pubspec.yaml` `name`, `package.json` `name`, the `*.xcodeproj` name, or the root directory name. Persist: `mauto config set project_name <name>`.
|
|
66
|
+
- **Platform details** — versions like `minSdk`/`targetSdk`/`compileSdk` (Gradle) or the deployment target (Xcode); format as e.g. `"Android (minSdk 24, targetSdk 34)"` or `"iOS (deployment target 16)"`. Persist: `mauto config set platform_details "<details>"`.
|
|
67
|
+
- **Build system** — `"Gradle (AGP)"`, `"Xcode"`, `"Flutter CLI + Gradle + Xcode"`, or `"Metro + Gradle + Xcode"`. Persist: `mauto config set build_system "<system>"`.
|
|
68
|
+
- **Build command** — the default debug/development build command (e.g. `./gradlew assembleDemoDebug`, `flutter build apk --flavor demo`, `npx react-native run-android`, or `xcodebuild -scheme <scheme>`). Persist: `mauto config set build_command "<command>"`.
|
|
69
|
+
- **Architecture** — infer from directory and class naming (`viewmodel/` → MVVM; `repository/`+`usecase/`+`domain/`+`data/` → Clean Architecture; `bloc/`/`cubit/` → BLoC; `reducer/`/`store/` → Redux/MVI; `presenter/`/`interactor/` → MVP/VIPER). Combine findings (e.g. `"MVVM with Clean Architecture"`). If unclear, ask the user. Persist: `mauto config set architecture "<pattern>"`.
|
|
70
|
+
- **Business domain** — read `README.md`, app-store metadata, `pubspec.yaml`/`package.json` description, or the app display name to infer a one-sentence domain description. If you cannot tell, ask the user. Persist: `mauto config set business_domain "<one sentence>"`.
|
|
71
|
+
- **Business-critical paths** — analyze navigation graphs, route/deep-link definitions, screen/Activity/Fragment/ViewController/page definitions, and feature modules to find the most important user flows (login, signup, onboarding, checkout, payment, search, profile, …). Confirm the list with the user. Persist a comma-separated value (stored as a list): `mauto config set business_critical_paths "<onboarding, login, checkout>"`.
|
|
72
|
+
- **Loading indicators** — grep for loading widgets/components (progress indicators, spinners, shimmer/skeleton placeholders) and any `*Loading*`/`*Spinner*`/`*Shimmer*`/`*Skeleton*` classes. Persist the found names, or a description like `"progress bars, spinners, shimmer effects"` if none found. Persist: `mauto config set loading_indicators "<list>"`.
|
|
73
|
+
- **Protected directories** — list the source directories the QA tooling must NEVER modify (e.g. `app/src/`, `lib/`, `src/`, `shared/`, `composeApp/`, `iosApp/`). Confirm with the user. Persist a comma-separated value (stored as a list): `mauto config set protected_directories "<app/src/, lib/, core/>"`.
|
|
74
|
+
|
|
75
|
+
## 5. Confirm the config
|
|
76
|
+
|
|
77
|
+
Read the persisted values back with `mauto config get <key>` for each finding, and present a concise summary to the user. The workspace is now analyzed and configured: `mauto guide generate` and `mauto guide execute` will read these values to drive scenario authoring and execution.
|
|
78
|
+
|
|
79
|
+
## Operational Boundaries
|
|
80
|
+
|
|
81
|
+
### DO
|
|
82
|
+
|
|
83
|
+
- Inspect real project files before inferring any value.
|
|
84
|
+
- Persist every finding with `mauto config set <key> <value>` and verify with `mauto config get`.
|
|
85
|
+
- Ask the user directly for any value that cannot be inferred, and confirm uncertain inferences.
|
|
86
|
+
|
|
87
|
+
### DON'T
|
|
88
|
+
|
|
89
|
+
- Modify app source code — this guide only reads it and writes the workspace config.
|
|
90
|
+
- Re-scaffold the workspace — `mauto setup` already created the directory tree and starter config.
|
|
91
|
+
- Invent a value you cannot find evidence for — ask the user instead.
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
- Inspect real project files before inferring any value; never invent a value you cannot find evidence for — ask the user instead.
|
|
2
|
+
- Persist every finding with `mauto config set <key> <value>` and verify it with `mauto config get <key>`.
|
|
3
|
+
- Do not modify app source code, and do not re-scaffold the workspace — `mauto setup` already created it. This guide only reads the project and writes the workspace config.
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Reasoning-delivery floor for the `mauto` CLI.
|
|
4
|
+
//
|
|
5
|
+
// emitGuide / emitSchema / emitBootstrap produce RAW content an agent injects
|
|
6
|
+
// directly into its own context (markdown / JSON schema / text). They do NOT
|
|
7
|
+
// wrap their output in the JSON envelope — only the CLI's ERROR paths (unknown
|
|
8
|
+
// topic/name) do that. See src/cli.js for the raw-vs-envelope wiring.
|
|
9
|
+
//
|
|
10
|
+
// These are structured STUBS in slice 3; the full prose ports land in later
|
|
11
|
+
// slices. The stubs are deliberately minimal but already honour every contract
|
|
12
|
+
// the lint guards enforce: no `{{placeholder}}` tokens, no leaked `mobile_*`
|
|
13
|
+
// tool names (the agent drives the device only through `mauto` verbs), and in
|
|
14
|
+
// platform-agnostic mode no OS / OS-specific tooling words — per-OS facts are
|
|
15
|
+
// reached through the four semantic actions, never named here.
|
|
16
|
+
|
|
17
|
+
const fs = require('fs');
|
|
18
|
+
const path = require('path');
|
|
19
|
+
|
|
20
|
+
const { interpolate } = require('./placeholders');
|
|
21
|
+
|
|
22
|
+
// Topics with REAL ported prose live as markdown content files (carrying
|
|
23
|
+
// `{{placeholder}}` tokens lifted from the Gemini SKILLs). They are emitted by
|
|
24
|
+
// loading the mode-appropriate file and running it through interpolate(...),
|
|
25
|
+
// which fills tokens from the workspace config and applies a fallback so no
|
|
26
|
+
// `{{` survives. Topics absent from this map fall through to the stub emitter.
|
|
27
|
+
const CONTENT_DIR = path.resolve(__dirname, 'content');
|
|
28
|
+
const PORTED_TOPICS = new Set(['generate', 'execute', 'setup']);
|
|
29
|
+
|
|
30
|
+
const SCENARIO_SCHEMA = path.resolve(__dirname, '../schemas/scenario_schema.json');
|
|
31
|
+
const RESULT_SCHEMA = path.resolve(__dirname, '../schemas/result_schema.json');
|
|
32
|
+
const CONFIG_SCHEMA = path.resolve(__dirname, '../schemas/config_schema.json');
|
|
33
|
+
|
|
34
|
+
const TOPICS = {
|
|
35
|
+
generate:
|
|
36
|
+
'Author a test scenario. The agent drives `mauto elements`/`tap`/`type`/`swipe`/`press` to explore the app, then assembles a scenario JSON validated with `mauto validate` against `mauto schema scenario`.',
|
|
37
|
+
execute:
|
|
38
|
+
'Run a scenario. The agent replays each step through `mauto` action verbs, evaluates checks with `mauto assert`, and records progress with `mauto result add-step` / `mauto result finalize`.',
|
|
39
|
+
setup:
|
|
40
|
+
'Initialise the workspace. `mauto setup` scaffolds the mobile-automator tree and a config; `mauto config get/set` reads and updates it.',
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const SEMANTIC_ACTIONS = ['press_back', 'dismiss_keyboard', 'grant_permission', 'deny_permission'];
|
|
44
|
+
|
|
45
|
+
function emitGuide(topic, { mode = 'platform-aware', projectRoot } = {}) {
|
|
46
|
+
const summary = TOPICS[topic];
|
|
47
|
+
if (!summary) {
|
|
48
|
+
throw new Error(`unknown guide topic: ${topic}`);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (PORTED_TOPICS.has(topic)) {
|
|
52
|
+
const variant = mode === 'platform-agnostic' ? 'agnostic' : 'aware';
|
|
53
|
+
const file = path.join(CONTENT_DIR, `${topic}.${variant}.md`);
|
|
54
|
+
const template = fs.readFileSync(file, 'utf8');
|
|
55
|
+
return interpolate(template, { projectRoot, mode });
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const lines = [];
|
|
59
|
+
lines.push(`# Guide: ${topic} (${mode})`);
|
|
60
|
+
lines.push('');
|
|
61
|
+
lines.push(summary);
|
|
62
|
+
lines.push('');
|
|
63
|
+
lines.push(`This guide directs the agent through \`mauto\` verbs for the \`${topic}\` workflow.`);
|
|
64
|
+
|
|
65
|
+
if (mode === 'platform-agnostic') {
|
|
66
|
+
lines.push('');
|
|
67
|
+
lines.push(
|
|
68
|
+
'Platform-agnostic: never rely on a specific platform. Map every OS-level gesture to one of the four semantic actions, executed through `mauto press`:'
|
|
69
|
+
);
|
|
70
|
+
lines.push('');
|
|
71
|
+
for (const act of SEMANTIC_ACTIONS) {
|
|
72
|
+
lines.push(`- \`${act}\``);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
lines.push('');
|
|
77
|
+
lines.push('_(full guide content lands in a later slice)_');
|
|
78
|
+
lines.push('');
|
|
79
|
+
return lines.join('\n');
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const SCHEMA_FILES = {
|
|
83
|
+
scenario: SCENARIO_SCHEMA,
|
|
84
|
+
result: RESULT_SCHEMA,
|
|
85
|
+
config: CONFIG_SCHEMA,
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
function emitSchema(name) {
|
|
89
|
+
const file = SCHEMA_FILES[name];
|
|
90
|
+
if (!file) {
|
|
91
|
+
throw new Error(`unknown schema name: ${name}`);
|
|
92
|
+
}
|
|
93
|
+
return fs.readFileSync(file, 'utf8');
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function emitBootstrap() {
|
|
97
|
+
const verbs = [
|
|
98
|
+
['elements', 'list the UI elements currently on screen'],
|
|
99
|
+
['tap', 'tap at absolute screen coordinates'],
|
|
100
|
+
['long-press', 'long-press at absolute screen coordinates (optional --duration <ms>)'],
|
|
101
|
+
['double-tap', 'double-tap at absolute screen coordinates'],
|
|
102
|
+
['type', 'type text into the focused element'],
|
|
103
|
+
['swipe', 'swipe in a cardinal direction'],
|
|
104
|
+
['press', 'press a system/hardware button or semantic action'],
|
|
105
|
+
['launch', 'launch an installed app by its package/bundle id'],
|
|
106
|
+
['install', 'install an app from a local artifact path'],
|
|
107
|
+
['uninstall', 'uninstall an app by its package/bundle id'],
|
|
108
|
+
['open-url', 'open a URL (deep link or web) on the device'],
|
|
109
|
+
['orientation', 'set the device orientation (portrait | landscape)'],
|
|
110
|
+
['screenshot', 'save a screenshot to a path'],
|
|
111
|
+
['assert', 'evaluate an assertion against the current screen'],
|
|
112
|
+
['validate', 'validate a scenario file against the scenario schema'],
|
|
113
|
+
['result', 'record step results and finalize a run'],
|
|
114
|
+
['setup', 'scaffold the workspace and write a config'],
|
|
115
|
+
['config', 'get/set values in the workspace config'],
|
|
116
|
+
['guide', 'print the guide for a workflow topic'],
|
|
117
|
+
['schema', 'print the scenario, result, or config JSON schema'],
|
|
118
|
+
];
|
|
119
|
+
|
|
120
|
+
const lines = [];
|
|
121
|
+
lines.push('# mauto bootstrap');
|
|
122
|
+
lines.push('');
|
|
123
|
+
lines.push('Verb map:');
|
|
124
|
+
for (const [verb, desc] of verbs) {
|
|
125
|
+
lines.push(`- \`mauto ${verb}\` — ${desc}`);
|
|
126
|
+
}
|
|
127
|
+
lines.push('');
|
|
128
|
+
lines.push('Invariants:');
|
|
129
|
+
lines.push('- Run `mauto guide <topic>` before first using a workflow.');
|
|
130
|
+
lines.push('- Drive the device ONLY through `mauto` verbs.');
|
|
131
|
+
lines.push('- Platform-agnostic: never rely on a stable element id from the view hierarchy.');
|
|
132
|
+
lines.push('- Every action verb returns a JSON envelope ({ ok, data } or { ok:false, error }).');
|
|
133
|
+
lines.push('');
|
|
134
|
+
return lines.join('\n');
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
module.exports = { emitGuide, emitSchema, emitBootstrap, TOPICS, SEMANTIC_ACTIONS };
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Placeholder interpolation for ported guide prose.
|
|
4
|
+
//
|
|
5
|
+
// Guide content files (src/guide/content/*.md) carry `{{token}}` placeholders
|
|
6
|
+
// lifted verbatim from the Gemini SKILL templates. At emit time we replace every
|
|
7
|
+
// token with a config-derived value so the agent reads concrete, project-specific
|
|
8
|
+
// prose. A token with no backing config value falls back to a clear "not
|
|
9
|
+
// configured" note — guaranteeing NO `{{` survives, which the guide lint guards
|
|
10
|
+
// enforce.
|
|
11
|
+
//
|
|
12
|
+
// PLACEHOLDER_KEYS maps each placeholder name to the config lookup(s) used to
|
|
13
|
+
// resolve it. It is exported so the execute port (slice 5) reuses the
|
|
14
|
+
// same contract instead of re-deriving it.
|
|
15
|
+
|
|
16
|
+
const configManager = require('../config/manager');
|
|
17
|
+
|
|
18
|
+
// How an UNSET placeholder renders has to fit the sentence it lands in. A
|
|
19
|
+
// single context-blind string cannot: the 13 placeholder sites in the aware
|
|
20
|
+
// guides occupy at least five grammatical roles (label value, inline noun,
|
|
21
|
+
// parenthetical appositive, inline tail, whole section body). There are two
|
|
22
|
+
// kinds of slot, and they want opposite things.
|
|
23
|
+
//
|
|
24
|
+
// VALUE slots — `**Platform:** {{platform_details}}`, `the app package
|
|
25
|
+
// ({{app_package}})`. These want a short noun phrase naming the key to set.
|
|
26
|
+
// Critically it must carry NO parentheses of its own: the prose already
|
|
27
|
+
// supplies them where they belong, so a self-parenthesizing fallback rendered
|
|
28
|
+
// `the app package ((not configured …))` — the same class of malformation
|
|
29
|
+
// #143 set out to remove, in a different shape.
|
|
30
|
+
//
|
|
31
|
+
// OPTIONAL slots — `the `mauto` CLI{{automation_extras}}.` and the trailing
|
|
32
|
+
// `{{additional_resources}}` bullet. These are additive prose; when there is
|
|
33
|
+
// nothing to add, the correct rendering is NOTHING. A diagnostic jammed onto
|
|
34
|
+
// the preceding word (`the `mauto` CLI(not configured …).`) is strictly worse
|
|
35
|
+
// than silence.
|
|
36
|
+
const FALLBACK = 'not configured';
|
|
37
|
+
|
|
38
|
+
// The fallback also used to read `set it via mauto config set <key>` with
|
|
39
|
+
// `<key>` left LITERAL — so the guide told the agent to run a command with a
|
|
40
|
+
// placeholder still in it, 13 times, and never named the missing key. The
|
|
41
|
+
// candidate list knows the key, so name it.
|
|
42
|
+
function fallbackFor(spec) {
|
|
43
|
+
if (!spec) return FALLBACK; // unknown token — nothing to name
|
|
44
|
+
if (spec.optional) return '';
|
|
45
|
+
return `${FALLBACK} — mauto config set ${spec.keys[0]}`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Each entry lists candidate config keys, tried in order. The config may be in
|
|
49
|
+
// the flat agnostic shape (top-level keys) or the nested aware shape written by
|
|
50
|
+
// setup (app.*, knowledge.*) — we probe both so one map serves every shape.
|
|
51
|
+
//
|
|
52
|
+
// `join: true` -> array values are rendered as a comma-joined list.
|
|
53
|
+
// `prefix` -> rendered as `${prefix}${value}` when a value is present
|
|
54
|
+
// (used by automation_extras / additional_resources, which in
|
|
55
|
+
// the original prose are inline tails like ", plus adb").
|
|
56
|
+
const PLACEHOLDER_KEYS = {
|
|
57
|
+
// ---- shared (aware + agnostic) ----
|
|
58
|
+
project_name: { keys: ['project_name', 'knowledge.project_name'] },
|
|
59
|
+
loading_indicators: { keys: ['loading_indicators', 'knowledge.loading_indicators'] },
|
|
60
|
+
protected_directories: {
|
|
61
|
+
keys: ['protected_directories', 'knowledge.protected_directories'],
|
|
62
|
+
join: true,
|
|
63
|
+
},
|
|
64
|
+
// `optional: true` -> renders as nothing when unset (see FALLBACK above).
|
|
65
|
+
additional_resources: {
|
|
66
|
+
keys: ['additional_resources', 'knowledge.additional_resources'],
|
|
67
|
+
prefix: '\n',
|
|
68
|
+
optional: true,
|
|
69
|
+
},
|
|
70
|
+
|
|
71
|
+
// ---- aware only ----
|
|
72
|
+
app_package: { keys: ['android_package', 'app.android_package', 'ios_bundle_id', 'app.ios_bundle_id'] },
|
|
73
|
+
architecture: { keys: ['architecture', 'knowledge.architecture'] },
|
|
74
|
+
platform_details: { keys: ['platform_details', 'knowledge.platform_details', 'platform'] },
|
|
75
|
+
build_system: { keys: ['build_system', 'knowledge.build_system'] },
|
|
76
|
+
build_command: { keys: ['build_command', 'knowledge.build_command'] },
|
|
77
|
+
// An inline tail (", plus adb") appended to "the `mauto` CLI" — optional, so
|
|
78
|
+
// an unset value ends the sentence cleanly instead of jamming a note onto it.
|
|
79
|
+
automation_extras: {
|
|
80
|
+
keys: ['automation_extras', 'knowledge.automation_extras'],
|
|
81
|
+
optional: true,
|
|
82
|
+
},
|
|
83
|
+
environments: { keys: ['environments'], join: true },
|
|
84
|
+
|
|
85
|
+
// ---- agnostic only ----
|
|
86
|
+
business_domain: { keys: ['business_domain', 'knowledge.business_domain'] },
|
|
87
|
+
business_critical_paths: {
|
|
88
|
+
keys: ['business_critical_paths', 'knowledge.business_critical_paths'],
|
|
89
|
+
join: true,
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
function firstDefined(projectRoot, keys) {
|
|
94
|
+
// No project root (e.g. lint guards emit guides without one) -> no config,
|
|
95
|
+
// every placeholder takes the fallback. Never let config I/O throw here.
|
|
96
|
+
if (!projectRoot) return undefined;
|
|
97
|
+
for (const k of keys) {
|
|
98
|
+
const v = configManager.get(projectRoot, k);
|
|
99
|
+
if (v !== undefined && v !== null && v !== '') return v;
|
|
100
|
+
}
|
|
101
|
+
return undefined;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function renderValue(value, spec) {
|
|
105
|
+
if (value === undefined) return undefined;
|
|
106
|
+
let out;
|
|
107
|
+
if (Array.isArray(value)) {
|
|
108
|
+
if (value.length === 0) return undefined;
|
|
109
|
+
out = spec.join ? value.join(', ') : value.join(', ');
|
|
110
|
+
} else {
|
|
111
|
+
out = String(value);
|
|
112
|
+
}
|
|
113
|
+
if (spec.prefix) out = `${spec.prefix}${out}`;
|
|
114
|
+
return out;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// interpolate(template, { projectRoot, mode })
|
|
118
|
+
// Replaces every {{token}} in `template`. Known placeholders resolve from config
|
|
119
|
+
// (per PLACEHOLDER_KEYS); known-but-unset and entirely unknown tokens both fall
|
|
120
|
+
// back to FALLBACK, so the returned string never contains `{{`.
|
|
121
|
+
function interpolate(template, { projectRoot, mode } = {}) {
|
|
122
|
+
// `mode` is accepted for symmetry with emitGuide and future per-mode tweaks;
|
|
123
|
+
// resolution today is mode-agnostic (the candidate-key probing covers both).
|
|
124
|
+
void mode;
|
|
125
|
+
return String(template).replace(/\{\{\s*([a-zA-Z0-9_.]+)\s*\}\}/g, (_, name) => {
|
|
126
|
+
const spec = PLACEHOLDER_KEYS[name];
|
|
127
|
+
const rendered = spec ? renderValue(firstDefined(projectRoot, spec.keys), spec) : undefined;
|
|
128
|
+
return rendered === undefined ? fallbackFor(spec) : rendered;
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
module.exports = { interpolate, PLACEHOLDER_KEYS, FALLBACK, fallbackFor };
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// VendorAdapters — `mauto init --agent <claude|cursor|gemini|copilot|agents>`.
|
|
4
|
+
//
|
|
5
|
+
// Each adapter installs native Agent Skills (open standard: a SKILL.md folder
|
|
6
|
+
// per topic) into the agent's skills directory, and is idempotent (re-running
|
|
7
|
+
// yields byte-identical files). claude/cursor additionally keep writing their
|
|
8
|
+
// thin slash-command/rule and merging the shared `mauto` MCP server entry.
|
|
9
|
+
// Adapters never clobber a file they don't own.
|
|
10
|
+
|
|
11
|
+
const fs = require('fs');
|
|
12
|
+
const path = require('path');
|
|
13
|
+
|
|
14
|
+
const guideEmitter = require('../guide/emitter');
|
|
15
|
+
const { renderSkill, readInvariants, SKILL_TOPICS } = require('./skill-renderer');
|
|
16
|
+
const { SKILL_META } = require('./skill-meta');
|
|
17
|
+
|
|
18
|
+
const TOPICS = ['generate', 'execute', 'setup'];
|
|
19
|
+
|
|
20
|
+
// The single mauto MCP server entry merged into a vendor's mcp config.
|
|
21
|
+
const MAUTO_SERVER = { command: 'mauto', args: ['mcp'] };
|
|
22
|
+
|
|
23
|
+
// Per-agent skills directory (relative to projectRoot). claude uses its own
|
|
24
|
+
// namespace; cursor/gemini/copilot each use their native dir; `agents` targets
|
|
25
|
+
// the open-standard location.
|
|
26
|
+
const SKILL_DEST = {
|
|
27
|
+
claude: path.join('.claude', 'skills'),
|
|
28
|
+
cursor: path.join('.cursor', 'skills'),
|
|
29
|
+
gemini: path.join('.gemini', 'skills'),
|
|
30
|
+
copilot: path.join('.github', 'skills'),
|
|
31
|
+
agents: path.join('.agents', 'skills'),
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
// BACK-COMPAT FALLBACK — NOT the live instruction surface for a current host.
|
|
35
|
+
//
|
|
36
|
+
// Claude Code merged custom commands into skills, and a same-named skill wins:
|
|
37
|
+
// "if a skill and a command share the same name, the skill takes precedence"
|
|
38
|
+
// https://code.claude.com/docs/en/skills
|
|
39
|
+
// We write both `mobile-automator-<topic>.md` here and a skill folder of the
|
|
40
|
+
// same name, so on any host with skills support THIS FILE IS NEVER READ.
|
|
41
|
+
// Editing it to change agent behavior on a current host does nothing — change
|
|
42
|
+
// `<topic>.invariants.md` (directives) or the guide content (workflow) instead.
|
|
43
|
+
//
|
|
44
|
+
// It still wins in two places, which is why it is kept and why it must not be
|
|
45
|
+
// thin: a Claude Code old enough to predate skills, and a workspace whose last
|
|
46
|
+
// `mauto init` predates the release that started writing skills. On both, this
|
|
47
|
+
// file IS the registered surface — so it renders from the same two sources the
|
|
48
|
+
// skill does (`SKILL_META` + `<topic>.invariants.md`) and can never drift into
|
|
49
|
+
// being the weaker of the two. The `description` frontmatter matters for the
|
|
50
|
+
// same reason: without it a host derives one by truncating the body, which
|
|
51
|
+
// yields a description of the mechanism rather than of when to use this.
|
|
52
|
+
function claudeCommandBody(topic) {
|
|
53
|
+
return (
|
|
54
|
+
'---\n' +
|
|
55
|
+
`description: ${SKILL_META[topic].description}\n` +
|
|
56
|
+
'---\n\n' +
|
|
57
|
+
`Run \`mauto guide ${topic}\` and follow it.\n\n` +
|
|
58
|
+
'## Non-negotiable directives (always apply)\n' +
|
|
59
|
+
`${readInvariants(topic)}\n`
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function cursorRuleBody() {
|
|
64
|
+
return (
|
|
65
|
+
'---\n' +
|
|
66
|
+
'description: Mobile Automator — drive mobile QA through the mauto CLI.\n' +
|
|
67
|
+
'alwaysApply: true\n' +
|
|
68
|
+
'---\n\n' +
|
|
69
|
+
guideEmitter.emitBootstrap() +
|
|
70
|
+
'\nRun `mauto guide <topic>` (generate|execute|setup) before a workflow. ' +
|
|
71
|
+
'Drive the device only through `mauto` verbs.\n'
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function writeIfChanged(filePath, content) {
|
|
76
|
+
const prev = fs.existsSync(filePath) ? fs.readFileSync(filePath, 'utf8') : null;
|
|
77
|
+
if (prev === content) return false;
|
|
78
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
79
|
+
fs.writeFileSync(filePath, content);
|
|
80
|
+
return true;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function mergeMcpConfig(filePath) {
|
|
84
|
+
let doc = {};
|
|
85
|
+
if (fs.existsSync(filePath)) {
|
|
86
|
+
const existing = fs.readFileSync(filePath, 'utf8');
|
|
87
|
+
try {
|
|
88
|
+
doc = JSON.parse(existing);
|
|
89
|
+
} catch (err) {
|
|
90
|
+
// A hand-edited / partially-written host config. Surface a typed,
|
|
91
|
+
// actionable error instead of a bare SyntaxError, and never clobber the
|
|
92
|
+
// user's file — re-running converges once they fix it.
|
|
93
|
+
const e = new Error(`existing MCP config is not valid JSON: ${filePath}`);
|
|
94
|
+
e.code = 'corrupt_mcp_config';
|
|
95
|
+
throw e;
|
|
96
|
+
}
|
|
97
|
+
if (doc == null || typeof doc !== 'object' || Array.isArray(doc)) doc = {};
|
|
98
|
+
}
|
|
99
|
+
if (doc.mcpServers == null || typeof doc.mcpServers !== 'object') {
|
|
100
|
+
doc.mcpServers = {};
|
|
101
|
+
}
|
|
102
|
+
doc.mcpServers.mauto = { ...MAUTO_SERVER };
|
|
103
|
+
const next = JSON.stringify(doc, null, 2) + '\n';
|
|
104
|
+
const prev = fs.existsSync(filePath) ? fs.readFileSync(filePath, 'utf8') : null;
|
|
105
|
+
if (prev === next) return { changed: false };
|
|
106
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
107
|
+
fs.writeFileSync(filePath, next);
|
|
108
|
+
return { changed: true };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Install one SKILL.md folder per topic under the agent's skills dir. Only the
|
|
112
|
+
// folders we own are written; foreign skill folders are never touched.
|
|
113
|
+
function writeSkills(projectRoot, agent, written) {
|
|
114
|
+
const root = path.join(projectRoot, SKILL_DEST[agent]);
|
|
115
|
+
for (const topic of SKILL_TOPICS) {
|
|
116
|
+
const { dirName, content } = renderSkill(topic);
|
|
117
|
+
const file = path.join(root, dirName, 'SKILL.md');
|
|
118
|
+
writeIfChanged(file, content);
|
|
119
|
+
written.push(file);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function makeAdapter(agent, extra) {
|
|
124
|
+
return {
|
|
125
|
+
apply({ projectRoot }) {
|
|
126
|
+
const written = [];
|
|
127
|
+
const merged = [];
|
|
128
|
+
if (extra) extra({ projectRoot, written, merged });
|
|
129
|
+
writeSkills(projectRoot, agent, written);
|
|
130
|
+
return { agent, written, merged };
|
|
131
|
+
},
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const ADAPTERS = {
|
|
136
|
+
claude: makeAdapter('claude', ({ projectRoot, written, merged }) => {
|
|
137
|
+
const commandsDir = path.join(projectRoot, '.claude', 'commands');
|
|
138
|
+
for (const topic of TOPICS) {
|
|
139
|
+
const file = path.join(commandsDir, `mobile-automator-${topic}.md`);
|
|
140
|
+
writeIfChanged(file, claudeCommandBody(topic));
|
|
141
|
+
written.push(file);
|
|
142
|
+
}
|
|
143
|
+
const mcpPath = path.join(projectRoot, '.mcp.json');
|
|
144
|
+
mergeMcpConfig(mcpPath);
|
|
145
|
+
merged.push(mcpPath);
|
|
146
|
+
}),
|
|
147
|
+
|
|
148
|
+
cursor: makeAdapter('cursor', ({ projectRoot, written, merged }) => {
|
|
149
|
+
const rulePath = path.join(projectRoot, '.cursor', 'rules', 'mobile-automator.mdc');
|
|
150
|
+
writeIfChanged(rulePath, cursorRuleBody());
|
|
151
|
+
written.push(rulePath);
|
|
152
|
+
const mcpPath = path.join(projectRoot, '.cursor', 'mcp.json');
|
|
153
|
+
mergeMcpConfig(mcpPath);
|
|
154
|
+
merged.push(mcpPath);
|
|
155
|
+
}),
|
|
156
|
+
|
|
157
|
+
gemini: makeAdapter('gemini'),
|
|
158
|
+
copilot: makeAdapter('copilot'),
|
|
159
|
+
agents: makeAdapter('agents'),
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
module.exports = { ADAPTERS, MAUTO_SERVER, SKILL_DEST };
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Frontmatter metadata for the installed Agent Skills. `name` MUST equal the
|
|
4
|
+
// skill folder name and satisfy the strictest host rules (lowercase/hyphen,
|
|
5
|
+
// <=64 chars, no reserved words). `description` is generic and placeholder-free
|
|
6
|
+
// and names WHEN to use the skill so hosts surface it at the right moment.
|
|
7
|
+
const SKILL_META = {
|
|
8
|
+
generate: {
|
|
9
|
+
name: 'mobile-automator-generate',
|
|
10
|
+
description:
|
|
11
|
+
'Author a mobile QA test scenario by driving the app on a device and ' +
|
|
12
|
+
'recording each step as structured JSON. Use when the user wants to ' +
|
|
13
|
+
'create, author, record, or generate a mobile test scenario.',
|
|
14
|
+
},
|
|
15
|
+
execute: {
|
|
16
|
+
name: 'mobile-automator-execute',
|
|
17
|
+
description:
|
|
18
|
+
'Execute a saved mobile QA scenario on a device and produce a pass/fail ' +
|
|
19
|
+
'report with diagnostics. Use when the user wants to run, replay, or ' +
|
|
20
|
+
'verify a mobile test scenario.',
|
|
21
|
+
},
|
|
22
|
+
setup: {
|
|
23
|
+
name: 'mobile-automator-setup',
|
|
24
|
+
description:
|
|
25
|
+
'Initialise and configure a mobile-automator workspace by analysing the ' +
|
|
26
|
+
'project. Use when the user wants to set up, configure, or initialise ' +
|
|
27
|
+
'mobile QA automation for this project.',
|
|
28
|
+
},
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
module.exports = { SKILL_META };
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Deterministic SKILL.md renderer (option C: hybrid). The body inlines the
|
|
4
|
+
// placeholder-free QA invariants (guaranteed forcing the moment a host
|
|
5
|
+
// activates the skill) and points to `mauto guide <topic>` for the full,
|
|
6
|
+
// mode-aware, interpolated workflow. NO interpolation happens here, so the
|
|
7
|
+
// renderer never reads config.json — see docs/plans/2026-06-28-*-design.md §5.
|
|
8
|
+
const fs = require('fs');
|
|
9
|
+
const path = require('path');
|
|
10
|
+
|
|
11
|
+
const { SKILL_META } = require('./skill-meta');
|
|
12
|
+
|
|
13
|
+
const SKILL_TOPICS = ['generate', 'execute', 'setup'];
|
|
14
|
+
const CONTENT_DIR = path.resolve(__dirname, '../guide/content');
|
|
15
|
+
|
|
16
|
+
const TITLES = {
|
|
17
|
+
generate: 'Generate',
|
|
18
|
+
execute: 'Execute',
|
|
19
|
+
setup: 'Setup',
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
// The non-negotiable directives for a topic, verbatim from disk. Exported so
|
|
23
|
+
// every surface that has to carry these directives renders them from this one
|
|
24
|
+
// source rather than restating them — see `claudeCommandBody` in adapters.js.
|
|
25
|
+
function readInvariants(topic) {
|
|
26
|
+
return fs.readFileSync(path.join(CONTENT_DIR, `${topic}.invariants.md`), 'utf8').trim();
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function renderSkill(topic) {
|
|
30
|
+
const meta = SKILL_META[topic];
|
|
31
|
+
if (!meta) {
|
|
32
|
+
throw new Error(`unknown skill topic: ${topic}`);
|
|
33
|
+
}
|
|
34
|
+
if (!TITLES[topic]) {
|
|
35
|
+
throw new Error(`unknown skill topic title: ${topic}`);
|
|
36
|
+
}
|
|
37
|
+
const invariants = readInvariants(topic);
|
|
38
|
+
|
|
39
|
+
const content =
|
|
40
|
+
'---\n' +
|
|
41
|
+
`name: ${meta.name}\n` +
|
|
42
|
+
`description: ${meta.description}\n` +
|
|
43
|
+
'---\n\n' +
|
|
44
|
+
`# Mobile Automator — ${TITLES[topic]}\n\n` +
|
|
45
|
+
'## Non-negotiable directives (always apply)\n' +
|
|
46
|
+
`${invariants}\n\n` +
|
|
47
|
+
'## Full workflow\n' +
|
|
48
|
+
'Load the complete, project-configured playbook and follow it step by step:\n\n' +
|
|
49
|
+
` mauto guide ${topic}\n`;
|
|
50
|
+
|
|
51
|
+
// Defensive: the invariants are placeholder-free by construction; never let a
|
|
52
|
+
// stray token reach an installed skill.
|
|
53
|
+
if (content.includes('{{')) {
|
|
54
|
+
throw new Error(`skill ${topic} leaked a placeholder token`);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return { dirName: meta.name, content };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
module.exports = { renderSkill, readInvariants, SKILL_TOPICS };
|