caps-brief 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/CHANGELOG.md +12 -0
- package/LICENSE +21 -0
- package/README.md +162 -0
- package/SETUP.md +101 -0
- package/dist/auth.js +203 -0
- package/dist/capacities.js +70 -0
- package/dist/cli.js +182 -0
- package/dist/llm.js +104 -0
- package/dist/sources.js +142 -0
- package/dist/store.js +47 -0
- package/package.json +56 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.1.0 — 2026-07-16
|
|
4
|
+
|
|
5
|
+
Initial release.
|
|
6
|
+
|
|
7
|
+
- `caps-brief auth` — OAuth 2.1 + PKCE browser sign-in against the Capacities API 2.0, with automatic (RFC 7591) client registration and rotating refresh tokens; `--token` for personal API tokens.
|
|
8
|
+
- `caps-brief config` — manage RSS/Atom feeds and the wttr.in weather location.
|
|
9
|
+
- `caps-brief run` — fetches Hacker News top 10, your feeds, and the weather; summarises with Claude (`claude-haiku-4-5`); appends the briefing to your Daily Note via `POST /blocks/daily-note/append`.
|
|
10
|
+
- `caps-brief status` — auth, space, key, sources, and last-run at a glance.
|
|
11
|
+
- Cron-grade reliability: once-per-day duplicate guard (`--force` to override), automatic fallback to a plain digest when Claude is transiently unavailable, per-source failure isolation, Capacities rate-limit backoff, refuses to write an empty briefing.
|
|
12
|
+
- `--dry-run`, `--no-llm`, `--date YYYY-MM-DD`, `--no-timestamp` flags.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Booyaka101
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
# caps-brief
|
|
2
|
+
|
|
3
|
+
> **Status: archived, unmaintained, not published to npm.**
|
|
4
|
+
>
|
|
5
|
+
> caps-brief requires **Capacities Pro** — API access is a Pro-only feature (~SGD 12.99/mo), which isn't obvious from the Capacities pricing page until you look closely. I don't have Pro, so **the OAuth sign-in and the Claude summarisation path have never run against a live account.** They're implemented and unit-tested, but unproven end-to-end. Against a Basic account, `caps-brief auth` fails with a bare `notAuthenticated`.
|
|
6
|
+
>
|
|
7
|
+
> I'm not going to maintain a tool I can't run, so this is archived rather than deleted. It may still be useful as a **reference for building on the Capacities API 2.0** — [`src/auth.ts`](src/auth.ts) has a working RFC 7591 dynamic client registration and OAuth 2.1 + PKCE flow (registration is confirmed working: `/oauth/reg` returns 201 for a public client with a localhost redirect URI). The old Beta API shuts down 2026-09-01; this is built entirely on 2.0.
|
|
8
|
+
>
|
|
9
|
+
> MIT — fork it, finish it, no need to ask.
|
|
10
|
+
|
|
11
|
+
A Node.js CLI that auto-populates your [Capacities](https://capacities.io) Daily Note with an AI morning briefing — Hacker News front page, your RSS feeds, and the weather — summarised by Claude and written via the **Capacities API 2.0** (launched July 2026, [developers.capacities.io](https://developers.capacities.io)).
|
|
12
|
+
|
|
13
|
+
Every morning your Daily Note starts with:
|
|
14
|
+
|
|
15
|
+
```
|
|
16
|
+
## ☀️ Morning Briefing
|
|
17
|
+
|
|
18
|
+
### Top Stories
|
|
19
|
+
A 2–4 sentence synthesis of the HN front page, plus the three most notable links…
|
|
20
|
+
|
|
21
|
+
### RSS Highlights
|
|
22
|
+
What's new across your subscribed feeds…
|
|
23
|
+
|
|
24
|
+
### Weather
|
|
25
|
+
Singapore: 🌤️ +31°C
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
**BYOK (bring your own keys):** you use your own Capacities account and your own Anthropic API key. Nothing goes through anyone else's server.
|
|
29
|
+
|
|
30
|
+
## Requirements
|
|
31
|
+
|
|
32
|
+
- Node.js ≥ 22
|
|
33
|
+
- **Capacities Pro** — API access is a Pro-only feature (~SGD 12.99/mo; there's a free trial in the app). Basic accounts can't use this tool: both the OAuth sign-in and personal tokens are gated behind Pro.
|
|
34
|
+
- An Anthropic API key from [platform.claude.com](https://platform.claude.com) — optional if you run with `--no-llm`
|
|
35
|
+
|
|
36
|
+
## Install
|
|
37
|
+
|
|
38
|
+
Not on npm — build from source:
|
|
39
|
+
|
|
40
|
+
```sh
|
|
41
|
+
git clone https://github.com/Booyaka101/caps-brief.git
|
|
42
|
+
cd caps-brief && npm install && npm run build && npm link
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## 1. Authenticate with Capacities
|
|
46
|
+
|
|
47
|
+
**Option A — browser sign-in (OAuth 2.1 + PKCE):**
|
|
48
|
+
|
|
49
|
+
```sh
|
|
50
|
+
caps-brief auth
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Your browser opens the Capacities consent screen; approve, and tokens land in `~/.caps-brief/tokens.json`. The CLI registers itself as a public OAuth client automatically via the API's dynamic client registration endpoint — no manual app registration needed. Access tokens auto-refresh (rotating refresh tokens), so this is fire-and-forget for cron use. If you are building your own *published* integration instead, register a permanent OAuth client with the Capacities team via [developers.capacities.io](https://developers.capacities.io).
|
|
54
|
+
|
|
55
|
+
**Option B — personal API token (simplest):**
|
|
56
|
+
|
|
57
|
+
In the Capacities desktop app go to **Settings → Capacities API → Generate new token** (scopes: `api:read` + `api:write`), then:
|
|
58
|
+
|
|
59
|
+
```sh
|
|
60
|
+
caps-brief auth --token cap-api-xxxxxxxx
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Either way, `auth` verifies the credential against `GET /space` and tells you which space it's connected to. Note: a token/OAuth grant is bound to **one space** — the briefing lands in that space's Daily Note.
|
|
64
|
+
|
|
65
|
+
## 2. Configure sources
|
|
66
|
+
|
|
67
|
+
```sh
|
|
68
|
+
caps-brief config --add-rss https://feeds.arstechnica.com/arstechnica/index
|
|
69
|
+
caps-brief config --add-rss https://www.theverge.com/rss/index.xml
|
|
70
|
+
caps-brief config --weather "Singapore" # any wttr.in location; omit for IP-based
|
|
71
|
+
caps-brief config --list
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
RSS 2.0 and Atom are both supported. Hacker News (top 10 front-page stories) and weather are on by default.
|
|
75
|
+
|
|
76
|
+
## 3. Set your Anthropic key and run
|
|
77
|
+
|
|
78
|
+
```sh
|
|
79
|
+
# macOS/Linux
|
|
80
|
+
export ANTHROPIC_API_KEY=sk-ant-...
|
|
81
|
+
# Windows (PowerShell)
|
|
82
|
+
$env:ANTHROPIC_API_KEY = "sk-ant-..."
|
|
83
|
+
|
|
84
|
+
caps-brief run --dry-run # preview without writing
|
|
85
|
+
caps-brief run # write to today's Daily Note
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
Summarisation uses `claude-haiku-4-5` (~$0.002/day at typical volumes). No key? `caps-brief run --no-llm` writes a clean, unsummarised digest instead.
|
|
89
|
+
|
|
90
|
+
Check everything at a glance any time:
|
|
91
|
+
|
|
92
|
+
```sh
|
|
93
|
+
caps-brief status # auth ✔/✖, connected space, key, sources, last run
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
**Built for unattended mornings:**
|
|
97
|
+
|
|
98
|
+
- Runs are **once per day** — if your scheduler fires twice, the second run is a clean no-op (`--force` overrides).
|
|
99
|
+
- If Claude is transiently unreachable (rate limit, network, 5xx), the run **falls back to the plain digest** with a warning instead of leaving your note empty. Bad/missing API keys still fail loudly — config problems are never papered over.
|
|
100
|
+
- Each source fails independently; the run only aborts if *everything* is unreachable (it never writes an empty briefing).
|
|
101
|
+
|
|
102
|
+
Other flags: `--date 2026-07-17` (target another day's note, bypasses the once-per-day guard), `--no-timestamp` (omit the timestamp heading Capacities adds to appended blocks).
|
|
103
|
+
|
|
104
|
+
## 4. Schedule it
|
|
105
|
+
|
|
106
|
+
**Linux/macOS (cron):**
|
|
107
|
+
|
|
108
|
+
```cron
|
|
109
|
+
30 6 * * * ANTHROPIC_API_KEY=sk-ant-... /usr/local/bin/caps-brief run >> ~/.caps-brief/run.log 2>&1
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
**Windows (Task Scheduler):** — use the full path; scheduled tasks don't reliably inherit your PATH:
|
|
113
|
+
|
|
114
|
+
```powershell
|
|
115
|
+
$action = New-ScheduledTaskAction -Execute "$env:APPDATA\npm\caps-brief.cmd" -Argument "run"
|
|
116
|
+
$trigger = New-ScheduledTaskTrigger -Daily -At 6:30am
|
|
117
|
+
Register-ScheduledTask -TaskName "caps-brief" -Action $action -Trigger $trigger
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
(Set `ANTHROPIC_API_KEY` as a user environment variable first: `[Environment]::SetEnvironmentVariable("ANTHROPIC_API_KEY","sk-ant-...","User")`.)
|
|
121
|
+
|
|
122
|
+
Missed a morning (laptop asleep)? Just run `caps-brief run` when you're back — the once-per-day guard means double-scheduling is always safe.
|
|
123
|
+
|
|
124
|
+
## How it talks to Capacities
|
|
125
|
+
|
|
126
|
+
- Auth: OAuth 2.1 authorization-code + PKCE (S256) against `https://api.capacities.io/oauth/authorize` / `/oauth/token`, discovered via RFC 8414; or a `cap-api-…` bearer token.
|
|
127
|
+
- Write: `POST /blocks/daily-note/append` with a `markdown` body — creates today's Daily Note if needed and appends the briefing (rate limit: 30 req/min; we make 1/day).
|
|
128
|
+
- The old Capacities Beta API is **not** used anywhere — it shuts down September 1, 2026. This tool is built entirely on API 2.0.
|
|
129
|
+
|
|
130
|
+
## Troubleshooting
|
|
131
|
+
|
|
132
|
+
Start with `caps-brief status` — it diagnoses most of these in one shot.
|
|
133
|
+
|
|
134
|
+
| Symptom | Fix |
|
|
135
|
+
|---|---|
|
|
136
|
+
| `Not authenticated. Run: caps-brief auth` | Token missing/revoked — re-run `caps-brief auth` |
|
|
137
|
+
| `forbidden … api:write scope` | Regenerate your personal token with the **write** scope enabled |
|
|
138
|
+
| `ANTHROPIC_API_KEY is not set` | Export the key, or add `--no-llm` |
|
|
139
|
+
| `Already briefed today` | Working as intended — add `--force` to send another |
|
|
140
|
+
| Note shows the plain digest, not the AI summary | Claude was briefly unreachable that morning; check the run log for the ⚠ line |
|
|
141
|
+
| Wrong space gets the briefing | Tokens are per-space — re-auth and pick the right space |
|
|
142
|
+
| All sources failed | Network/proxy issue; nothing is written on total failure |
|
|
143
|
+
|
|
144
|
+
Files live in `~/.caps-brief/`: `tokens.json` (credentials, 0600), `config.json` (feeds/weather), `client.json` (OAuth client registration), `state.json` (last-run marker).
|
|
145
|
+
|
|
146
|
+
## Forking
|
|
147
|
+
|
|
148
|
+
This repo is archived, so issues and pull requests are closed — but it's MIT, so fork away.
|
|
149
|
+
|
|
150
|
+
```sh
|
|
151
|
+
npm install
|
|
152
|
+
npm run build
|
|
153
|
+
npm test # unit suite (feed parsing, formatting) on Node's built-in test runner
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
If you pick this up with a Pro account, the two unverified paths are `caps-brief auth` (the OAuth browser flow — dynamic client registration works, the consent step is unproven) and [`src/llm.ts`](src/llm.ts) (Claude summarisation; `--no-llm` skips it and is exercised by the tests).
|
|
157
|
+
|
|
158
|
+
Adding a source (a new feed type, a calendar, anything else) means a fetcher in [`src/sources.ts`](src/sources.ts) that fails independently of the others — the run should degrade to whatever else succeeded rather than abort.
|
|
159
|
+
|
|
160
|
+
## License
|
|
161
|
+
|
|
162
|
+
[MIT](LICENSE) © Booyaka101
|
package/SETUP.md
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# caps-brief — 10-Minute Setup Guide
|
|
2
|
+
|
|
3
|
+
*By the end of this guide your Capacities Daily Note will greet you every morning with an AI-written briefing.*
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## What you need
|
|
8
|
+
|
|
9
|
+
| Item | Where to get it | Cost |
|
|
10
|
+
|---|---|---|
|
|
11
|
+
| Node.js 22+ | [nodejs.org](https://nodejs.org) | free |
|
|
12
|
+
| **Capacities Pro** — API access is Pro-only | [capacities.io](https://capacities.io) | ~SGD 12.99/mo (free trial in the app) |
|
|
13
|
+
| Anthropic API key | [platform.claude.com](https://platform.claude.com) → API Keys | ~$0.002/day of usage |
|
|
14
|
+
|
|
15
|
+
caps-brief is **BYOK** — your keys, your data, no middleman server.
|
|
16
|
+
|
|
17
|
+
## Step 1 — Install (2 min)
|
|
18
|
+
|
|
19
|
+
Open a terminal in your download folder:
|
|
20
|
+
|
|
21
|
+
```sh
|
|
22
|
+
npm install -g ./caps-brief-0.1.0.tgz
|
|
23
|
+
caps-brief --version # → 0.1.0
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## Step 2 — Connect Capacities (3 min)
|
|
27
|
+
|
|
28
|
+
The easy path is the browser flow:
|
|
29
|
+
|
|
30
|
+
```sh
|
|
31
|
+
caps-brief auth
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
1. Your browser opens Capacities' sign-in/consent page.
|
|
35
|
+
2. Approve access for **caps-brief CLI** (scopes: read, write, offline access).
|
|
36
|
+
3. The terminal prints `✅ Signed in` and the name of your connected space.
|
|
37
|
+
|
|
38
|
+
Prefer a static token? In the Capacities **desktop app**: Settings → Capacities API → *Generate new token* → tick **read** and **write** → copy it, then:
|
|
39
|
+
|
|
40
|
+
```sh
|
|
41
|
+
caps-brief auth --token cap-api-PASTE-YOURS-HERE
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
> ⚠️ Tokens are bound to **one space**. The briefing will be appended to the Daily Note of the space you approved.
|
|
45
|
+
|
|
46
|
+
## Step 3 — Connect Claude (1 min)
|
|
47
|
+
|
|
48
|
+
Create a key at platform.claude.com, then make it permanent:
|
|
49
|
+
|
|
50
|
+
- **macOS/Linux** — add to `~/.zshrc` or `~/.bashrc`:
|
|
51
|
+
`export ANTHROPIC_API_KEY=sk-ant-...`
|
|
52
|
+
- **Windows** — PowerShell (once):
|
|
53
|
+
`[Environment]::SetEnvironmentVariable("ANTHROPIC_API_KEY","sk-ant-...","User")`
|
|
54
|
+
|
|
55
|
+
*(Skip this step entirely if you're happy with an unsummarised link digest — just always run with `--no-llm`.)*
|
|
56
|
+
|
|
57
|
+
## Step 4 — Pick your sources (2 min)
|
|
58
|
+
|
|
59
|
+
```sh
|
|
60
|
+
caps-brief config --add-rss https://feeds.arstechnica.com/arstechnica/index
|
|
61
|
+
caps-brief config --add-rss https://www.theverge.com/rss/index.xml
|
|
62
|
+
caps-brief config --weather "Berlin"
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Any RSS 2.0 or Atom feed works. Hacker News top-10 and weather are included automatically.
|
|
66
|
+
|
|
67
|
+
## Step 5 — Test, then go live (2 min)
|
|
68
|
+
|
|
69
|
+
```sh
|
|
70
|
+
caps-brief status # everything green?
|
|
71
|
+
caps-brief run --dry-run # preview in the terminal — writes nothing
|
|
72
|
+
caps-brief run # ✨ check today's Daily Note in Capacities
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Open Capacities → today's Daily Note. You should see **☀️ Morning Briefing** with Top Stories, RSS Highlights, and Weather.
|
|
76
|
+
|
|
77
|
+
## Step 6 — Automate every morning
|
|
78
|
+
|
|
79
|
+
**macOS/Linux** — `crontab -e`, then:
|
|
80
|
+
|
|
81
|
+
```cron
|
|
82
|
+
30 6 * * * ANTHROPIC_API_KEY=sk-ant-... /usr/local/bin/caps-brief run >> ~/.caps-brief/run.log 2>&1
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
**Windows** — PowerShell (as your user; note the full path — scheduled tasks don't reliably see your PATH):
|
|
86
|
+
|
|
87
|
+
```powershell
|
|
88
|
+
$action = New-ScheduledTaskAction -Execute "$env:APPDATA\npm\caps-brief.cmd" -Argument "run"
|
|
89
|
+
$trigger = New-ScheduledTaskTrigger -Daily -At 6:30am
|
|
90
|
+
Register-ScheduledTask -TaskName "caps-brief" -Action $action -Trigger $trigger
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
Done. Coffee → open Capacities → your day is already summarised. ☕
|
|
94
|
+
|
|
95
|
+
## FAQ
|
|
96
|
+
|
|
97
|
+
**Does it duplicate if I run it twice?** No — caps-brief remembers it already briefed today and the second run is a no-op. Want a second one anyway? `caps-brief run --force`.
|
|
98
|
+
**What if Claude is down at 6:30am?** Your note still gets the briefing — as a plain link digest, with a warning in the log. Bad API keys fail loudly instead (so you notice).
|
|
99
|
+
**Can I write to yesterday's/tomorrow's note?** Yes: `caps-brief run --date 2026-07-17`.
|
|
100
|
+
**Where are my credentials?** `~/.caps-brief/tokens.json`, file mode 0600, never sent anywhere except `api.capacities.io`.
|
|
101
|
+
**Something broke?** Run the failing command again and read the `✖` line — every error tells you the fix. Note this project is archived and unmaintained: there's no issue tracker to fall back on. See the status banner in the [README](README.md).
|
package/dist/auth.js
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import http from "node:http";
|
|
3
|
+
import fetch from "node-fetch";
|
|
4
|
+
import open from "open";
|
|
5
|
+
import { loadClient, loadTokens, saveClient, saveTokens, TOKENS_PATH, } from "./store.js";
|
|
6
|
+
export const API_BASE = "https://api.capacities.io";
|
|
7
|
+
const DISCOVERY_URL = `${API_BASE}/.well-known/oauth-authorization-server`;
|
|
8
|
+
const SCOPES = "api:read api:write offline_access";
|
|
9
|
+
/** Refresh when the access token has less than this many ms of life left. */
|
|
10
|
+
const EXPIRY_SKEW_MS = 60_000;
|
|
11
|
+
function b64url(buf) {
|
|
12
|
+
return buf.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
13
|
+
}
|
|
14
|
+
async function discover() {
|
|
15
|
+
const res = await fetch(DISCOVERY_URL);
|
|
16
|
+
if (!res.ok) {
|
|
17
|
+
throw new Error(`OAuth discovery failed (${res.status}). Is https://api.capacities.io reachable?`);
|
|
18
|
+
}
|
|
19
|
+
return (await res.json());
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Register caps-brief as a public OAuth client via RFC 7591 dynamic client
|
|
23
|
+
* registration, or reuse a previously registered client if the redirect URI
|
|
24
|
+
* still matches (the URI embeds the callback port, so a port change forces
|
|
25
|
+
* re-registration).
|
|
26
|
+
*/
|
|
27
|
+
async function getClientId(discovery, redirectUri) {
|
|
28
|
+
const cached = await loadClient();
|
|
29
|
+
if (cached && cached.redirectUri === redirectUri)
|
|
30
|
+
return cached.clientId;
|
|
31
|
+
const res = await fetch(discovery.registration_endpoint, {
|
|
32
|
+
method: "POST",
|
|
33
|
+
headers: { "Content-Type": "application/json" },
|
|
34
|
+
body: JSON.stringify({
|
|
35
|
+
client_name: "caps-brief CLI",
|
|
36
|
+
redirect_uris: [redirectUri],
|
|
37
|
+
grant_types: ["authorization_code", "refresh_token"],
|
|
38
|
+
response_types: ["code"],
|
|
39
|
+
token_endpoint_auth_method: "none",
|
|
40
|
+
scope: SCOPES,
|
|
41
|
+
}),
|
|
42
|
+
});
|
|
43
|
+
if (!res.ok) {
|
|
44
|
+
const body = await res.text();
|
|
45
|
+
throw new Error(`Dynamic client registration failed (${res.status}): ${body}\n` +
|
|
46
|
+
`You can instead use a personal API token: caps-brief auth --token cap-api-...`);
|
|
47
|
+
}
|
|
48
|
+
const reg = (await res.json());
|
|
49
|
+
await saveClient({ clientId: reg.client_id, redirectUri, registeredAt: Date.now() });
|
|
50
|
+
return reg.client_id;
|
|
51
|
+
}
|
|
52
|
+
/** Wait for the OAuth redirect on a local loopback server; resolve with the code. */
|
|
53
|
+
function waitForCallback(server, expectedState, timeoutMs = 5 * 60_000) {
|
|
54
|
+
return new Promise((resolve, reject) => {
|
|
55
|
+
const timer = setTimeout(() => {
|
|
56
|
+
reject(new Error("Timed out waiting for the browser sign-in (5 minutes)."));
|
|
57
|
+
}, timeoutMs);
|
|
58
|
+
server.on("request", (req, res) => {
|
|
59
|
+
const url = new URL(req.url ?? "/", "http://127.0.0.1");
|
|
60
|
+
if (url.pathname !== "/callback") {
|
|
61
|
+
res.writeHead(404).end();
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
const err = url.searchParams.get("error");
|
|
65
|
+
const code = url.searchParams.get("code");
|
|
66
|
+
const state = url.searchParams.get("state");
|
|
67
|
+
const finish = (ok, message) => {
|
|
68
|
+
res.writeHead(ok ? 200 : 400, { "Content-Type": "text/html; charset=utf-8" });
|
|
69
|
+
res.end(`<html><body style="font-family:sans-serif;margin:3rem auto;max-width:30rem;text-align:center">` +
|
|
70
|
+
`<h2>${ok ? "✅ caps-brief connected" : "❌ Sign-in failed"}</h2><p>${message}</p></body></html>`);
|
|
71
|
+
};
|
|
72
|
+
if (err) {
|
|
73
|
+
finish(false, `Capacities returned: ${err}`);
|
|
74
|
+
clearTimeout(timer);
|
|
75
|
+
reject(new Error(`Authorization was denied: ${err}`));
|
|
76
|
+
}
|
|
77
|
+
else if (!code || state !== expectedState) {
|
|
78
|
+
finish(false, "Invalid callback (state mismatch). Please retry.");
|
|
79
|
+
clearTimeout(timer);
|
|
80
|
+
reject(new Error("OAuth state mismatch — possible CSRF or a stale browser tab. Retry caps-brief auth."));
|
|
81
|
+
}
|
|
82
|
+
else {
|
|
83
|
+
finish(true, "You can close this tab and return to the terminal.");
|
|
84
|
+
clearTimeout(timer);
|
|
85
|
+
resolve(code);
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
async function exchangeToken(tokenEndpoint, params) {
|
|
91
|
+
const res = await fetch(tokenEndpoint, {
|
|
92
|
+
method: "POST",
|
|
93
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
94
|
+
body: new URLSearchParams(params).toString(),
|
|
95
|
+
});
|
|
96
|
+
const body = await res.text();
|
|
97
|
+
if (!res.ok) {
|
|
98
|
+
throw new Error(`Token request failed (${res.status}): ${body}`);
|
|
99
|
+
}
|
|
100
|
+
return JSON.parse(body);
|
|
101
|
+
}
|
|
102
|
+
function toStoredTokens(t) {
|
|
103
|
+
return {
|
|
104
|
+
authMethod: "oauth",
|
|
105
|
+
accessToken: t.access_token,
|
|
106
|
+
refreshToken: t.refresh_token,
|
|
107
|
+
expiresAt: t.expires_in ? Date.now() + t.expires_in * 1000 : undefined,
|
|
108
|
+
scope: t.scope,
|
|
109
|
+
obtainedAt: Date.now(),
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
/** Full browser-based OAuth 2.1 PKCE flow. Stores tokens in ~/.caps-brief/tokens.json. */
|
|
113
|
+
export async function oauthLogin() {
|
|
114
|
+
const discovery = await discover();
|
|
115
|
+
const server = http.createServer();
|
|
116
|
+
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
|
117
|
+
const port = server.address().port;
|
|
118
|
+
const redirectUri = `http://127.0.0.1:${port}/callback`;
|
|
119
|
+
try {
|
|
120
|
+
const clientId = await getClientId(discovery, redirectUri);
|
|
121
|
+
const verifier = b64url(crypto.randomBytes(48));
|
|
122
|
+
const challenge = b64url(crypto.createHash("sha256").update(verifier).digest());
|
|
123
|
+
const state = b64url(crypto.randomBytes(24));
|
|
124
|
+
const authUrl = new URL(discovery.authorization_endpoint);
|
|
125
|
+
authUrl.search = new URLSearchParams({
|
|
126
|
+
response_type: "code",
|
|
127
|
+
client_id: clientId,
|
|
128
|
+
redirect_uri: redirectUri,
|
|
129
|
+
scope: SCOPES,
|
|
130
|
+
resource: API_BASE,
|
|
131
|
+
code_challenge: challenge,
|
|
132
|
+
code_challenge_method: "S256",
|
|
133
|
+
state,
|
|
134
|
+
}).toString();
|
|
135
|
+
console.log("Opening your browser to sign in to Capacities…");
|
|
136
|
+
console.log(`If it does not open, visit:\n ${authUrl.toString()}\n`);
|
|
137
|
+
await open(authUrl.toString()).catch(() => {
|
|
138
|
+
/* headless environments: the URL is already printed */
|
|
139
|
+
});
|
|
140
|
+
const code = await waitForCallback(server, state);
|
|
141
|
+
const tokens = await exchangeToken(discovery.token_endpoint, {
|
|
142
|
+
grant_type: "authorization_code",
|
|
143
|
+
code,
|
|
144
|
+
redirect_uri: redirectUri,
|
|
145
|
+
client_id: clientId,
|
|
146
|
+
code_verifier: verifier,
|
|
147
|
+
});
|
|
148
|
+
await saveTokens(toStoredTokens(tokens));
|
|
149
|
+
console.log(`✅ Signed in. Tokens saved to ${TOKENS_PATH}`);
|
|
150
|
+
}
|
|
151
|
+
finally {
|
|
152
|
+
server.close();
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
/** Store a personal API token (generated in Capacities → Settings → Capacities API). */
|
|
156
|
+
export async function tokenLogin(token) {
|
|
157
|
+
if (!token.startsWith("cap-api-")) {
|
|
158
|
+
console.warn("Warning: Capacities personal tokens normally start with 'cap-api-'. Storing anyway.");
|
|
159
|
+
}
|
|
160
|
+
await saveTokens({ authMethod: "token", accessToken: token, obtainedAt: Date.now() });
|
|
161
|
+
console.log(`✅ Token saved to ${TOKENS_PATH}`);
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* Return a valid access token, refreshing via the rotating refresh token if
|
|
165
|
+
* the OAuth access token is expired or about to expire.
|
|
166
|
+
*/
|
|
167
|
+
export async function getAccessToken() {
|
|
168
|
+
const stored = await loadTokens();
|
|
169
|
+
if (!stored) {
|
|
170
|
+
throw new Error("Not authenticated. Run: caps-brief auth (or: caps-brief auth --token cap-api-...)");
|
|
171
|
+
}
|
|
172
|
+
if (stored.authMethod === "token")
|
|
173
|
+
return stored.accessToken;
|
|
174
|
+
const fresh = stored.expiresAt !== undefined && Date.now() < stored.expiresAt - EXPIRY_SKEW_MS;
|
|
175
|
+
if (fresh)
|
|
176
|
+
return stored.accessToken;
|
|
177
|
+
return refreshAccessToken(stored);
|
|
178
|
+
}
|
|
179
|
+
/** Refresh the OAuth access token. Refresh tokens rotate on every use, so persist immediately. */
|
|
180
|
+
export async function refreshAccessToken(stored) {
|
|
181
|
+
const current = stored ?? (await loadTokens());
|
|
182
|
+
if (!current || current.authMethod !== "oauth") {
|
|
183
|
+
throw new Error("No OAuth session to refresh. Run: caps-brief auth");
|
|
184
|
+
}
|
|
185
|
+
if (!current.refreshToken) {
|
|
186
|
+
throw new Error("Access token expired and no refresh token was granted. Run: caps-brief auth");
|
|
187
|
+
}
|
|
188
|
+
const discovery = await discover();
|
|
189
|
+
const client = await loadClient();
|
|
190
|
+
if (!client) {
|
|
191
|
+
throw new Error("OAuth client registration missing. Run: caps-brief auth");
|
|
192
|
+
}
|
|
193
|
+
const tokens = await exchangeToken(discovery.token_endpoint, {
|
|
194
|
+
grant_type: "refresh_token",
|
|
195
|
+
refresh_token: current.refreshToken,
|
|
196
|
+
client_id: client.clientId,
|
|
197
|
+
});
|
|
198
|
+
const next = toStoredTokens(tokens);
|
|
199
|
+
// Some servers omit the rotated refresh token on error paths; keep the old one if so.
|
|
200
|
+
next.refreshToken = tokens.refresh_token ?? current.refreshToken;
|
|
201
|
+
await saveTokens(next);
|
|
202
|
+
return next.accessToken;
|
|
203
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import fetch from "node-fetch";
|
|
2
|
+
import { API_BASE, getAccessToken, refreshAccessToken } from "./auth.js";
|
|
3
|
+
import { loadTokens } from "./store.js";
|
|
4
|
+
async function request(method, path, body, retry = true) {
|
|
5
|
+
const token = await getAccessToken();
|
|
6
|
+
const res = await fetch(`${API_BASE}${path}`, {
|
|
7
|
+
method,
|
|
8
|
+
headers: {
|
|
9
|
+
Authorization: `Bearer ${token}`,
|
|
10
|
+
...(body !== undefined ? { "Content-Type": "application/json" } : {}),
|
|
11
|
+
},
|
|
12
|
+
body: body !== undefined ? JSON.stringify(body) : undefined,
|
|
13
|
+
});
|
|
14
|
+
// Expired OAuth access token → refresh once and retry.
|
|
15
|
+
if (res.status === 401 && retry) {
|
|
16
|
+
const stored = await loadTokens();
|
|
17
|
+
if (stored?.authMethod === "oauth" && stored.refreshToken) {
|
|
18
|
+
await refreshAccessToken(stored);
|
|
19
|
+
return request(method, path, body, false);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
// Rate limited → honor the RateLimit reset window once (draft-8 header, e.g. "limit=30, remaining=0, reset=12").
|
|
23
|
+
if (res.status === 429 && retry) {
|
|
24
|
+
const header = res.headers.get("ratelimit") ?? "";
|
|
25
|
+
const reset = /reset=(\d+)/.exec(header)?.[1];
|
|
26
|
+
const waitS = Math.min(reset ? parseInt(reset, 10) : 15, 60);
|
|
27
|
+
console.error(`Capacities rate limit hit; retrying in ${waitS}s…`);
|
|
28
|
+
await new Promise((r) => setTimeout(r, waitS * 1000));
|
|
29
|
+
return request(method, path, body, false);
|
|
30
|
+
}
|
|
31
|
+
return res;
|
|
32
|
+
}
|
|
33
|
+
async function throwApiError(res, context) {
|
|
34
|
+
let detail = "";
|
|
35
|
+
try {
|
|
36
|
+
const err = (await res.json());
|
|
37
|
+
detail = err.message ? ` — ${err.message}${err.code ? ` (${err.code})` : ""}` : "";
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
/* non-JSON body */
|
|
41
|
+
}
|
|
42
|
+
if (res.status === 401) {
|
|
43
|
+
throw new Error(`${context}: not authenticated${detail}. Run: caps-brief auth`);
|
|
44
|
+
}
|
|
45
|
+
if (res.status === 403) {
|
|
46
|
+
throw new Error(`${context}: forbidden${detail}. Does your token have the api:write scope?`);
|
|
47
|
+
}
|
|
48
|
+
throw new Error(`${context}: HTTP ${res.status}${detail}`);
|
|
49
|
+
}
|
|
50
|
+
/** Fetch the space bound to the current token — used to verify authentication. */
|
|
51
|
+
export async function getSpace() {
|
|
52
|
+
const res = await request("GET", "/space");
|
|
53
|
+
if (!res.ok)
|
|
54
|
+
await throwApiError(res, "GET /space");
|
|
55
|
+
return (await res.json());
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Append Markdown to a daily note via POST /blocks/daily-note/append
|
|
59
|
+
* (Capacities API 2.0; processed asynchronously server-side).
|
|
60
|
+
*/
|
|
61
|
+
export async function appendToDailyNote(markdown, options = {}) {
|
|
62
|
+
const body = { markdown };
|
|
63
|
+
if (options.date)
|
|
64
|
+
body.date = options.date;
|
|
65
|
+
if (options.noTimeStamp !== undefined)
|
|
66
|
+
body.noTimeStamp = options.noTimeStamp;
|
|
67
|
+
const res = await request("POST", "/blocks/daily-note/append", body);
|
|
68
|
+
if (!res.ok)
|
|
69
|
+
await throwApiError(res, "POST /blocks/daily-note/append");
|
|
70
|
+
}
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { promises as fs } from "node:fs";
|
|
3
|
+
import { Command } from "commander";
|
|
4
|
+
import { oauthLogin, tokenLogin } from "./auth.js";
|
|
5
|
+
import { appendToDailyNote, getSpace } from "./capacities.js";
|
|
6
|
+
import { formatWithoutLlm, LlmTransientError, summarise } from "./llm.js";
|
|
7
|
+
import { fetchAllSources } from "./sources.js";
|
|
8
|
+
import { CONFIG_PATH, loadConfig, loadState, loadTokens, localDateString, saveConfig, saveState, TOKENS_PATH, } from "./store.js";
|
|
9
|
+
const program = new Command();
|
|
10
|
+
program
|
|
11
|
+
.name("caps-brief")
|
|
12
|
+
.description("Auto-populate your Capacities Daily Note with an AI morning briefing.")
|
|
13
|
+
.version("0.1.0");
|
|
14
|
+
program
|
|
15
|
+
.command("auth")
|
|
16
|
+
.description("Sign in to Capacities (browser OAuth 2.1 PKCE flow, or --token for a personal API token)")
|
|
17
|
+
.option("--token <token>", "use a personal API token from Capacities → Settings → Capacities API")
|
|
18
|
+
.action(async (opts) => {
|
|
19
|
+
if (opts.token) {
|
|
20
|
+
await tokenLogin(opts.token);
|
|
21
|
+
}
|
|
22
|
+
else {
|
|
23
|
+
await oauthLogin();
|
|
24
|
+
}
|
|
25
|
+
try {
|
|
26
|
+
const space = await getSpace();
|
|
27
|
+
console.log(`Connected to space: "${space.title}" (${space.id})`);
|
|
28
|
+
}
|
|
29
|
+
catch (error) {
|
|
30
|
+
// Don't leave a credential on disk that the API just rejected.
|
|
31
|
+
await fs.rm(TOKENS_PATH, { force: true });
|
|
32
|
+
throw error;
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
program
|
|
36
|
+
.command("config")
|
|
37
|
+
.description("Manage caps-brief configuration (~/.caps-brief/config.json)")
|
|
38
|
+
.option("--add-rss <url>", "add an RSS/Atom feed URL")
|
|
39
|
+
.option("--remove-rss <url>", "remove a feed URL")
|
|
40
|
+
.option("--weather <location>", 'set the wttr.in weather location (e.g. "Singapore")')
|
|
41
|
+
.option("--list", "show the current configuration")
|
|
42
|
+
.action(async (opts) => {
|
|
43
|
+
const config = await loadConfig();
|
|
44
|
+
let changed = false;
|
|
45
|
+
if (opts.addRss) {
|
|
46
|
+
try {
|
|
47
|
+
new URL(opts.addRss);
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
throw new Error(`"${opts.addRss}" is not a valid URL.`);
|
|
51
|
+
}
|
|
52
|
+
if (config.rssFeeds.includes(opts.addRss)) {
|
|
53
|
+
console.log("Feed already configured.");
|
|
54
|
+
}
|
|
55
|
+
else {
|
|
56
|
+
config.rssFeeds.push(opts.addRss);
|
|
57
|
+
changed = true;
|
|
58
|
+
console.log(`Added feed: ${opts.addRss}`);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
if (opts.removeRss) {
|
|
62
|
+
const before = config.rssFeeds.length;
|
|
63
|
+
config.rssFeeds = config.rssFeeds.filter((u) => u !== opts.removeRss);
|
|
64
|
+
changed = config.rssFeeds.length !== before;
|
|
65
|
+
console.log(changed ? `Removed feed: ${opts.removeRss}` : "Feed was not configured.");
|
|
66
|
+
}
|
|
67
|
+
if (opts.weather !== undefined) {
|
|
68
|
+
config.weatherLocation = opts.weather;
|
|
69
|
+
changed = true;
|
|
70
|
+
console.log(`Weather location set to: ${opts.weather}`);
|
|
71
|
+
}
|
|
72
|
+
if (changed)
|
|
73
|
+
await saveConfig(config);
|
|
74
|
+
if (opts.list || (!opts.addRss && !opts.removeRss && opts.weather === undefined)) {
|
|
75
|
+
console.log(`Config file: ${CONFIG_PATH}`);
|
|
76
|
+
console.log(JSON.stringify(config, null, 2));
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
program
|
|
80
|
+
.command("status")
|
|
81
|
+
.description("Show auth state, connected space, sources, and last run")
|
|
82
|
+
.action(async () => {
|
|
83
|
+
const [tokens, config, state] = await Promise.all([loadTokens(), loadConfig(), loadState()]);
|
|
84
|
+
if (!tokens) {
|
|
85
|
+
console.log("Auth: ✖ not signed in — run: caps-brief auth");
|
|
86
|
+
}
|
|
87
|
+
else {
|
|
88
|
+
const method = tokens.authMethod === "oauth" ? "OAuth (auto-refreshing)" : "personal API token";
|
|
89
|
+
console.log(`Auth: ✔ ${method}`);
|
|
90
|
+
try {
|
|
91
|
+
const space = await getSpace();
|
|
92
|
+
console.log(`Space: "${space.title}" (${space.id})`);
|
|
93
|
+
}
|
|
94
|
+
catch (error) {
|
|
95
|
+
console.log(`Space: ✖ credential check failed — ${error instanceof Error ? error.message : error}`);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
console.log(`Claude: ${process.env.ANTHROPIC_API_KEY ? "✔ ANTHROPIC_API_KEY is set" : "✖ ANTHROPIC_API_KEY not set (runs need --no-llm)"}`);
|
|
99
|
+
console.log(`Sources: Hacker News top 10 (always on)`);
|
|
100
|
+
if (config.rssFeeds.length === 0) {
|
|
101
|
+
console.log(" no RSS feeds — add one: caps-brief config --add-rss <url>");
|
|
102
|
+
}
|
|
103
|
+
else {
|
|
104
|
+
for (const feed of config.rssFeeds)
|
|
105
|
+
console.log(` RSS: ${feed}`);
|
|
106
|
+
}
|
|
107
|
+
console.log(` Weather: ${config.weatherLocation || "(IP-based location)"}`);
|
|
108
|
+
console.log(`Last run: ${state.lastRunDate ? `${state.lastRunDate}${state.lastRunUsedLlm === false ? " (plain digest)" : ""}` : "never"}`);
|
|
109
|
+
});
|
|
110
|
+
program
|
|
111
|
+
.command("run")
|
|
112
|
+
.description("Fetch sources, build the briefing, and append it to today's Capacities Daily Note")
|
|
113
|
+
.option("--dry-run", "print the briefing instead of writing to Capacities")
|
|
114
|
+
.option("--no-llm", "skip Claude summarisation; write a plain formatted digest")
|
|
115
|
+
.option("--date <iso-date>", "target daily note date (YYYY-MM-DD, defaults to today)")
|
|
116
|
+
.option("--no-timestamp", "omit the timestamp heading Capacities adds to appended blocks")
|
|
117
|
+
.option("--force", "write even if a briefing was already sent today")
|
|
118
|
+
.action(async (opts) => {
|
|
119
|
+
if (opts.date && !/^\d{4}-\d{2}-\d{2}$/.test(opts.date)) {
|
|
120
|
+
throw new Error(`--date must be YYYY-MM-DD (got "${opts.date}").`);
|
|
121
|
+
}
|
|
122
|
+
// Once-per-day guard so an over-eager cron/Task Scheduler can't spam the note.
|
|
123
|
+
const today = localDateString();
|
|
124
|
+
const state = await loadState();
|
|
125
|
+
if (!opts.dryRun && !opts.force && !opts.date && state.lastRunDate === today) {
|
|
126
|
+
console.log(`Already briefed today (${today}) — nothing written. Use --force to send another.`);
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
const config = await loadConfig();
|
|
130
|
+
console.error("Fetching sources (Hacker News, RSS, weather)…");
|
|
131
|
+
const data = await fetchAllSources(config.rssFeeds, config.weatherLocation);
|
|
132
|
+
for (const w of data.warnings)
|
|
133
|
+
console.error(`⚠ ${w}`);
|
|
134
|
+
if (data.hackerNews.length === 0 && data.rss.length === 0 && !data.weather) {
|
|
135
|
+
throw new Error("All sources failed — refusing to write an empty briefing. Check your network.");
|
|
136
|
+
}
|
|
137
|
+
console.error(`Fetched: ${data.hackerNews.length} HN stories, ${data.rss.length} RSS items, weather ${data.weather ? "OK" : "unavailable"}.`);
|
|
138
|
+
let briefing;
|
|
139
|
+
let usedLlm = false;
|
|
140
|
+
if (opts.llm) {
|
|
141
|
+
console.error("Summarising with Claude (claude-haiku-4-5)…");
|
|
142
|
+
try {
|
|
143
|
+
briefing = await summarise(data);
|
|
144
|
+
usedLlm = true;
|
|
145
|
+
}
|
|
146
|
+
catch (error) {
|
|
147
|
+
if (error instanceof LlmTransientError) {
|
|
148
|
+
// Cron reliability beats purity: deliver the plain digest rather than nothing.
|
|
149
|
+
console.error(`⚠ ${error.message} Falling back to the plain digest for today.`);
|
|
150
|
+
briefing = formatWithoutLlm(data);
|
|
151
|
+
}
|
|
152
|
+
else {
|
|
153
|
+
throw error;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
else {
|
|
158
|
+
briefing = formatWithoutLlm(data);
|
|
159
|
+
}
|
|
160
|
+
const content = `## ☀️ Morning Briefing\n\n${briefing}`;
|
|
161
|
+
if (opts.dryRun) {
|
|
162
|
+
console.log(content);
|
|
163
|
+
console.error("\n(dry run — nothing was written to Capacities)");
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
console.error("Appending to your Capacities Daily Note…");
|
|
167
|
+
await appendToDailyNote(content, {
|
|
168
|
+
date: opts.date,
|
|
169
|
+
noTimeStamp: opts.timestamp ? undefined : true,
|
|
170
|
+
});
|
|
171
|
+
if (!opts.date) {
|
|
172
|
+
await saveState({ lastRunDate: today, lastRunUsedLlm: usedLlm });
|
|
173
|
+
}
|
|
174
|
+
const spaceTitle = await getSpace()
|
|
175
|
+
.then((s) => ` in "${s.title}"`)
|
|
176
|
+
.catch(() => "");
|
|
177
|
+
console.log(`✅ Briefing appended to ${opts.date ?? "today"}'s Daily Note${spaceTitle}.`);
|
|
178
|
+
});
|
|
179
|
+
program.parseAsync().catch((error) => {
|
|
180
|
+
console.error(`\n✖ ${error instanceof Error ? error.message : String(error)}`);
|
|
181
|
+
process.exitCode = 1;
|
|
182
|
+
});
|
package/dist/llm.js
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import Anthropic from "@anthropic-ai/sdk";
|
|
2
|
+
const MODEL = "claude-haiku-4-5";
|
|
3
|
+
/** Setup problem (missing/invalid key) — the user must act; never silently degrade. */
|
|
4
|
+
export class LlmConfigError extends Error {
|
|
5
|
+
}
|
|
6
|
+
/** Transient problem (rate limit, network, 5xx) — safe to fall back to the plain digest. */
|
|
7
|
+
export class LlmTransientError extends Error {
|
|
8
|
+
}
|
|
9
|
+
const SYSTEM_PROMPT = `You write a concise morning briefing (about 120 words of prose, excluding links) for a personal knowledge base.
|
|
10
|
+
Output Markdown with exactly these sections, in this order:
|
|
11
|
+
|
|
12
|
+
### Top Stories
|
|
13
|
+
2-4 sentences synthesizing the day's Hacker News front page: the big themes, not a list. Then the three most notable stories as a Markdown bullet list of [title](url) links.
|
|
14
|
+
|
|
15
|
+
### RSS Highlights
|
|
16
|
+
1-3 sentences on the user's subscribed feeds, plus up to three [title](url) bullet links. If no RSS items were provided, write "No feed items today." and nothing else in this section.
|
|
17
|
+
|
|
18
|
+
### Weather
|
|
19
|
+
One short line based on the provided weather string. If none was provided, write "Weather unavailable."
|
|
20
|
+
|
|
21
|
+
Rules: no preamble, no closing remarks, no H1/H2 headings, keep links exactly as given, never invent stories or links.`;
|
|
22
|
+
/** Summarise fetched sources into a briefing via Claude Haiku. Requires ANTHROPIC_API_KEY. */
|
|
23
|
+
export async function summarise(data) {
|
|
24
|
+
if (!process.env.ANTHROPIC_API_KEY) {
|
|
25
|
+
throw new LlmConfigError("ANTHROPIC_API_KEY is not set. Export it (https://platform.claude.com) or run with --no-llm for an unsummarised briefing.");
|
|
26
|
+
}
|
|
27
|
+
const client = new Anthropic();
|
|
28
|
+
const payload = {
|
|
29
|
+
hackerNews: data.hackerNews.map((s) => ({
|
|
30
|
+
title: s.title,
|
|
31
|
+
url: s.url,
|
|
32
|
+
points: s.points,
|
|
33
|
+
comments: s.comments,
|
|
34
|
+
})),
|
|
35
|
+
rss: data.rss,
|
|
36
|
+
weather: data.weather,
|
|
37
|
+
};
|
|
38
|
+
try {
|
|
39
|
+
const response = await client.messages.create({
|
|
40
|
+
model: MODEL,
|
|
41
|
+
max_tokens: 1024,
|
|
42
|
+
system: SYSTEM_PROMPT,
|
|
43
|
+
messages: [
|
|
44
|
+
{
|
|
45
|
+
role: "user",
|
|
46
|
+
content: `Here is today's fetched data as JSON:\n\n${JSON.stringify(payload, null, 2)}`,
|
|
47
|
+
},
|
|
48
|
+
],
|
|
49
|
+
});
|
|
50
|
+
const text = response.content
|
|
51
|
+
.filter((b) => b.type === "text")
|
|
52
|
+
.map((b) => b.text)
|
|
53
|
+
.join("\n")
|
|
54
|
+
.trim();
|
|
55
|
+
if (!text)
|
|
56
|
+
throw new LlmTransientError("Claude returned an empty briefing.");
|
|
57
|
+
return text;
|
|
58
|
+
}
|
|
59
|
+
catch (error) {
|
|
60
|
+
if (error instanceof Anthropic.AuthenticationError) {
|
|
61
|
+
throw new LlmConfigError("Anthropic rejected the API key (401). Check ANTHROPIC_API_KEY.");
|
|
62
|
+
}
|
|
63
|
+
if (error instanceof Anthropic.PermissionDeniedError) {
|
|
64
|
+
throw new LlmConfigError("Anthropic key lacks permission (403). Check your key/plan at platform.claude.com.");
|
|
65
|
+
}
|
|
66
|
+
if (error instanceof Anthropic.RateLimitError) {
|
|
67
|
+
throw new LlmTransientError("Anthropic rate limit hit (429).");
|
|
68
|
+
}
|
|
69
|
+
if (error instanceof Anthropic.APIConnectionError) {
|
|
70
|
+
throw new LlmTransientError("Could not reach the Anthropic API (network error).");
|
|
71
|
+
}
|
|
72
|
+
if (error instanceof Anthropic.APIError) {
|
|
73
|
+
const status = error.status ?? 0;
|
|
74
|
+
const message = `Anthropic API error ${status}: ${error.message}`;
|
|
75
|
+
throw status >= 500 ? new LlmTransientError(message) : new LlmConfigError(message);
|
|
76
|
+
}
|
|
77
|
+
throw error;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
/** Deterministic briefing without an LLM — used by --no-llm and as an offline fallback. */
|
|
81
|
+
export function formatWithoutLlm(data) {
|
|
82
|
+
const lines = [];
|
|
83
|
+
lines.push("### Top Stories");
|
|
84
|
+
if (data.hackerNews.length === 0) {
|
|
85
|
+
lines.push("Hacker News unavailable today.");
|
|
86
|
+
}
|
|
87
|
+
else {
|
|
88
|
+
for (const s of data.hackerNews.slice(0, 10)) {
|
|
89
|
+
lines.push(`- [${s.title}](${s.url}) — ${s.points} points, ${s.comments} comments`);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
lines.push("", "### RSS Highlights");
|
|
93
|
+
if (data.rss.length === 0) {
|
|
94
|
+
lines.push("No feed items today.");
|
|
95
|
+
}
|
|
96
|
+
else {
|
|
97
|
+
for (const item of data.rss) {
|
|
98
|
+
lines.push(`- **${item.feedTitle}**: [${item.title}](${item.link})`);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
lines.push("", "### Weather");
|
|
102
|
+
lines.push(data.weather ?? "Weather unavailable.");
|
|
103
|
+
return lines.join("\n");
|
|
104
|
+
}
|
package/dist/sources.js
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import fetch from "node-fetch";
|
|
2
|
+
import { XMLParser } from "fast-xml-parser";
|
|
3
|
+
const HN_URL = "https://hn.algolia.com/api/v1/search?tags=front_page&hitsPerPage=10";
|
|
4
|
+
const FETCH_TIMEOUT_MS = 20_000;
|
|
5
|
+
const ITEMS_PER_FEED = 5;
|
|
6
|
+
function withTimeout() {
|
|
7
|
+
const controller = new AbortController();
|
|
8
|
+
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
|
9
|
+
return { signal: controller.signal, done: () => clearTimeout(timer) };
|
|
10
|
+
}
|
|
11
|
+
export async function fetchHackerNews() {
|
|
12
|
+
const t = withTimeout();
|
|
13
|
+
try {
|
|
14
|
+
const res = await fetch(HN_URL, { signal: t.signal });
|
|
15
|
+
if (!res.ok)
|
|
16
|
+
throw new Error(`HN Algolia responded ${res.status}`);
|
|
17
|
+
const data = (await res.json());
|
|
18
|
+
return data.hits.map((h) => ({
|
|
19
|
+
title: h.title ?? "(untitled)",
|
|
20
|
+
url: h.url ?? `https://news.ycombinator.com/item?id=${h.objectID}`,
|
|
21
|
+
points: h.points ?? 0,
|
|
22
|
+
comments: h.num_comments ?? 0,
|
|
23
|
+
}));
|
|
24
|
+
}
|
|
25
|
+
finally {
|
|
26
|
+
t.done();
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
/** Feeds often double-encode entities; decode numeric and common named ones. */
|
|
30
|
+
function decodeEntities(s) {
|
|
31
|
+
const named = { amp: "&", lt: "<", gt: ">", quot: '"', apos: "'", nbsp: " " };
|
|
32
|
+
return s
|
|
33
|
+
.replace(/&#(\d+);/g, (_, d) => String.fromCodePoint(parseInt(d, 10)))
|
|
34
|
+
.replace(/&#x([0-9a-fA-F]+);/g, (_, h) => String.fromCodePoint(parseInt(h, 16)))
|
|
35
|
+
.replace(/&([a-z]+);/g, (m, name) => named[name] ?? m);
|
|
36
|
+
}
|
|
37
|
+
/** Normalize a parsed value that may be a string, a { "#text": ... } node, or a CDATA node. */
|
|
38
|
+
function text(v) {
|
|
39
|
+
if (typeof v === "string")
|
|
40
|
+
return decodeEntities(v.trim());
|
|
41
|
+
if (typeof v === "number")
|
|
42
|
+
return String(v);
|
|
43
|
+
if (v && typeof v === "object") {
|
|
44
|
+
const o = v;
|
|
45
|
+
if (typeof o["#text"] === "string")
|
|
46
|
+
return decodeEntities(o["#text"].trim());
|
|
47
|
+
if (typeof o["__cdata"] === "string")
|
|
48
|
+
return decodeEntities(o["__cdata"].trim());
|
|
49
|
+
}
|
|
50
|
+
return "";
|
|
51
|
+
}
|
|
52
|
+
/** Atom links can be a single object or an array; prefer rel="alternate". */
|
|
53
|
+
function atomLink(link) {
|
|
54
|
+
const links = Array.isArray(link) ? link : [link];
|
|
55
|
+
const objs = links
|
|
56
|
+
.filter((l) => !!l && typeof l === "object")
|
|
57
|
+
.map((l) => l);
|
|
58
|
+
const alt = objs.find((l) => !l["@_rel"] || l["@_rel"] === "alternate");
|
|
59
|
+
return alt?.["@_href"] ?? objs[0]?.["@_href"] ?? "";
|
|
60
|
+
}
|
|
61
|
+
/** Parse RSS 2.0 or Atom XML into items. Pure function — exported for tests. */
|
|
62
|
+
export function parseFeed(xml, url) {
|
|
63
|
+
const parser = new XMLParser({ ignoreAttributes: false, cdataPropName: "__cdata" });
|
|
64
|
+
const doc = parser.parse(xml);
|
|
65
|
+
// RSS 2.0
|
|
66
|
+
if (doc.rss?.channel) {
|
|
67
|
+
const channel = doc.rss.channel;
|
|
68
|
+
const feedTitle = text(channel.title) || url;
|
|
69
|
+
const items = Array.isArray(channel.item) ? channel.item : channel.item ? [channel.item] : [];
|
|
70
|
+
return items.slice(0, ITEMS_PER_FEED).map((it) => ({
|
|
71
|
+
feedTitle,
|
|
72
|
+
title: text(it.title) || "(untitled)",
|
|
73
|
+
link: text(it.link),
|
|
74
|
+
}));
|
|
75
|
+
}
|
|
76
|
+
// Atom
|
|
77
|
+
if (doc.feed) {
|
|
78
|
+
const feedTitle = text(doc.feed.title) || url;
|
|
79
|
+
const entries = Array.isArray(doc.feed.entry) ? doc.feed.entry : doc.feed.entry ? [doc.feed.entry] : [];
|
|
80
|
+
return entries.slice(0, ITEMS_PER_FEED).map((e) => ({
|
|
81
|
+
feedTitle,
|
|
82
|
+
title: text(e.title) || "(untitled)",
|
|
83
|
+
link: atomLink(e.link),
|
|
84
|
+
}));
|
|
85
|
+
}
|
|
86
|
+
throw new Error("not a recognizable RSS 2.0 or Atom feed");
|
|
87
|
+
}
|
|
88
|
+
export async function fetchRssFeed(url) {
|
|
89
|
+
const t = withTimeout();
|
|
90
|
+
try {
|
|
91
|
+
const res = await fetch(url, {
|
|
92
|
+
signal: t.signal,
|
|
93
|
+
headers: { "User-Agent": "caps-brief/0.1 (+https://capacities.io daily briefing CLI)" },
|
|
94
|
+
});
|
|
95
|
+
if (!res.ok)
|
|
96
|
+
throw new Error(`feed responded ${res.status}`);
|
|
97
|
+
return parseFeed(await res.text(), url);
|
|
98
|
+
}
|
|
99
|
+
finally {
|
|
100
|
+
t.done();
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
export async function fetchWeather(location) {
|
|
104
|
+
const t = withTimeout();
|
|
105
|
+
try {
|
|
106
|
+
const url = `https://wttr.in/${encodeURIComponent(location ?? "")}?format=3`;
|
|
107
|
+
const res = await fetch(url, { signal: t.signal, headers: { "User-Agent": "curl/8" } });
|
|
108
|
+
if (!res.ok)
|
|
109
|
+
throw new Error(`wttr.in responded ${res.status}`);
|
|
110
|
+
const line = (await res.text()).trim();
|
|
111
|
+
if (!line || line.toLowerCase().includes("unknown location")) {
|
|
112
|
+
throw new Error(`wttr.in could not resolve location "${location}"`);
|
|
113
|
+
}
|
|
114
|
+
return line;
|
|
115
|
+
}
|
|
116
|
+
finally {
|
|
117
|
+
t.done();
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
/** Fetch all sources concurrently; individual failures become warnings, never a crash. */
|
|
121
|
+
export async function fetchAllSources(rssFeeds, weatherLocation) {
|
|
122
|
+
const warnings = [];
|
|
123
|
+
const [hnResult, weatherResult, ...rssResults] = await Promise.allSettled([
|
|
124
|
+
fetchHackerNews(),
|
|
125
|
+
fetchWeather(weatherLocation),
|
|
126
|
+
...rssFeeds.map((u) => fetchRssFeed(u)),
|
|
127
|
+
]);
|
|
128
|
+
const hackerNews = hnResult.status === "fulfilled" ? hnResult.value : [];
|
|
129
|
+
if (hnResult.status === "rejected")
|
|
130
|
+
warnings.push(`Hacker News: ${hnResult.reason}`);
|
|
131
|
+
const weather = weatherResult.status === "fulfilled" ? weatherResult.value : null;
|
|
132
|
+
if (weatherResult.status === "rejected")
|
|
133
|
+
warnings.push(`Weather: ${weatherResult.reason}`);
|
|
134
|
+
const rss = [];
|
|
135
|
+
rssResults.forEach((r, i) => {
|
|
136
|
+
if (r.status === "fulfilled")
|
|
137
|
+
rss.push(...r.value);
|
|
138
|
+
else
|
|
139
|
+
warnings.push(`RSS ${rssFeeds[i]}: ${r.reason}`);
|
|
140
|
+
});
|
|
141
|
+
return { hackerNews, rss, weather, warnings };
|
|
142
|
+
}
|
package/dist/store.js
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { promises as fs } from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
export const CONFIG_DIR = path.join(os.homedir(), ".caps-brief");
|
|
5
|
+
export const TOKENS_PATH = path.join(CONFIG_DIR, "tokens.json");
|
|
6
|
+
export const CONFIG_PATH = path.join(CONFIG_DIR, "config.json");
|
|
7
|
+
export const CLIENT_PATH = path.join(CONFIG_DIR, "client.json");
|
|
8
|
+
export const STATE_PATH = path.join(CONFIG_DIR, "state.json");
|
|
9
|
+
async function ensureDir() {
|
|
10
|
+
await fs.mkdir(CONFIG_DIR, { recursive: true });
|
|
11
|
+
}
|
|
12
|
+
async function readJson(file) {
|
|
13
|
+
try {
|
|
14
|
+
// Strip a UTF-8 BOM — Notepad adds one when users hand-edit config.json.
|
|
15
|
+
const raw = (await fs.readFile(file, "utf8")).replace(/^/, "");
|
|
16
|
+
return JSON.parse(raw);
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
async function writeJson(file, data) {
|
|
23
|
+
await ensureDir();
|
|
24
|
+
await fs.writeFile(file, JSON.stringify(data, null, 2) + "\n", {
|
|
25
|
+
encoding: "utf8",
|
|
26
|
+
mode: 0o600,
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
export const loadTokens = () => readJson(TOKENS_PATH);
|
|
30
|
+
export const saveTokens = (t) => writeJson(TOKENS_PATH, t);
|
|
31
|
+
export const loadClient = () => readJson(CLIENT_PATH);
|
|
32
|
+
export const saveClient = (c) => writeJson(CLIENT_PATH, c);
|
|
33
|
+
export async function loadConfig() {
|
|
34
|
+
return (await readJson(CONFIG_PATH)) ?? { rssFeeds: [] };
|
|
35
|
+
}
|
|
36
|
+
export const saveConfig = (c) => writeJson(CONFIG_PATH, c);
|
|
37
|
+
export async function loadState() {
|
|
38
|
+
return (await readJson(STATE_PATH)) ?? {};
|
|
39
|
+
}
|
|
40
|
+
export const saveState = (s) => writeJson(STATE_PATH, s);
|
|
41
|
+
/** Today's date in the machine's local timezone as YYYY-MM-DD. */
|
|
42
|
+
export function localDateString(d = new Date()) {
|
|
43
|
+
const y = d.getFullYear();
|
|
44
|
+
const m = String(d.getMonth() + 1).padStart(2, "0");
|
|
45
|
+
const day = String(d.getDate()).padStart(2, "0");
|
|
46
|
+
return `${y}-${m}-${day}`;
|
|
47
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "caps-brief",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Auto-populate your Capacities Daily Note with an AI morning briefing (Hacker News, RSS, weather) via the Capacities API 2.0.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"caps-brief": "./dist/cli.js"
|
|
8
|
+
},
|
|
9
|
+
"main": "./dist/cli.js",
|
|
10
|
+
"files": [
|
|
11
|
+
"dist",
|
|
12
|
+
"README.md",
|
|
13
|
+
"SETUP.md",
|
|
14
|
+
"CHANGELOG.md"
|
|
15
|
+
],
|
|
16
|
+
"engines": {
|
|
17
|
+
"node": ">=22"
|
|
18
|
+
},
|
|
19
|
+
"scripts": {
|
|
20
|
+
"build": "tsc",
|
|
21
|
+
"test": "npm run build && node --test test/parse.test.mjs",
|
|
22
|
+
"prepack": "npm run build"
|
|
23
|
+
},
|
|
24
|
+
"author": "Booyaka101",
|
|
25
|
+
"repository": {
|
|
26
|
+
"type": "git",
|
|
27
|
+
"url": "git+https://github.com/Booyaka101/caps-brief.git"
|
|
28
|
+
},
|
|
29
|
+
"bugs": {
|
|
30
|
+
"url": "https://github.com/Booyaka101/caps-brief/issues"
|
|
31
|
+
},
|
|
32
|
+
"homepage": "https://github.com/Booyaka101/caps-brief#readme",
|
|
33
|
+
"keywords": [
|
|
34
|
+
"capacities",
|
|
35
|
+
"daily-note",
|
|
36
|
+
"briefing",
|
|
37
|
+
"cli",
|
|
38
|
+
"oauth",
|
|
39
|
+
"pkce",
|
|
40
|
+
"claude",
|
|
41
|
+
"hacker-news",
|
|
42
|
+
"rss"
|
|
43
|
+
],
|
|
44
|
+
"license": "MIT",
|
|
45
|
+
"dependencies": {
|
|
46
|
+
"@anthropic-ai/sdk": "^0.111.0",
|
|
47
|
+
"commander": "^12.1.0",
|
|
48
|
+
"fast-xml-parser": "^5.10.1",
|
|
49
|
+
"node-fetch": "^3.3.2",
|
|
50
|
+
"open": "^10.1.0"
|
|
51
|
+
},
|
|
52
|
+
"devDependencies": {
|
|
53
|
+
"@types/node": "^22.10.0",
|
|
54
|
+
"typescript": "^5.7.0"
|
|
55
|
+
}
|
|
56
|
+
}
|