mobile-automator 0.30.1 โ†’ 0.31.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/README.md CHANGED
@@ -73,6 +73,8 @@ In **Cursor**, `init` installs a project rule instead โ€” just ask the agent in
73
73
 
74
74
  That's it. Scenarios land in `mobile-automator/scenarios/`, results in `mobile-automator/results/`.
75
75
 
76
+ **Upgrading:** after `npm i -g mobile-automator@latest`, re-run `mauto init --agent <host>` in each project to refresh the skills, rules and commands it installed. Until you do, `mauto guide` starts with a notice that the host integration is out of date, and `mauto doctor` reports which hosts need refreshing.
77
+
76
78
  ---
77
79
 
78
80
  ## ๐Ÿง  How it works
@@ -135,6 +137,7 @@ Most low-level verbs are called **by the agent**, not by you. The ones you'll ru
135
137
  | Command | What it does |
136
138
  |---------|--------------|
137
139
  | `mauto init --agent <claude\|cursor\|gemini\|copilot\|agents\|all>` | Install native Agent Skills into each host's skills directory; also writes slash-commands/rules + MCP entry for claude/cursor |
140
+ | `mauto doctor` | Report whether the integration `init` installed is missing or out of date, and the `init` command that refreshes it |
138
141
  | `mauto setup [--mode aware\|agnostic]` | Scaffold the `mobile-automator/` workspace + config |
139
142
  | `mauto devices` ยท `devices use <id>` ยท `devices clear` | List / pin / unpin the target device |
140
143
  | `mauto guide <generate\|execute\|setup>` | Print the workflow guidance for a topic |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mobile-automator",
3
- "version": "0.30.1",
3
+ "version": "0.31.1",
4
4
  "description": "Host-agnostic mauto CLI for AI-driven mobile QA automation โ€” analyzes a mobile project and drives devices through mobile-mcp.",
5
5
  "homepage": "https://github.com/sh3lan93/mobile-automator#readme",
6
6
  "repository": {
package/src/cli.js CHANGED
@@ -23,6 +23,9 @@ const { validateAt } = require('./config/schema');
23
23
  const { scaffold } = require('./setup/scaffold');
24
24
  const guideEmitter = require('./guide/emitter');
25
25
  const { ADAPTERS } = require('./init/adapters');
26
+ const { inspectHosts, ADVISORY_FS_CODES } = require('./init/host-plan');
27
+ const { renderStaleNotice, installedBefore, refreshHint } = require('./init/stale-notice');
28
+ const { doctorReport } = require('./init/doctor');
26
29
  const { isSemanticAction, ACTION_METHOD, selectResolver } = require('./device/semantic-press');
27
30
  const connection = require('./device/connection');
28
31
  const { isSupportedHost, unsupportedHostError } = require('./device/host-support');
@@ -990,7 +993,20 @@ async function handleTelemetryFlush({ projectRoot, env = process.env, flusher })
990
993
  }
991
994
 
992
995
  // RAW content on success; fail envelope only when the topic is unknown.
