mobile-automator 0.30.0 โ 0.31.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -0
- package/bin/mauto.js +2 -1
- package/package.json +9 -2
- package/src/cli.js +55 -12
- package/src/device/bridge.js +10 -10
- package/src/device/mobile-mcp-client.js +2 -2
- package/src/device/resolve-connection.js +0 -2
- package/src/device/session-daemon.js +40 -6
- package/src/guide/emitter.js +1 -0
- package/src/init/adapters.js +27 -110
- package/src/init/doctor.js +30 -0
- package/src/init/host-plan.js +197 -0
- package/src/init/skill-renderer.js +1 -1
- package/src/init/stale-notice.js +85 -0
- package/src/mcp/server.js +5 -2
- package/src/memory/entries.js +1 -0
- package/src/observe/vocab.js +2 -0
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/bin/mauto.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mobile-automator",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.31.0",
|
|
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": {
|
|
@@ -26,6 +26,7 @@
|
|
|
26
26
|
"prepublishOnly": "npm test",
|
|
27
27
|
"test:watch": "jest --watch",
|
|
28
28
|
"test:coverage": "jest --coverage",
|
|
29
|
+
"lint": "eslint .",
|
|
29
30
|
"lint:guides": "jest tests/lint",
|
|
30
31
|
"lint:schema-additive": "jest tests/lint/schema-additive.test.js",
|
|
31
32
|
"test:unit": "jest tests/unit",
|
|
@@ -39,9 +40,15 @@
|
|
|
39
40
|
"commander": "^12.1.0"
|
|
40
41
|
},
|
|
41
42
|
"devDependencies": {
|
|
43
|
+
"@eslint/js": "^9.39.5",
|
|
44
|
+
"@types/node": "^22.20.5",
|
|
45
|
+
"eslint": "^9.39.5",
|
|
46
|
+
"globals": "^17.13.0",
|
|
42
47
|
"jest": "^29.7.0",
|
|
43
48
|
"jest-environment-jsdom": "^29.7.0",
|
|
44
|
-
"js-yaml": "^4.3.2"
|
|
49
|
+
"js-yaml": "^4.3.2",
|
|
50
|
+
"typescript": "~6.0.3",
|
|
51
|
+
"typescript-eslint": "^8.72.0"
|
|
45
52
|
},
|
|
46
53
|
"engines": {
|
|
47
54
|
"node": ">=22.12.0"
|
package/src/cli.js
CHANGED
|
@@ -7,7 +7,6 @@ const { Command } = require('commander');
|
|
|
7
7
|
const { version: PKG_VERSION } = require('../package.json');
|
|
8
8
|
|
|
9
9
|
const { ok, fail, render, exitCodeFor } = require('./output/envelope');
|
|
10
|
-
const { DeviceBridge } = require('./device/bridge');
|
|
11
10
|
const selectionStore = require('./device/selection');
|
|
12
11
|
const { engineHint } = require('./device/engine-hints');
|
|
13
12
|
const { ScenarioValidator } = require('./scenario/validator');
|
|
@@ -24,6 +23,9 @@ const { validateAt } = require('./config/schema');
|
|
|
24
23
|
const { scaffold } = require('./setup/scaffold');
|
|
25
24
|
const guideEmitter = require('./guide/emitter');
|
|
26
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');
|
|
27
29
|
const { isSemanticAction, ACTION_METHOD, selectResolver } = require('./device/semantic-press');
|
|
28
30
|
const connection = require('./device/connection');
|
|
29
31
|
const { isSupportedHost, unsupportedHostError } = require('./device/host-support');
|
|
@@ -991,7 +993,20 @@ async function handleTelemetryFlush({ projectRoot, env = process.env, flusher })
|
|
|
991
993
|
}
|
|
992
994
|
|
|
993
995
|
// RAW content on success; fail envelope only when the topic is unknown.
|
|
994
|
-
|
|
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
|
+
) {
|
|
995
1010
|
const cfg = config.load(projectRoot);
|
|
996
1011
|
const mode = config.resolveMode(cfg);
|
|
997
1012
|
let raw;
|
|
@@ -1003,6 +1018,14 @@ function handleGuide({ projectRoot, emitter = guideEmitter, config = configManag
|
|
|
1003
1018
|
exitKind: 'invalid_input',
|
|
1004
1019
|
};
|
|
1005
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;
|
|
1006
1029
|
return { raw, exitKind: 'ok' };
|
|
1007
1030
|
}
|
|
1008
1031
|
|
|
@@ -1130,7 +1153,7 @@ function hintForAdapterError(cls) {
|
|
|
1130
1153
|
function applyOneAdapter(adapter, agent, projectRoot) {
|
|
1131
1154
|
try {
|
|
1132
1155
|
const r = adapter.apply({ projectRoot });
|
|
1133
|
-
return { agent, ok: true,
|
|
1156
|
+
return { agent, ok: true, changes: r.changes };
|
|
1134
1157
|
} catch (err) {
|
|
1135
1158
|
return {
|
|
1136
1159
|
agent,
|
|
@@ -1141,15 +1164,16 @@ function applyOneAdapter(adapter, agent, projectRoot) {
|
|
|
1141
1164
|
}
|
|
1142
1165
|
}
|
|
1143
1166
|
|
|
1144
|
-
function handleInit({ projectRoot, adapters = ADAPTERS }, agent) {
|
|
1167
|
+
function handleInit({ projectRoot, adapters = ADAPTERS, inspect = inspectHosts }, agent) {
|
|
1145
1168
|
const known = Object.keys(adapters);
|
|
1146
1169
|
if (agent === 'all') {
|
|
1170
|
+
const wasInstalled = installedBefore(inspect, projectRoot);
|
|
1147
1171
|
// Continue-on-error: every host is attempted; the result is ONE envelope
|
|
1148
1172
|
// carrying a per-agent ok/failed map. `ok` only when all succeed.
|
|
1149
1173
|
const perAgent = known.map((a) => applyOneAdapter(adapters[a], a, projectRoot));
|
|
1150
1174
|
const failed = perAgent.filter((r) => !r.ok);
|
|
1151
1175
|
if (failed.length === 0) {
|
|
1152
|
-
return { envelope: ok({ agents: perAgent }), exitKind: 'ok' };
|
|
1176
|
+
return { envelope: ok({ agents: perAgent }, refreshHint(perAgent, wasInstalled)), exitKind: 'ok' };
|
|
1153
1177
|
}
|
|
1154
1178
|
return {
|
|
1155
1179
|
envelope: fail(
|
|
@@ -1172,6 +1196,7 @@ function handleInit({ projectRoot, adapters = ADAPTERS }, agent) {
|
|
|
1172
1196
|
exitKind: 'invalid_input',
|
|
1173
1197
|
};
|
|
1174
1198
|
}
|
|
1199
|
+
const wasInstalled = installedBefore(inspect, projectRoot);
|
|
1175
1200
|
const r = applyOneAdapter(adapter, agent, projectRoot);
|
|
1176
1201
|
if (!r.ok) {
|
|
1177
1202
|
// A malformed host config is the user's input, not an environment fault โ
|
|
@@ -1189,11 +1214,21 @@ function handleInit({ projectRoot, adapters = ADAPTERS }, agent) {
|
|
|
1189
1214
|
};
|
|
1190
1215
|
}
|
|
1191
1216
|
return {
|
|
1192
|
-
envelope: ok({ agent: r.agent,
|
|
1217
|
+
envelope: ok({ agent: r.agent, changes: r.changes }, refreshHint([r], wasInstalled)),
|
|
1193
1218
|
exitKind: 'ok',
|
|
1194
1219
|
};
|
|
1195
1220
|
}
|
|
1196
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
|
+
|
|
1197
1232
|
// --- Issue #91: persistent device session daemon -------------------------
|
|
1198
1233
|
//
|
|
1199
1234
|
// `mauto session start|status|end` manage the per-workspace daemon that holds
|
|
@@ -1210,7 +1245,7 @@ async function handleSessionStart(
|
|
|
1210
1245
|
projectRoot,
|
|
1211
1246
|
spawn = require('./device/session-spawn'),
|
|
1212
1247
|
client = require('./device/session-client'),
|
|
1213
|
-
readHandle = require('./device/resolve-connection').readHandleDevice,
|
|
1248
|
+
readHandle: readHandleDevice = require('./device/resolve-connection').readHandleDevice,
|
|
1214
1249
|
},
|
|
1215
1250
|
opts = {}
|
|
1216
1251
|
) {
|
|
@@ -1233,7 +1268,7 @@ async function handleSessionStart(
|
|
|
1233
1268
|
// pinned to A reported already_running:true, device:B while every verb
|
|
1234
1269
|
// kept driving A. Fail with a hint instead โ the agent stays in control
|
|
1235
1270
|
// and decides whether to `session end` and repin.
|
|
1236
|
-
const pinned =
|
|
1271
|
+
const pinned = readHandleDevice(projectRoot);
|
|
1237
1272
|
if (device !== null && device !== pinned) {
|
|
1238
1273
|
return {
|
|
1239
1274
|
envelope: fail(
|
|
@@ -1391,7 +1426,7 @@ async function handleDevicesClear({
|
|
|
1391
1426
|
store = selectionStore,
|
|
1392
1427
|
projectRoot,
|
|
1393
1428
|
isAlive = require('./device/connection').isSessionAlive,
|
|
1394
|
-
readHandle = require('./device/resolve-connection').readHandleDevice,
|
|
1429
|
+
readHandle: readHandleDevice = require('./device/resolve-connection').readHandleDevice,
|
|
1395
1430
|
} = {}) {
|
|
1396
1431
|
const previous = store.read(projectRoot);
|
|
1397
1432
|
store.clear(projectRoot);
|
|
@@ -1403,7 +1438,7 @@ async function handleDevicesClear({
|
|
|
1403
1438
|
if (!alive) {
|
|
1404
1439
|
return { envelope: ok({ cleared: previous || null }), exitKind: 'ok' };
|
|
1405
1440
|
}
|
|
1406
|
-
const pinned =
|
|
1441
|
+
const pinned = readHandleDevice(projectRoot);
|
|
1407
1442
|
return {
|
|
1408
1443
|
envelope: ok(
|
|
1409
1444
|
{ cleared: previous || null, daemon_still_pinned: pinned },
|
|
@@ -2126,6 +2161,13 @@ function buildProgram(deps = {}) {
|
|
|
2126
2161
|
emit(r, humanFlag());
|
|
2127
2162
|
}));
|
|
2128
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
|
+
|
|
2129
2171
|
// --- Issue #91: device session lifecycle ---------------------------------
|
|
2130
2172
|
|
|
2131
2173
|
const session = program
|
|
@@ -2325,7 +2367,7 @@ function makeEmitters({ projectRoot = process.cwd() } = {}) {
|
|
|
2325
2367
|
// needed). A Date.now() stamped at require time would measure from whenever
|
|
2326
2368
|
// cli.js was loaded, which is not process start for anything that requires it
|
|
2327
2369
|
// first (a test harness, bin/mauto.js's own guards).
|
|
2328
|
-
function finish({ text = '', exitKind, ok, errorKind, exitCode = exitCodeFor(exitKind) }) {
|
|
2370
|
+
function finish({ text = '', exitKind, ok: succeeded, errorKind, exitCode = exitCodeFor(exitKind) }) {
|
|
2329
2371
|
// The flag-resolved id when a command parsed, the environment otherwise: a
|
|
2330
2372
|
// parse failure reaches no preAction hook and so has no flag, but it is
|
|
2331
2373
|
// still part of the run and is exactly the class of failure (#146) this
|
|
@@ -2355,7 +2397,7 @@ function makeEmitters({ projectRoot = process.cwd() } = {}) {
|
|
|
2355
2397
|
// tying this line to a run. sends:false in the event catalog.
|
|
2356
2398
|
run_id: runId || undefined,
|
|
2357
2399
|
session_id: resolvedSessionId || undefined,
|
|
2358
|
-
ok,
|
|
2400
|
+
ok: succeeded,
|
|
2359
2401
|
error_kind: errorKind,
|
|
2360
2402
|
exit_code: exitCode,
|
|
2361
2403
|
dur_ms: Math.round(process.uptime() * 1000),
|
|
@@ -2498,6 +2540,7 @@ module.exports = {
|
|
|
2498
2540
|
handleMemoryAdd,
|
|
2499
2541
|
handleMemoryForget,
|
|
2500
2542
|
handleInit,
|
|
2543
|
+
handleDoctor,
|
|
2501
2544
|
handleSessionStart,
|
|
2502
2545
|
handleSessionStatus,
|
|
2503
2546
|
handleSessionEnd,
|
package/src/device/bridge.js
CHANGED
|
@@ -44,7 +44,7 @@ class DeviceBridge {
|
|
|
44
44
|
// Tap at absolute screen coordinates. The agent derives x,y from listElements
|
|
45
45
|
// centers, so the bridge stays free of any element targeting logic.
|
|
46
46
|
async tap({ x, y } = {}) {
|
|
47
|
-
return this._call('mobile_click_on_screen_at_coordinates', { x, y });
|
|
47
|
+
return await this._call('mobile_click_on_screen_at_coordinates', { x, y });
|
|
48
48
|
}
|
|
49
49
|
|
|
50
50
|
// Long-press at absolute screen coordinates. Optional duration (ms) overrides
|
|
@@ -52,18 +52,18 @@ class DeviceBridge {
|
|
|
52
52
|
async longPress({ x, y, duration } = {}) {
|
|
53
53
|
const args = { x, y };
|
|
54
54
|
if (duration !== undefined) args.duration = duration;
|
|
55
|
-
return this._call('mobile_long_press_on_screen_at_coordinates', args);
|
|
55
|
+
return await this._call('mobile_long_press_on_screen_at_coordinates', args);
|
|
56
56
|
}
|
|
57
57
|
|
|
58
58
|
// Double-tap at absolute screen coordinates.
|
|
59
59
|
async doubleTap({ x, y } = {}) {
|
|
60
|
-
return this._call('mobile_double_tap_on_screen', { x, y });
|
|
60
|
+
return await this._call('mobile_double_tap_on_screen', { x, y });
|
|
61
61
|
}
|
|
62
62
|
|
|
63
63
|
// Type into the focused element. We never auto-submit; pressing ENTER is a
|
|
64
64
|
// distinct, explicit action via pressButton.
|
|
65
65
|
async type(text) {
|
|
66
|
-
return this._call('mobile_type_keys', { text, submit: false });
|
|
66
|
+
return await this._call('mobile_type_keys', { text, submit: false });
|
|
67
67
|
}
|
|
68
68
|
|
|
69
69
|
// Swipe in a cardinal direction.
|
|
@@ -135,32 +135,32 @@ class DeviceBridge {
|
|
|
135
135
|
|
|
136
136
|
// Press a hardware/system button (BACK, HOME, ENTER, ...).
|
|
137
137
|
async pressButton(button) {
|
|
138
|
-
return this._call('mobile_press_button', { button });
|
|
138
|
+
return await this._call('mobile_press_button', { button });
|
|
139
139
|
}
|
|
140
140
|
|
|
141
141
|
// Launch an installed app by its package/bundle identifier.
|
|
142
142
|
async launchApp(packageName) {
|
|
143
|
-
return this._call('mobile_launch_app', { packageName });
|
|
143
|
+
return await this._call('mobile_launch_app', { packageName });
|
|
144
144
|
}
|
|
145
145
|
|
|
146
146
|
// Install an app from a local artifact (apk/ipa) path.
|
|
147
147
|
async installApp(appPath) {
|
|
148
|
-
return this._call('mobile_install_app', { path: appPath });
|
|
148
|
+
return await this._call('mobile_install_app', { path: appPath });
|
|
149
149
|
}
|
|
150
150
|
|
|
151
151
|
// Uninstall an app by its bundle/package identifier.
|
|
152
152
|
async uninstallApp(appId) {
|
|
153
|
-
return this._call('mobile_uninstall_app', { bundle_id: appId });
|
|
153
|
+
return await this._call('mobile_uninstall_app', { bundle_id: appId });
|
|
154
154
|
}
|
|
155
155
|
|
|
156
156
|
// Open a URL (deep link or web) on the device.
|
|
157
157
|
async openUrl(url) {
|
|
158
|
-
return this._call('mobile_open_url', { url });
|
|
158
|
+
return await this._call('mobile_open_url', { url });
|
|
159
159
|
}
|
|
160
160
|
|
|
161
161
|
// Set the device orientation ('portrait'/'landscape').
|
|
162
162
|
async setOrientation(orientation) {
|
|
163
|
-
return this._call('mobile_set_orientation', { orientation });
|
|
163
|
+
return await this._call('mobile_set_orientation', { orientation });
|
|
164
164
|
}
|
|
165
165
|
|
|
166
166
|
// Crash reports currently readable on the device, as the agnostic crash
|
|
@@ -133,8 +133,8 @@ async function createCall({
|
|
|
133
133
|
throw connectError(err, connectTimeoutMs);
|
|
134
134
|
}
|
|
135
135
|
|
|
136
|
-
async function rawCall(toolName,
|
|
137
|
-
const res = await client.callTool({ name: toolName, arguments:
|
|
136
|
+
async function rawCall(toolName, toolArgs = {}) {
|
|
137
|
+
const res = await client.callTool({ name: toolName, arguments: toolArgs });
|
|
138
138
|
return parseToolResult(res);
|
|
139
139
|
}
|
|
140
140
|
const { call } = makeCall({ rawCall, device });
|
|
@@ -31,8 +31,6 @@ function defaultCreateCall(opts) {
|
|
|
31
31
|
return require('./mobile-mcp-client').createCall(opts);
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
-
const NOOP_CLOSE = () => Promise.resolve();
|
|
35
|
-
|
|
36
34
|
// Read the live daemon's pinned device from its handle, or null. A null pin
|
|
37
35
|
// means the daemon serves whatever device mobile-mcp selected (matches any
|
|
38
36
|
// request that doesn't pin a specific device).
|
|
@@ -386,7 +386,13 @@ async function startDaemon({
|
|
|
386
386
|
clearFlush();
|
|
387
387
|
if (!flush || stopping) return;
|
|
388
388
|
if (!(delayMs > 0) || !Number.isFinite(delayMs)) return;
|
|
389
|
-
|
|
389
|
+
// tick() never rejects (its flush() is wrapped), so `void` is honest here.
|
|
390
|
+
flushTimer = setTimeout(() => {
|
|
391
|
+
void tick();
|
|
392
|
+
}, delayMs);
|
|
393
|
+
if (typeof flushTimer.unref === 'function') flushTimer.unref();
|
|
394
|
+
|
|
395
|
+
async function tick() {
|
|
390
396
|
if (stopping) return;
|
|
391
397
|
if (inFlight > 0) {
|
|
392
398
|
armFlush(delayMs);
|
|
@@ -402,8 +408,7 @@ async function startDaemon({
|
|
|
402
408
|
// current caller.
|
|
403
409
|
}
|
|
404
410
|
armFlush(next);
|
|
405
|
-
}
|
|
406
|
-
if (typeof flushTimer.unref === 'function') flushTimer.unref();
|
|
411
|
+
}
|
|
407
412
|
}
|
|
408
413
|
|
|
409
414
|
async function stop(reason = 'explicit') {
|
|
@@ -541,7 +546,27 @@ async function startDaemon({
|
|
|
541
546
|
return true;
|
|
542
547
|
};
|
|
543
548
|
|
|
544
|
-
socket.on('data', async
|
|
549
|
+
// A plain listener around an async body, never `socket.on('data', async โฆ)`:
|
|
550
|
+
// the emitter ignores the returned promise, so a throw in the body would be
|
|
551
|
+
// an unhandled rejection, and bin/mauto-session-daemon.js exits on those.
|
|
552
|
+
// Found by @typescript-eslint/no-misused-promises (#164).
|
|
553
|
+
socket.on('data', (chunk) => {
|
|
554
|
+
handleData(chunk).catch(onHandlerError);
|
|
555
|
+
});
|
|
556
|
+
|
|
557
|
+
// Recorded, then the idle clock re-armed: handleData paused it for the
|
|
558
|
+
// frame that threw, and nothing else would restart it. The remaining frames
|
|
559
|
+
// of that chunk are lost; the socket and the daemon both keep serving.
|
|
560
|
+
function onHandlerError(err) {
|
|
561
|
+
safeObserve({
|
|
562
|
+
level: 'error',
|
|
563
|
+
event: 'daemon.handler_error',
|
|
564
|
+
message: err && (err.message || String(err)),
|
|
565
|
+
});
|
|
566
|
+
armIdle();
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
async function handleData(chunk) {
|
|
545
570
|
const frames = parser.push(chunk);
|
|
546
571
|
for (const f of frames) {
|
|
547
572
|
// Pause the idle clock while we handle a frame so it can never fire
|
|
@@ -554,11 +579,20 @@ async function startDaemon({
|
|
|
554
579
|
continue;
|
|
555
580
|
}
|
|
556
581
|
const req = f.value;
|
|
582
|
+
// Valid JSON is not yet a request: `null` would throw on `req.type`
|
|
583
|
+
// below, and a primitive or array is no frame the protocol defines.
|
|
584
|
+
if (req === null || typeof req !== 'object' || Array.isArray(req)) {
|
|
585
|
+
await reply({ id: null, ok: false, error: { message: 'malformed frame: not a JSON object' } });
|
|
586
|
+
armIdle();
|
|
587
|
+
continue;
|
|
588
|
+
}
|
|
557
589
|
|
|
558
590
|
if (req.type === 'shutdown') {
|
|
559
591
|
await reply({ id: req.id, ok: true, result: { stopping: true } });
|
|
560
592
|
// Reply already flushed (awaited) โ now tear down.
|
|
561
|
-
setImmediate(() =>
|
|
593
|
+
setImmediate(() => {
|
|
594
|
+
stop('shutdown').catch(() => {});
|
|
595
|
+
});
|
|
562
596
|
continue;
|
|
563
597
|
}
|
|
564
598
|
if (req.type === 'ping') {
|
|
@@ -616,7 +650,7 @@ async function startDaemon({
|
|
|
616
650
|
await reply({ id: req.id, ok: false, error: { message: `unknown request type: ${req.type}` } });
|
|
617
651
|
armIdle();
|
|
618
652
|
}
|
|
619
|
-
}
|
|
653
|
+
}
|
|
620
654
|
|
|
621
655
|
socket.on('error', () => {
|
|
622
656
|
sockets.delete(socket);
|
package/src/guide/emitter.js
CHANGED
|
@@ -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
|
];
|
package/src/init/adapters.js
CHANGED
|
@@ -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
|
|
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
|
|
23
|
+
if (prev === content) return 'unchanged';
|
|
78
24
|
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
79
25
|
fs.writeFileSync(filePath, content);
|
|
80
|
-
return
|
|
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
|
-
|
|
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 =
|
|
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
|
|
59
|
+
function makeAdapter(agent) {
|
|
124
60
|
return {
|
|
125
61
|
apply({ projectRoot }) {
|
|
126
|
-
const
|
|
127
|
-
const
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|
|
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 };
|
package/src/mcp/server.js
CHANGED
|
@@ -31,11 +31,14 @@ function buildServer({ projectRoot } = {}) {
|
|
|
31
31
|
{ capabilities: { prompts: {} } }
|
|
32
32
|
);
|
|
33
33
|
|
|
34
|
-
|
|
34
|
+
// Plain functions, not async: both handlers are synchronous, and the SDK
|
|
35
|
+
// accepts a result or a promise of one. It calls them inside a .then(), so a
|
|
36
|
+
// throw (an unknown prompt name) still becomes an MCP error reply.
|
|
37
|
+
server.setRequestHandler(ListPromptsRequestSchema, () => ({
|
|
35
38
|
prompts: handlers.listPrompts(),
|
|
36
39
|
}));
|
|
37
40
|
|
|
38
|
-
server.setRequestHandler(GetPromptRequestSchema,
|
|
41
|
+
server.setRequestHandler(GetPromptRequestSchema, (request) => {
|
|
39
42
|
return handlers.getPrompt(request.params.name);
|
|
40
43
|
});
|
|
41
44
|
|
package/src/memory/entries.js
CHANGED
|
@@ -12,6 +12,7 @@ const ENTRY_RE = /^-\s+\[(\d{4}-\d{2}-\d{2})\]\[asserted\]\s+(.*)$/;
|
|
|
12
12
|
|
|
13
13
|
function sanitizeEntryText(raw) {
|
|
14
14
|
return String(raw == null ? '' : raw)
|
|
15
|
+
// eslint-disable-next-line no-control-regex -- matching control chars is the point
|
|
15
16
|
.replace(/[\u0000-\u001F\u007F]+/g, ' ') // control chars / newlines / tabs -> space
|
|
16
17
|
.replace(/\s+/g, ' ')
|
|
17
18
|
.trim();
|
package/src/observe/vocab.js
CHANGED
|
@@ -46,6 +46,7 @@ const EVENT_NAMES = Object.freeze([
|
|
|
46
46
|
'daemon.engine_exit',
|
|
47
47
|
'daemon.lock_conflict',
|
|
48
48
|
'daemon.undeliverable',
|
|
49
|
+
'daemon.handler_error',
|
|
49
50
|
// Emitted by the flush path, which lands in a later slice-5 task; declared
|
|
50
51
|
// now because its event shape is already exercised by unit tests.
|
|
51
52
|
'telemetry.flush',
|
|
@@ -79,6 +80,7 @@ const VERB_NAMES = Object.freeze([
|
|
|
79
80
|
'bootstrap',
|
|
80
81
|
'memory',
|
|
81
82
|
'init',
|
|
83
|
+
'doctor',
|
|
82
84
|
'session',
|
|
83
85
|
'devices',
|
|
84
86
|
'crash',
|