-
Notifications
You must be signed in to change notification settings - Fork 7
add inbox-watch plugin #363
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,86 @@ | ||
| const fs = require("fs") | ||
| const path = require("path") | ||
| const { runNoServer, makePluginEnv, withInstalledPlugin } = require("./helpers/plugin-test-env") | ||
|
|
||
| // The real tool follows cli-output-spec: versioned JSON on stdout, typed errors | ||
| // on stderr, and a semantic exit code -- 10 meaning "new items", which is a | ||
| // SUCCESS signal a cron acts on, not a failure. The fake reproduces that shape. | ||
| function writeFakeInboxWatchBinary(dir) { | ||
| const bin = path.join(dir, "inbox-watch") | ||
| fs.writeFileSync(bin, [ | ||
| "#!/usr/bin/env node", | ||
| "const args = process.argv.slice(2);", | ||
| "if (args[0] === 'help-json') {", | ||
| " console.log(JSON.stringify({ version: '1.0.0', output: 'json', interactive: false,", | ||
| " commands: { 'help-json': { args: [] } },", | ||
| " exit_codes: { '0': 'success — nothing new', '10': 'success — new items found (the cron signal)' },", | ||
| " env: ['INBOX_TOKEN'] })); process.exit(0);", | ||
| "}", | ||
| "if (args[0] === 'guide') {", | ||
| " console.log(JSON.stringify({ version: '1.0.0', summary: 'tells you only when a human replied',", | ||
| " gotchas: ['The cursor is consumed on read: use --peek to diagnose.'] })); process.exit(0);", | ||
| "}", | ||
| "// --peek reports WITHOUT consuming; a bare run consumes and exits 10 when new.", | ||
| "const peek = args.includes('--peek');", | ||
| "const exitZero = args.includes('--exit-zero');", | ||
| "const item = { channel: 'mailbox', kind: 'reply', from: 'someone@example.com', title: 'Re: hello', url: '' };", | ||
| "console.log(JSON.stringify({ ok: true, version: '1.0.0', new: [item], count: 1,", | ||
| " disabled: [{ channel: 'resend', reason: 'set RESEND_API_KEY', recoverable: true }] }));", | ||
| "process.exit(exitZero ? 0 : 10);" | ||
| ].join("\n"), "utf-8") | ||
| fs.chmodSync(bin, 0o755) | ||
| return bin | ||
| } | ||
|
|
||
| describe("inbox-watch plugin", () => { | ||
| const ctx = makePluginEnv("inbox-watch") | ||
| writeFakeInboxWatchBinary(ctx.fakeDir) | ||
| withInstalledPlugin(ctx) | ||
|
|
||
| test("exposes the cli-output-spec command catalog", () => { | ||
| const r = runNoServer("inbox-watch self help-json --json", { env: ctx.env }) | ||
| expect(r.ok).toBe(true) | ||
| const data = JSON.parse(r.output) | ||
| expect(data.command).toBe("inbox-watch.self.help-json") | ||
| expect(data.data.version).toBe("1.0.0") | ||
| // exit code 10 must be documented as a success signal, not an error | ||
| expect(data.data.exit_codes["10"]).toMatch(/success/) | ||
| }) | ||
|
|
||
| test("routes the embedded guide", () => { | ||
| const r = runNoServer("inbox-watch self guide --json", { env: ctx.env }) | ||
| expect(r.ok).toBe(true) | ||
| const data = JSON.parse(r.output) | ||
| expect(data.command).toBe("inbox-watch.self.guide") | ||
| expect(data.data.gotchas.join(" ")).toMatch(/--peek/) | ||
| }) | ||
|
|
||
| test("returns the versioned envelope with classified items", () => { | ||
| const r = runNoServer("inbox-watch inbox check --json", { env: ctx.env }) | ||
| expect(r.ok).toBe(true) | ||
| const data = JSON.parse(r.output) | ||
| expect(data.command).toBe("inbox-watch.inbox.check") | ||
| expect(data.data.ok).toBe(true) | ||
| expect(data.data.new[0].kind).toBe("reply") | ||
| // a disabled channel is structured context, not prose | ||
| expect(data.data.disabled[0]).toHaveProperty("recoverable") | ||
| }) | ||
|
|
||
| test("exit 10 (new items) is a SUCCESS, not a failure", () => { | ||
| // supercli's process adapter treats any non-zero exit as failure, and | ||
| // inbox-watch exits 10 when mail arrived -- so routed naively, the single | ||
| // most important case would surface as an error. The plugin normalises it. | ||
| const r = runNoServer("inbox-watch inbox check --json", { env: ctx.env }) | ||
| expect(r.ok).toBe(true) | ||
| expect(JSON.parse(r.output).data.count).toBe(1) | ||
| }) | ||
|
|
||
| test("peek is a distinct command from check", () => { | ||
| // They are separate on purpose: the destructive one must never be the one | ||
| // reached for while debugging, since a normal run consumes the cursor and | ||
| // eats the alert the cron would have sent. | ||
| const r = runNoServer("inbox-watch inbox peek --json", { env: ctx.env }) | ||
| expect(r.ok).toBe(true) | ||
| expect(JSON.parse(r.output).command).toBe("inbox-watch.inbox.peek") | ||
| }) | ||
| }) | ||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,12 @@ | ||||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||||
| "plugin": "inbox-watch", | ||||||||||||||||||||||||||||
| "binary": "inbox-watch", | ||||||||||||||||||||||||||||
| "check": "which inbox-watch", | ||||||||||||||||||||||||||||
| "install_steps": [ | ||||||||||||||||||||||||||||
| "git clone https://github.com/javimosch/inbox-watch /tmp/inbox-watch", | ||||||||||||||||||||||||||||
| "install -m755 /tmp/inbox-watch/inbox-watch ~/.local/bin/inbox-watch", | ||||||||||||||||||||||||||||
| "mkdir -p ~/.inbox-watch && cp /tmp/inbox-watch/config.example.json ~/.inbox-watch/config.json", | ||||||||||||||||||||||||||||
| "Verify: inbox-watch help-json" | ||||||||||||||||||||||||||||
| ], | ||||||||||||||||||||||||||||
|
Comment on lines
+5
to
+10
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win Add the configuration step to the standalone flow. After Line 8, instruct users to edit Proposed fix "mkdir -p ~/.inbox-watch && cp /tmp/inbox-watch/config.example.json ~/.inbox-watch/config.json",
+ "Edit ~/.inbox-watch/config.json (what to watch); secrets go in env vars",
"Verify: inbox-watch help-json"📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||
| "note": "Single Python 3 file, no dependencies. No auth required by the tool itself; each channel needs its own secret in an env var." | ||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| { | ||
| "description": "Poll GitHub notifications, IMAP and Resend inbound for items new since the last run. Classifies DMARC reports, out-of-office autoreplies and real human replies so alerts stay worth reading. Exit 10 when something arrived — cron-shaped. Agent-first: versioned JSON on stdout, typed errors on stderr, semantic exit codes, help-json and guide.", | ||
| "tags": ["inbox-watch", "cli", "email", "notifications", "monitoring", "agent-first", "cron"], | ||
| "has_learn": true | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,124 @@ | ||
| { | ||
| "name": "inbox-watch", | ||
| "version": "1.0.0", | ||
| "description": "Tell me only when a human actually replied \u2014 poll GitHub/IMAP/Resend inbound, classify DMARC vs autoreply vs reply, exit 10 when something real arrived", | ||
| "source": "https://github.com/javimosch/inbox-watch", | ||
| "checks": [ | ||
| { | ||
| "type": "binary", | ||
| "name": "inbox-watch" | ||
| } | ||
| ], | ||
| "install_guidance": { | ||
| "plugin": "inbox-watch", | ||
| "binary": "inbox-watch", | ||
| "check": "which inbox-watch", | ||
| "install_steps": [ | ||
| "git clone https://github.com/javimosch/inbox-watch /tmp/inbox-watch", | ||
| "install -m755 /tmp/inbox-watch/inbox-watch ~/.local/bin/inbox-watch", | ||
| "mkdir -p ~/.inbox-watch && cp /tmp/inbox-watch/config.example.json ~/.inbox-watch/config.json", | ||
| "Edit ~/.inbox-watch/config.json (what to watch); secrets go in env vars", | ||
| "Verify: inbox-watch help-json", | ||
| "supercli plugins install ./plugins/inbox-watch --on-conflict replace --json" | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win 🧩 Analysis chain🏁 Script executed: #!/bin/bash
set -euo pipefail
echo "== file context =="
sed -n '1,140p' plugins/inbox-watch/plugin.json
echo
echo "== occurrences of native install command syntax =="
rg -n "plugins install|supercli plugins install|sc plugins install|SuperCLI|native" -S plugins README.md . --glob '!node_modules' --glob '!dist' --glob '!build' | head -200
echo
echo "== sc availability =="
command -v sc || true
command -v sc-zig || true
if command -v sc >/tmp/sc_bin 2>/tmp/sc_err; then
bin="$(cat /tmp/sc_bin)"
echo "SC_BIN=$bin"
sc --version 2>/tmp/sc_ver || true
echo "--- sc inspect available via CLI help? ---"
sc --help 2>&1 | rg -n "inspect|install|Native|native|plugins" || true
fi
if command -v sc-zig >/tmp/sc_zig_bin 2>/tmp/sc_zig_err; then
bin="$(cat /tmp/sc_zig_bin)"
echo "SC_ZIG_BIN=$bin"
sc-zig --version 2>/tmp/sc_zig_ver || true
sc-zig --help 2>&1 | rg -n "inspect|install|Native|native|plugins" || true
fi
echo
echo "== repo source inspection for sc supercli aliases =="
rg -n "function sc|alias sc|bin[[:space:]]*=|supercli|SC_CLI|super-cli" -S --glob '!node_modules' --glob '!dist' --glob '!build' | head -200Repository: javimosch/supercli Length of output: 32023 Use
🤖 Prompt for AI AgentsSource: Coding guidelines |
||
| ], | ||
| "note": "Single Python 3 file, no dependencies. Config holds what to watch; env vars hold every secret (INBOX_TOKEN / RESEND_API_KEY / ZOHO_IMAP_PASS)." | ||
| }, | ||
| "learn": { | ||
| "file": "skills/quickstart/SKILL.md" | ||
| }, | ||
| "commands": [ | ||
| { | ||
| "namespace": "inbox-watch", | ||
| "resource": "inbox", | ||
| "action": "check", | ||
| "description": "Poll every configured channel and print items new since the last run. Exit 10 if any. CONSUMES the cursor \u2014 use inbox_peek to diagnose.", | ||
| "adapter": "process", | ||
| "adapterConfig": { | ||
| "command": "inbox-watch", | ||
| "baseArgs": [ | ||
| "--exit-zero" | ||
| ], | ||
| "missingDependencyHelp": "Install: see https://github.com/javimosch/inbox-watch", | ||
| "note": "--exit-zero because a non-zero exit is treated as failure by the process adapter, and inbox-watch's exit 10 means 'new items' \u2014 a success. Read data.count instead." | ||
| }, | ||
| "args": [] | ||
| }, | ||
| { | ||
| "namespace": "inbox-watch", | ||
| "resource": "inbox", | ||
| "action": "peek", | ||
| "description": "Same report WITHOUT marking items seen. Use this for any diagnostic run: a normal run consumes the alert your cron would have sent.", | ||
| "adapter": "process", | ||
| "adapterConfig": { | ||
| "command": "inbox-watch", | ||
| "baseArgs": [ | ||
| "--peek", | ||
| "--exit-zero" | ||
| ], | ||
| "missingDependencyHelp": "Install: see https://github.com/javimosch/inbox-watch", | ||
| "note": "--exit-zero because a non-zero exit is treated as failure by the process adapter, and inbox-watch's exit 10 means 'new items' \u2014 a success. Read data.count instead." | ||
| }, | ||
| "args": [] | ||
| }, | ||
| { | ||
| "namespace": "inbox-watch", | ||
| "resource": "inbox", | ||
| "action": "seed", | ||
| "description": "Mark everything currently pending as seen, reporting nothing. Run once when adding a new consumer, or it alerts on the whole backlog.", | ||
| "adapter": "process", | ||
| "adapterConfig": { | ||
| "command": "inbox-watch", | ||
| "baseArgs": [ | ||
| "--seed", | ||
| "--exit-zero" | ||
| ], | ||
| "missingDependencyHelp": "Install: see https://github.com/javimosch/inbox-watch", | ||
| "note": "--exit-zero because a non-zero exit is treated as failure by the process adapter, and inbox-watch's exit 10 means 'new items' \u2014 a success. Read data.count instead." | ||
| }, | ||
| "args": [] | ||
| }, | ||
| { | ||
| "namespace": "inbox-watch", | ||
| "resource": "self", | ||
| "action": "guide", | ||
| "description": "Embedded operator manual: the model, the loop, and the gotchas", | ||
| "adapter": "process", | ||
| "adapterConfig": { | ||
| "command": "inbox-watch", | ||
| "baseArgs": [ | ||
| "guide" | ||
| ], | ||
| "missingDependencyHelp": "Install: see https://github.com/javimosch/inbox-watch" | ||
| }, | ||
| "args": [] | ||
| }, | ||
| { | ||
| "namespace": "inbox-watch", | ||
| "resource": "self", | ||
| "action": "help-json", | ||
| "description": "Machine-readable command catalog (cli-output-spec)", | ||
| "adapter": "process", | ||
| "adapterConfig": { | ||
| "command": "inbox-watch", | ||
| "baseArgs": [ | ||
| "help-json" | ||
| ], | ||
| "missingDependencyHelp": "Install: see https://github.com/javimosch/inbox-watch" | ||
| }, | ||
| "args": [] | ||
| }, | ||
| { | ||
| "namespace": "inbox-watch", | ||
| "resource": "_", | ||
| "action": "_", | ||
| "description": "Passthrough to the inbox-watch CLI", | ||
| "adapter": "process", | ||
| "adapterConfig": { | ||
| "command": "inbox-watch", | ||
| "passthrough": true, | ||
| "missingDependencyHelp": "Install: see https://github.com/javimosch/inbox-watch" | ||
| }, | ||
| "args": [] | ||
|
Comment on lines
+110
to
+121
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win Normalize exit 10 for passthrough too. This exposed route omits 🤖 Prompt for AI Agents |
||
| } | ||
| ] | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,96 @@ | ||
| --- | ||
| name: inbox-watch-quickstart | ||
| description: Poll GitHub, IMAP and Resend inbound for genuinely new items and alert only when a human actually replied. Use when setting up reply monitoring, wiring a cron/timer to an inbox, or debugging why an expected alert never fired. | ||
| --- | ||
|
|
||
| # inbox-watch | ||
|
|
||
| **Tells you only when a human actually replied.** Polls GitHub notifications, IMAP | ||
| and Resend inbound; prints what is new since the last run; **exits 10** if there | ||
| was anything — so a cron alerts only when something real arrived. | ||
|
Comment on lines
+8
to
+10
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win Align the public alert contract for autoreplies. The descriptions promise alerts only for human replies, while the quickstart table says labelled autoreplies also alert. Choose the intended behavior and make every public surface match.
📍 Affects 3 files
🤖 Prompt for AI Agents |
||
|
|
||
| ## The one idea | ||
|
|
||
| Inbound is mostly machine noise. On a domain with DMARC reporting on, aggregate | ||
| reports outnumber real mail several to one. A notifier that pings for all of it | ||
| gets muted — **and then the reply that mattered is muted with it.** So every item | ||
| is classified and only one kind is a person: | ||
|
|
||
| | kind | alert? | | ||
| |---|---| | ||
| | `dmarc` | no — counted, never pinged alone | | ||
| | `autoreply` | yes, but **labelled** so it doesn't read as a reply | | ||
| | `reply` | **yes** — this is the product | | ||
|
|
||
| ## Use | ||
|
|
||
| ```sh | ||
| inbox-watch # JSON on stdout, exit 10 if new | ||
| inbox-watch --human # readable lines | ||
| inbox-watch guide # embedded operator manual | ||
| inbox-watch help-json # command catalog | ||
| inbox-watch setup # what each channel needs | ||
| ``` | ||
|
|
||
| Output follows [cli-output-spec](https://cli-specs.intrane.fr/): stdout is | ||
| versioned data, stderr is context and typed errors, exit code is the signal. | ||
|
|
||
| ```json | ||
| { "ok": true, "version": "1.0.0", "count": 1, | ||
| "new": [ { "channel": "mailbox", "kind": "reply", "from": "…", "title": "…" } ], | ||
| "disabled": [ { "channel": "resend", "reason": "set RESEND_API_KEY …", "recoverable": true } ] } | ||
| ``` | ||
|
|
||
| | exit | meaning | | ||
| |---:|---| | ||
| | 0 | nothing new | | ||
| | **10** | **new items — the cron signal** | | ||
| | 80 / 90 / 100 / 110 | input / precondition / external / internal | | ||
|
|
||
| ## Two traps — read these before debugging | ||
|
|
||
| Both were found in production, and both make a *working* system look broken (or | ||
| a broken one look fine). | ||
|
|
||
| **1. The cursor is consumed on read.** Any normal run marks items seen. So | ||
| running `inbox-watch` by hand to "check if it works" **eats the alert the cron | ||
| would have sent** — and the cron then reports nothing, which looks like success. | ||
|
|
||
| > Use `--peek` for anything diagnostic. It reports identically without | ||
| > committing state. | ||
|
|
||
| **2. Two watchers on one host steal from each other.** Same cause: they share a | ||
| cursor, so whichever polls first wins and the other's alert never fires. | ||
|
|
||
| > Give each its own: `--consumer NAME`. Run `--seed` once for a new consumer, or | ||
| > its first run alerts on the entire backlog. | ||
|
|
||
| ```sh | ||
| inbox-watch --peek --human # safe diagnosis | ||
| inbox-watch --consumer laptop --seed # add a second watcher, no backlog flood | ||
| inbox-watch --consumer laptop | ||
| ``` | ||
|
|
||
| ## Configure | ||
|
|
||
| `~/.inbox-watch/config.json` holds **what to watch**; env vars hold **every | ||
| secret**. Start from `config.example.json`. Unconfigured channels report why | ||
| they are disabled rather than failing, so a fresh install runs and tells you | ||
| what it needs. | ||
|
|
||
| | channel | secret | note | | ||
| |---|---|---| | ||
| | `mailbox` | `INBOX_TOKEN` + `mailbox.url` | token-gated `GET /api/inbound`. **Prefer this over `resend`** — a read-only token cannot send mail as you | | ||
| | `resend` | `RESEND_API_KEY` | note this key *can also send* | | ||
| | `imap` | `ZOHO_IMAP_PASS` + `imap.user` | any IMAP host, app-specific password | | ||
| | `github` | authenticated `gh` | mentions/review-requests, plus specific issue/PR threads you name | | ||
|
|
||
| ## Wire it up | ||
|
|
||
| ``` | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win Declare the cron snippet as shell. Use 🧰 Tools🪛 markdownlint-cli2 (0.23.1)[warning] 90-90: Fenced code blocks should have a language specified (MD040, fenced-code-language) 🤖 Prompt for AI AgentsSource: Linters/SAST tools |
||
| */15 * * * * inbox-watch --human | grep . && <notify> | ||
| ``` | ||
|
|
||
| `run.sh.template` in the repo is a worked example (Telegram). It **logs the | ||
| delivery result** — a send that fails silently is the same failure mode this | ||
| tool exists to prevent, so don't discard it. | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win
Assert that
--peekreaches the binary.This only checks the wrapper command name. The fake already calculates
peekat Line 24, but never returns it, so removing--peekfrom the manifest still passes. Includepeekin the fake JSON and assertdata.data.peek === true.🤖 Prompt for AI Agents