siftctl 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/dist/api.js +63 -0
- package/dist/cli.js +234 -0
- package/dist/config.js +30 -0
- package/dist/fingerprint.js +16 -0
- package/dist/items.js +78 -0
- package/package.json +26 -0
package/dist/api.js
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { baseUrl } from './config.js';
|
|
2
|
+
export class ApiError extends Error {
|
|
3
|
+
status;
|
|
4
|
+
constructor(message, status) {
|
|
5
|
+
super(message);
|
|
6
|
+
this.status = status;
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
async function request(path, opts = {}) {
|
|
10
|
+
const headers = {};
|
|
11
|
+
if (opts.token)
|
|
12
|
+
headers['X-Sync-Key'] = opts.token;
|
|
13
|
+
if (opts.body !== undefined)
|
|
14
|
+
headers['Content-Type'] = 'application/json';
|
|
15
|
+
return fetch(baseUrl() + path, {
|
|
16
|
+
method: opts.method ?? 'GET',
|
|
17
|
+
headers,
|
|
18
|
+
body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined,
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
export async function capabilities() {
|
|
22
|
+
const res = await request('/sync/capabilities');
|
|
23
|
+
if (!res.ok)
|
|
24
|
+
throw new ApiError(`Capabilities failed: ${res.status}`, res.status);
|
|
25
|
+
return (await res.json());
|
|
26
|
+
}
|
|
27
|
+
export async function redeemToken(code) {
|
|
28
|
+
const res = await request('/sync/tokens/redeem', {
|
|
29
|
+
method: 'POST',
|
|
30
|
+
body: { code },
|
|
31
|
+
});
|
|
32
|
+
if (res.status === 404)
|
|
33
|
+
throw new ApiError('Code not found or expired', 404);
|
|
34
|
+
if (res.status === 429)
|
|
35
|
+
throw new ApiError('Rate limited — try again in a minute', 429);
|
|
36
|
+
if (!res.ok)
|
|
37
|
+
throw new ApiError(`Redeem failed: ${res.status}`, res.status);
|
|
38
|
+
const body = (await res.json());
|
|
39
|
+
return body.token;
|
|
40
|
+
}
|
|
41
|
+
export async function pull(token, since = 0) {
|
|
42
|
+
const res = await request(`/sync/pull?since=${encodeURIComponent(String(since))}`, { token });
|
|
43
|
+
if (res.status === 401)
|
|
44
|
+
throw new ApiError('Unauthorized — token revoked or invalid; run `siftctl pair` again', 401);
|
|
45
|
+
if (res.status === 429)
|
|
46
|
+
throw new ApiError('Rate limited — wait a moment and retry', 429);
|
|
47
|
+
if (!res.ok)
|
|
48
|
+
throw new ApiError(`Pull failed: ${res.status}`, res.status);
|
|
49
|
+
return (await res.json());
|
|
50
|
+
}
|
|
51
|
+
export async function push(token, body) {
|
|
52
|
+
const res = await request('/sync/push', { method: 'POST', token, body });
|
|
53
|
+
if (res.status === 401)
|
|
54
|
+
throw new ApiError('Unauthorized — token revoked or invalid; run `siftctl pair` again', 401);
|
|
55
|
+
if (res.status === 429)
|
|
56
|
+
throw new ApiError('Rate limited — wait a moment and retry', 429);
|
|
57
|
+
if (res.status === 400) {
|
|
58
|
+
const errBody = (await res.json().catch(() => null));
|
|
59
|
+
throw new ApiError(errBody?.error ?? 'Bad request', 400);
|
|
60
|
+
}
|
|
61
|
+
if (!res.ok)
|
|
62
|
+
throw new ApiError(`Push failed: ${res.status}`, res.status);
|
|
63
|
+
}
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { pathToFileURL } from 'node:url';
|
|
3
|
+
import { baseUrl, readToken, writeToken } from './config.js';
|
|
4
|
+
import { ApiError, capabilities, pull, push, redeemToken } from './api.js';
|
|
5
|
+
import { tokenFingerprint } from './fingerprint.js';
|
|
6
|
+
import { fetchItems } from './items.js';
|
|
7
|
+
const USAGE = `siftctl — control your Sift subscriptions
|
|
8
|
+
|
|
9
|
+
Usage:
|
|
10
|
+
siftctl pair <code> Redeem an agent pairing code from Sift Settings
|
|
11
|
+
siftctl status [--json] Show API status, base URL, and token fingerprint
|
|
12
|
+
siftctl feeds [--json] List subscribed feeds
|
|
13
|
+
siftctl feed add <url> Subscribe to a feed
|
|
14
|
+
siftctl feed remove <url> --yes Unsubscribe from a feed
|
|
15
|
+
siftctl items <url> [--limit N] Show recent items from a feed (default 20)
|
|
16
|
+
siftctl mark read <itemId> Mark an item read
|
|
17
|
+
siftctl help Show this help
|
|
18
|
+
|
|
19
|
+
Environment:
|
|
20
|
+
SIFTCTL_TOKEN Agent token (overrides the config file)
|
|
21
|
+
SIFTCTL_URL Sift base URL (default ${baseUrl()})
|
|
22
|
+
SIFTCTL_HOME Config directory (default ~/.config)
|
|
23
|
+
|
|
24
|
+
Exit codes: 0 success, 1 runtime/API error, 2 usage error.`;
|
|
25
|
+
class UsageError extends Error {
|
|
26
|
+
}
|
|
27
|
+
function isFlag(args, flag) {
|
|
28
|
+
const idx = args.indexOf(flag);
|
|
29
|
+
if (idx === -1)
|
|
30
|
+
return false;
|
|
31
|
+
args.splice(idx, 1);
|
|
32
|
+
return true;
|
|
33
|
+
}
|
|
34
|
+
function requireToken() {
|
|
35
|
+
const token = readToken();
|
|
36
|
+
if (!token) {
|
|
37
|
+
throw new Error('Not paired. Pair an agent from Sift Settings, then run: siftctl pair <code>');
|
|
38
|
+
}
|
|
39
|
+
return token;
|
|
40
|
+
}
|
|
41
|
+
function out(data) {
|
|
42
|
+
console.log(JSON.stringify(data, null, 2));
|
|
43
|
+
}
|
|
44
|
+
function liveRows(rows) {
|
|
45
|
+
return rows.filter((r) => r.deleted !== 1 && r.feed_url);
|
|
46
|
+
}
|
|
47
|
+
async function cmdPair(code) {
|
|
48
|
+
if (!code)
|
|
49
|
+
throw new UsageError('pair requires a code: siftctl pair <code>');
|
|
50
|
+
const token = await redeemToken(code.trim());
|
|
51
|
+
writeToken(token);
|
|
52
|
+
console.log(`Paired. Token fingerprint: ${await tokenFingerprint(token)}`);
|
|
53
|
+
}
|
|
54
|
+
async function cmdStatus(json) {
|
|
55
|
+
const cap = await capabilities();
|
|
56
|
+
const token = readToken();
|
|
57
|
+
if (json) {
|
|
58
|
+
out({ sync: cap.sync, url: baseUrl(), paired: token !== null, fingerprint: token ? await tokenFingerprint(token) : null });
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
console.log(`Sync: ${cap.sync ? 'available' : 'unavailable'}`);
|
|
62
|
+
console.log(`URL: ${baseUrl()}`);
|
|
63
|
+
if (token) {
|
|
64
|
+
console.log(`Paired: yes (fingerprint ${await tokenFingerprint(token)})`);
|
|
65
|
+
}
|
|
66
|
+
else {
|
|
67
|
+
console.log('Paired: no — run `siftctl pair <code>`');
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
async function cmdFeeds(json) {
|
|
71
|
+
const token = requireToken();
|
|
72
|
+
const payload = await pull(token);
|
|
73
|
+
const seen = new Set();
|
|
74
|
+
const feeds = liveRows(payload.feeds)
|
|
75
|
+
.filter((f) => {
|
|
76
|
+
if (!f.feed_url || seen.has(f.feed_url))
|
|
77
|
+
return false;
|
|
78
|
+
seen.add(f.feed_url);
|
|
79
|
+
return true;
|
|
80
|
+
});
|
|
81
|
+
if (json) {
|
|
82
|
+
out(feeds.map((f) => ({ feedId: f.feed_id, url: f.feed_url, title: f.title, folder: parseJsonArray(f.folder), tags: parseJsonArray(f.tags) })));
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
for (const f of feeds) {
|
|
86
|
+
console.log(`${f.title ?? '(untitled)'}\t${f.feed_url}`);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
function parseJsonArray(value) {
|
|
90
|
+
if (!value)
|
|
91
|
+
return null;
|
|
92
|
+
try {
|
|
93
|
+
const parsed = JSON.parse(value);
|
|
94
|
+
return Array.isArray(parsed) ? parsed : null;
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
async function resolveFeedId(token, url) {
|
|
101
|
+
const payload = await pull(token);
|
|
102
|
+
const row = liveRows(payload.feeds).find((f) => f.feed_url === url);
|
|
103
|
+
return row?.feed_id ?? url;
|
|
104
|
+
}
|
|
105
|
+
async function cmdFeedAdd(url) {
|
|
106
|
+
if (!url)
|
|
107
|
+
throw new UsageError('feed add requires a URL: siftctl feed add <url>');
|
|
108
|
+
const token = requireToken();
|
|
109
|
+
const feedId = await resolveFeedId(token, url);
|
|
110
|
+
await push(token, { feeds: [{ feedId, feedUrl: url, deleted: 0 }] });
|
|
111
|
+
console.log(`Subscribed: ${url}`);
|
|
112
|
+
}
|
|
113
|
+
async function cmdFeedRemove(url, yes) {
|
|
114
|
+
if (!url)
|
|
115
|
+
throw new UsageError('feed remove requires a URL: siftctl feed remove <url> --yes');
|
|
116
|
+
if (!yes)
|
|
117
|
+
throw new UsageError('feed remove is destructive — pass --yes to confirm: siftctl feed remove <url> --yes');
|
|
118
|
+
const token = requireToken();
|
|
119
|
+
const feedId = await resolveFeedId(token, url);
|
|
120
|
+
await push(token, { feeds: [{ feedId, feedUrl: url, deleted: 1 }] });
|
|
121
|
+
console.log(`Unsubscribed: ${url}`);
|
|
122
|
+
}
|
|
123
|
+
async function cmdItems(url, limit, json) {
|
|
124
|
+
if (!url)
|
|
125
|
+
throw new UsageError('items requires a URL: siftctl items <url>');
|
|
126
|
+
const items = await fetchItems(url, limit);
|
|
127
|
+
if (json) {
|
|
128
|
+
out(items);
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
for (const item of items) {
|
|
132
|
+
console.log(`- ${item.title}`);
|
|
133
|
+
if (item.link)
|
|
134
|
+
console.log(` ${item.link}`);
|
|
135
|
+
console.log(` id: ${item.itemId}`);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
async function cmdMarkRead(itemId) {
|
|
139
|
+
if (!itemId)
|
|
140
|
+
throw new UsageError('mark read requires an item id: siftctl mark read <itemId>');
|
|
141
|
+
const lastSep = itemId.lastIndexOf('::');
|
|
142
|
+
if (lastSep === -1)
|
|
143
|
+
throw new UsageError('item id must contain "::" (feedId::guid)');
|
|
144
|
+
let feedId;
|
|
145
|
+
try {
|
|
146
|
+
feedId = decodeURIComponent(itemId.slice(0, lastSep));
|
|
147
|
+
}
|
|
148
|
+
catch {
|
|
149
|
+
throw new UsageError('item id has an invalid feed prefix');
|
|
150
|
+
}
|
|
151
|
+
const token = requireToken();
|
|
152
|
+
await push(token, { flags: [{ itemId, feedId, read: 1 }] });
|
|
153
|
+
console.log('Marked read.');
|
|
154
|
+
}
|
|
155
|
+
export async function main(argv) {
|
|
156
|
+
const [cmd, ...rest] = argv;
|
|
157
|
+
switch (cmd) {
|
|
158
|
+
case 'pair':
|
|
159
|
+
await cmdPair(rest[0]);
|
|
160
|
+
return 0;
|
|
161
|
+
case 'status':
|
|
162
|
+
await cmdStatus(isFlag(rest, '--json'));
|
|
163
|
+
return 0;
|
|
164
|
+
case 'feeds': {
|
|
165
|
+
const json = isFlag(rest, '--json');
|
|
166
|
+
if (rest.length > 0)
|
|
167
|
+
throw new UsageError('feeds takes no arguments');
|
|
168
|
+
await cmdFeeds(json);
|
|
169
|
+
return 0;
|
|
170
|
+
}
|
|
171
|
+
case 'feed': {
|
|
172
|
+
const sub = rest[0];
|
|
173
|
+
if (sub === 'add') {
|
|
174
|
+
await cmdFeedAdd(rest[1]);
|
|
175
|
+
return 0;
|
|
176
|
+
}
|
|
177
|
+
if (sub === 'remove') {
|
|
178
|
+
const yes = isFlag(rest, '--yes');
|
|
179
|
+
await cmdFeedRemove(rest[1], yes);
|
|
180
|
+
return 0;
|
|
181
|
+
}
|
|
182
|
+
throw new UsageError('feed requires a subcommand: add or remove');
|
|
183
|
+
}
|
|
184
|
+
case 'items': {
|
|
185
|
+
const json = isFlag(rest, '--json');
|
|
186
|
+
let limit = 20;
|
|
187
|
+
const limitIdx = rest.indexOf('--limit');
|
|
188
|
+
if (limitIdx !== -1) {
|
|
189
|
+
const raw = rest[limitIdx + 1];
|
|
190
|
+
limit = Number(raw);
|
|
191
|
+
if (!Number.isInteger(limit) || limit < 1)
|
|
192
|
+
throw new UsageError('--limit must be a positive integer');
|
|
193
|
+
rest.splice(limitIdx, 2);
|
|
194
|
+
}
|
|
195
|
+
await cmdItems(rest[0], limit, json);
|
|
196
|
+
return 0;
|
|
197
|
+
}
|
|
198
|
+
case 'mark':
|
|
199
|
+
if (rest[0] !== 'read')
|
|
200
|
+
throw new UsageError('mark requires: mark read <itemId>');
|
|
201
|
+
await cmdMarkRead(rest[1]);
|
|
202
|
+
return 0;
|
|
203
|
+
case 'help':
|
|
204
|
+
case '--help':
|
|
205
|
+
case '-h':
|
|
206
|
+
case undefined:
|
|
207
|
+
console.log(USAGE);
|
|
208
|
+
return 0;
|
|
209
|
+
default:
|
|
210
|
+
throw new UsageError(`Unknown command: ${cmd}\n\n${USAGE}`);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
export async function runCli(argv) {
|
|
214
|
+
try {
|
|
215
|
+
return await main(argv);
|
|
216
|
+
}
|
|
217
|
+
catch (err) {
|
|
218
|
+
if (err instanceof UsageError) {
|
|
219
|
+
console.error(`Usage: ${err.message}`);
|
|
220
|
+
return 2;
|
|
221
|
+
}
|
|
222
|
+
if (err instanceof ApiError) {
|
|
223
|
+
console.error(`Error: ${err.message}`);
|
|
224
|
+
return 1;
|
|
225
|
+
}
|
|
226
|
+
console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
|
|
227
|
+
return 1;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
231
|
+
runCli(process.argv.slice(2)).then((code) => {
|
|
232
|
+
process.exitCode = code;
|
|
233
|
+
});
|
|
234
|
+
}
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { homedir } from 'node:os';
|
|
2
|
+
import { mkdirSync, readFileSync, writeFileSync, chmodSync } from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
export const DEFAULT_URL = 'https://sift.davegarvey.workers.dev';
|
|
5
|
+
export function baseUrl() {
|
|
6
|
+
return (process.env.SIFTCTL_URL ?? DEFAULT_URL).replace(/\/+$/, '');
|
|
7
|
+
}
|
|
8
|
+
export function tokenPath() {
|
|
9
|
+
const home = process.env.SIFTCTL_HOME ?? path.join(homedir(), '.config');
|
|
10
|
+
return path.join(home, 'siftctl', 'token');
|
|
11
|
+
}
|
|
12
|
+
/** Token precedence: SIFTCTL_TOKEN env, then the config file. */
|
|
13
|
+
export function readToken() {
|
|
14
|
+
const env = process.env.SIFTCTL_TOKEN;
|
|
15
|
+
if (env)
|
|
16
|
+
return env;
|
|
17
|
+
try {
|
|
18
|
+
const value = readFileSync(tokenPath(), 'utf8').trim();
|
|
19
|
+
return value.length > 0 ? value : null;
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
export function writeToken(token) {
|
|
26
|
+
const p = tokenPath();
|
|
27
|
+
mkdirSync(path.dirname(p), { recursive: true, mode: 0o700 });
|
|
28
|
+
writeFileSync(p, token + '\n', { mode: 0o600 });
|
|
29
|
+
chmodSync(p, 0o600);
|
|
30
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/** Crockford base32 alphabet (0-9 and A-Z minus I, L, O, U). */
|
|
2
|
+
const CROCKFORD = '0123456789ABCDEFGHJKMNPQRSTVWXYZ';
|
|
3
|
+
/**
|
|
4
|
+
* Display fingerprint for a credential: SHA-256, first 20 bits rendered as
|
|
5
|
+
* 4 uppercase Crockford characters — identical to the Sift server's scheme,
|
|
6
|
+
* so `siftctl status` shows the same string as the Settings agents list.
|
|
7
|
+
*/
|
|
8
|
+
export async function tokenFingerprint(token) {
|
|
9
|
+
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(token));
|
|
10
|
+
const bytes = new Uint8Array(digest);
|
|
11
|
+
const value = ((bytes[0] << 16) | (bytes[1] << 8) | bytes[2]) & 0xFFFFF;
|
|
12
|
+
return (CROCKFORD[(value >> 15) & 31] +
|
|
13
|
+
CROCKFORD[(value >> 10) & 31] +
|
|
14
|
+
CROCKFORD[(value >> 5) & 31] +
|
|
15
|
+
CROCKFORD[value & 31]);
|
|
16
|
+
}
|
package/dist/items.js
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { extractFromXml } from '@extractus/feed-extractor';
|
|
2
|
+
/**
|
|
3
|
+
* Fetch and parse a feed, mirroring the Sift browser's item identity rules
|
|
4
|
+
* (src/feeds/parse.ts) so item IDs produced here match the flags the
|
|
5
|
+
* browser writes: guid ?? id, else `${link}|${published}`, else link.
|
|
6
|
+
*/
|
|
7
|
+
export async function fetchItems(feedUrl, limit) {
|
|
8
|
+
let res;
|
|
9
|
+
try {
|
|
10
|
+
res = await fetch(feedUrl, {
|
|
11
|
+
headers: { 'User-Agent': 'siftctl/0.1' },
|
|
12
|
+
redirect: 'follow',
|
|
13
|
+
});
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
throw new Error(`Failed to fetch feed: ${feedUrl}`);
|
|
17
|
+
}
|
|
18
|
+
if (!res.ok)
|
|
19
|
+
throw new Error(`HTTP ${res.status} fetching feed: ${feedUrl}`);
|
|
20
|
+
const xml = await res.text();
|
|
21
|
+
let data;
|
|
22
|
+
try {
|
|
23
|
+
data = extractFromXml(xml, {
|
|
24
|
+
descriptionMaxLen: 0,
|
|
25
|
+
getExtraEntryFields: (raw) => {
|
|
26
|
+
const entry = raw;
|
|
27
|
+
const rawGuid = entry['guid'] ?? entry['id'];
|
|
28
|
+
const link = typeof entry['link'] === 'string' ? entry['link'] : undefined;
|
|
29
|
+
const published = entry['pubDate'] ?? entry['published'] ?? entry['updated'];
|
|
30
|
+
let stableGuid;
|
|
31
|
+
if (typeof rawGuid === 'string' && rawGuid.length > 0) {
|
|
32
|
+
stableGuid = rawGuid;
|
|
33
|
+
}
|
|
34
|
+
else if (link && typeof published === 'string') {
|
|
35
|
+
stableGuid = `${link}|${published}`;
|
|
36
|
+
}
|
|
37
|
+
else if (link) {
|
|
38
|
+
stableGuid = link;
|
|
39
|
+
}
|
|
40
|
+
const result = {};
|
|
41
|
+
if (stableGuid)
|
|
42
|
+
result['_guid'] = stableGuid;
|
|
43
|
+
return result;
|
|
44
|
+
},
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
throw new Error(`Failed to parse feed XML: ${feedUrl}`);
|
|
49
|
+
}
|
|
50
|
+
if (!data)
|
|
51
|
+
throw new Error(`Failed to parse feed XML: ${feedUrl}`);
|
|
52
|
+
return (data['entries'] ?? [])
|
|
53
|
+
.map((entry) => mapEntry(entry, feedUrl))
|
|
54
|
+
.filter((i) => i !== null)
|
|
55
|
+
.slice(0, limit);
|
|
56
|
+
}
|
|
57
|
+
function mapEntry(entry, feedUrl) {
|
|
58
|
+
const extra = entry;
|
|
59
|
+
const guid = extra['_guid'] ?? entry.id ?? entry.link ?? '';
|
|
60
|
+
if (!guid)
|
|
61
|
+
return null;
|
|
62
|
+
const publishedAt = parseDate(entry.published);
|
|
63
|
+
const excerpt = (entry.description ?? '').slice(0, 500);
|
|
64
|
+
return {
|
|
65
|
+
title: entry.title ?? '(untitled)',
|
|
66
|
+
link: entry.link,
|
|
67
|
+
publishedAt,
|
|
68
|
+
excerpt,
|
|
69
|
+
guid,
|
|
70
|
+
itemId: `${encodeURIComponent(feedUrl)}::${guid}`,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
function parseDate(value) {
|
|
74
|
+
if (!value)
|
|
75
|
+
return Date.now();
|
|
76
|
+
const t = Date.parse(value);
|
|
77
|
+
return Number.isNaN(t) ? Date.now() : t;
|
|
78
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "siftctl",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Control your Sift RSS subscriptions from the command line or an AI agent",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"siftctl": "dist/cli.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"dist"
|
|
11
|
+
],
|
|
12
|
+
"engines": {
|
|
13
|
+
"node": ">=20"
|
|
14
|
+
},
|
|
15
|
+
"license": "MIT",
|
|
16
|
+
"dependencies": {
|
|
17
|
+
"@extractus/feed-extractor": "^7.2.1"
|
|
18
|
+
},
|
|
19
|
+
"devDependencies": {
|
|
20
|
+
"@types/node": "^26.0.1",
|
|
21
|
+
"typescript": "^6.0.3"
|
|
22
|
+
},
|
|
23
|
+
"scripts": {
|
|
24
|
+
"build": "tsc -p tsconfig.json"
|
|
25
|
+
}
|
|
26
|
+
}
|