@hasna/hooks 0.0.1
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/.npmrc.example +2 -0
- package/AGENTS.md +54 -0
- package/CLAUDE.md +70 -0
- package/CONTRIBUTING.md +45 -0
- package/README.md +232 -0
- package/bin/index.js +5171 -0
- package/hooks/hook-agentmessages/CLAUDE.md +79 -0
- package/hooks/hook-agentmessages/LICENSE +21 -0
- package/hooks/hook-agentmessages/README.md +107 -0
- package/hooks/hook-agentmessages/package.json +31 -0
- package/hooks/hook-agentmessages/src/check-messages.ts +151 -0
- package/hooks/hook-agentmessages/src/install.ts +126 -0
- package/hooks/hook-agentmessages/src/session-start.ts +255 -0
- package/hooks/hook-agentmessages/src/uninstall.ts +89 -0
- package/hooks/hook-branchprotect/CLAUDE.md +23 -0
- package/hooks/hook-branchprotect/README.md +25 -0
- package/hooks/hook-branchprotect/package.json +42 -0
- package/hooks/hook-branchprotect/src/cli.ts +126 -0
- package/hooks/hook-branchprotect/src/hook.ts +88 -0
- package/hooks/hook-branchprotect/tsconfig.json +25 -0
- package/hooks/hook-checkbugs/LICENSE +21 -0
- package/hooks/hook-checkbugs/README.md +140 -0
- package/hooks/hook-checkbugs/package.json +58 -0
- package/hooks/hook-checkbugs/src/cli.ts +628 -0
- package/hooks/hook-checkbugs/src/hook.ts +335 -0
- package/hooks/hook-checkbugs/tsconfig.json +15 -0
- package/hooks/hook-checkdocs/README.md +137 -0
- package/hooks/hook-checkdocs/package.json +57 -0
- package/hooks/hook-checkdocs/src/cli.ts +628 -0
- package/hooks/hook-checkdocs/src/hook.ts +310 -0
- package/hooks/hook-checkdocs/tsconfig.json +15 -0
- package/hooks/hook-checkfiles/LICENSE +21 -0
- package/hooks/hook-checkfiles/README.md +141 -0
- package/hooks/hook-checkfiles/package.json +56 -0
- package/hooks/hook-checkfiles/src/cli.ts +545 -0
- package/hooks/hook-checkfiles/src/hook.ts +321 -0
- package/hooks/hook-checkfiles/tsconfig.json +15 -0
- package/hooks/hook-checklint/LICENSE +21 -0
- package/hooks/hook-checklint/README.md +147 -0
- package/hooks/hook-checklint/package.json +57 -0
- package/hooks/hook-checklint/src/cli-patch.ts +32 -0
- package/hooks/hook-checklint/src/cli.ts +667 -0
- package/hooks/hook-checklint/src/hook.ts +473 -0
- package/hooks/hook-checklint/tsconfig.json +15 -0
- package/hooks/hook-checkpoint/CLAUDE.md +23 -0
- package/hooks/hook-checkpoint/README.md +37 -0
- package/hooks/hook-checkpoint/package.json +58 -0
- package/hooks/hook-checkpoint/src/cli.ts +191 -0
- package/hooks/hook-checkpoint/src/hook.ts +207 -0
- package/hooks/hook-checkpoint/tsconfig.json +25 -0
- package/hooks/hook-checksecurity/LICENSE +21 -0
- package/hooks/hook-checksecurity/README.md +158 -0
- package/hooks/hook-checksecurity/package.json +57 -0
- package/hooks/hook-checksecurity/src/cli.ts +601 -0
- package/hooks/hook-checksecurity/src/hook.ts +334 -0
- package/hooks/hook-checksecurity/tsconfig.json +15 -0
- package/hooks/hook-checktasks/README.md +144 -0
- package/hooks/hook-checktasks/package.json +55 -0
- package/hooks/hook-checktasks/src/cli.ts +578 -0
- package/hooks/hook-checktasks/src/hook.ts +308 -0
- package/hooks/hook-checktasks/tsconfig.json +20 -0
- package/hooks/hook-checktests/LICENSE +21 -0
- package/hooks/hook-checktests/README.md +137 -0
- package/hooks/hook-checktests/package.json +57 -0
- package/hooks/hook-checktests/src/cli.ts +627 -0
- package/hooks/hook-checktests/src/hook.ts +334 -0
- package/hooks/hook-checktests/tsconfig.json +15 -0
- package/hooks/hook-contextrefresh/CLAUDE.md +23 -0
- package/hooks/hook-contextrefresh/README.md +42 -0
- package/hooks/hook-contextrefresh/package.json +42 -0
- package/hooks/hook-contextrefresh/src/cli.ts +152 -0
- package/hooks/hook-contextrefresh/src/hook.ts +148 -0
- package/hooks/hook-contextrefresh/tsconfig.json +25 -0
- package/hooks/hook-gitguard/CLAUDE.md +22 -0
- package/hooks/hook-gitguard/README.md +30 -0
- package/hooks/hook-gitguard/package.json +57 -0
- package/hooks/hook-gitguard/src/cli.ts +159 -0
- package/hooks/hook-gitguard/src/hook.ts +129 -0
- package/hooks/hook-gitguard/tsconfig.json +25 -0
- package/hooks/hook-packageage/CLAUDE.md +23 -0
- package/hooks/hook-packageage/README.md +33 -0
- package/hooks/hook-packageage/package.json +42 -0
- package/hooks/hook-packageage/src/cli.ts +165 -0
- package/hooks/hook-packageage/src/hook.ts +177 -0
- package/hooks/hook-packageage/tsconfig.json +25 -0
- package/hooks/hook-phonenotify/CLAUDE.md +25 -0
- package/hooks/hook-phonenotify/README.md +44 -0
- package/hooks/hook-phonenotify/package.json +42 -0
- package/hooks/hook-phonenotify/src/cli.ts +196 -0
- package/hooks/hook-phonenotify/src/hook.ts +139 -0
- package/hooks/hook-phonenotify/tsconfig.json +25 -0
- package/hooks/hook-precompact/CLAUDE.md +23 -0
- package/hooks/hook-precompact/README.md +36 -0
- package/hooks/hook-precompact/package.json +42 -0
- package/hooks/hook-precompact/src/cli.ts +168 -0
- package/hooks/hook-precompact/src/hook.ts +122 -0
- package/hooks/hook-precompact/tsconfig.json +25 -0
- package/package.json +61 -0
- package/src/cli/components/App.tsx +191 -0
- package/src/cli/components/CategorySelect.tsx +37 -0
- package/src/cli/components/DataTable.tsx +133 -0
- package/src/cli/components/Header.tsx +18 -0
- package/src/cli/components/HookSelect.tsx +29 -0
- package/src/cli/components/InstallProgress.tsx +105 -0
- package/src/cli/components/SearchView.tsx +86 -0
- package/src/cli/index.tsx +218 -0
- package/src/index.ts +31 -0
- package/src/lib/installer.ts +288 -0
- package/src/lib/registry.ts +205 -0
- package/tsconfig.json +17 -0
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* CLI for hook-packageage
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync } from "fs";
|
|
8
|
+
import { join } from "path";
|
|
9
|
+
import { homedir } from "os";
|
|
10
|
+
|
|
11
|
+
const HOOK_NAME = "hook-packageage";
|
|
12
|
+
const SETTINGS_PATH = join(homedir(), ".claude", "settings.json");
|
|
13
|
+
|
|
14
|
+
interface ClaudeSettings {
|
|
15
|
+
hooks?: {
|
|
16
|
+
PreToolUse?: Array<{
|
|
17
|
+
matcher: string;
|
|
18
|
+
hooks: Array<{ type: "command"; command: string }>;
|
|
19
|
+
}>;
|
|
20
|
+
};
|
|
21
|
+
[key: string]: unknown;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function readSettings(): ClaudeSettings {
|
|
25
|
+
try {
|
|
26
|
+
if (existsSync(SETTINGS_PATH)) {
|
|
27
|
+
return JSON.parse(readFileSync(SETTINGS_PATH, "utf-8"));
|
|
28
|
+
}
|
|
29
|
+
} catch {}
|
|
30
|
+
return {};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function writeSettings(settings: ClaudeSettings): void {
|
|
34
|
+
const dir = join(homedir(), ".claude");
|
|
35
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
36
|
+
writeFileSync(SETTINGS_PATH, JSON.stringify(settings, null, 2));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function install(): void {
|
|
40
|
+
const settings = readSettings();
|
|
41
|
+
if (!settings.hooks) settings.hooks = {};
|
|
42
|
+
if (!settings.hooks.PreToolUse) settings.hooks.PreToolUse = [];
|
|
43
|
+
|
|
44
|
+
const existing = settings.hooks.PreToolUse.find((h) =>
|
|
45
|
+
h.hooks.some((hook) => hook.command.includes(HOOK_NAME))
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
if (existing) {
|
|
49
|
+
console.log(`${HOOK_NAME} is already installed`);
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
settings.hooks.PreToolUse.push({
|
|
54
|
+
matcher: "Bash",
|
|
55
|
+
hooks: [{ type: "command", command: `bunx @hasnaxyz/${HOOK_NAME}` }],
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
writeSettings(settings);
|
|
59
|
+
console.log(`${HOOK_NAME} installed successfully`);
|
|
60
|
+
console.log("Hook will check package age before npm/bun install commands");
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function uninstall(): void {
|
|
64
|
+
const settings = readSettings();
|
|
65
|
+
if (!settings.hooks?.PreToolUse) {
|
|
66
|
+
console.log(`${HOOK_NAME} is not installed`);
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const before = settings.hooks.PreToolUse.length;
|
|
71
|
+
settings.hooks.PreToolUse = settings.hooks.PreToolUse.filter(
|
|
72
|
+
(h) => !h.hooks.some((hook) => hook.command.includes(HOOK_NAME))
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
if (before === settings.hooks.PreToolUse.length) {
|
|
76
|
+
console.log(`${HOOK_NAME} is not installed`);
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
writeSettings(settings);
|
|
81
|
+
console.log(`${HOOK_NAME} uninstalled successfully`);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function status(): void {
|
|
85
|
+
const settings = readSettings();
|
|
86
|
+
const installed = settings.hooks?.PreToolUse?.some((h) =>
|
|
87
|
+
h.hooks.some((hook) => hook.command.includes(HOOK_NAME))
|
|
88
|
+
);
|
|
89
|
+
console.log(`${HOOK_NAME} is ${installed ? "installed" : "not installed"}`);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function check(packageName: string): Promise<void> {
|
|
93
|
+
console.log(`Checking ${packageName}...`);
|
|
94
|
+
try {
|
|
95
|
+
const response = await fetch(`https://registry.npmjs.org/${encodeURIComponent(packageName)}`);
|
|
96
|
+
if (!response.ok) {
|
|
97
|
+
console.error(`Package not found: ${packageName}`);
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
const data = await response.json() as Record<string, unknown>;
|
|
101
|
+
const time = data.time as Record<string, string> | undefined;
|
|
102
|
+
const modified = time?.modified;
|
|
103
|
+
if (modified) {
|
|
104
|
+
const days = Math.floor((Date.now() - new Date(modified).getTime()) / (1000 * 60 * 60 * 24));
|
|
105
|
+
const status = days > 730 ? "ABANDONED" : days > 365 ? "STALE" : "ACTIVE";
|
|
106
|
+
console.log(` Last updated: ${modified} (${days} days ago) — ${status}`);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const distTags = data["dist-tags"] as Record<string, string> | undefined;
|
|
110
|
+
const latestVersion = distTags?.latest;
|
|
111
|
+
const versions = data.versions as Record<string, Record<string, unknown>> | undefined;
|
|
112
|
+
if (latestVersion && versions?.[latestVersion]?.deprecated) {
|
|
113
|
+
console.log(` DEPRECATED: ${versions[latestVersion].deprecated}`);
|
|
114
|
+
}
|
|
115
|
+
} catch (error) {
|
|
116
|
+
console.error(`Error checking ${packageName}:`, error);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function help(): void {
|
|
121
|
+
console.log(`
|
|
122
|
+
${HOOK_NAME} - Check package age before install
|
|
123
|
+
|
|
124
|
+
Usage: ${HOOK_NAME} <command>
|
|
125
|
+
|
|
126
|
+
Commands:
|
|
127
|
+
install Install hook to Claude Code settings
|
|
128
|
+
uninstall Remove hook from Claude Code settings
|
|
129
|
+
status Check if hook is installed
|
|
130
|
+
check <pkg> Manually check a package's age
|
|
131
|
+
help Show this help message
|
|
132
|
+
|
|
133
|
+
Thresholds:
|
|
134
|
+
> 1 year since last publish: STALE warning
|
|
135
|
+
> 2 years since last publish: ABANDONED warning
|
|
136
|
+
Deprecated packages: always warned
|
|
137
|
+
`);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const command = process.argv[2];
|
|
141
|
+
|
|
142
|
+
switch (command) {
|
|
143
|
+
case "install": install(); break;
|
|
144
|
+
case "uninstall": uninstall(); break;
|
|
145
|
+
case "status": status(); break;
|
|
146
|
+
case "check":
|
|
147
|
+
if (process.argv[3]) {
|
|
148
|
+
check(process.argv[3]);
|
|
149
|
+
} else {
|
|
150
|
+
console.error("Usage: hook-packageage check <package-name>");
|
|
151
|
+
process.exit(1);
|
|
152
|
+
}
|
|
153
|
+
break;
|
|
154
|
+
case "help":
|
|
155
|
+
case "--help":
|
|
156
|
+
case "-h": help(); break;
|
|
157
|
+
default:
|
|
158
|
+
if (!command) {
|
|
159
|
+
import("./hook.ts").then((m) => m.run());
|
|
160
|
+
} else {
|
|
161
|
+
console.error(`Unknown command: ${command}`);
|
|
162
|
+
help();
|
|
163
|
+
process.exit(1);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Claude Code Hook: packageage
|
|
5
|
+
*
|
|
6
|
+
* PreToolUse hook that checks package age before npm/bun install commands.
|
|
7
|
+
* Warns on packages that haven't been updated in over a year (potentially
|
|
8
|
+
* abandoned) or have known deprecation markers.
|
|
9
|
+
*
|
|
10
|
+
* Checks the npm registry for last publish date and warns accordingly.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { readFileSync } from "fs";
|
|
14
|
+
|
|
15
|
+
interface HookInput {
|
|
16
|
+
session_id: string;
|
|
17
|
+
cwd: string;
|
|
18
|
+
tool_name: string;
|
|
19
|
+
tool_input: Record<string, unknown>;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface HookOutput {
|
|
23
|
+
decision?: "approve" | "block";
|
|
24
|
+
reason?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const INSTALL_PATTERNS = [
|
|
28
|
+
/(?:npm|bun|yarn|pnpm)\s+(?:install|add|i)\s+/,
|
|
29
|
+
];
|
|
30
|
+
|
|
31
|
+
const STALE_THRESHOLD_DAYS = 365; // 1 year
|
|
32
|
+
const ABANDONED_THRESHOLD_DAYS = 730; // 2 years
|
|
33
|
+
|
|
34
|
+
function readStdinJson(): HookInput | null {
|
|
35
|
+
try {
|
|
36
|
+
const input = readFileSync(0, "utf-8").trim();
|
|
37
|
+
if (!input) return null;
|
|
38
|
+
return JSON.parse(input);
|
|
39
|
+
} catch {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Extract package names from an install command
|
|
46
|
+
*/
|
|
47
|
+
function extractPackageNames(command: string): string[] {
|
|
48
|
+
// Match: npm install pkg1 pkg2 / bun add pkg1 / etc.
|
|
49
|
+
const match = command.match(/(?:npm|bun|yarn|pnpm)\s+(?:install|add|i)\s+(.*)/);
|
|
50
|
+
if (!match) return [];
|
|
51
|
+
|
|
52
|
+
return match[1]
|
|
53
|
+
.split(/\s+/)
|
|
54
|
+
.filter((arg) => !arg.startsWith("-") && !arg.startsWith("--"))
|
|
55
|
+
.map((pkg) => pkg.replace(/@[\^~><=\d].*$/, "")) // strip version specifier
|
|
56
|
+
.filter((pkg) => pkg.length > 0 && !pkg.startsWith(".")); // filter paths
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Check package age via npm registry
|
|
61
|
+
*/
|
|
62
|
+
async function checkPackageAge(packageName: string): Promise<{
|
|
63
|
+
name: string;
|
|
64
|
+
lastPublish: Date | null;
|
|
65
|
+
daysSincePublish: number;
|
|
66
|
+
deprecated: boolean;
|
|
67
|
+
error?: string;
|
|
68
|
+
}> {
|
|
69
|
+
try {
|
|
70
|
+
const response = await fetch(
|
|
71
|
+
`https://registry.npmjs.org/${encodeURIComponent(packageName)}`,
|
|
72
|
+
{ signal: AbortSignal.timeout(5000) }
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
if (!response.ok) {
|
|
76
|
+
return { name: packageName, lastPublish: null, daysSincePublish: 0, deprecated: false, error: `HTTP ${response.status}` };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const data = await response.json() as Record<string, unknown>;
|
|
80
|
+
|
|
81
|
+
// Check deprecation
|
|
82
|
+
const distTags = data["dist-tags"] as Record<string, string> | undefined;
|
|
83
|
+
const latestVersion = distTags?.latest;
|
|
84
|
+
const versions = data.versions as Record<string, Record<string, unknown>> | undefined;
|
|
85
|
+
const deprecated = latestVersion && versions?.[latestVersion]?.deprecated ? true : false;
|
|
86
|
+
|
|
87
|
+
// Get last publish date
|
|
88
|
+
const time = data.time as Record<string, string> | undefined;
|
|
89
|
+
const modified = time?.modified;
|
|
90
|
+
|
|
91
|
+
if (!modified) {
|
|
92
|
+
return { name: packageName, lastPublish: null, daysSincePublish: 0, deprecated };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const lastPublish = new Date(modified);
|
|
96
|
+
const daysSincePublish = Math.floor(
|
|
97
|
+
(Date.now() - lastPublish.getTime()) / (1000 * 60 * 60 * 24)
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
return { name: packageName, lastPublish, daysSincePublish, deprecated };
|
|
101
|
+
} catch (error) {
|
|
102
|
+
return {
|
|
103
|
+
name: packageName,
|
|
104
|
+
lastPublish: null,
|
|
105
|
+
daysSincePublish: 0,
|
|
106
|
+
deprecated: false,
|
|
107
|
+
error: error instanceof Error ? error.message : String(error),
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function respond(output: HookOutput): void {
|
|
113
|
+
console.log(JSON.stringify(output));
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export async function run(): Promise<void> {
|
|
117
|
+
const input = readStdinJson();
|
|
118
|
+
|
|
119
|
+
if (!input) {
|
|
120
|
+
respond({ decision: "approve" });
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (input.tool_name !== "Bash") {
|
|
125
|
+
respond({ decision: "approve" });
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const command = input.tool_input?.command as string;
|
|
130
|
+
if (!command || typeof command !== "string") {
|
|
131
|
+
respond({ decision: "approve" });
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Check if this is a package install command
|
|
136
|
+
const isInstall = INSTALL_PATTERNS.some((p) => p.test(command));
|
|
137
|
+
if (!isInstall) {
|
|
138
|
+
respond({ decision: "approve" });
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const packages = extractPackageNames(command);
|
|
143
|
+
if (packages.length === 0) {
|
|
144
|
+
respond({ decision: "approve" });
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const warnings: string[] = [];
|
|
149
|
+
|
|
150
|
+
// Check each package (in parallel, with timeout)
|
|
151
|
+
const results = await Promise.all(packages.map(checkPackageAge));
|
|
152
|
+
|
|
153
|
+
for (const result of results) {
|
|
154
|
+
if (result.deprecated) {
|
|
155
|
+
warnings.push(`${result.name}: DEPRECATED`);
|
|
156
|
+
}
|
|
157
|
+
if (result.daysSincePublish > ABANDONED_THRESHOLD_DAYS) {
|
|
158
|
+
warnings.push(`${result.name}: possibly abandoned (last updated ${result.daysSincePublish} days ago)`);
|
|
159
|
+
} else if (result.daysSincePublish > STALE_THRESHOLD_DAYS) {
|
|
160
|
+
warnings.push(`${result.name}: stale (last updated ${result.daysSincePublish} days ago)`);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (warnings.length > 0) {
|
|
165
|
+
const reason = `Package age warnings:\n${warnings.map((w) => ` - ${w}`).join("\n")}\n\nConsider using more actively maintained alternatives.`;
|
|
166
|
+
console.error(`[hook-packageage] ${reason}`);
|
|
167
|
+
// Warn but don't block — just inject the warning
|
|
168
|
+
respond({ decision: "approve", reason });
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
respond({ decision: "approve" });
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
if (import.meta.main) {
|
|
176
|
+
run();
|
|
177
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ESNext",
|
|
4
|
+
"module": "ESNext",
|
|
5
|
+
"lib": ["ESNext"],
|
|
6
|
+
"moduleResolution": "bundler",
|
|
7
|
+
"allowImportingTsExtensions": true,
|
|
8
|
+
"strict": true,
|
|
9
|
+
"esModuleInterop": true,
|
|
10
|
+
"skipLibCheck": true,
|
|
11
|
+
"forceConsistentCasingInFileNames": true,
|
|
12
|
+
"resolveJsonModule": true,
|
|
13
|
+
"isolatedModules": true,
|
|
14
|
+
"noEmit": true,
|
|
15
|
+
"noUnusedLocals": true,
|
|
16
|
+
"noUnusedParameters": true,
|
|
17
|
+
"declaration": true,
|
|
18
|
+
"declarationMap": true,
|
|
19
|
+
"outDir": "./dist",
|
|
20
|
+
"rootDir": "./src",
|
|
21
|
+
"types": ["bun-types"]
|
|
22
|
+
},
|
|
23
|
+
"include": ["src/**/*"],
|
|
24
|
+
"exclude": ["node_modules", "dist"]
|
|
25
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# CLAUDE.md
|
|
2
|
+
|
|
3
|
+
## hook-phonenotify
|
|
4
|
+
|
|
5
|
+
A Stop/Notification hook that sends push notifications to phone via ntfy.sh.
|
|
6
|
+
|
|
7
|
+
### Key Files
|
|
8
|
+
|
|
9
|
+
| File | Purpose |
|
|
10
|
+
|------|---------|
|
|
11
|
+
| `src/hook.ts` | Main hook logic — reads stdin, sends ntfy.sh notification |
|
|
12
|
+
| `src/cli.ts` | CLI — install/uninstall/status/test |
|
|
13
|
+
|
|
14
|
+
### Hook Events
|
|
15
|
+
|
|
16
|
+
- **Stop** — notifies when Claude finishes
|
|
17
|
+
- **Notification** — notifies when Claude needs attention
|
|
18
|
+
|
|
19
|
+
### Configuration
|
|
20
|
+
|
|
21
|
+
Reads `phoneNotifyConfig` from `~/.claude/settings.json`:
|
|
22
|
+
- `topic` — ntfy.sh topic name (required)
|
|
23
|
+
- `server` — ntfy server URL (default: https://ntfy.sh)
|
|
24
|
+
- `priority` — notification priority 1-5 (default: 3)
|
|
25
|
+
- `enabled` — toggle on/off
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# hook-phonenotify
|
|
2
|
+
|
|
3
|
+
Claude Code hook that sends push notifications to your phone via ntfy.sh.
|
|
4
|
+
|
|
5
|
+
## Overview
|
|
6
|
+
|
|
7
|
+
Get notified on your phone when Claude finishes a task or needs your attention. Uses [ntfy.sh](https://ntfy.sh) for free, no-account-required push notifications.
|
|
8
|
+
|
|
9
|
+
## Installation
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
bun install -g @hasnaxyz/hook-phonenotify
|
|
13
|
+
hook-phonenotify install my-secret-topic
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
Then subscribe to `my-secret-topic` in the ntfy app on your phone.
|
|
17
|
+
|
|
18
|
+
## Commands
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
hook-phonenotify install [topic] # Install with ntfy topic
|
|
22
|
+
hook-phonenotify uninstall # Remove hook
|
|
23
|
+
hook-phonenotify status # Show config
|
|
24
|
+
hook-phonenotify test # Send test notification
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Configuration
|
|
28
|
+
|
|
29
|
+
In `~/.claude/settings.json`:
|
|
30
|
+
|
|
31
|
+
```json
|
|
32
|
+
{
|
|
33
|
+
"phoneNotifyConfig": {
|
|
34
|
+
"enabled": true,
|
|
35
|
+
"topic": "your-secret-topic",
|
|
36
|
+
"server": "https://ntfy.sh",
|
|
37
|
+
"priority": 3
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## License
|
|
43
|
+
|
|
44
|
+
MIT
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@hasnaxyz/hook-phonenotify",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Claude Code hook that sends push notifications to phone via ntfy.sh",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"hook-phonenotify": "./dist/cli.js"
|
|
8
|
+
},
|
|
9
|
+
"main": "./dist/hook.js",
|
|
10
|
+
"exports": {
|
|
11
|
+
".": {
|
|
12
|
+
"import": "./dist/hook.js",
|
|
13
|
+
"types": "./dist/hook.d.ts"
|
|
14
|
+
},
|
|
15
|
+
"./cli": {
|
|
16
|
+
"import": "./dist/cli.js"
|
|
17
|
+
}
|
|
18
|
+
},
|
|
19
|
+
"files": ["dist", "README.md"],
|
|
20
|
+
"scripts": {
|
|
21
|
+
"build": "bun build ./src/cli.ts ./src/hook.ts --outdir ./dist --target node",
|
|
22
|
+
"prepublishOnly": "bun run build",
|
|
23
|
+
"typecheck": "tsc --noEmit"
|
|
24
|
+
},
|
|
25
|
+
"keywords": ["claude-code", "claude", "hook", "notifications", "phone", "ntfy", "push", "cli"],
|
|
26
|
+
"author": "Hasna",
|
|
27
|
+
"license": "MIT",
|
|
28
|
+
"repository": {
|
|
29
|
+
"type": "git",
|
|
30
|
+
"url": "https://github.com/hasnaxyz/hook-phonenotify.git"
|
|
31
|
+
},
|
|
32
|
+
"publishConfig": {
|
|
33
|
+
"access": "restricted",
|
|
34
|
+
"registry": "https://registry.npmjs.org/"
|
|
35
|
+
},
|
|
36
|
+
"engines": { "node": ">=18", "bun": ">=1.0" },
|
|
37
|
+
"devDependencies": {
|
|
38
|
+
"@types/bun": "^1.3.8",
|
|
39
|
+
"@types/node": "^20",
|
|
40
|
+
"typescript": "^5.0.0"
|
|
41
|
+
}
|
|
42
|
+
}
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* CLI for hook-phonenotify
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync } from "fs";
|
|
8
|
+
import { join } from "path";
|
|
9
|
+
import { homedir } from "os";
|
|
10
|
+
|
|
11
|
+
const HOOK_NAME = "hook-phonenotify";
|
|
12
|
+
const SETTINGS_PATH = join(homedir(), ".claude", "settings.json");
|
|
13
|
+
|
|
14
|
+
interface ClaudeSettings {
|
|
15
|
+
hooks?: {
|
|
16
|
+
Stop?: Array<{ matcher?: string; hooks: Array<{ type: "command"; command: string }> }>;
|
|
17
|
+
Notification?: Array<{ matcher?: string; hooks: Array<{ type: "command"; command: string }> }>;
|
|
18
|
+
};
|
|
19
|
+
phoneNotifyConfig?: {
|
|
20
|
+
enabled?: boolean;
|
|
21
|
+
topic?: string;
|
|
22
|
+
server?: string;
|
|
23
|
+
priority?: number;
|
|
24
|
+
};
|
|
25
|
+
[key: string]: unknown;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function readSettings(): ClaudeSettings {
|
|
29
|
+
try {
|
|
30
|
+
if (existsSync(SETTINGS_PATH)) {
|
|
31
|
+
return JSON.parse(readFileSync(SETTINGS_PATH, "utf-8"));
|
|
32
|
+
}
|
|
33
|
+
} catch {}
|
|
34
|
+
return {};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function writeSettings(settings: ClaudeSettings): void {
|
|
38
|
+
const dir = join(homedir(), ".claude");
|
|
39
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
40
|
+
writeFileSync(SETTINGS_PATH, JSON.stringify(settings, null, 2));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function install(topic?: string): void {
|
|
44
|
+
const settings = readSettings();
|
|
45
|
+
if (!settings.hooks) settings.hooks = {};
|
|
46
|
+
if (!settings.hooks.Stop) settings.hooks.Stop = [];
|
|
47
|
+
if (!settings.hooks.Notification) settings.hooks.Notification = [];
|
|
48
|
+
|
|
49
|
+
const hookCommand = `bunx @hasnaxyz/${HOOK_NAME}`;
|
|
50
|
+
|
|
51
|
+
const existing = settings.hooks.Stop.find((h) =>
|
|
52
|
+
h.hooks.some((hook) => hook.command.includes(HOOK_NAME))
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
if (existing) {
|
|
56
|
+
console.log(`${HOOK_NAME} is already installed`);
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
settings.hooks.Stop.push({
|
|
61
|
+
hooks: [{ type: "command", command: hookCommand }],
|
|
62
|
+
});
|
|
63
|
+
settings.hooks.Notification.push({
|
|
64
|
+
hooks: [{ type: "command", command: hookCommand }],
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
if (!settings.phoneNotifyConfig) {
|
|
68
|
+
settings.phoneNotifyConfig = {
|
|
69
|
+
enabled: true,
|
|
70
|
+
topic: topic || "claude-code",
|
|
71
|
+
server: "https://ntfy.sh",
|
|
72
|
+
priority: 3,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
writeSettings(settings);
|
|
77
|
+
console.log(`${HOOK_NAME} installed successfully`);
|
|
78
|
+
console.log(`Topic: ${settings.phoneNotifyConfig.topic}`);
|
|
79
|
+
console.log(`\nTo receive notifications, subscribe to your topic in the ntfy app:`);
|
|
80
|
+
console.log(` https://ntfy.sh/${settings.phoneNotifyConfig.topic}`);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function uninstall(): void {
|
|
84
|
+
const settings = readSettings();
|
|
85
|
+
let removed = false;
|
|
86
|
+
|
|
87
|
+
if (settings.hooks?.Stop) {
|
|
88
|
+
const before = settings.hooks.Stop.length;
|
|
89
|
+
settings.hooks.Stop = settings.hooks.Stop.filter(
|
|
90
|
+
(h) => !h.hooks.some((hook) => hook.command.includes(HOOK_NAME))
|
|
91
|
+
);
|
|
92
|
+
if (before !== settings.hooks.Stop.length) removed = true;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (settings.hooks?.Notification) {
|
|
96
|
+
const before = settings.hooks.Notification.length;
|
|
97
|
+
settings.hooks.Notification = settings.hooks.Notification.filter(
|
|
98
|
+
(h) => !h.hooks.some((hook) => hook.command.includes(HOOK_NAME))
|
|
99
|
+
);
|
|
100
|
+
if (before !== settings.hooks.Notification.length) removed = true;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (!removed) {
|
|
104
|
+
console.log(`${HOOK_NAME} is not installed`);
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
writeSettings(settings);
|
|
109
|
+
console.log(`${HOOK_NAME} uninstalled successfully`);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function status(): void {
|
|
113
|
+
const settings = readSettings();
|
|
114
|
+
const installed = settings.hooks?.Stop?.some((h) =>
|
|
115
|
+
h.hooks.some((hook) => hook.command.includes(HOOK_NAME))
|
|
116
|
+
);
|
|
117
|
+
console.log(`${HOOK_NAME} is ${installed ? "installed" : "not installed"}`);
|
|
118
|
+
|
|
119
|
+
if (settings.phoneNotifyConfig) {
|
|
120
|
+
console.log(`\nConfig:`);
|
|
121
|
+
console.log(` Enabled: ${settings.phoneNotifyConfig.enabled !== false}`);
|
|
122
|
+
console.log(` Topic: ${settings.phoneNotifyConfig.topic || "(not set)"}`);
|
|
123
|
+
console.log(` Server: ${settings.phoneNotifyConfig.server || "https://ntfy.sh"}`);
|
|
124
|
+
console.log(` Priority: ${settings.phoneNotifyConfig.priority || 3}`);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async function test(): Promise<void> {
|
|
129
|
+
console.log("Sending test notification...");
|
|
130
|
+
const settings = readSettings();
|
|
131
|
+
const config = settings.phoneNotifyConfig;
|
|
132
|
+
|
|
133
|
+
if (!config?.topic) {
|
|
134
|
+
console.error("No topic configured. Run: hook-phonenotify install <topic>");
|
|
135
|
+
process.exit(1);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const server = config.server || "https://ntfy.sh";
|
|
139
|
+
const url = `${server}/${config.topic}`;
|
|
140
|
+
|
|
141
|
+
const response = await fetch(url, {
|
|
142
|
+
method: "POST",
|
|
143
|
+
headers: {
|
|
144
|
+
Title: "Claude Code - Test",
|
|
145
|
+
Priority: String(config.priority || 3),
|
|
146
|
+
Tags: "robot,test_tube",
|
|
147
|
+
},
|
|
148
|
+
body: "This is a test notification from hook-phonenotify.",
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
if (response.ok) {
|
|
152
|
+
console.log("Test notification sent!");
|
|
153
|
+
} else {
|
|
154
|
+
console.error(`Failed: ${response.status} ${response.statusText}`);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function help(): void {
|
|
159
|
+
console.log(`
|
|
160
|
+
${HOOK_NAME} - Push notifications to phone via ntfy.sh
|
|
161
|
+
|
|
162
|
+
Usage: ${HOOK_NAME} <command>
|
|
163
|
+
|
|
164
|
+
Commands:
|
|
165
|
+
install [topic] Install hook (optional: set ntfy topic)
|
|
166
|
+
uninstall Remove hook from Claude Code settings
|
|
167
|
+
status Check if hook is installed and show config
|
|
168
|
+
test Send a test notification
|
|
169
|
+
help Show this help message
|
|
170
|
+
|
|
171
|
+
Setup:
|
|
172
|
+
1. Install the ntfy app on your phone (iOS/Android)
|
|
173
|
+
2. Subscribe to your chosen topic
|
|
174
|
+
3. Run: ${HOOK_NAME} install my-secret-topic
|
|
175
|
+
`);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const command = process.argv[2];
|
|
179
|
+
|
|
180
|
+
switch (command) {
|
|
181
|
+
case "install": install(process.argv[3]); break;
|
|
182
|
+
case "uninstall": uninstall(); break;
|
|
183
|
+
case "status": status(); break;
|
|
184
|
+
case "test": test(); break;
|
|
185
|
+
case "help":
|
|
186
|
+
case "--help":
|
|
187
|
+
case "-h": help(); break;
|
|
188
|
+
default:
|
|
189
|
+
if (!command) {
|
|
190
|
+
import("./hook.ts").then((m) => m.run());
|
|
191
|
+
} else {
|
|
192
|
+
console.error(`Unknown command: ${command}`);
|
|
193
|
+
help();
|
|
194
|
+
process.exit(1);
|
|
195
|
+
}
|
|
196
|
+
}
|