claude-remote-approver 0.6.1 → 0.7.2

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/README.md CHANGED
@@ -128,6 +128,7 @@ claude-remote-approver status
128
128
  # Topic: cra-a1b2c3d4...
129
129
  # Server: https://ntfy.sh
130
130
  # Timeout: 120s
131
+ # Auth: not configured
131
132
  ```
132
133
 
133
134
  ### `enable`
@@ -197,6 +198,8 @@ Config file location: `~/.claude-remote-approver.json`
197
198
  | `planTimeout` | `number` | `300` | Seconds to wait for ExitPlanMode (plan approval) responses. Plan reviews need more reading time. |
198
199
  | `autoApprove` | `string[]` | `[]` | Reserved for future use. |
199
200
  | `autoDeny` | `string[]` | `[]` | Reserved for future use. |
201
+ | `ntfyUsername` | `string` | `""` | Username for ntfy Basic Auth. Set this if your ntfy server requires authentication. |
202
+ | `ntfyPassword` | `string` | `""` | Password for ntfy Basic Auth. Set this if your ntfy server requires authentication. |
200
203
 
201
204
  ### Using a self-hosted ntfy server
202
205
 
@@ -210,6 +213,39 @@ Edit `~/.claude-remote-approver.json` and set `ntfyServer` to your server URL:
210
213
 
211
214
  Then subscribe to the topic on your self-hosted server in the ntfy app.
212
215
 
216
+ ### Using authenticated topics
217
+
218
+ If your ntfy server requires authentication, you can configure Basic Auth credentials.
219
+
220
+ **Option 1: Interactive setup**
221
+
222
+ ```bash
223
+ claude-remote-approver setup
224
+ # ... after topic generation, you will be asked:
225
+ # Use authenticated topics? (y/n): y
226
+ # Username: myuser
227
+ # Password: mypassword
228
+ ```
229
+
230
+ **Option 2: Edit `~/.claude-remote-approver.json`**
231
+
232
+ ```json
233
+ {
234
+ "ntfyServer": "https://ntfy.example.com",
235
+ "ntfyUsername": "myuser",
236
+ "ntfyPassword": "mypassword"
237
+ }
238
+ ```
239
+
240
+ **Option 3: Environment variables**
241
+
242
+ ```bash
243
+ export NTFY_USERNAME=myuser
244
+ export NTFY_PASSWORD=mypassword
245
+ ```
246
+
247
+ Environment variables take priority over settings.json values. Credentials are included as `Authorization: Basic <base64>` headers in all requests to the ntfy server, including action button callbacks.
248
+
213
249
  ## How ntfy.sh works
214
250
 
215
251
  [ntfy.sh](https://ntfy.sh) is a simple HTTP-based pub-sub notification service. Any client can publish a message to a topic by sending a POST request, and any client subscribed to that topic receives the message as a push notification.
package/bin/cli.mjs CHANGED
@@ -40,7 +40,7 @@ export async function main(args, deps) {
40
40
  const isHttps = serverUrl.protocol === "https:";
41
41
  const ntfyUrl = isHttps
42
42
  ? `ntfy://${serverUrl.host}/${result.topic}`
43
- : `${result.ntfyServer.replace(/\/+$/, "")}/${result.topic}`;
43
+ : `ntfy://${serverUrl.host}/${result.topic}?secure=false`;
44
44
  const subscribeUrl = `${result.ntfyServer.replace(/\/+$/, "")}/${result.topic}`;
45
45
 
46
46
  deps.stdout.write("Scan this QR code in the ntfy app to subscribe:\n\n");
@@ -62,6 +62,7 @@ export async function main(args, deps) {
62
62
  deps.stderr.write("Error: No topic configured. Run 'claude-remote-approver setup' first.\n");
63
63
  break;
64
64
  }
