@cursor/july 0.1.16 → 0.1.17

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 (236) hide show
  1. package/README.md +9 -4
  2. package/dist/bin/agent-serve.js +60 -2
  3. package/dist/channels/slack/api.d.ts.map +1 -1
  4. package/dist/channels/slack/api.js +13 -3
  5. package/dist/channels/slack/auth.d.ts +7 -1
  6. package/dist/channels/slack/auth.d.ts.map +1 -1
  7. package/dist/channels/slack/auth.js +14 -0
  8. package/dist/channels/slack/blocks.d.ts +61 -0
  9. package/dist/channels/slack/blocks.d.ts.map +1 -0
  10. package/dist/channels/slack/blocks.js +90 -0
  11. package/dist/channels/slack/defaults.d.ts +2 -0
  12. package/dist/channels/slack/defaults.d.ts.map +1 -1
  13. package/dist/channels/slack/defaults.js +29 -3
  14. package/dist/channels/slack/dispatch.d.ts +3 -17
  15. package/dist/channels/slack/dispatch.d.ts.map +1 -1
  16. package/dist/channels/slack/index.d.ts +1 -0
  17. package/dist/channels/slack/index.d.ts.map +1 -1
  18. package/dist/channels/slack/index.js +1 -0
  19. package/dist/channels/slack/interactive.d.ts +39 -3
  20. package/dist/channels/slack/interactive.d.ts.map +1 -1
  21. package/dist/channels/slack/interactive.js +152 -7
  22. package/dist/channels/slack/manifest.d.ts +3 -0
  23. package/dist/channels/slack/manifest.d.ts.map +1 -1
  24. package/dist/channels/slack/manifest.js +3 -0
  25. package/dist/channels/slack/setup.d.ts +4 -1
  26. package/dist/channels/slack/setup.d.ts.map +1 -1
  27. package/dist/channels/slack/setup.js +16 -2
  28. package/dist/channels/slack/slack-channel.d.ts.map +1 -1
  29. package/dist/channels/slack/slack-channel.js +24 -3
  30. package/dist/channels/slack/types.d.ts +77 -0
  31. package/dist/channels/slack/types.d.ts.map +1 -1
  32. package/dist/connections.d.ts +4 -1
  33. package/dist/connections.d.ts.map +1 -1
  34. package/dist/connections.js +4 -1
  35. package/dist/docs/404.html +2 -2
  36. package/dist/docs/ab.html +3 -3
  37. package/dist/docs/assets/{app.DjHC-Ggf.js → app.BbEXr2KO.js} +1 -1
  38. package/dist/docs/assets/chunks/@localSearchIndexroot.DCBsrfwP.js +1 -0
  39. package/dist/docs/assets/chunks/{VPLocalSearchBox.DwviwmIq.js → VPLocalSearchBox.DBEi4HDp.js} +1 -1
  40. package/dist/docs/assets/chunks/{theme.BIpeYSon.js → theme.DGfptcHw.js} +2 -2
  41. package/dist/docs/assets/{guides_mcp-oauth.md.DqmEOQY1.js → guides_mcp-oauth.md.Dd8EgSem.js} +3 -2
  42. package/dist/docs/assets/{guides_mcp-oauth.md.DqmEOQY1.lean.js → guides_mcp-oauth.md.Dd8EgSem.lean.js} +1 -1
  43. package/dist/docs/assets/{guides_slack.md.FWale6Ip.js → guides_slack.md.D_xFKlnF.js} +1 -1
  44. package/dist/docs/assets/{reference_connections.md.CsSr3rDz.js → reference_connections.md.B9Q3TOve.js} +12 -5
  45. package/dist/docs/assets/{reference_connections.md.CsSr3rDz.lean.js → reference_connections.md.B9Q3TOve.lean.js} +1 -1
  46. package/dist/docs/building-with-agents.html +3 -3
  47. package/dist/docs/concepts.html +3 -3
  48. package/dist/docs/deployment.html +3 -3
  49. package/dist/docs/evals.html +3 -3
  50. package/dist/docs/example-agents/approval-buddy.html +3 -3
  51. package/dist/docs/example-agents/benny.html +3 -3
  52. package/dist/docs/example-agents/bugbot.html +3 -3
  53. package/dist/docs/example-agents/codebase-wiki.html +3 -3
  54. package/dist/docs/example-agents/codeowners-review.html +3 -3
  55. package/dist/docs/example-agents/concierge.html +3 -3
  56. package/dist/docs/example-agents/fsd.html +3 -3
  57. package/dist/docs/example-agents/index.html +3 -3
  58. package/dist/docs/example-agents/knowledge-base.html +3 -3
  59. package/dist/docs/example-agents/oncall.html +3 -3
  60. package/dist/docs/example-agents/security-reviewer.html +3 -3
  61. package/dist/docs/example-agents/slack-agent.html +3 -3
  62. package/dist/docs/example-agents/weather-agent.html +3 -3
  63. package/dist/docs/guides/agent-to-agent.html +3 -3
  64. package/dist/docs/guides/cloud-runtime.html +3 -3
  65. package/dist/docs/guides/github.html +3 -3
  66. package/dist/docs/guides/human-in-the-loop.html +3 -3
  67. package/dist/docs/guides/mcp-oauth.html +6 -5
  68. package/dist/docs/guides/slack.html +5 -5
  69. package/dist/docs/guides/webhooks.html +3 -3
  70. package/dist/docs/hashmap.json +1 -1
  71. package/dist/docs/hillclimbing.html +3 -3
  72. package/dist/docs/index.html +3 -3
  73. package/dist/docs/quickstart.html +3 -3
  74. package/dist/docs/reference/agent-config.html +3 -3
  75. package/dist/docs/reference/channels.html +3 -3
  76. package/dist/docs/reference/cli.html +3 -3
  77. package/dist/docs/reference/connections.html +14 -7
  78. package/dist/docs/reference/hooks.html +3 -3
  79. package/dist/docs/reference/http-api.html +3 -3
  80. package/dist/docs/reference/instructions.html +3 -3
  81. package/dist/docs/reference/playground.html +3 -3
  82. package/dist/docs/reference/project-layout.html +3 -3
  83. package/dist/docs/reference/prompt.html +3 -3
  84. package/dist/docs/reference/schedules.html +3 -3
  85. package/dist/docs/reference/sessions.html +3 -3
  86. package/dist/docs/reference/skills.html +3 -3
  87. package/dist/docs/reference/subagents.html +3 -3
  88. package/dist/docs/reference/tools.html +3 -3
  89. package/dist/docs/scaffolding-agents.html +3 -3
  90. package/dist/docs/storage.html +3 -3
  91. package/dist/docs/troubleshooting.html +3 -3
  92. package/dist/evals/assertions.d.ts +190 -0
  93. package/dist/evals/assertions.d.ts.map +1 -0
  94. package/dist/evals/assertions.js +461 -0
  95. package/dist/evals/expect.d.ts +95 -0
  96. package/dist/evals/expect.d.ts.map +1 -0
  97. package/dist/evals/expect.js +194 -0
  98. package/dist/evals/judge.d.ts +133 -0
  99. package/dist/evals/judge.d.ts.map +1 -0
  100. package/dist/evals/judge.js +228 -0
  101. package/dist/evals/loaders.d.ts +51 -0
  102. package/dist/evals/loaders.d.ts.map +1 -0
  103. package/dist/evals/loaders.js +124 -0
  104. package/dist/evals/matchers.d.ts +95 -0
  105. package/dist/evals/matchers.d.ts.map +1 -0
  106. package/dist/evals/matchers.js +145 -0
  107. package/dist/evals/reporters.d.ts +70 -0
  108. package/dist/evals/reporters.d.ts.map +1 -0
  109. package/dist/evals/reporters.js +221 -0
  110. package/dist/evals/results.d.ts +103 -0
  111. package/dist/evals/results.d.ts.map +1 -0
  112. package/dist/evals/results.js +28 -0
  113. package/dist/evals/run-facts.d.ts +77 -0
  114. package/dist/evals/run-facts.d.ts.map +1 -0
  115. package/dist/evals/run-facts.js +187 -0
  116. package/dist/evals.d.ts +118 -31
  117. package/dist/evals.d.ts.map +1 -1
  118. package/dist/evals.js +46 -12
  119. package/dist/index.d.ts +1 -1
  120. package/dist/index.d.ts.map +1 -1
  121. package/dist/index.js +1 -1
  122. package/dist/internal/cli-ax.d.ts +27 -0
  123. package/dist/internal/cli-ax.d.ts.map +1 -1
  124. package/dist/internal/cli-ax.js +90 -12
  125. package/dist/internal/cli-mcp.d.ts +8 -0
  126. package/dist/internal/cli-mcp.d.ts.map +1 -1
  127. package/dist/internal/cli-mcp.js +98 -0
  128. package/dist/internal/cli-slack.d.ts +29 -1
  129. package/dist/internal/cli-slack.d.ts.map +1 -1
  130. package/dist/internal/cli-slack.js +777 -12
  131. package/dist/internal/deploy-client.d.ts +7 -0
  132. package/dist/internal/deploy-client.d.ts.map +1 -1
  133. package/dist/internal/deploy-client.js +9 -0
  134. package/dist/internal/discovery.d.ts.map +1 -1
  135. package/dist/internal/discovery.js +29 -9
  136. package/dist/internal/distribution.d.ts.map +1 -1
  137. package/dist/internal/distribution.js +2 -0
  138. package/dist/internal/eval-judge-model.d.ts +59 -0
  139. package/dist/internal/eval-judge-model.d.ts.map +1 -0
  140. package/dist/internal/eval-judge-model.js +131 -0
  141. package/dist/internal/eval-run-store.d.ts.map +1 -1
  142. package/dist/internal/eval-run-store.js +12 -20
  143. package/dist/internal/eval-runner.d.ts +31 -25
  144. package/dist/internal/eval-runner.d.ts.map +1 -1
  145. package/dist/internal/eval-runner.js +329 -126
  146. package/dist/internal/evals-client.d.ts.map +1 -1
  147. package/dist/internal/evals-client.js +4 -1
  148. package/dist/internal/local-env.d.ts +8 -0
  149. package/dist/internal/local-env.d.ts.map +1 -0
  150. package/dist/internal/local-env.js +47 -0
  151. package/dist/internal/playground/static.d.ts +3 -0
  152. package/dist/internal/playground/static.d.ts.map +1 -1
  153. package/dist/internal/playground/static.js +3 -0
  154. package/dist/internal/run-client.d.ts +15 -0
  155. package/dist/internal/run-client.d.ts.map +1 -1
  156. package/dist/internal/run-client.js +3 -2
  157. package/dist/internal/sdk-runner.d.ts +36 -1
  158. package/dist/internal/sdk-runner.d.ts.map +1 -1
  159. package/dist/internal/sdk-runner.js +65 -25
  160. package/dist/internal/server.d.ts.map +1 -1
  161. package/dist/internal/server.js +10 -1
  162. package/dist/internal/session-engine.d.ts +18 -5
  163. package/dist/internal/session-engine.d.ts.map +1 -1
  164. package/dist/internal/session-engine.js +72 -19
  165. package/dist/internal/slack-provision-client.d.ts +130 -0
  166. package/dist/internal/slack-provision-client.d.ts.map +1 -0
  167. package/dist/internal/slack-provision-client.js +284 -0
  168. package/dist/playground/assets/cursor-icons-16-f_W_ogc-.woff2 +0 -0
  169. package/dist/playground/assets/index-CidizGZv.css +1 -0
  170. package/dist/playground/assets/index-DNqirsrK.js +85 -0
  171. package/dist/playground/index.html +2 -2
  172. package/dist/types.d.ts +70 -4
  173. package/dist/types.d.ts.map +1 -1
  174. package/dist/types.js +26 -0
  175. package/docs/guides/mcp-oauth.md +10 -5
  176. package/docs/guides/slack.md +3 -2
  177. package/docs/reference/connections.md +28 -18
  178. package/package.json +20 -2
  179. package/skills/create-agent/SKILL.md +15 -7
  180. package/skills/debug/SKILL.md +3 -1
  181. package/skills/mcp-auth/SKILL.md +1 -1
  182. package/skills/setup-slack/SKILL.md +173 -11
  183. package/src/bin/agent-serve.ts +63 -2
  184. package/src/channels/slack/api.ts +16 -3
  185. package/src/channels/slack/auth.ts +16 -1
  186. package/src/channels/slack/blocks.ts +139 -0
  187. package/src/channels/slack/defaults.ts +32 -4
  188. package/src/channels/slack/dispatch.ts +3 -18
  189. package/src/channels/slack/index.ts +1 -0
  190. package/src/channels/slack/interactive.ts +183 -19
  191. package/src/channels/slack/manifest.ts +3 -0
  192. package/src/channels/slack/setup.ts +16 -2
  193. package/src/channels/slack/slack-channel.ts +37 -6
  194. package/src/channels/slack/types.ts +89 -0
  195. package/src/connections.ts +4 -1
  196. package/src/evals/assertions.ts +704 -0
  197. package/src/evals/expect.ts +272 -0
  198. package/src/evals/judge.ts +346 -0
  199. package/src/evals/loaders.ts +113 -0
  200. package/src/evals/matchers.ts +246 -0
  201. package/src/evals/reporters.ts +274 -0
  202. package/src/evals/results.ts +132 -0
  203. package/src/evals/run-facts.ts +274 -0
  204. package/src/evals.ts +205 -42
  205. package/src/index.ts +2 -0
  206. package/src/internal/cli-ax.ts +134 -15
  207. package/src/internal/cli-mcp.ts +118 -5
  208. package/src/internal/cli-slack.ts +993 -15
  209. package/src/internal/deploy-client.ts +10 -0
  210. package/src/internal/discovery.ts +31 -5
  211. package/src/internal/distribution.ts +2 -0
  212. package/src/internal/eval-judge-model.ts +148 -0
  213. package/src/internal/eval-run-store.ts +10 -14
  214. package/src/internal/eval-runner.ts +460 -214
  215. package/src/internal/evals-client.ts +5 -0
  216. package/src/internal/local-env.ts +53 -0
  217. package/src/internal/playground/static.ts +3 -0
  218. package/src/internal/run-client.ts +17 -1
  219. package/src/internal/sdk-runner.ts +103 -22
  220. package/src/internal/server.ts +10 -0
  221. package/src/internal/session-engine.ts +89 -21
  222. package/src/internal/slack-provision-client.ts +438 -0
  223. package/src/types.ts +92 -4
  224. package/dist/channels/slack/post-update-delivery.d.ts +0 -85
  225. package/dist/channels/slack/post-update-delivery.d.ts.map +0 -1
  226. package/dist/docs/assets/chunks/@localSearchIndexroot.DZtu0ZIc.js +0 -1
  227. package/dist/internal/json-dir-store.d.ts +0 -32
  228. package/dist/internal/json-dir-store.d.ts.map +0 -1
  229. package/dist/internal/persistence-coordinator.d.ts +0 -127
  230. package/dist/internal/persistence-coordinator.d.ts.map +0 -1
  231. package/dist/persistence.d.ts +0 -184
  232. package/dist/persistence.d.ts.map +0 -1
  233. package/dist/playground/assets/cursor-icons-16-CQ50JpfO.woff2 +0 -0
  234. package/dist/playground/assets/index-Bwl4Onx4.js +0 -85
  235. package/dist/playground/assets/index-ByV4nfch.css +0 -1
  236. /package/dist/docs/assets/{guides_slack.md.FWale6Ip.lean.js → guides_slack.md.D_xFKlnF.lean.js} +0 -0
