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/README.md +35 -3
- package/SKILL.md +27 -9
- package/dist/cli.js +184 -138
- package/package.json +1 -1
- package/ts/agent.ts +148 -0
- package/ts/ask.ts +14 -3
- package/ts/auth.ts +185 -16
- package/ts/cli.ts +216 -38
- package/ts/profiles.ts +11 -5
- package/ts/sentlog.ts +356 -0
- package/ts/slack-app.ts +180 -102
- package/ts/slack.ts +56 -5
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
|
|
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
|
|
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
|
|
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
|
-
|
|
1937
|
-
|
|
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
|
-
|
|
2080
|
-
|
|
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
|
-
|
|
2104
|
-
|
|
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
|
-
|
|
2128
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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" })
|
|
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
|
-
|
|
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
|
-
|
|
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 —
|
|
274
|
-
" slack auth
|
|
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
|
);
|