ai-notify 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/LICENSE +21 -0
- package/README.md +83 -0
- package/menubar/AiNotifyMenuBar.swift +122 -0
- package/menubar/README.md +58 -0
- package/menubar/build.sh +56 -0
- package/menubar/dist/ai-notify.app/Contents/Info.plist +16 -0
- package/menubar/dist/ai-notify.app/Contents/MacOS/ai-notify-menubar +0 -0
- package/menubar/dist/ai-notify.app/Contents/_CodeSignature/CodeResources +115 -0
- package/package.json +52 -0
- package/recipes/claude-statusline/README.md +30 -0
- package/recipes/hammerspoon/ai-notify.lua +69 -0
- package/recipes/macos-shortcut/README.md +23 -0
- package/recipes/raycast/ai-notify-toggle.sh +15 -0
- package/recipes/swiftbar/ai-notify.3s.sh +23 -0
- package/recipes/tmux/README.md +37 -0
- package/src/cli.mjs +311 -0
- package/src/menubar.mjs +97 -0
- package/src/notify.mjs +133 -0
- package/src/providers/claude.mjs +92 -0
- package/src/providers/codex.mjs +86 -0
- package/src/providers/gemini.mjs +30 -0
- package/src/providers/index.mjs +10 -0
- package/src/state.mjs +102 -0
- package/src/translate.mjs +46 -0
- package/src/util.mjs +41 -0
- package/src/voices.mjs +69 -0
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// Provider: Claude Code (Anthropic).
|
|
2
|
+
//
|
|
3
|
+
// Wires two hooks in ~/.claude/settings.json:
|
|
4
|
+
// - Notification -> "waiting" (Claude is asking for input/permission)
|
|
5
|
+
// - Stop -> "done" (Claude finished its turn)
|
|
6
|
+
// Existing hooks are preserved; we only add/remove our own entries.
|
|
7
|
+
|
|
8
|
+
import { homedir } from 'node:os';
|
|
9
|
+
import { join } from 'node:path';
|
|
10
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
|
11
|
+
import { MARKER } from '../util.mjs';
|
|
12
|
+
|
|
13
|
+
const settingsPath = () => join(homedir(), '.claude', 'settings.json');
|
|
14
|
+
|
|
15
|
+
export const id = 'claude';
|
|
16
|
+
export const displayName = 'Claude Code';
|
|
17
|
+
|
|
18
|
+
export const detect = () => existsSync(join(homedir(), '.claude'));
|
|
19
|
+
|
|
20
|
+
const load = () => {
|
|
21
|
+
try {
|
|
22
|
+
return JSON.parse(readFileSync(settingsPath(), 'utf8'));
|
|
23
|
+
} catch {
|
|
24
|
+
return {};
|
|
25
|
+
}
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const save = (data) => {
|
|
29
|
+
const dir = join(homedir(), '.claude');
|
|
30
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
31
|
+
writeFileSync(settingsPath(), JSON.stringify(data, null, 2) + '\n');
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const isOurs = (cmd) => typeof cmd === 'string' && cmd.includes(MARKER) && cmd.includes(' hook ');
|
|
35
|
+
|
|
36
|
+
const entry = (node, cliPath, event) => ({
|
|
37
|
+
matcher: '',
|
|
38
|
+
hooks: [
|
|
39
|
+
{
|
|
40
|
+
type: 'command',
|
|
41
|
+
command: `${node} ${cliPath} hook --source claude --event ${event}`,
|
|
42
|
+
async: true,
|
|
43
|
+
},
|
|
44
|
+
],
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
const EVENTS = { Notification: 'waiting', Stop: 'done' };
|
|
48
|
+
|
|
49
|
+
export const status = () => {
|
|
50
|
+
if (!detect()) return { installed: false, wired: false };
|
|
51
|
+
const data = load();
|
|
52
|
+
const hooks = data.hooks || {};
|
|
53
|
+
const wired = Object.keys(EVENTS).every((k) =>
|
|
54
|
+
(hooks[k] || []).some((g) => (g.hooks || []).some((h) => isOurs(h.command)))
|
|
55
|
+
);
|
|
56
|
+
return { installed: true, wired };
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
export const wire = ({ node, cliPath, dryRun }) => {
|
|
60
|
+
const data = load();
|
|
61
|
+
data.hooks = data.hooks || {};
|
|
62
|
+
const changes = [];
|
|
63
|
+
for (const [event, kind] of Object.entries(EVENTS)) {
|
|
64
|
+
data.hooks[event] = data.hooks[event] || [];
|
|
65
|
+
const already = data.hooks[event].some((g) => (g.hooks || []).some((h) => isOurs(h.command)));
|
|
66
|
+
if (!already) {
|
|
67
|
+
if (!dryRun) data.hooks[event].push(entry(node, cliPath, kind));
|
|
68
|
+
changes.push(`${event} -> ${kind}`);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
if (!dryRun && changes.length) save(data);
|
|
72
|
+
return { changed: changes.length > 0, detail: changes.length ? changes.join(', ') : 'already wired', file: settingsPath() };
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
export const unwire = ({ dryRun } = {}) => {
|
|
76
|
+
const data = load();
|
|
77
|
+
if (!data.hooks) return { changed: false, detail: 'nothing to remove' };
|
|
78
|
+
let removed = 0;
|
|
79
|
+
for (const event of Object.keys(EVENTS)) {
|
|
80
|
+
const groups = data.hooks[event];
|
|
81
|
+
if (!Array.isArray(groups)) continue;
|
|
82
|
+
for (const g of groups) {
|
|
83
|
+
const before = (g.hooks || []).length;
|
|
84
|
+
g.hooks = (g.hooks || []).filter((h) => !isOurs(h.command));
|
|
85
|
+
removed += before - g.hooks.length;
|
|
86
|
+
}
|
|
87
|
+
data.hooks[event] = groups.filter((g) => (g.hooks || []).length > 0);
|
|
88
|
+
if (data.hooks[event].length === 0) delete data.hooks[event];
|
|
89
|
+
}
|
|
90
|
+
if (!dryRun && removed) save(data);
|
|
91
|
+
return { changed: removed > 0, detail: removed ? `removed ${removed} hook(s)` : 'nothing to remove', file: settingsPath() };
|
|
92
|
+
};
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// Provider: Codex CLI (OpenAI).
|
|
2
|
+
//
|
|
3
|
+
// Codex calls a `notify` program with a single JSON argument (event
|
|
4
|
+
// `agent-turn-complete`). We set the root `notify` key in ~/.codex/config.toml.
|
|
5
|
+
//
|
|
6
|
+
// TOML constraint: root keys must appear before any [table]. We insert our line
|
|
7
|
+
// just before the first table. We never clobber a user's pre-existing notify
|
|
8
|
+
// program — if one exists that isn't ours, we warn and skip.
|
|
9
|
+
|
|
10
|
+
import { homedir } from 'node:os';
|
|
11
|
+
import { join } from 'node:path';
|
|
12
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
13
|
+
import { MARKER } from '../util.mjs';
|
|
14
|
+
|
|
15
|
+
const configPath = () => join(homedir(), '.codex', 'config.toml');
|
|
16
|
+
|
|
17
|
+
export const id = 'codex';
|
|
18
|
+
export const displayName = 'Codex CLI';
|
|
19
|
+
|
|
20
|
+
export const detect = () => existsSync(join(homedir(), '.codex'));
|
|
21
|
+
|
|
22
|
+
const read = () => {
|
|
23
|
+
try {
|
|
24
|
+
return readFileSync(configPath(), 'utf8');
|
|
25
|
+
} catch {
|
|
26
|
+
return '';
|
|
27
|
+
}
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const ourLine = (node, cliPath) =>
|
|
31
|
+
`notify = ["${node}", "${cliPath}", "hook", "--source", "codex"]`;
|
|
32
|
+
|
|
33
|
+
const COMMENT = '# ai-notify: desktop/sound notification on agent-turn-complete';
|
|
34
|
+
|
|
35
|
+
const findNotify = (text) => text.match(/^notify\s*=.*$/m);
|
|
36
|
+
|
|
37
|
+
export const status = () => {
|
|
38
|
+
if (!detect()) return { installed: false, wired: false };
|
|
39
|
+
const m = findNotify(read());
|
|
40
|
+
return { installed: true, wired: !!(m && m[0].includes(MARKER)) };
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export const wire = ({ node, cliPath, dryRun }) => {
|
|
44
|
+
let text = read();
|
|
45
|
+
const existing = findNotify(text);
|
|
46
|
+
|
|
47
|
+
if (existing && !existing[0].includes(MARKER)) {
|
|
48
|
+
return {
|
|
49
|
+
changed: false,
|
|
50
|
+
skipped: true,
|
|
51
|
+
detail: 'a custom `notify` already exists in config.toml — not overwriting it',
|
|
52
|
+
file: configPath(),
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const line = ourLine(node, cliPath);
|
|
57
|
+
|
|
58
|
+
if (existing) {
|
|
59
|
+
if (existing[0] === line) return { changed: false, detail: 'already wired', file: configPath() };
|
|
60
|
+
text = text.replace(/^notify\s*=.*$/m, line);
|
|
61
|
+
} else {
|
|
62
|
+
const block = `${COMMENT}\n${line}\n`;
|
|
63
|
+
const tableIdx = text.search(/^\s*\[/m); // first [table]
|
|
64
|
+
if (tableIdx === -1) {
|
|
65
|
+
text = (text ? text.replace(/\n*$/, '\n') : '') + block;
|
|
66
|
+
} else {
|
|
67
|
+
text = text.slice(0, tableIdx) + block + '\n' + text.slice(tableIdx);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (!dryRun) writeFileSync(configPath(), text);
|
|
72
|
+
return { changed: true, detail: 'set `notify`', file: configPath() };
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
export const unwire = ({ dryRun } = {}) => {
|
|
76
|
+
let text = read();
|
|
77
|
+
const existing = findNotify(text);
|
|
78
|
+
if (!existing || !existing[0].includes(MARKER)) {
|
|
79
|
+
return { changed: false, detail: 'nothing to remove', file: configPath() };
|
|
80
|
+
}
|
|
81
|
+
text = text
|
|
82
|
+
.replace(new RegExp(`^${COMMENT.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\n`, 'm'), '')
|
|
83
|
+
.replace(/^notify\s*=.*\n?/m, '');
|
|
84
|
+
if (!dryRun) writeFileSync(configPath(), text);
|
|
85
|
+
return { changed: true, detail: 'removed `notify`', file: configPath() };
|
|
86
|
+
};
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// Provider: Gemini CLI (Google) — contributor stub.
|
|
2
|
+
//
|
|
3
|
+
// Gemini CLI is detected here, but a stable "turn complete" hook/notify
|
|
4
|
+
// mechanism is not yet wired. This file is the on-ramp for contributors: see
|
|
5
|
+
// claude.mjs / codex.mjs for the shape, implement detect/wire/unwire, and open
|
|
6
|
+
// a PR. Until then `wire()` is a no-op that explains the situation.
|
|
7
|
+
//
|
|
8
|
+
// The same applies to adding any other agent (aider, opencode, amp, ...):
|
|
9
|
+
// drop a new file in src/providers/ exporting the same interface and register
|
|
10
|
+
// it in src/providers/index.mjs.
|
|
11
|
+
|
|
12
|
+
import { homedir } from 'node:os';
|
|
13
|
+
import { join } from 'node:path';
|
|
14
|
+
import { existsSync } from 'node:fs';
|
|
15
|
+
|
|
16
|
+
export const id = 'gemini';
|
|
17
|
+
export const displayName = 'Gemini CLI';
|
|
18
|
+
export const experimental = true;
|
|
19
|
+
|
|
20
|
+
export const detect = () => existsSync(join(homedir(), '.gemini'));
|
|
21
|
+
|
|
22
|
+
export const status = () => ({ installed: detect(), wired: false });
|
|
23
|
+
|
|
24
|
+
export const wire = () => ({
|
|
25
|
+
changed: false,
|
|
26
|
+
skipped: true,
|
|
27
|
+
detail: 'detected, but no supported notification hook yet — contributions welcome',
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
export const unwire = () => ({ changed: false, detail: 'nothing to remove' });
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// Provider registry. To add a new agent, implement a provider module with the
|
|
2
|
+
// shape { id, displayName, detect, status, wire, unwire } and add it here.
|
|
3
|
+
|
|
4
|
+
import * as claude from './claude.mjs';
|
|
5
|
+
import * as codex from './codex.mjs';
|
|
6
|
+
import * as gemini from './gemini.mjs';
|
|
7
|
+
|
|
8
|
+
export const providers = [claude, codex, gemini];
|
|
9
|
+
|
|
10
|
+
export const byId = (id) => providers.find((p) => p.id === id);
|
package/src/state.mjs
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// Shared state for ai-notify: the mute flag and user config.
|
|
2
|
+
//
|
|
3
|
+
// Everything lives under XDG paths so that ALL wired agents (Claude Code, Codex,
|
|
4
|
+
// Gemini, ...) read the same single source of truth. Flip the flag once and every
|
|
5
|
+
// agent in every terminal obeys it — no daemon, no per-terminal action.
|
|
6
|
+
|
|
7
|
+
import { homedir } from 'node:os';
|
|
8
|
+
import { join } from 'node:path';
|
|
9
|
+
import {
|
|
10
|
+
mkdirSync,
|
|
11
|
+
existsSync,
|
|
12
|
+
writeFileSync,
|
|
13
|
+
readFileSync,
|
|
14
|
+
rmSync,
|
|
15
|
+
} from 'node:fs';
|
|
16
|
+
|
|
17
|
+
const APP = 'ai-notify';
|
|
18
|
+
|
|
19
|
+
const xdg = (envVar, fallback) =>
|
|
20
|
+
join(process.env[envVar] || join(homedir(), fallback), APP);
|
|
21
|
+
|
|
22
|
+
export const stateDir = () => xdg('XDG_STATE_HOME', '.local/state');
|
|
23
|
+
export const configDir = () => xdg('XDG_CONFIG_HOME', '.config');
|
|
24
|
+
|
|
25
|
+
const muteFlagPath = () => join(stateDir(), 'muted');
|
|
26
|
+
const configPath = () => join(configDir(), 'config.json');
|
|
27
|
+
|
|
28
|
+
const ensureDir = (dir) => {
|
|
29
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
// --- Mute flag -------------------------------------------------------------
|
|
33
|
+
|
|
34
|
+
export const isMuted = () => existsSync(muteFlagPath());
|
|
35
|
+
|
|
36
|
+
export const setMuted = (muted) => {
|
|
37
|
+
if (muted) {
|
|
38
|
+
ensureDir(stateDir());
|
|
39
|
+
writeFileSync(muteFlagPath(), '');
|
|
40
|
+
} else if (existsSync(muteFlagPath())) {
|
|
41
|
+
rmSync(muteFlagPath());
|
|
42
|
+
}
|
|
43
|
+
return muted;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
export const toggleMuted = () => setMuted(!isMuted());
|
|
47
|
+
|
|
48
|
+
// --- Config ----------------------------------------------------------------
|
|
49
|
+
|
|
50
|
+
// Sounds default to OS built-ins so we ship no audio assets (clean repo, no
|
|
51
|
+
// licensing). Users can override any of this in config.json.
|
|
52
|
+
export const DEFAULT_CONFIG = {
|
|
53
|
+
// Keep the desktop banner even while muted, so you still notice when you
|
|
54
|
+
// come back to your desk during a meeting.
|
|
55
|
+
bannerWhenMuted: true,
|
|
56
|
+
// Spoken read-out of which terminal finished (helps tell tabs apart).
|
|
57
|
+
speak: true,
|
|
58
|
+
// Whether to speak the agent's own text (Codex's reply, a Claude prompt).
|
|
59
|
+
// That text is in the agent's language — set this false to keep every spoken
|
|
60
|
+
// read-out in your own language via doneMessage / waitingMessage instead.
|
|
61
|
+
speakAgentMessage: true,
|
|
62
|
+
// Optional: translate the agent's message into this language before speaking
|
|
63
|
+
// it (e.g. 'ja'). Empty = off. Key-less, no cost; makes a network request.
|
|
64
|
+
// Toggle with `ai-notify translate on ja` / `off`.
|
|
65
|
+
translateTo: '',
|
|
66
|
+
// Spoken confirmation when you un-mute. Override per language/voice — e.g. a
|
|
67
|
+
// Japanese TTS voice reads the English word more naturally in katakana.
|
|
68
|
+
onMessage: 'notifications on',
|
|
69
|
+
// Global TTS voice for the spoken read-out (macOS `say` voice name, e.g.
|
|
70
|
+
// 'Kyoko'). Empty = OS default voice. Switch it with `ai-notify voice`. A
|
|
71
|
+
// per-provider `voice` below, if set, overrides this for that agent.
|
|
72
|
+
voice: '',
|
|
73
|
+
// Spoken read-out templates for agent events. `{label}` is the working-dir
|
|
74
|
+
// name. Override per language (e.g. Japanese) in config.json. An agent that
|
|
75
|
+
// supplies its own message (Codex's last reply, a Claude prompt) wins over
|
|
76
|
+
// these.
|
|
77
|
+
doneMessage: '{label} finished',
|
|
78
|
+
waitingMessage: '{label} is waiting for input',
|
|
79
|
+
providers: {
|
|
80
|
+
claude: { sound: { waiting: 'Glass', done: 'Hero' }, voice: '' },
|
|
81
|
+
codex: { sound: { done: 'Submarine' }, voice: '' },
|
|
82
|
+
gemini: { sound: { done: 'Ping' }, voice: '' },
|
|
83
|
+
default: { sound: { waiting: 'Glass', done: 'Hero' }, voice: '' },
|
|
84
|
+
},
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
export const readConfig = () => {
|
|
88
|
+
try {
|
|
89
|
+
const raw = JSON.parse(readFileSync(configPath(), 'utf8'));
|
|
90
|
+
return { ...DEFAULT_CONFIG, ...raw, providers: { ...DEFAULT_CONFIG.providers, ...(raw.providers || {}) } };
|
|
91
|
+
} catch {
|
|
92
|
+
return DEFAULT_CONFIG;
|
|
93
|
+
}
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
export const writeConfig = (config) => {
|
|
97
|
+
ensureDir(configDir());
|
|
98
|
+
writeFileSync(configPath(), JSON.stringify(config, null, 2) + '\n');
|
|
99
|
+
return configPath();
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
export const paths = { muteFlagPath, configPath, stateDir, configDir };
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// Optional translation of an agent's spoken message into the user's language.
|
|
2
|
+
//
|
|
3
|
+
// The popular key-less translation npm packages (google-translate-api-x,
|
|
4
|
+
// @vitalets/google-translate-api, bing-translate-api, ...) all boil down to one
|
|
5
|
+
// HTTP GET against a public, key-less translate endpoint plus a little array
|
|
6
|
+
// parsing. Rather than take a dependency, we reimplement that in a few lines
|
|
7
|
+
// with `curl` — keeping the package dependency-free, with no API key and no
|
|
8
|
+
// cost. (It does make a network request; offline use falls back to templates.)
|
|
9
|
+
//
|
|
10
|
+
// Best-effort: any failure returns null and the caller falls back to a
|
|
11
|
+
// localized template, so notifications never break or hang.
|
|
12
|
+
|
|
13
|
+
import { execFileSync } from 'node:child_process';
|
|
14
|
+
|
|
15
|
+
const ENDPOINT = 'https://translate.googleapis.com/translate_a/single';
|
|
16
|
+
|
|
17
|
+
// Translate `text` into `to` (BCP-47-ish, e.g. 'ja'). Source auto-detected.
|
|
18
|
+
export const translate = (text, to = 'ja', timeoutMs = 4000) => {
|
|
19
|
+
if (!text || !to) return null;
|
|
20
|
+
try {
|
|
21
|
+
const out = execFileSync(
|
|
22
|
+
'curl',
|
|
23
|
+
[
|
|
24
|
+
'-s',
|
|
25
|
+
'--max-time', String(Math.max(1, Math.ceil(timeoutMs / 1000))),
|
|
26
|
+
'-G', ENDPOINT,
|
|
27
|
+
'--data-urlencode', `q=${text}`,
|
|
28
|
+
'-d', 'client=gtx',
|
|
29
|
+
'-d', 'sl=auto',
|
|
30
|
+
'-d', `tl=${to}`,
|
|
31
|
+
'-d', 'dt=t',
|
|
32
|
+
],
|
|
33
|
+
{ timeout: timeoutMs + 1000, encoding: 'utf8', maxBuffer: 1 << 20, stdio: ['ignore', 'pipe', 'ignore'] }
|
|
34
|
+
);
|
|
35
|
+
const data = JSON.parse(out);
|
|
36
|
+
// Shape: [ [ [translatedChunk, originalChunk, ...], ... ], ..., srcLang ]
|
|
37
|
+
const segments = Array.isArray(data) && Array.isArray(data[0]) ? data[0] : [];
|
|
38
|
+
const result = segments
|
|
39
|
+
.map((s) => (s && typeof s[0] === 'string' ? s[0] : ''))
|
|
40
|
+
.join('')
|
|
41
|
+
.trim();
|
|
42
|
+
return result || null;
|
|
43
|
+
} catch {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
};
|
package/src/util.mjs
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// Small shared helpers (no third-party deps).
|
|
2
|
+
|
|
3
|
+
import { execFileSync } from 'node:child_process';
|
|
4
|
+
import { basename } from 'node:path';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
|
|
7
|
+
// A human label for the terminal/tab, in priority order:
|
|
8
|
+
// 1. $AI_NOTIFY_LABEL (set it per tab to name your work)
|
|
9
|
+
// 2. git branch of the working dir
|
|
10
|
+
// 3. the directory name
|
|
11
|
+
export const deriveLabel = (cwd) => {
|
|
12
|
+
if (process.env.AI_NOTIFY_LABEL) return process.env.AI_NOTIFY_LABEL;
|
|
13
|
+
if (cwd) {
|
|
14
|
+
try {
|
|
15
|
+
const branch = execFileSync('git', ['-C', cwd, 'rev-parse', '--abbrev-ref', 'HEAD'], {
|
|
16
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
17
|
+
})
|
|
18
|
+
.toString()
|
|
19
|
+
.trim();
|
|
20
|
+
if (branch && branch !== 'HEAD') return `${basename(cwd)}/${branch}`;
|
|
21
|
+
} catch {
|
|
22
|
+
/* not a git repo */
|
|
23
|
+
}
|
|
24
|
+
return basename(cwd);
|
|
25
|
+
}
|
|
26
|
+
return 'somewhere';
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
// Absolute path to this package's CLI, and the node that should run it.
|
|
30
|
+
// These get embedded into each agent's hook config so the hooks work
|
|
31
|
+
// regardless of the shell's PATH at fire time.
|
|
32
|
+
export const cliInvocation = () => ({
|
|
33
|
+
node: process.execPath,
|
|
34
|
+
cliPath: fileURLToPath(new URL('./cli.mjs', import.meta.url)),
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
// Hooks need a persistent install. `npx` runs from an ephemeral cache that can
|
|
38
|
+
// be garbage-collected, which would break the wiring later.
|
|
39
|
+
export const isEphemeralInstall = (cliPath) => /[/\\]_npx[/\\]/.test(cliPath);
|
|
40
|
+
|
|
41
|
+
export const MARKER = 'ai-notify'; // substring used to detect our own wiring
|
package/src/voices.mjs
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// Voice discovery & preview for the spoken read-out.
|
|
2
|
+
//
|
|
3
|
+
// macOS only: we shell out to the built-in `say` command, which ships a large
|
|
4
|
+
// set of offline system voices. No network, no API, no cost — switching voices
|
|
5
|
+
// is free. On other platforms this returns an empty list and the `voice`
|
|
6
|
+
// command degrades to "set any name you like by hand".
|
|
7
|
+
|
|
8
|
+
import { execFileSync } from 'node:child_process';
|
|
9
|
+
|
|
10
|
+
const isMac = process.platform === 'darwin';
|
|
11
|
+
|
|
12
|
+
// Curated, ordered shortlist of distinct, good-quality built-in voices. We only
|
|
13
|
+
// show the ones actually installed, then pad up to `limit` from the rest so the
|
|
14
|
+
// menu is always ~10 even on a trimmed-down system.
|
|
15
|
+
const PRESET = [
|
|
16
|
+
'Kyoko', 'Eddy', 'Flo', 'Reed', 'Rocko', 'Sandy', 'Shelley',
|
|
17
|
+
'Grandpa', 'Grandma', 'Otoya', 'Samantha', 'Alex', 'Daniel', 'Karen',
|
|
18
|
+
];
|
|
19
|
+
|
|
20
|
+
// Unique base voice names installed on this machine. Multilingual voices are
|
|
21
|
+
// listed once per language by `say -v ?` (e.g. "Eddy (日本語(日本))"); we strip
|
|
22
|
+
// the "(language)" suffix and de-duplicate, since `say -v Eddy` works directly.
|
|
23
|
+
export const installedVoiceNames = () => {
|
|
24
|
+
if (!isMac) return [];
|
|
25
|
+
let out = '';
|
|
26
|
+
try {
|
|
27
|
+
out = execFileSync('say', ['-v', '?'], { encoding: 'utf8' });
|
|
28
|
+
} catch {
|
|
29
|
+
return [];
|
|
30
|
+
}
|
|
31
|
+
const names = [];
|
|
32
|
+
for (const line of out.split('\n')) {
|
|
33
|
+
const m = line.match(/^(.+?)\s{2,}/); // name column = text before 2+ spaces
|
|
34
|
+
if (!m) continue;
|
|
35
|
+
const name = m[1].replace(/\s*\(.*\)\s*$/, '').trim(); // drop "(language)" suffix
|
|
36
|
+
if (name && !names.includes(name)) names.push(name);
|
|
37
|
+
}
|
|
38
|
+
return names;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
// The ~10 options shown by `ai-notify voice`.
|
|
42
|
+
export const curatedVoices = (limit = 10) => {
|
|
43
|
+
const installed = installedVoiceNames();
|
|
44
|
+
const have = new Set(installed);
|
|
45
|
+
const picked = PRESET.filter((n) => have.has(n));
|
|
46
|
+
for (const n of installed) {
|
|
47
|
+
if (picked.length >= limit) break;
|
|
48
|
+
if (!picked.includes(n)) picked.push(n);
|
|
49
|
+
}
|
|
50
|
+
return picked.slice(0, limit);
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
// Resolve a user argument (1-based number, or a name) to an installed voice.
|
|
54
|
+
export const resolveVoice = (arg, list) => {
|
|
55
|
+
if (!arg) return null;
|
|
56
|
+
if (/^\d+$/.test(arg)) return list[Number(arg) - 1] || null;
|
|
57
|
+
const all = installedVoiceNames();
|
|
58
|
+
return all.find((n) => n.toLowerCase() === String(arg).toLowerCase()) || null;
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
// Speak a sample synchronously so previews play one after another, in order.
|
|
62
|
+
export const previewVoice = (name, text) => {
|
|
63
|
+
if (!isMac || !name || !text) return;
|
|
64
|
+
try {
|
|
65
|
+
execFileSync('say', ['-v', name, text], { stdio: 'ignore' });
|
|
66
|
+
} catch {
|
|
67
|
+
/* voice missing / say unavailable — ignore */
|
|
68
|
+
}
|
|
69
|
+
};
|