@@ -1,15 +1,39 @@
1
1
  /**
2
- * `agent-serve slack …` CLI — manifests, setup, doctor (Socket Mode).
2
+ * `agent-serve slack …` CLI — manifests, setup, doctor (Socket Mode), and
3
+ * managed provisioning (`create` / `destroy`) through Cursor's
4
+ * "Add to Slack" API (`slack-provision-client.ts`).
3
5
  */
4
6
 
5
- import { mkdir, writeFile } from "node:fs/promises";
6
- import { basename, join, resolve as resolvePath } from "node:path";
7
+ import { access, chmod, mkdir, readFile, writeFile } from "node:fs/promises";
8
+ import { basename, extname, join, resolve as resolvePath } from "node:path";
7
9
  import {
10
+ buildSlackManifest,
8
11
  buildSlackManifestPair,
12
+ envPrefixFromName,
9
13
  formatSlackSetupGuide,
10
14
  initSlackChannel,
11
15
  runSlackDoctor,
16
+ type SlackDoctorResult,
17
+ slackEnvKeys,
12
18
  } from "../channels/slack/index.js";
19
+ import { resolveDeployAuth } from "./cli-deploy.js";
20
+ import { cursorExternalApiUrl } from "./cursor/credentials.js";
21
+ import { slugFromDirectoryName } from "./deploy-client.js";
22
+ import { describeError } from "./describe-error.js";
23
+ import { CLI_COMMAND_NAME as CLI } from "./distribution.js";
24
+ import { loadAgentLocalEnv } from "./local-env.js";
25
+ import { openBrowser } from "./open-browser.js";
26
+ import {
27
+ AgentServeSlackClient,
28
+ type SlackAppEnv,
29
+ type SlackAppIconContentType,
30
+ type SlackAppIconInput,
31
+ type SlackAppProvisionResult,
32
+ type SlackConnectedWorkspace,
33
+ type SlackManagedAppInfo,
34
+ SlackProvisionApiError,
35
+ type SlackProvisionStatus,
36
+ } from "./slack-provision-client.js";
13
37
 