65
+ const auth = deps.resolveAuth(config);
65
66
  try {
66
67
  await deps.sendNotification({
67
68
  server: config.ntfyServer,
@@ -70,6 +71,7 @@ export async function main(args, deps) {
70
71
  message: "Test notification - if you see this, setup is working!",
71
72
  actions: [],
72
73
  requestId: "test",
74
+ auth,
73
75
  });
74
76
  deps.stdout.write("Test notification sent successfully.\n");
75
77
  } catch (err) {
@@ -83,6 +85,12 @@ export async function main(args, deps) {
83
85
  deps.stdout.write(`Topic: ${config.topic}\n`);
84
86
  deps.stdout.write(`Server: ${config.ntfyServer}\n`);
85
87
  deps.stdout.write(`Timeout: ${config.timeout}s\n`);
88
+ const auth = deps.resolveAuth(config);
89
+ if (auth) {
90
+ deps.stdout.write(`Auth: configured (username: ${auth.username})\n`);
91
+ } else {
92
+ deps.stdout.write(`Auth: not configured\n`);
93
+ }
86
94
  break;
87
95
  }
88
96
 
@@ -184,7 +192,7 @@ const isMain =
184
192
  if (isMain) {
185
193
  const pkg = JSON.parse(fs.readFileSync(new URL("../package.json", import.meta.url), "utf-8"));
186
194
 
187
- const { loadConfig, saveConfig, generateTopic } = await import(
195
+ const { loadConfig, saveConfig, generateTopic, resolveAuth } = await import(
188
196
  "../src/config.mjs"
189
197
  );
190
198
  const { sendNotification, waitForResponse, formatToolInfo } = await import(
@@ -208,6 +216,7 @@ if (isMain) {
208
216
  loadConfig,
209
217
  saveConfig,
210
218
  generateTopic,
219
+ resolveAuth,
211
220
  sendNotification,
212
221
  waitForResponse,
213
222
  formatToolInfo,
@@ -225,6 +234,40 @@ if (isMain) {
225
234
  stderr: process.stderr,
226
235
  stdin: stdinData,
227
236
  exit: process.exit,
237
+ prompt: async (question) => {
238
+ const { createInterface } = await import("node:readline");
239
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
240
+ return new Promise((resolve) => rl.question(question, (answer) => { rl.close(); resolve(answer); }));
241
+ },
242
+ promptSecret: async (question) => {
243
+ process.stdout.write(question);
244
+ return new Promise((resolve) => {
245
+ let input = '';
246
+ process.stdin.setRawMode(true);
247
+ process.stdin.resume();
248
+ process.stdin.setEncoding('utf8');
249
+ const onData = (ch) => {
250
+ if (ch === '\r' || ch === '\n') {
251
+ process.stdin.setRawMode(false);
252
+ process.stdin.pause();
253
+ process.stdin.removeListener('data', onData);
254
+ process.stdout.write('\n');
255
+ resolve(input);
256
+ } else if (ch === '\u007f' || ch === '\b') {
257
+ if (input.length > 0) {
258
+ input = input.slice(0, -1);
259
+ process.stdout.write('\b \b');
260
+ }
261
+ } else if (ch === '\u0003') {
262
+ process.exit(0);
263
+ } else {
264
+ input += ch;
265
+ process.stdout.write('*');
266
+ }
267
+ };
268
+ process.stdin.on('data', onData);
269
+ });
270
+ },
228
271
  };
229
272
 
230
273
  await main(args, deps);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-remote-approver",
3
- "version": "0.6.1",
3
+ "version": "0.7.2",
4
4
  "description": "Approve or deny Claude Code permission prompts remotely from your phone via ntfy.sh",
5
5
  "type": "module",
6
6
  "bin": {
package/src/config.mjs CHANGED
@@ -13,6 +13,8 @@ export const DEFAULT_CONFIG = {
13
13
  // autoApprove/autoDeny are reserved for future use and not yet implemented
14
14
  autoApprove: [],
15
15
  autoDeny: [],
16
+ ntfyUsername: "",
17
+ ntfyPassword: "",
16
18
  };
17
19
 
18
20
  export function loadConfig(configPath = CONFIG_PATH) {
@@ -26,6 +28,8 @@ export function loadConfig(configPath = CONFIG_PATH) {
26
28
  if (!Number.isFinite(config.planTimeout) || config.planTimeout <= 0) config.planTimeout = DEFAULT_CONFIG.planTimeout;
27
29
  if (!Array.isArray(config.autoApprove)) config.autoApprove = DEFAULT_CONFIG.autoApprove;
28
30
  if (!Array.isArray(config.autoDeny)) config.autoDeny = DEFAULT_CONFIG.autoDeny;
31
+ if (typeof config.ntfyUsername !== "string") config.ntfyUsername = DEFAULT_CONFIG.ntfyUsername;
32
+ if (typeof config.ntfyPassword !== "string") config.ntfyPassword = DEFAULT_CONFIG.ntfyPassword;
29
33
  return config;
30
34
  } catch (err) {
31
35
  if (err.code === "ENOENT") {
@@ -42,3 +46,10 @@ export function saveConfig(config, configPath = CONFIG_PATH) {
42
46
  export function generateTopic() {
43
47
  return `cra-${crypto.randomBytes(16).toString("hex")}`;
44
48
  }
49
+
50
+ export function resolveAuth(config, env = process.env) {
51
+ const username = env.NTFY_USERNAME || config.ntfyUsername || "";
52
+ const password = env.NTFY_PASSWORD || config.ntfyPassword || "";
53
+ if (!username || !password) return null;
54
+ return { username, password };
55
+ }
package/src/hook.mjs CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  import crypto from "node:crypto";
4
4
  import { DEFAULT_CONFIG } from "./config.mjs";
5
+ import { buildAuthHeader } from "./ntfy.mjs";
5
6
 
6
7
  export const ASK = Object.freeze({ hookSpecificOutput: Object.freeze({ hookEventName: "PermissionRequest", decision: Object.freeze({ behavior: "ask" }) }) });
7
8
  const DENY = Object.freeze({ hookSpecificOutput: Object.freeze({ hookEventName: "PermissionRequest", decision: Object.freeze({ behavior: "deny" }) }) });
@@ -20,8 +21,9 @@ export const _internal = { delay: ms => new Promise(r => setTimeout(r, ms)) };
20
21
  * @param {string[]} [options.permissionSuggestions] - When non-empty, adds an "Always Approve" button
21
22
  * @returns {Array<object>} Array of action objects
22
23
  */
23
- export function buildActions(server, topic, requestId, { permissionSuggestions } = {}) {
24
+ export function buildActions(server, topic, requestId, { permissionSuggestions, auth } = {}) {
24
25
  const url = `${server}/${topic}-response`;
26
+ const authHeaders = auth ? buildAuthHeader(auth) : undefined;
25
27
  const actions = [
26
28
  {
27
29
  action: "http",
@@ -29,6 +31,7 @@ export function buildActions(server, topic, requestId, { permissionSuggestions }
29
31
  url,
30
32
  body: JSON.stringify({ requestId, approved: true }),
31
33
  method: "POST",
34
+ ...(authHeaders && { headers: authHeaders }),
32
35
  },
33
36
  {
34
37
  action: "http",
@@ -36,6 +39,7 @@ export function buildActions(server, topic, requestId, { permissionSuggestions }
36
39
  url,
37
40
  body: JSON.stringify({ requestId, approved: false }),
38
41
  method: "POST",
42
+ ...(authHeaders && { headers: authHeaders }),
39
43
  },
40
44
  ];
41
45
  if (permissionSuggestions?.length > 0) {
@@ -45,6 +49,7 @@ export function buildActions(server, topic, requestId, { permissionSuggestions }
45
49
  url,
46
50
  body: JSON.stringify({ requestId, approved: true, alwaysAllow: true }),
47
51
  method: "POST",
52
+ ...(authHeaders && { headers: authHeaders }),
48
53
  });
49
54
  }
50
55
  return actions;
@@ -83,14 +88,16 @@ export function isAskUserQuestion(input) {
83
88
  /**
84
89
  * Build ntfy action buttons for question options.
85
90
  */
86
- export function buildQuestionActions(server, topic, requestId, options) {
91
+ export function buildQuestionActions(server, topic, requestId, options, { auth } = {}) {
87
92
  const url = `${server}/${topic}-response`;
93
+ const authHeaders = auth ? buildAuthHeader(auth) : undefined;
88
94
  return options.map((opt) => ({
89
95
  action: "http",
90
96
  label: opt.label,
91
97
  url,
92
98
  body: JSON.stringify({ requestId, answer: opt.label }),
93
99
  method: "POST",
100
+ ...(authHeaders && { headers: authHeaders }),
94
101
  }));
95
102
  }
96
103
 
@@ -116,6 +123,7 @@ export async function processAskUserQuestion(input, deps) {
116
123
  const config = deps.loadConfig();
117
124
  if (!config.topic) return ASK;
118
125
 
126
+ const auth = deps.resolveAuth ? deps.resolveAuth(config) : null;
119
127
  const questions = input.tool_input.questions;
120
128
  const answers = {};
121
129
 
@@ -132,7 +140,7 @@ export async function processAskUserQuestion(input, deps) {
132
140
  for (let i = 0; i < batches.length; i++) {
133
141
  const batch = batches[i];
134
142
  const batchInfo = batches.length > 1 ? `(${i + 1}/${batches.length})` : undefined;
135
- const actions = buildQuestionActions(config.ntfyServer, config.topic, requestId, batch);
143
+ const actions = buildQuestionActions(config.ntfyServer, config.topic, requestId, batch, { ...(auth && { auth }) });
136
144
  const message = buildQuestionMessage(q.question, batch, { multiSelect: q.multiSelect, batchInfo });
137
145
 
138
146
  const sent = await sendWithRetry(deps.sendNotification, {
@@ -142,6 +150,7 @@ export async function processAskUserQuestion(input, deps) {
142
150
  message,
143
151
  actions,
144
152
  requestId,
153
+ ...(auth && { auth }),
145
154
  });
146
155
  if (!sent) return ASK;
147
156
  }
@@ -154,6 +163,7 @@ export async function processAskUserQuestion(input, deps) {
154
163
  topic: config.topic,
155
164
  requestId,
156
165
  timeout: config.timeout * 1000,
166
+ ...(auth && { auth }),
157
167
  });
158
168
  } catch (err) {
159
169
  console.error("[claude-remote-approver] Response listener failed:", err.message, "— Falling back to CLI.");
@@ -193,21 +203,24 @@ export async function processAskUserQuestion(input, deps) {
193
203
  * @param {Function} deps.formatToolInfo
194
204
  * @returns {Promise<object>} Decision JSON
195
205
  */
196
- export async function processHook(input, { loadConfig, sendNotification, waitForResponse, formatToolInfo }) {
206
+ export async function processHook(input, { loadConfig, sendNotification, waitForResponse, formatToolInfo, resolveAuth }) {
197
207
  const config = loadConfig();
198
208
 
199
209
  if (!config.topic) {
200
210
  return ASK;
201
211
  }
202
212
 
213
+ const auth = resolveAuth ? resolveAuth(config) : null;
214
+
203
215
  if (isAskUserQuestion(input)) {
204
- return processAskUserQuestion(input, { loadConfig, sendNotification, waitForResponse });
216
+ return processAskUserQuestion(input, { loadConfig, sendNotification, waitForResponse, resolveAuth });
205
217
  }
206
218
 
207
219
  const requestId = crypto.randomUUID();
208
220
  const { title, message } = formatToolInfo(input);
209
221
  const actions = buildActions(config.ntfyServer, config.topic, requestId, {
210
222
  permissionSuggestions: input.permission_suggestions,
223
+ ...(auth && { auth }),
211
224
  });
212
225
 
213
226
  const sent = await sendWithRetry(sendNotification, {
@@ -217,6 +230,7 @@ export async function processHook(input, { loadConfig, sendNotification, waitFor
217
230
  message,
218
231
  actions,
219
232
  requestId,
233
+ ...(auth && { auth }),
220
234
  });
221
235
  if (!sent) return ASK;
222
236
 
@@ -229,6 +243,7 @@ export async function processHook(input, { loadConfig, sendNotification, waitFor
229
243
  topic: config.topic,
230
244
  requestId,
231
245
  timeout,
246
+ ...(auth && { auth }),
232
247
  });
233
248
  } catch (err) {
234
249
  console.error("[claude-remote-approver] Response listener failed:", err.message, "— Falling back to CLI.");
package/src/ntfy.mjs CHANGED
@@ -1,18 +1,23 @@
1
1
  // src/ntfy.mjs
2
2
 
3
+ export function buildAuthHeader(auth) {
4
+ if (!auth) return {};
5
+ return { Authorization: `Basic ${Buffer.from(auth.username + ':' + auth.password).toString('base64')}` };
6
+ }
7
+
3
8
  /**
4
9
  * Send a push notification via ntfy.
5
10
  *
6
11
  * @param {{ server: string, topic: string, title: string, message: string, actions: unknown[], requestId: string }} params
7
12
  * @returns {Promise<Response>}
8
13
  */
9
- export async function sendNotification({ server, topic, title, message, actions, requestId }) {
14
+ export async function sendNotification({ server, topic, title, message, actions, requestId, auth }) {
10
15
  const baseUrl = server.replace(/\/+$/, '');
11
16
  const url = baseUrl;
12
17
 
13
18
  const response = await fetch(url, {
14
19
  method: 'POST',
15
- headers: { 'Content-Type': 'application/json' },
20
+ headers: { 'Content-Type': 'application/json', ...buildAuthHeader(auth) },
16
21
  body: JSON.stringify({ topic, title, message, actions }),
17
22
  });
18
23
 
@@ -29,7 +34,7 @@ export async function sendNotification({ server, topic, title, message, actions,
29
34
  * @param {{ server: string, topic: string, requestId: string, timeout: number }} params
30
35
  * @returns {Promise<{ approved: boolean } | { timeout: true } | { error: Error } | { answer: string }>}
31
36
  */
32
- export async function waitForResponse({ server, topic, requestId, timeout }) {
37
+ export async function waitForResponse({ server, topic, requestId, timeout, auth }) {
33
38
  const baseUrl = server.replace(/\/+$/, '');
34
39
  const url = `${baseUrl}/${topic}-response/json`;
35
40
 
@@ -39,7 +44,8 @@ export async function waitForResponse({ server, topic, requestId, timeout }) {
39
44
  let timer;
40
45
 
41
46
  try {
42
- const response = await fetch(url, { signal: controller.signal });
47
+ const authHeaders = auth ? buildAuthHeader(auth) : undefined;
48
+ const response = await fetch(url, { signal: controller.signal, ...(authHeaders && { headers: authHeaders }) });
43
49
  const reader = response.body.getReader();
44
50
  const decoder = new TextDecoder();
45
51
  let buffer = '';
@@ -127,6 +133,7 @@ export function stripMarkdown(text) {
127
133
  // ---------------------------------------------------------------------------
128
134
 
129
135
  const MAX_INPUT = 10000;
136
+ const MESSAGE_MAX_LENGTH = 1000;
130
137
 
131
138
  /**
132
139
  * Count consecutive runs of character ch starting at pos.
@@ -442,37 +449,32 @@ function stripInline(text) {
442
449
  * @returns {{ title: string, message: string }}
443
450
  */
444
451
  export function formatToolInfo({ hook_event_name, tool_name, tool_input }) {
445
- // Plan approval detection
452
+ let title;
453
+ let message;
454
+
446
455
  if (tool_name === 'ExitPlanMode' && typeof tool_input?.plan === 'string') {
447
- const PLAN_MESSAGE_MAX_LENGTH = 300;
448
- const title = 'Claude Code: Plan Review';
449
- if (!tool_input.plan.trim()) {
450
- return { title, message: '(empty plan)' };
456
+ title = 'Claude Code: Plan Review';
457
+ const plain = tool_input.plan.trim() ? stripMarkdown(tool_input.plan) : '';
458
+ message = plain || '(empty plan)';
459
+ } else {
460
+ title = `Claude Code: ${tool_name}`;
461
+ switch (tool_name) {
462
+ case 'Bash':
463
+ message = tool_input?.command ?? JSON.stringify(tool_input);
464
+ break;
465
+ case 'Read':
466
+ case 'Write':
467
+ case 'Edit':
468
+ message = tool_input?.file_path ?? JSON.stringify(tool_input);
469
+ break;
470
+ default:
471
+ message = JSON.stringify(tool_input);
472
+ break;
451
473
  }
452
- const raw = tool_input.plan;
453
- const plain = stripMarkdown(raw);
454
- const message = plain
455
- ? (plain.length > PLAN_MESSAGE_MAX_LENGTH ? plain.slice(0, PLAN_MESSAGE_MAX_LENGTH) + '...' : plain)
456
- : '(empty plan)';
457
- return { title, message };
458
474
  }
459
475
 
460
- const title = `Claude Code: ${tool_name}`;
461
- let message;
462
-
463
- switch (tool_name) {
464
- case 'Bash':
465
- message = tool_input?.command ?? JSON.stringify(tool_input);
466
- break;
467
- case 'Read':
468
- case 'Write':
469
- case 'Edit':
470
- message = tool_input?.file_path ?? JSON.stringify(tool_input);
471
- break;
472
- default:
473
- message = JSON.stringify(tool_input);
474
- break;
476
+ if (message.length > MESSAGE_MAX_LENGTH) {
477
+ message = message.slice(0, MESSAGE_MAX_LENGTH) + '...';
475
478
  }
476
-
477
479
  return { title, message };
478
480
  }
package/src/setup.mjs CHANGED
@@ -105,11 +105,23 @@ export async function runSetup({
105
105
  generateTopic,
106
106
  saveConfig,
107
107
  loadConfig,
108
+ prompt,
109
+ promptSecret,
108
110
  }) {
109
111
  const topic = generateTopic();
110
112
 
111
113
  const config = loadConfig(configPath);
112
114
  config.topic = topic;
115
+
116
+ if (prompt) {
117
+ const useAuth = await prompt("Use authenticated topics? (y/n): ");
118
+ if (useAuth?.toLowerCase() === "y") {
119
+ config.ntfyUsername = await prompt("Username: ");
120
+ const promptSecretFn = promptSecret || prompt;
121
+ config.ntfyPassword = await promptSecretFn("Password: ");
122
+ }
123
+ }
124
+
113
125
  saveConfig(config, configPath);
114
126
 
115
127
  const hookCommand = getHookCommand();