agentalarm 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 AChub
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,32 @@
1
- # Temporary Holding Version
1
+ # agentalarm
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Alerts on your iPhone from scripts, CI and AI agents, through [AgentAlarm](https://alarm.achub.net). Without a time an
4
+ alert is a notification, now (the app's Notifications feed); with `--at` it is a reminder (the Reminders list). Quiet by
5
+ default; `--ring` makes it a call that rings your phone like an alarm, through Silent and Focus, until you tap Done or
6
+ Snooze. It is never a phone call. Node 18+, no dependencies.
7
+
8
+ ```bash
9
+ npx agentalarm login "<the text copied from the app>" # Settings > Connected Agents > Connect
10
+ npx agentalarm "Backup finished"
11
+ npx agentalarm "Deploy failed" --ring --color red
12
+ npx agentalarm "Stand-up" --at 2026-10-28T09:00
13
+ ```
14
+
15
+ | Command | |
16
+ |---|---|
17
+ | `agentalarm "text" [-r] [--at <time>] [-b <body>] [-c <color>] [-i <icon>] [-l <location>] [-s <sound>] [--json]` | Send a notification now; `--at` makes a reminder, `-r` / `--ring` (or `-u`) rings. |
18
+ | `agentalarm login "<text or link>"` | Redeem the app's one-time link; the key goes to `~/.config/agentalarm/config.json` (600). |
19
+ | `agentalarm key "<text>" [--name CI] \| gh secret set AGENTALARM_KEY` | A separate key for CI, printed only into a pipe. |
20
+ | `agentalarm setup`, `agentalarm config [set <option> <value>]` | When agent hooks alert you. |
21
+ | `agentalarm test` (a test call), `status`, `pause [1h]`, `resume`, `logout` | |
22
+ | `agentalarm hook <claude-code\|cursor\|codex>` | Agent hook: reads the event on stdin, alerts without spam, always exits 0. |
23
+ | `agentalarm mcp` | Local stdio MCP server that forwards to AgentAlarm with the saved key. |
24
+
25
+ `AGENTALARM_KEY` in the environment wins over the saved key. Exit codes: 0 sent, 1 network or server error, 2 usage,
26
+ 3 not logged in or key rejected, 4 refused by the server (its message is printed).
27
+
28
+ Hook settings, the Claude Code and Cursor plugins, Codex hooks and CI examples:
29
+ https://github.com/achubnet/agentalarm-plugins#readme
30
+
31
+ Works with AI agents that support MCP, for example Claude Code, Codex, Cursor, VS Code, Windsurf and Grok-based bots.
32
+ AgentAlarm is not affiliated with Anthropic, OpenAI, Anysphere, Microsoft or xAI.
@@ -0,0 +1,4 @@
1
+ #!/usr/bin/env node
2
+ import { main } from '../src/cli.js';
3
+
4
+ process.exitCode = await main(process.argv.slice(2));
package/package.json CHANGED
@@ -1,6 +1,30 @@
1
1
  {
2
2
  "name": "agentalarm",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "Let AI agents, scripts and CI ring your iPhone through AgentAlarm: urgent alerts ring through Silent and Focus.",
5
+ "keywords": ["agentalarm", "alarm", "iphone", "notifications", "claude-code", "cursor", "codex", "hooks", "mcp", "ai-agents"],
6
+ "homepage": "https://alarm.achub.net",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/achubnet/agentalarm-plugins.git",
10
+ "directory": "cli"
11
+ },
12
+ "license": "MIT",
13
+ "author": "AgentAlarm",
14
+ "type": "module",
15
+ "bin": {
16
+ "agentalarm": "bin/agentalarm.js"
17
+ },
18
+ "files": [
19
+ "bin",
20
+ "src",
21
+ "README.md",
22
+ "LICENSE"
23
+ ],
24
+ "engines": {
25
+ "node": ">=18"
26
+ },
27
+ "scripts": {
28
+ "test": "node --test test/*.test.js"
29
+ }
30
+ }
package/src/api.js ADDED
@@ -0,0 +1,116 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { CliError, EXIT } from './errors.js';
3
+ import { DEFAULT_BASE_URL } from './config.js';
4
+ import { checkUrl, isLoopback, request } from './http.js';
5
+
6
+ const CODE = /([A-Z2-9]{4}-[A-Z2-9]{4})/;
7
+ const SETUP_LINK = /(https?:\/\/[^\s"'<>()]+?)\/api\/setup\/([A-Za-z2-9]{4}-[A-Za-z2-9]{4})(?![A-Za-z0-9-])/;
8
+
9
+ function serverUrl(value) {
10
+ const url = checkUrl(value);
11
+ if (url.username || url.password) {
12
+ throw new CliError(`Refusing ${url.protocol}//${url.host}: a server address must not contain a user name or password.`, EXIT.USAGE);
13
+ }
14
+ return url;
15
+ }
16
+
17
+ /**
18
+ * Finds the one-time setup link in what the user copied from the app (the whole text, the link alone, or the bare
19
+ * code) and returns `<origin>/api/setup/<CODE>`. Null when there is none. A link to any server but AgentAlarm's (or
20
+ * a loopback one, for development) is refused unless `server` names that origin: pasted text could carry a lookalike
21
+ * host that would receive the redeem request and later every alert.
22
+ */
23
+ export function extractSetupUrl(text, { server } = {}) {
24
+ if (typeof text !== 'string') return null;
25
+ const allowed = server ? serverUrl(server).origin : null;
26
+ const link = text.match(SETUP_LINK);
27
+ if (link) {
28
+ const url = serverUrl(link[1]);
29
+ if (url.origin !== DEFAULT_BASE_URL && url.origin !== allowed && !isLoopback(url)) {
30
+ throw new CliError(
31
+ `The link points to ${url.origin}, not ${DEFAULT_BASE_URL}. Only if you run that AgentAlarm server yourself, add --server ${url.origin}.`,
32
+ EXIT.USAGE,
33
+ );
34
+ }
35
+ return `${url.origin}/api/setup/${link[2].toUpperCase()}`;
36
+ }
37
+ const code = text.trim().toUpperCase().match(new RegExp(`^${CODE.source}$`));
38
+ return code ? `${allowed ?? DEFAULT_BASE_URL}/api/setup/${code[1]}` : null;
39
+ }
40
+
41
+ function serverMessage(res) {
42
+ const message = res.json && typeof res.json.message === 'string' ? res.json.message.trim() : '';
43
+ return message.slice(0, 500);
44
+ }
45
+
46
+ /** Maps a failed response to a CliError with the server's own message where it has one. */
47
+ export function errorFrom(res) {
48
+ const message = serverMessage(res);
49
+ if (res.status === 401) {
50
+ return new CliError('AgentAlarm rejected the key (revoked or expired). Run "agentalarm login" with a new link from the app.', EXIT.AUTH);
51
+ }
52
+ if (res.status === 403) {
53
+ return new CliError('This key cannot create alerts (read-only). Connect again from the app without "read only".', EXIT.AUTH);
54
+ }
55
+ if (res.status === 422) {
56
+ return new CliError(message || 'AgentAlarm refused the alert.', EXIT.REFUSED);
57
+ }
58
+ if (res.status === 429) {
59
+ return new CliError('Too many requests. Wait a minute and try again.', EXIT.FAILURE);
60
+ }
61
+ return new CliError(`AgentAlarm answered HTTP ${res.status}${message ? `: ${message}` : '.'}`, EXIT.FAILURE);
62
+ }
63
+
64
+ /**
65
+ * Opens the key page once (`?format=json`): the server creates the key named `name` and returns it.
66
+ * The key must come from the same server as the link.
67
+ */
68
+ export async function redeemSetup(setupUrl, name) {
69
+ const url = new URL(`${setupUrl}/key`);
70
+ url.searchParams.set('format', 'json');
71
+ url.searchParams.set('name', name);
72
+ const res = await request('GET', url.toString());
73
+
74
+ if (res.status === 404) {
75
+ throw new CliError(serverMessage(res) || 'This setup link is invalid, expired or already used. Create a new one in the AgentAlarm app.', EXIT.USAGE);
76
+ }
77
+ if (res.status !== 200) throw errorFrom(res);
78
+
79
+ const data = res.json;
80
+ if (!data || typeof data.key !== 'string' || !/^\d+\|\S{10,200}$/.test(data.key)) {
81
+ throw new CliError('The setup page did not return a key. Update agentalarm ("npx agentalarm@latest") and try a new link.', EXIT.FAILURE);
82
+ }
83
+ return {
84
+ key: data.key,
85
+ name: typeof data.name === 'string' ? data.name : name,
86
+ readOnly: data.read_only === true,
87
+ baseUrl: new URL(setupUrl).origin,
88
+ };
89
+ }
90
+
91
+ function authHeaders(creds) {
92
+ if (!creds.key) {
93
+ throw new CliError('Not logged in. Run: npx agentalarm login "<the text copied from the AgentAlarm app>" (or set AGENTALARM_KEY).', EXIT.AUTH);
94
+ }
95
+ return { Authorization: `Bearer ${creds.key}` };
96
+ }
97
+
98
+ /** POST /api/alarms with a client-made id, so a retried create never makes a second alert. */
99
+ export async function createAlarm(creds, alarm) {
100
+ const body = { id: randomUUID(), ...alarm };
101
+ const res = await request('POST', `${creds.baseUrl}/api/alarms`, { headers: authHeaders(creds), body });
102
+ if (res.status !== 200 && res.status !== 201) throw errorFrom(res);
103
+ return res.json ?? {};
104
+ }
105
+
106
+ export async function me(creds) {
107
+ const res = await request('GET', `${creds.baseUrl}/api/me`, { headers: authHeaders(creds) });
108
+ if (res.status !== 200) throw errorFrom(res);
109
+ return res.json ?? {};
110
+ }
111
+
112
+ /** Revokes the key that makes the request (only that one). */
113
+ export async function revokeKey(creds) {
114
+ const res = await request('POST', `${creds.baseUrl}/api/auth/logout`, { headers: authHeaders(creds) });
115
+ if (res.status !== 200 && res.status !== 401) throw errorFrom(res);
116
+ }
package/src/cli.js ADDED
@@ -0,0 +1,453 @@
1
+ import { spawn } from 'node:child_process';
2
+ import fs from 'node:fs';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import readline from 'node:readline/promises';
6
+ import { fileURLToPath } from 'node:url';
7
+ import { createAlarm, extractSetupUrl, me, redeemSetup, revokeKey } from './api.js';
8
+ import {
9
+ HOOK_DEFAULTS,
10
+ HOOK_ENV_NAMES,
11
+ configPath,
12
+ credentials,
13
+ hookSettings,
14
+ loadConfig,
15
+ parseHookOption,
16
+ saveConfig,
17
+ stateDir,
18
+ updateConfig,
19
+ } from './config.js';
20
+ import { CliError, EXIT } from './errors.js';
21
+ import { AGENTS, SessionStore, handleHook, hookOutput } from './hook.js';
22
+ import { idleMs } from './idle.js';
23
+ import { runMcpProxy } from './mcp.js';
24
+ import { askHookSettings } from './setup.js';
25
+ import { VERSION } from './version.js';
26
+
27
+ const BIN = fileURLToPath(new URL('../bin/agentalarm.js', import.meta.url));
28
+
29
+ const HELP = `agentalarm ${VERSION}: alerts on your iPhone from scripts, CI and AI agents (https://alarm.achub.net)
30
+
31
+ Usage:
32
+ agentalarm "text" [options] Send a notification now (the app's Notifications feed).
33
+ -r, --ring, -u, --urgent A call: rings like an alarm, through Silent and Focus, until Done or Snooze.
34
+ --at <time> A reminder at that time instead (the app's Reminders list), e.g. 2026-10-28T09:00
35
+ (your timezone in the app) or with an offset.
36
+ -b, --body <text> Details; line breaks and links are kept.
37
+ -c, --color <name> blue, red, orange, green or purple.
38
+ -i, --icon <name> bell, checkmark.circle, exclamationmark.triangle, calendar, sportscourt, gift,
39
+ cart, airplane, heart or briefcase.
40
+ -l, --location <text> An address or place; the app opens it in Maps.
41
+ -s, --sound <id> Ring sound (with --ring), e.g. loudalarm, urgentpulse, calmpiano.
42
+ --json Print the created alert as JSON.
43
+
44
+ agentalarm login "<text from the app>" Connect: paste the text (or link) from AgentAlarm > Connect an agent.
45
+ Links to another server need --server <origin> (self-hosted only).
46
+ agentalarm key "<text from the app>" [--name CI] | gh secret set AGENTALARM_KEY
47
+ Redeem a link for CI: prints the new key into a pipe, never to a terminal.
48
+ agentalarm setup [--defaults] Choose when agent hooks alert you.
49
+ agentalarm config [set <option> <value> | unset <option>] Show or change one hook option.
50
+ agentalarm test Send a test call (it rings).
51
+ agentalarm status Show the connection and hook settings.
52
+ agentalarm pause [30m|1h|2d] Pause hook alerts (default 1h). Direct alerts still go out.
53
+ agentalarm resume Resume hook alerts.
54
+ agentalarm logout Revoke this computer's key and forget it.
55
+ agentalarm hook <claude-code|cursor|codex> Run as an agent hook (reads the event JSON on stdin).
56
+ agentalarm mcp Local MCP server (stdio) that forwards to AgentAlarm with your key.
57
+
58
+ Environment: AGENTALARM_KEY (a key, for CI), AGENTALARM_<OPTION> for hook options (see the README).
59
+ Exit codes: 0 ok, 1 network or server error, 2 usage, 3 not logged in or key rejected, 4 refused by the server.`;
60
+
61
+ const VALUE_OPTIONS = {
62
+ '--name': 'name', '--server': 'server', '--at': 'at', '--body': 'body', '-b': 'body', '--color': 'color', '-c': 'color',
63
+ '--sound': 'sound', '-s': 'sound', '--icon': 'icon', '-i': 'icon', '--location': 'location', '-l': 'location',
64
+ };
65
+ const FLAG_OPTIONS = {
66
+ '--urgent': 'urgent', '-u': 'urgent', '--ring': 'urgent', '-r': 'urgent',
67
+ '--json': 'json', '--defaults': 'defaults', '--local': 'local', '--no-setup': 'noSetup',
68
+ };
69
+
70
+ export function parseArgs(argv) {
71
+ const positional = [];
72
+ const options = {};
73
+ for (let i = 0; i < argv.length; i++) {
74
+ const arg = argv[i];
75
+ if (arg === '--') {
76
+ positional.push(...argv.slice(i + 1));
77
+ break;
78
+ }
79
+ const [flag, inline] = arg.startsWith('--') && arg.includes('=') ? [arg.slice(0, arg.indexOf('=')), arg.slice(arg.indexOf('=') + 1)] : [arg, undefined];
80
+ if (VALUE_OPTIONS[flag]) {
81
+ const value = inline ?? argv[++i];
82
+ if (value === undefined) throw new CliError(`${flag} needs a value.`, EXIT.USAGE);
83
+ options[VALUE_OPTIONS[flag]] = value;
84
+ } else if (FLAG_OPTIONS[flag] && inline === undefined) {
85
+ options[FLAG_OPTIONS[flag]] = true;
86
+ } else if (arg === '-h' || arg === '--help') {
87
+ options.help = true;
88
+ } else if (arg === '-v' || arg === '--version') {
89
+ options.version = true;
90
+ } else if (arg.startsWith('-') && arg.length > 1) {
91
+ throw new CliError(`Unknown option ${flag}. See "agentalarm --help".`, EXIT.USAGE);
92
+ } else {
93
+ positional.push(arg);
94
+ }
95
+ }
96
+ return { positional, options };
97
+ }
98
+
99
+ /** "30m", "1h", "2d", "90" (minutes) → ms; null when invalid. */
100
+ export function parseDuration(text) {
101
+ const m = String(text).trim().toLowerCase().match(/^(\d+(?:\.\d+)?)\s*(m|min|mins|minutes?|h|hours?|d|days?)?$/);
102
+ if (!m) return null;
103
+ const unit = (m[2] || 'm')[0];
104
+ const ms = Number(m[1]) * { m: 60_000, h: 3_600_000, d: 86_400_000 }[unit];
105
+ return ms > 0 && ms <= 30 * 86_400_000 ? ms : null;
106
+ }
107
+
108
+ function readStdin(stdin, { limit = 1_000_000, timeoutMs = 2000 } = {}) {
109
+ if (stdin.isTTY) return Promise.resolve('');
110
+ return new Promise((resolve) => {
111
+ let data = '';
112
+ const done = () => {
113
+ clearTimeout(timer);
114
+ stdin.removeAllListeners('data');
115
+ stdin.pause();
116
+ resolve(data);
117
+ };
118
+ const timer = setTimeout(done, timeoutMs);
119
+ stdin.setEncoding('utf8');
120
+ stdin.on('data', (chunk) => {
121
+ data += chunk;
122
+ if (data.length > limit) done();
123
+ });
124
+ stdin.on('end', done);
125
+ stdin.on('error', done);
126
+ });
127
+ }
128
+
129
+ // ---------------------------------------------------------------------------------------------------------------
130
+ // Hook log: why each hook did or did not alert. No keys, no prompts, no code.
131
+
132
+ const LOG_MAX_BYTES = 256 * 1024;
133
+
134
+ export function logHook(env, line) {
135
+ try {
136
+ const dir = stateDir(env);
137
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
138
+ const file = path.join(dir, 'hook.log');
139
+ try {
140
+ if (fs.statSync(file).size > LOG_MAX_BYTES) fs.renameSync(file, `${file}.1`);
141
+ } catch {
142
+ // No log yet.
143
+ }
144
+ fs.appendFileSync(file, `${new Date().toISOString()} ${line}\n`, { mode: 0o600 });
145
+ } catch {
146
+ // Logging must never break a hook.
147
+ }
148
+ }
149
+
150
+ /** Sends from a detached process, so the hook exits at once and the agent never waits for the network. */
151
+ function deliverInBackground(alarm, env) {
152
+ const child = spawn(process.execPath, [BIN, '__deliver'], {
153
+ detached: true,
154
+ stdio: 'ignore',
155
+ windowsHide: true,
156
+ env: { ...env, AGENTALARM_DELIVERY: JSON.stringify(alarm) },
157
+ });
158
+ child.unref();
159
+ }
160
+
161
+ async function deliver(alarm, env) {
162
+ const creds = credentials(loadConfig(env), env);
163
+ try {
164
+ await createAlarm(creds, alarm);
165
+ logHook(env, `delivered ${alarm.type}: ${alarm.title}`);
166
+ } catch (error) {
167
+ logHook(env, `delivery failed: ${error.message}`);
168
+ }
169
+ }
170
+
171
+ async function runHook(agent, { stdin, stdout, env }) {
172
+ if (!AGENTS[agent]) {
173
+ logHook(env, `unknown agent "${agent}" (use ${Object.keys(AGENTS).join(', ')})`);
174
+ return EXIT.OK;
175
+ }
176
+ const raw = await readStdin(stdin);
177
+ let payload = {};
178
+ try {
179
+ payload = JSON.parse(raw || '{}');
180
+ } catch {
181
+ logHook(env, `${agent}: hook input is not JSON`);
182
+ }
183
+ stdout.write(hookOutput(agent, payload));
184
+
185
+ try {
186
+ let config = {};
187
+ try {
188
+ config = loadConfig(env);
189
+ } catch (error) {
190
+ logHook(env, `${agent}: ${error.message}`);
191
+ }
192
+ const result = await handleHook(agent, payload, {
193
+ store: new SessionStore(stateDir(env)),
194
+ now: () => new Date(),
195
+ settings: hookSettings(config, env),
196
+ pausedUntil: config.pausedUntil ?? null,
197
+ hasKey: Boolean(credentials(config, env).key),
198
+ idle: () => idleMs(),
199
+ deliver: (alarm) => (env.AGENTALARM_HOOK_SYNC === '1' ? deliver(alarm, env) : deliverInBackground(alarm, env)),
200
+ });
201
+ logHook(env, `${agent} ${payload.hook_event_name ?? '?'}: ${result.sent ? 'alert' : 'skip'} (${result.reason})`);
202
+ } catch (error) {
203
+ logHook(env, `${agent}: hook error: ${error.message}`);
204
+ }
205
+ return EXIT.OK;
206
+ }
207
+
208
+ // ---------------------------------------------------------------------------------------------------------------
209
+
210
+ function hostLabel() {
211
+ return os.hostname().split('.')[0].slice(0, 30) || 'this computer';
212
+ }
213
+
214
+ /** No --at: a notification now (a call with --ring). --at: a reminder at that time (the server's rule). */
215
+ function describeAlarm(alarm, requested) {
216
+ const rings = requested.type === 'urgent';
217
+ if (requested.fire_at) {
218
+ const when = alarm.rings_at ? ` for ${alarm.rings_at}` : '';
219
+ return `Reminder set${when}${rings ? ' (rings)' : ''}: ${requested.title}`;
220
+ }
221
+ return `${rings ? 'Call sent (rings now)' : 'Notification sent'}: ${requested.title}`;
222
+ }
223
+
224
+ async function send(title, options, io) {
225
+ if (!title) throw new CliError('Nothing to send. Usage: agentalarm "text" [--ring] [--at <time>]', EXIT.USAGE);
226
+ if (options.at !== undefined && !Number.isFinite(Date.parse(options.at))) {
227
+ throw new CliError(`--at: "${options.at}" is not a date and time like 2026-10-28T09:00.`, EXIT.USAGE);
228
+ }
229
+ const alarm = { type: options.urgent ? 'urgent' : 'normal', title };
230
+ for (const field of ['body', 'color', 'sound', 'icon', 'location']) if (options[field] !== undefined) alarm[field] = options[field];
231
+ if (options.at !== undefined) alarm.fire_at = options.at;
232
+
233
+ const created = await createAlarm(credentials(loadConfig(io.env), io.env), alarm);
234
+ io.stdout.write(options.json ? `${JSON.stringify(created, null, 2)}\n` : `${describeAlarm(created, alarm)}\n`);
235
+ return EXIT.OK;
236
+ }
237
+
238
+ function interactive(io) {
239
+ return Boolean(io.stdin.isTTY && io.stdout.isTTY);
240
+ }
241
+
242
+ async function withPrompt(io, fn) {
243
+ const rl = readline.createInterface({ input: io.stdin, output: io.stdout });
244
+ try {
245
+ return await fn((question) => rl.question(question));
246
+ } finally {
247
+ rl.close();
248
+ }
249
+ }
250
+
251
+ async function setup(options, io) {
252
+ const config = loadConfig(io.env);
253
+ let hooks;
254
+ if (options.defaults) {
255
+ hooks = { ...HOOK_DEFAULTS };
256
+ } else if (!interactive(io)) {
257
+ throw new CliError('Run "agentalarm setup" in a terminal, or "agentalarm setup --defaults".', EXIT.USAGE);
258
+ } else {
259
+ hooks = await withPrompt(io, (ask) => askHookSettings(ask, hookSettings(config, {}), (line) => io.stdout.write(`${line}\n`)));
260
+ }
261
+ saveConfig({ ...config, hooks }, io.env);
262
+ io.stdout.write(`Saved to ${configPath(io.env)}:\n${JSON.stringify(hooks, null, 2)}\n`);
263
+ return EXIT.OK;
264
+ }
265
+
266
+ async function setupUrlFrom(positional, options, io, announce) {
267
+ let text = positional.join(' ');
268
+ if (!text && !io.stdin.isTTY) text = await readStdin(io.stdin, { limit: 64_000, timeoutMs: 10_000 });
269
+ const setupUrl = extractSetupUrl(text, { server: options.server });
270
+ if (!setupUrl) {
271
+ throw new CliError('No AgentAlarm setup link found. In the app: Settings > Connected Agents > Connect, then: agentalarm login "<the copied text>"', EXIT.USAGE);
272
+ }
273
+ announce(`Redeeming the one-time link at ${new URL(setupUrl).origin} ...\n`);
274
+ return setupUrl;
275
+ }
276
+
277
+ /** A key for CI secrets: written only into a pipe, so it does not land in the terminal's scrollback. */
278
+ async function printKey(positional, options, io) {
279
+ if (io.stdout.isTTY) {
280
+ throw new CliError('The key is printed only into a pipe, e.g.: agentalarm key "<text>" | gh secret set AGENTALARM_KEY', EXIT.USAGE);
281
+ }
282
+ // stdout is the pipe that receives the key, so the origin goes to stderr.
283
+ const setupUrl = await setupUrlFrom(positional, options, io, (line) => io.stderr.write(line));
284
+ const redeemed = await redeemSetup(setupUrl, options.name || `agentalarm CI key from ${hostLabel()}`);
285
+ io.stdout.write(redeemed.key);
286
+ io.stderr.write(`Created the key "${redeemed.name}". Revoke it in the app under Connected Agents.\n`);
287
+ return EXIT.OK;
288
+ }
289
+
290
+ async function login(positional, options, io) {
291
+ const setupUrl = await setupUrlFrom(positional, options, io, (line) => io.stdout.write(line));
292
+ const redeemed = await redeemSetup(setupUrl, options.name || `agentalarm CLI on ${hostLabel()}`);
293
+ updateConfig((config) => ({ ...config, key: redeemed.key, keyName: redeemed.name, baseUrl: redeemed.baseUrl }), io.env);
294
+
295
+ const out = (line = '') => io.stdout.write(`${line}\n`);
296
+ out(`Connected as "${redeemed.name}". The key is saved in ${configPath(io.env)} (readable only by you).`);
297
+ if (redeemed.readOnly) out('Note: this key is read-only, so it cannot send alerts. Connect again without "read only".');
298
+ if (io.env.AGENTALARM_KEY) out('Note: AGENTALARM_KEY is set in this shell and wins over the saved key.');
299
+
300
+ if (!options.noSetup && interactive(io)) {
301
+ const answer = await withPrompt(io, (ask) => ask('Choose when agent hooks alert you now? [Y/n] '));
302
+ if (!/^n/i.test(answer.trim())) return setup({}, io);
303
+ }
304
+ out('Next: "agentalarm setup" chooses when hooks alert you; "agentalarm test" sends a test alert.');
305
+ return EXIT.OK;
306
+ }
307
+
308
+ async function status(io) {
309
+ const config = loadConfig(io.env);
310
+ const creds = credentials(config, io.env);
311
+ const out = (line = '') => io.stdout.write(`${line}\n`);
312
+ out(`Config: ${configPath(io.env)}`);
313
+ out(`Server: ${creds.baseUrl}`);
314
+ if (!creds.key) {
315
+ out('Not logged in. Run: npx agentalarm login "<the text copied from the AgentAlarm app>"');
316
+ return EXIT.AUTH;
317
+ }
318
+ const info = await me(creds);
319
+ const keyName = info.key && info.key.name ? info.key.name : config.keyName;
320
+ out(`Connected: key "${keyName}"${creds.fromEnv ? ' (from AGENTALARM_KEY)' : ''}, timezone ${info.timezone ?? 'unknown'}`);
321
+ if (info.push_registered === false) out('Warning: no iPhone registered for pushes yet. Open the AgentAlarm app once.');
322
+ const paused = Date.parse(config.pausedUntil ?? '');
323
+ out(Number.isFinite(paused) && paused > Date.now() ? `Hooks: paused until ${new Date(paused).toLocaleString()}` : 'Hooks: active');
324
+ out(`Hook settings: ${JSON.stringify(hookSettings(config, io.env))}`);
325
+ return EXIT.OK;
326
+ }
327
+
328
+ async function logout(options, io) {
329
+ const config = loadConfig(io.env);
330
+ if (!config.key) {
331
+ io.stdout.write('Not logged in on this computer.\n');
332
+ return EXIT.OK;
333
+ }
334
+ let revoked = false;
335
+ if (!options.local) {
336
+ try {
337
+ await revokeKey({ key: config.key, baseUrl: credentials(config, {}).baseUrl });
338
+ revoked = true;
339
+ } catch (error) {
340
+ io.stderr.write(`Could not revoke the key on the server (${error.message}). Revoke "${config.keyName ?? 'agentalarm CLI'}" in the app.\n`);
341
+ }
342
+ }
343
+ const { key, keyName, ...rest } = config;
344
+ saveConfig(rest, io.env);
345
+ io.stdout.write(`Logged out: the key is ${revoked ? 'revoked and ' : ''}removed from this computer.\n`);
346
+ return EXIT.OK;
347
+ }
348
+
349
+ function configCommand(args, io) {
350
+ const [action, name, ...value] = args;
351
+ const out = (line) => io.stdout.write(`${line}\n`);
352
+ if (action === undefined) {
353
+ out(JSON.stringify(hookSettings(loadConfig(io.env), io.env), null, 2));
354
+ return EXIT.OK;
355
+ }
356
+ const names = Object.keys(HOOK_DEFAULTS);
357
+ if (!['set', 'unset'].includes(action) || !names.includes(name) || (action === 'set' && value.length === 0)) {
358
+ throw new CliError(`Usage: agentalarm config set <option> <value> | unset <option>. Options: ${names.join(', ')}.`, EXIT.USAGE);
359
+ }
360
+ if (action === 'unset') {
361
+ updateConfig((config) => {
362
+ const { [name]: removed, ...hooks } = config.hooks ?? {};
363
+ return { ...config, hooks };
364
+ }, io.env);
365
+ out(`${name} is back to the default (${JSON.stringify(HOOK_DEFAULTS[name])}).`);
366
+ return EXIT.OK;
367
+ }
368
+ const parsed = parseHookOption(name, value.join(' '));
369
+ if (parsed === undefined) throw new CliError(`Invalid value for ${name}: "${value.join(' ')}".`, EXIT.USAGE);
370
+ updateConfig((config) => ({ ...config, hooks: { ...(config.hooks ?? {}), [name]: parsed } }), io.env);
371
+ out(`${name} = ${JSON.stringify(parsed)}${io.env[HOOK_ENV_NAMES[name]] !== undefined ? ` (note: ${HOOK_ENV_NAMES[name]} is set and wins)` : ''}`);
372
+ return EXIT.OK;
373
+ }
374
+
375
+ function pause(positional, io) {
376
+ const ms = parseDuration(positional[0] ?? '1h');
377
+ if (ms === null) throw new CliError('Usage: agentalarm pause [30m|1h|2d] (at most 30 days).', EXIT.USAGE);
378
+ const until = new Date(Date.now() + ms).toISOString();
379
+ updateConfig((config) => ({ ...config, pausedUntil: until }), io.env);
380
+ io.stdout.write(`Hook alerts paused until ${new Date(until).toLocaleString()}. "agentalarm resume" ends the pause.\n`);
381
+ return EXIT.OK;
382
+ }
383
+
384
+ function resume(io) {
385
+ updateConfig(({ pausedUntil, ...config }) => config, io.env);
386
+ io.stdout.write('Hook alerts resumed.\n');
387
+ return EXIT.OK;
388
+ }
389
+
390
+ async function deliverFromEnv(io) {
391
+ try {
392
+ await deliver(JSON.parse(io.env.AGENTALARM_DELIVERY || ''), io.env);
393
+ } catch (error) {
394
+ logHook(io.env, `delivery failed: ${error.message}`);
395
+ }
396
+ return EXIT.OK;
397
+ }
398
+
399
+ export async function main(argv, io = { stdin: process.stdin, stdout: process.stdout, stderr: process.stderr, env: process.env }) {
400
+ try {
401
+ const [command, ...rest] = argv;
402
+ // Hooks never fail the agent: no option parsing, no error exit.
403
+ if (command === 'hook') return await runHook(rest[0], io);
404
+ if (command === '__deliver') return await deliverFromEnv(io);
405
+ if (command === 'mcp') {
406
+ await runMcpProxy({ creds: credentials(loadConfig(io.env), io.env), input: io.stdin, output: io.stdout });
407
+ return EXIT.OK;
408
+ }
409
+
410
+ const { positional, options } = parseArgs(argv);
411
+ if (options.version) {
412
+ io.stdout.write(`${VERSION}\n`);
413
+ return EXIT.OK;
414
+ }
415
+ if (options.help || argv.length === 0 || command === 'help') {
416
+ io.stdout.write(`${HELP}\n`);
417
+ return argv.length === 0 ? EXIT.USAGE : EXIT.OK;
418
+ }
419
+
420
+ const [name, ...args] = positional;
421
+ switch (name) {
422
+ case 'login':
423
+ return await login(args, options, io);
424
+ case 'key':
425
+ return await printKey(args, options, io);
426
+ case 'logout':
427
+ return await logout(options, io);
428
+ case 'status':
429
+ return await status(io);
430
+ case 'setup':
431
+ return await setup(options, io);
432
+ case 'config':
433
+ return configCommand(args, io);
434
+ case 'pause':
435
+ return pause(args, io);
436
+ case 'resume':
437
+ return resume(io);
438
+ case 'test':
439
+ return await send('AgentAlarm test: your agents can reach you', { ...options, urgent: true, body: 'Sent by "agentalarm test". Tap Done to stop the ring.' }, io);
440
+ case 'send':
441
+ return await send(args.join(' ').trim(), options, io);
442
+ default:
443
+ return await send(positional.join(' ').trim(), options, io);
444
+ }
445
+ } catch (error) {
446
+ if (error instanceof CliError) {
447
+ io.stderr.write(`agentalarm: ${error.message}\n`);
448
+ return error.exitCode;
449
+ }
450
+ io.stderr.write(`agentalarm: unexpected error: ${error.message}\n`);
451
+ return EXIT.FAILURE;
452
+ }
453
+ }