Skip to content

Commit 54f4609

Browse files
authored
Add Codex and OpenCode --install support (#38)
- install_codex(): writes notify config to ~/.codex/config.toml - install_opencode(): writes JS plugin to ~/.config/opencode/plugins/toasty.js - uninstall support for both agents - Auto-detect opencode process in parent chain - OpenCode preset (uses default Toasty icon) - 3 new tests (28 total, all passing) - Updated README agent support matrix Closes #24, Closes #20
1 parent 0100bf3 commit 54f4609

File tree

3 files changed

+279
-11
lines changed

3 files changed

+279
-11
lines changed

README.md

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -38,15 +38,13 @@ Options:
3838
| Claude Code |||| `Stop` | `~/.claude/settings.json` |
3939
| GitHub Copilot |||| `sessionEnd` | `.github/hooks/toasty.json` |
4040
| Gemini CLI |||| `AfterAgent` | `~/.gemini/settings.json` |
41-
| OpenAI Codex ||| | `notify` | `~/.codex/config.toml` |
42-
| OpenCode | | | | JS plugin | `~/.config/opencode/plugins/` |
41+
| OpenAI Codex ||| | `notify` | `~/.codex/config.toml` |
42+
| OpenCode | | | | JS plugin | `~/.config/opencode/plugins/` |
4343

44-
- **Icon**: Built-in icon for toast notifications
44+
- **Icon**: Built-in icon for toast notifications (⬜ = uses default Toasty icon)
4545
- **Auto-Detect**: Toasty recognizes the agent's process and applies the preset automatically
4646
- **`--install`**: `toasty --install` can automatically configure the agent's hook
4747

48-
Agents without `--install` support can still use toasty manually: `toasty "Task done" --app codex`
49-
5048
> Don't see your agent? Any CLI tool with a hook/notification mechanism can call toasty directly.
5149
5250
## Auto-Detection

main.cpp

Lines changed: 250 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,8 @@ const AppPreset APP_PRESETS[] = {
5757
{ L"copilot", L"GitHub Copilot", IDI_COPILOT },
5858
{ L"gemini", L"Gemini", IDI_GEMINI },
5959
{ L"codex", L"Codex", IDI_CODEX },
60-
{ L"cursor", L"Cursor", IDI_CURSOR }
60+
{ L"cursor", L"Cursor", IDI_CURSOR },
61+
{ L"opencode", L"OpenCode", IDI_TOASTY }
6162
};
6263

6364
// Extract embedded PNG resource to temp file and return path
@@ -199,6 +200,11 @@ const AppPreset* check_command_line_for_preset(const std::wstring& cmdLine) {
199200
return find_preset(L"cursor");
200201
}
201202

203+
// Check for OpenCode
204+
if (lowerCmd.find(L"opencode") != std::wstring::npos) {
205+
return find_preset(L"opencode");
206+
}
207+
202208
return nullptr;
203209
}
204210