14
38
  export interface SlackCliOptions {
15
39
  dir: string;
@@ -23,22 +47,69 @@ export interface SlackCliOptions {
23
47
  noPrefix?: boolean;
24
48
  /** Subscribe manifests to channel-post events (opt-in channel watch). */
25
49
  channelPosts?: boolean;
50
+ /** create/destroy: target Slack workspace id (`T…`). */
51
+ slackTeam?: string;
52
+ /** create/destroy: target the prod app (default dev). */
53
+ prod?: boolean;
54
+ /** create/destroy: Cursor team id; resolved from the account when omitted. */
55
+ team?: string;
56
+ /** create/destroy: Cursor API key (defaults to the stored login / env). */
57
+ apiKey?: string;
58
+ /** create: app icon — an https image URL or a local png/jpg/gif ≤512KB. */
59
+ icon?: string;
60
+ /** create: overwrite manifest edits made in Slack (skip the drift check). */
61
+ force?: boolean;
62
+ /** `slack icon <source>` positional (same URL-or-file forms as --icon). */
63
+ iconSource?: string;
64
+ /** Test seams. */
65
+ fetchImpl?: typeof fetch;
66
+ openBrowserImpl?: (url: string) => void;
67
+ connectPollIntervalMs?: number;
68
+ connectPollTimeoutMs?: number;
69
+ doctorImpl?: typeof runSlackDoctor;
70
+ out?: (text: string) => void;
71
+ err?: (text: string) => void;
26
72
  }
27
73
 
28
74
  const SLACK_HELP = `agent-serve slack — Slack channel pack helpers (Socket Mode)
29
75
 
30
76
  Usage:
31
77
  agent-serve slack setup
78
+ agent-serve slack create [--dir .] [--name NAME] [--slack-team T0123ABCD]
79
+ [--prod] [--team <id>] [--icon URL-or-FILE]
80
+ [--force] [--prefix PREFIX | --no-prefix] [--json]
81
+ agent-serve slack destroy [--dir .] [--slack-team T0123ABCD] [--prod]
82
+ [--team <id>] [--json]
83
+ agent-serve slack icon <URL-or-FILE> [--dir .] [--slack-team T0123ABCD]
84
+ [--prod] [--team <id>] [--json]
32
85
  agent-serve slack init [--dir .] [--name NAME] [--prefix PREFIX | --no-prefix]
33
86
  [--channel-posts]
34
87
  agent-serve slack manifest --env dev|prod|both [--dir .] [--name NAME]
35
88
  [--print]
36
- agent-serve slack doctor [--prefix PREFIX] [--json]
89
+ agent-serve slack doctor [--dir .] [--prefix PREFIX | --no-prefix] [--json]
90
+
91
+ create/destroy/icon — Cursor "Add to Slack" managed provisioning:
92
+ create One-command Path B setup: connects the Slack workspace (browser
93
+ consent) when needed, creates + installs this agent's dedicated
94
+ Socket Mode app from the generated manifest, writes the token
95
+ pair into <dir>/.env.local, then runs doctor (prose mode; --json
96
+ reports without the doctor pass). Requires
97
+ \`agent-serve login\` (or CURSOR_API_KEY) and the feature gate.
98
+ --prod provisions the prod app (default dev); --slack-team picks
99
+ the workspace when several are connected. --icon sets the app's
100
+ icon (best-effort: a bad icon warns, never fails the create).
101
+ A re-run refuses to overwrite manifest edits made by hand at
102
+ api.slack.com (manifest_drift); --force overwrites them.
103
+ destroy Delete the provisioned Slack app for this agent (+env). Tokens
104
+ already in .env.local are left in place (they just stop working).
105
+ icon Set the provisioned app's icon: an https image URL or a local
106
+ png/jpg/gif file (at most 512KB).
37
107
 
38
108
  Env tokens:
39
109
  single agent: SLACK_BOT_TOKEN + SLACK_APP_TOKEN
40
110
  multi-agent: <PREFIX>_SLACK_BOT_TOKEN + <PREFIX>_SLACK_APP_TOKEN
41
- (init defaults PREFIX from the directory name)
111
+ (init/create default PREFIX from the directory name;
112
+ doctor defaults to it too when those keys are set)
42
113
 
43
114
  See also: skills/setup-slack/SKILL.md
44
115
  `;
@@ -57,6 +128,12 @@ export async function cmdSlack(
57
128
  case "setup":
58
129
  process.stdout.write(formatSlackSetupGuide());
59
130
  return 0;
131
+ case "create":
132
+ return cmdSlackCreate(options);
133
+ case "destroy":
134
+ return cmdSlackDestroy(options);
135
+ case "icon":
136
+ return cmdSlackIcon(options);
60
137
  case "init":
61
138
  return cmdSlackInit(options);
62
139
  case "manifest":
@@ -155,21 +232,922 @@ async function runManifest(options: SlackCliOptions): Promise<number> {
155
232
  }
156
233
 
157
234
  async function runDoctor(options: SlackCliOptions): Promise<number> {
158
- const result = await runSlackDoctor(
159
- options.prefix === undefined ? {} : { envPrefix: options.prefix }
160
- );
235
+ const dir = resolvePath(options.dir);
236
+ // Tokens usually live in the agent's .env.local; real env still wins.
237
+ loadAgentLocalEnv(dir);
238
+ const envPrefix = resolveDoctorEnvPrefix(options, dir);
239
+ const doctor = options.doctorImpl ?? runSlackDoctor;
240
+ const result = await doctor(envPrefix === undefined ? {} : { envPrefix });
161
241
  if (options.json) {
162
242
  process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
163
243
  } else {
164
- process.stdout.write(
165
- `env: ${result.envKeys.bot} / ${result.envKeys.app}\n`
244
+ process.stdout.write(renderDoctorReport(result));
245
+ }
246
+ return result.ok ? 0 : 1;
247
+ }
248
+
249
+ /**
250
+ * --prefix / --no-prefix win; otherwise the directory-derived prefix
251
+ * create/init use, but only when one of its keys is set — unprefixed
252
+ * setups keep the bare SLACK_* default.
253
+ */
254
+ function resolveDoctorEnvPrefix(
255
+ options: SlackCliOptions,
256
+ dir: string
257
+ ): string | undefined {
258
+ if (options.noPrefix === true) {
259
+ return undefined;
260
+ }
261
+ if (options.prefix !== undefined) {
262
+ return options.prefix;
263
+ }
264
+ const derived = envPrefixFromName(basename(dir));
265
+ const keys = slackEnvKeys(derived);
266
+ return process.env[keys.bot] !== undefined ||
267
+ process.env[keys.app] !== undefined
268
+ ? derived
269
+ : undefined;
270
+ }
271
+
272
+ // ============================================================================
273
+ // create / destroy — Cursor "Add to Slack" managed provisioning
274
+ // ============================================================================
275
+
276
+ const DEFAULT_CONNECT_POLL_INTERVAL_MS = 3_000;
277
+ const DEFAULT_CONNECT_POLL_TIMEOUT_MS = 5 * 60 * 1000;
278
+
279
+ interface SlackProvisionContext {
280
+ client: AgentServeSlackClient;
281
+ teamId: number;
282
+ json: boolean;
283
+ out: (text: string) => void;
284
+ err: (text: string) => void;
285
+ openBrowserImpl: (url: string) => void;
286
+ pollIntervalMs: number;
287
+ pollTimeoutMs: number;
288
+ }
289
+
290
+ /** Local facts about the app being provisioned, resolved before any I/O. */
291
+ interface SlackAppTarget {
292
+ dir: string;
293
+ name: string;
294
+ slug: string;
295
+ env: SlackAppEnv;
296
+ envPrefix: string | undefined;
297
+ envKeys: { bot: string; app: string };
298
+ }
299
+
300
+ function resolveSlackAppTarget(
301
+ options: SlackCliOptions,
302
+ err: (text: string) => void
303
+ ): SlackAppTarget | number {
304
+ if (options.env === "both" && options.prod !== true) {
305
+ err(
306
+ "Provisioning targets one env at a time; pass --prod for the prod app (default dev).\n"
166
307
  );
167
- for (const check of result.checks) {
168
- process.stdout.write(
169
- `${check.ok ? "ok" : "!!"} ${check.id}: ${check.message}\n`
308
+ return 1;
309
+ }
310
+ const env: SlackAppEnv =
311
+ options.prod === true || options.env === "prod" ? "prod" : "dev";
312
+ const dir = resolvePath(options.dir);
313
+ // Backend slug rule, mirrored so the error names the real problem
314
+ // instead of a generic 400.
315
+ const slug = slugFromDirectoryName(basename(dir));
316
+ if (slug === undefined || !/^[a-z0-9][a-z0-9_-]{0,63}$/.test(slug)) {
317
+ err(
318
+ `Could not derive a valid app slug from the directory name ${JSON.stringify(basename(dir))} (slugs start with a letter or digit; lowercase letters, digits, '-', '_'); rename the directory or pass --dir <agent>.\n`
319
+ );
320
+ return 1;
321
+ }
322
+ const name = options.name ?? basename(dir);
323
+ const envPrefix =
324
+ options.noPrefix === true
325
+ ? undefined
326
+ : (options.prefix ?? envPrefixFromName(basename(dir)));
327
+ return { dir, name, slug, env, envPrefix, envKeys: slackEnvKeys(envPrefix) };
328
+ }
329
+
330
+ function errStream(options: SlackCliOptions): (text: string) => void {
331
+ return options.err ?? ((text: string) => void process.stderr.write(text));
332
+ }
333
+
334
+ const SLACK_ICON_MAX_BYTES = 524_288;
335
+
336
+ const SLACK_ICON_URL_MAX_LENGTH = 3000;
337
+
338
+ const SLACK_ICON_CONTENT_TYPES: Record<string, SlackAppIconContentType> = {
339
+ ".png": "image/png",
340
+ ".jpg": "image/jpeg",
341
+ ".jpeg": "image/jpeg",
342
+ ".gif": "image/gif",
343
+ };
344
+
345
+ type SlackIconSource =
346
+ | { kind: "url"; url: string }
347
+ | {
348
+ kind: "file";
349
+ base64: string;
350
+ contentType: SlackAppIconContentType;
351
+ path: string;
352
+ };
353
+
354
+ async function resolveSlackIconSource(
355
+ raw: string,
356
+ err: (text: string) => void
357
+ ): Promise<SlackIconSource | number> {
358
+ if (raw.startsWith("https://")) {
359
+ if (raw.length > SLACK_ICON_URL_MAX_LENGTH) {
360
+ err(
361
+ `Icon URLs must be at most ${SLACK_ICON_URL_MAX_LENGTH} characters (got ${raw.length}).\n`
170
362
  );
363
+ return 1;
171
364
  }
172
- process.stdout.write(result.ok ? "doctor: pass\n" : "doctor: fail\n");
365
+ return { kind: "url", url: raw };
173
366
  }
174
- return result.ok ? 0 : 1;
367
+ if (/^[a-z][a-z0-9+.-]*:\/\//i.test(raw)) {
368
+ err(`Icon URLs must be https (got ${JSON.stringify(raw)}).\n`);
369
+ return 1;
370
+ }
371
+ const path = resolvePath(raw);
372
+ const contentType = SLACK_ICON_CONTENT_TYPES[extname(path).toLowerCase()];
373
+ if (contentType === undefined) {
374
+ err(
375
+ `Icon files must be .png, .jpg/.jpeg, or .gif (got ${JSON.stringify(basename(path))}).\n`
376
+ );
377
+ return 1;
378
+ }
379
+ let data: Buffer;
380
+ try {
381
+ data = await readFile(path);
382
+ } catch (error) {
383
+ err(`Could not read the icon file ${path}: ${describeError(error)}\n`);
384
+ return 1;
385
+ }
386
+ if (data.length > SLACK_ICON_MAX_BYTES) {
387
+ err(
388
+ `Icon files must be at most ${SLACK_ICON_MAX_BYTES} bytes (${path} is ${data.length}).\n`
389
+ );
390
+ return 1;
391
+ }
392
+ return { kind: "file", base64: data.toString("base64"), contentType, path };
393
+ }
394
+
395
+ function toSlackAppIconInput(source: SlackIconSource): SlackAppIconInput {
396
+ return source.kind === "url"
397
+ ? { url: source.url }
398
+ : { base64: source.base64, contentType: source.contentType };
399
+ }
400
+
401
+ /**
402
+ * URL icons ride the provision request; file icons post to the icon
403
+ * endpoint afterwards. Best-effort: failures warn, never fail the create.
404
+ */
405
+ async function applySlackCreateIcon(
406
+ ctx: SlackProvisionContext,
407
+ source: SlackIconSource | undefined,
408
+ result: SlackAppProvisionResult
409
+ ): Promise<"set" | "failed" | undefined> {
410
+ if (source === undefined) {
411
+ return undefined;
412
+ }
413
+ let failure: string | undefined;
414
+ if (source.kind === "url") {
415
+ if (result.iconStatus !== "set") {
416
+ failure = "Slack did not accept the icon URL";
417
+ }
418
+ } else {
419
+ try {
420
+ await ctx.client.setAppIcon({
421
+ teamId: ctx.teamId,
422
+ slackAppId: result.appId,
423
+ icon: toSlackAppIconInput(source),
424
+ });
425
+ } catch (error) {
426
+ failure = describeError(error);
427
+ }
428
+ }
429
+ if (failure === undefined) {
430
+ return "set";
431
+ }
432
+ const retryWith = source.kind === "url" ? source.url : source.path;
433
+ ctx.err(
434
+ `warning: setting the Slack app icon failed: ${failure}\nRetry with \`${CLI} slack icon ${retryWith}\`.\n`
435
+ );
436
+ return "failed";
437
+ }
438
+
439
+ /**
440
+ * Credentials → provisioning client → team id, sharing the deploy CLI's
441
+ * resolution (stored login / CURSOR_API_KEY, `--team` override, GetMe
442
+ * default-team fallback). Returns an exit code (error already rendered)
443
+ * on failure.
444
+ */
445
+ async function prepareProvision(
446
+ options: SlackCliOptions
447
+ ): Promise<SlackProvisionContext | number> {
448
+ const out =
449
+ options.out ?? ((text: string) => void process.stdout.write(text));
450
+ const err = errStream(options);
451
+ const auth = await resolveDeployAuth({
452
+ team: options.team,
453
+ apiKey: options.apiKey,
454
+ fetchImpl: options.fetchImpl,
455
+ err,
456
+ });
457
+ if (typeof auth === "number") {
458
+ return auth;
459
+ }
460
+ // Provisioning routes live on the external API host beside deploys.
461
+ return {
462
+ client: new AgentServeSlackClient({
463
+ backendUrl: cursorExternalApiUrl(),
464
+ apiKey: auth.apiKey,
465
+ fetchImpl: options.fetchImpl,
466
+ }),
467
+ teamId: auth.teamId,
468
+ json: options.json === true,
469
+ out,
470
+ err,
471
+ openBrowserImpl: options.openBrowserImpl ?? openBrowser,
472
+ pollIntervalMs:
473
+ options.connectPollIntervalMs ?? DEFAULT_CONNECT_POLL_INTERVAL_MS,
474
+ pollTimeoutMs:
475
+ options.connectPollTimeoutMs ?? DEFAULT_CONNECT_POLL_TIMEOUT_MS,
476
+ };
477
+ }
478
+
479
+ /** What the connect poll waits for in `GET /status`. */
480
+ interface ConnectWaitTarget {
481
+ /** Wait for this workspace to be connected (else: any workspace). */
482
+ slackTeamId?: string;
483
+ /**
484
+ * Wait for connectedAt to move past this value — a fresh grant. Used
485
+ * whenever the workspace row already exists and only the new approval
486
+ * bumps its timestamp.
487
+ */
488
+ notConnectedAt?: string;
489
+ }
490
+
491
+ function matchConnectedWorkspace(
492
+ workspaces: SlackConnectedWorkspace[],
493
+ wait: ConnectWaitTarget
494
+ ): SlackConnectedWorkspace | undefined {
495
+ const candidates =
496
+ wait.slackTeamId === undefined
497
+ ? workspaces
498
+ : workspaces.filter(
499
+ (workspace) => workspace.slackTeamId === wait.slackTeamId
500
+ );
501
+ if (wait.notConnectedAt === undefined) {
502
+ return candidates[0];
503
+ }
504
+ return candidates.find(
505
+ (workspace) => workspace.connectedAt !== wait.notConnectedAt
506
+ );
507
+ }
508
+
509
+ /**
510
+ * Mint the browser consent URL, open it, and poll `GET /status` until the
511
+ * awaited workspace shows up (or the poll times out). Returns the
512
+ * connected workspace, or an exit code with the error already rendered.
513
+ */
514
+ async function runConnectFlow(
515
+ ctx: SlackProvisionContext,
516
+ wait: ConnectWaitTarget
517
+ ): Promise<SlackConnectedWorkspace | number> {
518
+ let url: string;
519
+ try {
520
+ url = await ctx.client.connect(ctx.teamId);
521
+ } catch (error) {
522
+ return renderSlackApiError(error, ctx.err);
523
+ }
524
+ ctx.err(
525
+ `\nConnect your Slack workspace to Cursor — approve in the browser:\n\n ${url}\n\n(waiting for the Slack consent to complete, up to ${Math.round(ctx.pollTimeoutMs / 60_000)} minutes)\n`
526
+ );
527
+ ctx.openBrowserImpl(url);
528
+ const deadline = Date.now() + ctx.pollTimeoutMs;
529
+ let lastPollFailure: string | undefined;
530
+ for (;;) {
531
+ let workspace: SlackConnectedWorkspace | undefined;
532
+ let sleepMs = ctx.pollIntervalMs;
533
+ try {
534
+ const status = await ctx.client.status(ctx.teamId);
535
+ workspace = matchConnectedWorkspace(status.workspaces, wait);
536
+ lastPollFailure = undefined;
537
+ } catch (error) {
538
+ // Transient poll failures must not abort a minted consent flow; only
539
+ // a still-failing poll at the deadline surfaces the error.
540
+ if (Date.now() >= deadline) {
541
+ return renderSlackApiError(error, ctx.err);
542
+ }
543
+ const failure = describeError(error);
544
+ if (failure !== lastPollFailure) {
545
+ lastPollFailure = failure;
546
+ ctx.err(`(status poll failed, retrying: ${failure})\n`);
547
+ }
548
+ if (
549
+ error instanceof SlackProvisionApiError &&
550
+ error.retryAfterSeconds !== undefined
551
+ ) {
552
+ // Respect backend backpressure instead of hammering at the poll rate.
553
+ sleepMs = Math.max(sleepMs, error.retryAfterSeconds * 1000);
554
+ }
555
+ }
556
+ if (workspace !== undefined) {
557
+ ctx.err(`Connected Slack workspace ${workspace.slackTeamId}.\n`);
558
+ return workspace;
559
+ }
560
+ if (Date.now() >= deadline) {
561
+ ctx.err(
562
+ `Timed out waiting for the Slack workspace connection. The consent link stays valid for ~10 minutes — approve it and re-run \`${CLI} slack create\`.\n`
563
+ );
564
+ return 1;
565
+ }
566
+ await sleep(sleepMs);
567
+ }
568
+ }
569
+
570
+ export async function cmdSlackCreate(
571
+ options: SlackCliOptions
572
+ ): Promise<number> {
573
+ const target = resolveSlackAppTarget(options, errStream(options));
574
+ if (typeof target === "number") {
575
+ return target;
576
+ }
577
+ // Resolved before any network I/O so a bad path/type/size fails fast.
578
+ let iconSource: SlackIconSource | undefined;
579
+ if (options.icon !== undefined) {
580
+ const resolved = await resolveSlackIconSource(
581
+ options.icon,
582
+ errStream(options)
583
+ );
584
+ if (typeof resolved === "number") {
585
+ return resolved;
586
+ }
587
+ iconSource = resolved;
588
+ }
589
+ const ctx = await prepareProvision(options);
590
+ if (typeof ctx === "number") {
591
+ return ctx;
592
+ }
593
+
594
+ // Workspace: --slack-team wins; one → use it; none → connect; several → ask.
595
+ let status: SlackProvisionStatus;
596
+ try {
597
+ status = await ctx.client.status(ctx.teamId);
598
+ } catch (error) {
599
+ return renderSlackApiError(error, ctx.err);
600
+ }
601
+ let workspace: SlackConnectedWorkspace | undefined;
602
+ if (options.slackTeam !== undefined) {
603
+ workspace = status.workspaces.find(
604
+ (candidate) => candidate.slackTeamId === options.slackTeam
605
+ );
606
+ } else if (status.workspaces.length === 1) {
607
+ workspace = status.workspaces[0];
608
+ } else if (status.workspaces.length > 1) {
609
+ ctx.err(
610
+ `Multiple Slack workspaces are connected:\n` +
611
+ status.workspaces
612
+ .map(
613
+ (candidate) =>
614
+ ` - ${candidate.slackTeamId} (Slack user ${candidate.slackUserId}, connected ${candidate.connectedAt})\n`
615
+ )
616
+ .join("") +
617
+ `Pass --slack-team <id> to pick one.\n`
618
+ );
619
+ return 1;
620
+ }
621
+ if (workspace === undefined) {
622
+ const connected = await runConnectFlow(ctx, {
623
+ slackTeamId: options.slackTeam,
624
+ });
625
+ if (typeof connected === "number") {
626
+ return connected;
627
+ }
628
+ workspace = connected;
629
+ }
630
+
631
+ let wrote: string[];
632
+ try {
633
+ wrote = await scaffoldSlackChannelFiles(options, target);
634
+ } catch (error) {
635
+ ctx.err(`${describeError(error)}\n`);
636
+ return 1;
637
+ }
638
+ if (wrote.length > 0 && !ctx.json) {
639
+ ctx.err(`Scaffolded the Slack channel: ${wrote.join(", ")}\n`);
640
+ }
641
+ // A re-run rebuilds this manifest from flags, so backend-side
642
+ // customizations reset to it (documented in the skill).
643
+ const manifest = buildSlackManifest({
644
+ name: target.name,
645
+ env: target.env,
646
+ channelPosts: options.channelPosts === true,
647
+ });
648
+ const manifestJson = JSON.stringify(manifest);
649
+
650
+ // One connect-and-retry for not_connected / insufficient_scopes (both
651
+ // are fixed by a fresh consent grant).
652
+ let result: SlackAppProvisionResult;
653
+ let connectRetried = false;
654
+ for (;;) {
655
+ try {
656
+ result = await ctx.client.provisionApp({
657
+ teamId: ctx.teamId,
658
+ slackTeamId: workspace.slackTeamId,
659
+ slug: target.slug,
660
+ env: target.env,
661
+ manifest: manifestJson,
662
+ icon: iconSource?.kind === "url" ? { url: iconSource.url } : undefined,
663
+ force: options.force === true ? true : undefined,
664
+ });
665
+ break;
666
+ } catch (error) {
667
+ if (
668
+ error instanceof SlackProvisionApiError &&
669
+ (error.code === "not_connected" ||
670
+ error.code === "insufficient_scopes") &&
671
+ !connectRetried
672
+ ) {
673
+ connectRetried = true;
674
+ // Both codes arrive holding a row that GET /status already lists, so
675
+ // the poll waits for a fresh grant instead of re-matching that row.
676
+ const wait: ConnectWaitTarget = {
677
+ slackTeamId: workspace.slackTeamId,
678
+ notConnectedAt: workspace.connectedAt,
679
+ };
680
+ if (error.code === "insufficient_scopes") {
681
+ ctx.err(
682
+ `The Slack connection is missing scopes provisioning needs; approving the consent link again grants them.\n`
683
+ );
684
+ } else {
685
+ ctx.err(
686
+ `The Slack workspace is not connected to Cursor; starting the connect flow.\n`
687
+ );
688
+ }
689
+ const connected = await runConnectFlow(ctx, wait);
690
+ if (typeof connected === "number") {
691
+ return connected;
692
+ }
693
+ workspace = connected;
694
+ continue;
695
+ }
696
+ return renderCreateProvisionError(error, ctx.err);
697
+ }
698
+ }
699
+
700
+ const iconStatus = await applySlackCreateIcon(ctx, iconSource, result);
701
+
702
+ if (result.status === "pending_admin_approval") {
703
+ if (ctx.json) {
704
+ ctx.out(
705
+ `${JSON.stringify(
706
+ {
707
+ ok: false,
708
+ appId: result.appId,
709
+ status: result.status,
710
+ slackTeamId: workspace.slackTeamId,
711
+ envKeys: target.envKeys,
712
+ wrote,
713
+ oauthAuthorizeUrl: result.oauthAuthorizeUrl ?? null,
714
+ iconStatus,
715
+ },
716
+ null,
717
+ 2
718
+ )}\n`
719
+ );
720
+ } else {
721
+ ctx.err(
722
+ `The Slack app was created (app ${result.appId}), but installing it needs a Slack workspace-admin approval.\n` +
723
+ (result.oauthAuthorizeUrl === undefined
724
+ ? ""
725
+ : `\n Approve it (or send this to a workspace admin):\n\n ${result.oauthAuthorizeUrl}\n\n`) +
726
+ `Once an admin approves the app, re-run \`${CLI} slack create\` to finish the install and mint the tokens.\n`
727
+ );
728
+ }
729
+ return 1;
730
+ }
731
+
732
+ // Tokens come back exactly once and are never printed.
733
+ const envEntries = [{ key: target.envKeys.bot, value: result.botToken }];
734
+ if (result.appToken !== undefined) {
735
+ envEntries.push({ key: target.envKeys.app, value: result.appToken });
736
+ }
737
+ const envFile = join(target.dir, ".env.local");
738
+ try {
739
+ await upsertEnvFile(envFile, envEntries);
740
+ if (await ensureEnvLocalGitignored(target.dir)) {
741
+ wrote.push(".gitignore (+.env.local)");
742
+ }
743
+ } catch (error) {
744
+ ctx.err(`Could not write ${envFile}: ${describeError(error)}\n`);
745
+ return 1;
746
+ }
747
+ wrote.push(".env.local");
748
+ for (const { key, value } of envEntries) {
749
+ process.env[key] = value;
750
+ }
751
+ if (result.appToken === undefined) {
752
+ ctx.err(
753
+ `warning: Slack did not return an app-level token, so ${target.envKeys.app} was not written (an existing value was left as-is and may belong to an older app); re-run \`${CLI} slack create\` to recover it.\n`
754
+ );
755
+ }
756
+
757
+ if (ctx.json) {
758
+ ctx.out(
759
+ `${JSON.stringify(
760
+ {
761
+ ok: true,
762
+ appId: result.appId,
763
+ status: result.status,
764
+ slackTeamId: workspace.slackTeamId,
765
+ envKeys: target.envKeys,
766
+ wrote,
767
+ iconStatus,
768
+ },
769
+ null,
770
+ 2
771
+ )}\n`
772
+ );
773
+ return 0;
774
+ }
775
+
776
+ ctx.out(
777
+ `Provisioned the ${target.env} Slack app for ${target.slug} (app ${result.appId}) in workspace ${workspace.slackTeamId}.\n` +
778
+ ` wrote ${envEntries.map((entry) => entry.key).join(" + ")} to ${envFile}\n\n`
779
+ );
780
+
781
+ const doctor = options.doctorImpl ?? runSlackDoctor;
782
+ const doctorResult = await doctor(
783
+ target.envPrefix === undefined ? {} : { envPrefix: target.envPrefix }
784
+ );
785
+ ctx.out(renderDoctorReport(doctorResult));
786
+
787
+ ctx.out(
788
+ `\nNext steps:\n` +
789
+ ` 1. ${CLI} dev ${options.dir}\n` +
790
+ ` 2. In Slack, invite @${manifest.features.bot_user.display_name} to a channel and @mention it (or DM it).\n`
791
+ );
792
+ return 0;
793
+ }
794
+
795
+ /**
796
+ * Resolve the one provisioned app for (slug, env[, --slack-team]) from
797
+ * `GET /status`. Returns an exit code with the error already rendered
798
+ * when zero or several apps match.
799
+ */
800
+ async function resolveProvisionedApp(
801
+ ctx: SlackProvisionContext,
802
+ target: SlackAppTarget,
803
+ options: SlackCliOptions
804
+ ): Promise<SlackManagedAppInfo | number> {
805
+ let status: SlackProvisionStatus;
806
+ try {
807
+ status = await ctx.client.status(ctx.teamId);
808
+ } catch (error) {
809
+ return renderSlackApiError(error, ctx.err);
810
+ }
811
+ const sameSlug = status.apps.filter((app) => app.slug === target.slug);
812
+ let candidates = sameSlug.filter((app) => app.env === target.env);
813
+ if (options.slackTeam !== undefined) {
814
+ candidates = candidates.filter(
815
+ (app) => app.slackTeamId === options.slackTeam
816
+ );
817
+ }
818
+ if (candidates.length === 0) {
819
+ ctx.err(
820
+ `No provisioned ${target.env} Slack app for "${target.slug}"${
821
+ options.slackTeam === undefined
822
+ ? ""
823
+ : ` in workspace ${options.slackTeam}`
824
+ } (team ${ctx.teamId}).\n` +
825
+ (sameSlug.length === 0
826
+ ? ""
827
+ : `Provisioned apps for this agent:\n${formatAppList(sameSlug)}`)
828
+ );
829
+ return 1;
830
+ }
831
+ if (candidates.length > 1) {
832
+ ctx.err(
833
+ `Multiple provisioned Slack apps match "${target.slug}" (${target.env}):\n` +
834
+ formatAppList(candidates) +
835
+ `Pass --slack-team <id> to pick one.\n`
836
+ );
837
+ return 1;
838
+ }
839
+ return candidates[0]!;
840
+ }
841
+
842
+ export async function cmdSlackDestroy(
843
+ options: SlackCliOptions
844
+ ): Promise<number> {
845
+ const target = resolveSlackAppTarget(options, errStream(options));
846
+ if (typeof target === "number") {
847
+ return target;
848
+ }
849
+ const ctx = await prepareProvision(options);
850
+ if (typeof ctx === "number") {
851
+ return ctx;
852
+ }
853
+
854
+ const app = await resolveProvisionedApp(ctx, target, options);
855
+ if (typeof app === "number") {
856
+ return app;
857
+ }
858
+ try {
859
+ await ctx.client.deleteApp({
860
+ teamId: ctx.teamId,
861
+ slackAppId: app.slackAppId,
862
+ });
863
+ } catch (error) {
864
+ return renderSlackApiError(error, ctx.err);
865
+ }
866
+ if (ctx.json) {
867
+ ctx.out(
868
+ `${JSON.stringify(
869
+ {
870
+ ok: true,
871
+ deleted: true,
872
+ appId: app.slackAppId,
873
+ slackTeamId: app.slackTeamId,
874
+ slug: target.slug,
875
+ env: target.env,
876
+ },
877
+ null,
878
+ 2
879
+ )}\n`
880
+ );
881
+ return 0;
882
+ }
883
+ ctx.out(
884
+ `Deleted the ${target.env} Slack app for ${target.slug} (app ${app.slackAppId}) from workspace ${app.slackTeamId}.\n` +
885
+ `Tokens in .env.local were left in place — ${target.envKeys.bot} / ${target.envKeys.app} are now inert; remove them whenever you like.\n`
886
+ );
887
+ return 0;
888
+ }
889
+
890
+ export async function cmdSlackIcon(options: SlackCliOptions): Promise<number> {
891
+ const err = errStream(options);
892
+ if (options.iconSource === undefined) {
893
+ err(
894
+ `Usage: ${CLI} slack icon <https-url-or-file> [--dir .] [--slack-team T0123ABCD] [--prod] [--team <id>] [--json]\n`
895
+ );
896
+ return 1;
897
+ }
898
+ const source = await resolveSlackIconSource(options.iconSource, err);
899
+ if (typeof source === "number") {
900
+ return source;
901
+ }
902
+ const target = resolveSlackAppTarget(options, err);
903
+ if (typeof target === "number") {
904
+ return target;
905
+ }
906
+ const ctx = await prepareProvision(options);
907
+ if (typeof ctx === "number") {
908
+ return ctx;
909
+ }
910
+ const app = await resolveProvisionedApp(ctx, target, options);
911
+ if (typeof app === "number") {
912
+ return app;
913
+ }
914
+ try {
915
+ await ctx.client.setAppIcon({
916
+ teamId: ctx.teamId,
917
+ slackAppId: app.slackAppId,
918
+ icon: toSlackAppIconInput(source),
919
+ });
920
+ } catch (error) {
921
+ return renderSlackApiError(error, ctx.err);
922
+ }
923
+ if (ctx.json) {
924
+ ctx.out(
925
+ `${JSON.stringify(
926
+ {
927
+ ok: true,
928
+ appId: app.slackAppId,
929
+ slackTeamId: app.slackTeamId,
930
+ slug: target.slug,
931
+ env: target.env,
932
+ iconStatus: "set",
933
+ },
934
+ null,
935
+ 2
936
+ )}\n`
937
+ );
938
+ return 0;
939
+ }
940
+ ctx.out(
941
+ `Set the icon for the ${target.env} Slack app for ${target.slug} (app ${app.slackAppId}).\n`
942
+ );
943
+ return 0;
944
+ }
945
+
946
+ function formatAppList(
947
+ apps: ReadonlyArray<{
948
+ slackAppId: string;
949
+ slackTeamId: string;
950
+ env: string;
951
+ installStatus: string;
952
+ }>
953
+ ): string {
954
+ return apps
955
+ .map(
956
+ (app) =>
957
+ ` - ${app.slackAppId} (${app.env}) in workspace ${app.slackTeamId} — ${app.installStatus}\n`
958
+ )
959
+ .join("");
960
+ }
961
+
962
+ /**
963
+ * Scaffold the channel files when the channel file is absent. Existing
964
+ * files are never overwritten: initSlackChannel writes with `wx`, and
965
+ * EEXIST means "already scaffolded" (provisioning builds its manifest in
966
+ * memory and does not need the on-disk copies).
967
+ */
968
+ async function scaffoldSlackChannelFiles(
969
+ options: SlackCliOptions,
970
+ target: SlackAppTarget
971
+ ): Promise<string[]> {
972
+ const channelFile = "agent/channels/slack.ts";
973
+ if (await fileExists(join(target.dir, channelFile))) {
974
+ return [];
975
+ }
976
+ try {
977
+ // Pass the resolved name/prefix so the channel file and the env keys
978
+ // share one derivation.
979
+ const result = await initSlackChannel({
980
+ dir: target.dir,
981
+ name: target.name,
982
+ envPrefix: target.envPrefix ?? "",
983
+ channelPosts: options.channelPosts,
984
+ });
985
+ return result.files;
986
+ } catch (error) {
987
+ if (isErrnoException(error) && error.code === "EEXIST") {
988
+ // The channel file is written first, so it exists by the time a
989
+ // later pre-existing file aborts the scaffold.
990
+ return (await fileExists(join(target.dir, channelFile)))
991
+ ? [channelFile]
992
+ : [];
993
+ }
994
+ throw error;
995
+ }
996
+ }
997
+
998
+ /**
999
+ * Append `KEY=value` lines to an env file, replacing every existing line
1000
+ * for the same key instead of appending duplicates. Creates the file when
1001
+ * missing.
1002
+ */
1003
+ async function upsertEnvFile(
1004
+ path: string,
1005
+ entries: Array<{ key: string; value: string }>
1006
+ ): Promise<void> {
1007
+ let existing = "";
1008
+ try {
1009
+ existing = await readFile(path, "utf8");
1010
+ } catch (error) {
1011
+ if (!isErrnoException(error) || error.code !== "ENOENT") {
1012
+ throw error;
1013
+ }
1014
+ }
1015
+ const lines =
1016
+ existing === "" ? [] : existing.replace(/\r?\n$/, "").split(/\r?\n/);
1017
+ // A stale `export KEY=…` line would shadow or duplicate the bare line
1018
+ // under some loaders; treat both spellings as the same entry.
1019
+ const matchesKey = (line: string, key: string): boolean =>
1020
+ line.startsWith(`${key}=`) || line.startsWith(`export ${key}=`);
1021
+ for (const { key, value } of entries) {
1022
+ const next = `${key}=${value}`;
1023
+ let replaced = false;
1024
+ for (let i = 0; i < lines.length; ) {
1025
+ if (!matchesKey(lines[i] ?? "", key)) {
1026
+ i += 1;
1027
+ continue;
1028
+ }
1029
+ if (replaced) {
1030
+ lines.splice(i, 1);
1031
+ continue;
1032
+ }
1033
+ lines[i] = next;
1034
+ replaced = true;
1035
+ i += 1;
1036
+ }
1037
+ if (!replaced) {
1038
+ lines.push(next);
1039
+ }
1040
+ }
1041
+ // writeFile's mode only applies on create; clamp pre-existing files too.
1042
+ await writeFile(path, `${lines.join("\n")}\n`, { mode: 0o600 });
1043
+ await chmod(path, 0o600);
1044
+ }
1045
+
1046
+ /**
1047
+ * Ensure the agent directory's .gitignore covers .env.local (create or
1048
+ * append). A bare `.env` line does not count — that pattern matches only
1049
+ * the file literally named .env.
1050
+ */
1051
+ async function ensureEnvLocalGitignored(dir: string): Promise<boolean> {
1052
+ const path = join(dir, ".gitignore");
1053
+ let existing = "";
1054
+ try {
1055
+ existing = await readFile(path, "utf8");
1056
+ } catch (error) {
1057
+ if (!isErrnoException(error) || error.code !== "ENOENT") {
1058
+ throw error;
1059
+ }
1060
+ }
1061
+ const covered = existing
1062
+ .split(/\r?\n/)
1063
+ .some((line) => /^\.env(\.local\*?|\.\*|\*)$/.test(line.trim()));
1064
+ if (covered) {
1065
+ return false;
1066
+ }
1067
+ const next =
1068
+ existing === "" || existing.endsWith("\n")
1069
+ ? `${existing}.env.local\n`
1070
+ : `${existing}\n.env.local\n`;
1071
+ await writeFile(path, next);
1072
+ return true;
1073
+ }
1074
+
1075
+ function renderDoctorReport(result: SlackDoctorResult): string {
1076
+ let text = `env: ${result.envKeys.bot} / ${result.envKeys.app}\n`;
1077
+ for (const check of result.checks) {
1078
+ text += `${check.ok ? "ok" : "!!"} ${check.id}: ${check.message}\n`;
1079
+ }
1080
+ text += result.ok ? "doctor: pass\n" : "doctor: fail\n";
1081
+ return text;
1082
+ }
1083
+
1084
+ /**
1085
+ * Actionable rendering for `POST /apps` failures the create flow does not
1086
+ * retry itself (the connect-and-retry codes are handled by the caller).
1087
+ */
1088
+ function renderCreateProvisionError(
1089
+ error: unknown,
1090
+ err: (text: string) => void
1091
+ ): number {
1092
+ if (!(error instanceof SlackProvisionApiError)) {
1093
+ throw error;
1094
+ }
1095
+ switch (error.code) {
1096
+ case "app_conflict":
1097
+ err(
1098
+ `${error.message}\nA concurrent \`${CLI} slack create\` for this agent won the race; re-run create to pick up that app.\n`
1099
+ );
1100
+ return 1;
1101
+ case "app_cap_reached":
1102
+ err(
1103
+ `${error.message}\nDelete unused apps with \`${CLI} slack destroy\`, then re-run create.\n`
1104
+ );
1105
+ return 1;
1106
+ case "ratelimited":
1107
+ err(`${error.message}\nRe-run \`${CLI} slack create\` after the wait.\n`);
1108
+ return 1;
1109
+ case "invalid_app_id":
1110
+ err(
1111
+ `${error.message}\nThe provisioned Slack app seems to have been deleted outside Cursor. Run \`${CLI} slack destroy\` to drop the stale record, then re-run \`${CLI} slack create\`.\n`
1112
+ );
1113
+ return 1;
1114
+ case "manifest_drift":
1115
+ err(
1116
+ `${error.message}\n` +
1117
+ (error.changedPaths ?? []).map((path) => ` - ${path}\n`).join("") +
1118
+ `Re-run \`${CLI} slack create --force\` to overwrite those edits.\n`
1119
+ );
1120
+ return 1;
1121
+ default:
1122
+ err(`${error.message}\n`);
1123
+ return 1;
1124
+ }
1125
+ }
1126
+
1127
+ function renderSlackApiError(
1128
+ error: unknown,
1129
+ err: (text: string) => void
1130
+ ): number {
1131
+ if (error instanceof SlackProvisionApiError) {
1132
+ err(`${error.message}\n`);
1133
+ return 1;
1134
+ }
1135
+ throw error;
1136
+ }
1137
+
1138
+ async function fileExists(path: string): Promise<boolean> {
1139
+ try {
1140
+ await access(path);
1141
+ return true;
1142
+ } catch {
1143
+ return false;
1144
+ }
1145
+ }
1146
+
1147
+ function isErrnoException(error: unknown): error is NodeJS.ErrnoException {
1148
+ return error instanceof Error && "code" in error;
1149
+ }
1150
+
1151
+ function sleep(ms: number): Promise<void> {
1152
+ return new Promise((resolvePromise) => setTimeout(resolvePromise, ms));
175
1153
  }