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.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.
Expand Down
43 changes: 42 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"
```

Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
18 changes: 18 additions & 0 deletions skills/activitysmith/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down
10 changes: 8 additions & 2 deletions src/auth.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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 {
Expand All @@ -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:");
Expand Down
137 changes: 136 additions & 1 deletion src/cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 = [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 <name>", 'Key name, such as "GitHub Actions"')
.option("--channels <slugs>", "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");
Expand Down
Loading
Loading