Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json",
"name": "iterable-sdk",
"version": "26.10.9-beta",
"version": "26.10.10-beta",
"description": "Takes an Android or iOS app — native or React Native — from nothing to a push notification proven on a device: Firebase or Apple (APNs) provisioning, version-pinned SDK integration, silent-failure pitfalls, and device-side proof that a push actually arrived. Android proof runs on a device or emulator; iOS proof runs on the iOS Simulator.",
"author": { "name": "Iterable" },
"repository": "https://github.com/Iterable/iterable-sdk-skill",
Expand Down
2 changes: 1 addition & 1 deletion .cursor-plugin/marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"owner": { "name": "Iterable" },
"metadata": {
"description": "Iterable's official skills for AI coding assistants — mobile SDK integration guidance.",
"version": "26.10.9-beta"
"version": "26.10.10-beta"
},
"plugins": [
{
Expand Down
2 changes: 1 addition & 1 deletion .cursor-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "iterable-sdk",
"version": "26.10.9-beta",
"version": "26.10.10-beta",
"description": "Takes an Android or iOS app — native or React Native — from nothing to a push notification proven on a device: Firebase or Apple (APNs) provisioning, version-pinned SDK integration, silent-failure pitfalls, and device-side proof that a push actually arrived. Android proof runs on a device or emulator; iOS proof runs on the iOS Simulator.",
"author": { "name": "Iterable" },
"repository": "https://github.com/Iterable/iterable-sdk-skill",
Expand Down
3 changes: 2 additions & 1 deletion INTENDED-FLOW.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,8 @@ Numbered because the auditor reports divergence by waypoint.
**W1 — Scope.** First prompt, the agent establishes what the developer wants integrated and
confirms it. On Android it is recorded, not remembered: `agent preflight` returns `scope`
ahead of provisioning and code until `agent question scope` has put the features, the
identity and any existing-push choice to the developer and their picks are on disk.
identity, any existing-push choice, the data region, and the JWT policy to the developer
and their picks are on disk (the same region and JWT questions native iOS asks).
An agent that enters through provisioning meets the same gate: `agent question opening`
prints the scope screen, and a status read reports `scope`, until those picks are on disk.

Expand Down
39 changes: 36 additions & 3 deletions bin/agent
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,7 @@ case "${1:-status}" in
# Only the scope screens' options carry these, one answer per call.
scope)
shift
extra_env=""
case "${1:-}" in
feature)
keys=SCOPE_FEATURES
Expand All @@ -221,13 +222,45 @@ case "${1:-status}" in
keys=SCOPE_PUSH v="${2:-}"
[[ "$v" == keep-both || "$v" == iterable-only ]] || {
printf '{"error":%s}\n' "$(jstr "the app's own push is keep-both or iterable-only; got: '$v'")"; exit 30; } ;;
clear) keys="SCOPE_FEATURES SCOPE_IDENTITY SCOPE_PUSH" v="" ;;
*) printf '{"error":%s}\n' "$(jstr "usage: agent scope feature <ids> | identity email|user-id|unknown-user | existing-push keep-both|iterable-only | clear")"; exit 30 ;;
region)
keys=SCOPE_REGION v="${2:-}"
[[ "$v" == US || "$v" == EU ]] || {
printf '{"error":%s}\n' "$(jstr "the region is US or EU; got: '$v'")"; exit 30; }
if [[ "$v" == EU ]]; then extra_env="ITBL_BASE=https://api.eu.iterable.com"
else extra_env="ITBL_BASE="; fi ;;
jwt)
keys="SCOPE_JWT SCOPE_JWT_CHECK" v="${2:-}"
[[ "$v" == true || "$v" == false ]] || {
printf '{"error":%s}\n' "$(jstr "JWT is true or false; got: '$v'")"; exit 30; }
# jwt-check is only a waypoint; a recorded answer clears it.
[[ "$v" == true ]] && v_jwt=true || v_jwt=false
v="$v_jwt"
v_check="" ;;
jwt-check)
keys="SCOPE_JWT_CHECK SCOPE_JWT" v_check=true v="" ;;
jwt-check-clear)
keys=SCOPE_JWT_CHECK v="" ;;
clear)
keys="SCOPE_FEATURES SCOPE_IDENTITY SCOPE_PUSH SCOPE_REGION SCOPE_JWT SCOPE_JWT_CHECK" v="" ;;
*) printf '{"error":%s}\n' "$(jstr "usage: agent scope feature <ids> | identity email|user-id|unknown-user | existing-push keep-both|iterable-only | region US|EU | jwt true|false | jwt-check | jwt-check-clear | clear")"; exit 30 ;;
esac
for k in $keys; do
save_resolved "$k" "$v" && printf -v "$k" '%s' "$v" ||
kv="$v"
[[ "$k" == SCOPE_JWT_CHECK ]] && kv="${v_check-}"
save_resolved "$k" "$kv" && printf -v "$k" '%s' "$kv" ||
{ printf '{"error":%s}\n' "$(jstr "the scope was not saved: could not write to $WS")"; exit 30; }
done
if [[ -n "$extra_env" ]]; then
ek="${extra_env%%=*}"; ev="${extra_env#*=}"
if [[ -n "$ev" ]]; then
save_env "$ek" "$ev" || {
printf '{"error":%s}\n' "$(jstr "the region was not saved: could not write to $ITBL_ENV")"; exit 30; }
elif [[ -f "$ITBL_ENV" ]]; then
grep -v "^${ek}=" "$ITBL_ENV" > "$ITBL_ENV.tmp" 2>/dev/null || true
mv "$ITBL_ENV.tmp" "$ITBL_ENV"
fi
printf -v "$ek" '%s' "$ev"
fi
M="$(scope_missing)"
printf '{"recorded":%s,"file":%s,"next":{"command":%s}}\n' "$(scope_json "$M")" "$(jstr "$WS/resolved.env")" \
"$(jstr "$BIN/agent $([[ -n "$M" ]] && printf 'question scope' || printf preflight)")"
Expand Down
21 changes: 15 additions & 6 deletions bin/ios-provision
Original file line number Diff line number Diff line change
Expand Up @@ -3704,14 +3704,22 @@ import_from_android() {
die "no Android setup with a Mobile key was found at $(android_env_path)"
require_ios_next "import-from-android" import_android
local mobile server base email user_id region delivery existing server_lines="" android_env
local android_ws jwt_enabled=false scope_jwt="" scope_region=""
android_env="$(android_env_path)"
android_ws="$(dirname "$android_env")"
mobile="$(android_env_value ITBL_MOBILE_KEY)"
server="$(android_env_value ITBL_SERVER_KEY)"
base="$(android_env_value ITBL_BASE)"
email="$(android_env_value ITBL_EMAIL)"
user_id="$(android_env_value ITBL_USER_ID)"
region=US
[[ "$base" != https://api.eu.iterable.com ]] || region=EU
if [[ -f "$android_ws/resolved.env" ]]; then
scope_region="$(grep '^SCOPE_REGION=' "$android_ws/resolved.env" 2>/dev/null | tail -1 | cut -d= -f2-)"
scope_jwt="$(grep '^SCOPE_JWT=' "$android_ws/resolved.env" 2>/dev/null | tail -1 | cut -d= -f2-)"
[[ "$scope_region" == US || "$scope_region" == EU ]] && region="$scope_region"
[[ "$scope_jwt" == true ]] && jwt_enabled=true
fi
[[ "$mobile" =~ ^[A-Za-z0-9._~+/=-]{20,}$ ]] ||
die "the Android Mobile key in $(android_env_path) does not look like an Iterable API key"
if [[ -n "$server" && ! "$server" =~ ^[A-Za-z0-9._~+/=-]{20,}$ ]]; then
Expand All @@ -3728,7 +3736,7 @@ import_from_android() {
[[ "$delivery" != expo-env ]] || expo_env_ignored "$(app_root)" || deliver=later
[[ -z "$server" ]] ||
server_lines="$(printf '# Read-only verification credential; never linked into the app.\nITERABLE_SERVER_KEY=%s' "$server")"
write_mobile_env "$mobile" "$region" false "$delivery" "$server_lines" import-from-android "$deliver"
write_mobile_env "$mobile" "$region" "$jwt_enabled" "$delivery" "$server_lines" import-from-android "$deliver"
printf 'ITERABLE_IMPORTED_FROM=%s\n' "$android_env" >> "$MOBILE_ENV"
rm -f "$MOBILE_VALIDATION" "$SERVER_VALIDATION"
unset mobile server server_lines
Expand All @@ -3740,20 +3748,21 @@ import_from_android() {
(
umask 077
node -e '
const [path, current, region, email, source, userId] = process.argv.slice(1);
const [path, current, region, jwtEnabled, email, source, userId] = process.argv.slice(1);
const value = JSON.parse(current);
value.android_import = "imported";
value.android_import_path = source;
value.data_region = region;
value.jwt_enabled = false;
value.jwt_enabled = jwtEnabled === "true";
if (email && !value.identity_mode) value.identity_mode = "email";
else if (userId && !value.identity_mode) value.identity_mode = "user-id";
require("fs").writeFileSync(path, JSON.stringify(value, null, 2) + "\n", { mode: 0o600 });
' "$DISCOVERY_META" "$current" "$region" "$email" "$android_env" "$user_id"
' "$DISCOVERY_META" "$current" "$region" "$jwt_enabled" "$email" "$android_env" "$user_id"
)
chmod 600 "$DISCOVERY_META"
printf 'Reused the Android setup from %s: the Mobile%s key, region %s, and No JWT. Both keys are validated again for iOS later; nothing was printed.\n' \
"$android_env" "$([[ -n "$(env_value ITERABLE_SERVER_KEY)" ]] && printf ' and Server-side')" "$region" >&2
printf 'Reused the Android setup from %s: the Mobile%s key, region %s, and JWT %s. Both keys are validated again for iOS later; nothing was printed.\n' \
"$android_env" "$([[ -n "$(env_value ITERABLE_SERVER_KEY)" ]] && printf ' and Server-side')" "$region" \
"$([[ "$jwt_enabled" == true ]] && printf required || printf 'not required')" >&2
[[ -z "$carried" ]] ||
printf 'Proposed the feature scope confirmed in the %s: %s. It is recorded only after the confirmation screen.\n' \
"$(ios_q_step0_skill "$(step0_source)")" "$carried" >&2
Expand Down
110 changes: 107 additions & 3 deletions bin/questions.sh
Original file line number Diff line number Diff line change
Expand Up @@ -153,12 +153,24 @@ scope_missing() {
elif [[ -z "${SCOPE_IDENTITY:-}" ]]; then echo identity
elif [[ -z "${SCOPE_PUSH:-}" && ",$SCOPE_FEATURES," == *,push-notifications,* && -n "$(own_push)" ]]; then
echo existing-push
elif [[ -z "${SCOPE_REGION:-}" ]]; then echo region
elif [[ -z "${SCOPE_JWT:-}" && "${SCOPE_JWT_CHECK:-}" == true ]]; then echo jwt-check
elif [[ -z "${SCOPE_JWT:-}" ]]; then echo jwt
fi
}

scope_jwt_keys_url() {
if [[ "${SCOPE_REGION:-}" == EU ]]; then
printf 'https://app.eu.iterable.com/settings/apiKeys'
else
printf 'https://app.iterable.com/settings/apiKeys'
fi
}

scope_json() { # <missing>
printf '{"features":%s,"identity":%s,"existing_push":%s,"missing":%s}' \
printf '{"features":%s,"identity":%s,"existing_push":%s,"region":%s,"jwt":%s,"missing":%s}' \
"$(jstr "${SCOPE_FEATURES:-}")" "$(jstr "${SCOPE_IDENTITY:-}")" "$(jstr "${SCOPE_PUSH:-}")" \
"$(jstr "${SCOPE_REGION:-}")" "$(jstr "${SCOPE_JWT:-}")" \
"$([[ -n "$1" ]] && jstr "$1" || printf null)"
}

Expand All @@ -167,6 +179,9 @@ q_scope() {
features) q_scope_features ;;
identity) q_scope_identity ;;
existing-push) q_scope_push ;;
region) q_scope_region ;;
jwt) q_scope_jwt ;;
jwt-check) q_scope_jwt_check ;;
*) q_scope_recorded ;;
esac
}
Expand Down Expand Up @@ -299,15 +314,104 @@ EOF
printf ']}'
}

