Skip to content

Commit 1ca4daa

Browse files
sweetmantechclaude
andauthored
feat: add notifications command to CLI (#8)
* feat: add notifications command to CLI Adds `recoup notifications` command that wraps POST /api/notifications to send email notifications to the authenticated account's email address. Supports --subject, --text, --html, --cc (repeatable), --room-id, and --json flags. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * ci: add test workflow for pull requests Runs unit tests and build on all PRs targeting main. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 79de41b commit 1ca4daa

File tree

4 files changed

+208
-0
lines changed

4 files changed

+208
-0
lines changed

.github/workflows/test.yml

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
name: Test
2+
3+
on:
4+
pull_request:
5+
branches: [main]
6+
7+
jobs:
8+
test:
9+
runs-on: ubuntu-latest
10+
steps:
11+
- uses: actions/checkout@v4
12+
13+
- uses: pnpm/action-setup@v4
14+
with:
15+
version: 9
16+
17+
- uses: actions/setup-node@v4
18+
with:
19+
node-version: 22
20+
21+
- run: pnpm install --frozen-lockfile
22+
23+
- run: pnpm test
24+
25+
- run: pnpm build
Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2+
3+
vi.mock("../../src/client.js", () => ({
4+
get: vi.fn(),
5+
post: vi.fn(),
6+
}));
7+
8+
import { notificationsCommand } from "../../src/commands/notifications.js";
9+
import { post } from "../../src/client.js";
10+
11+
let logSpy: ReturnType<typeof vi.spyOn>;
12+
let errorSpy: ReturnType<typeof vi.spyOn>;
13+
let exitSpy: ReturnType<typeof vi.spyOn>;
14+
15+
beforeEach(() => {
16+
logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
17+
errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
18+
exitSpy = vi
19+
.spyOn(process, "exit")
20+
.mockImplementation(() => undefined as never);
21+
});
22+
23+
afterEach(() => {
24+
vi.restoreAllMocks();
25+
});
26+
27+
describe("notifications command", () => {
28+
it("sends notification with subject and text", async () => {
29+
vi.mocked(post).mockResolvedValue({
30+
success: true,
31+
message: "Email sent successfully.",
32+
id: "email-123",
33+
});
34+
35+
await notificationsCommand.parseAsync(
36+
["--subject", "Test Subject", "--text", "Hello world"],
37+
{ from: "user" },
38+
);
39+
40+
expect(post).toHaveBeenCalledWith("/api/notifications", {
41+
subject: "Test Subject",
42+
text: "Hello world",
43+
});
44+
expect(logSpy).toHaveBeenCalledWith("Email sent successfully.");
45+
});
46+
47+
it("sends notification with html body", async () => {
48+
vi.mocked(post).mockResolvedValue({
49+
success: true,
50+
message: "Email sent successfully.",
51+
id: "email-456",
52+
});
53+
54+
await notificationsCommand.parseAsync(
55+
["--subject", "Weekly Pulse", "--html", "<h1>Report</h1>"],
56+
{ from: "user" },
57+
);
58+
59+
expect(post).toHaveBeenCalledWith("/api/notifications", {
60+
subject: "Weekly Pulse",
61+
html: "<h1>Report</h1>",
62+
});
63+
});
64+
65+
it("passes cc and room-id options", async () => {
66+
vi.mocked(post).mockResolvedValue({
67+
success: true,
68+
message: "Email sent successfully.",
69+
id: "email-789",
70+
});
71+
72+
await notificationsCommand.parseAsync(
73+
[
74+
"--subject",
75+
"Update",
76+
"--text",
77+
"Hello",
78+
"--cc",
79+
"cc@example.com",
80+
"--room-id",
81+
"room-abc",
82+
],
83+
{ from: "user" },
84+
);
85+
86+
expect(post).toHaveBeenCalledWith("/api/notifications", {
87+
subject: "Update",
88+
text: "Hello",
89+
cc: ["cc@example.com"],
90+
room_id: "room-abc",
91+
});
92+
});
93+
94+
it("supports multiple cc recipients", async () => {
95+
vi.mocked(post).mockResolvedValue({
96+
success: true,
97+
message: "Email sent successfully.",
98+
id: "email-multi",
99+
});
100+
101+
await notificationsCommand.parseAsync(
102+
[
103+
"--subject",
104+
"Update",
105+
"--cc",
106+
"a@example.com",
107+
"--cc",
108+
"b@example.com",
109+
],
110+
{ from: "user" },
111+
);
112+
113+
expect(post).toHaveBeenCalledWith("/api/notifications", {
114+
subject: "Update",
115+
cc: ["a@example.com", "b@example.com"],
116+
});
117+
});
118+
119+
it("prints JSON with --json flag", async () => {
120+
const response = {
121+
success: true,
122+
message: "Email sent successfully.",
123+
id: "email-123",
124+
};
125+
vi.mocked(post).mockResolvedValue(response);
126+
127+
await notificationsCommand.parseAsync(
128+
["--subject", "Test", "--json"],
129+
{ from: "user" },
130+
);
131+
132+
expect(logSpy).toHaveBeenCalledWith(
133+
JSON.stringify(response, null, 2),
134+
);
135+
});
136+
137+
it("prints error on failure", async () => {
138+
vi.mocked(post).mockRejectedValue(new Error("No email address found"));
139+
140+
await notificationsCommand.parseAsync(
141+
["--subject", "Test"],
142+
{ from: "user" },
143+
);
144+
145+
expect(errorSpy).toHaveBeenCalledWith("Error: No email address found");
146+
expect(exitSpy).toHaveBeenCalledWith(1);
147+
});
148+
});

src/bin.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { whoamiCommand } from "./commands/whoami.js";
33
import { artistsCommand } from "./commands/artists.js";
44
import { chatsCommand } from "./commands/chats.js";
55
import { sandboxesCommand } from "./commands/sandboxes.js";
6+
import { notificationsCommand } from "./commands/notifications.js";
67
import { orgsCommand } from "./commands/orgs.js";
78

89
const program = new Command();
@@ -15,6 +16,7 @@ program
1516
program.addCommand(whoamiCommand);
1617
program.addCommand(artistsCommand);
1718
program.addCommand(chatsCommand);
19+
program.addCommand(notificationsCommand);
1820
program.addCommand(sandboxesCommand);
1921
program.addCommand(orgsCommand);
2022

src/commands/notifications.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { Command } from "commander";
2+
import { post } from "../client.js";
3+
import { printJson, printError } from "../output.js";
4+
5+
export const notificationsCommand = new Command("notifications")
6+
.description("Send a notification email to the authenticated account")
7+
.requiredOption("--subject <text>", "Email subject line")
8+
.option("--text <body>", "Plain text or Markdown body")
9+
.option("--html <body>", "Raw HTML body (takes precedence over --text)")
10+
.option("--cc <email>", "CC recipient (repeatable)", (val: string, prev: string[]) => prev.concat(val), [] as string[])
11+
.option("--room-id <id>", "Room ID for chat link in footer")
12+
.option("--json", "Output as JSON")
13+
.action(async (opts) => {
14+
try {
15+
const body: Record<string, unknown> = {
16+
subject: opts.subject,
17+
};
18+
if (opts.text) body.text = opts.text;
19+
if (opts.html) body.html = opts.html;
20+
if (opts.cc && opts.cc.length > 0) body.cc = opts.cc;
21+
if (opts.roomId) body.room_id = opts.roomId;
22+
23+
const data = await post("/api/notifications", body);
24+
25+
if (opts.json) {
26+
printJson(data);
27+
} else {
28+
console.log(data.message || "Notification sent.");
29+
}
30+
} catch (err) {
31+
printError((err as Error).message);
32+
}
33+
});

0 commit comments

Comments
 (0)