@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,285 @@
1
+ const PAGE_SIZE = 128;
2
+ const DAY_MS = 86_400_000;
3
+ function integer(value, fallback, maximum, name) {
4
+ const result = value ?? fallback;
5
+ if (!Number.isInteger(result) || result < 1 || result > maximum) {
6
+ throw new Error(`${name} must be an integer between 1 and ${maximum}.`);
7
+ }
8
+ return result;
9
+ }
10
+ function parseDay(value, name = 'day') {
11
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) {
12
+ throw new Error(`${name} must be a valid date in YYYY-MM-DD format.`);
13
+ }
14
+ const time = Date.parse(`${value}T00:00:00.000Z`);
15
+ if (!Number.isFinite(time) || new Date(time).toISOString().slice(0, 10) !== value) {
16
+ throw new Error(`${name} must be a valid date in YYYY-MM-DD format.`);
17
+ }
18
+ return time;
19
+ }
20
+ function formatDay(time) {
21
+ const result = new Date(time).toISOString().slice(0, 10);
22
+ parseDay(result);
23
+ return result;
24
+ }
25
+ function range(from, to) {
26
+ const start = parseDay(from, 'from');
27
+ const end = parseDay(to, 'to');
28
+ const count = (end - start) / DAY_MS + 1;
29
+ if (count < 1 || count > 31) {
30
+ throw new Error('The date range must be ascending and contain at most 31 days.');
31
+ }
32
+ return Array.from({ length: count }, (_, index) => formatDay(end - index * DAY_MS));
33
+ }
34
+ function todayInTimezone(now, timezone) {
35
+ let parts;
36
+ try {
37
+ parts = new Intl.DateTimeFormat('en-US', {
38
+ timeZone: timezone, year: 'numeric', month: '2-digit', day: '2-digit',
39
+ }).formatToParts(now);
40
+ }
41
+ catch {
42
+ throw new Error(`The NEWS service returned an invalid timezone: ${timezone}`);
43
+ }
44
+ const part = (kind) => parts.find(entry => entry.type === kind)?.value ?? '';
45
+ const result = `${part('year').padStart(4, '0')}-${part('month')}-${part('day')}`;
46
+ parseDay(result);
47
+ return result;
48
+ }
49
+ function normalizeText(value) {
50
+ return value.normalize('NFKC').toLowerCase();
51
+ }
52
+ function matchesText(item, query) {
53
+ return [item.title, item.summary, item.title_en, item.summary_en, item.source, ...item.tags]
54
+ .some(value => normalizeText(value).includes(query));
55
+ }
56
+ function normalizedUrl(value) {
57
+ try {
58
+ const url = new URL(value);
59
+ if (url.protocol !== 'http:' && url.protocol !== 'https:')
60
+ return null;
61
+ url.hash = '';
62
+ for (const key of [...url.searchParams.keys()]) {
63
+ if (/^utm_/i.test(key) || /^(fbclid|gclid|msclkid)$/i.test(key))
64
+ url.searchParams.delete(key);
65
+ }
66
+ url.searchParams.sort();
67
+ return url.href;
68
+ }
69
+ catch {
70
+ // Missing or invalid URLs cannot identify duplicates of different IDs.
71
+ return null;
72
+ }
73
+ }
74
+ function timestamp(item) {
75
+ const published = item.published_at === null ? NaN : Date.parse(item.published_at);
76
+ if (Number.isFinite(published))
77
+ return published;
78
+ const seen = Date.parse(item.first_seen_at);
79
+ return Number.isFinite(seen) ? seen : 0;
80
+ }
81
+ function compareLatest(left, right) {
82
+ return timestamp(right) - timestamp(left)
83
+ || right.importance - left.importance
84
+ || left.id.localeCompare(right.id)
85
+ || left.url.localeCompare(right.url);
86
+ }
87
+ /** Scan the selected calendar days before ranking and applying the global limit. */
88
+ export async function queryNews(api, options = {}, now = new Date()) {
89
+ const limit = integer(options.limit, 10, 100, 'limit');
90
+ const maxPages = integer(options.maxPages, 20, 200, 'maxPages');
91
+ const minImportance = integer(options.minImportance, 1, 5, 'minImportance');
92
+ const sort = options.sort ?? 'latest';
93
+ if (sort !== 'latest' && sort !== 'importance')
94
+ throw new Error('sort must be latest or importance.');
95
+ if (!Number.isFinite(now.getTime()))
96
+ throw new Error('now must be a valid date.');
97
+ if (options.topic !== undefined && !options.topic.trim())
98
+ throw new Error('topic must not be empty.');
99
+ const hasRange = options.from !== undefined || options.to !== undefined;
100
+ const selectors = Number(options.day !== undefined) + Number(options.days !== undefined) + Number(hasRange);
101
+ if (selectors > 1)
102
+ throw new Error('Use only one of day, days, or from/to.');
103
+ if (hasRange && (options.from === undefined || options.to === undefined)) {
104
+ throw new Error('from and to must be supplied together.');
105
+ }
106
+ const dayCount = options.days === undefined ? undefined : integer(options.days, 1, 31, 'days');
107
+ let selectedDays;
108
+ if (hasRange)
109
+ selectedDays = range(options.from, options.to);
110
+ else if (options.day !== undefined && options.day !== 'today' && options.day !== 'yesterday') {
111
+ parseDay(options.day);
112
+ selectedDays = [options.day];
113
+ }
114
+ const query = options.query?.trim() || null;
115
+ const needle = normalizeText(query ?? '');
116
+ const params = (day, topic = options.topic, after) => ({
117
+ ...(topic !== undefined ? { topic } : {}),
118
+ ...(day !== undefined ? { day } : {}),
119
+ limit: PAGE_SIZE,
120
+ ...(after !== undefined ? { after } : {}),
121
+ });
122
+ // For relative selectors this call discovers the service timezone. Its page is
123
+ // reused when it belongs to the requested days; otherwise it is metadata only.
124
+ const initial = await api.digest(params(selectedDays?.[0]));
125
+ if (selectedDays !== undefined && initial.day !== selectedDays[0]) {
126
+ throw new Error(`The NEWS service returned day ${initial.day ?? 'null'} while requesting ${selectedDays[0]}.`);
127
+ }
128
+ const today = todayInTimezone(now, initial.timezone);
129
+ if (selectedDays === undefined) {
130
+ if (dayCount !== undefined) {
131
+ selectedDays = Array.from({ length: dayCount }, (_, index) => formatDay(parseDay(today) - index * DAY_MS));
132
+ }
133
+ else if (options.day === 'today' || options.day === 'yesterday') {
134
+ selectedDays = [formatDay(parseDay(today) - (options.day === 'yesterday' ? DAY_MS : 0))];
135
+ }
136
+ else {
137
+ selectedDays = initial.day === null ? [] : [initial.day];
138
+ if (initial.day !== null)
139
+ parseDay(initial.day);
140
+ }
141
+ }
142
+ const selectedTopics = initial.topics.filter(topic => options.topic === undefined || topic.id === options.topic);
143
+ if (options.topic !== undefined && selectedTopics.length === 0) {
144
+ throw new Error(`The NEWS service did not return the requested topic: ${options.topic}`);
145
+ }
146
+ const topicIds = new Set(selectedTopics.map(topic => topic.id));
147
+ const sources = new Map();
148
+ const groups = new Set();
149
+ const byId = new Map();
150
+ const byUrl = new Map();
151
+ let pagesFetched = 0;
152
+ let complete = true;
153
+ function merge(target, incoming) {
154
+ const preferred = compareLatest(target.item, incoming.item) <= 0 ? target.item : incoming.item;
155
+ target.item = {
156
+ ...preferred,
157
+ importance: Math.max(target.item.importance, incoming.item.importance),
158
+ pinned: target.item.pinned || incoming.item.pinned,
159
+ tags: [...new Set([...target.item.tags, ...incoming.item.tags])],
160
+ };
161
+ for (const id of incoming.ids) {
162
+ target.ids.add(id);
163
+ byId.set(id, target);
164
+ }
165
+ for (const url of incoming.urls) {
166
+ target.urls.add(url);
167
+ byUrl.set(url, target);
168
+ }
169
+ for (const [id, name] of incoming.topics)
170
+ target.topics.set(id, name);
171
+ for (const day of incoming.days)
172
+ target.days.add(day);
173
+ target.matches ||= incoming.matches;
174
+ groups.delete(incoming);
175
+ }
176
+ function collect(topic, day) {
177
+ topicIds.add(topic.id);
178
+ sources.set(`${topic.id}\n${day}`, { topic_id: topic.id, day, updated_at: topic.updated_at });
179
+ for (const item of topic.items) {
180
+ const url = normalizedUrl(item.url);
181
+ const existingId = item.id ? byId.get(item.id) : undefined;
182
+ const existingUrl = url === null ? undefined : byUrl.get(url);
183
+ const incoming = {
184
+ item: { ...item, tags: [...item.tags] },
185
+ ids: new Set(item.id ? [item.id] : []),
186
+ urls: new Set(url === null ? [] : [url]),
187
+ topics: new Map([[topic.id, topic.name]]),
188
+ days: new Set([day]),
189
+ matches: !needle || matchesText(item, needle),
190
+ };
191
+ const existing = existingId ?? existingUrl;
192
+ if (existing === undefined) {
193
+ groups.add(incoming);
194
+ for (const id of incoming.ids)
195
+ byId.set(id, incoming);
196
+ for (const key of incoming.urls)
197
+ byUrl.set(key, incoming);
198
+ }
199
+ else {
200
+ if (existingId !== undefined && existingUrl !== undefined && existingId !== existingUrl) {
201
+ merge(existingId, existingUrl);
202
+ }
203
+ merge(existing, incoming);
204
+ }
205
+ }
206
+ }
207
+ for (const day of selectedDays) {
208
+ if (pagesFetched >= maxPages) {
209
+ complete = false;
210
+ break;
211
+ }
212
+ const root = initial.day === day ? initial : await api.digest(params(day));
213
+ const queue = [];
214
+ const seen = new Map();
215
+ function consume(digest, onlyTopic) {
216
+ if (digest.day !== day) {
217
+ throw new Error(`The NEWS service returned day ${digest.day ?? 'null'} while requesting ${day}.`);
218
+ }
219
+ if (digest.timezone !== initial.timezone)
220
+ throw new Error('The NEWS service timezone changed during pagination. Retry the query.');
221
+ pagesFetched += 1;
222
+ const topics = digest.topics.filter(topic => (onlyTopic ?? options.topic) === undefined || topic.id === (onlyTopic ?? options.topic));
223
+ if (onlyTopic !== undefined && topics.length === 0) {
224
+ throw new Error(`The NEWS service omitted topic ${onlyTopic} during pagination.`);
225
+ }
226
+ for (const topic of topics) {
227
+ collect(topic, day);
228
+ if (topic.next !== null) {
229
+ if (seen.get(topic.id)?.has(topic.next)) {
230
+ throw new Error(`The NEWS service repeated a pagination cursor for topic ${topic.id} on ${day}.`);
231
+ }
232
+ queue.push({ topic: topic.id, cursor: topic.next });
233
+ }
234
+ }
235
+ }
236
+ consume(root);
237
+ while (queue.length > 0) {
238
+ if (pagesFetched >= maxPages) {
239
+ complete = false;
240
+ break;
241
+ }
242
+ const next = queue.shift();
243
+ const cursors = seen.get(next.topic) ?? new Set();
244
+ if (cursors.has(next.cursor)) {
245
+ throw new Error(`The NEWS service repeated a pagination cursor for topic ${next.topic} on ${day}.`);
246
+ }
247
+ cursors.add(next.cursor);
248
+ seen.set(next.topic, cursors);
249
+ consume(await api.digest(params(day, next.topic, next.cursor)), next.topic);
250
+ }
251
+ if (!complete)
252
+ break;
253
+ }
254
+ // An empty catalogue is still one fully consumed response for an unfiltered query.
255
+ if (selectors === 0 && initial.day === null)
256
+ pagesFetched = 1;
257
+ const matchedItems = [...groups]
258
+ .filter(group => group.matches && group.item.importance >= minImportance)
259
+ .map(group => ({
260
+ ...group.item,
261
+ topic_ids: [...group.topics.keys()].sort(),
262
+ topic_names: [...new Set(group.topics.values())].sort(),
263
+ days: [...group.days].sort().reverse(),
264
+ }));
265
+ matchedItems.sort((left, right) => (sort === 'importance' ? right.importance - left.importance : 0) || compareLatest(left, right));
266
+ const items = matchedItems.slice(0, limit);
267
+ return {
268
+ query,
269
+ retrieved_at: now.toISOString(),
270
+ timezone: initial.timezone,
271
+ sort,
272
+ returned: items.length,
273
+ matched: matchedItems.length,
274
+ items,
275
+ sources: [...sources.values()].sort((a, b) => b.day.localeCompare(a.day) || a.topic_id.localeCompare(b.topic_id)),
276
+ coverage: {
277
+ complete,
278
+ pages_fetched: pagesFetched,
279
+ max_pages: maxPages,
280
+ days: selectedDays,
281
+ topic_ids: [...topicIds].sort(),
282
+ reason: complete ? null : 'max_pages',
283
+ },
284
+ };
285
+ }
@@ -0,0 +1,247 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { chmod, lstat, mkdir, mkdtemp, readFile, readdir, realpath, rename, rm, rmdir, writeFile } from 'node:fs/promises';
3
+ import { homedir } from 'node:os';
4
+ import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
6
+ const AGENTS = ['codex', 'claude', 'opencode', 'pi'];
7
+ const SOURCE = fileURLToPath(new URL('../', import.meta.url));
8
+ const MARKER = '.news-skill-install.json';
9
+ const OWNER = 'news-skill';
10
+ function isMissing(error) {
11
+ return error instanceof Error && 'code' in error && error.code === 'ENOENT';
12
+ }
13
+ async function statOrUndefined(path) {
14
+ try {
15
+ return await lstat(path);
16
+ }
17
+ catch (error) {
18
+ if (isMissing(error))
19
+ return undefined;
20
+ throw error;
21
+ }
22
+ }
23
+ function contains(parent, child) {
24
+ const rest = relative(parent, child);
25
+ return rest === '' || (rest !== '..' && !rest.startsWith(`..${sep}`) && !isAbsolute(rest));
26
+ }
27
+ // Resolve even a not-yet-created path through its closest existing ancestor.
28
+ async function canonicalPath(path) {
29
+ const stat = await statOrUndefined(path);
30
+ if (stat)
31
+ return realpath(path);
32
+ const parent = dirname(path);
33
+ if (parent === path)
34
+ throw new Error(`Cannot resolve installation path: ${path}`);
35
+ return join(await canonicalPath(parent), basename(path));
36
+ }
37
+ async function snapshot(root) {
38
+ const rootStat = await lstat(root);
39
+ if (rootStat.isSymbolicLink() || !rootStat.isDirectory()) {
40
+ throw new Error(`Skill path must be a directory, not a symlink: ${root}`);
41
+ }
42
+ const entries = [];
43
+ async function visit(directory, prefix) {
44
+ for (const name of (await readdir(directory)).sort()) {
45
+ if (prefix === '' && name === MARKER)
46
+ continue;
47
+ const path = join(directory, name);
48
+ const local = prefix ? `${prefix}/${name}` : name;
49
+ const stat = await lstat(path);
50
+ if (stat.isSymbolicLink())
51
+ throw new Error(`Skill contains a symlink: ${path}`);
52
+ if (stat.isDirectory()) {
53
+ entries.push({ path: local, kind: 'directory' });
54
+ await visit(path, local);
55
+ }
56
+ else if (stat.isFile()) {
57
+ entries.push({ path: local, kind: 'file', data: await readFile(path), mode: stat.mode & 0o777 });
58
+ }
59
+ else {
60
+ throw new Error(`Skill contains an unsupported file: ${path}`);
61
+ }
62
+ }
63
+ }
64
+ await visit(root, '');
65
+ return entries;
66
+ }
67
+ function fingerprint(entries) {
68
+ const hash = createHash('sha256');
69
+ for (const entry of entries) {
70
+ hash.update(JSON.stringify([entry.path, entry.kind]));
71
+ if (entry.kind === 'file') {
72
+ hash.update(JSON.stringify([entry.data.length, entry.mode & 0o111]));
73
+ hash.update(entry.data);
74
+ }
75
+ }
76
+ return hash.digest('hex');
77
+ }
78
+ async function assertOwned(path) {
79
+ const marker = join(path, MARKER);
80
+ const stat = await statOrUndefined(marker);
81
+ if (stat?.isFile() && !stat.isSymbolicLink()) {
82
+ try {
83
+ const value = JSON.parse(await readFile(marker, 'utf8'));
84
+ if (typeof value === 'object' && value !== null && 'owner' in value && value.owner === OWNER && 'schema' in value && value.schema === 1)
85
+ return;
86
+ }
87
+ catch {
88
+ // An invalid marker does not establish ownership.
89
+ }
90
+ }
91
+ throw new Error(`Refusing to replace an existing unowned skill at ${path}. Move it aside before installing NEWS.`);
92
+ }
93
+ function destination(agent, scope, cwd, home, env) {
94
+ if (scope === 'project') {
95
+ const dirs = { codex: '.agents', claude: '.claude', opencode: '.opencode', pi: '.pi' };
96
+ return join(cwd, dirs[agent], 'skills', 'news');
97
+ }
98
+ switch (agent) {
99
+ case 'codex': return join(home, '.agents', 'skills', 'news');
100
+ case 'claude': return join(home, '.claude', 'skills', 'news');
101
+ case 'opencode': return resolve(cwd, env.XDG_CONFIG_HOME || join(home, '.config'), 'opencode', 'skills', 'news');
102
+ case 'pi': return resolve(cwd, env.PI_CODING_AGENT_DIR || join(home, '.pi', 'agent'), 'skills', 'news');
103
+ }
104
+ }
105
+ async function ensureParents(path, created) {
106
+ const stat = await statOrUndefined(path);
107
+ if (stat) {
108
+ if (!stat.isDirectory() || stat.isSymbolicLink())
109
+ throw new Error(`Installation parent is not a directory: ${path}`);
110
+ return;
111
+ }
112
+ await ensureParents(dirname(path), created);
113
+ try {
114
+ await mkdir(path);
115
+ created.push(path);
116
+ }
117
+ catch (error) {
118
+ if (!(error instanceof Error && 'code' in error && error.code === 'EEXIST'))
119
+ throw error;
120
+ const current = await lstat(path);
121
+ if (!current.isDirectory() || current.isSymbolicLink())
122
+ throw new Error(`Installation parent changed: ${path}`);
123
+ }
124
+ }
125
+ async function writeSnapshot(root, entries) {
126
+ await mkdir(root);
127
+ for (const entry of entries) {
128
+ const path = join(root, entry.path);
129
+ if (entry.kind === 'directory')
130
+ await mkdir(path);
131
+ else {
132
+ await writeFile(path, entry.data, { flag: 'wx', mode: entry.mode });
133
+ await chmod(path, entry.mode);
134
+ }
135
+ }
136
+ await writeFile(join(root, MARKER), `${JSON.stringify({ owner: OWNER, schema: 1 }, null, 2)}\n`, { flag: 'wx' });
137
+ }
138
+ export async function readSkill(source = SOURCE) {
139
+ return readFile(join(source, 'SKILL.md'), 'utf8');
140
+ }
141
+ export async function installSkills(options) {
142
+ if (options.agent !== 'all' && !AGENTS.includes(options.agent)) {
143
+ throw new Error('Choose an agent with --agent codex, claude, opencode, pi, or all.');
144
+ }
145
+ const scope = options.scope ?? 'user';
146
+ if (scope !== 'user' && scope !== 'project')
147
+ throw new Error('Skill scope must be user or project.');
148
+ const agents = options.agent === 'all' ? AGENTS : [options.agent];
149
+ const cwd = resolve(options.cwd ?? process.cwd());
150
+ const home = resolve(options.home ?? homedir());
151
+ const env = options.env ?? process.env;
152
+ const source = resolve(options.source ?? SOURCE);
153
+ const sourceEntries = await snapshot(source);
154
+ if (!sourceEntries.some(entry => entry.path === 'SKILL.md' && entry.kind === 'file')) {
155
+ throw new Error(`Bundled skill is missing SKILL.md: ${source}`);
156
+ }
157
+ const sourceReal = await realpath(source);
158
+ const sourceHash = fingerprint(sourceEntries);
159
+ const plans = [];
160
+ // Validate every destination before creating any directories or staging files.
161
+ for (const agent of agents) {
162
+ const path = destination(agent, scope, cwd, home, env);
163
+ const stat = await statOrUndefined(path);
164
+ if (stat?.isSymbolicLink())
165
+ throw new Error(`Refusing to install over a symlink: ${path}`);
166
+ const canonical = await canonicalPath(path);
167
+ if (contains(source, path) || contains(path, source) || contains(sourceReal, canonical) || contains(canonical, sourceReal)) {
168
+ throw new Error(`Skill source and installation destination overlap: ${path}`);
169
+ }
170
+ if (plans.some(plan => contains(plan.canonicalPath, canonical) || contains(canonical, plan.canonicalPath))) {
171
+ throw new Error(`Selected agent installation paths overlap: ${path}`);
172
+ }
173
+ const plan = { agent, scope, path, canonicalPath: canonical, status: 'installed' };
174
+ if (stat) {
175
+ if (!stat.isDirectory())
176
+ throw new Error(`Installation destination is not a directory: ${path}`);
177
+ await assertOwned(path);
178
+ plan.originalHash = fingerprint(await snapshot(path));
179
+ if (plan.originalHash === sourceHash)
180
+ plan.status = 'unchanged';
181
+ else {
182
+ if (!options.force)
183
+ throw new Error(`NEWS skill already exists with different contents at ${path}. Use --force to replace this NEWS installation.`);
184
+ plan.status = 'updated';
185
+ }
186
+ }
187
+ plans.push(plan);
188
+ }
189
+ const created = [];
190
+ const pending = plans.filter(plan => plan.status !== 'unchanged');
191
+ try {
192
+ for (const plan of pending) {
193
+ // Work at the resolved location so a symlinked home/config directory is supported.
194
+ await ensureParents(dirname(plan.canonicalPath), created);
195
+ plan.staging = await mkdtemp(join(dirname(plan.canonicalPath), '.news-skill-install-'));
196
+ await writeSnapshot(join(plan.staging, 'new'), sourceEntries);
197
+ }
198
+ for (const plan of pending) {
199
+ if (await canonicalPath(plan.path) !== plan.canonicalPath)
200
+ throw new Error(`Installation path changed during installation: ${plan.path}`);
201
+ const stat = await statOrUndefined(plan.path);
202
+ if (stat?.isSymbolicLink())
203
+ throw new Error(`Installation path became a symlink: ${plan.path}`);
204
+ if (plan.originalHash !== undefined) {
205
+ await assertOwned(plan.path);
206
+ if (fingerprint(await snapshot(plan.path)) !== plan.originalHash)
207
+ throw new Error(`NEWS skill changed during installation: ${plan.path}`);
208
+ await rename(plan.canonicalPath, join(plan.staging, 'backup'));
209
+ plan.movedOld = true;
210
+ }
211
+ else if (stat) {
212
+ throw new Error(`Installation destination appeared during installation: ${plan.path}`);
213
+ }
214
+ await rename(join(plan.staging, 'new'), plan.canonicalPath);
215
+ plan.installed = true;
216
+ }
217
+ }
218
+ catch (error) {
219
+ const recovery = [];
220
+ for (const plan of [...pending].reverse()) {
221
+ try {
222
+ if (plan.installed)
223
+ await rm(plan.canonicalPath, { recursive: true, force: true });
224
+ if (plan.movedOld)
225
+ await rename(join(plan.staging, 'backup'), plan.canonicalPath);
226
+ if (plan.staging)
227
+ await rm(plan.staging, { recursive: true, force: true });
228
+ }
229
+ catch {
230
+ if (plan.staging)
231
+ recovery.push(plan.staging);
232
+ }
233
+ }
234
+ for (const path of [...created].reverse()) {
235
+ try {
236
+ await rmdir(path);
237
+ }
238
+ catch { /* Keep directories if another process has populated them. */ }
239
+ }
240
+ if (recovery.length)
241
+ throw new Error(`Skill installation failed; previous files are preserved for recovery at ${recovery.join(', ')}.`, { cause: error });
242
+ throw error;
243
+ }
244
+ for (const plan of pending)
245
+ await rm(plan.staging, { recursive: true, force: true });
246
+ return { installations: plans.map(({ agent, scope, path, status }) => ({ agent, scope, path, status })) };
247
+ }
@@ -0,0 +1 @@
1
+ export {};