@grknbyk/agent-wire 0.4.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/LICENSE +21 -0
- package/README.md +243 -0
- package/assets/agent-wire.png +0 -0
- package/bin/agent-wire.mjs +115 -0
- package/manifest.json +38 -0
- package/package.json +40 -0
- package/src/config.mjs +68 -0
- package/src/identity.mjs +73 -0
- package/src/inbox.mjs +76 -0
- package/src/mcp.mjs +309 -0
- package/src/protocol.mjs +68 -0
- package/src/setup.mjs +243 -0
- package/src/slack.mjs +236 -0
- package/src/status.mjs +167 -0
package/src/slack.mjs
ADDED
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
// The Slack side: one thin client, three read-only probes that setup and doctor
|
|
2
|
+
// both use, and the poll that turns channel history into local inbox items.
|
|
3
|
+
import { basename } from 'node:path';
|
|
4
|
+
import { readFileSync } from 'node:fs';
|
|
5
|
+
|
|
6
|
+
import { paths, readJson, writeJson } from './config.mjs';
|
|
7
|
+
import { checkAuthorship } from './identity.mjs';
|
|
8
|
+
import { HUMAN_TEXT_CAP, METADATA_EVENT, fromSlackText, parseMessage } from './protocol.mjs';
|
|
9
|
+
|
|
10
|
+
const API = 'https://slack.com/api/';
|
|
11
|
+
const RATE_LIMITED = 429;
|
|
12
|
+
const DEFAULT_RETRY_SECONDS = 5;
|
|
13
|
+
const PAGE_LIMIT = 100;
|
|
14
|
+
const MAX_PAGES = 10;
|
|
15
|
+
|
|
16
|
+
// conversations.* reject a JSON body and chat.postMessage needs one for metadata,
|
|
17
|
+
// so the client speaks both and the caller picks per method.
|
|
18
|
+
export function slackClient(token) {
|
|
19
|
+
const request = async (method, init) => {
|
|
20
|
+
const response = await fetch(API + method, {
|
|
21
|
+
...init,
|
|
22
|
+
headers: { authorization: `Bearer ${token}`, ...init.headers },
|
|
23
|
+
});
|
|
24
|
+
if (response.status !== RATE_LIMITED) return response.json();
|
|
25
|
+
|
|
26
|
+
const wait = Number(response.headers.get('retry-after') || DEFAULT_RETRY_SECONDS);
|
|
27
|
+
await new Promise((done) => setTimeout(done, wait * 1000));
|
|
28
|
+
return request(method, init);
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
return {
|
|
32
|
+
form: (method, params) => request(method, {
|
|
33
|
+
method: 'POST',
|
|
34
|
+
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
|
35
|
+
body: new URLSearchParams(params),
|
|
36
|
+
}),
|
|
37
|
+
json: (method, body) => request(method, {
|
|
38
|
+
method: 'POST',
|
|
39
|
+
headers: { 'content-type': 'application/json; charset=utf-8' },
|
|
40
|
+
body: JSON.stringify(body),
|
|
41
|
+
}),
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// --- probes: read-only, and the same three answer "is setup done" and "what
|
|
46
|
+
// broke". Each returns a verdict plus the reason, never a bare boolean, because
|
|
47
|
+
// a spinner that cannot say why it is still spinning is the worst dead-end.
|
|
48
|
+
|
|
49
|
+
export async function probeToken(client) {
|
|
50
|
+
const result = await client.form('auth.test', {});
|
|
51
|
+
if (result.ok) return { ok: true, teamId: result.team_id, botUserId: result.user_id, team: result.team };
|
|
52
|
+
return { ok: false, reason: result.error };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export async function probeChannel(client, name) {
|
|
56
|
+
const wanted = String(name).replace(/^#/, '').toLowerCase();
|
|
57
|
+
let cursor = '';
|
|
58
|
+
for (let page = 0; page < MAX_PAGES; page++) {
|
|
59
|
+
const result = await client.form('conversations.list', {
|
|
60
|
+
types: 'public_channel,private_channel',
|
|
61
|
+
exclude_archived: true,
|
|
62
|
+
limit: 200,
|
|
63
|
+
cursor,
|
|
64
|
+
});
|
|
65
|
+
if (!result.ok) return { ok: false, reason: result.error };
|
|
66
|
+
|
|
67
|
+
const found = result.channels.find((channel) => channel.name.toLowerCase() === wanted);
|
|
68
|
+
if (found) return { ok: true, id: found.id, name: found.name, isMember: found.is_member, isPrivate: found.is_private };
|
|
69
|
+
|
|
70
|
+
cursor = result.response_metadata?.next_cursor ?? '';
|
|
71
|
+
if (!cursor) return { ok: false, reason: 'channel_not_found' };
|
|
72
|
+
}
|
|
73
|
+
return { ok: false, reason: 'channel_not_found' };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// A public channel we can create and join ourselves, which removes the invite
|
|
77
|
+
// step entirely. A private one has to be created by a human and the bot invited,
|
|
78
|
+
// because no scope lets an app add itself to a private conversation.
|
|
79
|
+
export async function ensureChannel(client, name) {
|
|
80
|
+
const existing = await probeChannel(client, name);
|
|
81
|
+
if (existing.ok && existing.isMember) return existing;
|
|
82
|
+
|
|
83
|
+
if (existing.ok && !existing.isPrivate) {
|
|
84
|
+
const joined = await client.form('conversations.join', { channel: existing.id });
|
|
85
|
+
if (!joined.ok) return { ok: false, reason: joined.error, id: existing.id };
|
|
86
|
+
return { ...existing, isMember: true };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (existing.ok) return { ok: false, reason: 'needs_invite', id: existing.id, name: existing.name };
|
|
90
|
+
|
|
91
|
+
if (existing.reason !== 'channel_not_found') return existing;
|
|
92
|
+
|
|
93
|
+
const created = await client.form('conversations.create', { name: String(name).replace(/^#/, ''), is_private: false });
|
|
94
|
+
if (!created.ok) return { ok: false, reason: created.error };
|
|
95
|
+
return { ok: true, id: created.channel.id, name: created.channel.name, isMember: true, isPrivate: false, created: true };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// --- sending
|
|
99
|
+
|
|
100
|
+
// `rendered` is the whole visible message — header line plus body — because that
|
|
101
|
+
// is what a human scrolling the channel reads. The signature and routing fields
|
|
102
|
+
// travel in metadata, which Slack never renders.
|
|
103
|
+
export async function postMessage(client, { channel, rendered, signature, publicKey, from, to, conv, hop }) {
|
|
104
|
+
const result = await client.json('chat.postMessage', {
|
|
105
|
+
channel,
|
|
106
|
+
text: rendered,
|
|
107
|
+
metadata: {
|
|
108
|
+
event_type: METADATA_EVENT,
|
|
109
|
+
event_payload: { v: 1, from, to, conv, hop, sig: signature, key: publicKey },
|
|
110
|
+
},
|
|
111
|
+
});
|
|
112
|
+
if (result.ok) return { ok: true, ts: result.ts };
|
|
113
|
+
return { ok: false, reason: result.error };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export async function uploadFile(client, { channel, path, comment }) {
|
|
117
|
+
const bytes = readFileSync(path);
|
|
118
|
+
const name = basename(path);
|
|
119
|
+
const slot = await client.form('files.getUploadURLExternal', { filename: name, length: bytes.length });
|
|
120
|
+
if (!slot.ok) return { ok: false, reason: slot.error };
|
|
121
|
+
|
|
122
|
+
const upload = await fetch(slot.upload_url, { method: 'POST', body: bytes });
|
|
123
|
+
if (!upload.ok) return { ok: false, reason: `upload_failed_http_${upload.status}` };
|
|
124
|
+
|
|
125
|
+
const done = await client.form('files.completeUploadExternal', {
|
|
126
|
+
files: JSON.stringify([{ id: slot.file_id, title: name }]),
|
|
127
|
+
channel_id: channel,
|
|
128
|
+
initial_comment: comment,
|
|
129
|
+
});
|
|
130
|
+
return done.ok ? { ok: true } : { ok: false, reason: done.error };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// --- polling
|
|
134
|
+
|
|
135
|
+
async function resolveUserName(client, userId) {
|
|
136
|
+
const cached = readJson(paths.users, {});
|
|
137
|
+
if (cached[userId]) return cached[userId];
|
|
138
|
+
|
|
139
|
+
const result = await client.form('users.info', { user: userId });
|
|
140
|
+
const name = result.ok ? (result.user.profile?.display_name || result.user.real_name || userId) : userId;
|
|
141
|
+
writeJson(paths.users, { ...cached, [userId]: name });
|
|
142
|
+
return name;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// A human typing in the channel is worth seeing but is never a directive: it is
|
|
146
|
+
// capped, marked, and handed to the agent as data. Slack's own user id is the
|
|
147
|
+
// identity here — that field is not writable by the person typing.
|
|
148
|
+
async function humanItem(client, message, channel) {
|
|
149
|
+
const typed = fromSlackText(message.text ?? '').trim();
|
|
150
|
+
if (!typed) return null;
|
|
151
|
+
|
|
152
|
+
const text = typed.length > HUMAN_TEXT_CAP
|
|
153
|
+
? `${typed.slice(0, HUMAN_TEXT_CAP)}\n... ${typed.length - HUMAN_TEXT_CAP} more characters truncated`
|
|
154
|
+
: typed;
|
|
155
|
+
return {
|
|
156
|
+
ts: message.ts,
|
|
157
|
+
at: new Date(Number(message.ts) * 1000).toISOString(),
|
|
158
|
+
channel: channel.name,
|
|
159
|
+
channelId: channel.id,
|
|
160
|
+
from: await resolveUserName(client, message.user),
|
|
161
|
+
userId: message.user,
|
|
162
|
+
kind: 'human',
|
|
163
|
+
authorship: 'slack-verified',
|
|
164
|
+
hop: 1,
|
|
165
|
+
text,
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function agentItem(message, channel, payload) {
|
|
170
|
+
const parsed = parseMessage(message.text ?? '');
|
|
171
|
+
const text = parsed?.text ?? fromSlackText(message.text ?? '');
|
|
172
|
+
const authorship = checkAuthorship({
|
|
173
|
+
from: payload.from,
|
|
174
|
+
publicKey: payload.key,
|
|
175
|
+
signature: payload.sig,
|
|
176
|
+
channel: channel.id,
|
|
177
|
+
to: payload.to,
|
|
178
|
+
conv: payload.conv,
|
|
179
|
+
hop: payload.hop,
|
|
180
|
+
text,
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
return {
|
|
184
|
+
ts: message.ts,
|
|
185
|
+
at: new Date(Number(message.ts) * 1000).toISOString(),
|
|
186
|
+
channel: channel.name,
|
|
187
|
+
channelId: channel.id,
|
|
188
|
+
from: payload.from,
|
|
189
|
+
to: payload.to,
|
|
190
|
+
kind: 'agent',
|
|
191
|
+
authorship: authorship.verdict,
|
|
192
|
+
conv: payload.conv,
|
|
193
|
+
hop: Number(payload.hop) || 1,
|
|
194
|
+
text,
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Returns items oldest-first. With `oldest` set, Slack answers from the old end of
|
|
199
|
+
// the range, so messages[0] is the high-water mark and a burst wider than one page
|
|
200
|
+
// is carried across polls rather than dropped.
|
|
201
|
+
export async function pollChannel(client, channel, { since, myNickname }) {
|
|
202
|
+
const items = [];
|
|
203
|
+
let newest = since;
|
|
204
|
+
let cursor = '';
|
|
205
|
+
|
|
206
|
+
for (let page = 0; page < MAX_PAGES; page++) {
|
|
207
|
+
const history = await client.form('conversations.history', {
|
|
208
|
+
channel: channel.id,
|
|
209
|
+
limit: PAGE_LIMIT,
|
|
210
|
+
...(since ? { oldest: since } : {}),
|
|
211
|
+
...(cursor ? { cursor } : {}),
|
|
212
|
+
});
|
|
213
|
+
if (!history.ok) return { ok: false, reason: history.error, items: [] };
|
|
214
|
+
|
|
215
|
+
for (const message of history.messages.slice().reverse()) {
|
|
216
|
+
if (message.subtype) continue;
|
|
217
|
+
if (!newest || Number(message.ts) > Number(newest)) newest = message.ts;
|
|
218
|
+
|
|
219
|
+
const payload = message.metadata?.event_type === METADATA_EVENT ? message.metadata.event_payload : null;
|
|
220
|
+
if (payload) {
|
|
221
|
+
if (payload.from === myNickname) continue; // our own post, already in our log
|
|
222
|
+
items.push(agentItem(message, channel, payload));
|
|
223
|
+
continue;
|
|
224
|
+
}
|
|
225
|
+
if (message.bot_id) continue; // another app, or one of our own header-only posts
|
|
226
|
+
|
|
227
|
+
const human = await humanItem(client, message, channel);
|
|
228
|
+
if (human) items.push(human);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
cursor = history.response_metadata?.next_cursor ?? '';
|
|
232
|
+
if (!history.has_more || !cursor) break;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
return { ok: true, items, newest };
|
|
236
|
+
}
|
package/src/status.mjs
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
// A local status panel. It reads the config and the log and nothing else: no
|
|
2
|
+
// network call, so it answers instantly. Whether Slack still accepts the token is
|
|
3
|
+
// `doctor`'s question, and duplicating it here would make one of the two slow.
|
|
4
|
+
import { existsSync, statSync } from 'node:fs';
|
|
5
|
+
|
|
6
|
+
import { loadConfig, paths, readJson } from './config.mjs';
|
|
7
|
+
import { readInbox, stateOf } from './inbox.mjs';
|
|
8
|
+
|
|
9
|
+
const INNER_WIDTH = 42;
|
|
10
|
+
const LABEL_WIDTH = 6;
|
|
11
|
+
const HALF = INNER_WIDTH / 2;
|
|
12
|
+
const PEER_CELL = INNER_WIDTH / 3;
|
|
13
|
+
|
|
14
|
+
const graphemes = new Intl.Segmenter(undefined, { granularity: 'grapheme' });
|
|
15
|
+
|
|
16
|
+
// A terminal draws an emoji two columns wide and a box character one, so counting
|
|
17
|
+
// characters misaligns any row holding an emoji nickname. Count columns instead.
|
|
18
|
+
const WIDE = /^[ᄀ-ᅟ⺀-가-힣豈-︰--⦆¢-₩]/;
|
|
19
|
+
const ZERO = /^[̀-ͯ-︀-️]/;
|
|
20
|
+
|
|
21
|
+
export function displayWidth(text) {
|
|
22
|
+
let columns = 0;
|
|
23
|
+
for (const { segment } of graphemes.segment(String(text))) {
|
|
24
|
+
if (ZERO.test(segment)) continue;
|
|
25
|
+
columns += (WIDE.test(segment) || /\p{Extended_Pictographic}/u.test(segment)) ? 2 : 1;
|
|
26
|
+
}
|
|
27
|
+
return columns;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const pad = (text, columns) => text + ' '.repeat(Math.max(0, columns - displayWidth(text)));
|
|
31
|
+
|
|
32
|
+
// Cut to fit and say so with one character, so a long nickname costs the row one
|
|
33
|
+
// column rather than pushing the right border out and ragging the whole panel.
|
|
34
|
+
function fit(text, columns) {
|
|
35
|
+
if (displayWidth(text) <= columns) return text;
|
|
36
|
+
|
|
37
|
+
let kept = '';
|
|
38
|
+
for (const { segment } of graphemes.segment(String(text))) {
|
|
39
|
+
if (displayWidth(kept + segment) > columns - 1) break;
|
|
40
|
+
kept += segment;
|
|
41
|
+
}
|
|
42
|
+
return `${kept}…`;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// A label longer than the column takes the room it needs; the space after it is
|
|
46
|
+
// part of the label, so the value never runs into it.
|
|
47
|
+
function cell(label, value, columns) {
|
|
48
|
+
const head = pad(`${label} `, LABEL_WIDTH);
|
|
49
|
+
return pad(head + fit(value, columns - displayWidth(head)), columns);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// One space of margin on each side, so a row is INNER_WIDTH + 4 columns and lines
|
|
53
|
+
// up with the dividers, which span INNER_WIDTH + 2 between their corners.
|
|
54
|
+
const row = (text) => `│ ${pad(fit(text, INNER_WIDTH), INNER_WIDTH)} │`;
|
|
55
|
+
const pair = (label, value, otherLabel, otherValue) =>
|
|
56
|
+
row(cell(label, value, HALF) + cell(otherLabel, otherValue, HALF));
|
|
57
|
+
|
|
58
|
+
function divider(title) {
|
|
59
|
+
const label = ` ${title} `;
|
|
60
|
+
const left = Math.floor((INNER_WIDTH + 2 - displayWidth(label)) / 2);
|
|
61
|
+
return `├${'─'.repeat(left)}${label}${'─'.repeat(INNER_WIDTH + 2 - left - displayWidth(label))}┤`;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const top = (title) => divider(title).replace('├', '┌').replace('┤', '┐');
|
|
65
|
+
|
|
66
|
+
function ago(milliseconds) {
|
|
67
|
+
const seconds = Math.round(milliseconds / 1000);
|
|
68
|
+
if (seconds < 60) return `${seconds}s ago`;
|
|
69
|
+
if (seconds < 3600) return `${Math.round(seconds / 60)}m ago`;
|
|
70
|
+
if (seconds < 86400) return `${Math.round(seconds / 3600)}h ago`;
|
|
71
|
+
return `${Math.round(seconds / 86400)}d ago`;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const lastPoll = () => (existsSync(paths.pollLock) ? ago(Date.now() - statSync(paths.pollLock).mtimeMs) : 'never');
|
|
75
|
+
|
|
76
|
+
const PEERS_SHOWN = 9; // three full rows of the three-column layout
|
|
77
|
+
|
|
78
|
+
// Who this agent has actually heard from. The pinned keys say which agents are
|
|
79
|
+
// known; the log says when each of them last spoke, and which humans did too.
|
|
80
|
+
function correspondents() {
|
|
81
|
+
const seen = new Map();
|
|
82
|
+
for (const item of readInbox()) {
|
|
83
|
+
if (item.authorship === 'self') continue;
|
|
84
|
+
|
|
85
|
+
const previous = seen.get(item.from) ?? { name: item.from, everForged: false, at: '' };
|
|
86
|
+
// One forged message stays on the record. A later message that verifies
|
|
87
|
+
// does not undo it, or an attacker could bury the sighting by writing
|
|
88
|
+
// again, which is exactly what an attacker would do.
|
|
89
|
+
previous.everForged = previous.everForged || item.authorship === 'impostor';
|
|
90
|
+
if (item.at >= previous.at) Object.assign(previous, { kind: item.kind, authorship: item.authorship, at: item.at });
|
|
91
|
+
seen.set(item.from, previous);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// A pinned key whose messages have all been pruned still counts as known.
|
|
95
|
+
for (const [name, peer] of Object.entries(readJson(paths.peers, {}))) {
|
|
96
|
+
if (seen.has(name)) continue;
|
|
97
|
+
seen.set(name, { name, kind: 'agent', authorship: 'signed', at: peer.firstSeen, everForged: false });
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return [...seen.values()].sort((first, second) => second.at.localeCompare(first.at));
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function unreadByChannel() {
|
|
104
|
+
const states = readJson(paths.states, {});
|
|
105
|
+
const counts = {};
|
|
106
|
+
for (const item of readInbox()) {
|
|
107
|
+
if (stateOf(states, item) !== 'unread') continue;
|
|
108
|
+
counts[item.channel] = (counts[item.channel] ?? 0) + 1;
|
|
109
|
+
}
|
|
110
|
+
return counts;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// The panel has room for a symbol, not a word. `*` and `@` are the two marks a
|
|
114
|
+
// reader already associates with a machine and a person, and `!` is the one that
|
|
115
|
+
// stops the eye. All three are ASCII, so no terminal draws them double-width and
|
|
116
|
+
// tips a row over its border.
|
|
117
|
+
const peerMark = (peer) => (peer.everForged ? '!' : peer.kind === 'human' ? '@' : '*');
|
|
118
|
+
|
|
119
|
+
export function renderStatus(config) {
|
|
120
|
+
const counts = unreadByChannel();
|
|
121
|
+
const lines = [
|
|
122
|
+
top('agent-wire'),
|
|
123
|
+
pair('name', config.nickname ?? '(unset)', 'mark', config.mark || '(none)'),
|
|
124
|
+
row(cell('key', config.public_key ?? '', INNER_WIDTH)),
|
|
125
|
+
divider('CHANNELS'),
|
|
126
|
+
];
|
|
127
|
+
|
|
128
|
+
const channels = config.channels ?? [];
|
|
129
|
+
if (channels.length === 0) lines.push(row('none configured'));
|
|
130
|
+
|
|
131
|
+
for (const channel of channels) {
|
|
132
|
+
const isOn = channel.active !== false;
|
|
133
|
+
const waiting = counts[channel.name] ?? 0;
|
|
134
|
+
lines.push(row(
|
|
135
|
+
`${pad(fit(channel.name, 12), 13)}${isOn ? '● on ' : '○ off'}`
|
|
136
|
+
+ `${String(waiting).padStart(6)} ${isOn ? 'unread' : 'held'}`,
|
|
137
|
+
));
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const peers = correspondents();
|
|
141
|
+
lines.push(divider('PEERS'));
|
|
142
|
+
if (peers.length === 0) lines.push(row('nobody has written yet'));
|
|
143
|
+
|
|
144
|
+
const shown = peers.slice(0, PEERS_SHOWN);
|
|
145
|
+
for (let index = 0; index < shown.length; index += 3) {
|
|
146
|
+
lines.push(row(shown.slice(index, index + 3)
|
|
147
|
+
.map((peer) => pad(`${peerMark(peer)} ${fit(peer.name, PEER_CELL - 3)}`, PEER_CELL))
|
|
148
|
+
.join('')));
|
|
149
|
+
}
|
|
150
|
+
if (peers.length > PEERS_SHOWN) lines.push(row(`and ${peers.length - PEERS_SHOWN} more`));
|
|
151
|
+
|
|
152
|
+
lines.push(
|
|
153
|
+
divider('STATE'),
|
|
154
|
+
pair('workspace', config.team ?? config.team_id ?? '(unknown)', 'poll', lastPoll()),
|
|
155
|
+
`└${'─'.repeat(INNER_WIDTH + 2)}┘`,
|
|
156
|
+
);
|
|
157
|
+
// The leading blank line keeps the box off the command that produced it.
|
|
158
|
+
return `\n${lines.join('\n')}`;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export function runStatus() {
|
|
162
|
+
const config = loadConfig();
|
|
163
|
+
if (!config) return null;
|
|
164
|
+
|
|
165
|
+
console.log(renderStatus(config));
|
|
166
|
+
return 0;
|
|
167
|
+
}
|