@lowzj/news-skill 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.
@@ -0,0 +1,251 @@
1
+ import { DEFAULT_API_URL, VERSION } from './meta.js';
2
+ const MAX_RESPONSE_BYTES = 16 * 1024 * 1024;
3
+ export class NewsError extends Error {
4
+ code;
5
+ constructor(code, message) {
6
+ super(message);
7
+ this.code = code;
8
+ this.name = 'NewsError';
9
+ }
10
+ }
11
+ function invalid(message) {
12
+ throw new NewsError('INVALID_RESPONSE', `Invalid NEWS response: ${message}`);
13
+ }
14
+ function object(value, label) {
15
+ if (!value || typeof value !== 'object' || Array.isArray(value))
16
+ invalid(`${label} must be an object`);
17
+ return value;
18
+ }
19
+ function string(value, label) {
20
+ if (typeof value !== 'string')
21
+ invalid(`${label} must be a string`);
22
+ return value;
23
+ }
24
+ function array(value, label) {
25
+ if (!Array.isArray(value))
26
+ invalid(`${label} must be an array`);
27
+ return value;
28
+ }
29
+ function integer(value, label, minimum = 0, maximum = Number.MAX_SAFE_INTEGER) {
30
+ if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < minimum || value > maximum) {
31
+ invalid(`${label} must be an integer from ${minimum} to ${maximum}`);
32
+ }
33
+ return value;
34
+ }
35
+ function optionalString(value, label) {
36
+ return value === null ? null : string(value, label);
37
+ }
38
+ function timestamp(value, label, nullable = true) {
39
+ if (value === null && nullable)
40
+ return null;
41
+ const result = string(value, label);
42
+ if (!/T.*(?:Z|[+-]\d{2}:\d{2})$/.test(result) || !Number.isFinite(Date.parse(result)))
43
+ invalid(`${label} must be an ISO timestamp`);
44
+ return result;
45
+ }
46
+ function date(value, label) {
47
+ const result = string(value, label);
48
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(result) || !Number.isFinite(Date.parse(result)) || new Date(result).toISOString().slice(0, 10) !== result) {
49
+ invalid(`${label} must be a calendar date`);
50
+ }
51
+ return result;
52
+ }
53
+ function parseTopic(value) {
54
+ const row = object(value, 'topic');
55
+ const id = string(row.id, 'topic.id');
56
+ if (!id.trim())
57
+ invalid('topic.id must not be empty');
58
+ return {
59
+ id,
60
+ name: string(row.name, 'topic.name'),
61
+ name_en: string(row.name_en, 'topic.name_en'),
62
+ description: string(row.description, 'topic.description'),
63
+ latest_day: row.latest_day === null ? null : date(row.latest_day, 'topic.latest_day'),
64
+ updated_at: timestamp(row.updated_at, 'topic.updated_at'),
65
+ ...(row.today_count !== undefined ? { today_count: integer(row.today_count, 'topic.today_count') } : {}),
66
+ };
67
+ }
68
+ function parseItem(value) {
69
+ const row = object(value, 'item');
70
+ if (typeof row.pinned !== 'boolean')
71
+ invalid('item.pinned must be boolean');
72
+ const id = string(row.id, 'item.id');
73
+ if (!id.trim())
74
+ invalid('item.id must not be empty');
75
+ return {
76
+ id,
77
+ title: string(row.title, 'item.title'),
78
+ summary: string(row.summary, 'item.summary'),
79
+ title_en: string(row.title_en, 'item.title_en'),
80
+ summary_en: string(row.summary_en, 'item.summary_en'),
81
+ url: string(row.url, 'item.url'),
82
+ source: string(row.source, 'item.source'),
83
+ published_at: timestamp(row.published_at, 'item.published_at'),
84
+ first_seen_at: timestamp(row.first_seen_at, 'item.first_seen_at', false),
85
+ tags: array(row.tags, 'item.tags').map((tag) => string(tag, 'tag')),
86
+ importance: integer(row.importance, 'item.importance', 1, 5),
87
+ pinned: row.pinned,
88
+ };
89
+ }
90
+ export class NewsClient {
91
+ baseUrl;
92
+ timeoutMs;
93
+ constructor(options = {}) {
94
+ try {
95
+ this.baseUrl = new URL(options.url ?? process.env.NEWS_API_URL ?? DEFAULT_API_URL);
96
+ }
97
+ catch {
98
+ throw new NewsError('INVALID_ARGUMENT', '--url / NEWS_API_URL must be an absolute URL');
99
+ }
100
+ const url = this.baseUrl;
101
+ const loopback = ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname);
102
+ if (url.protocol !== 'https:' && !(url.protocol === 'http:' && loopback)) {
103
+ throw new NewsError('INVALID_ARGUMENT', 'Use an HTTPS API URL, or HTTP on localhost for development');
104
+ }
105
+ if (url.username || url.password || url.search || url.hash) {
106
+ throw new NewsError('INVALID_ARGUMENT', 'The API URL must not contain credentials, a query, or a fragment');
107
+ }
108
+ url.pathname = `${url.pathname.replace(/\/+$/, '')}/`;
109
+ this.timeoutMs = options.timeoutMs ?? 20_000;
110
+ if (!Number.isInteger(this.timeoutMs) || this.timeoutMs < 1 || this.timeoutMs > 120_000) {
111
+ throw new NewsError('INVALID_ARGUMENT', 'The request timeout must be between 1 and 120000 milliseconds');
112
+ }
113
+ }
114
+ async get(path, params = {}) {
115
+ const url = new URL(path, this.baseUrl);
116
+ for (const [key, value] of Object.entries(params))
117
+ if (value !== undefined)
118
+ url.searchParams.set(key, String(value));
119
+ const signal = AbortSignal.timeout(this.timeoutMs);
120
+ try {
121
+ const response = await fetch(url, {
122
+ method: 'GET',
123
+ redirect: 'manual',
124
+ signal,
125
+ headers: { accept: 'application/json', 'user-agent': `news-skill/${VERSION}` },
126
+ });
127
+ if (response.status >= 300 && response.status < 400) {
128
+ await response.body?.cancel();
129
+ throw new NewsError('HTTP_ERROR', `NEWS returned HTTP ${response.status} for /${path}; configure the final API URL`);
130
+ }
131
+ const reader = response.body?.getReader();
132
+ const chunks = [];
133
+ let size = 0;
134
+ if (reader) {
135
+ try {
136
+ while (true) {
137
+ const { done, value } = await reader.read();
138
+ if (done)
139
+ break;
140
+ size += value.byteLength;
141
+ if (size > MAX_RESPONSE_BYTES) {
142
+ await reader.cancel();
143
+ throw new NewsError('RESPONSE_TOO_LARGE', 'NEWS response exceeds 16 MiB');
144
+ }
145
+ chunks.push(value);
146
+ }
147
+ }
148
+ finally {
149
+ reader.releaseLock();
150
+ }
151
+ }
152
+ const body = Buffer.concat(chunks).toString('utf8');
153
+ let data;
154
+ try {
155
+ data = JSON.parse(body);
156
+ }
157
+ catch { /* Report HTTP status before JSON errors. */ }
158
+ if (!response.ok) {
159
+ let detail = '';
160
+ if (data && typeof data === 'object' && 'error' in data) {
161
+ const error = data.error;
162
+ if (error && typeof error === 'object' && 'message' in error && typeof error.message === 'string') {
163
+ detail = `: ${error.message.replace(/[\x00-\x1f\x7f]/g, ' ').slice(0, 400)}`;
164
+ }
165
+ }
166
+ throw new NewsError('HTTP_ERROR', `NEWS returned HTTP ${response.status} for /${path}${detail}`);
167
+ }
168
+ if (data === undefined)
169
+ invalid(`/${path} did not return JSON`);
170
+ return data;
171
+ }
172
+ catch (error) {
173
+ if (error instanceof NewsError)
174
+ throw error;
175
+ if (signal.aborted)
176
+ throw new NewsError('TIMEOUT', `NEWS request timed out after ${this.timeoutMs / 1000}s for /${path}`);
177
+ const cause = error instanceof Error ? error.message : 'Connection failed';
178
+ throw new NewsError('NETWORK_ERROR', `Could not reach ${this.baseUrl.origin}: ${cause}`);
179
+ }
180
+ }
181
+ async topics() {
182
+ const data = object(await this.get('topics'), 'topics response');
183
+ return { topics: array(data.topics, 'topics').map(parseTopic) };
184
+ }
185
+ async days(topic, limit = 30) {
186
+ const data = object(await this.get('days', { topic, limit }), 'days response');
187
+ return { days: array(data.days, 'days').map((value) => {
188
+ const row = object(value, 'day');
189
+ return { day: date(row.day, 'day.day'), count: integer(row.count, 'day.count') };
190
+ }) };
191
+ }
192
+ async digest(params = {}) {
193
+ const data = object(await this.get('digest', { ...params }), 'digest response');
194
+ const timezone = string(data.timezone, 'timezone');
195
+ try {
196
+ new Intl.DateTimeFormat('en', { timeZone: timezone });
197
+ }
198
+ catch {
199
+ invalid('timezone must be an IANA timezone');
200
+ }
201
+ return {
202
+ day: data.day === null ? null : date(data.day, 'day'),
203
+ timezone,
204
+ generated_at: timestamp(data.generated_at, 'generated_at', false),
205
+ topics: array(data.topics, 'topics').map((value) => {
206
+ const row = object(value, 'topic digest');
207
+ return {
208
+ ...parseTopic(row),
209
+ total: integer(row.total, 'topic.total'),
210
+ next: optionalString(row.next, 'topic.next'),
211
+ items: array(row.items, 'topic.items').map(parseItem),
212
+ };
213
+ }),
214
+ };
215
+ }
216
+ async insight() {
217
+ const data = object(await this.get('insight'), 'insight response');
218
+ if (data.insight === null)
219
+ return { insight: null };
220
+ const row = object(data.insight, 'insight');
221
+ const indexes = (value) => array(value, 'source_indexes').map((index) => integer(index, 'source index'));
222
+ const signals = (value) => array(value, 'signals').map((value) => {
223
+ const signal = object(value, 'signal');
224
+ return {
225
+ ...signal,
226
+ title: string(signal.title, 'signal.title'), title_en: string(signal.title_en, 'signal.title_en'),
227
+ summary: string(signal.summary, 'signal.summary'), summary_en: string(signal.summary_en, 'signal.summary_en'),
228
+ source_indexes: indexes(signal.source_indexes ?? []),
229
+ };
230
+ });
231
+ return { insight: {
232
+ ...row,
233
+ generated_at: timestamp(row.generated_at, 'insight.generated_at', false),
234
+ window_start: timestamp(row.window_start, 'insight.window_start', false),
235
+ headline: string(row.headline, 'headline'), headline_en: string(row.headline_en, 'headline_en'),
236
+ overview: string(row.overview, 'overview'), overview_en: string(row.overview_en, 'overview_en'),
237
+ changes: string(row.changes, 'changes'), changes_en: string(row.changes_en, 'changes_en'),
238
+ changes_source_indexes: indexes(row.changes_source_indexes ?? []),
239
+ wealth: signals(row.wealth), employment: signals(row.employment), risks: signals(row.risks), actions: signals(row.actions),
240
+ sources: array(row.sources, 'sources').map((value) => {
241
+ const source = object(value, 'source');
242
+ return { ...source, index: integer(source.index, 'source.index'), title: string(source.title, 'source.title'),
243
+ title_en: string(source.title_en, 'source.title_en'), url: string(source.url, 'source.url'), source: string(source.source, 'source.source') };
244
+ }),
245
+ research_sources: array(row.research_sources ?? [], 'research_sources').map((value) => {
246
+ const source = object(value, 'research source');
247
+ return { title: string(source.title, 'research source.title'), url: string(source.url, 'research source.url') };
248
+ }),
249
+ } };
250
+ }
251
+ }
@@ -0,0 +1,119 @@
1
+ /** Render service text as text, not HTML, terminal escapes, or new Markdown blocks. */
2
+ export function escapeMarkdown(value) {
3
+ return value.replace(/[\x00-\x1f\x7f-\x9f]/g, ' ').replace(/[\\`*_[\]<>#|]/g, '\\$&');
4
+ }
5
+ function link(title, url) {
6
+ const label = escapeMarkdown(title);
7
+ try {
8
+ const parsed = new URL(url);
9
+ if (!['https:', 'http:'].includes(parsed.protocol) || parsed.username || parsed.password)
10
+ return label;
11
+ const href = parsed.href.replace(/[<>\\]/g, (char) => encodeURIComponent(char));
12
+ return `[${label}](<${href}>)`;
13
+ }
14
+ catch {
15
+ return label;
16
+ }
17
+ }
18
+ function localized(row, key, lang) {
19
+ const english = row[`${key}_en`];
20
+ const native = row[key];
21
+ return lang === 'en' && typeof english === 'string' && english.trim()
22
+ ? english : typeof native === 'string' ? native : '';
23
+ }
24
+ function timestamp(value, timezone) {
25
+ const formatted = new Intl.DateTimeFormat('sv-SE', {
26
+ timeZone: timezone, year: 'numeric', month: '2-digit', day: '2-digit',
27
+ hour: '2-digit', minute: '2-digit', second: '2-digit', hourCycle: 'h23',
28
+ }).format(new Date(value));
29
+ return `${formatted} (${timezone})`;
30
+ }
31
+ export function formatTopics(data, lang) {
32
+ const lines = [lang === 'zh' ? '# NEWS 主题' : '# NEWS topics', ''];
33
+ for (const topic of data.topics) {
34
+ lines.push(`- **${escapeMarkdown(lang === 'en' ? topic.name_en || topic.name : topic.name)}** — \`${escapeMarkdown(topic.id)}\``);
35
+ if (topic.description)
36
+ lines.push(` ${escapeMarkdown(topic.description)}`);
37
+ lines.push(` ${lang === 'zh' ? '最近收录日期' : 'Latest collection day'}: ${topic.latest_day ?? '—'}`);
38
+ }
39
+ if (!data.topics.length)
40
+ lines.push(lang === 'zh' ? '暂无公开主题。' : 'No public topics available.');
41
+ return lines.join('\n');
42
+ }
43
+ export function formatDays(data, lang) {
44
+ return [lang === 'zh' ? '# NEWS 收录日期' : '# NEWS collection days', '',
45
+ ...data.days.map((day) => `- ${day.day}: ${day.count} ${lang === 'zh' ? '条' : 'items'}`),
46
+ ...(!data.days.length ? [lang === 'zh' ? '暂无收录。' : 'No archived items.'] : []),
47
+ ].join('\n');
48
+ }
49
+ export function formatNews(data, lang) {
50
+ const en = lang === 'en';
51
+ const dates = data.coverage.days;
52
+ const range = dates.length > 1 ? `${dates.at(-1)} – ${dates[0]}` : dates[0] ?? (en ? 'No collection date' : '暂无收录日期');
53
+ const lines = [data.query ? `# NEWS · ${escapeMarkdown(data.query)}` : '# NEWS', '',
54
+ `${en ? 'Collection dates' : '收录日期'}: ${range} · ${escapeMarkdown(data.timezone)}`,
55
+ `${en ? 'Retrieved' : '查询时间'}: ${timestamp(data.retrieved_at, data.timezone)}`,
56
+ `${en ? 'Returned / matched in scanned content' : '返回 / 已扫描内容中的匹配数'}: ${data.returned} / ${data.matched}`, ''];
57
+ if (!data.coverage.complete) {
58
+ lines.push(en
59
+ ? `> Partial results: reached the ${data.coverage.max_pages}-page scan limit. More matches may exist. Increase --max-pages or narrow the selection.`
60
+ : `> 结果不完整:已达到 ${data.coverage.max_pages} 页扫描上限,可能还有匹配新闻。可增加 --max-pages 或缩小查询范围。`, '');
61
+ }
62
+ if (!data.items.length)
63
+ lines.push(en ? 'No matches in the scanned NEWS content.' : '已扫描的 NEWS 内容中没有匹配项。', '');
64
+ for (const [index, item] of data.items.entries()) {
65
+ const title = en ? item.title_en || item.title : item.title;
66
+ const summary = en ? item.summary_en || item.summary : item.summary;
67
+ lines.push(`${index + 1}. **${link(title, item.url)}**`, ` ${escapeMarkdown(summary)}`);
68
+ const when = item.published_at ? timestamp(item.published_at, data.timezone) : (en ? 'Unknown' : '未知');
69
+ lines.push(` ${escapeMarkdown(item.source)} · ${en ? 'Published' : '发布'}: ${when} · ${en ? 'Importance' : '重要性'}: ${item.importance}/5`);
70
+ lines.push(` ${en ? 'Collected' : '收录'}: ${timestamp(item.first_seen_at, data.timezone)} · ${item.topic_ids.map(escapeMarkdown).join(', ')}`, '');
71
+ }
72
+ if (data.sources.length) {
73
+ const updates = new Map();
74
+ for (const source of data.sources) {
75
+ if (source.updated_at && source.updated_at > (updates.get(source.topic_id) ?? ''))
76
+ updates.set(source.topic_id, source.updated_at);
77
+ }
78
+ if (updates.size) {
79
+ lines.push(en ? 'Source snapshot updates (within the selected dates):' : '来源快照更新时间(所选日期内):');
80
+ for (const [topic, updatedAt] of updates)
81
+ lines.push(`- ${escapeMarkdown(topic)}: ${timestamp(updatedAt, data.timezone)}`);
82
+ }
83
+ }
84
+ return lines.join('\n');
85
+ }
86
+ function references(indexes, insight) {
87
+ return indexes.filter((index) => insight.sources.some((source) => source.index === index)).map((index) => `[${index}]`).join(' ');
88
+ }
89
+ export function formatInsight(data, lang) {
90
+ const en = lang === 'en';
91
+ const insight = data.insight;
92
+ if (!insight)
93
+ return en ? 'No NEWS analysis is available yet.' : 'NEWS 暂无综合研判。';
94
+ const lines = [`# ${escapeMarkdown(localized(insight, 'headline', lang))}`, '',
95
+ `${en ? 'NEWS AI analysis · Generated' : 'NEWS AI 综合研判 · 生成时间'}: ${escapeMarkdown(insight.generated_at)}`,
96
+ `${en ? 'Analysis window starts' : '分析窗口起点'}: ${escapeMarkdown(insight.window_start)}`, '',
97
+ escapeMarkdown(localized(insight, 'overview', lang)), '',
98
+ `${escapeMarkdown(localized(insight, 'changes', lang))} ${references(insight.changes_source_indexes, insight)}`, ''];
99
+ const sections = [['wealth', '商业与机遇', 'Business and opportunities'], ['employment', '就业与技能', 'Employment and skills'],
100
+ ['risks', '风险预警', 'Risks'], ['actions', '观察方向', 'What to watch']];
101
+ for (const [key, chinese, english] of sections) {
102
+ if (!insight[key].length)
103
+ continue;
104
+ lines.push(`## ${en ? english : chinese}`, '');
105
+ for (const signal of insight[key]) {
106
+ lines.push(`- **${escapeMarkdown(localized(signal, 'title', lang))}** — ${escapeMarkdown(localized(signal, 'summary', lang))} ${references(signal.source_indexes, insight)}`);
107
+ }
108
+ lines.push('');
109
+ }
110
+ if (insight.sources.length || insight.research_sources.length) {
111
+ lines.push(en ? '## Sources' : '## 来源', '');
112
+ for (const source of insight.sources) {
113
+ lines.push(`- [${source.index}] ${link(localized(source, 'title', lang), source.url)} — ${escapeMarkdown(source.source)}`);
114
+ }
115
+ for (const source of insight.research_sources)
116
+ lines.push(`- ${link(source.title, source.url)}`);
117
+ }
118
+ return lines.join('\n');
119
+ }
@@ -0,0 +1,4 @@
1
+ import { readFileSync } from 'node:fs';
2
+ // The build copies only this metadata into the installed, self-contained skill.
3
+ export const VERSION = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
4
+ export const DEFAULT_API_URL = 'https://news.xairouter.com';
@@ -0,0 +1,174 @@
1
+ #!/usr/bin/env node
2
+ import { parseArgs } from 'node:util';
3
+ import { NewsClient, NewsError } from './client.js';
4
+ import { formatDays, formatInsight, formatNews, formatTopics } from './format.js';
5
+ import { VERSION } from './meta.js';
6
+ import { queryNews } from './query.js';
7
+ import { installSkills, readSkill } from './skills.js';
8
+ const HELP = `NEWS — public news in your agent (Node.js 22.12+)
9
+
10
+ Usage:
11
+ news topics
12
+ news days [--topic ID] [--limit N]
13
+ news read [selection options]
14
+ news search "KEYWORDS" [selection options]
15
+ news insight
16
+ news skills show
17
+ news skills install --agent codex|claude|opencode|pi|all
18
+ [--scope user|project] [--force]
19
+
20
+ Selection options:
21
+ --topic ID One public topic ID (discover with news topics)
22
+ --day DATE YYYY-MM-DD, today, or yesterday
23
+ --days N Last N calendar days including today (1–31)
24
+ --from DATE --to DATE Inclusive collection-date range (at most 31 days)
25
+ Use one date selector; default: latest available day
26
+ --limit N Global output limit (default 10, max 100)
27
+ --sort latest|importance Default: latest
28
+ --min-importance N Minimum importance, 1–5 (default 1)
29
+ --max-pages N Total digest scan requests (default 20, max 200)
30
+
31
+ Common options:
32
+ --format json|markdown Default: JSON; Markdown source needs a renderer
33
+ --lang zh|en Markdown language (default zh)
34
+ --url URL API base URL (or NEWS_API_URL)
35
+ --timeout SECONDS Per-request timeout (default 20, max 120)
36
+ -h, --help Show this help
37
+ -v, --version Show version
38
+
39
+ Examples:
40
+ news read --topic openai --day today --limit 5 --format markdown
41
+ news search "Claude Code" --days 3 --format markdown
42
+ news read --topic github --sort importance --lang en --format markdown
43
+
44
+ Search matches a literal phrase in existing bilingual titles, summaries,
45
+ sources and tags. It does not request fresh crawling or web search.
46
+ Dates select collection days in the API timezone. Incomplete scans are
47
+ marked in coverage.complete; no matches need not mean no news exists.
48
+
49
+ Skills are copied with the CLI included. Reload skills or start a new
50
+ agent session after installation. Existing unrelated skills are preserved.
51
+ `;
52
+ const definition = {
53
+ help: { type: 'boolean', short: 'h' }, version: { type: 'boolean', short: 'v' },
54
+ topic: { type: 'string' }, day: { type: 'string' }, days: { type: 'string' },
55
+ from: { type: 'string' }, to: { type: 'string' }, limit: { type: 'string' },
56
+ sort: { type: 'string' }, 'min-importance': { type: 'string' }, 'max-pages': { type: 'string' },
57
+ format: { type: 'string' }, lang: { type: 'string' }, url: { type: 'string' }, timeout: { type: 'string' },
58
+ agent: { type: 'string' }, scope: { type: 'string' }, force: { type: 'boolean' },
59
+ };
60
+ function number(value, option, max) {
61
+ if (value === undefined)
62
+ return undefined;
63
+ if (!/^\d+$/.test(value) || !Number.isSafeInteger(Number(value)) || Number(value) < 1 || Number(value) > max) {
64
+ throw new NewsError('INVALID_ARGUMENT', `--${option} must be an integer from 1 to ${max}`);
65
+ }
66
+ return Number(value);
67
+ }
68
+ function print(data, markdown, format) {
69
+ process.stdout.write(`${format === 'markdown' ? markdown() : JSON.stringify(data, null, 2)}\n`);
70
+ }
71
+ export async function main(args = process.argv.slice(2)) {
72
+ let parsed;
73
+ try {
74
+ parsed = parseArgs({ args, options: definition, allowPositionals: true, tokens: true });
75
+ }
76
+ catch (error) {
77
+ throw new NewsError('INVALID_ARGUMENT', error instanceof Error ? error.message : 'Invalid arguments');
78
+ }
79
+ const { values, positionals, tokens } = parsed;
80
+ if (values.help || args.length === 0) {
81
+ process.stdout.write(HELP);
82
+ return;
83
+ }
84
+ if (values.version) {
85
+ process.stdout.write(`${VERSION}\n`);
86
+ return;
87
+ }
88
+ const seen = new Set();
89
+ for (const token of tokens) {
90
+ if (token.kind !== 'option')
91
+ continue;
92
+ if (seen.has(token.name))
93
+ throw new NewsError('INVALID_ARGUMENT', `--${token.name} must only be provided once`);
94
+ seen.add(token.name);
95
+ }
96
+ const [command, ...rest] = positionals;
97
+ const format = values.format ?? 'json';
98
+ if (!['json', 'markdown'].includes(format))
99
+ throw new NewsError('INVALID_ARGUMENT', '--format must be json or markdown');
100
+ const lang = values.lang ?? 'zh';
101
+ if (!['zh', 'en'].includes(lang))
102
+ throw new NewsError('INVALID_ARGUMENT', '--lang must be zh or en');
103
+ const language = lang;
104
+ const common = ['help', 'version', 'format', 'lang', 'url', 'timeout'];
105
+ const selectionFlags = ['topic', 'day', 'days', 'from', 'to', 'limit', 'sort', 'min-importance', 'max-pages'];
106
+ const allowed = {
107
+ topics: [], days: ['topic', 'limit'], read: selectionFlags, search: selectionFlags,
108
+ insight: [], skills: rest[0] === 'install' ? ['agent', 'scope', 'force'] : [],
109
+ };
110
+ if (!command || !(command in allowed))
111
+ throw new NewsError('INVALID_ARGUMENT', `Unknown command: ${command ?? '(missing)'}. Run news --help.`);
112
+ for (const flag of seen) {
113
+ if (!common.includes(flag) && !allowed[command].includes(flag))
114
+ throw new NewsError('INVALID_ARGUMENT', `--${flag} is not supported by ${command}`);
115
+ }
116
+ if (command === 'skills') {
117
+ if (rest.length !== 1)
118
+ throw new NewsError('INVALID_ARGUMENT', 'Use news skills show or news skills install --agent AGENT');
119
+ if (rest[0] === 'show') {
120
+ process.stdout.write(await readSkill());
121
+ return;
122
+ }
123
+ if (rest[0] !== 'install')
124
+ throw new NewsError('INVALID_ARGUMENT', 'Unknown skills command');
125
+ if (!values.agent)
126
+ throw new NewsError('INVALID_ARGUMENT', 'Specify --agent codex|claude|opencode|pi|all');
127
+ const result = await installSkills({ agent: values.agent, scope: values.scope, force: values.force });
128
+ print(result, () => result.installations.map((item) => `- ${item.agent}: ${item.status} — ${item.path}`).join('\n'), format);
129
+ return;
130
+ }
131
+ if (command === 'search' ? rest.length !== 1 || !rest[0]?.trim() : rest.length !== 0) {
132
+ throw new NewsError('INVALID_ARGUMENT', command === 'search' ? 'Supply one quoted search phrase: news search "Claude Code"' : `${command} does not accept positional arguments`);
133
+ }
134
+ if (values.topic !== undefined && !values.topic.trim())
135
+ throw new NewsError('INVALID_ARGUMENT', '--topic must not be empty');
136
+ const timeout = number(values.timeout, 'timeout', 120) ?? 20;
137
+ const client = new NewsClient({ url: values.url, timeoutMs: timeout * 1000 });
138
+ if (command === 'topics') {
139
+ const result = await client.topics();
140
+ print(result, () => formatTopics(result, language), format);
141
+ }
142
+ else if (command === 'days') {
143
+ const result = await client.days(values.topic, number(values.limit, 'limit', 366) ?? 30);
144
+ print(result, () => formatDays(result, language), format);
145
+ }
146
+ else if (command === 'insight') {
147
+ const result = await client.insight();
148
+ print(result, () => formatInsight(result, language), format);
149
+ }
150
+ else {
151
+ if (values.sort && !['latest', 'importance'].includes(values.sort))
152
+ throw new NewsError('INVALID_ARGUMENT', '--sort must be latest or importance');
153
+ const options = {
154
+ topic: values.topic, day: values.day, days: number(values.days, 'days', 31), from: values.from, to: values.to,
155
+ limit: number(values.limit, 'limit', 100), sort: values.sort,
156
+ minImportance: number(values['min-importance'], 'min-importance', 5), maxPages: number(values['max-pages'], 'max-pages', 200),
157
+ ...(command === 'search' ? { query: rest[0] } : {}),
158
+ };
159
+ const result = await queryNews(client, options);
160
+ print(result, () => formatNews(result, language), format);
161
+ }
162
+ }
163
+ // Pipe consumers such as head may close stdout early. That is a successful read.
164
+ process.stdout.on('error', (error) => {
165
+ if (error.code === 'EPIPE')
166
+ process.exit(0);
167
+ throw error;
168
+ });
169
+ main().catch((error) => {
170
+ const code = error instanceof NewsError ? error.code : 'COMMAND_FAILED';
171
+ const message = error instanceof Error ? error.message : 'NEWS command failed';
172
+ process.stderr.write(`${JSON.stringify({ error: { code, message } })}\n`);
173
+ process.exitCode = 1;
174
+ });