@popoverinstall/cli 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.
@@ -0,0 +1,286 @@
1
+ #!/usr/bin/env node
2
+ // Popover MCP server — lets an agent see and ask its teammates' agents.
3
+ //
4
+ // Implemented directly against the JSON-RPC stdio protocol rather than the MCP SDK. The
5
+ // plugin is distributed through a marketplace and installed into a cache directory where
6
+ // dependency installation is conditional and can fail; a server with zero dependencies
7
+ // simply cannot fail that way. The protocol surface we need is small — initialize,
8
+ // tools/list, tools/call — so the SDK buys us little here.
9
+ //
10
+ // All real work happens in the daemon. This process is a thin bridge to it.
11
+
12
+ import { readFileSync } from "node:fs";
13
+ import { callerSessionId, request } from "../scripts/_ipc.mjs";
14
+
15
+ const PROTOCOL_VERSION = "2025-06-18";
16
+
17
+ // Read from the plugin manifest, which Claude Code copies into its cache alongside this
18
+ // file — so the relative path survives the severing copy that breaks everything else.
19
+ const SERVER_INFO = {
20
+ name: "popover",
21
+ version: JSON.parse(
22
+ readFileSync(new URL("../.claude-plugin/plugin.json", import.meta.url), "utf8"),
23
+ ).version,
24
+ };
25
+
26
+ const TOOLS = [
27
+ {
28
+ name: "team_list",
29
+ description:
30
+ "List your teammates' currently-active Claude Code agents in THIS repo: who owns " +
31
+ "each one, what it is doing right now, and its short handle (e.g. B1). Only agents " +
32
+ "working in the same repository as you are visible — agents your teammates are " +
33
+ "running in other repos are not listed and cannot be asked. Use this before " +
34
+ "team_ask to find out which agent to ask. Read-only.",
35
+ inputSchema: { type: "object", properties: {}, additionalProperties: false },
36
+ annotations: { readOnlyHint: true, openWorldHint: true, title: "List team agents" },
37
+ },
38
+ {
39
+ name: "team_ask",
40
+ description:
41
+ "Ask a teammate's Claude Code agent a question and wait for the answer. A " +
42
+ "read-only copy of that agent answers from its full accumulated context; the " +
43
+ "teammate's live session is never interrupted or modified. Use this when a " +
44
+ "teammate's agent already knows something that would take you a long time to " +
45
+ "work out — why a decision was made, what they already ruled out, how a service " +
46
+ "they own behaves. Only agents working in the same repository as you can be asked. " +
47
+ "Answering costs the teammate tokens and takes up to ~90s, so ask one well-formed " +
48
+ "question rather than several exploratory ones.",
49
+ inputSchema: {
50
+ type: "object",
51
+ properties: {
52
+ target: {
53
+ type: "string",
54
+ description:
55
+ "Which agent to ask: a handle from team_list such as 'B1', or a teammate's " +
56
+ "name, or a repo name.",
57
+ },
58
+ question: {
59
+ type: "string",
60
+ description:
61
+ "A self-contained question. The answering agent has none of your " +
62
+ "conversation's context, so expand pronouns and vague references.",
63
+ },
64
+ timeout_seconds: {
65
+ type: "number",
66
+ description: "How long to wait for an answer. Default 90, max 300.",
67
+ minimum: 5,
68
+ maximum: 300,
69
+ },
70
+ },
71
+ required: ["target", "question"],
72
+ additionalProperties: false,
73
+ },
74
+ annotations: { readOnlyHint: true, openWorldHint: true, title: "Ask a teammate's agent" },
75
+ },
76
+ ];
77
+
78
+ // ---------------------------------------------------------------------------
79
+ // Tool implementations
80
+ // ---------------------------------------------------------------------------
81
+
82
+ async function callTeamList() {
83
+ // Sent so the daemon can mark which line is this agent's own, and so it can scope the
84
+ // roster to the repo this session is working in. Absent outside a session, in which case
85
+ // the roster comes back unmarked rather than failing.
86
+ const reply = await request(
87
+ {
88
+ t: "roster",
89
+ id: rpcId(),
90
+ refresh: true,
91
+ ...(callerSessionId() ? { fromSessionId: callerSessionId() } : {}),
92
+ // Fallback for the narrow window where the daemon has not seen this session yet. The
93
+ // session is authoritative when known; this is only ever consulted when it is not.
94
+ cwd: process.cwd(),
95
+ },
96
+ { timeoutMs: 15000 },
97
+ );
98
+
99
+ if (!reply) return errorText(daemonDownMessage());
100
+ if (reply.t === "error") return errorText(explain(reply));
101
+ if (reply.t !== "roster.ok") return errorText("The popover daemon returned an unexpected response.");
102
+
103
+ if (!reply.entries || reply.entries.length === 0) {
104
+ // The daemon's own text, because only it knows whether the answer is "nobody on your
105
+ // team is working" or "nobody is working in this repo" — and those lead a model to say
106
+ // very different things.
107
+ return text(
108
+ reply.rendered ||
109
+ "No teammates currently have active Claude Code sessions. Nobody is available to ask right now.",
110
+ );
111
+ }
112
+ // Returned as text rather than structuredContent on purpose: setting
113
+ // structuredContent suppresses text blocks, and the rendered table is both easier for
114
+ // the model to read and directly presentable to the user.
115
+ return text(reply.rendered);
116
+ }
117
+
118
+ async function callTeamAsk(args) {
119
+ const target = typeof args?.target === "string" ? args.target.trim() : "";
120
+ const question = typeof args?.question === "string" ? args.question.trim() : "";
121
+
122
+ if (!target) return errorText("`target` is required — call team_list to see the options.");
123
+ if (!question) return errorText("`question` is required.");
124
+
125
+ const timeoutSeconds = clamp(Number(args?.timeout_seconds) || 90, 5, 300);
126
+
127
+ const reply = await request(
128
+ {
129
+ t: "ask",
130
+ id: rpcId(),
131
+ req: {
132
+ target,
133
+ question,
134
+ timeoutSeconds,
135
+ // Lets the audit log show that an agent asked, not a human, and scopes the target
136
+ // to this session's repo.
137
+ ...(callerSessionId() ? { fromSessionId: callerSessionId() } : {}),
138
+ },
139
+ cwd: process.cwd(),
140
+ },
141
+ // Outlast the daemon's own wait so its specific error wins over a generic timeout.
142
+ { timeoutMs: timeoutSeconds * 1000 + 15000 },
143
+ );
144
+
145
+ if (!reply) return errorText(daemonDownMessage());
146
+ if (reply.t === "error") return errorText(explain(reply));
147
+ if (reply.t !== "ask.ok") return errorText("The popover daemon returned an unexpected response.");
148
+
149
+ const { answer } = reply;
150
+ const by = answer.answeredBy;
151
+ const attribution = by ? `${by.handle} (${by.ownerName}, ${by.repo})` : "a teammate's agent";
152
+ const cost =
153
+ answer.costUsd != null && answer.durationMs != null
154
+ ? `\n\n_(answered in ${(answer.durationMs / 1000).toFixed(1)}s, cost $${answer.costUsd.toFixed(3)} on their account)_`
155
+ : "";
156
+
157
+ return text(`Answer from ${attribution}:\n\n${answer.answer}${cost}`);
158
+ }
159
+
160
+ function explain(errorReply) {
161
+ switch (errorReply.code) {
162
+ // The daemon knows whether credentials are missing or merely unusable; relaying its
163
+ // message avoids telling an already signed-in user to sign in again.
164
+ case "not_authenticated":
165
+ case "target_offline":
166
+ case "unknown_target":
167
+ case "timeout":
168
+ case "fork_failed":
169
+ case "bad_request":
170
+ return errorReply.message;
171
+ case "cloud_unreachable":
172
+ return `Could not reach the popover backend: ${errorReply.message}`;
173
+ default:
174
+ return errorReply.message || "The request failed.";
175
+ }
176
+ }
177
+
178
+ function daemonDownMessage() {
179
+ return (
180
+ "The popover daemon is not running on this machine, so teammate agents are not " +
181
+ "reachable. Start it with `popover daemon start`, or run `popover doctor` to diagnose."
182
+ );
183
+ }
184
+
185
+ // ---------------------------------------------------------------------------
186
+ // JSON-RPC plumbing
187
+ // ---------------------------------------------------------------------------
188
+
189
+ function text(value) {
190
+ return { content: [{ type: "text", text: value }] };
191
+ }
192
+
193
+ function errorText(message) {
194
+ // isError tells the model the call failed while still handing it a readable reason,
195
+ // which is far more useful than a transport-level error.
196
+ return { content: [{ type: "text", text: message }], isError: true };
197
+ }
198
+
199
+ function clamp(n, lo, hi) {
200
+ return Number.isFinite(n) ? Math.min(hi, Math.max(lo, n)) : lo;
201
+ }
202
+
203
+ let counter = 0;
204
+ function rpcId() {
205
+ counter += 1;
206
+ return `mcp-${process.pid}-${counter}`;
207
+ }
208
+
209
+ function send(message) {
210
+ process.stdout.write(`${JSON.stringify(message)}\n`);
211
+ }
212
+
213
+ function reply(id, result) {
214
+ send({ jsonrpc: "2.0", id, result });
215
+ }
216
+
217
+ function replyError(id, code, message) {
218
+ send({ jsonrpc: "2.0", id, error: { code, message } });
219
+ }
220
+
221
+ async function handle(message) {
222
+ const { id, method, params } = message;
223
+
224
+ // Notifications have no id and must never be answered.
225
+ const isNotification = id === undefined || id === null;
226
+
227
+ switch (method) {
228
+ case "initialize":
229
+ return reply(id, {
230
+ protocolVersion: PROTOCOL_VERSION,
231
+ capabilities: { tools: {} },
232
+ serverInfo: SERVER_INFO,
233
+ });
234
+
235
+ case "notifications/initialized":
236
+ case "notifications/cancelled":
237
+ return;
238
+
239
+ case "ping":
240
+ return reply(id, {});
241
+
242
+ case "tools/list":
243
+ return reply(id, { tools: TOOLS });
244
+
245
+ case "tools/call": {
246
+ const name = params?.name;
247
+ try {
248
+ if (name === "team_list") return reply(id, await callTeamList());
249
+ if (name === "team_ask") return reply(id, await callTeamAsk(params?.arguments ?? {}));
250
+ return replyError(id, -32602, `Unknown tool: ${name}`);
251
+ } catch (err) {
252
+ // A thrown handler must not kill the server; report it as a failed tool call.
253
+ return reply(id, errorText(`popover failed: ${err?.message ?? String(err)}`));
254
+ }
255
+ }
256
+
257
+ default:
258
+ if (!isNotification) replyError(id, -32601, `Method not found: ${method}`);
259
+ return;
260
+ }
261
+ }
262
+
263
+ let buffer = "";
264
+ process.stdin.setEncoding("utf8");
265
+ process.stdin.on("data", (chunk) => {
266
+ buffer += chunk;
267
+ let idx = buffer.indexOf("\n");
268
+ while (idx !== -1) {
269
+ const line = buffer.slice(0, idx).trim();
270
+ buffer = buffer.slice(idx + 1);
271
+ if (line) {
272
+ let message;
273
+ try {
274
+ message = JSON.parse(line);
275
+ } catch {
276
+ idx = buffer.indexOf("\n");
277
+ continue;
278
+ }
279
+ void handle(message);
280
+ }
281
+ idx = buffer.indexOf("\n");
282
+ }
283
+ });
284
+
285
+ process.stdin.on("end", () => process.exit(0));
286
+ process.on("uncaughtException", () => {});
@@ -0,0 +1,146 @@
1
+ // Zero-dependency IPC helpers for the plugin's hook scripts.
2
+ //
3
+ // These deliberately duplicate a small amount of logic from @popoverinstall/shared. The plugin is
4
+ // installed standalone from a marketplace, where there is no node_modules to import
5
+ // from, and a hook that fails to resolve an import would break every tool call in the
6
+ // session. Fifteen duplicated lines is the cheaper trade.
7
+ //
8
+ // Keep this file dependency-free and fast: a cold node spawn already costs ~50ms, and
9
+ // PreToolUse runs on every tool call.
10
+
11
+ import net from "node:net";
12
+ import os from "node:os";
13
+ import path from "node:path";
14
+ import { appendFileSync, mkdirSync } from "node:fs";
15
+
16
+ export function popoverHome() {
17
+ return process.env.POPOVER_HOME ?? path.join(os.homedir(), ".popover");
18
+ }
19
+
20
+ export function daemonAddress() {
21
+ if (process.env.POPOVER_DAEMON_ADDR) return process.env.POPOVER_DAEMON_ADDR;
22
+ if (process.platform === "win32") {
23
+ const user = process.env.USERNAME || process.env.USER || "default";
24
+ return `\\\\.\\pipe\\popover-${user.replace(/[^A-Za-z0-9_-]/g, "_")}`;
25
+ }
26
+ return path.join(popoverHome(), "daemon.sock");
27
+ }
28
+
29
+ /**
30
+ * Send one message and do not wait for a reply.
31
+ *
32
+ * Used by the status hooks. Any failure falls back to the spool file, which the daemon
33
+ * drains the next time it starts — so events survive the daemon being down.
34
+ */
35
+ export function sendFireAndForget(message, { timeoutMs = 400 } = {}) {
36
+ return new Promise((resolve) => {
37
+ let done = false;
38
+ const finish = (ok) => {
39
+ if (done) return;
40
+ done = true;
41
+ clearTimeout(timer);
42
+ try {
43
+ socket.destroy();
44
+ } catch {}
45
+ if (!ok) spool(message);
46
+ resolve(ok);
47
+ };
48
+
49
+ const socket = net.createConnection(daemonAddress());
50
+ const timer = setTimeout(() => finish(false), timeoutMs);
51
+
52
+ socket.on("connect", () => {
53
+ socket.write(`${JSON.stringify(message)}\n`, () => finish(true));
54
+ });
55
+ socket.on("error", () => finish(false));
56
+ });
57
+ }
58
+
59
+ /** Send a request and wait for the matching reply. */
60
+ export function request(message, { timeoutMs = 5000 } = {}) {
61
+ return new Promise((resolve) => {
62
+ let buffer = "";
63
+ let done = false;
64
+ const finish = (value) => {
65
+ if (done) return;
66
+ done = true;
67
+ clearTimeout(timer);
68
+ try {
69
+ socket.destroy();
70
+ } catch {}
71
+ resolve(value);
72
+ };
73
+
74
+ const socket = net.createConnection(daemonAddress());
75
+ const timer = setTimeout(() => finish(null), timeoutMs);
76
+
77
+ socket.on("connect", () => socket.write(`${JSON.stringify(message)}\n`));
78
+ socket.on("data", (chunk) => {
79
+ buffer += chunk.toString();
80
+ let idx = buffer.indexOf("\n");
81
+ while (idx !== -1) {
82
+ const line = buffer.slice(0, idx).trim();
83
+ buffer = buffer.slice(idx + 1);
84
+ if (line) {
85
+ try {
86
+ return finish(JSON.parse(line));
87
+ } catch {
88
+ /* keep reading */
89
+ }
90
+ }
91
+ idx = buffer.indexOf("\n");
92
+ }
93
+ });
94
+ socket.on("error", () => finish(null));
95
+ });
96
+ }
97
+
98
+ function spool(message) {
99
+ try {
100
+ mkdirSync(popoverHome(), { recursive: true });
101
+ // Only status payloads are worth replaying; a stale roster request is meaningless.
102
+ if (message?.t !== "status") return;
103
+ appendFileSync(
104
+ path.join(popoverHome(), "spool.jsonl"),
105
+ `${JSON.stringify(message.payload)}\n`,
106
+ "utf8",
107
+ );
108
+ } catch {
109
+ /* never let a hook fail because of logging */
110
+ }
111
+ }
112
+
113
+ /** Read this hook's JSON payload from stdin. */
114
+ export async function readStdin(timeoutMs = 2000) {
115
+ return new Promise((resolve) => {
116
+ let data = "";
117
+ const timer = setTimeout(() => resolve(data), timeoutMs);
118
+ process.stdin.setEncoding("utf8");
119
+ process.stdin.on("data", (chunk) => {
120
+ data += chunk;
121
+ });
122
+ process.stdin.on("end", () => {
123
+ clearTimeout(timer);
124
+ resolve(data);
125
+ });
126
+ process.stdin.on("error", () => {
127
+ clearTimeout(timer);
128
+ resolve(data);
129
+ });
130
+ });
131
+ }
132
+
133
+ /**
134
+ * The Claude session this process belongs to, or undefined outside one.
135
+ *
136
+ * Claude Code exports `CLAUDE_CODE_SESSION_ID`. This was previously read as
137
+ * `CLAUDE_SESSION_ID`, which does not exist — so every ask recorded a null
138
+ * `from_session_id` and the dashboard could never say which agent had asked. Verified
139
+ * against a live session: the value is the same id the daemon publishes as
140
+ * `cc_session_id`, so it joins directly to a roster entry.
141
+ *
142
+ * The older name is still consulted second, in case a future or older Claude Code uses it.
143
+ */
144
+ export function callerSessionId() {
145
+ return process.env.CLAUDE_CODE_SESSION_ID || process.env.CLAUDE_SESSION_ID || undefined;
146
+ }
@@ -0,0 +1,27 @@
1
+ #!/usr/bin/env node
2
+ // Status hook handler.
3
+ //
4
+ // Reads the hook payload from stdin, forwards it to the daemon, exits. That is the whole
5
+ // job — all interpretation happens in the daemon, so this stays fast enough to sit on
6
+ // PreToolUse, which fires on every single tool call.
7
+ //
8
+ // Rules this script must never break:
9
+ // - never write to stdout (for UserPromptSubmit and SessionStart, stdout is injected
10
+ // into the model's context)
11
+ // - never exit non-zero (exit 2 would BLOCK the tool call it is reporting on)
12
+ // - never hang (hooks run async, but a wedged process still costs a handle)
13
+
14
+ import { readStdin, sendFireAndForget } from "./_ipc.mjs";
15
+
16
+ try {
17
+ const raw = await readStdin();
18
+ const payload = JSON.parse(raw);
19
+
20
+ if (payload?.session_id && payload?.hook_event_name) {
21
+ await sendFireAndForget({ t: "status", payload });
22
+ }
23
+ } catch {
24
+ // A malformed payload or an absent daemon is not worth interrupting anyone's work.
25
+ }
26
+
27
+ process.exit(0);
@@ -0,0 +1,116 @@
1
+ #!/usr/bin/env node
2
+ // SessionStart hook: make sure exactly one daemon is running, then register this session.
3
+ //
4
+ // This runs on every session start, so the common case — a daemon is already up — must
5
+ // be a single cheap probe with no side effects.
6
+
7
+ import { spawn } from "node:child_process";
8
+ import { existsSync, mkdirSync, openSync, readFileSync } from "node:fs";
9
+ import path from "node:path";
10
+ import { fileURLToPath } from "node:url";
11
+ import { daemonAddress, popoverHome, readStdin, request, sendFireAndForget } from "./_ipc.mjs";
12
+
13
+ const HERE = path.dirname(fileURLToPath(import.meta.url));
14
+
15
+ async function main() {
16
+ const raw = await readStdin().catch(() => "");
17
+ let payload = null;
18
+ try {
19
+ payload = JSON.parse(raw);
20
+ } catch {
21
+ /* still worth ensuring the daemon is up */
22
+ }
23
+
24
+ const alive = await ping();
25
+ if (!alive) {
26
+ const entry = resolveDaemonEntry();
27
+ if (entry) {
28
+ launch(entry);
29
+ // Give it a moment to bind before we register, but do not block the session on it:
30
+ // the hook is async and the status event falls back to the spool file regardless.
31
+ await waitForDaemon(3000);
32
+ }
33
+ }
34
+
35
+ if (payload?.session_id) {
36
+ await sendFireAndForget({ t: "status", payload });
37
+ }
38
+ process.exit(0);
39
+ }
40
+
41
+ async function ping() {
42
+ const reply = await request({ t: "ping", id: "ensure" }, { timeoutMs: 1200 });
43
+ return reply?.t === "pong";
44
+ }
45
+
46
+ async function waitForDaemon(budgetMs) {
47
+ const deadline = Date.now() + budgetMs;
48
+ while (Date.now() < deadline) {
49
+ if (await ping()) return true;
50
+ await new Promise((r) => setTimeout(r, 200));
51
+ }
52
+ return false;
53
+ }
54
+
55
+ /**
56
+ * Find the daemon entry point.
57
+ *
58
+ * Order matters: an explicit override wins, then the pointer file, then the monorepo
59
+ * layouts used during development.
60
+ *
61
+ * The pointer file is the one that matters in production, and it holds an absolute path on
62
+ * purpose. Installing a plugin *copies* it into
63
+ * `~/.claude/plugins/cache/<mkt>/<plugin>/<version>/`, severed from whatever tree it was
64
+ * built in — so every path relative to this file lands inside that cache directory, where
65
+ * no daemon has ever existed. Only a fixed location under `~/.popover` survives the copy,
66
+ * and `popover setup` is what writes it there.
67
+ *
68
+ * Keep in step with `daemonEntryPointerPath()` in @popoverinstall/shared. This file cannot import
69
+ * it: a hook that fails to resolve an import would break every tool call in the session.
70
+ */
71
+ function resolveDaemonEntry() {
72
+ if (process.env.POPOVER_DAEMON_ENTRY && existsSync(process.env.POPOVER_DAEMON_ENTRY)) {
73
+ return process.env.POPOVER_DAEMON_ENTRY;
74
+ }
75
+
76
+ // Written by `popover setup`. Survives the plugin-cache copy; see above.
77
+ try {
78
+ const entry = readFileSync(path.join(popoverHome(), "daemon-entry"), "utf8").trim();
79
+ if (entry && existsSync(entry)) return entry;
80
+ } catch {
81
+ /* no pointer yet — `popover setup` has not run */
82
+ }
83
+
84
+ const candidates = [
85
+ // Vendored beside the plugin.
86
+ path.join(HERE, "..", "node_modules", "@popoverinstall", "daemon", "dist", "index.js"),
87
+ // Monorepo checkout.
88
+ path.join(HERE, "..", "..", "daemon", "dist", "index.js"),
89
+ path.join(HERE, "..", "..", "..", "packages", "daemon", "dist", "index.js"),
90
+ ];
91
+
92
+ for (const candidate of candidates) {
93
+ if (existsSync(candidate)) return candidate;
94
+ }
95
+ return null;
96
+ }
97
+
98
+ function launch(entry) {
99
+ try {
100
+ // Detached with stdio to a log file: the daemon must outlive this hook, this session,
101
+ // and the terminal window it was started from.
102
+ mkdirSync(popoverHome(), { recursive: true });
103
+ const out = openSync(path.join(popoverHome(), "daemon-stdout.log"), "a");
104
+ const child = spawn(process.execPath, [entry], {
105
+ detached: true,
106
+ stdio: ["ignore", out, out],
107
+ windowsHide: true,
108
+ env: { ...process.env, POPOVER_DAEMON_ADDR: daemonAddress() },
109
+ });
110
+ child.unref();
111
+ } catch {
112
+ // If the daemon cannot start, /team will say so plainly. Never fail the session.
113
+ }
114
+ }
115
+
116
+ main().catch(() => process.exit(0));
@@ -0,0 +1,47 @@
1
+ #!/usr/bin/env node
2
+ // Renders the /team menu.
3
+ //
4
+ // Invoked from the command's `!` injection, so its stdout becomes part of the prompt
5
+ // Claude sees. Two consequences shape everything here:
6
+ // - always exit 0. A non-zero exit aborts the whole slash command.
7
+ // - always print something useful. If the daemon is down, say so in words the model
8
+ // can relay, rather than printing an empty section and letting it invent a reason.
9
+
10
+ import { callerSessionId, request } from "./_ipc.mjs";
11
+
12
+ // `fromSessionId` is what lets the daemon mark this agent's own line. It comes from the
13
+ // session's environment, so a bare shell run of this script sends nothing and the roster
14
+ // comes back unmarked.
15
+ const reply = await request(
16
+ {
17
+ t: "roster",
18
+ id: "cli",
19
+ refresh: true,
20
+ ...(callerSessionId() ? { fromSessionId: callerSessionId() } : {}),
21
+ },
22
+ { timeoutMs: 8000 },
23
+ );
24
+
25
+ if (!reply) {
26
+ console.log(
27
+ "The popover daemon is not running on this machine, so teammate agents are not visible.\n" +
28
+ "Start it with `popover daemon start`, or check `popover doctor`.",
29
+ );
30
+ } else if (reply.t === "error") {
31
+ // The daemon distinguishes "no credentials" from "signed in but cannot connect", so
32
+ // relay its message rather than assuming the user has not logged in.
33
+ console.log(
34
+ reply.code === "not_authenticated"
35
+ ? reply.message
36
+ : `Could not load the team roster: ${reply.message}`,
37
+ );
38
+ } else if (reply.t === "roster.ok") {
39
+ console.log(reply.rendered);
40
+ if (reply.stale) {
41
+ console.log("\n(Showing cached data — the backend was unreachable.)");
42
+ }
43
+ } else {
44
+ console.log("The popover daemon returned an unexpected response.");
45
+ }
46
+
47
+ process.exit(0);