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 +64 -0
- package/assets/nathan-1.png +0 -0
- package/assets/nathan-2.png +0 -0
- package/assets/nathan-3.png +0 -0
- package/assets/nathan-4.png +0 -0
- package/bin/cli.js +55 -0
- package/index.js +23 -0
- package/package.json +37 -0
- package/scripts/notify.js +104 -0
- package/scripts/postinstall.js +50 -0
- package/scripts/preuninstall.js +15 -0
- package/scripts/settings.js +121 -0
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
|
+
};
|