@@ -298,7 +304,7 @@ void print_usage() {
298304
<< L" -i, --icon <path> Custom icon path (PNG recommended, 48x48px)\n"
299305
<< L" -v, --version Show version and exit\n"
300306
<< L" -h, --help Show this help\n"
301-
<< L" --install [agent] Install hooks for AI CLI agents (claude, gemini, copilot, or all)\n"
307+
<< L" --install [agent] Install hooks for AI CLI agents (claude, gemini, copilot, codex, opencode, or all)\n"
302308
<< L" --uninstall Remove hooks from all AI CLI agents\n"
303309
<< L" --status Show installation status\n"
304310
<< L" --register Re-register app for notifications (troubleshooting)\n"
@@ -876,6 +882,16 @@ bool detect_copilot() {
876882
return path_exists(L".github\\hooks") || path_exists(L".github");
877883
}
878884

885+
bool detect_codex() {
886+
std::wstring codexPath = expand_env(L"%USERPROFILE%\\.codex");
887+
return path_exists(codexPath);
888+
}
889+
890+
bool detect_opencode() {
891+
std::wstring opencodePath = expand_env(L"%USERPROFILE%\\.config\\opencode");
892+
return path_exists(opencodePath);
893+
}
894+
879895
// Read file content as string
880896
std::string read_file(const std::wstring& path) {
881897
std::ifstream file(path, std::ios::binary);
@@ -1154,6 +1170,116 @@ bool install_copilot(const std::wstring& exePath) {
11541170
return write_file(configPath, jsonStr);
11551171
}
11561172

1173+
// Install hook for OpenAI Codex CLI
1174+
// Codex uses TOML config at ~/.codex/config.toml with notify = "command"
1175+
bool install_codex(const std::wstring& exePath) {
1176+
std::wstring configDir = expand_env(L"%USERPROFILE%\\.codex");
1177+
std::wstring configPath = configDir + L"\\config.toml";
1178+
1179+
// Create .codex directory if needed
1180+
fs::create_directories(configDir);
1181+
1182+
std::string content = read_file(configPath);
1183+
1184+
// Escape backslashes for TOML string
1185+
std::string exePathUtf8;
1186+
{
1187+
int size = WideCharToMultiByte(CP_UTF8, 0, exePath.c_str(), -1, nullptr, 0, nullptr, nullptr);
1188+
if (size > 0) {
1189+
exePathUtf8.resize(size - 1);
1190+
WideCharToMultiByte(CP_UTF8, 0, exePath.c_str(), -1, &exePathUtf8[0], size, nullptr, nullptr);
1191+
}
1192+
}
1193+
// Double backslashes for TOML
1194+
std::string escapedPath;
1195+
for (char c : exePathUtf8) {
1196+
if (c == '\\') escapedPath += "\\\\";
1197+
else escapedPath += c;
1198+
}
1199+
1200+
std::string notifyLine = "notify = [\"" + escapedPath + "\", \"Codex finished\", \"-t\", \"Codex\"]\n";
1201+
1202+
if (content.empty()) {
1203+
// New file
1204+
return write_file(configPath, notifyLine);
1205+
}
1206+
1207+
// Check if notify is already set
1208+
if (content.find("toasty") != std::string::npos) {
1209+
return true; // Already installed
1210+
}
1211+
1212+
backup_file(configPath);
1213+
1214+
// Replace existing notify line or append
1215+
size_t notifyPos = content.find("notify");
1216+
if (notifyPos != std::string::npos) {
1217+
// Find end of line
1218+
size_t lineEnd = content.find('\n', notifyPos);
1219+
if (lineEnd == std::string::npos) lineEnd = content.size();
1220+
else lineEnd++; // include the newline
1221+
content.replace(notifyPos, lineEnd - notifyPos, notifyLine);
1222+
} else {
1223+
// Append
1224+
if (!content.empty() && content.back() != '\n') content += '\n';
1225+
content += notifyLine;
1226+
}
1227+
1228+
return write_file(configPath, content);
1229+
}
1230+
1231+
// Install plugin for OpenCode
1232+
// OpenCode uses JS plugins at ~/.config/opencode/plugins/
1233+
bool install_opencode(const std::wstring& exePath) {
1234+
std::wstring pluginsDir = expand_env(L"%USERPROFILE%\\.config\\opencode\\plugins");
1235+
std::wstring pluginPath = pluginsDir + L"\\toasty.js";
1236+
1237+
fs::create_directories(pluginsDir);
1238+
1239+
if (path_exists(pluginPath)) {
1240+
std::string content = read_file(pluginPath);
1241+
if (content.find("toasty") != std::string::npos) {
1242+
return true; // Already installed
1243+
}
1244+
}
1245+
1246+
// Convert exe path to JS-safe string (forward slashes)
1247+
std::string exePathUtf8;
1248+
{
1249+
int size = WideCharToMultiByte(CP_UTF8, 0, exePath.c_str(), -1, nullptr, 0, nullptr, nullptr);
1250+
if (size > 0) {
1251+
exePathUtf8.resize(size - 1);
1252+
WideCharToMultiByte(CP_UTF8, 0, exePath.c_str(), -1, &exePathUtf8[0], size, nullptr, nullptr);
1253+
}
1254+
}
1255+
// Escape backslashes for JS string
1256+
std::string jsPath;
1257+
for (char c : exePathUtf8) {
1258+
if (c == '\\') jsPath += "\\\\";
1259+
else jsPath += c;
1260+
}
1261+
1262+
std::string plugin =
1263+
"// Toasty notification plugin for OpenCode\n"
1264+
"const { execSync } = require('child_process');\n"
1265+
"\n"
1266+
"module.exports = {\n"
1267+
" name: 'toasty',\n"
1268+
" description: 'Toast notification when OpenCode finishes',\n"
1269+
" onComplete: () => {\n"
1270+
" try {\n"
1271+
" execSync('\"" + jsPath + "\" \"OpenCode finished\" -t \"OpenCode\"', { timeout: 5000 });\n"
1272+
" } catch (e) { /* ignore */ }\n"
1273+
" }\n"
1274+
"};\n";
1275+
1276+
if (path_exists(pluginPath)) {
1277+
backup_file(pluginPath);
1278+
}
1279+
1280+
return write_file(pluginPath, plugin);
1281+
}
1282+
11571283
// Check if Claude hook is installed
11581284
bool is_claude_installed() {
11591285
std::wstring configPath = expand_env(L"%USERPROFILE%\\.claude\\settings.json");
@@ -1371,24 +1497,87 @@ bool uninstall_copilot() {
13711497
return true;
13721498
}
13731499

1500+
// Uninstall hook for OpenAI Codex
1501+
bool uninstall_codex() {
1502+
std::wstring configPath = expand_env(L"%USERPROFILE%\\.codex\\config.toml");
1503+
std::string content = read_file(configPath);
1504+
1505+
if (content.empty()) return true;
1506+
1507+
if (content.find("toasty") == std::string::npos) return true;
1508+
1509+
backup_file(configPath);
1510+
1511+
// Remove the notify line containing toasty
1512+
std::string result;
1513+
std::istringstream stream(content);
1514+
std::string line;
1515+
while (std::getline(stream, line)) {
1516+
if (line.find("notify") != std::string::npos && line.find("toasty") != std::string::npos) {
1517+
continue; // Skip toasty notify line
1518+
}
1519+
result += line + '\n';
1520+
}
1521+
1522+
return write_file(configPath, result);
1523+
}
1524+
1525+
// Uninstall plugin for OpenCode
1526+
bool uninstall_opencode() {
1527+
std::wstring pluginPath = expand_env(L"%USERPROFILE%\\.config\\opencode\\plugins\\toasty.js");
1528+
1529+
try {
1530+
if (path_exists(pluginPath)) {
1531+
backup_file(pluginPath);
1532+
fs::remove(pluginPath);
1533+
}
1534+
} catch (const std::exception&) {
1535+
std::wcerr << L"Error uninstalling OpenCode plugin\n";
1536+
return false;
1537+
}
1538+
1539+
return true;
1540+
}
1541+
1542+
// Check if Codex hook is installed
1543+
bool is_codex_installed() {
1544+
std::wstring configPath = expand_env(L"%USERPROFILE%\\.codex\\config.toml");
1545+
std::string content = read_file(configPath);
1546+
return content.find("toasty") != std::string::npos;
1547+
}
1548+
1549+
// Check if OpenCode plugin is installed
1550+
bool is_opencode_installed() {
1551+
std::wstring pluginPath = expand_env(L"%USERPROFILE%\\.config\\opencode\\plugins\\toasty.js");
1552+
if (!path_exists(pluginPath)) return false;
1553+
std::string content = read_file(pluginPath);
1554+
return content.find("toasty") != std::string::npos;
1555+
}
1556+
13741557
// Show installation status
13751558
void show_status() {
13761559
std::wcout << L"Installation status:\n\n";
13771560

13781561
bool claudeDetected = detect_claude();
13791562
bool geminiDetected = detect_gemini();
13801563
bool copilotDetected = detect_copilot();
1564+
bool codexDetected = detect_codex();
1565+
bool opencodeDetected = detect_opencode();
13811566

13821567
std::wcout << L"Detected agents:\n";
13831568
std::wcout << L" " << (claudeDetected ? L"[x]" : L"[ ]") << L" Claude Code\n";
13841569
std::wcout << L" " << (geminiDetected ? L"[x]" : L"[ ]") << L" Gemini CLI\n";
13851570
std::wcout << L" " << (copilotDetected ? L"[x]" : L"[ ]") << L" GitHub Copilot (in current repo)\n";
1571+
std::wcout << L" " << (codexDetected ? L"[x]" : L"[ ]") << L" OpenAI Codex\n";
1572+
std::wcout << L" " << (opencodeDetected ? L"[x]" : L"[ ]") << L" OpenCode\n";
13861573
std::wcout << L"\n";
13871574

13881575
std::wcout << L"Installed hooks:\n";
13891576
std::wcout << L" " << (is_claude_installed() ? L"[x]" : L"[ ]") << L" Claude Code\n";
13901577
std::wcout << L" " << (is_gemini_installed() ? L"[x]" : L"[ ]") << L" Gemini CLI\n";
13911578
std::wcout << L" " << (is_copilot_installed() ? L"[x]" : L"[ ]") << L" GitHub Copilot\n";
1579+
std::wcout << L" " << (is_codex_installed() ? L"[x]" : L"[ ]") << L" OpenAI Codex\n";
1580+
std::wcout << L" " << (is_opencode_installed() ? L"[x]" : L"[ ]") << L" OpenCode\n";
13921581
}
13931582

13941583
// Handle --install command
@@ -1405,12 +1594,16 @@ void handle_install(const std::wstring& agent) {
14051594
bool installClaude = installAll || agent == L"claude";
14061595
bool installGemini = installAll || agent == L"gemini";
14071596
bool installCopilot = installAll || agent == L"copilot";
1597+
bool installCodex = installAll || agent == L"codex";
1598+
bool installOpencode = installAll || agent == L"opencode";
14081599

14091600
if (g_dryRun) {
14101601
std::wcout << L"[dry-run] Install targets:";
14111602
if (installClaude) std::wcout << L" claude";
14121603
if (installGemini) std::wcout << L" gemini";
14131604
if (installCopilot) std::wcout << L" copilot";
1605+
if (installCodex) std::wcout << L" codex";
1606+
if (installOpencode) std::wcout << L" opencode";
14141607
std::wcout << L"\n";
14151608

14161609
if (installClaude) {
@@ -1432,6 +1625,16 @@ void handle_install(const std::wstring& agent) {
14321625
std::wcout << L"[dry-run] Hook command: toasty 'Copilot finished' -t 'GitHub Copilot'\n";
14331626
std::wcout << L"[dry-run] Hook type: sessionEnd\n";
14341627
}
1628+
if (installCodex) {
1629+
std::wstring configPath = expand_env(L"%USERPROFILE%\\.codex\\config.toml");
1630+
std::wcout << L"[dry-run] Would write: " << configPath << L"\n";
1631+
std::wcout << L"[dry-run] Hook type: notify\n";
1632+
}
1633+
if (installOpencode) {
1634+
std::wstring pluginPath = expand_env(L"%USERPROFILE%\\.config\\opencode\\plugins\\toasty.js");
1635+
std::wcout << L"[dry-run] Would write: " << pluginPath << L"\n";
1636+
std::wcout << L"[dry-run] Hook type: JS plugin\n";
1637+
}
14351638
return;
14361639
}
14371640

@@ -1440,10 +1643,14 @@ void handle_install(const std::wstring& agent) {
14401643
bool claudeDetected = detect_claude();
14411644
bool geminiDetected = detect_gemini();
14421645
bool copilotDetected = detect_copilot();
1646+
bool codexDetected = detect_codex();
1647+
bool opencodeDetected = detect_opencode();
14431648

14441649
std::wcout << L" " << (claudeDetected ? L"[x]" : L"[ ]") << L" Claude Code found\n";
14451650
std::wcout << L" " << (geminiDetected ? L"[x]" : L"[ ]") << L" Gemini CLI found\n";
14461651
std::wcout << L" " << (copilotDetected ? L"[x]" : L"[ ]") << L" GitHub Copilot (in current repo)\n";
1652+
std::wcout << L" " << (codexDetected ? L"[x]" : L"[ ]") << L" OpenAI Codex found\n";
1653+
std::wcout << L" " << (opencodeDetected ? L"[x]" : L"[ ]") << L" OpenCode found\n";
14471654
std::wcout << L"\n";
14481655

14491656
std::wcout << L"Installing toasty hooks...\n";
@@ -1479,6 +1686,24 @@ void handle_install(const std::wstring& agent) {
14791686
}
14801687
}
14811688

1689+
if (installCodex && (codexDetected || explicitAgent)) {
1690+
if (install_codex(exePath)) {
1691+
std::wcout << L" [x] OpenAI Codex: Added notify hook\n";
1692+
anyInstalled = true;
1693+
} else {
1694+
std::wcout << L" [ ] OpenAI Codex: Failed to install\n";
1695+
}
1696+
}
1697+
1698+
if (installOpencode && (opencodeDetected || explicitAgent)) {
1699+
if (install_opencode(exePath)) {
1700+
std::wcout << L" [x] OpenCode: Added toasty.js plugin\n";
1701+
anyInstalled = true;
1702+
} else {
1703+
std::wcout << L" [ ] OpenCode: Failed to install\n";
1704+
}
1705+
}
1706+
14821707
if (anyInstalled) {
14831708
std::wcout << L"\nDone! You'll get notifications when AI agents finish.\n";
14841709
} else {
@@ -1492,9 +1717,13 @@ void handle_uninstall() {
14921717
std::wcout << L"[dry-run] Would check and remove hooks from:\n";
14931718
std::wstring claudePath = expand_env(L"%USERPROFILE%\\.claude\\settings.json");
14941719
std::wstring geminiPath = expand_env(L"%USERPROFILE%\\.gemini\\settings.json");
1720+
std::wstring codexPath = expand_env(L"%USERPROFILE%\\.codex\\config.toml");
1721+
std::wstring opencodePath = expand_env(L"%USERPROFILE%\\.config\\opencode\\plugins\\toasty.js");
14951722
std::wcout << L"[dry-run] Claude: " << claudePath << L"\n";
14961723
std::wcout << L"[dry-run] Gemini: " << geminiPath << L"\n";
14971724
std::wcout << L"[dry-run] Copilot: .github\\hooks\\toasty.json\n";
1725+
std::wcout << L"[dry-run] Codex: " << codexPath << L"\n";
1726+
std::wcout << L"[dry-run] OpenCode: " << opencodePath << L"\n";
14981727
return;
14991728
}
15001729

@@ -1529,6 +1758,24 @@ void handle_uninstall() {
15291758
}
15301759
}
15311760

1761+
if (is_codex_installed()) {
1762+
if (uninstall_codex()) {
1763+
std::wcout << L" [x] OpenAI Codex: Removed notify hook\n";
1764+
anyUninstalled = true;
1765+
} else {
1766+
std::wcout << L" [ ] OpenAI Codex: Failed to remove\n";
1767+
}
1768+
}
1769+
1770+
if (is_opencode_installed()) {
1771+
if (uninstall_opencode()) {
1772+
std::wcout << L" [x] OpenCode: Removed toasty.js plugin\n";
1773+
anyUninstalled = true;
1774+
} else {
1775+
std::wcout << L" [ ] OpenCode: Failed to remove\n";
1776+
}
1777+
}
1778+
15321779
if (anyUninstalled) {
15331780
std::wcout << L"\nDone! Hooks have been removed.\n";
15341781
} else {
@@ -1757,7 +2004,7 @@ int wmain(int argc, wchar_t* argv[]) {
17572004
explicitApp = true;
17582005
} else {
17592006
std::wcerr << L"Error: Unknown app preset '" << appName << L"'\n";
1760-
std::wcerr << L"Available presets: claude, copilot, gemini, codex, cursor\n";
2007+
std::wcerr << L"Available presets: claude, copilot, gemini, codex, cursor, opencode\n";
17612008
return 1;
17622009
}
17632010
} else {

0 commit comments

Comments
 (0)