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 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 topic name. Subscribe to that topic in the ntfy app, and you are done.
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, open the ntfy app on your phone and subscribe to the topic printed in the terminal.
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 { realpathSync } from "node:fs";
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 Set up remote approval\n test Send a test notification\n status Show current configuration\n hook Process a Claude Code hook (internal)\n");
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("0.1.0\n");
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 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",
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.1.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/**/*.test.mjs"
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.dirname, "hook.mjs");
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
  }