@mmerterden/multi-agent-pipeline 20.9.0 → 20.11.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 +21 -0
- package/docs/facts.json +5 -4
- package/install/catalog-history.json +1 -1
- package/manifest.json +31 -28
- package/package.json +1 -1
- package/pipeline/commands/multi-agent/SKILL.md +1 -1
- package/pipeline/commands/multi-agent/help/SKILL.md +2 -2
- package/pipeline/commands/multi-agent/setup/SKILL.md +12 -13
- package/pipeline/commands/multi-agent/store-ready/SKILL.md +63 -19
- package/pipeline/lib/credential-inventory.sh +3 -2
- package/pipeline/lib/repo-profile-derive.mjs +21 -12
- package/pipeline/multi-agent-refs/features/doctor.md +17 -0
- package/pipeline/multi-agent-refs/features/repo-profile.md +1 -1
- package/pipeline/multi-agent-refs/keychain.md +2 -0
- package/pipeline/multi-agent-refs/outside-the-pipeline.md +1 -0
- package/pipeline/multi-agent-refs/setup/app-store-connect.md +106 -20
- package/pipeline/multi-agent-refs/setup/google-play.md +70 -0
- package/pipeline/preferences-template.json +3 -1
- package/pipeline/schemas/prefs.schema.json +9 -1
- package/pipeline/schemas/repo-profile.schema.json +32 -1
- package/pipeline/scripts/doctor.mjs +91 -1
- package/pipeline/scripts/pre-commit-check.sh +11 -2
- package/pipeline/scripts/probe-store-key.mjs +306 -0
- package/pipeline/scripts/write-store-config.mjs +293 -0
- package/pipeline/skills/.skill-manifest.json +4 -4
- package/pipeline/skills/shared/core/google-play-compliance/SKILL.md +1 -1
- package/pipeline/skills/shared/core/multi-agent-setup/SKILL.md +58 -6
- package/pipeline/skills/shared/core/multi-agent-store-ready/SKILL.md +11 -7
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* @file probe-store-key.mjs - does a store key work, asked before it is saved.
|
|
4
|
+
*
|
|
5
|
+
* Setup stores the App Store Connect .p8 and the Google Play service-account
|
|
6
|
+
* JSON in the Keychain. A key that is saved first and tried later fails at the
|
|
7
|
+
* first store_* call, long after the person who could fix it has moved on, so
|
|
8
|
+
* setup runs this against the file on disk and saves only on ok.
|
|
9
|
+
*
|
|
10
|
+
* --asc --key-id <id> --issuer-id <id> --p8 <path>
|
|
11
|
+
* Signs an ES256 token (aud appstoreconnect-v1, 10 minute lifetime) and
|
|
12
|
+
* reads GET /v1/apps?limit=1.
|
|
13
|
+
* --play --service-account <path>
|
|
14
|
+
* Exchanges an RS256 jwt-bearer assertion at oauth2.googleapis.com for
|
|
15
|
+
* the androidpublisher + playdeveloperreporting scopes, then reads
|
|
16
|
+
* GET /v1beta1/apps:search?pageSize=1 on the Play Developer Reporting API.
|
|
17
|
+
*
|
|
18
|
+
* Prints one JSON line {ok, store, status, hint}. Key material, the signed
|
|
19
|
+
* assertion and the access token never reach stdout, stderr or argv.
|
|
20
|
+
*
|
|
21
|
+
* Exit: 0 accepted, 1 rejected or unreachable, 2 usage error.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { createPrivateKey, sign } from "node:crypto";
|
|
25
|
+
import { readFileSync, realpathSync, statSync } from "node:fs";
|
|
26
|
+
import { homedir } from "node:os";
|
|
27
|
+
import { join } from "node:path";
|
|
28
|
+
import { fileURLToPath } from "node:url";
|
|
29
|
+
|
|
30
|
+
export const ASC_URL = "https://api.appstoreconnect.apple.com/v1/apps?limit=1";
|
|
31
|
+
export const GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token";
|
|
32
|
+
export const PLAY_URL =
|
|
33
|
+
"https://playdeveloperreporting.googleapis.com/v1beta1/apps:search?pageSize=1";
|
|
34
|
+
export const PLAY_SCOPES = [
|
|
35
|
+
"https://www.googleapis.com/auth/androidpublisher",
|
|
36
|
+
"https://www.googleapis.com/auth/playdeveloperreporting",
|
|
37
|
+
];
|
|
38
|
+
const ASC_TOKEN_SECONDS = 600;
|
|
39
|
+
const GOOGLE_ASSERTION_SECONDS = 3600;
|
|
40
|
+
const MAX_KEY_FILE_BYTES = 64 * 1024;
|
|
41
|
+
const TIMEOUT_MS = 15000;
|
|
42
|
+
|
|
43
|
+
export class UsageError extends Error {}
|
|
44
|
+
|
|
45
|
+
const b64url = (input) =>
|
|
46
|
+
Buffer.from(typeof input === "string" ? input : JSON.stringify(input)).toString("base64url");
|
|
47
|
+
|
|
48
|
+
const expandHome = (p) =>
|
|
49
|
+
p === "~" ? homedir() : p.startsWith("~/") ? join(homedir(), p.slice(2)) : p;
|
|
50
|
+
|
|
51
|
+
function readKeyFile(path, what) {
|
|
52
|
+
const p = expandHome(path);
|
|
53
|
+
let size;
|
|
54
|
+
try {
|
|
55
|
+
size = statSync(p).size;
|
|
56
|
+
} catch {
|
|
57
|
+
throw new UsageError(`${what} file not found: ${p}`);
|
|
58
|
+
}
|
|
59
|
+
if (size > MAX_KEY_FILE_BYTES)
|
|
60
|
+
throw new UsageError(`${what} file is too large to be a key: ${p}`);
|
|
61
|
+
return readFileSync(p, "utf8");
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* The App Store Connect bearer token. ES256 with the raw r||s signature the
|
|
66
|
+
* JOSE spec requires, which node:crypto produces only with dsaEncoding
|
|
67
|
+
* "ieee-p1363"; its DER default is rejected by Apple as a bad signature.
|
|
68
|
+
*/
|
|
69
|
+
export function ascToken({ keyId, issuerId, privateKey, now = Date.now() }) {
|
|
70
|
+
const key = createPrivateKey(privateKey);
|
|
71
|
+
if (key.asymmetricKeyType !== "ec") throw new UsageError("the .p8 is not an EC private key");
|
|
72
|
+
const iat = Math.floor(now / 1000);
|
|
73
|
+
const data = `${b64url({ alg: "ES256", kid: keyId, typ: "JWT" })}.${b64url({
|
|
74
|
+
iss: issuerId,
|
|
75
|
+
iat,
|
|
76
|
+
exp: iat + ASC_TOKEN_SECONDS,
|
|
77
|
+
aud: "appstoreconnect-v1",
|
|
78
|
+
})}`;
|
|
79
|
+
const sig = sign("sha256", Buffer.from(data), { key, dsaEncoding: "ieee-p1363" });
|
|
80
|
+
return `${data}.${sig.toString("base64url")}`;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function parseServiceAccount(text) {
|
|
84
|
+
let sa;
|
|
85
|
+
try {
|
|
86
|
+
sa = JSON.parse(text);
|
|
87
|
+
} catch {
|
|
88
|
+
throw new UsageError("the service-account file is not JSON");
|
|
89
|
+
}
|
|
90
|
+
if (!sa || sa.type !== "service_account" || !sa.client_email || !sa.private_key) {
|
|
91
|
+
throw new UsageError(
|
|
92
|
+
"the file is not a service-account key (needs type service_account, client_email, private_key)",
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
return sa;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function playAssertion({ serviceAccount, now = Date.now() }) {
|
|
99
|
+
const key = createPrivateKey(serviceAccount.private_key);
|
|
100
|
+
const iat = Math.floor(now / 1000);
|
|
101
|
+
const header = { alg: "RS256", typ: "JWT" };
|
|
102
|
+
if (serviceAccount.private_key_id) header.kid = serviceAccount.private_key_id;
|
|
103
|
+
const data = `${b64url(header)}.${b64url({
|
|
104
|
+
iss: serviceAccount.client_email,
|
|
105
|
+
scope: PLAY_SCOPES.join(" "),
|
|
106
|
+
aud: GOOGLE_TOKEN_URL,
|
|
107
|
+
iat,
|
|
108
|
+
exp: iat + GOOGLE_ASSERTION_SECONDS,
|
|
109
|
+
})}`;
|
|
110
|
+
return `${data}.${sign("sha256", Buffer.from(data), key).toString("base64url")}`;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async function readBody(res) {
|
|
114
|
+
try {
|
|
115
|
+
return await res.json();
|
|
116
|
+
} catch {
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async function request(fetchImpl, url, init) {
|
|
122
|
+
try {
|
|
123
|
+
return await fetchImpl(url, { ...init, signal: AbortSignal.timeout(TIMEOUT_MS) });
|
|
124
|
+
} catch (e) {
|
|
125
|
+
return { networkError: e?.cause?.code || e?.name || "network error" };
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const unreachable = (store, res, host) => ({
|
|
130
|
+
ok: false,
|
|
131
|
+
store,
|
|
132
|
+
status: null,
|
|
133
|
+
hint: `${host} did not answer (${res.networkError}); check the network, then probe again`,
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
export function ascHint(status) {
|
|
137
|
+
if (status === 401) {
|
|
138
|
+
return "App Store Connect rejected the token: the key id and issuer id do not match this .p8, the key was revoked, or this machine's clock is off";
|
|
139
|
+
}
|
|
140
|
+
if (status === 403) {
|
|
141
|
+
return "the key authenticates but its role may not read apps; use a key with a role that can see the app (App Manager or Admin)";
|
|
142
|
+
}
|
|
143
|
+
return `App Store Connect answered ${status}`;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function isServiceDisabled(body) {
|
|
147
|
+
const err = body?.error;
|
|
148
|
+
if (!err) return false;
|
|
149
|
+
if ((err.details || []).some((d) => d?.reason === "SERVICE_DISABLED")) return true;
|
|
150
|
+
return /has not been used in project|is disabled/i.test(String(err.message || ""));
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export function playHint(status, body, projectId) {
|
|
154
|
+
if (status === 403 && isServiceDisabled(body)) {
|
|
155
|
+
const where = projectId
|
|
156
|
+
? ` in Google Cloud project ${projectId}`
|
|
157
|
+
: " in the service account's Google Cloud project";
|
|
158
|
+
return `enable the Google Play Android Developer API and the Google Play Developer Reporting API${where}, then probe again`;
|
|
159
|
+
}
|
|
160
|
+
if (status === 403) {
|
|
161
|
+
return "the service account authenticates but Play Console has not granted it access; invite it in Play Console -> Users and permissions with permissions for the app";
|
|
162
|
+
}
|
|
163
|
+
if (status === 401)
|
|
164
|
+
return "Google rejected the access token; generate a new key for the service account";
|
|
165
|
+
return `Play Developer Reporting answered ${status}`;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export async function probeAsc({ keyId, issuerId, privateKey, fetchImpl = fetch, now }) {
|
|
169
|
+
const token = ascToken({ keyId, issuerId, privateKey, now });
|
|
170
|
+
const res = await request(fetchImpl, ASC_URL, {
|
|
171
|
+
headers: { Authorization: `Bearer ${token}`, Accept: "application/json" },
|
|
172
|
+
});
|
|
173
|
+
if (res.networkError) return unreachable("appStoreConnect", res, "api.appstoreconnect.apple.com");
|
|
174
|
+
if (res.ok) return { ok: true, store: "appStoreConnect", status: res.status, hint: null };
|
|
175
|
+
return { ok: false, store: "appStoreConnect", status: res.status, hint: ascHint(res.status) };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export async function probePlay({ serviceAccount, fetchImpl = fetch, now }) {
|
|
179
|
+
const assertion = playAssertion({ serviceAccount, now });
|
|
180
|
+
const tokenRes = await request(fetchImpl, GOOGLE_TOKEN_URL, {
|
|
181
|
+
method: "POST",
|
|
182
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
183
|
+
body: new URLSearchParams({
|
|
184
|
+
grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer",
|
|
185
|
+
assertion,
|
|
186
|
+
}).toString(),
|
|
187
|
+
});
|
|
188
|
+
if (tokenRes.networkError) return unreachable("googlePlay", tokenRes, "oauth2.googleapis.com");
|
|
189
|
+
const tokenBody = await readBody(tokenRes);
|
|
190
|
+
if (!tokenRes.ok || !tokenBody?.access_token) {
|
|
191
|
+
const reason = tokenBody?.error ? ` (${String(tokenBody.error).slice(0, 40)})` : "";
|
|
192
|
+
return {
|
|
193
|
+
ok: false,
|
|
194
|
+
store: "googlePlay",
|
|
195
|
+
status: tokenRes.status,
|
|
196
|
+
hint: `Google refused the service-account assertion${reason}: the key was deleted or disabled, or this machine's clock is off`,
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
const res = await request(fetchImpl, PLAY_URL, {
|
|
200
|
+
headers: { Authorization: `Bearer ${tokenBody.access_token}`, Accept: "application/json" },
|
|
201
|
+
});
|
|
202
|
+
if (res.networkError)
|
|
203
|
+
return unreachable("googlePlay", res, "playdeveloperreporting.googleapis.com");
|
|
204
|
+
if (res.ok) return { ok: true, store: "googlePlay", status: res.status, hint: null };
|
|
205
|
+
const body = await readBody(res);
|
|
206
|
+
return {
|
|
207
|
+
ok: false,
|
|
208
|
+
store: "googlePlay",
|
|
209
|
+
status: res.status,
|
|
210
|
+
hint: playHint(res.status, body, serviceAccount.project_id),
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
export function parseArgs(argv) {
|
|
215
|
+
const opts = {};
|
|
216
|
+
const take = (i, flag) => {
|
|
217
|
+
const v = argv[i + 1];
|
|
218
|
+
if (v === undefined || v.startsWith("--")) throw new UsageError(`${flag} needs a value`);
|
|
219
|
+
return v;
|
|
220
|
+
};
|
|
221
|
+
for (let i = 0; i < argv.length; i++) {
|
|
222
|
+
const a = argv[i];
|
|
223
|
+
if (a === "--asc" || a === "--play") {
|
|
224
|
+
if (opts.mode) throw new UsageError("pass exactly one of --asc and --play");
|
|
225
|
+
opts.mode = a.slice(2);
|
|
226
|
+
} else if (a === "--key-id") opts.keyId = take(i++, a);
|
|
227
|
+
else if (a === "--issuer-id") opts.issuerId = take(i++, a);
|
|
228
|
+
else if (a === "--p8") opts.p8 = take(i++, a);
|
|
229
|
+
else if (a === "--service-account") opts.serviceAccount = take(i++, a);
|
|
230
|
+
else throw new UsageError(`unknown argument: ${a}`);
|
|
231
|
+
}
|
|
232
|
+
if (!opts.mode) throw new UsageError("pass --asc or --play");
|
|
233
|
+
if (opts.mode === "asc" && !(opts.keyId && opts.issuerId && opts.p8)) {
|
|
234
|
+
throw new UsageError("--asc needs --key-id, --issuer-id and --p8");
|
|
235
|
+
}
|
|
236
|
+
if (opts.mode === "play" && !opts.serviceAccount)
|
|
237
|
+
throw new UsageError("--play needs --service-account");
|
|
238
|
+
return opts;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const USAGE =
|
|
242
|
+
"usage: probe-store-key.mjs --asc --key-id <id> --issuer-id <id> --p8 <path>\n" +
|
|
243
|
+
" probe-store-key.mjs --play --service-account <path>\n";
|
|
244
|
+
|
|
245
|
+
export async function main(
|
|
246
|
+
argv,
|
|
247
|
+
{ fetchImpl = globalThis.fetch, out = process.stdout, err = process.stderr } = {},
|
|
248
|
+
) {
|
|
249
|
+
let result;
|
|
250
|
+
try {
|
|
251
|
+
const opts = parseArgs(argv);
|
|
252
|
+
if (opts.mode === "asc") {
|
|
253
|
+
const privateKey = readKeyFile(opts.p8, "App Store Connect private key");
|
|
254
|
+
try {
|
|
255
|
+
createPrivateKey(privateKey);
|
|
256
|
+
} catch {
|
|
257
|
+
throw new UsageError("the .p8 file does not hold a readable private key");
|
|
258
|
+
}
|
|
259
|
+
result = await probeAsc({
|
|
260
|
+
keyId: opts.keyId,
|
|
261
|
+
issuerId: opts.issuerId,
|
|
262
|
+
privateKey,
|
|
263
|
+
fetchImpl,
|
|
264
|
+
});
|
|
265
|
+
} else {
|
|
266
|
+
const serviceAccount = parseServiceAccount(
|
|
267
|
+
readKeyFile(opts.serviceAccount, "service-account"),
|
|
268
|
+
);
|
|
269
|
+
try {
|
|
270
|
+
createPrivateKey(serviceAccount.private_key);
|
|
271
|
+
} catch {
|
|
272
|
+
throw new UsageError("the service-account private_key is not a readable private key");
|
|
273
|
+
}
|
|
274
|
+
result = await probePlay({ serviceAccount, fetchImpl });
|
|
275
|
+
}
|
|
276
|
+
} catch (e) {
|
|
277
|
+
if (e instanceof UsageError) {
|
|
278
|
+
err.write(`probe-store-key: ${e.message}\n${USAGE}`);
|
|
279
|
+
return 2;
|
|
280
|
+
}
|
|
281
|
+
throw e;
|
|
282
|
+
}
|
|
283
|
+
out.write(`${JSON.stringify(result)}\n`);
|
|
284
|
+
return result.ok ? 0 : 1;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function invokedDirectly() {
|
|
288
|
+
if (!process.argv[1]) return false;
|
|
289
|
+
try {
|
|
290
|
+
return fileURLToPath(import.meta.url) === realpathSync(process.argv[1]);
|
|
291
|
+
} catch {
|
|
292
|
+
return false;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
if (invokedDirectly()) {
|
|
297
|
+
main(process.argv.slice(2)).then(
|
|
298
|
+
(code) => {
|
|
299
|
+
process.exitCode = code;
|
|
300
|
+
},
|
|
301
|
+
() => {
|
|
302
|
+
process.stderr.write("probe-store-key: unexpected failure\n");
|
|
303
|
+
process.exitCode = 1;
|
|
304
|
+
},
|
|
305
|
+
);
|
|
306
|
+
}
|
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* @file write-store-config.mjs - tell the toolkit's store_* tools where the keys are.
|
|
4
|
+
*
|
|
5
|
+
* The multi-agent-toolkit MCP server reads App Store Connect and Google Play
|
|
6
|
+
* credentials from the Keychain through a reference file that names Keychain
|
|
7
|
+
* services and holds no secret:
|
|
8
|
+
*
|
|
9
|
+
* {"appStoreConnect": {"keyIdService", "issuerIdService", "privateKeyService"},
|
|
10
|
+
* "googlePlay": {"serviceAccountService"}}
|
|
11
|
+
*
|
|
12
|
+
* This writes that file from the pipeline's own keychainMapping, so the two
|
|
13
|
+
* never name different items. Each service name is the mapping value, else the
|
|
14
|
+
* standard `${USER}_...` name. A store is written only when every one of its
|
|
15
|
+
* items exists; existence is asked through `credential-store.sh probe`, which
|
|
16
|
+
* sends the value to /dev/null, so no secret passes through this process.
|
|
17
|
+
* Top-level keys other than the two stores are preserved.
|
|
18
|
+
*
|
|
19
|
+
* Paths: preferences at MULTI_AGENT_PREFS or ~/.claude/multi-agent-preferences.json
|
|
20
|
+
* (the default credential-store.sh uses); the reference file at
|
|
21
|
+
* MCP_TOOLKIT_STORE_CONFIG or ~/.config/multi-agent-toolkit/store.json. The
|
|
22
|
+
* file is written 0600 through a temp file and a rename, its directory 0700
|
|
23
|
+
* when this creates it.
|
|
24
|
+
*
|
|
25
|
+
* Usage:
|
|
26
|
+
* write-store-config.mjs write the file, print one JSON line
|
|
27
|
+
* write-store-config.mjs --check compare only; exit 0 current, 1 missing or stale
|
|
28
|
+
*
|
|
29
|
+
* Exit: 0 written / current, 1 stale (--check) or credential backend unavailable,
|
|
30
|
+
* 2 usage error.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
import { spawnSync } from "node:child_process";
|
|
34
|
+
import {
|
|
35
|
+
chmodSync,
|
|
36
|
+
existsSync,
|
|
37
|
+
mkdirSync,
|
|
38
|
+
readFileSync,
|
|
39
|
+
realpathSync,
|
|
40
|
+
renameSync,
|
|
41
|
+
rmSync,
|
|
42
|
+
writeFileSync,
|
|
43
|
+
} from "node:fs";
|
|
44
|
+
import { homedir } from "node:os";
|
|
45
|
+
import { dirname, join } from "node:path";
|
|
46
|
+
import { fileURLToPath } from "node:url";
|
|
47
|
+
|
|
48
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
49
|
+
const SERVICE_NAME = /^[A-Za-z0-9_.@-]{1,200}$/;
|
|
50
|
+
|
|
51
|
+
export const STORES = Object.freeze({
|
|
52
|
+
appStoreConnect: [
|
|
53
|
+
{
|
|
54
|
+
logical: "appstore_connect_key_id",
|
|
55
|
+
field: "keyIdService",
|
|
56
|
+
standard: "AppStoreConnect_Key_Id",
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
logical: "appstore_connect_issuer_id",
|
|
60
|
+
field: "issuerIdService",
|
|
61
|
+
standard: "AppStoreConnect_Issuer_Id",
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
logical: "appstore_connect_private_key",
|
|
65
|
+
field: "privateKeyService",
|
|
66
|
+
standard: "AppStoreConnect_Private_Key",
|
|
67
|
+
},
|
|
68
|
+
],
|
|
69
|
+
googlePlay: [
|
|
70
|
+
{ logical: "google_play", field: "serviceAccountService", standard: "GooglePlay_Access_Json" },
|
|
71
|
+
],
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
export const STORE_LOGICAL_KEYS = Object.freeze(
|
|
75
|
+
Object.values(STORES).flatMap((items) => items.map((i) => i.logical)),
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
const expandHome = (p) =>
|
|
79
|
+
p === "~" ? homedir() : p.startsWith("~/") ? join(homedir(), p.slice(2)) : p;
|
|
80
|
+
|
|
81
|
+
export function prefsPath(env = process.env) {
|
|
82
|
+
return env.MULTI_AGENT_PREFS || join(homedir(), ".claude", "multi-agent-preferences.json");
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function storeConfigPath(env = process.env) {
|
|
86
|
+
return expandHome(
|
|
87
|
+
env.MCP_TOOLKIT_STORE_CONFIG || join(homedir(), ".config", "multi-agent-toolkit", "store.json"),
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function readMapping(path) {
|
|
92
|
+
try {
|
|
93
|
+
const prefs = JSON.parse(readFileSync(path, "utf8"));
|
|
94
|
+
const km = prefs?.global?.keychainMapping;
|
|
95
|
+
return km && typeof km === "object" ? km : {};
|
|
96
|
+
} catch {
|
|
97
|
+
return {};
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function serviceName(mapping, item, user) {
|
|
102
|
+
const mapped = mapping[item.logical];
|
|
103
|
+
if (typeof mapped === "string" && mapped.trim()) return mapped.trim();
|
|
104
|
+
return user ? `${user}_${item.standard}` : null;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Presence through credential-store.sh probe, passed the resolved service name
|
|
109
|
+
* so a mapping cannot redirect it. `locked` counts as present: the item is
|
|
110
|
+
* there, and the toolkit reports the lock itself when it reads. The probe finds
|
|
111
|
+
* an item by label or by service while the toolkit reads by service; items that
|
|
112
|
+
* credential-store.sh writes carry both, so the two agree for every key setup
|
|
113
|
+
* stores. A hand-made item with only a label passes here and is reported by the
|
|
114
|
+
* toolkit as missing.
|
|
115
|
+
*
|
|
116
|
+
* @returns {"present"|"missing"|"unavailable"}
|
|
117
|
+
*/
|
|
118
|
+
export function probeWith(storeScript) {
|
|
119
|
+
return (service) => {
|
|
120
|
+
const r = spawnSync("bash", [storeScript, "probe", service], {
|
|
121
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
122
|
+
encoding: "utf8",
|
|
123
|
+
timeout: 15000,
|
|
124
|
+
});
|
|
125
|
+
if (r.status === 0 || r.status === 5) return "present";
|
|
126
|
+
if (r.status === 1) return "missing";
|
|
127
|
+
return "unavailable";
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* What the reference file should say, and why each store is in or out.
|
|
133
|
+
*
|
|
134
|
+
* @param {{mapping: object, user: string, probe: (service: string) => string}} input
|
|
135
|
+
*/
|
|
136
|
+
export function desiredConfig({ mapping, user, probe }) {
|
|
137
|
+
const config = {};
|
|
138
|
+
const stores = {};
|
|
139
|
+
let unavailable = false;
|
|
140
|
+
for (const [store, items] of Object.entries(STORES)) {
|
|
141
|
+
const entry = {};
|
|
142
|
+
const missing = [];
|
|
143
|
+
for (const item of items) {
|
|
144
|
+
const primary = serviceName(mapping, item, user);
|
|
145
|
+
// Unmapped, credential-store.sh stores a key under its bare logical name,
|
|
146
|
+
// so that name is tried after the standard one.
|
|
147
|
+
const candidates = [primary, ...(mapping[item.logical] ? [] : [item.logical])].filter(
|
|
148
|
+
(n) => n && SERVICE_NAME.test(n),
|
|
149
|
+
);
|
|
150
|
+
if (!candidates.length) {
|
|
151
|
+
missing.push(`${item.logical} (no usable service name)`);
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
let name = candidates[0];
|
|
155
|
+
let state = probe(name);
|
|
156
|
+
for (const alt of candidates.slice(1)) {
|
|
157
|
+
if (state === "present") break;
|
|
158
|
+
const altState = probe(alt);
|
|
159
|
+
if (altState === "present") {
|
|
160
|
+
name = alt;
|
|
161
|
+
state = altState;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
if (state === "unavailable") unavailable = true;
|
|
165
|
+
if (state !== "present") missing.push(item.logical);
|
|
166
|
+
entry[item.field] = name;
|
|
167
|
+
}
|
|
168
|
+
if (missing.length) {
|
|
169
|
+
stores[store] = { included: false, missing };
|
|
170
|
+
} else {
|
|
171
|
+
config[store] = entry;
|
|
172
|
+
stores[store] = { included: true };
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
return { config, stores, unavailable };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export function readExisting(path) {
|
|
179
|
+
if (!existsSync(path)) return { exists: false, config: null };
|
|
180
|
+
try {
|
|
181
|
+
const parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
182
|
+
return {
|
|
183
|
+
exists: true,
|
|
184
|
+
config: parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : null,
|
|
185
|
+
};
|
|
186
|
+
} catch {
|
|
187
|
+
return { exists: true, config: null };
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export function merged(existing, desired) {
|
|
192
|
+
const out = { ...(existing || {}) };
|
|
193
|
+
for (const store of Object.keys(STORES)) {
|
|
194
|
+
if (desired[store]) out[store] = desired[store];
|
|
195
|
+
else delete out[store];
|
|
196
|
+
}
|
|
197
|
+
return out;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const canonical = (o) =>
|
|
201
|
+
JSON.stringify(o, (_, v) =>
|
|
202
|
+
v && typeof v === "object" && !Array.isArray(v)
|
|
203
|
+
? Object.fromEntries(
|
|
204
|
+
Object.keys(v)
|
|
205
|
+
.sort()
|
|
206
|
+
.map((k) => [k, v[k]]),
|
|
207
|
+
)
|
|
208
|
+
: v,
|
|
209
|
+
);
|
|
210
|
+
|
|
211
|
+
export function sameStores(a, b) {
|
|
212
|
+
return Object.keys(STORES).every((s) => canonical(a?.[s] ?? null) === canonical(b?.[s] ?? null));
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export function writeAtomic(path, config) {
|
|
216
|
+
const dir = dirname(path);
|
|
217
|
+
if (!existsSync(dir)) {
|
|
218
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
219
|
+
chmodSync(dir, 0o700);
|
|
220
|
+
}
|
|
221
|
+
const tmp = join(dir, `.store.json.${process.pid}.${Date.now()}.tmp`);
|
|
222
|
+
try {
|
|
223
|
+
writeFileSync(tmp, `${JSON.stringify(config, null, 2)}\n`, { mode: 0o600, flag: "wx" });
|
|
224
|
+
chmodSync(tmp, 0o600);
|
|
225
|
+
renameSync(tmp, path);
|
|
226
|
+
} catch (e) {
|
|
227
|
+
rmSync(tmp, { force: true });
|
|
228
|
+
throw e;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
export function run(
|
|
233
|
+
argv,
|
|
234
|
+
{ env = process.env, probe, out = process.stdout, err = process.stderr } = {},
|
|
235
|
+
) {
|
|
236
|
+
const check = argv.includes("--check");
|
|
237
|
+
const unknown = argv.filter((a) => a !== "--check");
|
|
238
|
+
if (unknown.length) {
|
|
239
|
+
err.write(`usage: write-store-config.mjs [--check]\n`);
|
|
240
|
+
return 2;
|
|
241
|
+
}
|
|
242
|
+
const storeScript = env.CRED_STORE || join(HERE, "..", "lib", "credential-store.sh");
|
|
243
|
+
const path = storeConfigPath(env);
|
|
244
|
+
const mapping = readMapping(prefsPath(env));
|
|
245
|
+
const desired = desiredConfig({
|
|
246
|
+
mapping,
|
|
247
|
+
user: env.USER || "",
|
|
248
|
+
probe: probe || probeWith(storeScript),
|
|
249
|
+
});
|
|
250
|
+
const existing = readExisting(path);
|
|
251
|
+
const current = existing.config !== null && sameStores(existing.config, desired.config);
|
|
252
|
+
const wanted = Object.keys(desired.config).length > 0;
|
|
253
|
+
const matches = existing.exists
|
|
254
|
+
? current && (wanted || Object.keys(existing.config).length > 0)
|
|
255
|
+
: !wanted;
|
|
256
|
+
|
|
257
|
+
const summary = { path, stores: desired.stores };
|
|
258
|
+
if (desired.unavailable) {
|
|
259
|
+
out.write(`${JSON.stringify({ ...summary, error: "credential store unavailable" })}\n`);
|
|
260
|
+
return 1;
|
|
261
|
+
}
|
|
262
|
+
if (check) {
|
|
263
|
+
const state = matches ? "current" : existing.exists ? "stale" : "missing";
|
|
264
|
+
out.write(`${JSON.stringify({ ...summary, state })}\n`);
|
|
265
|
+
return matches ? 0 : 1;
|
|
266
|
+
}
|
|
267
|
+
if (matches) {
|
|
268
|
+
out.write(`${JSON.stringify({ ...summary, written: false, state: "current" })}\n`);
|
|
269
|
+
return 0;
|
|
270
|
+
}
|
|
271
|
+
const next = merged(existing.config, desired.config);
|
|
272
|
+
if (Object.keys(next).length === 0) {
|
|
273
|
+
rmSync(path, { force: true });
|
|
274
|
+
out.write(`${JSON.stringify({ ...summary, written: false, removed: existing.exists })}\n`);
|
|
275
|
+
return 0;
|
|
276
|
+
}
|
|
277
|
+
writeAtomic(path, next);
|
|
278
|
+
out.write(`${JSON.stringify({ ...summary, written: true })}\n`);
|
|
279
|
+
return 0;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function invokedDirectly() {
|
|
283
|
+
if (!process.argv[1]) return false;
|
|
284
|
+
try {
|
|
285
|
+
return fileURLToPath(import.meta.url) === realpathSync(process.argv[1]);
|
|
286
|
+
} catch {
|
|
287
|
+
return false;
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
if (invokedDirectly()) {
|
|
292
|
+
process.exitCode = run(process.argv.slice(2));
|
|
293
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schemaVersion": "1.0.0",
|
|
3
|
-
"generatedAt": "2026-09-
|
|
3
|
+
"generatedAt": "2026-09-29T06:46:22Z",
|
|
4
4
|
"skillCount": 218,
|
|
5
5
|
"entries": [
|
|
6
6
|
{
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
},
|
|
10
10
|
{
|
|
11
11
|
"path": "shared/core/google-play-compliance/SKILL.md",
|
|
12
|
-
"sha256": "
|
|
12
|
+
"sha256": "3089e8c5f99c1ec7c1358394c398335ccb5ebc20ca05d60d870127da4592de34"
|
|
13
13
|
},
|
|
14
14
|
{
|
|
15
15
|
"path": "shared/core/multi-agent-analysis-jira/SKILL.md",
|
|
@@ -201,7 +201,7 @@
|
|
|
201
201
|
},
|
|
202
202
|
{
|
|
203
203
|
"path": "shared/core/multi-agent-setup/SKILL.md",
|
|
204
|
-
"sha256": "
|
|
204
|
+
"sha256": "f73388e0a64ba8a1606e3364441b078f3df6f7e99e589fb9bc8b93269a2733b2"
|
|
205
205
|
},
|
|
206
206
|
{
|
|
207
207
|
"path": "shared/core/multi-agent-stack/SKILL.md",
|
|
@@ -217,7 +217,7 @@
|
|
|
217
217
|
},
|
|
218
218
|
{
|
|
219
219
|
"path": "shared/core/multi-agent-store-ready/SKILL.md",
|
|
220
|
-
"sha256": "
|
|
220
|
+
"sha256": "514cb5b80f9584da035f6ac23681b42c765fdced25d704f8e36b6cc079b8093d"
|
|
221
221
|
},
|
|
222
222
|
{
|
|
223
223
|
"path": "shared/core/multi-agent-sync/SKILL.md",
|
|
@@ -341,7 +341,7 @@ The appended section is structured:
|
|
|
341
341
|
|
|
342
342
|
- **Policy thresholds change yearly.** Target API level (currently 34) advances each August for new apps / November for updates. When a new Android version ships, update Rule #1 `target-api-level` with the new threshold.
|
|
343
343
|
- **Not a substitute for Play Console pre-launch report.** This skill catches static issues (manifest, signing, size, debug tools). Dynamic issues (crashes, ANRs, security vulnerabilities) require Play Console's own pre-launch testing on real devices.
|
|
344
|
-
- **Data Safety form audit is partial.** Rule #14 flags manifest permissions that commonly trigger form updates, but the
|
|
344
|
+
- **Data Safety form audit is partial.** Rule #14 flags manifest permissions that commonly trigger form updates, but the declared answers live in Play Console and cannot be cross-checked from here. The Android Publisher API has no Data Safety read: its only operation, `applications.dataSafety`, writes the declaration. With `google_play` onboarded, `store_search` / `store_call` reach the rest of the Android Publisher and Play Developer Reporting APIs (tracks, bundles, vitals), but the gap for Data Safety stays.
|
|
345
345
|
|
|
346
346
|
## Version history
|
|
347
347
|
|