claudenews 1.0.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/README.md +97 -0
- package/bin/claudenews.js +3 -0
- package/dist/app.d.ts +1 -0
- package/dist/app.js +80 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +40 -0
- package/dist/hooks/on-pre-tool-use.d.ts +1 -0
- package/dist/hooks/on-pre-tool-use.js +14 -0
- package/dist/hooks/on-session-start.d.ts +1 -0
- package/dist/hooks/on-session-start.js +11 -0
- package/dist/lib/config.d.ts +10 -0
- package/dist/lib/config.js +29 -0
- package/dist/lib/refresh.d.ts +5 -0
- package/dist/lib/refresh.js +23 -0
- package/dist/lib/settings.d.ts +4 -0
- package/dist/lib/settings.js +69 -0
- package/dist/lib/sources/devto.d.ts +3 -0
- package/dist/lib/sources/devto.js +20 -0
- package/dist/lib/sources/github-trending.d.ts +3 -0
- package/dist/lib/sources/github-trending.js +23 -0
- package/dist/lib/sources/hackernews.d.ts +3 -0
- package/dist/lib/sources/hackernews.js +24 -0
- package/dist/lib/sources/index.d.ts +4 -0
- package/dist/lib/sources/index.js +27 -0
- package/dist/lib/sources/lobsters.d.ts +3 -0
- package/dist/lib/sources/lobsters.js +20 -0
- package/dist/lib/sources/reddit.d.ts +3 -0
- package/dist/lib/sources/reddit.js +28 -0
- package/dist/lib/types.d.ts +12 -0
- package/dist/lib/types.js +1 -0
- package/package.json +46 -0
package/README.md
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# claudenews
|
|
2
|
+
|
|
3
|
+
News headlines in your Claude Code spinner. Instead of "Thinking..." you get real headlines from Hacker News, Reddit, Lobsters, dev.to, and GitHub Trending.
|
|
4
|
+
|
|
5
|
+
```
|
|
6
|
+
* [HN] UUID package coming to Go standard library
|
|
7
|
+
● Searching for 1 pattern, reading 7 files...
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
## Install
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
npx claudenews
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
Or install globally:
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
npm install -g claudenews
|
|
20
|
+
claudenews
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
That's it. First run automatically:
|
|
24
|
+
- Creates config at `~/.claudenews/config.json`
|
|
25
|
+
- Fetches headlines from your enabled sources
|
|
26
|
+
- Installs Claude Code hooks for automatic refresh
|
|
27
|
+
- Writes headlines to your spinner
|
|
28
|
+
|
|
29
|
+
## Pick your sources
|
|
30
|
+
|
|
31
|
+
Run `claudenews` to open the source picker:
|
|
32
|
+
|
|
33
|
+
```
|
|
34
|
+
claudenews — pick your news sources
|
|
35
|
+
|
|
36
|
+
> [x] [HN] Hacker News news.ycombinator.com
|
|
37
|
+
[ ] [/r] Reddit reddit.com
|
|
38
|
+
[ ] [🦞] Lobsters lobste.rs
|
|
39
|
+
[ ] [dev] dev.to dev.to
|
|
40
|
+
[ ] [GH] GitHub Trending github.com/trending
|
|
41
|
+
|
|
42
|
+
[Space] Toggle [r] Refresh [q] Quit built by souls.zip
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Headlines show up in your Claude Code spinner with source prefixes:
|
|
46
|
+
|
|
47
|
+
- `[HN]` — Hacker News
|
|
48
|
+
- `[/r]` — Reddit (r/programming, r/technology)
|
|
49
|
+
- `[🦞]` — Lobsters
|
|
50
|
+
- `[dev]` — dev.to
|
|
51
|
+
- `[GH]` — GitHub Trending
|
|
52
|
+
|
|
53
|
+
## How it works
|
|
54
|
+
|
|
55
|
+
- **Session start hook** fetches fresh headlines every time you start Claude Code
|
|
56
|
+
- **Pre-tool-use hook** refreshes if headlines are older than 30 minutes
|
|
57
|
+
- Headlines are written to `spinnerVerbs` in `~/.claude/settings.json`
|
|
58
|
+
- Claude Code reads spinner verbs at session startup
|
|
59
|
+
|
|
60
|
+
## Configuration
|
|
61
|
+
|
|
62
|
+
Config lives at `~/.claudenews/config.json`:
|
|
63
|
+
|
|
64
|
+
```json
|
|
65
|
+
{
|
|
66
|
+
"headlineCount": 15,
|
|
67
|
+
"refreshIntervalMinutes": 30,
|
|
68
|
+
"enabledSources": ["hackernews"],
|
|
69
|
+
"redditSubs": ["programming", "technology"]
|
|
70
|
+
}
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
- **headlineCount** — headlines per source (default: 15)
|
|
74
|
+
- **refreshIntervalMinutes** — how often to fetch new headlines (default: 30)
|
|
75
|
+
- **enabledSources** — which sources to pull from
|
|
76
|
+
- **redditSubs** — subreddits to include when Reddit is enabled
|
|
77
|
+
|
|
78
|
+
## Uninstall
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
claudenews --uninstall
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
Removes hooks, restores default spinner, and deletes config.
|
|
85
|
+
|
|
86
|
+
## Requirements
|
|
87
|
+
|
|
88
|
+
- Node.js 18+
|
|
89
|
+
- Claude Code
|
|
90
|
+
|
|
91
|
+
## License
|
|
92
|
+
|
|
93
|
+
MIT
|
|
94
|
+
|
|
95
|
+
---
|
|
96
|
+
|
|
97
|
+
Built by [souls.zip](https://souls.zip)
|
package/dist/app.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export default function App(): import("react/jsx-runtime").JSX.Element;
|
package/dist/app.js
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useState, useEffect } from 'react';
|
|
3
|
+
import { Box, Text, useInput, useApp } from 'ink';
|
|
4
|
+
import { readConfig, writeConfig } from './lib/config.js';
|
|
5
|
+
import { getAllSources } from './lib/sources/index.js';
|
|
6
|
+
import { refresh } from './lib/refresh.js';
|
|
7
|
+
export default function App() {
|
|
8
|
+
const { exit } = useApp();
|
|
9
|
+
const [sources, setSources] = useState([]);
|
|
10
|
+
const [enabled, setEnabled] = useState([]);
|
|
11
|
+
const [cursor, setCursor] = useState(0);
|
|
12
|
+
const [status, setStatus] = useState('');
|
|
13
|
+
const [refreshing, setRefreshing] = useState(false);
|
|
14
|
+
useEffect(() => {
|
|
15
|
+
(async () => {
|
|
16
|
+
const config = await readConfig();
|
|
17
|
+
setEnabled(config.enabledSources);
|
|
18
|
+
setSources(getAllSources());
|
|
19
|
+
// Auto-refresh on open
|
|
20
|
+
try {
|
|
21
|
+
await refresh();
|
|
22
|
+
setStatus('Headlines updated — restart Claude Code to apply');
|
|
23
|
+
}
|
|
24
|
+
catch { }
|
|
25
|
+
})();
|
|
26
|
+
}, []);
|
|
27
|
+
// Periodic refresh
|
|
28
|
+
useEffect(() => {
|
|
29
|
+
const id = setInterval(async () => {
|
|
30
|
+
try {
|
|
31
|
+
await refresh();
|
|
32
|
+
setStatus('Headlines updated — restart Claude Code to apply');
|
|
33
|
+
}
|
|
34
|
+
catch { }
|
|
35
|
+
}, 30 * 60 * 1000);
|
|
36
|
+
return () => clearInterval(id);
|
|
37
|
+
}, []);
|
|
38
|
+
useInput((input, key) => {
|
|
39
|
+
if (input === 'q')
|
|
40
|
+
return exit();
|
|
41
|
+
if (key.upArrow || input === 'k')
|
|
42
|
+
setCursor(i => Math.max(0, i - 1));
|
|
43
|
+
if (key.downArrow || input === 'j')
|
|
44
|
+
setCursor(i => Math.min(sources.length - 1, i + 1));
|
|
45
|
+
if (input === ' ') {
|
|
46
|
+
const src = sources[cursor];
|
|
47
|
+
if (!src)
|
|
48
|
+
return;
|
|
49
|
+
setEnabled(prev => {
|
|
50
|
+
const next = prev.includes(src.id)
|
|
51
|
+
? prev.filter(id => id !== src.id)
|
|
52
|
+
: [...prev, src.id];
|
|
53
|
+
setStatus('Saving...');
|
|
54
|
+
readConfig().then(config => {
|
|
55
|
+
writeConfig({ ...config, enabledSources: next })
|
|
56
|
+
.then(() => refresh())
|
|
57
|
+
.then(() => setStatus('Updated — restart Claude Code to apply'))
|
|
58
|
+
.catch(() => setStatus('Failed to refresh'));
|
|
59
|
+
});
|
|
60
|
+
return next;
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
if (input === 'r' && !refreshing) {
|
|
64
|
+
setRefreshing(true);
|
|
65
|
+
setStatus('Refreshing headlines...');
|
|
66
|
+
refresh()
|
|
67
|
+
.then(() => setStatus('Refreshed — restart Claude Code to apply'))
|
|
68
|
+
.catch(() => setStatus('Failed to refresh'))
|
|
69
|
+
.finally(() => setRefreshing(false));
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
if (sources.length === 0) {
|
|
73
|
+
return _jsx(Text, { dimColor: true, children: "Loading..." });
|
|
74
|
+
}
|
|
75
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { marginBottom: 1, children: [_jsx(Text, { bold: true, children: "claudenews" }), _jsx(Text, { dimColor: true, children: " \u2014 pick your news sources" })] }), sources.map((src, i) => {
|
|
76
|
+
const active = i === cursor;
|
|
77
|
+
const on = enabled.includes(src.id);
|
|
78
|
+
return (_jsxs(Box, { children: [_jsxs(Text, { bold: active, children: [active ? '> ' : ' ', on ? '[x]' : '[ ]', " ", src.prefix, " ", src.name] }), _jsxs(Text, { dimColor: true, children: [" ", src.url] })] }, src.id));
|
|
79
|
+
}), _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: "[Space] Toggle [r] Refresh [q] Quit | by souls.zip & @tolibear_" }) }), status && (_jsx(Box, { marginTop: 0, children: _jsx(Text, { dimColor: true, children: status }) }))] }));
|
|
80
|
+
}
|
package/dist/cli.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { mkdir, rm } from 'node:fs/promises';
|
|
3
|
+
import React from 'react';
|
|
4
|
+
import { render } from 'ink';
|
|
5
|
+
import { getConfigDir, readConfig, writeConfig } from './lib/config.js';
|
|
6
|
+
import { refresh } from './lib/refresh.js';
|
|
7
|
+
import { installHooks, removeHooks, restoreDefaultVerbs } from './lib/settings.js';
|
|
8
|
+
import App from './app.js';
|
|
9
|
+
async function autoSetup() {
|
|
10
|
+
const configPath = `${getConfigDir()}/config.json`;
|
|
11
|
+
if (existsSync(configPath))
|
|
12
|
+
return;
|
|
13
|
+
await mkdir(getConfigDir(), { recursive: true });
|
|
14
|
+
await writeConfig(await readConfig());
|
|
15
|
+
try {
|
|
16
|
+
await refresh();
|
|
17
|
+
}
|
|
18
|
+
catch { }
|
|
19
|
+
await installHooks();
|
|
20
|
+
}
|
|
21
|
+
async function uninstall() {
|
|
22
|
+
console.log('Uninstalling claudenews...\n');
|
|
23
|
+
await removeHooks();
|
|
24
|
+
await restoreDefaultVerbs();
|
|
25
|
+
try {
|
|
26
|
+
await rm(getConfigDir(), { recursive: true });
|
|
27
|
+
console.log(`Removed ${getConfigDir()}`);
|
|
28
|
+
}
|
|
29
|
+
catch { }
|
|
30
|
+
console.log('Done! Spinner restored to defaults.');
|
|
31
|
+
}
|
|
32
|
+
async function main() {
|
|
33
|
+
if (process.argv.includes('--uninstall')) {
|
|
34
|
+
await uninstall();
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
await autoSetup();
|
|
38
|
+
render(React.createElement(App));
|
|
39
|
+
}
|
|
40
|
+
main();
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { readConfig } from '../lib/config.js';
|
|
2
|
+
import { refresh, isStale } from '../lib/refresh.js';
|
|
3
|
+
async function main() {
|
|
4
|
+
let input = '';
|
|
5
|
+
for await (const chunk of process.stdin)
|
|
6
|
+
input += chunk;
|
|
7
|
+
try {
|
|
8
|
+
const config = await readConfig();
|
|
9
|
+
if (isStale(config))
|
|
10
|
+
await refresh();
|
|
11
|
+
}
|
|
12
|
+
catch { }
|
|
13
|
+
}
|
|
14
|
+
main();
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export interface Config {
|
|
2
|
+
headlineCount: number;
|
|
3
|
+
refreshIntervalMinutes: number;
|
|
4
|
+
enabledSources: string[];
|
|
5
|
+
redditSubs: string[];
|
|
6
|
+
lastRefresh?: string;
|
|
7
|
+
}
|
|
8
|
+
export declare function getConfigDir(): string;
|
|
9
|
+
export declare function readConfig(): Promise<Config>;
|
|
10
|
+
export declare function writeConfig(config: Config): Promise<void>;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { readFile, writeFile, mkdir } from 'node:fs/promises';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
const defaultConfig = {
|
|
5
|
+
headlineCount: 15,
|
|
6
|
+
refreshIntervalMinutes: 30,
|
|
7
|
+
enabledSources: ['hackernews'],
|
|
8
|
+
redditSubs: ['programming', 'technology'],
|
|
9
|
+
};
|
|
10
|
+
export function getConfigDir() {
|
|
11
|
+
return join(homedir(), '.claudenews');
|
|
12
|
+
}
|
|
13
|
+
function getConfigPath() {
|
|
14
|
+
return join(getConfigDir(), 'config.json');
|
|
15
|
+
}
|
|
16
|
+
export async function readConfig() {
|
|
17
|
+
try {
|
|
18
|
+
const raw = await readFile(getConfigPath(), 'utf-8');
|
|
19
|
+
return { ...defaultConfig, ...JSON.parse(raw) };
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
return { ...defaultConfig };
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
export async function writeConfig(config) {
|
|
26
|
+
const dir = getConfigDir();
|
|
27
|
+
await mkdir(dir, { recursive: true });
|
|
28
|
+
await writeFile(getConfigPath(), JSON.stringify(config, null, 2) + '\n');
|
|
29
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { readConfig, writeConfig } from './config.js';
|
|
2
|
+
import { fetchAllHeadlines, getPrefix } from './sources/index.js';
|
|
3
|
+
import { writeSpinnerVerbs } from './settings.js';
|
|
4
|
+
export async function refresh() {
|
|
5
|
+
const config = await readConfig();
|
|
6
|
+
const headlines = await fetchAllHeadlines(config.enabledSources, config.headlineCount);
|
|
7
|
+
const MAX_LEN = 80;
|
|
8
|
+
const verbs = headlines
|
|
9
|
+
.map(h => {
|
|
10
|
+
const prefix = getPrefix(h.source);
|
|
11
|
+
const full = `${prefix} ${h.title}`;
|
|
12
|
+
return full.length > MAX_LEN ? `${full.slice(0, MAX_LEN - 1)}…` : full;
|
|
13
|
+
})
|
|
14
|
+
.filter(v => v.length > 0);
|
|
15
|
+
await writeSpinnerVerbs(verbs);
|
|
16
|
+
await writeConfig({ ...config, lastRefresh: new Date().toISOString() });
|
|
17
|
+
}
|
|
18
|
+
export function isStale(config) {
|
|
19
|
+
if (!config.lastRefresh)
|
|
20
|
+
return true;
|
|
21
|
+
const age = Date.now() - new Date(config.lastRefresh).getTime();
|
|
22
|
+
return age > config.refreshIntervalMinutes * 60 * 1000;
|
|
23
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { readFile, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { join, resolve, dirname } from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
const SETTINGS_PATH = join(homedir(), '.claude', 'settings.json');
|
|
6
|
+
async function readSettings() {
|
|
7
|
+
try {
|
|
8
|
+
const raw = await readFile(SETTINGS_PATH, 'utf-8');
|
|
9
|
+
return JSON.parse(raw);
|
|
10
|
+
}
|
|
11
|
+
catch {
|
|
12
|
+
return {};
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
async function saveSettings(settings) {
|
|
16
|
+
await writeFile(SETTINGS_PATH, JSON.stringify(settings, null, 2) + '\n');
|
|
17
|
+
}
|
|
18
|
+
export async function writeSpinnerVerbs(verbs) {
|
|
19
|
+
const settings = await readSettings();
|
|
20
|
+
settings.spinnerVerbs = { mode: 'replace', verbs };
|
|
21
|
+
await saveSettings(settings);
|
|
22
|
+
}
|
|
23
|
+
export async function restoreDefaultVerbs() {
|
|
24
|
+
const settings = await readSettings();
|
|
25
|
+
delete settings.spinnerVerbs;
|
|
26
|
+
await saveSettings(settings);
|
|
27
|
+
}
|
|
28
|
+
function getDistDir() {
|
|
29
|
+
return resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
30
|
+
}
|
|
31
|
+
function getHookCommand(scriptName) {
|
|
32
|
+
return `node "${join(getDistDir(), 'hooks', scriptName)}"`;
|
|
33
|
+
}
|
|
34
|
+
const MARKER = 'claudenews';
|
|
35
|
+
export async function installHooks() {
|
|
36
|
+
const settings = await readSettings();
|
|
37
|
+
if (!settings.hooks)
|
|
38
|
+
settings.hooks = {};
|
|
39
|
+
for (const [event, script, timeout] of [
|
|
40
|
+
['SessionStart', 'on-session-start.js', 15000],
|
|
41
|
+
['PreToolUse', 'on-pre-tool-use.js', 10000],
|
|
42
|
+
]) {
|
|
43
|
+
const cmd = getHookCommand(script);
|
|
44
|
+
if (!settings.hooks[event])
|
|
45
|
+
settings.hooks[event] = [];
|
|
46
|
+
const existing = settings.hooks[event].find(e => e.hooks?.some(h => h.command.includes(MARKER)));
|
|
47
|
+
const hookEntry = { type: 'command', command: cmd, timeout };
|
|
48
|
+
if (existing) {
|
|
49
|
+
existing.hooks = [hookEntry];
|
|
50
|
+
}
|
|
51
|
+
else {
|
|
52
|
+
settings.hooks[event].push({ hooks: [hookEntry] });
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
await saveSettings(settings);
|
|
56
|
+
}
|
|
57
|
+
export async function removeHooks() {
|
|
58
|
+
const settings = await readSettings();
|
|
59
|
+
if (!settings.hooks)
|
|
60
|
+
return;
|
|
61
|
+
for (const event of Object.keys(settings.hooks)) {
|
|
62
|
+
settings.hooks[event] = settings.hooks[event].filter(e => !e.hooks?.some(h => h.command.includes(MARKER)));
|
|
63
|
+
if (settings.hooks[event].length === 0)
|
|
64
|
+
delete settings.hooks[event];
|
|
65
|
+
}
|
|
66
|
+
if (Object.keys(settings.hooks).length === 0)
|
|
67
|
+
delete settings.hooks;
|
|
68
|
+
await saveSettings(settings);
|
|
69
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
const devto = {
|
|
2
|
+
id: 'devto',
|
|
3
|
+
name: 'dev.to',
|
|
4
|
+
prefix: '[dev]',
|
|
5
|
+
url: 'dev.to',
|
|
6
|
+
defaultEnabled: false,
|
|
7
|
+
async fetch(count) {
|
|
8
|
+
const ctrl = new AbortController();
|
|
9
|
+
const t = setTimeout(() => ctrl.abort(), 10_000);
|
|
10
|
+
try {
|
|
11
|
+
const r = await fetch(`https://dev.to/api/articles?per_page=${count}`, { signal: ctrl.signal });
|
|
12
|
+
const items = await r.json();
|
|
13
|
+
return items.map(i => ({ title: i.title, source: 'devto' }));
|
|
14
|
+
}
|
|
15
|
+
finally {
|
|
16
|
+
clearTimeout(t);
|
|
17
|
+
}
|
|
18
|
+
},
|
|
19
|
+
};
|
|
20
|
+
export default devto;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
const githubTrending = {
|
|
2
|
+
id: 'github',
|
|
3
|
+
name: 'GitHub Trending',
|
|
4
|
+
prefix: '[GH]',
|
|
5
|
+
url: 'github.com/trending',
|
|
6
|
+
defaultEnabled: false,
|
|
7
|
+
async fetch(count) {
|
|
8
|
+
const ctrl = new AbortController();
|
|
9
|
+
const t = setTimeout(() => ctrl.abort(), 10_000);
|
|
10
|
+
try {
|
|
11
|
+
const r = await fetch('https://ghapi.huchen.dev/repositories?since=daily', { signal: ctrl.signal });
|
|
12
|
+
const repos = await r.json();
|
|
13
|
+
return repos.slice(0, count).map(i => ({
|
|
14
|
+
title: i.description ? `${i.author}/${i.name} — ${i.description}` : `${i.author}/${i.name}`,
|
|
15
|
+
source: 'github',
|
|
16
|
+
}));
|
|
17
|
+
}
|
|
18
|
+
finally {
|
|
19
|
+
clearTimeout(t);
|
|
20
|
+
}
|
|
21
|
+
},
|
|
22
|
+
};
|
|
23
|
+
export default githubTrending;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
const hackernews = {
|
|
2
|
+
id: 'hackernews',
|
|
3
|
+
name: 'Hacker News',
|
|
4
|
+
prefix: '[HN]',
|
|
5
|
+
url: 'news.ycombinator.com',
|
|
6
|
+
defaultEnabled: true,
|
|
7
|
+
async fetch(count) {
|
|
8
|
+
const ctrl = new AbortController();
|
|
9
|
+
const t = setTimeout(() => ctrl.abort(), 10_000);
|
|
10
|
+
try {
|
|
11
|
+
const res = await fetch('https://hacker-news.firebaseio.com/v0/topstories.json', { signal: ctrl.signal });
|
|
12
|
+
const ids = await res.json();
|
|
13
|
+
const items = await Promise.all(ids.slice(0, count).map(async (id) => {
|
|
14
|
+
const r = await fetch(`https://hacker-news.firebaseio.com/v0/item/${id}.json`, { signal: ctrl.signal });
|
|
15
|
+
return r.json();
|
|
16
|
+
}));
|
|
17
|
+
return items.filter(i => i?.title).map(i => ({ title: i.title, source: 'hackernews' }));
|
|
18
|
+
}
|
|
19
|
+
finally {
|
|
20
|
+
clearTimeout(t);
|
|
21
|
+
}
|
|
22
|
+
},
|
|
23
|
+
};
|
|
24
|
+
export default hackernews;
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { Headline, SourceDef } from '../types.js';
|
|
2
|
+
export declare function getAllSources(): SourceDef[];
|
|
3
|
+
export declare function getPrefix(source: string): string;
|
|
4
|
+
export declare function fetchAllHeadlines(enabledIds: string[], countPerSource: number): Promise<Headline[]>;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import hackernews from './hackernews.js';
|
|
2
|
+
import reddit from './reddit.js';
|
|
3
|
+
import lobsters from './lobsters.js';
|
|
4
|
+
import devto from './devto.js';
|
|
5
|
+
import githubTrending from './github-trending.js';
|
|
6
|
+
const allSources = [hackernews, reddit, lobsters, devto, githubTrending];
|
|
7
|
+
export function getAllSources() {
|
|
8
|
+
return allSources;
|
|
9
|
+
}
|
|
10
|
+
export function getPrefix(source) {
|
|
11
|
+
return allSources.find(s => s.id === source)?.prefix ?? `[${source}]`;
|
|
12
|
+
}
|
|
13
|
+
export async function fetchAllHeadlines(enabledIds, countPerSource) {
|
|
14
|
+
const sources = allSources.filter(s => enabledIds.includes(s.id));
|
|
15
|
+
const results = await Promise.allSettled(sources.map(s => s.fetch(countPerSource)));
|
|
16
|
+
const headlines = [];
|
|
17
|
+
for (const result of results) {
|
|
18
|
+
if (result.status === 'fulfilled')
|
|
19
|
+
headlines.push(...result.value);
|
|
20
|
+
}
|
|
21
|
+
// Fisher-Yates shuffle
|
|
22
|
+
for (let i = headlines.length - 1; i > 0; i--) {
|
|
23
|
+
const j = Math.floor(Math.random() * (i + 1));
|
|
24
|
+
[headlines[i], headlines[j]] = [headlines[j], headlines[i]];
|
|
25
|
+
}
|
|
26
|
+
return headlines;
|
|
27
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
const lobsters = {
|
|
2
|
+
id: 'lobsters',
|
|
3
|
+
name: 'Lobsters',
|
|
4
|
+
prefix: '[🦞]',
|
|
5
|
+
url: 'lobste.rs',
|
|
6
|
+
defaultEnabled: false,
|
|
7
|
+
async fetch(count) {
|
|
8
|
+
const ctrl = new AbortController();
|
|
9
|
+
const t = setTimeout(() => ctrl.abort(), 10_000);
|
|
10
|
+
try {
|
|
11
|
+
const r = await fetch('https://lobste.rs/hottest.json', { signal: ctrl.signal });
|
|
12
|
+
const items = await r.json();
|
|
13
|
+
return items.slice(0, count).map(i => ({ title: i.title, source: 'lobsters' }));
|
|
14
|
+
}
|
|
15
|
+
finally {
|
|
16
|
+
clearTimeout(t);
|
|
17
|
+
}
|
|
18
|
+
},
|
|
19
|
+
};
|
|
20
|
+
export default lobsters;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { readConfig } from '../config.js';
|
|
2
|
+
const reddit = {
|
|
3
|
+
id: 'reddit',
|
|
4
|
+
name: 'Reddit',
|
|
5
|
+
prefix: '[/r]',
|
|
6
|
+
url: 'reddit.com',
|
|
7
|
+
defaultEnabled: false,
|
|
8
|
+
async fetch(count) {
|
|
9
|
+
const ctrl = new AbortController();
|
|
10
|
+
const t = setTimeout(() => ctrl.abort(), 10_000);
|
|
11
|
+
try {
|
|
12
|
+
const config = await readConfig();
|
|
13
|
+
const perSub = Math.max(1, Math.floor(count / config.redditSubs.length));
|
|
14
|
+
const results = await Promise.all(config.redditSubs.map(async (sub) => {
|
|
15
|
+
const r = await fetch(`https://www.reddit.com/r/${sub}/hot.json?limit=${perSub}`, {
|
|
16
|
+
signal: ctrl.signal,
|
|
17
|
+
headers: { 'User-Agent': 'claudenews/1.0' },
|
|
18
|
+
});
|
|
19
|
+
return r.json();
|
|
20
|
+
}));
|
|
21
|
+
return results.flatMap(r => r.data.children.map(c => ({ title: c.data.title, source: 'reddit' })));
|
|
22
|
+
}
|
|
23
|
+
finally {
|
|
24
|
+
clearTimeout(t);
|
|
25
|
+
}
|
|
26
|
+
},
|
|
27
|
+
};
|
|
28
|
+
export default reddit;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/package.json
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "claudenews",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "News headlines in your Claude Code spinner",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"claudenews": "./bin/claudenews.js"
|
|
8
|
+
},
|
|
9
|
+
"scripts": {
|
|
10
|
+
"build": "tsc",
|
|
11
|
+
"dev": "tsc --watch",
|
|
12
|
+
"prepublishOnly": "npm run build"
|
|
13
|
+
},
|
|
14
|
+
"files": [
|
|
15
|
+
"dist",
|
|
16
|
+
"bin"
|
|
17
|
+
],
|
|
18
|
+
"keywords": [
|
|
19
|
+
"claude",
|
|
20
|
+
"claude-code",
|
|
21
|
+
"news",
|
|
22
|
+
"spinner",
|
|
23
|
+
"hackernews",
|
|
24
|
+
"cli",
|
|
25
|
+
"tui"
|
|
26
|
+
],
|
|
27
|
+
"repository": {
|
|
28
|
+
"type": "git",
|
|
29
|
+
"url": "https://github.com/tolibear/claudenews"
|
|
30
|
+
},
|
|
31
|
+
"author": "souls.zip",
|
|
32
|
+
"homepage": "https://github.com/tolibear/claudenews",
|
|
33
|
+
"dependencies": {
|
|
34
|
+
"ink": "^5.2.0",
|
|
35
|
+
"react": "^18.3.0"
|
|
36
|
+
},
|
|
37
|
+
"devDependencies": {
|
|
38
|
+
"typescript": "^5.7.0",
|
|
39
|
+
"@types/react": "^18.3.0",
|
|
40
|
+
"@types/node": "^22.0.0"
|
|
41
|
+
},
|
|
42
|
+
"engines": {
|
|
43
|
+
"node": ">=18.0.0"
|
|
44
|
+
},
|
|
45
|
+
"license": "MIT"
|
|
46
|
+
}
|