claude-remote-approver 0.1.0 → 0.3.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 +43 -2
- package/bin/cli.mjs +84 -8
- package/package.json +5 -2
- package/src/setup.mjs +47 -6
package/README.md
CHANGED
|
@@ -51,7 +51,7 @@ npm install -g claude-remote-approver
|
|
|
51
51
|
claude-remote-approver setup
|
|
52
52
|
```
|
|
53
53
|
|
|
54
|
-
Setup prints a
|
|
54
|
+
Setup prints a QR code. Scan it with the ntfy app to subscribe, and you are done.
|
|
55
55
|
|
|
56
56
|
## Installation
|
|
57
57
|
|
|
@@ -73,7 +73,7 @@ This command does three things:
|
|
|
73
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
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
75
|
|
|
76
|
-
After running setup,
|
|
76
|
+
After running setup, scan the QR code displayed in the terminal with the ntfy app on your phone. You can also manually subscribe using the URL printed below the QR code.
|
|
77
77
|
|
|
78
78
|
## Usage
|
|
79
79
|
|
|
@@ -84,6 +84,14 @@ Configure the tool and register the hook with Claude Code.
|
|
|
84
84
|
```bash
|
|
85
85
|
claude-remote-approver setup
|
|
86
86
|
# Setup complete. Topic: cra-<hex>
|
|
87
|
+
#
|
|
88
|
+
# Scan this QR code in the ntfy app to subscribe:
|
|
89
|
+
#
|
|
90
|
+
# ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
|
|
91
|
+
# █ ▄▄▄▄▄ █ █ ▄▄▄▄▄ █
|
|
92
|
+
# ...
|
|
93
|
+
#
|
|
94
|
+
# Subscribe URL: https://ntfy.sh/cra-<hex>
|
|
87
95
|
```
|
|
88
96
|
|
|
89
97
|
### `test`
|
|
@@ -108,6 +116,39 @@ claude-remote-approver status
|
|
|
108
116
|
# Timeout: 120s
|
|
109
117
|
```
|
|
110
118
|
|
|
119
|
+
### `enable`
|
|
120
|
+
|
|
121
|
+
Re-enable the hook after it has been disabled.
|
|
122
|
+
|
|
123
|
+
```bash
|
|
124
|
+
claude-remote-approver enable
|
|
125
|
+
# Hook enabled.
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
This reads the existing configuration and re-registers the hook in Claude Code's settings. You must have run `setup` at least once before using this command.
|
|
129
|
+
|
|
130
|
+
### `disable`
|
|
131
|
+
|
|
132
|
+
Temporarily disable the hook without removing your configuration.
|
|
133
|
+
|
|
134
|
+
```bash
|
|
135
|
+
claude-remote-approver disable
|
|
136
|
+
# Hook disabled. Run 'claude-remote-approver enable' to re-enable.
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
Your topic and settings are preserved. Use `enable` to re-activate.
|
|
140
|
+
|
|
141
|
+
### `uninstall`
|
|
142
|
+
|
|
143
|
+
Remove the hook and delete all configuration.
|
|
144
|
+
|
|
145
|
+
```bash
|
|
146
|
+
claude-remote-approver uninstall
|
|
147
|
+
# Uninstalled. Hook removed and configuration deleted.
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
This removes the hook entry from Claude Code's settings.json and deletes `~/.claude-remote-approver.json`. To use the tool again, run `setup`.
|
|
151
|
+
|
|
111
152
|
### `hook`
|
|
112
153
|
|
|
113
154
|
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.
|
package/bin/cli.mjs
CHANGED
|
@@ -3,12 +3,15 @@
|
|
|
3
3
|
/**
|
|
4
4
|
* CLI entry point for claude-remote-approver.
|
|
5
5
|
*
|
|
6
|
-
* Subcommands: setup | test | status | hook
|
|
6
|
+
* Subcommands: setup | test | status | enable | disable | uninstall | hook
|
|
7
7
|
* All I/O goes through the injected `deps` object so the module is fully testable.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import { fileURLToPath } from "node:url";
|
|
11
|
-
import
|
|
11
|
+
import fs from "node:fs";
|
|
12
|
+
import path from "node:path";
|
|
13
|
+
import os from "node:os";
|
|
14
|
+
import qrcode from "qrcode-terminal";
|
|
12
15
|
|
|
13
16
|
// ---------------------------------------------------------------------------
|
|
14
17
|
// main
|
|
@@ -16,11 +19,11 @@ import { realpathSync } from "node:fs";
|
|
|
16
19
|
|
|
17
20
|
export async function main(args, deps) {
|
|
18
21
|
if (args.includes("--help") || args.includes("-h")) {
|
|
19
|
-
deps.stdout.write("Usage: claude-remote-approver <command>\n\nCommands:\n setup
|
|
22
|
+
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 enable Re-enable the hook\n disable Temporarily disable the hook\n uninstall Remove hook and delete configuration\n hook Process a Claude Code hook (internal)\n");
|
|
20
23
|
return;
|
|
21
24
|
}
|
|
22
25
|
if (args.includes("--version") || args.includes("-v")) {
|
|
23
|
-
deps.stdout.write(
|
|
26
|
+
deps.stdout.write(`${deps.version}\n`);
|
|
24
27
|
return;
|
|
25
28
|
}
|
|
26
29
|
|
|
@@ -29,7 +32,23 @@ export async function main(args, deps) {
|
|
|
29
32
|
switch (command) {
|
|
30
33
|
case "setup": {
|
|
31
34
|
const result = await deps.runSetup(deps);
|
|
32
|
-
deps.stdout.write(`Setup complete. Topic: ${result.topic}\n`);
|
|
35
|
+
deps.stdout.write(`Setup complete. Topic: ${result.topic}\n\n`);
|
|
36
|
+
|
|
37
|
+
try {
|
|
38
|
+
const host = new URL(result.ntfyServer).host;
|
|
39
|
+
const ntfyUrl = `ntfy://${host}/${result.topic}`;
|
|
40
|
+
const httpsUrl = `${result.ntfyServer.replace(/\/+$/, "")}/${result.topic}`;
|
|
41
|
+
|
|
42
|
+
deps.stdout.write("Scan this QR code in the ntfy app to subscribe:\n\n");
|
|
43
|
+
// qrcode-terminal invokes the callback synchronously
|
|
44
|
+
deps.generateQR(ntfyUrl, { small: true }, (qrString) => {
|
|
45
|
+
deps.stdout.write(qrString + "\n\n");
|
|
46
|
+
deps.stdout.write(`Subscribe URL: ${httpsUrl}\n`);
|
|
47
|
+
});
|
|
48
|
+
} catch {
|
|
49
|
+
deps.stderr.write(`Warning: Invalid ntfyServer URL in config: ${result.ntfyServer}\n`);
|
|
50
|
+
deps.stdout.write(`Subscribe to topic "${result.topic}" in the ntfy app.\n`);
|
|
51
|
+
}
|
|
33
52
|
break;
|
|
34
53
|
}
|
|
35
54
|
|
|
@@ -86,9 +105,56 @@ export async function main(args, deps) {
|
|
|
86
105
|
break;
|
|
87
106
|
}
|
|
88
107
|
|
|
108
|
+
case "uninstall": {
|
|
109
|
+
try {
|
|
110
|
+
deps.unregisterHook(deps.settingsPath);
|
|
111
|
+
} catch (err) {
|
|
112
|
+
deps.stderr.write(`Error: Failed to remove hook: ${err.message}\n`);
|
|
113
|
+
break;
|
|
114
|
+
}
|
|
115
|
+
try {
|
|
116
|
+
deps.unlinkSync(deps.configPath);
|
|
117
|
+
} catch (err) {
|
|
118
|
+
if (err.code !== "ENOENT") {
|
|
119
|
+
deps.stderr.write(`Error: Failed to delete config: ${err.message}\n`);
|
|
120
|
+
break;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
deps.stdout.write("Uninstalled. Hook removed and configuration deleted.\n");
|
|
124
|
+
break;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
case "disable": {
|
|
128
|
+
try {
|
|
129
|
+
deps.unregisterHook(deps.settingsPath);
|
|
130
|
+
} catch (err) {
|
|
131
|
+
deps.stderr.write(`Error: Failed to disable hook: ${err.message}\n`);
|
|
132
|
+
break;
|
|
133
|
+
}
|
|
134
|
+
deps.stdout.write("Hook disabled. Run 'claude-remote-approver enable' to re-enable.\n");
|
|
135
|
+
break;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
case "enable": {
|
|
139
|
+
const config = deps.loadConfig();
|
|
140
|
+
if (!config.topic) {
|
|
141
|
+
deps.stderr.write("Error: No topic configured. Run 'claude-remote-approver setup' first.\n");
|
|
142
|
+
deps.exit(1);
|
|
143
|
+
break;
|
|
144
|
+
}
|
|
145
|
+
try {
|
|
146
|
+
deps.registerHook(deps.settingsPath, deps.getHookCommand());
|
|
147
|
+
} catch (err) {
|
|
148
|
+
deps.stderr.write(`Error: Failed to enable hook: ${err.message}\n`);
|
|
149
|
+
break;
|
|
150
|
+
}
|
|
151
|
+
deps.stdout.write("Hook enabled.\n");
|
|
152
|
+
break;
|
|
153
|
+
}
|
|
154
|
+
|
|
89
155
|
default: {
|
|
90
156
|
deps.stderr.write(
|
|
91
|
-
"Usage: claude-remote-approver <command>\n\nCommands:\n setup
|
|
157
|
+
"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 enable Re-enable the hook\n disable Temporarily disable the hook\n uninstall Remove hook and delete configuration\n hook Process a Claude Code hook (internal)\n",
|
|
92
158
|
);
|
|
93
159
|
deps.exit(1);
|
|
94
160
|
break;
|
|
@@ -105,13 +171,15 @@ const isMain =
|
|
|
105
171
|
process.argv[1] &&
|
|
106
172
|
(() => {
|
|
107
173
|
try {
|
|
108
|
-
return realpathSync(fileURLToPath(import.meta.url)) === realpathSync(process.argv[1]);
|
|
174
|
+
return fs.realpathSync(fileURLToPath(import.meta.url)) === fs.realpathSync(process.argv[1]);
|
|
109
175
|
} catch {
|
|
110
176
|
return false;
|
|
111
177
|
}
|
|
112
178
|
})();
|
|
113
179
|
|
|
114
180
|
if (isMain) {
|
|
181
|
+
const pkg = JSON.parse(fs.readFileSync(new URL("../package.json", import.meta.url), "utf-8"));
|
|
182
|
+
|
|
115
183
|
const { loadConfig, saveConfig, generateTopic } = await import(
|
|
116
184
|
"../src/config.mjs"
|
|
117
185
|
);
|
|
@@ -119,7 +187,7 @@ if (isMain) {
|
|
|
119
187
|
"../src/ntfy.mjs"
|
|
120
188
|
);
|
|
121
189
|
const { processHook } = await import("../src/hook.mjs");
|
|
122
|
-
const { runSetup } = await import("../src/setup.mjs");
|
|
190
|
+
const { runSetup, registerHook, getHookCommand, unregisterHook } = await import("../src/setup.mjs");
|
|
123
191
|
|
|
124
192
|
const args = process.argv.slice(2);
|
|
125
193
|
|
|
@@ -141,6 +209,14 @@ if (isMain) {
|
|
|
141
209
|
formatToolInfo,
|
|
142
210
|
processHook,
|
|
143
211
|
runSetup,
|
|
212
|
+
registerHook,
|
|
213
|
+
getHookCommand,
|
|
214
|
+
unregisterHook,
|
|
215
|
+
version: pkg.version,
|
|
216
|
+
generateQR: (text, opts, cb) => qrcode.generate(text, opts, cb),
|
|
217
|
+
unlinkSync: fs.unlinkSync,
|
|
218
|
+
configPath: (await import("../src/config.mjs")).CONFIG_PATH,
|
|
219
|
+
settingsPath: path.join(os.homedir(), ".claude", "settings.json"),
|
|
144
220
|
stdout: process.stdout,
|
|
145
221
|
stderr: process.stderr,
|
|
146
222
|
stdin: stdinData,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-remote-approver",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Approve or deny Claude Code permission prompts remotely from your phone via ntfy.sh",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
"README.md"
|
|
14
14
|
],
|
|
15
15
|
"scripts": {
|
|
16
|
-
"test": "node --test test
|
|
16
|
+
"test": "node --test test/*.test.mjs"
|
|
17
17
|
},
|
|
18
18
|
"keywords": [
|
|
19
19
|
"claude",
|
|
@@ -32,5 +32,8 @@
|
|
|
32
32
|
"repository": {
|
|
33
33
|
"type": "git",
|
|
34
34
|
"url": "git+https://github.com/eguchiyuuichi/claude-remote-approver.git"
|
|
35
|
+
},
|
|
36
|
+
"dependencies": {
|
|
37
|
+
"qrcode-terminal": "0.12.0"
|
|
35
38
|
}
|
|
36
39
|
}
|
package/src/setup.mjs
CHANGED
|
@@ -1,11 +1,21 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Returns true if the entry belongs to claude-remote-approver.
|
|
7
|
+
*/
|
|
8
|
+
function isCraEntry(entry) {
|
|
9
|
+
if (entry.hooks?.some((h) => h.command?.includes("claude-remote-approver"))) return true;
|
|
10
|
+
if (entry.command?.includes("claude-remote-approver")) return true;
|
|
11
|
+
return false;
|
|
12
|
+
}
|
|
3
13
|
|
|
4
14
|
/**
|
|
5
15
|
* Returns the hook command string: `node <absolute_path_to_src/hook.mjs>`
|
|
6
16
|
*/
|
|
7
17
|
export function getHookCommand() {
|
|
8
|
-
const hookPath = path.resolve(import.meta.
|
|
18
|
+
const hookPath = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "hook.mjs");
|
|
9
19
|
return `node "${hookPath}"`;
|
|
10
20
|
}
|
|
11
21
|
|
|
@@ -33,11 +43,9 @@ export function registerHook(settingsPath, hookCommand) {
|
|
|
33
43
|
settings.hooks.PermissionRequest = [];
|
|
34
44
|
}
|
|
35
45
|
|
|
36
|
-
const existingIndex = settings.hooks.PermissionRequest.findIndex(
|
|
37
|
-
(h) => h.command && h.command.includes("claude-remote-approver")
|
|
38
|
-
);
|
|
46
|
+
const existingIndex = settings.hooks.PermissionRequest.findIndex(isCraEntry);
|
|
39
47
|
|
|
40
|
-
const hookEntry = { type: "command", command: hookCommand };
|
|
48
|
+
const hookEntry = { hooks: [{ type: "command", command: hookCommand }] };
|
|
41
49
|
|
|
42
50
|
if (existingIndex >= 0) {
|
|
43
51
|
settings.hooks.PermissionRequest[existingIndex] = hookEntry;
|
|
@@ -48,6 +56,39 @@ export function registerHook(settingsPath, hookCommand) {
|
|
|
48
56
|
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2));
|
|
49
57
|
}
|
|
50
58
|
|
|
59
|
+
/**
|
|
60
|
+
* Removes the claude-remote-approver hook entry from Claude's settings.json.
|
|
61
|
+
* If the file does not exist, does nothing.
|
|
62
|
+
*/
|
|
63
|
+
export function unregisterHook(settingsPath) {
|
|
64
|
+
let settings;
|
|
65
|
+
try {
|
|
66
|
+
settings = JSON.parse(fs.readFileSync(settingsPath, "utf-8"));
|
|
67
|
+
} catch (err) {
|
|
68
|
+
if (err.code === "ENOENT") return;
|
|
69
|
+
throw err;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (!settings.hooks?.PermissionRequest) return;
|
|
73
|
+
|
|
74
|
+
const original = settings.hooks.PermissionRequest;
|
|
75
|
+
const filtered = original.filter((entry) => !isCraEntry(entry));
|
|
76
|
+
|
|
77
|
+
if (filtered.length === original.length) return;
|
|
78
|
+
|
|
79
|
+
if (filtered.length === 0) {
|
|
80
|
+
delete settings.hooks.PermissionRequest;
|
|
81
|
+
} else {
|
|
82
|
+
settings.hooks.PermissionRequest = filtered;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (Object.keys(settings.hooks).length === 0) {
|
|
86
|
+
delete settings.hooks;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2));
|
|
90
|
+
}
|
|
91
|
+
|
|
51
92
|
/**
|
|
52
93
|
* Runs the full setup flow:
|
|
53
94
|
* 1. Generate a topic
|
|
@@ -71,5 +112,5 @@ export async function runSetup({
|
|
|
71
112
|
const hookCommand = getHookCommand();
|
|
72
113
|
registerHook(settingsPath, hookCommand);
|
|
73
114
|
|
|
74
|
-
return { topic, configPath, settingsPath };
|
|
115
|
+
return { topic, ntfyServer: config.ntfyServer, configPath, settingsPath };
|
|
75
116
|
}
|