@simplepush/cli 0.2.1 → 0.3.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/README.md CHANGED
@@ -22,6 +22,7 @@ sp cancel Withdraw a pending task, subtask, or task group you sent.
22
22
  sp notify Send a fire-and-forget notification.
23
23
  sp collect Collect replies, inputs, or submissions as bounded NDJSON.
24
24
  sp download Download one file a collect line referenced, by its ids.
25
+ sp get Read what came back: task listings, one task with its subtasks, a task group, submissions.
25
26
  sp events Stream or replay the raw event feed.
26
27
  sp auth Log in to an organization (login / logout / status).
27
28
  sp org Administer an organization: members, invites, topics, API key, encryption.
@@ -192,3 +193,18 @@ Bun can compile the CLI into a single-file native binary:
192
193
  bun run build:binary
193
194
  ./bin/simplepush --help
194
195
  ```
196
+
197
+ ### Query what came back
198
+
199
+ The read commands mirror the MCP query tools, so an agent can ask questions like "every task that expired unanswered this week, grouped by assignee":
200
+
201
+ ```bash
202
+ sp get tasks --status expired --since 7d --format json # one JSON object per line
203
+ sp get tasks --status pending --member anna --all # follow cursors to the end
204
+ sp get tsk_… # the chain: root + subtasks, decrypted where keys are held
205
+ sp get grptsk_… # per-recipient status of a group send
206
+ sp get submissions --since 24h --member anna
207
+ sp events --since 7d --member anna # events replay, narrowed client-side
208
+ ```
209
+
210
+ A page that was cut off ends with `{"type":"more","cursor":"…"}` (`json`) or an info line (`pretty`); pass the cursor back with `--cursor`, or `--all` to page automatically. `--since`/`--until` take ISO-8601 instants or relative windows (`7d`, `12h`). For personal accounts the HTTP query surface (`sp get`) is a subscription feature — `sp events`, `sp collect` and `sp download` stay free.
package/dist/main.mjs CHANGED
@@ -9,7 +9,7 @@ import { wordlist } from "@scure/bip39/wordlists/english.js";
9
9
  import { homedir, tmpdir } from "node:os";
10
10
  import { createServer } from "node:http";
11
11
  import { spawn } from "node:child_process";
12
- import { Client, Keyring, OrgClient, TypeFilter, cancelSubtask, cancelTask, cancelTaskGroup, decryptBytes, deriveKey, encrypt, fetchUserInfo, isSubtaskGroupResponse, tryDecryptEventData } from "@simplepush/sdk";
12
+ import { Client, Keyring, OrgClient, TypeFilter, cancelSubtask, cancelTask, cancelTaskGroup, decryptBytes, decryptSubmission, decryptTaskPayload, decryptTaskSummary, deriveKey, encrypt, fetchUserInfo, isSubtaskGroupResponse, tryDecryptEventData } from "@simplepush/sdk";
13
13
  import { mkdir, stat, writeFile } from "node:fs/promises";
14
14
  import { connect, createConnection } from "node:net";
15
15
  import path, { basename, extname, join } from "node:path";
@@ -546,7 +546,7 @@ var VaultAccess = class extends Effect.Service()("cli/VaultAccess", {
546
546
  //#region src/global-options.ts
547
547
  const DEFAULT_BASE_URL = "https://api.simplepu.sh";
548
548
  const toHelp = (e) => HelpDoc.p(e instanceof Error ? e.message : String(e));
549
- const topicOption = Options.text("topic").pipe(Options.withAlias("t"), Options.withDescription("Topic to send to (`task`) or filter on (`events`, repeatable). Omit on `task` for a self-send to your own devices."), Options.repeated);
549
+ const topicOption$1 = Options.text("topic").pipe(Options.withAlias("t"), Options.withDescription("Topic to send to (`task`) or filter on (`events`, repeatable). Omit on `task` for a self-send to your own devices."), Options.repeated);
550
550
  const apiTokenOption = Options.text("api-token").pipe(Options.withDescription("API token. Required for `get`; `collect`, `events`, and `subtask` fall back to the logged-in org session when omitted. Defaults to $SP_API_TOKEN."), Options.withFallbackConfig(Config.string("SP_API_TOKEN")), Options.optional);
551
551
  /** Personal-credential commands need the token; fail typed when absent. */
552
552
  const requireApiToken = (token) => Option.match(token, {
@@ -2099,11 +2099,12 @@ function extractRaw(v) {
2099
2099
  //#endregion
2100
2100
  //#region src/commands/events.ts
2101
2101
  const eventTypeOption = Options.text("type").pipe(Options.withDescription("Filter by event type. Repeatable."), Options.repeated);
2102
- const sinceOption = mappedText("since", resolveSince).pipe(Options.withDescription("Replay from this point. Accepts `24h`, `7d`, or an ISO 8601 timestamp."), Options.optional);
2103
- const untilOption = mappedText("until", resolveUntil).pipe(Options.withDescription("Stop at this timestamp. Forces a finite range, so `--follow` is ignored."), Options.optional);
2104
- const limitOption = Options.integer("limit").pipe(Options.withDescription("Maximum number of events to print, then exit."), Options.optional);
2102
+ const sinceOption$1 = mappedText("since", resolveSince).pipe(Options.withDescription("Replay from this point. Accepts `24h`, `7d`, or an ISO 8601 timestamp."), Options.optional);
2103
+ const untilOption$1 = mappedText("until", resolveUntil).pipe(Options.withDescription("Stop at this timestamp. Forces a finite range, so `--follow` is ignored."), Options.optional);
2104
+ const limitOption$1 = Options.integer("limit").pipe(Options.withDescription("Maximum number of events to print, then exit."), Options.optional);
2105
2105
  const followOption = Options.boolean("follow").pipe(Options.withAlias("f"), Options.withDescription("After history is drained, keep streaming live instead of exiting. Only meaningful with `--since`."));
2106
- const formatOption$3 = Options.choice("format", [
2106
+ const memberOption$3 = Options.text("member").pipe(Options.optional, Options.withDescription("Only events by this person: a usr_ id, or the actor's name."));
2107
+ const formatOption$4 = Options.choice("format", [
2107
2108
  "json",
2108
2109
  "pretty",
2109
2110
  "raw"
@@ -2112,13 +2113,14 @@ const directOption = Options.boolean("direct").pipe(Options.withDescription("Ope
2112
2113
  const QUIET_EXIT_MS = 2e3;
2113
2114
  const eventsCommand = Command.make("events", {
2114
2115
  type: eventTypeOption,
2115
- since: sinceOption,
2116
- until: untilOption,
2117
- limit: limitOption,
2116
+ member: memberOption$3,
2117
+ since: sinceOption$1,
2118
+ until: untilOption$1,
2119
+ limit: limitOption$1,
2118
2120
  follow: followOption,
2119
- format: formatOption$3,
2121
+ format: formatOption$4,
2120
2122
  direct: directOption,
2121
- topic: topicOption,
2123
+ topic: topicOption$1,
2122
2124
  "api-token": apiTokenOption,
2123
2125
  password: passwordOption,
2124
2126
  "base-url": baseUrlOption,
@@ -2132,6 +2134,8 @@ const eventsCommand = Command.make("events", {
2132
2134
  const untilDate = Option.getOrUndefined(args.until);
2133
2135
  const limit = Option.getOrUndefined(args.limit);
2134
2136
  const filter = new TypeFilter(args.type);
2137
+ const member = Option.getOrUndefined(args.member);
2138
+ const memberMatches = (ev) => member === void 0 || ev.actor?.publicId === member || ev.actor?.name === member;
2135
2139
  yield* Effect.forEach(filter.unknown, (u) => out.warn(`ignoring unknown --type \`${u}\``));
