nathangong 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/README.md ADDED
@@ -0,0 +1,64 @@
1
+ # nathangong
2
+
3
+ Pops a macOS notification with a photo of **Nathan Gong** every time Claude Code
4
+ finishes responding to a prompt. Download it and it just works.
5
+
6
+ ## Install
7
+
8
+ ```bash
9
+ npm install -g nathangong
10
+ ```
11
+
12
+ That's it. The `postinstall` step wires a Claude Code [`Stop` hook](https://docs.claude.com/en/docs/claude-code/hooks)
13
+ into `~/.claude/settings.json`. The next time Claude finishes a turn, Nathan says hi.
14
+
15
+ ### Photos vs. emoji
16
+
17
+ The photo is rendered via the bundled [`node-notifier`](https://github.com/mikaelbr/node-notifier),
18
+ whose `terminal-notifier` binary is **x86_64**. So:
19
+
20
+ - **Intel Mac**, or **Apple Silicon with Rosetta** β†’ you get the **photo**. πŸ“Έ
21
+ - **Apple Silicon without Rosetta** β†’ you get an **emoji notification** (macOS
22
+ can't attach a custom image without a runnable helper binary).
23
+
24
+ To get photos on Apple Silicon, install Rosetta once:
25
+
26
+ ```bash
27
+ softwareupdate --install-rosetta --agree-to-license
28
+ ```
29
+
30
+ ## Commands
31
+
32
+ ```bash
33
+ nathangong test # fire a notification right now to check it works
34
+ nathangong install # (re)wire the Stop hook
35
+ nathangong uninstall # remove the Stop hook
36
+ ```
37
+
38
+ ## Uninstall
39
+
40
+ ```bash
41
+ npm uninstall -g nathangong
42
+ ```
43
+
44
+ The `preuninstall` step removes the hook from your settings. (You can also run
45
+ `nathangong uninstall` first.)
46
+
47
+ ## How it works
48
+
49
+ - The `Stop` hook runs `node .../scripts/notify.js` when Claude ends a turn.
50
+ - `notify.js` picks a random photo from `assets/` and fires a notification via
51
+ `node-notifier` (`contentImage`), falling back to `osascript` (emoji only).
52
+ - Editing `~/.claude/settings.json` is idempotent and backs the file up to
53
+ `settings.json.nathangong.bak` before any change.
54
+
55
+ ## Use as a library
56
+
57
+ ```js
58
+ const { photos, randomPhoto } = require("nathangong");
59
+ randomPhoto(); // β†’ absolute path to a Nathan Gong photo
60
+ ```
61
+
62
+ ## License
63
+
64
+ MIT
Binary file
Binary file
Binary file
Binary file
package/bin/cli.js ADDED
@@ -0,0 +1,55 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+
4
+ const { spawn } = require("child_process");
5
+ const { addHook, removeHook, notifyScriptPath } = require("../scripts/settings");
6
+
7
+ process.stdout.on("error", () => {});
8
+
9
+ const cmd = (process.argv[2] || "").toLowerCase();
10
+
11
+ function install() {
12
+ const res = addHook();
13
+ console.log(
14
+ res.changed
15
+ ? "πŸ”” nathangong: Stop hook installed β†’ " + res.path
16
+ : "πŸ”” nathangong: Stop hook already present β†’ " + res.path
17
+ );
18
+ }
19
+
20
+ function uninstall() {
21
+ const res = removeHook();
22
+ console.log(
23
+ res.changed
24
+ ? "πŸ‘‹ nathangong: Stop hook removed."
25
+ : "nathangong: no Stop hook found (nothing to remove)."
26
+ );
27
+ }
28
+
29
+ function test() {
30
+ console.log("nathangong: firing a test notification…");
31
+ const child = spawn(process.execPath, [notifyScriptPath()], { stdio: "inherit" });
32
+ child.on("exit", (code) => process.exit(code || 0));
33
+ }
34
+
35
+ function help() {
36
+ console.log("nathangong β€” Nathan Gong greets you after every Claude Code turn.\n");
37
+ console.log("Usage:");
38
+ console.log(" nathangong install wire the Claude Code Stop hook");
39
+ console.log(" nathangong uninstall remove the hook");
40
+ console.log(" nathangong test fire a test notification now");
41
+ }
42
+
43
+ switch (cmd) {
44
+ case "install":
45
+ install();
46
+ break;
47
+ case "uninstall":
48
+ uninstall();
49
+ break;
50
+ case "test":
51
+ test();
52
+ break;
53
+ default:
54
+ help();
55
+ }
package/index.js ADDED
@@ -0,0 +1,23 @@
1
+ "use strict";
2
+
3
+ // A real, importable module β€” not just a hook. Exposes the bundled photos.
4
+
5
+ const fs = require("fs");
6
+ const path = require("path");
7
+
8
+ /** Absolute paths to every bundled Nathan Gong photo. */
9
+ function photos() {
10
+ const dir = path.join(__dirname, "assets");
11
+ return fs
12
+ .readdirSync(dir)
13
+ .filter((f) => /\.(png|jpe?g|gif)$/i.test(f))
14
+ .map((f) => path.join(dir, f));
15
+ }
16
+
17
+ /** A random Nathan Gong photo path. */
18
+ function randomPhoto() {
19
+ const all = photos();
20
+ return all[Math.floor(Math.random() * all.length)];
21
+ }
22
+
23
+ module.exports = { photos, randomPhoto };
package/package.json ADDED
@@ -0,0 +1,37 @@
1
+ {
2
+ "name": "nathangong",
3
+ "version": "0.1.0",
4
+ "description": "Pops a macOS notification with a photo of Nathan Gong after every Claude Code turn.",
5
+ "keywords": [
6
+ "nathan gong",
7
+ "claude",
8
+ "claude-code",
9
+ "hook",
10
+ "notification",
11
+ "macos",
12
+ "fun"
13
+ ],
14
+ "license": "MIT",
15
+ "main": "index.js",
16
+ "bin": {
17
+ "nathangong": "bin/cli.js"
18
+ },
19
+ "files": [
20
+ "bin",
21
+ "scripts",
22
+ "assets",
23
+ "index.js",
24
+ "README.md"
25
+ ],
26
+ "scripts": {
27
+ "postinstall": "node scripts/postinstall.js",
28
+ "preuninstall": "node scripts/preuninstall.js",
29
+ "test": "node bin/cli.js test"
30
+ },
31
+ "engines": {
32
+ "node": ">=14"
33
+ },
34
+ "dependencies": {
35
+ "node-notifier": "^10.0.1"
36
+ }
37
+ }
@@ -0,0 +1,104 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+
4
+ // The Claude Code `Stop` hook body: fires each time Claude finishes a turn.
5
+ // Pops a macOS notification with a random photo of Nathan Gong.
6
+ //
7
+ // Uses node-notifier, which bundles its own copy of terminal-notifier and
8
+ // supports `contentImage` β€” so the photo shows up with no extra system setup
9
+ // (no Homebrew needed). Falls back to plain `osascript` (emoji only) if
10
+ // node-notifier is unavailable or errors.
11
+ //
12
+ // Rules: always exit 0 (a failing hook nags every turn) and never hang.
13
+
14
+ const fs = require("fs");
15
+ const path = require("path");
16
+ const { spawn } = require("child_process");
17
+
18
+ process.stdout.on("error", () => {});
19
+ process.stderr.on("error", () => {});
20
+
21
+ const TITLE = "nathan gong πŸ””";
22
+ const LINES = [
23
+ "nathan gong has entered the chat πŸ§‘β€πŸ’»",
24
+ "a wild nathan gong appears πŸ‘‹",
25
+ "nathan gong approves this prompt βœ…",
26
+ "thinking about nathan gong πŸ€”",
27
+ "nathan gong says: nice turn πŸ€",
28
+ ];
29
+
30
+ function pick(arr) {
31
+ return arr[Math.floor(Math.random() * arr.length)];
32
+ }
33
+
34
+ function pickImage() {
35
+ try {
36
+ const dir = path.join(__dirname, "..", "assets");
37
+ const files = fs
38
+ .readdirSync(dir)
39
+ .filter((f) => /\.(png|jpe?g|gif)$/i.test(f))
40
+ .map((f) => path.join(dir, f));
41
+ return files.length ? pick(files) : null;
42
+ } catch (_) {
43
+ return null;
44
+ }
45
+ }
46
+
47
+ function done() {
48
+ process.exit(0);
49
+ }
50
+
51
+ // Emoji-only fallback β€” macOS's built-in notification can't render an image.
52
+ function osascriptFallback(line) {
53
+ const script =
54
+ "display notification " +
55
+ JSON.stringify(line) +
56
+ " with title " +
57
+ JSON.stringify(TITLE);
58
+ const child = spawn("osascript", ["-e", script], {
59
+ detached: true,
60
+ stdio: "ignore",
61
+ });
62
+ child.unref();
63
+ }
64
+
65
+ function main() {
66
+ if (process.platform !== "darwin") return done();
67
+
68
+ const line = pick(LINES);
69
+ const image = pickImage();
70
+
71
+ // Safety net: never let the hook hang the turn.
72
+ const safety = setTimeout(done, 5000);
73
+ safety.unref();
74
+
75
+ let notifier;
76
+ try {
77
+ notifier = require("node-notifier");
78
+ } catch (_) {
79
+ osascriptFallback(line);
80
+ return done();
81
+ }
82
+
83
+ try {
84
+ notifier.notify(
85
+ {
86
+ title: TITLE,
87
+ message: line,
88
+ contentImage: image || undefined, // right-side thumbnail (the photo)
89
+ appIcon: image || undefined, // left icon
90
+ sound: false,
91
+ wait: false,
92
+ },
93
+ (err) => {
94
+ if (err) osascriptFallback(line);
95
+ done();
96
+ }
97
+ );
98
+ } catch (_) {
99
+ osascriptFallback(line);
100
+ done();
101
+ }
102
+ }
103
+
104
+ main();
@@ -0,0 +1,50 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+
4
+ // Runs automatically on `npm install`. Wires the Claude Code `Stop` hook so
5
+ // Nathan Gong greets you after every turn. Must NEVER exit non-zero β€” that
6
+ // would fail the user's `npm install`.
7
+
8
+ // Never let a closed output pipe (e.g. npm truncating our logs) crash install.
9
+ process.stdout.on("error", () => {});
10
+
11
+ function log(msg) {
12
+ try {
13
+ process.stdout.write(msg + "\n");
14
+ } catch (_) {}
15
+ }
16
+
17
+ try {
18
+ const { addHook } = require("./settings");
19
+ const res = addHook();
20
+ if (res.changed) {
21
+ log("πŸ”” nathangong: wired a Claude Code Stop hook β†’ " + res.path);
22
+ log(" Nathan Gong will now greet you after every Claude turn.");
23
+ log(" Try it now: nathangong test");
24
+ } else {
25
+ log("πŸ”” nathangong: Stop hook already present (nothing to do).");
26
+ }
27
+ } catch (err) {
28
+ log("nathangong: couldn't wire the hook automatically (" + (err && err.message) + ").");
29
+ log("nathangong: run `nathangong install` to set it up manually.");
30
+ }
31
+
32
+ // Apple Silicon without Rosetta can't run the bundled x86_64 terminal-notifier,
33
+ // so it falls back to an emoji notification. Let the user know how to get photos.
34
+ try {
35
+ const os = require("os");
36
+ if (process.platform === "darwin" && os.arch() === "arm64") {
37
+ const { execFileSync } = require("child_process");
38
+ let rosetta = false;
39
+ try {
40
+ execFileSync("/usr/bin/pgrep", ["-q", "oahd"]);
41
+ rosetta = true;
42
+ } catch (_) {}
43
+ if (!rosetta) {
44
+ log("");
45
+ log("nathangong: you're on Apple Silicon without Rosetta β€” you'll get emoji");
46
+ log("nathangong: notifications. For actual PHOTOS of Nathan, install Rosetta once:");
47
+ log(" softwareupdate --install-rosetta --agree-to-license");
48
+ }
49
+ }
50
+ } catch (_) {}
@@ -0,0 +1,15 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+
4
+ // Runs on `npm uninstall`. Removes our Stop hook so a deleted notify.js can't
5
+ // error on every turn. Best-effort β€” never throw.
6
+
7
+ try {
8
+ const { removeHook } = require("./settings");
9
+ const res = removeHook();
10
+ if (res.changed) {
11
+ process.stdout.write("nathangong: removed the Claude Code Stop hook. πŸ‘‹\n");
12
+ }
13
+ } catch (_) {
14
+ /* best effort */
15
+ }
@@ -0,0 +1,121 @@
1
+ "use strict";
2
+
3
+ // Shared helpers for reading/writing the Claude Code user settings file and
4
+ // adding/removing our `Stop` hook. The Stop hook fires each time Claude Code
5
+ // finishes responding to a prompt β€” that's our "after every claude prompt".
6
+
7
+ const fs = require("fs");
8
+ const os = require("os");
9
+ const path = require("path");
10
+
11
+ function settingsPath() {
12
+ return path.join(os.homedir(), ".claude", "settings.json");
13
+ }
14
+
15
+ function notifyScriptPath() {
16
+ return path.join(__dirname, "notify.js");
17
+ }
18
+
19
+ function hookCommand() {
20
+ return `node "${notifyScriptPath()}"`;
21
+ }
22
+
23
+ // A hook is "ours" if its command shells out to nathangong's notify.js.
24
+ function isOurHook(command) {
25
+ return (
26
+ typeof command === "string" &&
27
+ command.includes("nathangong") &&
28
+ command.includes("notify.js")
29
+ );
30
+ }
31
+
32
+ function readSettings(p) {
33
+ try {
34
+ const parsed = JSON.parse(fs.readFileSync(p, "utf8"));
35
+ return parsed && typeof parsed === "object" ? parsed : {};
36
+ } catch (err) {
37
+ if (err && err.code === "ENOENT") return {};
38
+ // Malformed/unreadable: preserve it rather than clobber blindly.
39
+ try {
40
+ fs.copyFileSync(p, p + ".nathangong.corrupt.bak");
41
+ } catch (_) {}
42
+ return {};
43
+ }
44
+ }
45
+
46
+ function writeSettings(p, settings) {
47
+ fs.mkdirSync(path.dirname(p), { recursive: true });
48
+ fs.writeFileSync(p, JSON.stringify(settings, null, 2) + "\n", "utf8");
49
+ }
50
+
51
+ function hasOurHook(settings) {
52
+ const stop = settings && settings.hooks && settings.hooks.Stop;
53
+ return (
54
+ Array.isArray(stop) &&
55
+ stop.some(
56
+ (entry) =>
57
+ entry &&
58
+ Array.isArray(entry.hooks) &&
59
+ entry.hooks.some((h) => isOurHook(h && h.command))
60
+ )
61
+ );
62
+ }
63
+
64
+ function addHook() {
65
+ const p = settingsPath();
66
+ const settings = readSettings(p);
67
+
68
+ // Back up the existing file before we touch it.
69
+ try {
70
+ if (fs.existsSync(p)) fs.copyFileSync(p, p + ".nathangong.bak");
71
+ } catch (_) {}
72
+
73
+ if (hasOurHook(settings)) return { changed: false, path: p };
74
+
75
+ if (!settings.hooks || typeof settings.hooks !== "object") settings.hooks = {};
76
+ if (!Array.isArray(settings.hooks.Stop)) settings.hooks.Stop = [];
77
+
78
+ settings.hooks.Stop.push({
79
+ hooks: [{ type: "command", command: hookCommand() }],
80
+ });
81
+
82
+ writeSettings(p, settings);
83
+ return { changed: true, path: p };
84
+ }
85
+
86
+ function removeHook() {
87
+ const p = settingsPath();
88
+ if (!fs.existsSync(p)) return { changed: false, path: p };
89
+
90
+ const settings = readSettings(p);
91
+ const stop = settings && settings.hooks && settings.hooks.Stop;
92
+ if (!Array.isArray(stop)) return { changed: false, path: p };
93
+
94
+ let changed = false;
95
+ const filtered = stop
96
+ .map((entry) => {
97
+ if (!entry || !Array.isArray(entry.hooks)) return entry;
98
+ const kept = entry.hooks.filter((h) => !isOurHook(h && h.command));
99
+ if (kept.length !== entry.hooks.length) changed = true;
100
+ return Object.assign({}, entry, { hooks: kept });
101
+ })
102
+ .filter((entry) => !entry || !Array.isArray(entry.hooks) || entry.hooks.length > 0);
103
+
104
+ if (!changed) return { changed: false, path: p };
105
+
106
+ settings.hooks.Stop = filtered;
107
+ if (settings.hooks.Stop.length === 0) delete settings.hooks.Stop;
108
+ if (settings.hooks && Object.keys(settings.hooks).length === 0) delete settings.hooks;
109
+
110
+ writeSettings(p, settings);
111
+ return { changed: true, path: p };
112
+ }
113
+
114
+ module.exports = {
115
+ settingsPath,
116
+ notifyScriptPath,
117
+ hookCommand,
118
+ isOurHook,
119
+ addHook,
120
+ removeHook,
121
+ };