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.
@@ -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
+ }