q_scope_region() {
printf '{"header":"Region","selection":"single","prompt":%s,' "$(jstr "Which Iterable project region does this app use?")"
printf '"context":%s,' "$(q_arr <<'EOF'
The Mobile API key and SDK data region must match the selected Iterable project. A US key cannot be treated as an EU key, or the reverse.
EOF
)"
printf '"requires":[],"recommended":null,"options":['
q_opt "US project" \
"Use the standard Iterable US API endpoints." \
"SCOPE_REGION=US recorded" "" \
"$BIN/agent scope region US"
printf ','
q_opt "EU project" \
"Use the EU Iterable API host for server-side calls and the EU project key." \
"SCOPE_REGION=EU recorded; ITBL_BASE set to api.eu.iterable.com" "" \
"$BIN/agent scope region EU"
printf ','
q_opt "Not sure — help me find out" \
"Shows how to read the region from the project address, and what to do with no project yet." \
"nothing recorded; open european-data-center and android-sdk Step 5.2 in the skill, then ask this screen again" ""
printf ']}'
}

q_scope_jwt() {
printf '{"header":"JWT","selection":"single","prompt":%s,' "$(jstr "Unless your app already has a backend that issues user-specific Iterable JWTs, choose No JWT. Should the Mobile key you create or pick in a later step require JWT?")"
printf '"context":%s,' "$(q_arr <<'EOF'
You do not need a Mobile API key yet. A later step creates one in Iterable, or picks the one you already have, and its Require JWT setting must match this answer.

Choose JWT only when the app already runs a backend that issues user-specific Iterable JWTs. The app then asks that backend for each token and never mints one itself. A new app without such a backend should choose No JWT.
EOF
)"
printf '"requires":[],"recommended":%s,"options":[' "$(jstr "No JWT — new app or no JWT backend")"
q_opt "No JWT — new app or no JWT backend" \
"Records No JWT, right for an app without a JWT backend. The Iterable walk creates the Mobile key with Require JWT Off." \
"SCOPE_JWT=false recorded" "" \
"$BIN/agent scope jwt false"
printf ','
q_opt "I already have a Mobile key — check its setting" \
"Shows where to read the existing key's Require JWT setting, then you answer from what it shows." \
"opens the JWT check screen; nothing recorded yet" "" \
"$BIN/agent scope jwt-check"
printf ','
q_opt "Not sure — help me decide" \
"Explains the one fact that decides it; for most apps the answer is No JWT." \
"nothing recorded; open jwt-enabled-api-keys in the skill, then ask this screen again" ""
printf ','
q_opt "JWT required — we run a JWT backend" \
"Only when a backend already issues user-specific Iterable JWTs; the integration adds an auth handler that calls it." \
"SCOPE_JWT=true recorded" "" \
"$BIN/agent scope jwt true"
printf ']}'
}

