claude-persona 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 +134 -0
- package/defaults/claude-persona.json +82 -0
- package/dist/cli/index.d.ts +2 -0
- package/dist/cli/index.js +28 -0
- package/dist/cli/init.d.ts +6 -0
- package/dist/cli/init.js +165 -0
- package/dist/cli/test.d.ts +5 -0
- package/dist/cli/test.js +47 -0
- package/dist/cli/uninstall.d.ts +6 -0
- package/dist/cli/uninstall.js +60 -0
- package/dist/config.d.ts +17 -0
- package/dist/config.js +58 -0
- package/dist/flag-scanner.d.ts +5 -0
- package/dist/flag-scanner.js +53 -0
- package/dist/handler.d.ts +10 -0
- package/dist/handler.js +106 -0
- package/dist/player.d.ts +6 -0
- package/dist/player.js +29 -0
- package/dist/spam-detector.d.ts +5 -0
- package/dist/spam-detector.js +31 -0
- package/dist/types.d.ts +64 -0
- package/dist/types.js +1 -0
- package/hooks/hooks.json +86 -0
- package/package.json +46 -0
- package/plugin.json +5 -0
- package/sounds/peasant//320/262/320/260/320/274 /320/274/320/265/320/275/321/217 /320/275/320/265 /320/266/320/260/320/273/320/272/320/276.wav +0 -0
- package/sounds/peasant//320/263/320/276/321/202/320/276/320/262/320/276.mp3 +0 -0
- package/sounds/peasant//320/264/320/260 /320/263/320/276/321/201/320/277/320/276/320/264/320/270/320/275.wav +0 -0
- package/sounds/peasant//320/264/320/260.wav +0 -0
- package/sounds/peasant//320/267/320/264/320/265/321/201/321/214 /320/275/320/265/320/273/321/214/320/267/321/217 /321/201/321/202/321/200/320/276/320/270/321/202/321/214.m4a +0 -0
- package/sounds/peasant//320/274/320/265/320/275/321/217 /321/203/320/261/321/214/321/216/321/202.wav +0 -0
- package/sounds/peasant//320/274/321/213 /320/277/320/276/320/271/320/274/320/260/320/273/320/270 /320/262/320/265/320/264/321/214/320/274/321/203.wav +0 -0
- package/sounds/peasant//320/275/320/260 /320/274/320/265/320/275/321/217 /320/264/320/260/320/262/321/217/321/202.wav +0 -0
- package/sounds/peasant//320/275/321/203 /321/217 /320/277/320/276/321/210/320/265/320/273.wav +0 -0
- package/sounds/peasant//320/276/320/276/320/276.wav +0 -0
- package/sounds/peasant//320/276/320/277/321/217/321/202/321/214 /321/200/320/260/320/261/320/276/321/202/320/260/321/202/321/214.wav +0 -0
- package/sounds/peasant//320/277/321/200/320/270/320/264/320/265/321/202/321/201/321/217 /320/264/321/200/320/260/321/202/321/214/321/201/321/217.wav +0 -0
- package/sounds/peasant//320/277/321/200/320/276/321/211/320/260/320/271/321/202/320/265.wav +0 -0
- package/sounds/peasant//321/200/320/260/320/261/320/276/321/202/320/260 /320/275/320/265 /320/262/320/276/320/273/320/272.wav +0 -0
- package/sounds/peasant//321/201/320/274/320/265/321/200/321/202/321/214.mp3 +0 -0
- package/sounds/peasant//321/202/320/265/320/261/321/217 /320/261/321/213 /321/202/320/260/320/272.wav +0 -0
- package/sounds/peasant//321/202/321/213 /321/207/321/202/320/276 /320/273/320/270 /320/272/320/276/321/200/320/276/320/273/321/214.wav +0 -0
- package/sounds/peasant//321/203/320/263/321/203.wav +0 -0
- package/sounds/peasant//321/205/320/276/321/200/320/276/321/210/320/276.wav +0 -0
- package/sounds/peasant//321/207/320/265/320/263/320/276.wav +0 -0
- package/sounds/peasant//321/217 /320/263/320/276/321/202/320/276/320/262.wav +0 -0
package/README.md
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
# claude-persona
|
|
2
|
+
|
|
3
|
+
Sound effects for [Claude Code](https://docs.anthropic.com/en/docs/claude-code) sessions. Hear audio feedback when Claude starts a task, finishes responding, hits an error, and more.
|
|
4
|
+
|
|
5
|
+
Ships with a default Warcraft 3 peasant sound pack. Easily configurable with your own sounds and situations.
|
|
6
|
+
|
|
7
|
+
## Quick Start
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
# Install globally (works in all projects)
|
|
11
|
+
npx claude-persona init --global
|
|
12
|
+
|
|
13
|
+
# Or install for current project only
|
|
14
|
+
npx claude-persona init --project
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
That's it. Start a Claude Code session and you'll hear sounds.
|
|
18
|
+
|
|
19
|
+
## How It Works
|
|
20
|
+
|
|
21
|
+
claude-persona installs [Claude Code hooks](https://docs.anthropic.com/en/docs/claude-code/hooks) that play audio clips when specific events happen during a session. Everything is controlled by a single JSON config file.
|
|
22
|
+
|
|
23
|
+
## Configuration
|
|
24
|
+
|
|
25
|
+
After `init`, your config lives at:
|
|
26
|
+
- **Global:** `~/.claude-persona/claude-persona.json`
|
|
27
|
+
- **Project:** `.claude/persona/claude-persona.json`
|
|
28
|
+
|
|
29
|
+
```json
|
|
30
|
+
{
|
|
31
|
+
"persona": "peasant",
|
|
32
|
+
"situations": [
|
|
33
|
+
{
|
|
34
|
+
"name": "task-complete",
|
|
35
|
+
"trigger": "Stop",
|
|
36
|
+
"description": "Claude finished responding",
|
|
37
|
+
"sounds": ["готово.mp3", "я готов.wav", "да.wav"]
|
|
38
|
+
}
|
|
39
|
+
]
|
|
40
|
+
}
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
### Config fields
|
|
44
|
+
|
|
45
|
+
| Field | Description |
|
|
46
|
+
|---|---|
|
|
47
|
+
| `persona` | Name of your sound pack. Maps to `sounds/<persona>/` directory. |
|
|
48
|
+
| `situations` | Array of situations that trigger sounds. |
|
|
49
|
+
|
|
50
|
+
### Situation fields
|
|
51
|
+
|
|
52
|
+
| Field | Description |
|
|
53
|
+
|---|---|
|
|
54
|
+
| `name` | Unique identifier. For `flag` triggers, this becomes the flag name. |
|
|
55
|
+
| `trigger` | What fires this situation (see trigger types below). |
|
|
56
|
+
| `description` | Human-readable description. Used in CLAUDE.md for flag situations. |
|
|
57
|
+
| `sounds` | Array of filenames in `sounds/<persona>/`. A random one is picked each time. |
|
|
58
|
+
|
|
59
|
+
### Trigger types
|
|
60
|
+
|
|
61
|
+
| Trigger | When it fires |
|
|
62
|
+
|---|---|
|
|
63
|
+
| `UserPromptSubmit` | User sends a prompt |
|
|
64
|
+
| `Stop` | Claude finishes responding |
|
|
65
|
+
| `PostToolUseFailure` | A tool call fails |
|
|
66
|
+
| `SessionStart` | New session begins |
|
|
67
|
+
| `SessionEnd` | Session ends |
|
|
68
|
+
| `Notification` | Claude needs attention or permission |
|
|
69
|
+
| `SubagentStart` | A subagent is spawned |
|
|
70
|
+
| `SubagentStop` | A subagent finishes |
|
|
71
|
+
| `PreToolUse` | Before a tool executes |
|
|
72
|
+
| `PostToolUse` | After a tool succeeds |
|
|
73
|
+
| `flag` | Detected via `<!-- persona:<name> -->` in Claude's response |
|
|
74
|
+
| `spam` | User sending 3+ prompts within 15 seconds (overrides `UserPromptSubmit`) |
|
|
75
|
+
|
|
76
|
+
Any [Claude Code hook event](https://docs.anthropic.com/en/docs/claude-code/hooks) can be used as a trigger.
|
|
77
|
+
|
|
78
|
+
### Flag situations
|
|
79
|
+
|
|
80
|
+
Situations with `"trigger": "flag"` are special. Claude self-inserts an HTML comment flag at the end of its response when the situation applies. The hook system detects it from the transcript.
|
|
81
|
+
|
|
82
|
+
For this to work, `claude-persona init --project` appends instructions to your `CLAUDE.md`:
|
|
83
|
+
|
|
84
|
+
```markdown
|
|
85
|
+
<!-- persona:admitted-wrong --> → Claude admits a mistake
|
|
86
|
+
<!-- persona:found-bug --> → Claude found or fixed a bug
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
You can add your own flag situations — just add an entry with `"trigger": "flag"` and re-run `init`.
|
|
90
|
+
|
|
91
|
+
## CLI Commands
|
|
92
|
+
|
|
93
|
+
```bash
|
|
94
|
+
claude-persona init --global # Install globally
|
|
95
|
+
claude-persona init --project # Install for current project
|
|
96
|
+
claude-persona test # List all situations
|
|
97
|
+
claude-persona test task-complete # Play a random sound for "task-complete"
|
|
98
|
+
claude-persona uninstall --global # Remove global hooks
|
|
99
|
+
claude-persona uninstall --project # Remove project hooks
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
## Custom Sound Packs
|
|
103
|
+
|
|
104
|
+
1. Create a directory: `sounds/my-character/`
|
|
105
|
+
2. Add your audio files (WAV, MP3, M4A, OGG)
|
|
106
|
+
3. Update `persona` in your config to `"my-character"`
|
|
107
|
+
4. Reference the filenames in your situations
|
|
108
|
+
|
|
109
|
+
Sound files are stored flat — all files for a persona go in one directory.
|
|
110
|
+
|
|
111
|
+
## Adding Situations
|
|
112
|
+
|
|
113
|
+
Just add an entry to the `situations` array in your config:
|
|
114
|
+
|
|
115
|
+
```json
|
|
116
|
+
{
|
|
117
|
+
"name": "subagent-stop",
|
|
118
|
+
"trigger": "SubagentStop",
|
|
119
|
+
"description": "A subagent finished its work",
|
|
120
|
+
"sounds": ["phew.wav", "finally.wav"]
|
|
121
|
+
}
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
Re-run `claude-persona init` to register any new hook events.
|
|
125
|
+
|
|
126
|
+
## Requirements
|
|
127
|
+
|
|
128
|
+
- Node.js 18+
|
|
129
|
+
- Claude Code CLI
|
|
130
|
+
- Audio playback: `afplay` (macOS), `paplay` (Linux), or PowerShell (Windows)
|
|
131
|
+
|
|
132
|
+
## License
|
|
133
|
+
|
|
134
|
+
MIT
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
{
|
|
2
|
+
"persona": "peasant",
|
|
3
|
+
"situations": [
|
|
4
|
+
{
|
|
5
|
+
"name": "prompt-submitted",
|
|
6
|
+
"trigger": "UserPromptSubmit",
|
|
7
|
+
"description": "User sends a prompt",
|
|
8
|
+
"sounds": [
|
|
9
|
+
"да господин.wav",
|
|
10
|
+
"угу.wav",
|
|
11
|
+
"хорошо.wav",
|
|
12
|
+
"ну я пошел.wav",
|
|
13
|
+
"опять работать.wav",
|
|
14
|
+
"чего.wav"
|
|
15
|
+
]
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
"name": "task-complete",
|
|
19
|
+
"trigger": "Stop",
|
|
20
|
+
"description": "Claude finished responding",
|
|
21
|
+
"sounds": ["готово.mp3", "я готов.wav", "да.wav", "хорошо.wav"]
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
"name": "tool-failed",
|
|
25
|
+
"trigger": "PostToolUseFailure",
|
|
26
|
+
"description": "A tool call failed",
|
|
27
|
+
"sounds": [
|
|
28
|
+
"меня убьют.wav",
|
|
29
|
+
"ооо.wav",
|
|
30
|
+
"придется драться.wav",
|
|
31
|
+
"здесь нельзя строить.m4a"
|
|
32
|
+
]
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
"name": "session-start",
|
|
36
|
+
"trigger": "SessionStart",
|
|
37
|
+
"description": "New session begins",
|
|
38
|
+
"sounds": ["опять работать.wav", "я готов.wav", "да господин.wav"]
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
"name": "session-end",
|
|
42
|
+
"trigger": "SessionEnd",
|
|
43
|
+
"description": "Session ends",
|
|
44
|
+
"sounds": ["смерть.mp3", "прощайте.wav", "ну я пошел.wav"]
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
"name": "notification",
|
|
48
|
+
"trigger": "Notification",
|
|
49
|
+
"description": "Claude needs attention or permission",
|
|
50
|
+
"sounds": ["на меня давят.wav", "чего.wav"]
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
"name": "subagent-start",
|
|
54
|
+
"trigger": "SubagentStart",
|
|
55
|
+
"description": "A subagent is spawned",
|
|
56
|
+
"sounds": ["работа не волк.wav"]
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
"name": "admitted-wrong",
|
|
60
|
+
"trigger": "flag",
|
|
61
|
+
"description": "Claude admits a mistake or corrects itself",
|
|
62
|
+
"sounds": [
|
|
63
|
+
"вам меня не жалко.wav",
|
|
64
|
+
"меня убьют.wav",
|
|
65
|
+
"ооо.wav",
|
|
66
|
+
"тебя бы так.wav"
|
|
67
|
+
]
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
"name": "found-bug",
|
|
71
|
+
"trigger": "flag",
|
|
72
|
+
"description": "Claude found or fixed a bug",
|
|
73
|
+
"sounds": ["мы поймали ведьму.wav"]
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
"name": "spam-detected",
|
|
77
|
+
"trigger": "spam",
|
|
78
|
+
"description": "User sending too many prompts too fast",
|
|
79
|
+
"sounds": ["ты что ли король.wav", "на меня давят.wav", "тебя бы так.wav"]
|
|
80
|
+
}
|
|
81
|
+
]
|
|
82
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { Command } from 'commander';
|
|
3
|
+
import { initCommand } from './init.js';
|
|
4
|
+
import { testCommand } from './test.js';
|
|
5
|
+
import { uninstallCommand } from './uninstall.js';
|
|
6
|
+
const program = new Command();
|
|
7
|
+
program
|
|
8
|
+
.name('claude-persona')
|
|
9
|
+
.description('Sound effects for Claude Code sessions')
|
|
10
|
+
.version('0.1.0');
|
|
11
|
+
program
|
|
12
|
+
.command('init')
|
|
13
|
+
.description('Install claude-persona hooks into Claude Code')
|
|
14
|
+
.option('--global', 'Install globally (~/.claude-persona/ + ~/.claude/settings.json)')
|
|
15
|
+
.option('--project', 'Install for current project (.claude/persona/ + .claude/settings.local.json)')
|
|
16
|
+
.action(initCommand);
|
|
17
|
+
program
|
|
18
|
+
.command('test [situation]')
|
|
19
|
+
.description('Play a random sound for a situation (or list all situations)')
|
|
20
|
+
.option('-c, --config <path>', 'Path to claude-persona.json config')
|
|
21
|
+
.action(testCommand);
|
|
22
|
+
program
|
|
23
|
+
.command('uninstall')
|
|
24
|
+
.description('Remove claude-persona hooks from Claude Code')
|
|
25
|
+
.option('--global', 'Remove from global settings')
|
|
26
|
+
.option('--project', 'Remove from project settings')
|
|
27
|
+
.action(uninstallCommand);
|
|
28
|
+
program.parse();
|
package/dist/cli/init.js
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import { loadConfig, getRequiredHookEvents, hasFlagSituations } from '../config.js';
|
|
5
|
+
/** Locate the package root (where sounds/ and defaults/ live) */
|
|
6
|
+
function getPackageRoot() {
|
|
7
|
+
// When running from dist/cli/index.js, package root is ../../
|
|
8
|
+
return path.resolve(new URL('..', import.meta.url).pathname, '..');
|
|
9
|
+
}
|
|
10
|
+
export async function initCommand(options) {
|
|
11
|
+
const isGlobal = options.global ?? false;
|
|
12
|
+
const isProject = options.project ?? false;
|
|
13
|
+
if (!isGlobal && !isProject) {
|
|
14
|
+
console.log('Please specify --global or --project:\n');
|
|
15
|
+
console.log(' claude-persona init --global # Install for all projects');
|
|
16
|
+
console.log(' claude-persona init --project # Install for current project only');
|
|
17
|
+
process.exit(1);
|
|
18
|
+
}
|
|
19
|
+
const packageRoot = getPackageRoot();
|
|
20
|
+
const defaultConfigPath = path.join(packageRoot, 'defaults', 'claude-persona.json');
|
|
21
|
+
const defaultSoundsDir = path.join(packageRoot, 'sounds');
|
|
22
|
+
if (!fs.existsSync(defaultConfigPath)) {
|
|
23
|
+
console.error('Error: Default config not found at', defaultConfigPath);
|
|
24
|
+
process.exit(1);
|
|
25
|
+
}
|
|
26
|
+
// Determine target paths
|
|
27
|
+
let targetDir;
|
|
28
|
+
let settingsPath;
|
|
29
|
+
if (isGlobal) {
|
|
30
|
+
targetDir = path.join(os.homedir(), '.claude-persona');
|
|
31
|
+
settingsPath = path.join(os.homedir(), '.claude', 'settings.json');
|
|
32
|
+
}
|
|
33
|
+
else {
|
|
34
|
+
targetDir = path.join(process.cwd(), '.claude', 'persona');
|
|
35
|
+
settingsPath = path.join(process.cwd(), '.claude', 'settings.local.json');
|
|
36
|
+
}
|
|
37
|
+
const targetConfigPath = path.join(targetDir, 'claude-persona.json');
|
|
38
|
+
const targetSoundsDir = path.join(targetDir, 'sounds');
|
|
39
|
+
// 1. Create target directory
|
|
40
|
+
fs.mkdirSync(targetDir, { recursive: true });
|
|
41
|
+
// 2. Copy config (don't overwrite if exists)
|
|
42
|
+
if (fs.existsSync(targetConfigPath)) {
|
|
43
|
+
console.log(` Config already exists at ${targetConfigPath}, skipping.`);
|
|
44
|
+
}
|
|
45
|
+
else {
|
|
46
|
+
fs.copyFileSync(defaultConfigPath, targetConfigPath);
|
|
47
|
+
console.log(` Config: ${targetConfigPath}`);
|
|
48
|
+
}
|
|
49
|
+
// 3. Copy sounds
|
|
50
|
+
const config = loadConfig(targetConfigPath);
|
|
51
|
+
const personaSoundsSource = path.join(defaultSoundsDir, config.persona);
|
|
52
|
+
const personaSoundsTarget = path.join(targetSoundsDir, config.persona);
|
|
53
|
+
if (fs.existsSync(personaSoundsSource)) {
|
|
54
|
+
fs.mkdirSync(personaSoundsTarget, { recursive: true });
|
|
55
|
+
const files = fs.readdirSync(personaSoundsSource);
|
|
56
|
+
let copied = 0;
|
|
57
|
+
for (const file of files) {
|
|
58
|
+
const targetFile = path.join(personaSoundsTarget, file);
|
|
59
|
+
if (!fs.existsSync(targetFile)) {
|
|
60
|
+
fs.copyFileSync(path.join(personaSoundsSource, file), targetFile);
|
|
61
|
+
copied++;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
console.log(` Sounds: ${copied} files copied to ${personaSoundsTarget}`);
|
|
65
|
+
}
|
|
66
|
+
else {
|
|
67
|
+
console.log(` No default sounds found for persona "${config.persona}".`);
|
|
68
|
+
fs.mkdirSync(personaSoundsTarget, { recursive: true });
|
|
69
|
+
console.log(` Created empty sounds dir: ${personaSoundsTarget}`);
|
|
70
|
+
}
|
|
71
|
+
// 4. Register hooks in Claude settings
|
|
72
|
+
const handlerPath = path.join(packageRoot, 'dist', 'handler.js');
|
|
73
|
+
registerHooks(config, settingsPath, handlerPath, targetConfigPath);
|
|
74
|
+
// 5. Append CLAUDE.md flag instructions (project install only)
|
|
75
|
+
if (isProject && hasFlagSituations(config)) {
|
|
76
|
+
appendClaudeMdFlags(config);
|
|
77
|
+
}
|
|
78
|
+
console.log('\n claude-persona installed successfully!\n');
|
|
79
|
+
console.log(` Edit your config: ${targetConfigPath}`);
|
|
80
|
+
console.log(` Add sounds to: ${personaSoundsTarget}`);
|
|
81
|
+
console.log(` Test it: claude-persona test task-complete`);
|
|
82
|
+
}
|
|
83
|
+
function registerHooks(config, settingsPath, handlerPath, configPath) {
|
|
84
|
+
// Load existing settings
|
|
85
|
+
let settings = {};
|
|
86
|
+
if (fs.existsSync(settingsPath)) {
|
|
87
|
+
try {
|
|
88
|
+
settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
// Start fresh if corrupt
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
if (!settings.hooks) {
|
|
95
|
+
settings.hooks = {};
|
|
96
|
+
}
|
|
97
|
+
const requiredEvents = getRequiredHookEvents(config);
|
|
98
|
+
const hasFlags = hasFlagSituations(config);
|
|
99
|
+
const handlerCmd = (event, extra = '') => `node "${handlerPath}" --event ${event} --config "${configPath}"${extra}`;
|
|
100
|
+
// Marker to identify our hooks for idempotent re-runs
|
|
101
|
+
const marker = 'claude-persona';
|
|
102
|
+
for (const event of requiredEvents) {
|
|
103
|
+
if (!settings.hooks[event]) {
|
|
104
|
+
settings.hooks[event] = [];
|
|
105
|
+
}
|
|
106
|
+
// Remove any existing claude-persona hooks for this event
|
|
107
|
+
settings.hooks[event] = settings.hooks[event].filter((m) => !m.hooks.some((h) => h.command.includes(marker)));
|
|
108
|
+
// Build hook entries for this event
|
|
109
|
+
const hookEntries = {
|
|
110
|
+
matcher: '',
|
|
111
|
+
hooks: [
|
|
112
|
+
{ type: 'command', command: handlerCmd(event) },
|
|
113
|
+
],
|
|
114
|
+
};
|
|
115
|
+
// For Stop event, add async flag scanner
|
|
116
|
+
if (event === 'Stop' && hasFlags) {
|
|
117
|
+
hookEntries.hooks.push({
|
|
118
|
+
type: 'command',
|
|
119
|
+
command: handlerCmd(event, ' --flags'),
|
|
120
|
+
async: true,
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
settings.hooks[event].push(hookEntries);
|
|
124
|
+
}
|
|
125
|
+
// Ensure settings directory exists
|
|
126
|
+
fs.mkdirSync(path.dirname(settingsPath), { recursive: true });
|
|
127
|
+
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n');
|
|
128
|
+
console.log(` Hooks: registered in ${settingsPath}`);
|
|
129
|
+
}
|
|
130
|
+
function appendClaudeMdFlags(config) {
|
|
131
|
+
const claudeMdPath = path.join(process.cwd(), 'CLAUDE.md');
|
|
132
|
+
const flagSituations = config.situations.filter((s) => s.trigger === 'flag');
|
|
133
|
+
if (flagSituations.length === 0)
|
|
134
|
+
return;
|
|
135
|
+
const marker = '## Persona Flags';
|
|
136
|
+
let content = '';
|
|
137
|
+
if (fs.existsSync(claudeMdPath)) {
|
|
138
|
+
content = fs.readFileSync(claudeMdPath, 'utf8');
|
|
139
|
+
// Don't append if already present
|
|
140
|
+
if (content.includes(marker)) {
|
|
141
|
+
console.log(' CLAUDE.md: persona flags section already exists, skipping.');
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
const rows = flagSituations
|
|
146
|
+
.map((s) => `| \`<!-- persona:${s.name} -->\` | ${s.description} |`)
|
|
147
|
+
.join('\n');
|
|
148
|
+
const section = `
|
|
149
|
+
${marker}
|
|
150
|
+
|
|
151
|
+
When your response matches one of these situations, include the corresponding HTML comment flag **at the very end** of your text output. The hook system will detect it and play the appropriate sound.
|
|
152
|
+
|
|
153
|
+
| Flag | When to use |
|
|
154
|
+
|---|---|
|
|
155
|
+
${rows}
|
|
156
|
+
|
|
157
|
+
Rules:
|
|
158
|
+
- Only include ONE flag per response, at the very end
|
|
159
|
+
- Only use when the situation genuinely applies
|
|
160
|
+
- The flag is invisible to markdown renderers but the hook script reads it from the transcript
|
|
161
|
+
`;
|
|
162
|
+
content = content ? content.trimEnd() + '\n' + section : section.trimStart();
|
|
163
|
+
fs.writeFileSync(claudeMdPath, content);
|
|
164
|
+
console.log(' CLAUDE.md: appended persona flags section.');
|
|
165
|
+
}
|
package/dist/cli/test.js
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import { loadConfig, resolveSoundPath } from '../config.js';
|
|
5
|
+
import { playRandom } from '../player.js';
|
|
6
|
+
/** Resolve config path: explicit > project > global */
|
|
7
|
+
function findConfig(explicit) {
|
|
8
|
+
if (explicit)
|
|
9
|
+
return explicit;
|
|
10
|
+
const projectConfig = path.join(process.cwd(), '.claude', 'persona', 'claude-persona.json');
|
|
11
|
+
if (fs.existsSync(projectConfig))
|
|
12
|
+
return projectConfig;
|
|
13
|
+
const globalConfig = path.join(os.homedir(), '.claude-persona', 'claude-persona.json');
|
|
14
|
+
if (fs.existsSync(globalConfig))
|
|
15
|
+
return globalConfig;
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
export async function testCommand(situation, options) {
|
|
19
|
+
const configPath = findConfig(options.config);
|
|
20
|
+
if (!configPath) {
|
|
21
|
+
console.error('No claude-persona.json found. Run `claude-persona init` first.');
|
|
22
|
+
process.exit(1);
|
|
23
|
+
}
|
|
24
|
+
const config = loadConfig(configPath);
|
|
25
|
+
const configDir = path.dirname(configPath);
|
|
26
|
+
if (!situation) {
|
|
27
|
+
// List all situations
|
|
28
|
+
console.log(`Persona: ${config.persona}\n`);
|
|
29
|
+
console.log('Situations:');
|
|
30
|
+
for (const s of config.situations) {
|
|
31
|
+
console.log(` ${s.name.padEnd(20)} [${s.trigger}] ${s.sounds.length} sound(s) — ${s.description}`);
|
|
32
|
+
}
|
|
33
|
+
console.log(`\nTest a situation: claude-persona test <situation-name>`);
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
const match = config.situations.find((s) => s.name === situation);
|
|
37
|
+
if (!match) {
|
|
38
|
+
console.error(`Unknown situation: "${situation}"`);
|
|
39
|
+
console.error('Available:', config.situations.map((s) => s.name).join(', '));
|
|
40
|
+
process.exit(1);
|
|
41
|
+
}
|
|
42
|
+
const soundPaths = match.sounds.map((s) => resolveSoundPath(configDir, config.persona, s));
|
|
43
|
+
console.log(`Playing "${match.name}" (${match.sounds.length} sound(s))...`);
|
|
44
|
+
await playRandom(soundPaths);
|
|
45
|
+
// Give audio time to finish
|
|
46
|
+
await new Promise((resolve) => setTimeout(resolve, 3000));
|
|
47
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
export async function uninstallCommand(options) {
|
|
5
|
+
const isGlobal = options.global ?? false;
|
|
6
|
+
const isProject = options.project ?? false;
|
|
7
|
+
if (!isGlobal && !isProject) {
|
|
8
|
+
console.log('Please specify --global or --project:\n');
|
|
9
|
+
console.log(' claude-persona uninstall --global # Remove from global settings');
|
|
10
|
+
console.log(' claude-persona uninstall --project # Remove from project settings');
|
|
11
|
+
process.exit(1);
|
|
12
|
+
}
|
|
13
|
+
let settingsPath;
|
|
14
|
+
let personaDir;
|
|
15
|
+
if (isGlobal) {
|
|
16
|
+
settingsPath = path.join(os.homedir(), '.claude', 'settings.json');
|
|
17
|
+
personaDir = path.join(os.homedir(), '.claude-persona');
|
|
18
|
+
}
|
|
19
|
+
else {
|
|
20
|
+
settingsPath = path.join(process.cwd(), '.claude', 'settings.local.json');
|
|
21
|
+
personaDir = path.join(process.cwd(), '.claude', 'persona');
|
|
22
|
+
}
|
|
23
|
+
// Remove hooks from settings
|
|
24
|
+
if (fs.existsSync(settingsPath)) {
|
|
25
|
+
try {
|
|
26
|
+
const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
|
|
27
|
+
if (settings.hooks) {
|
|
28
|
+
let removed = 0;
|
|
29
|
+
for (const event of Object.keys(settings.hooks)) {
|
|
30
|
+
const before = settings.hooks[event].length;
|
|
31
|
+
settings.hooks[event] = settings.hooks[event].filter((m) => !m.hooks.some((h) => h.command.includes('claude-persona')));
|
|
32
|
+
removed += before - settings.hooks[event].length;
|
|
33
|
+
// Clean up empty arrays
|
|
34
|
+
if (settings.hooks[event].length === 0) {
|
|
35
|
+
delete settings.hooks[event];
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
// Clean up empty hooks object
|
|
39
|
+
if (Object.keys(settings.hooks).length === 0) {
|
|
40
|
+
delete settings.hooks;
|
|
41
|
+
}
|
|
42
|
+
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n');
|
|
43
|
+
console.log(` Removed ${removed} hook(s) from ${settingsPath}`);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
catch (err) {
|
|
47
|
+
console.error(` Error reading ${settingsPath}:`, err);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
else {
|
|
51
|
+
console.log(` No settings file at ${settingsPath}`);
|
|
52
|
+
}
|
|
53
|
+
// Inform about sound files (don't auto-delete)
|
|
54
|
+
if (fs.existsSync(personaDir)) {
|
|
55
|
+
console.log(`\n Sound files and config are still at: ${personaDir}`);
|
|
56
|
+
console.log(' Remove manually if no longer needed:');
|
|
57
|
+
console.log(` rm -rf "${personaDir}"`);
|
|
58
|
+
}
|
|
59
|
+
console.log('\n claude-persona uninstalled.');
|
|
60
|
+
}
|
package/dist/config.d.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { ClaudePersonaConfig, Situation, TriggerType } from './types.js';
|
|
2
|
+
/** Load and validate a claude-persona.json config file */
|
|
3
|
+
export declare function loadConfig(configPath: string): ClaudePersonaConfig;
|
|
4
|
+
/** Resolve the full path to a sound file given the config location */
|
|
5
|
+
export declare function resolveSoundPath(configDir: string, persona: string, soundFile: string): string;
|
|
6
|
+
/** Get all situations that match a given trigger type */
|
|
7
|
+
export declare function getSituationsForTrigger(config: ClaudePersonaConfig, trigger: TriggerType): Situation[];
|
|
8
|
+
/** Get a situation by name */
|
|
9
|
+
export declare function getSituationByName(config: ClaudePersonaConfig, name: string): Situation | undefined;
|
|
10
|
+
/** Get all unique hook events that need to be registered */
|
|
11
|
+
export declare function getRequiredHookEvents(config: ClaudePersonaConfig): string[];
|
|
12
|
+
/** Check whether the config has any flag-type situations */
|
|
13
|
+
export declare function hasFlagSituations(config: ClaudePersonaConfig): boolean;
|
|
14
|
+
/** Check whether the config has a spam-type situation */
|
|
15
|
+
export declare function hasSpamSituation(config: ClaudePersonaConfig): boolean;
|
|
16
|
+
/** Get names of all flag-trigger situations (used as valid flag identifiers) */
|
|
17
|
+
export declare function getFlagNames(config: ClaudePersonaConfig): string[];
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
/** Load and validate a claude-persona.json config file */
|
|
4
|
+
export function loadConfig(configPath) {
|
|
5
|
+
const raw = fs.readFileSync(configPath, 'utf8');
|
|
6
|
+
const config = JSON.parse(raw);
|
|
7
|
+
if (!config.persona || typeof config.persona !== 'string') {
|
|
8
|
+
throw new Error('Config must have a "persona" string');
|
|
9
|
+
}
|
|
10
|
+
if (!Array.isArray(config.situations) || config.situations.length === 0) {
|
|
11
|
+
throw new Error('Config must have a non-empty "situations" array');
|
|
12
|
+
}
|
|
13
|
+
return config;
|
|
14
|
+
}
|
|
15
|
+
/** Resolve the full path to a sound file given the config location */
|
|
16
|
+
export function resolveSoundPath(configDir, persona, soundFile) {
|
|
17
|
+
return path.join(configDir, 'sounds', persona, soundFile);
|
|
18
|
+
}
|
|
19
|
+
/** Get all situations that match a given trigger type */
|
|
20
|
+
export function getSituationsForTrigger(config, trigger) {
|
|
21
|
+
return config.situations.filter((s) => s.trigger === trigger);
|
|
22
|
+
}
|
|
23
|
+
/** Get a situation by name */
|
|
24
|
+
export function getSituationByName(config, name) {
|
|
25
|
+
return config.situations.find((s) => s.name === name);
|
|
26
|
+
}
|
|
27
|
+
/** Get all unique hook events that need to be registered */
|
|
28
|
+
export function getRequiredHookEvents(config) {
|
|
29
|
+
const events = new Set();
|
|
30
|
+
for (const situation of config.situations) {
|
|
31
|
+
if (situation.trigger === 'flag') {
|
|
32
|
+
// Flag situations are detected on Stop hook
|
|
33
|
+
events.add('Stop');
|
|
34
|
+
}
|
|
35
|
+
else if (situation.trigger === 'spam') {
|
|
36
|
+
// Spam overrides UserPromptSubmit
|
|
37
|
+
events.add('UserPromptSubmit');
|
|
38
|
+
}
|
|
39
|
+
else {
|
|
40
|
+
events.add(situation.trigger);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return [...events];
|
|
44
|
+
}
|
|
45
|
+
/** Check whether the config has any flag-type situations */
|
|
46
|
+
export function hasFlagSituations(config) {
|
|
47
|
+
return config.situations.some((s) => s.trigger === 'flag');
|
|
48
|
+
}
|
|
49
|
+
/** Check whether the config has a spam-type situation */
|
|
50
|
+
export function hasSpamSituation(config) {
|
|
51
|
+
return config.situations.some((s) => s.trigger === 'spam');
|
|
52
|
+
}
|
|
53
|
+
/** Get names of all flag-trigger situations (used as valid flag identifiers) */
|
|
54
|
+
export function getFlagNames(config) {
|
|
55
|
+
return config.situations
|
|
56
|
+
.filter((s) => s.trigger === 'flag')
|
|
57
|
+
.map((s) => s.name);
|
|
58
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
const FLAG_PATTERN = /<!--\s*persona:(\S+)\s*-->/g;
|
|
3
|
+
/**
|
|
4
|
+
* Scan the transcript for persona flags in the last assistant message.
|
|
5
|
+
* Returns the first matching flag name, or null if none found.
|
|
6
|
+
*/
|
|
7
|
+
export function scanForFlags(transcriptPath, validFlags) {
|
|
8
|
+
if (!transcriptPath || !fs.existsSync(transcriptPath)) {
|
|
9
|
+
return null;
|
|
10
|
+
}
|
|
11
|
+
// Read last 30KB of transcript
|
|
12
|
+
const stat = fs.statSync(transcriptPath);
|
|
13
|
+
const readSize = Math.min(stat.size, 30 * 1024);
|
|
14
|
+
const fd = fs.openSync(transcriptPath, 'r');
|
|
15
|
+
const buffer = Buffer.alloc(readSize);
|
|
16
|
+
fs.readSync(fd, buffer, 0, readSize, Math.max(0, stat.size - readSize));
|
|
17
|
+
fs.closeSync(fd);
|
|
18
|
+
const tail = buffer.toString('utf8');
|
|
19
|
+
const lines = tail.split('\n').filter(Boolean);
|
|
20
|
+
// Find the last assistant message
|
|
21
|
+
let lastAssistantText = '';
|
|
22
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
23
|
+
try {
|
|
24
|
+
const entry = JSON.parse(lines[i]);
|
|
25
|
+
if (entry.role === 'assistant') {
|
|
26
|
+
if (typeof entry.content === 'string') {
|
|
27
|
+
lastAssistantText = entry.content;
|
|
28
|
+
}
|
|
29
|
+
else if (Array.isArray(entry.content)) {
|
|
30
|
+
lastAssistantText = entry.content
|
|
31
|
+
.filter((b) => b.type === 'text')
|
|
32
|
+
.map((b) => b.text ?? '')
|
|
33
|
+
.join(' ');
|
|
34
|
+
}
|
|
35
|
+
break;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
// Skip malformed lines
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
if (!lastAssistantText)
|
|
43
|
+
return null;
|
|
44
|
+
// Scan for persona flags
|
|
45
|
+
let match;
|
|
46
|
+
while ((match = FLAG_PATTERN.exec(lastAssistantText)) !== null) {
|
|
47
|
+
const flagName = match[1];
|
|
48
|
+
if (validFlags.includes(flagName)) {
|
|
49
|
+
return flagName;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return null;
|
|
53
|
+
}
|
package/dist/handler.js
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* claude-persona hook handler
|
|
4
|
+
*
|
|
5
|
+
* Called by Claude Code hooks with:
|
|
6
|
+
* node handler.js --event <HookEvent> [--flags] --config <path>
|
|
7
|
+
*
|
|
8
|
+
* Reads hook JSON from stdin, resolves a situation, plays a sound.
|
|
9
|
+
*/
|
|
10
|
+
import path from 'node:path';
|
|
11
|
+
import { loadConfig, resolveSoundPath, getSituationsForTrigger, getSituationByName, getFlagNames, hasSpamSituation, } from './config.js';
|
|
12
|
+
import { playRandom, randomElement } from './player.js';
|
|
13
|
+
import { checkSpam } from './spam-detector.js';
|
|
14
|
+
import { scanForFlags } from './flag-scanner.js';
|
|
15
|
+
// Safety timeout — never block Claude
|
|
16
|
+
setTimeout(() => process.exit(0), 5000);
|
|
17
|
+
function parseArgs(argv) {
|
|
18
|
+
let event = '';
|
|
19
|
+
let flags = false;
|
|
20
|
+
let config = '';
|
|
21
|
+
for (let i = 2; i < argv.length; i++) {
|
|
22
|
+
switch (argv[i]) {
|
|
23
|
+
case '--event':
|
|
24
|
+
event = argv[++i] ?? '';
|
|
25
|
+
break;
|
|
26
|
+
case '--flags':
|
|
27
|
+
flags = true;
|
|
28
|
+
break;
|
|
29
|
+
case '--config':
|
|
30
|
+
config = argv[++i] ?? '';
|
|
31
|
+
break;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return { event, flags, config };
|
|
35
|
+
}
|
|
36
|
+
async function readStdin() {
|
|
37
|
+
return new Promise((resolve) => {
|
|
38
|
+
let data = '';
|
|
39
|
+
process.stdin.setEncoding('utf8');
|
|
40
|
+
process.stdin.on('data', (chunk) => { data += chunk; });
|
|
41
|
+
process.stdin.on('end', () => resolve(data));
|
|
42
|
+
// If stdin isn't piped, resolve immediately
|
|
43
|
+
if (process.stdin.isTTY)
|
|
44
|
+
resolve('{}');
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
async function main() {
|
|
48
|
+
const args = parseArgs(process.argv);
|
|
49
|
+
if (!args.config) {
|
|
50
|
+
process.exit(0);
|
|
51
|
+
}
|
|
52
|
+
let hookInput;
|
|
53
|
+
try {
|
|
54
|
+
const raw = await readStdin();
|
|
55
|
+
hookInput = JSON.parse(raw || '{}');
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
hookInput = { session_id: '', hook_event_name: args.event };
|
|
59
|
+
}
|
|
60
|
+
let personaConfig;
|
|
61
|
+
try {
|
|
62
|
+
personaConfig = loadConfig(args.config);
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
process.exit(0);
|
|
66
|
+
}
|
|
67
|
+
const configDir = path.dirname(args.config);
|
|
68
|
+
// Flag scanning mode (async Stop hook)
|
|
69
|
+
if (args.flags) {
|
|
70
|
+
const flagNames = getFlagNames(personaConfig);
|
|
71
|
+
if (flagNames.length === 0 || !hookInput.transcript_path) {
|
|
72
|
+
process.exit(0);
|
|
73
|
+
}
|
|
74
|
+
const matchedFlag = scanForFlags(hookInput.transcript_path, flagNames);
|
|
75
|
+
if (matchedFlag) {
|
|
76
|
+
const situation = getSituationByName(personaConfig, matchedFlag);
|
|
77
|
+
if (situation) {
|
|
78
|
+
const soundPaths = situation.sounds.map((s) => resolveSoundPath(configDir, personaConfig.persona, s));
|
|
79
|
+
await playRandom(soundPaths);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
// Give sound time to start before exiting
|
|
83
|
+
await new Promise((resolve) => setTimeout(resolve, 1500));
|
|
84
|
+
process.exit(0);
|
|
85
|
+
}
|
|
86
|
+
// Normal mode: resolve situation from event
|
|
87
|
+
let situation;
|
|
88
|
+
if (args.event === 'UserPromptSubmit' && hasSpamSituation(personaConfig)) {
|
|
89
|
+
const isSpam = checkSpam();
|
|
90
|
+
if (isSpam) {
|
|
91
|
+
const spamSituations = getSituationsForTrigger(personaConfig, 'spam');
|
|
92
|
+
situation = spamSituations.length > 0 ? randomElement(spamSituations) : undefined;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
if (!situation) {
|
|
96
|
+
const matches = getSituationsForTrigger(personaConfig, args.event);
|
|
97
|
+
situation = matches.length > 0 ? matches[0] : undefined;
|
|
98
|
+
}
|
|
99
|
+
if (!situation || situation.sounds.length === 0) {
|
|
100
|
+
process.exit(0);
|
|
101
|
+
}
|
|
102
|
+
const soundPaths = situation.sounds.map((s) => resolveSoundPath(configDir, personaConfig.persona, s));
|
|
103
|
+
await playRandom(soundPaths);
|
|
104
|
+
process.exit(0);
|
|
105
|
+
}
|
|
106
|
+
main().catch(() => process.exit(0));
|
package/dist/player.d.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/** Pick a random element from an array */
|
|
2
|
+
export declare function randomElement<T>(arr: T[]): T;
|
|
3
|
+
/** Play a single sound file. Silently fails if file missing or playback errors. */
|
|
4
|
+
export declare function play(filePath: string): Promise<void>;
|
|
5
|
+
/** Pick a random sound from a list of file paths and play it */
|
|
6
|
+
export declare function playRandom(filePaths: string[]): Promise<void>;
|
package/dist/player.js
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
// @ts-expect-error no types for play-sound
|
|
3
|
+
import playSound from 'play-sound';
|
|
4
|
+
const player = playSound();
|
|
5
|
+
/** Pick a random element from an array */
|
|
6
|
+
export function randomElement(arr) {
|
|
7
|
+
return arr[Math.floor(Math.random() * arr.length)];
|
|
8
|
+
}
|
|
9
|
+
/** Play a single sound file. Silently fails if file missing or playback errors. */
|
|
10
|
+
export function play(filePath) {
|
|
11
|
+
return new Promise((resolve) => {
|
|
12
|
+
if (!fs.existsSync(filePath)) {
|
|
13
|
+
resolve();
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
player.play(filePath, (err) => {
|
|
17
|
+
if (err && err.killed !== true) {
|
|
18
|
+
// Silently fail — don't block Claude
|
|
19
|
+
}
|
|
20
|
+
resolve();
|
|
21
|
+
});
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
/** Pick a random sound from a list of file paths and play it */
|
|
25
|
+
export function playRandom(filePaths) {
|
|
26
|
+
if (filePaths.length === 0)
|
|
27
|
+
return Promise.resolve();
|
|
28
|
+
return play(randomElement(filePaths));
|
|
29
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
const STAMP_FILE = path.join(os.tmpdir(), 'claude-persona-stamps.json');
|
|
5
|
+
const SPAM_WINDOW_MS = 15_000;
|
|
6
|
+
const SPAM_THRESHOLD = 3;
|
|
7
|
+
function getTimestamps() {
|
|
8
|
+
try {
|
|
9
|
+
if (fs.existsSync(STAMP_FILE)) {
|
|
10
|
+
return JSON.parse(fs.readFileSync(STAMP_FILE, 'utf8'));
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
catch {
|
|
14
|
+
// ignore corrupt file
|
|
15
|
+
}
|
|
16
|
+
return [];
|
|
17
|
+
}
|
|
18
|
+
function saveTimestamps(stamps) {
|
|
19
|
+
fs.writeFileSync(STAMP_FILE, JSON.stringify(stamps));
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Record a prompt timestamp and check if the user is spamming.
|
|
23
|
+
* Returns true if 3+ prompts were submitted within 15 seconds.
|
|
24
|
+
*/
|
|
25
|
+
export function checkSpam() {
|
|
26
|
+
const now = Date.now();
|
|
27
|
+
const stamps = getTimestamps().filter((t) => now - t < SPAM_WINDOW_MS);
|
|
28
|
+
stamps.push(now);
|
|
29
|
+
saveTimestamps(stamps);
|
|
30
|
+
return stamps.length >= SPAM_THRESHOLD;
|
|
31
|
+
}
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/** All Claude Code hook event names that can be used as triggers */
|
|
2
|
+
export type HookEvent = 'UserPromptSubmit' | 'Stop' | 'PreToolUse' | 'PostToolUse' | 'PostToolUseFailure' | 'SessionStart' | 'SessionEnd' | 'Notification' | 'SubagentStart' | 'SubagentStop' | 'TeammateIdle' | 'TaskCompleted' | 'PreCompact' | 'PermissionRequest';
|
|
3
|
+
/** Special trigger types beyond hook events */
|
|
4
|
+
export type SpecialTrigger = 'flag' | 'spam';
|
|
5
|
+
/** All valid trigger values */
|
|
6
|
+
export type TriggerType = HookEvent | SpecialTrigger;
|
|
7
|
+
/** A situation that triggers a sound */
|
|
8
|
+
export interface Situation {
|
|
9
|
+
/** Unique name, also used as flag identifier for flag triggers */
|
|
10
|
+
name: string;
|
|
11
|
+
/** What triggers this situation */
|
|
12
|
+
trigger: TriggerType;
|
|
13
|
+
/** Human-readable description of when this plays */
|
|
14
|
+
description: string;
|
|
15
|
+
/** Sound filenames (resolved relative to sounds/<persona>/) */
|
|
16
|
+
sounds: string[];
|
|
17
|
+
}
|
|
18
|
+
/** The user-facing config file */
|
|
19
|
+
export interface ClaudePersonaConfig {
|
|
20
|
+
/** Name of the sound pack / character (maps to sounds/<persona>/) */
|
|
21
|
+
persona: string;
|
|
22
|
+
/** All configured situations */
|
|
23
|
+
situations: Situation[];
|
|
24
|
+
}
|
|
25
|
+
/** JSON payload from Claude Code hooks via stdin */
|
|
26
|
+
export interface HookInput {
|
|
27
|
+
session_id: string;
|
|
28
|
+
transcript_path?: string;
|
|
29
|
+
cwd?: string;
|
|
30
|
+
hook_event_name: string;
|
|
31
|
+
permission_mode?: string;
|
|
32
|
+
prompt?: string;
|
|
33
|
+
tool_name?: string;
|
|
34
|
+
tool_input?: Record<string, unknown>;
|
|
35
|
+
tool_response?: string;
|
|
36
|
+
tool_use_id?: string;
|
|
37
|
+
message?: string;
|
|
38
|
+
title?: string;
|
|
39
|
+
notification_type?: string;
|
|
40
|
+
source?: string;
|
|
41
|
+
model?: string;
|
|
42
|
+
stop_hook_active?: boolean;
|
|
43
|
+
}
|
|
44
|
+
/** Structure of Claude Code settings.json hooks section */
|
|
45
|
+
export interface ClaudeHookEntry {
|
|
46
|
+
type: 'command';
|
|
47
|
+
command: string;
|
|
48
|
+
async?: boolean;
|
|
49
|
+
timeout?: number;
|
|
50
|
+
}
|
|
51
|
+
export interface ClaudeHookMatcher {
|
|
52
|
+
matcher: string;
|
|
53
|
+
hooks: ClaudeHookEntry[];
|
|
54
|
+
}
|
|
55
|
+
export interface ClaudeSettings {
|
|
56
|
+
hooks?: Record<string, ClaudeHookMatcher[]>;
|
|
57
|
+
[key: string]: unknown;
|
|
58
|
+
}
|
|
59
|
+
/** CLI handler args parsed from process.argv */
|
|
60
|
+
export interface HandlerArgs {
|
|
61
|
+
event: string;
|
|
62
|
+
flags: boolean;
|
|
63
|
+
config: string;
|
|
64
|
+
}
|
package/dist/types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/hooks/hooks.json
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
{
|
|
2
|
+
"hooks": {
|
|
3
|
+
"UserPromptSubmit": [
|
|
4
|
+
{
|
|
5
|
+
"matcher": "",
|
|
6
|
+
"hooks": [
|
|
7
|
+
{
|
|
8
|
+
"type": "command",
|
|
9
|
+
"command": "node \"$CLAUDE_PLUGIN_ROOT/dist/handler.js\" --event UserPromptSubmit --config \"$CLAUDE_PLUGIN_ROOT/defaults/claude-persona.json\""
|
|
10
|
+
}
|
|
11
|
+
]
|
|
12
|
+
}
|
|
13
|
+
],
|
|
14
|
+
"Stop": [
|
|
15
|
+
{
|
|
16
|
+
"matcher": "",
|
|
17
|
+
"hooks": [
|
|
18
|
+
{
|
|
19
|
+
"type": "command",
|
|
20
|
+
"command": "node \"$CLAUDE_PLUGIN_ROOT/dist/handler.js\" --event Stop --config \"$CLAUDE_PLUGIN_ROOT/defaults/claude-persona.json\""
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
"type": "command",
|
|
24
|
+
"command": "node \"$CLAUDE_PLUGIN_ROOT/dist/handler.js\" --event Stop --flags --config \"$CLAUDE_PLUGIN_ROOT/defaults/claude-persona.json\"",
|
|
25
|
+
"async": true
|
|
26
|
+
}
|
|
27
|
+
]
|
|
28
|
+
}
|
|
29
|
+
],
|
|
30
|
+
"PostToolUseFailure": [
|
|
31
|
+
{
|
|
32
|
+
"matcher": "",
|
|
33
|
+
"hooks": [
|
|
34
|
+
{
|
|
35
|
+
"type": "command",
|
|
36
|
+
"command": "node \"$CLAUDE_PLUGIN_ROOT/dist/handler.js\" --event PostToolUseFailure --config \"$CLAUDE_PLUGIN_ROOT/defaults/claude-persona.json\""
|
|
37
|
+
}
|
|
38
|
+
]
|
|
39
|
+
}
|
|
40
|
+
],
|
|
41
|
+
"SessionStart": [
|
|
42
|
+
{
|
|
43
|
+
"matcher": "",
|
|
44
|
+
"hooks": [
|
|
45
|
+
{
|
|
46
|
+
"type": "command",
|
|
47
|
+
"command": "node \"$CLAUDE_PLUGIN_ROOT/dist/handler.js\" --event SessionStart --config \"$CLAUDE_PLUGIN_ROOT/defaults/claude-persona.json\""
|
|
48
|
+
}
|
|
49
|
+
]
|
|
50
|
+
}
|
|
51
|
+
],
|
|
52
|
+
"SessionEnd": [
|
|
53
|
+
{
|
|
54
|
+
"matcher": "",
|
|
55
|
+
"hooks": [
|
|
56
|
+
{
|
|
57
|
+
"type": "command",
|
|
58
|
+
"command": "node \"$CLAUDE_PLUGIN_ROOT/dist/handler.js\" --event SessionEnd --config \"$CLAUDE_PLUGIN_ROOT/defaults/claude-persona.json\""
|
|
59
|
+
}
|
|
60
|
+
]
|
|
61
|
+
}
|
|
62
|
+
],
|
|
63
|
+
"Notification": [
|
|
64
|
+
{
|
|
65
|
+
"matcher": "",
|
|
66
|
+
"hooks": [
|
|
67
|
+
{
|
|
68
|
+
"type": "command",
|
|
69
|
+
"command": "node \"$CLAUDE_PLUGIN_ROOT/dist/handler.js\" --event Notification --config \"$CLAUDE_PLUGIN_ROOT/defaults/claude-persona.json\""
|
|
70
|
+
}
|
|
71
|
+
]
|
|
72
|
+
}
|
|
73
|
+
],
|
|
74
|
+
"SubagentStart": [
|
|
75
|
+
{
|
|
76
|
+
"matcher": "",
|
|
77
|
+
"hooks": [
|
|
78
|
+
{
|
|
79
|
+
"type": "command",
|
|
80
|
+
"command": "node \"$CLAUDE_PLUGIN_ROOT/dist/handler.js\" --event SubagentStart --config \"$CLAUDE_PLUGIN_ROOT/defaults/claude-persona.json\""
|
|
81
|
+
}
|
|
82
|
+
]
|
|
83
|
+
}
|
|
84
|
+
]
|
|
85
|
+
}
|
|
86
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "claude-persona",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Sound effects for Claude Code sessions — configurable audio feedback for hooks and events",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"bin": {
|
|
8
|
+
"claude-persona": "./dist/cli/index.js"
|
|
9
|
+
},
|
|
10
|
+
"main": "./dist/handler.js",
|
|
11
|
+
"files": [
|
|
12
|
+
"dist",
|
|
13
|
+
"sounds",
|
|
14
|
+
"defaults",
|
|
15
|
+
"hooks",
|
|
16
|
+
"plugin.json"
|
|
17
|
+
],
|
|
18
|
+
"scripts": {
|
|
19
|
+
"build": "tsc",
|
|
20
|
+
"prepublishOnly": "npm run build"
|
|
21
|
+
},
|
|
22
|
+
"dependencies": {
|
|
23
|
+
"commander": "^12.1.0",
|
|
24
|
+
"play-sound": "^1.1.6"
|
|
25
|
+
},
|
|
26
|
+
"devDependencies": {
|
|
27
|
+
"@types/node": "^22.0.0",
|
|
28
|
+
"typescript": "^5.7.0"
|
|
29
|
+
},
|
|
30
|
+
"engines": {
|
|
31
|
+
"node": ">=18"
|
|
32
|
+
},
|
|
33
|
+
"keywords": [
|
|
34
|
+
"claude",
|
|
35
|
+
"claude-code",
|
|
36
|
+
"hooks",
|
|
37
|
+
"sound",
|
|
38
|
+
"audio",
|
|
39
|
+
"persona",
|
|
40
|
+
"notifications"
|
|
41
|
+
],
|
|
42
|
+
"repository": {
|
|
43
|
+
"type": "git",
|
|
44
|
+
"url": "git+https://github.com/baunov/claude-persona.git"
|
|
45
|
+
}
|
|
46
|
+
}
|
package/plugin.json
ADDED
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/sounds/peasant//320/274/320/265/320/275/321/217 /321/203/320/261/321/214/321/216/321/202.wav
ADDED
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|