plugins/lisa-expo-copilot/skills/play-store-access/SKILL.md
Read-only Google Play Console access layer for Expo projects. Use when an operator or agent needs to set up Play Developer API credentials, resolve the Android package name, or check the production track release state without using the Play Console UI. Reads the androidpublisher v3 API only; track promotion, rollout changes, and submissions are out of scope.
npx skillsauth add codyswanngt/lisa play-store-accessInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
Read-only access layer for Google Play release state in Expo projects. Use it to answer whether the Android app is live, halted, draft, or in staged rollout on a Play track after EAS submission.
This skill MUST NOT promote tracks, change rollout percentages, create releases, or submit builds. EAS remains the submission path.
operation: setup
operation: production-status [track:production] [package:<android.package>]
operation: track-status track:<track> [package:<android.package>]
operation: resolve-package
Return a concise result with the package name, track, release name /
versionCodes, status, userFraction when present, and any countryTargeting
or in-app update priority fields present in the API response.
Project-safe config can live in .lisa.config.json:
{
"playStore": {
"packageName": "com.example.app",
"track": "production"
}
}
Local credential config belongs in .lisa.config.local.json, which must stay
gitignored:
{
"playStore": {
"serviceAccountKeyPath": "./google-play-service-account.json"
}
}
Supported credential locations, in resolution order:
GOOGLE_PLAY_SERVICE_ACCOUNT_JSON containing the raw service-account JSON.GOOGLE_PLAY_SERVICE_ACCOUNT_KEY_BASE64 containing base64 JSON..lisa.config.local.json playStore.serviceAccountKeyPath.lisa-google-play account <packageName>.Fail closed if no credential is available. Never prompt indefinitely and never fall back to Play Console UI scraping.
For operation: setup:
Confirm the project is an Expo app by checking for app.json,
app.config.json, app.config.js, or app.config.ts.
Resolve or ask the operator to provide the Android package name. Prefer
.lisa.config.json playStore.packageName, then expo.android.package from
app config.
In Google Cloud Console, create a service account dedicated to read-only Play release visibility and create a JSON key for it.
In Play Console, open Setup -> API access, link the Google Cloud project if needed, then grant the service-account email app access with read-level release visibility. Do not grant release-management write permissions for v1.
Store the key locally using one of the credential locations above. If using a
path, add the path to .gitignore and record only the path in
.lisa.config.local.json.
Record non-secret defaults in .lisa.config.json:
jq '.playStore.packageName = "com.example.app" | .playStore.track = "production"' \
.lisa.config.json > /tmp/lisa-config.json && mv /tmp/lisa-config.json .lisa.config.json
The key material itself must never be committed.
Resolution order:
Explicit package:<value> argument.
.lisa.config.json playStore.packageName.
app.json / app.config.json expo.android.package.
app.config.ts / app.config.js by running Expo config export:
npx expo config --json | jq -r '.android.package // empty'
If the package still cannot be resolved, fail with:
Error: Google Play package name is not configured. Set playStore.packageName in .lisa.config.json or pass package:<name>.
The androidpublisher API requires an edit even for track reads. Create the edit, list the track, then delete the edit. Do not commit the edit.
PACKAGE_NAME="${PACKAGE_NAME:-$(jq -r '.playStore.packageName // empty' .lisa.config.json)}"
TRACK="${TRACK:-$(jq -r '.playStore.track // "production"' .lisa.config.json)}"
KEY_PATH="$(jq -r '.playStore.serviceAccountKeyPath // empty' .lisa.config.local.json 2>/dev/null)"
read_service_account_json() {
if [ -n "${GOOGLE_PLAY_SERVICE_ACCOUNT_JSON:-}" ]; then
printf '%s' "$GOOGLE_PLAY_SERVICE_ACCOUNT_JSON"
return
fi
if [ -n "${GOOGLE_PLAY_SERVICE_ACCOUNT_KEY_BASE64:-}" ]; then
printf '%s' "$GOOGLE_PLAY_SERVICE_ACCOUNT_KEY_BASE64" | base64 --decode 2>/dev/null ||
printf '%s' "$GOOGLE_PLAY_SERVICE_ACCOUNT_KEY_BASE64" | base64 -D
return
fi
if [ -n "$KEY_PATH" ] && [ -f "$KEY_PATH" ]; then
cat "$KEY_PATH"
return
fi
if [ "$(uname -s)" = "Darwin" ] && [ -n "$PACKAGE_NAME" ]; then
security find-generic-password -s lisa-google-play -a "$PACKAGE_NAME" -w 2>/dev/null
return
fi
}
SERVICE_ACCOUNT_JSON="$(read_service_account_json)"
[ -n "$SERVICE_ACCOUNT_JSON" ] || {
echo "Error: no Google Play service-account key found. Run play-store-access operation: setup." >&2
exit 1
}
[ -n "$PACKAGE_NAME" ] || {
echo "Error: Google Play package name is not configured. Set playStore.packageName or pass package:<name>." >&2
exit 1
}
ACCESS_TOKEN="$(
SERVICE_ACCOUNT_JSON="$SERVICE_ACCOUNT_JSON" node <<'NODE'
const crypto = require("crypto");
const account = JSON.parse(process.env.SERVICE_ACCOUNT_JSON);
const now = Math.floor(Date.now() / 1000);
const encode = (value) =>
Buffer.from(JSON.stringify(value)).toString("base64url");
const unsigned = `${encode({ alg: "RS256", typ: "JWT" })}.${encode({
iss: account.client_email,
scope: "https://www.googleapis.com/auth/androidpublisher",
aud: "https://oauth2.googleapis.com/token",
iat: now,
exp: now + 3600,
})}`;
const signature = crypto
.createSign("RSA-SHA256")
.update(unsigned)
.sign(account.private_key, "base64url");
fetch("https://oauth2.googleapis.com/token", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer",
assertion: `${unsigned}.${signature}`,
}),
})
.then(async (response) => {
const body = await response.json();
if (!response.ok) {
throw new Error(JSON.stringify(body));
}
process.stdout.write(body.access_token);
})
.catch((error) => {
console.error(`Error: failed to obtain Google Play access token: ${error.message}`);
process.exit(1);
});
NODE
)"
BASE="https://androidpublisher.googleapis.com/androidpublisher/v3/applications/${PACKAGE_NAME}"
EDIT_ID="$(curl -fsS -X POST "${BASE}/edits" -H "Authorization: Bearer ${ACCESS_TOKEN}" | jq -r '.id')"
trap 'curl -fsS -X DELETE "${BASE}/edits/${EDIT_ID}" -H "Authorization: Bearer ${ACCESS_TOKEN}" >/dev/null 2>&1 || true' EXIT
curl -fsS "${BASE}/edits/${EDIT_ID}/tracks/${TRACK}" \
-H "Authorization: Bearer ${ACCESS_TOKEN}" |
jq --arg packageName "$PACKAGE_NAME" \
'{packageName: $packageName, track: .track, releases: (.releases // [])}'
Interpretation:
status: completed means the release is live on that track.status: inProgress means staged rollout; inspect userFraction.status: halted means rollout was stopped.status: draft means it is not live.Known limitation: androidpublisher exposes track/release state well, but pre-publication review state is coarse. The reliable watchable signal is whether the target version is live on the production track.
401 / token exchange failure: service-account key is invalid or expired.403: service account is not invited to the Play Console app or lacks read
access to releases.404: package name is wrong, the app is not created in Play Console, or the
service account has no access to that app.In all cases, report the package name and credential source used, but never print private key material or the access token.
development
Prepare a machine — a fresh laptop or a throwaway container — to run coding agents, before any repository exists. Detects which of Lisa's supported agents (Claude Code, Codex, Cursor, OpenCode, Antigravity, Copilot) are already installed, asks which credential manager the machine uses (Bitwarden, 1Password, Doppler, Vault, AWS, or none), and installs only what is missing, each by its vendor's own preferred method. Idempotent, headless by default, and emits a Dockerfile for a spin-up/spin-down environment. Run it on a new machine, in a container, or before cloning anything.
tools
Provision and verify a remote execution environment for a host project — Codex Cloud today, other remote surfaces as they are added. Generates a repository-owned setup script that installs the declared toolchain, materializes secrets through lisa-secrets-access, and runs the project's own hook. Provisions by API where one exists, by driving the vendor console where one does not, and by emitting exact config otherwise — then proves the result with the same read-back regardless of which tier did the work. Use before dispatching any work with executionEnv.
tools
Bring a developer's machine in line with the toolchain the project declares. Reports every tool in remoteEnv.tools that is missing, outdated, or unpinned for this platform, and installs the missing ones into ~/.local/bin from the same pinned, checksummed entries the remote surfaces use — but only when asked. Same manifest, same pins, same installers as lisa-setup-remote-env; what differs is consent and that the pin is a floor rather than an equality. Run it on a fresh checkout, after a manifest change, or when a tool fails at the moment of use.
tools
Route one unit of work to a remote execution surface. Reads the executionEnv parameter (local by default, codex-cloud or claude-web today), verifies the environment is provisioned and bound to this repository, submits a thin skill invocation, records the task identifier to .lisa/remote-dispatch.json, and exits without polling. Routing only — the remote runs the identical skill from the identical repository. Composable and inline: other skills invoke it via the Skill tool rather than users calling it directly.