claude-remote-approver 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
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 ADDED
@@ -0,0 +1,210 @@
1
+ # claude-remote-approver
2
+
3
+ Approve or deny Claude Code permission prompts from your phone.
4
+
5
+ ---
6
+
7
+ ## Problem
8
+
9
+ Claude Code asks for permission before running tools like `Bash`, `Write`, and `Edit`. These prompts require you to be sitting at your terminal. If you step away, Claude Code stalls until you come back and press "y".
10
+
11
+ **claude-remote-approver** sends each permission prompt as a push notification to your phone via [ntfy.sh](https://ntfy.sh). You tap **Approve** or **Deny**, and Claude Code continues immediately -- no terminal required.
12
+
13
+ ## How it works
14
+
15
+ ```
16
+ Claude Code
17
+
18
+ │ PermissionRequest hook (stdin JSON)
19
+
20
+ hook.mjs
21
+
22
+ ├──POST──▶ ntfy.sh/<topic> ──push──▶ Phone (ntfy app)
23
+ │ │
24
+ │ Approve / Deny tap
25
+ │ │
26
+ └──SSE───▶ ntfy.sh/<topic>-response ◀──POST──┘
27
+
28
+ │ stdout JSON: { "behavior": "allow" } or { "behavior": "deny" }
29
+
30
+ Claude Code continues or stops
31
+ ```
32
+
33
+ 1. Claude Code invokes the hook, piping the tool request as JSON to stdin.
34
+ 2. `hook.mjs` sends a notification to your ntfy topic with **Approve** and **Deny** action buttons.
35
+ 3. The hook subscribes to a response topic (`<topic>-response`) via server-sent events.
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, writes `{"behavior":"allow"}` or `{"behavior":"deny"}` to stdout, and exits.
38
+ 6. Claude Code proceeds accordingly.
39
+
40
+ ## Quick Start
41
+
42
+ ```bash
43
+ # 1. Install the ntfy app on your phone
44
+ # iOS: https://apps.apple.com/app/ntfy/id1625396347
45
+ # Android: https://play.google.com/store/apps/details?id=io.heckel.ntfy
46
+
47
+ # 2. Install claude-remote-approver
48
+ npm install -g claude-remote-approver
49
+
50
+ # 3. Run setup
51
+ claude-remote-approver setup
52
+ ```
53
+
54
+ Setup prints a topic name. Subscribe to that topic in the ntfy app, and you are done.
55
+
56
+ ## Installation
57
+
58
+ ```bash
59
+ npm install -g claude-remote-approver
60
+ ```
61
+
62
+ Requires Node.js 18 or later.
63
+
64
+ ## Setup
65
+
66
+ ```bash
67
+ claude-remote-approver setup
68
+ ```
69
+
70
+ This command does three things:
71
+
72
+ 1. **Generates a unique topic** -- a random string like `cra-a1b2c3d4e5f6...` (128 bits of entropy).
73
+ 2. **Creates a config file** at `~/.claude-remote-approver.json` with your topic and default settings. The file is created with permission `0600` (owner read/write only).
74
+ 3. **Registers the hook** in Claude Code's `~/.claude/settings.json` under `hooks.PermissionRequest`. If a previous hook entry from this tool exists, it is replaced.
75
+
76
+ After running setup, open the ntfy app on your phone and subscribe to the topic printed in the terminal.
77
+
78
+ ## Usage
79
+
80
+ ### `setup`
81
+
82
+ Configure the tool and register the hook with Claude Code.
83
+
84
+ ```bash
85
+ claude-remote-approver setup
86
+ # Setup complete. Topic: cra-<hex>
87
+ ```
88
+
89
+ ### `test`
90
+
91
+ Send a test notification to verify your setup is working.
92
+
93
+ ```bash
94
+ claude-remote-approver test
95
+ # Test notification sent successfully.
96
+ ```
97
+
98
+ If you see the notification on your phone, everything is configured correctly.
99
+
100
+ ### `status`
101
+
102
+ Display the current configuration.
103
+
104
+ ```bash
105
+ claude-remote-approver status
106
+ # Topic: cra-a1b2c3d4...
107
+ # Server: https://ntfy.sh
108
+ # Timeout: 120s
109
+ ```
110
+
111
+ ### `hook`
112
+
113
+ Internal command. Claude Code calls this automatically via the registered hook. It reads a JSON payload from stdin and writes a decision to stdout. You do not need to run this manually.
114
+
115
+ ### Flags
116
+
117
+ ```
118
+ --help, -h Show usage information
119
+ --version, -v Print version number
120
+ ```
121
+
122
+ ## Configuration
123
+
124
+ Config file location: `~/.claude-remote-approver.json`
125
+
126
+ ```json
127
+ {
128
+ "topic": "cra-a1b2c3d4e5f67890abcdef1234567890",
129
+ "ntfyServer": "https://ntfy.sh",
130
+ "timeout": 120,
131
+ "autoApprove": [],
132
+ "autoDeny": []
133
+ }
134
+ ```
135
+
136
+ | Field | Type | Default | Description |
137
+ |---|---|---|---|
138
+ | `topic` | `string` | `""` | Your unique ntfy topic. Generated by `setup`. |
139
+ | `ntfyServer` | `string` | `"https://ntfy.sh"` | The ntfy server URL. Change this if you self-host. |
140
+ | `timeout` | `number` | `120` | Seconds to wait for a response before auto-denying. |
141
+ | `autoApprove` | `string[]` | `[]` | Reserved for future use. |
142
+ | `autoDeny` | `string[]` | `[]` | Reserved for future use. |
143
+
144
+ ### Using a self-hosted ntfy server
145
+
146
+ Edit `~/.claude-remote-approver.json` and set `ntfyServer` to your server URL:
147
+
148
+ ```json
149
+ {
150
+ "ntfyServer": "https://ntfy.example.com"
151
+ }
152
+ ```
153
+
154
+ Then subscribe to the topic on your self-hosted server in the ntfy app.
155
+
156
+ ## How ntfy.sh works
157
+
158
+ [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.
159
+
160
+ claude-remote-approver uses two topics:
161
+
162
+ - **`<topic>`** -- The hook publishes permission requests here. Your phone receives these as notifications with action buttons.
163
+ - **`<topic>-response`** -- When you tap Approve or Deny, the ntfy app sends an HTTP POST to this topic. The hook subscribes to it via SSE (server-sent events) and reads your decision.
164
+
165
+ No account is required. Topics are identified by name only, which is why the generated topic contains 128 bits of randomness.
166
+
167
+ For more details, see the [ntfy documentation](https://docs.ntfy.sh).
168
+
169
+ ## Security
170
+
171
+ ### Topic entropy
172
+
173
+ The topic name is generated using `crypto.randomBytes(16)`, producing 128 bits of randomness (32 hex characters). This makes the topic effectively unguessable.
174
+
175
+ ### File permissions
176
+
177
+ The config file (`~/.claude-remote-approver.json`) is written with mode `0600` -- only the file owner can read or write it. This prevents other users on the system from reading your topic name.
178
+
179
+ ### Self-hosting recommendation
180
+
181
+ The public ntfy.sh server is convenient but means your permission request details (tool names, commands, file paths) pass through a third-party server. For sensitive work, consider [self-hosting ntfy](https://docs.ntfy.sh/install/) and setting `ntfyServer` in your config to your own server.
182
+
183
+ ### Timeout behavior
184
+
185
+ If no response is received within the configured timeout (default: 120 seconds), the hook automatically **denies** the request. This fail-closed design ensures Claude Code does not proceed without explicit approval.
186
+
187
+ ## Disclaimer
188
+
189
+ **Use at your own risk.** This tool automates permission control for Claude Code. Misuse or misconfiguration may result in unintended code execution, file modification, or data loss.
190
+
191
+ The authors are not responsible for any damages or losses arising from the use of this tool, including but not limited to:
192
+
193
+ - Accidental approval of dangerous commands (e.g., mistapping Approve on your phone)
194
+ - Unintended denial of safe commands (e.g., timeout, network issues)
195
+ - Security breaches if the topic name is compromised
196
+
197
+ **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.
198
+
199
+ This software is provided "AS IS" without warranty of any kind, as stated in the [MIT License](LICENSE).
200
+
201
+ ## Requirements
202
+
203
+ - **Node.js** >= 18.0.0
204
+ - **ntfy app** on your phone
205
+ - [iOS (App Store)](https://apps.apple.com/app/ntfy/id1625396347)
206
+ - [Android (Google Play)](https://play.google.com/store/apps/details?id=io.heckel.ntfy)
207
+
208
+ ## License
209
+
210
+ [MIT](LICENSE)
package/bin/cli.mjs ADDED
@@ -0,0 +1,151 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * CLI entry point for claude-remote-approver.
5
+ *
6
+ * Subcommands: setup | test | status | hook
7
+ * All I/O goes through the injected `deps` object so the module is fully testable.
8
+ */
9
+
10
+ import { fileURLToPath } from "node:url";
11
+ import { realpathSync } from "node:fs";
12
+
13
+ // ---------------------------------------------------------------------------
14
+ // main
15
+ // ---------------------------------------------------------------------------
16
+
17
+ export async function main(args, deps) {
18
+ if (args.includes("--help") || args.includes("-h")) {
19
+ deps.stdout.write("Usage: claude-remote-approver <command>\n\nCommands:\n setup Set up remote approval\n test Send a test notification\n status Show current configuration\n hook Process a Claude Code hook (internal)\n");
20
+ return;
21
+ }
22
+ if (args.includes("--version") || args.includes("-v")) {
23
+ deps.stdout.write("0.1.0\n");
24
+ return;
25
+ }
26
+
27
+ const command = args[0];
28
+
29
+ switch (command) {
30
+ case "setup": {
31
+ const result = await deps.runSetup(deps);
32
+ deps.stdout.write(`Setup complete. Topic: ${result.topic}\n`);
33
+ break;
34
+ }
35
+
36
+ case "test": {
37
+ const config = deps.loadConfig();
38
+ if (!config.topic) {
39
+ deps.stderr.write("Error: No topic configured. Run 'claude-remote-approver setup' first.\n");
40
+ break;
41
+ }
42
+ try {
43
+ await deps.sendNotification({
44
+ server: config.ntfyServer,
45
+ topic: config.topic,
46
+ title: "Claude Remote Approver",
47
+ message: "Test notification - if you see this, setup is working!",
48
+ actions: [],
49
+ requestId: "test",
50
+ });
51
+ deps.stdout.write("Test notification sent successfully.\n");
52
+ } catch (err) {
53
+ deps.stderr.write(`Error: Failed to send notification: ${err.message}\n`);
54
+ }
55
+ break;
56
+ }
57
+
58
+ case "status": {
59
+ const config = deps.loadConfig();
60
+ deps.stdout.write(`Topic: ${config.topic}\n`);
61
+ deps.stdout.write(`Server: ${config.ntfyServer}\n`);
62
+ deps.stdout.write(`Timeout: ${config.timeout}s\n`);
63
+ break;
64
+ }
65
+
66
+ case "hook": {
67
+ let input;
68
+ try {
69
+ input = JSON.parse(deps.stdin);
70
+ } catch {
71
+ const deny = { hookSpecificOutput: { decision: { behavior: "deny" } } };
72
+ deps.stdout.write(JSON.stringify(deny) + "\n");
73
+ break;
74
+ }
75
+
76
+ let result;
77
+ try {
78
+ result = await deps.processHook(input, deps);
79
+ } catch {
80
+ const deny = { hookSpecificOutput: { decision: { behavior: "deny" } } };
81
+ deps.stdout.write(JSON.stringify(deny) + "\n");
82
+ break;
83
+ }
84
+
85
+ deps.stdout.write(JSON.stringify(result) + "\n");
86
+ break;
87
+ }
88
+
89
+ default: {
90
+ deps.stderr.write(
91
+ "Usage: claude-remote-approver <command>\n\nCommands:\n setup Configure topic and register hook\n test Send a test notification\n status Show current configuration\n hook Process a Claude Code hook (reads JSON from stdin)\n",
92
+ );
93
+ deps.exit(1);
94
+ break;
95
+ }
96
+ }
97
+ }
98
+
99
+ // ---------------------------------------------------------------------------
100
+ // Auto-execute when run directly (not imported)
101
+ // ---------------------------------------------------------------------------
102
+
103
+ const isMain =
104
+ typeof process !== "undefined" &&
105
+ process.argv[1] &&
106
+ (() => {
107
+ try {
108
+ return realpathSync(fileURLToPath(import.meta.url)) === realpathSync(process.argv[1]);
109
+ } catch {
110
+ return false;
111
+ }
112
+ })();
113
+
114
+ if (isMain) {
115
+ const { loadConfig, saveConfig, generateTopic } = await import(
116
+ "../src/config.mjs"
117
+ );
118
+ const { sendNotification, waitForResponse, formatToolInfo } = await import(
119
+ "../src/ntfy.mjs"
120
+ );
121
+ const { processHook } = await import("../src/hook.mjs");
122
+ const { runSetup } = await import("../src/setup.mjs");
123
+
124
+ const args = process.argv.slice(2);
125
+
126
+ let stdinData = "";
127
+ if (!process.stdin.isTTY) {
128
+ const chunks = [];
129
+ for await (const chunk of process.stdin) {
130
+ chunks.push(chunk);
131
+ }
132
+ stdinData = Buffer.concat(chunks).toString("utf-8");
133
+ }
134
+
135
+ const deps = {
136
+ loadConfig,
137
+ saveConfig,
138
+ generateTopic,
139
+ sendNotification,
140
+ waitForResponse,
141
+ formatToolInfo,
142
+ processHook,
143
+ runSetup,
144
+ stdout: process.stdout,
145
+ stderr: process.stderr,
146
+ stdin: stdinData,
147
+ exit: process.exit,
148
+ };
149
+
150
+ await main(args, deps);
151
+ }
package/package.json ADDED
@@ -0,0 +1,36 @@
1
+ {
2
+ "name": "claude-remote-approver",
3
+ "version": "0.1.0",
4
+ "description": "Approve or deny Claude Code permission prompts remotely from your phone via ntfy.sh",
5
+ "type": "module",
6
+ "bin": {
7
+ "claude-remote-approver": "./bin/cli.mjs"
8
+ },
9
+ "files": [
10
+ "bin/",
11
+ "src/",
12
+ "LICENSE",
13
+ "README.md"
14
+ ],
15
+ "scripts": {
16
+ "test": "node --test test/**/*.test.mjs"
17
+ },
18
+ "keywords": [
19
+ "claude",
20
+ "claude-code",
21
+ "remote",
22
+ "approval",
23
+ "ntfy",
24
+ "notification",
25
+ "hook"
26
+ ],
27
+ "author": "Yuuichi Eguchi",
28
+ "license": "MIT",
29
+ "engines": {
30
+ "node": ">=18.0.0"
31
+ },
32
+ "repository": {
33
+ "type": "git",
34
+ "url": "git+https://github.com/eguchiyuuichi/claude-remote-approver.git"
35
+ }
36
+ }
package/src/config.mjs ADDED
@@ -0,0 +1,42 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import os from "node:os";
4
+ import crypto from "node:crypto";
5
+
6
+ export const CONFIG_PATH = path.join(os.homedir(), ".claude-remote-approver.json");
7
+
8
+ export const DEFAULT_CONFIG = {
9
+ topic: "",
10
+ ntfyServer: "https://ntfy.sh",
11
+ timeout: 120,
12
+ // autoApprove/autoDeny are reserved for future use and not yet implemented
13
+ autoApprove: [],
14
+ autoDeny: [],
15
+ };
16
+
17
+ export function loadConfig(configPath = CONFIG_PATH) {
18
+ try {
19
+ const raw = fs.readFileSync(configPath, "utf-8");
20
+ const fileConfig = JSON.parse(raw);
21
+ const config = { ...DEFAULT_CONFIG, ...fileConfig };
22
+ if (typeof config.topic !== "string") config.topic = DEFAULT_CONFIG.topic;
23
+ if (typeof config.ntfyServer !== "string") config.ntfyServer = DEFAULT_CONFIG.ntfyServer;
24
+ if (typeof config.timeout !== "number" || config.timeout <= 0) config.timeout = DEFAULT_CONFIG.timeout;
25
+ if (!Array.isArray(config.autoApprove)) config.autoApprove = DEFAULT_CONFIG.autoApprove;
26
+ if (!Array.isArray(config.autoDeny)) config.autoDeny = DEFAULT_CONFIG.autoDeny;
27
+ return config;
28
+ } catch (err) {
29
+ if (err.code === "ENOENT") {
30
+ return { ...DEFAULT_CONFIG };
31
+ }
32
+ throw err;
33
+ }
34
+ }
35
+
36
+ export function saveConfig(config, configPath = CONFIG_PATH) {
37
+ fs.writeFileSync(configPath, JSON.stringify(config, null, 2), { mode: 0o600 });
38
+ }
39
+
40
+ export function generateTopic() {
41
+ return `cra-${crypto.randomBytes(16).toString("hex")}`;
42
+ }
package/src/hook.mjs ADDED
@@ -0,0 +1,84 @@
1
+ // src/hook.mjs
2
+
3
+ import crypto from "node:crypto";
4
+
5
+ /**
6
+ * Build ntfy action buttons for Approve / Deny.
7
+ *
8
+ * @param {string} server - ntfy server URL
9
+ * @param {string} topic - ntfy topic
10
+ * @param {string} requestId - Unique request identifier
11
+ * @returns {Array<object>} Array of 2 action objects
12
+ */
13
+ export function buildActions(server, topic, requestId) {
14
+ const url = `${server}/${topic}-response`;
15
+ return [
16
+ {
17
+ action: "http",
18
+ label: "Approve",
19
+ url,
20
+ body: JSON.stringify({ requestId, approved: true }),
21
+ method: "POST",
22
+ headers: { "Content-Type": "application/json" },
23
+ },
24
+ {
25
+ action: "http",
26
+ label: "Deny",
27
+ url,
28
+ body: JSON.stringify({ requestId, approved: false }),
29
+ method: "POST",
30
+ headers: { "Content-Type": "application/json" },
31
+ },
32
+ ];
33
+ }
34
+
35
+ /**
36
+ * Process a Claude Code hook request.
37
+ *
38
+ * @param {object} input - The hook input payload
39
+ * @param {object} deps - Injected dependencies
40
+ * @param {Function} deps.loadConfig
41
+ * @param {Function} deps.sendNotification
42
+ * @param {Function} deps.waitForResponse
43
+ * @param {Function} deps.formatToolInfo
44
+ * @returns {Promise<object>} Decision JSON
45
+ */
46
+ export async function processHook(input, { loadConfig, sendNotification, waitForResponse, formatToolInfo }) {
47
+ const config = loadConfig();
48
+
49
+ if (!config.topic) {
50
+ return { hookSpecificOutput: { decision: { behavior: "deny" } } };
51
+ }
52
+
53
+ const requestId = crypto.randomUUID();
54
+ const { title, message } = formatToolInfo(input);
55
+ const actions = buildActions(config.ntfyServer, config.topic, requestId);
56
+
57
+ try {
58
+ await sendNotification({
59
+ server: config.ntfyServer,
60
+ topic: config.topic,
61
+ title,
62
+ message,
63
+ actions,
64
+ requestId,
65
+ });
66
+ } catch {
67
+ return { hookSpecificOutput: { decision: { behavior: "deny" } } };
68
+ }
69
+
70
+ let response;
71
+ try {
72
+ response = await waitForResponse({
73
+ server: config.ntfyServer,
74
+ topic: config.topic,
75
+ requestId,
76
+ timeout: config.timeout * 1000,
77
+ });
78
+ } catch {
79
+ return { hookSpecificOutput: { decision: { behavior: "deny" } } };
80
+ }
81
+
82
+ const behavior = response.approved ? "allow" : "deny";
83
+ return { hookSpecificOutput: { decision: { behavior } } };
84
+ }
package/src/ntfy.mjs ADDED
@@ -0,0 +1,119 @@
1
+ // src/ntfy.mjs
2
+
3
+ /**
4
+ * Send a push notification via ntfy.
5
+ *
6
+ * @param {{ server: string, topic: string, title: string, message: string, actions: unknown[], requestId: string }} params
7
+ * @returns {Promise<Response>}
8
+ */
9
+ export async function sendNotification({ server, topic, title, message, actions, requestId }) {
10
+ const baseUrl = server.replace(/\/+$/, '');
11
+ const url = `${baseUrl}/${topic}`;
12
+
13
+ const response = await fetch(url, {
14
+ method: 'POST',
15
+ headers: { 'Content-Type': 'application/json' },
16
+ body: JSON.stringify({ topic, title, message, actions }),
17
+ });
18
+
19
+ if (!response.ok) {
20
+ throw new Error(`ntfy notification failed: HTTP ${response.status}`);
21
+ }
22
+
23
+ return response;
24
+ }
25
+
26
+ /**
27
+ * Subscribe to the response topic via SSE and wait for a matching requestId.
28
+ *
29
+ * @param {{ server: string, topic: string, requestId: string, timeout: number }} params
30
+ * @returns {Promise<{ approved: boolean }>}
31
+ */
32
+ export async function waitForResponse({ server, topic, requestId, timeout }) {
33
+ const baseUrl = server.replace(/\/+$/, '');
34
+ const url = `${baseUrl}/${topic}-response/json`;
35
+
36
+ const controller = new AbortController();
37
+
38
+ /** @type {ReturnType<typeof setTimeout> | undefined} */
39
+ let timer;
40
+
41
+ try {
42
+ const response = await fetch(url, { signal: controller.signal });
43
+ const reader = response.body.getReader();
44
+ const decoder = new TextDecoder();
45
+ let buffer = '';
46
+
47
+ // Listen to abort so we can cancel the reader even when the mock stream
48
+ // never closes (the real fetch would propagate the signal, but mocks may not).
49
+ const onAbort = () => reader.cancel();
50
+ controller.signal.addEventListener('abort', onAbort);
51
+
52
+ // Start the timeout AFTER fetch resolves so we measure waiting time only.
53
+ timer = setTimeout(() => controller.abort(), timeout);
54
+
55
+ try {
56
+ while (true) {
57
+ const { done, value } = await reader.read();
58
+ if (done) break;
59
+
60
+ buffer += decoder.decode(value, { stream: true });
61
+ const lines = buffer.split('\n');
62
+ buffer = lines.pop();
63
+
64
+ for (const line of lines) {
65
+ if (!line.trim()) continue;
66
+ try {
67
+ const event = JSON.parse(line);
68
+ const parsed = JSON.parse(event.message);
69
+ if (parsed.requestId === requestId) {
70
+ clearTimeout(timer);
71
+ controller.signal.removeEventListener('abort', onAbort);
72
+ return { approved: parsed.approved };
73
+ }
74
+ } catch {
75
+ // skip non-JSON lines
76
+ }
77
+ }
78
+ }
79
+ } finally {
80
+ controller.signal.removeEventListener('abort', onAbort);
81
+ }
82
+
83
+ clearTimeout(timer);
84
+ return { approved: false };
85
+ } catch (err) {
86
+ if (timer !== undefined) clearTimeout(timer);
87
+ if (err?.name !== "AbortError") {
88
+ console.error("[claude-remote-approver] waitForResponse error:", err);
89
+ }
90
+ return { approved: false };
91
+ }
92
+ }
93
+
94
+ /**
95
+ * Format tool information for display in the notification.
96
+ *
97
+ * @param {{ hookName: string, toolName: string, toolInput: Record<string, unknown> }} params
98
+ * @returns {{ title: string, message: string }}
99
+ */
100
+ export function formatToolInfo({ hookName, toolName, toolInput }) {
101
+ const title = `Claude Code: ${toolName}`;
102
+ let message;
103
+
104
+ switch (toolName) {
105
+ case 'Bash':
106
+ message = toolInput?.command ?? JSON.stringify(toolInput);
107
+ break;
108
+ case 'Read':
109
+ case 'Write':
110
+ case 'Edit':
111
+ message = toolInput?.file_path ?? JSON.stringify(toolInput);
112
+ break;
113
+ default:
114
+ message = JSON.stringify(toolInput);
115
+ break;
116
+ }
117
+
118
+ return { title, message };
119
+ }
package/src/setup.mjs ADDED
@@ -0,0 +1,75 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+
4
+ /**
5
+ * Returns the hook command string: `node <absolute_path_to_src/hook.mjs>`
6
+ */
7
+ export function getHookCommand() {
8
+ const hookPath = path.resolve(import.meta.dirname, "hook.mjs");
9
+ return `node "${hookPath}"`;
10
+ }
11
+
12
+ /**
13
+ * Registers the PermissionRequest hook in Claude's settings.json.
14
+ * Creates the file if it does not exist. Preserves all existing settings and hooks.
15
+ */
16
+ export function registerHook(settingsPath, hookCommand) {
17
+ let settings = {};
18
+
19
+ try {
20
+ const raw = fs.readFileSync(settingsPath, "utf-8");
21
+ settings = JSON.parse(raw);
22
+ } catch (err) {
23
+ if (err.code !== "ENOENT") {
24
+ throw err;
25
+ }
26
+ }
27
+
28
+ if (!settings.hooks) {
29
+ settings.hooks = {};
30
+ }
31
+
32
+ if (!Array.isArray(settings.hooks.PermissionRequest)) {
33
+ settings.hooks.PermissionRequest = [];
34
+ }
35
+
36
+ const existingIndex = settings.hooks.PermissionRequest.findIndex(
37
+ (h) => h.command && h.command.includes("claude-remote-approver")
38
+ );
39
+
40
+ const hookEntry = { type: "command", command: hookCommand };
41
+
42
+ if (existingIndex >= 0) {
43
+ settings.hooks.PermissionRequest[existingIndex] = hookEntry;
44
+ } else {
45
+ settings.hooks.PermissionRequest.push(hookEntry);
46
+ }
47
+
48
+ fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2));
49
+ }
50
+
51
+ /**
52
+ * Runs the full setup flow:
53
+ * 1. Generate a topic
54
+ * 2. Build and save config
55
+ * 3. Register the hook in settings.json
56
+ * 4. Return { topic, configPath, settingsPath }
57
+ */
58
+ export async function runSetup({
59
+ configPath,
60
+ settingsPath,
61
+ generateTopic,
62
+ saveConfig,
63
+ loadConfig,
64
+ }) {
65
+ const topic = generateTopic();
66
+
67
+ const config = loadConfig(configPath);
68
+ config.topic = topic;
69
+ saveConfig(config, configPath);
70
+
71
+ const hookCommand = getHookCommand();
72
+ registerHook(settingsPath, hookCommand);
73
+
74
+ return { topic, configPath, settingsPath };
75
+ }