slack-term 1.35.0 → 1.37.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
@@ -2,16 +2,16 @@
2
2
  // Slack CLI entry — mirrors the Rust impl in src/main.rs.
3
3
 
4
4
  import { createHash } from "node:crypto";
5
- import { existsSync, readFileSync, mkdirSync, writeFileSync } from "node:fs";
5
+ import { existsSync, readFileSync, mkdirSync, realpathSync, writeFileSync } from "node:fs";
6
6
  import { homedir } from "node:os";
7
- import { join } from "node:path";
7
+ import { join, resolve } from "node:path";
8
8
 
9
9
  import yargs, { type Options } from "yargs";
10
10
  import { hideBin } from "yargs/helpers";
11
11
  import { guardUrlBoundaries } from "./urlGuard.ts";
12
12
  import { listProfiles, removeProfile, resolveBotToken, resolveCookie, resolveToken, useProfile, type Profile } from "./profiles.ts";
13
13
  import { diagnoseBotMessaging, formatDiagnosis } from "./botdoctor.ts";
14
- import { cmdAuthLogin, cmdAuthChrome, cmdAuthFirefox, cmdAuthToken, cmdAuthApp } from "./auth.ts";
14
+ import { cmdAuthLogin, cmdAuthLoginChrome, cmdAuthChrome, cmdAuthFirefox, cmdAuthToken, cmdAuthApp, cmdAuthSave, cmdAuthTokens } from "./auth.ts";
15
15
  import { cmdTail } from "./tail.ts";
16
16
  import { agentCommands } from "./agent.ts";
17
17
 
@@ -42,6 +42,7 @@ import {
42
42
  pollTally,
43
43
  } from "./poll.ts";
44
44
  import { seedReactionsInOrder } from "./reactionSeed.ts";
