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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
## 1.14.0

Adds the ability to replace a Push Notification you already sent with a new one.

## 1.13.0

Adds custom icons and interruption levels for Push Notifications. Set `--icon` to show a service logo or avatar on the notification, and `--interruption-level` to deliver it as `passive`, `active`, or `time-sensitive`.
Expand Down
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,22 @@ activitysmith push \
--interruption-level "time-sensitive"
```

### Replace a Push Notification

Every `push` prints a `Notification ID`. Pass it to `--replace` to update a Push Notification you already sent, for example when an incident you alerted on is resolved. The replacement takes the old notification's place on the Lock Screen and in history instead of stacking a second one. It arrives silently, without sound or waking the screen, and goes to the same recipients as the original, so `--channels` and `--interruption-level` don't apply.

```bash
activitysmith push \
--title "🚨 checkout-api is down" \
--message "500 errors on 62% of requests."
# Notification ID: Xk3pQ8mV2nR7tL9wB4yZ

activitysmith push \
--replace "Xk3pQ8mV2nR7tL9wB4yZ" \
--title "✅ checkout-api recovered" \
--message "Error rate is back to normal. Incident resolved."
```

## Live Activities

Choose the Live Activity type that matches what you want to show:
Expand Down
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "activitysmith-cli",
"version": "1.13.0",
"version": "1.14.0",
"description": "Command-line interface for ActivitySmith. Send Push Notifications, start, update, and end Live Activities, and set App Icon Badge Counts from your terminal.",
"keywords": [
"activitysmith",
Expand Down Expand Up @@ -42,7 +42,7 @@
"test": "node --test"
},
"dependencies": {
"activitysmith": "^1.13.0",
"activitysmith": "^1.14.0",
"commander": "^12.1.0"
}
}
17 changes: 17 additions & 0 deletions skills/activitysmith/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ export ACTIVITYSMITH_API_KEY="..."
## Pick The Right Signal

- Push Notification: one important event, completion, blocker, review request, or handoff.
- Push Notification replacement: an earlier Push Notification is out of date and should be swapped, not stacked.
- Push Notification with redirection: tapping the notification should open a URL or run a specific Shortcut that already exists on the user's iPhone.
- Push Notification with actions: long-press should show up to 4 buttons.
- Live Activity: a real-time metric, operational state, or ongoing task should stay visible.
Expand Down Expand Up @@ -65,6 +66,22 @@ Use this when the user says "notify me when done" or when a long task completes.
-m "Your agent finished the task."
```

### Replace A Notification When The Status Changes

Use this to update a Push Notification you already sent, for example when an incident you alerted on is resolved. Every push prints a `Notification ID`. Pass it with `-R` to swap the old notification for the new one on the Lock Screen and in history. The replacement is silent and goes to the same recipients.

```bash
./skills/activitysmith/scripts/send_push.sh \
-t "🚨 checkout-api is down" \
-m "500 errors on 62% of requests."
# Output includes: Notification ID: Xk3pQ8mV2nR7tL9wB4yZ

./skills/activitysmith/scripts/send_push.sh \
-R "Xk3pQ8mV2nR7tL9wB4yZ" \
-t "✅ checkout-api recovered" \
-m "Error rate is back to normal. Incident resolved."
```

### Notify With A Shortcut Tap Target

Use this when tapping the notification should run a specific Shortcut that already exists on the user's iPhone.
Expand Down
17 changes: 15 additions & 2 deletions skills/activitysmith/scripts/send_push.sh
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ set -euo pipefail

usage() {
cat <<'USAGE'
Usage: send_push.sh -m "message" [-t "title"] [-s "subtitle"] [-c "channels"] [-g "tags"] [-M "media"] [-r "redirection"] [-a "actions-json"] [-A "actions-file"]
Usage: send_push.sh -m "message" [-t "title"] [-s "subtitle"] [-c "channels"] [-g "tags"] [-M "media"] [-r "redirection"] [-a "actions-json"] [-A "actions-file"] [-R "notification-id"]

Required:
-m Push message
Expand All @@ -17,6 +17,8 @@ Optional:
-r Redirection HTTPS or shortcuts:// URL (opened on notification tap)
-a Actions JSON array string (max 4 actions)
-A Path to actions JSON array file
-R Notification ID to silently replace instead of sending a new push
(cannot be combined with -c)
USAGE
}

Expand All @@ -29,8 +31,9 @@ media=""
redirection=""
actions_json=""
actions_file=""
replace_id=""