q_scope_jwt_check() {
local url; url="$(scope_jwt_keys_url)"
printf '{"header":"JWT check","selection":"single","prompt":%s,' "$(jstr "Read the setting on the Mobile key this app will use:
1. Open $url (or go to Integrations > API Keys).
2. Find the key whose type is Mobile. If there are several, use the one this app will ship with.
3. Read its JWT column, or open the key and read Require JWT.
4. Answer below with what it shows.

In Iterable → Integrations → API Keys, read your Mobile key's Require JWT setting — is it on or off?")"
printf '"context":%s,' "$(q_arr <<'EOF'
Nothing is copied here; the key itself is captured securely in a later step. If there is no Mobile key, go back and choose for the new one.
EOF
)"
printf '"requires":[],"recommended":null,"options":['
q_opt "Require JWT is off" \
"The existing Mobile key does not require JWT." \
"SCOPE_JWT=false recorded" "" \
"$BIN/agent scope jwt false"
printf ','
q_opt "Require JWT is on" \
"The existing Mobile key requires JWT; the integration must add an IterableAuthHandler backed by your backend." \
"SCOPE_JWT=true recorded" "" \
"$BIN/agent scope jwt true"
printf ','
q_opt "Show the JWT question again" \
"Returns to the JWT screen without recording an answer yet." \
"SCOPE_JWT_CHECK cleared" "" \
"$BIN/agent scope jwt-check-clear"
printf ']}'
}

