slack-term 1.34.0 → 1.36.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,17 +2,18 @@
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, cmdAuthChrome, cmdAuthFirefox, cmdAuthToken, cmdAuthApp, cmdAuthSave, cmdAuthTokens } from "./auth.ts";
15
15
  import { cmdTail } from "./tail.ts";
16
+ import { agentCommands } from "./agent.ts";
16
17
 
17
18
  import {
18
19
  ASK_KEYCAPS,
@@ -41,6 +42,7 @@ import {
41
42
  pollTally,
42
43
  } from "./poll.ts";
43
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";
44
46
  import {
45
47
  authTest,
46
48
  authScopes,
@@ -74,6 +76,7 @@ import {
74
76
  search,
75
77
  searchAll,
76
78
  send as slackSend,
79
+ type MessageMetadata,
77
80
  getPermalink,
78
81
  scheduleMessage,
79
82
  listScheduledMessages,
@@ -462,8 +465,8 @@ async function cmdNews(token: string, limit: number): Promise<void> {
462
465
  }
463
466
 
464
467
  // --- channels ---
465
- async function cmdChannels(token: string, limit: number, filter?: string, all?: boolean, format = "text"): Promise<void> {
466
- 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>;
467
470
  const channels = asArray(resp.channels)
468
471
  .map(asRecord)
469
472
  .filter((c) => all || c.is_member === true)
@@ -1067,6 +1070,38 @@ function selfLookup(token: string, cookie?: string): () => Promise<Self | null>
1067
1070
  };
1068
1071
  }
1069
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
+
1070
1105
  /** Name the identity a write will be performed AS, for confirm gates:
1071
1106
  * "@snomiao (U0123ABC) — Acme". Slack shows only the author on the resulting
1072
1107
  * message, so a wrong profile / stale SLACK_TOKEN / unintended --as-bot is
@@ -1274,8 +1309,10 @@ async function cmdEdit(token: string, args: EditArgs): Promise<void> {
1274
1309
  ]);
1275
1310
  }
1276
1311
 
1277
- 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);
1278
1314
  console.log(`✓ Edited (ts: ${newTs})`);
1315
+ attr.record({ team: self?.team, channel: channelId, target: args.target, ts, text: newText, asBot: args.asBot });
1279
1316
  }
1280
1317
 
1281
1318
  // --- delete ---
@@ -1656,7 +1693,8 @@ async function cmdSend(token: string, args: SendArgs): Promise<void> {
1656
1693
  ], recipientTz);
1657
1694
  }
1658
1695
  }
1659
- 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);
1660
1698
  let permalink = "";
1661
1699
  try {
1662
1700
  permalink = await getPermalink(token, channelId, ts, cookie);
@@ -1664,6 +1702,7 @@ async function cmdSend(token: string, args: SendArgs): Promise<void> {
1664
1702
  // fail-soft: getPermalink failure (rate limit, network, etc.) should not
1665
1703
  // mask send success — fall back to ts-only output below.
1666
1704
  }
1705
+ attr.record({ team: (await getSelf())?.team, channel: channelId, target: args.target, ts, threadTs, permalink, text: message, asBot: args.asBot });
1667
1706
  if (permalink) {
1668
1707
  console.log(`✓ Sent: ${permalink}`);
1669
1708
  } else {
@@ -1708,7 +1747,10 @@ async function cmdSend(token: string, args: SendArgs): Promise<void> {
1708
1747
  // 0 answered (the answer text is on stdout, and nothing else is)
1709
1748
  // 2 timed out (nobody replied)
1710
1749
  // 3 transport/config failure
1711
- // 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>`).
1712
1754
  // Everything human-facing goes to stderr, so `ANS=$(slack ask ... --wait)` is safe.
1713
1755
 
1714
1756
  const ASK_EXIT_TIMEOUT = 2;
@@ -1718,6 +1760,12 @@ const ASK_EXIT_ERROR = 3;
1718
1760
  * automated caller act on a decision nobody took, and exit 2 ("nobody
1719
1761
  * answered") would hide that a human is standing there waiting for something. */
1720
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;
1721
1769
 
1722
1770
  const ASK_PHI = 1.618033988749895;
1723
1771
  const ASK_POLL_MIN_MS = 1000;
@@ -1822,6 +1870,9 @@ interface AskWaitCtx {
1822
1870
  /** Only so the resume hint printed on timeout names the right identity — the
1823
1871
  * ✅ rewrite can be done by the original poster alone. */
1824
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;
1825
1876
  }
1826
1877
 
1827
1878
  /** Poll until answered. Never returns: exits 0 with the answer on stdout, or 2
@@ -1915,6 +1966,7 @@ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never>
1915
1966
  // Strictly after the question. A question posted INTO an existing thread
1916
1967
  // sits among replies that predate it, and those answered something else.
1917
1968
  if (!(Number(m.ts) > Number(ts))) continue;
1969
+ if (ctx.after && !(Number(m.ts) > Number(ctx.after))) continue;
1918
1970
  if (!isAnswerer(m.user)) continue;
1919
1971
  if (typeof m.subtype === "string" && !ASK_ANSWERABLE_SUBTYPES.has(m.subtype)) continue;
1920
1972
  if (Number(m.ts) >= before) break;
@@ -1933,8 +1985,14 @@ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never>
1933
1985
  // Replied, but picked nothing. Recorded rather than returned: a later
1934
1986
  // reply may still choose, and the first one is what the operator needs to
1935
1987
  // see — it is usually a question back.
1936
- if (!unchosen || Number(m.ts) < Number(unchosen.ts)) {
1937
- 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 };
1938
1996
  }
1939
1997
  }
1940
1998
  return null;
@@ -2064,6 +2122,25 @@ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never>
2064
2122
  console.log(found.answer);
2065
2123
  process.exit(0);
2066
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
+ }
2067
2144
  // Said as soon as it is seen, not only at the timeout: the reply is usually
2068
2145
  // a question BACK, and the person who can unblock it is the one watching
2069
2146
  // this command. Once per reply — a poller that repeated itself every tick
@@ -2076,11 +2153,8 @@ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never>
2076
2153
  } catch {
2077
2154
  // the id is still a usable answer to "who"
2078
2155
  }
2079
- console.error(
2080
- unchosen.ambiguous
2081
- ? ` (${stripTerminalControls(who)} の返信は複数の選択肢に一致します。1 つに絞ってもらうまで待ちます)`
2082
- : ` (${stripTerminalControls(who)} が返信しましたが、選択肢のどれでもありません。待機を続けます)`,
2083
- );
2156
+ // Only the ambiguous case gets here — a plain free-text reply exited above.
2157
+ console.error(` (${stripTerminalControls(who)} の返信は複数の選択肢に一致します。1 つに絞ってもらうまで待ちます)`);
2084
2158
  console.error(` 「${stripTerminalControls(unchosen.text)}」`);
2085
2159
  }
2086
2160
  // Checked after the first poll, never before it: `--timeout 0` means one
@@ -2100,15 +2174,12 @@ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never>
2100
2174
  // standing there. stdout stays empty in both: the caller must not be handed
2101
2175
  // something to act on.
2102
2176
  if (unchosen) {
2103
- console.error(
2104
- unchosen.ambiguous
2105
- ? `Error: 返信はありましたが、複数の選択肢に一致するため確定できません (メッセージはそのまま残っています)`
2106
- : `Error: 返信はありましたが、選択肢のどれも選ばれていません (メッセージはそのまま残っています)`,
2107
- );
2177
+ // Ambiguous only: a plain free-text reply was delivered as exit 5 already.
2178
+ console.error(`Error: 返信はありましたが、複数の選択肢に一致するため確定できません (メッセージはそのまま残っています)`);
2108
2179
  console.error(` 返信: 「${stripTerminalControls(unchosen.text)}」`);
2109
2180
  console.error(` 選択肢: ${candidates.map((c, i) => `${i + 1}. ${stripTerminalControls(askFlatten(c))}`).join(" ")}`);
2110
2181
  console.error(` ${shown}`);
2111
- console.error(` 返答してから回収する: ${askResumeCommand(shown, ctx.asBot)}`);
2182
+ console.error(` 返答してから回収する: ${askResumeCommand(shown, ctx.asBot, ctx.after)}`);
2112
2183
  process.exit(ASK_EXIT_UNCHOSEN);
2113
2184
  }
2114
2185
  if (timeout === 0) {
@@ -2116,7 +2187,7 @@ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never>
2116
2187
  } else {
2117
2188
  console.error(`Error: ${timeout}s 以内に回答がありませんでした (メッセージはそのまま残っています)`);
2118
2189
  console.error(` ${shown}`);
2119
- console.error(` あとで回収する: ${askResumeCommand(shown, ctx.asBot)}`);
2190
+ console.error(` あとで回収する: ${askResumeCommand(shown, ctx.asBot, ctx.after)}`);
2120
2191
  }
2121
2192
  process.exit(ASK_EXIT_TIMEOUT);
2122
2193
  }
@@ -2124,8 +2195,10 @@ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never>
2124
2195
  /** The `slack ask --waitFor=…` line handed to a caller that did not block.
2125
2196
  * Printed as a runnable command rather than a bare permalink: the collect step
2126
2197
  * is the half everyone forgets, and a link does not tell you how to collect. */
2127
- function askResumeCommand(shown: string, asBot: boolean): string {
2128
- 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}` : ""}`;
2129
2202
  }
2130
2203
 
2131
2204
  async function cmdAsk(token: string, args: AskArgs): Promise<void> {
@@ -2282,7 +2355,8 @@ async function cmdAsk(token: string, args: AskArgs): Promise<void> {
2282
2355
  // `plain`: with blocks attached Slack rewrites the stored text (newlines to
2283
2356
  // spaces, emoji to `:one:`) and `--waitFor` can no longer read the question
2284
2357
  // back out of it — which is the entire recovery path.
2285
- 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);
2286
2360
 
2287
2361
  // The marker goes on FIRST so it sits left of the pills, and as a reaction so
2288
2362
  // `has::question:` lists every question the way `has::pushpin:` lists every
@@ -2317,6 +2391,7 @@ async function cmdAsk(token: string, args: AskArgs): Promise<void> {
2317
2391
  }
2318
2392
  const shown = permalink || `${channelId}:${ts}`;
2319
2393
  console.error(`✓ Asked: ${shown}`);
2394
+ attr.record({ team: self?.team, channel: channelId, target: args.target, ts, threadTs, permalink, text: message, asBot: args.asBot });
2320
2395
 
2321
2396
  if (!args.wait) {
2322
2397
  // Not waiting. stdout is the RESUME COMMAND, not just a link: nothing else
@@ -2361,7 +2436,7 @@ async function cmdAsk(token: string, args: AskArgs): Promise<void> {
2361
2436
  * file to go stale, lose, or disagree with Slack, and any machine holding the
2362
2437
  * permalink can collect — at the cost of the body being a parseable format
2363
2438
  * (`askBuildText` ⇄ `askParseMessage`). */
2364
- 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> {
2365
2440
  const url = parseSlackPermalink(args.link);
2366
2441
  let channelId: string;
2367
2442
  let ts: string;
@@ -2463,6 +2538,7 @@ async function cmdAskWaitFor(token: string, args: { link: string; timeout: numbe
2463
2538
  };
2464
2539
  if (threadTs) ctx.threadParentTs = threadTs;
2465
2540
  if (args.cookie) ctx.cookie = args.cookie;
2541
+ if (args.after) ctx.after = args.after;
2466
2542
  await askWaitForAnswer(token, ctx);
2467
2543
  }
2468
2544
 
@@ -2605,7 +2681,8 @@ async function cmdPoll(token: string, args: PollArgs): Promise<void> {
2605
2681
 
2606
2682
  // `plain`: no blocks, or Slack rewrites the stored text and the ballot
2607
2683
  // stops parsing on the way back.
2608
- 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);
2609
2686
 
2610
2687
  // Marker first, for the same reason as `ask`: it is what makes
2611
2688
  // `has::ballot_box_with_ballot:` list every poll. An unrelated reaction on the message is
@@ -2633,6 +2710,7 @@ async function cmdPoll(token: string, args: PollArgs): Promise<void> {
2633
2710
  }
2634
2711
  const shown = permalink || `${channelId}:${ts}`;
2635
2712
  console.error(`✓ Posted: ${shown}`);
2713
+ attr.record({ team: self?.team, channel: channelId, target: args.target, ts, threadTs, permalink, text: message, asBot: args.asBot });
2636
2714
  console.log(pollResultsCommand(shown));
2637
2715
  console.error(` 締め切る: slack poll --close='${shown}'${args.asBot ? " --as-bot" : ""}`);
2638
2716
  }
@@ -3283,7 +3361,8 @@ async function main(): Promise<void> {
3283
3361
  .option("workspace", { alias: "w", type: "string", describe: "Workspace name" })
3284
3362
  .middleware(async (argv) => {
3285
3363
  const cmd = String((argv._ ?? [])[0] ?? "");
3286
- if (!cmd || cmd === "auth" || cmd === "login") return;
3364
+ // `sent` reads only the local log — no token, no network.
3365
+ if (!cmd || cmd === "auth" || cmd === "login" || cmd === "agent" || cmd === "sent") return;
3287
3366
  try {
3288
3367
  resolveToken((argv as W).workspace);
3289
3368
  } catch (e) {
@@ -3296,6 +3375,7 @@ async function main(): Promise<void> {
3296
3375
  throw e;
3297
3376
  }
3298
3377
  }, true)
3378
+ .command("agent", "Bot agent session status and command heartbeat", (y) => agentCommands(y, parseTargetThread))
3299
3379
  .command(
3300
3380
  ["read [target]", "msgs [target]"],
3301
3381
  "Browse messages",
@@ -3340,7 +3420,7 @@ async function main(): Promise<void> {
3340
3420
  .option("format", { type: "string", choices: ["text", "jsonl"] as const, default: "text" })
3341
3421
  .option("json", { type: "boolean", default: false, describe: "Alias for --format=jsonl" }),
3342
3422
  async (argv) => {
3343
- 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));
3344
3424
  },
3345
3425
  )
3346
3426
  .command(
@@ -3710,8 +3790,9 @@ async function main(): Promise<void> {
3710
3790
  .option("code", { type: "string", describe: "Safety hash to confirm the ask" })
3711
3791
  .option("allow-url-adjacent", { type: "boolean", default: false, describe: "Warn instead of refusing ambiguous bare URL boundaries" })
3712
3792
  .option("body", { type: "string", describe: "Extra context shown under the question" })
3713
- .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)." })
3714
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." })
3715
3796
  .option("timeout", { type: "number", default: 3600, describe: "Overall limit for --wait / --waitFor, in seconds (0 with --waitFor = check once)" })
3716
3797
  .option("channel-id", { type: "string", describe: "Raw channel ID" })
3717
3798
  .option("user-id", { type: "string", describe: "Raw user ID (opens DM)" })
@@ -3750,8 +3831,16 @@ async function main(): Promise<void> {
3750
3831
  waitCookie = ck(argv as W);
3751
3832
  }
3752
3833
  try {
3753
- 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"] };
3754
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
+ }
3755
3844
  await cmdAskWaitFor(waitToken, a);
3756
3845
  } catch (e: unknown) {
3757
3846
  console.error(friendlySlackError(e));
@@ -3949,6 +4038,56 @@ async function main(): Promise<void> {
3949
4038
  }
3950
4039
  },
3951
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
+ )
3952
4091
  .command(
3953
4092
  "doctor",
3954
4093
  "Check the bot token (xoxb) can carry a two-way DM (scopes + Messages Tab)",
@@ -4527,14 +4666,41 @@ async function main(): Promise<void> {
4527
4666
  "Interactive auth wizard (all auth methods: desktop app, token, new app)",
4528
4667
  (y2) => y2
4529
4668
  .option("token", { type: "string", describe: "Token to save directly (non-interactive)" })
4530
- .option("name", { type: "string", describe: "Workspace name (used with --token)" }),
4669
+ .option("name", { type: "string", describe: "Workspace name (used with --token)" })
4670
+ .option("yes", { type: "boolean", default: false, describe: "Allow reading browser profiles without a confirmation prompt" })
4671
+ .option("from-desktop", { type: "boolean", default: false, describe: "Import the Slack Desktop token without scanning a browser" })
4672
+ .option("from-chrome", { type: "boolean", default: false, describe: "Import the desktop token and Chrome cookie" })
4673
+ .option("from-firefox", { type: "boolean", default: false, describe: "Import the desktop token and Firefox cookie" })
4674
+ .option("from-all", { type: "boolean", default: false, describe: "Import the desktop token and scan Chrome and Firefox" }),
4531
4675
  async (argv) => {
4532
4676
  await cmdAuthLogin({
4533
4677
  ...(argv.token !== undefined ? { token: argv.token } : {}),
4534
4678
  ...(argv.name !== undefined ? { name: argv.name } : {}),
4679
+ yes: argv.yes,
4680
+ fromDesktop: argv.fromDesktop,
4681
+ fromChrome: argv.fromChrome,
4682
+ fromFirefox: argv.fromFirefox,
4683
+ fromAll: argv.fromAll,
4535
4684
  });
4536
4685
  },
4537
4686
  )
4687
+ .command(
4688
+ "tokens",
4689
+ "Print the selected credentials as dotenv assignments",
4690
+ (y2) => y2.option("workspace", { type: "string", alias: "w", describe: "Workspace name (default: active)" }),
4691
+ (argv) => cmdAuthTokens({ ...(argv.workspace !== undefined ? { workspace: argv.workspace } : {}) }),
4692
+ )
4693
+ .command(
4694
+ "save",
4695
+ "Save the active profile token and cookie to an env file",
4696
+ (y2) => y2
4697
+ .option("envfile", { type: "string", demandOption: true, describe: "Destination env file (e.g. ./.env.local)" })
4698
+ .option("workspace", { type: "string", alias: "w", describe: "Workspace name (default: active)" }),
4699
+ (argv) => cmdAuthSave({
4700
+ envfile: argv.envfile!,
4701
+ ...(argv.workspace !== undefined ? { workspace: argv.workspace } : {}),
4702
+ }),
4703
+ )
4538
4704
  .command(["ls", "status"], "Show auth status", () => {}, () => {
4539
4705
  const profiles = listProfiles();
4540
4706
  if (profiles.length === 0) { console.log("No workspaces configured. Run: slack auth login"); return; }
@@ -4564,20 +4730,22 @@ async function main(): Promise<void> {
4564
4730
  )
4565
4731
  .command(
4566
4732
  ["chrome", "cookie"],
4567
- "Attach Chrome browser xoxd cookie to a workspace (macOS, interactive)",
4733
+ "Attach Chrome browser xoxd cookie to a workspace (macOS/Linux, interactive)",
4568
4734
  (y2) => y2
4569
- .option("workspace", { type: "string", alias: "w", describe: "Workspace name to update (default: active)" }),
4735
+ .option("workspace", { type: "string", alias: "w", describe: "Workspace name to update (default: active)" })
4736
+ .option("yes", { type: "boolean", default: false, describe: "Allow reading browser profiles without a confirmation prompt" }),
4570
4737
  async (argv) => {
4571
- await cmdAuthChrome({ ...(argv.workspace !== undefined ? { workspace: argv.workspace } : {}) });
4738
+ await cmdAuthChrome({ ...(argv.workspace !== undefined ? { workspace: argv.workspace } : {}), yes: argv.yes });
4572
4739
  },
4573
4740
  )
4574
4741
  .command(
4575
4742
  "firefox",
4576
4743
  "Attach Firefox browser xoxd cookie to a workspace (all platforms)",
4577
4744
  (y2) => y2
4578
- .option("workspace", { type: "string", alias: "w", describe: "Workspace name to update (default: active)" }),
4745
+ .option("workspace", { type: "string", alias: "w", describe: "Workspace name to update (default: active)" })
4746
+ .option("yes", { type: "boolean", default: false, describe: "Allow reading browser profiles without a confirmation prompt" }),
4579
4747
  async (argv) => {
4580
- await cmdAuthFirefox({ ...(argv.workspace !== undefined ? { workspace: argv.workspace } : {}) });
4748
+ await cmdAuthFirefox({ ...(argv.workspace !== undefined ? { workspace: argv.workspace } : {}), yes: argv.yes });
4581
4749
  },
4582
4750
  )
4583
4751
  .command("$0", false as unknown as string, () => {}, () => { y.showHelp(); process.exit(0); }),
@@ -4615,10 +4783,20 @@ async function main(): Promise<void> {
4615
4783
  )
4616
4784
  .command("login", false as unknown as string, (y2) => y2
4617
4785
  .option("token", { type: "string" })
4618
- .option("name", { type: "string" }), async (argv) => {
4786
+ .option("name", { type: "string" })
4787
+ .option("yes", { type: "boolean", default: false })
4788
+ .option("from-desktop", { type: "boolean", default: false })
4789
+ .option("from-chrome", { type: "boolean", default: false })
4790
+ .option("from-firefox", { type: "boolean", default: false })
4791
+ .option("from-all", { type: "boolean", default: false }), async (argv) => {
4619
4792
  await cmdAuthLogin({
4620
4793
  ...(argv.token !== undefined ? { token: argv.token } : {}),
4621
4794
  ...(argv.name !== undefined ? { name: argv.name } : {}),
4795
+ yes: argv.yes,
4796
+ fromDesktop: argv.fromDesktop,
4797
+ fromChrome: argv.fromChrome,
4798
+ fromFirefox: argv.fromFirefox,
4799
+ fromAll: argv.fromAll,
4622
4800
  });
4623
4801
  })
4624
4802
  .demandCommand(1, "Specify a command. Run with --help for usage.")
package/ts/profiles.ts CHANGED
@@ -20,7 +20,7 @@
20
20
  // Note: lockfiles and profiles.json use .slack-cli/ (older name); new per-dir token storage
21
21
  // uses .slack-term/ (current project name). Both coexist intentionally.
22
22
 
23
- import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
23
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, chmodSync } from "node:fs";
24
24
  import { homedir } from "node:os";
25
25
  import { join, dirname } from "node:path";
26
26
 
@@ -62,7 +62,9 @@ function load(): ProfileStore {
62
62
  function save(store: ProfileStore): void {
63
63
  const path = profilesPath();
64
64
  mkdirSync(dirname(path), { recursive: true });
65
- writeFileSync(path, JSON.stringify(store, null, 2) + "\n");
65
+ if (process.platform !== "win32" && existsSync(path)) chmodSync(path, 0o600);
66
+ writeFileSync(path, JSON.stringify(store, null, 2) + "\n", { mode: 0o600 });
67
+ if (process.platform !== "win32") chmodSync(path, 0o600);
66
68
  }
67
69
 
68
70
  function readLockfile(path: string): string | undefined {
@@ -150,7 +152,9 @@ export function saveToEnvFile(filePath: string, updates: Record<string, string>)
150
152
  lines.push(newLine);
151
153
  }
152
154
  }
153
- writeFileSync(filePath, lines.join("\n").trimEnd() + "\n");
155
+ if (process.platform !== "win32" && existsSync(filePath)) chmodSync(filePath, 0o600);
156
+ writeFileSync(filePath, lines.join("\n").trimEnd() + "\n", { mode: 0o600 });
157
+ if (process.platform !== "win32") chmodSync(filePath, 0o600);
154
158
  }
155
159
 
156
160
  export function listProfiles(): { name: string; profile: Profile; current: boolean }[] {
@@ -270,8 +274,10 @@ export function resolveToken(workspaceFlag?: string): string {
270
274
  throw new Error(
271
275
  "No Slack token found.\n" +
272
276
  " Run one of:\n" +
273
- " slack auth login — paste an existing xoxp-/xoxb- token\n" +
274
- " slack auth chrome — import from Chrome browser (macOS)\n" +
277
+ " slack auth login — interactive wizard (desktop session, token, or new app)\n" +
278
+ " slack auth token — print the active token, or save one with --token\n" +
279
+ " slack auth chrome — import the xoxd cookie from Chrome (macOS/Linux)\n" +
280
+ " slack auth firefox — import the xoxd cookie from Firefox (all platforms)\n" +
275
281
  " slack auth app — guided Slack app creation\n" +
276
282
  " Or set SLACK_TOKEN=xoxp-... in .slack-term/.env.local",
277
283
  );