slack-term 1.37.0 → 1.38.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 +53 -0
- package/SKILL.md +17 -0
- package/dist/cli.js +178 -152
- package/package.json +1 -1
- package/ts/cli.ts +686 -1
- package/ts/escapes.ts +11 -0
- package/ts/pinlog.ts +215 -0
- package/ts/profiles.ts +8 -2
- package/ts/slack.ts +22 -0
package/ts/escapes.ts
CHANGED
|
@@ -46,3 +46,14 @@ export function unescapeArg(s: string): string {
|
|
|
46
46
|
export function hasEscapes(s: string): boolean {
|
|
47
47
|
return unescapeArg(s) !== s;
|
|
48
48
|
}
|
|
49
|
+
|
|
50
|
+
/** The inverse of `unescapeArg`, for printing a command that will be run again:
|
|
51
|
+
* `unescapeArg(escapeArg(x)) === x` for every `x`. Each lead is doubled first
|
|
52
|
+
* (so a literal `\\n` survives), then real newlines/tabs become `\\n`/`\\t` so
|
|
53
|
+
* the printed command stays on one line. */
|
|
54
|
+
export function escapeArg(s: string): string {
|
|
55
|
+
return s
|
|
56
|
+
.replace(new RegExp(`[${ESCAPE_LEAD}]`, "g"), (c) => `\\${c}`)
|
|
57
|
+
.replace(/\n/g, "\\n")
|
|
58
|
+
.replace(/\t/g, "\\t");
|
|
59
|
+
}
|
package/ts/pinlog.ts
ADDED
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
// Pinlog — a pinned status board in a Slack channel.
|
|
2
|
+
//
|
|
3
|
+
// - The pinned TOP-LEVEL message is the current state (HEAD). It is edited in
|
|
4
|
+
// place, so it is always the whole truth and never needs scrolling.
|
|
5
|
+
// - Every change ALSO gets one short thread reply under it: the log.
|
|
6
|
+
// - Edits are silent; replies notify. So people are pinged once per real
|
|
7
|
+
// change, and the board itself never adds noise.
|
|
8
|
+
//
|
|
9
|
+
// This module is the part that needs no Slack: the footer marker that makes a
|
|
10
|
+
// message recognisable as a pinlog HEAD, and the JST stamp in it. Stateless by
|
|
11
|
+
// design: a board IS its message — addressed by permalink / C…:ts, found by
|
|
12
|
+
// its footer — so nothing about boards is kept on this machine. The commands themselves live in cli.ts beside send/edit,
|
|
13
|
+
// because they share its confirm gate.
|
|
14
|
+
|
|
15
|
+
import { closeSync, mkdirSync, openSync, readFileSync, statSync, unlinkSync, writeFileSync } from "node:fs";
|
|
16
|
+
import { homedir } from "node:os";
|
|
17
|
+
import { join } from "node:path";
|
|
18
|
+
|
|
19
|
+
/** The words every HEAD ends with. `list` finds boards by this, and `update`
|
|
20
|
+
* refuses a message without it — editing an arbitrary message into a "board"
|
|
21
|
+
* by passing the wrong ts is the mistake this guards against. */
|
|
22
|
+
export const PINLOG_MARKER = "Pinlog · 最終更新";
|
|
23
|
+
|
|
24
|
+
/** `2026-10-08 15:10 JST`. Always JST: the board is read by one team in Japan,
|
|
25
|
+
* and a stamp whose zone depends on whichever machine last updated it would
|
|
26
|
+
* make two consecutive updates disagree about what time it is. */
|
|
27
|
+
export function formatJst(now: Date): string {
|
|
28
|
+
const parts = new Intl.DateTimeFormat("en-GB", {
|
|
29
|
+
timeZone: "Asia/Tokyo",
|
|
30
|
+
hour12: false,
|
|
31
|
+
year: "numeric",
|
|
32
|
+
month: "2-digit",
|
|
33
|
+
day: "2-digit",
|
|
34
|
+
hour: "2-digit",
|
|
35
|
+
minute: "2-digit",
|
|
36
|
+
}).formatToParts(now);
|
|
37
|
+
const p = (t: string): string => parts.find((x) => x.type === t)?.value ?? "??";
|
|
38
|
+
// en-GB renders midnight as "24" in some engines; normalise to 00.
|
|
39
|
+
const hour = p("hour") === "24" ? "00" : p("hour");
|
|
40
|
+
return `${p("year")}-${p("month")}-${p("day")} ${hour}:${p("minute")} JST`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function pinlogFooter(now: Date): string {
|
|
44
|
+
return `_${PINLOG_MARKER} ${formatJst(now)} · 更新はスレッドに_`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// The footer is the whole LAST line, exactly as `pinlogFooter` writes it — the
|
|
48
|
+
// underscores optional, since a human hand-editing the board in the Slack
|
|
49
|
+
// client may drop them. Anything looser (no line start, no timestamp) matches
|
|
50
|
+
// ordinary text that merely mentions the marker, and `stripPinlogFooter` would
|
|
51
|
+
// then cut that text off mid-sentence.
|
|
52
|
+
const FOOTER_RE = new RegExp(
|
|
53
|
+
`(^|\\n+)_?${PINLOG_MARKER} \\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2} JST · 更新はスレッドに_?[ \\t]*\\n*$`,
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
/** True when `text` is a pinlog HEAD (carries the footer marker). */
|
|
57
|
+
export function isPinlogHead(text: string): boolean {
|
|
58
|
+
return FOOTER_RE.test(text);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** The state without its footer — what the owner wrote. */
|
|
62
|
+
export function stripPinlogFooter(text: string): string {
|
|
63
|
+
return text.replace(FOOTER_RE, "");
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** The full HEAD text: the state, a blank line, the footer. A state that
|
|
67
|
+
* already carries a footer (copied from `show`) has it replaced, not doubled. */
|
|
68
|
+
export function composeHead(state: string, now: Date): string {
|
|
69
|
+
return `${stripPinlogFooter(state).trimEnd()}\n\n${pinlogFooter(now)}`;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** The "last updated" stamp in a HEAD's (trailing) footer, or null when there is
|
|
73
|
+
* none — not the first stamp anywhere: a state may quote an older footer. */
|
|
74
|
+
export function headUpdatedAt(text: string): string | null {
|
|
75
|
+
const footer = text.match(FOOTER_RE)?.[0];
|
|
76
|
+
const m = footer?.match(/(\d{4}-\d{2}-\d{2} \d{2}:\d{2} JST)/);
|
|
77
|
+
return m ? m[1]! : null;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// --- lock: one writer per board on this machine -------------------------------
|
|
81
|
+
|
|
82
|
+
export class LockBusyError extends Error {
|
|
83
|
+
constructor(public holder: string) {
|
|
84
|
+
super(`locked by ${holder}`);
|
|
85
|
+
this.name = "LockBusyError";
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function lockDir(): string {
|
|
90
|
+
return join(process.env.HOME || homedir(), ".config", "slack-cli", "locks");
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Is the process that wrote a lock still running? Unknown → assume yes. */
|
|
94
|
+
function holderAlive(content: string): boolean {
|
|
95
|
+
const m = content.match(/^pid=(\d+)/);
|
|
96
|
+
if (!m) return true;
|
|
97
|
+
try {
|
|
98
|
+
process.kill(Number(m[1]), 0);
|
|
99
|
+
return true;
|
|
100
|
+
} catch (e: unknown) {
|
|
101
|
+
return (e as NodeJS.ErrnoException).code === "EPERM"; // exists, not ours
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Remove `path` only if it still holds `deadContent`. Read-compare-unlink is not
|
|
106
|
+
* atomic, so two breakers could both judge the same dead lock, and the slower
|
|
107
|
+
* one's unlink would then delete the lock the faster one just took. Every
|
|
108
|
+
* non-owner unlink therefore happens under a second O_EXCL file (`.break`):
|
|
109
|
+
* the re-read and the unlink are serialised, and a breaker that comes second
|
|
110
|
+
* re-reads the NEW owner's content and leaves it alone. (Owners unlink their
|
|
111
|
+
* own lock without it: nobody breaks a live owner's lock.)
|
|
112
|
+
*
|
|
113
|
+
* The mutex is owned like a lock: it names its pid, is released only by its
|
|
114
|
+
* owner (content check), and is cleared by others only when that pid is
|
|
115
|
+
* provably DEAD — never by age. Clearing by age let a live breaker that paused
|
|
116
|
+
* between its compare and its unlink lose the mutex, and then delete the live
|
|
117
|
+
* lock of whoever broke the dead one in the meantime. The one exception is a
|
|
118
|
+
* mutex with no parsable owner (a breaker that died between creating and
|
|
119
|
+
* writing it — microseconds), cleared after 10 s so it cannot wedge the lock. */
|
|
120
|
+
export function breakStaleLock(path: string, deadContent: string): void {
|
|
121
|
+
const mutex = `${path}.break`;
|
|
122
|
+
const mine = `pid=${process.pid} nonce=${Math.random().toString(36).slice(2)}${Date.now()}\n`;
|
|
123
|
+
let fd: number;
|
|
124
|
+
try {
|
|
125
|
+
fd = openSync(mutex, "wx");
|
|
126
|
+
writeFileSync(fd, mine);
|
|
127
|
+
} catch (e: unknown) {
|
|
128
|
+
if ((e as NodeJS.ErrnoException).code !== "EEXIST") throw e;
|
|
129
|
+
// Another breaker holds it — leave it, unless its owner is gone.
|
|
130
|
+
try {
|
|
131
|
+
const held = readFileSync(mutex, "utf8");
|
|
132
|
+
const owned = /^pid=\d+/.test(held);
|
|
133
|
+
const stale = owned ? !holderAlive(held) : Date.now() - statSync(mutex).mtimeMs > 10_000;
|
|
134
|
+
if (stale && readFileSync(mutex, "utf8") === held) unlinkSync(mutex);
|
|
135
|
+
} catch { /* gone */ }
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
try {
|
|
139
|
+
if (readFileSync(path, "utf8") === deadContent) unlinkSync(path);
|
|
140
|
+
} catch { /* already gone */ } finally {
|
|
141
|
+
closeSync(fd);
|
|
142
|
+
try { if (readFileSync(mutex, "utf8") === mine) unlinkSync(mutex); } catch { /* gone */ }
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Take an exclusive lock (O_EXCL file) or throw LockBusyError.
|
|
147
|
+
*
|
|
148
|
+
* A lock is broken only when its holder is provably DEAD (no such pid) — age
|
|
149
|
+
* alone is not enough: a slow but live holder whose lock is broken would then
|
|
150
|
+
* run concurrently with the breaker, which is the race this exists to stop. A
|
|
151
|
+
* lock whose content cannot be parsed is broken after `staleMs`.
|
|
152
|
+
*
|
|
153
|
+
* Each lock carries a unique nonce, and release unlinks only while the file
|
|
154
|
+
* still carries OURS — so a holder whose lock was (wrongly) broken can never
|
|
155
|
+
* delete its successor's lock on the way out. Release is idempotent and also
|
|
156
|
+
* runs on process exit, because the confirm gate leaves via `process.exit`.
|
|
157
|
+
*
|
|
158
|
+
* Slack has no compare-and-swap on chat.update, so this is the only thing that
|
|
159
|
+
* keeps two confirmed updates from interleaving fetch → edit → log. It covers
|
|
160
|
+
* writers on THIS machine (the fleet runs on one); writers elsewhere are not
|
|
161
|
+
* serialised. */
|
|
162
|
+
export function acquireLock(key: string, opts: { dir?: string; staleMs?: number } = {}): () => void {
|
|
163
|
+
const dir = opts.dir ?? lockDir();
|
|
164
|
+
const staleMs = opts.staleMs ?? 120_000;
|
|
165
|
+
mkdirSync(dir, { recursive: true });
|
|
166
|
+
const path = join(dir, `${key.replace(/[^A-Za-z0-9._-]/g, "_")}.lock`);
|
|
167
|
+
const content = `pid=${process.pid} since=${new Date().toISOString()} nonce=${Math.random().toString(36).slice(2)}${Date.now()}\n`;
|
|
168
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
169
|
+
try {
|
|
170
|
+
const fd = openSync(path, "wx");
|
|
171
|
+
writeFileSync(fd, content);
|
|
172
|
+
closeSync(fd);
|
|
173
|
+
let held = true;
|
|
174
|
+
const release = (): void => {
|
|
175
|
+
if (!held) return;
|
|
176
|
+
held = false;
|
|
177
|
+
try {
|
|
178
|
+
if (readFileSync(path, "utf8") === content) unlinkSync(path);
|
|
179
|
+
} catch { /* already gone */ }
|
|
180
|
+
};
|
|
181
|
+
process.once("exit", release);
|
|
182
|
+
return release;
|
|
183
|
+
} catch (e: unknown) {
|
|
184
|
+
if ((e as NodeJS.ErrnoException).code !== "EEXIST") throw e;
|
|
185
|
+
let existing: string;
|
|
186
|
+
let age: number;
|
|
187
|
+
try {
|
|
188
|
+
existing = readFileSync(path, "utf8");
|
|
189
|
+
age = Date.now() - statSync(path).mtimeMs;
|
|
190
|
+
} catch {
|
|
191
|
+
continue; // vanished between open and read — just retry
|
|
192
|
+
}
|
|
193
|
+
const parsable = /^pid=\d+/.test(existing);
|
|
194
|
+
const dead = parsable ? !holderAlive(existing) : age > staleMs;
|
|
195
|
+
if (dead) {
|
|
196
|
+
breakStaleLock(path, existing);
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
throw new LockBusyError(existing.trim().replace(/ nonce=\S+/, "") || "another process");
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
throw new LockBusyError("another process");
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** The id `create` prints and every other subcommand accepts. */
|
|
206
|
+
export function pinlogId(channel: string, ts: string): string {
|
|
207
|
+
return `${channel}:${ts}`;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** `C00000001:1700000000.000100` → parts; anything else → null (the caller then
|
|
211
|
+
* tries #chan:ts / permalink). */
|
|
212
|
+
export function parsePinlogId(s: string): { channel: string; ts: string } | null {
|
|
213
|
+
const m = s.match(/^([CDG][A-Z0-9]{8,}):(\d{10}\.\d{6})$/);
|
|
214
|
+
return m ? { channel: m[1]!, ts: m[2]! } : null;
|
|
215
|
+
}
|
package/ts/profiles.ts
CHANGED
|
@@ -268,10 +268,16 @@ export function resolveToken(workspaceFlag?: string): string {
|
|
|
268
268
|
// Bot tokens in shell env or .env files must not shadow workspace profiles.
|
|
269
269
|
const legacyEnvToken = process.env.SLACK_BOT_TOKEN ?? process.env.SLACK_MCP_XOXP_TOKEN;
|
|
270
270
|
if (legacyEnvToken && names.length === 0) return legacyEnvToken;
|
|
271
|
-
|
|
271
|
+
// A bot token (xoxb-) belongs in SLACK_BOT_TOKEN: it is how `slack ask` / --as-bot send, and
|
|
272
|
+
// ~/.config/slack-cli/.env is its documented home. It never stands in for the user token, so it is
|
|
273
|
+
// no conflict; warn only about a *user* token parked in the legacy variables.
|
|
274
|
+
const legacyUserToken = [process.env.SLACK_BOT_TOKEN, process.env.SLACK_MCP_XOXP_TOKEN].find(
|
|
275
|
+
(t) => t && !t.startsWith("xoxb-"),
|
|
276
|
+
);
|
|
277
|
+
if (legacyUserToken && names.length > 0) {
|
|
272
278
|
// Warn only when the token differs from what's in .env.local (same value = not a real conflict)
|
|
273
279
|
const envFileToken = walkDirEnv(process.cwd(), ["SLACK_BOT_TOKEN", "SLACK_MCP_XOXP_TOKEN"]);
|
|
274
|
-
if (
|
|
280
|
+
if (legacyUserToken !== envFileToken) {
|
|
275
281
|
if (!(globalThis as Record<string, unknown>).__slackEnvWarnShown) {
|
|
276
282
|
(globalThis as Record<string, unknown>).__slackEnvWarnShown = true;
|
|
277
283
|
console.error(
|
package/ts/slack.ts
CHANGED
|
@@ -460,6 +460,28 @@ export async function deleteMessage(
|
|
|
460
460
|
await post(token, "chat.delete", { channel, ts }, cookie);
|
|
461
461
|
}
|
|
462
462
|
|
|
463
|
+
/** One page of a thread, with Slack's cursor — for callers that must see the
|
|
464
|
+
* WHOLE thread (or say that they did not). */
|
|
465
|
+
export async function repliesCursor(
|
|
466
|
+
token: string,
|
|
467
|
+
channel: string,
|
|
468
|
+
ts: string,
|
|
469
|
+
cursor?: string,
|
|
470
|
+
limit = 200,
|
|
471
|
+
cookie?: string,
|
|
472
|
+
): Promise<Json> {
|
|
473
|
+
const params: Record<string, string> = { channel, ts, limit: String(limit) };
|
|
474
|
+
if (cursor !== undefined) params.cursor = cursor;
|
|
475
|
+
return get(token, "conversations.replies", params, cookie);
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/** Pin a message to its channel (pins.add, scope pins:write). Slack answers
|
|
479
|
+
* `already_pinned` when it already is — surfaced as an error like any other;
|
|
480
|
+
* the caller decides whether that counts. */
|
|
481
|
+
export async function pinsAdd(token: string, channel: string, ts: string, cookie?: string): Promise<void> {
|
|
482
|
+
await post(token, "pins.add", { channel, timestamp: ts }, cookie);
|
|
483
|
+
}
|
|
484
|
+
|
|
463
485
|
// Add or remove an emoji reaction on a message. `name` is the emoji shortcode
|
|
464
486
|
// without colons (e.g. "eyes", "white_check_mark"). Slack's reactions.add
|
|
465
487
|
// returns already_reacted when the reaction exists; reactions.remove returns
|