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.18.0

Adds `activitysmith push dismiss` to clear a Push Notification you already sent from the Lock Screen and Notification Center, using the Notification ID from its send output. History keeps it. Also adds `activitysmith metrics create` to create a Lock Screen Widget metric and set its label and format, such as currency or percent.

## 1.17.0

Adds approvals. Your agents can now ask for your approval before they act, such as sending an email or deploying. `activitysmith approval request` shows the request on your iPhone and waits for your answer. It exits with code 0 only when you approve, so the agent continues only after that. `approval wait`, `approval get`, and `approval cancel` work with a request you already sent.
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.17.0",
"version": "1.18.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.14.0",
"activitysmith": "^1.15.0",
"commander": "^12.1.0"
}
}
15 changes: 15 additions & 0 deletions skills/activitysmith/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ If it prints `No paired devices.`, give the user the App Store link it prints. A

- 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 dismissal: an earlier Push Notification no longer needs attention, such as a question the user already answered somewhere else.
- 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 @@ -95,6 +96,14 @@ Use this to update a Push Notification you already sent, for example when an inc
-m "Error rate is back to normal. Incident resolved."
```

### Dismiss A Notification That No Longer Needs Attention

Use this when a Push Notification you sent is no longer relevant, for example after you asked the user a question and they answered it in the terminal. Pass its `Notification ID` to clear it from the Lock Screen and Notification Center. History keeps it.

```bash
activitysmith push dismiss "Xk3pQ8mV2nR7tL9wB4yZ"
```

### 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 Expand Up @@ -223,6 +232,12 @@ String values work too:
activitysmith metrics update prod.status healthy
```

The first value creates the metric. To set its label and format, such as currency or percent, run `metrics create` first. It is safe to run again:

```bash
activitysmith metrics create mrr --label MRR --format currency --currency-code USD
```

### Store An API Key For CI

Use this when code that runs elsewhere, like a CI job, server, or cron job, should send Push Notifications or Live Activities. Pipe the key straight into the secret store so it never appears in chat or terminal output:
Expand Down
77 changes: 73 additions & 4 deletions src/cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -1282,10 +1282,11 @@ const outputResult = (payload, options, lines) => {
lines.filter(Boolean).forEach((line) => console.log(line));
};

program
const pushCommand = program
.command("push")
.description("Send a push notification, or replace one you already sent")
.requiredOption("--title <title>", "Push title")
.description("Send a push notification, or replace or dismiss one you already sent")
// Not requiredOption: commander would then demand --title for the dismiss subcommand too.
.option("--title <title>", "Push title (required to send or replace)")
.option(
"--replace <notification-id>",
"Silently replace a sent push notification, using the notification ID from its send output"
Expand Down Expand Up @@ -1322,6 +1323,10 @@ program
.action(async (options) => {
const globalOptions = program.opts();

if (options.title === undefined) {
pushCommand.error("error: required option '--title <title>' not specified");
}

try {
const apiKey = await requireApiKey(globalOptions);
const client = createClient(apiKey);
Expand Down Expand Up @@ -1390,6 +1395,35 @@ program
}
});

pushCommand
.command("dismiss")
.description(
"Clear a sent push notification from the Lock Screen and Notification Center. History keeps it."
)
.argument("<notification-id>", "Notification ID from the push send output")
.action(async (notificationId) => {
const globalOptions = program.opts();

try {
const id = String(notificationId).trim();
if (!id) {
throw new Error("Notification ID is required.");
}
const apiKey = await requireApiKey(globalOptions);
const client = createClient(apiKey);
const response = await client.notifications.dismiss(id);
const dismissedAt = response?.dismissed_at ?? response?.dismissedAt;

outputResult(response, globalOptions, [
"Push dismissed.",
`Notification ID: ${response?.notification_id ?? response?.notificationId ?? id}`,
dismissedAt ? `Dismissed at: ${dismissedAt}` : null,
]);
} catch (error) {
await handleError(error, globalOptions);
}
});

program
.command("badge")
.alias("badge-count")
Expand Down Expand Up @@ -1432,7 +1466,42 @@ program
const metricsCommand = program
.command("metrics")
.alias("metric")
.description("Update widget metric values");
.description("Create widget metrics and update their values");

metricsCommand
.command("create")
.description("Create a widget metric, or update the fields you pass for an existing one")
.argument("<metric-key>", "Metric key")
.option("--label <label>", "Name shown on the widget (defaults to a label made from the key)")
.option("--format <format>", "number, currency, percent, unit, or string (defaults to number)")
.option("--currency-code <code>", "3-letter ISO currency code, required for the currency format")
.option("--unit <unit>", "Unit suffix for the unit format, such as ms or GB")
.option("--unit-spacing <spacing>", "For the unit format: none (120ms) or space (120 ms)")
.action(async (metricKey, options) => {
const globalOptions = program.opts();

try {
const apiKey = await requireApiKey(globalOptions);
const client = createClient(apiKey);
const definition = Object.fromEntries(
Object.entries({
label: options.label,
format: options.format,
currency_code: options.currencyCode,
unit: options.unit,
unit_spacing: options.unitSpacing,
}).filter(([, value]) => value !== undefined)
);
const response = await client.metrics.create(
metricKey,
Object.keys(definition).length > 0 ? definition : undefined
);

outputResult(response, globalOptions, ["Metric saved."]);
} catch (error) {
await handleError(error, globalOptions);
}
});

metricsCommand
.command("update")
Expand Down
53 changes: 53 additions & 0 deletions test/cli.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -390,6 +390,27 @@ test("push --replace rejects options that only apply to new sends", async () =>
}
});

test("push dismiss clears a sent push notification", async () => {
const result = await runCli(["push", "dismiss", "pn-1"]);

assert.equal(result.code, 0, result.stderr);
assert.equal(result.request.method, "POST");
assert.match(result.request.url, /\/push-notification\/pn-1\/dismiss$/);
});

test("push dismiss needs a notification ID and no title", async () => {
const missing = await runCli(["push", "dismiss"]);
assert.notEqual(missing.code, 0);
assert.equal(missing.request, null);
});

test("push still requires a title when sending", async () => {
const result = await runCli(["push", "--message", "No title"]);
assert.notEqual(result.code, 0);
assert.match(result.stdout + result.stderr, /--title/);
assert.equal(result.request, null);
});

// Runs the CLI with a fake API that answers every request with `response` and records the requests.
const runCliWithApi = (args, { response, env = {} }) =>
new Promise((resolve, reject) => {
Expand Down Expand Up @@ -594,3 +615,35 @@ test("metrics update rejects Metadata options before sending", async () => {
assert.equal(result.request, null);
}
});

test("metrics create sends the metric definition", async () => {
const result = await runCli([
"metrics",
"create",
"mrr",
"--label",
"MRR",
"--format",
"currency",
"--currency-code",
"USD",
]);

assert.equal(result.code, 0, result.stderr);
assert.equal(result.request.method, "PUT");
assert.equal(result.request.url, "https://activitysmith.com/api/metrics/mrr");
assert.deepEqual(result.request.body, {
label: "MRR",
format: "currency",
currency_code: "USD",
});
});

test("metrics create without options uses the API defaults", async () => {
const result = await runCli(["metrics", "create", "deploy.success_rate"]);

assert.equal(result.code, 0, result.stderr);
assert.equal(result.request.method, "PUT");
assert.equal(result.request.url, "https://activitysmith.com/api/metrics/deploy.success_rate");
assert.equal(result.request.body, null);
});
Loading