@cursor/july 0.1.16 → 0.1.18

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