@hrushiborhade/pingme 1.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/LICENSE +21 -0
- package/README.md +170 -0
- package/bin/pingme.js +2 -0
- package/dist/commands/init.d.ts +1 -0
- package/dist/commands/init.js +83 -0
- package/dist/commands/test.d.ts +1 -0
- package/dist/commands/test.js +38 -0
- package/dist/commands/uninstall.d.ts +1 -0
- package/dist/commands/uninstall.js +46 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +56 -0
- package/dist/utils/install.d.ts +8 -0
- package/dist/utils/install.js +131 -0
- package/dist/utils/twilio.d.ts +18 -0
- package/dist/utils/twilio.js +92 -0
- package/package.json +52 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Hrushikesh Borhade
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
# pingme
|
|
2
|
+
|
|
3
|
+
> My Claude agent pings me when it's stuck. Now I doom scroll guilt-free. Yours is just... stuck.
|
|
4
|
+
|
|
5
|
+
[](https://opensource.org/licenses/MIT)
|
|
6
|
+
|
|
7
|
+
Get texted when your Claude Code agent needs attention. No more checking terminals.
|
|
8
|
+
|
|
9
|
+
## The Problem
|
|
10
|
+
|
|
11
|
+
You're running multiple Claude Code instances across tmux panes. One stops because it needs permission or has a question. You think it's still working. Hours later, you find it blocked. Time wasted.
|
|
12
|
+
|
|
13
|
+
## The Solution
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
npx @hrushiborhade/pingme init
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Now your Claude agent texts you when it needs you.
|
|
20
|
+
|
|
21
|
+
## What You Get
|
|
22
|
+
|
|
23
|
+
SMS notifications when:
|
|
24
|
+
- Agent stops
|
|
25
|
+
- Agent asks a clarifying question
|
|
26
|
+
- Agent needs permission
|
|
27
|
+
- Agent hits rate limits
|
|
28
|
+
|
|
29
|
+
Each message includes:
|
|
30
|
+
- **Project name** - which codebase needs you
|
|
31
|
+
- **tmux context** - which pane to jump to
|
|
32
|
+
- **Reason** - what the agent needs
|
|
33
|
+
|
|
34
|
+
## Setup
|
|
35
|
+
|
|
36
|
+
### 1. Get Twilio Credentials (free trial works)
|
|
37
|
+
|
|
38
|
+
1. Sign up at [twilio.com/console](https://console.twilio.com)
|
|
39
|
+
2. Get your Account SID and Auth Token from the dashboard
|
|
40
|
+
3. Get a phone number (or use the trial number)
|
|
41
|
+
|
|
42
|
+
### 2. Install pingme
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
npx @hrushiborhade/pingme init
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Follow the prompts. Done.
|
|
49
|
+
|
|
50
|
+
## Commands
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
npx @hrushiborhade/pingme init # Setup pingme
|
|
54
|
+
npx @hrushiborhade/pingme test # Send a test SMS
|
|
55
|
+
npx @hrushiborhade/pingme uninstall # Remove pingme
|
|
56
|
+
npx @hrushiborhade/pingme --version # Show version
|
|
57
|
+
npx @hrushiborhade/pingme --help # Show help
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## How It Works
|
|
61
|
+
|
|
62
|
+
pingme uses Claude Code's [hooks system](https://docs.anthropic.com/en/docs/claude-code/hooks) to detect when the agent needs your attention.
|
|
63
|
+
|
|
64
|
+
1. **Installation**: When you run `npx @hrushiborhade/pingme init`, pingme creates:
|
|
65
|
+
- A bash script at `~/.claude/hooks/pingme.sh` that sends SMS via Twilio
|
|
66
|
+
- Hook entries in `~/.claude/settings.json` that trigger the script
|
|
67
|
+
|
|
68
|
+
2. **Hook Triggers**: Two hooks are configured:
|
|
69
|
+
- `PostToolUse` with `AskUserQuestion` matcher - triggers when Claude asks you a question
|
|
70
|
+
- `Stop` - triggers when Claude stops execution for any reason
|
|
71
|
+
|
|
72
|
+
3. **Notification Flow**: When triggered, the hook script:
|
|
73
|
+
- Detects your current project name from the working directory
|
|
74
|
+
- Captures tmux session/window/pane info (if available)
|
|
75
|
+
- Sends an SMS via Twilio's API with context about what needs attention
|
|
76
|
+
|
|
77
|
+
## Example SMS
|
|
78
|
+
|
|
79
|
+
```
|
|
80
|
+
[question emoji] agentQ
|
|
81
|
+
|
|
82
|
+
[location emoji] dev:2.1 (main)
|
|
83
|
+
[message emoji] Asking question
|
|
84
|
+
|
|
85
|
+
Do you want me to run npm install?
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
## Security
|
|
89
|
+
|
|
90
|
+
- **Credentials are stored locally** in `~/.claude/hooks/pingme.sh`
|
|
91
|
+
- Credentials are never sent to any server except Twilio's API
|
|
92
|
+
- The hook script only runs when Claude Code triggers it
|
|
93
|
+
- Input is sanitized to prevent shell injection
|
|
94
|
+
- SMS requests are made over HTTPS
|
|
95
|
+
|
|
96
|
+
To update or remove credentials, run `npx @hrushiborhade/pingme init` again or `npx @hrushiborhade/pingme uninstall`.
|
|
97
|
+
|
|
98
|
+
## Troubleshooting
|
|
99
|
+
|
|
100
|
+
### SMS not sending
|
|
101
|
+
|
|
102
|
+
1. **Verify credentials**: Run `npx @hrushiborhade/pingme test` to send a test message
|
|
103
|
+
2. **Check Twilio balance**: Free trial includes $15 credit; ensure it's not exhausted
|
|
104
|
+
3. **Verify phone numbers**: Both numbers must include country code (e.g., `+1` for US)
|
|
105
|
+
4. **Trial account limitations**: Twilio trial accounts can only send to verified numbers
|
|
106
|
+
|
|
107
|
+
### Hook not triggering
|
|
108
|
+
|
|
109
|
+
1. **Restart Claude Code**: Hooks are loaded on startup
|
|
110
|
+
2. **Check settings.json**: Verify hooks are present in `~/.claude/settings.json`
|
|
111
|
+
3. **Check script permissions**: Run `chmod +x ~/.claude/hooks/pingme.sh`
|
|
112
|
+
|
|
113
|
+
### "curl not found" or no SMS sent
|
|
114
|
+
|
|
115
|
+
The hook script requires `curl`. Install it via your package manager:
|
|
116
|
+
- macOS: `brew install curl` (usually pre-installed)
|
|
117
|
+
- Ubuntu/Debian: `sudo apt install curl`
|
|
118
|
+
|
|
119
|
+
### Uninstalling
|
|
120
|
+
|
|
121
|
+
```bash
|
|
122
|
+
npx @hrushiborhade/pingme uninstall
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
This removes the hook script and settings entries. Your Twilio credentials are deleted locally.
|
|
126
|
+
|
|
127
|
+
## Requirements
|
|
128
|
+
|
|
129
|
+
- Node.js 18+
|
|
130
|
+
- Twilio account (free trial includes $15 credit)
|
|
131
|
+
- Claude Code CLI
|
|
132
|
+
- `curl` (pre-installed on most systems)
|
|
133
|
+
|
|
134
|
+
## Contributing
|
|
135
|
+
|
|
136
|
+
Contributions are welcome! Here's how to get started:
|
|
137
|
+
|
|
138
|
+
1. Fork the repository
|
|
139
|
+
2. Create a feature branch: `git checkout -b feature/my-feature`
|
|
140
|
+
3. Make your changes
|
|
141
|
+
4. Run the build: `npm run build`
|
|
142
|
+
5. Test locally: `npm start init`
|
|
143
|
+
6. Commit your changes: `git commit -m 'Add my feature'`
|
|
144
|
+
7. Push to your fork: `git push origin feature/my-feature`
|
|
145
|
+
8. Open a Pull Request
|
|
146
|
+
|
|
147
|
+
### Development
|
|
148
|
+
|
|
149
|
+
```bash
|
|
150
|
+
git clone https://github.com/HrushiBorhade/pingme-cli.git
|
|
151
|
+
cd pingme-cli
|
|
152
|
+
npm install
|
|
153
|
+
npm run dev # Watch mode for TypeScript
|
|
154
|
+
npm run build # Build for production
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
### Ideas for Contribution
|
|
158
|
+
|
|
159
|
+
- Support for other notification providers (Slack, Discord, Pushover)
|
|
160
|
+
- Rate limiting to prevent SMS spam
|
|
161
|
+
- Quiet hours configuration
|
|
162
|
+
- Custom message templates
|
|
163
|
+
|
|
164
|
+
## License
|
|
165
|
+
|
|
166
|
+
MIT
|
|
167
|
+
|
|
168
|
+
---
|
|
169
|
+
|
|
170
|
+
Built for developers who run AI agents and want their life back.
|
package/bin/pingme.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function init(): Promise<void>;
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import * as p from '@clack/prompts';
|
|
2
|
+
import pc from 'picocolors';
|
|
3
|
+
import { installHook } from '../utils/install.js';
|
|
4
|
+
import { sendTestSMS } from '../utils/twilio.js';
|
|
5
|
+
export async function init() {
|
|
6
|
+
p.log.info(pc.dim('Get your Twilio credentials at: ') + pc.cyan('https://console.twilio.com'));
|
|
7
|
+
const credentials = await p.group({
|
|
8
|
+
twilioSid: () => p.text({
|
|
9
|
+
message: 'Twilio Account SID',
|
|
10
|
+
placeholder: 'ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx',
|
|
11
|
+
validate: (value) => {
|
|
12
|
+
if (!value)
|
|
13
|
+
return 'Required';
|
|
14
|
+
if (!value.startsWith('AC'))
|
|
15
|
+
return 'Should start with AC';
|
|
16
|
+
},
|
|
17
|
+
}),
|
|
18
|
+
twilioToken: () => p.password({
|
|
19
|
+
message: 'Twilio Auth Token',
|
|
20
|
+
validate: (value) => {
|
|
21
|
+
if (!value)
|
|
22
|
+
return 'Required';
|
|
23
|
+
if (value.length < 20)
|
|
24
|
+
return 'Token seems too short';
|
|
25
|
+
},
|
|
26
|
+
}),
|
|
27
|
+
twilioFrom: () => p.text({
|
|
28
|
+
message: 'Twilio Phone Number',
|
|
29
|
+
placeholder: '+14155238886',
|
|
30
|
+
validate: (value) => {
|
|
31
|
+
if (!value)
|
|
32
|
+
return 'Required';
|
|
33
|
+
if (!value.startsWith('+'))
|
|
34
|
+
return 'Include country code (e.g., +1...)';
|
|
35
|
+
},
|
|
36
|
+
}),
|
|
37
|
+
myPhone: () => p.text({
|
|
38
|
+
message: 'Your Phone Number',
|
|
39
|
+
placeholder: '+1234567890',
|
|
40
|
+
validate: (value) => {
|
|
41
|
+
if (!value)
|
|
42
|
+
return 'Required';
|
|
43
|
+
if (!value.startsWith('+'))
|
|
44
|
+
return 'Include country code (e.g., +1...)';
|
|
45
|
+
},
|
|
46
|
+
}),
|
|
47
|
+
}, {
|
|
48
|
+
onCancel: () => {
|
|
49
|
+
p.cancel('Setup cancelled.');
|
|
50
|
+
process.exit(0);
|
|
51
|
+
},
|
|
52
|
+
});
|
|
53
|
+
const s = p.spinner();
|
|
54
|
+
// Install hook
|
|
55
|
+
s.start('Creating hook script');
|
|
56
|
+
try {
|
|
57
|
+
await installHook(credentials);
|
|
58
|
+
s.stop('Hook script created');
|
|
59
|
+
}
|
|
60
|
+
catch (err) {
|
|
61
|
+
s.stop(pc.red('Failed to create hook script'));
|
|
62
|
+
p.log.error(err instanceof Error ? err.message : 'Unknown error');
|
|
63
|
+
p.log.info('Check that you have write permissions to ~/.claude/');
|
|
64
|
+
process.exit(1);
|
|
65
|
+
}
|
|
66
|
+
// Send test SMS
|
|
67
|
+
s.start('Sending test SMS');
|
|
68
|
+
const testResult = await sendTestSMS(credentials);
|
|
69
|
+
if (testResult.success) {
|
|
70
|
+
s.stop('Test SMS sent');
|
|
71
|
+
}
|
|
72
|
+
else {
|
|
73
|
+
s.stop(pc.yellow('Could not send test SMS'));
|
|
74
|
+
p.log.warn('Setup completed, but test SMS failed. Check your Twilio credentials.');
|
|
75
|
+
}
|
|
76
|
+
// Done
|
|
77
|
+
p.note(`Your Claude agent will now ping you when it needs attention.
|
|
78
|
+
|
|
79
|
+
${pc.dim('Commands:')}
|
|
80
|
+
${pc.cyan('npx pingme-cli test')} Send a test SMS
|
|
81
|
+
${pc.cyan('npx pingme-cli uninstall')} Remove pingme`, 'Setup complete');
|
|
82
|
+
p.outro(pc.dim('Now go doom scroll guilt-free ') + '🚀');
|
|
83
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function test(): Promise<void>;
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import * as p from '@clack/prompts';
|
|
2
|
+
import pc from 'picocolors';
|
|
3
|
+
import { existsSync } from 'fs';
|
|
4
|
+
import { homedir } from 'os';
|
|
5
|
+
import path from 'path';
|
|
6
|
+
import { execSync } from 'child_process';
|
|
7
|
+
export async function test() {
|
|
8
|
+
const hookPath = path.join(homedir(), '.claude', 'hooks', 'pingme.sh');
|
|
9
|
+
if (!existsSync(hookPath)) {
|
|
10
|
+
p.log.error('pingme is not installed');
|
|
11
|
+
p.log.info(`Run ${pc.cyan('npx pingme-cli init')} to set up`);
|
|
12
|
+
process.exit(1);
|
|
13
|
+
}
|
|
14
|
+
const s = p.spinner();
|
|
15
|
+
s.start('Sending test SMS');
|
|
16
|
+
try {
|
|
17
|
+
execSync(`echo "🧪 Test ping from pingme-cli" | "${hookPath}" test`, {
|
|
18
|
+
timeout: 15000,
|
|
19
|
+
stdio: 'ignore',
|
|
20
|
+
});
|
|
21
|
+
s.stop('Test SMS sent!');
|
|
22
|
+
p.log.success('Check your phone for the message');
|
|
23
|
+
}
|
|
24
|
+
catch (err) {
|
|
25
|
+
s.stop('Failed to send SMS');
|
|
26
|
+
// Provide more specific error message
|
|
27
|
+
const isTimeout = err instanceof Error && err.message.includes('ETIMEDOUT');
|
|
28
|
+
if (isTimeout) {
|
|
29
|
+
p.log.error('Request timed out - check your network connection');
|
|
30
|
+
}
|
|
31
|
+
else {
|
|
32
|
+
p.log.error('SMS send failed - check your Twilio credentials');
|
|
33
|
+
}
|
|
34
|
+
p.log.info(`Run ${pc.cyan('npx pingme-cli init')} to reconfigure`);
|
|
35
|
+
process.exit(1);
|
|
36
|
+
}
|
|
37
|
+
p.outro(pc.dim('All good!'));
|
|
38
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function uninstall(): Promise<void>;
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import * as p from '@clack/prompts';
|
|
2
|
+
import pc from 'picocolors';
|
|
3
|
+
import { existsSync } from 'fs';
|
|
4
|
+
import { unlink, access, constants } from 'fs/promises';
|
|
5
|
+
import { homedir } from 'os';
|
|
6
|
+
import path from 'path';
|
|
7
|
+
export async function uninstall() {
|
|
8
|
+
const hookPath = path.join(homedir(), '.claude', 'hooks', 'pingme.sh');
|
|
9
|
+
if (!existsSync(hookPath)) {
|
|
10
|
+
p.log.warn('pingme is not installed (hook file not found)');
|
|
11
|
+
p.outro(pc.dim('Nothing to do'));
|
|
12
|
+
return;
|
|
13
|
+
}
|
|
14
|
+
// Check if we have write permission to delete the file
|
|
15
|
+
try {
|
|
16
|
+
await access(hookPath, constants.W_OK);
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
p.log.error(`Permission denied: Cannot delete ${hookPath}`);
|
|
20
|
+
p.log.info(pc.dim(`Try running: sudo rm "${hookPath}"`));
|
|
21
|
+
process.exit(1);
|
|
22
|
+
}
|
|
23
|
+
const confirm = await p.confirm({
|
|
24
|
+
message: 'Remove pingme?',
|
|
25
|
+
});
|
|
26
|
+
if (p.isCancel(confirm) || !confirm) {
|
|
27
|
+
p.cancel('Cancelled');
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
const s = p.spinner();
|
|
31
|
+
s.start('Removing pingme');
|
|
32
|
+
try {
|
|
33
|
+
await unlink(hookPath);
|
|
34
|
+
s.stop('Hook script removed');
|
|
35
|
+
}
|
|
36
|
+
catch (err) {
|
|
37
|
+
s.stop('Could not remove hook');
|
|
38
|
+
const errorMessage = err instanceof Error ? err.message : 'Unknown error';
|
|
39
|
+
p.log.error(`Failed to delete hook: ${errorMessage}`);
|
|
40
|
+
p.log.info(pc.dim(`Manually delete: rm "${hookPath}"`));
|
|
41
|
+
process.exit(1);
|
|
42
|
+
}
|
|
43
|
+
p.note(pc.dim(`Hook entries in ~/.claude/settings.json remain.
|
|
44
|
+
They're harmless, but you can remove them manually if you want.`), 'Note');
|
|
45
|
+
p.outro(pc.dim('pingme uninstalled'));
|
|
46
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import * as p from '@clack/prompts';
|
|
2
|
+
import pc from 'picocolors';
|
|
3
|
+
import { init } from './commands/init.js';
|
|
4
|
+
import { test } from './commands/test.js';
|
|
5
|
+
import { uninstall } from './commands/uninstall.js';
|
|
6
|
+
const VERSION = '1.0.1';
|
|
7
|
+
async function main() {
|
|
8
|
+
const args = process.argv.slice(2);
|
|
9
|
+
const command = args[0] || 'init';
|
|
10
|
+
// Version flag (no header needed)
|
|
11
|
+
if (command === '--version' || command === '-v') {
|
|
12
|
+
console.log(`pingme-cli v${VERSION}`);
|
|
13
|
+
return;
|
|
14
|
+
}
|
|
15
|
+
// Header
|
|
16
|
+
console.log(); // Add spacing
|
|
17
|
+
p.intro(pc.bgCyan(pc.black(' pingme ')));
|
|
18
|
+
switch (command) {
|
|
19
|
+
case 'init':
|
|
20
|
+
await init();
|
|
21
|
+
break;
|
|
22
|
+
case 'test':
|
|
23
|
+
await test();
|
|
24
|
+
break;
|
|
25
|
+
case 'uninstall':
|
|
26
|
+
await uninstall();
|
|
27
|
+
break;
|
|
28
|
+
case 'help':
|
|
29
|
+
case '--help':
|
|
30
|
+
case '-h':
|
|
31
|
+
showHelp();
|
|
32
|
+
break;
|
|
33
|
+
default:
|
|
34
|
+
p.log.error(`Unknown command: ${command}`);
|
|
35
|
+
showHelp();
|
|
36
|
+
process.exit(1);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
function showHelp() {
|
|
40
|
+
console.log(`
|
|
41
|
+
${pc.bold('Usage:')} npx pingme-cli ${pc.dim('<command>')}
|
|
42
|
+
|
|
43
|
+
${pc.bold('Commands:')}
|
|
44
|
+
${pc.cyan('init')} Setup pingme (default)
|
|
45
|
+
${pc.cyan('test')} Send a test SMS
|
|
46
|
+
${pc.cyan('uninstall')} Remove pingme
|
|
47
|
+
|
|
48
|
+
${pc.bold('Examples:')}
|
|
49
|
+
${pc.dim('$')} npx pingme-cli init
|
|
50
|
+
${pc.dim('$')} npx pingme-cli test
|
|
51
|
+
`);
|
|
52
|
+
}
|
|
53
|
+
main().catch((err) => {
|
|
54
|
+
p.log.error(err.message);
|
|
55
|
+
process.exit(1);
|
|
56
|
+
});
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { writeFile, readFile, mkdir } from 'fs/promises';
|
|
2
|
+
import { existsSync } from 'fs';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import { homedir } from 'os';
|
|
5
|
+
// Escape special characters for bash strings (prevents shell injection)
|
|
6
|
+
function escapeForBash(str) {
|
|
7
|
+
// Replace backslashes first, then other special chars
|
|
8
|
+
return str
|
|
9
|
+
.replace(/\\/g, '\\\\')
|
|
10
|
+
.replace(/"/g, '\\"')
|
|
11
|
+
.replace(/\$/g, '\\$')
|
|
12
|
+
.replace(/`/g, '\\`')
|
|
13
|
+
.replace(/!/g, '\\!');
|
|
14
|
+
}
|
|
15
|
+
const HOOK_SCRIPT = `#!/usr/bin/env bash
|
|
16
|
+
|
|
17
|
+
# ┌───────────────────────────────────────────────────────────────┐
|
|
18
|
+
# │ pingme - Get texted when your Claude agent is stuck │
|
|
19
|
+
# │ https://github.com/HrushiBorhade/pingme-cli │
|
|
20
|
+
# └───────────────────────────────────────────────────────────────┘
|
|
21
|
+
|
|
22
|
+
# Config (do not edit manually - use 'npx pingme-cli init' to reconfigure)
|
|
23
|
+
TWILIO_SID="{{TWILIO_SID}}"
|
|
24
|
+
TWILIO_TOKEN="{{TWILIO_TOKEN}}"
|
|
25
|
+
TWILIO_FROM="{{TWILIO_FROM}}"
|
|
26
|
+
MY_PHONE="{{MY_PHONE}}"
|
|
27
|
+
|
|
28
|
+
# Check for curl
|
|
29
|
+
if ! command -v curl &> /dev/null; then
|
|
30
|
+
exit 0 # Silently exit if curl not available
|
|
31
|
+
fi
|
|
32
|
+
|
|
33
|
+
# Context
|
|
34
|
+
EVENT="\${1:-unknown}"
|
|
35
|
+
PROJECT=\$(basename "\$PWD" | tr -cd '[:alnum:]._-') # Sanitize project name
|
|
36
|
+
|
|
37
|
+
# tmux info (if available)
|
|
38
|
+
TMUX_INFO=""
|
|
39
|
+
if [ -n "\$TMUX" ]; then
|
|
40
|
+
TMUX_INFO=\$(tmux display-message -p '#S:#I.#P (#W)' 2>/dev/null || echo "")
|
|
41
|
+
fi
|
|
42
|
+
|
|
43
|
+
# Read context from stdin (limit to 280 chars for SMS)
|
|
44
|
+
CONTEXT=""
|
|
45
|
+
if [ ! -t 0 ]; then
|
|
46
|
+
CONTEXT=\$(head -c 280 | tr -cd '[:print:][:space:]') # Sanitize input
|
|
47
|
+
fi
|
|
48
|
+
|
|
49
|
+
# Message emoji/reason
|
|
50
|
+
case "\$EVENT" in
|
|
51
|
+
question) EMOJI="❓"; REASON="Asking question" ;;
|
|
52
|
+
permission) EMOJI="🔐"; REASON="Needs permission" ;;
|
|
53
|
+
limit) EMOJI="⚠️"; REASON="Hit limit" ;;
|
|
54
|
+
stopped) EMOJI="🛑"; REASON="Agent stopped" ;;
|
|
55
|
+
test) EMOJI="🧪"; REASON="Test ping" ;;
|
|
56
|
+
*) EMOJI="🔔"; REASON="Needs attention" ;;
|
|
57
|
+
esac
|
|
58
|
+
|
|
59
|
+
# Build message
|
|
60
|
+
MESSAGE="\$EMOJI \$PROJECT"
|
|
61
|
+
[ -n "\$TMUX_INFO" ] && MESSAGE="\$MESSAGE
|
|
62
|
+
📍 \$TMUX_INFO"
|
|
63
|
+
MESSAGE="\$MESSAGE
|
|
64
|
+
💬 \$REASON"
|
|
65
|
+
[ -n "\$CONTEXT" ] && MESSAGE="\$MESSAGE
|
|
66
|
+
|
|
67
|
+
\$CONTEXT"
|
|
68
|
+
|
|
69
|
+
# Send SMS (background, detached so it survives script exit)
|
|
70
|
+
(
|
|
71
|
+
curl -s -X POST "https://api.twilio.com/2010-04-01/Accounts/\$TWILIO_SID/Messages.json" \\
|
|
72
|
+
--user "\$TWILIO_SID:\$TWILIO_TOKEN" \\
|
|
73
|
+
--data-urlencode "From=\$TWILIO_FROM" \\
|
|
74
|
+
--data-urlencode "To=\$MY_PHONE" \\
|
|
75
|
+
--data-urlencode "Body=\$MESSAGE" \\
|
|
76
|
+
--max-time 10 \\
|
|
77
|
+
> /dev/null 2>&1
|
|
78
|
+
) &
|
|
79
|
+
disown 2>/dev/null || true
|
|
80
|
+
|
|
81
|
+
exit 0
|
|
82
|
+
`;
|
|
83
|
+
export async function installHook(credentials) {
|
|
84
|
+
const homeDir = homedir();
|
|
85
|
+
const hooksDir = path.join(homeDir, '.claude', 'hooks');
|
|
86
|
+
const hookPath = path.join(hooksDir, 'pingme.sh');
|
|
87
|
+
const configPath = path.join(homeDir, '.claude', 'settings.json');
|
|
88
|
+
// Create hooks directory
|
|
89
|
+
await mkdir(hooksDir, { recursive: true });
|
|
90
|
+
// Create hook script with escaped credentials (prevents shell injection)
|
|
91
|
+
const script = HOOK_SCRIPT
|
|
92
|
+
.replaceAll('{{TWILIO_SID}}', escapeForBash(credentials.twilioSid))
|
|
93
|
+
.replaceAll('{{TWILIO_TOKEN}}', escapeForBash(credentials.twilioToken))
|
|
94
|
+
.replaceAll('{{TWILIO_FROM}}', escapeForBash(credentials.twilioFrom))
|
|
95
|
+
.replaceAll('{{MY_PHONE}}', escapeForBash(credentials.myPhone));
|
|
96
|
+
await writeFile(hookPath, script, { mode: 0o755 });
|
|
97
|
+
// Update Claude config
|
|
98
|
+
let config = {};
|
|
99
|
+
try {
|
|
100
|
+
if (existsSync(configPath)) {
|
|
101
|
+
const existing = await readFile(configPath, 'utf-8');
|
|
102
|
+
config = JSON.parse(existing);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
catch {
|
|
106
|
+
// Start fresh
|
|
107
|
+
}
|
|
108
|
+
// Initialize hooks with Claude Code 2.1+ format
|
|
109
|
+
// Format: { matcher: "ToolName" (regex string), hooks: [{ type: "command", command: "..." }] }
|
|
110
|
+
config.hooks = config.hooks || {};
|
|
111
|
+
const hooks = config.hooks;
|
|
112
|
+
hooks.PostToolUse = hooks.PostToolUse || [];
|
|
113
|
+
hooks.Stop = hooks.Stop || [];
|
|
114
|
+
const postToolHooks = hooks.PostToolUse;
|
|
115
|
+
const stopHooks = hooks.Stop;
|
|
116
|
+
// Check if pingme hook already exists (check in hooks array)
|
|
117
|
+
const hasPingmePostTool = postToolHooks.some((h) => h.hooks?.some((hook) => hook.command?.includes('pingme.sh')));
|
|
118
|
+
const hasPingmeStop = stopHooks.some((h) => h.hooks?.some((hook) => hook.command?.includes('pingme.sh')));
|
|
119
|
+
if (!hasPingmePostTool) {
|
|
120
|
+
postToolHooks.push({
|
|
121
|
+
matcher: 'AskUserQuestion',
|
|
122
|
+
hooks: [{ type: 'command', command: '~/.claude/hooks/pingme.sh question' }],
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
if (!hasPingmeStop) {
|
|
126
|
+
stopHooks.push({
|
|
127
|
+
hooks: [{ type: 'command', command: '~/.claude/hooks/pingme.sh stopped' }],
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
await writeFile(configPath, JSON.stringify(config, null, 2));
|
|
131
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
interface Credentials {
|
|
2
|
+
twilioSid: string;
|
|
3
|
+
twilioToken: string;
|
|
4
|
+
twilioFrom: string;
|
|
5
|
+
myPhone: string;
|
|
6
|
+
}
|
|
7
|
+
export type TestErrorCode = 'HOOK_NOT_FOUND' | 'TIMEOUT' | 'NETWORK_ERROR' | 'PERMISSION_DENIED' | 'SCRIPT_ERROR' | 'UNKNOWN';
|
|
8
|
+
interface TestResult {
|
|
9
|
+
success: boolean;
|
|
10
|
+
error?: string;
|
|
11
|
+
errorCode?: TestErrorCode;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Sends a test SMS using the installed hook script.
|
|
15
|
+
* This validates that the Twilio credentials are working correctly.
|
|
16
|
+
*/
|
|
17
|
+
export declare function sendTestSMS(credentials: Credentials): Promise<TestResult>;
|
|
18
|
+
export {};
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { execSync } from 'child_process';
|
|
2
|
+
import { existsSync } from 'fs';
|
|
3
|
+
import { homedir } from 'os';
|
|
4
|
+
import path from 'path';
|
|
5
|
+
const TIMEOUT_MS = 15000;
|
|
6
|
+
/**
|
|
7
|
+
* Determines the error code based on the error type and message
|
|
8
|
+
*/
|
|
9
|
+
function getErrorCode(err) {
|
|
10
|
+
if (!(err instanceof Error)) {
|
|
11
|
+
return 'UNKNOWN';
|
|
12
|
+
}
|
|
13
|
+
const message = err.message.toLowerCase();
|
|
14
|
+
// Node.js child_process timeout (ETIMEDOUT or killed due to timeout)
|
|
15
|
+
if (message.includes('etimedout') || message.includes('timedout') || message.includes('timed out')) {
|
|
16
|
+
return 'TIMEOUT';
|
|
17
|
+
}
|
|
18
|
+
// Check for signal-based timeout (execSync kills with SIGTERM on timeout)
|
|
19
|
+
if ('signal' in err && err.signal === 'SIGTERM') {
|
|
20
|
+
return 'TIMEOUT';
|
|
21
|
+
}
|
|
22
|
+
// Network-related errors
|
|
23
|
+
if (message.includes('enotfound') ||
|
|
24
|
+
message.includes('econnrefused') ||
|
|
25
|
+
message.includes('econnreset') ||
|
|
26
|
+
message.includes('network') ||
|
|
27
|
+
message.includes('could not resolve')) {
|
|
28
|
+
return 'NETWORK_ERROR';
|
|
29
|
+
}
|
|
30
|
+
// Permission errors
|
|
31
|
+
if (message.includes('eacces') || message.includes('permission denied')) {
|
|
32
|
+
return 'PERMISSION_DENIED';
|
|
33
|
+
}
|
|
34
|
+
// Script execution errors (non-zero exit code)
|
|
35
|
+
if (message.includes('exited with') || message.includes('exit code')) {
|
|
36
|
+
return 'SCRIPT_ERROR';
|
|
37
|
+
}
|
|
38
|
+
return 'UNKNOWN';
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Returns a user-friendly error message based on the error code
|
|
42
|
+
*/
|
|
43
|
+
function getErrorMessage(errorCode, originalError) {
|
|
44
|
+
switch (errorCode) {
|
|
45
|
+
case 'HOOK_NOT_FOUND':
|
|
46
|
+
return 'Hook script not found. Run "npx pingme-cli init" to set up.';
|
|
47
|
+
case 'TIMEOUT':
|
|
48
|
+
return `Request timed out after ${TIMEOUT_MS / 1000} seconds. Check your network connection or Twilio service status.`;
|
|
49
|
+
case 'NETWORK_ERROR':
|
|
50
|
+
return 'Network error. Check your internet connection and try again.';
|
|
51
|
+
case 'PERMISSION_DENIED':
|
|
52
|
+
return 'Permission denied. Check that the hook script is executable (chmod +x ~/.claude/hooks/pingme.sh).';
|
|
53
|
+
case 'SCRIPT_ERROR':
|
|
54
|
+
return 'Hook script failed. Check your Twilio credentials with "npx pingme-cli init".';
|
|
55
|
+
case 'UNKNOWN':
|
|
56
|
+
default:
|
|
57
|
+
return originalError || 'An unexpected error occurred while sending SMS.';
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Sends a test SMS using the installed hook script.
|
|
62
|
+
* This validates that the Twilio credentials are working correctly.
|
|
63
|
+
*/
|
|
64
|
+
export async function sendTestSMS(credentials) {
|
|
65
|
+
const hookPath = path.join(homedir(), '.claude', 'hooks', 'pingme.sh');
|
|
66
|
+
// Pre-flight check: verify hook script exists
|
|
67
|
+
if (!existsSync(hookPath)) {
|
|
68
|
+
return {
|
|
69
|
+
success: false,
|
|
70
|
+
error: getErrorMessage('HOOK_NOT_FOUND'),
|
|
71
|
+
errorCode: 'HOOK_NOT_FOUND',
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
const execOptions = {
|
|
75
|
+
timeout: TIMEOUT_MS,
|
|
76
|
+
stdio: ['pipe', 'pipe', 'pipe'], // Capture stdout/stderr for better error diagnosis
|
|
77
|
+
killSignal: 'SIGTERM',
|
|
78
|
+
};
|
|
79
|
+
try {
|
|
80
|
+
execSync(`echo "pingme installed! Your Claude agent can now reach you." | "${hookPath}" test`, execOptions);
|
|
81
|
+
return { success: true };
|
|
82
|
+
}
|
|
83
|
+
catch (err) {
|
|
84
|
+
const errorCode = getErrorCode(err);
|
|
85
|
+
const originalMessage = err instanceof Error ? err.message : String(err);
|
|
86
|
+
return {
|
|
87
|
+
success: false,
|
|
88
|
+
error: getErrorMessage(errorCode, originalMessage),
|
|
89
|
+
errorCode,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@hrushiborhade/pingme",
|
|
3
|
+
"version": "1.0.1",
|
|
4
|
+
"description": "Get texted when your Claude agent is stuck",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"pingme": "./bin/pingme.js"
|
|
8
|
+
},
|
|
9
|
+
"main": "./dist/index.js",
|
|
10
|
+
"scripts": {
|
|
11
|
+
"build": "tsc",
|
|
12
|
+
"dev": "tsc --watch",
|
|
13
|
+
"start": "node bin/pingme.js",
|
|
14
|
+
"test": "vitest run",
|
|
15
|
+
"test:watch": "vitest",
|
|
16
|
+
"test:coverage": "vitest run --coverage"
|
|
17
|
+
},
|
|
18
|
+
"files": [
|
|
19
|
+
"bin",
|
|
20
|
+
"dist"
|
|
21
|
+
],
|
|
22
|
+
"keywords": [
|
|
23
|
+
"claude",
|
|
24
|
+
"claude-code",
|
|
25
|
+
"sms",
|
|
26
|
+
"notifications",
|
|
27
|
+
"twilio",
|
|
28
|
+
"ai-agents",
|
|
29
|
+
"cli",
|
|
30
|
+
"terminal",
|
|
31
|
+
"productivity"
|
|
32
|
+
],
|
|
33
|
+
"author": "Hrushikesh Borhade",
|
|
34
|
+
"license": "MIT",
|
|
35
|
+
"repository": {
|
|
36
|
+
"type": "git",
|
|
37
|
+
"url": "https://github.com/HrushiBorhade/pingme-cli"
|
|
38
|
+
},
|
|
39
|
+
"dependencies": {
|
|
40
|
+
"@clack/prompts": "^0.9.1",
|
|
41
|
+
"picocolors": "^1.1.1"
|
|
42
|
+
},
|
|
43
|
+
"devDependencies": {
|
|
44
|
+
"@types/node": "^20.10.0",
|
|
45
|
+
"@vitest/coverage-v8": "^4.0.18",
|
|
46
|
+
"typescript": "^5.3.0",
|
|
47
|
+
"vitest": "^4.0.18"
|
|
48
|
+
},
|
|
49
|
+
"engines": {
|
|
50
|
+
"node": ">=18"
|
|
51
|
+
}
|
|
52
|
+
}
|