claude-remote-approver 0.4.1 → 0.5.1
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 +2 -0
- package/bin/cli.mjs +5 -4
- package/package.json +1 -1
- package/src/config.mjs +3 -1
- package/src/hook.mjs +164 -17
- package/src/ntfy.mjs +9 -5
package/README.md
CHANGED
|
@@ -169,6 +169,7 @@ Config file location: `~/.claude-remote-approver.json`
|
|
|
169
169
|
"topic": "cra-a1b2c3d4e5f67890abcdef1234567890",
|
|
170
170
|
"ntfyServer": "https://ntfy.sh",
|
|
171
171
|
"timeout": 120,
|
|
172
|
+
"planTimeout": 300,
|
|
172
173
|
"autoApprove": [],
|
|
173
174
|
"autoDeny": []
|
|
174
175
|
}
|
|
@@ -179,6 +180,7 @@ Config file location: `~/.claude-remote-approver.json`
|
|
|
179
180
|
| `topic` | `string` | `""` | Your unique ntfy topic. Generated by `setup`. |
|
|
180
181
|
| `ntfyServer` | `string` | `"https://ntfy.sh"` | The ntfy server URL. Change this if you self-host. |
|
|
181
182
|
| `timeout` | `number` | `120` | Seconds to wait for a response before auto-denying. |
|
|
183
|
+
| `planTimeout` | `number` | `300` | Seconds to wait for ExitPlanMode (plan approval) responses. Plan reviews need more reading time. |
|
|
182
184
|
| `autoApprove` | `string[]` | `[]` | Reserved for future use. |
|
|
183
185
|
| `autoDeny` | `string[]` | `[]` | Reserved for future use. |
|
|
184
186
|
|
package/bin/cli.mjs
CHANGED
|
@@ -12,6 +12,7 @@ import fs from "node:fs";
|
|
|
12
12
|
import path from "node:path";
|
|
13
13
|
import os from "node:os";
|
|
14
14
|
import qrcode from "qrcode-terminal";
|
|
15
|
+
import { ASK } from "../src/hook.mjs";
|
|
15
16
|
|
|
16
17
|
// ---------------------------------------------------------------------------
|
|
17
18
|
// main
|
|
@@ -87,8 +88,8 @@ export async function main(args, deps) {
|
|
|
87
88
|
try {
|
|
88
89
|
input = JSON.parse(deps.stdin);
|
|
89
90
|
} catch {
|
|
90
|
-
|
|
91
|
-
deps.stdout.write(JSON.stringify(
|
|
91
|
+
deps.stderr.write("[claude-remote-approver] Invalid hook input. Falling back to CLI.\n");
|
|
92
|
+
deps.stdout.write(JSON.stringify(ASK) + "\n");
|
|
92
93
|
break;
|
|
93
94
|
}
|
|
94
95
|
|
|
@@ -96,8 +97,8 @@ export async function main(args, deps) {
|
|
|
96
97
|
try {
|
|
97
98
|
result = await deps.processHook(input, deps);
|
|
98
99
|
} catch {
|
|
99
|
-
|
|
100
|
-
deps.stdout.write(JSON.stringify(
|
|
100
|
+
deps.stderr.write("[claude-remote-approver] Hook processing failed. Falling back to CLI.\n");
|
|
101
|
+
deps.stdout.write(JSON.stringify(ASK) + "\n");
|
|
101
102
|
break;
|
|
102
103
|
}
|
|
103
104
|
|
package/package.json
CHANGED
package/src/config.mjs
CHANGED
|
@@ -9,6 +9,7 @@ export const DEFAULT_CONFIG = {
|
|
|
9
9
|
topic: "",
|
|
10
10
|
ntfyServer: "https://ntfy.sh",
|
|
11
11
|
timeout: 120,
|
|
12
|
+
planTimeout: 300,
|
|
12
13
|
// autoApprove/autoDeny are reserved for future use and not yet implemented
|
|
13
14
|
autoApprove: [],
|
|
14
15
|
autoDeny: [],
|
|
@@ -21,7 +22,8 @@ export function loadConfig(configPath = CONFIG_PATH) {
|
|
|
21
22
|
const config = { ...DEFAULT_CONFIG, ...fileConfig };
|
|
22
23
|
if (typeof config.topic !== "string") config.topic = DEFAULT_CONFIG.topic;
|
|
23
24
|
if (typeof config.ntfyServer !== "string") config.ntfyServer = DEFAULT_CONFIG.ntfyServer;
|
|
24
|
-
if (
|
|
25
|
+
if (!Number.isFinite(config.timeout) || config.timeout <= 0) config.timeout = DEFAULT_CONFIG.timeout;
|
|
26
|
+
if (!Number.isFinite(config.planTimeout) || config.planTimeout <= 0) config.planTimeout = DEFAULT_CONFIG.planTimeout;
|
|
25
27
|
if (!Array.isArray(config.autoApprove)) config.autoApprove = DEFAULT_CONFIG.autoApprove;
|
|
26
28
|
if (!Array.isArray(config.autoDeny)) config.autoDeny = DEFAULT_CONFIG.autoDeny;
|
|
27
29
|
return config;
|
package/src/hook.mjs
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
// src/hook.mjs
|
|
2
2
|
|
|
3
3
|
import crypto from "node:crypto";
|
|
4
|
+
import { DEFAULT_CONFIG } from "./config.mjs";
|
|
5
|
+
|
|
6
|
+
export const ASK = Object.freeze({ hookSpecificOutput: Object.freeze({ hookEventName: "PermissionRequest", decision: Object.freeze({ behavior: "ask" }) }) });
|
|
7
|
+
const DENY = Object.freeze({ hookSpecificOutput: Object.freeze({ hookEventName: "PermissionRequest", decision: Object.freeze({ behavior: "deny" }) }) });
|
|
8
|
+
const MAX_RETRIES = 3;
|
|
4
9
|
|
|
5
10
|
/**
|
|
6
11
|
* Build ntfy action buttons for Approve / Deny.
|
|
@@ -30,6 +35,136 @@ export function buildActions(server, topic, requestId) {
|
|
|
30
35
|
];
|
|
31
36
|
}
|
|
32
37
|
|
|
38
|
+
/**
|
|
39
|
+
* Send with retry, returning null on exhausted retries.
|
|
40
|
+
*/
|
|
41
|
+
export async function sendWithRetry(sendFn, params) {
|
|
42
|
+
for (let i = 0; i < MAX_RETRIES; i++) {
|
|
43
|
+
try {
|
|
44
|
+
return await sendFn(params);
|
|
45
|
+
} catch (err) {
|
|
46
|
+
if (i === MAX_RETRIES - 1) {
|
|
47
|
+
console.error(`[claude-remote-approver] Notification failed after ${MAX_RETRIES} attempts:`, err.message, "— Falling back to CLI.");
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Check if the input is an AskUserQuestion tool call with questions.
|
|
57
|
+
*/
|
|
58
|
+
export function isAskUserQuestion(input) {
|
|
59
|
+
return (
|
|
60
|
+
input?.tool_name === "AskUserQuestion" &&
|
|
61
|
+
Array.isArray(input?.tool_input?.questions) &&
|
|
62
|
+
input.tool_input.questions.length > 0
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Build ntfy action buttons for question options.
|
|
68
|
+
*/
|
|
69
|
+
export function buildQuestionActions(server, topic, requestId, options) {
|
|
70
|
+
const url = `${server}/${topic}-response`;
|
|
71
|
+
return options.map((opt) => ({
|
|
72
|
+
action: "http",
|
|
73
|
+
label: opt.label,
|
|
74
|
+
url,
|
|
75
|
+
body: JSON.stringify({ requestId, answer: opt.label }),
|
|
76
|
+
method: "POST",
|
|
77
|
+
}));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Build a human-readable message for a question with options.
|
|
82
|
+
*/
|
|
83
|
+
export function buildQuestionMessage(question, options, opts = {}) {
|
|
84
|
+
const { multiSelect, batchInfo } = opts;
|
|
85
|
+
let msg = question;
|
|
86
|
+
if (batchInfo) msg += ` ${batchInfo}`;
|
|
87
|
+
if (multiSelect) msg += "\n(multiple selections allowed)";
|
|
88
|
+
msg += "\n\n";
|
|
89
|
+
for (const opt of options) {
|
|
90
|
+
msg += `• ${opt.label}: ${opt.description}\n`;
|
|
91
|
+
}
|
|
92
|
+
return msg.trimEnd();
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Process an AskUserQuestion hook request.
|
|
97
|
+
*/
|
|
98
|
+
export async function processAskUserQuestion(input, deps) {
|
|
99
|
+
const config = deps.loadConfig();
|
|
100
|
+
if (!config.topic) return ASK;
|
|
101
|
+
|
|
102
|
+
const questions = input.tool_input.questions;
|
|
103
|
+
const answers = {};
|
|
104
|
+
|
|
105
|
+
for (const q of questions) {
|
|
106
|
+
const requestId = crypto.randomUUID();
|
|
107
|
+
const options = q.options;
|
|
108
|
+
|
|
109
|
+
const MAX_BUTTONS = 3;
|
|
110
|
+
const batches = [];
|
|
111
|
+
for (let j = 0; j < options.length; j += MAX_BUTTONS) {
|
|
112
|
+
batches.push(options.slice(j, j + MAX_BUTTONS));
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
for (let i = 0; i < batches.length; i++) {
|
|
116
|
+
const batch = batches[i];
|
|
117
|
+
const batchInfo = batches.length > 1 ? `(${i + 1}/${batches.length})` : undefined;
|
|
118
|
+
const actions = buildQuestionActions(config.ntfyServer, config.topic, requestId, batch);
|
|
119
|
+
const message = buildQuestionMessage(q.question, batch, { multiSelect: q.multiSelect, batchInfo });
|
|
120
|
+
|
|
121
|
+
const sent = await sendWithRetry(deps.sendNotification, {
|
|
122
|
+
server: config.ntfyServer,
|
|
123
|
+
topic: config.topic,
|
|
124
|
+
title: `Claude Code: ${q.header || "Question"}`,
|
|
125
|
+
message,
|
|
126
|
+
actions,
|
|
127
|
+
requestId,
|
|
128
|
+
});
|
|
129
|
+
if (!sent) return ASK;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// AskUserQuestion uses standard timeout (not planTimeout)
|
|
133
|
+
let response;
|
|
134
|
+
try {
|
|
135
|
+
response = await deps.waitForResponse({
|
|
136
|
+
server: config.ntfyServer,
|
|
137
|
+
topic: config.topic,
|
|
138
|
+
requestId,
|
|
139
|
+
timeout: config.timeout * 1000,
|
|
140
|
+
});
|
|
141
|
+
} catch (err) {
|
|
142
|
+
console.error("[claude-remote-approver] Response listener failed:", err.message, "— Falling back to CLI.");
|
|
143
|
+
return ASK;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (response.answer) {
|
|
147
|
+
answers[q.question] = response.answer;
|
|
148
|
+
} else {
|
|
149
|
+
console.error("[claude-remote-approver] No answer received. Falling back to CLI.");
|
|
150
|
+
return ASK;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return {
|
|
155
|
+
hookSpecificOutput: {
|
|
156
|
+
hookEventName: "PermissionRequest",
|
|
157
|
+
decision: {
|
|
158
|
+
behavior: "allow",
|
|
159
|
+
updatedInput: {
|
|
160
|
+
questions: input.tool_input.questions,
|
|
161
|
+
answers,
|
|
162
|
+
},
|
|
163
|
+
},
|
|
164
|
+
},
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
33
168
|
/**
|
|
34
169
|
* Process a Claude Code hook request.
|
|
35
170
|
*
|
|
@@ -45,38 +180,50 @@ export async function processHook(input, { loadConfig, sendNotification, waitFor
|
|
|
45
180
|
const config = loadConfig();
|
|
46
181
|
|
|
47
182
|
if (!config.topic) {
|
|
48
|
-
return
|
|
183
|
+
return ASK;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
if (isAskUserQuestion(input)) {
|
|
187
|
+
return processAskUserQuestion(input, { loadConfig, sendNotification, waitForResponse });
|
|
49
188
|
}
|
|
50
189
|
|
|
51
190
|
const requestId = crypto.randomUUID();
|
|
52
191
|
const { title, message } = formatToolInfo(input);
|
|
53
192
|
const actions = buildActions(config.ntfyServer, config.topic, requestId);
|
|
54
193
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
} catch (err) {
|
|
65
|
-
return { hookSpecificOutput: { hookEventName: "PermissionRequest", decision: { behavior: "deny" } } };
|
|
66
|
-
}
|
|
194
|
+
const sent = await sendWithRetry(sendNotification, {
|
|
195
|
+
server: config.ntfyServer,
|
|
196
|
+
topic: config.topic,
|
|
197
|
+
title,
|
|
198
|
+
message,
|
|
199
|
+
actions,
|
|
200
|
+
requestId,
|
|
201
|
+
});
|
|
202
|
+
if (!sent) return ASK;
|
|
67
203
|
|
|
68
204
|
let response;
|
|
69
205
|
try {
|
|
206
|
+
const isPlanReview = input.tool_name === "ExitPlanMode";
|
|
207
|
+
const timeout = (isPlanReview ? (config.planTimeout ?? DEFAULT_CONFIG.planTimeout) : config.timeout) * 1000;
|
|
70
208
|
response = await waitForResponse({
|
|
71
209
|
server: config.ntfyServer,
|
|
72
210
|
topic: config.topic,
|
|
73
211
|
requestId,
|
|
74
|
-
timeout
|
|
212
|
+
timeout,
|
|
75
213
|
});
|
|
76
214
|
} catch (err) {
|
|
77
|
-
|
|
215
|
+
console.error("[claude-remote-approver] Response listener failed:", err.message, "— Falling back to CLI.");
|
|
216
|
+
return ASK;
|
|
78
217
|
}
|
|
79
218
|
|
|
80
|
-
|
|
81
|
-
|
|
219
|
+
if (response.timeout) {
|
|
220
|
+
console.error("[claude-remote-approver] Timed out waiting for response. Falling back to CLI.");
|
|
221
|
+
return ASK;
|
|
222
|
+
}
|
|
223
|
+
if (response.error) {
|
|
224
|
+
console.error("[claude-remote-approver] Response error:", response.error.message, "— Falling back to CLI.");
|
|
225
|
+
return ASK;
|
|
226
|
+
}
|
|
227
|
+
if (response.approved === false) return DENY;
|
|
228
|
+
return { hookSpecificOutput: { hookEventName: "PermissionRequest", decision: { behavior: "allow" } } };
|
|
82
229
|
}
|
package/src/ntfy.mjs
CHANGED
|
@@ -27,7 +27,7 @@ export async function sendNotification({ server, topic, title, message, actions,
|
|
|
27
27
|
* Subscribe to the response topic via SSE and wait for a matching requestId.
|
|
28
28
|
*
|
|
29
29
|
* @param {{ server: string, topic: string, requestId: string, timeout: number }} params
|
|
30
|
-
* @returns {Promise<{ approved: boolean }>}
|
|
30
|
+
* @returns {Promise<{ approved: boolean } | { timeout: true } | { error: Error } | { answer: string }>}
|
|
31
31
|
*/
|
|
32
32
|
export async function waitForResponse({ server, topic, requestId, timeout }) {
|
|
33
33
|
const baseUrl = server.replace(/\/+$/, '');
|
|
@@ -70,6 +70,9 @@ export async function waitForResponse({ server, topic, requestId, timeout }) {
|
|
|
70
70
|
clearTimeout(timer);
|
|
71
71
|
controller.signal.removeEventListener('abort', onAbort);
|
|
72
72
|
controller.abort();
|
|
73
|
+
if (typeof parsed.answer === 'string') {
|
|
74
|
+
return { answer: parsed.answer };
|
|
75
|
+
}
|
|
73
76
|
return { approved: parsed.approved };
|
|
74
77
|
}
|
|
75
78
|
} catch {
|
|
@@ -82,13 +85,14 @@ export async function waitForResponse({ server, topic, requestId, timeout }) {
|
|
|
82
85
|
}
|
|
83
86
|
|
|
84
87
|
clearTimeout(timer);
|
|
85
|
-
return {
|
|
88
|
+
return { timeout: true };
|
|
86
89
|
} catch (err) {
|
|
87
90
|
if (timer !== undefined) clearTimeout(timer);
|
|
88
|
-
if (err?.name
|
|
89
|
-
|
|
91
|
+
if (err?.name === "AbortError") {
|
|
92
|
+
return { timeout: true };
|
|
90
93
|
}
|
|
91
|
-
|
|
94
|
+
console.error("[claude-remote-approver] waitForResponse error:", err.message ?? err);
|
|
95
|
+
return { error: err };
|
|
92
96
|
}
|
|
93
97
|
}
|
|
94
98
|
|