slack-term 1.24.0 → 1.25.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/ts/cli.ts CHANGED
@@ -29,6 +29,7 @@ import {
29
29
  reactionRemove,
30
30
  filesInfo,
31
31
  filesList,
32
+ listRecords,
32
33
  history,
33
34
  listConversations,
34
35
  listDrafts,
@@ -53,6 +54,22 @@ import {
53
54
  getPath,
54
55
  type Json,
55
56
  } from "./slack.ts";
57
+ import {
58
+ buildTodoQuery,
59
+ loadTodoConfig,
60
+ resolveChannelCached,
61
+ todoDoctor,
62
+ todoFlag,
63
+ userHandleCached,
64
+ todoSet,
65
+ withRateLimitRetry,
66
+ DOCTOR_DEFAULT_LIMIT,
67
+ type FlagName,
68
+ type LsQueryOpts,
69
+ type LsState,
70
+ type ProgressState,
71
+ } from "./todo.ts";
72
+ import { setCacheEnabled } from "./cache.ts";
56
73
  import { dayLabel, encodeMentions, encodeMentionsDetailed, findUntaggedMentions, formatYmdHm, mentionWarnings, resolveDateMarkup, resolveMentions, type MentionEncodeResult } from "./format.ts";
57
74
 
58
75
  function loadDotenv(path: string): void {
@@ -162,6 +179,9 @@ async function formatMsgLine(
162
179
  m: Record<string, Json>,
163
180
  cache: Map<string, string>,
164
181
  chLabel?: string,
182
+ // Session cookie (xoxd) for `token` — required for an xoxc- desktop session
183
+ // token; without it users.info 401s and every author renders as a raw ID.
184
+ cookie?: string,
165
185
  ): Promise<string> {
166
186
  const rawTs = typeof m.ts === "string" ? m.ts : `${tsNum(m)}.000000`;
167
187
  const stamp = slackTsToIso(rawTs);
@@ -169,8 +189,11 @@ async function formatMsgLine(
169
189
  if (typeof m.user === "string") {
170
190
  const uid = m.user;
171
191
  const handleKey = "@" + uid;
192
+ // Two-tier: this call's in-process Map → ~/.config/slack-cli/cache.json
193
+ // (1h TTL, workspace-scoped) → users.info. Handles barely change, and a
194
+ // long listing re-resolves the same few authors on every run.
172
195
  if (!cache.has(handleKey)) {
173
- const [, h] = await userInfoPair(token, uid);
196
+ const h = await userHandleCached(token, uid, async (t, u, c) => (await userInfoPair(t, u, c))[1], cookie);
174
197
  cache.set(handleKey, h);
175
198
  }
176
199
  handle = cache.get(handleKey) ?? uid;
@@ -178,7 +201,7 @@ async function formatMsgLine(
178
201
  handle = m.username;
179
202
  }
180
203
  const raw = typeof m.text === "string" ? m.text : "";
181
- const resolved = resolveDateMarkup(await resolveMentions(token, raw, cache));
204
+ const resolved = resolveDateMarkup(await resolveMentions(token, raw, cache, cookie));
182
205
  const lines = resolved.split("\n");
183
206
  const body = lines[0] + (lines.length > 1 ? "\n" + lines.slice(1).map(l => ` ${l}`).join("\n") : "");
184
207
  const who = chLabel ? `${chLabel} @${handle}` : `@${handle}`;
@@ -200,8 +223,12 @@ async function formatMsgLine(
200
223
  return `\n 📎 ${name}${sz}${id}`;
201
224
  })
202
225
  .join("");
226
+ // Thread parents carry reply_count; surface it inline so a reader of
227
+ // top-level history can see there is more to read without opening each ts.
228
+ const replyCount = Number(m.reply_count ?? 0);
229
+ const threadMark = replyCount > 0 ? ` [+${replyCount} ${replyCount === 1 ? "reply" : "replies"}]` : "";
203
230
  const tail = (reactions ? `\n ${reactions}` : "") + attachTail;
204
- return `${stamp} ${who}: ${body}${tail}`;
231
+ return `${stamp} ${who}: ${body}${threadMark}${tail}`;
205
232
  }
206
233
 
207
234
  // Human-readable byte size, shared by upload/download/attachment rendering.
@@ -236,39 +263,73 @@ function slimMsg(m: Record<string, Json>): Record<string, Json> {
236
263
  bot_id: m.bot_id ?? null,
237
264
  thread_ts: m.thread_ts ?? null,
238
265
  text: typeof m.text === "string" ? m.text : "",
266
+ // Thread stats, present only on thread parents. Without these a caller
267
+ // reading top-level history cannot tell a post with replies from one
268
+ // without, and silently misses everything said inside the thread.
269
+ ...(m.reply_count !== undefined ? { reply_count: m.reply_count } : {}),
270
+ ...(m.reply_users_count !== undefined ? { reply_users_count: m.reply_users_count } : {}),
271
+ ...(m.latest_reply !== undefined ? { latest_reply: m.latest_reply } : {}),
239
272
  // Present only when the message carries attachments, so scripts can reliably
240
273
  // detect files without the key adding noise to every plain-text line.
241
274
  ...(files.length > 0 ? { files } : {}),
242
275
  };
243
276
  }
244
277
 
278
+ // Keep only conversations whose last word is somebody else's — i.e. still
279
+ // awaiting our answer. For a thread parent that means the newest reply, which
280
+ // costs one conversations.replies call per thread (latest_reply gives the ts but
281
+ // not its author, so it cannot answer this on its own).
282
+ async function filterUnreplied(
283
+ token: string,
284
+ channelId: string,
285
+ msgs: Record<string, Json>[],
286
+ selfId: string,
287
+ cookie?: string,
288
+ ): Promise<Record<string, Json>[]> {
289
+ const out: Record<string, Json>[] = [];
290
+ for (const m of msgs) {
291
+ let last = m;
292
+ if (Number(m.reply_count ?? 0) > 0 && typeof m.ts === "string") {
293
+ const resp = (await replies(token, channelId, m.ts, 200, cookie)) as Record<string, Json>;
294
+ const all = asArray(resp.messages).map(asRecord);
295
+ last = all[all.length - 1] ?? m;
296
+ }
297
+ if (last.user !== selfId) out.push(m);
298
+ }
299
+ return out;
300
+ }
301
+
245
302
  // --- msgs <target> — channel/DM history with timestamps ---
246
- async function cmdMsgsTarget(token: string, target: string, limit: number, format: "text" | "jsonl" = "text"): Promise<void> {
303
+ async function cmdMsgsTarget(token: string, target: string, limit: number, format: "text" | "jsonl" = "text", unreplied = false, cookie?: string): Promise<void> {
247
304
  const parsed = parseSlackPermalink(target);
248
- const channelId = await resolveChannel(token, target);
305
+ const channelId = await resolveChannel(token, target, cookie);
249
306
  const cache = new Map<string, string>();
250
307
  const fetchMsgs = async (): Promise<Record<string, Json>[]> => {
251
308
  if (parsed?.threadTs) {
252
- const resp = (await replies(token, channelId, parsed.threadTs, limit)) as Record<string, Json>;
309
+ const resp = (await replies(token, channelId, parsed.threadTs, limit, cookie)) as Record<string, Json>;
253
310
  return asArray(resp.messages).map(asRecord);
254
311
  }
255
- const hist = (await history(token, channelId, limit)) as Record<string, Json>;
312
+ const hist = (await history(token, channelId, limit, undefined, undefined, cookie)) as Record<string, Json>;
256
313
  return asArray(hist.messages).map(asRecord).reverse();
257
314
  };
258
- const msgs = await fetchMsgs();
315
+ let msgs = await fetchMsgs();
316
+ if (unreplied) {
317
+ const { userId } = await authTest(token, cookie);
318
+ msgs = await filterUnreplied(token, channelId, msgs, userId, cookie);
319
+ }
259
320
  if (format === "jsonl") {
260
321
  for (const m of msgs) console.log(JSON.stringify(slimMsg(m)));
261
322
  return;
262
323
  }
263
324
  for (const m of msgs) {
264
- console.log(await formatMsgLine(token, m, cache));
325
+ console.log(await formatMsgLine(token, m, cache, undefined, cookie));
265
326
  }
266
327
  }
267
328
 
268
329
  // --- thread ---
269
- async function cmdThread(token: string, target: string, ts: string, limit: number, format: "text" | "jsonl" = "text"): Promise<void> {
270
- const channelId = await resolveChannel(token, target);
271
- const resp = (await replies(token, channelId, parseInputTs(ts), limit)) as Record<string, Json>;
330
+ async function cmdThread(token: string, target: string, ts: string, limit: number, format: "text" | "jsonl" = "text", cookie?: string): Promise<void> {
331
+ const channelId = await resolveChannel(token, target, cookie);
332
+ const resp = (await replies(token, channelId, parseInputTs(ts), limit, cookie)) as Record<string, Json>;
272
333
  const msgs = asArray(resp.messages).map(asRecord);
273
334
  if (format === "jsonl") {
274
335
  for (const m of msgs) console.log(JSON.stringify(slimMsg(m)));
@@ -276,7 +337,7 @@ async function cmdThread(token: string, target: string, ts: string, limit: numbe
276
337
  }
277
338
  const cache = new Map<string, string>();
278
339
  for (const m of msgs) {
279
- console.log(await formatMsgLine(token, m, cache));
340
+ console.log(await formatMsgLine(token, m, cache, undefined, cookie));
280
341
  }
281
342
  }
282
343
 
@@ -457,7 +518,104 @@ async function cmdSearch(token: string, query: string, count: number, json: bool
457
518
  } else {
458
519
  chLabel = `#${rawName}`;
459
520
  }
460
- console.log(await formatMsgLine(searchToken, m, cache, chLabel));
521
+ // Pass searchCookie: an xoxc- desktop token is rejected by users.info without
522
+ // its session cookie, and both the author handle and every <@UID> mention
523
+ // silently degrade to raw IDs. The DM-label lookup above already forwards it.
524
+ console.log(await formatMsgLine(searchToken, m, cache, chLabel, searchCookie));
525
+ }
526
+ }
527
+
528
+ // --- todo — reaction-backed task tracking (see ts/todo.ts for the state model) ---
529
+
530
+ /** Resolve a `#chan:<ts>` / permalink target to (channelId, ts) for todo writes. */
531
+ async function todoTarget(
532
+ token: string,
533
+ target: string,
534
+ channelIdOpt: string | undefined,
535
+ cookie: string | undefined,
536
+ ): Promise<{ channelId: string; ts: string }> {
537
+ const { ref, ts } = splitRefTs(target);
538
+ if (!ts) {
539
+ console.error("Error: target must embed a message ts (e.g. #chan:1700000000.000100 or a Slack permalink URL)");
540
+ process.exit(2);
541
+ }
542
+ const channelId = channelIdOpt ?? (await resolveChannelCached(token, ref, resolveChannel, cookie));
543
+ return { channelId, ts };
544
+ }
545
+
546
+ async function cmdTodoLs(
547
+ token: string,
548
+ opts: { state: LsState; in?: string; from?: string; shared?: boolean; count: number; json: boolean; cookie?: string },
549
+ ): Promise<void> {
550
+ const cfg = loadTodoConfig();
551
+ const queryOpts: LsQueryOpts = {};
552
+ if (opts.shared) queryOpts.shared = true;
553
+ if (opts.in) queryOpts.in = opts.in;
554
+ if (opts.from) queryOpts.from = opts.from;
555
+ const query = buildTodoQuery(opts.state, cfg, queryOpts);
556
+ if (!opts.json) console.error(`(query: ${query})`);
557
+ // One search per invocation — search.messages is Tier 2 (~20 req/min), so the
558
+ // priority rule is encoded as negations in the query, not as extra searches.
559
+ await withRateLimitRetry("search.messages", () => cmdSearch(token, query, opts.count, opts.json, opts.cookie));
560
+ }
561
+
562
+ async function cmdTodoSet(
563
+ token: string,
564
+ args: { target: string; state: ProgressState; channelId?: string; cookie?: string },
565
+ ): Promise<void> {
566
+ const cfg = loadTodoConfig();
567
+ const { channelId, ts } = await todoTarget(token, args.target, args.channelId, args.cookie);
568
+ const res = await todoSet(token, channelId, ts, args.state, cfg, args.cookie);
569
+ if (res.noop) {
570
+ console.log(`= Already ${args.state} (ts: ${ts})`);
571
+ return;
572
+ }
573
+ const removedNote = res.removed.length ? `, removed ${res.removed.map((e) => `:${e}:`).join(" ")}` : "";
574
+ console.log(`✓ ${args.state} :${cfg.progress[args.state]}: (ts: ${ts})${removedNote}`);
575
+ if (res.leftover.length) {
576
+ console.error(
577
+ `⚠ ${res.leftover.map((e) => `:${e}:`).join(" ")} could not be removed — this message now carries more than one ` +
578
+ `progress reaction. Readers collapse by priority so nothing is lost; repair with: slack todo doctor --in <#chan> --fix`,
579
+ );
580
+ }
581
+ }
582
+
583
+ async function cmdTodoFlag(
584
+ token: string,
585
+ args: { target: string; flag: FlagName; remove?: boolean; channelId?: string; cookie?: string },
586
+ ): Promise<void> {
587
+ const cfg = loadTodoConfig();
588
+ const { channelId, ts } = await todoTarget(token, args.target, args.channelId, args.cookie);
589
+ const emoji = await todoFlag(token, channelId, ts, args.flag, cfg, args.remove === true, args.cookie);
590
+ console.log(`✓ ${args.remove ? "Cleared" : "Flagged"} ${args.flag} :${emoji}: (ts: ${ts})`);
591
+ }
592
+
593
+ async function cmdTodoDoctor(
594
+ token: string,
595
+ args: { channel: string; fix?: boolean; limit: number; channelId?: string; cookie?: string },
596
+ ): Promise<void> {
597
+ const cfg = loadTodoConfig();
598
+ const channelId = args.channelId ?? (await resolveChannelCached(token, args.channel, resolveChannel, args.cookie));
599
+ const doctorOpts: { fix?: boolean; limit: number; cookie?: string } = { limit: args.limit };
600
+ if (args.fix) doctorOpts.fix = true;
601
+ if (args.cookie) doctorOpts.cookie = args.cookie;
602
+ const report = await todoDoctor(token, channelId, cfg, doctorOpts);
603
+ if (report.findings.length === 0) {
604
+ console.log(`✓ No multi-state tasks in ${report.scanned} message(s).`);
605
+ } else {
606
+ for (const f of report.findings) {
607
+ const status = f.fixed === true ? "fixed" : f.fixed === false ? `FAILED: ${f.error ?? "?"}` : "needs --fix";
608
+ console.log(
609
+ `${f.ts} ${f.emoji.map((e) => `:${e}:`).join(" ")} → keep :${f.keep}: [${status}] ` +
610
+ `${stripTerminalControls(f.text).slice(0, 60)}`,
611
+ );
612
+ }
613
+ console.log(`${report.findings.length} multi-state task(s) in ${report.scanned} message(s).`);
614
+ }
615
+ if (report.truncated) {
616
+ console.error(
617
+ `⚠ Scan limit reached (${args.limit} messages) — older history was NOT checked. Raise it with --limit N.`,
618
+ );
461
619
  }
462
620
  }
463
621
 
@@ -747,7 +905,7 @@ function parseTargetThread(s: string): { ref: string; threadTs?: string } {
747
905
  return { ref: s };
748
906
  }
749
907
 
750
- /** Human-readable destination label for confirm gates, e.g. "#symval (C0B0L6SSAMD)".
908
+ /** Human-readable destination label for confirm gates, e.g. "#general (C0123456789)".
751
909
  * When ref is already a #channel/@user label, keep it; otherwise resolve the channel
752
910
  * name via conversations.info (fail-soft: a raw ID is still unambiguous). */
753
911
  async function destLabel(token: string, channelId: string, ref: string, cookie?: string): Promise<string> {
@@ -769,6 +927,55 @@ async function destLabel(token: string, channelId: string, ref: string, cookie?:
769
927
  return name === channelId ? channelId : `${name} (${channelId})`;
770
928
  }
771
929
 
930
+ /** Identity behind the token a write command will act with. */
931
+ type Self = { userId: string; user: string; botId: string; team: string };
932
+
933
+ /** `auth.test` for the token that will perform a write. Every confirm gate names
934
+ * the acting identity and binds it into the safety hash. Resolves to null
935
+ * (never throws) when the lookup fails, so a gate still renders and the write
936
+ * still goes through. */
937
+ async function selfIdentity(token: string, cookie?: string): Promise<Self | null> {
938
+ try {
939
+ return await authScopes(token, cookie);
940
+ } catch {
941
+ return null;
942
+ }
943
+ }
944
+
945
+ /** Memoized `selfIdentity`, for commands that consult it more than once (`send`
946
+ * needs it for the unreplied guard as well as the gate) — a command must never
947
+ * pay for more than one lookup. */
948
+ function selfLookup(token: string, cookie?: string): () => Promise<Self | null> {
949
+ let cache: Self | null | undefined;
950
+ return async () => {
951
+ if (cache === undefined) cache = await selfIdentity(token, cookie);
952
+ return cache;
953
+ };
954
+ }
955
+
956
+ /** Name the identity a write will be performed AS, for confirm gates:
957
+ * "@snomiao (U0123ABC) — Acme". Slack shows only the author on the resulting
958
+ * message, so a wrong profile / stale SLACK_TOKEN / unintended --as-bot is
959
+ * otherwise invisible until after it lands — handle, user id and workspace pin
960
+ * all three down. `self` is null when the auth.test lookup failed (fail-soft:
961
+ * the gate still renders, it just can't name the actor). */
962
+ function senderLabel(self: Self | null, asBot?: boolean): string {
963
+ const suffix = asBot ? " [as bot]" : "";
964
+ if (!self) return `(unknown — auth.test failed)${suffix}`;
965
+ // Display text is workspace-controlled; strip control chars like the other gates.
966
+ const name = stripTerminalControls(self.user || self.userId || "?");
967
+ const id = self.userId ? ` (${self.userId})` : "";
968
+ const team = self.team ? ` — ${stripTerminalControls(self.team)}` : "";
969
+ return `@${name}${id}${team}${suffix}`;
970
+ }
971
+
972
+ /** The `From:` line every write gate opens its action block with. `pad` is the
973
+ * label field width, for gates whose other labels sit on a wider column
974
+ * (`To:`/`At:`/`Message:` on schedule, `Title:`/`Size:` on upload). */
975
+ function fromLine(self: Self | null, opts: { asBot?: boolean | undefined; pad?: number } = {}): string {
976
+ return ` ${"From:".padEnd(opts.pad ?? 6)}${senderLabel(self, opts.asBot)}`;
977
+ }
978
+
772
979
  /** One-line description of a single thread message for confirm gates:
773
980
  * "2026-06-11 08:05 @handle: head…". `headLen` bounds the text preview. */
774
981
  async function threadMsgLine(token: string, m: Record<string, Json>, headLen = 60, cookie?: string): Promise<string> {
@@ -850,6 +1057,7 @@ interface EditArgs {
850
1057
  cookie?: string;
851
1058
  }
852
1059
  async function cmdEdit(token: string, args: EditArgs): Promise<void> {
1060
+ const getSelf = selfLookup(token, args.cookie);
853
1061
  const { ref, ts } = splitRefTs(args.target);
854
1062
  if (!ts) {
855
1063
  console.error("Error: target must embed a message ts (e.g. #chan:2026-05-11T06:01:04.000100 or a Slack permalink URL)");
@@ -875,9 +1083,16 @@ async function cmdEdit(token: string, args: EditArgs): Promise<void> {
875
1083
  ? await encodeMentions(token, args.newText, channelId, args.cookie ? { cookie: args.cookie } : {})
876
1084
  : args.newText;
877
1085
 
878
- const code = safetyCode(originalText, newText);
1086
+ // Identity is part of the hash for the same reason it is on `send`: a code
1087
+ // minted while previewing as one identity must not confirm the write as
1088
+ // another (Slack only lets you edit your own messages, so a silent profile
1089
+ // switch between preview and confirm otherwise turns into a bare API error).
1090
+ const self = await getSelf();
1091
+ const code = safetyCode(originalText, newText, self?.userId ?? "");
879
1092
  if (args.code !== code) {
880
1093
  requireCode(args.code, code, [
1094
+ `--- Editing as -------------------------------`,
1095
+ fromLine(self),
881
1096
  `--- Original message -------------------------`,
882
1097
  ...originalText.split("\n").map((l) => ` ${l}`),
883
1098
  `--- Replacing with ---------------------------`,
@@ -898,6 +1113,7 @@ interface DeleteArgs {
898
1113
  cookie?: string;
899
1114
  }
900
1115
  async function cmdDelete(token: string, args: DeleteArgs): Promise<void> {
1116
+ const getSelf = selfLookup(token, args.cookie);
901
1117
  const { ref, ts } = splitRefTs(args.target);
902
1118
  if (!ts) {
903
1119
  console.error("Error: target must embed a message ts (e.g. #chan:2026-05-11T06:01:04.000100 or a Slack permalink URL)");
@@ -918,11 +1134,15 @@ async function cmdDelete(token: string, args: DeleteArgs): Promise<void> {
918
1134
  }
919
1135
  const originalText = typeof original.text === "string" ? original.text : "";
920
1136
 
921
- const code = safetyCode(channelId, ts, originalText);
1137
+ // Identity in the hash: same rule as send/edit — a code minted as one
1138
+ // identity can't confirm the delete as another.
1139
+ const self = await getSelf();
1140
+ const code = safetyCode(channelId, ts, originalText, self?.userId ?? "");
922
1141
  if (args.code !== code) {
923
1142
  const dest = await destLabel(token, channelId, ref, args.cookie);
924
1143
  requireCode(args.code, code, [
925
1144
  `--- Deleting message -------------------------`,
1145
+ fromLine(self),
926
1146
  ` → ${dest} at ${slackTsToIso(ts)}`,
927
1147
  ...originalText.split("\n").map((l) => ` ${l}`),
928
1148
  `---------------------------------------------`,
@@ -1010,6 +1230,10 @@ async function cmdSend(token: string, args: SendArgs): Promise<void> {
1010
1230
  const { ref, threadTs } = parseTargetThread(args.target);
1011
1231
  const cookie = args.cookie;
1012
1232
 
1233
+ // Identity of the token that will actually post — the bot token under
1234
+ // --as-bot, the user token otherwise.
1235
+ const getSelf = selfLookup(token, cookie);
1236
+
1013
1237
  // Guard the self-DM footgun before doing anything else, unless sending as the
1014
1238
  // bot (which delivers a notifiable DM from a different identity).
1015
1239
  if (!args.asBot && !args.channelId && !args.userId && await isSelfDm(token, ref, cookie)) {
@@ -1141,10 +1365,12 @@ async function cmdSend(token: string, args: SendArgs): Promise<void> {
1141
1365
  // shape `send --as-bot` produces. Opt out with SLACK_UNREPLIED_WARN=0.
1142
1366
  if (process.env.SLACK_UNREPLIED_WARN !== "0" && lastMsgTs && (lastMsgUserId || lastMsgBotId)) {
1143
1367
  try {
1144
- const self = await authScopes(token, cookie);
1145
- const isSelfAuthor =
1368
+ const self = await getSelf();
1369
+ // `self` is null when the identity lookup failed — can't tell whose
1370
+ // message it is, so stay quiet rather than guess.
1371
+ const isSelfAuthor = !!self && (
1146
1372
  (!!lastMsgUserId && !!self.userId && self.userId === lastMsgUserId) ||
1147
- (!!lastMsgBotId && !!self.botId && self.botId === lastMsgBotId);
1373
+ (!!lastMsgBotId && !!self.botId && self.botId === lastMsgBotId));
1148
1374
  if (isSelfAuthor) {
1149
1375
  // Prefer a real permalink (exact, copy-pasteable). If that lookup fails,
1150
1376
  // fall back to a form guaranteed parseable by `slack edit` regardless of
@@ -1187,12 +1413,17 @@ async function cmdSend(token: string, args: SendArgs): Promise<void> {
1187
1413
  }
1188
1414
  }
1189
1415
 
1190
- // Hash covers the destination too — a code minted for one channel/thread
1191
- // cannot confirm a send to another.
1192
- const code = safetyCode(channelId, threadTs ?? "", lastText, message);
1416
+ // Hash covers the destination and the sending identity too — a code minted
1417
+ // for one channel/thread cannot confirm a send to another, and one minted
1418
+ // while previewing as identity A cannot confirm a send as identity B (a
1419
+ // profile / SLACK_TOKEN / --as-bot switch between preview and confirm
1420
+ // re-previews instead). Empty on lookup failure, same fail-soft as lastText.
1421
+ const self = await getSelf();
1422
+ const code = safetyCode(channelId, threadTs ?? "", lastText, message, self?.userId ?? "");
1193
1423
 
1194
1424
  if (args.code !== code) {
1195
1425
  const dest = await destLabel(token, channelId, ref, cookie);
1426
+ const from = fromLine(self, { asBot: args.asBot });
1196
1427
  if (threadTs) {
1197
1428
  const parentLine = await threadParentLine(token, threadTs, threadMsgs, cookie);
1198
1429
  // Preview the tail of the thread (up to 3 most recent messages) so the
@@ -1206,6 +1437,7 @@ async function cmdSend(token: string, args: SendArgs): Promise<void> {
1206
1437
  ...recentLines,
1207
1438
  ...mentionLines,
1208
1439
  `--- Sending ----------------------------------`,
1440
+ from,
1209
1441
  ` → ${dest} thread of ${parentLine} — THREAD REPLY`,
1210
1442
  ` Message: ${message}`,
1211
1443
  `--------------------------------────────────`,
@@ -1216,6 +1448,7 @@ async function cmdSend(token: string, args: SendArgs): Promise<void> {
1216
1448
  ` ${lastUser}: ${lastText.split("\n")[0]?.slice(0, 100) ?? "(empty)"}`,
1217
1449
  ...mentionLines,
1218
1450
  `--- Sending ----------------------------------`,
1451
+ from,
1219
1452
  ` → ${dest} — NEW top-level message`,
1220
1453
  ` Message: ${message}`,
1221
1454
  `--------------------------------────────────`,
@@ -1272,6 +1505,26 @@ function parsePostAt(at: string): number {
1272
1505
  return Math.floor(d.getTime() / 1000);
1273
1506
  }
1274
1507
 
1508
+ /** Render a scheduled post time as local AND UTC: "2026-08-10 18:00:00 GMT+9
1509
+ * (local) / 2026-08-10T09:00:00.000Z (Unix: 1786352400)". A schedule gate is
1510
+ * exactly where an off-by-a-timezone mistake gets locked in — `--at` accepts
1511
+ * both bare local times and `Z`-suffixed UTC, so showing only one form leaves
1512
+ * the reader doing the conversion in their head. Local is first: it's the form
1513
+ * the sender thinks in. */
1514
+ function fmtPostAt(postAt: number): string {
1515
+ const d = new Date(postAt * 1000);
1516
+ const local = new Intl.DateTimeFormat("sv-SE", {
1517
+ year: "numeric", month: "2-digit", day: "2-digit",
1518
+ hour: "2-digit", minute: "2-digit", second: "2-digit",
1519
+ hour12: false, timeZoneName: "short",
1520
+ })
1521
+ .format(d)
1522
+ // sv-SE renders a west-of-UTC offset with U+2212 MINUS SIGN ("GMT−4"), which
1523
+ // survives copy/paste into a shell or a grep as a non-ASCII byte. Plain hyphen.
1524
+ .replace(/−/g, "-");
1525
+ return `${local} (local) / ${d.toISOString()} (Unix: ${postAt})`;
1526
+ }
1527
+
1275
1528
  interface ScheduleSendArgs {
1276
1529
  target: string;
1277
1530
  message: string;
@@ -1282,6 +1535,7 @@ interface ScheduleSendArgs {
1282
1535
  cookie?: string;
1283
1536
  }
1284
1537
  async function cmdScheduleSend(token: string, args: ScheduleSendArgs): Promise<void> {
1538
+ const getSelf = selfLookup(token, args.cookie);
1285
1539
  const { ref, threadTs } = parseTargetThread(args.target);
1286
1540
 
1287
1541
  let channelId: string;
@@ -1291,13 +1545,18 @@ async function cmdScheduleSend(token: string, args: ScheduleSendArgs): Promise<v
1291
1545
 
1292
1546
  const postAt = parsePostAt(args.at);
1293
1547
  const postAtDate = new Date(postAt * 1000).toISOString();
1294
- const code = safetyCode(channelId, args.message, String(postAt));
1548
+ // Identity in the hash, same as the other gated writes. It matters more here
1549
+ // than anywhere else: the message goes out later, unattended, under whichever
1550
+ // identity was current at schedule time.
1551
+ const self = await getSelf();
1552
+ const code = safetyCode(channelId, args.message, String(postAt), self?.userId ?? "");
1295
1553
 
1296
1554
  if (args.code !== code) {
1297
1555
  requireCode(args.code, code, [
1298
1556
  `--- Scheduling message -----------------------`,
1557
+ fromLine(self, { pad: 9 }),
1299
1558
  ` To: ${ref}${threadTs ? ` (thread ${threadTs})` : ""}`,
1300
- ` At: ${postAtDate} (Unix: ${postAt})`,
1559
+ ` At: ${fmtPostAt(postAt)}`,
1301
1560
  ` Message: ${args.message}`,
1302
1561
  `---------------------------------------------`,
1303
1562
  ]);
@@ -1336,10 +1595,12 @@ async function cmdScheduleRm(token: string, args: ScheduleRmArgs): Promise<void>
1336
1595
  if (args.channelId) channelId = args.channelId;
1337
1596
  else channelId = await resolveChannel(token, args.target, args.cookie);
1338
1597
 
1339
- const code = safetyCode(channelId, args.id);
1598
+ const self = await selfIdentity(token, args.cookie);
1599
+ const code = safetyCode(channelId, args.id, self?.userId ?? "");
1340
1600
  if (args.code !== code) {
1341
1601
  requireCode(args.code, code, [
1342
1602
  `--- Deleting scheduled message ---------------`,
1603
+ fromLine(self, { pad: 9 }),
1343
1604
  ` Channel: ${args.target}`,
1344
1605
  ` ID: ${args.id}`,
1345
1606
  `---------------------------------------------`,
@@ -1361,6 +1622,7 @@ interface UploadArgs {
1361
1622
  cookie?: string;
1362
1623
  }
1363
1624
  async function cmdUpload(token: string, args: UploadArgs): Promise<void> {
1625
+ const getSelf = selfLookup(token, args.cookie);
1364
1626
  const { statSync, existsSync } = await import("node:fs");
1365
1627
  const { basename } = await import("node:path");
1366
1628
 
@@ -1385,19 +1647,23 @@ async function cmdUpload(token: string, args: UploadArgs): Promise<void> {
1385
1647
  return { fp, filename, title, sizeFmt: fmtSize(statSync(fp).size) };
1386
1648
  });
1387
1649
 
1388
- // Safety code covers the full batch — single-file code is identical to the old formula.
1389
- const code = safetyCode(channelId, ...files.flatMap((f) => [f.fp, f.title]));
1650
+ // Safety code covers the full batch, plus the uploading identity (same rule
1651
+ // as send/edit/delete — the file lands under whoever the token is).
1652
+ const self = await getSelf();
1653
+ const code = safetyCode(channelId, ...files.flatMap((f) => [f.fp, f.title]), self?.userId ?? "");
1390
1654
  if (args.code !== code) {
1391
1655
  const destLine = ` To: ${ref}${threadTs ? ` (thread ${threadTs})` : ""}`;
1392
1656
  const lines = isBatch
1393
1657
  ? [
1394
1658
  `--- Uploading ${files.length} files ------------------------`,
1659
+ fromLine(self, { pad: 7 }),
1395
1660
  destLine,
1396
1661
  ...files.map((f) => ` ${f.filename} (${f.sizeFmt})`),
1397
1662
  `--------------------------------────────────`,
1398
1663
  ]
1399
1664
  : [
1400
1665
  `--- Uploading file ---------------------------`,
1666
+ fromLine(self, { pad: 7 }),
1401
1667
  destLine,
1402
1668
  ` File: ${files[0]!.fp}`,
1403
1669
  ` Title: ${files[0]!.title}`,
@@ -1467,6 +1733,74 @@ async function cmdFilesList(
1467
1733
  }
1468
1734
  }
1469
1735
 
1736
+ // --- lists <ref> — read a Slack List's rows (read-only) ---
1737
+ // ref: a list URL (…/lists/<TEAM>/<F-id> or …/lists/<F-id>) or a bare F-id.
1738
+ // There is no list-enumeration API, so the id must come from the list's URL.
1739
+ function parseListId(ref: string): string | undefined {
1740
+ if (/^F[A-Z0-9]+$/i.test(ref)) return ref;
1741
+ return ref.match(/\/lists\/[A-Za-z0-9]+\/(F[A-Za-z0-9]+)/i)?.[1]
1742
+ ?? ref.match(/\/lists\/(F[A-Za-z0-9]+)/i)?.[1];
1743
+ }
1744
+
1745
+ // Best-effort display of a Slack List cell across the value shapes the API uses
1746
+ // (plain text, typed value, arrays of {text|name|value}, typed sub-objects).
1747
+ function listFieldValue(f: Record<string, Json>): string {
1748
+ if (typeof f.text === "string" && f.text) return f.text;
1749
+ const v = f.value;
1750
+ if (typeof v === "string" || typeof v === "number" || typeof v === "boolean") return String(v);
1751
+ if (Array.isArray(v)) {
1752
+ return v.map((x) => typeof x === "string" ? x : String(asRecord(x).text ?? asRecord(x).name ?? asRecord(x).value ?? "")).filter(Boolean).join(", ");
1753
+ }
1754
+ for (const k of ["rich_text", "select", "user", "date", "number", "checkbox", "email", "phone"]) {
1755
+ const tv = f[k];
1756
+ if (tv != null) return typeof tv === "object" ? JSON.stringify(tv) : String(tv);
1757
+ }
1758
+ return "";
1759
+ }
1760
+
1761
+ async function cmdLists(
1762
+ token: string,
1763
+ cookie: string | undefined,
1764
+ ref: string,
1765
+ opts: { limit: number; format: string },
1766
+ ): Promise<void> {
1767
+ const listId = parseListId(ref);
1768
+ if (!listId) {
1769
+ throw new Error(
1770
+ `Not a list ID or list URL: ${ref}\n` +
1771
+ `Expected F… or https://<ws>.slack.com/lists/<TEAM>/<F-id>`,
1772
+ );
1773
+ }
1774
+ const records: Record<string, Json>[] = [];
1775
+ let cursor: string | undefined;
1776
+ do {
1777
+ const listOpts: { limit: number; cursor?: string } = { limit: 200 };
1778
+ if (cursor) listOpts.cursor = cursor;
1779
+ const resp = asRecord((await listRecords(token, listId, listOpts, cookie)) as Json);
1780
+ for (const it of asArray(resp.items ?? resp.records)) records.push(asRecord(it));
1781
+ const next = asRecord(resp.response_metadata).next_cursor;
1782
+ cursor = typeof next === "string" && next ? next : undefined;
1783
+ } while (cursor && records.length < opts.limit);
1784
+ const shown = records.slice(0, opts.limit);
1785
+
1786
+ if (opts.format === "jsonl") {
1787
+ for (const r of shown) console.log(JSON.stringify(r));
1788
+ return;
1789
+ }
1790
+ for (const r of shown) {
1791
+ const id = String(r.id ?? "");
1792
+ const cells = asArray(r.fields)
1793
+ .map(asRecord)
1794
+ .map((f) => {
1795
+ const col = String(f.key ?? f.column_id ?? "?");
1796
+ const val = listFieldValue(f);
1797
+ return val ? `${col}=${val}` : "";
1798
+ })
1799
+ .filter(Boolean);
1800
+ console.log(`${id} ${cells.join(" | ")}`);
1801
+ }
1802
+ }
1803
+
1470
1804
  // --- download <ref> [dest] — fetch an attachment's bytes to disk (read-only) ---
1471
1805
  // ref: a file ID (F…) or a Slack file permalink (…/files/<UID>/<FID>/<name>).
1472
1806
  function parseFileId(ref: string): string | undefined {
@@ -1552,11 +1886,12 @@ async function main(): Promise<void> {
1552
1886
  .positional("target", { type: "string", describe: "#channel, @user, or URL" })
1553
1887
  .option("limit", { alias: "n", type: "number", default: 20, describe: "Number of messages" })
1554
1888
  .option("format", { type: "string", choices: ["text", "jsonl"] as const, default: "text", describe: "jsonl exposes raw ts/user/text (incl. external-guest user IDs)" })
1555
- .option("json", { type: "boolean", default: false, describe: "Alias for --format=jsonl" }),
1889
+ .option("json", { type: "boolean", default: false, describe: "Alias for --format=jsonl" })
1890
+ .option("unreplied", { type: "boolean", default: false, describe: "Only messages whose last word (incl. thread replies) is not yours — i.e. awaiting your answer" }),
1556
1891
  async (argv) => {
1557
1892
  const token = tok(argv as W);
1558
1893
  const format = argv.json ? "jsonl" : (argv.format as "text" | "jsonl");
1559
- if (argv.target) await cmdMsgsTarget(token, argv.target, argv.limit, format);
1894
+ if (argv.target) await cmdMsgsTarget(token, argv.target, argv.limit, format, argv.unreplied, ck(argv as W));
1560
1895
  else await cmdMsgs(token);
1561
1896
  },
1562
1897
  )
@@ -1571,7 +1906,7 @@ async function main(): Promise<void> {
1571
1906
  .option("json", { type: "boolean", default: false, describe: "Alias for --format=jsonl" }),
1572
1907
  async (argv) => {
1573
1908
  const format = argv.json ? "jsonl" : (argv.format as "text" | "jsonl");
1574
- await cmdThread(tok(argv as W), argv.target!, argv.ts!, argv.limit, format);
1909
+ await cmdThread(tok(argv as W), argv.target!, argv.ts!, argv.limit, format, ck(argv as W));
1575
1910
  },
1576
1911
  )
1577
1912
  .command(
@@ -1631,9 +1966,13 @@ async function main(): Promise<void> {
1631
1966
  const name = argv.name!.replace(/^#/, "").toLowerCase();
1632
1967
  const isPrivate = argv.private === true;
1633
1968
  const invites = (argv.invite as string[]).filter(Boolean);
1634
- const code = safetyCode(name, String(isPrivate), invites.join(","));
1969
+ // The creator becomes the channel's owner and the inviter of record —
1970
+ // so the gate names them, and the code is bound to them.
1971
+ const self = await selfIdentity(token, cookie);
1972
+ const code = safetyCode(name, String(isPrivate), invites.join(","), self?.userId ?? "");
1635
1973
  if (argv.code !== code) requireCode(argv.code, code, [
1636
1974
  `--- Creating channel -------------------------`,
1975
+ fromLine(self, { pad: 9 }),
1637
1976
  ` name: ${isPrivate ? "🔒 " : "#"}${name}`,
1638
1977
  ` private: ${isPrivate}`,
1639
1978
  ...(invites.length ? [` invite: ${invites.join(", ")}`] : []),
@@ -2017,6 +2356,118 @@ async function main(): Promise<void> {
2017
2356
  }
2018
2357
  },
2019
2358
  )
2359
+ .command(
2360
+ "todo",
2361
+ "Reaction-backed task tracking (:pushpin: marks a task, progress lives in a second reaction)",
2362
+ (y) => y
2363
+ // yargs turns this into --no-cache automatically (boolean negation).
2364
+ .option("cache", { type: "boolean", default: true, describe: "Use ~/.config/slack-cli/cache.json for channel/user lookups (--no-cache to bypass)" })
2365
+ .middleware((argv) => { if (argv.cache === false) setCacheEnabled(false); })
2366
+ .command(
2367
+ ["ls", "list"],
2368
+ "List tasks (read-only; one search per run)",
2369
+ (y2) => y2
2370
+ .option("state", {
2371
+ type: "string",
2372
+ choices: ["open", "untriaged", "pending", "doing", "done", "dropped", "stuck"],
2373
+ default: "open",
2374
+ describe: "open (default) = marked and not done/dropped; untriaged = marked with no progress; stuck = unfinished with a reason flag",
2375
+ })
2376
+ .option("in", { type: "string", describe: "Limit to a channel (#chan)" })
2377
+ .option("from", { type: "string", describe: "Limit to a sender (@user, or 'me') — usually the person a task is waiting on" })
2378
+ .option("shared", { type: "boolean", default: false, describe: "Match anyone's reactions (has:) instead of only yours (hasmy:)" })
2379
+ .option("n", { alias: "count", type: "number", default: 50, describe: "Max results" })
2380
+ .option("json", { type: "boolean", default: false }),
2381
+ async (argv) => {
2382
+ const opts: { state: LsState; in?: string; from?: string; shared?: boolean; count: number; json: boolean; cookie?: string } = {
2383
+ state: argv.state as LsState,
2384
+ count: argv.n,
2385
+ json: argv.json,
2386
+ };
2387
+ if (argv.in) opts.in = argv.in;
2388
+ if (argv.from) opts.from = argv.from;
2389
+ if (argv.shared) opts.shared = true;
2390
+ const cookie = ck(argv as W);
2391
+ if (cookie) opts.cookie = cookie;
2392
+ await cmdTodoLs(tok(argv as W), opts);
2393
+ },
2394
+ )
2395
+ .command(
2396
+ "set <target> <state>",
2397
+ "Move a task to a progress state (adds the new reaction before removing the old one)",
2398
+ (y2) => y2
2399
+ .positional("target", { type: "string", demandOption: true, describe: "#chan:ts or permalink" })
2400
+ .positional("state", { type: "string", demandOption: true, choices: ["pending", "doing", "done", "dropped"] })
2401
+ .option("channel-id", { type: "string", describe: "Raw channel ID" }),
2402
+ async (argv) => {
2403
+ const args: { target: string; state: ProgressState; channelId?: string; cookie?: string } = {
2404
+ target: argv.target!,
2405
+ state: argv.state as ProgressState,
2406
+ };
2407
+ if (argv["channel-id"]) args.channelId = argv["channel-id"];
2408
+ const cookie = ck(argv as W);
2409
+ if (cookie) args.cookie = cookie;
2410
+ try {
2411
+ await cmdTodoSet(tok(argv as W), args);
2412
+ } catch (e: unknown) {
2413
+ console.error(friendlySlackError(e));
2414
+ process.exit(1);
2415
+ }
2416
+ },
2417
+ )
2418
+ .command(
2419
+ "flag <target> <flag>",
2420
+ "Add (or --remove) a reason flag (alert | needs-discussion | waiting | blocked) — flags stack, independent of progress",
2421
+ (y2) => y2
2422
+ .positional("target", { type: "string", demandOption: true, describe: "#chan:ts or permalink" })
2423
+ .positional("flag", {
2424
+ type: "string",
2425
+ demandOption: true,
2426
+ choices: ["alert", "needs-discussion", "waiting", "blocked"],
2427
+ describe: "waiting = the other party in this conversation owes a reply; blocked = stuck on something outside it",
2428
+ })
2429
+ .option("remove", { type: "boolean", default: false, describe: "Remove the flag instead of adding it" })
2430
+ .option("channel-id", { type: "string", describe: "Raw channel ID" }),
2431
+ async (argv) => {
2432
+ const args: { target: string; flag: FlagName; remove?: boolean; channelId?: string; cookie?: string } = {
2433
+ target: argv.target!,
2434
+ flag: argv.flag as FlagName,
2435
+ };
2436
+ if (argv.remove) args.remove = true;
2437
+ if (argv["channel-id"]) args.channelId = argv["channel-id"];
2438
+ const cookie = ck(argv as W);
2439
+ if (cookie) args.cookie = cookie;
2440
+ try {
2441
+ await cmdTodoFlag(tok(argv as W), args);
2442
+ } catch (e: unknown) {
2443
+ console.error(friendlySlackError(e));
2444
+ process.exit(1);
2445
+ }
2446
+ },
2447
+ )
2448
+ .command(
2449
+ "doctor",
2450
+ "Find messages carrying two or more progress reactions (--fix repairs them)",
2451
+ (y2) => y2
2452
+ .option("in", { type: "string", demandOption: true, describe: "Channel to scan (#chan)" })
2453
+ .option("fix", { type: "boolean", default: false, describe: "Repair findings (keeps the highest-priority state)" })
2454
+ .option("limit", { type: "number", default: DOCTOR_DEFAULT_LIMIT, describe: "Max messages to scan" })
2455
+ .option("channel-id", { type: "string", describe: "Raw channel ID" }),
2456
+ async (argv) => {
2457
+ const args: { channel: string; fix?: boolean; limit: number; channelId?: string; cookie?: string } = {
2458
+ channel: argv.in!,
2459
+ limit: argv.limit,
2460
+ };
2461
+ if (argv.fix) args.fix = true;
2462
+ if (argv["channel-id"]) args.channelId = argv["channel-id"];
2463
+ const cookie = ck(argv as W);
2464
+ if (cookie) args.cookie = cookie;
2465
+ await cmdTodoDoctor(tok(argv as W), args);
2466
+ },
2467
+ )
2468
+ .demandCommand(1, "Specify a todo subcommand (ls, set, flag, doctor)"),
2469
+ () => {},
2470
+ )
2020
2471
  .command(
2021
2472
  "upload <target> <file..>",
2022
2473
  "Upload one or more files to a channel or DM",
@@ -2069,6 +2520,24 @@ async function main(): Promise<void> {
2069
2520
  .showHelpOnFail(true),
2070
2521
  () => {},
2071
2522
  )
2523
+ .command(
2524
+ "lists <ref>",
2525
+ "Read a Slack List's rows (by list URL or F-id) — read-only",
2526
+ (y) => y
2527
+ .positional("ref", { type: "string", demandOption: true, describe: "List URL (…/lists/<TEAM>/<F-id>) or bare F-id" })
2528
+ .option("limit", { alias: "l", type: "number", default: 100, describe: "Max rows to show" })
2529
+ .option("format", { type: "string", choices: ["text", "jsonl"], default: "text" })
2530
+ .option("json", { type: "boolean", default: false, describe: "Alias for --format=jsonl (raw records)" }),
2531
+ async (argv) => {
2532
+ const format = argv.json ? "jsonl" : (argv.format as string);
2533
+ try {
2534
+ await cmdLists(tok(argv as W), ck(argv as W), argv.ref!, { limit: argv.limit as number, format });
2535
+ } catch (e: unknown) {
2536
+ console.error(friendlySlackError(e));
2537
+ process.exit(1);
2538
+ }
2539
+ },
2540
+ )
2072
2541
  .command(
2073
2542
  "download <ref> [dest]",
2074
2543
  "Download a file attachment (by file ID or file permalink) to disk",
@@ -2140,8 +2609,11 @@ async function main(): Promise<void> {
2140
2609
  const d = asArray(listResp.drafts).map(asRecord).find((x) => String(x.id) === argv.id);
2141
2610
  if (!d) { console.error(`Draft not found: ${argv.id}`); process.exit(1); }
2142
2611
  const prevText = draftText(d);
2143
- const code = safetyCode(prevText, text);
2612
+ const self = await selfIdentity(token, cookie);
2613
+ const code = safetyCode(prevText, text, self?.userId ?? "");
2144
2614
  if (argv.code !== code) requireCode(argv.code, code, [
2615
+ `--- Editing draft as -------------------------`,
2616
+ fromLine(self),
2145
2617
  `--- Current draft ----------------------------`,
2146
2618
  ...prevText.split("\n").map((l) => ` ${l}`),
2147
2619
  `--- Replacing with ---------------------------`,
@@ -2164,9 +2636,11 @@ async function main(): Promise<void> {
2164
2636
  const d = asArray(listResp.drafts).map(asRecord).find((x) => String(x.id) === argv.id);
2165
2637
  if (!d) { console.error(`Draft not found: ${argv.id}`); process.exit(1); }
2166
2638
  const prevText = draftText(d);
2167
- const code = safetyCode(argv.id!, prevText);
2639
+ const self = await selfIdentity(token, cookie);
2640
+ const code = safetyCode(argv.id!, prevText, self?.userId ?? "");
2168
2641
  if (argv.code !== code) requireCode(argv.code, code, [
2169
2642
  `─-- Deleting draft ───────────────────────────`,
2643
+ fromLine(self),
2170
2644
  ` id: ${argv.id}`,
2171
2645
  ...prevText.split("\n").map((l) => ` ${l}`),
2172
2646
  `--------------------------------────────────`,