45
+ import { attributionEnabled, attributionMetadata, captureAttribution, parseSince, querySent, recordSent, SENT_LOG_UNAVAILABLE, sentDbPath, type RecordSent, type SentKind, type SentRow } from "./sentlog.ts";
45
46
  import {
46
47
  authTest,
47
48
  authScopes,
@@ -75,6 +76,7 @@ import {
75
76
  search,
76
77
  searchAll,
77
78
  send as slackSend,
79
+ type MessageMetadata,
78
80
  getPermalink,
79
81
  scheduleMessage,
80
82
  listScheduledMessages,
@@ -463,8 +465,8 @@ async function cmdNews(token: string, limit: number): Promise<void> {
463
465
  }
464
466
 
465
467
  // --- channels ---
466
- async function cmdChannels(token: string, limit: number, filter?: string, all?: boolean, format = "text"): Promise<void> {
467
- const resp = (await listConversations(token)) as Record<string, Json>;
468
+ async function cmdChannels(token: string, limit: number, filter?: string, all?: boolean, format = "text", cookie?: string): Promise<void> {
469
+ const resp = (await listConversations(token, cookie)) as Record<string, Json>;
468
470
  const channels = asArray(resp.channels)
469
471
  .map(asRecord)
470
472
  .filter((c) => all || c.is_member === true)
@@ -1068,6 +1070,38 @@ function selfLookup(token: string, cookie?: string): () => Promise<Self | null>
1068
1070
  };
1069
1071
  }
1070
1072
 
1073
+ /** Who is sending, captured once per write: the Slack `metadata` to attach to
1074
+ * the post, and `record` to append it to the local log once the ts is known.
1075
+ * Both vanish under SLACK_TERM_ATTRIBUTION=off. */
1076
+ function sentAttribution(kind: SentKind): {
1077
+ metadata?: MessageMetadata;
1078
+ record: (r: Omit<RecordSent, "kind" | "attribution">) => void;
1079
+ } {
1080
+ if (!attributionEnabled()) return { record: () => {} };
1081
+ const attribution = captureAttribution();
1082
+ return {
1083
+ metadata: attributionMetadata(kind, attribution),
1084
+ record: (r) => { recordSent({ ...r, kind, attribution }); },
1085
+ };
1086
+ }
1087
+
1088
+ /** One `slack sent` row: when/what/where, then who — the sender block is the
1089
+ * point of the command, so it gets its own line rather than a trailing column. */
1090
+ function formatSentRow(r: SentRow): string {
1091
+ const first = stripTerminalControls(r.text.split("\n")[0] ?? "").slice(0, 100);
1092
+ const where = r.permalink || `${r.channel}:${r.ts}`;
1093
+ const who = [
1094
+ r.cli ?? "?",
1095
+ r.session_id ? `session=${r.session_id}` : "",
1096
+ r.agent_pid ? `pid=${r.agent_pid}` : `pid=${r.pid}`,
1097
+ r.git_branch ? `branch=${r.git_branch}` : "",
1098
+ `cwd=${r.cwd}`,
1099
+ ].filter(Boolean).join(" ");
1100
+ return `${formatYmdHm(r.sent_at / 1000)} ${r.kind.padEnd(4)} ${stripTerminalControls(r.target ?? r.channel)} ${where}\n` +
1101
+ ` ${first}\n` +
1102
+ ` ${stripTerminalControls(who)}`;
1103
+ }
1104
+
1071
1105
  /** Name the identity a write will be performed AS, for confirm gates:
1072
1106
  * "@snomiao (U0123ABC) — Acme". Slack shows only the author on the resulting
1073
1107
  * message, so a wrong profile / stale SLACK_TOKEN / unintended --as-bot is
@@ -1275,8 +1309,10 @@ async function cmdEdit(token: string, args: EditArgs): Promise<void> {
1275
1309
  ]);
1276
1310
  }
1277
1311
 
1278
- const newTs = await editMessage(token, channelId, ts, newText, args.cookie);
1312
+ const attr = sentAttribution("edit");
1313
+ const newTs = await editMessage(token, channelId, ts, newText, args.cookie, undefined, attr.metadata);
1279
1314
  console.log(`✓ Edited (ts: ${newTs})`);
1315
+ attr.record({ team: self?.team, channel: channelId, target: args.target, ts, text: newText, asBot: args.asBot });
1280
1316
  }
1281
1317
 
1282
1318
  // --- delete ---
@@ -1657,7 +1693,8 @@ async function cmdSend(token: string, args: SendArgs): Promise<void> {
1657
1693
  ], recipientTz);
1658
1694
  }
1659
1695
  }
1660
- const ts = await slackSend(token, channelId, message, threadTs, args.broadcast, cookie);
1696
+ const attr = sentAttribution("send");
1697
+ const ts = await slackSend(token, channelId, message, threadTs, args.broadcast, cookie, undefined, attr.metadata);
1661
1698
  let permalink = "";
1662
1699
  try {
1663
1700
  permalink = await getPermalink(token, channelId, ts, cookie);
@@ -1665,6 +1702,7 @@ async function cmdSend(token: string, args: SendArgs): Promise<void> {
1665
1702
  // fail-soft: getPermalink failure (rate limit, network, etc.) should not
1666
1703
  // mask send success — fall back to ts-only output below.
1667
1704
  }
1705
+ attr.record({ team: (await getSelf())?.team, channel: channelId, target: args.target, ts, threadTs, permalink, text: message, asBot: args.asBot });
1668
1706
  if (permalink) {
1669
1707
  console.log(`✓ Sent: ${permalink}`);
1670
1708
  } else {
@@ -1709,7 +1747,10 @@ async function cmdSend(token: string, args: SendArgs): Promise<void> {
1709
1747
  // 0 answered (the answer text is on stdout, and nothing else is)
1710
1748
  // 2 timed out (nobody replied)
1711
1749
  // 3 transport/config failure
1712
- // 4 replied, but chose none of the offered choices — stdout stays EMPTY
1750
+ // 4 replied, but the reply matches SEVERAL choices — stdout stays EMPTY
1751
+ // 5 replied in free text, picking none of the offered choices — the reply
1752
+ // text is on stdout. NOT a decision: the question is left open, so a pill
1753
+ // pressed later still settles it (`--waitFor … --after=<reply ts>`).
1713
1754
  // Everything human-facing goes to stderr, so `ANS=$(slack ask ... --wait)` is safe.
1714
1755
 
1715
1756
  const ASK_EXIT_TIMEOUT = 2;
@@ -1719,6 +1760,12 @@ const ASK_EXIT_ERROR = 3;
1719
1760
  * automated caller act on a decision nobody took, and exit 2 ("nobody
1720
1761
  * answered") would hide that a human is standing there waiting for something. */
1721
1762
  const ASK_EXIT_UNCHOSEN = 4;
1763
+ /** A free-text reply that picks none of the choices, DELIVERED: the text is on
1764
+ * stdout. Exit 4 with stdout empty used to be the answer here, and the
1765
+ * instruction the human typed never reached the agent — it kept waiting
1766
+ * (measured 2026-09-27: 8 of 71 asks answered this way, none collected). Still
1767
+ * not 0, so nothing mistakes it for a decision. */
1768
+ const ASK_EXIT_FREETEXT = 5;
1722
1769
 
1723
1770
  const ASK_PHI = 1.618033988749895;
1724
1771
  const ASK_POLL_MIN_MS = 1000;
@@ -1823,6 +1870,9 @@ interface AskWaitCtx {
1823
1870
  /** Only so the resume hint printed on timeout names the right identity — the
1824
1871
  * ✅ rewrite can be done by the original poster alone. */
1825
1872
  asBot: boolean;
1873
+ /** Text replies at or before this ts are ignored — the free-text reply a
1874
+ * previous exit 5 already delivered. Reactions still count. */
1875
+ after?: string;
1826
1876
  }
1827
1877
 
1828
1878
  /** Poll until answered. Never returns: exits 0 with the answer on stdout, or 2
@@ -1916,6 +1966,7 @@ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never>
1916
1966
  // Strictly after the question. A question posted INTO an existing thread
1917
1967
  // sits among replies that predate it, and those answered something else.
1918
1968
  if (!(Number(m.ts) > Number(ts))) continue;
1969
+ if (ctx.after && !(Number(m.ts) > Number(ctx.after))) continue;
1919
1970
  if (!isAnswerer(m.user)) continue;
1920
1971
  if (typeof m.subtype === "string" && !ASK_ANSWERABLE_SUBTYPES.has(m.subtype)) continue;
1921
1972
  if (Number(m.ts) >= before) break;
@@ -1934,8 +1985,14 @@ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never>
1934
1985
  // Replied, but picked nothing. Recorded rather than returned: a later
1935
1986
  // reply may still choose, and the first one is what the operator needs to
1936
1987
  // see — it is usually a question back.
1937
- if (!unchosen || Number(m.ts) < Number(unchosen.ts)) {
1938
- unchosen = { text: t, who: typeof m.user === "string" ? m.user : "", ts: String(m.ts), ambiguous: match.kind === "ambiguous" };
1988
+ // A plain free-text reply outranks an ambiguous one, whatever the order:
1989
+ // the free text is delivered (exit 5), and an earlier ambiguous attempt
1990
+ // must not hide it behind the refuse-to-guess path. Within a class, the
1991
+ // earliest wins.
1992
+ const ambiguous = match.kind === "ambiguous";
1993
+ if (!unchosen || (unchosen.ambiguous && !ambiguous) ||
1994
+ (unchosen.ambiguous === ambiguous && Number(m.ts) < Number(unchosen.ts))) {
1995
+ unchosen = { text: t, who: typeof m.user === "string" ? m.user : "", ts: String(m.ts), ambiguous };
1939
1996
  }
1940
1997
  }
1941
1998
  return null;
@@ -2065,6 +2122,25 @@ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never>
2065
2122
  console.log(found.answer);
2066
2123
  process.exit(0);
2067
2124
  }
2125
+ // A free-text reply that picks nothing is delivered NOW, not held until the
2126
+ // deadline: it is usually an instruction or a question back, and the only
2127
+ // one who can act on it is the caller. Left unstamped — a pill pressed
2128
+ // later is still the decision. An AMBIGUOUS reply did try to choose, so it
2129
+ // keeps the refuse-to-guess path below.
2130
+ if (unchosen && !unchosen.ambiguous) {
2131
+ let who = unchosen.who;
2132
+ try {
2133
+ if (who) who = await userName(token, who, cookie);
2134
+ } catch {
2135
+ // the id is still a usable answer to "who"
2136
+ }
2137
+ console.error(` (${stripTerminalControls(who)} が選択肢外の返信をしました — 決定ではありません。本文を stdout に出します)`);
2138
+ console.error(` 選択肢: ${candidates.map((c, i) => `${i + 1}. ${stripTerminalControls(askFlatten(c))}`).join(" ")}`);
2139
+ console.error(` ${shown}`);
2140
+ console.error(` この返信の後を待つ: ${askResumeCommand(shown, ctx.asBot, unchosen.ts)}`);
2141
+ console.log(unchosen.text);
2142
+ process.exit(ASK_EXIT_FREETEXT);
2143
+ }
2068
2144
  // Said as soon as it is seen, not only at the timeout: the reply is usually
2069
2145
  // a question BACK, and the person who can unblock it is the one watching
2070
2146
  // this command. Once per reply — a poller that repeated itself every tick
@@ -2077,11 +2153,8 @@ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never>
2077
2153
  } catch {
2078
2154
  // the id is still a usable answer to "who"
2079
2155
  }
2080
- console.error(
2081
- unchosen.ambiguous
2082
- ? ` (${stripTerminalControls(who)} の返信は複数の選択肢に一致します。1 つに絞ってもらうまで待ちます)`
2083
- : ` (${stripTerminalControls(who)} が返信しましたが、選択肢のどれでもありません。待機を続けます)`,
2084
- );
2156
+ // Only the ambiguous case gets here — a plain free-text reply exited above.
2157
+ console.error(` (${stripTerminalControls(who)} の返信は複数の選択肢に一致します。1 つに絞ってもらうまで待ちます)`);
2085
2158
  console.error(` 「${stripTerminalControls(unchosen.text)}」`);
2086
2159
  }
2087
2160
  // Checked after the first poll, never before it: `--timeout 0` means one
@@ -2101,15 +2174,12 @@ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never>
2101
2174
  // standing there. stdout stays empty in both: the caller must not be handed
2102
2175
  // something to act on.
2103
2176
  if (unchosen) {
2104
- console.error(
2105
- unchosen.ambiguous
2106
- ? `Error: 返信はありましたが、複数の選択肢に一致するため確定できません (メッセージはそのまま残っています)`
2107
- : `Error: 返信はありましたが、選択肢のどれも選ばれていません (メッセージはそのまま残っています)`,
2108
- );
2177
+ // Ambiguous only: a plain free-text reply was delivered as exit 5 already.
2178
+ console.error(`Error: 返信はありましたが、複数の選択肢に一致するため確定できません (メッセージはそのまま残っています)`);
2109
2179
  console.error(` 返信: 「${stripTerminalControls(unchosen.text)}」`);
2110
2180
  console.error(` 選択肢: ${candidates.map((c, i) => `${i + 1}. ${stripTerminalControls(askFlatten(c))}`).join(" ")}`);
2111
2181
  console.error(` ${shown}`);
2112
- console.error(` 返答してから回収する: ${askResumeCommand(shown, ctx.asBot)}`);
2182
+ console.error(` 返答してから回収する: ${askResumeCommand(shown, ctx.asBot, ctx.after)}`);
2113
2183
  process.exit(ASK_EXIT_UNCHOSEN);
2114
2184
  }
2115
2185
  if (timeout === 0) {
@@ -2117,7 +2187,7 @@ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never>
2117
2187
  } else {
2118
2188
  console.error(`Error: ${timeout}s 以内に回答がありませんでした (メッセージはそのまま残っています)`);
2119
2189
  console.error(` ${shown}`);
2120
- console.error(` あとで回収する: ${askResumeCommand(shown, ctx.asBot)}`);
2190
+ console.error(` あとで回収する: ${askResumeCommand(shown, ctx.asBot, ctx.after)}`);
2121
2191
  }
2122
2192
  process.exit(ASK_EXIT_TIMEOUT);
2123
2193
  }
@@ -2125,8 +2195,10 @@ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never>
2125
2195
  /** The `slack ask --waitFor=…` line handed to a caller that did not block.
2126
2196
  * Printed as a runnable command rather than a bare permalink: the collect step
2127
2197
  * is the half everyone forgets, and a link does not tell you how to collect. */
2128
- function askResumeCommand(shown: string, asBot: boolean): string {
2129
- return `slack ask --waitFor='${shown}'${asBot ? " --as-bot" : ""}`;
2198
+ /** `after` is carried into EVERY resume hint once set: dropping it would have
2199
+ * the next `--waitFor` re-deliver a free-text reply already acted on. */
2200
+ function askResumeCommand(shown: string, asBot: boolean, after?: string): string {
2201
+ return `slack ask --waitFor='${shown}'${asBot ? " --as-bot" : ""}${after ? ` --after=${after}` : ""}`;
2130
2202
  }
2131
2203
 
2132
2204
  async function cmdAsk(token: string, args: AskArgs): Promise<void> {
@@ -2283,7 +2355,8 @@ async function cmdAsk(token: string, args: AskArgs): Promise<void> {
2283
2355
  // `plain`: with blocks attached Slack rewrites the stored text (newlines to
2284
2356
  // spaces, emoji to `:one:`) and `--waitFor` can no longer read the question
2285
2357
  // back out of it — which is the entire recovery path.
2286
- const ts = await slackSend(token, channelId, message, threadTs, false, cookie, true);
2358
+ const attr = sentAttribution("ask");
2359
+ const ts = await slackSend(token, channelId, message, threadTs, false, cookie, true, attr.metadata);
2287
2360
 
2288
2361
  // The marker goes on FIRST so it sits left of the pills, and as a reaction so
2289
2362
  // `has::question:` lists every question the way `has::pushpin:` lists every
@@ -2318,6 +2391,7 @@ async function cmdAsk(token: string, args: AskArgs): Promise<void> {
2318
2391
  }
2319
2392
  const shown = permalink || `${channelId}:${ts}`;
2320
2393
  console.error(`✓ Asked: ${shown}`);
2394
+ attr.record({ team: self?.team, channel: channelId, target: args.target, ts, threadTs, permalink, text: message, asBot: args.asBot });
2321
2395
 
2322
2396
  if (!args.wait) {
2323
2397
  // Not waiting. stdout is the RESUME COMMAND, not just a link: nothing else
@@ -2362,7 +2436,7 @@ async function cmdAsk(token: string, args: AskArgs): Promise<void> {
2362
2436
  * file to go stale, lose, or disagree with Slack, and any machine holding the
2363
2437
  * permalink can collect — at the cost of the body being a parseable format
2364
2438
  * (`askBuildText` ⇄ `askParseMessage`). */
2365
- async function cmdAskWaitFor(token: string, args: { link: string; timeout: number; asBot: boolean; cookie?: string }): Promise<void> {
2439
+ async function cmdAskWaitFor(token: string, args: { link: string; timeout: number; asBot: boolean; cookie?: string; after?: string }): Promise<void> {
2366
2440
  const url = parseSlackPermalink(args.link);
2367
2441
  let channelId: string;
2368
2442
  let ts: string;
@@ -2464,6 +2538,7 @@ async function cmdAskWaitFor(token: string, args: { link: string; timeout: numbe
2464
2538
  };
2465
2539
  if (threadTs) ctx.threadParentTs = threadTs;
2466
2540
  if (args.cookie) ctx.cookie = args.cookie;
2541
+ if (args.after) ctx.after = args.after;
2467
2542
  await askWaitForAnswer(token, ctx);
2468
2543
  }
2469
2544
 
@@ -2606,7 +2681,8 @@ async function cmdPoll(token: string, args: PollArgs): Promise<void> {
2606
2681
 
2607
2682
  // `plain`: no blocks, or Slack rewrites the stored text and the ballot
2608
2683
  // stops parsing on the way back.
2609
- const ts = await slackSend(token, channelId, message, threadTs, false, cookie, true);
2684
+ const attr = sentAttribution("poll");
2685
+ const ts = await slackSend(token, channelId, message, threadTs, false, cookie, true, attr.metadata);
2610
2686
 
2611
2687
  // Marker first, for the same reason as `ask`: it is what makes
2612
2688
  // `has::ballot_box_with_ballot:` list every poll. An unrelated reaction on the message is
@@ -2634,6 +2710,7 @@ async function cmdPoll(token: string, args: PollArgs): Promise<void> {
2634
2710
  }
2635
2711
  const shown = permalink || `${channelId}:${ts}`;
2636
2712
  console.error(`✓ Posted: ${shown}`);
2713
+ attr.record({ team: self?.team, channel: channelId, target: args.target, ts, threadTs, permalink, text: message, asBot: args.asBot });
2637
2714
  console.log(pollResultsCommand(shown));
2638
2715
  console.error(` 締め切る: slack poll --close='${shown}'${args.asBot ? " --as-bot" : ""}`);
2639
2716
  }
@@ -3284,7 +3361,8 @@ async function main(): Promise<void> {
3284
3361
  .option("workspace", { alias: "w", type: "string", describe: "Workspace name" })
3285
3362
  .middleware(async (argv) => {
3286
3363
  const cmd = String((argv._ ?? [])[0] ?? "");
3287
- if (!cmd || cmd === "auth" || cmd === "login" || cmd === "agent") return;
3364
+ // `sent` reads only the local log — no token, no network.
3365
+ if (!cmd || cmd === "auth" || cmd === "login" || cmd === "agent" || cmd === "sent") return;
3288
3366
  try {
3289
3367
  resolveToken((argv as W).workspace);
3290
3368
  } catch (e) {
@@ -3342,7 +3420,7 @@ async function main(): Promise<void> {
3342
3420
  .option("format", { type: "string", choices: ["text", "jsonl"] as const, default: "text" })
3343
3421
  .option("json", { type: "boolean", default: false, describe: "Alias for --format=jsonl" }),
3344
3422
  async (argv) => {
3345
- await cmdChannels(tok(argv as W), argv.limit, argv.filter, argv.all, argv.json ? "jsonl" : argv.format);
3423
+ await cmdChannels(tok(argv as W), argv.limit, argv.filter, argv.all, argv.json ? "jsonl" : argv.format, ck(argv as W));
3346
3424
  },
3347
3425
  )
3348
3426
  .command(
@@ -3712,8 +3790,9 @@ async function main(): Promise<void> {
3712
3790
  .option("code", { type: "string", describe: "Safety hash to confirm the ask" })
3713
3791
  .option("allow-url-adjacent", { type: "boolean", default: false, describe: "Warn instead of refusing ambiguous bare URL boundaries" })
3714
3792
  .option("body", { type: "string", describe: "Extra context shown under the question" })
3715
- .option("wait", { type: "boolean", default: false, describe: "Block until answered; print ONLY the answer on stdout. Exit 0 = answered, 2 = nobody replied, 3 = transport failure, 4 = somebody replied but picked none of the choices (stdout empty — do not act on it)." })
3793
+ .option("wait", { type: "boolean", default: false, describe: "Block until answered; print ONLY the answer on stdout. Exit 0 = answered, 2 = nobody replied, 3 = transport failure, 4 = a reply matched several choices (stdout empty), 5 = a free-text reply that picked none of the choices (the reply is on stdout; NOT a decision — the question stays open)." })
3716
3794
  .option("waitFor", { type: "string", describe: "Collect the answer to a question already posted: pass its permalink. Nothing is posted. Same stdout/exit contract as --wait; --timeout 0 checks once and exits 2 if still open." })
3795
+ .option("after", { type: "string", describe: "With --waitFor: ignore text replies at or before this ts (the reply an exit 5 already delivered). Reactions still count." })
3717
3796
  .option("timeout", { type: "number", default: 3600, describe: "Overall limit for --wait / --waitFor, in seconds (0 with --waitFor = check once)" })
3718
3797
  .option("channel-id", { type: "string", describe: "Raw channel ID" })
3719
3798
  .option("user-id", { type: "string", describe: "Raw user ID (opens DM)" })
@@ -3752,8 +3831,16 @@ async function main(): Promise<void> {
3752
3831
  waitCookie = ck(argv as W);
3753
3832
  }
3754
3833
  try {
3755
- const a: { link: string; timeout: number; asBot: boolean; cookie?: string } = { link: waitFor, timeout, asBot: !!argv["as-bot"] };
3834
+ const a: { link: string; timeout: number; asBot: boolean; cookie?: string; after?: string } = { link: waitFor, timeout, asBot: !!argv["as-bot"] };
3756
3835
  if (waitCookie) a.cookie = waitCookie;
3836
+ if (argv.after) {
3837
+ const after = String(argv.after);
3838
+ if (!/^\d{10}\.\d{6}$/.test(after)) {
3839
+ console.error(`Error: --after needs a message ts like 1700000000.000100 (got ${stripTerminalControls(after)})`);
3840
+ process.exit(ASK_EXIT_ERROR);
3841
+ }
3842
+ a.after = after;
3843
+ }
3757
3844
  await cmdAskWaitFor(waitToken, a);
3758
3845
  } catch (e: unknown) {
3759
3846
  console.error(friendlySlackError(e));
@@ -3951,6 +4038,56 @@ async function main(): Promise<void> {
3951
4038
  }
3952
4039
  },
3953
4040
  )
4041
+ .command(
4042
+ "sent [query]",
4043
+ "Search what THIS machine posted/edited (send, ask, poll, edit) and who sent it — read-only, local log",
4044
+ (y) => y
4045
+ .positional("query", { type: "string", describe: "Substring of the message text (case-insensitive)" })
4046
+ .option("channel", { type: "string", describe: "Channel/DM id (C…/D…) or the target as typed (#eng, @bob)" })
4047
+ .option("since", { type: "string", describe: "Only newer than this: 30m, 2h, 7d, 1w, or an ISO date" })
4048
+ .option("session", { type: "string", describe: "Agent session id (prefix match)" })
4049
+ .option("cwd", { type: "string", describe: "Working directory the sender ran in (prefix match)" })
4050
+ .option("kind", { type: "string", choices: ["send", "ask", "poll", "edit"], describe: "Only this kind of write" })
4051
+ .option("json", { type: "boolean", default: false, describe: "One JSON object per line (every recorded field)" })
4052
+ .option("count", { alias: "n", type: "number", default: 50, describe: "Max rows (newest first)" }),
4053
+ (argv) => {
4054
+ let sinceMs: number | undefined;
4055
+ if (argv.since !== undefined) {
4056
+ sinceMs = parseSince(String(argv.since));
4057
+ if (sinceMs === undefined) {
4058
+ console.error(`Error: --since needs 30m / 2h / 7d / 1w or an ISO date (got ${stripTerminalControls(String(argv.since))})`);
4059
+ process.exit(1);
4060
+ }
4061
+ }
4062
+ const q: Parameters<typeof querySent>[0] = { limit: Number(argv.count) || 50 };
4063
+ if (argv.query) q.text = String(argv.query);
4064
+ if (argv.channel) q.channel = String(argv.channel);
4065
+ if (sinceMs !== undefined) q.sinceMs = sinceMs;
4066
+ if (argv.session) q.session = String(argv.session);
4067
+ if (argv.cwd) {
4068
+ // Resolved like the recorded cwd was (process.cwd() is a realpath), so
4069
+ // `--cwd .` works and a symlinked path (macOS /var → /private/var)
4070
+ // still matches. A path that no longer exists is used as typed.
4071
+ const raw = resolve(String(argv.cwd));
4072
+ try { q.cwd = realpathSync(raw); } catch { q.cwd = raw; }
4073
+ }
4074
+ if (argv.kind) q.kind = argv.kind as SentKind;
4075
+ const rows = querySent(q);
4076
+ if (!rows) {
4077
+ console.error(`Error: ${SENT_LOG_UNAVAILABLE}\n (${sentDbPath()})`);
4078
+ process.exit(1);
4079
+ }
4080
+ if (argv.json) {
4081
+ for (const r of rows) console.log(JSON.stringify(r));
4082
+ return;
4083
+ }
4084
+ if (!rows.length) {
4085
+ console.error(`No sent messages match (log: ${sentDbPath()}).`);
4086
+ return;
4087
+ }
4088
+ for (const r of rows) console.log(formatSentRow(r));
4089
+ },
4090
+ )
3954
4091
  .command(
3955
4092
  "doctor",
3956
4093
  "Check the bot token (xoxb) can carry a two-way DM (scopes + Messages Tab)",
@@ -4526,17 +4663,73 @@ async function main(): Promise<void> {
4526
4663
  )
4527
4664
  .command(
4528
4665
  "login",
4529
- "Interactive auth wizard (all auth methods: desktop app, token, new app)",
4666
+ "Log in: interactive wizard, or a source (chrome, firefox)",
4530
4667
  (y2) => y2
4668
+ .command(
4669
+ "chrome",
4670
+ "Import workspaces from Chrome, grouped by account (all platforms)",
4671
+ (y3) => y3
4672
+ .option("profile", { type: "string", alias: "p", describe: 'Chrome profile email or dir (e.g. "you@example.com" or "Profile 3")' }),
4673
+ async (argv) => {
4674
+ await cmdAuthLoginChrome({ ...(argv.profile !== undefined ? { profile: argv.profile } : {}) });
4675
+ },
4676
+ )
4677
+ .command(
4678
+ "firefox",
4679
+ "Import the xoxd cookie from Firefox into a workspace",
4680
+ (y3) => y3
4681
+ .option("workspace", { type: "string", alias: "w", describe: "Workspace name to update (default: active)" }),
4682
+ async (argv) => {
4683
+ await cmdAuthFirefox({ ...(argv.workspace !== undefined ? { workspace: argv.workspace } : {}) });
4684
+ },
4685
+ )
4531
4686
  .option("token", { type: "string", describe: "Token to save directly (non-interactive)" })
4532
- .option("name", { type: "string", describe: "Workspace name (used with --token)" }),
4687
+ .option("name", { type: "string", describe: "Workspace name (used with --token)" })
4688
+ .option("yes", { type: "boolean", default: false, describe: "Allow reading browser profiles without a confirmation prompt" })
4689
+ .option("from-desktop", { type: "boolean", default: false, describe: "Import the Slack Desktop token without scanning a browser" })
4690
+ .option("from-chrome", { type: "boolean", default: false, describe: "Import the desktop token and Chrome cookie" })
4691
+ .option("from-firefox", { type: "boolean", default: false, describe: "Import the desktop token and Firefox cookie" })
4692
+ .option("from-all", { type: "boolean", default: false, describe: "Import the desktop token and scan Chrome and Firefox" })
4693
+ .command("$0", false as unknown as string, () => {}, async (argv) => {
4694
+ await cmdAuthLogin({
4695
+ ...(argv.token !== undefined ? { token: argv.token as string } : {}),
4696
+ ...(argv.name !== undefined ? { name: argv.name as string } : {}),
4697
+ yes: argv.yes as boolean,
4698
+ fromDesktop: argv.fromDesktop as boolean,
4699
+ fromChrome: argv.fromChrome as boolean,
4700
+ fromFirefox: argv.fromFirefox as boolean,
4701
+ fromAll: argv.fromAll as boolean,
4702
+ });
4703
+ }),
4533
4704
  async (argv) => {
4534
4705
  await cmdAuthLogin({
4535
- ...(argv.token !== undefined ? { token: argv.token } : {}),
4536
- ...(argv.name !== undefined ? { name: argv.name } : {}),
4706
+ ...(argv.token !== undefined ? { token: argv.token as string } : {}),
4707
+ ...(argv.name !== undefined ? { name: argv.name as string } : {}),
4708
+ yes: argv.yes as boolean,
4709
+ fromDesktop: argv.fromDesktop as boolean,
4710
+ fromChrome: argv.fromChrome as boolean,
4711
+ fromFirefox: argv.fromFirefox as boolean,
4712
+ fromAll: argv.fromAll as boolean,
4537
4713
  });
4538
4714
  },
4539
4715
  )
4716
+ .command(
4717
+ "tokens",
4718
+ "Print the selected credentials as dotenv assignments",
4719
+ (y2) => y2.option("workspace", { type: "string", alias: "w", describe: "Workspace name (default: active)" }),
4720
+ (argv) => cmdAuthTokens({ ...(argv.workspace !== undefined ? { workspace: argv.workspace } : {}) }),
4721
+ )
4722
+ .command(
4723
+ "save",
4724
+ "Save the active profile token and cookie to an env file",
4725
+ (y2) => y2
4726
+ .option("envfile", { type: "string", demandOption: true, describe: "Destination env file (e.g. ./.env.local)" })
4727
+ .option("workspace", { type: "string", alias: "w", describe: "Workspace name (default: active)" }),
4728
+ (argv) => cmdAuthSave({
4729
+ envfile: argv.envfile!,
4730
+ ...(argv.workspace !== undefined ? { workspace: argv.workspace } : {}),
4731
+ }),
4732
+ )
4540
4733
  .command(["ls", "status"], "Show auth status", () => {}, () => {
4541
4734
  const profiles = listProfiles();
4542
4735
  if (profiles.length === 0) { console.log("No workspaces configured. Run: slack auth login"); return; }
@@ -4565,21 +4758,28 @@ async function main(): Promise<void> {
4565
4758
  },
4566
4759
  )
4567
4760
  .command(
4568
- ["chrome", "cookie"],
4569
- "Attach Chrome browser xoxd cookie to a workspace (macOS, interactive)",
4761
+ ["cookie", "chrome"],
4762
+ "Attach a Chrome xoxd cookie to a workspace (macOS/Linux/Windows). To import workspaces, use: slack auth login chrome",
4570
4763
  (y2) => y2
4571
- .option("workspace", { type: "string", alias: "w", describe: "Workspace name to update (default: active)" }),
4764
+ .option("workspace", { type: "string", alias: "w", describe: "Workspace name to update (default: active)" })
4765
+ .option("profile", { type: "string", alias: "p", describe: "Chrome profile email or dir (Windows, when several have a session)" })
4766
+ .option("yes", { type: "boolean", default: false, describe: "Allow reading browser profiles without a confirmation prompt" }),
4572
4767
  async (argv) => {
4573
- await cmdAuthChrome({ ...(argv.workspace !== undefined ? { workspace: argv.workspace } : {}) });
4768
+ await cmdAuthChrome({
4769
+ ...(argv.workspace !== undefined ? { workspace: argv.workspace } : {}),
4770
+ ...(argv.profile !== undefined ? { profile: argv.profile } : {}),
4771
+ yes: argv.yes as boolean,
4772
+ });
4574
4773
  },
4575
4774
  )
4576
4775
  .command(
4577
4776
  "firefox",
4578
4777
  "Attach Firefox browser xoxd cookie to a workspace (all platforms)",
4579
4778
  (y2) => y2
4580
- .option("workspace", { type: "string", alias: "w", describe: "Workspace name to update (default: active)" }),
4779
+ .option("workspace", { type: "string", alias: "w", describe: "Workspace name to update (default: active)" })
4780
+ .option("yes", { type: "boolean", default: false, describe: "Allow reading browser profiles without a confirmation prompt" }),
4581
4781
  async (argv) => {
4582
- await cmdAuthFirefox({ ...(argv.workspace !== undefined ? { workspace: argv.workspace } : {}) });
4782
+ await cmdAuthFirefox({ ...(argv.workspace !== undefined ? { workspace: argv.workspace } : {}), yes: argv.yes });
4583
4783
  },
4584
4784
  )
4585
4785
  .command("$0", false as unknown as string, () => {}, () => { y.showHelp(); process.exit(0); }),
@@ -4617,10 +4817,20 @@ async function main(): Promise<void> {
4617
4817
  )
4618
4818
  .command("login", false as unknown as string, (y2) => y2
4619
4819
  .option("token", { type: "string" })
4620
- .option("name", { type: "string" }), async (argv) => {
4820
+ .option("name", { type: "string" })
4821
+ .option("yes", { type: "boolean", default: false })
4822
+ .option("from-desktop", { type: "boolean", default: false })
4823
+ .option("from-chrome", { type: "boolean", default: false })
4824
+ .option("from-firefox", { type: "boolean", default: false })
4825
+ .option("from-all", { type: "boolean", default: false }), async (argv) => {
4621
4826
  await cmdAuthLogin({
4622
4827
  ...(argv.token !== undefined ? { token: argv.token } : {}),
4623
4828
  ...(argv.name !== undefined ? { name: argv.name } : {}),
4829
+ yes: argv.yes,
4830
+ fromDesktop: argv.fromDesktop,
4831
+ fromChrome: argv.fromChrome,
4832
+ fromFirefox: argv.fromFirefox,
4833
+ fromAll: argv.fromAll,
4624
4834
  });
4625
4835
  })
4626
4836
  .demandCommand(1, "Specify a command. Run with --help for usage.")