@sweny-ai/core 0.1.51 → 0.1.53

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/dist/__tests__/publish.test.js +146 -0
  2. package/dist/__tests__/schema.test.js +156 -1
  3. package/dist/__tests__/skills.test.js +135 -4
  4. package/dist/__tests__/templates.test.d.ts +1 -0
  5. package/dist/__tests__/templates.test.js +49 -0
  6. package/dist/__tests__/version-consistency.test.js +7 -7
  7. package/dist/cli/config-file.d.ts +1 -1
  8. package/dist/cli/config-file.js +1 -1
  9. package/dist/cli/credentials.d.ts +1 -1
  10. package/dist/cli/credentials.js +1 -1
  11. package/dist/cli/credentials.test.js +1 -1
  12. package/dist/cli/e2e.d.ts +5 -1
  13. package/dist/cli/e2e.js +7 -3
  14. package/dist/cli/e2e.test.js +46 -1
  15. package/dist/cli/main.js +21 -7
  16. package/dist/cli/new.d.ts +101 -0
  17. package/dist/cli/new.js +670 -0
  18. package/dist/cli/new.test.d.ts +1 -0
  19. package/dist/cli/{init.test.js → new.test.js} +216 -78
  20. package/dist/cli/publish.d.ts +34 -0
  21. package/dist/cli/publish.js +368 -0
  22. package/dist/cli/templates.d.ts +13 -0
  23. package/dist/cli/templates.js +181 -0
  24. package/dist/executor.js +63 -20
  25. package/dist/executor.test.js +233 -0
  26. package/dist/index.d.ts +5 -5
  27. package/dist/index.js +3 -3
  28. package/dist/mcp.d.ts +2 -0
  29. package/dist/mcp.js +8 -0
  30. package/dist/mcp.test.js +67 -0
  31. package/dist/schema.d.ts +428 -18
  32. package/dist/schema.js +71 -4
  33. package/dist/skills/custom-loader.d.ts +12 -12
  34. package/dist/skills/custom-loader.js +105 -50
  35. package/dist/types.d.ts +18 -1
  36. package/dist/workflows/browser.js +3 -3
  37. package/package.json +10 -2
  38. package/dist/cli/init.d.ts +0 -53
  39. package/dist/cli/init.js +0 -521
  40. /package/dist/{cli/init.test.d.ts → __tests__/publish.test.d.ts} +0 -0
