@mmerterden/multi-agent-pipeline 20.17.2 → 20.18.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/CHANGELOG.md CHANGED
@@ -16,6 +16,19 @@ Internal file-layout changes that don't affect the slash-command surface are sti
16
16
 
17
17
  ## [Unreleased]
18
18
 
19
+ ## [20.18.0] - 2026-10-09
20
+
21
+ ### Added
22
+
23
+ - Apple's Xcode-provided skills are linked from the machine's own Xcode 27.1 or later (`xcrun agent plugin path`), never shipped in this package. The installer keeps a local directory marketplace (`~/.claude/xcode-apple-marketplace`, plugin `xcode-integration@xcode-apple`) at the installed Xcode build for Claude Code, and copies the same skills beside the stack plugin skills for Copilot CLI and as refs for Codex CLI when the iOS toolkit is delivered, with a manifest for refresh and uninstall. No Xcode, an older one or a failing `xcrun` links nothing and fails nothing. `MULTI_AGENT_SKIP_XCODE_SKILLS=1` (and the existing `MULTI_AGENT_SKIP_MCP_REGISTER=1`) skip the link for hermetic installs.
24
+ - `/multi-agent:stack` turns `xcode-integration@xcode-apple` on with `ios` and off without it, once the link exists.
25
+ - doctor check `xcode-skills`: SKIP without the Xcode plugin, INFO when available but not linked, WARN when the link is older than the installed Xcode build.
26
+ - `ios-iphone-duo` routes the code scan to Apple's `app-resizability` first, and covers the scene-based life cycle (required from iOS 27 for apps built with the latest SDK), legacy layout guides, `keyboardLayoutGuide` and stored insets for machines without it.
27
+
28
+ ### Changed
29
+
30
+ - Uninstall removes the Claude Code link (plugin, marketplace and directory) and the delivered copies, since nothing refreshes them without the pipeline.
31
+
19
32
  ## [20.17.2] - 2026-10-08
20
33
 
21
34
  ### Added
package/docs/facts.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$comment": "Generated by pipeline/scripts/gen-facts.mjs. Do not hand-edit: the site reads this, and a number edited here instead of at its source is the drift this file removes.",
3
- "generatedAt": "2026-10-08",
4
- "version": "20.17.2",
3
+ "generatedAt": "2026-10-09",
4
+ "version": "20.18.0",
5
5
  "phaseSchema": 2,
