@cursor/july 0.1.19 → 0.1.21

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.
Files changed (217) hide show
  1. package/AGENTS.md +10 -0
  2. package/dist/bin/agent-serve.js +0 -0
  3. package/dist/channels/github/instrument.d.ts +20 -0
  4. package/dist/channels/github/instrument.d.ts.map +1 -0
  5. package/dist/channels/slack/api.d.ts +2 -0
  6. package/dist/channels/slack/api.d.ts.map +1 -1
  7. package/dist/channels/slack/api.js +3 -0
  8. package/dist/channels/slack/bot-mentions.d.ts +106 -0
  9. package/dist/channels/slack/bot-mentions.d.ts.map +1 -0
  10. package/dist/channels/slack/bot-mentions.js +243 -0
  11. package/dist/channels/slack/dispatch.d.ts +6 -0
  12. package/dist/channels/slack/dispatch.d.ts.map +1 -1
  13. package/dist/channels/slack/dispatch.js +24 -3
  14. package/dist/channels/slack/inbound.d.ts +10 -1
  15. package/dist/channels/slack/inbound.d.ts.map +1 -1
  16. package/dist/channels/slack/inbound.js +12 -4
  17. package/dist/channels/slack/index.d.ts +1 -0
  18. package/dist/channels/slack/index.d.ts.map +1 -1
  19. package/dist/channels/slack/index.js +1 -0
  20. package/dist/channels/slack/slack-channel.d.ts.map +1 -1
  21. package/dist/channels/slack/slack-channel.js +61 -22
  22. package/dist/channels/slack/types.d.ts +58 -0
  23. package/dist/channels/slack/types.d.ts.map +1 -1
  24. package/dist/docs/404.html +1 -1
  25. package/dist/docs/ab.html +2 -2
  26. package/dist/docs/assets/{app.CrsWMchO.js → app.jDxLzWv4.js} +1 -1
  27. package/dist/docs/assets/chunks/@localSearchIndexroot.DoJHJjqF.js +1 -0
  28. package/dist/docs/assets/chunks/{VPLocalSearchBox.D1JqzSh8.js → VPLocalSearchBox.Y6bDR1-a.js} +1 -1
  29. package/dist/docs/assets/chunks/{theme.DaBvZYwl.js → theme.CLazCWlJ.js} +2 -2
  30. package/dist/docs/building-with-agents.html +2 -2
  31. package/dist/docs/concepts.html +2 -2
  32. package/dist/docs/deployment.html +2 -2
  33. package/dist/docs/evals.html +2 -2
  34. package/dist/docs/example-agents/approval-buddy.html +2 -2
  35. package/dist/docs/example-agents/benny.html +2 -2
  36. package/dist/docs/example-agents/bugbot.html +2 -2
  37. package/dist/docs/example-agents/codebase-wiki.html +2 -2
  38. package/dist/docs/example-agents/codeowners-review.html +2 -2
  39. package/dist/docs/example-agents/concierge.html +2 -2
  40. package/dist/docs/example-agents/fsd.html +2 -2
  41. package/dist/docs/example-agents/index.html +2 -2
  42. package/dist/docs/example-agents/knowledge-base.html +2 -2
  43. package/dist/docs/example-agents/oncall.html +2 -2
  44. package/dist/docs/example-agents/security-reviewer.html +2 -2
  45. package/dist/docs/example-agents/slack-agent.html +2 -2
  46. package/dist/docs/example-agents/weather-agent.html +2 -2
  47. package/dist/docs/guides/agent-to-agent.html +2 -2
  48. package/dist/docs/guides/cloud-runtime.html +2 -2
  49. package/dist/docs/guides/github.html +2 -2
  50. package/dist/docs/guides/human-in-the-loop.html +2 -2
  51. package/dist/docs/guides/mcp-oauth.html +2 -2
  52. package/dist/docs/guides/slack.html +2 -2
  53. package/dist/docs/guides/webhooks.html +2 -2
  54. package/dist/docs/hillclimbing.html +2 -2
  55. package/dist/docs/index.html +2 -2
  56. package/dist/docs/quickstart.html +2 -2
  57. package/dist/docs/reference/agent-config.html +2 -2
  58. package/dist/docs/reference/channels.html +2 -2
  59. package/dist/docs/reference/cli.html +2 -2
  60. package/dist/docs/reference/connections.html +2 -2
  61. package/dist/docs/reference/hooks.html +2 -2
  62. package/dist/docs/reference/http-api.html +2 -2
  63. package/dist/docs/reference/instructions.html +2 -2
  64. package/dist/docs/reference/playground.html +2 -2
  65. package/dist/docs/reference/project-layout.html +2 -2
  66. package/dist/docs/reference/prompt.html +2 -2
  67. package/dist/docs/reference/schedules.html +2 -2
  68. package/dist/docs/reference/sessions.html +2 -2
  69. package/dist/docs/reference/skills.html +2 -2
  70. package/dist/docs/reference/subagents.html +2 -2
  71. package/dist/docs/reference/tools.html +2 -2
  72. package/dist/docs/scaffolding-agents.html +2 -2
  73. package/dist/docs/storage.html +2 -2
  74. package/dist/docs/troubleshooting.html +2 -2
  75. package/dist/internal/json-dir-store.d.ts +32 -0
  76. package/dist/internal/json-dir-store.d.ts.map +1 -0
  77. package/dist/internal/json-dir-store.js +100 -0
  78. package/dist/internal/resolved-connections.d.ts +1 -1
  79. package/dist/internal/resolved-connections.d.ts.map +1 -1
  80. package/dist/internal/resolved-connections.js +10 -0
  81. package/dist/internal/session-engine.d.ts +4 -1
  82. package/dist/internal/session-engine.d.ts.map +1 -1
  83. package/dist/internal/session-engine.js +13 -2
  84. package/dist/playground/assets/index-CjOQ4hN9.css +1 -0
  85. package/dist/playground/assets/{index-C2SU2xV5.js → index-dshZQJCp.js} +46 -46
  86. package/dist/playground/index.html +2 -2
  87. package/dist/types.d.ts +7 -0
  88. package/dist/types.d.ts.map +1 -1
  89. package/dist/types.js +9 -0
  90. package/package.json +25 -25
  91. package/skills/create-agent/SKILL.md +36 -4
  92. package/skills/evals/SKILL.md +3 -0
  93. package/skills/framework-map/SKILL.md +14 -0
  94. package/skills/github/SKILL.md +6 -1
  95. package/skills/hillclimb/SKILL.md +28 -7
  96. package/src/bin/agent-serve.version.test.ts +62 -0
  97. package/src/channels/github/api.test.ts +64 -0
  98. package/src/channels/github/auth.test.ts +105 -0
  99. package/src/channels/github/cursor-account.test.ts +204 -0
  100. package/src/channels/github/forward.test.ts +457 -0
  101. package/src/channels/github/github.test.ts +937 -0
  102. package/src/channels/github/replay.test.ts +179 -0
  103. package/src/channels/slack/api.post-message.test.ts +148 -0
  104. package/src/channels/slack/api.ts +5 -0
  105. package/src/channels/slack/approvals.test.ts +328 -0
  106. package/src/channels/slack/block-actions.test.ts +452 -0
  107. package/src/channels/slack/bot-mentions.test.ts +217 -0
  108. package/src/channels/slack/bot-mentions.ts +315 -0
  109. package/src/channels/slack/channel-watch.test.ts +363 -0
  110. package/src/channels/slack/cursor-account.test.ts +253 -0
  111. package/src/channels/slack/defaults.final-post.test.ts +182 -0
  112. package/src/channels/slack/dispatch.test.ts +795 -0
  113. package/src/channels/slack/dispatch.ts +27 -1
  114. package/src/channels/slack/eval-directive.test.ts +273 -0
  115. package/src/channels/slack/inbound.ts +25 -4
  116. package/src/channels/slack/index.ts +1 -0
  117. package/src/channels/slack/message-body.test.ts +54 -0
  118. package/src/channels/slack/nudge-store.test.ts +143 -0
  119. package/src/channels/slack/slack-channel.ts +66 -8
  120. package/src/channels/slack/slack.test.ts +391 -0
  121. package/src/channels/slack/stop.test.ts +23 -0
  122. package/src/channels/slack/thread-context.test.ts +202 -0
  123. package/src/channels/slack/types.ts +60 -0
  124. package/src/evals/assertions.test.ts +580 -0
  125. package/src/evals/expect.test.ts +144 -0
  126. package/src/evals/judge.test.ts +181 -0
  127. package/src/evals/loaders.test.ts +132 -0
  128. package/src/evals/matchers.test.ts +95 -0
  129. package/src/evals/reporters.test.ts +303 -0
  130. package/src/evals/run-facts.test.ts +259 -0
  131. package/src/internal/ab-snapshot.test.ts +325 -0
  132. package/src/internal/approval-gate.test.ts +49 -0
  133. package/src/internal/approvals.integration.test.ts +383 -0
  134. package/src/internal/authored-loaders.test.ts +31 -0
  135. package/src/internal/builtin-tools/reminders.test.ts +201 -0
  136. package/src/internal/channel-route-schema.test.ts +294 -0
  137. package/src/internal/chat-attach.test.ts +262 -0
  138. package/src/internal/cli-deploy.test.ts +1991 -0
  139. package/src/internal/cli-mcp.test.ts +789 -0
  140. package/src/internal/cli-skills.test.ts +133 -0
  141. package/src/internal/cli-slack.test.ts +1647 -0
  142. package/src/internal/cloud-merge.test.ts +74 -0
  143. package/src/internal/cron.test.ts +22 -0
  144. package/src/internal/cursor/account-mcp.test.ts +807 -0
  145. package/src/internal/cursor/backend-client.test.ts +591 -0
  146. package/src/internal/cursor/credentials.test.ts +351 -0
  147. package/src/internal/cursor/github-credentials.test.ts +136 -0
  148. package/src/internal/cursor-account-mcp-auth.test.ts +310 -0
  149. package/src/internal/cursor-account.integration.test.ts +441 -0
  150. package/src/internal/cursor-event-relay.test.ts +746 -0
  151. package/src/internal/cursor-github-credentials.integration.test.ts +271 -0
  152. package/src/internal/cursor-slack-relay.test.ts +525 -0
  153. package/src/internal/deploy-source.test.ts +111 -0
  154. package/src/internal/discovery.builtin-tools.test.ts +94 -0
  155. package/src/internal/discovery.concurrency.test.ts +60 -0
  156. package/src/internal/discovery.cursor-account.test.ts +133 -0
  157. package/src/internal/discovery.cwd.test.ts +83 -0
  158. package/src/internal/discovery.hosting.test.ts +80 -0
  159. package/src/internal/discovery.identity.test.ts +44 -0
  160. package/src/internal/docs-site.test.ts +66 -0
  161. package/src/internal/duration.test.ts +29 -0
  162. package/src/internal/eval-judge-model.test.ts +187 -0
  163. package/src/internal/eval-run-store.cancel.test.ts +142 -0
  164. package/src/internal/eval-run-store.storage.test.ts +211 -0
  165. package/src/internal/eval-runner.http.test.ts +403 -0
  166. package/src/internal/eval-runner.run.test.ts +928 -0
  167. package/src/internal/evals-client.test.ts +307 -0
  168. package/src/internal/event-mapper.test.ts +243 -0
  169. package/src/internal/github-fanout.test.ts +213 -0
  170. package/src/internal/handleAgentServeTrigger.test.ts +179 -0
  171. package/src/internal/host-kv.test.ts +82 -0
  172. package/src/internal/host-platforms.test.ts +126 -0
  173. package/src/internal/http-channel.test.ts +402 -0
  174. package/src/internal/init-project.test.ts +269 -0
  175. package/src/internal/install-cursor-skills.test.ts +262 -0
  176. package/src/internal/local-env.test.ts +100 -0
  177. package/src/internal/log-ring.test.ts +31 -0
  178. package/src/internal/logs-client.test.ts +350 -0
  179. package/src/internal/mcp-endpoint.test.ts +436 -0
  180. package/src/internal/mcp-host.test.ts +298 -0
  181. package/src/internal/mcp-oauth.test.ts +148 -0
  182. package/src/internal/net.test.ts +17 -0
  183. package/src/internal/peer-connections.test.ts +128 -0
  184. package/src/internal/peer-mcp.integration.test.ts +289 -0
  185. package/src/internal/playground/toolchain.test.ts +53 -0
  186. package/src/internal/playground-cli.test.ts +187 -0
  187. package/src/internal/playground-proxy.test.ts +376 -0
  188. package/src/internal/prompt-context.integration.test.ts +232 -0
  189. package/src/internal/prompt-context.test.ts +127 -0
  190. package/src/internal/reminder-runner.test.ts +390 -0
  191. package/src/internal/reminder-store.test.ts +53 -0
  192. package/src/internal/request-headers.test.ts +27 -0
  193. package/src/internal/resolve-prod-target.test.ts +787 -0
  194. package/src/internal/resolved-connections.test.ts +295 -0
  195. package/src/internal/resolved-connections.ts +18 -1
  196. package/src/internal/router.test.ts +57 -0
  197. package/src/internal/sdk-runner.test.ts +290 -0
  198. package/src/internal/session-engine.coalesce.test.ts +169 -0
  199. package/src/internal/session-engine.concurrency.test.ts +250 -0
  200. package/src/internal/session-engine.host-oauth-mcp.test.ts +110 -0
  201. package/src/internal/session-engine.interrupt.test.ts +577 -0
  202. package/src/internal/session-engine.storage.test.ts +547 -0
  203. package/src/internal/session-engine.ts +15 -1
  204. package/src/internal/session-urls.test.ts +28 -0
  205. package/src/internal/sessions-client.test.ts +518 -0
  206. package/src/internal/storage-coordinator.test.ts +517 -0
  207. package/src/internal/tool-call.test.ts +458 -0
  208. package/src/internal/tool-result.test.ts +52 -0
  209. package/src/internal/trajectory.approvals.test.ts +83 -0
  210. package/src/internal/trajectory.subagents.test.ts +198 -0
  211. package/src/internal/turn-governor.test.ts +137 -0
  212. package/src/internal/update-check.test.ts +485 -0
  213. package/src/internal/workspace.test.ts +81 -0
  214. package/src/storage-backends/cursor-hosted.test.ts +121 -0
  215. package/src/types.ts +12 -0
  216. package/dist/docs/assets/chunks/@localSearchIndexroot.BnHRjfoe.js +0 -1
  217. package/dist/playground/assets/index-CidizGZv.css +0 -1