while getopts ":m:t:s:c:g:M:r:a:A:h" opt; do
while getopts ":m:t:s:c:g:M:r:a:A:R:h" opt; do
case "$opt" in
m) message="$OPTARG" ;;
t) title="$OPTARG" ;;
Expand All @@ -41,6 +44,7 @@ while getopts ":m:t:s:c:g:M:r:a:A:h" opt; do
r) redirection="$OPTARG" ;;
a) actions_json="$OPTARG" ;;
A) actions_file="$OPTARG" ;;
R) replace_id="$OPTARG" ;;
h) usage; exit 0 ;;
\?) echo "Unknown option: -$OPTARG"; usage; exit 1 ;;
:) echo "Missing value for -$OPTARG"; usage; exit 1 ;;
Expand All @@ -65,13 +69,22 @@ if [[ -n "$media" && ( -n "$actions_json" || -n "$actions_file" ) ]]; then
exit 1
fi

if [[ -n "$replace_id" && -n "$channels" ]]; then
echo "Replacements go to the original recipients; -c cannot be combined with -R."
usage
exit 1
fi

# shellcheck disable=SC1091
source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/common.sh"
load_env_if_present
require_api_key
require_cli

cmd=(push --title "$title" --message "$message")
if [[ -n "$replace_id" ]]; then
cmd+=(--replace "$replace_id")
fi
if [[ -n "$subtitle" ]]; then
cmd+=(--subtitle "$subtitle")
fi
Expand Down
34 changes: 27 additions & 7 deletions src/cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -1257,8 +1257,12 @@ const outputResult = (payload, options, lines) => {

program
.command("push")
.description("Send a push notification")
.description("Send a push notification, or replace one you already sent")
.requiredOption("--title <title>", "Push title")
.option(
"--replace <notification-id>",
"Silently replace a sent push notification, using the notification ID from its send output"
)
.option("--message <message>", "Push message")
.option("--subtitle <subtitle>", "Push subtitle")
.option(
Expand Down Expand Up @@ -1299,6 +1303,16 @@ program
media: options.media,
actions,
});
if (options.replace !== undefined) {
if (options.channels !== undefined || options.interruptionLevel !== undefined) {
throw new Error(
"--channels and --interruption-level cannot be combined with --replace. Replacements go to the original recipients and arrive silently."
);
}
if (options.replace.trim() === "") {
throw new Error("--replace requires the notification ID from the original push output.");
}
}

const pushNotificationRequest = withTargetChannels(
{
Expand All @@ -1325,18 +1339,24 @@ program
options.channels
);

const response = await client.notifications.sendPushNotification({
pushNotificationRequest,
});
const isReplacement = options.replace !== undefined;
const response = isReplacement
? await client.notifications.replace(options.replace.trim(), pushNotificationRequest)
: await client.notifications.sendPushNotification({
pushNotificationRequest,
});
const devicesNotified = response?.devices_notified ?? response?.devicesNotified;
const notificationId = response?.notification_id ?? response?.notificationId;

outputResult(response, globalOptions, [
"Push sent.",
isReplacement ? "Push replaced." : "Push sent.",
response?.success !== undefined
? `Success: ${response.success}`
: null,
response?.devicesNotified !== undefined
? `Devices notified: ${response.devicesNotified}`
devicesNotified !== undefined
? `Devices notified: ${devicesNotified}`
: null,
notificationId ? `Notification ID: ${notificationId}` : null,
]);
} catch (error) {
await handleError(error, globalOptions);
Expand Down
33 changes: 33 additions & 0 deletions test/cli.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,14 @@ const runCli = (args) =>
globalThis.fetch = async (url, init) => {
process.stdout.write("CAPTURE:" + JSON.stringify({
url,
method: init.method,
body: init.body ? JSON.parse(init.body) : null
}) + "\\n");
return new Response(JSON.stringify({
success: true,
activity_id: "activity-1",
notification_id: "pn-1",
devices_notified: 1,
operation: "started"
}), {
status: 200,
Expand Down Expand Up @@ -356,3 +359,33 @@ test('push rejects invalid interruption levels and non-HTTPS icons before transp
assert.equal(result.request, null);
}
});

test("push --replace silently replaces a sent push notification", async () => {
const result = await runCli([
"push",
"--replace",
"pn-1",
"--title",
"Answered from desk",
"--message",
"No input needed",
]);

assert.equal(result.code, 0, result.stderr);
assert.equal(result.request.method, "PUT");
assert.match(result.request.url, /\/push-notification\/pn-1$/);
assert.deepEqual(result.request.body, {
title: "Answered from desk",
message: "No input needed",
});
});

test("push --replace rejects options that only apply to new sends", async () => {
for (const extra of [["--channels", "ops"], ["--interruption-level", "passive"]]) {
const result = await runCli(["push", "--replace", "pn-1", "--title", "Done", ...extra]);

assert.notEqual(result.code, 0);
assert.match(result.stdout + result.stderr, /cannot be combined with --replace/);
assert.equal(result.request, null);
}
});
Loading