diff --git a/CHANGELOG.md b/CHANGELOG.md index c03b301..6de68d2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/package.json b/package.json index 2c115b8..21d53f2 100644 --- a/package.json +++ b/package.json @@ -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", @@ -42,7 +42,7 @@ "test": "node --test" }, "dependencies": { - "activitysmith": "^1.14.0", + "activitysmith": "^1.15.0", "commander": "^12.1.0" } } diff --git a/skills/activitysmith/SKILL.md b/skills/activitysmith/SKILL.md index 820b24a..8bba4d2 100644 --- a/skills/activitysmith/SKILL.md +++ b/skills/activitysmith/SKILL.md @@ -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. @@ -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. @@ -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: diff --git a/src/cli.js b/src/cli.js index 7cae7d0..a01221e 100755 --- a/src/cli.js +++ b/src/cli.js @@ -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 ", "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" @@ -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); @@ -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") @@ -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") diff --git a/test/cli.test.js b/test/cli.test.js index 7aca79e..b33977c 100644 --- a/test/cli.test.js +++ b/test/cli.test.js @@ -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) => { @@ -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); +});