993
- function handleGuide({ projectRoot, emitter = guideEmitter, config = configManager }, topic) {
996
+ //
997
+ // #144: when a host integration `mauto init` wrote is missing or out of date,
998
+ // a notice is prepended. It lives HERE and never inside emitter.emitGuide /
999
+ // emitBootstrap: init embeds emitBootstrap in the cursor rule and the MCP
1000
+ // prompts server serves emitGuide, so putting it there would make init's
1001
+ // output depend on workspace state (and break byte-identical reruns). The
1002
+ // inspection is advisory: a filesystem fault that means "the workspace's
1003
+ // integration files can't be read" simply yields no notice, while any other
1004
+ // failure escapes to the envelope boundary โ€” a defect in the inspection must
1005
+ // be visible, not silently disable the notice forever.
1006
+ function handleGuide(
1007
+ { projectRoot, emitter = guideEmitter, config = configManager, inspect = inspectHosts },
1008
+ topic
1009
+ ) {
994
1010
  const cfg = config.load(projectRoot);
995
1011
  const mode = config.resolveMode(cfg);
996
1012
  let raw;
@@ -1002,6 +1018,14 @@ function handleGuide({ projectRoot, emitter = guideEmitter, config = configManag
1002
1018
  exitKind: 'invalid_input',
1003
1019
  };
1004
1020
  }
1021
+ let stale;
1022
+ try {
1023
+ stale = inspect(projectRoot).filter((h) => h.stale);
1024
+ } catch (err) {
1025
+ if (!(err && ADVISORY_FS_CODES.has(err.code))) throw err;
1026
+ stale = [];
1027
+ }
1028
+ if (stale.length) raw = renderStaleNotice(stale) + raw;
1005
1029
  return { raw, exitKind: 'ok' };
1006
1030
  }
1007
1031
 
@@ -1129,7 +1153,7 @@ function hintForAdapterError(cls) {
1129
1153
  function applyOneAdapter(adapter, agent, projectRoot) {
1130
1154
  try {
1131
1155
  const r = adapter.apply({ projectRoot });
1132
- return { agent, ok: true, written: r.written, merged: r.merged };
1156
+ return { agent, ok: true, changes: r.changes };
1133
1157
  } catch (err) {
1134
1158
  return {
1135
1159
  agent,
@@ -1140,15 +1164,16 @@ function applyOneAdapter(adapter, agent, projectRoot) {
1140
1164
  }
1141
1165
  }
1142
1166
 
1143
- function handleInit({ projectRoot, adapters = ADAPTERS }, agent) {
1167
+ function handleInit({ projectRoot, adapters = ADAPTERS, inspect = inspectHosts }, agent) {
1144
1168
  const known = Object.keys(adapters);
1145
1169
  if (agent === 'all') {
1170
+ const wasInstalled = installedBefore(inspect, projectRoot);
1146
1171
  // Continue-on-error: every host is attempted; the result is ONE envelope
1147
1172
  // carrying a per-agent ok/failed map. `ok` only when all succeed.
1148
1173
  const perAgent = known.map((a) => applyOneAdapter(adapters[a], a, projectRoot));
1149
1174
  const failed = perAgent.filter((r) => !r.ok);
1150
1175
  if (failed.length === 0) {
1151
- return { envelope: ok({ agents: perAgent }), exitKind: 'ok' };
1176
+ return { envelope: ok({ agents: perAgent }, refreshHint(perAgent, wasInstalled)), exitKind: 'ok' };
1152
1177
  }
1153
1178
  return {
1154
1179
  envelope: fail(
@@ -1171,6 +1196,7 @@ function handleInit({ projectRoot, adapters = ADAPTERS }, agent) {
1171
1196
  exitKind: 'invalid_input',
1172
1197
  };
1173
1198
  }
1199
+ const wasInstalled = installedBefore(inspect, projectRoot);
1174
1200
  const r = applyOneAdapter(adapter, agent, projectRoot);
1175
1201
  if (!r.ok) {
1176
1202
  // A malformed host config is the user's input, not an environment fault โ€”
@@ -1188,11 +1214,21 @@ function handleInit({ projectRoot, adapters = ADAPTERS }, agent) {
1188
1214
  };
1189
1215
  }
1190
1216
  return {
1191
- envelope: ok({ agent: r.agent, written: r.written, merged: r.merged }),
1217
+ envelope: ok({ agent: r.agent, changes: r.changes }, refreshHint([r], wasInstalled)),
1192
1218
  exitKind: 'ok',
1193
1219
  };
1194
1220
  }
1195
1221
 
1222
+ // `mauto doctor` (#144): report whether the host integration `mauto init`
1223
+ // wrote is missing or out of date. A report, not a gate โ€” always ok. The
1224
+ // report itself (data + hint) lives in src/init/doctor.js; this is the
1225
+ // handler-shape adapter to the emit contract. An unexpected fs error from the
1226
+ // inspection (EACCES, ...) is left to the envelope boundary to classify.
1227
+ function handleDoctor({ projectRoot, inspect = inspectHosts }) {
1228
+ const { data, hint } = doctorReport({ projectRoot, inspect });
1229
+ return { envelope: ok(data, hint), exitKind: 'ok' };
1230
+ }
1231
+
1196
1232
  // --- Issue #91: persistent device session daemon -------------------------
1197
1233
  //
1198
1234
  // `mauto session start|status|end` manage the per-workspace daemon that holds
@@ -2125,6 +2161,13 @@ function buildProgram(deps = {}) {
2125
2161
  emit(r, humanFlag());
2126
2162
  }));
2127
2163
 
2164
+ // No requireWorkspace: like guide, doctor runs on the discovered workspace
2165
+ // or cwd, and must work before `mauto setup` has ever run.
2166
+ program
2167
+ .command('doctor')
2168
+ .description("Report whether this project's agent integration (mauto init) is missing or out of date")
2169
+ .action(withEnvelope(() => emit(handleDoctor({ projectRoot }), humanFlag())));
2170
+
2128
2171
  // --- Issue #91: device session lifecycle ---------------------------------
2129
2172
 
2130
2173
  const session = program
@@ -2497,6 +2540,7 @@ module.exports = {
2497
2540
  handleMemoryAdd,
2498
2541
  handleMemoryForget,
2499
2542
  handleInit,
2543
+ handleDoctor,
2500
2544
  handleSessionStart,
2501
2545
  handleSessionStatus,
2502
2546
  handleSessionEnd,
@@ -12,14 +12,14 @@
12
12
 
13
13
  const fs = require('fs');
14
14
  const path = require('path');
15
- const Ajv = require('ajv');
16
15
 
17
16
  const { formatError } = require('../schemas/format-error');
17
+ const { createCliAjv } = require('../schemas/cli-ajv');
18
18
 
19
19
  const CONFIG_SCHEMA_PATH = path.resolve(__dirname, '../schemas/config_schema.json');
20
20
 
21
21
  const schema = JSON.parse(fs.readFileSync(CONFIG_SCHEMA_PATH, 'utf8'));
22
- const ajv = new Ajv({ allErrors: true, strict: false });
22
+ const ajv = createCliAjv();
23
23
 
24
24
  // Resolve a local `$ref` ("#/definitions/stringList") to a fixpoint: a
25
25
  // resolved node may itself be another `$ref` (e.g. a `$ref` to a `$ref`), so
@@ -113,6 +113,7 @@ function emitBootstrap() {
113
113
  ['result', 'record step results and finalize a run'],
114
114
  ['setup', 'scaffold the workspace and write a config'],
115
115
  ['config', 'get/set values in the workspace config'],
116
+ ['doctor', 'report whether the agent integration `mauto init` installed is missing or out of date'],
116
117
  ['guide', 'print the guide for a workflow topic'],
117
118
  ['schema', 'print the scenario, result, or config JSON schema'],
118
119
  ];
@@ -7,82 +7,30 @@
7
7
  // yields byte-identical files). claude/cursor additionally keep writing their
8
8
  // thin slash-command/rule and merging the shared `mauto` MCP server entry.
9
9
  // Adapters never clobber a file they don't own.
10
+ //
11
+ // WHAT each host owns lives in host-plan.js (`planHost`); this module only
12
+ // applies that plan, so the staleness detector and the writers share one
13
+ // source (#144).
10
14
 
11
15
  const fs = require('fs');
12
16
  const path = require('path');
13
17
 
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
- }
18
+ const { planHost, HOSTS, MAUTO_SERVER, SKILL_DEST } = require('./host-plan');
74
19
 
20
+ // Returns 'created' | 'updated' | 'unchanged'.
75
21
  function writeIfChanged(filePath, content) {
76
22
  const prev = fs.existsSync(filePath) ? fs.readFileSync(filePath, 'utf8') : null;
77
- if (prev === content) return false;
23
+ if (prev === content) return 'unchanged';
78
24
  fs.mkdirSync(path.dirname(filePath), { recursive: true });
79
25
  fs.writeFileSync(filePath, content);
80
- return true;
26
+ return prev === null ? 'created' : 'updated';
81
27
  }
82
28
 
29
+ // Returns { changed, status } where status is 'created' | 'updated' | 'unchanged'.
83
30
  function mergeMcpConfig(filePath) {
84
31
  let doc = {};
85
- if (fs.existsSync(filePath)) {
32
+ const existed = fs.existsSync(filePath);
33
+ if (existed) {
86
34
  const existing = fs.readFileSync(filePath, 'utf8');
87
35
  try {
88
36
  doc = JSON.parse(existing);
@@ -101,62 +49,31 @@ function mergeMcpConfig(filePath) {
101
49
  }
102
50
  doc.mcpServers.mauto = { ...MAUTO_SERVER };
103
51
  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 };
52
+ const prev = existed ? fs.readFileSync(filePath, 'utf8') : null;
53
+ if (prev === next) return { changed: false, status: 'unchanged' };
106
54
  fs.mkdirSync(path.dirname(filePath), { recursive: true });
107
55
  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
- }
56
+ return { changed: true, status: prev === null ? 'created' : 'updated' };
121
57
  }
122
58
 
123
- function makeAdapter(agent, extra) {
59
+ function makeAdapter(agent) {
124
60
  return {
125
61
  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 };
62
+ const plan = planHost(agent);
63
+ const changes = [];
64
+ for (const { rel, content } of plan.files) {
65
+ const file = path.join(projectRoot, rel);
66
+ changes.push({ path: file, status: writeIfChanged(file, content) });
67
+ }
68
+ if (plan.mcp) {
69
+ const mcpPath = path.join(projectRoot, plan.mcp);
70
+ changes.push({ path: mcpPath, status: mergeMcpConfig(mcpPath).status });
71
+ }
72
+ return { agent, changes };
131
73
  },
132
74
  };
133
75
  }
134
76
 
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
- };
77
+ const ADAPTERS = Object.fromEntries(HOSTS.map((agent) => [agent, makeAdapter(agent)]));
161
78
 
162
79
  module.exports = { ADAPTERS, MAUTO_SERVER, SKILL_DEST };
@@ -0,0 +1,30 @@
1
+ 'use strict';
2
+
3
+ // `mauto doctor` (#144): report whether the host integration `mauto init`
4
+ // wrote is missing or out of date. A report, not a gate โ€” the CLI always
5
+ // renders it as an ok envelope. The inspection is honest: an unexpected fs
6
+ // error from inspectHosts (EACCES, ...) propagates to the caller's envelope
7
+ // boundary to classify, it is never swallowed.
8
+
9
+ const { HOSTS } = require('./host-plan');
10
+ const { initCommands } = require('./stale-notice');
11
+
12
+ // Returns { data, hint } for the `mauto doctor` envelope.
13
+ function doctorReport({ projectRoot, inspect }) {
14
+ const installed = inspect(projectRoot).filter((h) => h.installed);
15
+ const stale = installed.filter((h) => h.stale).map((h) => h.agent);
16
+ const data = {
17
+ initialised: installed.length > 0,
18
+ hosts: installed.map(({ agent, stale: s, files, mcp }) => ({ agent, stale: s, files, mcp })),
19
+ stale,
20
+ };
21
+ let hint = null;
22
+ if (stale.length) {
23
+ hint = initCommands(stale).map((c) => `run \`${c}\``).join('; ');
24
+ } else if (installed.length === 0) {
25
+ hint = `no host integration found โ€” run \`mauto init --agent <host>\` (${HOSTS.join(' | ')} | all)`;
26
+ }
27
+ return { data, hint };
28
+ }
29
+
30
+ module.exports = { doctorReport };
@@ -0,0 +1,197 @@
1
+ 'use strict';
2
+
3
+ // HostPlan โ€” the single source of "what `mauto init --agent <host>` owns".
4
+ //
5
+ // Every byte init writes is a pure function of the installed package: the
6
+ // rendered skills, the claude command bodies, the cursor rule and the `mauto`
7
+ // MCP server entry. None of it reads config.json. So "is this host's
8
+ // integration stale?" is answered by "would init write different bytes?" โ€” no
9
+ // version stamp exists or is needed (#144).
10
+ //
11
+ // The writers (adapters.js) and the detector (inspectHosts) both read
12
+ // `planHost`, so detection cannot drift from what init actually writes.
13
+
14
+ const fs = require('fs');
15
+ const path = require('path');
16
+ const { isDeepStrictEqual } = require('util');
17
+
18
+ const guideEmitter = require('../guide/emitter');
19
+ const { renderSkill, readInvariants, SKILL_TOPICS } = require('./skill-renderer');
20
+ const { SKILL_META } = require('./skill-meta');
21
+
22
+ const TOPICS = ['generate', 'execute', 'setup'];
23
+
24
+ // The single mauto MCP server entry merged into a vendor's mcp config.
25
+ const MAUTO_SERVER = { command: 'mauto', args: ['mcp'] };
26
+
27
+ // Per-agent skills directory (relative to projectRoot). claude uses its own
28
+ // namespace; cursor/gemini/copilot each use their native dir; `agents` targets
29
+ // the open-standard location.
30
+ const SKILL_DEST = {
31
+ claude: path.join('.claude', 'skills'),
32
+ cursor: path.join('.cursor', 'skills'),
33
+ gemini: path.join('.gemini', 'skills'),
34
+ copilot: path.join('.github', 'skills'),
35
+ agents: path.join('.agents', 'skills'),
36
+ };
37
+
38
+ const HOSTS = Object.keys(SKILL_DEST);
39
+
40
+ // Filesystem errno values that mean "the workspace's integration files can't
41
+ // be read the way they normally sit" โ€” a file or its parent is missing,
42
+ // unreadable, or a directory sits where a file is expected. The advisory
43
+ // surfaces that consume the inspection without failing (`guide`'s notice,
44
+ // init's pre-run probe) treat these as "say nothing", never as a reason to
45
+ // fail the verb. Any other errno (or a non-errno throw) escapes: a defect in
46
+ // the inspection itself must be visible, not silently absorbed.
47
+ const ADVISORY_FS_CODES = new Set(['ENOENT', 'EACCES', 'EPERM', 'EISDIR', 'ENOTDIR']);
48
+
49
+ // BACK-COMPAT FALLBACK โ€” NOT the live instruction surface for a current host.
50
+ //
51
+ // Claude Code merged custom commands into skills, and a same-named skill wins:
52
+ // "if a skill and a command share the same name, the skill takes precedence"
53
+ // https://code.claude.com/docs/en/skills
54
+ // We write both `mobile-automator-<topic>.md` here and a skill folder of the
55
+ // same name, so on any host with skills support THIS FILE IS NEVER READ.
56
+ // Editing it to change agent behavior on a current host does nothing โ€” change
57
+ // `<topic>.invariants.md` (directives) or the guide content (workflow) instead.
58
+ //
59
+ // It still wins in two places, which is why it is kept and why it must not be
60
+ // thin: a Claude Code old enough to predate skills, and a workspace whose last
61
+ // `mauto init` predates the release that started writing skills. On both, this
62
+ // file IS the registered surface โ€” so it renders from the same two sources the
63
+ // skill does (`SKILL_META` + `<topic>.invariants.md`) and can never drift into
64
+ // being the weaker of the two. The `description` frontmatter matters for the
65
+ // same reason: without it a host derives one by truncating the body, which
66
+ // yields a description of the mechanism rather than of when to use this.
67
+ function claudeCommandBody(topic) {
68
+ return (
69
+ '---\n' +
70
+ `description: ${SKILL_META[topic].description}\n` +
71
+ '---\n\n' +
72
+ `Run \`mauto guide ${topic}\` and follow it.\n\n` +
73
+ '## Non-negotiable directives (always apply)\n' +
74
+ `${readInvariants(topic)}\n`
75
+ );
76
+ }
77
+
78
+ function cursorRuleBody() {
79
+ return (
80
+ '---\n' +
81
+ 'description: Mobile Automator โ€” drive mobile QA through the mauto CLI.\n' +
82
+ 'alwaysApply: true\n' +
83
+ '---\n\n' +
84
+ guideEmitter.emitBootstrap() +
85
+ '\nRun `mauto guide <topic>` (generate|execute|setup) before a workflow. ' +
86
+ 'Drive the device only through `mauto` verbs.\n'
87
+ );
88
+ }
89
+
90
+ // One SKILL.md folder per topic under the agent's skills dir. Only the folders
91
+ // we own are listed; foreign skill folders are never part of the plan.
92
+ function skillFiles(agent) {
93
+ return SKILL_TOPICS.map((topic) => {
94
+ const { dirName, content } = renderSkill(topic);
95
+ return { rel: path.join(SKILL_DEST[agent], dirName, 'SKILL.md'), content };
96
+ });
97
+ }
98
+
99
+ // Everything init owns for one host, in write order. `files` are written whole;
100
+ // `mcp` (when non-null) is the host config into which the `mauto` server entry
101
+ // is merged. Paths are relative to the project root.
102
+ function planHost(agent) {
103
+ if (!Object.prototype.hasOwnProperty.call(SKILL_DEST, agent)) {
104
+ throw new Error(`unknown agent: ${agent}`);
105
+ }
106
+ const files = [];
107
+ let mcp = null;
108
+ if (agent === 'claude') {
109
+ for (const topic of TOPICS) {
110
+ files.push({
111
+ rel: path.join('.claude', 'commands', `mobile-automator-${topic}.md`),
112
+ content: claudeCommandBody(topic),
113
+ });
114
+ }
115
+ mcp = '.mcp.json';
116
+ } else if (agent === 'cursor') {
117
+ files.push({
118
+ rel: path.join('.cursor', 'rules', 'mobile-automator.mdc'),
119
+ content: cursorRuleBody(),
120
+ });
121
+ mcp = path.join('.cursor', 'mcp.json');
122
+ }
123
+ files.push(...skillFiles(agent));
124
+ return { files, mcp };
125
+ }
126
+
127
+ function readOrNull(file) {
128
+ try {
129
+ return fs.readFileSync(file, 'utf8');
130
+ } catch (err) {
131
+ if (err && err.code === 'ENOENT') return null;
132
+ throw err;
133
+ }
134
+ }
135
+
136
+ // The mauto entry is compared semantically โ€” the user's formatting, key order
137
+ // and other servers never make a host look stale.
138
+ function inspectMcp(file) {
139
+ const raw = readOrNull(file);
140
+ if (raw === null) return { status: 'missing', hasEntry: false };
141
+ let doc;
142
+ try {
143
+ doc = JSON.parse(raw);
144
+ } catch {
145
+ return { status: 'unreadable', hasEntry: false };
146
+ }
147
+ if (doc == null || typeof doc !== 'object' || Array.isArray(doc)) {
148
+ return { status: 'unreadable', hasEntry: false };
149
+ }
150
+ const servers = doc.mcpServers;
151
+ const entry =
152
+ servers && typeof servers === 'object' && !Array.isArray(servers) ? servers.mauto : undefined;
153
+ if (entry === undefined) return { status: 'missing', hasEntry: false };
154
+ return {
155
+ status: isDeepStrictEqual(entry, MAUTO_SERVER) ? 'current' : 'outdated',
156
+ hasEntry: true,
157
+ };
158
+ }
159
+
160
+ // Per-host report of what init owns vs. what is on disk. Never throws on a
161
+ // malformed host config (that is reported as 'unreadable').
162
+ function inspectHosts(projectRoot) {
163
+ return HOSTS.map((agent) => {
164
+ const plan = planHost(agent);
165
+ let installed = false;
166
+ const files = plan.files.map(({ rel, content }) => {
167
+ const abs = path.join(projectRoot, rel);
168
+ const onDisk = readOrNull(abs);
169
+ if (onDisk !== null) installed = true;
170
+ const status = onDisk === null ? 'missing' : onDisk === content ? 'current' : 'outdated';
171
+ return { path: abs, status };
172
+ });
173
+ let mcp = null;
174
+ if (plan.mcp) {
175
+ const abs = path.join(projectRoot, plan.mcp);
176
+ const { status, hasEntry } = inspectMcp(abs);
177
+ if (hasEntry) installed = true;
178
+ mcp = { path: abs, status };
179
+ }
180
+ const stale =
181
+ installed &&
182
+ (files.some((f) => f.status !== 'current') || (mcp !== null && mcp.status !== 'current'));
183
+ return { agent, installed, stale, files, mcp };
184
+ });
185
+ }
186
+
187
+ module.exports = {
188
+ HOSTS,
189
+ ADVISORY_FS_CODES,
190
+ MAUTO_SERVER,
191
+ SKILL_DEST,
192
+ TOPICS,
193
+ claudeCommandBody,
194
+ cursorRuleBody,
195
+ planHost,
196
+ inspectHosts,
197
+ };
@@ -21,7 +21,7 @@ const TITLES = {
21
21
 
22
22
  // The non-negotiable directives for a topic, verbatim from disk. Exported so
23
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.
24
+ // source rather than restating them โ€” see `claudeCommandBody` in host-plan.js.
25
25
  function readInvariants(topic) {
26
26
  return fs.readFileSync(path.join(CONTENT_DIR, `${topic}.invariants.md`), 'utf8').trim();
27
27
  }
@@ -0,0 +1,85 @@
1
+ 'use strict';
2
+
3
+ // The advisory `mauto guide` prepends when a host integration that
4
+ // `mauto init` wrote is missing or out of date (#144).
5
+ //
6
+ // Every surface init has ever written tells the agent to run
7
+ // `mauto guide <topic>`, so a notice here reaches every stale workspace. It
8
+ // tells the agent to TELL THE USER โ€” init is never run on the user's behalf.
9
+ //
10
+ // The text is read by the agent alongside the guide, so it is held to the
11
+ // guide lint guards: no OS names, no `{{`, no raw engine tool names
12
+ // (tests/lint/stale-notice-fixture.js feeds it to them).
13
+
14
+ const { HOSTS } = require('./host-plan');
15
+
16
+ function byHostOrder(a, b) {
17
+ return HOSTS.indexOf(a) - HOSTS.indexOf(b);
18
+ }
19
+
20
+ // The init command(s) that refresh `agents`: one per host, in HOSTS order, or
21
+ // the single `--agent all` when every host needs it.
22
+ function initCommands(agents) {
23
+ const unique = [...new Set(agents)].sort(byHostOrder);
24
+ if (HOSTS.every((h) => unique.includes(h))) return ['mauto init --agent all'];
25
+ return unique.map((a) => `mauto init --agent ${a}`);
26
+ }
27
+
28
+ function describeHost(h) {
29
+ const statuses = h.files.map((f) => f.status);
30
+ if (h.mcp) statuses.push(h.mcp.status);
31
+ const missing = statuses.filter((s) => s === 'missing').length;
32
+ const outdated = statuses.filter((s) => s === 'outdated').length;
33
+ const parts = [];
34
+ if (missing) parts.push(`${missing} missing`);
35
+ if (outdated) parts.push(`${outdated} outdated`);
36
+ if (h.mcp && h.mcp.status === 'unreadable') parts.push('MCP config unreadable');
37
+ return `${h.agent} (${parts.join(', ')})`;
38
+ }
39
+
40
+ function joinList(items) {
41
+ if (items.length <= 2) return items.join(' and ');
42
+ return `${items.slice(0, -1).join(', ')}, and ${items[items.length - 1]}`;
43
+ }
44
+
45
+ // Which hosts already had an integration BEFORE this init run (#144). A
46
+ // refresh of a host that was never installed is a fresh install, not an
47
+ // upgrade, so it gets no hint. Advisory only โ€” an inspection failure (an
48
+ // unreadable workspace) must never fail init, so it means "no hosts".
49
+ function installedBefore(inspect, projectRoot) {
50
+ try {
51
+ return new Set(inspect(projectRoot).filter((h) => h.installed).map((h) => h.agent));
52
+ } catch {
53
+ return new Set();
54
+ }
55
+ }
56
+
57
+ // `refreshed N stale file(s) for <agent>` for each previously-installed host
58
+ // whose run created or updated anything; null when there is nothing to say.
59
+ // `results` hold per-agent adapter results (`{ ok, agent, changes }`).
60
+ function refreshHint(results, wasInstalled) {
61
+ const parts = [];
62
+ for (const r of results) {
63
+ if (!r.ok || !wasInstalled.has(r.agent)) continue;
64
+ const n = r.changes.filter((c) => c.status === 'created' || c.status === 'updated').length;
65
+ if (n > 0) parts.push(`refreshed ${n} stale file(s) for ${r.agent}`);
66
+ }
67
+ return parts.length ? parts.join('; ') : null;
68
+ }
69
+
70
+ // `staleHosts`: entries from inspectHosts() with `stale: true`. Returns a
71
+ // markdown blockquote followed by a blank line, or '' when there is nothing
72
+ // stale. Output is independent of input order.
73
+ function renderStaleNotice(staleHosts) {
74
+ if (!staleHosts || staleHosts.length === 0) return '';
75
+ const hosts = [...staleHosts].sort((a, b) => byHostOrder(a.agent, b.agent));
76
+ const commands = initCommands(hosts.map((h) => h.agent)).map((c) => `\`${c}\``);
77
+ return (
78
+ '> **Notice โ€” host integration out of date.** This project\'s agent integration was installed ' +
79
+ 'by an older `mauto` and is missing or out of date: ' +
80
+ `${hosts.map(describeHost).join(', ')}. ` +
81
+ `Tell the user to run ${joinList(commands)} to refresh it, then continue with this guide.\n\n`
82
+ );
83
+ }
84
+
85
+ module.exports = { renderStaleNotice, initCommands, installedBefore, refreshHint };
@@ -80,6 +80,7 @@ const VERB_NAMES = Object.freeze([
80
80
  'bootstrap',
81
81
  'memory',
82
82
  'init',
83
+ 'doctor',
83
84
  'session',
84
85
  'devices',
85
86
  'crash',
@@ -2,9 +2,9 @@
2
2
 
3
3
  const fs = require('fs');
4
4
  const path = require('path');
5
- const Ajv = require('ajv');
6
5
 
7
6
  const { formatError } = require('../schemas/format-error');
7
+ const { createCliAjv } = require('../schemas/cli-ajv');
8
8
 
9
9
  // Path to the canonical scenario schema, bundled inside the package.
10
10
  // src/scenario/validator.js -> ../schemas/scenario_schema.json
@@ -13,7 +13,7 @@ const DEFAULT_SCHEMA_PATH = path.resolve(__dirname, '../schemas/scenario_schema.
13
13
  class ScenarioValidator {
14
14
  constructor({ schemaPath = DEFAULT_SCHEMA_PATH } = {}) {
15
15
  const schema = JSON.parse(fs.readFileSync(schemaPath, 'utf8'));
16
- const ajv = new Ajv({ allErrors: true, strict: false });
16
+ const ajv = createCliAjv();
17
17
  this._validateFn = ajv.compile(schema);
18
18
  }
19
19
 
@@ -0,0 +1,32 @@
1
+ 'use strict';
2
+
3
+ // The only place the CLI's Ajv instance is constructed (#191).
4
+ //
5
+ // There used to be two hand-copied `new Ajv({ allErrors: true, strict: false })`
6
+ // literals โ€” one in src/config/schema.js, one in src/scenario/validator.js โ€”
7
+ // plus a third mirror in the schema lint guard tied to them by nothing but a
8
+ // comment. Three copies of "the CLI's own options" that nothing kept true:
9
+ // change the options in src and the guard would keep vouching for the old
10
+ // ones. A guard that hand-maintains a copy of the thing it guards is itself a
11
+ // drift surface โ€” the same lesson that produced docs-corpus.js.
12
+ //
13
+ // Everything that needs the CLI's Ajv โ€” both call sites above, the unit tests
14
+ // that validate envelopes against the shipped schemas, and
15
+ // tests/lint/schemas-valid.test.js, which compiles every schema with the real
16
+ // instance โ€” now consumes this factory. That guard also fails on any other
17
+ // construction site in src/ or bin/, so the options cannot quietly return as
18
+ // literals.
19
+
20
+ const Ajv = require('ajv');
21
+
22
+ // allErrors: true โ€” `mauto validate` and config validation report every
23
+ // problem in one pass, not just the first one they hit.
24
+ // strict: false โ€” result_schema.json declares `format: "date-time"` and no
25
+ // instance here registers formats, so strict mode would turn Ajv's warning
26
+ // into an error. The CLI tolerates the warning rather than claim format
27
+ // checking it does not actually do.
28
+ function createCliAjv() {
29
+ return new Ajv({ allErrors: true, strict: false });
30
+ }
31
+
32
+ module.exports = { createCliAjv };