@sweny-ai/core 0.1.51 → 0.1.52

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/dist/cli/init.js CHANGED
@@ -1,6 +1,9 @@
1
1
  /**
2
2
  * sweny init — Interactive setup wizard
3
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
+ *
4
7
  * Pure functions for file generation + thin @clack/prompts interactive layer.
5
8
  * Tests cover the pure functions; the interactive wizard is thin glue.
6
9
  */
@@ -8,8 +11,13 @@ import * as fs from "node:fs";
8
11
  import * as path from "node:path";
9
12
  import * as p from "@clack/prompts";
10
13
  import chalk from "chalk";
11
- // ── Credential table ───────────────────────────────────────────────────
12
- export const PROVIDER_CREDENTIALS = {
14
+ import { WORKFLOW_TEMPLATES } from "./templates.js";
15
+ // ── Skill → Credential mapping ────────────────────────────────────────
16
+ /**
17
+ * Maps skill IDs (as used in workflow nodes) to the credentials they require.
18
+ * When a workflow uses a skill, we collect only those credentials.
19
+ */
20
+ export const SKILL_CREDENTIALS = {
13
21
  github: [
14
22
  {
15
23
  key: "GITHUB_TOKEN",
@@ -48,12 +56,6 @@ export const PROVIDER_CREDENTIALS = {
48
56
  },
49
57
  ],
50
58
  newrelic: [{ key: "NR_API_KEY", url: "https://one.newrelic.com/api-keys" }],
51
- cloudwatch: [
52
- { key: "AWS_ACCESS_KEY_ID", hint: "IAM user access key" },
53
- { key: "AWS_SECRET_ACCESS_KEY", hint: "IAM user secret key" },
54
- { key: "AWS_REGION", hint: "e.g. us-east-1", default: "us-east-1" },
55
- ],
56
- "github-issues": [],
57
59
  linear: [
58
60
  {
59
61
  key: "LINEAR_API_KEY",
@@ -72,11 +74,9 @@ export const PROVIDER_CREDENTIALS = {
72
74
  url: "https://id.atlassian.com/manage-profile/security/api-tokens",
73
75
  },
74
76
  ],
75
- console: [],
76
77
  slack: [{ key: "SLACK_BOT_TOKEN", url: "https://api.slack.com/apps" }],
77
78
  discord: [{ key: "DISCORD_WEBHOOK_URL", hint: "Server Settings > Integrations > Webhooks" }],
78
- teams: [{ key: "TEAMS_WEBHOOK_URL", hint: "Channel > Connectors > Incoming Webhook" }],
79
- webhook: [{ key: "NOTIFICATION_WEBHOOK_URL", hint: "Your webhook endpoint URL" }],
79
+ notification: [{ key: "NOTIFICATION_WEBHOOK_URL", hint: "Your webhook endpoint URL" }],
80
80
  };
81
81
  const ALWAYS_CREDENTIALS = [
82
82
  {
@@ -85,14 +85,63 @@ const ALWAYS_CREDENTIALS = [
85
85
  hint: "Claude API key",
86
86
  },
87
87
  ];
88
+ // ── Backward compat alias ─────────────────────────────────────────────
89
+ // Old tests/code may reference PROVIDER_CREDENTIALS
90
+ export const PROVIDER_CREDENTIALS = {
91
+ ...SKILL_CREDENTIALS,
92
+ "github-issues": [],
93
+ cloudwatch: [
94
+ { key: "AWS_ACCESS_KEY_ID", hint: "IAM user access key" },
95
+ { key: "AWS_SECRET_ACCESS_KEY", hint: "IAM user secret key" },
96
+ { key: "AWS_REGION", hint: "e.g. us-east-1", default: "us-east-1" },
97
+ ],
98
+ console: [],
99
+ teams: [{ key: "TEAMS_WEBHOOK_URL", hint: "Channel > Connectors > Incoming Webhook" }],
100
+ webhook: [{ key: "NOTIFICATION_WEBHOOK_URL", hint: "Your webhook endpoint URL" }],
101
+ };
88
102
  // ── Functions ──────────────────────────────────────────────────────────
103
+ /**
104
+ * Extract all unique skill IDs from a workflow template YAML string.
105
+ */
106
+ export function extractSkillsFromYaml(yaml) {
107
+ const skills = new Set();
108
+ // Match skills: [github, linear, sentry] patterns
109
+ const matches = yaml.matchAll(/skills:\s*\[([^\]]+)\]/g);
110
+ for (const m of matches) {
111
+ for (const skill of m[1].split(",")) {
112
+ skills.add(skill.trim());
113
+ }
114
+ }
115
+ return [...skills];
116
+ }
117
+ /**
118
+ * Gather credentials required for a set of skills.
119
+ * Always includes ANTHROPIC_API_KEY. Deduplicates by key name.
120
+ */
121
+ export function collectCredentialsForSkills(skills) {
122
+ const seen = new Map();
123
+ for (const cred of ALWAYS_CREDENTIALS) {
124
+ seen.set(cred.key, cred);
125
+ }
126
+ for (const skill of skills) {
127
+ const creds = SKILL_CREDENTIALS[skill];
128
+ if (!creds)
129
+ continue;
130
+ for (const cred of creds) {
131
+ if (!seen.has(cred.key)) {
132
+ seen.set(cred.key, cred);
133
+ }
134
+ }
135
+ }
136
+ return Array.from(seen.values());
137
+ }
89
138
  /**
90
139
  * Gather all unique credentials required for the selected providers.
91
140
  * Always includes ANTHROPIC_API_KEY. Deduplicates by key name.
141
+ * @deprecated Use collectCredentialsForSkills instead — kept for backward compat.
92
142
  */
93
143
  export function collectCredentials(selections) {
94
144
  const seen = new Map();
95
- // Always-present credentials first
96
145
  for (const cred of ALWAYS_CREDENTIALS) {
97
146
  seen.set(cred.key, cred);
98
147
  }
@@ -174,23 +223,49 @@ export function detectGitRemote(cwd) {
174
223
  return null;
175
224
  }
176
225
  /**
177
- * Generate .sweny.yml content from selections.
226
+ * Infer source-control-provider from git remote detection.
227
+ */
228
+ function inferSourceControl(gitInfo) {
229
+ return gitInfo?.provider ?? "github";
230
+ }
231
+ /**
232
+ * Infer issue-tracker-provider from skills in the workflow.
233
+ */
234
+ function inferIssueTracker(skills, sourceControl) {
235
+ if (skills.includes("linear"))
236
+ return "linear";
237
+ if (skills.includes("jira"))
238
+ return "jira";
239
+ return sourceControl === "github" ? "github-issues" : "github-issues";
240
+ }
241
+ /**
242
+ * Infer observability-provider from skills in the workflow.
243
+ */
244
+ function inferObservability(skills) {
245
+ if (skills.includes("datadog"))
246
+ return "datadog";
247
+ if (skills.includes("sentry"))
248
+ return "sentry";
249
+ if (skills.includes("betterstack"))
250
+ return "betterstack";
251
+ if (skills.includes("newrelic"))
252
+ return "newrelic";
253
+ return null;
254
+ }
255
+ /**
256
+ * Generate .sweny.yml content from inferred providers.
178
257
  */
179
- export function buildSwenyYml(selections) {
258
+ export function buildSwenyYml(sourceControl, observability, issueTracker) {
180
259
  const lines = [];
181
260
  lines.push("# .sweny.yml — SWEny project configuration");
182
261
  lines.push("# Secrets (API keys, tokens) go in .env (gitignored).");
183
262
  lines.push("# Docs: https://docs.sweny.ai/cli");
184
263
  lines.push("");
185
- lines.push(`source-control-provider: ${selections.sourceControl}`);
186
- if (selections.observability !== null) {
187
- lines.push(`observability-provider: ${selections.observability}`);
188
- }
189
- lines.push(`issue-tracker-provider: ${selections.issueTracker}`);
190
- // Omit notification-provider when "console" (it's the default)
191
- if (selections.notification !== "console") {
192
- lines.push(`notification-provider: ${selections.notification}`);
264
+ lines.push(`source-control-provider: ${sourceControl}`);
265
+ if (observability !== null) {
266
+ lines.push(`observability-provider: ${observability}`);
193
267
  }
268
+ lines.push(`issue-tracker-provider: ${issueTracker}`);
194
269
  lines.push("");
195
270
  return lines.join("\n");
196
271
  }
@@ -256,8 +331,13 @@ function cancel() {
256
331
  process.exit(0);
257
332
  }
258
333
  /**
259
- * Interactive setup wizard — prompts the user through provider selection,
260
- * then generates `.sweny.yml`, `.env`, and an optional GitHub Action workflow.
334
+ * Interactive setup wizard — workflow-first approach.
335
+ *
336
+ * 1. Detect git remote (infer source control)
337
+ * 2. Pick a workflow template
338
+ * 3. Infer providers from workflow skills
339
+ * 4. Collect only the credentials those skills need
340
+ * 5. Generate files
261
341
  */
262
342
  export async function runInit() {
263
343
  const cwd = process.cwd();
@@ -265,7 +345,7 @@ export async function runInit() {
265
345
  p.intro("Let's set up SWEny");
266
346
  const gitInfo = detectGitRemote(cwd);
267
347
  if (gitInfo) {
268
- p.log.info(`Detected git remote: ${chalk.cyan(gitInfo.remote)}`);
348
+ p.log.info(`Detected: ${chalk.cyan(gitInfo.remote)} (${gitInfo.provider})`);
269
349
  }
270
350
  // ── Existing file check ─────────────────────────────────────────────
271
351
  const configPath = path.join(cwd, ".sweny.yml");
@@ -281,148 +361,51 @@ export async function runInit() {
281
361
  process.exit(0);
282
362
  }
283
363
  }
284
- // ── Screen 1: Source control ────────────────────────────────────────
285
- const sourceControl = await p.select({
286
- message: "Source control provider",
287
- initialValue: gitInfo?.provider ?? "github",
288
- options: [
289
- {
290
- value: "github",
291
- label: "GitHub" + (gitInfo?.provider === "github" ? " (detected)" : ""),
292
- },
293
- {
294
- value: "gitlab",
295
- label: "GitLab" + (gitInfo?.provider === "gitlab" ? " (detected)" : ""),
296
- },
297
- ],
298
- });
299
- if (p.isCancel(sourceControl))
300
- cancel();
301
- // ── Screen 2: Observability ─────────────────────────────────────────
302
- const obsRaw = await p.select({
303
- message: "Observability provider",
364
+ // ── Step 1: Pick a workflow ─────────────────────────────────────────
365
+ const templateChoice = await p.select({
366
+ message: "What do you want to do?",
304
367
  options: [
305
- { value: "datadog", label: "Datadog" },
306
- { value: "sentry", label: "Sentry" },
307
- { value: "betterstack", label: "BetterStack" },
308
- { value: "newrelic", label: "New Relic" },
309
- { value: "cloudwatch", label: "CloudWatch" },
310
- { value: "__other", label: "Other" },
311
- { value: "__none", label: "None" },
368
+ ...WORKFLOW_TEMPLATES.map((t) => ({
369
+ value: t.id,
370
+ label: t.name,
371
+ hint: t.description,
372
+ })),
373
+ { value: "__blank", label: "Start blank", hint: "just set up config, I'll create workflows later" },
312
374
  ],
313
375
  });
314
- if (p.isCancel(obsRaw))
376
+ if (p.isCancel(templateChoice))
315
377
  cancel();
316
- let observability;
317
- if (obsRaw === "__none") {
318
- observability = null;
319
- }
320
- else if (obsRaw === "__other") {
321
- const custom = await p.text({
322
- message: "Enter your observability provider name",
323
- validate: (v) => {
324
- if (!v || v.trim().length === 0)
325
- return "Provider name is required";
326
- if (!/^[a-z0-9-]+$/.test(v.trim()))
327
- return "Provider name must be lowercase letters, numbers, and hyphens only";
328
- return undefined;
329
- },
330
- });
331
- if (p.isCancel(custom))
332
- cancel();
333
- observability = custom.trim().toLowerCase();
334
- }
335
- else {
336
- observability = obsRaw;
378
+ // ── Step 2: Infer everything from the workflow ──────────────────────
379
+ const template = WORKFLOW_TEMPLATES.find((t) => t.id === templateChoice);
380
+ const workflowSkills = template ? extractSkillsFromYaml(template.yaml) : [];
381
+ const sourceControl = inferSourceControl(gitInfo);
382
+ const issueTracker = inferIssueTracker(workflowSkills, sourceControl);
383
+ const observability = inferObservability(workflowSkills);
384
+ // Collect credentials for the workflow's skills (+ always ANTHROPIC_API_KEY)
385
+ const credentials = collectCredentialsForSkills(workflowSkills);
386
+ // ── Step 3: Summary + confirm ───────────────────────────────────────
387
+ const files = [".sweny.yml", ".env"];
388
+ if (template)
389
+ files.push(`.sweny/workflows/${template.id}.yml`);
390
+ const inferred = [
391
+ ` Source control: ${sourceControl}${gitInfo ? " (detected)" : ""}`,
392
+ ` Issue tracker: ${issueTracker}`,
393
+ ];
394
+ if (observability) {
395
+ inferred.push(` Observability: ${observability}`);
337
396
  }
338
- // ── Screen 3: Issue tracker ─────────────────────────────────────────
339
- const issueTracker = await p.select({
340
- message: "Issue tracker",
341
- initialValue: sourceControl === "github" ? "github-issues" : undefined,
342
- options: [
343
- { value: "github-issues", label: "GitHub Issues" },
344
- { value: "linear", label: "Linear" },
345
- { value: "jira", label: "Jira" },
346
- ],
347
- });
348
- if (p.isCancel(issueTracker))
349
- cancel();
350
- // ── Screen 4: Notification ──────────────────────────────────────────
351
- const notification = await p.select({
352
- message: "Notification channel",
353
- initialValue: "console",
354
- options: [
355
- { value: "console", label: "Console (default)" },
356
- { value: "slack", label: "Slack" },
357
- { value: "discord", label: "Discord" },
358
- { value: "teams", label: "Microsoft Teams" },
359
- { value: "webhook", label: "Webhook" },
360
- ],
361
- });
362
- if (p.isCancel(notification))
363
- cancel();
364
- // ── Screen 5: GitHub Action ─────────────────────────────────────────
365
- const wantAction = await p.confirm({
366
- message: "Set up a GitHub Action workflow?",
367
- initialValue: true,
368
- });
369
- if (p.isCancel(wantAction))
370
- cancel();
371
- let cronExpression = null;
372
- if (wantAction) {
373
- const schedule = await p.select({
374
- message: "How often should the triage run?",
375
- options: [
376
- { value: "0 9 * * *", label: "Daily (9 AM UTC)" },
377
- { value: "0 9 * * 1", label: "Weekly (Monday 9 AM UTC)" },
378
- { value: "__custom", label: "Custom cron expression" },
379
- ],
380
- });
381
- if (p.isCancel(schedule))
382
- cancel();
383
- if (schedule === "__custom") {
384
- const customCron = await p.text({
385
- message: "Enter cron expression (e.g. 0 8 * * 1-5)",
386
- validate: (v) => {
387
- if (!v || v.trim().length === 0)
388
- return "Cron expression is required";
389
- if (!/^[\d*,\/-]+(\s+[\d*,\/-]+){4}$/.test(v.trim()))
390
- return "Must be a valid 5-field cron expression";
391
- return undefined;
392
- },
393
- });
394
- if (p.isCancel(customCron))
395
- cancel();
396
- cronExpression = customCron.trim();
397
- }
398
- else {
399
- cronExpression = schedule;
400
- }
397
+ if (workflowSkills.length > 0) {
398
+ inferred.push(` Skills: ${workflowSkills.join(", ")}`);
401
399
  }
402
- // ── Assemble selections ─────────────────────────────────────────────
403
- const selections = {
404
- sourceControl: sourceControl,
405
- observability,
406
- issueTracker: issueTracker,
407
- notification: notification,
408
- githubAction: !!wantAction,
409
- cronExpression,
410
- };
411
- const credentials = collectCredentials(selections);
412
- // ── Screen 6: Summary ───────────────────────────────────────────────
413
- const files = [".sweny.yml", ".env"];
414
- if (selections.githubAction)
415
- files.push(".github/workflows/sweny.yml");
416
400
  p.log.message([
417
401
  chalk.bold("Files to create:"),
418
402
  ...files.map((f) => ` ${chalk.cyan(f)}`),
419
403
  "",
420
- chalk.bold("Providers:"),
421
- ` Source control: ${selections.sourceControl}`,
422
- ` Observability: ${selections.observability ?? "none"}`,
423
- ` Issue tracker: ${selections.issueTracker}`,
424
- ` Notification: ${selections.notification}`,
425
- selections.githubAction ? ` GitHub Action: ${selections.cronExpression}` : " GitHub Action: no",
404
+ chalk.bold("Inferred from workflow:"),
405
+ ...inferred,
406
+ "",
407
+ chalk.bold(`Credentials needed: ${credentials.length}`),
408
+ ...credentials.map((c) => ` ${chalk.dim(c.key)}`),
426
409
  ].join("\n"));
427
410
  const confirmed = await p.confirm({
428
411
  message: "Create these files?",
@@ -434,15 +417,14 @@ export async function runInit() {
434
417
  p.cancel("Setup cancelled.");
435
418
  process.exit(0);
436
419
  }
437
- // ── Screen 7: Write files ──────────────────────────────────────────
420
+ // ── Step 4: Write files ─────────────────────────────────────────────
438
421
  // 1. Write .sweny.yml
439
- fs.writeFileSync(configPath, buildSwenyYml(selections), "utf-8");
422
+ fs.writeFileSync(configPath, buildSwenyYml(sourceControl, observability, issueTracker), "utf-8");
440
423
  p.log.success("Created .sweny.yml");
441
424
  // 2. Write .env
442
425
  const envPath = path.join(cwd, ".env");
443
426
  if (fs.existsSync(envPath)) {
444
427
  const existing = fs.readFileSync(envPath, "utf-8");
445
- // Parse defined keys (ignore comment lines)
446
428
  const definedKeys = new Set(existing
447
429
  .split("\n")
448
430
  .filter((l) => !l.trimStart().startsWith("#") && l.includes("="))
@@ -466,31 +448,13 @@ export async function runInit() {
466
448
  fs.writeFileSync(envPath, buildEnvTemplate(credentials), "utf-8");
467
449
  p.log.success("Created .env");
468
450
  }
469
- // 3. GitHub Action workflow
470
- if (selections.githubAction && selections.cronExpression) {
471
- const workflowDir = path.join(cwd, ".github", "workflows");
472
- const workflowPath = path.join(workflowDir, "sweny.yml");
473
- if (fs.existsSync(workflowPath)) {
474
- const overwriteWf = await p.confirm({
475
- message: ".github/workflows/sweny.yml already exists. Overwrite?",
476
- initialValue: false,
477
- });
478
- if (p.isCancel(overwriteWf))
479
- cancel();
480
- if (!overwriteWf) {
481
- p.log.info("Skipped GitHub Action workflow");
482
- }
483
- else {
484
- fs.mkdirSync(workflowDir, { recursive: true });
485
- fs.writeFileSync(workflowPath, buildActionWorkflow(credentials, selections.cronExpression), "utf-8");
486
- p.log.success("Created .github/workflows/sweny.yml");
487
- }
488
- }
489
- else {
490
- fs.mkdirSync(workflowDir, { recursive: true });
491
- fs.writeFileSync(workflowPath, buildActionWorkflow(credentials, selections.cronExpression), "utf-8");
492
- p.log.success("Created .github/workflows/sweny.yml");
493
- }
451
+ // 3. Workflow template
452
+ if (template) {
453
+ const workflowDir = path.join(cwd, ".sweny", "workflows");
454
+ fs.mkdirSync(workflowDir, { recursive: true });
455
+ const templatePath = path.join(workflowDir, `${template.id}.yml`);
456
+ fs.writeFileSync(templatePath, template.yaml, "utf-8");
457
+ p.log.success(`Created .sweny/workflows/${template.id}.yml`);
494
458
  }
495
459
  // 4. .gitignore check
496
460
  const gitignorePath = path.join(cwd, ".gitignore");
@@ -505,17 +469,14 @@ export async function runInit() {
505
469
  p.log.warn(chalk.yellow("No .gitignore found — make sure .env is not committed"));
506
470
  }
507
471
  // 5. Next steps
508
- const docUrls = credentials.filter((c) => c.url).map((c) => ` ${c.key}: ${c.url}`);
509
- p.note([
510
- "1. Fill in your API keys in .env:",
511
- ...docUrls,
512
- "",
513
- "2. Verify connectivity:",
514
- " sweny check",
515
- "",
516
- "3. Run a dry-run triage:",
517
- " sweny triage --dry-run",
518
- ].join("\n"), "Next steps");
519
- // 6. Done
472
+ const credUrls = credentials.filter((c) => c.url).map((c) => ` ${c.key}: ${c.url}`);
473
+ const steps = ["1. Fill in your API keys in .env:", ...credUrls, "", "2. Verify connectivity:", " sweny check"];
474
+ if (template) {
475
+ steps.push("", `3. Run your workflow:`, ` sweny workflow run .sweny/workflows/${template.id}.yml`);
476
+ }
477
+ else {
478
+ steps.push("", "3. Create your first workflow:", ' sweny workflow create "describe what you want"');
479
+ }
480
+ p.note(steps.join("\n"), "Next steps");
520
481
  p.outro("You're all set!");
521
482
  }