q_scope_recorded() {
local jwt_label="not recorded"
[[ "${SCOPE_JWT:-}" == true ]] && jwt_label="required"
[[ "${SCOPE_JWT:-}" == false ]] && jwt_label="not required"
printf '{"header":"Scope","selection":"single","prompt":%s,' "$(jstr "Recorded for this app:
· Features: ${SCOPE_FEATURES//,/, }
· Identity: $SCOPE_IDENTITY
· The app's own push: ${SCOPE_PUSH:-not asked, because none was found or push is not in scope}
· Region: ${SCOPE_REGION:-not recorded}
· JWT: $jwt_label

Keep these answers?")"
printf '"context":%s,' "$(q_arr <<'EOF'
Changing them clears all three, and the questions are asked again from the first.
Changing them clears every recorded scope answer, and the questions are asked again from the first.
EOF
)"
printf '"requires":[],"recommended":%s,"options":[' "$(jstr "Keep these answers")"
Expand All @@ -318,7 +422,7 @@ EOF
printf ','
q_opt "Change the answers" \
"Clears the recorded answers, and the features screen comes back." \
"SCOPE_FEATURES, SCOPE_IDENTITY and SCOPE_PUSH cleared" "" \
"all scope fields cleared" "" \
"$BIN/agent scope clear"
printf ']}'
}
Expand Down
12 changes: 7 additions & 5 deletions iterable-android/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,11 +49,13 @@ human readers but omit several silent-failure traps documented in
`<root>/bin/agent preflight` returns `scope`, ahead of provisioning and code,
until the developer has answered these. `<root>/bin/agent question scope` asks
them one screen at a time: the features, the identity (project type first),
and, when the app already handles push itself, whether to keep it. Each
option's command records its answer in `.iterable/resolved.env`; preflight's
`scope` field reports them, and `question scope` offers to change them once all
are in. Never pick an option for the developer. Prerequisites are preflight's
to find: never ask for a key in the chat.
when the app already handles push itself whether to keep it, the Iterable
project region (US or EU), and whether the Mobile key requires JWT (same
screens as native iOS provisioning). Each option's command records its answer
in `.iterable/resolved.env`; EU also records `ITBL_BASE` in `.iterable/.env`.
Preflight's `scope` field reports them, and `question scope` offers to change
them once all are in. Never pick an option for the developer. Prerequisites are
preflight's to find: never ask for a key in the chat.

