standup-mr 0.4.1 → 0.6.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/CHANGELOG.md +40 -0
- package/README.md +23 -3
- package/dist/{chunk-2X2XEEUY.js → chunk-MB7KWLUR.js} +57 -2
- package/dist/cli.js +23 -40
- package/dist/index.d.ts +5 -1
- package/dist/index.js +5 -1
- package/dist/mcp/server.js +119 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,46 @@ All notable changes to standup-mr are recorded here. The format follows
|
|
|
4
4
|
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the project uses
|
|
5
5
|
[semantic versioning](https://semver.org/spec/v2.0.0.html).
|
|
6
6
|
|
|
7
|
+
## [0.6.0] - 2026-09-02
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- **Google Chat webhooks.** `--google-chat URL` on the CLI, `kind:
|
|
12
|
+
"google-chat"` in `post_standup_note`, and `chat.googleapis.com` added to
|
|
13
|
+
host inference so neither is usually needed. Google Chat takes the same
|
|
14
|
+
`{"text"}` body as Slack, so this was only ever a naming gap — but telling
|
|
15
|
+
someone to pass `--slack` for a Google Chat webhook is a hack, not a
|
|
16
|
+
feature.
|
|
17
|
+
|
|
18
|
+
Anything else that accepts a Slack-shaped body — Mattermost, Rocket.Chat, an
|
|
19
|
+
n8n or Zapier endpoint — already works by passing the slack kind.
|
|
20
|
+
|
|
21
|
+
### Changed
|
|
22
|
+
|
|
23
|
+
- `standup post` builds its flags and its error message from the list of
|
|
24
|
+
implemented channels instead of a hand-written pair, so adding a channel is
|
|
25
|
+
one map entry. The MCP tool's `kind` enum is now tested against that same
|
|
26
|
+
list: a channel can no longer reach the CLI and quietly miss MCP.
|
|
27
|
+
|
|
28
|
+
## [0.5.0] - 2026-09-02
|
|
29
|
+
|
|
30
|
+
### Added
|
|
31
|
+
|
|
32
|
+
- **`post_standup_note`** — posts a finished note to a Slack or Discord
|
|
33
|
+
webhook, so an MCP client can deliver the note it just wrote instead of
|
|
34
|
+
handing it back as text. The webhook URL comes from `STANDUP_WEBHOOK_URL`
|
|
35
|
+
and is never a tool argument: anyone holding that URL can post to the
|
|
36
|
+
channel, which makes it a credential. The payload shape is inferred from the
|
|
37
|
+
URL host, so `kind` is only needed when a proxy hides it. A missing URL, an
|
|
38
|
+
unrecognisable host, or a webhook that rejects the message is reported as an
|
|
39
|
+
error — a note is never announced as sent when it was not — and the error
|
|
40
|
+
text never repeats the URL.
|
|
41
|
+
- **`get_note_instructions`** — returns the note-writing rules the Claude Code
|
|
42
|
+
skill carries, so a non-Claude assistant can write the note the same way
|
|
43
|
+
instead of inventing a format. Same content as `standup instructions`.
|
|
44
|
+
|
|
45
|
+
Both were already in the CLI. Only the MCP server was missing them.
|
|
46
|
+
|
|
7
47
|
## [0.4.1] - 2026-09-02
|
|
8
48
|
|
|
9
49
|
### Changed
|
package/README.md
CHANGED
|
@@ -30,6 +30,10 @@ npx standup-mr fetch --markdown # structured digest
|
|
|
30
30
|
npx standup-mr fetch --lang tr # Turkish date labels
|
|
31
31
|
```
|
|
32
32
|
|
|
33
|
+
```bash
|
|
34
|
+
npx standup-mr fetch --markdown | npx standup-mr post --google-chat "$URL"
|
|
35
|
+
```
|
|
36
|
+
|
|
33
37
|
### Identity
|
|
34
38
|
|
|
35
39
|
| | GitHub | GitLab |
|
|
@@ -117,9 +121,25 @@ if a snippet above doesn't work, check [Cursor's MCP
|
|
|
117
121
|
docs](https://docs.cursor.com/context/mcp) or Codex's own config
|
|
118
122
|
documentation for the current format rather than trusting this file blindly.
|
|
119
123
|
|
|
120
|
-
The
|
|
121
|
-
|
|
122
|
-
|
|
124
|
+
The server exposes three tools:
|
|
125
|
+
|
|
126
|
+
| Tool | What it does |
|
|
127
|
+
|---|---|
|
|
128
|
+
| `get_standup_data` | Reads the provider and returns the report as JSON. Optional `provider`, `host`, `lang`. |
|
|
129
|
+
| `get_note_instructions` | Returns the note-writing rules, so the assistant can write the note the way the skill would. |
|
|
130
|
+
| `post_standup_note` | Posts a finished note to a Slack, Discord or Google Chat webhook. |
|
|
131
|
+
|
|
132
|
+
`post_standup_note` reads the webhook URL from `STANDUP_WEBHOOK_URL` and never
|
|
133
|
+
takes it as an argument — anyone holding that URL can post to the channel, so
|
|
134
|
+
it belongs with the tokens, not in a transcript. The payload shape is inferred
|
|
135
|
+
from the URL host; `kind` is only needed when a proxy hides it.
|
|
136
|
+
|
|
137
|
+
Three shapes are implemented: `slack` and `google-chat` both post `{"text"}`,
|
|
138
|
+
`discord` posts `{"content"}`. Anything else that accepts a Slack-shaped body —
|
|
139
|
+
Mattermost, Rocket.Chat, an n8n or Zapier endpoint — works today by passing
|
|
140
|
+
`kind: "slack"`, or `--slack URL` on the CLI.
|
|
141
|
+
|
|
142
|
+
Outside MCP, the same rules are available on stdout:
|
|
123
143
|
|
|
124
144
|
```bash
|
|
125
145
|
npx standup-mr instructions >> AGENTS.md
|
|
@@ -187,7 +187,12 @@ function ghToken(host) {
|
|
|
187
187
|
}
|
|
188
188
|
|
|
189
189
|
// src/notify/notify.constants.ts
|
|
190
|
-
var PAYLOAD_FIELD = {
|
|
190
|
+
var PAYLOAD_FIELD = {
|
|
191
|
+
slack: "text",
|
|
192
|
+
discord: "content",
|
|
193
|
+
"google-chat": "text"
|
|
194
|
+
};
|
|
195
|
+
var WEBHOOK_KINDS = Object.keys(PAYLOAD_FIELD);
|
|
191
196
|
|
|
192
197
|
// src/notify/notify.ts
|
|
193
198
|
async function postWebhook(url, text, kind = "slack", fetchImpl = fetch) {
|
|
@@ -206,6 +211,21 @@ async function postWebhook(url, text, kind = "slack", fetchImpl = fetch) {
|
|
|
206
211
|
throw new Error(`Webhook rejected the message: HTTP ${response.status}.`);
|
|
207
212
|
}
|
|
208
213
|
}
|
|
214
|
+
var WEBHOOK_HOSTS = {
|
|
215
|
+
"hooks.slack.com": "slack",
|
|
216
|
+
"chat.googleapis.com": "google-chat",
|
|
217
|
+
"discord.com": "discord",
|
|
218
|
+
"discordapp.com": "discord",
|
|
219
|
+
"ptb.discord.com": "discord",
|
|
220
|
+
"canary.discord.com": "discord"
|
|
221
|
+
};
|
|
222
|
+
function inferWebhookKind(url) {
|
|
223
|
+
try {
|
|
224
|
+
return WEBHOOK_HOSTS[new URL(url).hostname] ?? null;
|
|
225
|
+
} catch {
|
|
226
|
+
return null;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
209
229
|
|
|
210
230
|
// src/providers/base/http.ts
|
|
211
231
|
function buildUrl(api, path, params) {
|
|
@@ -1133,6 +1153,37 @@ async function buildReport(provider, today, lang = "en", lookbackDays = LOOKBACK
|
|
|
1133
1153
|
};
|
|
1134
1154
|
}
|
|
1135
1155
|
|
|
1156
|
+
// src/manifest/manifest.ts
|
|
1157
|
+
import { existsSync, readFileSync } from "fs";
|
|
1158
|
+
import { dirname, join } from "path";
|
|
1159
|
+
import { fileURLToPath } from "url";
|
|
1160
|
+
function findPackageRoot(startDir) {
|
|
1161
|
+
let dir = startDir;
|
|
1162
|
+
while (!existsSync(join(dir, "package.json"))) {
|
|
1163
|
+
const parent = dirname(dir);
|
|
1164
|
+
if (parent === dir) {
|
|
1165
|
+
throw new Error(`Could not locate package.json above ${startDir}`);
|
|
1166
|
+
}
|
|
1167
|
+
dir = parent;
|
|
1168
|
+
}
|
|
1169
|
+
return dir;
|
|
1170
|
+
}
|
|
1171
|
+
|
|
1172
|
+
// src/skill/skill.ts
|
|
1173
|
+
import { existsSync as existsSync2, readFileSync as readFileSync2 } from "fs";
|
|
1174
|
+
import { dirname as dirname2, join as join2 } from "path";
|
|
1175
|
+
import { fileURLToPath as fileURLToPath2 } from "url";
|
|
1176
|
+
function readStandupSkillBody() {
|
|
1177
|
+
const moduleDir = dirname2(fileURLToPath2(import.meta.url));
|
|
1178
|
+
const skillPath = join2(findPackageRoot(moduleDir), "skills", "standup", "SKILL.md");
|
|
1179
|
+
if (!existsSync2(skillPath)) {
|
|
1180
|
+
throw new Error(`Cannot find the standup skill file at ${skillPath}`);
|
|
1181
|
+
}
|
|
1182
|
+
const content = readFileSync2(skillPath, "utf8");
|
|
1183
|
+
const withoutFrontmatter = content.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n/, "");
|
|
1184
|
+
return withoutFrontmatter.replace(/^\n+/, "");
|
|
1185
|
+
}
|
|
1186
|
+
|
|
1136
1187
|
export {
|
|
1137
1188
|
STALE_DAYS,
|
|
1138
1189
|
isoDay,
|
|
@@ -1152,7 +1203,9 @@ export {
|
|
|
1152
1203
|
glabToken,
|
|
1153
1204
|
ghHosts,
|
|
1154
1205
|
ghToken,
|
|
1206
|
+
WEBHOOK_KINDS,
|
|
1155
1207
|
postWebhook,
|
|
1208
|
+
inferWebhookKind,
|
|
1156
1209
|
buildUrl,
|
|
1157
1210
|
ApiError,
|
|
1158
1211
|
assertUsable,
|
|
@@ -1175,5 +1228,7 @@ export {
|
|
|
1175
1228
|
chooseKind,
|
|
1176
1229
|
connect,
|
|
1177
1230
|
toMarkdown,
|
|
1178
|
-
buildReport
|
|
1231
|
+
buildReport,
|
|
1232
|
+
findPackageRoot,
|
|
1233
|
+
readStandupSkillBody
|
|
1179
1234
|
};
|
package/dist/cli.js
CHANGED
|
@@ -1,16 +1,19 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import {
|
|
3
3
|
ConfigError,
|
|
4
|
+
WEBHOOK_KINDS,
|
|
4
5
|
buildReport,
|
|
5
6
|
connect,
|
|
7
|
+
findPackageRoot,
|
|
6
8
|
postWebhook,
|
|
9
|
+
readStandupSkillBody,
|
|
7
10
|
toMarkdown
|
|
8
|
-
} from "./chunk-
|
|
11
|
+
} from "./chunk-MB7KWLUR.js";
|
|
9
12
|
|
|
10
13
|
// src/cli/cli.ts
|
|
11
|
-
import {
|
|
12
|
-
import { dirname
|
|
13
|
-
import { fileURLToPath
|
|
14
|
+
import { realpathSync } from "fs";
|
|
15
|
+
import { dirname, join } from "path";
|
|
16
|
+
import { fileURLToPath, pathToFileURL } from "url";
|
|
14
17
|
import { parseArgs } from "util";
|
|
15
18
|
|
|
16
19
|
// src/cli/cli.constants.ts
|
|
@@ -19,7 +22,7 @@ var USAGE = `standup \u2014 standup notes from merge request state
|
|
|
19
22
|
Usage:
|
|
20
23
|
standup fetch [--provider github|gitlab] [--host H] [--token T]
|
|
21
24
|
[--lang en|tr] [--markdown]
|
|
22
|
-
standup post (--slack URL | --discord URL) [--text TEXT]
|
|
25
|
+
standup post (--slack URL | --discord URL | --google-chat URL) [--text TEXT]
|
|
23
26
|
standup mcp
|
|
24
27
|
standup instructions
|
|
25
28
|
|
|
@@ -30,43 +33,18 @@ Credentials resolve as: flag, then GITHUB_HOST / GITHUB_TOKEN (or GITLAB_HOST /
|
|
|
30
33
|
GITLAB_TOKEN), then the gh or glab config.
|
|
31
34
|
--text defaults to '-', meaning read stdin.
|
|
32
35
|
|
|
33
|
-
standup mcp starts the stdio MCP server, exposing get_standup_data
|
|
36
|
+
standup mcp starts the stdio MCP server, exposing get_standup_data,
|
|
37
|
+
post_standup_note and get_note_instructions.
|
|
34
38
|
standup instructions prints the note-writing playbook (the standup skill body)
|
|
35
39
|
to stdout, for teaching a non-Claude assistant the same rules, e.g.:
|
|
36
40
|
npx standup-mr instructions >> AGENTS.md`;
|
|
37
41
|
|
|
38
|
-
// src/manifest/manifest.ts
|
|
39
|
-
import { existsSync, readFileSync } from "fs";
|
|
40
|
-
import { dirname, join } from "path";
|
|
41
|
-
import { fileURLToPath } from "url";
|
|
42
|
-
function findPackageRoot(startDir) {
|
|
43
|
-
let dir = startDir;
|
|
44
|
-
while (!existsSync(join(dir, "package.json"))) {
|
|
45
|
-
const parent = dirname(dir);
|
|
46
|
-
if (parent === dir) {
|
|
47
|
-
throw new Error(`Could not locate package.json above ${startDir}`);
|
|
48
|
-
}
|
|
49
|
-
dir = parent;
|
|
50
|
-
}
|
|
51
|
-
return dir;
|
|
52
|
-
}
|
|
53
|
-
|
|
54
42
|
// src/cli/cli.ts
|
|
55
43
|
async function readStdin() {
|
|
56
44
|
const chunks = [];
|
|
57
45
|
for await (const chunk of process.stdin) chunks.push(Buffer.from(chunk));
|
|
58
46
|
return Buffer.concat(chunks).toString("utf8");
|
|
59
47
|
}
|
|
60
|
-
function readStandupSkillBody() {
|
|
61
|
-
const moduleDir = dirname2(fileURLToPath2(import.meta.url));
|
|
62
|
-
const skillPath = join2(findPackageRoot(moduleDir), "skills", "standup", "SKILL.md");
|
|
63
|
-
if (!existsSync2(skillPath)) {
|
|
64
|
-
throw new Error(`Cannot find the standup skill file at ${skillPath}`);
|
|
65
|
-
}
|
|
66
|
-
const content = readFileSync2(skillPath, "utf8");
|
|
67
|
-
const withoutFrontmatter = content.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n/, "");
|
|
68
|
-
return withoutFrontmatter.replace(/^\n+/, "");
|
|
69
|
-
}
|
|
70
48
|
async function main(argv) {
|
|
71
49
|
const [command, ...rest] = argv;
|
|
72
50
|
if (!command || command === "--help" || command === "-h") {
|
|
@@ -96,27 +74,32 @@ async function main(argv) {
|
|
|
96
74
|
const { values } = parseArgs({
|
|
97
75
|
args: rest,
|
|
98
76
|
options: {
|
|
99
|
-
|
|
100
|
-
|
|
77
|
+
...Object.fromEntries(
|
|
78
|
+
WEBHOOK_KINDS.map((kind2) => [kind2, { type: "string" }])
|
|
79
|
+
),
|
|
101
80
|
text: { type: "string", default: "-" }
|
|
102
81
|
}
|
|
103
82
|
});
|
|
104
|
-
const
|
|
105
|
-
|
|
106
|
-
|
|
83
|
+
const urls = values;
|
|
84
|
+
const kind = WEBHOOK_KINDS.find((candidate) => urls[candidate]);
|
|
85
|
+
if (!kind) {
|
|
86
|
+
const flags = WEBHOOK_KINDS.map((candidate) => `--${candidate} URL`).join(" | ");
|
|
87
|
+
process.stderr.write(`Pass one of: ${flags}.
|
|
88
|
+
`);
|
|
107
89
|
return 1;
|
|
108
90
|
}
|
|
91
|
+
const url = urls[kind];
|
|
109
92
|
const text = values.text === "-" ? await readStdin() : values.text;
|
|
110
93
|
if (!text.trim()) {
|
|
111
94
|
process.stderr.write("Nothing to post: empty text.\n");
|
|
112
95
|
return 1;
|
|
113
96
|
}
|
|
114
|
-
await postWebhook(url, text,
|
|
97
|
+
await postWebhook(url, text, kind);
|
|
115
98
|
return 0;
|
|
116
99
|
}
|
|
117
100
|
if (command === "mcp") {
|
|
118
|
-
const moduleDir =
|
|
119
|
-
const serverPath =
|
|
101
|
+
const moduleDir = dirname(fileURLToPath(import.meta.url));
|
|
102
|
+
const serverPath = join(findPackageRoot(moduleDir), "dist", "mcp", "server.js");
|
|
120
103
|
const serverUrl = pathToFileURL(serverPath).href;
|
|
121
104
|
const { main: startMcpServer } = await import(serverUrl);
|
|
122
105
|
await startMcpServer();
|
package/dist/index.d.ts
CHANGED
|
@@ -125,11 +125,13 @@ declare function previousActiveDays(eventDates: Set<string>, today: Date): Array
|
|
|
125
125
|
declare const PAYLOAD_FIELD: {
|
|
126
126
|
readonly slack: "text";
|
|
127
127
|
readonly discord: "content";
|
|
128
|
+
readonly 'google-chat': "text";
|
|
128
129
|
};
|
|
129
130
|
|
|
130
131
|
type WebhookKind = keyof typeof PAYLOAD_FIELD;
|
|
131
132
|
|
|
132
133
|
declare function postWebhook(url: string, text: string, kind?: WebhookKind, fetchImpl?: FetchLike): Promise<void>;
|
|
134
|
+
declare function inferWebhookKind(url: string): WebhookKind | null;
|
|
133
135
|
|
|
134
136
|
interface Provider {
|
|
135
137
|
readonly kind: ProviderKind;
|
|
@@ -279,6 +281,8 @@ declare function toMarkdown(report: StandupReport, lang?: string): string;
|
|
|
279
281
|
|
|
280
282
|
declare function buildReport(provider: Provider, today: Date, lang?: string, lookbackDays?: number): Promise<StandupReport>;
|
|
281
283
|
|
|
284
|
+
declare function readStandupSkillBody(): string;
|
|
285
|
+
|
|
282
286
|
declare function extractErrors(rawTrace: string, limit?: number): string[];
|
|
283
287
|
|
|
284
|
-
export { type ActiveDay, type ActivityEvent, ApiError, type Blocker, type Bucket, ConfigError, DIAGNOSIS_UNAVAILABLE, type FetchLike, GITHUB_LABELS, GITLAB_LABELS, GitHubProvider, GitLabProvider, type Identity, type MergeRequest, type Provider, type ProviderKind, type ProviderLabels, RETRY_ATTEMPTS, RETRY_BACKOFF_MS, type Review, STALE_DAYS, type SelectOptions, type StandupReport, approvedBy, assertUsable, buildReport, buildUrl, chooseKind, classify, connect, countChangesRequested, degradable, extractErrors, ghHosts, ghToken, glabHosts, glabToken, isoDay, label, latestStateByReviewer, localAt, mapEvent, markMissingPipelines, normalizeChecks, parseGlabHosts, parseLoggedInHosts, postWebhook, previousActiveDays, repoFromUrl, resolveHost, resolveToken, sendWithRetry, toMarkdown, undiagnosed, unreachable };
|
|
288
|
+
export { type ActiveDay, type ActivityEvent, ApiError, type Blocker, type Bucket, ConfigError, DIAGNOSIS_UNAVAILABLE, type FetchLike, GITHUB_LABELS, GITLAB_LABELS, GitHubProvider, GitLabProvider, type Identity, type MergeRequest, type Provider, type ProviderKind, type ProviderLabels, RETRY_ATTEMPTS, RETRY_BACKOFF_MS, type Review, STALE_DAYS, type SelectOptions, type StandupReport, approvedBy, assertUsable, buildReport, buildUrl, chooseKind, classify, connect, countChangesRequested, degradable, extractErrors, ghHosts, ghToken, glabHosts, glabToken, inferWebhookKind, isoDay, label, latestStateByReviewer, localAt, mapEvent, markMissingPipelines, normalizeChecks, parseGlabHosts, parseLoggedInHosts, postWebhook, previousActiveDays, readStandupSkillBody, repoFromUrl, resolveHost, resolveToken, sendWithRetry, toMarkdown, undiagnosed, unreachable };
|
package/dist/index.js
CHANGED
|
@@ -24,6 +24,7 @@ import {
|
|
|
24
24
|
ghToken,
|
|
25
25
|
glabHosts,
|
|
26
26
|
glabToken,
|
|
27
|
+
inferWebhookKind,
|
|
27
28
|
isoDay,
|
|
28
29
|
label,
|
|
29
30
|
latestStateByReviewer,
|
|
@@ -35,6 +36,7 @@ import {
|
|
|
35
36
|
parseLoggedInHosts,
|
|
36
37
|
postWebhook,
|
|
37
38
|
previousActiveDays,
|
|
39
|
+
readStandupSkillBody,
|
|
38
40
|
repoFromUrl,
|
|
39
41
|
resolveHost,
|
|
40
42
|
resolveToken,
|
|
@@ -42,7 +44,7 @@ import {
|
|
|
42
44
|
toMarkdown,
|
|
43
45
|
undiagnosed,
|
|
44
46
|
unreachable
|
|
45
|
-
} from "./chunk-
|
|
47
|
+
} from "./chunk-MB7KWLUR.js";
|
|
46
48
|
export {
|
|
47
49
|
ApiError,
|
|
48
50
|
ConfigError,
|
|
@@ -68,6 +70,7 @@ export {
|
|
|
68
70
|
ghToken,
|
|
69
71
|
glabHosts,
|
|
70
72
|
glabToken,
|
|
73
|
+
inferWebhookKind,
|
|
71
74
|
isoDay,
|
|
72
75
|
label,
|
|
73
76
|
latestStateByReviewer,
|
|
@@ -79,6 +82,7 @@ export {
|
|
|
79
82
|
parseLoggedInHosts,
|
|
80
83
|
postWebhook,
|
|
81
84
|
previousActiveDays,
|
|
85
|
+
readStandupSkillBody,
|
|
82
86
|
repoFromUrl,
|
|
83
87
|
resolveHost,
|
|
84
88
|
resolveToken,
|
package/dist/mcp/server.js
CHANGED
|
@@ -31,6 +31,47 @@ function packageVersion(moduleUrl) {
|
|
|
31
31
|
return manifest.version;
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
+
// src/notify/notify.constants.ts
|
|
35
|
+
var PAYLOAD_FIELD = {
|
|
36
|
+
slack: "text",
|
|
37
|
+
discord: "content",
|
|
38
|
+
"google-chat": "text"
|
|
39
|
+
};
|
|
40
|
+
var WEBHOOK_KINDS = Object.keys(PAYLOAD_FIELD);
|
|
41
|
+
|
|
42
|
+
// src/notify/notify.ts
|
|
43
|
+
async function postWebhook(url, text2, kind = "slack", fetchImpl = fetch) {
|
|
44
|
+
const field = PAYLOAD_FIELD[kind];
|
|
45
|
+
if (!field) {
|
|
46
|
+
throw new Error(
|
|
47
|
+
`Unknown webhook kind "${kind}". Use one of: ${Object.keys(PAYLOAD_FIELD).join(", ")}.`
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
const response = await fetchImpl(url, {
|
|
51
|
+
method: "POST",
|
|
52
|
+
headers: { "Content-Type": "application/json" },
|
|
53
|
+
body: JSON.stringify({ [field]: text2 })
|
|
54
|
+
});
|
|
55
|
+
if (!response.ok) {
|
|
56
|
+
throw new Error(`Webhook rejected the message: HTTP ${response.status}.`);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
var WEBHOOK_HOSTS = {
|
|
60
|
+
"hooks.slack.com": "slack",
|
|
61
|
+
"chat.googleapis.com": "google-chat",
|
|
62
|
+
"discord.com": "discord",
|
|
63
|
+
"discordapp.com": "discord",
|
|
64
|
+
"ptb.discord.com": "discord",
|
|
65
|
+
"canary.discord.com": "discord"
|
|
66
|
+
};
|
|
67
|
+
function inferWebhookKind(url) {
|
|
68
|
+
try {
|
|
69
|
+
return WEBHOOK_HOSTS[new URL(url).hostname] ?? null;
|
|
70
|
+
} catch {
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
34
75
|
// src/config/config.ts
|
|
35
76
|
import { execFileSync } from "child_process";
|
|
36
77
|
|
|
@@ -1002,6 +1043,21 @@ function connect(options = {}) {
|
|
|
1002
1043
|
return new GitLabProvider(host, token);
|
|
1003
1044
|
}
|
|
1004
1045
|
|
|
1046
|
+
// src/skill/skill.ts
|
|
1047
|
+
import { existsSync as existsSync2, readFileSync as readFileSync2 } from "fs";
|
|
1048
|
+
import { dirname as dirname2, join as join2 } from "path";
|
|
1049
|
+
import { fileURLToPath as fileURLToPath2 } from "url";
|
|
1050
|
+
function readStandupSkillBody() {
|
|
1051
|
+
const moduleDir = dirname2(fileURLToPath2(import.meta.url));
|
|
1052
|
+
const skillPath = join2(findPackageRoot(moduleDir), "skills", "standup", "SKILL.md");
|
|
1053
|
+
if (!existsSync2(skillPath)) {
|
|
1054
|
+
throw new Error(`Cannot find the standup skill file at ${skillPath}`);
|
|
1055
|
+
}
|
|
1056
|
+
const content = readFileSync2(skillPath, "utf8");
|
|
1057
|
+
const withoutFrontmatter = content.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n/, "");
|
|
1058
|
+
return withoutFrontmatter.replace(/^\n+/, "");
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1005
1061
|
// src/report/report.constants.ts
|
|
1006
1062
|
var LOOKBACK_DAYS = 21;
|
|
1007
1063
|
|
|
@@ -1064,6 +1120,51 @@ async function runStandupTool(args, collector = collect) {
|
|
|
1064
1120
|
});
|
|
1065
1121
|
return { content: [{ type: "text", text: JSON.stringify(report) }] };
|
|
1066
1122
|
}
|
|
1123
|
+
var WEBHOOK_URL_ENV = "STANDUP_WEBHOOK_URL";
|
|
1124
|
+
var POST_TOOL_DESCRIPTION = `Posts a finished standup note to a chat webhook. This one has a side effect: it sends a message other people will see, so only call it on a note the user has agreed to send.
|
|
1125
|
+
|
|
1126
|
+
The webhook URL is read from ${WEBHOOK_URL_ENV}, never from an argument \u2014 a webhook URL is a credential, since anyone holding it can post to the channel. Slack and Discord payload shapes are supported; the shape is inferred from the URL host, and \`kind\` is only needed for a proxied or self-hosted endpoint whose host gives nothing away \u2014 Mattermost and Rocket.Chat take the slack shape. A missing URL, an unrecognised host, or a webhook that rejects the message comes back as an error rather than a silent success.`;
|
|
1127
|
+
var POST_TOOL_SCHEMA = {
|
|
1128
|
+
text: z.string().min(1).describe(
|
|
1129
|
+
"The note to post, as the chat should render it. Slack and Discord both take Markdown, so send the written note rather than the raw JSON from get_standup_data."
|
|
1130
|
+
),
|
|
1131
|
+
kind: z.enum(["slack", "discord", "google-chat"]).optional().describe(
|
|
1132
|
+
'Which payload shape to send: slack and google-chat post {"text"}, discord posts {"content"}. Omit it \u2014 the shape is inferred from the webhook host. Pass it only when the host is not recognisable, e.g. a proxy in front of the real webhook.'
|
|
1133
|
+
)
|
|
1134
|
+
};
|
|
1135
|
+
var INSTRUCTIONS_TOOL_DESCRIPTION = "Returns the note-writing rules \u2014 the playbook for turning get_standup_data output into a standup note a person would actually say out loud: how to group the previous day by theme, how to derive today from open merge requests, and how to report a blocker from its job log. Read-only, no arguments, no network. Call it once before writing the first note; the rules do not change between calls.";
|
|
1136
|
+
function text(body) {
|
|
1137
|
+
return { content: [{ type: "text", text: body }] };
|
|
1138
|
+
}
|
|
1139
|
+
function failure(body) {
|
|
1140
|
+
return { content: [{ type: "text", text: body }], isError: true };
|
|
1141
|
+
}
|
|
1142
|
+
async function runPostTool(args, deps = {}) {
|
|
1143
|
+
const env = deps.env ?? process.env;
|
|
1144
|
+
const post = deps.post ?? postWebhook;
|
|
1145
|
+
const url = env[WEBHOOK_URL_ENV];
|
|
1146
|
+
if (!url) {
|
|
1147
|
+
return failure(
|
|
1148
|
+
`No webhook configured. Set ${WEBHOOK_URL_ENV} to the Slack or Discord webhook URL; it is deliberately not accepted as a tool argument.`
|
|
1149
|
+
);
|
|
1150
|
+
}
|
|
1151
|
+
const kind = args.kind ?? inferWebhookKind(url);
|
|
1152
|
+
if (!kind) {
|
|
1153
|
+
return failure(
|
|
1154
|
+
`Could not tell from the ${WEBHOOK_URL_ENV} host whether this is a Slack or a Discord webhook. Pass kind explicitly.`
|
|
1155
|
+
);
|
|
1156
|
+
}
|
|
1157
|
+
try {
|
|
1158
|
+
await post(url, args.text, kind);
|
|
1159
|
+
} catch (cause) {
|
|
1160
|
+
const detail = cause instanceof Error ? cause.message : String(cause);
|
|
1161
|
+
return failure(`The ${kind} webhook did not accept the note: ${detail}`);
|
|
1162
|
+
}
|
|
1163
|
+
return text(`Posted the note to the configured ${kind} webhook.`);
|
|
1164
|
+
}
|
|
1165
|
+
async function runInstructionsTool() {
|
|
1166
|
+
return text(readStandupSkillBody());
|
|
1167
|
+
}
|
|
1067
1168
|
async function main() {
|
|
1068
1169
|
const server = new McpServer({ name: "standup-mr", version: packageVersion(import.meta.url) });
|
|
1069
1170
|
server.tool(
|
|
@@ -1072,6 +1173,18 @@ async function main() {
|
|
|
1072
1173
|
STANDUP_TOOL_SCHEMA,
|
|
1073
1174
|
async (args) => await runStandupTool(args)
|
|
1074
1175
|
);
|
|
1176
|
+
server.tool(
|
|
1177
|
+
"post_standup_note",
|
|
1178
|
+
POST_TOOL_DESCRIPTION,
|
|
1179
|
+
POST_TOOL_SCHEMA,
|
|
1180
|
+
async (args) => await runPostTool(args)
|
|
1181
|
+
);
|
|
1182
|
+
server.tool(
|
|
1183
|
+
"get_note_instructions",
|
|
1184
|
+
INSTRUCTIONS_TOOL_DESCRIPTION,
|
|
1185
|
+
{},
|
|
1186
|
+
async () => await runInstructionsTool()
|
|
1187
|
+
);
|
|
1075
1188
|
await server.connect(new StdioServerTransport());
|
|
1076
1189
|
}
|
|
1077
1190
|
var entry = process.argv[1];
|
|
@@ -1089,9 +1202,15 @@ if (entryUrl && import.meta.url === entryUrl) {
|
|
|
1089
1202
|
});
|
|
1090
1203
|
}
|
|
1091
1204
|
export {
|
|
1205
|
+
INSTRUCTIONS_TOOL_DESCRIPTION,
|
|
1206
|
+
POST_TOOL_DESCRIPTION,
|
|
1207
|
+
POST_TOOL_SCHEMA,
|
|
1092
1208
|
STANDUP_TOOL_DESCRIPTION,
|
|
1093
1209
|
STANDUP_TOOL_SCHEMA,
|
|
1210
|
+
WEBHOOK_URL_ENV,
|
|
1094
1211
|
collect,
|
|
1095
1212
|
main,
|
|
1213
|
+
runInstructionsTool,
|
|
1214
|
+
runPostTool,
|
|
1096
1215
|
runStandupTool
|
|
1097
1216
|
};
|