6
6
  "phases": [
7
7
  {
@@ -119,7 +119,7 @@ function hostConfig(host) {
119
119
  * @param {"claude"|"codex"|"copilot"} host
120
120
  * @returns {string}
121
121
  */
122
- function resolveCli(host) {
122
+ export function resolveCli(host) {
123
123
  const { candidates } = hostConfig(host);
124
124
  for (const c of candidates) {
125
125
  if (c.includes("/") && existsSync(c)) return c;
@@ -0,0 +1,263 @@
1
+ /**
2
+ * Apple's Xcode-provided skills, linked from the machine's own Xcode.
3
+ *
4
+ * Xcode 27.1 and later materialize an agent plugin (`xcode-integration`: Apple's
5
+ * skills for app resizability and iPhone Duo, SwiftUI, App Intents, accessibility
6
+ * audits, String Catalog translation, test modernization and more, plus Xcode's
7
+ * MCP bridge) and print its path with `xcrun agent plugin path`. These skills are
8
+ * the reference for the topics they cover, so iOS work routes to them first.
9
+ *
10
+ * Nothing of Apple's is shipped here. Each host gets the content its own Xcode
11
+ * produced, refreshed whenever the Xcode build changes:
12
+ *
13
+ * - **Claude Code** loads it as a plugin from a local directory marketplace
14
+ * (`xcode-apple`). A directory marketplace is read in place, so a refresh is a
15
+ * re-copy into that directory; the next session sees it.
16
+ * - **Copilot CLI** has no plugin loader, so the skills are copied into its flat
17
+ * skills directory, the same delivery the stack plugins get.
18
+ * - **Codex CLI** takes Xcode's Codex-format skills as reference files beside the
19
+ * stack plugin skills: it lists plugin skills without their descriptions and
20
+ * truncates its skills block once it overflows.
21
+ *
22
+ * No developer tools, an Xcode older than 27.1, a failing `xcrun` or a failed
23
+ * copy means nothing is linked and the pipeline's own skills cover the topics.
24
+ * The install never fails over it: every outcome is returned, never thrown.
25
+ *
26
+ * @module install/_xcode-skills
27
+ */
28
+
29
+ import { existsSync, readdirSync, readFileSync, writeFileSync } from "fs";
30
+ import { join } from "path";
31
+
32
+ import { copyDir, ensureDir, isDryRun, wipeDir } from "./_common.mjs";
33
+ import { resolveCli } from "./_mcp-register.mjs";
34
+ import {
35
+ BUILD_STAMP,
36
+ CLAUDE_MARKETPLACE_DIR,
37
+ XCODE_MARKETPLACE,
38
+ XCODE_PLUGIN,
39
+ XCODE_PLUGIN_ID,
40
+ XCODE_SKILLS_MANIFEST,
41
+ claudeLinkedBuild,
42
+ defaultRun,
43
+ resolveXcodePlugin,
44
+ xcodeSkillsStatus,
45
+ } from "../pipeline/lib/xcode-plugin.mjs";
46
+
47
+ export {
48
+ CLAUDE_MARKETPLACE_DIR,
49
+ XCODE_MARKETPLACE,
50
+ XCODE_PLUGIN,
51
+ XCODE_PLUGIN_ID,
52
+ XCODE_SKILLS_MANIFEST,
53
+ resolveXcodePlugin,
54
+ xcodeSkillsStatus,
55
+ };
56
+
57
+ /**
58
+ * Hermetic installs skip the link: it shells out to `xcrun` and to the host CLI,
59
+ * so the result would depend on the machine. The layout smokes already set the
60
+ * MCP opt-out, and Apple's plugin carries an MCP server of its own.
61
+ */
62
+ export function xcodeLinkDisabled(env = process.env) {
63
+ return env.MULTI_AGENT_SKIP_XCODE_SKILLS === "1" || env.MULTI_AGENT_SKIP_MCP_REGISTER === "1";
64
+ }
65
+
66
+ const firstLine = (e) =>
67
+ (
68
+ `${e?.stderr ?? ""}${e?.stdout ?? ""}${e?.message ?? ""}`.split("\n").find((l) => l.trim()) ||
69
+ "unknown error"
70
+ ).trim();
71
+
72
+ function marketplaceJson(build) {
73
+ return {
74
+ name: XCODE_MARKETPLACE,
75
+ owner: { name: "Xcode on this Mac" },
76
+ metadata: {
77
+ description: `Apple's Xcode-provided skills, copied from this Mac's Xcode (build ${build}).`,
78
+ },
79
+ plugins: [
80
+ {
81
+ name: XCODE_PLUGIN,
82
+ source: `./${XCODE_PLUGIN}`,
83
+ description: "Apple's skills and Xcode MCP bridge, as Xcode ships them.",
84
+ },
85
+ ],
86
+ };
87
+ }
88
+
89
+ /**
90
+ * Bring Claude Code's `xcode-apple` marketplace to the installed Xcode build and
91
+ * make sure the plugin is installed at user scope.
92
+ *
93
+ * User scope enables it in every repo, as the stack plugins are when installed
94
+ * that way; `/multi-agent:stack` turns it off for a repo whose stack is not iOS.
95
+ *
96
+ * Idempotent: the copy is skipped when the stamp names this build, and both CLI
97
+ * calls succeed on an existing marketplace or install. The stamp is written only
98
+ * after both succeed, so a failed link never reads as current.
99
+ *
100
+ * @param {string} home
101
+ * @param {{run?: (cmd: string, args: string[]) => string, cli?: string, env?: object}} [opts]
102
+ * @returns {{status: "linked"|"refreshed"|"current"|"unavailable"|"skipped"|"failed", build?: string, skills?: number, reason?: string}}
103
+ */
104
+ export function linkClaudeXcodePlugin(home, { run = defaultRun, cli, env = process.env } = {}) {
105
+ if (xcodeLinkDisabled(env)) return { status: "skipped", reason: "opted out" };
106
+ const plugin = resolveXcodePlugin("claude", { run, env });
107
+ if (!plugin) return { status: "unavailable", reason: "no Xcode agent plugin on this Mac" };
108
+
109
+ const mpDir = join(home, CLAUDE_MARKETPLACE_DIR);
110
+ const target = join(mpDir, XCODE_PLUGIN);
111
+ const previous = claudeLinkedBuild(home);
112
+ const fresh = previous === plugin.build && existsSync(join(target, ".claude-plugin"));
113
+
114
+ if (isDryRun()) {
115
+ console.log(` [dry-run] would link ${XCODE_PLUGIN_ID} from Xcode ${plugin.build}`);
116
+ return { status: "skipped", reason: "dry-run", build: plugin.build };
117
+ }
118
+
119
+ try {
120
+ if (!fresh) {
121
+ wipeDir(join(mpDir, BUILD_STAMP));
122
+ ensureDir(join(mpDir, ".claude-plugin"));
123
+ wipeDir(target);
124
+ copyDir(plugin.path, target);
125
+ writeFileSync(
126
+ join(mpDir, ".claude-plugin", "marketplace.json"),
127
+ JSON.stringify(marketplaceJson(plugin.build), null, 2) + "\n",
128
+ );
129
+ }
130
+ } catch (e) {
131
+ return { status: "failed", reason: `copy failed: ${firstLine(e)}`, build: plugin.build };
132
+ }
133
+
134
+ const claude = cli || resolveCli("claude");
135
+ try {
136
+ run(claude, ["plugin", "marketplace", "add", mpDir]);
137
+ run(claude, ["plugin", "install", XCODE_PLUGIN_ID, "--scope", "user"]);
138
+ } catch (e) {
139
+ const reason = e?.code === "ENOENT" ? "claude not on PATH" : firstLine(e);
140
+ return { status: "failed", reason, build: plugin.build };
141
+ }
142
+
143
+ try {
144
+ if (!fresh) writeFileSync(join(mpDir, BUILD_STAMP), plugin.build + "\n");
145
+ } catch (e) {
146
+ return { status: "failed", reason: `stamp not written: ${firstLine(e)}`, build: plugin.build };
147
+ }
148
+
149
+ const status = fresh ? "current" : previous ? "refreshed" : "linked";
150
+ return { status, build: plugin.build, skills: plugin.skills.length };
151
+ }
152
+
153
+ function readManifest(dest) {
154
+ try {
155
+ const names = JSON.parse(readFileSync(join(dest, XCODE_SKILLS_MANIFEST), "utf8"));
156
+ return Array.isArray(names) ? names : [];
157
+ } catch {
158
+ return [];
159
+ }
160
+ }
161
+
162
+ function existingDirs(dest) {
163
+ try {
164
+ return readdirSync(dest, { withFileTypes: true })
165
+ .filter((e) => e.isDirectory())
166
+ .map((e) => e.name);
167
+ } catch {
168
+ return [];
169
+ }
170
+ }
171
+
172
+ /**
173
+ * Copy Apple's skills into a flat skills directory (Copilot) or refs directory
174
+ * (Codex), one directory per skill, replacing what an earlier pass delivered.
175
+ *
176
+ * A name already present in `dest` that an earlier pass did not deliver - a
177
+ * pipeline skill, a stack plugin skill, or one the user wrote - is never
178
+ * overwritten: Apple's skill goes in as `xcode-<name>` instead, and is left out
179
+ * if that name is taken too.
180
+ *
181
+ * @param {object} opts
182
+ * @param {string} opts.dest
183
+ * @param {string} opts.label - host label for log lines
184
+ * @param {"claude"|"codex"} [opts.format] - which of Xcode's plugin formats to copy
185
+ * @param {Set<string>} [opts.taken] - names this install delivers into `dest`
186
+ * @param {boolean} [opts.enabled] - false removes an earlier delivery (the repo is not iOS)
187
+ * @param {(from: string, to: string) => void} [opts.copy]
188
+ * @param {(cmd: string, args: string[]) => string} [opts.run]
189
+ * @param {object} [opts.env]
190
+ * @returns {{status: "delivered"|"unavailable"|"skipped"|"failed", build?: string, delivered: string[], reason?: string}}
191
+ */
192
+ export function deliverXcodeSkills(opts) {
193
+ const {
194
+ dest,
195
+ label,
196
+ format = "claude",
197
+ taken: takenByInstall = new Set(),
198
+ enabled = true,
199
+ copy = (from, to) => copyDir(from, to),
200
+ run = defaultRun,
201
+ env = process.env,
202
+ } = opts;
203
+ const previous = readManifest(dest);
204
+ const ours = new Set(previous);
205
+ const taken = new Set([
206
+ ...takenByInstall,
207
+ ...existingDirs(dest).filter((name) => !ours.has(name)),
208
+ ]);
209
+
210
+ try {
211
+ const clear = () => {
212
+ for (const name of previous) if (!taken.has(name)) wipeDir(join(dest, name));
213
+ if (!isDryRun() && previous.length) writeFileSync(join(dest, XCODE_SKILLS_MANIFEST), "[]\n");
214
+ };
215
+
216
+ if (!enabled) {
217
+ clear();
218
+ return { status: "skipped", delivered: [] };
219
+ }
220
+ if (xcodeLinkDisabled(env)) return { status: "skipped", delivered: [] };
221
+ const plugin = resolveXcodePlugin(format, { run, env });
222
+ if (!plugin) {
223
+ clear();
224
+ console.log(` -> ${label}: no Xcode agent plugin on this Mac; Apple's skills not linked`);
225
+ return { status: "unavailable", delivered: [] };
226
+ }
227
+
228
+ for (const name of previous) if (!taken.has(name)) wipeDir(join(dest, name));
229
+ const delivered = [];
230
+ let skipped = 0;
231
+ for (const name of plugin.skills) {
232
+ const deliveredName = !taken.has(name)
233
+ ? name
234
+ : !taken.has(`xcode-${name}`)
235
+ ? `xcode-${name}`
236
+ : null;
237
+ if (!deliveredName) {
238
+ skipped++;
239
+ continue;
240
+ }
241
+ const to = join(dest, deliveredName);
242
+ ensureDir(dest);
243
+ wipeDir(to);
244
+ if (!isDryRun()) copy(join(plugin.path, "skills", name), to);
245
+ delivered.push(deliveredName);
246
+ }
247
+ if (isDryRun()) {
248
+ console.log(
249
+ ` [dry-run] would link ${delivered.length} Apple skill(s) from Xcode ${plugin.build} into ${dest}`,
250
+ );
251
+ return { status: "skipped", build: plugin.build, delivered };
252
+ }
253
+ writeFileSync(join(dest, XCODE_SKILLS_MANIFEST), JSON.stringify(delivered, null, 2) + "\n");
254
+ console.log(
255
+ ` -> ${label}: ${delivered.length} Apple skill(s) linked from Xcode ${plugin.build}` +
256
+ (skipped ? ` (${skipped} left out: both names taken)` : ""),
257
+ );
258
+ return { status: "delivered", build: plugin.build, delivered };
259
+ } catch (e) {
260
+ console.log(` -> ${label}: Apple's skills not linked: ${firstLine(e)}`);
261
+ return { status: "failed", delivered: [], reason: firstLine(e) };
262
+ }
263
+ }