Do **not** assume the developer wants every feature, and do **not** start
implementing on a guessed scope.
Expand Down
12 changes: 7 additions & 5 deletions iterable-react-native/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,11 +68,13 @@ cost the developer a permission prompt.
`<root>/bin/agent preflight` returns `scope`, ahead of provisioning and code,
until the developer has answered these. `<root>/bin/agent question scope` asks
them one screen at a time: the features, the identity (project type first),
and, when the app already handles push itself, whether to keep it. Each
option's command records its answer in `.iterable/resolved.env`; preflight's
`scope` field reports them, and `question scope` offers to change them once all
are in. Never pick an option for the developer. Prerequisites are preflight's
to find: never ask for a key in the chat.
when the app already handles push itself whether to keep it, the Iterable
project region (US or EU), and whether the Mobile key requires JWT (same
screens as native iOS provisioning). Each option's command records its answer
in `.iterable/resolved.env`; EU also records `ITBL_BASE` in `.iterable/.env`.
Preflight's `scope` field reports them, and `question scope` offers to change
them once all are in. Never pick an option for the developer. Prerequisites are
preflight's to find: never ask for a key in the chat.

**Detect the workflow from the project, do not ask which docs to use.**

Expand Down
5 changes: 4 additions & 1 deletion tests/agent-preflight.sh
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,10 @@ echo " bin/agent preflight — local files only, before any consent"
echo

# The Step 0 answers, as `agent scope` records them; tests/agent-scope.sh covers that gate.
scoped() { mkdir -p "$1/.iterable"; printf 'SCOPE_FEATURES=push-notifications,sdk-initialization\nSCOPE_IDENTITY=email\n' >> "$1/.iterable/resolved.env"; }
scoped() {
mkdir -p "$1/.iterable"
printf 'SCOPE_FEATURES=push-notifications,sdk-initialization\nSCOPE_IDENTITY=email\nSCOPE_REGION=US\nSCOPE_JWT=false\n' >> "$1/.iterable/resolved.env"
}

A="$TMP/fresh"; new_app "$A"
OUT="$(pf "$A")"
Expand Down
2 changes: 1 addition & 1 deletion tests/agent-questions.sh
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
set -uo pipefail
# Step 0 is recorded: these checks are about the setup screens, which the scope gate
# holds back until it is (tests/agent-scope.sh covers that gate).
export SCOPE_FEATURES=push-notifications,sdk-initialization SCOPE_IDENTITY=email SCOPE_PUSH=keep-both
export SCOPE_FEATURES=push-notifications,sdk-initialization SCOPE_IDENTITY=email SCOPE_PUSH=keep-both SCOPE_REGION=US SCOPE_JWT=false
cd "$(dirname "$0")/.."

FAILED=0
Expand Down
Loading
Loading