@@ -0,0 +1,670 @@
1
+ /**
2
+ * sweny new — Interactive workflow creation wizard
3
+ *
4
+ * Workflow-first: pick a template or describe what you want, then SWEny
5
+ * infers which skills are needed and only asks for those credentials.
6
+ *
7
+ * Pure functions for file generation + thin @clack/prompts interactive layer.
8
+ * Tests cover the pure functions; the interactive wizard is thin glue.
9
+ */
10
+ import * as fs from "node:fs";
11
+ import * as path from "node:path";
12
+ import * as p from "@clack/prompts";
13
+ import chalk from "chalk";
14
+ import { parse as parseYaml, stringify as stringifyYaml } from "yaml";
15
+ import { WORKFLOW_TEMPLATES } from "./templates.js";
16
+ import { buildWorkflow, refineWorkflow } from "../workflow-builder.js";
17
+ import { ClaudeClient } from "../claude.js";
18
+ import { consoleLogger } from "../types.js";
19
+ import { configuredSkills } from "../skills/custom-loader.js";
20
+ import { DagRenderer } from "./renderer.js";
21
+ import { runE2eInit } from "./e2e.js";
22
+ // ── Skill → Credential mapping ────────────────────────────────────────
23
+ /**
24
+ * Maps skill IDs (as used in workflow nodes) to the credentials they require.
25
+ * When a workflow uses a skill, we collect only those credentials.
26
+ */
27
+ export const SKILL_CREDENTIALS = {
28
+ github: [
29
+ {
30
+ key: "GITHUB_TOKEN",
31
+ url: "https://github.com/settings/tokens",
32
+ hint: "repo + issues scopes",
33
+ },
34
+ ],
35
+ gitlab: [
36
+ {
37
+ key: "GITLAB_TOKEN",
38
+ hint: "api scope",
39
+ url: "https://gitlab.com/-/profile/personal_access_tokens",
40
+ },
41
+ { key: "GITLAB_URL", hint: "e.g. https://gitlab.com", default: "https://gitlab.com" },
42
+ ],
43
+ datadog: [
44
+ {
45
+ key: "DD_API_KEY",
46
+ url: "https://app.datadoghq.com/organization-settings",
47
+ hint: "Organization Settings > API Keys",
48
+ },
49
+ { key: "DD_APP_KEY", hint: "Organization Settings > Application Keys" },
50
+ { key: "DD_SITE", hint: "datadoghq.com, datadoghq.eu, etc.", default: "datadoghq.com" },
51
+ ],
52
+ sentry: [
53
+ {
54
+ key: "SENTRY_AUTH_TOKEN",
55
+ url: "https://sentry.io/settings/auth-tokens/",
56
+ },
57
+ { key: "SENTRY_ORG", hint: "sentry.io/organizations/slug/" },
58
+ ],
59
+ betterstack: [
60
+ {
61
+ key: "BETTERSTACK_API_TOKEN",
62
+ url: "https://betterstack.com/docs/logs/api",
63
+ },
64
+ ],
65
+ newrelic: [{ key: "NR_API_KEY", url: "https://one.newrelic.com/api-keys" }],
66
+ linear: [
67
+ {
68
+ key: "LINEAR_API_KEY",
69
+ url: "https://linear.app/settings/api",
70
+ },
71
+ {
72
+ key: "LINEAR_TEAM_ID",
73
+ hint: "Settings > Teams > copy ID from URL",
74
+ },
75
+ ],
76
+ jira: [
77
+ { key: "JIRA_BASE_URL", hint: "e.g. https://your-org.atlassian.net" },
78
+ { key: "JIRA_EMAIL", hint: "your Atlassian account email" },
79
+ {
80
+ key: "JIRA_API_TOKEN",
81
+ url: "https://id.atlassian.com/manage-profile/security/api-tokens",
82
+ },
83
+ ],
84
+ slack: [{ key: "SLACK_BOT_TOKEN", url: "https://api.slack.com/apps" }],
85
+ discord: [{ key: "DISCORD_WEBHOOK_URL", hint: "Server Settings > Integrations > Webhooks" }],
86
+ notification: [{ key: "NOTIFICATION_WEBHOOK_URL", hint: "Your webhook endpoint URL" }],
87
+ };
88
+ const ALWAYS_CREDENTIALS = [
89
+ {
90
+ key: "ANTHROPIC_API_KEY",
91
+ url: "https://console.anthropic.com/settings/api-keys",
92
+ hint: "Claude API key",
93
+ },
94
+ ];
95
+ // ── Backward compat alias ─────────────────────────────────────────────
96
+ // Old tests/code may reference PROVIDER_CREDENTIALS
97
+ export const PROVIDER_CREDENTIALS = {
98
+ ...SKILL_CREDENTIALS,
99
+ "github-issues": [],
100
+ cloudwatch: [
101
+ { key: "AWS_ACCESS_KEY_ID", hint: "IAM user access key" },
102
+ { key: "AWS_SECRET_ACCESS_KEY", hint: "IAM user secret key" },
103
+ { key: "AWS_REGION", hint: "e.g. us-east-1", default: "us-east-1" },
104
+ ],
105
+ console: [],
106
+ teams: [{ key: "TEAMS_WEBHOOK_URL", hint: "Channel > Connectors > Incoming Webhook" }],
107
+ webhook: [{ key: "NOTIFICATION_WEBHOOK_URL", hint: "Your webhook endpoint URL" }],
108
+ };
109
+ // ── Functions ──────────────────────────────────────────────────────────
110
+ /**
111
+ * Extract all unique skill IDs used by nodes in a workflow YAML string.
112
+ *
113
+ * Handles both inline (`skills: [github, linear]`) and block-style arrays:
114
+ *
115
+ * ```yaml
116
+ * skills:
117
+ * - github
118
+ * - linear
119
+ * ```
120
+ *
121
+ * Returns `[]` for malformed YAML or YAML without node skills.
122
+ */
123
+ export function extractSkillsFromYaml(yaml) {
124
+ const skills = new Set();
125
+ let parsed;
126
+ try {
127
+ parsed = parseYaml(yaml);
128
+ }
129
+ catch {
130
+ return [];
131
+ }
132
+ if (!parsed || typeof parsed !== "object")
133
+ return [];
134
+ const nodes = parsed.nodes;
135
+ if (!nodes || typeof nodes !== "object")
136
+ return [];
137
+ for (const node of Object.values(nodes)) {
138
+ if (!node || typeof node !== "object")
139
+ continue;
140
+ const nodeSkills = node.skills;
141
+ if (!Array.isArray(nodeSkills))
142
+ continue;
143
+ for (const skill of nodeSkills) {
144
+ if (typeof skill === "string" && skill.trim().length > 0) {
145
+ skills.add(skill.trim());
146
+ }
147
+ }
148
+ }
149
+ return [...skills];
150
+ }
151
+ /**
152
+ * Gather credentials required for a set of skills.
153
+ * Always includes ANTHROPIC_API_KEY. Deduplicates by key name.
154
+ */
155
+ export function collectCredentialsForSkills(skills) {
156
+ const seen = new Map();
157
+ for (const cred of ALWAYS_CREDENTIALS) {
158
+ seen.set(cred.key, cred);
159
+ }
160
+ for (const skill of skills) {
161
+ const creds = SKILL_CREDENTIALS[skill];
162
+ if (!creds)
163
+ continue;
164
+ for (const cred of creds) {
165
+ if (!seen.has(cred.key)) {
166
+ seen.set(cred.key, cred);
167
+ }
168
+ }
169
+ }
170
+ return Array.from(seen.values());
171
+ }
172
+ /**
173
+ * Gather all unique credentials required for the selected providers.
174
+ * Always includes ANTHROPIC_API_KEY. Deduplicates by key name.
175
+ * @deprecated Use collectCredentialsForSkills instead — kept for backward compat.
176
+ */
177
+ export function collectCredentials(selections) {
178
+ const seen = new Map();
179
+ for (const cred of ALWAYS_CREDENTIALS) {
180
+ seen.set(cred.key, cred);
181
+ }
182
+ const providers = [
183
+ selections.sourceControl,
184
+ selections.observability,
185
+ selections.issueTracker,
186
+ selections.notification,
187
+ ].filter((p) => p !== null);
188
+ for (const provider of providers) {
189
+ const creds = PROVIDER_CREDENTIALS[provider];
190
+ if (!creds)
191
+ continue;
192
+ for (const cred of creds) {
193
+ if (!seen.has(cred.key)) {
194
+ seen.set(cred.key, cred);
195
+ }
196
+ }
197
+ }
198
+ return Array.from(seen.values());
199
+ }
200
+ /**
201
+ * Detect the git remote origin from `.git/config`.
202
+ * Returns provider + normalised remote (e.g. "github.com/owner/repo"), or null.
203
+ */
204
+ export function detectGitRemote(cwd) {
205
+ const configPath = path.join(cwd, ".git", "config");
206
+ let content;
207
+ try {
208
+ content = fs.readFileSync(configPath, "utf-8");
209
+ }
210
+ catch {
211
+ return null;
212
+ }
213
+ // Find [remote "origin"] section and extract url
214
+ const remoteMatch = content.match(/\[remote\s+"origin"\]\s*\n(?:\s+[^\[]*?\n)*?\s*url\s*=\s*(.+)/);
215
+ if (!remoteMatch)
216
+ return null;
217
+ const url = remoteMatch[1].trim();
218
+ // SSH: git@github.com:owner/repo.git
219
+ const sshMatch = url.match(/^git@([^:]+):(.+?)(?:\.git)?$/);
220
+ if (sshMatch) {
221
+ const host = sshMatch[1];
222
+ const repoPath = sshMatch[2];
223
+ if (host === "github.com") {
224
+ return { provider: "github", remote: `github.com/${repoPath}` };
225
+ }
226
+ if (host === "gitlab.com") {
227
+ return { provider: "gitlab", remote: `gitlab.com/${repoPath}` };
228
+ }
229
+ return null;
230
+ }
231
+ // SSH scheme: ssh://git@github.com/owner/repo.git
232
+ const sshSchemeMatch = url.match(/^ssh:\/\/git@([^/]+)\/(.+?)(?:\.git)?$/);
233
+ if (sshSchemeMatch) {
234
+ const host = sshSchemeMatch[1];
235
+ const repoPath = sshSchemeMatch[2];
236
+ if (host === "github.com") {
237
+ return { provider: "github", remote: `github.com/${repoPath}` };
238
+ }
239
+ if (host === "gitlab.com") {
240
+ return { provider: "gitlab", remote: `gitlab.com/${repoPath}` };
241
+ }
242
+ return null;
243
+ }
244
+ // HTTPS: https://github.com/owner/repo.git
245
+ const httpsMatch = url.match(/^https?:\/\/([^/]+)\/(.+?)(?:\.git)?$/);
246
+ if (httpsMatch) {
247
+ const host = httpsMatch[1];
248
+ const repoPath = httpsMatch[2];
249
+ if (host === "github.com") {
250
+ return { provider: "github", remote: `github.com/${repoPath}` };
251
+ }
252
+ if (host === "gitlab.com") {
253
+ return { provider: "gitlab", remote: `gitlab.com/${repoPath}` };
254
+ }
255
+ return null;
256
+ }
257
+ return null;
258
+ }
259
+ /**
260
+ * Infer source-control-provider from git remote detection.
261
+ */
262
+ function inferSourceControl(gitInfo) {
263
+ return gitInfo?.provider ?? "github";
264
+ }
265
+ /**
266
+ * Infer issue-tracker-provider from skills in the workflow.
267
+ *
268
+ * Explicit issue-tracker skills win. Otherwise we default to `github-issues`
269
+ * regardless of source-control provider: we don't ship a gitlab issues
270
+ * provider (yet), and `github-issues` is a sensible "use the same platform
271
+ * as source control" default for the supported case.
272
+ */
273
+ function inferIssueTracker(skills, _sourceControl) {
274
+ if (skills.includes("linear"))
275
+ return "linear";
276
+ if (skills.includes("jira"))
277
+ return "jira";
278
+ return "github-issues";
279
+ }
280
+ /**
281
+ * Infer observability-provider from skills in the workflow.
282
+ */
283
+ function inferObservability(skills) {
284
+ if (skills.includes("datadog"))
285
+ return "datadog";
286
+ if (skills.includes("sentry"))
287
+ return "sentry";
288
+ if (skills.includes("betterstack"))
289
+ return "betterstack";
290
+ if (skills.includes("newrelic"))
291
+ return "newrelic";
292
+ return null;
293
+ }
294
+ /**
295
+ * Generate .sweny.yml content from inferred providers.
296
+ */
297
+ export function buildSwenyYml(sourceControl, observability, issueTracker) {
298
+ const lines = [];
299
+ lines.push("# .sweny.yml — SWEny project configuration");
300
+ lines.push("# Secrets (API keys, tokens) go in .env (gitignored).");
301
+ lines.push("# Docs: https://docs.sweny.ai/cli");
302
+ lines.push("");
303
+ lines.push(`source-control-provider: ${sourceControl}`);
304
+ if (observability !== null) {
305
+ lines.push(`observability-provider: ${observability}`);
306
+ }
307
+ lines.push(`issue-tracker-provider: ${issueTracker}`);
308
+ lines.push("");
309
+ return lines.join("\n");
310
+ }
311
+ /**
312
+ * Generate .env template with comments for each credential.
313
+ */
314
+ export function buildEnvTemplate(credentials) {
315
+ const lines = [];
316
+ lines.push("# .env — SWEny credentials (DO NOT COMMIT)");
317
+ lines.push("# Fill in each value, then run: sweny check");
318
+ lines.push("");
319
+ for (const cred of credentials) {
320
+ if (cred.url) {
321
+ lines.push(`# ${cred.url}`);
322
+ }
323
+ if (cred.hint) {
324
+ lines.push(`# ${cred.hint}`);
325
+ }
326
+ if (cred.default !== undefined) {
327
+ lines.push(`${cred.key}=${cred.default}`);
328
+ }
329
+ else {
330
+ lines.push(`${cred.key}=`);
331
+ }
332
+ lines.push("");
333
+ }
334
+ return lines.join("\n");
335
+ }
336
+ /**
337
+ * Generate a GitHub Actions workflow YAML for SWEny.
338
+ * Only includes credentials WITHOUT defaults as secrets.
339
+ */
340
+ export function buildActionWorkflow(credentials, cronExpression) {
341
+ const secretCreds = credentials.filter((c) => c.default === undefined);
342
+ const lines = [];
343
+ lines.push("name: SWEny Triage");
344
+ lines.push("");
345
+ lines.push("on:");
346
+ lines.push(" schedule:");
347
+ lines.push(` - cron: "${cronExpression}"`);
348
+ lines.push(" workflow_dispatch:");
349
+ lines.push("");
350
+ lines.push("jobs:");
351
+ lines.push(" triage:");
352
+ lines.push(" runs-on: ubuntu-latest");
353
+ lines.push(" steps:");
354
+ lines.push(" - uses: actions/checkout@v4");
355
+ lines.push(" - uses: swenyai/sweny@v5");
356
+ lines.push(" with:");
357
+ lines.push(" workflow: triage");
358
+ if (secretCreds.length > 0) {
359
+ lines.push(" env:");
360
+ for (const cred of secretCreds) {
361
+ lines.push(` ${cred.key}: \${{ secrets.${cred.key} }}`);
362
+ }
363
+ }
364
+ lines.push("");
365
+ return lines.join("\n");
366
+ }
367
+ // ── Interactive wizard ────────────────────────────────────────────────
368
+ function cancel() {
369
+ p.cancel("Setup cancelled.");
370
+ process.exit(0);
371
+ }
372
+ /**
373
+ * Interactive workflow creation wizard — workflow-first approach.
374
+ *
375
+ * Flow:
376
+ * 1. Detect git remote (used to infer source-control provider)
377
+ * 2. Pick one of four branches:
378
+ * - A built-in `WORKFLOW_TEMPLATES` entry → write that workflow file
379
+ * - `__custom` → LLM generates a workflow from a user description
380
+ * - `__e2e` → delegate to the e2e browser-testing wizard in `./e2e.ts`
381
+ * - `__blank` → only set up config / credentials (no workflow file)
382
+ * 3. Infer observability + issue-tracker providers from the workflow's skills
383
+ * 4. Collect only the credentials those skills need (+ ANTHROPIC_API_KEY)
384
+ * 5. Write files idempotently:
385
+ * - `.sweny.yml` is never overwritten — if it exists, we keep it
386
+ * - `.env` is append-only (dedupes by key, incl. commented placeholders)
387
+ * - Workflow files in `.sweny/workflows/` prompt before overwriting
388
+ *
389
+ * Safe to re-run in an existing project to add additional workflows.
390
+ */
391
+ export async function runNew() {
392
+ const cwd = process.cwd();
393
+ // ── Fresh vs existing detection ─────────────────────────────────────
394
+ const configPath = path.join(cwd, ".sweny.yml");
395
+ const hasExistingConfig = fs.existsSync(configPath);
396
+ // ── Intro ───────────────────────────────────────────────────────────
397
+ p.intro(hasExistingConfig ? "Adding a new workflow" : "Let's set up SWEny");
398
+ const gitInfo = detectGitRemote(cwd);
399
+ if (gitInfo && !hasExistingConfig) {
400
+ p.log.info(`Detected: ${chalk.cyan(gitInfo.remote)} (${gitInfo.provider})`);
401
+ }
402
+ // ── Step 1: Pick a workflow ─────────────────────────────────────────
403
+ const templateChoice = await p.select({
404
+ message: "What do you want to do?",
405
+ options: [
406
+ ...WORKFLOW_TEMPLATES.map((t) => ({
407
+ value: t.id,
408
+ label: t.name,
409
+ hint: t.description,
410
+ })),
411
+ { value: "__e2e", label: "End-to-end browser testing", hint: "Automated browser tests for your app" },
412
+ { value: "__custom", label: "Describe your own", hint: "AI-generated from your description" },
413
+ { value: "__blank", label: "Start blank", hint: "just set up config, I'll create workflows later" },
414
+ ],
415
+ });
416
+ if (p.isCancel(templateChoice))
417
+ cancel();
418
+ // ── E2E short-circuit: delegate to the e2e wizard ────────────────────
419
+ if (templateChoice === "__e2e") {
420
+ await runE2eInit({ skipIntro: true });
421
+ return;
422
+ }
423
+ // ── Step 2: Resolve the chosen workflow ─────────────────────────────
424
+ let template;
425
+ if (templateChoice === "__custom") {
426
+ template = (await runCustomWorkflowBuilder()) ?? undefined;
427
+ if (!template) {
428
+ p.cancel("Setup cancelled.");
429
+ process.exit(0);
430
+ }
431
+ }
432
+ else {
433
+ template = WORKFLOW_TEMPLATES.find((t) => t.id === templateChoice);
434
+ }
435
+ const workflowSkills = template ? extractSkillsFromYaml(template.yaml) : [];
436
+ const sourceControl = inferSourceControl(gitInfo);
437
+ const issueTracker = inferIssueTracker(workflowSkills, sourceControl);
438
+ const observability = inferObservability(workflowSkills);
439
+ // Collect credentials for the workflow's skills (+ always ANTHROPIC_API_KEY)
440
+ const credentials = collectCredentialsForSkills(workflowSkills);
441
+ // ── Step 3: Summary + confirm ───────────────────────────────────────
442
+ const files = [];
443
+ if (!hasExistingConfig)
444
+ files.push(".sweny.yml", ".env");
445
+ if (template)
446
+ files.push(`.sweny/workflows/${template.id}.yml`);
447
+ const inferred = [
448
+ ` Source control: ${sourceControl}${gitInfo ? " (detected)" : ""}`,
449
+ ` Issue tracker: ${issueTracker}`,
450
+ ];
451
+ if (observability) {
452
+ inferred.push(` Observability: ${observability}`);
453
+ }
454
+ if (workflowSkills.length > 0) {
455
+ inferred.push(` Skills: ${workflowSkills.join(", ")}`);
456
+ }
457
+ const summaryLines = [];
458
+ if (files.length > 0) {
459
+ summaryLines.push(chalk.bold("Files to create:"));
460
+ summaryLines.push(...files.map((f) => ` ${chalk.cyan(f)}`));
461
+ summaryLines.push("");
462
+ }
463
+ if (hasExistingConfig) {
464
+ summaryLines.push(chalk.dim("(.sweny.yml already exists — keeping your existing config)"));
465
+ summaryLines.push("");
466
+ }
467
+ summaryLines.push(chalk.bold(hasExistingConfig ? "Workflow uses:" : "Inferred from workflow:"));
468
+ summaryLines.push(...inferred);
469
+ summaryLines.push("");
470
+ summaryLines.push(chalk.bold(`Credentials needed: ${credentials.length}`));
471
+ summaryLines.push(...credentials.map((c) => ` ${chalk.dim(c.key)}`));
472
+ p.log.message(summaryLines.join("\n"));
473
+ const confirmed = await p.confirm({
474
+ message: hasExistingConfig ? "Add this workflow?" : "Create these files?",
475
+ initialValue: true,
476
+ });
477
+ if (p.isCancel(confirmed))
478
+ cancel();
479
+ if (!confirmed) {
480
+ p.cancel("Setup cancelled.");
481
+ process.exit(0);
482
+ }
483
+ // ── Step 4: Write files ─────────────────────────────────────────────
484
+ // 1. Write .sweny.yml (only if fresh — never clobber existing config)
485
+ if (!hasExistingConfig) {
486
+ fs.writeFileSync(configPath, buildSwenyYml(sourceControl, observability, issueTracker), "utf-8");
487
+ p.log.success("Created .sweny.yml");
488
+ }
489
+ else {
490
+ p.log.info(".sweny.yml already exists — keeping existing config");
491
+ }
492
+ // 2. Write .env (append-only: safe for both fresh and existing repos)
493
+ let addedNewKeys = false;
494
+ const envPath = path.join(cwd, ".env");
495
+ if (fs.existsSync(envPath)) {
496
+ const existing = fs.readFileSync(envPath, "utf-8");
497
+ // A key is "present" if it appears as either an active `KEY=...` line
498
+ // or a commented `# KEY=...` placeholder. Either way appending the same
499
+ // key would create a duplicate the user has to reconcile.
500
+ const definedKeys = new Set();
501
+ for (const rawLine of existing.split("\n")) {
502
+ const line = rawLine.trimStart().replace(/^#\s*/, "");
503
+ const eq = line.indexOf("=");
504
+ if (eq <= 0)
505
+ continue;
506
+ const key = line.slice(0, eq).trim();
507
+ if (/^[A-Z_][A-Z0-9_]*$/i.test(key)) {
508
+ definedKeys.add(key);
509
+ }
510
+ }
511
+ const newKeys = [];
512
+ for (const cred of credentials) {
513
+ if (!definedKeys.has(cred.key)) {
514
+ newKeys.push(cred);
515
+ }
516
+ }
517
+ if (newKeys.length > 0) {
518
+ const appendBlock = "\n" + buildEnvTemplate(newKeys);
519
+ fs.appendFileSync(envPath, appendBlock, "utf-8");
520
+ p.log.success(`Appended ${newKeys.length} new key(s) to .env`);
521
+ addedNewKeys = true;
522
+ }
523
+ else {
524
+ p.log.info(".env already contains all required keys — skipped");
525
+ }
526
+ }
527
+ else {
528
+ fs.writeFileSync(envPath, buildEnvTemplate(credentials), "utf-8");
529
+ p.log.success("Created .env");
530
+ addedNewKeys = true;
531
+ }
532
+ // 3. Workflow template (prompt per-file if it already exists)
533
+ if (template) {
534
+ const workflowDir = path.join(cwd, ".sweny", "workflows");
535
+ fs.mkdirSync(workflowDir, { recursive: true });
536
+ const templatePath = path.join(workflowDir, `${template.id}.yml`);
537
+ if (fs.existsSync(templatePath)) {
538
+ const overwrite = await p.confirm({
539
+ message: `.sweny/workflows/${template.id}.yml already exists. Overwrite?`,
540
+ initialValue: false,
541
+ });
542
+ if (p.isCancel(overwrite))
543
+ cancel();
544
+ if (!overwrite) {
545
+ p.log.info("Workflow file preserved — no changes");
546
+ p.outro("Done.");
547
+ return;
548
+ }
549
+ }
550
+ fs.writeFileSync(templatePath, template.yaml, "utf-8");
551
+ p.log.success(`Created .sweny/workflows/${template.id}.yml`);
552
+ }
553
+ // 4. .gitignore check
554
+ const gitignorePath = path.join(cwd, ".gitignore");
555
+ if (fs.existsSync(gitignorePath)) {
556
+ const gitignore = fs.readFileSync(gitignorePath, "utf-8");
557
+ const envIgnored = gitignore.split("\n").some((line) => line.trim() === ".env");
558
+ if (!envIgnored) {
559
+ p.log.warn(chalk.yellow(".env is not in .gitignore — add it to avoid committing secrets"));
560
+ }
561
+ }
562
+ else {
563
+ p.log.warn(chalk.yellow("No .gitignore found — make sure .env is not committed"));
564
+ }
565
+ // 5. Next steps
566
+ const credUrls = credentials.filter((c) => c.url).map((c) => ` ${c.key}: ${c.url}`);
567
+ const steps = [];
568
+ let stepNum = 1;
569
+ if (addedNewKeys) {
570
+ steps.push(`${stepNum++}. Fill in your API keys in .env:`, ...credUrls, "", `${stepNum++}. Verify connectivity:`, " sweny check", "");
571
+ }
572
+ if (template) {
573
+ steps.push(`${stepNum++}. Run your workflow:`, ` sweny workflow run .sweny/workflows/${template.id}.yml`);
574
+ }
575
+ else {
576
+ steps.push(`${stepNum++}. Create your first workflow:`, " sweny new");
577
+ }
578
+ p.note(steps.join("\n"), "Next steps");
579
+ p.outro(hasExistingConfig ? "Workflow added!" : "You're all set!");
580
+ }
581
+ /**
582
+ * @deprecated Use `runNew` instead. Kept as a named re-export so external
583
+ * callers (e.g. create-sweny, plugin skills) don't break during the migration.
584
+ */
585
+ export const runInit = runNew;
586
+ // ── Custom workflow branch ────────────────────────────────────────────
587
+ /**
588
+ * Prompt the user for a description, generate a workflow via the LLM,
589
+ * show it, and run an accept/refine loop. Returns a WorkflowTemplate-shaped
590
+ * object on accept, null on cancel.
591
+ *
592
+ * Consumed by the `__custom` branch of `runNew`'s picker.
593
+ */
594
+ async function runCustomWorkflowBuilder() {
595
+ const description = await p.text({
596
+ message: "Describe the workflow you want",
597
+ placeholder: "e.g. Review Python PRs for security issues and post a comment",
598
+ validate: (v) => {
599
+ if (!v || v.trim().length === 0)
600
+ return "Description is required";
601
+ if (v.trim().length < 10)
602
+ return "Please give a bit more detail (10+ characters)";
603
+ return undefined;
604
+ },
605
+ });
606
+ if (p.isCancel(description))
607
+ return null;
608
+ const skills = configuredSkills();
609
+ const claude = new ClaudeClient({
610
+ maxTurns: 3,
611
+ cwd: process.cwd(),
612
+ logger: consoleLogger,
613
+ });
614
+ const spinner = p.spinner();
615
+ spinner.start("Generating workflow...");
616
+ let workflow;
617
+ try {
618
+ workflow = await buildWorkflow(description, { claude, skills, logger: consoleLogger });
619
+ spinner.stop("Generated");
620
+ }
621
+ catch (err) {
622
+ spinner.stop("Failed");
623
+ p.log.error(` ${err instanceof Error ? err.message : String(err)}`);
624
+ return null;
625
+ }
626
+ while (true) {
627
+ // Show the DAG
628
+ console.log("");
629
+ const renderer = new DagRenderer(workflow, { animate: false });
630
+ console.log(renderer.renderToString());
631
+ console.log("");
632
+ const action = await p.select({
633
+ message: "Looks good?",
634
+ options: [
635
+ { value: "accept", label: "Yes — use this workflow" },
636
+ { value: "refine", label: "Refine — describe what to change" },
637
+ { value: "cancel", label: "Cancel" },
638
+ ],
639
+ });
640
+ if (p.isCancel(action) || action === "cancel")
641
+ return null;
642
+ if (action === "accept") {
643
+ return {
644
+ id: workflow.id,
645
+ name: workflow.name,
646
+ description: workflow.description ?? "",
647
+ yaml: stringifyYaml(workflow, { indent: 2, lineWidth: 120 }),
648
+ };
649
+ }
650
+ if (action === "refine") {
651
+ const refinement = await p.text({
652
+ message: "What would you like to change?",
653
+ validate: (v) => (v && v.trim().length > 0 ? undefined : "Refinement is required"),
654
+ });
655
+ if (p.isCancel(refinement))
656
+ return null;
657
+ const rspin = p.spinner();
658
+ rspin.start("Refining...");
659
+ try {
660
+ workflow = await refineWorkflow(workflow, refinement, { claude, skills, logger: consoleLogger });
661
+ rspin.stop("Refined");
662
+ }
663
+ catch (err) {
664
+ rspin.stop("Failed");
665
+ p.log.error(` ${err instanceof Error ? err.message : String(err)}`);
666
+ // Stay in the loop — let them try again
667
+ }
668
+ }
669
+ }
670
+ }
@@ -0,0 +1 @@
1
+ export {};