From ccdf902ab84f6fcf61b46ab59ef1287ee82443d8 Mon Sep 17 00:00:00 2001 From: bardonadam Date: Tue, 27 Oct 2026 14:28:46 +0700 Subject: [PATCH 1/3] Add devices list and keys create - `activitysmith devices list` shows the iPhones and iPads paired to the account, and points to the App Store when there are none. - `activitysmith keys create --name ` creates an API key with the browser sign-in. The first time it signs in again to ask for API key access. `--raw` prints only the key, for piping into a secret store, and `--channels` limits it to Channels. Co-Authored-By: Claude Opus 5.5 --- src/auth.js | 10 +++- src/cli.js | 137 ++++++++++++++++++++++++++++++++++++++++++++++- test/cli.test.js | 96 +++++++++++++++++++++++++++++++++ 3 files changed, 240 insertions(+), 3 deletions(-) diff --git a/src/auth.js b/src/auth.js index 71fd0ed..33cd97e 100644 --- a/src/auth.js +++ b/src/auth.js @@ -16,6 +16,10 @@ export const CLIENT_NAME = "ActivitySmith CLI"; export const CLIENT_ID = "https://activitysmith.com/.well-known/oauth-client/activitysmith-cli.json"; // Sending Push Notifications, Live Activities, badges, and widget values. Nothing else. export const LOGIN_SCOPES = "mcp:write"; +// Asked for only by `activitysmith keys create`, which signs in again so the user approves it in the browser. +export const API_KEYS_SCOPE = "api_keys:write"; + +export const hasScope = (credentials, scope) => String(credentials?.scope ?? "").split(/\s+/).includes(scope); const LOGIN_TIMEOUT_MS = 5 * 60 * 1000; // Refresh a little early so a token does not expire mid-request. const ACCESS_TOKEN_REFRESH_MARGIN_MS = 60 * 1000; @@ -95,13 +99,13 @@ export const discoverOAuthServer = async ({ issuer, fetchImpl }) => { return metadata; }; -export const buildAuthorizeUrl = ({ authorizationEndpoint, redirectUri, challenge, state, resource }) => { +export const buildAuthorizeUrl = ({ authorizationEndpoint, redirectUri, challenge, state, resource, scope = LOGIN_SCOPES }) => { const url = new URL(authorizationEndpoint); url.search = new URLSearchParams({ response_type: "code", client_id: CLIENT_ID, redirect_uri: redirectUri, - scope: LOGIN_SCOPES, + scope, state, code_challenge: challenge, code_challenge_method: "S256", @@ -255,6 +259,7 @@ export const login = async ({ log = (line) => console.error(line), now = Date.now, timeoutMs = LOGIN_TIMEOUT_MS, + scope = LOGIN_SCOPES, } = {}) => { let previous = null; try { @@ -275,6 +280,7 @@ export const login = async ({ challenge, state, resource, + scope, }); const opened = noBrowser ? false : await launchBrowser(authorizeUrl); log(opened ? "Opened your browser to sign in to ActivitySmith." : "Open this URL in a browser to sign in to ActivitySmith:"); diff --git a/src/cli.js b/src/cli.js index 3e9f0d9..579739d 100755 --- a/src/cli.js +++ b/src/cli.js @@ -2,7 +2,17 @@ import { Command, InvalidArgumentError, Option } from "commander"; import { loadMetadata } from "./metadata.js"; -import { credentialsPath, getAccessToken, login, logout, readCredentials } from "./auth.js"; +import { + API_KEYS_SCOPE, + DEFAULT_API_RESOURCE, + LOGIN_SCOPES, + credentialsPath, + getAccessToken, + hasScope, + login, + logout, + readCredentials, +} from "./auth.js"; import ActivitySmith from "activitysmith"; import { createRequire } from "module"; import { readFile } from "fs/promises"; @@ -1702,6 +1712,131 @@ addLiveActivityActionOptions(addContentStateOptions( { includeAutoDismiss: true, includeAutoDismissSeconds: true } )); +const APP_STORE_URL = "https://apps.apple.com/us/app/activitysmith/id6752254835"; + +// Account endpoints the generated SDK does not cover. Same API and SDK header as the SDK client, so +// errors print the same way and usage shows the CLI version. +const apiRequest = async (path, { token, method = "GET", body } = {}) => { + const response = await fetch(`${DEFAULT_API_RESOURCE}${path}`, { + method, + headers: { + Authorization: `Bearer ${token}`, + Accept: "application/json", + "X-ActivitySmith-SDK": `cli-v${version}`, + ...(body ? { "Content-Type": "application/json" } : {}), + }, + ...(body ? { body: JSON.stringify(body) } : {}), + }); + if (!response.ok) { + const error = new Error(`Request failed with status ${response.status}`); + error.response = response; + throw error; + } + return response.json(); +}; + +const describeDevice = (device) => { + const name = device.device_name || device.device_type || "Device"; + const system = [device.system_name, device.system_version].filter(Boolean).join(" "); + const details = [device.device_type, system, device.is_active ? "active" : "inactive", device.user_email].filter(Boolean); + return `- ${name} (${details.join(", ")}) ID: ${device.device_id}`; +}; + +const devicesCommand = program + .command("devices") + .description("List the iPhones and iPads paired to your account"); + +devicesCommand + .command("list", { isDefault: true }) + .description("List paired devices, active ones first") + .option("--include-inactive", "Also list devices that can no longer receive deliveries") + .action(async (options) => { + const globalOptions = program.opts(); + + try { + const token = await requireApiKey(globalOptions); + const query = options.includeInactive ? "?include_inactive=true" : ""; + const result = await apiRequest(`/account/devices${query}`, { token }); + const devices = Array.isArray(result.devices) ? result.devices : []; + const active = devices.filter((device) => device.is_active).length; + outputResult( + result, + globalOptions, + devices.length === 0 + ? [ + "No paired devices.", + "Install ActivitySmith from the App Store, sign in with this account, and allow notifications:", + APP_STORE_URL, + ] + : [ + `${devices.length} ${devices.length === 1 ? "device" : "devices"}, ${active} active:`, + ...devices.map(describeDevice), + result.meta?.has_more ? "More devices exist. Use --json to see the meta.next_offset value." : null, + ] + ); + } catch (error) { + await handleError(error, globalOptions); + } + }); + +const keysCommand = program + .command("keys") + .description("Create API keys for CI, servers, and scripts"); + +keysCommand + .command("create") + .description( + "Create an API key with the browser sign-in. The first time, approve API key access in the browser" + ) + .requiredOption("--name ", 'Key name, such as "GitHub Actions"') + .option("--channels ", "Comma-separated channel slugs. The key reaches only these Channels") + .option("--raw", "Print only the key on standard output, for piping it into a secret store") + .action(async (options) => { + const globalOptions = program.opts(); + + try { + // API keys cannot create keys, so this always uses the browser sign-in, even when an API key is set. + let stored = null; + try { + stored = readCredentials(); + } catch { + // An unreadable sign-in is replaced by the login below. + } + if (!hasScope(stored, API_KEYS_SCOPE)) { + console.error("Approve API key access in the browser so the CLI can create API keys."); + await login({ scope: `${LOGIN_SCOPES} ${API_KEYS_SCOPE}` }); + } + const token = await getAccessToken(); + usingSignIn = true; + const channels = String(options.channels ?? "") + .split(",") + .map((slug) => slug.trim()) + .filter(Boolean); + const result = await apiRequest("/account/api-keys", { + token, + method: "POST", + body: { name: options.name, ...(channels.length > 0 ? { channels } : {}) }, + }); + const key = result.api_key?.key; + if (!key) { + throw new Error("The API did not return a key. Run the command again."); + } + + if (options.raw) { + process.stdout.write(`${key}\n`); + console.error(`Created API key "${result.api_key.name}". Store it as a secret and never commit it.`); + return; + } + outputResult(result, globalOptions, [ + `Created API key "${result.api_key.name}".`, + `Key: ${key}`, + "This is the only time the key is shown. Store it as a secret named ACTIVITYSMITH_API_KEY and never commit it.", + ]); + } catch (error) { + await handleError(error, globalOptions); + } + }); + const authCommand = program .command("auth") .description("Sign in through the browser and manage the saved sign-in"); diff --git a/test/cli.test.js b/test/cli.test.js index c602520..9b28083 100644 --- a/test/cli.test.js +++ b/test/cli.test.js @@ -389,3 +389,99 @@ test("push --replace rejects options that only apply to new sends", async () => 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) => { + const runner = ` + globalThis.fetch = async (url, init) => { + process.stdout.write("CAPTURE:" + JSON.stringify({ + url, + method: init.method, + headers: init.headers, + body: init.body ? JSON.parse(init.body) : null + }) + "\\n"); + return new Response(process.env.CLI_TEST_RESPONSE, { status: 200, headers: { "Content-Type": "application/json" } }); + }; + process.argv = ["node", "activitysmith", ...JSON.parse(process.env.CLI_TEST_ARGS)]; + await import("./src/cli.js"); + `; + const child = spawn(process.execPath, ["--input-type=module", "--eval", runner], { + cwd: process.cwd(), + env: { ...process.env, ...env, CLI_TEST_ARGS: JSON.stringify(args), CLI_TEST_RESPONSE: JSON.stringify(response) }, + }); + let stdout = ""; + let stderr = ""; + child.stdout.on("data", (chunk) => { stdout += chunk; }); + child.stderr.on("data", (chunk) => { stderr += chunk; }); + child.on("error", reject); + child.on("close", (code) => { + const lines = stdout.split("\n"); + const requests = lines.filter((line) => line.startsWith("CAPTURE:")).map((line) => JSON.parse(line.slice("CAPTURE:".length))); + const output = lines.filter((line) => line && !line.startsWith("CAPTURE:")).join("\n"); + resolve({ code, stdout: output, stderr, requests }); + }); + }); + +const signedInConfigDir = async (scope) => { + const { mkdtemp, writeFile } = await import("node:fs/promises"); + const { tmpdir } = await import("node:os"); + const { join } = await import("node:path"); + const dir = await mkdtemp(join(tmpdir(), "activitysmith-cli-test-")); + await writeFile(join(dir, "credentials.json"), JSON.stringify({ + client_id: "https://activitysmith.com/.well-known/oauth-client/activitysmith-cli.json", + issuer: "https://activitysmith.com", + resource: "https://activitysmith.com/api", + token_endpoint: "https://activitysmith.com/oauth/token", + access_token: "signed-in-token", + access_token_expires_at: new Date(Date.now() + 3_600_000).toISOString(), + refresh_token: "refresh", + scope, + signed_in_at: new Date().toISOString(), + })); + return dir; +}; + +test("devices list reads the paired devices and points to the App Store when there are none", async () => { + const empty = await runCliWithApi(["--api-key", "test", "devices", "list"], { + response: { devices: [], meta: { total: 0, limit: 100, offset: 0, has_more: false, next_offset: null, include_inactive: false } }, + }); + assert.equal(empty.code, 0, empty.stderr); + assert.equal(empty.requests[0].url, "https://activitysmith.com/api/account/devices"); + assert.equal(empty.requests[0].method, "GET"); + assert.equal(empty.requests[0].headers.Authorization, "Bearer test"); + assert.match(empty.requests[0].headers["X-ActivitySmith-SDK"], /^cli-v/); + assert.match(empty.stdout, /No paired devices/); + assert.match(empty.stdout, /apps\.apple\.com/); + + const paired = await runCliWithApi(["--api-key", "test", "devices", "--include-inactive"], { + response: { + devices: [{ user_email: "you@example.com", device_id: "dev-1", device_type: "iPhone", is_active: true, device_name: "Adam's iPhone", system_name: "iOS", system_version: "27.0" }], + meta: { total: 1, limit: 100, offset: 0, has_more: false, next_offset: null, include_inactive: true }, + }, + }); + assert.equal(paired.requests[0].url, "https://activitysmith.com/api/account/devices?include_inactive=true"); + assert.match(paired.stdout, /1 device, 1 active/); + assert.match(paired.stdout, /Adam's iPhone \(iPhone, iOS 27\.0, active, you@example\.com\) ID: dev-1/); +}); + +test("keys create uses the browser sign-in and can print only the key", async () => { + const dir = await signedInConfigDir("mcp:write api_keys:write"); + const response = { api_key: { public_id: "pub", name: "GitHub Actions", key: "ask_secret", prefix: "ask_secret", scope_mode: "channels", channel_slugs: ["ops"] } }; + const raw = await runCliWithApi(["keys", "create", "--name", "GitHub Actions", "--channels", "ops, alerts", "--raw"], { + response, + env: { ACTIVITYSMITH_CONFIG_DIR: dir, ACTIVITYSMITH_API_KEY: "ask_ignored" }, + }); + assert.equal(raw.code, 0, raw.stderr); + assert.equal(raw.requests[0].url, "https://activitysmith.com/api/account/api-keys"); + assert.equal(raw.requests[0].method, "POST"); + // API keys cannot create keys: the sign-in is used even when ACTIVITYSMITH_API_KEY is set. + assert.equal(raw.requests[0].headers.Authorization, "Bearer signed-in-token"); + assert.deepEqual(raw.requests[0].body, { name: "GitHub Actions", channels: ["ops", "alerts"] }); + assert.equal(raw.stdout, "ask_secret"); + assert.match(raw.stderr, /Created API key "GitHub Actions"/); + + const shown = await runCliWithApi(["keys", "create", "--name", "GitHub Actions"], { response, env: { ACTIVITYSMITH_CONFIG_DIR: dir } }); + assert.match(shown.stdout, /Key: ask_secret/); + assert.match(shown.stdout, /only time the key is shown/); +}); From da49d31d962f4faa19629c7b93c773bb40646cae Mon Sep 17 00:00:00 2001 From: bardonadam Date: Tue, 27 Oct 2026 14:31:43 +0700 Subject: [PATCH 2/3] Show device name and system without the platform in devices list Co-Authored-By: Claude Opus 5.5 --- src/cli.js | 2 +- test/cli.test.js | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/cli.js b/src/cli.js index 579739d..6a7cf80 100755 --- a/src/cli.js +++ b/src/cli.js @@ -1738,7 +1738,7 @@ const apiRequest = async (path, { token, method = "GET", body } = {}) => { const describeDevice = (device) => { const name = device.device_name || device.device_type || "Device"; const system = [device.system_name, device.system_version].filter(Boolean).join(" "); - const details = [device.device_type, system, device.is_active ? "active" : "inactive", device.user_email].filter(Boolean); + const details = [system, device.is_active ? "active" : "inactive", device.user_email].filter(Boolean); return `- ${name} (${details.join(", ")}) ID: ${device.device_id}`; }; diff --git a/test/cli.test.js b/test/cli.test.js index 9b28083..ca66868 100644 --- a/test/cli.test.js +++ b/test/cli.test.js @@ -456,13 +456,13 @@ test("devices list reads the paired devices and points to the App Store when the const paired = await runCliWithApi(["--api-key", "test", "devices", "--include-inactive"], { response: { - devices: [{ user_email: "you@example.com", device_id: "dev-1", device_type: "iPhone", is_active: true, device_name: "Adam's iPhone", system_name: "iOS", system_version: "27.0" }], + devices: [{ user_email: "you@example.com", device_id: "dev-1", device_type: "ios", is_active: true, device_name: "Adam's iPhone", system_name: "iOS", system_version: "27.0" }], meta: { total: 1, limit: 100, offset: 0, has_more: false, next_offset: null, include_inactive: true }, }, }); assert.equal(paired.requests[0].url, "https://activitysmith.com/api/account/devices?include_inactive=true"); assert.match(paired.stdout, /1 device, 1 active/); - assert.match(paired.stdout, /Adam's iPhone \(iPhone, iOS 27\.0, active, you@example\.com\) ID: dev-1/); + assert.match(paired.stdout, /Adam's iPhone \(iOS 27\.0, active, you@example\.com\) ID: dev-1/); }); test("keys create uses the browser sign-in and can print only the key", async () => { From b41fb07cf4254e18a7614f3e2af0994937d65dd1 Mon Sep 17 00:00:00 2001 From: bardonadam Date: Tue, 27 Oct 2026 15:36:33 +0700 Subject: [PATCH 3/3] Release 1.16.0: devices list and keys create Document `activitysmith devices list` and `activitysmith keys create` in the README (synced from the website docs), the changelog, and the bundled agent skill, and bump the version to 1.16.0. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 4 ++++ README.md | 43 ++++++++++++++++++++++++++++++++++- package.json | 2 +- skills/activitysmith/SKILL.md | 18 +++++++++++++++ 4 files changed, 65 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 340247f..4d76f53 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +## 1.16.0 + +Adds two commands so a coding agent can finish setup on its own. `activitysmith devices list` shows the iPhones and iPads paired to your account, and points to the App Store when none is paired. `activitysmith keys create` creates an API key for CI, servers, and scripts with the browser sign-in; the first time, it asks you to approve API key access in the browser. Add `--raw` to print only the key, so it can go straight into a secret store. + ## 1.15.0 Adds browser sign-in. Run `activitysmith auth login`, approve access in your browser, and commands work without an API key. `activitysmith auth status` shows how the CLI authenticates, and `activitysmith auth logout` disconnects it from your account. `--api-key` and `ACTIVITYSMITH_API_KEY` still work and take precedence, so CI pipelines can keep using API keys. diff --git a/README.md b/README.md index 64245d4..4d8db1d 100644 --- a/README.md +++ b/README.md @@ -25,11 +25,14 @@ For example, a Codex agent can work on your computer, send a Push Notification w ## Quickstart 1. Run `activitysmith auth login` and approve access in your browser. -2. Run `activitysmith --help` to inspect available commands. +2. Run `activitysmith devices list` to check that your iPhone is paired. +3. Run `activitysmith --help` to inspect available commands. ```bash activitysmith auth login +activitysmith devices list + activitysmith push --title "Hello" ``` @@ -86,6 +89,44 @@ Or pass the key directly: activitysmith --api-key "YOUR-API-KEY" push --title "Hello" ``` +### Create an API key from the CLI + +Create an API key for CI, servers, and scripts without opening the web app. The command uses the browser sign-in. The first time, it opens your browser so you can approve API key access. + +```bash +activitysmith keys create --name "GitHub Actions" +``` + +The key is shown only once. Add `--raw` to print only the key, then pipe it straight into a secret store. The key never appears in your terminal: + +```bash +activitysmith keys create --name "GitHub Actions" --raw | gh secret set ACTIVITYSMITH_API_KEY +``` + +Add `--channels` to limit the key to some Channels: + +```bash +activitysmith keys create --name "Ops alerts" --channels ops,alerts +``` + +An API key cannot create another key. This command always uses the browser sign-in, even when `ACTIVITYSMITH_API_KEY` is set. + +## Paired Devices + +List the iPhones and iPads paired to your account. Active devices come first: + +```bash +activitysmith devices list +``` + +If no device is paired, the command prints the App Store link. Install ActivitySmith on your iPhone, sign in with the same account, and allow notifications. + +Add `--include-inactive` to also list devices that can no longer receive deliveries: + +```bash +activitysmith devices list --include-inactive +``` + ## Push Notifications ### Send a Push Notification diff --git a/package.json b/package.json index d29aa88..7c28262 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "activitysmith-cli", - "version": "1.15.0", + "version": "1.16.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", diff --git a/skills/activitysmith/SKILL.md b/skills/activitysmith/SKILL.md index 050f1b7..6fd1413 100644 --- a/skills/activitysmith/SKILL.md +++ b/skills/activitysmith/SKILL.md @@ -33,6 +33,14 @@ activitysmith auth login It opens the user's browser and waits until they approve, then saves the sign-in for this machine. If no browser can open, it prints a URL for the user to visit. Never ask the user to paste an API key into chat. +Before the first send, check that a device is paired: + +```bash +activitysmith devices list +``` + +If it prints `No paired devices.`, give the user the App Store link it prints. Ask them to sign in to the app with the same account and allow notifications, then run `devices list` again. + ## Pick The Right Signal - Push Notification: one important event, completion, blocker, review request, or handoff. @@ -194,6 +202,16 @@ String values work too: activitysmith metrics update prod.status healthy ``` +### 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: + +```bash +activitysmith keys create --name "GitHub Actions" --raw | gh secret set ACTIVITYSMITH_API_KEY +``` + +The first `keys create` opens the browser once. Ask the user to approve API key access. Name the secret `ACTIVITYSMITH_API_KEY`, and tell the user where you stored it. Never print or commit the key. + ## Tags Use tags to organize and filter Push Notification and Live Activity history. Tags are created automatically when they are first used.