@cursor/july 0.1.15 → 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.
- package/README.md +9 -4
- package/dist/bin/agent-serve.js +60 -2
- package/dist/channels/slack/api.d.ts.map +1 -1
- package/dist/channels/slack/api.js +13 -3
- package/dist/channels/slack/auth.d.ts +7 -1
- package/dist/channels/slack/auth.d.ts.map +1 -1
- package/dist/channels/slack/auth.js +14 -0
- package/dist/channels/slack/blocks.d.ts +61 -0
- package/dist/channels/slack/blocks.d.ts.map +1 -0
- package/dist/channels/slack/blocks.js +90 -0
- package/dist/channels/slack/defaults.d.ts +2 -0
- package/dist/channels/slack/defaults.d.ts.map +1 -1
- package/dist/channels/slack/defaults.js +29 -3
- package/dist/channels/slack/dispatch.d.ts +3 -17
- package/dist/channels/slack/dispatch.d.ts.map +1 -1
- package/dist/channels/slack/index.d.ts +1 -0
- package/dist/channels/slack/index.d.ts.map +1 -1
- package/dist/channels/slack/index.js +1 -0
- package/dist/channels/slack/interactive.d.ts +39 -3
- package/dist/channels/slack/interactive.d.ts.map +1 -1
- package/dist/channels/slack/interactive.js +152 -7
- package/dist/channels/slack/manifest.d.ts +3 -0
- package/dist/channels/slack/manifest.d.ts.map +1 -1
- package/dist/channels/slack/manifest.js +3 -0
- package/dist/channels/slack/setup.d.ts +4 -1
- package/dist/channels/slack/setup.d.ts.map +1 -1
- package/dist/channels/slack/setup.js +16 -2
- package/dist/channels/slack/slack-channel.d.ts.map +1 -1
- package/dist/channels/slack/slack-channel.js +24 -3
- package/dist/channels/slack/types.d.ts +77 -0
- package/dist/channels/slack/types.d.ts.map +1 -1
- package/dist/connections.d.ts +4 -1
- package/dist/connections.d.ts.map +1 -1
- package/dist/connections.js +4 -1
- package/dist/docs/404.html +2 -2
- package/dist/docs/ab.html +3 -3
- package/dist/docs/assets/{app.DpdGZTLe.js → app.BbEXr2KO.js} +1 -1
- package/dist/docs/assets/chunks/@localSearchIndexroot.DCBsrfwP.js +1 -0
- package/dist/docs/assets/chunks/{VPLocalSearchBox.IB-0QqIT.js → VPLocalSearchBox.DBEi4HDp.js} +1 -1
- package/dist/docs/assets/chunks/{theme.-fYBoNtR.js → theme.DGfptcHw.js} +2 -2
- package/dist/docs/assets/{example-agents_index.md.BPXjUtRB.js → example-agents_index.md.D2PEVSXl.js} +2 -2
- package/dist/docs/assets/{example-agents_index.md.BPXjUtRB.lean.js → example-agents_index.md.D2PEVSXl.lean.js} +1 -1
- package/dist/docs/assets/example-agents_security-reviewer.md.BZSNycMf.js +19 -0
- package/dist/docs/assets/{example-agents_security-reviewer.md.CE9v-aKm.lean.js → example-agents_security-reviewer.md.BZSNycMf.lean.js} +1 -1
- package/dist/docs/assets/{guides_mcp-oauth.md.DqmEOQY1.js → guides_mcp-oauth.md.Dd8EgSem.js} +3 -2
- package/dist/docs/assets/{guides_mcp-oauth.md.DqmEOQY1.lean.js → guides_mcp-oauth.md.Dd8EgSem.lean.js} +1 -1
- package/dist/docs/assets/{guides_slack.md.FWale6Ip.js → guides_slack.md.D_xFKlnF.js} +1 -1
- package/dist/docs/assets/{reference_connections.md.CsSr3rDz.js → reference_connections.md.B9Q3TOve.js} +12 -5
- package/dist/docs/assets/{reference_connections.md.CsSr3rDz.lean.js → reference_connections.md.B9Q3TOve.lean.js} +1 -1
- package/dist/docs/building-with-agents.html +3 -3
- package/dist/docs/concepts.html +3 -3
- package/dist/docs/deployment.html +3 -3
- package/dist/docs/evals.html +3 -3
- package/dist/docs/example-agents/approval-buddy.html +3 -3
- package/dist/docs/example-agents/benny.html +3 -3
- package/dist/docs/example-agents/bugbot.html +3 -3
- package/dist/docs/example-agents/codebase-wiki.html +3 -3
- package/dist/docs/example-agents/codeowners-review.html +3 -3
- package/dist/docs/example-agents/concierge.html +3 -3
- package/dist/docs/example-agents/fsd.html +3 -3
- package/dist/docs/example-agents/index.html +5 -5
- package/dist/docs/example-agents/knowledge-base.html +3 -3
- package/dist/docs/example-agents/oncall.html +3 -3
- package/dist/docs/example-agents/security-reviewer.html +14 -14
- package/dist/docs/example-agents/slack-agent.html +3 -3
- package/dist/docs/example-agents/weather-agent.html +3 -3
- package/dist/docs/guides/agent-to-agent.html +3 -3
- package/dist/docs/guides/cloud-runtime.html +3 -3
- package/dist/docs/guides/github.html +3 -3
- package/dist/docs/guides/human-in-the-loop.html +3 -3
- package/dist/docs/guides/mcp-oauth.html +6 -5
- package/dist/docs/guides/slack.html +5 -5
- package/dist/docs/guides/webhooks.html +3 -3
- package/dist/docs/hashmap.json +1 -1
- package/dist/docs/hillclimbing.html +3 -3
- package/dist/docs/index.html +3 -3
- package/dist/docs/quickstart.html +3 -3
- package/dist/docs/reference/agent-config.html +3 -3
- package/dist/docs/reference/channels.html +3 -3
- package/dist/docs/reference/cli.html +3 -3
- package/dist/docs/reference/connections.html +14 -7
- package/dist/docs/reference/hooks.html +3 -3
- package/dist/docs/reference/http-api.html +3 -3
- package/dist/docs/reference/instructions.html +3 -3
- package/dist/docs/reference/playground.html +3 -3
- package/dist/docs/reference/project-layout.html +3 -3
- package/dist/docs/reference/prompt.html +3 -3
- package/dist/docs/reference/schedules.html +3 -3
- package/dist/docs/reference/sessions.html +3 -3
- package/dist/docs/reference/skills.html +3 -3
- package/dist/docs/reference/subagents.html +3 -3
- package/dist/docs/reference/tools.html +3 -3
- package/dist/docs/scaffolding-agents.html +3 -3
- package/dist/docs/storage.html +3 -3
- package/dist/docs/troubleshooting.html +3 -3
- package/dist/evals/assertions.d.ts +190 -0
- package/dist/evals/assertions.d.ts.map +1 -0
- package/dist/evals/assertions.js +461 -0
- package/dist/evals/expect.d.ts +95 -0
- package/dist/evals/expect.d.ts.map +1 -0
- package/dist/evals/expect.js +194 -0
- package/dist/evals/judge.d.ts +133 -0
- package/dist/evals/judge.d.ts.map +1 -0
- package/dist/evals/judge.js +228 -0
- package/dist/evals/loaders.d.ts +51 -0
- package/dist/evals/loaders.d.ts.map +1 -0
- package/dist/evals/loaders.js +124 -0
- package/dist/evals/matchers.d.ts +95 -0
- package/dist/evals/matchers.d.ts.map +1 -0
- package/dist/evals/matchers.js +145 -0
- package/dist/evals/reporters.d.ts +70 -0
- package/dist/evals/reporters.d.ts.map +1 -0
- package/dist/evals/reporters.js +221 -0
- package/dist/evals/results.d.ts +103 -0
- package/dist/evals/results.d.ts.map +1 -0
- package/dist/evals/results.js +28 -0
- package/dist/evals/run-facts.d.ts +77 -0
- package/dist/evals/run-facts.d.ts.map +1 -0
- package/dist/evals/run-facts.js +187 -0
- package/dist/evals.d.ts +136 -31
- package/dist/evals.d.ts.map +1 -1
- package/dist/evals.js +76 -12
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/internal/cli-ax.d.ts +27 -0
- package/dist/internal/cli-ax.d.ts.map +1 -1
- package/dist/internal/cli-ax.js +90 -12
- package/dist/internal/cli-mcp.d.ts +8 -0
- package/dist/internal/cli-mcp.d.ts.map +1 -1
- package/dist/internal/cli-mcp.js +98 -0
- package/dist/internal/cli-slack.d.ts +29 -1
- package/dist/internal/cli-slack.d.ts.map +1 -1
- package/dist/internal/cli-slack.js +777 -12
- package/dist/internal/deploy-client.d.ts +7 -0
- package/dist/internal/deploy-client.d.ts.map +1 -1
- package/dist/internal/deploy-client.js +9 -0
- package/dist/internal/discovery.d.ts.map +1 -1
- package/dist/internal/discovery.js +29 -9
- package/dist/internal/distribution.d.ts.map +1 -1
- package/dist/internal/distribution.js +2 -0
- package/dist/internal/eval-judge-model.d.ts +59 -0
- package/dist/internal/eval-judge-model.d.ts.map +1 -0
- package/dist/internal/eval-judge-model.js +131 -0
- package/dist/internal/eval-run-store.d.ts.map +1 -1
- package/dist/internal/eval-run-store.js +12 -20
- package/dist/internal/eval-runner.d.ts +31 -25
- package/dist/internal/eval-runner.d.ts.map +1 -1
- package/dist/internal/eval-runner.js +332 -128
- package/dist/internal/evals-client.d.ts.map +1 -1
- package/dist/internal/evals-client.js +7 -3
- package/dist/internal/local-env.d.ts +8 -0
- package/dist/internal/local-env.d.ts.map +1 -0
- package/dist/internal/local-env.js +47 -0
- package/dist/internal/playground/static.d.ts +3 -0
- package/dist/internal/playground/static.d.ts.map +1 -1
- package/dist/internal/playground/static.js +3 -0
- package/dist/internal/run-client.d.ts +15 -0
- package/dist/internal/run-client.d.ts.map +1 -1
- package/dist/internal/run-client.js +3 -2
- package/dist/internal/sdk-runner.d.ts +36 -1
- package/dist/internal/sdk-runner.d.ts.map +1 -1
- package/dist/internal/sdk-runner.js +65 -25
- package/dist/internal/server.d.ts.map +1 -1
- package/dist/internal/server.js +10 -1
- package/dist/internal/session-engine.d.ts +18 -5
- package/dist/internal/session-engine.d.ts.map +1 -1
- package/dist/internal/session-engine.js +72 -19
- package/dist/internal/slack-provision-client.d.ts +130 -0
- package/dist/internal/slack-provision-client.d.ts.map +1 -0
- package/dist/internal/slack-provision-client.js +284 -0
- package/dist/playground/assets/cursor-icons-16-f_W_ogc-.woff2 +0 -0
- package/dist/playground/assets/index-CidizGZv.css +1 -0
- package/dist/playground/assets/index-DNqirsrK.js +85 -0
- package/dist/playground/index.html +2 -2
- package/dist/types.d.ts +70 -4
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +26 -0
- package/docs/example-agents/index.md +1 -1
- package/docs/example-agents/security-reviewer.md +23 -21
- package/docs/guides/mcp-oauth.md +10 -5
- package/docs/guides/slack.md +3 -2
- package/docs/reference/connections.md +28 -18
- package/package.json +20 -2
- package/skills/create-agent/SKILL.md +15 -7
- package/skills/debug/SKILL.md +3 -1
- package/skills/mcp-auth/SKILL.md +1 -1
- package/skills/setup-slack/SKILL.md +173 -11
- package/src/bin/agent-serve.ts +63 -2
- package/src/channels/slack/api.ts +16 -3
- package/src/channels/slack/auth.ts +16 -1
- package/src/channels/slack/blocks.ts +139 -0
- package/src/channels/slack/defaults.ts +32 -4
- package/src/channels/slack/dispatch.ts +3 -18
- package/src/channels/slack/index.ts +1 -0
- package/src/channels/slack/interactive.ts +183 -19
- package/src/channels/slack/manifest.ts +3 -0
- package/src/channels/slack/setup.ts +16 -2
- package/src/channels/slack/slack-channel.ts +37 -6
- package/src/channels/slack/types.ts +89 -0
- package/src/connections.ts +4 -1
- package/src/evals/assertions.ts +704 -0
- package/src/evals/expect.ts +272 -0
- package/src/evals/judge.ts +346 -0
- package/src/evals/loaders.ts +113 -0
- package/src/evals/matchers.ts +246 -0
- package/src/evals/reporters.ts +274 -0
- package/src/evals/results.ts +132 -0
- package/src/evals/run-facts.ts +274 -0
- package/src/evals.ts +249 -42
- package/src/index.ts +2 -0
- package/src/internal/cli-ax.ts +134 -15
- package/src/internal/cli-mcp.ts +118 -5
- package/src/internal/cli-slack.ts +993 -15
- package/src/internal/deploy-client.ts +10 -0
- package/src/internal/discovery.ts +31 -5
- package/src/internal/distribution.ts +2 -0
- package/src/internal/eval-judge-model.ts +148 -0
- package/src/internal/eval-run-store.ts +10 -14
- package/src/internal/eval-runner.ts +466 -215
- package/src/internal/evals-client.ts +14 -2
- package/src/internal/local-env.ts +53 -0
- package/src/internal/playground/static.ts +3 -0
- package/src/internal/run-client.ts +17 -1
- package/src/internal/sdk-runner.ts +103 -22
- package/src/internal/server.ts +10 -0
- package/src/internal/session-engine.ts +89 -21
- package/src/internal/slack-provision-client.ts +438 -0
- package/src/types.ts +92 -4
- package/dist/channels/slack/post-update-delivery.d.ts +0 -85
- package/dist/channels/slack/post-update-delivery.d.ts.map +0 -1
- package/dist/docs/assets/chunks/@localSearchIndexroot.D2d8CfFS.js +0 -1
- package/dist/docs/assets/example-agents_security-reviewer.md.CE9v-aKm.js +0 -19
- package/dist/internal/json-dir-store.d.ts +0 -32
- package/dist/internal/json-dir-store.d.ts.map +0 -1
- package/dist/internal/persistence-coordinator.d.ts +0 -127
- package/dist/internal/persistence-coordinator.d.ts.map +0 -1
- package/dist/persistence.d.ts +0 -184
- package/dist/persistence.d.ts.map +0 -1
- package/dist/playground/assets/cursor-icons-16-CQ50JpfO.woff2 +0 -0
- package/dist/playground/assets/index-BVMOoiek.js +0 -86
- package/dist/playground/assets/index-DYmP4b--.css +0 -1
- /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
|
|
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
|
|
159
|
-
|
|
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
|
-
|
|
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
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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
|
-
|
|
365
|
+
return { kind: "url", url: raw };
|
|
173
366
|
}
|
|
174
|
-
|
|
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
|
}
|