claude-remote-approver 0.5.1 → 0.6.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/README.md +21 -7
- package/package.json +1 -1
- package/src/hook.mjs +29 -6
- package/src/ntfy.mjs +1 -1
package/README.md
CHANGED
|
@@ -21,22 +21,36 @@ cli.mjs hook
|
|
|
21
21
|
│
|
|
22
22
|
├──POST──▶ ntfy.sh/<topic> ──push──▶ Phone (ntfy app)
|
|
23
23
|
│ │
|
|
24
|
-
│
|
|
24
|
+
│ Approve / Always Allow / Deny tap
|
|
25
25
|
│ │
|
|
26
26
|
└──SSE───▶ ntfy.sh/<topic>-response ◀──POST──┘
|
|
27
27
|
│
|
|
28
|
-
│ stdout JSON:
|
|
28
|
+
│ stdout JSON: allow / deny / ask (CLI fallback)
|
|
29
29
|
▼
|
|
30
30
|
Claude Code continues or stops
|
|
31
31
|
```
|
|
32
32
|
|
|
33
33
|
1. Claude Code invokes the hook, piping the tool request as JSON to stdin.
|
|
34
|
-
2. `cli.mjs hook` sends a notification to your ntfy topic with **Approve** and **Deny** action buttons.
|
|
34
|
+
2. `cli.mjs hook` sends a notification to your ntfy topic with **Approve**, **Always Allow**, and **Deny** action buttons.
|
|
35
35
|
3. The hook subscribes to a response topic (`<topic>-response`) via server-sent events.
|
|
36
36
|
4. When you tap a button on your phone, ntfy.sh publishes your decision to the response topic.
|
|
37
|
-
5. The hook reads the decision
|
|
37
|
+
5. The hook reads the decision and writes `{"behavior":"allow"}` or `{"behavior":"deny"}` to stdout. If the notification fails or times out, the hook returns `{"behavior":"ask"}` so Claude Code falls back to the CLI prompt.
|
|
38
38
|
6. Claude Code proceeds accordingly.
|
|
39
39
|
|
|
40
|
+
### AskUserQuestion support
|
|
41
|
+
|
|
42
|
+
When Claude Code calls the `AskUserQuestion` tool, the hook sends the question to your phone as a notification. Each option appears as an action button you can tap. If the question has more than 3 options, they are split across multiple notifications. If no response is received before the timeout, the hook falls back to the CLI prompt so you can answer at your terminal.
|
|
43
|
+
|
|
44
|
+
### Always Allow
|
|
45
|
+
|
|
46
|
+
When Claude Code sends a `permission_suggestions` field with the hook request (indicating the tool can be auto-approved in future), the notification shows three buttons: **Approve**, **Always Allow**, and **Deny**.
|
|
47
|
+
|
|
48
|
+
- **Approve** -- Allow this one request.
|
|
49
|
+
- **Always Allow** -- Allow this request and tell Claude Code to auto-approve this tool in future sessions. Claude Code adds the permission rule to its settings so it won't ask again.
|
|
50
|
+
- **Deny** -- Reject this request.
|
|
51
|
+
|
|
52
|
+
If the hook request does not include `permission_suggestions`, only the standard **Approve** and **Deny** buttons are shown.
|
|
53
|
+
|
|
40
54
|
## Quick Start
|
|
41
55
|
|
|
42
56
|
```bash
|
|
@@ -179,7 +193,7 @@ Config file location: `~/.claude-remote-approver.json`
|
|
|
179
193
|
|---|---|---|---|
|
|
180
194
|
| `topic` | `string` | `""` | Your unique ntfy topic. Generated by `setup`. |
|
|
181
195
|
| `ntfyServer` | `string` | `"https://ntfy.sh"` | The ntfy server URL. Change this if you self-host. |
|
|
182
|
-
| `timeout` | `number` | `120` | Seconds to wait for a response before
|
|
196
|
+
| `timeout` | `number` | `120` | Seconds to wait for a response before falling back to CLI. |
|
|
183
197
|
| `planTimeout` | `number` | `300` | Seconds to wait for ExitPlanMode (plan approval) responses. Plan reviews need more reading time. |
|
|
184
198
|
| `autoApprove` | `string[]` | `[]` | Reserved for future use. |
|
|
185
199
|
| `autoDeny` | `string[]` | `[]` | Reserved for future use. |
|
|
@@ -225,7 +239,7 @@ The public ntfy.sh server is convenient but means your permission request detail
|
|
|
225
239
|
|
|
226
240
|
### Timeout behavior
|
|
227
241
|
|
|
228
|
-
If no response is received within the configured timeout (default: 120 seconds), the hook
|
|
242
|
+
If no response is received within the configured timeout (default: 120 seconds), the hook falls back to the **CLI prompt** (`ask`), so you can still respond at your terminal.
|
|
229
243
|
|
|
230
244
|
## Disclaimer
|
|
231
245
|
|
|
@@ -234,7 +248,7 @@ If no response is received within the configured timeout (default: 120 seconds),
|
|
|
234
248
|
The authors are not responsible for any damages or losses arising from the use of this tool, including but not limited to:
|
|
235
249
|
|
|
236
250
|
- Accidental approval of dangerous commands (e.g., mistapping Approve on your phone)
|
|
237
|
-
- Unintended
|
|
251
|
+
- Unintended CLI fallback when away from terminal (e.g., timeout, network issues)
|
|
238
252
|
- Security breaches if the topic name is compromised
|
|
239
253
|
|
|
240
254
|
**Not a substitute for careful review.** The push notification shows the tool name and a brief summary, but not the full context of what Claude Code is doing. Always review what you are approving.
|
package/package.json
CHANGED
package/src/hook.mjs
CHANGED
|
@@ -6,18 +6,23 @@ import { DEFAULT_CONFIG } from "./config.mjs";
|
|
|
6
6
|
export const ASK = Object.freeze({ hookSpecificOutput: Object.freeze({ hookEventName: "PermissionRequest", decision: Object.freeze({ behavior: "ask" }) }) });
|
|
7
7
|
const DENY = Object.freeze({ hookSpecificOutput: Object.freeze({ hookEventName: "PermissionRequest", decision: Object.freeze({ behavior: "deny" }) }) });
|
|
8
8
|
const MAX_RETRIES = 3;
|
|
9
|
+
export const RETRY_DELAY_MS = 1000;
|
|
10
|
+
/** @internal Replaceable delay for testing. Do not use outside of tests. */
|
|
11
|
+
export const _internal = { delay: ms => new Promise(r => setTimeout(r, ms)) };
|
|
9
12
|
|
|
10
13
|
/**
|
|
11
|
-
* Build ntfy action buttons for Approve / Deny.
|
|
14
|
+
* Build ntfy action buttons for Approve / Deny (and optionally Always Allow).
|
|
12
15
|
*
|
|
13
16
|
* @param {string} server - ntfy server URL
|
|
14
17
|
* @param {string} topic - ntfy topic
|
|
15
18
|
* @param {string} requestId - Unique request identifier
|
|
16
|
-
* @
|
|
19
|
+
* @param {object} [options] - Optional settings
|
|
20
|
+
* @param {string[]} [options.permissionSuggestions] - When non-empty, adds an "Always Allow" button
|
|
21
|
+
* @returns {Array<object>} Array of action objects
|
|
17
22
|
*/
|
|
18
|
-
export function buildActions(server, topic, requestId) {
|
|
23
|
+
export function buildActions(server, topic, requestId, { permissionSuggestions } = {}) {
|
|
19
24
|
const url = `${server}/${topic}-response`;
|
|
20
|
-
|
|
25
|
+
const actions = [
|
|
21
26
|
{
|
|
22
27
|
action: "http",
|
|
23
28
|
label: "Approve",
|
|
@@ -33,10 +38,21 @@ export function buildActions(server, topic, requestId) {
|
|
|
33
38
|
method: "POST",
|
|
34
39
|
},
|
|
35
40
|
];
|
|
41
|
+
if (permissionSuggestions?.length > 0) {
|
|
42
|
+
actions.splice(1, 0, {
|
|
43
|
+
action: "http",
|
|
44
|
+
label: "Always Allow",
|
|
45
|
+
url,
|
|
46
|
+
body: JSON.stringify({ requestId, approved: true, alwaysAllow: true }),
|
|
47
|
+
method: "POST",
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
return actions;
|
|
36
51
|
}
|
|
37
52
|
|
|
38
53
|
/**
|
|
39
54
|
* Send with retry, returning null on exhausted retries.
|
|
55
|
+
* Uses linear backoff: delay = RETRY_DELAY_MS * attempt (1s, 2s, …).
|
|
40
56
|
*/
|
|
41
57
|
export async function sendWithRetry(sendFn, params) {
|
|
42
58
|
for (let i = 0; i < MAX_RETRIES; i++) {
|
|
@@ -47,6 +63,7 @@ export async function sendWithRetry(sendFn, params) {
|
|
|
47
63
|
console.error(`[claude-remote-approver] Notification failed after ${MAX_RETRIES} attempts:`, err.message, "— Falling back to CLI.");
|
|
48
64
|
return null;
|
|
49
65
|
}
|
|
66
|
+
await _internal.delay(RETRY_DELAY_MS * (i + 1));
|
|
50
67
|
}
|
|
51
68
|
}
|
|
52
69
|
return null;
|
|
@@ -189,7 +206,9 @@ export async function processHook(input, { loadConfig, sendNotification, waitFor
|
|
|
189
206
|
|
|
190
207
|
const requestId = crypto.randomUUID();
|
|
191
208
|
const { title, message } = formatToolInfo(input);
|
|
192
|
-
const actions = buildActions(config.ntfyServer, config.topic, requestId
|
|
209
|
+
const actions = buildActions(config.ntfyServer, config.topic, requestId, {
|
|
210
|
+
permissionSuggestions: input.permission_suggestions,
|
|
211
|
+
});
|
|
193
212
|
|
|
194
213
|
const sent = await sendWithRetry(sendNotification, {
|
|
195
214
|
server: config.ntfyServer,
|
|
@@ -225,5 +244,9 @@ export async function processHook(input, { loadConfig, sendNotification, waitFor
|
|
|
225
244
|
return ASK;
|
|
226
245
|
}
|
|
227
246
|
if (response.approved === false) return DENY;
|
|
228
|
-
|
|
247
|
+
const decision = { behavior: "allow" };
|
|
248
|
+
if (response.alwaysAllow === true && input.permission_suggestions?.length > 0) {
|
|
249
|
+
decision.updatedPermissions = input.permission_suggestions;
|
|
250
|
+
}
|
|
251
|
+
return { hookSpecificOutput: { hookEventName: "PermissionRequest", decision } };
|
|
229
252
|
}
|
package/src/ntfy.mjs
CHANGED
|
@@ -73,7 +73,7 @@ export async function waitForResponse({ server, topic, requestId, timeout }) {
|
|
|
73
73
|
if (typeof parsed.answer === 'string') {
|
|
74
74
|
return { answer: parsed.answer };
|
|
75
75
|
}
|
|
76
|
-
return { approved: parsed.approved };
|
|
76
|
+
return { approved: parsed.approved, alwaysAllow: parsed.alwaysAllow === true };
|
|
77
77
|
}
|
|
78
78
|
} catch {
|
|
79
79
|
// skip non-JSON lines
|