@@ -0,0 +1,1647 @@
1
+ /**
2
+ * Slack provisioning client + `slack create` / `slack destroy` CLI
3
+ * handlers against an injected-fetch fake of the Agent Serve Slack API
4
+ * (backend/server/src/agentServe/agentServeSlackEndpoints): status,
5
+ * connect, app provision, app delete, the stable error codes, and the
6
+ * gate-off 404. Temp dirs play the agent project; the Slack doctor is
7
+ * stubbed (the real one calls slack.com).
8
+ */
9
+
10
+ import {
11
+ mkdir,
12
+ mkdtemp,
13
+ readFile,
14
+ rm,
15
+ stat,
16
+ writeFile,
17
+ } from "node:fs/promises";
18
+ import { tmpdir } from "node:os";
19
+ import { join } from "node:path";
20
+ import { afterEach, describe, expect, it } from "vitest";
21
+ import type { SlackDoctorResult } from "../channels/slack/index.js";
22
+ import { cmdSlack, cmdSlackCreate, cmdSlackDestroy } from "./cli-slack.js";
23
+ import {
24
+ AgentServeSlackClient,
25
+ SlackProvisionApiError,
26
+ } from "./slack-provision-client.js";
27
+
28
+ const TEAM_ID = 7;
29
+ const API_KEY = "crsr_test_key";
30
+ const BACKEND_URL = "http://backend.test";
31
+ const CONNECT_URL = "https://slack.com/oauth/agentkit-consent";
32
+ const AGENT_NAME = "approval-buddy";
33
+ const BOT_KEY = "APPROVAL_BUDDY_SLACK_BOT_TOKEN";
34
+ const APP_KEY = "APPROVAL_BUDDY_SLACK_APP_TOKEN";
35
+
36
+ interface FakeWorkspace {
37
+ slackTeamId: string;
38
+ /** Absent on workspaces connected before the backend captured names. */
39
+ slackTeamName?: string;
40
+ slackUserId: string;
41
+ connectedAt: string;
42
+ }
43
+
44
+ interface FakeApp {
45
+ slackAppId: string;
46
+ slackTeamId: string;
47
+ slug: string;
48
+ env: string;
49
+ installStatus: string;
50
+ createdAt: string;
51
+ updatedAt: string;
52
+ }
53
+
54
+ interface ScriptedResponse {
55
+ status: number;
56
+ body: unknown;
57
+ retryAfterSeconds?: number;
58
+ }
59
+
60
+ interface FakeSlackBackend {
61
+ fetchImpl: typeof fetch;
62
+ workspaces: FakeWorkspace[];
63
+ apps: FakeApp[];
64
+ /**
65
+ * Upserted into `workspaces` after this many status calls once /connect
66
+ * was hit (the consent completing in the browser).
67
+ */
68
+ pendingWorkspace?: { workspace: FakeWorkspace; afterStatusCalls: number };
69
+ /** Scripted `POST /apps` outcomes; the last one repeats. */
70
+ provisionQueue: ScriptedResponse[];
71
+ /** When set, the next matching request answers with this and clears it. */
72
+ nextError?: ScriptedResponse;
73
+ /** When set, the next icon call answers with this and clears it. */
74
+ nextIconError?: ScriptedResponse;
75
+ statusCalls: number;
76
+ connectCalls: number;
77
+ provisionBodies: Array<Record<string, unknown>>;
78
+ iconRequests: Array<{
79
+ slackAppId: string;
80
+ body: Record<string, unknown>;
81
+ }>;
82
+ deletedAppIds: string[];
83
+ seenAuth: string[];
84
+ }
85
+
86
+ function workspaceRow(
87
+ slackTeamId: string,
88
+ connectedAt = "2026-07-01T00:00:00.000Z"
89
+ ): FakeWorkspace {
90
+ return { slackTeamId, slackUserId: `U-${slackTeamId}`, connectedAt };
91
+ }
92
+
93
+ function appRow(overrides: Partial<FakeApp> & { slackAppId: string }): FakeApp {
94
+ return {
95
+ slackTeamId: "T0AAAA",
96
+ slug: AGENT_NAME,
97
+ env: "dev",
98
+ installStatus: "installed",
99
+ createdAt: "2026-07-01T00:00:00.000Z",
100
+ updatedAt: "2026-07-01T00:00:00.000Z",
101
+ ...overrides,
102
+ };
103
+ }
104
+
105
+ const INSTALLED_RESPONSE: ScriptedResponse = {
106
+ status: 200,
107
+ body: {
108
+ status: "installed",
109
+ appId: "A0001",
110
+ botToken: "xoxb-fake-1",
111
+ appToken: "xapp-fake-1",
112
+ },
113
+ };
114
+
115
+ function createFakeSlackBackend(): FakeSlackBackend {
116
+ const state: FakeSlackBackend = {
117
+ fetchImpl: fetch,
118
+ workspaces: [],
119
+ apps: [],
120
+ provisionQueue: [INSTALLED_RESPONSE],
121
+ statusCalls: 0,
122
+ connectCalls: 0,
123
+ provisionBodies: [],
124
+ iconRequests: [],
125
+ deletedAppIds: [],
126
+ seenAuth: [],
127
+ };
128
+
129
+ const json = (scripted: ScriptedResponse): Response => {
130
+ const headers: Record<string, string> = {
131
+ "content-type": "application/json",
132
+ };
133
+ if (scripted.retryAfterSeconds !== undefined) {
134
+ headers["retry-after"] = String(scripted.retryAfterSeconds);
135
+ }
136
+ return new Response(JSON.stringify(scripted.body), {
137
+ status: scripted.status,
138
+ headers,
139
+ });
140
+ };
141
+
142
+ state.fetchImpl = (async (
143
+ input: string | URL | Request,
144
+ init?: RequestInit
145
+ ): Promise<Response> => {
146
+ const url = new URL(String(input));
147
+ const method = init?.method ?? "GET";
148
+ const headers = init?.headers as Record<string, string> | undefined;
149
+ state.seenAuth.push(headers?.authorization ?? "");
150
+
151
+ if (!url.pathname.startsWith("/internal/agent-serve/slack")) {
152
+ return json({ status: 404, body: { error: "Not found" } });
153
+ }
154
+ if (state.nextError !== undefined) {
155
+ const scripted = state.nextError;
156
+ state.nextError = undefined;
157
+ return json(scripted);
158
+ }
159
+
160
+ const rest = url.pathname.slice("/internal/agent-serve/slack".length);
161
+
162
+ if (method === "GET" && rest === "/status") {
163
+ state.statusCalls += 1;
164
+ const pending = state.pendingWorkspace;
165
+ if (pending !== undefined && state.connectCalls > 0) {
166
+ pending.afterStatusCalls -= 1;
167
+ if (pending.afterStatusCalls <= 0) {
168
+ state.workspaces = [
169
+ ...state.workspaces.filter(
170
+ (workspace) =>
171
+ workspace.slackTeamId !== pending.workspace.slackTeamId
172
+ ),
173
+ pending.workspace,
174
+ ];
175
+ state.pendingWorkspace = undefined;
176
+ }
177
+ }
178
+ return json({
179
+ status: 200,
180
+ body: { workspaces: state.workspaces, apps: state.apps },
181
+ });
182
+ }
183
+
184
+ if (method === "POST" && rest === "/connect") {
185
+ state.connectCalls += 1;
186
+ return json({ status: 200, body: { url: CONNECT_URL } });
187
+ }
188
+
189
+ if (method === "POST" && rest === "/apps") {
190
+ state.provisionBodies.push(
191
+ JSON.parse(String(init?.body)) as Record<string, unknown>
192
+ );
193
+ const scripted =
194
+ state.provisionQueue.length > 1
195
+ ? state.provisionQueue.shift()
196
+ : state.provisionQueue[0];
197
+ if (scripted === undefined) {
198
+ return json({
199
+ status: 500,
200
+ body: { error: "no scripted provision outcome" },
201
+ });
202
+ }
203
+ return json(scripted);
204
+ }
205
+
206
+ if (method === "POST" && /^\/apps\/[^/]+\/icon$/.test(rest)) {
207
+ const slackAppId = decodeURIComponent(
208
+ rest.slice("/apps/".length, -"/icon".length)
209
+ );
210
+ state.iconRequests.push({
211
+ slackAppId,
212
+ body: JSON.parse(String(init?.body)) as Record<string, unknown>,
213
+ });
214
+ if (state.nextIconError !== undefined) {
215
+ const scripted = state.nextIconError;
216
+ state.nextIconError = undefined;
217
+ return json(scripted);
218
+ }
219
+ return json({ status: 200, body: { ok: true } });
220
+ }
221
+
222
+ if (method === "DELETE" && rest.startsWith("/apps/")) {
223
+ const slackAppId = decodeURIComponent(
224
+ rest.slice("/apps/".length).split("?")[0] ?? ""
225
+ );
226
+ state.deletedAppIds.push(slackAppId);
227
+ state.apps = state.apps.filter((app) => app.slackAppId !== slackAppId);
228
+ return json({ status: 200, body: { deleted: true } });
229
+ }
230
+
231
+ return json({ status: 404, body: { error: "Not found" } });
232
+ }) as typeof fetch;
233
+
234
+ return state;
235
+ }
236
+
237
+ /** Capture out/err streams for a handler invocation. */
238
+ function capture(): {
239
+ out: (text: string) => void;
240
+ err: (text: string) => void;
241
+ stdout: () => string;
242
+ stderr: () => string;
243
+ } {
244
+ let stdout = "";
245
+ let stderr = "";
246
+ return {
247
+ out: (text) => {
248
+ stdout += text;
249
+ },
250
+ err: (text) => {
251
+ stderr += text;
252
+ },
253
+ stdout: () => stdout,
254
+ stderr: () => stderr,
255
+ };
256
+ }
257
+
258
+ const passingDoctor = async (): Promise<SlackDoctorResult> => ({
259
+ ok: true,
260
+ checks: [
261
+ { id: "app_token", ok: true, message: `${APP_KEY} present` },
262
+ { id: "bot_token", ok: true, message: `${BOT_KEY} present` },
263
+ ],
264
+ envKeys: { bot: BOT_KEY, app: APP_KEY },
265
+ });
266
+
267
+ const tempDirs: string[] = [];
268
+
269
+ afterEach(async () => {
270
+ while (tempDirs.length > 0) {
271
+ const dir = tempDirs.pop();
272
+ if (dir !== undefined) {
273
+ await rm(dir, { recursive: true, force: true });
274
+ }
275
+ }
276
+ // create exports minted tokens for the doctor run; scrub them.
277
+ delete process.env[BOT_KEY];
278
+ delete process.env[APP_KEY];
279
+ });
280
+
281
+ async function makeAgentDir(): Promise<string> {
282
+ const parent = await mkdtemp(join(tmpdir(), "agentkit-slack-cli-"));
283
+ tempDirs.push(parent);
284
+ const dir = join(parent, AGENT_NAME);
285
+ await mkdir(dir, { recursive: true });
286
+ return dir;
287
+ }
288
+
289
+ function makeClient(backend: FakeSlackBackend): AgentServeSlackClient {
290
+ return new AgentServeSlackClient({
291
+ backendUrl: BACKEND_URL,
292
+ apiKey: API_KEY,
293
+ fetchImpl: backend.fetchImpl,
294
+ });
295
+ }
296
+
297
+ type CliIo = ReturnType<typeof capture>;
298
+
299
+ function provisionOptions(
300
+ backend: FakeSlackBackend,
301
+ dir: string,
302
+ io: CliIo,
303
+ extra?: Partial<Parameters<typeof cmdSlackCreate>[0]>
304
+ ): Parameters<typeof cmdSlackCreate>[0] {
305
+ return {
306
+ dir,
307
+ team: String(TEAM_ID),
308
+ apiKey: API_KEY,
309
+ fetchImpl: backend.fetchImpl,
310
+ openBrowserImpl: () => {},
311
+ connectPollIntervalMs: 2,
312
+ connectPollTimeoutMs: 2_000,
313
+ doctorImpl: passingDoctor,
314
+ out: io.out,
315
+ err: io.err,
316
+ ...extra,
317
+ };
318
+ }
319
+
320
+ describe("AgentServeSlackClient", () => {
321
+ it("sends the API key as the bearer", async () => {
322
+ const backend = createFakeSlackBackend();
323
+ await makeClient(backend).status(TEAM_ID);
324
+ expect(backend.seenAuth.at(-1)).toBe(`Bearer ${API_KEY}`);
325
+ });
326
+
327
+ it("maps the gate-off 404 to a feature-flag explanation", async () => {
328
+ const backend = createFakeSlackBackend();
329
+ backend.nextError = { status: 404, body: { error: "Not found" } };
330
+ const error = await makeClient(backend)
331
+ .status(TEAM_ID)
332
+ .then(
333
+ () => undefined,
334
+ (e: unknown) => e
335
+ );
336
+ expect(error).toBeInstanceOf(SlackProvisionApiError);
337
+ expect((error as SlackProvisionApiError).status).toBe(404);
338
+ expect((error as SlackProvisionApiError).message).toMatch(
339
+ /Add to Slack.*agent_serve_slack_provisioning.*not enabled/
340
+ );
341
+ });
342
+
343
+ it("maps Fastify route-not-found 404s to a wrong-host hint", async () => {
344
+ const backend = createFakeSlackBackend();
345
+ backend.nextError = {
346
+ status: 404,
347
+ body: {
348
+ message: "Route POST:/internal/agent-serve/slack/apps not found",
349
+ error: "Not Found",
350
+ statusCode: 404,
351
+ },
352
+ };
353
+ await expect(makeClient(backend).connect(TEAM_ID)).rejects.toThrow(
354
+ /api\.cursor\.com|CURSOR_API_BASE_URL/
355
+ );
356
+ });
357
+
358
+ it("maps 429 with the code and Retry-After seconds", async () => {
359
+ const backend = createFakeSlackBackend();
360
+ backend.provisionQueue = [
361
+ {
362
+ status: 429,
363
+ body: {
364
+ error: "Slack rate limited the provisioning call; retry later",
365
+ code: "ratelimited",
366
+ retryAfterSeconds: 42,
367
+ },
368
+ retryAfterSeconds: 42,
369
+ },
370
+ ];
371
+ const error = await makeClient(backend)
372
+ .provisionApp({
373
+ teamId: TEAM_ID,
374
+ slackTeamId: "T0AAAA",
375
+ slug: AGENT_NAME,
376
+ env: "dev",
377
+ manifest: "{}",
378
+ })
379
+ .then(
380
+ () => undefined,
381
+ (e: unknown) => e
382
+ );
383
+ expect(error).toBeInstanceOf(SlackProvisionApiError);
384
+ expect((error as SlackProvisionApiError).status).toBe(429);
385
+ expect((error as SlackProvisionApiError).code).toBe("ratelimited");
386
+ expect((error as SlackProvisionApiError).retryAfterSeconds).toBe(42);
387
+ expect((error as SlackProvisionApiError).message).toMatch(/retry in 42s/);
388
+ });
389
+
390
+ it("passes manifest_drift changedPaths through on the error", async () => {
391
+ const backend = createFakeSlackBackend();
392
+ backend.provisionQueue = [
393
+ {
394
+ status: 409,
395
+ body: {
396
+ error:
397
+ "The Slack app's manifest was modified outside Cursor; re-run with force to overwrite those changes",
398
+ code: "manifest_drift",
399
+ changedPaths: ["display_information.name", "oauth_config.scopes.bot"],
400
+ },
401
+ },
402
+ ];
403
+ const error = await makeClient(backend)
404
+ .provisionApp({
405
+ teamId: TEAM_ID,
406
+ slackTeamId: "T0AAAA",
407
+ slug: AGENT_NAME,
408
+ env: "dev",
409
+ manifest: "{}",
410
+ })
411
+ .then(
412
+ () => undefined,
413
+ (e: unknown) => e
414
+ );
415
+ expect(error).toBeInstanceOf(SlackProvisionApiError);
416
+ expect((error as SlackProvisionApiError).status).toBe(409);
417
+ expect((error as SlackProvisionApiError).code).toBe("manifest_drift");
418
+ expect((error as SlackProvisionApiError).changedPaths).toEqual([
419
+ "display_information.name",
420
+ "oauth_config.scopes.bot",
421
+ ]);
422
+ });
423
+
424
+ it("sends force only when requested", async () => {
425
+ const backend = createFakeSlackBackend();
426
+ const client = makeClient(backend);
427
+ const args = {
428
+ teamId: TEAM_ID,
429
+ slackTeamId: "T0AAAA",
430
+ slug: AGENT_NAME,
431
+ env: "dev" as const,
432
+ manifest: "{}",
433
+ };
434
+ await client.provisionApp(args);
435
+ expect(backend.provisionBodies[0]).not.toHaveProperty("force");
436
+
437
+ await client.provisionApp({ ...args, force: true });
438
+ expect(backend.provisionBodies[1]!.force).toBe(true);
439
+ });
440
+
441
+ it("passes stable backend codes through on 4xx errors", async () => {
442
+ const backend = createFakeSlackBackend();
443
+ for (const [status, code] of [
444
+ [409, "app_conflict"],
445
+ [400, "app_cap_reached"],
446
+ [400, "invalid_app_id"],
447
+ [409, "insufficient_scopes"],
448
+ [409, "manager_reauth_required"],
449
+ ] as const) {
450
+ backend.provisionQueue = [
451
+ { status, body: { error: `backend says ${code}`, code } },
452
+ ];
453
+ const error = await makeClient(backend)
454
+ .provisionApp({
455
+ teamId: TEAM_ID,
456
+ slackTeamId: "T0AAAA",
457
+ slug: AGENT_NAME,
458
+ env: "dev",
459
+ manifest: "{}",
460
+ })
461
+ .then(
462
+ () => undefined,
463
+ (e: unknown) => e
464
+ );
465
+ expect(error).toBeInstanceOf(SlackProvisionApiError);
466
+ expect((error as SlackProvisionApiError).status).toBe(status);
467
+ expect((error as SlackProvisionApiError).code).toBe(code);
468
+ expect((error as SlackProvisionApiError).message).toContain(code);
469
+ }
470
+ });
471
+ });
472
+
473
+ describe("cmdSlackCreate", () => {
474
+ it("provisions, scaffolds, writes both tokens, and runs the doctor", async () => {
475
+ const backend = createFakeSlackBackend();
476
+ backend.workspaces = [workspaceRow("T0AAAA")];
477
+ const dir = await makeAgentDir();
478
+ const io = capture();
479
+
480
+ const code = await cmdSlackCreate(provisionOptions(backend, dir, io));
481
+ expect(code).toBe(0);
482
+
483
+ // The provision request carried the slug/env/manifest the backend expects.
484
+ expect(backend.provisionBodies).toHaveLength(1);
485
+ const body = backend.provisionBodies[0]!;
486
+ expect(body).toMatchObject({
487
+ teamId: TEAM_ID,
488
+ slackTeamId: "T0AAAA",
489
+ slug: AGENT_NAME,
490
+ env: "dev",
491
+ });
492
+ const manifest = JSON.parse(String(body.manifest)) as {
493
+ features: { bot_user: { display_name: string } };
494
+ settings: { socket_mode_enabled: boolean };
495
+ };
496
+ expect(manifest.features.bot_user.display_name).toBe("approval-buddy-dev");
497
+ expect(manifest.settings.socket_mode_enabled).toBe(true);
498
+
499
+ // Channel scaffold + token delivery.
500
+ const channel = await readFile(
501
+ join(dir, "agent/channels/slack.ts"),
502
+ "utf8"
503
+ );
504
+ expect(channel).toContain('envPrefix: "APPROVAL_BUDDY"');
505
+ const envLocal = await readFile(join(dir, ".env.local"), "utf8");
506
+ expect(envLocal).toContain(`${BOT_KEY}=xoxb-fake-1\n`);
507
+ expect(envLocal).toContain(`${APP_KEY}=xapp-fake-1\n`);
508
+ expect(process.env[BOT_KEY]).toBe("xoxb-fake-1");
509
+ expect(process.env[APP_KEY]).toBe("xapp-fake-1");
510
+
511
+ // Rendered summary, doctor table, and next steps (tokens never printed).
512
+ expect(io.stdout()).toContain("Provisioned the dev Slack app");
513
+ expect(io.stdout()).toContain("doctor: pass");
514
+ expect(io.stdout()).toContain("Next steps");
515
+ expect(io.stdout()).toContain("@approval-buddy-dev");
516
+ expect(io.stdout()).not.toContain("xoxb-fake-1");
517
+ expect(io.stdout()).not.toContain("xapp-fake-1");
518
+ });
519
+
520
+ it("replaces existing token lines on rerun instead of appending duplicates", async () => {
521
+ const backend = createFakeSlackBackend();
522
+ backend.workspaces = [workspaceRow("T0AAAA")];
523
+ const dir = await makeAgentDir();
524
+ await writeFile(
525
+ join(dir, ".env.local"),
526
+ `EXISTING=keepme\n${BOT_KEY}=xoxb-old\n`
527
+ );
528
+
529
+ const first = capture();
530
+ expect(await cmdSlackCreate(provisionOptions(backend, dir, first))).toBe(0);
531
+ backend.provisionQueue = [
532
+ {
533
+ status: 200,
534
+ body: {
535
+ status: "installed",
536
+ appId: "A0001",
537
+ botToken: "xoxb-fake-2",
538
+ appToken: "xapp-fake-2",
539
+ },
540
+ },
541
+ ];
542
+ const second = capture();
543
+ expect(await cmdSlackCreate(provisionOptions(backend, dir, second))).toBe(
544
+ 0
545
+ );
546
+
547
+ const lines = (await readFile(join(dir, ".env.local"), "utf8"))
548
+ .trim()
549
+ .split("\n");
550
+ expect(lines.filter((line) => line.startsWith(`${BOT_KEY}=`))).toEqual([
551
+ `${BOT_KEY}=xoxb-fake-2`,
552
+ ]);
553
+ expect(lines.filter((line) => line.startsWith(`${APP_KEY}=`))).toEqual([
554
+ `${APP_KEY}=xapp-fake-2`,
555
+ ]);
556
+ expect(lines).toContain("EXISTING=keepme");
557
+ });
558
+
559
+ it("runs the connect flow when no workspace is connected, then provisions", async () => {
560
+ const backend = createFakeSlackBackend();
561
+ backend.pendingWorkspace = {
562
+ workspace: workspaceRow("T0AAAA"),
563
+ afterStatusCalls: 2,
564
+ };
565
+ const dir = await makeAgentDir();
566
+ const io = capture();
567
+ const opened: string[] = [];
568
+
569
+ const code = await cmdSlackCreate(
570
+ provisionOptions(backend, dir, io, {
571
+ openBrowserImpl: (url: string) => void opened.push(url),
572
+ })
573
+ );
574
+ expect(code).toBe(0);
575
+ expect(opened).toEqual([CONNECT_URL]);
576
+ expect(backend.connectCalls).toBe(1);
577
+ // Initial status + at least two polls before the workspace appeared.
578
+ expect(backend.statusCalls).toBeGreaterThanOrEqual(3);
579
+ expect(io.stderr()).toContain(CONNECT_URL);
580
+ expect(io.stderr()).toContain("Connected Slack workspace T0AAAA");
581
+ expect(backend.provisionBodies[0]).toMatchObject({
582
+ slackTeamId: "T0AAAA",
583
+ });
584
+ });
585
+
586
+ it("waits only for the --slack-team workspace during connect", async () => {
587
+ const backend = createFakeSlackBackend();
588
+ backend.workspaces = [workspaceRow("T0OTHER")];
589
+ backend.pendingWorkspace = {
590
+ workspace: workspaceRow("T0WANTED"),
591
+ afterStatusCalls: 1,
592
+ };
593
+ const dir = await makeAgentDir();
594
+ const io = capture();
595
+ const code = await cmdSlackCreate(
596
+ provisionOptions(backend, dir, io, { slackTeam: "T0WANTED" })
597
+ );
598
+ expect(code).toBe(0);
599
+ expect(backend.connectCalls).toBe(1);
600
+ expect(backend.provisionBodies[0]).toMatchObject({
601
+ slackTeamId: "T0WANTED",
602
+ });
603
+ });
604
+
605
+ it("times out the connect poll with a rerun hint", async () => {
606
+ const backend = createFakeSlackBackend();
607
+ const dir = await makeAgentDir();
608
+ const io = capture();
609
+ const code = await cmdSlackCreate(
610
+ provisionOptions(backend, dir, io, {
611
+ connectPollIntervalMs: 1,
612
+ connectPollTimeoutMs: 10,
613
+ })
614
+ );
615
+ expect(code).toBe(1);
616
+ expect(io.stderr()).toContain("consent link stays valid");
617
+ expect(io.stderr()).toContain("slack create");
618
+ expect(backend.provisionBodies).toHaveLength(0);
619
+ });
620
+
621
+ it("loops back to connect once on not_connected, then retries the provision", async () => {
622
+ const backend = createFakeSlackBackend();
623
+ backend.workspaces = [workspaceRow("T0AAAA", "2026-07-01T00:00:00.000Z")];
624
+ backend.pendingWorkspace = {
625
+ workspace: workspaceRow("T0AAAA", "2026-07-02T00:00:00.000Z"),
626
+ afterStatusCalls: 1,
627
+ };
628
+ backend.provisionQueue = [
629
+ {
630
+ status: 409,
631
+ body: { error: "Slack workspace not connected", code: "not_connected" },
632
+ },
633
+ INSTALLED_RESPONSE,
634
+ ];
635
+ const dir = await makeAgentDir();
636
+ const io = capture();
637
+ const code = await cmdSlackCreate(provisionOptions(backend, dir, io));
638
+ expect(code).toBe(0);
639
+ expect(backend.connectCalls).toBe(1);
640
+ expect(backend.provisionBodies).toHaveLength(2);
641
+ expect(io.stderr()).toContain("not connected to Cursor");
642
+ });
643
+
644
+ it("does not accept a stale status row as the not_connected re-grant", async () => {
645
+ const backend = createFakeSlackBackend();
646
+ // Status keeps listing the pre-existing row, so no fresh grant lands.
647
+ backend.workspaces = [workspaceRow("T0AAAA", "2026-07-01T00:00:00.000Z")];
648
+ backend.provisionQueue = [
649
+ {
650
+ status: 409,
651
+ body: { error: "Slack workspace not connected", code: "not_connected" },
652
+ },
653
+ INSTALLED_RESPONSE,
654
+ ];
655
+ const dir = await makeAgentDir();
656
+ const io = capture();
657
+ const code = await cmdSlackCreate(
658
+ provisionOptions(backend, dir, io, {
659
+ connectPollIntervalMs: 1,
660
+ connectPollTimeoutMs: 10,
661
+ })
662
+ );
663
+ expect(code).toBe(1);
664
+ expect(io.stderr()).toContain("consent link stays valid");
665
+ expect(backend.provisionBodies).toHaveLength(1);
666
+ });
667
+
668
+ it("re-consents on insufficient_scopes (waits for a fresh grant), then retries", async () => {
669
+ const backend = createFakeSlackBackend();
670
+ backend.workspaces = [workspaceRow("T0AAAA", "2026-07-01T00:00:00.000Z")];
671
+ backend.pendingWorkspace = {
672
+ workspace: workspaceRow("T0AAAA", "2026-07-02T00:00:00.000Z"),
673
+ afterStatusCalls: 1,
674
+ };
675
+ backend.provisionQueue = [
676
+ {
677
+ status: 409,
678
+ body: {
679
+ error:
680
+ "Slack manager authorization is missing scopes managed_apps:install; re-connect the workspace",
681
+ code: "insufficient_scopes",
682
+ },
683
+ },
684
+ INSTALLED_RESPONSE,
685
+ ];
686
+ const dir = await makeAgentDir();
687
+ const io = capture();
688
+ const code = await cmdSlackCreate(provisionOptions(backend, dir, io));
689
+ expect(code).toBe(0);
690
+ expect(backend.connectCalls).toBe(1);
691
+ expect(backend.provisionBodies).toHaveLength(2);
692
+ expect(io.stderr()).toContain("missing scopes");
693
+ });
694
+
695
+ function reauthRequired(slackTeamId: string): ScriptedResponse {
696
+ return {
697
+ status: 409,
698
+ body: {
699
+ error: `Slack no longer accepts Cursor's authorization for workspace ${slackTeamId} (token_revoked); reconnect the workspace to manage its apps`,
700
+ code: "manager_reauth_required",
701
+ slackTeamId,
702
+ },
703
+ };
704
+ }
705
+
706
+ it("re-consents on manager_reauth_required (waits for a fresh grant), then retries", async () => {
707
+ const backend = createFakeSlackBackend();
708
+ backend.workspaces = [workspaceRow("T0AAAA", "2026-07-01T00:00:00.000Z")];
709
+ backend.pendingWorkspace = {
710
+ workspace: workspaceRow("T0AAAA", "2026-07-02T00:00:00.000Z"),
711
+ afterStatusCalls: 1,
712
+ };
713
+ backend.provisionQueue = [reauthRequired("T0AAAA"), INSTALLED_RESPONSE];
714
+ const dir = await makeAgentDir();
715
+ const io = capture();
716
+
717
+ const code = await cmdSlackCreate(provisionOptions(backend, dir, io));
718
+
719
+ expect(code).toBe(0);
720
+ expect(backend.connectCalls).toBe(1);
721
+ expect(backend.provisionBodies).toHaveLength(2);
722
+ expect(io.stderr()).toContain(
723
+ "Slack revoked Cursor's authorization for workspace T0AAAA"
724
+ );
725
+ });
726
+
727
+ it("names the re-auth command when the reconnect does not fix it", async () => {
728
+ const backend = createFakeSlackBackend();
729
+ backend.workspaces = [workspaceRow("T0AAAA", "2026-07-01T00:00:00.000Z")];
730
+ backend.pendingWorkspace = {
731
+ workspace: workspaceRow("T0AAAA", "2026-07-02T00:00:00.000Z"),
732
+ afterStatusCalls: 1,
733
+ };
734
+ backend.provisionQueue = [reauthRequired("T0AAAA")];
735
+ const dir = await makeAgentDir();
736
+ const io = capture();
737
+
738
+ const code = await cmdSlackCreate(provisionOptions(backend, dir, io));
739
+
740
+ expect(code).toBe(1);
741
+ expect(backend.provisionBodies).toHaveLength(2);
742
+ expect(io.stderr()).toContain("workspace T0AAAA (token_revoked)");
743
+ expect(io.stderr()).toContain("slack create");
744
+ });
745
+
746
+ it("exits 1 on pending_admin_approval with the approval link", async () => {
747
+ const backend = createFakeSlackBackend();
748
+ backend.workspaces = [workspaceRow("T0AAAA")];
749
+ backend.provisionQueue = [
750
+ {
751
+ status: 200,
752
+ body: {
753
+ status: "pending_admin_approval",
754
+ appId: "A0009",
755
+ oauthAuthorizeUrl: "https://slack.com/admin-approve/A0009",
756
+ },
757
+ },
758
+ ];
759
+ const dir = await makeAgentDir();
760
+ const io = capture();
761
+ const code = await cmdSlackCreate(provisionOptions(backend, dir, io));
762
+ expect(code).toBe(1);
763
+ expect(io.stderr()).toContain("workspace-admin approval");
764
+ expect(io.stderr()).toContain("https://slack.com/admin-approve/A0009");
765
+ expect(io.stderr()).toContain("re-run");
766
+ await expect(readFile(join(dir, ".env.local"), "utf8")).rejects.toThrow();
767
+ });
768
+
769
+ it("names the workspaces in the picker, falling back to the raw id", async () => {
770
+ const backend = createFakeSlackBackend();
771
+ backend.workspaces = [
772
+ { ...workspaceRow("T0AAAA"), slackTeamName: "Acme Corp" },
773
+ workspaceRow("T0BBBB"),
774
+ ];
775
+ const dir = await makeAgentDir();
776
+ const io = capture();
777
+
778
+ const code = await cmdSlackCreate(provisionOptions(backend, dir, io));
779
+
780
+ expect(code).toBe(1);
781
+ expect(io.stderr()).toContain("Acme Corp (T0AAAA)");
782
+ expect(io.stderr()).toContain("- T0BBBB (Slack user");
783
+ });
784
+
785
+ it("errors on multiple connected workspaces without --slack-team", async () => {
786
+ const backend = createFakeSlackBackend();
787
+ backend.workspaces = [workspaceRow("T0AAAA"), workspaceRow("T0BBBB")];
788
+ const dir = await makeAgentDir();
789
+ const io = capture();
790
+ const code = await cmdSlackCreate(provisionOptions(backend, dir, io));
791
+ expect(code).toBe(1);
792
+ expect(io.stderr()).toContain("T0AAAA");
793
+ expect(io.stderr()).toContain("T0BBBB");
794
+ expect(io.stderr()).toContain("--slack-team");
795
+ expect(backend.provisionBodies).toHaveLength(0);
796
+
797
+ const picked = capture();
798
+ const pickedCode = await cmdSlackCreate(
799
+ provisionOptions(backend, dir, picked, { slackTeam: "T0BBBB" })
800
+ );
801
+ expect(pickedCode).toBe(0);
802
+ expect(backend.provisionBodies[0]).toMatchObject({
803
+ slackTeamId: "T0BBBB",
804
+ });
805
+ });
806
+
807
+ it("provisions the prod app with --prod", async () => {
808
+ const backend = createFakeSlackBackend();
809
+ backend.workspaces = [workspaceRow("T0AAAA")];
810
+ const dir = await makeAgentDir();
811
+ const io = capture();
812
+ const code = await cmdSlackCreate(
813
+ provisionOptions(backend, dir, io, { prod: true })
814
+ );
815
+ expect(code).toBe(0);
816
+ expect(backend.provisionBodies[0]).toMatchObject({ env: "prod" });
817
+ const manifest = JSON.parse(
818
+ String(backend.provisionBodies[0]!.manifest)
819
+ ) as { features: { bot_user: { display_name: string } } };
820
+ expect(manifest.features.bot_user.display_name).toBe("approval-buddy");
821
+ });
822
+
823
+ it("renders actionable errors for app_cap_reached and invalid_app_id", async () => {
824
+ const backend = createFakeSlackBackend();
825
+ backend.workspaces = [workspaceRow("T0AAAA")];
826
+ const dir = await makeAgentDir();
827
+
828
+ backend.provisionQueue = [
829
+ {
830
+ status: 400,
831
+ body: {
832
+ error: "You already provisioned 25 live Slack apps (limit 25)",
833
+ code: "app_cap_reached",
834
+ },
835
+ },
836
+ ];
837
+ const capReached = capture();
838
+ expect(
839
+ await cmdSlackCreate(provisionOptions(backend, dir, capReached))
840
+ ).toBe(1);
841
+ expect(capReached.stderr()).toContain("limit 25");
842
+ expect(capReached.stderr()).toContain("slack destroy");
843
+
844
+ backend.provisionQueue = [
845
+ {
846
+ status: 400,
847
+ body: {
848
+ error: "Slack rejected the provisioning call: invalid_app_id",
849
+ code: "invalid_app_id",
850
+ },
851
+ },
852
+ ];
853
+ const invalidApp = capture();
854
+ expect(
855
+ await cmdSlackCreate(provisionOptions(backend, dir, invalidApp))
856
+ ).toBe(1);
857
+ expect(invalidApp.stderr()).toContain("deleted outside Cursor");
858
+ expect(invalidApp.stderr()).toContain("slack destroy");
859
+ });
860
+
861
+ it("surfaces the gate-off 404 through the CLI", async () => {
862
+ const backend = createFakeSlackBackend();
863
+ backend.nextError = { status: 404, body: { error: "Not found" } };
864
+ const dir = await makeAgentDir();
865
+ const io = capture();
866
+ const code = await cmdSlackCreate(provisionOptions(backend, dir, io));
867
+ expect(code).toBe(1);
868
+ expect(io.stderr()).toContain("agent_serve_slack_provisioning");
869
+ });
870
+
871
+ it("emits machine-readable JSON with --json (no tokens)", async () => {
872
+ const backend = createFakeSlackBackend();
873
+ backend.workspaces = [workspaceRow("T0AAAA")];
874
+ const dir = await makeAgentDir();
875
+ const io = capture();
876
+ const code = await cmdSlackCreate(
877
+ provisionOptions(backend, dir, io, { json: true })
878
+ );
879
+ expect(code).toBe(0);
880
+ const parsed = JSON.parse(io.stdout()) as Record<string, unknown>;
881
+ expect(parsed).toMatchObject({
882
+ ok: true,
883
+ appId: "A0001",
884
+ status: "installed",
885
+ slackTeamId: "T0AAAA",
886
+ envKeys: { bot: BOT_KEY, app: APP_KEY },
887
+ });
888
+ expect(parsed.wrote).toContain(".env.local");
889
+ expect(parsed.wrote).toContain("agent/channels/slack.ts");
890
+ expect(io.stdout()).not.toContain("xoxb-fake-1");
891
+ expect(io.stdout()).not.toContain("xapp-fake-1");
892
+ });
893
+ });
894
+
895
+ describe("cmdSlackCreate manifest drift", () => {
896
+ const DRIFT_RESPONSE: ScriptedResponse = {
897
+ status: 409,
898
+ body: {
899
+ error:
900
+ "The Slack app's manifest was modified outside Cursor; re-run with force to overwrite those changes",
901
+ code: "manifest_drift",
902
+ changedPaths: [
903
+ "display_information.name",
904
+ "settings.event_subscriptions.bot_events",
905
+ ],
906
+ },
907
+ };
908
+
909
+ it("renders the changed paths and the --force hint, exiting 1", async () => {
910
+ const backend = createFakeSlackBackend();
911
+ backend.workspaces = [workspaceRow("T0AAAA")];
912
+ backend.provisionQueue = [DRIFT_RESPONSE];
913
+ const dir = await makeAgentDir();
914
+ const io = capture();
915
+
916
+ const code = await cmdSlackCreate(provisionOptions(backend, dir, io));
917
+
918
+ expect(code).toBe(1);
919
+ expect(io.stderr()).toContain("modified outside Cursor");
920
+ expect(io.stderr()).toContain(" - display_information.name\n");
921
+ expect(io.stderr()).toContain(
922
+ " - settings.event_subscriptions.bot_events\n"
923
+ );
924
+ expect(io.stderr()).toContain("slack create --force");
925
+ expect(backend.provisionBodies[0]).not.toHaveProperty("force");
926
+ await expect(readFile(join(dir, ".env.local"), "utf8")).rejects.toThrow();
927
+ });
928
+
929
+ it("re-running with --force sends force: true and completes the create", async () => {
930
+ const backend = createFakeSlackBackend();
931
+ backend.workspaces = [workspaceRow("T0AAAA")];
932
+ backend.provisionQueue = [DRIFT_RESPONSE, INSTALLED_RESPONSE];
933
+ const dir = await makeAgentDir();
934
+
935
+ const refused = capture();
936
+ expect(await cmdSlackCreate(provisionOptions(backend, dir, refused))).toBe(
937
+ 1
938
+ );
939
+ const forced = capture();
940
+ expect(
941
+ await cmdSlackCreate(
942
+ provisionOptions(backend, dir, forced, { force: true })
943
+ )
944
+ ).toBe(0);
945
+
946
+ expect(backend.provisionBodies).toHaveLength(2);
947
+ expect(backend.provisionBodies[1]!.force).toBe(true);
948
+ expect(await readFile(join(dir, ".env.local"), "utf8")).toContain(BOT_KEY);
949
+ });
950
+
951
+ it("keeps the --json shape on a forced create", async () => {
952
+ const backend = createFakeSlackBackend();
953
+ backend.workspaces = [workspaceRow("T0AAAA")];
954
+ const dir = await makeAgentDir();
955
+ const io = capture();
956
+
957
+ const code = await cmdSlackCreate(
958
+ provisionOptions(backend, dir, io, { force: true, json: true })
959
+ );
960
+
961
+ expect(code).toBe(0);
962
+ expect(backend.provisionBodies[0]!.force).toBe(true);
963
+ expect(JSON.parse(io.stdout())).toMatchObject({
964
+ ok: true,
965
+ appId: "A0001",
966
+ status: "installed",
967
+ slackTeamId: "T0AAAA",
968
+ });
969
+ });
970
+ });
971
+
972
+ describe("cmdSlackDestroy", () => {
973
+ it("deletes the matching app and leaves .env.local alone", async () => {
974
+ const backend = createFakeSlackBackend();
975
+ backend.workspaces = [workspaceRow("T0AAAA")];
976
+ backend.apps = [
977
+ appRow({ slackAppId: "A0100" }),
978
+ appRow({ slackAppId: "A0101", env: "prod" }),
979
+ ];
980
+ const dir = await makeAgentDir();
981
+ await writeFile(join(dir, ".env.local"), `${BOT_KEY}=xoxb-live\n`);
982
+ const io = capture();
983
+
984
+ const code = await cmdSlackDestroy(provisionOptions(backend, dir, io));
985
+ expect(code).toBe(0);
986
+ expect(backend.deletedAppIds).toEqual(["A0100"]);
987
+ expect(io.stdout()).toContain("Deleted the dev Slack app");
988
+ expect(io.stdout()).toContain("left in place");
989
+ // Stale tokens are deliberately untouched.
990
+ expect(await readFile(join(dir, ".env.local"), "utf8")).toBe(
991
+ `${BOT_KEY}=xoxb-live\n`
992
+ );
993
+ });
994
+
995
+ it("targets the prod app with --prod", async () => {
996
+ const backend = createFakeSlackBackend();
997
+ backend.workspaces = [workspaceRow("T0AAAA")];
998
+ backend.apps = [
999
+ appRow({ slackAppId: "A0100" }),
1000
+ appRow({ slackAppId: "A0101", env: "prod" }),
1001
+ ];
1002
+ const dir = await makeAgentDir();
1003
+ const io = capture();
1004
+ const code = await cmdSlackDestroy(
1005
+ provisionOptions(backend, dir, io, { prod: true })
1006
+ );
1007
+ expect(code).toBe(0);
1008
+ expect(backend.deletedAppIds).toEqual(["A0101"]);
1009
+ });
1010
+
1011
+ it("errors listing candidates when several workspaces match, then --slack-team picks one", async () => {
1012
+ const backend = createFakeSlackBackend();
1013
+ backend.workspaces = [workspaceRow("T0AAAA"), workspaceRow("T0BBBB")];
1014
+ backend.apps = [
1015
+ appRow({ slackAppId: "A0100", slackTeamId: "T0AAAA" }),
1016
+ appRow({ slackAppId: "A0200", slackTeamId: "T0BBBB" }),
1017
+ ];
1018
+ const dir = await makeAgentDir();
1019
+ const ambiguous = capture();
1020
+ const code = await cmdSlackDestroy(
1021
+ provisionOptions(backend, dir, ambiguous)
1022
+ );
1023
+ expect(code).toBe(1);
1024
+ expect(ambiguous.stderr()).toContain("A0100");
1025
+ expect(ambiguous.stderr()).toContain("A0200");
1026
+ expect(ambiguous.stderr()).toContain("--slack-team");
1027
+ expect(backend.deletedAppIds).toEqual([]);
1028
+
1029
+ const picked = capture();
1030
+ const pickedCode = await cmdSlackDestroy(
1031
+ provisionOptions(backend, dir, picked, { slackTeam: "T0BBBB" })
1032
+ );
1033
+ expect(pickedCode).toBe(0);
1034
+ expect(backend.deletedAppIds).toEqual(["A0200"]);
1035
+ });
1036
+
1037
+ it("reports when no app matches, listing the agent's other apps", async () => {
1038
+ const backend = createFakeSlackBackend();
1039
+ backend.workspaces = [workspaceRow("T0AAAA")];
1040
+ backend.apps = [appRow({ slackAppId: "A0101", env: "prod" })];
1041
+ const dir = await makeAgentDir();
1042
+ const io = capture();
1043
+ const code = await cmdSlackDestroy(provisionOptions(backend, dir, io));
1044
+ expect(code).toBe(1);
1045
+ expect(io.stderr()).toContain("No provisioned dev Slack app");
1046
+ expect(io.stderr()).toContain("A0101");
1047
+ expect(backend.deletedAppIds).toEqual([]);
1048
+ });
1049
+ });
1050
+
1051
+ async function writeIconFile(
1052
+ dir: string,
1053
+ name = "icon.png",
1054
+ bytes: Buffer = Buffer.from("png-bytes")
1055
+ ): Promise<string> {
1056
+ const path = join(dir, name);
1057
+ await writeFile(path, bytes);
1058
+ return path;
1059
+ }
1060
+
1061
+ describe("create --icon", () => {
1062
+ const ICON_URL = "https://example.com/icon.png";
1063
+ const INSTALLED_WITH_ICON: ScriptedResponse = {
1064
+ status: 200,
1065
+ body: { ...(INSTALLED_RESPONSE.body as object), iconStatus: "set" },
1066
+ };
1067
+
1068
+ it("sends an https --icon inline on the provision and reports it in --json", async () => {
1069
+ const backend = createFakeSlackBackend();
1070
+ backend.workspaces = [workspaceRow("T0AAAA")];
1071
+ backend.provisionQueue = [INSTALLED_WITH_ICON];
1072
+ const dir = await makeAgentDir();
1073
+ const io = capture();
1074
+
1075
+ const code = await cmdSlackCreate(
1076
+ provisionOptions(backend, dir, io, { icon: ICON_URL, json: true })
1077
+ );
1078
+
1079
+ expect(code).toBe(0);
1080
+ expect(backend.provisionBodies[0]!.icon).toEqual({ url: ICON_URL });
1081
+ // URL icons ride the provision request; nothing posts to the endpoint.
1082
+ expect(backend.iconRequests).toEqual([]);
1083
+ const parsed = JSON.parse(io.stdout()) as Record<string, unknown>;
1084
+ expect(parsed.iconStatus).toBe("set");
1085
+ expect(io.stderr()).not.toContain("warning");
1086
+ });
1087
+
1088
+ it("warns without failing when the backend reports the URL icon failed", async () => {
1089
+ const backend = createFakeSlackBackend();
1090
+ backend.workspaces = [workspaceRow("T0AAAA")];
1091
+ backend.provisionQueue = [
1092
+ {
1093
+ status: 200,
1094
+ body: { ...(INSTALLED_RESPONSE.body as object), iconStatus: "failed" },
1095
+ },
1096
+ ];
1097
+ const dir = await makeAgentDir();
1098
+ const io = capture();
1099
+
1100
+ const code = await cmdSlackCreate(
1101
+ provisionOptions(backend, dir, io, { icon: ICON_URL })
1102
+ );
1103
+
1104
+ expect(code).toBe(0);
1105
+ expect(io.stderr()).toContain("warning: setting the Slack app icon");
1106
+ expect(io.stderr()).toContain(`slack icon ${ICON_URL}`);
1107
+ });
1108
+
1109
+ it("posts a file --icon to the icon endpoint after the install", async () => {
1110
+ const backend = createFakeSlackBackend();
1111
+ backend.workspaces = [workspaceRow("T0AAAA")];
1112
+ const dir = await makeAgentDir();
1113
+ const iconPath = await writeIconFile(dir);
1114
+ const io = capture();
1115
+
1116
+ const code = await cmdSlackCreate(
1117
+ provisionOptions(backend, dir, io, { icon: iconPath, json: true })
1118
+ );
1119
+
1120
+ expect(code).toBe(0);
1121
+ expect(backend.provisionBodies[0]!.icon).toBeUndefined();
1122
+ expect(backend.iconRequests).toEqual([
1123
+ {
1124
+ slackAppId: "A0001",
1125
+ body: {
1126
+ teamId: TEAM_ID,
1127
+ icon: {
1128
+ base64: Buffer.from("png-bytes").toString("base64"),
1129
+ contentType: "image/png",
1130
+ },
1131
+ },
1132
+ },
1133
+ ]);
1134
+ const parsed = JSON.parse(io.stdout()) as Record<string, unknown>;
1135
+ expect(parsed.iconStatus).toBe("set");
1136
+ });
1137
+
1138
+ it("warns without failing the create when the icon endpoint rejects", async () => {
1139
+ const backend = createFakeSlackBackend();
1140
+ backend.workspaces = [workspaceRow("T0AAAA")];
1141
+ backend.nextIconError = {
1142
+ status: 400,
1143
+ body: {
1144
+ error: "Slack rejected the provisioning call: invalid_parameters",
1145
+ code: "invalid_parameters",
1146
+ },
1147
+ };
1148
+ const dir = await makeAgentDir();
1149
+ const iconPath = await writeIconFile(dir);
1150
+ const io = capture();
1151
+
1152
+ const code = await cmdSlackCreate(
1153
+ provisionOptions(backend, dir, io, { icon: iconPath })
1154
+ );
1155
+
1156
+ expect(code).toBe(0);
1157
+ expect(io.stderr()).toContain("warning: setting the Slack app icon");
1158
+ expect(io.stderr()).toContain("invalid_parameters");
1159
+ expect(io.stderr()).toContain(`slack icon ${iconPath}`);
1160
+ // Tokens were still written.
1161
+ expect(await readFile(join(dir, ".env.local"), "utf8")).toContain(BOT_KEY);
1162
+ });
1163
+
1164
+ it("warns without failing the create when the icon endpoint returns a malformed 200", async () => {
1165
+ const backend = createFakeSlackBackend();
1166
+ backend.workspaces = [workspaceRow("T0AAAA")];
1167
+ backend.nextIconError = { status: 200, body: { unexpected: "shape" } };
1168
+ const dir = await makeAgentDir();
1169
+ const iconPath = await writeIconFile(dir);
1170
+ const io = capture();
1171
+
1172
+ const code = await cmdSlackCreate(
1173
+ provisionOptions(backend, dir, io, { icon: iconPath })
1174
+ );
1175
+
1176
+ expect(code).toBe(0);
1177
+ expect(io.stderr()).toContain("warning: setting the Slack app icon");
1178
+ expect(io.stderr()).toContain(`slack icon ${iconPath}`);
1179
+ expect(await readFile(join(dir, ".env.local"), "utf8")).toContain(BOT_KEY);
1180
+ });
1181
+
1182
+ it("still sets a file icon on pending_admin_approval (the app exists)", async () => {
1183
+ const backend = createFakeSlackBackend();
1184
+ backend.workspaces = [workspaceRow("T0AAAA")];
1185
+ backend.provisionQueue = [
1186
+ {
1187
+ status: 200,
1188
+ body: {
1189
+ status: "pending_admin_approval",
1190
+ appId: "A0009",
1191
+ oauthAuthorizeUrl: "https://slack.com/admin-approve/A0009",
1192
+ },
1193
+ },
1194
+ ];
1195
+ const dir = await makeAgentDir();
1196
+ const iconPath = await writeIconFile(dir);
1197
+ const io = capture();
1198
+
1199
+ const code = await cmdSlackCreate(
1200
+ provisionOptions(backend, dir, io, { icon: iconPath, json: true })
1201
+ );
1202
+
1203
+ expect(code).toBe(1);
1204
+ expect(backend.iconRequests.map((req) => req.slackAppId)).toEqual([
1205
+ "A0009",
1206
+ ]);
1207
+ const parsed = JSON.parse(io.stdout()) as Record<string, unknown>;
1208
+ expect(parsed).toMatchObject({
1209
+ ok: false,
1210
+ status: "pending_admin_approval",
1211
+ iconStatus: "set",
1212
+ });
1213
+ });
1214
+
1215
+ it("rejects a non-image icon path before provisioning", async () => {
1216
+ const backend = createFakeSlackBackend();
1217
+ backend.workspaces = [workspaceRow("T0AAAA")];
1218
+ const dir = await makeAgentDir();
1219
+ const iconPath = await writeIconFile(dir, "icon.bmp");
1220
+ const io = capture();
1221
+
1222
+ const code = await cmdSlackCreate(
1223
+ provisionOptions(backend, dir, io, { icon: iconPath })
1224
+ );
1225
+
1226
+ expect(code).toBe(1);
1227
+ expect(io.stderr()).toContain(".png, .jpg/.jpeg, or .gif");
1228
+ expect(backend.provisionBodies).toEqual([]);
1229
+ });
1230
+
1231
+ it("rejects an oversized icon file before provisioning", async () => {
1232
+ const backend = createFakeSlackBackend();
1233
+ backend.workspaces = [workspaceRow("T0AAAA")];
1234
+ const dir = await makeAgentDir();
1235
+ const iconPath = await writeIconFile(
1236
+ dir,
1237
+ "icon.png",
1238
+ Buffer.alloc(524_289)
1239
+ );
1240
+ const io = capture();
1241
+
1242
+ const code = await cmdSlackCreate(
1243
+ provisionOptions(backend, dir, io, { icon: iconPath })
1244
+ );
1245
+
1246
+ expect(code).toBe(1);
1247
+ expect(io.stderr()).toContain("at most 524288 bytes");
1248
+ expect(backend.provisionBodies).toEqual([]);
1249
+ });
1250
+
1251
+ it("rejects a non-https icon URL", async () => {
1252
+ const backend = createFakeSlackBackend();
1253
+ backend.workspaces = [workspaceRow("T0AAAA")];
1254
+ const dir = await makeAgentDir();
1255
+ const io = capture();
1256
+
1257
+ const code = await cmdSlackCreate(
1258
+ provisionOptions(backend, dir, io, {
1259
+ icon: "http://example.com/icon.png",
1260
+ })
1261
+ );
1262
+
1263
+ expect(code).toBe(1);
1264
+ expect(io.stderr()).toContain("must be https");
1265
+ expect(backend.provisionBodies).toEqual([]);
1266
+ });
1267
+ });
1268
+
1269
+ describe("cmdSlack icon", () => {
1270
+ const ICON_URL = "https://example.com/icon.png";
1271
+
1272
+ it("resolves the app like destroy and posts the url form", async () => {
1273
+ const backend = createFakeSlackBackend();
1274
+ backend.workspaces = [workspaceRow("T0AAAA")];
1275
+ backend.apps = [
1276
+ appRow({ slackAppId: "A0100" }),
1277
+ appRow({ slackAppId: "A0101", env: "prod" }),
1278
+ ];
1279
+ const dir = await makeAgentDir();
1280
+ const io = capture();
1281
+
1282
+ const code = await cmdSlack(
1283
+ "icon",
1284
+ provisionOptions(backend, dir, io, { iconSource: ICON_URL })
1285
+ );
1286
+
1287
+ expect(code).toBe(0);
1288
+ expect(backend.iconRequests).toEqual([
1289
+ {
1290
+ slackAppId: "A0100",
1291
+ body: { teamId: TEAM_ID, icon: { url: ICON_URL } },
1292
+ },
1293
+ ]);
1294
+ expect(io.stdout()).toContain("Set the icon for the dev Slack app");
1295
+ });
1296
+
1297
+ it("targets the prod app with --prod and reads a file source with --json", async () => {
1298
+ const backend = createFakeSlackBackend();
1299
+ backend.workspaces = [workspaceRow("T0AAAA")];
1300
+ backend.apps = [
1301
+ appRow({ slackAppId: "A0100" }),
1302
+ appRow({ slackAppId: "A0101", env: "prod" }),
1303
+ ];
1304
+ const dir = await makeAgentDir();
1305
+ const iconPath = await writeIconFile(
1306
+ dir,
1307
+ "icon.gif",
1308
+ Buffer.from("gif-bytes")
1309
+ );
1310
+ const io = capture();
1311
+
1312
+ const code = await cmdSlack(
1313
+ "icon",
1314
+ provisionOptions(backend, dir, io, {
1315
+ iconSource: iconPath,
1316
+ prod: true,
1317
+ json: true,
1318
+ })
1319
+ );
1320
+
1321
+ expect(code).toBe(0);
1322
+ expect(backend.iconRequests).toEqual([
1323
+ {
1324
+ slackAppId: "A0101",
1325
+ body: {
1326
+ teamId: TEAM_ID,
1327
+ icon: {
1328
+ base64: Buffer.from("gif-bytes").toString("base64"),
1329
+ contentType: "image/gif",
1330
+ },
1331
+ },
1332
+ },
1333
+ ]);
1334
+ expect(JSON.parse(io.stdout())).toEqual({
1335
+ ok: true,
1336
+ appId: "A0101",
1337
+ slackTeamId: "T0AAAA",
1338
+ slug: AGENT_NAME,
1339
+ env: "prod",
1340
+ iconStatus: "set",
1341
+ });
1342
+ });
1343
+
1344
+ it("reports when no app matches", async () => {
1345
+ const backend = createFakeSlackBackend();
1346
+ backend.workspaces = [workspaceRow("T0AAAA")];
1347
+ const dir = await makeAgentDir();
1348
+ const io = capture();
1349
+
1350
+ const code = await cmdSlack(
1351
+ "icon",
1352
+ provisionOptions(backend, dir, io, { iconSource: ICON_URL })
1353
+ );
1354
+
1355
+ expect(code).toBe(1);
1356
+ expect(io.stderr()).toContain("No provisioned dev Slack app");
1357
+ expect(backend.iconRequests).toEqual([]);
1358
+ });
1359
+
1360
+ it("renders icon API errors and exits 1 (no warn-don't-fail here)", async () => {
1361
+ const backend = createFakeSlackBackend();
1362
+ backend.workspaces = [workspaceRow("T0AAAA")];
1363
+ backend.apps = [appRow({ slackAppId: "A0100" })];
1364
+ backend.nextIconError = {
1365
+ status: 429,
1366
+ body: {
1367
+ error: "Slack rate limited the provisioning call; retry later",
1368
+ code: "ratelimited",
1369
+ retryAfterSeconds: 30,
1370
+ },
1371
+ retryAfterSeconds: 30,
1372
+ };
1373
+ const dir = await makeAgentDir();
1374
+ const io = capture();
1375
+
1376
+ const code = await cmdSlack(
1377
+ "icon",
1378
+ provisionOptions(backend, dir, io, { iconSource: ICON_URL })
1379
+ );
1380
+
1381
+ expect(code).toBe(1);
1382
+ expect(io.stderr()).toContain("retry in 30s");
1383
+ });
1384
+
1385
+ // No standalone connect command exists; create runs the consent flow.
1386
+ it("points manager_reauth_required at the re-auth command", async () => {
1387
+ const backend = createFakeSlackBackend();
1388
+ backend.workspaces = [workspaceRow("T0AAAA")];
1389
+ backend.apps = [appRow({ slackAppId: "A0100" })];
1390
+ backend.nextIconError = {
1391
+ status: 409,
1392
+ body: {
1393
+ error:
1394
+ "Slack no longer accepts Cursor's authorization for workspace T0AAAA (invalid_auth); reconnect the workspace to manage its apps",
1395
+ code: "manager_reauth_required",
1396
+ slackTeamId: "T0AAAA",
1397
+ },
1398
+ };
1399
+ const dir = await makeAgentDir();
1400
+ const io = capture();
1401
+
1402
+ const code = await cmdSlack(
1403
+ "icon",
1404
+ provisionOptions(backend, dir, io, { iconSource: ICON_URL })
1405
+ );
1406
+
1407
+ expect(code).toBe(1);
1408
+ expect(io.stderr()).toContain("workspace T0AAAA (invalid_auth)");
1409
+ expect(io.stderr()).toContain("Re-approve the Slack consent");
1410
+ expect(io.stderr()).toContain("slack create");
1411
+ });
1412
+
1413
+ it("rejects an over-long icon URL before hitting the backend", async () => {
1414
+ const backend = createFakeSlackBackend();
1415
+ backend.workspaces = [workspaceRow("T0AAAA")];
1416
+ backend.apps = [appRow({ slackAppId: "A0100" })];
1417
+ const dir = await makeAgentDir();
1418
+ const io = capture();
1419
+
1420
+ const code = await cmdSlack(
1421
+ "icon",
1422
+ provisionOptions(backend, dir, io, {
1423
+ iconSource: `https://example.com/${"x".repeat(3000)}`,
1424
+ })
1425
+ );
1426
+
1427
+ expect(code).toBe(1);
1428
+ expect(io.stderr()).toContain("at most 3000 characters");
1429
+ expect(backend.iconRequests).toEqual([]);
1430
+ });
1431
+
1432
+ it("prints usage when the source argument is missing", async () => {
1433
+ const backend = createFakeSlackBackend();
1434
+ const dir = await makeAgentDir();
1435
+ const io = capture();
1436
+
1437
+ const code = await cmdSlack("icon", provisionOptions(backend, dir, io));
1438
+
1439
+ expect(code).toBe(1);
1440
+ expect(io.stderr()).toContain("Usage:");
1441
+ expect(io.stderr()).toContain("slack icon");
1442
+ });
1443
+ });
1444
+
1445
+ describe("cmdSlackCreate hardening", () => {
1446
+ it("provisions channel-post events when --channel-posts is set", async () => {
1447
+ const backend = createFakeSlackBackend();
1448
+ backend.workspaces = [workspaceRow("T0AAAA")];
1449
+ const io = capture();
1450
+ const dir = await makeAgentDir();
1451
+
1452
+ const code = await cmdSlackCreate(
1453
+ provisionOptions(backend, dir, io, { channelPosts: true })
1454
+ );
1455
+ expect(code).toBe(0);
1456
+ const manifest = JSON.parse(
1457
+ String(backend.provisionBodies[0]!.manifest)
1458
+ ) as { settings: { event_subscriptions: { bot_events: string[] } } };
1459
+ expect(manifest.settings.event_subscriptions.bot_events).toContain(
1460
+ "message.channels"
1461
+ );
1462
+ });
1463
+
1464
+ it("replaces stale export-prefixed token lines instead of duplicating", async () => {
1465
+ const backend = createFakeSlackBackend();
1466
+ backend.workspaces = [workspaceRow("T0AAAA")];
1467
+ const io = capture();
1468
+ const dir = await makeAgentDir();
1469
+ await writeFile(
1470
+ join(dir, ".env.local"),
1471
+ `export ${BOT_KEY}=xoxb-stale\nOTHER=keep\n`
1472
+ );
1473
+
1474
+ expect(await cmdSlackCreate(provisionOptions(backend, dir, io))).toBe(0);
1475
+ const envLocal = await readFile(join(dir, ".env.local"), "utf8");
1476
+ expect(envLocal).toContain("OTHER=keep");
1477
+ expect(envLocal).toContain(`${BOT_KEY}=xoxb-fake-1`);
1478
+ expect(envLocal).not.toContain("xoxb-stale");
1479
+ expect(envLocal.match(new RegExp(BOT_KEY, "g"))).toHaveLength(1);
1480
+ });
1481
+
1482
+ it("writes .env.local owner-only and gitignores it", async () => {
1483
+ const backend = createFakeSlackBackend();
1484
+ backend.workspaces = [workspaceRow("T0AAAA")];
1485
+ const io = capture();
1486
+ const dir = await makeAgentDir();
1487
+
1488
+ expect(await cmdSlackCreate(provisionOptions(backend, dir, io))).toBe(0);
1489
+ const mode = (await stat(join(dir, ".env.local"))).mode & 0o777;
1490
+ expect(mode).toBe(0o600);
1491
+ const gitignore = await readFile(join(dir, ".gitignore"), "utf8");
1492
+ expect(gitignore).toContain(".env.local");
1493
+ });
1494
+
1495
+ it("clamps a pre-existing group-readable .env.local to owner-only", async () => {
1496
+ const backend = createFakeSlackBackend();
1497
+ backend.workspaces = [workspaceRow("T0AAAA")];
1498
+ const io = capture();
1499
+ const dir = await makeAgentDir();
1500
+ await writeFile(join(dir, ".env.local"), "OTHER=keep\n", { mode: 0o644 });
1501
+
1502
+ expect(await cmdSlackCreate(provisionOptions(backend, dir, io))).toBe(0);
1503
+ const mode = (await stat(join(dir, ".env.local"))).mode & 0o777;
1504
+ expect(mode).toBe(0o600);
1505
+ });
1506
+
1507
+ it("leaves an existing .gitignore alone when it already covers env files", async () => {
1508
+ const backend = createFakeSlackBackend();
1509
+ backend.workspaces = [workspaceRow("T0AAAA")];
1510
+ const io = capture();
1511
+ const dir = await makeAgentDir();
1512
+ await writeFile(join(dir, ".gitignore"), "node_modules/\n.env*\n");
1513
+
1514
+ expect(await cmdSlackCreate(provisionOptions(backend, dir, io))).toBe(0);
1515
+ const gitignore = await readFile(join(dir, ".gitignore"), "utf8");
1516
+ expect(gitignore).toBe("node_modules/\n.env*\n");
1517
+ });
1518
+
1519
+ it("appends .env.local when .gitignore has only a bare .env line", async () => {
1520
+ // The gitignore pattern `.env` matches only the file literally named
1521
+ // .env — .env.local would stay committable without the append.
1522
+ const backend = createFakeSlackBackend();
1523
+ backend.workspaces = [workspaceRow("T0AAAA")];
1524
+ const io = capture();
1525
+ const dir = await makeAgentDir();
1526
+ await writeFile(join(dir, ".gitignore"), ".env\n");
1527
+
1528
+ expect(await cmdSlackCreate(provisionOptions(backend, dir, io))).toBe(0);
1529
+ const gitignore = await readFile(join(dir, ".gitignore"), "utf8");
1530
+ expect(gitignore).toBe(".env\n.env.local\n");
1531
+ });
1532
+
1533
+ it("tolerates transient status-poll failures during the consent wait", async () => {
1534
+ const backend = createFakeSlackBackend();
1535
+ backend.pendingWorkspace = {
1536
+ workspace: workspaceRow("T0AAAA"),
1537
+ afterStatusCalls: 2,
1538
+ };
1539
+ const io = capture();
1540
+ const dir = await makeAgentDir();
1541
+
1542
+ const inner = backend.fetchImpl;
1543
+ let failedOnce = false;
1544
+ backend.fetchImpl = (async (
1545
+ input: string | URL | Request,
1546
+ init?: RequestInit
1547
+ ): Promise<Response> => {
1548
+ const url = new URL(String(input));
1549
+ if (
1550
+ !failedOnce &&
1551
+ backend.connectCalls > 0 &&
1552
+ url.pathname.endsWith("/status")
1553
+ ) {
1554
+ failedOnce = true;
1555
+ return new Response(JSON.stringify({ error: "boom" }), {
1556
+ status: 500,
1557
+ headers: { "content-type": "application/json" },
1558
+ });
1559
+ }
1560
+ return inner(input, init);
1561
+ }) as typeof fetch;
1562
+
1563
+ expect(await cmdSlackCreate(provisionOptions(backend, dir, io))).toBe(0);
1564
+ expect(io.stderr()).toContain("status poll failed, retrying");
1565
+ });
1566
+
1567
+ it("terminates after a second insufficient_scopes instead of looping", async () => {
1568
+ const backend = createFakeSlackBackend();
1569
+ backend.workspaces = [workspaceRow("T0AAAA", "2026-07-01T00:00:00.000Z")];
1570
+ backend.pendingWorkspace = {
1571
+ workspace: workspaceRow("T0AAAA", "2026-07-02T00:00:00.000Z"),
1572
+ afterStatusCalls: 1,
1573
+ };
1574
+ backend.provisionQueue = [
1575
+ {
1576
+ status: 409,
1577
+ body: {
1578
+ error: "Slack manager authorization is missing scopes",
1579
+ code: "insufficient_scopes",
1580
+ },
1581
+ },
1582
+ ];
1583
+ const io = capture();
1584
+ const dir = await makeAgentDir();
1585
+
1586
+ expect(await cmdSlackCreate(provisionOptions(backend, dir, io))).toBe(1);
1587
+ expect(backend.connectCalls).toBe(1);
1588
+ expect(backend.provisionBodies.length).toBe(2);
1589
+ });
1590
+ });
1591
+
1592
+ describe("cmdSlack doctor", () => {
1593
+ async function runDoctorSpy(
1594
+ dir: string,
1595
+ extra?: Partial<Parameters<typeof cmdSlack>[1]>
1596
+ ): Promise<{ code: number; prefixes: Array<string | undefined> }> {
1597
+ const prefixes: Array<string | undefined> = [];
1598
+ const code = await cmdSlack("doctor", {
1599
+ dir,
1600
+ doctorImpl: async (options?: { envPrefix?: string }) => {
1601
+ prefixes.push(options?.envPrefix);
1602
+ return passingDoctor();
1603
+ },
1604
+ ...extra,
1605
+ });
1606
+ return { code, prefixes };
1607
+ }
1608
+
1609
+ it("defaults to the directory-derived prefix when create's keys are set", async () => {
1610
+ const dir = await makeAgentDir();
1611
+ await writeFile(
1612
+ join(dir, ".env.local"),
1613
+ `${BOT_KEY}=xoxb-x\n${APP_KEY}=xapp-x\n`
1614
+ );
1615
+
1616
+ const { code, prefixes } = await runDoctorSpy(dir);
1617
+
1618
+ expect(code).toBe(0);
1619
+ expect(prefixes).toEqual(["APPROVAL_BUDDY"]);
1620
+ });
1621
+
1622
+ it("keeps the bare SLACK_* default when no prefixed keys are set", async () => {
1623
+ const dir = await makeAgentDir();
1624
+
1625
+ const { prefixes } = await runDoctorSpy(dir);
1626
+
1627
+ expect(prefixes).toEqual([undefined]);
1628
+ });
1629
+
1630
+ it("forces the bare keys with --no-prefix", async () => {
1631
+ const dir = await makeAgentDir();
1632
+ await writeFile(join(dir, ".env.local"), `${BOT_KEY}=xoxb-x\n`);
1633
+
1634
+ const { prefixes } = await runDoctorSpy(dir, { noPrefix: true });
1635
+
1636
+ expect(prefixes).toEqual([undefined]);
1637
+ });
1638
+
1639
+ it("lets an explicit --prefix win over the derived default", async () => {
1640
+ const dir = await makeAgentDir();
1641
+ await writeFile(join(dir, ".env.local"), `${BOT_KEY}=xoxb-x\n`);
1642
+
1643
+ const { prefixes } = await runDoctorSpy(dir, { prefix: "CUSTOM" });
1644
+
1645
+ expect(prefixes).toEqual(["CUSTOM"]);
1646
+ });
1647
+ });