mobile-agent-orchestrator 0.0.1 → 0.2.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/LICENSE +21 -0
- package/README.md +270 -2
- package/bin/mobile-agent-orchestrator.mjs +513 -0
- package/lib/detect.mjs +117 -0
- package/lib/hosts.mjs +48 -0
- package/lib/installer.mjs +934 -0
- package/lib/prompt.mjs +250 -0
- package/package.json +36 -8
- package/skills/mobile-agent-orchestrator/SKILL.md +48 -0
- package/skills/mobile-agent-orchestrator/references/agent-hosts.md +45 -0
- package/skills/mobile-agent-orchestrator/references/guided-install.md +89 -0
- package/skills/mobile-agent-orchestrator/references/platform-matrix.md +73 -0
- package/skills/mobile-agent-orchestrator/references/verification-and-recovery.md +64 -0
|
@@ -0,0 +1,513 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { readFileSync, realpathSync } from "node:fs";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { dirname, join, resolve } from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import { Readable, Writable } from "node:stream";
|
|
7
|
+
import { detectHosts } from "../lib/detect.mjs";
|
|
8
|
+
import { scopes, targets, resolveDestination } from "../lib/hosts.mjs";
|
|
9
|
+
import { inspectInstallations, buildPlan, executePlan, buildUninstallPlan, executeUninstallPlan } from "../lib/installer.mjs";
|
|
10
|
+
import { selectInstallPlan } from "../lib/prompt.mjs";
|
|
11
|
+
|
|
12
|
+
const usage = `Usage:
|
|
13
|
+
mobile-agent-orchestrator install --target <name> [--target <name> ...] --scope <user|project> [--dry-run]
|
|
14
|
+
mobile-agent-orchestrator install
|
|
15
|
+
mobile-agent-orchestrator status [--target <name>]... [--scope <user|project>]
|
|
16
|
+
mobile-agent-orchestrator uninstall --target <name> [--target <name> ...] [--scope <user|project>] [--dry-run]
|
|
17
|
+
|
|
18
|
+
With no arguments the installer runs interactively: it detects installed coding-agent CLIs, asks
|
|
19
|
+
which targets to configure and which scope to use, and shows the plan before writing anything.
|
|
20
|
+
Interactive mode requires BOTH stdin and stdout to be terminals (TTYs); a piped stdin with a
|
|
21
|
+
terminal stdout is treated as non-interactive. CI and automation must use the explicit-flag form
|
|
22
|
+
above.
|
|
23
|
+
|
|
24
|
+
Targets: ${targets.join(", ")}
|
|
25
|
+
Scopes: ${scopes.join(", ")} (required with --target, no default)
|
|
26
|
+
|
|
27
|
+
Options:
|
|
28
|
+
--target <name> Host target to install for; repeatable. Omit to select interactively.
|
|
29
|
+
--scope <scope> user (~/.agents/skills) or project (<cwd>/.agents/skills). With no --target it
|
|
30
|
+
preselects the interactive scope question.
|
|
31
|
+
--dry-run Print the exact plan and write nothing.
|
|
32
|
+
--help Show this help.
|
|
33
|
+
|
|
34
|
+
Status (strictly read-only):
|
|
35
|
+
mobile-agent-orchestrator status inspects installations without writing anything. By default it
|
|
36
|
+
inspects every write target in BOTH scopes (pi is detect-only and has no managed destination, so
|
|
37
|
+
it is never inspected). Each inspected destination prints one line:
|
|
38
|
+
|
|
39
|
+
STATUS [scope] destination (targets)
|
|
40
|
+
|
|
41
|
+
where STATUS is absent, managed, outdated, modified, or unknown, followed by indented detail
|
|
42
|
+
lines when a destination needs attention. When both scopes resolve to the same destination
|
|
43
|
+
(e.g. the working directory is the home directory), it is inspected once and reported with
|
|
44
|
+
both scopes: STATUS [user, project] destination (targets).
|
|
45
|
+
|
|
46
|
+
Status options:
|
|
47
|
+
--target <name> Limit inspection to these targets; repeatable; defaults to all write targets.
|
|
48
|
+
--scope <scope> Limit inspection to user or project; defaults to both.
|
|
49
|
+
|
|
50
|
+
Exit codes:
|
|
51
|
+
0 every inspected destination is absent or managed (nothing needs attention).
|
|
52
|
+
1 at least one destination is outdated, modified, or unknown, or a usage/validation error
|
|
53
|
+
occurred.
|
|
54
|
+
|
|
55
|
+
Uninstall (removes only installer-managed content):
|
|
56
|
+
mobile-agent-orchestrator uninstall removes only the files recorded in the installation manifest,
|
|
57
|
+
the directories those files leave empty, and the manifest itself. Unmanaged files are kept. A
|
|
58
|
+
locally modified managed file blocks its whole destination and nothing is removed there. Targets
|
|
59
|
+
sharing one .agents/skills directory (codex, opencode, gemini) are installed as one copy and are
|
|
60
|
+
removed as that single copy. By default BOTH scopes are planned; pass --scope to narrow it.
|
|
61
|
+
Destinations that both scopes resolve to (e.g. the working directory is the home directory)
|
|
62
|
+
are planned once and reported with both scopes: uninstall [user, project] destination.
|
|
63
|
+
|
|
64
|
+
Uninstall options:
|
|
65
|
+
--target <name> Host target to uninstall for; repeatable; REQUIRED.
|
|
66
|
+
--scope <scope> Limit the uninstall to user (~/.agents/skills) or project (<cwd>/.agents/skills);
|
|
67
|
+
defaults to both.
|
|
68
|
+
--dry-run Print the exact removal plan and write nothing.
|
|
69
|
+
|
|
70
|
+
Uninstall exit codes:
|
|
71
|
+
0 every planned destination was uninstalled or was already absent (nothing to remove).
|
|
72
|
+
1 at least one destination is blocked (modified or unsafe content) or unknown, or a
|
|
73
|
+
usage/validation error occurred.
|
|
74
|
+
`;
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Default interactive-mode derivation: BOTH stdin and stdout must be terminals. Checking only
|
|
78
|
+
* stdout let a piped stdin (e.g. `printf '2\\n2\\ny\\n' | mao install` from a real terminal)
|
|
79
|
+
* drive the interactive prompts and write files without explicit flags. stdin.isTTY alone is
|
|
80
|
+
* not sufficient either: a redirected stdout means the prompts are not visible to a human.
|
|
81
|
+
*
|
|
82
|
+
* @param {{ isTTY?: boolean | undefined }} stdin
|
|
83
|
+
* @param {{ isTTY?: boolean | undefined }} stdout
|
|
84
|
+
* @returns {boolean}
|
|
85
|
+
*/
|
|
86
|
+
export function deriveInteractiveTTY(stdin, stdout) {
|
|
87
|
+
return Boolean(stdin.isTTY && stdout.isTTY);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** The version recorded in installed manifests and compared against by `status`. */
|
|
91
|
+
const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
92
|
+
|
|
93
|
+
function readInstallerVersion() {
|
|
94
|
+
return JSON.parse(readFileSync(resolve(packageRoot, "package.json"), "utf8")).version;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function fail(errorOutput, message) {
|
|
98
|
+
errorOutput.write(`${message}\n`);
|
|
99
|
+
return 1;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function parseArguments(argv, { allowDryRun = true } = {}) {
|
|
103
|
+
const selectedTargets = [];
|
|
104
|
+
let scope = null;
|
|
105
|
+
let dryRun = false;
|
|
106
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
107
|
+
const argument = argv[index];
|
|
108
|
+
if (argument === "--dry-run") {
|
|
109
|
+
if (!allowDryRun) throw new Error(`Unknown argument: ${argument}.`);
|
|
110
|
+
dryRun = true;
|
|
111
|
+
} else if (argument === "--target") {
|
|
112
|
+
index += 1;
|
|
113
|
+
const value = argv[index];
|
|
114
|
+
if (value === undefined) throw new Error("--target requires a value.");
|
|
115
|
+
if (!selectedTargets.includes(value)) selectedTargets.push(value);
|
|
116
|
+
} else if (argument === "--scope") {
|
|
117
|
+
index += 1;
|
|
118
|
+
scope = argv[index];
|
|
119
|
+
if (scope === undefined) throw new Error("--scope requires a value.");
|
|
120
|
+
} else {
|
|
121
|
+
throw new Error(`Unknown argument: ${argument}.`);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return { selectedTargets, scope, dryRun };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Targets sharing one destination (codex, opencode, gemini in .agents/skills) are installed as one
|
|
129
|
+
* copy and are removed as that single copy, so an uninstall of any one of them must report — and
|
|
130
|
+
* conceptually plan for — every target the copy serves. The expansion is scope-independent (each
|
|
131
|
+
* target's location segments are fixed), so resolving through the user scope is exact for both.
|
|
132
|
+
* It never adds a destination: expanded targets share the destination of a selected one by
|
|
133
|
+
* construction, and a blocked destination refuses all-or-nothing regardless (S7).
|
|
134
|
+
*/
|
|
135
|
+
function expandSharedTargets(selectedTargets, { cwd, home }) {
|
|
136
|
+
const destinationGroups = new Map();
|
|
137
|
+
for (const target of targets) {
|
|
138
|
+
const destination = resolveDestination(target, "user", { cwd, home });
|
|
139
|
+
const group = destinationGroups.get(destination) ?? [];
|
|
140
|
+
group.push(target);
|
|
141
|
+
destinationGroups.set(destination, group);
|
|
142
|
+
}
|
|
143
|
+
const expanded = new Set();
|
|
144
|
+
for (const target of selectedTargets) {
|
|
145
|
+
for (const member of destinationGroups.get(resolveDestination(target, "user", { cwd, home }))) expanded.add(member);
|
|
146
|
+
}
|
|
147
|
+
return [...expanded];
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Render an uninstall plan: one status header per destination followed by the exact removals,
|
|
152
|
+
* directory cleanup, manifest removal, preserved unmanaged files, and the blocking details of
|
|
153
|
+
* blocked/unknown destinations. The trailing "N destination(s) inspected" summary is written by
|
|
154
|
+
* the caller so the executed path can interleave its "removed" result lines before it.
|
|
155
|
+
*/
|
|
156
|
+
function renderUninstallPlan(output, plan) {
|
|
157
|
+
for (const entry of plan.destinations) {
|
|
158
|
+
output.write(`${entry.status} [${entry.scopes.join(", ")}] ${entry.destination} (${entry.targets.join(", ")})\n`);
|
|
159
|
+
for (const removal of entry.removals) output.write(` - remove ${removal.destination}\n`);
|
|
160
|
+
for (const directory of entry.directories) output.write(` - remove directory ${directory}\n`);
|
|
161
|
+
if (entry.manifestRemoved) output.write(` - remove manifest ${entry.manifestPath}\n`);
|
|
162
|
+
for (const unmanaged of entry.unmanagedFiles) {
|
|
163
|
+
output.write(` - keep ${join(entry.destination, ...unmanaged.split("/"))} (unmanaged)\n`);
|
|
164
|
+
}
|
|
165
|
+
if (entry.status === "blocked" || entry.status === "unknown") {
|
|
166
|
+
for (const detail of entry.details) output.write(` - ${detail}\n`);
|
|
167
|
+
} else if (entry.status === "uninstall") {
|
|
168
|
+
// Informational details (e.g. already-absent recorded files) are part of the exact plan the
|
|
169
|
+
// dry-run contract promises; `(unmanaged)` details are already rendered as `keep` above.
|
|
170
|
+
for (const detail of entry.details) {
|
|
171
|
+
if (!detail.endsWith("(unmanaged)")) output.write(` - ${detail}\n`);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Stream proxy pair that lets an explicit `--scope` value skip the interactive scope question
|
|
179
|
+
* without modifying lib/prompt.mjs. selectInstallPlan renders every non-prompt line with a
|
|
180
|
+
* trailing newline, so a write that does not end in "\n" is exactly a prompt awaiting an answer.
|
|
181
|
+
* The proxy suppresses the scope-question rendering (from its header up to that section's prompt,
|
|
182
|
+
* the first prompt-like write after the header, so it can never swallow later output), answers
|
|
183
|
+
* that prompt itself with the preselected value, and releases the user's buffered input one line
|
|
184
|
+
* per remaining prompt so no user line is consumed by the skipped question.
|
|
185
|
+
*/
|
|
186
|
+
function createScopePreselectedFlow(input, output, scopeAnswer) {
|
|
187
|
+
const queuedLines = [];
|
|
188
|
+
let partialLine = "";
|
|
189
|
+
let inputEnded = false;
|
|
190
|
+
let scopeInjected = false;
|
|
191
|
+
let sourceEnded = false;
|
|
192
|
+
let promptOpen = false;
|
|
193
|
+
let promptText = "";
|
|
194
|
+
let suppressingScopeQuestion = false;
|
|
195
|
+
|
|
196
|
+
const source = new Readable({ read() {} });
|
|
197
|
+
|
|
198
|
+
const maybeEndSource = () => {
|
|
199
|
+
if (inputEnded && queuedLines.length === 0 && !sourceEnded) {
|
|
200
|
+
sourceEnded = true;
|
|
201
|
+
source.push(null);
|
|
202
|
+
}
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
const tryDeliver = () => {
|
|
206
|
+
if (!promptOpen) return;
|
|
207
|
+
if (!scopeInjected && promptText.startsWith("Select scope")) {
|
|
208
|
+
scopeInjected = true;
|
|
209
|
+
promptOpen = false;
|
|
210
|
+
source.push(`${scopeAnswer}\n`);
|
|
211
|
+
maybeEndSource();
|
|
212
|
+
} else if (queuedLines.length > 0) {
|
|
213
|
+
promptOpen = false;
|
|
214
|
+
source.push(queuedLines.shift());
|
|
215
|
+
maybeEndSource();
|
|
216
|
+
}
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
const outputProxy = new Writable({
|
|
220
|
+
write(chunk, _encoding, callback) {
|
|
221
|
+
const text = chunk.toString();
|
|
222
|
+
if (suppressingScopeQuestion) {
|
|
223
|
+
// Skip the scope-question rendering; it always ends at its own prompt.
|
|
224
|
+
if (!text.endsWith("\n")) {
|
|
225
|
+
suppressingScopeQuestion = false;
|
|
226
|
+
promptOpen = true;
|
|
227
|
+
promptText = text;
|
|
228
|
+
tryDeliver();
|
|
229
|
+
}
|
|
230
|
+
callback();
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
if (!scopeInjected && text.includes("Select scope:")) {
|
|
234
|
+
suppressingScopeQuestion = true;
|
|
235
|
+
callback();
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
output.write(chunk);
|
|
239
|
+
if (!text.endsWith("\n")) {
|
|
240
|
+
promptOpen = true;
|
|
241
|
+
promptText = text;
|
|
242
|
+
tryDeliver();
|
|
243
|
+
}
|
|
244
|
+
callback();
|
|
245
|
+
},
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
const queueChunk = (text) => {
|
|
249
|
+
const pieces = (partialLine + text).split("\n");
|
|
250
|
+
partialLine = pieces.pop();
|
|
251
|
+
for (const piece of pieces) queuedLines.push(`${piece}\n`);
|
|
252
|
+
tryDeliver();
|
|
253
|
+
};
|
|
254
|
+
const endInput = () => {
|
|
255
|
+
if (partialLine.length > 0) queuedLines.push(partialLine);
|
|
256
|
+
partialLine = "";
|
|
257
|
+
inputEnded = true;
|
|
258
|
+
tryDeliver();
|
|
259
|
+
maybeEndSource();
|
|
260
|
+
};
|
|
261
|
+
const onData = (chunk) => queueChunk(chunk.toString());
|
|
262
|
+
input.on("data", onData);
|
|
263
|
+
input.on("end", endInput);
|
|
264
|
+
input.on("error", endInput);
|
|
265
|
+
|
|
266
|
+
return {
|
|
267
|
+
input: source,
|
|
268
|
+
output: outputProxy,
|
|
269
|
+
/**
|
|
270
|
+
* Detach from the real input stream: remove every listener this flow attached and stop
|
|
271
|
+
* pumping it. selectInstallPlan disposes its own inner line reader (attached to `source`)
|
|
272
|
+
* in its finally, so this only needs to cover the listeners on the caller-supplied input;
|
|
273
|
+
* without it, listeners left on a terminal-backed stdin keep the process alive after the
|
|
274
|
+
* interactive flow completes.
|
|
275
|
+
*/
|
|
276
|
+
dispose() {
|
|
277
|
+
input.removeListener("data", onData);
|
|
278
|
+
input.removeListener("end", endInput);
|
|
279
|
+
input.removeListener("error", endInput);
|
|
280
|
+
if (typeof input.pause === "function") input.pause();
|
|
281
|
+
// Unref-style cleanup only fires on real handle-backed streams (e.g. process.stdin);
|
|
282
|
+
// in-memory fixtures such as PassThrough/Readable have no unref and are unaffected.
|
|
283
|
+
if (typeof input.unref === "function") input.unref();
|
|
284
|
+
},
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Testable CLI entry point. Interactive behavior is fully injectable (streams, TTY flag, home,
|
|
290
|
+
* cwd, host detection); the explicit-flag path never touches any of them. Returns the process
|
|
291
|
+
* exit code (0 on success, 1 on failure).
|
|
292
|
+
*
|
|
293
|
+
* @param {string[]} argv - Arguments after the program name.
|
|
294
|
+
* @param {{
|
|
295
|
+
* input?: import("node:stream").Readable,
|
|
296
|
+
* output?: import("node:stream").Writable,
|
|
297
|
+
* errorOutput?: import("node:stream").Writable,
|
|
298
|
+
* isTTY?: boolean,
|
|
299
|
+
* home?: string,
|
|
300
|
+
* cwd?: string,
|
|
301
|
+
* detect?: typeof detectHosts,
|
|
302
|
+
* }} [options]
|
|
303
|
+
*/
|
|
304
|
+
export async function run(argv, options = {}) {
|
|
305
|
+
const {
|
|
306
|
+
input = process.stdin,
|
|
307
|
+
output = process.stdout,
|
|
308
|
+
errorOutput = process.stderr,
|
|
309
|
+
isTTY = deriveInteractiveTTY(process.stdin, process.stdout),
|
|
310
|
+
home = homedir(),
|
|
311
|
+
cwd = process.cwd(),
|
|
312
|
+
detect = detectHosts,
|
|
313
|
+
} = options;
|
|
314
|
+
|
|
315
|
+
const [command, ...rest] = argv;
|
|
316
|
+
if (command === "--help" || command === "help") {
|
|
317
|
+
output.write(`${usage}\n`);
|
|
318
|
+
return 0;
|
|
319
|
+
}
|
|
320
|
+
if (command !== "install" && command !== "status" && command !== "uninstall") {
|
|
321
|
+
return fail(errorOutput, command === undefined ? "Missing subcommand." : `Unknown subcommand: ${command}.`);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
if (command === "status") {
|
|
325
|
+
let parsed;
|
|
326
|
+
try {
|
|
327
|
+
parsed = parseArguments(rest, { allowDryRun: false });
|
|
328
|
+
} catch (error) {
|
|
329
|
+
return fail(errorOutput, error.message);
|
|
330
|
+
}
|
|
331
|
+
// Same validation and messages as the install path; no default scope (both by default).
|
|
332
|
+
if (parsed.scope && !scopes.includes(parsed.scope)) {
|
|
333
|
+
return fail(errorOutput, `Unknown scope: ${parsed.scope}. Expected one of: ${scopes.join(", ")}.`);
|
|
334
|
+
}
|
|
335
|
+
const invalidTarget = parsed.selectedTargets.find((target) => !targets.includes(target));
|
|
336
|
+
if (invalidTarget) return fail(errorOutput, `Unknown target: ${invalidTarget}. Expected one of: ${targets.join(", ")}.`);
|
|
337
|
+
const selectedTargets = parsed.selectedTargets.length > 0 ? parsed.selectedTargets : [...targets];
|
|
338
|
+
const selectedScopes = parsed.scope ? [parsed.scope] : [...scopes];
|
|
339
|
+
let entries;
|
|
340
|
+
try {
|
|
341
|
+
entries = inspectInstallations({
|
|
342
|
+
targets: selectedTargets,
|
|
343
|
+
scopes: selectedScopes,
|
|
344
|
+
cwd,
|
|
345
|
+
home,
|
|
346
|
+
installerVersion: readInstallerVersion(),
|
|
347
|
+
});
|
|
348
|
+
} catch (error) {
|
|
349
|
+
return fail(errorOutput, `Status inspection failed: ${error.message}`);
|
|
350
|
+
}
|
|
351
|
+
let attentionNeeded = false;
|
|
352
|
+
for (const entry of entries) {
|
|
353
|
+
output.write(`${entry.status} [${entry.scopes.join(", ")}] ${entry.destination} (${entry.targets.join(", ")})\n`);
|
|
354
|
+
for (const detail of entry.details) output.write(` - ${detail}\n`);
|
|
355
|
+
if (entry.status === "outdated" || entry.status === "modified" || entry.status === "unknown") attentionNeeded = true;
|
|
356
|
+
}
|
|
357
|
+
output.write(`${entries.length} destination(s) inspected\n`);
|
|
358
|
+
return attentionNeeded ? 1 : 0;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
if (command === "uninstall") {
|
|
362
|
+
// Same parser as install, so --dry-run is accepted (S5) and every validation message is
|
|
363
|
+
// identical to install's. Unlike install there is no interactive fallback: uninstall is
|
|
364
|
+
// destructive, so --target is required and --dry-run is the safety net (design decision 6).
|
|
365
|
+
let parsed;
|
|
366
|
+
try {
|
|
367
|
+
parsed = parseArguments(rest);
|
|
368
|
+
} catch (error) {
|
|
369
|
+
return fail(errorOutput, error.message);
|
|
370
|
+
}
|
|
371
|
+
if (parsed.selectedTargets.length === 0) {
|
|
372
|
+
return fail(errorOutput, `Missing required --target (${targets.join(", ")}); no default is provided.`);
|
|
373
|
+
}
|
|
374
|
+
if (parsed.scope && !scopes.includes(parsed.scope)) {
|
|
375
|
+
return fail(errorOutput, `Unknown scope: ${parsed.scope}. Expected one of: ${scopes.join(", ")}.`);
|
|
376
|
+
}
|
|
377
|
+
const invalidTarget = parsed.selectedTargets.find((target) => !targets.includes(target));
|
|
378
|
+
if (invalidTarget) return fail(errorOutput, `Unknown target: ${invalidTarget}. Expected one of: ${targets.join(", ")}.`);
|
|
379
|
+
const selectedScopes = parsed.scope ? [parsed.scope] : [...scopes];
|
|
380
|
+
const selectedTargets = expandSharedTargets(parsed.selectedTargets, { cwd, home });
|
|
381
|
+
let plan;
|
|
382
|
+
try {
|
|
383
|
+
plan = buildUninstallPlan({ targets: selectedTargets, scopes: selectedScopes, cwd, home });
|
|
384
|
+
} catch (error) {
|
|
385
|
+
return fail(errorOutput, `Uninstall planning failed: ${error.message}`);
|
|
386
|
+
}
|
|
387
|
+
renderUninstallPlan(output, plan);
|
|
388
|
+
if (!parsed.dryRun) {
|
|
389
|
+
let result;
|
|
390
|
+
try {
|
|
391
|
+
result = executeUninstallPlan(plan);
|
|
392
|
+
} catch (error) {
|
|
393
|
+
errorOutput.write(
|
|
394
|
+
`Uninstall failed partway through; the destination may be inconsistent. Re-running the uninstall or install repairs it. (${error.message})\n`,
|
|
395
|
+
);
|
|
396
|
+
return 1;
|
|
397
|
+
}
|
|
398
|
+
for (const path of result.removedFiles) output.write(` - removed ${path}\n`);
|
|
399
|
+
for (const directory of result.removedDirectories) output.write(` - removed directory ${directory}\n`);
|
|
400
|
+
}
|
|
401
|
+
const blockedOrUnknown = plan.destinations.some((entry) => entry.status === "blocked" || entry.status === "unknown");
|
|
402
|
+
output.write(`${plan.destinations.length} destination(s) inspected\n`);
|
|
403
|
+
return blockedOrUnknown ? 1 : 0;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
let parsed;
|
|
407
|
+
try {
|
|
408
|
+
parsed = parseArguments(rest);
|
|
409
|
+
} catch (error) {
|
|
410
|
+
return fail(errorOutput, error.message);
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
let selectedTargets;
|
|
414
|
+
let scope;
|
|
415
|
+
if (parsed.selectedTargets.length === 0) {
|
|
416
|
+
// Interactive path. An explicit --scope is validated first, exactly like the explicit path,
|
|
417
|
+
// and then preselects the scope question.
|
|
418
|
+
if (parsed.scope && !scopes.includes(parsed.scope)) {
|
|
419
|
+
return fail(errorOutput, `Unknown scope: ${parsed.scope}. Expected one of: ${scopes.join(", ")}.`);
|
|
420
|
+
}
|
|
421
|
+
if (!isTTY) {
|
|
422
|
+
return fail(
|
|
423
|
+
errorOutput,
|
|
424
|
+
"Interactive install requires a TTY. For CI or non-interactive shells, pass explicit flags: --target <name> and --scope <user|project>.",
|
|
425
|
+
);
|
|
426
|
+
}
|
|
427
|
+
const hosts = detect({ home });
|
|
428
|
+
const flow = parsed.scope
|
|
429
|
+
? createScopePreselectedFlow(input, output, parsed.scope)
|
|
430
|
+
: { input, output, dispose: null };
|
|
431
|
+
let selection;
|
|
432
|
+
try {
|
|
433
|
+
selection = await selectInstallPlan({ hosts, input: flow.input, output: flow.output });
|
|
434
|
+
} finally {
|
|
435
|
+
// Every exit path must release the real input stream. In the preselected case this
|
|
436
|
+
// removes the proxy's listeners; otherwise selectInstallPlan's own finally already
|
|
437
|
+
// disposed the line reader attached directly to the input.
|
|
438
|
+
if (typeof flow.dispose === "function") flow.dispose();
|
|
439
|
+
}
|
|
440
|
+
if (selection === null) {
|
|
441
|
+
output.write("Install cancelled; nothing was written.\n");
|
|
442
|
+
return 0;
|
|
443
|
+
}
|
|
444
|
+
selectedTargets = selection.targets;
|
|
445
|
+
scope = selection.scope;
|
|
446
|
+
} else {
|
|
447
|
+
if (!parsed.scope) return fail(errorOutput, "Missing required --scope (user or project); no default is provided.");
|
|
448
|
+
if (!scopes.includes(parsed.scope)) return fail(errorOutput, `Unknown scope: ${parsed.scope}. Expected one of: ${scopes.join(", ")}.`);
|
|
449
|
+
const invalidTarget = parsed.selectedTargets.find((target) => !targets.includes(target));
|
|
450
|
+
if (invalidTarget) return fail(errorOutput, `Unknown target: ${invalidTarget}. Expected one of: ${targets.join(", ")}.`);
|
|
451
|
+
selectedTargets = parsed.selectedTargets;
|
|
452
|
+
scope = parsed.scope;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
const root = packageRoot;
|
|
456
|
+
let plan;
|
|
457
|
+
try {
|
|
458
|
+
plan = buildPlan({
|
|
459
|
+
targets: selectedTargets,
|
|
460
|
+
scope,
|
|
461
|
+
cwd,
|
|
462
|
+
home,
|
|
463
|
+
skillRoot: resolve(root, "skills", "mobile-agent-orchestrator"),
|
|
464
|
+
installerVersion: readInstallerVersion(),
|
|
465
|
+
});
|
|
466
|
+
} catch (error) {
|
|
467
|
+
return fail(errorOutput, `Install planning failed: ${error.message}`);
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
let result;
|
|
471
|
+
try {
|
|
472
|
+
result = executePlan(plan, { dryRun: parsed.dryRun });
|
|
473
|
+
} catch (error) {
|
|
474
|
+
errorOutput.write(
|
|
475
|
+
`Install failed partway through; the destination may be inconsistent. Re-running the install repairs it. (${error.message})\n`,
|
|
476
|
+
);
|
|
477
|
+
return 1;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
if (result.status === "aborted") {
|
|
481
|
+
errorOutput.write("Install aborted; nothing was written. Conflicting destination content:\n");
|
|
482
|
+
for (const conflict of result.conflicts) errorOutput.write(`- ${conflict}\n`);
|
|
483
|
+
return 1;
|
|
484
|
+
}
|
|
485
|
+
if (parsed.dryRun) {
|
|
486
|
+
output.write(`Dry run; nothing was written. Plan (${result.status}):\n`);
|
|
487
|
+
output.write(`${JSON.stringify(plan, null, 2)}\n`);
|
|
488
|
+
return 0;
|
|
489
|
+
}
|
|
490
|
+
if (result.status === "up-to-date") {
|
|
491
|
+
output.write(`Already up to date: ${plan.destinations.map((entry) => entry.destination).join(", ")}\n`);
|
|
492
|
+
return 0;
|
|
493
|
+
}
|
|
494
|
+
for (const destination of plan.destinations) {
|
|
495
|
+
output.write(`Installed for ${destination.targets.join(", ")} -> ${destination.destination}\n`);
|
|
496
|
+
for (const file of destination.files) output.write(` ${file.action.padEnd(8)} ${file.path}\n`);
|
|
497
|
+
output.write(` manifest ${destination.manifestPath}\n`);
|
|
498
|
+
}
|
|
499
|
+
return 0;
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
function isDirectExecution() {
|
|
503
|
+
if (!process.argv[1]) return false;
|
|
504
|
+
try {
|
|
505
|
+
return realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url));
|
|
506
|
+
} catch {
|
|
507
|
+
return false;
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
if (isDirectExecution()) {
|
|
512
|
+
process.exitCode = await run(process.argv.slice(2));
|
|
513
|
+
}
|
package/lib/detect.mjs
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { statSync } from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* CLI executable names per host, used for the PATH scan. `pi` is detection-only and never
|
|
7
|
+
* becomes a write target; the other four are the installer's supported destinations.
|
|
8
|
+
*/
|
|
9
|
+
export const HOST_EXECUTABLES = {
|
|
10
|
+
pi: "pi",
|
|
11
|
+
"claude-code": "claude",
|
|
12
|
+
codex: "codex",
|
|
13
|
+
opencode: "opencode",
|
|
14
|
+
gemini: "gemini",
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Well-known config-directory candidates per host (relative to the user's home directory).
|
|
19
|
+
* Locations follow skills/mobile-agent-orchestrator/references/agent-hosts.md where documented:
|
|
20
|
+
* `~/.pi` (default agent directory `~/.pi/agent`), `~/.claude`, `~/.gemini`,
|
|
21
|
+
* `~/.config/opencode` (alternative `~/.opencode`). Codex has no documented config dir in that
|
|
22
|
+
* reference (its user skills live in the shared `~/.agents/skills`), so the conventional Codex
|
|
23
|
+
* CLI config dir `~/.codex` is used as the least ambiguous probe. The first existing candidate
|
|
24
|
+
* wins; hosts with several candidates are tried in order.
|
|
25
|
+
*/
|
|
26
|
+
export const HOST_CONFIG_DIRS = {
|
|
27
|
+
pi: [".pi"],
|
|
28
|
+
"claude-code": [".claude"],
|
|
29
|
+
codex: [".codex"],
|
|
30
|
+
opencode: [".config/opencode", ".opencode"],
|
|
31
|
+
gemini: [".gemini"],
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/** Canonical order of detection results (Pi first, since it is detect-only). */
|
|
35
|
+
const HOST_ORDER = Object.keys(HOST_EXECUTABLES);
|
|
36
|
+
|
|
37
|
+
const WINDOWS_EXECUTABLE_EXTENSIONS = [".exe", ".cmd", ".bat"];
|
|
38
|
+
|
|
39
|
+
/** Existence-only directory check; never throws and never writes. */
|
|
40
|
+
function isDirectory(path) {
|
|
41
|
+
try {
|
|
42
|
+
return statSync(path).isDirectory();
|
|
43
|
+
} catch {
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Executable-file check; never throws and never writes. The candidate must be a regular file
|
|
50
|
+
* (a directory that merely shares an executable's name must not count as installed), and on
|
|
51
|
+
* POSIX it must carry at least one executable bit. On win32 a regular file is enough: the
|
|
52
|
+
* scanned extensions (.exe/.cmd/.bat) already imply executability.
|
|
53
|
+
*/
|
|
54
|
+
function isExecutableFile(path, platform) {
|
|
55
|
+
try {
|
|
56
|
+
const stats = statSync(path);
|
|
57
|
+
if (!stats.isFile()) return false;
|
|
58
|
+
if (platform === "win32") return true;
|
|
59
|
+
return (stats.mode & 0o111) !== 0;
|
|
60
|
+
} catch {
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Scan PATH directories for the per-host executables with platform-aware extensions.
|
|
67
|
+
* Nonexistent entries, non-directory entries, and empty segments are skipped without throwing;
|
|
68
|
+
* a candidate is only reported when it is an executable regular file (see isExecutableFile).
|
|
69
|
+
* Returns a Set of executable names found.
|
|
70
|
+
*/
|
|
71
|
+
function findExecutablesInPath(pathEnv, platform) {
|
|
72
|
+
const found = new Set();
|
|
73
|
+
if (!pathEnv) return found;
|
|
74
|
+
const separator = platform === "win32" ? ";" : ":";
|
|
75
|
+
const extensions = platform === "win32" ? WINDOWS_EXECUTABLE_EXTENSIONS : [""];
|
|
76
|
+
for (const entry of pathEnv.split(separator)) {
|
|
77
|
+
if (!entry || !isDirectory(entry)) continue;
|
|
78
|
+
for (const id of HOST_ORDER) {
|
|
79
|
+
const name = HOST_EXECUTABLES[id];
|
|
80
|
+
if (found.has(name)) continue;
|
|
81
|
+
for (const extension of extensions) {
|
|
82
|
+
if (isExecutableFile(join(entry, name + extension), platform)) {
|
|
83
|
+
found.add(name);
|
|
84
|
+
break;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return found;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Detect coding-agent CLIs read-only: probe well-known config directories under `home` and scan
|
|
94
|
+
* `pathEnv` for the CLI executables. No process spawning, no filesystem writes.
|
|
95
|
+
*
|
|
96
|
+
* @param {{ home?: string, pathEnv?: string, platform?: string }} options - All injectable for
|
|
97
|
+
* deterministic tests; defaults to the current user's environment.
|
|
98
|
+
* @returns {Array<{ id: string, detected: boolean, evidence: string }>} One entry per host in
|
|
99
|
+
* canonical order (pi, claude-code, codex, opencode, gemini). `evidence` is a short
|
|
100
|
+
* human-readable string ("" when nothing was found).
|
|
101
|
+
*/
|
|
102
|
+
export function detectHosts({ home = os.homedir(), pathEnv = process.env.PATH, platform = process.platform } = {}) {
|
|
103
|
+
const executablesFound = findExecutablesInPath(pathEnv, platform);
|
|
104
|
+
return HOST_ORDER.map((id) => {
|
|
105
|
+
const evidence = [];
|
|
106
|
+
for (const candidate of HOST_CONFIG_DIRS[id]) {
|
|
107
|
+
if (isDirectory(join(home, ...candidate.split("/")))) {
|
|
108
|
+
evidence.push(`config dir ~/${candidate}`);
|
|
109
|
+
break;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
if (executablesFound.has(HOST_EXECUTABLES[id])) {
|
|
113
|
+
evidence.push("found in PATH");
|
|
114
|
+
}
|
|
115
|
+
return { id, detected: evidence.length > 0, evidence: evidence.join("; ") };
|
|
116
|
+
});
|
|
117
|
+
}
|
package/lib/hosts.mjs
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { resolve } from "node:path";
|
|
2
|
+
|
|
3
|
+
const SKILL_DIRECTORY = "mobile-agent-orchestrator";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Per-target skill directory segments under the scope base (see references/agent-hosts.md).
|
|
7
|
+
* Codex, OpenCode, and Gemini CLI share the `.agents/skills` interoperable location; Claude Code
|
|
8
|
+
* uses its own documented `.claude/skills` location (`~/.claude/skills` user, `<cwd>/.claude/skills` project).
|
|
9
|
+
*/
|
|
10
|
+
const targetLocations = {
|
|
11
|
+
codex: [".agents", "skills"],
|
|
12
|
+
opencode: [".agents", "skills"],
|
|
13
|
+
gemini: [".agents", "skills"],
|
|
14
|
+
"claude-code": [".claude", "skills"],
|
|
15
|
+
};
|
|
16
|
+
export const targets = Object.keys(targetLocations);
|
|
17
|
+
export const scopes = ["user", "project"];
|
|
18
|
+
|
|
19
|
+
function resolveBase(scope, { cwd, home }) {
|
|
20
|
+
if (!scopes.includes(scope)) {
|
|
21
|
+
throw new Error(`Unknown scope: ${JSON.stringify(scope)}. Expected one of: ${scopes.join(", ")}.`);
|
|
22
|
+
}
|
|
23
|
+
if (scope === "user") {
|
|
24
|
+
if (!home) throw new Error("User scope requires an explicit home directory.");
|
|
25
|
+
return home;
|
|
26
|
+
}
|
|
27
|
+
if (!cwd) throw new Error("Project scope requires an explicit working directory.");
|
|
28
|
+
return cwd;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Resolve the absolute skill directory for one target and scope. */
|
|
32
|
+
export function resolveDestination(target, scope, { cwd, home } = {}) {
|
|
33
|
+
if (!targets.includes(target)) {
|
|
34
|
+
throw new Error(`Unknown target: ${JSON.stringify(target)}. Expected one of: ${targets.join(", ")}.`);
|
|
35
|
+
}
|
|
36
|
+
return resolve(resolveBase(scope, { cwd, home }), ...targetLocations[target], SKILL_DIRECTORY);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Resolve destinations for many targets, deduplicated: shared locations collapse to one entry. */
|
|
40
|
+
export function resolveUniqueDestinations(selectedTargets, scope, options = {}) {
|
|
41
|
+
const unique = new Map();
|
|
42
|
+
for (const target of selectedTargets) {
|
|
43
|
+
const destination = resolveDestination(target, scope, options);
|
|
44
|
+
if (!unique.has(destination)) unique.set(destination, { destination, targets: [] });
|
|
45
|
+
unique.get(destination).targets.push(target);
|
|
46
|
+
}
|
|
47
|
+
return [...unique.values()];
|
|
48
|
+
}
|