2136
2140
  const webSocketFactory = args.direct || sinceIso !== void 0 ? Option.none() : yield* sharedWebSocketFactory({
2137
2141
  credential: cred.kind === "personal" ? {
@@ -2194,7 +2198,7 @@ const eventsCommand = Command.make("events", {
2194
2198
  yield* sdkStream("events stream", (signal) => client.events({
2195
2199
  ...sinceIso !== void 0 ? { since: sinceIso } : {},
2196
2200
  signal: AbortSignal.any([signal, endController.signal])
2197
- })).pipe(Stream.tap(() => Ref.set(lastActivity, Date.now())), Stream.interruptWhen(Deferred.await(halt)), untilDate !== void 0 ? Stream.takeUntilEffect((ev) => untilReached(ev) ? note("--until reached, exiting").pipe(Effect.zipRight(Deferred.succeed(halt, void 0)), Effect.as(true)) : Effect.succeed(false)) : (s) => s, Stream.filter((ev) => !untilReached(ev) && filter.matches(ev)), Stream.mapEffect((ev) => Effect.gen(function* () {
2201
+ })).pipe(Stream.tap(() => Ref.set(lastActivity, Date.now())), Stream.interruptWhen(Deferred.await(halt)), untilDate !== void 0 ? Stream.takeUntilEffect((ev) => untilReached(ev) ? note("--until reached, exiting").pipe(Effect.zipRight(Deferred.succeed(halt, void 0)), Effect.as(true)) : Effect.succeed(false)) : (s) => s, Stream.filter((ev) => !untilReached(ev) && filter.matches(ev) && memberMatches(ev)), Stream.mapEffect((ev) => Effect.gen(function* () {
2198
2202
  const decrypted = keyring ? yield* sdkCall("decrypt event", () => tryDecryptEventData(ev, keyring)) : void 0;
2199
2203
  yield* out.print(formatEvent(ev, args.format, decrypted));
2200
2204
  const n = yield* Ref.updateAndGet(printed, (x) => x + 1);
@@ -2212,6 +2216,313 @@ const eventsCommand = Command.make("events", {
2212
2216
  }));
2213
2217
  }));
2214
2218
  //#endregion
2219
+ //#region src/commands/query-support.ts
2220
+ /** Options every read command shares: credential, decryption keys, paging, output. */
2221
+ const credentialOptions = {
2222
+ "api-token": apiTokenOption,
2223
+ password: passwordOption,
2224
+ "base-url": baseUrlOption,
2225
+ quiet: quietOption
2226
+ };
2227
+ const formatOption$3 = Options.choice("format", ["json", "pretty"]).pipe(Options.withDefault("json"), Options.withDescription("json = one JSON object per line (agents, jq); pretty = human-readable lines."));
2228
+ const sinceOption = mappedText("since", resolveSince).pipe(Options.optional, Options.withDescription("Only items created at or after this instant: ISO-8601, or a relative window like 7d, 12h, 30m."));
2229
+ const untilOption = mappedText("until", (raw) => resolveUntil(raw).toISOString()).pipe(Options.optional, Options.withDescription("Only items created at or before this instant (same forms as --since)."));
2230
+ const memberOption$2 = Options.text("member").pipe(Options.optional, Options.withDescription("Only items involving this person: a usr_ id, or a member name on an org."));
2231
+ const limitOption = Options.integer("limit").pipe(Options.optional, Options.withDescription("Page size (server default 50). With --all this is the page size, not a cap."));
2232
+ const cursorOption = Options.text("cursor").pipe(Options.optional, Options.withDescription("Continue a previous page: pass the cursor it reported."));
2233
+ const allOption = Options.boolean("all").pipe(Options.withDescription("Follow cursors until the last page instead of printing one page and a cursor."));
2234
+ const STATUSES = [
2235
+ "pending",
2236
+ "completed",
2237
+ "canceled",
2238
+ "declined",
2239
+ "expired"
2240
+ ];
2241
+ /** `--status` accepts repeats and comma lists: `--status expired --status declined` or `--status expired,declined`. */
2242
+ const statusOption = Options.text("status").pipe(Options.repeated, Options.mapTryCatch((values) => {
2243
+ const flat = values.flatMap((v) => v.split(",")).map((s) => s.trim()).filter((s) => s.length > 0);
2244
+ const bad = flat.filter((s) => !STATUSES.includes(s));
2245
+ if (bad.length > 0) throw new Error(`unknown status ${bad.join(", ")} — use ${STATUSES.join(", ")}`);
2246
+ return flat;
2247
+ }, (e) => HelpDoc.p(e instanceof Error ? e.message : String(e))), Options.withDescription("Only tasks in these states (repeatable or comma-separated)."));
2248
+ /** Resolves the credential (personal API-Token or the saved org session),
2249
+ * builds the matching client and — when keys are available — a keyring, and
2250
+ * runs `body` inside the client's scope. Mirrors what `sp events` does. */
2251
+ const withQueryClient = (args, body) => Effect.gen(function* () {
2252
+ const out = yield* CliOutput;
2253
+ yield* out.setQuiet(args.quiet);
2254
+ const cred = yield* resolveCredential(args["api-token"], args["base-url"]);
2255
+ const orgKeys = cred.kind === "org" ? yield* loadOrgMasterKeys : void 0;
2256
+ return yield* Effect.scoped(Effect.gen(function* () {
2257
+ const client = cred.kind === "personal" ? yield* acquireClient({
2258
+ baseUrl: cred.baseUrl,
2259
+ apiToken: cred.apiToken,
2260
+ passwords: args.password
2261
+ }) : yield* acquireOrgClient({
2262
+ baseUrl: cred.baseUrl,
2263
+ bearerToken: cred.bearer,
2264
+ ...orgKeys !== void 0 ? { orgMasterKeys: orgKeys } : {}
2265
+ });
2266
+ return yield* body({
2267
+ client,
2268
+ keyring: cred.kind === "org" ? orgKeys !== void 0 ? yield* sdkCall("keyring", () => client.keyring()) : void 0 : args.password.length > 0 ? yield* sdkCall("keyring", () => client.keyring({ includePasswordSalt: true })) : void 0,
2269
+ org: cred.kind === "org",
2270
+ out
2271
+ });
2272
+ }));
2273
+ });
2274
+ /** Decrypts what the keyring can and counts what it cannot, so a command can
2275
+ * report "N values left as ciphertext" once instead of per row. Each method
2276
+ * maps one wire shape onto its SDK field-schema decryptor. */
2277
+ var Decryptor = class {
2278
+ undecryptable = 0;
2279
+ constructor(keyring) {
2280
+ this.keyring = keyring;
2281
+ }
2282
+ run(value, go) {
2283
+ if (this.keyring === void 0) return Effect.succeed(value);
2284
+ const keyring = this.keyring;
2285
+ return Effect.promise(() => go(keyring)).pipe(Effect.map((r) => {
2286
+ this.undecryptable += r.undecryptable;
2287
+ return r.value;
2288
+ }));
2289
+ }
2290
+ /** A task or subtask payload (chain reads). */
2291
+ payload(value) {
2292
+ return this.run(value, (kr) => decryptTaskPayload(value, kr));
2293
+ }
2294
+ /** A task index / roster row. */
2295
+ summary(value) {
2296
+ return this.run(value, (kr) => decryptTaskSummary(value, kr));
2297
+ }
2298
+ /** A submission under its feed entry's marker. */
2299
+ submission(value, marker) {
2300
+ return this.run(value, (kr) => decryptSubmission(value, kr, marker));
2301
+ }
2302
+ report(out) {
2303
+ return this.undecryptable > 0 ? out.warn(`${this.undecryptable} value(s) are encrypted under a key this CLI does not hold and were left as ciphertext`) : Effect.void;
2304
+ }
2305
+ };
2306
+ /** Runs `page` for the first cursor, then keeps following cursors when `all`
2307
+ * is set; otherwise reports the cursor of the next page so the caller can
2308
+ * continue by hand. */
2309
+ const forEachPage = (ctx, format, all, first, page, handle) => Effect.gen(function* () {
2310
+ let cursor = first;
2311
+ for (;;) {
2312
+ const p = yield* page(cursor);
2313
+ yield* handle(p);
2314
+ if (p.nextCursor === void 0) return;
2315
+ if (!all) {
2316
+ yield* format === "json" ? ctx.out.print(JSON.stringify({
2317
+ type: "more",
2318
+ cursor: p.nextCursor
2319
+ })) : ctx.out.info(`more available — continue with --cursor ${p.nextCursor} (or pass --all)`);
2320
+ return;
2321
+ }
2322
+ cursor = p.nextCursor;
2323
+ }
2324
+ });
2325
+ function recipientLabels(t) {
2326
+ return t.recipients.map((r) => r.name ?? r.publicId);
2327
+ }
2328
+ /** One line per task: id, state, when, title, who, subtask counts, group. */
2329
+ function formatTaskSummary(t) {
2330
+ const subs = Object.entries(t.subtasks).map(([k, v]) => `${k} ${v}`).join(", ");
2331
+ return [
2332
+ `${t.taskId} [${t.status}] ${t.createdAt}`,
2333
+ t.title !== void 0 ? ` ${t.title}` : void 0,
2334
+ ` to: ${recipientLabels(t).join(", ") || "-"}`,
2335
+ t.inputs.length > 0 ? ` inputs: ${t.inputs.join(", ")}` : void 0,
2336
+ t.reply !== void 0 ? ` reply: ${t.reply}` : void 0,
2337
+ subs ? ` subtasks: ${subs}` : void 0,
2338
+ t.expiresAt !== void 0 ? ` expires: ${t.expiresAt}` : void 0,
2339
+ t.groupId !== void 0 ? ` group: ${t.groupId}` : void 0
2340
+ ].filter((l) => l !== void 0).join("\n");
2341
+ }
2342
+ //#endregion
2343
+ //#region src/commands/get.ts
2344
+ const topicOption = Options.text("topic").pipe(Options.optional, Options.withDescription("Only tasks sent to this topic: its value (name), or its id."));
2345
+ const groupOption = Options.text("group").pipe(Options.optional, Options.withDescription("Only the instances of this grptsk_ group."));
2346
+ /** Flags that only make sense on the paged listings. Id reads reject them by
2347
+ * name instead of silently ignoring them. */
2348
+ function rejectListFlags(args, target, allowStatus) {
2349
+ const set = [
2350
+ ...!allowStatus && args.status.length > 0 ? ["--status"] : [],
2351
+ ...Option.isSome(args.since) ? ["--since"] : [],
2352
+ ...Option.isSome(args.until) ? ["--until"] : [],
2353
+ ...Option.isSome(args.topic) ? ["--topic"] : [],
2354
+ ...Option.isSome(args.member) ? ["--member"] : [],
2355
+ ...Option.isSome(args.group) ? ["--group"] : [],
2356
+ ...Option.isSome(args.limit) ? ["--limit"] : [],
2357
+ ...Option.isSome(args.cursor) ? ["--cursor"] : [],
2358
+ ...args.all ? ["--all"] : []
2359
+ ];
2360
+ return set.length === 0 ? Effect.void : Effect.fail(new UserError({ message: `${set.join(", ")} does not apply when reading ${target}` }));
2361
+ }
2362
+ function getChain(args, ctx) {
2363
+ return Effect.gen(function* () {
2364
+ yield* rejectListFlags(args, "one task", false);
2365
+ const chain = yield* sdkCall("read task chain", () => ctx.client.getTaskChain(args.what));
2366
+ const dec = new Decryptor(ctx.keyring);
2367
+ const task = yield* dec.payload(chain.task);
2368
+ const subtasks = [];
2369
+ for (const s of chain.subtasks) subtasks.push({
2370
+ subtask: yield* dec.payload(s.subtask),
2371
+ createdAt: s.createdAt
2372
+ });
2373
+ const view = {
2374
+ task,
2375
+ createdAt: chain.createdAt,
2376
+ subtasks
2377
+ };
2378
+ if (args.format === "json") yield* ctx.out.print(JSON.stringify(view));
2379
+ else {
2380
+ yield* ctx.out.print(`${task.taskId} [${task.status}] ${chain.createdAt}`);
2381
+ yield* ctx.out.print(JSON.stringify(task, null, 2));
2382
+ for (const s of subtasks) {
2383
+ yield* ctx.out.print(`\n${s.subtask.subtaskId} [${s.subtask.status}] ${s.createdAt}`);
2384
+ yield* ctx.out.print(JSON.stringify(s.subtask, null, 2));
2385
+ }
2386
+ }
2387
+ yield* dec.report(ctx.out);
2388
+ });
2389
+ }
2390
+ function getRoster(args, ctx) {
2391
+ return Effect.gen(function* () {
2392
+ yield* rejectListFlags(args, "a task group roster", true);
2393
+ const roster = yield* sdkCall("read task group", () => ctx.client.getTaskGroup(args.what, args.status.length > 0 ? { status: args.status } : {}));
2394
+ const dec = new Decryptor(ctx.keyring);
2395
+ const counts = {};
2396
+ for (const t of roster.tasks) counts[t.status] = (counts[t.status] ?? 0) + 1;
2397
+ if (args.format === "json") {
2398
+ yield* ctx.out.print(JSON.stringify({
2399
+ type: "group",
2400
+ groupId: roster.groupId,
2401
+ counts
2402
+ }));
2403
+ for (const t of roster.tasks) yield* ctx.out.print(JSON.stringify(yield* dec.summary(t)));
2404
+ } else {
2405
+ const summary = Object.entries(counts).map(([k, v]) => `${k} ${v}`).join(", ") || "no instances";
2406
+ yield* ctx.out.print(`${roster.groupId} ${summary}`);
2407
+ for (const t of roster.tasks) yield* ctx.out.print(formatTaskSummary(yield* dec.summary(t)));
2408
+ }
2409
+ yield* dec.report(ctx.out);
2410
+ });
2411
+ }
2412
+ function getTasks(args, ctx) {
2413
+ return Effect.gen(function* () {
2414
+ const dec = new Decryptor(ctx.keyring);
2415
+ const opts = {
2416
+ ...args.status.length > 0 ? { status: args.status } : {},
2417
+ ...Option.match(args.since, {
2418
+ onNone: () => ({}),
2419
+ onSome: (since) => ({ since })
2420
+ }),
2421
+ ...Option.match(args.until, {
2422
+ onNone: () => ({}),
2423
+ onSome: (until) => ({ until })
2424
+ }),
2425
+ ...Option.match(args.topic, {
2426
+ onNone: () => ({}),
2427
+ onSome: (topic) => ({ topic })
2428
+ }),
2429
+ ...Option.match(args.member, {
2430
+ onNone: () => ({}),
2431
+ onSome: (member) => ({ member })
2432
+ }),
2433
+ ...Option.match(args.group, {
2434
+ onNone: () => ({}),
2435
+ onSome: (group) => ({ group })
2436
+ }),
2437
+ ...Option.match(args.limit, {
2438
+ onNone: () => ({}),
2439
+ onSome: (limit) => ({ limit })
2440
+ })
2441
+ };
2442
+ yield* forEachPage(ctx, args.format, args.all, Option.getOrUndefined(args.cursor), (cursor) => sdkCall("list tasks", () => ctx.client.listTasks({
2443
+ ...opts,
2444
+ ...cursor !== void 0 ? { cursor } : {}
2445
+ })), (page) => Effect.forEach(page.tasks, (t) => Effect.gen(function* () {
2446
+ const row = yield* dec.summary(t);
2447
+ yield* ctx.out.print(args.format === "json" ? JSON.stringify(row) : formatTaskSummary(row));
2448
+ }), { discard: true }));
2449
+ yield* dec.report(ctx.out);
2450
+ });
2451
+ }
2452
+ function getSubmissions(args, ctx) {
2453
+ return Effect.gen(function* () {
2454
+ const wrong = [
2455
+ ...args.status.length > 0 ? ["--status"] : [],
2456
+ ...Option.isSome(args.topic) ? ["--topic"] : [],
2457
+ ...Option.isSome(args.group) ? ["--group"] : []
2458
+ ];
2459
+ if (wrong.length > 0) return yield* Effect.fail(new UserError({ message: `${wrong.join(", ")} does not apply to submissions` }));
2460
+ const dec = new Decryptor(ctx.keyring);
2461
+ const opts = {
2462
+ ...Option.match(args.since, {
2463
+ onNone: () => ({}),
2464
+ onSome: (since) => ({ since })
2465
+ }),
2466
+ ...Option.match(args.until, {
2467
+ onNone: () => ({}),
2468
+ onSome: (until) => ({ until })
2469
+ }),
2470
+ ...Option.match(args.member, {
2471
+ onNone: () => ({}),
2472
+ onSome: (member) => ({ member })
2473
+ }),
2474
+ ...Option.match(args.limit, {
2475
+ onNone: () => ({}),
2476
+ onSome: (limit) => ({ limit })
2477
+ })
2478
+ };
2479
+ yield* forEachPage(ctx, args.format, args.all, Option.getOrUndefined(args.cursor), (cursor) => sdkCall("list submissions", () => ctx.client.listSubmissions({
2480
+ ...opts,
2481
+ ...cursor !== void 0 ? { cursor } : {}
2482
+ })), (page) => Effect.forEach(page.submissions, (entry) => Effect.gen(function* () {
2483
+ const submission = yield* dec.submission(entry.submission, entry.encryption);
2484
+ const by = entry.actor?.name ?? entry.actor?.publicId;
2485
+ if (args.format === "json") yield* ctx.out.print(JSON.stringify({
2486
+ ...submission,
2487
+ ...entry.actor !== void 0 ? { actor: entry.actor } : {}
2488
+ }));
2489
+ else {
2490
+ const body = submission.body;
2491
+ const parts = [
2492
+ body?.value !== void 0 ? body.value : void 0,
2493
+ submission.photo !== void 0 ? "[photo]" : void 0,
2494
+ submission.file !== void 0 ? "[file]" : void 0,
2495
+ submission.audio !== void 0 ? "[audio]" : void 0,
2496
+ submission.location !== void 0 ? "[location]" : void 0
2497
+ ].filter((p) => p !== void 0);
2498
+ yield* ctx.out.print(`${submission.id} ${submission.createdAt} ${by ?? "-"}: ${parts.join(" ") || "(empty)"}`);
2499
+ }
2500
+ }), { discard: true }));
2501
+ yield* dec.report(ctx.out);
2502
+ });
2503
+ }
2504
+ const getCommand = Command.make("get", {
2505
+ what: Args.text({ name: "what" }).pipe(Args.withDescription("What to read: tasks, submissions, a tsk_ id, or a grptsk_ id.")),
2506
+ status: statusOption,
2507
+ since: sinceOption,
2508
+ until: untilOption,
2509
+ topic: topicOption,
2510
+ member: memberOption$2,
2511
+ group: groupOption,
2512
+ limit: limitOption,
2513
+ cursor: cursorOption,
2514
+ all: allOption,
2515
+ format: formatOption$3,
2516
+ ...credentialOptions
2517
+ }, (args) => withQueryClient(args, (ctx) => {
2518
+ if (args.what === "tasks") return getTasks(args, ctx);
2519
+ if (args.what === "submissions") return getSubmissions(args, ctx);
2520
+ if (args.what.startsWith("tsk_")) return getChain(args, ctx);
2521
+ if (args.what.startsWith("grptsk_")) return getRoster(args, ctx);
2522
+ const hint = args.what.startsWith("sub_") ? "a subtask is read through its parent: sp get tsk_…" : args.what.startsWith("sbm_") ? "a single submission has no read endpoint; find it in the listing: sp get submissions --since … --format json" : "expected tasks, submissions, a tsk_ id, or a grptsk_ id";
2523
+ return Effect.fail(new UserError({ message: `cannot read '${args.what}': ${hint}` }));
2524
+ })).pipe(Command.withDescription("Read what came back: 'tasks' or 'submissions' for filtered listings, a tsk_ id for one task with its subtasks, a grptsk_ id for a group's per-recipient roster."));
2525
+ //#endregion
2215
2526
  //#region src/input-spec.ts
2216
2527
  const SETTING_RE = /^[A-Za-z_][A-Za-z0-9_]*=/;
2217
2528
  function looksLikeSetting(seg) {
@@ -2621,7 +2932,7 @@ const notifyCommand = Command.make("notify", {
2621
2932
  shared: sharedOption$1,
2622
2933
  format: formatOption$2,
2623
2934
  noEncrypt: noEncryptOption$2,
2624
- topic: topicOption,
2935
+ topic: topicOption$1,
2625
2936
  "api-token": apiTokenOption,
2626
2937
  password: passwordOption,
2627
2938
  "base-url": baseUrlOption,
@@ -2812,7 +3123,7 @@ const putIntegrationWraps = (id, wraps) => Effect.gen(function* () {
2812
3123
  yield* (yield* Api).put("integration wrap", `/v1/org/integrations/${encodeURIComponent(id)}/wraps`, { wraps });
2813
3124
  });
2814
3125
  const nameOption = Options.text("name").pipe(Options.withDescription("Human-readable label, shown in `sp integration list` and nowhere else."));
2815
- const scopesOption = Options.text("scopes").pipe(Options.withDefault("send,events:read"), Options.withDescription("Comma-separated scopes (send, events:read, files:read). org:admin is refused — administration is a human act."));
3126
+ const scopesOption = Options.text("scopes").pipe(Options.withDefault("send,read"), Options.withDescription("Comma-separated scopes (send, read, files:read). org:admin is refused — administration is a human act."));
2816
3127
  const createCommand = Command.make("create", {
2817
3128
  name: nameOption,
2818
3129
  scopes: scopesOption,
@@ -3535,7 +3846,7 @@ const taskCommand = Command.make("task", {
3535
3846
  shared: sharedOption,
3536
3847
  expires: expiresOption,
3537
3848
  format: formatOption$1,
3538
- topic: topicOption,
3849
+ topic: topicOption$1,
3539
3850
  "api-token": apiTokenOption,
3540
3851
  password: passwordOption,
3541
3852
  "base-url": baseUrlOption,
@@ -3841,7 +4152,7 @@ const subtaskCommand = Command.make("subtask", {
3841
4152
  markdown: markdownOption,
3842
4153
  "no-encrypt": noEncryptOption,
3843
4154
  instance: instanceOption,
3844
- topic: topicOption,
4155
+ topic: topicOption$1,
3845
4156
  "api-token": apiTokenOption,
3846
4157
  password: passwordOption,
3847
4158
  "base-url": baseUrlOption,
@@ -4022,7 +4333,8 @@ const root = Command.make("simplepush").pipe(Command.withSubcommands([
4022
4333
  notifyCommand,
4023
4334
  taskCommand,
4024
4335
  subtaskCommand,
4025
- cancelCommand
4336
+ cancelCommand,
4337
+ getCommand
4026
4338
  ]));
4027
4339
  const cli = Command.run(root, {
4028
4340
  name: "Simplepush CLI",