@cursor/july 0.1.32 → 0.1.34
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 +1 -1
- package/dist/artifacts.d.ts.map +1 -1
- package/dist/artifacts.js +0 -5
- package/dist/bin/agent-serve.d.ts +1 -0
- package/dist/bin/agent-serve.d.ts.map +1 -1
- package/dist/bin/agent-serve.js +32 -2
- package/dist/channels/github/types.d.ts +10 -7
- package/dist/channels/github/types.d.ts.map +1 -1
- package/dist/docs/404.html +2 -2
- package/dist/docs/ab.html +3 -3
- package/dist/docs/assets/{app.C2aVczmo.js → app.FPupl4SP.js} +1 -1
- package/dist/docs/assets/chunks/@localSearchIndexroot.WoYunhnT.js +1 -0
- package/dist/docs/assets/chunks/{VPLocalSearchBox.DLEzetbD.js → VPLocalSearchBox.Cd182Cu0.js} +1 -1
- package/dist/docs/assets/chunks/{theme.BkOH1oMp.js → theme.BEM3Okcd.js} +2 -2
- package/dist/docs/assets/{example-agents_weather-agent.md.Dmrcphhl.js → example-agents_weather-agent.md.BADkPqxQ.js} +6 -4
- package/dist/docs/assets/example-agents_weather-agent.md.BADkPqxQ.lean.js +1 -0
- package/dist/docs/assets/guides_cloud-runtime.md.CDJGvVC4.js +9 -0
- package/dist/docs/assets/guides_cloud-runtime.md.CDJGvVC4.lean.js +1 -0
- 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 +3 -3
- 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 +3 -3
- package/dist/docs/example-agents/slack-agent.html +3 -3
- package/dist/docs/example-agents/weather-agent.html +10 -8
- package/dist/docs/guides/agent-to-agent.html +3 -3
- package/dist/docs/guides/cloud-runtime.html +5 -5
- 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 +3 -3
- package/dist/docs/guides/slack.html +3 -3
- 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 +3 -3
- 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/files-backends/agent-store-presigned-url.d.ts.map +1 -1
- package/dist/files-backends/agent-store-presigned-url.js +19 -810
- package/dist/files.d.ts +20 -0
- package/dist/files.d.ts.map +1 -1
- package/dist/files.js +20 -0
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -2
- package/dist/internal/artifacts-store.d.ts +14 -6
- package/dist/internal/artifacts-store.d.ts.map +1 -1
- package/dist/internal/artifacts-store.js +160 -16
- package/dist/internal/cli-convert-automation.d.ts +39 -0
- package/dist/internal/cli-convert-automation.d.ts.map +1 -0
- package/dist/internal/cli-convert-automation.js +280 -0
- package/dist/internal/cli-deploy.d.ts +2 -0
- package/dist/internal/cli-deploy.d.ts.map +1 -1
- package/dist/internal/cli-deploy.js +1 -1
- package/dist/internal/cli-mcp-oauth.d.ts +23 -0
- package/dist/internal/cli-mcp-oauth.d.ts.map +1 -1
- package/dist/internal/cli-mcp-oauth.js +57 -33
- package/dist/internal/convert-automation/convert-workflow.d.ts +10 -0
- package/dist/internal/convert-automation/convert-workflow.d.ts.map +1 -0
- package/dist/internal/convert-automation/convert-workflow.js +998 -0
- package/dist/internal/convert-automation/index.d.ts +3 -0
- package/dist/internal/convert-automation/index.d.ts.map +1 -0
- package/dist/internal/convert-automation/index.js +1 -0
- package/dist/internal/convert-automation/slug.d.ts +11 -0
- package/dist/internal/convert-automation/slug.d.ts.map +1 -0
- package/dist/internal/convert-automation/slug.js +36 -0
- package/dist/internal/convert-automation/types.d.ts +191 -0
- package/dist/internal/convert-automation/types.d.ts.map +1 -0
- package/dist/internal/convert-automation/types.js +13 -0
- package/dist/internal/cursor-relay-core.d.ts +12 -0
- package/dist/internal/cursor-relay-core.d.ts.map +1 -1
- package/dist/internal/cursor-relay-core.js +42 -8
- package/dist/internal/deploy-client.d.ts +31 -0
- package/dist/internal/deploy-client.d.ts.map +1 -1
- package/dist/internal/deploy-client.js +48 -0
- package/dist/internal/discovery.d.ts.map +1 -1
- package/dist/internal/discovery.js +3 -32
- package/dist/internal/init-project.d.ts +1 -0
- package/dist/internal/init-project.d.ts.map +1 -1
- package/dist/internal/init-project.js +1 -1
- package/dist/internal/mcp-endpoint.d.ts +6 -0
- package/dist/internal/mcp-endpoint.d.ts.map +1 -1
- package/dist/internal/mcp-endpoint.js +168 -0
- package/dist/internal/resolved-connections.d.ts +15 -0
- package/dist/internal/resolved-connections.d.ts.map +1 -1
- package/dist/internal/resolved-connections.js +12 -5
- package/dist/internal/review-comments.d.ts +9 -1
- package/dist/internal/review-comments.d.ts.map +1 -1
- package/dist/internal/review-comments.js +76 -0
- package/dist/internal/server.d.ts.map +1 -1
- package/dist/internal/server.js +83 -8
- package/dist/internal/session-engine.d.ts +56 -3
- package/dist/internal/session-engine.d.ts.map +1 -1
- package/dist/internal/session-engine.js +252 -83
- package/dist/internal/storage-coordinator.d.ts +0 -15
- package/dist/internal/storage-coordinator.d.ts.map +1 -1
- package/dist/internal/storage-coordinator.js +0 -52
- package/dist/internal/workspace.d.ts +10 -5
- package/dist/internal/workspace.d.ts.map +1 -1
- package/dist/internal/workspace.js +13 -0
- package/dist/memory.d.ts +22 -6
- package/dist/memory.d.ts.map +1 -1
- package/dist/memory.js +100 -8
- package/dist/playground/assets/index-CDDWw0YX.js +85 -0
- package/dist/playground/assets/index-MVuNTd8v.css +1 -0
- package/dist/playground/index.html +2 -2
- package/dist/storage.d.ts +0 -4
- package/dist/storage.d.ts.map +1 -1
- package/dist/storage.js +0 -4
- package/dist/types.d.ts +18 -0
- package/dist/types.d.ts.map +1 -1
- package/docs/example-agents/weather-agent.md +29 -11
- package/docs/guides/cloud-runtime.md +6 -4
- package/package.json +3 -22
- package/src/artifacts.ts +0 -6
- package/src/bin/agent-serve.ts +36 -2
- package/src/channels/github/types.ts +10 -7
- package/src/files-backends/agent-store-presigned-url.ts +7 -5
- package/src/files.ts +27 -0
- package/src/index.ts +6 -1
- package/src/internal/artifacts-store.ts +185 -22
- package/src/internal/cli-convert-automation.ts +352 -0
- package/src/internal/cli-deploy.ts +1 -1
- package/src/internal/cli-mcp-oauth.ts +69 -31
- package/src/internal/convert-automation/convert-workflow.ts +1422 -0
- package/src/internal/convert-automation/index.ts +10 -0
- package/src/internal/convert-automation/slug.ts +38 -0
- package/src/internal/convert-automation/types.ts +199 -0
- package/src/internal/cursor-relay-core.ts +62 -3
- package/src/internal/deploy-client.ts +78 -0
- package/src/internal/discovery.ts +3 -37
- package/src/internal/init-project.ts +1 -1
- package/src/internal/mcp-endpoint.ts +243 -0
- package/src/internal/resolved-connections.ts +32 -6
- package/src/internal/review-comments.ts +102 -2
- package/src/internal/server.ts +108 -2
- package/src/internal/session-engine.ts +329 -99
- package/src/internal/storage-coordinator.ts +0 -62
- package/src/internal/workspace.ts +19 -5
- package/src/memory.ts +126 -10
- package/src/storage.ts +0 -6
- package/src/types.ts +15 -0
- 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.xgMnlUSO.js +0 -1
- package/dist/docs/assets/example-agents_weather-agent.md.Dmrcphhl.lean.js +0 -1
- package/dist/docs/assets/guides_cloud-runtime.md.BZ2GA7Es.js +0 -9
- package/dist/docs/assets/guides_cloud-runtime.md.BZ2GA7Es.lean.js +0 -1
- 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/multi-tenant.d.ts +0 -80
- package/dist/multi-tenant.d.ts.map +0 -1
- package/dist/multi-tenant.js +0 -69
- package/dist/persistence.d.ts +0 -184
- package/dist/persistence.d.ts.map +0 -1
- package/dist/playground/assets/index-50PKeJlG.css +0 -1
- package/dist/playground/assets/index-C0f2Wl1q.js +0 -85
|
@@ -0,0 +1,1422 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure Workflow → agentkit project converter.
|
|
3
|
+
*
|
|
4
|
+
* Maps an exported Cursor automation (proto3 workflow JSON + enriched MCP
|
|
5
|
+
* server list) to project files, warnings, and setup steps. No I/O, no
|
|
6
|
+
* clock — `exportedAt` comes from the caller.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { CLI_COMMAND_NAME, PACKAGE_NAME } from "../distribution.js";
|
|
10
|
+
import { pascalCase, SlugRegistry, slugify } from "./slug.js";
|
|
11
|
+
import type {
|
|
12
|
+
ActionJson,
|
|
13
|
+
ConvertedFile,
|
|
14
|
+
ConvertedMcpConnection,
|
|
15
|
+
ConvertMcpServer,
|
|
16
|
+
ConvertWorkflowInput,
|
|
17
|
+
ConvertWorkflowResult,
|
|
18
|
+
GitTriggerJson,
|
|
19
|
+
PromptJson,
|
|
20
|
+
SlackActionJson,
|
|
21
|
+
SlackChannelsTriggerJson,
|
|
22
|
+
SlackWatchTriggerJson,
|
|
23
|
+
TriggerJson,
|
|
24
|
+
WorkflowJson,
|
|
25
|
+
} from "./types.js";
|
|
26
|
+
|
|
27
|
+
/** Mutable result the emitters append to (also the returned value). */
|
|
28
|
+
interface ConvertOutput {
|
|
29
|
+
files: ConvertedFile[];
|
|
30
|
+
warnings: string[];
|
|
31
|
+
setupSteps: string[];
|
|
32
|
+
mcpConnections: ConvertedMcpConnection[];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function convertWorkflow(
|
|
36
|
+
input: ConvertWorkflowInput
|
|
37
|
+
): ConvertWorkflowResult {
|
|
38
|
+
const { automation, workflow, mcpServers, exportedAt } = input;
|
|
39
|
+
const out: ConvertOutput = {
|
|
40
|
+
files: [],
|
|
41
|
+
warnings: [],
|
|
42
|
+
setupSteps: [],
|
|
43
|
+
mcpConnections: [],
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
if (workflow.managedConfig) {
|
|
47
|
+
out.warnings.push(
|
|
48
|
+
"This is a Cursor-managed automation: its behavior lives in managed configuration that is not exported. The exported project will NOT reproduce what this automation does."
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Pin the model the prompt actually ran with; the workflow-level model is
|
|
53
|
+
// only a fallback. Silently swapping models changes cost and behavior.
|
|
54
|
+
const promptModel = workflow.prompts?.[0]?.model;
|
|
55
|
+
const pinnedModel =
|
|
56
|
+
promptModel !== undefined && promptModel !== ""
|
|
57
|
+
? promptModel
|
|
58
|
+
: workflow.model;
|
|
59
|
+
if (
|
|
60
|
+
promptModel !== undefined &&
|
|
61
|
+
promptModel !== "" &&
|
|
62
|
+
workflow.model !== undefined &&
|
|
63
|
+
workflow.model !== "" &&
|
|
64
|
+
promptModel !== workflow.model
|
|
65
|
+
) {
|
|
66
|
+
out.warnings.push(
|
|
67
|
+
`The automation ran its prompt with model ${JSON.stringify(promptModel)} (pinned in agent/agent.ts); its workflow-level default was ${JSON.stringify(workflow.model)}.`
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
out.files.push({
|
|
72
|
+
path: "agent/instructions.md",
|
|
73
|
+
contents: renderInstructions(
|
|
74
|
+
automation,
|
|
75
|
+
workflow,
|
|
76
|
+
exportedAt,
|
|
77
|
+
out.warnings
|
|
78
|
+
),
|
|
79
|
+
});
|
|
80
|
+
out.files.push({
|
|
81
|
+
path: "agent/agent.ts",
|
|
82
|
+
contents: renderAgentTs(automation, workflow, pinnedModel),
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
const triggers = workflow.triggers ?? [];
|
|
86
|
+
const scheduleSlugs = new SlugRegistry();
|
|
87
|
+
const channelSlugs = new SlugRegistry();
|
|
88
|
+
|
|
89
|
+
const gitTriggers: TriggerJson[] = [];
|
|
90
|
+
const slackTriggers: TriggerJson[] = [];
|
|
91
|
+
const boilerplate: Array<{ trigger: TriggerJson; kind: BoilerplateKind }> =
|
|
92
|
+
[];
|
|
93
|
+
|
|
94
|
+
for (const trigger of triggers) {
|
|
95
|
+
if (trigger.cron !== undefined) {
|
|
96
|
+
emitSchedule(automation, trigger, scheduleSlugs, out);
|
|
97
|
+
} else if (trigger.git !== undefined) {
|
|
98
|
+
gitTriggers.push(trigger);
|
|
99
|
+
} else if (isSlackTrigger(trigger)) {
|
|
100
|
+
slackTriggers.push(trigger);
|
|
101
|
+
} else {
|
|
102
|
+
const kind = boilerplateKind(trigger);
|
|
103
|
+
if (kind === undefined) {
|
|
104
|
+
out.warnings.push(
|
|
105
|
+
`Unrecognized trigger (keys: ${Object.keys(trigger).join(", ") || "none"}) was skipped — export it manually.`
|
|
106
|
+
);
|
|
107
|
+
} else {
|
|
108
|
+
boilerplate.push({ trigger, kind });
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (gitTriggers.length > 0) {
|
|
114
|
+
emitGithubChannel(automation, workflow, gitTriggers, channelSlugs, out);
|
|
115
|
+
}
|
|
116
|
+
if (slackTriggers.length > 0) {
|
|
117
|
+
emitSlackChannel(automation, slackTriggers, channelSlugs, out);
|
|
118
|
+
}
|
|
119
|
+
for (const { trigger, kind } of boilerplate) {
|
|
120
|
+
emitBoilerplateChannel(automation, trigger, kind, channelSlugs, out);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
emitMcpConnections(automation, mcpServers, out);
|
|
124
|
+
const hasSlackAction = (workflow.actions ?? []).some(
|
|
125
|
+
(action) => action.slack !== undefined || action.readSlack !== undefined
|
|
126
|
+
);
|
|
127
|
+
const hasSlackConnection =
|
|
128
|
+
hasSlackAction && emitSlackAccountConnection(automation, out);
|
|
129
|
+
|
|
130
|
+
if (workflow.memoryEnabled === true) {
|
|
131
|
+
out.files.push({ path: "agent/hooks/memory.ts", contents: MEMORY_HOOK });
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
out.setupSteps.push(
|
|
135
|
+
...behaviorSteps(workflow, out.warnings, {
|
|
136
|
+
hasSlackChannel: slackTriggers.length > 0,
|
|
137
|
+
hasSlackConnection,
|
|
138
|
+
})
|
|
139
|
+
);
|
|
140
|
+
out.setupSteps.push(
|
|
141
|
+
`Disable the source automation before deploying this agent so both do not fire on the same events: ${automation.webUrl}${automation.enabled ? "" : " (it is currently disabled — keep it that way)"}`
|
|
142
|
+
);
|
|
143
|
+
|
|
144
|
+
return out;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// ============================================================================
|
|
148
|
+
// instructions.md
|
|
149
|
+
// ============================================================================
|
|
150
|
+
|
|
151
|
+
function renderInstructions(
|
|
152
|
+
automation: ConvertWorkflowInput["automation"],
|
|
153
|
+
workflow: WorkflowJson,
|
|
154
|
+
exportedAt: string,
|
|
155
|
+
warnings: string[]
|
|
156
|
+
): string {
|
|
157
|
+
const header = [
|
|
158
|
+
"<!--",
|
|
159
|
+
` Exported from Cursor automation "${commentSafe(automation.name)}"`,
|
|
160
|
+
` uuid: ${commentSafe(automation.uuid)}`,
|
|
161
|
+
` source: ${commentSafe(automation.webUrl)}`,
|
|
162
|
+
` exported: ${commentSafe(exportedAt)}`,
|
|
163
|
+
"-->",
|
|
164
|
+
].join("\n");
|
|
165
|
+
|
|
166
|
+
const prompts = workflow.prompts ?? [];
|
|
167
|
+
if (prompts.length === 0) {
|
|
168
|
+
warnings.push(
|
|
169
|
+
"The source automation has no prompts — agent/instructions.md contains a placeholder."
|
|
170
|
+
);
|
|
171
|
+
return `${header}\n\nTODO: the source automation had no prompt text. Write this agent's instructions.\n`;
|
|
172
|
+
}
|
|
173
|
+
// Prompt chaining was internal-only and never shipped; export the first
|
|
174
|
+
// prompt and warn if a legacy row somehow carries more.
|
|
175
|
+
if (prompts.length > 1) {
|
|
176
|
+
warnings.push(
|
|
177
|
+
`The source automation has ${prompts.length} prompts (a legacy internal feature); only the first was exported to agent/instructions.md.`
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
const prompt = prompts[0];
|
|
181
|
+
const meta = promptMeta(prompt);
|
|
182
|
+
const heading = meta === "" ? "" : `<!-- ${meta} -->\n\n`;
|
|
183
|
+
return `${header}\n\n${heading}${sanitizePromptText(prompt.prompt ?? "", warnings)}\n`;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Strip Cursor-backend prompt markup that means nothing in agentkit:
|
|
188
|
+
* `@[label](action:...)` action tokens become their label text, and
|
|
189
|
+
* unresolvable `%%INLINECODE<n>%%` storage placeholders are flagged (their
|
|
190
|
+
* content is not in the export).
|
|
191
|
+
*/
|
|
192
|
+
function sanitizePromptText(text: string, warnings: string[]): string {
|
|
193
|
+
let sanitized = text;
|
|
194
|
+
if (/@\[[^\]]*\]\(action:[^)]*\)/.test(sanitized)) {
|
|
195
|
+
warnings.push(
|
|
196
|
+
"The prompt referenced Cursor automation actions (@[...](action:...)); the tokens were replaced with their label text. Wire the equivalent channel/connection (see setup steps) to restore the behavior."
|
|
197
|
+
);
|
|
198
|
+
sanitized = sanitized.replace(/@\[([^\]]*)\]\(action:[^)]*\)/g, "$1");
|
|
199
|
+
}
|
|
200
|
+
if (/%%INLINECODE\d+%%/.test(sanitized)) {
|
|
201
|
+
warnings.push(
|
|
202
|
+
"agent/instructions.md contains unresolved %%INLINECODE<n>%% placeholders from Cursor's prompt storage — open the automation in the dashboard and paste the original snippet back in."
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
return sanitized;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const EFFORT_LABELS: Record<string, string> = {
|
|
209
|
+
PROMPT_EFFORT_LEVEL_STANDARD: "standard",
|
|
210
|
+
1: "standard",
|
|
211
|
+
PROMPT_EFFORT_LEVEL_HARD: "hard",
|
|
212
|
+
10: "hard",
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
function promptMeta(prompt: PromptJson): string {
|
|
216
|
+
const parts: string[] = [];
|
|
217
|
+
const effort = EFFORT_LABELS[String(prompt.effortLevel)];
|
|
218
|
+
if (effort !== undefined && effort !== "standard") {
|
|
219
|
+
parts.push(`effort: ${effort}`);
|
|
220
|
+
}
|
|
221
|
+
if (prompt.model !== undefined && prompt.model !== "") {
|
|
222
|
+
parts.push(`model: ${prompt.model}`);
|
|
223
|
+
}
|
|
224
|
+
return parts.join("; ");
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// ============================================================================
|
|
228
|
+
// agent.ts
|
|
229
|
+
// ============================================================================
|
|
230
|
+
|
|
231
|
+
function renderAgentTs(
|
|
232
|
+
automation: ConvertWorkflowInput["automation"],
|
|
233
|
+
workflow: WorkflowJson,
|
|
234
|
+
pinnedModel: string | undefined
|
|
235
|
+
): string {
|
|
236
|
+
const comments: string[] = [
|
|
237
|
+
`// Exported from Cursor automation "${commentSafe(automation.name)}"`,
|
|
238
|
+
`// ${commentSafe(automation.webUrl)}`,
|
|
239
|
+
];
|
|
240
|
+
if (workflow.gitConfig !== undefined) {
|
|
241
|
+
comments.push("// git_config (repo/branch the automation worked in):");
|
|
242
|
+
comments.push(...jsonComment(workflow.gitConfig));
|
|
243
|
+
}
|
|
244
|
+
if (workflow.agentOptions !== undefined) {
|
|
245
|
+
comments.push("// agent_options:");
|
|
246
|
+
comments.push(...jsonComment(workflow.agentOptions));
|
|
247
|
+
}
|
|
248
|
+
const body =
|
|
249
|
+
pinnedModel !== undefined && pinnedModel !== ""
|
|
250
|
+
? `export default defineAgent({\n model: ${JSON.stringify(pinnedModel)},\n});`
|
|
251
|
+
: "export default defineAgent({\n // The source automation used Cursor's default model.\n});";
|
|
252
|
+
return `import { defineAgent } from "${PACKAGE_NAME}";\n\n${comments.join("\n")}\n${body}\n`;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// ============================================================================
|
|
256
|
+
// Schedules (cron triggers)
|
|
257
|
+
// ============================================================================
|
|
258
|
+
|
|
259
|
+
function emitSchedule(
|
|
260
|
+
automation: ConvertWorkflowInput["automation"],
|
|
261
|
+
trigger: TriggerJson,
|
|
262
|
+
slugs: SlugRegistry,
|
|
263
|
+
out: ConvertOutput
|
|
264
|
+
): void {
|
|
265
|
+
const cron = trigger.cron?.cron;
|
|
266
|
+
if (cron === undefined || cron === "") {
|
|
267
|
+
out.warnings.push("A cron trigger had no cron expression and was skipped.");
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
const slug = slugs.claim(slugify(automation.name, "schedule"));
|
|
271
|
+
const path = `agent/schedules/${slug}.ts`;
|
|
272
|
+
const lines = [
|
|
273
|
+
`import { defineSchedule } from "${PACKAGE_NAME}/schedules";`,
|
|
274
|
+
"",
|
|
275
|
+
`// Exported from Cursor automation "${commentSafe(automation.name)}" cron trigger.`,
|
|
276
|
+
"// Cron is evaluated in UTC in both systems.",
|
|
277
|
+
"export default defineSchedule({",
|
|
278
|
+
` cron: ${JSON.stringify(cron)},`,
|
|
279
|
+
" markdown:",
|
|
280
|
+
' "Scheduled run: follow the instructions in agent/instructions.md.",',
|
|
281
|
+
"});",
|
|
282
|
+
"",
|
|
283
|
+
];
|
|
284
|
+
out.files.push({ path, contents: lines.join("\n") });
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// ============================================================================
|
|
288
|
+
// GitHub channel (git triggers)
|
|
289
|
+
// ============================================================================
|
|
290
|
+
|
|
291
|
+
const GIT_EVENT_WEBHOOKS: Record<string, string[]> = {
|
|
292
|
+
pullRequest: ["pull_request"],
|
|
293
|
+
push: ["push"],
|
|
294
|
+
ciCompleted: ["check_suite"],
|
|
295
|
+
issueLabeled: ["issues"],
|
|
296
|
+
label: [],
|
|
297
|
+
issueComment: ["issue_comment"],
|
|
298
|
+
pullRequestReviewComment: ["pull_request_review_comment"],
|
|
299
|
+
pullRequestReview: ["pull_request_review"],
|
|
300
|
+
reviewThread: ["pull_request_review_thread"],
|
|
301
|
+
workflowRun: ["workflow_run"],
|
|
302
|
+
pullRequestReviewRequested: ["pull_request"],
|
|
303
|
+
issueAssigned: ["issues"],
|
|
304
|
+
};
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* `pr.action`-based dispatch condition for one pull_request trigger,
|
|
308
|
+
* mirroring the backend matcher (`webhookHandler.ts`): OPENED is non-draft
|
|
309
|
+
* opened-or-ready-for-review; UNSPECIFIED is the legacy opened-or-synchronize
|
|
310
|
+
* gated by `only_once`; `ignore_draft_prs` applies per trigger.
|
|
311
|
+
*/
|
|
312
|
+
function prConditionFor(
|
|
313
|
+
prAction: string,
|
|
314
|
+
eventObject: Record<string, unknown>
|
|
315
|
+
): string | undefined {
|
|
316
|
+
const ignoreDrafts = eventObject.ignoreDraftPrs === true;
|
|
317
|
+
switch (prAction) {
|
|
318
|
+
case "GIT_PULL_REQUEST_ACTION_UNSPECIFIED": {
|
|
319
|
+
const base =
|
|
320
|
+
eventObject.onlyOnce === true
|
|
321
|
+
? 'pr.action === "opened"'
|
|
322
|
+
: '(pr.action === "opened" || pr.action === "synchronize")';
|
|
323
|
+
return `(${base}${ignoreDrafts ? " && !pr.draft" : ""})`;
|
|
324
|
+
}
|
|
325
|
+
case "GIT_PULL_REQUEST_ACTION_OPENED":
|
|
326
|
+
return '((pr.action === "opened" || pr.action === "ready_for_review") && !pr.draft)';
|
|
327
|
+
case "GIT_PULL_REQUEST_ACTION_PUSHED":
|
|
328
|
+
return ignoreDrafts
|
|
329
|
+
? '(pr.action === "synchronize" && !pr.draft)'
|
|
330
|
+
: '(pr.action === "synchronize")';
|
|
331
|
+
case "GIT_PULL_REQUEST_ACTION_MERGED":
|
|
332
|
+
return '(pr.action === "closed" && pr.merged)';
|
|
333
|
+
case "GIT_PULL_REQUEST_ACTION_DRAFT_OPENED":
|
|
334
|
+
return '(pr.action === "opened" && pr.draft)';
|
|
335
|
+
case "GIT_PULL_REQUEST_ACTION_LABELED":
|
|
336
|
+
return '(pr.action === "labeled")';
|
|
337
|
+
case "GIT_PULL_REQUEST_ACTION_UNLABELED":
|
|
338
|
+
return '(pr.action === "unlabeled")';
|
|
339
|
+
default:
|
|
340
|
+
return undefined;
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
const PR_ACTION_NUMBERS: Record<string, string> = {
|
|
344
|
+
0: "GIT_PULL_REQUEST_ACTION_UNSPECIFIED",
|
|
345
|
+
1: "GIT_PULL_REQUEST_ACTION_OPENED",
|
|
346
|
+
2: "GIT_PULL_REQUEST_ACTION_PUSHED",
|
|
347
|
+
3: "GIT_PULL_REQUEST_ACTION_MERGED",
|
|
348
|
+
4: "GIT_PULL_REQUEST_ACTION_COMMENTED",
|
|
349
|
+
5: "GIT_PULL_REQUEST_ACTION_DRAFT_OPENED",
|
|
350
|
+
6: "GIT_PULL_REQUEST_ACTION_LABELED",
|
|
351
|
+
7: "GIT_PULL_REQUEST_ACTION_UNLABELED",
|
|
352
|
+
};
|
|
353
|
+
|
|
354
|
+
function emitGithubChannel(
|
|
355
|
+
automation: ConvertWorkflowInput["automation"],
|
|
356
|
+
workflow: WorkflowJson,
|
|
357
|
+
triggers: TriggerJson[],
|
|
358
|
+
slugs: SlugRegistry,
|
|
359
|
+
out: ConvertOutput
|
|
360
|
+
): void {
|
|
361
|
+
const slug = slugs.claim("github");
|
|
362
|
+
const path = `agent/channels/${slug}.ts`;
|
|
363
|
+
const webhookEvents: string[] = [];
|
|
364
|
+
const repos: string[] = [];
|
|
365
|
+
let hasOrgs = false;
|
|
366
|
+
|
|
367
|
+
// Mechanical filters recovered from the triggers, turned into real hooks
|
|
368
|
+
// below so the exported channel dispatches on the same events the
|
|
369
|
+
// automation did instead of shipping inert.
|
|
370
|
+
const prConditions: string[] = [];
|
|
371
|
+
let hasCommentedAction = false;
|
|
372
|
+
let hasIssueCommentTrigger = false;
|
|
373
|
+
// COMMENTED / review-comment triggers whose backend gates (PR-author
|
|
374
|
+
// provenance via user_allowlist, commenter_allowlist, comment_contains)
|
|
375
|
+
// cannot be reproduced by an onComment hook.
|
|
376
|
+
let commentGatesLost = false;
|
|
377
|
+
let hasUnknownPrAction = false;
|
|
378
|
+
const pushBranches: string[] = [];
|
|
379
|
+
let hasCheckSuite = false;
|
|
380
|
+
let hasWorkflowRun = false;
|
|
381
|
+
const issueActions: string[] = [];
|
|
382
|
+
const userAllowlist: string[] = [];
|
|
383
|
+
|
|
384
|
+
for (const trigger of triggers) {
|
|
385
|
+
const git = trigger.git as GitTriggerJson;
|
|
386
|
+
userAllowlist.push(
|
|
387
|
+
...(git.userAllowlist ?? []).filter((user) => user !== "")
|
|
388
|
+
);
|
|
389
|
+
for (const [eventKey, events] of Object.entries(GIT_EVENT_WEBHOOKS)) {
|
|
390
|
+
const event = git[eventKey as keyof GitTriggerJson];
|
|
391
|
+
if (event === undefined || typeof event !== "object") {
|
|
392
|
+
continue;
|
|
393
|
+
}
|
|
394
|
+
webhookEvents.push(...events);
|
|
395
|
+
const eventObject = event as Record<string, unknown>;
|
|
396
|
+
if (eventKey === "pullRequest") {
|
|
397
|
+
const raw = String(eventObject.prAction ?? "");
|
|
398
|
+
const prAction =
|
|
399
|
+
PR_ACTION_NUMBERS[raw] ??
|
|
400
|
+
(raw === "" ? "GIT_PULL_REQUEST_ACTION_UNSPECIFIED" : raw);
|
|
401
|
+
if (prAction === "GIT_PULL_REQUEST_ACTION_COMMENTED") {
|
|
402
|
+
webhookEvents.push("issue_comment");
|
|
403
|
+
hasCommentedAction = true;
|
|
404
|
+
if (
|
|
405
|
+
(git.userAllowlist ?? []).length > 0 ||
|
|
406
|
+
(Array.isArray(eventObject.commenterAllowlist) &&
|
|
407
|
+
eventObject.commenterAllowlist.length > 0) ||
|
|
408
|
+
(typeof eventObject.commentContains === "string" &&
|
|
409
|
+
eventObject.commentContains !== "")
|
|
410
|
+
) {
|
|
411
|
+
commentGatesLost = true;
|
|
412
|
+
}
|
|
413
|
+
} else {
|
|
414
|
+
const condition = prConditionFor(prAction, eventObject);
|
|
415
|
+
if (condition === undefined) {
|
|
416
|
+
hasUnknownPrAction = true;
|
|
417
|
+
out.warnings.push(
|
|
418
|
+
`A PR trigger${repoLabel(eventObject)} used unrecognized pr_action ${JSON.stringify(raw)} — add its dispatch condition to onPullRequest in ${path} (exported fail-closed).`
|
|
419
|
+
);
|
|
420
|
+
} else {
|
|
421
|
+
prConditions.push(condition);
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
if (eventKey === "pullRequestReviewRequested") {
|
|
426
|
+
prConditions.push('(pr.action === "review_requested")');
|
|
427
|
+
}
|
|
428
|
+
if (eventKey === "issueComment") {
|
|
429
|
+
hasIssueCommentTrigger = true;
|
|
430
|
+
// A separate commenter_allowlist means BOTH lists must pass
|
|
431
|
+
// (intersection), and comment_contains filters the body — neither
|
|
432
|
+
// fits the single exported commenter gate.
|
|
433
|
+
if (
|
|
434
|
+
(Array.isArray(eventObject.commenterAllowlist) &&
|
|
435
|
+
eventObject.commenterAllowlist.length > 0) ||
|
|
436
|
+
(typeof eventObject.commentContains === "string" &&
|
|
437
|
+
eventObject.commentContains !== "")
|
|
438
|
+
) {
|
|
439
|
+
commentGatesLost = true;
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
if (eventKey === "pullRequestReviewComment") {
|
|
443
|
+
// user_allowlist gates the PR author and commenter_allowlist the
|
|
444
|
+
// commenter here — neither identity is visible to onComment — and
|
|
445
|
+
// comment_contains filters the body.
|
|
446
|
+
if (
|
|
447
|
+
(git.userAllowlist ?? []).length > 0 ||
|
|
448
|
+
(Array.isArray(eventObject.commenterAllowlist) &&
|
|
449
|
+
eventObject.commenterAllowlist.length > 0) ||
|
|
450
|
+
(typeof eventObject.commentContains === "string" &&
|
|
451
|
+
eventObject.commentContains !== "")
|
|
452
|
+
) {
|
|
453
|
+
commentGatesLost = true;
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
if (eventKey === "push") {
|
|
457
|
+
if (
|
|
458
|
+
typeof eventObject.branch === "string" &&
|
|
459
|
+
eventObject.branch !== ""
|
|
460
|
+
) {
|
|
461
|
+
pushBranches.push(eventObject.branch);
|
|
462
|
+
} else {
|
|
463
|
+
// The backend matcher rejects push triggers without a branch —
|
|
464
|
+
// they never fired, so exporting a dispatch would invent runs.
|
|
465
|
+
out.warnings.push(
|
|
466
|
+
`A push trigger${repoLabel(eventObject)} had no branch filter and never fired in the backend — exported without a dispatch.`
|
|
467
|
+
);
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
if (eventKey === "ciCompleted") {
|
|
471
|
+
hasCheckSuite = true;
|
|
472
|
+
}
|
|
473
|
+
if (eventKey === "workflowRun") {
|
|
474
|
+
hasWorkflowRun = true;
|
|
475
|
+
}
|
|
476
|
+
if (eventKey === "issueLabeled") {
|
|
477
|
+
if (
|
|
478
|
+
typeof eventObject.labelName === "string" &&
|
|
479
|
+
eventObject.labelName !== ""
|
|
480
|
+
) {
|
|
481
|
+
// The label-name filter is not visible to the typed hooks, so a
|
|
482
|
+
// dispatch would fire on EVERY label change.
|
|
483
|
+
out.warnings.push(
|
|
484
|
+
`An issue-label trigger filtered on label ${JSON.stringify(eventObject.labelName)}, which the exported hooks cannot check — exported without a dispatch; re-add the filter in an onEvent hook reading the raw payload.`
|
|
485
|
+
);
|
|
486
|
+
} else {
|
|
487
|
+
// `unlabeled: true` flips the legacy trigger to label removal.
|
|
488
|
+
issueActions.push(
|
|
489
|
+
eventObject.unlabeled === true ? "unlabeled" : "labeled"
|
|
490
|
+
);
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
if (eventKey === "issueAssigned") {
|
|
494
|
+
issueActions.push("assigned");
|
|
495
|
+
}
|
|
496
|
+
// Review events export fail-closed: the backend gated review/thread
|
|
497
|
+
// state and PR-author provenance, none of which the export can
|
|
498
|
+
// reproduce. Without an onEvent hook the SDK drops the deliveries.
|
|
499
|
+
if (eventKey === "pullRequestReview" || eventKey === "reviewThread") {
|
|
500
|
+
out.warnings.push(
|
|
501
|
+
`A ${eventKey === "pullRequestReview" ? "pull_request_review" : "review_thread"} trigger exported without a dispatch: the backend's review-state and PR-author provenance gates cannot be exported. Add an onEvent hook in ${path} to re-enable it with your own filters.`
|
|
502
|
+
);
|
|
503
|
+
}
|
|
504
|
+
if (typeof eventObject.repo === "string" && eventObject.repo !== "") {
|
|
505
|
+
repos.push(eventObject.repo);
|
|
506
|
+
}
|
|
507
|
+
if (Array.isArray(eventObject.repos)) {
|
|
508
|
+
repos.push(
|
|
509
|
+
...eventObject.repos.filter(
|
|
510
|
+
(repo): repo is string => typeof repo === "string" && repo !== ""
|
|
511
|
+
)
|
|
512
|
+
);
|
|
513
|
+
}
|
|
514
|
+
if (Array.isArray(eventObject.orgs) && eventObject.orgs.length > 0) {
|
|
515
|
+
hasOrgs = true;
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
const label = git.label;
|
|
519
|
+
if (label !== undefined) {
|
|
520
|
+
const labelObject = label as Record<string, unknown>;
|
|
521
|
+
// The backend fires only for the configured direction(s); neither
|
|
522
|
+
// flag set means the trigger never fired.
|
|
523
|
+
let labelActions = [
|
|
524
|
+
...(labelObject.onAdded === true ? ["labeled"] : []),
|
|
525
|
+
...(labelObject.onRemoved === true ? ["unlabeled"] : []),
|
|
526
|
+
];
|
|
527
|
+
if (labelActions.length === 0) {
|
|
528
|
+
out.warnings.push(
|
|
529
|
+
"A label trigger had neither on_added nor on_removed set and never fired in the backend — exported without a dispatch."
|
|
530
|
+
);
|
|
531
|
+
}
|
|
532
|
+
if (
|
|
533
|
+
typeof labelObject.labelName === "string" &&
|
|
534
|
+
labelObject.labelName !== ""
|
|
535
|
+
) {
|
|
536
|
+
// Label names are not visible to the typed hooks; dispatching would
|
|
537
|
+
// fire on EVERY label change instead of the filtered one.
|
|
538
|
+
out.warnings.push(
|
|
539
|
+
`A label trigger filtered on label ${JSON.stringify(labelObject.labelName)}, which the exported hooks cannot check — exported without a dispatch; re-add the filter in a hook reading the raw payload.`
|
|
540
|
+
);
|
|
541
|
+
labelActions = [];
|
|
542
|
+
}
|
|
543
|
+
if (label.pullRequests === true || label.issues !== true) {
|
|
544
|
+
webhookEvents.push("pull_request");
|
|
545
|
+
prConditions.push(
|
|
546
|
+
...labelActions.map(
|
|
547
|
+
(action) => `(pr.action === ${JSON.stringify(action)})`
|
|
548
|
+
)
|
|
549
|
+
);
|
|
550
|
+
}
|
|
551
|
+
if (label.issues === true) {
|
|
552
|
+
webhookEvents.push("issues");
|
|
553
|
+
issueActions.push(...labelActions);
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
if (
|
|
558
|
+
workflow.gitConfig?.repo !== undefined &&
|
|
559
|
+
workflow.gitConfig.repo !== ""
|
|
560
|
+
) {
|
|
561
|
+
repos.push(workflow.gitConfig.repo);
|
|
562
|
+
}
|
|
563
|
+
repos.push(...(workflow.gitConfig?.repos ?? []));
|
|
564
|
+
|
|
565
|
+
const uniqueEvents = dedupe(webhookEvents);
|
|
566
|
+
const uniqueRepos = dedupe(
|
|
567
|
+
dedupe(repos.filter((repo) => repo !== "")).flatMap((repo) => {
|
|
568
|
+
const normalized = normalizeRepoIdentifier(repo, out);
|
|
569
|
+
return normalized === undefined ? [] : [normalized];
|
|
570
|
+
})
|
|
571
|
+
);
|
|
572
|
+
|
|
573
|
+
if (hasOrgs) {
|
|
574
|
+
out.warnings.push(
|
|
575
|
+
"A git trigger watched whole GitHub orgs; the GitHub channel's cursorAccount scope needs explicit repos. List them in agent/channels/github.ts."
|
|
576
|
+
);
|
|
577
|
+
}
|
|
578
|
+
out.setupSteps.push(
|
|
579
|
+
`Review the generated hook filters in ${path} — mechanical trigger filters (PR action, push branch, issue action, user allowlist) were exported, but bespoke conditions Cursor's backend enforced (comment matching, CI conclusions, label names) must be re-added by hand.`
|
|
580
|
+
);
|
|
581
|
+
if (hasCheckSuite || hasWorkflowRun) {
|
|
582
|
+
out.warnings.push(
|
|
583
|
+
`A CI trigger (ci_completed / workflow_run) exported FAIL-CLOSED: its backend filters (conclusion, branch, actor allowlist) cannot be exported, so the generated hook in ${path} returns null until you re-add them and dispatch.`
|
|
584
|
+
);
|
|
585
|
+
}
|
|
586
|
+
if (uniqueRepos.length === 0) {
|
|
587
|
+
out.setupSteps.push(
|
|
588
|
+
`No repositories could be derived for the GitHub channel — set githubChannel({ cursorAccount: { repos: [...] } }) or GitHub App credentials in ${path}.`
|
|
589
|
+
);
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
const configLines: string[] = [];
|
|
593
|
+
if (uniqueRepos.length > 0) {
|
|
594
|
+
configLines.push(" cursorAccount: {");
|
|
595
|
+
configLines.push(
|
|
596
|
+
` repos: [${uniqueRepos.map((repo) => JSON.stringify(repo)).join(", ")}],`
|
|
597
|
+
);
|
|
598
|
+
configLines.push(" },");
|
|
599
|
+
}
|
|
600
|
+
configLines.push(
|
|
601
|
+
` webhookEvents: [${uniqueEvents.map((event) => JSON.stringify(event)).join(", ")}],`
|
|
602
|
+
);
|
|
603
|
+
|
|
604
|
+
const uniqueAllowlist = dedupe(
|
|
605
|
+
userAllowlist.map((user) => user.toLowerCase())
|
|
606
|
+
);
|
|
607
|
+
if (triggers.length > 1 && uniqueAllowlist.length > 0) {
|
|
608
|
+
out.warnings.push(
|
|
609
|
+
`user_allowlist applies per trigger in the backend; the export unions every trigger's allowlist across all hooks in ${path} — tighten per hook if the triggers differed.`
|
|
610
|
+
);
|
|
611
|
+
}
|
|
612
|
+
// The cursorAccount SCM-event relay delivers PR/comment snapshots only —
|
|
613
|
+
// no push ref, no issue payload, no CI nodes, and no sender for
|
|
614
|
+
// non-comment events — so hooks needing those fields only work with a
|
|
615
|
+
// real GitHub App webhook or `github forward`.
|
|
616
|
+
const relayLimited = [
|
|
617
|
+
...(dedupe(pushBranches).length > 0 ? ["push branch filters"] : []),
|
|
618
|
+
...(dedupe(issueActions).length > 0 ? ["issue triggers"] : []),
|
|
619
|
+
...(hasCheckSuite || hasWorkflowRun ? ["CI triggers"] : []),
|
|
620
|
+
];
|
|
621
|
+
if (uniqueRepos.length > 0 && relayLimited.length > 0) {
|
|
622
|
+
out.warnings.push(
|
|
623
|
+
`cursorAccount event delivery carries PR/comment data only, so ${relayLimited.join(", ")} will NOT fire through it — deliver real GitHub webhooks to the deployed agent (GitHub App or \`github forward\`) for those triggers.`
|
|
624
|
+
);
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
const hasCommentEvents =
|
|
628
|
+
uniqueEvents.includes("issue_comment") ||
|
|
629
|
+
uniqueEvents.includes("pull_request_review_comment");
|
|
630
|
+
const commentGate: CommentGate = !hasCommentEvents
|
|
631
|
+
? "none"
|
|
632
|
+
: commentGatesLost
|
|
633
|
+
? "failClosed"
|
|
634
|
+
: hasIssueCommentTrigger && uniqueAllowlist.length > 0
|
|
635
|
+
? "commenterAllowlist"
|
|
636
|
+
: "mentionDefault";
|
|
637
|
+
if (commentGate === "failClosed") {
|
|
638
|
+
out.warnings.push(
|
|
639
|
+
`A comment trigger's backend gates (PR-author provenance via user_allowlist, commenter_allowlist, comment_contains) cannot be exported — comments dispatch NOTHING until you re-add them in onComment in ${path}.`
|
|
640
|
+
);
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
const { constLines, hookLines, usesAuth } = renderGithubHooks({
|
|
644
|
+
prConditions: dedupe(prConditions),
|
|
645
|
+
hasUnknownPrAction,
|
|
646
|
+
pushBranches: dedupe(pushBranches),
|
|
647
|
+
hasCheckSuite,
|
|
648
|
+
hasWorkflowRun,
|
|
649
|
+
issueActions: dedupe(issueActions),
|
|
650
|
+
userAllowlist: uniqueAllowlist,
|
|
651
|
+
hasPullRequestEvents: uniqueEvents.includes("pull_request"),
|
|
652
|
+
commentGate,
|
|
653
|
+
hasCommentedAction,
|
|
654
|
+
});
|
|
655
|
+
const imports = usesAuth
|
|
656
|
+
? "import { defaultGitHubAuth, githubChannel }"
|
|
657
|
+
: "import { githubChannel }";
|
|
658
|
+
|
|
659
|
+
const lines = [
|
|
660
|
+
`${imports} from "${PACKAGE_NAME}/channels/github";`,
|
|
661
|
+
"",
|
|
662
|
+
`// Exported from Cursor automation "${commentSafe(automation.name)}" git trigger(s).`,
|
|
663
|
+
"// Original trigger config:",
|
|
664
|
+
...jsonComment(triggers.map((trigger) => trigger.git)),
|
|
665
|
+
"//",
|
|
666
|
+
"// The hooks below reproduce the triggers' mechanical filters. Cursor's",
|
|
667
|
+
"// backend also enforced any bespoke conditions (comment matching, CI",
|
|
668
|
+
"// conclusions, label names) — re-add those before relying on this.",
|
|
669
|
+
...constLines,
|
|
670
|
+
"export default githubChannel({",
|
|
671
|
+
...configLines,
|
|
672
|
+
...hookLines,
|
|
673
|
+
"});",
|
|
674
|
+
"",
|
|
675
|
+
];
|
|
676
|
+
out.files.push({ path, contents: lines.join("\n") });
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
type CommentGate =
|
|
680
|
+
| "none"
|
|
681
|
+
| "mentionDefault"
|
|
682
|
+
| "commenterAllowlist"
|
|
683
|
+
| "failClosed";
|
|
684
|
+
|
|
685
|
+
/** Render the dispatch hooks for {@link emitGithubChannel}. */
|
|
686
|
+
function renderGithubHooks(args: {
|
|
687
|
+
prConditions: string[];
|
|
688
|
+
hasUnknownPrAction: boolean;
|
|
689
|
+
pushBranches: string[];
|
|
690
|
+
hasCheckSuite: boolean;
|
|
691
|
+
hasWorkflowRun: boolean;
|
|
692
|
+
issueActions: string[];
|
|
693
|
+
userAllowlist: string[];
|
|
694
|
+
hasPullRequestEvents: boolean;
|
|
695
|
+
commentGate: CommentGate;
|
|
696
|
+
hasCommentedAction: boolean;
|
|
697
|
+
}): { constLines: string[]; hookLines: string[]; usesAuth: boolean } {
|
|
698
|
+
const lines: string[] = [];
|
|
699
|
+
const hasAllowlist = args.userAllowlist.length > 0;
|
|
700
|
+
const hasPush = args.pushBranches.length > 0;
|
|
701
|
+
let allowlistUsed = false;
|
|
702
|
+
let usesAuth = false;
|
|
703
|
+
|
|
704
|
+
if (args.prConditions.length > 0) {
|
|
705
|
+
lines.push(" onPullRequest: (ctx, pr) => {");
|
|
706
|
+
if (hasAllowlist) {
|
|
707
|
+
allowlistUsed = true;
|
|
708
|
+
lines.push(
|
|
709
|
+
" if (",
|
|
710
|
+
" pr.authorLogin === undefined ||",
|
|
711
|
+
" !ALLOWED_USERS.includes(pr.authorLogin.toLowerCase())",
|
|
712
|
+
" ) {",
|
|
713
|
+
" return null;",
|
|
714
|
+
" }"
|
|
715
|
+
);
|
|
716
|
+
}
|
|
717
|
+
lines.push(
|
|
718
|
+
" return (",
|
|
719
|
+
...args.prConditions.map(
|
|
720
|
+
(condition, index) =>
|
|
721
|
+
` ${condition}${index < args.prConditions.length - 1 ? " ||" : ""}`
|
|
722
|
+
),
|
|
723
|
+
" )",
|
|
724
|
+
" ? { auth: defaultGitHubAuth(ctx) }",
|
|
725
|
+
" : null;",
|
|
726
|
+
" },"
|
|
727
|
+
);
|
|
728
|
+
usesAuth = true;
|
|
729
|
+
} else if (args.hasPullRequestEvents) {
|
|
730
|
+
// Without this fail-safe the SDK's default gate would dispatch on
|
|
731
|
+
// opened/reopened/synchronize/ready_for_review — broader than any
|
|
732
|
+
// exported trigger. COMMENTED triggers get comments via the comment
|
|
733
|
+
// gate; unrecognized/label-filtered triggers need their condition
|
|
734
|
+
// re-added here.
|
|
735
|
+
lines.push(" onPullRequest: () => null,");
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
if (hasPush) {
|
|
739
|
+
lines.push(" onEvent: (ctx, event) => {");
|
|
740
|
+
if (hasAllowlist) {
|
|
741
|
+
allowlistUsed = true;
|
|
742
|
+
lines.push(
|
|
743
|
+
" // The backend gated push on the user allowlist (actor).",
|
|
744
|
+
" if (",
|
|
745
|
+
" ctx.senderLogin === undefined ||",
|
|
746
|
+
" !ALLOWED_USERS.includes(ctx.senderLogin.toLowerCase())",
|
|
747
|
+
" ) {",
|
|
748
|
+
" return null;",
|
|
749
|
+
" }"
|
|
750
|
+
);
|
|
751
|
+
}
|
|
752
|
+
const refs = args.pushBranches.map((branch) =>
|
|
753
|
+
JSON.stringify(`refs/heads/${branch}`)
|
|
754
|
+
);
|
|
755
|
+
lines.push(
|
|
756
|
+
' if (event.eventName === "push") {',
|
|
757
|
+
" // Branch filter from the original trigger.",
|
|
758
|
+
" const ref = (event.payload as { ref?: string }).ref;",
|
|
759
|
+
refs.length === 1
|
|
760
|
+
? ` if (ref !== ${refs[0]}) {`
|
|
761
|
+
: ` if (ref === undefined || ![${refs.join(", ")}].includes(ref)) {`,
|
|
762
|
+
" return null;",
|
|
763
|
+
" }",
|
|
764
|
+
" return { auth: defaultGitHubAuth(ctx) };",
|
|
765
|
+
" }"
|
|
766
|
+
);
|
|
767
|
+
usesAuth = true;
|
|
768
|
+
lines.push(" return null;", " },");
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
// check_suite / workflow_run only dispatch through their typed hooks —
|
|
772
|
+
// the onEvent catch-all never sees them. They export FAIL-CLOSED: the
|
|
773
|
+
// backend filters they relied on (CI conclusion/branch, and for
|
|
774
|
+
// workflow_run the actor AND triggering-actor allowlist) cannot be
|
|
775
|
+
// reproduced mechanically, and dispatching unfiltered would let any CI
|
|
776
|
+
// delivery start an agent turn.
|
|
777
|
+
if (args.hasCheckSuite) {
|
|
778
|
+
lines.push(
|
|
779
|
+
" onCheckSuite: () => {",
|
|
780
|
+
" // Fail closed: re-add the original ci_completed filters —",
|
|
781
|
+
' // `suite.action === "completed"`, conclusion/branch, user',
|
|
782
|
+
" // allowlist (see the trigger config above) — then dispatch with",
|
|
783
|
+
" // `(ctx, suite) => ({ auth: defaultGitHubAuth(ctx) })`.",
|
|
784
|
+
" return null;",
|
|
785
|
+
" },"
|
|
786
|
+
);
|
|
787
|
+
}
|
|
788
|
+
if (args.hasWorkflowRun) {
|
|
789
|
+
lines.push(
|
|
790
|
+
" onWorkflowRun: () => {",
|
|
791
|
+
" // Fail closed: re-add the original workflow_run filters — the",
|
|
792
|
+
' // backend required `run.action === "completed"`, a push-origin',
|
|
793
|
+
" // same-repo (non-fork) run, and gated the user allowlist on the",
|
|
794
|
+
" // run's actor AND triggering actor — then dispatch with",
|
|
795
|
+
" // `(ctx, run) => ({ auth: defaultGitHubAuth(ctx) })`.",
|
|
796
|
+
" return null;",
|
|
797
|
+
" },"
|
|
798
|
+
);
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
if (args.issueActions.length > 0) {
|
|
802
|
+
const condition =
|
|
803
|
+
args.issueActions.length === 1
|
|
804
|
+
? `issue.action === ${JSON.stringify(args.issueActions[0])}`
|
|
805
|
+
: `[${args.issueActions.map((action) => JSON.stringify(action)).join(", ")}].includes(issue.action)`;
|
|
806
|
+
lines.push(" onIssue: (ctx, issue) => {");
|
|
807
|
+
if (hasAllowlist) {
|
|
808
|
+
allowlistUsed = true;
|
|
809
|
+
lines.push(
|
|
810
|
+
" // The backend allowlisted the ACTOR (who labeled/assigned),",
|
|
811
|
+
" // not the issue author.",
|
|
812
|
+
" if (",
|
|
813
|
+
" ctx.senderLogin === undefined ||",
|
|
814
|
+
" !ALLOWED_USERS.includes(ctx.senderLogin.toLowerCase())",
|
|
815
|
+
" ) {",
|
|
816
|
+
" return null;",
|
|
817
|
+
" }"
|
|
818
|
+
);
|
|
819
|
+
}
|
|
820
|
+
lines.push(
|
|
821
|
+
` return ${condition} ? { auth: defaultGitHubAuth(ctx) } : null;`,
|
|
822
|
+
" },"
|
|
823
|
+
);
|
|
824
|
+
usesAuth = true;
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
if (args.commentGate === "failClosed") {
|
|
828
|
+
lines.push(
|
|
829
|
+
" onComment: () => {",
|
|
830
|
+
" // Fail closed: the trigger's backend comment gates (PR-author",
|
|
831
|
+
" // provenance via user_allowlist, commenter_allowlist,",
|
|
832
|
+
" // comment_contains — see the trigger config above) cannot be",
|
|
833
|
+
" // exported. Re-add them here (PR author needs a ctx.github",
|
|
834
|
+
" // lookup), then return { auth: defaultGitHubAuth(ctx) }.",
|
|
835
|
+
" return null;",
|
|
836
|
+
" },"
|
|
837
|
+
);
|
|
838
|
+
} else if (args.commentGate === "commenterAllowlist" && hasAllowlist) {
|
|
839
|
+
// issue_comment triggers gate the COMMENTER on user_allowlist — the one
|
|
840
|
+
// comment gate that is faithfully exportable.
|
|
841
|
+
allowlistUsed = true;
|
|
842
|
+
usesAuth = true;
|
|
843
|
+
lines.push(
|
|
844
|
+
" // Replaces the default @mention gate: the original fired on ALL",
|
|
845
|
+
" // comments by allowlisted commenters.",
|
|
846
|
+
" onComment: (ctx, comment) => {",
|
|
847
|
+
" const author = comment.author?.login;",
|
|
848
|
+
" if (",
|
|
849
|
+
" author === undefined ||",
|
|
850
|
+
" !ALLOWED_USERS.includes(author.toLowerCase())",
|
|
851
|
+
" ) {",
|
|
852
|
+
" return null;",
|
|
853
|
+
" }",
|
|
854
|
+
" return { auth: defaultGitHubAuth(ctx) };",
|
|
855
|
+
" },"
|
|
856
|
+
);
|
|
857
|
+
} else if (args.commentGate === "mentionDefault" && lines.length > 0) {
|
|
858
|
+
lines.push(
|
|
859
|
+
" // Comments dispatch via the default @mention gate; the original",
|
|
860
|
+
" // fired on ALL comments — declare onComment to widen it."
|
|
861
|
+
);
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
const constLines = allowlistUsed
|
|
865
|
+
? [
|
|
866
|
+
"// user_allowlist from the original trigger(s), lowercased — GitHub",
|
|
867
|
+
"// logins are case-insensitive and the backend compared them that way.",
|
|
868
|
+
`const ALLOWED_USERS = [${args.userAllowlist.map((user) => JSON.stringify(user)).join(", ")}];`,
|
|
869
|
+
"",
|
|
870
|
+
]
|
|
871
|
+
: [];
|
|
872
|
+
return { constLines, hookLines: lines, usesAuth };
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
function repoLabel(eventObject: Record<string, unknown>): string {
|
|
876
|
+
const repos = dedupe(
|
|
877
|
+
[
|
|
878
|
+
typeof eventObject.repo === "string" ? eventObject.repo : "",
|
|
879
|
+
...(Array.isArray(eventObject.repos)
|
|
880
|
+
? eventObject.repos.filter(
|
|
881
|
+
(repo): repo is string => typeof repo === "string"
|
|
882
|
+
)
|
|
883
|
+
: []),
|
|
884
|
+
].filter((repo) => repo !== "")
|
|
885
|
+
);
|
|
886
|
+
return repos.length > 0 ? ` (${repos.join(", ")})` : "";
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
/**
|
|
890
|
+
* Normalize a trigger/git_config repo into a GitHub `owner/repo` identifier
|
|
891
|
+
* for `cursorAccount: { repos: [...] }`. github.com URLs (https or ssh) are
|
|
892
|
+
* reduced to `owner/repo`; non-GitHub URLs are excluded with a warning;
|
|
893
|
+
* plain strings pass through.
|
|
894
|
+
*/
|
|
895
|
+
function normalizeRepoIdentifier(
|
|
896
|
+
raw: string,
|
|
897
|
+
out: ConvertOutput
|
|
898
|
+
): string | undefined {
|
|
899
|
+
let candidate = raw;
|
|
900
|
+
if (raw.startsWith("git@") && !raw.includes("://")) {
|
|
901
|
+
// git@host:path SSH shorthand → a parseable ssh:// URL.
|
|
902
|
+
candidate = `ssh://${raw.replace(":", "/")}`;
|
|
903
|
+
}
|
|
904
|
+
if (!candidate.includes("://")) {
|
|
905
|
+
return raw;
|
|
906
|
+
}
|
|
907
|
+
if (!URL.canParse(candidate)) {
|
|
908
|
+
out.warnings.push(
|
|
909
|
+
`Repo ${JSON.stringify(raw)} could not be parsed as a URL — excluded from the GitHub channel's cursorAccount repos; list it manually as owner/repo if it is GitHub-hosted.`
|
|
910
|
+
);
|
|
911
|
+
return undefined;
|
|
912
|
+
}
|
|
913
|
+
const url = new URL(candidate);
|
|
914
|
+
const host = url.hostname.toLowerCase();
|
|
915
|
+
if (host !== "github.com" && host !== "www.github.com") {
|
|
916
|
+
out.warnings.push(
|
|
917
|
+
`Repo ${JSON.stringify(raw)} is not GitHub-hosted (host: ${url.hostname}) — excluded from the GitHub channel's cursorAccount repos; cursorAccount can only watch GitHub repositories.`
|
|
918
|
+
);
|
|
919
|
+
// Losing a trigger repo silently halves coverage for dual-host setups,
|
|
920
|
+
// so surface it as a required setup step, not just a warning.
|
|
921
|
+
out.setupSteps.push(
|
|
922
|
+
`Trigger repo ${JSON.stringify(raw)} is not on GitHub, so its events will NOT reach this agent. Re-deliver them yourself (e.g. a webhook to the deployed agent's /v1/channels/github route) or drop that trigger.`
|
|
923
|
+
);
|
|
924
|
+
return undefined;
|
|
925
|
+
}
|
|
926
|
+
const segments = url.pathname.split("/").filter((s) => s !== "");
|
|
927
|
+
if (segments.length < 2) {
|
|
928
|
+
out.warnings.push(
|
|
929
|
+
`Repo URL ${JSON.stringify(raw)} has no owner/repo path — excluded from the GitHub channel's cursorAccount repos.`
|
|
930
|
+
);
|
|
931
|
+
return undefined;
|
|
932
|
+
}
|
|
933
|
+
return `${segments[0]}/${segments[1].replace(/\.git$/, "")}`;
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
// ============================================================================
|
|
937
|
+
// Slack channel
|
|
938
|
+
// ============================================================================
|
|
939
|
+
|
|
940
|
+
function isSlackTrigger(trigger: TriggerJson): boolean {
|
|
941
|
+
return (
|
|
942
|
+
trigger.slackMention !== undefined ||
|
|
943
|
+
trigger.slackTrigger !== undefined ||
|
|
944
|
+
trigger.slackReactionAdded !== undefined ||
|
|
945
|
+
trigger.slackAnyReactionAdded !== undefined ||
|
|
946
|
+
trigger.slackChannelCreated !== undefined
|
|
947
|
+
);
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
function emitSlackChannel(
|
|
951
|
+
automation: ConvertWorkflowInput["automation"],
|
|
952
|
+
triggers: TriggerJson[],
|
|
953
|
+
slugs: SlugRegistry,
|
|
954
|
+
out: ConvertOutput
|
|
955
|
+
): void {
|
|
956
|
+
const slug = slugs.claim("slack");
|
|
957
|
+
const path = `agent/channels/${slug}.ts`;
|
|
958
|
+
|
|
959
|
+
const watches = triggers.filter((t) => t.slackTrigger !== undefined);
|
|
960
|
+
const unsupported = triggers.filter(
|
|
961
|
+
(t) =>
|
|
962
|
+
t.slackReactionAdded !== undefined ||
|
|
963
|
+
t.slackAnyReactionAdded !== undefined ||
|
|
964
|
+
t.slackChannelCreated !== undefined
|
|
965
|
+
);
|
|
966
|
+
const socketMode = watches.length > 0 || unsupported.length > 0;
|
|
967
|
+
|
|
968
|
+
for (const trigger of unsupported) {
|
|
969
|
+
const kind =
|
|
970
|
+
trigger.slackChannelCreated !== undefined
|
|
971
|
+
? "channel-created"
|
|
972
|
+
: "reaction-added";
|
|
973
|
+
out.warnings.push(
|
|
974
|
+
`Slack ${kind} trigger has no agentkit equivalent — its config is inlined as a comment in ${path}; handle the Slack event yourself on a dedicated Socket Mode app.`
|
|
975
|
+
);
|
|
976
|
+
}
|
|
977
|
+
let reactionDropped = false;
|
|
978
|
+
for (const watch of watches) {
|
|
979
|
+
const config = watch.slackTrigger as SlackWatchTriggerJson;
|
|
980
|
+
if (config.messageContains !== undefined && config.messageContains !== "") {
|
|
981
|
+
out.warnings.push(
|
|
982
|
+
`Slack channel-watch message filter (${JSON.stringify(config.messageContains)}) was enforced by Cursor's backend — reimplement it (e.g. onChannelPost) in ${path}.`
|
|
983
|
+
);
|
|
984
|
+
}
|
|
985
|
+
// The backend defaults a missing/UNSPECIFIED mode to ON (checkmark
|
|
986
|
+
// reaction); only explicit OFF means no reaction was added.
|
|
987
|
+
const reactionMode = String(config.slackCompletionReactionMode ?? "");
|
|
988
|
+
if (
|
|
989
|
+
reactionMode !== "SLACK_COMPLETION_REACTION_MODE_OFF" &&
|
|
990
|
+
reactionMode !== "2"
|
|
991
|
+
) {
|
|
992
|
+
reactionDropped = true;
|
|
993
|
+
}
|
|
994
|
+
}
|
|
995
|
+
if (reactionDropped) {
|
|
996
|
+
out.warnings.push(
|
|
997
|
+
"The Slack trigger marked triggering messages with a completion reaction; agentkit has no completion-reaction option, so that signal is lost unless agent/instructions.md tells the agent to add the reaction itself via Slack tools."
|
|
998
|
+
);
|
|
999
|
+
}
|
|
1000
|
+
if (socketMode) {
|
|
1001
|
+
out.setupSteps.push(
|
|
1002
|
+
`Slack channel-watch / reaction / channel-created triggers need a dedicated Socket Mode Slack app (not the Cursor connection): run \`${CLI_COMMAND_NAME} slack setup\`, then \`${CLI_COMMAND_NAME} slack init --channel-posts\`, and set the generated tokens.`
|
|
1003
|
+
);
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
const header = [
|
|
1007
|
+
`import { slackChannel } from "${PACKAGE_NAME}/channels/slack";`,
|
|
1008
|
+
"",
|
|
1009
|
+
`// Exported from Cursor automation "${commentSafe(automation.name)}" Slack trigger(s).`,
|
|
1010
|
+
"// Original trigger config:",
|
|
1011
|
+
...jsonComment(triggers),
|
|
1012
|
+
];
|
|
1013
|
+
|
|
1014
|
+
let body: string[];
|
|
1015
|
+
if (socketMode) {
|
|
1016
|
+
const allow = dedupe(
|
|
1017
|
+
watches.flatMap((watch) => slackChannelList(watch.slackTrigger))
|
|
1018
|
+
);
|
|
1019
|
+
const anyThreadReplies = watches.some(
|
|
1020
|
+
(watch) =>
|
|
1021
|
+
(watch.slackTrigger as SlackWatchTriggerJson).topLevelOnly === false
|
|
1022
|
+
);
|
|
1023
|
+
// "Authenticated Cursor users" watches require a linked Cursor account
|
|
1024
|
+
// on the owning team, which bots never have — so the backend blocked
|
|
1025
|
+
// bot posts for them, and the sender gate itself has no SDK equivalent.
|
|
1026
|
+
// The SDK has ONE bot-post setting per channel, so mixed watches must
|
|
1027
|
+
// pick a side: keep bots when any watch allowed everyone (alert /
|
|
1028
|
+
// scorebot watches depend on them), and say so loudly.
|
|
1029
|
+
const blocksUnauthenticated = watches.some(
|
|
1030
|
+
(watch) =>
|
|
1031
|
+
(watch.slackTrigger as SlackWatchTriggerJson)
|
|
1032
|
+
.blockUnauthenticatedSlackUsers === true
|
|
1033
|
+
);
|
|
1034
|
+
const includeBotPosts =
|
|
1035
|
+
watches.length > 0 &&
|
|
1036
|
+
watches.some(
|
|
1037
|
+
(watch) =>
|
|
1038
|
+
(watch.slackTrigger as SlackWatchTriggerJson)
|
|
1039
|
+
.blockUnauthenticatedSlackUsers !== true
|
|
1040
|
+
);
|
|
1041
|
+
if (blocksUnauthenticated) {
|
|
1042
|
+
out.warnings.push(
|
|
1043
|
+
includeBotPosts
|
|
1044
|
+
? "Some Slack watches only fired for authenticated Cursor users while others allowed anyone; agentkit has one bot-post setting per channel, so the export keeps bot posts ENABLED (the open watches depend on them) and the authenticated-only watches lose both their sender gate and their bot exclusion. Split the channels or add an onChannelPost gate."
|
|
1045
|
+
: "The Slack trigger only fired for authenticated Cursor users (linked accounts on the owning team); agentkit has no equivalent sender gate, so the exported watch dispatches for ANY channel member (bot posts stay excluded). Add an onChannelPost gate in the channel if that matters."
|
|
1046
|
+
);
|
|
1047
|
+
}
|
|
1048
|
+
const engagement: string[] = [];
|
|
1049
|
+
if (allow.length > 0) {
|
|
1050
|
+
engagement.push(" engagement: {");
|
|
1051
|
+
engagement.push(" channelPosts: {");
|
|
1052
|
+
engagement.push(
|
|
1053
|
+
` allow: [${allow.map((channel) => JSON.stringify(channel)).join(", ")}],`
|
|
1054
|
+
);
|
|
1055
|
+
if (anyThreadReplies) {
|
|
1056
|
+
engagement.push(' posts: "all",');
|
|
1057
|
+
}
|
|
1058
|
+
if (includeBotPosts) {
|
|
1059
|
+
engagement.push(
|
|
1060
|
+
" // Cursor's channel-watch dispatched on bot-authored posts",
|
|
1061
|
+
" // (alerts, scorebots, ...); the SDK drops them by default and",
|
|
1062
|
+
" // always ignores this agent's own posts.",
|
|
1063
|
+
" includeBotPosts: true,"
|
|
1064
|
+
);
|
|
1065
|
+
}
|
|
1066
|
+
engagement.push(" },");
|
|
1067
|
+
engagement.push(" },");
|
|
1068
|
+
}
|
|
1069
|
+
body = [
|
|
1070
|
+
"//",
|
|
1071
|
+
"// This channel needs a dedicated Socket Mode Slack app (not the Cursor",
|
|
1072
|
+
`// connection): run \`${CLI_COMMAND_NAME} slack setup\` and \`${CLI_COMMAND_NAME} slack init\`.`,
|
|
1073
|
+
"export default slackChannel({",
|
|
1074
|
+
...engagement,
|
|
1075
|
+
"});",
|
|
1076
|
+
];
|
|
1077
|
+
} else {
|
|
1078
|
+
const agentName = pascalCase(automation.name, "ExportedAgent");
|
|
1079
|
+
body = [
|
|
1080
|
+
"//",
|
|
1081
|
+
`// Mention this agent in Slack with \`@Cursor ${agentName} ...\`.`,
|
|
1082
|
+
"export default slackChannel({",
|
|
1083
|
+
" cursorAccount: true,",
|
|
1084
|
+
` agentName: ${JSON.stringify(agentName)},`,
|
|
1085
|
+
"});",
|
|
1086
|
+
];
|
|
1087
|
+
}
|
|
1088
|
+
out.files.push({ path, contents: `${[...header, ...body].join("\n")}\n` });
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
function slackChannelList(
|
|
1092
|
+
trigger: SlackChannelsTriggerJson | undefined
|
|
1093
|
+
): string[] {
|
|
1094
|
+
return [...(trigger?.channels ?? []), trigger?.channel].filter(
|
|
1095
|
+
(channel): channel is string =>
|
|
1096
|
+
typeof channel === "string" && channel !== ""
|
|
1097
|
+
);
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
// ============================================================================
|
|
1101
|
+
// Boilerplate webhook channels (Linear / PagerDuty / Sentry / Teams / webhook)
|
|
1102
|
+
// ============================================================================
|
|
1103
|
+
|
|
1104
|
+
type BoilerplateKind = {
|
|
1105
|
+
key:
|
|
1106
|
+
| "linear"
|
|
1107
|
+
| "pagerduty"
|
|
1108
|
+
| "sentry"
|
|
1109
|
+
| "webhook"
|
|
1110
|
+
| "microsoftTeamsTrigger"
|
|
1111
|
+
| "microsoftTeamsChannelCreated";
|
|
1112
|
+
slug: string;
|
|
1113
|
+
label: string;
|
|
1114
|
+
};
|
|
1115
|
+
|
|
1116
|
+
const BOILERPLATE_KINDS: BoilerplateKind[] = [
|
|
1117
|
+
{ key: "linear", slug: "linear", label: "Linear" },
|
|
1118
|
+
{ key: "pagerduty", slug: "pagerduty", label: "PagerDuty" },
|
|
1119
|
+
{ key: "sentry", slug: "sentry", label: "Sentry" },
|
|
1120
|
+
{ key: "webhook", slug: "webhook", label: "webhook" },
|
|
1121
|
+
{
|
|
1122
|
+
key: "microsoftTeamsTrigger",
|
|
1123
|
+
slug: "teams",
|
|
1124
|
+
label: "Microsoft Teams message",
|
|
1125
|
+
},
|
|
1126
|
+
{
|
|
1127
|
+
key: "microsoftTeamsChannelCreated",
|
|
1128
|
+
slug: "teams-channel-created",
|
|
1129
|
+
label: "Microsoft Teams channel-created",
|
|
1130
|
+
},
|
|
1131
|
+
];
|
|
1132
|
+
|
|
1133
|
+
function boilerplateKind(trigger: TriggerJson): BoilerplateKind | undefined {
|
|
1134
|
+
return BOILERPLATE_KINDS.find((kind) => trigger[kind.key] !== undefined);
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
function emitBoilerplateChannel(
|
|
1138
|
+
automation: ConvertWorkflowInput["automation"],
|
|
1139
|
+
trigger: TriggerJson,
|
|
1140
|
+
kind: BoilerplateKind,
|
|
1141
|
+
slugs: SlugRegistry,
|
|
1142
|
+
out: ConvertOutput
|
|
1143
|
+
): void {
|
|
1144
|
+
const slug = slugs.claim(kind.slug);
|
|
1145
|
+
const path = `agent/channels/${slug}.ts`;
|
|
1146
|
+
out.warnings.push(
|
|
1147
|
+
`${kind.label} trigger exported as a boilerplate webhook channel (${path}) — Cursor-managed webhook delivery and event filtering do not carry over.`
|
|
1148
|
+
);
|
|
1149
|
+
out.setupSteps.push(
|
|
1150
|
+
`Re-register the ${kind.label} webhook against the deployed agent URL (/v1/channels/${slug}) and reimplement event filtering in ${path} — Cursor's backend did both before. The generated route accepts ANY unauthenticated POST and starts a run from it: add auth (e.g. bearerAuth or provider signature verification) before exposing it.`
|
|
1151
|
+
);
|
|
1152
|
+
const lines = [
|
|
1153
|
+
`import { defineChannel, POST } from "${PACKAGE_NAME}/channels";`,
|
|
1154
|
+
'import { z } from "zod";',
|
|
1155
|
+
"",
|
|
1156
|
+
`// Exported from Cursor automation "${commentSafe(automation.name)}" ${kind.label} trigger.`,
|
|
1157
|
+
"// Original trigger config:",
|
|
1158
|
+
...jsonComment(trigger[kind.key]),
|
|
1159
|
+
"//",
|
|
1160
|
+
`// TODO: Cursor's backend previously received ${kind.label} webhooks and`,
|
|
1161
|
+
"// applied the filtering above. Re-register the provider webhook against",
|
|
1162
|
+
`// the deployed agent URL (/v1/channels/${slug}) and reimplement the event`,
|
|
1163
|
+
"// filtering in this handler before dispatching.",
|
|
1164
|
+
"export default defineChannel({",
|
|
1165
|
+
" routes: [",
|
|
1166
|
+
' POST("/", {',
|
|
1167
|
+
" bodySchema: z.unknown(),",
|
|
1168
|
+
" handler: async (_req, { send, body }) => {",
|
|
1169
|
+
" const session = await send(",
|
|
1170
|
+
" [",
|
|
1171
|
+
` "Handle this ${kind.label} event:",`,
|
|
1172
|
+
' "",',
|
|
1173
|
+
" JSON.stringify(body, null, 2),",
|
|
1174
|
+
' ].join("\\n")',
|
|
1175
|
+
" );",
|
|
1176
|
+
" return Response.json({ sessionId: session.id });",
|
|
1177
|
+
" },",
|
|
1178
|
+
" }),",
|
|
1179
|
+
" ],",
|
|
1180
|
+
"});",
|
|
1181
|
+
"",
|
|
1182
|
+
];
|
|
1183
|
+
out.files.push({ path, contents: lines.join("\n") });
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
// ============================================================================
|
|
1187
|
+
// MCP connections
|
|
1188
|
+
// ============================================================================
|
|
1189
|
+
|
|
1190
|
+
function emitMcpConnections(
|
|
1191
|
+
automation: ConvertWorkflowInput["automation"],
|
|
1192
|
+
mcpServers: ConvertMcpServer[],
|
|
1193
|
+
out: ConvertOutput
|
|
1194
|
+
): void {
|
|
1195
|
+
const slugs = new SlugRegistry();
|
|
1196
|
+
for (const server of mcpServers) {
|
|
1197
|
+
const slug = slugs.claim(slugify(server.name, "mcp-server"));
|
|
1198
|
+
if (server.transportType !== "stdio") {
|
|
1199
|
+
out.mcpConnections.push({ name: slug, kind: "cursorAccount" });
|
|
1200
|
+
const lines = [
|
|
1201
|
+
`import { defineConnection } from "${PACKAGE_NAME}/connections";`,
|
|
1202
|
+
"",
|
|
1203
|
+
`// MCP server "${commentSafe(server.name)}" from Cursor automation "${commentSafe(automation.name)}".`,
|
|
1204
|
+
"// Resolved at runtime through your Cursor account: tools list and",
|
|
1205
|
+
"// execute on the Cursor backend with the account's stored credentials,",
|
|
1206
|
+
"// so no URL or tokens live in this project. Sign this host in",
|
|
1207
|
+
`// (\`${CLI_COMMAND_NAME} login\`) and manage the server's authorization`,
|
|
1208
|
+
"// in Cursor (dashboard -> MCP).",
|
|
1209
|
+
"export default defineConnection({",
|
|
1210
|
+
" cursorAccount: true,",
|
|
1211
|
+
` servers: [${JSON.stringify(server.name)}],`,
|
|
1212
|
+
"});",
|
|
1213
|
+
"",
|
|
1214
|
+
];
|
|
1215
|
+
out.files.push({
|
|
1216
|
+
path: `agent/mcp-connections/${slug}.ts`,
|
|
1217
|
+
contents: lines.join("\n"),
|
|
1218
|
+
});
|
|
1219
|
+
continue;
|
|
1220
|
+
}
|
|
1221
|
+
out.mcpConnections.push({ name: slug, kind: "stdioStub" });
|
|
1222
|
+
const reason =
|
|
1223
|
+
"it is a stdio server (its command, args, and env stay on Cursor's server). Run the server yourself and point a connection at it.";
|
|
1224
|
+
// A comment-only .ts in mcp-connections/ fails discovery (a connection
|
|
1225
|
+
// module must default-export defineConnection), so the stub is a .md the
|
|
1226
|
+
// framework ignores.
|
|
1227
|
+
const stubPath = `agent/mcp-connections/${slug}.todo.md`;
|
|
1228
|
+
out.warnings.push(
|
|
1229
|
+
`MCP server "${server.name}" exported as a stub (${stubPath}): ${reason}`
|
|
1230
|
+
);
|
|
1231
|
+
const lines = [
|
|
1232
|
+
`# TODO: MCP server "${commentSafe(server.name)}"`,
|
|
1233
|
+
"",
|
|
1234
|
+
`The source automation used MCP server "${commentSafe(server.name)}", which could not`,
|
|
1235
|
+
"be exported as a working connection:",
|
|
1236
|
+
"",
|
|
1237
|
+
`- ${reason}`,
|
|
1238
|
+
"",
|
|
1239
|
+
"Credentials are never exported. Once the server is reachable over",
|
|
1240
|
+
`HTTP, create \`agent/mcp-connections/${slug}.ts\`:`,
|
|
1241
|
+
"",
|
|
1242
|
+
"```ts",
|
|
1243
|
+
`import { defineConnection } from "${PACKAGE_NAME}/connections";`,
|
|
1244
|
+
"",
|
|
1245
|
+
"export default defineConnection({",
|
|
1246
|
+
' url: "https://example.com/mcp",',
|
|
1247
|
+
' oauth: true, // or headers: { authorization: "Bearer <token>" }',
|
|
1248
|
+
"});",
|
|
1249
|
+
"```",
|
|
1250
|
+
"",
|
|
1251
|
+
"This .md file is ignored by the framework; delete it once resolved.",
|
|
1252
|
+
"",
|
|
1253
|
+
];
|
|
1254
|
+
out.files.push({ path: stubPath, contents: lines.join("\n") });
|
|
1255
|
+
}
|
|
1256
|
+
}
|
|
1257
|
+
|
|
1258
|
+
/**
|
|
1259
|
+
* The automation posted to / read Slack via Cursor's built-in Slack action;
|
|
1260
|
+
* restore that access with a Cursor-account Slack connection so the exported
|
|
1261
|
+
* agent has Slack tools without any token plumbing. Returns whether the
|
|
1262
|
+
* project ends up with `agent/mcp-connections/slack.ts` — an MCP server named
|
|
1263
|
+
* "slack" may already have produced an equivalent connection, which counts.
|
|
1264
|
+
*/
|
|
1265
|
+
function emitSlackAccountConnection(
|
|
1266
|
+
automation: ConvertWorkflowInput["automation"],
|
|
1267
|
+
out: ConvertOutput
|
|
1268
|
+
): boolean {
|
|
1269
|
+
const path = "agent/mcp-connections/slack.ts";
|
|
1270
|
+
if (out.files.some((file) => file.path === path)) {
|
|
1271
|
+
return true;
|
|
1272
|
+
}
|
|
1273
|
+
const lines = [
|
|
1274
|
+
`import { defineConnection } from "${PACKAGE_NAME}/connections";`,
|
|
1275
|
+
"",
|
|
1276
|
+
`// Cursor automation "${commentSafe(automation.name)}" used the built-in Slack action.`,
|
|
1277
|
+
"// This connection restores Slack access through your Cursor account's",
|
|
1278
|
+
"// Slack connector (dashboard -> MCP): tools execute on the Cursor",
|
|
1279
|
+
"// backend with the account's stored credentials.",
|
|
1280
|
+
"export default defineConnection({",
|
|
1281
|
+
" cursorAccount: true,",
|
|
1282
|
+
' servers: ["Slack"],',
|
|
1283
|
+
"});",
|
|
1284
|
+
"",
|
|
1285
|
+
];
|
|
1286
|
+
out.files.push({ path, contents: lines.join("\n") });
|
|
1287
|
+
if (!out.mcpConnections.some((connection) => connection.name === "slack")) {
|
|
1288
|
+
out.mcpConnections.push({ name: "slack", kind: "cursorAccount" });
|
|
1289
|
+
}
|
|
1290
|
+
return true;
|
|
1291
|
+
}
|
|
1292
|
+
|
|
1293
|
+
// ============================================================================
|
|
1294
|
+
// Built-in actions → behaviors to verify
|
|
1295
|
+
// ============================================================================
|
|
1296
|
+
|
|
1297
|
+
const ACTION_STEPS: Partial<Record<keyof ActionJson, string>> = {
|
|
1298
|
+
gitPr:
|
|
1299
|
+
'Verify: git_pr — the automation auto-opened PRs. Have the agent push and open PRs itself; githubChannel needs permissions: "contents-write" to push code.',
|
|
1300
|
+
prComment:
|
|
1301
|
+
'Verify: pr_comment — githubChannel\'s default permissions ("pr-write") covers PR comments and reviews; guide comment behavior in agent/instructions.md.',
|
|
1302
|
+
manageCheckRun:
|
|
1303
|
+
"Verify: manage_check_run — no built-in equivalent; create/update check runs via ctx.github in a github channel hook.",
|
|
1304
|
+
requestReviewers:
|
|
1305
|
+
"Verify: request_reviewers — no built-in equivalent; request reviewers via ctx.github in a github channel hook.",
|
|
1306
|
+
approvePr:
|
|
1307
|
+
'Verify: approve_pr — PR approval needs githubChannel "pr-write" plus explicit approval guidance in agent/instructions.md.',
|
|
1308
|
+
resolveReviewThreads:
|
|
1309
|
+
"Verify: resolve_review_threads — no built-in equivalent; resolve threads via the GitHub GraphQL API in a hook or tool.",
|
|
1310
|
+
microsoftTeams:
|
|
1311
|
+
"Verify: microsoft_teams post — agentkit has no Teams channel; wire the Microsoft Teams API yourself.",
|
|
1312
|
+
readMicrosoftTeams:
|
|
1313
|
+
"Verify: read_microsoft_teams — agentkit has no Teams tools; wire the Microsoft Teams API yourself.",
|
|
1314
|
+
};
|
|
1315
|
+
|
|
1316
|
+
interface SlackIntegrationState {
|
|
1317
|
+
hasSlackChannel: boolean;
|
|
1318
|
+
hasSlackConnection: boolean;
|
|
1319
|
+
}
|
|
1320
|
+
|
|
1321
|
+
function slackActionStep(
|
|
1322
|
+
action: SlackActionJson,
|
|
1323
|
+
{ hasSlackChannel, hasSlackConnection }: SlackIntegrationState
|
|
1324
|
+
): string {
|
|
1325
|
+
const channels = dedupe(slackChannelList(action));
|
|
1326
|
+
const target =
|
|
1327
|
+
action.generalized === true
|
|
1328
|
+
? "any channel (generalized)"
|
|
1329
|
+
: channels.length > 0
|
|
1330
|
+
? channels.join(", ")
|
|
1331
|
+
: "unspecified channel";
|
|
1332
|
+
if (hasSlackChannel) {
|
|
1333
|
+
return `Verify: slack post (target: ${target}) — the Slack channel replies in the triggering thread; posting to other channels needs Slack tools or a Cursor account MCP connection.`;
|
|
1334
|
+
}
|
|
1335
|
+
if (hasSlackConnection) {
|
|
1336
|
+
return `Verify: slack post (target: ${target}) — agent/mcp-connections/slack.ts restores Slack through your Cursor account's Slack connector; confirm it is connected in Cursor (dashboard -> MCP) and that agent/instructions.md names the target channel.`;
|
|
1337
|
+
}
|
|
1338
|
+
return `Verify: slack post (target: ${target}) — the automation posted to Slack, but the exported project has no Slack integration; wire a Slack channel or a Cursor account MCP connection (defineConnection({ cursorAccount: true, servers: ["Slack"] })) to restore it.`;
|
|
1339
|
+
}
|
|
1340
|
+
|
|
1341
|
+
function behaviorSteps(
|
|
1342
|
+
workflow: WorkflowJson,
|
|
1343
|
+
warnings: string[],
|
|
1344
|
+
slack: SlackIntegrationState
|
|
1345
|
+
): string[] {
|
|
1346
|
+
const steps: string[] = [];
|
|
1347
|
+
for (const action of workflow.actions ?? []) {
|
|
1348
|
+
if (action.mcp !== undefined) {
|
|
1349
|
+
continue; // Covered by the exported mcpServers list.
|
|
1350
|
+
}
|
|
1351
|
+
if (action.slack !== undefined) {
|
|
1352
|
+
steps.push(slackActionStep(action.slack, slack));
|
|
1353
|
+
continue;
|
|
1354
|
+
}
|
|
1355
|
+
if (action.readSlack !== undefined) {
|
|
1356
|
+
steps.push(
|
|
1357
|
+
slack.hasSlackConnection
|
|
1358
|
+
? "Verify: read_slack — Slack read access is available through the generated agent/mcp-connections/slack.ts (Cursor-account Slack connector)."
|
|
1359
|
+
: 'Verify: read_slack — expose Slack read access via a Cursor account MCP connection (defineConnection({ cursorAccount: true, servers: ["Slack"] })).'
|
|
1360
|
+
);
|
|
1361
|
+
continue;
|
|
1362
|
+
}
|
|
1363
|
+
const step = Object.entries(ACTION_STEPS).find(
|
|
1364
|
+
([key]) => action[key as keyof ActionJson] !== undefined
|
|
1365
|
+
)?.[1];
|
|
1366
|
+
if (step === undefined) {
|
|
1367
|
+
warnings.push(
|
|
1368
|
+
`Unrecognized action (keys: ${Object.keys(action).join(", ") || "none"}) was skipped — verify its behavior manually.`
|
|
1369
|
+
);
|
|
1370
|
+
continue;
|
|
1371
|
+
}
|
|
1372
|
+
steps.push(step);
|
|
1373
|
+
}
|
|
1374
|
+
const disabled = workflow.disabledDefaultTools ?? [];
|
|
1375
|
+
if (disabled.length > 0) {
|
|
1376
|
+
steps.push(
|
|
1377
|
+
`Verify: disabled_default_tools (${disabled.join(", ")}) — agentkit has no default automation tools; enforce the equivalent restriction in agent/instructions.md.`
|
|
1378
|
+
);
|
|
1379
|
+
}
|
|
1380
|
+
return steps;
|
|
1381
|
+
}
|
|
1382
|
+
|
|
1383
|
+
// ============================================================================
|
|
1384
|
+
// Shared helpers
|
|
1385
|
+
// ============================================================================
|
|
1386
|
+
|
|
1387
|
+
/**
|
|
1388
|
+
* Neutralize comment-terminator sequences in server-provided text embedded
|
|
1389
|
+
* in generated comments: JS line terminators (\r, \n, U+2028, U+2029) end a
|
|
1390
|
+
* `//` comment, `-->` ends an HTML comment, and `*` + `/` ends a block
|
|
1391
|
+
* comment. Without this, a crafted automation or MCP server name injects
|
|
1392
|
+
* live code into the generated project.
|
|
1393
|
+
*/
|
|
1394
|
+
function commentSafe(text: string): string {
|
|
1395
|
+
return text
|
|
1396
|
+
.replace(/[\r\n\u2028\u2029]+/g, " ")
|
|
1397
|
+
.replace(/-->/g, "-- >")
|
|
1398
|
+
.replace(/\*\//g, "* /");
|
|
1399
|
+
}
|
|
1400
|
+
|
|
1401
|
+
function jsonComment(value: unknown): string[] {
|
|
1402
|
+
// JSON.stringify leaves U+2028/U+2029 raw; both terminate a `//` comment.
|
|
1403
|
+
return JSON.stringify(value, null, 2)
|
|
1404
|
+
.replace(/\u2028/g, "\\u2028")
|
|
1405
|
+
.replace(/\u2029/g, "\\u2029")
|
|
1406
|
+
.split("\n")
|
|
1407
|
+
.map((line) => `// ${line}`);
|
|
1408
|
+
}
|
|
1409
|
+
|
|
1410
|
+
function dedupe(values: string[]): string[] {
|
|
1411
|
+
return [...new Set(values)];
|
|
1412
|
+
}
|
|
1413
|
+
|
|
1414
|
+
/** Memory hook contents — identical to the `initProject` scaffold. */
|
|
1415
|
+
const MEMORY_HOOK = `// Journals every turn (timestamp, session, user message, result) so
|
|
1416
|
+
// future sessions can recall past work. The journal is shared across every
|
|
1417
|
+
// session — and every user — of this agent. Pass your own MemoryBackend to
|
|
1418
|
+
// change where memories live, or delete this file to opt out.
|
|
1419
|
+
import { memoryHook } from "${PACKAGE_NAME}/memory";
|
|
1420
|
+
|
|
1421
|
+
export default memoryHook();
|
|
1422
|
+
`;
|