@sweny-ai/core 0.1.52 → 0.1.54
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/__tests__/version-consistency.test.js +5 -5
- package/dist/cli/config-file.d.ts +1 -1
- package/dist/cli/config-file.js +1 -1
- package/dist/cli/credentials.d.ts +1 -1
- package/dist/cli/credentials.js +1 -1
- package/dist/cli/credentials.test.js +1 -1
- package/dist/cli/e2e.d.ts +5 -1
- package/dist/cli/e2e.js +7 -3
- package/dist/cli/e2e.test.js +46 -1
- package/dist/cli/main.js +6 -12
- package/dist/cli/{init.d.ts → new.d.ts} +29 -9
- package/dist/cli/{init.js → new.js} +242 -59
- package/dist/cli/{init.test.js → new.test.js} +82 -1
- package/dist/cli/publish.js +23 -12
- package/dist/cli/templates.d.ts +1 -1
- package/dist/cli/templates.js +1 -1
- package/package.json +4 -4
- /package/dist/cli/{init.test.d.ts → new.test.d.ts} +0 -0
|
@@ -9,7 +9,7 @@ import { execSync } from "node:child_process";
|
|
|
9
9
|
* These tests validate that:
|
|
10
10
|
* 1. No stale v4 action references remain in source or docs
|
|
11
11
|
* 2. The README uses dark-mode-safe logo rendering
|
|
12
|
-
* 3. The CLI
|
|
12
|
+
* 3. The CLI new wizard generates the correct action version
|
|
13
13
|
* 4. Docs code blocks don't mix the generic action with triage-only inputs
|
|
14
14
|
*/
|
|
15
15
|
const ROOT = resolve(import.meta.dirname, "..", "..", "..", "..");
|
|
@@ -69,10 +69,10 @@ describe("action version consistency", () => {
|
|
|
69
69
|
const claude = readFileSync(resolve(ROOT, "CLAUDE.md"), "utf-8");
|
|
70
70
|
expect(claude).toMatch(new RegExp(`@sweny-ai/core.*${CURRENT_ACTION_VERSION}`));
|
|
71
71
|
});
|
|
72
|
-
it("CLI
|
|
73
|
-
const
|
|
74
|
-
expect(
|
|
75
|
-
expect(
|
|
72
|
+
it("CLI new wizard generates workflows with the current action version", () => {
|
|
73
|
+
const newSrc = readFileSync(resolve(ROOT, "packages/core/src/cli/new.ts"), "utf-8");
|
|
74
|
+
expect(newSrc).toContain(`swenyai/sweny@${CURRENT_ACTION_VERSION}`);
|
|
75
|
+
expect(newSrc).not.toContain(`swenyai/sweny@${PREV_VERSION}`);
|
|
76
76
|
});
|
|
77
77
|
});
|
|
78
78
|
// ─── 2. README quality ──────────────────────────────────────────────
|
|
@@ -11,5 +11,5 @@ export type FileConfig = Record<string, string | string[]>;
|
|
|
11
11
|
* Returns empty object if no config file is found.
|
|
12
12
|
*/
|
|
13
13
|
export declare function loadConfigFile(cwd?: string): FileConfig;
|
|
14
|
-
/** Starter config written by `sweny
|
|
14
|
+
/** Starter config written by `sweny new`. */
|
|
15
15
|
export declare const STARTER_CONFIG = "# .sweny.yml \u2014 SWEny project configuration\n# Commit this file. Secrets (API keys, tokens) go in .env (gitignored).\n#\n# Every key matches a CLI flag: \"time-range: 4h\" is the same as \"--time-range 4h\".\n# CLI flags override this file; env vars override this file; this file overrides defaults.\n\n# \u2500\u2500 Providers \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n# observability-provider: datadog # datadog | sentry | cloudwatch | splunk | elastic | newrelic | loki | prometheus | pagerduty | heroku | opsgenie | vercel | supabase | netlify | fly | render | file\n# issue-tracker-provider: github-issues # github-issues | linear | jira\n# source-control-provider: github # github | gitlab\n# coding-agent-provider: claude # claude | codex | gemini\n# notification-provider: console # console | slack | teams | discord | email | webhook\n\n# \u2500\u2500 Investigation \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n# time-range: 24h\n# severity-focus: errors\n# service-filter: \"*\"\n# investigation-depth: standard # quick | standard | thorough\n\n# \u2500\u2500 PR / branch \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n# base-branch: main\n# pr-labels: agent,triage,needs-review\n\n# \u2500\u2500 Paths \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n# service-map-path: .github/service-map.yml\n# log-file: ./logs/errors.json # required when observability-provider is \"file\"\n\n# \u2500\u2500 Cache \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n# cache-dir: .sweny/cache\n# cache-ttl: 86400\n\n# \u2500\u2500 MCP servers \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n# Extend the coding agent with additional tools via MCP.\n# Value is a JSON object \u2014 each key is a server name you choose.\n# See docs/mcp-servers.md for a full catalog with copy-paste configs.\n#\n# Example: GitHub MCP server (query PRs, issues, CI run logs)\n# mcp-servers-json: '{\"github\":{\"type\":\"stdio\",\"command\":\"npx\",\"args\":[\"-y\",\"@modelcontextprotocol/server-github@latest\"],\"env\":{\"GITHUB_PERSONAL_ACCESS_TOKEN\":\"ghp_...\"}}}'\n\n# \u2500\u2500 Local-only quick start \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n# Uncomment to run without any external services (just an LLM API key):\n# observability-provider: file\n# log-file: ./sample-errors.json\n# issue-tracker-provider: file\n# source-control-provider: file\n# notification-provider: file\n# output-dir: .sweny/output\n\n# \u2500\u2500 Credentials (.env) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n# Copy the relevant block into your .env file and fill in the values.\n#\n# Claude (coding agent) \u2014 https://console.anthropic.com/settings/api-keys\n# ANTHROPIC_API_KEY=sk-ant-...\n#\n# GitHub (source control + issue tracker)\n# GITHUB_TOKEN=ghp_... # https://github.com/settings/tokens (repo + issues scopes)\n#\n# Datadog (observability) \u2014 https://app.datadoghq.com/organization-settings\n# DD_API_KEY=... # Organization Settings > API Keys\n# DD_APP_KEY=... # Organization Settings > Application Keys\n# DD_SITE=datadoghq.com # or datadoghq.eu, us3.datadoghq.com, etc.\n#\n# Sentry (observability) \u2014 https://sentry.io/settings/auth-tokens/\n# SENTRY_AUTH_TOKEN=sntrys_...\n# SENTRY_ORG=your-org-slug # from sentry.io/organizations/<slug>/\n# SENTRY_PROJECT=your-project # Project Settings > General > Project Slug\n#\n# Linear (issue tracker) \u2014 https://linear.app/settings/api\n# LINEAR_API_KEY=lin_api_...\n# LINEAR_TEAM_ID=... # Settings > Workspace > Teams > [team] > copy ID from URL\n# LINEAR_BUG_LABEL_ID=... # Settings > Labels > [label] > copy ID from URL\n#\n# Jira (issue tracker) \u2014 https://your-org.atlassian.net\n# JIRA_BASE_URL=https://your-org.atlassian.net\n# JIRA_EMAIL=you@company.com # your Atlassian account email\n# JIRA_API_TOKEN=... # https://id.atlassian.com/manage-profile/security/api-tokens\n#\n# Vercel (observability) \u2014 https://vercel.com/account/tokens\n# VERCEL_TOKEN=...\n# VERCEL_PROJECT_ID=prj_... # Project Settings > General > Project ID\n# VERCEL_TEAM_ID=team_... # optional, for team-owned projects\n#\n# Supabase (observability) \u2014 https://supabase.com/dashboard/account/tokens\n# SUPABASE_MANAGEMENT_KEY=...\n# SUPABASE_PROJECT_REF=... # Project Settings > General > Reference ID\n#\n# Netlify (observability) \u2014 https://app.netlify.com/user/applications#personal-access-tokens\n# NETLIFY_TOKEN=...\n# NETLIFY_SITE_ID=... # Site Settings > General > Site ID\n#\n# Fly.io (observability) \u2014 https://fly.io/user/personal_access_tokens\n# FLY_TOKEN=...\n# FLY_APP_NAME=... # the name of your Fly.io application\n#\n# Render (observability) \u2014 https://dashboard.render.com/u/settings\n# RENDER_API_KEY=...\n# RENDER_SERVICE_ID=srv-... # from your service's Settings page\n#\n# Prometheus (observability) \u2014 self-hosted or Grafana Cloud\n# PROMETHEUS_URL=http://prometheus.internal:9090\n# PROMETHEUS_TOKEN=... # optional, for secured instances\n#\n# PagerDuty (observability) \u2014 https://your-account.pagerduty.com/api_keys\n# PAGERDUTY_API_KEY=...\n#\n# Honeycomb (observability) \u2014 https://docs.honeycomb.io/api/\n# HONEYCOMB_API_KEY=...\n# HONEYCOMB_DATASET=... # dataset name (e.g. production)\n#\n# Heroku (observability) \u2014 https://devcenter.heroku.com/articles/platform-api-reference\n# HEROKU_API_KEY=... # https://dashboard.heroku.com/account\n# HEROKU_APP_NAME=... # the name of your Heroku application\n#\n# OpsGenie (observability) \u2014 https://support.atlassian.com/opsgenie/docs/api-key-management/\n# OPSGENIE_API_KEY=...\n# OPSGENIE_REGION=us # or eu for EU-hosted accounts\n#\n# Slack (notifications) \u2014 https://api.slack.com/apps\n# NOTIFICATION_WEBHOOK_URL=https://hooks.slack.com/services/...\n# # or use a bot token: SLACK_BOT_TOKEN=xoxb-...\n";
|
package/dist/cli/config-file.js
CHANGED
|
@@ -86,7 +86,7 @@ function findConfigFile(startDir) {
|
|
|
86
86
|
dir = parent;
|
|
87
87
|
}
|
|
88
88
|
}
|
|
89
|
-
/** Starter config written by `sweny
|
|
89
|
+
/** Starter config written by `sweny new`. */
|
|
90
90
|
export const STARTER_CONFIG = `# .sweny.yml — SWEny project configuration
|
|
91
91
|
# Commit this file. Secrets (API keys, tokens) go in .env (gitignored).
|
|
92
92
|
#
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* and buildSkillMcpServers.
|
|
4
4
|
*
|
|
5
5
|
* Several env vars have two accepted spellings — the user-facing one
|
|
6
|
-
* (`NR_API_KEY`, `JIRA_BASE_URL`, `SENTRY_BASE_URL`, …) used in
|
|
6
|
+
* (`NR_API_KEY`, `JIRA_BASE_URL`, `SENTRY_BASE_URL`, …) used in new.ts,
|
|
7
7
|
* config.ts, and docs, and the canonical one used by the MCP wiring layer
|
|
8
8
|
* (`NEW_RELIC_API_KEY`, `JIRA_URL`, `SENTRY_URL`, …). We accept either
|
|
9
9
|
* spelling and write the value under the canonical key the wiring functions
|
package/dist/cli/credentials.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* and buildSkillMcpServers.
|
|
4
4
|
*
|
|
5
5
|
* Several env vars have two accepted spellings — the user-facing one
|
|
6
|
-
* (`NR_API_KEY`, `JIRA_BASE_URL`, `SENTRY_BASE_URL`, …) used in
|
|
6
|
+
* (`NR_API_KEY`, `JIRA_BASE_URL`, `SENTRY_BASE_URL`, …) used in new.ts,
|
|
7
7
|
* config.ts, and docs, and the canonical one used by the MCP wiring layer
|
|
8
8
|
* (`NEW_RELIC_API_KEY`, `JIRA_URL`, `SENTRY_URL`, …). We accept either
|
|
9
9
|
* spelling and write the value under the canonical key the wiring functions
|
|
@@ -6,7 +6,7 @@ import { buildSkillMcpServers } from "../mcp.js";
|
|
|
6
6
|
// ─────────────────────────────────────────────────────────────────────
|
|
7
7
|
//
|
|
8
8
|
// These tests catch env-var name drift between:
|
|
9
|
-
// - the user-facing env var name (what docs /
|
|
9
|
+
// - the user-facing env var name (what docs / new.ts / config.ts read)
|
|
10
10
|
// - the canonical key the MCP wiring functions look up
|
|
11
11
|
//
|
|
12
12
|
// Unit tests in mcp.test.ts inject the credentials map directly. That
|
package/dist/cli/e2e.d.ts
CHANGED
|
@@ -66,11 +66,15 @@ export declare function buildFlowWorkflow(flow: FlowConfig, baseUrl: string, cle
|
|
|
66
66
|
* Build .env template content for e2e testing.
|
|
67
67
|
*/
|
|
68
68
|
export declare function buildE2eEnvTemplate(selections: E2eSelections): string;
|
|
69
|
+
export interface E2eInitOptions {
|
|
70
|
+
/** Skip the `p.intro()` call — used when invoked from inside another wizard. */
|
|
71
|
+
skipIntro?: boolean;
|
|
72
|
+
}
|
|
69
73
|
/**
|
|
70
74
|
* Interactive E2E setup wizard — prompts through flow selection,
|
|
71
75
|
* then generates .sweny/e2e/*.yml and .env additions.
|
|
72
76
|
*/
|
|
73
|
-
export declare function runE2eInit(): Promise<void>;
|
|
77
|
+
export declare function runE2eInit(options?: E2eInitOptions): Promise<void>;
|
|
74
78
|
export interface E2eRunOptions {
|
|
75
79
|
file?: string;
|
|
76
80
|
timeout?: number;
|
package/dist/cli/e2e.js
CHANGED
|
@@ -614,15 +614,19 @@ async function askFlowQuestions(type) {
|
|
|
614
614
|
}
|
|
615
615
|
return config;
|
|
616
616
|
}
|
|
617
|
-
// ── Interactive wizard ─────────────────────────────────────────────────
|
|
618
617
|
/**
|
|
619
618
|
* Interactive E2E setup wizard — prompts through flow selection,
|
|
620
619
|
* then generates .sweny/e2e/*.yml and .env additions.
|
|
621
620
|
*/
|
|
622
|
-
export async function runE2eInit() {
|
|
621
|
+
export async function runE2eInit(options = {}) {
|
|
623
622
|
const cwd = process.cwd();
|
|
624
623
|
// ── Screen 1: Intro ──────────────────────────────────────────
|
|
625
|
-
|
|
624
|
+
if (!options.skipIntro) {
|
|
625
|
+
p.intro("Let's set up end-to-end testing for your app");
|
|
626
|
+
}
|
|
627
|
+
else {
|
|
628
|
+
p.log.step("Setting up end-to-end browser testing");
|
|
629
|
+
}
|
|
626
630
|
// Check for existing files
|
|
627
631
|
const e2eDir = path.join(cwd, ".sweny", "e2e");
|
|
628
632
|
if (fs.existsSync(e2eDir)) {
|
package/dist/cli/e2e.test.js
CHANGED
|
@@ -1,7 +1,34 @@
|
|
|
1
|
-
import { describe, it, expect } from "vitest";
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
2
2
|
import { stringify as stringifyYaml, parse as parseYaml } from "yaml";
|
|
3
3
|
import { resolveTemplateVars, buildE2eVars, buildSetupNode, buildReportNode, buildCleanupNode, buildFlowNodes, buildFlowWorkflow, buildE2eEnvTemplate, } from "./e2e.js";
|
|
4
4
|
import { workflowZ, validateWorkflow } from "../schema.js";
|
|
5
|
+
// ── Clack mock for runE2eInit tests ────────────────────────────────────
|
|
6
|
+
// Hoisted spies so `vi.mock` (also hoisted) can reference them.
|
|
7
|
+
const { introSpy, logStepSpy } = vi.hoisted(() => ({
|
|
8
|
+
introSpy: vi.fn(),
|
|
9
|
+
logStepSpy: vi.fn(),
|
|
10
|
+
}));
|
|
11
|
+
vi.mock("@clack/prompts", () => ({
|
|
12
|
+
intro: (...args) => introSpy(...args),
|
|
13
|
+
outro: vi.fn(),
|
|
14
|
+
cancel: vi.fn(),
|
|
15
|
+
log: {
|
|
16
|
+
step: (...args) => logStepSpy(...args),
|
|
17
|
+
info: vi.fn(),
|
|
18
|
+
success: vi.fn(),
|
|
19
|
+
warn: vi.fn(),
|
|
20
|
+
message: vi.fn(),
|
|
21
|
+
},
|
|
22
|
+
// Abort the wizard right after the intro/step decision so we can assert on spies.
|
|
23
|
+
multiselect: vi.fn(async () => {
|
|
24
|
+
throw new Error("__abort_test__");
|
|
25
|
+
}),
|
|
26
|
+
select: vi.fn(),
|
|
27
|
+
confirm: vi.fn(),
|
|
28
|
+
text: vi.fn(),
|
|
29
|
+
isCancel: vi.fn(() => false),
|
|
30
|
+
spinner: vi.fn(() => ({ start: vi.fn(), stop: vi.fn(), message: vi.fn() })),
|
|
31
|
+
}));
|
|
5
32
|
describe("resolveTemplateVars", () => {
|
|
6
33
|
it("replaces known variables", () => {
|
|
7
34
|
const result = resolveTemplateVars("Navigate to {base_url}/signup", {
|
|
@@ -636,3 +663,21 @@ describe("buildFlowWorkflow — node naming conventions", () => {
|
|
|
636
663
|
}
|
|
637
664
|
});
|
|
638
665
|
});
|
|
666
|
+
describe("runE2eInit — skipIntro option", () => {
|
|
667
|
+
beforeEach(() => {
|
|
668
|
+
introSpy.mockClear();
|
|
669
|
+
logStepSpy.mockClear();
|
|
670
|
+
});
|
|
671
|
+
it("calls p.intro when skipIntro is not set", async () => {
|
|
672
|
+
const { runE2eInit } = await import("./e2e.js");
|
|
673
|
+
await expect(runE2eInit()).rejects.toThrow("__abort_test__");
|
|
674
|
+
expect(introSpy).toHaveBeenCalledTimes(1);
|
|
675
|
+
expect(logStepSpy).not.toHaveBeenCalledWith("Setting up end-to-end browser testing");
|
|
676
|
+
});
|
|
677
|
+
it("skips p.intro and logs a step when skipIntro: true", async () => {
|
|
678
|
+
const { runE2eInit } = await import("./e2e.js");
|
|
679
|
+
await expect(runE2eInit({ skipIntro: true })).rejects.toThrow("__abort_test__");
|
|
680
|
+
expect(introSpy).not.toHaveBeenCalled();
|
|
681
|
+
expect(logStepSpy).toHaveBeenCalledWith("Setting up end-to-end browser testing");
|
|
682
|
+
});
|
|
683
|
+
});
|
package/dist/cli/main.js
CHANGED
|
@@ -22,8 +22,8 @@ import { DagRenderer } from "./renderer.js";
|
|
|
22
22
|
import * as readline from "node:readline";
|
|
23
23
|
import { loadDotenv, loadConfigFile } from "./config-file.js";
|
|
24
24
|
import { buildCredentialMap } from "./credentials.js";
|
|
25
|
-
import {
|
|
26
|
-
import {
|
|
25
|
+
import { runNew } from "./new.js";
|
|
26
|
+
import { runE2eRun } from "./e2e.js";
|
|
27
27
|
import { registerTriageCommand, registerImplementCommand, parseCliInputs, validateInputs, validateWarnings, } from "./config.js";
|
|
28
28
|
import { c, formatBanner, getStepDetails, formatStepLine, formatDagResultHuman, formatDagResultMarkdown, formatResultJson, formatValidationErrors, formatCrashError, formatCheckResults, } from "./output.js";
|
|
29
29
|
import { checkProviderConnectivity } from "./check.js";
|
|
@@ -57,21 +57,15 @@ const program = new Command()
|
|
|
57
57
|
.name("sweny")
|
|
58
58
|
.description("SWEny CLI \u2014 autonomous engineering workflows")
|
|
59
59
|
.version(version);
|
|
60
|
-
// ── sweny
|
|
60
|
+
// ── sweny new ─────────────────────────────────────────────────────────
|
|
61
61
|
program
|
|
62
|
-
.command("
|
|
63
|
-
.description("
|
|
62
|
+
.command("new")
|
|
63
|
+
.description("Create a new workflow — interactive picker or direct template")
|
|
64
64
|
.action(async () => {
|
|
65
|
-
await
|
|
65
|
+
await runNew();
|
|
66
66
|
});
|
|
67
67
|
// ── sweny e2e ────────────────────────────────────────────────────────
|
|
68
68
|
const e2eCmd = program.command("e2e").description("End-to-end browser testing");
|
|
69
|
-
e2eCmd
|
|
70
|
-
.command("init")
|
|
71
|
-
.description("Interactive wizard — generates .sweny/e2e/*.yml workflow files")
|
|
72
|
-
.action(async () => {
|
|
73
|
-
await runE2eInit();
|
|
74
|
-
});
|
|
75
69
|
e2eCmd
|
|
76
70
|
.command("run [file]")
|
|
77
71
|
.description("Run e2e workflow files from .sweny/e2e/")
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* sweny
|
|
2
|
+
* sweny new — Interactive workflow creation wizard
|
|
3
3
|
*
|
|
4
4
|
* Workflow-first: pick a template or describe what you want, then SWEny
|
|
5
5
|
* infers which skills are needed and only asks for those credentials.
|
|
@@ -32,7 +32,17 @@ export interface GitRemoteInfo {
|
|
|
32
32
|
export declare const SKILL_CREDENTIALS: Record<string, Credential[]>;
|
|
33
33
|
export declare const PROVIDER_CREDENTIALS: Record<string, Credential[]>;
|
|
34
34
|
/**
|
|
35
|
-
* Extract all unique skill IDs
|
|
35
|
+
* Extract all unique skill IDs used by nodes in a workflow YAML string.
|
|
36
|
+
*
|
|
37
|
+
* Handles both inline (`skills: [github, linear]`) and block-style arrays:
|
|
38
|
+
*
|
|
39
|
+
* ```yaml
|
|
40
|
+
* skills:
|
|
41
|
+
* - github
|
|
42
|
+
* - linear
|
|
43
|
+
* ```
|
|
44
|
+
*
|
|
45
|
+
* Returns `[]` for malformed YAML or YAML without node skills.
|
|
36
46
|
*/
|
|
37
47
|
export declare function extractSkillsFromYaml(yaml: string): string[];
|
|
38
48
|
/**
|
|
@@ -65,12 +75,22 @@ export declare function buildEnvTemplate(credentials: Credential[]): string;
|
|
|
65
75
|
*/
|
|
66
76
|
export declare function buildActionWorkflow(credentials: Credential[], cronExpression: string): string;
|
|
67
77
|
/**
|
|
68
|
-
* Interactive
|
|
78
|
+
* Interactive workflow creation wizard — workflow-first approach.
|
|
79
|
+
*
|
|
80
|
+
* Flow:
|
|
81
|
+
* 1. Detect git remote (used to infer source-control provider)
|
|
82
|
+
* 2. Pick one of four branches:
|
|
83
|
+
* - A built-in `WORKFLOW_TEMPLATES` entry → write that workflow file
|
|
84
|
+
* - `__custom` → LLM generates a workflow from a user description
|
|
85
|
+
* - `__e2e` → delegate to the e2e browser-testing wizard in `./e2e.ts`
|
|
86
|
+
* - `__blank` → only set up config / credentials (no workflow file)
|
|
87
|
+
* 3. Infer observability + issue-tracker providers from the workflow's skills
|
|
88
|
+
* 4. Collect only the credentials those skills need (+ ANTHROPIC_API_KEY)
|
|
89
|
+
* 5. Write files idempotently:
|
|
90
|
+
* - `.sweny.yml` is never overwritten — if it exists, we keep it
|
|
91
|
+
* - `.env` is append-only (dedupes by key, incl. commented placeholders)
|
|
92
|
+
* - Workflow files in `.sweny/workflows/` prompt before overwriting
|
|
69
93
|
*
|
|
70
|
-
*
|
|
71
|
-
* 2. Pick a workflow template
|
|
72
|
-
* 3. Infer providers from workflow skills
|
|
73
|
-
* 4. Collect only the credentials those skills need
|
|
74
|
-
* 5. Generate files
|
|
94
|
+
* Safe to re-run in an existing project to add additional workflows.
|
|
75
95
|
*/
|
|
76
|
-
export declare function
|
|
96
|
+
export declare function runNew(): Promise<void>;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* sweny
|
|
2
|
+
* sweny new — Interactive workflow creation wizard
|
|
3
3
|
*
|
|
4
4
|
* Workflow-first: pick a template or describe what you want, then SWEny
|
|
5
5
|
* infers which skills are needed and only asks for those credentials.
|
|
@@ -11,7 +11,14 @@ import * as fs from "node:fs";
|
|
|
11
11
|
import * as path from "node:path";
|
|
12
12
|
import * as p from "@clack/prompts";
|
|
13
13
|
import chalk from "chalk";
|
|
14
|
+
import { parse as parseYaml, stringify as stringifyYaml } from "yaml";
|
|
14
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";
|
|
15
22
|
// ── Skill → Credential mapping ────────────────────────────────────────
|
|
16
23
|
/**
|
|
17
24
|
* Maps skill IDs (as used in workflow nodes) to the credentials they require.
|
|
@@ -101,15 +108,42 @@ export const PROVIDER_CREDENTIALS = {
|
|
|
101
108
|
};
|
|
102
109
|
// ── Functions ──────────────────────────────────────────────────────────
|
|
103
110
|
/**
|
|
104
|
-
* Extract all unique skill IDs
|
|
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.
|
|
105
122
|
*/
|
|
106
123
|
export function extractSkillsFromYaml(yaml) {
|
|
107
124
|
const skills = new Set();
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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
|
+
}
|
|
113
147
|
}
|
|
114
148
|
}
|
|
115
149
|
return [...skills];
|
|
@@ -230,13 +264,18 @@ function inferSourceControl(gitInfo) {
|
|
|
230
264
|
}
|
|
231
265
|
/**
|
|
232
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.
|
|
233
272
|
*/
|
|
234
|
-
function inferIssueTracker(skills,
|
|
273
|
+
function inferIssueTracker(skills, _sourceControl) {
|
|
235
274
|
if (skills.includes("linear"))
|
|
236
275
|
return "linear";
|
|
237
276
|
if (skills.includes("jira"))
|
|
238
277
|
return "jira";
|
|
239
|
-
return
|
|
278
|
+
return "github-issues";
|
|
240
279
|
}
|
|
241
280
|
/**
|
|
242
281
|
* Infer observability-provider from skills in the workflow.
|
|
@@ -331,36 +370,35 @@ function cancel() {
|
|
|
331
370
|
process.exit(0);
|
|
332
371
|
}
|
|
333
372
|
/**
|
|
334
|
-
* Interactive
|
|
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
|
|
335
388
|
*
|
|
336
|
-
*
|
|
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
|
|
389
|
+
* Safe to re-run in an existing project to add additional workflows.
|
|
341
390
|
*/
|
|
342
|
-
export async function
|
|
391
|
+
export async function runNew() {
|
|
343
392
|
const cwd = process.cwd();
|
|
393
|
+
// ── Fresh vs existing detection ─────────────────────────────────────
|
|
394
|
+
const configPath = path.join(cwd, ".sweny.yml");
|
|
395
|
+
const hasExistingConfig = fs.existsSync(configPath);
|
|
344
396
|
// ── Intro ───────────────────────────────────────────────────────────
|
|
345
|
-
p.intro("Let's set up SWEny");
|
|
397
|
+
p.intro(hasExistingConfig ? "Adding a new workflow" : "Let's set up SWEny");
|
|
346
398
|
const gitInfo = detectGitRemote(cwd);
|
|
347
|
-
if (gitInfo) {
|
|
399
|
+
if (gitInfo && !hasExistingConfig) {
|
|
348
400
|
p.log.info(`Detected: ${chalk.cyan(gitInfo.remote)} (${gitInfo.provider})`);
|
|
349
401
|
}
|
|
350
|
-
// ── Existing file check ─────────────────────────────────────────────
|
|
351
|
-
const configPath = path.join(cwd, ".sweny.yml");
|
|
352
|
-
if (fs.existsSync(configPath)) {
|
|
353
|
-
const overwrite = await p.confirm({
|
|
354
|
-
message: ".sweny.yml already exists. Overwrite?",
|
|
355
|
-
initialValue: false,
|
|
356
|
-
});
|
|
357
|
-
if (p.isCancel(overwrite))
|
|
358
|
-
cancel();
|
|
359
|
-
if (!overwrite) {
|
|
360
|
-
p.cancel("Setup cancelled — existing config preserved.");
|
|
361
|
-
process.exit(0);
|
|
362
|
-
}
|
|
363
|
-
}
|
|
364
402
|
// ── Step 1: Pick a workflow ─────────────────────────────────────────
|
|
365
403
|
const templateChoice = await p.select({
|
|
366
404
|
message: "What do you want to do?",
|
|
@@ -370,13 +408,30 @@ export async function runInit() {
|
|
|
370
408
|
label: t.name,
|
|
371
409
|
hint: t.description,
|
|
372
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" },
|
|
373
413
|
{ value: "__blank", label: "Start blank", hint: "just set up config, I'll create workflows later" },
|
|
374
414
|
],
|
|
375
415
|
});
|
|
376
416
|
if (p.isCancel(templateChoice))
|
|
377
417
|
cancel();
|
|
378
|
-
// ──
|
|
379
|
-
|
|
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
|
+
}
|
|
380
435
|
const workflowSkills = template ? extractSkillsFromYaml(template.yaml) : [];
|
|
381
436
|
const sourceControl = inferSourceControl(gitInfo);
|
|
382
437
|
const issueTracker = inferIssueTracker(workflowSkills, sourceControl);
|
|
@@ -384,7 +439,9 @@ export async function runInit() {
|
|
|
384
439
|
// Collect credentials for the workflow's skills (+ always ANTHROPIC_API_KEY)
|
|
385
440
|
const credentials = collectCredentialsForSkills(workflowSkills);
|
|
386
441
|
// ── Step 3: Summary + confirm ───────────────────────────────────────
|
|
387
|
-
const files = [
|
|
442
|
+
const files = [];
|
|
443
|
+
if (!hasExistingConfig)
|
|
444
|
+
files.push(".sweny.yml", ".env");
|
|
388
445
|
if (template)
|
|
389
446
|
files.push(`.sweny/workflows/${template.id}.yml`);
|
|
390
447
|
const inferred = [
|
|
@@ -397,18 +454,24 @@ export async function runInit() {
|
|
|
397
454
|
if (workflowSkills.length > 0) {
|
|
398
455
|
inferred.push(` Skills: ${workflowSkills.join(", ")}`);
|
|
399
456
|
}
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
chalk.
|
|
408
|
-
|
|
409
|
-
|
|
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"));
|
|
410
473
|
const confirmed = await p.confirm({
|
|
411
|
-
message: "Create these files?",
|
|
474
|
+
message: hasExistingConfig ? "Add this workflow?" : "Create these files?",
|
|
412
475
|
initialValue: true,
|
|
413
476
|
});
|
|
414
477
|
if (p.isCancel(confirmed))
|
|
@@ -418,17 +481,33 @@ export async function runInit() {
|
|
|
418
481
|
process.exit(0);
|
|
419
482
|
}
|
|
420
483
|
// ── Step 4: Write files ─────────────────────────────────────────────
|
|
421
|
-
// 1. Write .sweny.yml
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
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;
|
|
425
494
|
const envPath = path.join(cwd, ".env");
|
|
426
495
|
if (fs.existsSync(envPath)) {
|
|
427
496
|
const existing = fs.readFileSync(envPath, "utf-8");
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
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
|
+
}
|
|
432
511
|
const newKeys = [];
|
|
433
512
|
for (const cred of credentials) {
|
|
434
513
|
if (!definedKeys.has(cred.key)) {
|
|
@@ -439,6 +518,7 @@ export async function runInit() {
|
|
|
439
518
|
const appendBlock = "\n" + buildEnvTemplate(newKeys);
|
|
440
519
|
fs.appendFileSync(envPath, appendBlock, "utf-8");
|
|
441
520
|
p.log.success(`Appended ${newKeys.length} new key(s) to .env`);
|
|
521
|
+
addedNewKeys = true;
|
|
442
522
|
}
|
|
443
523
|
else {
|
|
444
524
|
p.log.info(".env already contains all required keys — skipped");
|
|
@@ -447,12 +527,26 @@ export async function runInit() {
|
|
|
447
527
|
else {
|
|
448
528
|
fs.writeFileSync(envPath, buildEnvTemplate(credentials), "utf-8");
|
|
449
529
|
p.log.success("Created .env");
|
|
530
|
+
addedNewKeys = true;
|
|
450
531
|
}
|
|
451
|
-
// 3. Workflow template
|
|
532
|
+
// 3. Workflow template (prompt per-file if it already exists)
|
|
452
533
|
if (template) {
|
|
453
534
|
const workflowDir = path.join(cwd, ".sweny", "workflows");
|
|
454
535
|
fs.mkdirSync(workflowDir, { recursive: true });
|
|
455
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
|
+
}
|
|
456
550
|
fs.writeFileSync(templatePath, template.yaml, "utf-8");
|
|
457
551
|
p.log.success(`Created .sweny/workflows/${template.id}.yml`);
|
|
458
552
|
}
|
|
@@ -470,13 +564,102 @@ export async function runInit() {
|
|
|
470
564
|
}
|
|
471
565
|
// 5. Next steps
|
|
472
566
|
const credUrls = credentials.filter((c) => c.url).map((c) => ` ${c.key}: ${c.url}`);
|
|
473
|
-
const steps = [
|
|
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
|
+
}
|
|
474
572
|
if (template) {
|
|
475
|
-
steps.push(
|
|
573
|
+
steps.push(`${stepNum++}. Run your workflow:`, ` sweny workflow run .sweny/workflows/${template.id}.yml`);
|
|
476
574
|
}
|
|
477
575
|
else {
|
|
478
|
-
steps.push(
|
|
576
|
+
steps.push(`${stepNum++}. Create your first workflow:`, " sweny new");
|
|
479
577
|
}
|
|
480
578
|
p.note(steps.join("\n"), "Next steps");
|
|
481
|
-
p.outro("You're all set!");
|
|
579
|
+
p.outro(hasExistingConfig ? "Workflow added!" : "You're all set!");
|
|
580
|
+
}
|
|
581
|
+
// ── Custom workflow branch ────────────────────────────────────────────
|
|
582
|
+
/**
|
|
583
|
+
* Prompt the user for a description, generate a workflow via the LLM,
|
|
584
|
+
* show it, and run an accept/refine loop. Returns a WorkflowTemplate-shaped
|
|
585
|
+
* object on accept, null on cancel.
|
|
586
|
+
*
|
|
587
|
+
* Consumed by the `__custom` branch of `runNew`'s picker.
|
|
588
|
+
*/
|
|
589
|
+
async function runCustomWorkflowBuilder() {
|
|
590
|
+
const description = await p.text({
|
|
591
|
+
message: "Describe the workflow you want",
|
|
592
|
+
placeholder: "e.g. Review Python PRs for security issues and post a comment",
|
|
593
|
+
validate: (v) => {
|
|
594
|
+
if (!v || v.trim().length === 0)
|
|
595
|
+
return "Description is required";
|
|
596
|
+
if (v.trim().length < 10)
|
|
597
|
+
return "Please give a bit more detail (10+ characters)";
|
|
598
|
+
return undefined;
|
|
599
|
+
},
|
|
600
|
+
});
|
|
601
|
+
if (p.isCancel(description))
|
|
602
|
+
return null;
|
|
603
|
+
const skills = configuredSkills();
|
|
604
|
+
const claude = new ClaudeClient({
|
|
605
|
+
maxTurns: 3,
|
|
606
|
+
cwd: process.cwd(),
|
|
607
|
+
logger: consoleLogger,
|
|
608
|
+
});
|
|
609
|
+
const spinner = p.spinner();
|
|
610
|
+
spinner.start("Generating workflow...");
|
|
611
|
+
let workflow;
|
|
612
|
+
try {
|
|
613
|
+
workflow = await buildWorkflow(description, { claude, skills, logger: consoleLogger });
|
|
614
|
+
spinner.stop("Generated");
|
|
615
|
+
}
|
|
616
|
+
catch (err) {
|
|
617
|
+
spinner.stop("Failed");
|
|
618
|
+
p.log.error(` ${err instanceof Error ? err.message : String(err)}`);
|
|
619
|
+
return null;
|
|
620
|
+
}
|
|
621
|
+
while (true) {
|
|
622
|
+
// Show the DAG
|
|
623
|
+
console.log("");
|
|
624
|
+
const renderer = new DagRenderer(workflow, { animate: false });
|
|
625
|
+
console.log(renderer.renderToString());
|
|
626
|
+
console.log("");
|
|
627
|
+
const action = await p.select({
|
|
628
|
+
message: "Looks good?",
|
|
629
|
+
options: [
|
|
630
|
+
{ value: "accept", label: "Yes — use this workflow" },
|
|
631
|
+
{ value: "refine", label: "Refine — describe what to change" },
|
|
632
|
+
{ value: "cancel", label: "Cancel" },
|
|
633
|
+
],
|
|
634
|
+
});
|
|
635
|
+
if (p.isCancel(action) || action === "cancel")
|
|
636
|
+
return null;
|
|
637
|
+
if (action === "accept") {
|
|
638
|
+
return {
|
|
639
|
+
id: workflow.id,
|
|
640
|
+
name: workflow.name,
|
|
641
|
+
description: workflow.description ?? "",
|
|
642
|
+
yaml: stringifyYaml(workflow, { indent: 2, lineWidth: 120 }),
|
|
643
|
+
};
|
|
644
|
+
}
|
|
645
|
+
if (action === "refine") {
|
|
646
|
+
const refinement = await p.text({
|
|
647
|
+
message: "What would you like to change?",
|
|
648
|
+
validate: (v) => (v && v.trim().length > 0 ? undefined : "Refinement is required"),
|
|
649
|
+
});
|
|
650
|
+
if (p.isCancel(refinement))
|
|
651
|
+
return null;
|
|
652
|
+
const rspin = p.spinner();
|
|
653
|
+
rspin.start("Refining...");
|
|
654
|
+
try {
|
|
655
|
+
workflow = await refineWorkflow(workflow, refinement, { claude, skills, logger: consoleLogger });
|
|
656
|
+
rspin.stop("Refined");
|
|
657
|
+
}
|
|
658
|
+
catch (err) {
|
|
659
|
+
rspin.stop("Failed");
|
|
660
|
+
p.log.error(` ${err instanceof Error ? err.message : String(err)}`);
|
|
661
|
+
// Stay in the loop — let them try again
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
}
|
|
482
665
|
}
|
|
@@ -3,7 +3,7 @@ import * as fs from "node:fs";
|
|
|
3
3
|
import * as path from "node:path";
|
|
4
4
|
import * as os from "node:os";
|
|
5
5
|
import { parse as parseYaml } from "yaml";
|
|
6
|
-
import { collectCredentials, collectCredentialsForSkills, extractSkillsFromYaml, detectGitRemote, buildSwenyYml, buildEnvTemplate, buildActionWorkflow, PROVIDER_CREDENTIALS, SKILL_CREDENTIALS, } from "./
|
|
6
|
+
import { collectCredentials, collectCredentialsForSkills, extractSkillsFromYaml, detectGitRemote, buildSwenyYml, buildEnvTemplate, buildActionWorkflow, PROVIDER_CREDENTIALS, SKILL_CREDENTIALS, } from "./new.js";
|
|
7
7
|
// ── detectGitRemote ────────────────────────────────────────────────────
|
|
8
8
|
describe("detectGitRemote", () => {
|
|
9
9
|
const tmpDirs = [];
|
|
@@ -682,6 +682,87 @@ describe("collectCredentialsForSkills", () => {
|
|
|
682
682
|
expect(creds[0].key).toBe("ANTHROPIC_API_KEY");
|
|
683
683
|
});
|
|
684
684
|
});
|
|
685
|
+
// ── extractSkillsFromYaml: block-style arrays + malformed input ───────
|
|
686
|
+
//
|
|
687
|
+
// Regression: an earlier regex-based implementation only matched inline
|
|
688
|
+
// arrays (`skills: [a, b]`). `yaml.stringify()` emits block style by
|
|
689
|
+
// default, so AI-generated `__custom` workflows silently dropped every
|
|
690
|
+
// skill and the wizard then asked for zero credentials. These tests pin
|
|
691
|
+
// the parser-based implementation to handle both forms.
|
|
692
|
+
describe("extractSkillsFromYaml — block-style arrays", () => {
|
|
693
|
+
it("extracts skills from block-style arrays (yaml.stringify default)", () => {
|
|
694
|
+
const yaml = `nodes:
|
|
695
|
+
review:
|
|
696
|
+
name: Code Review
|
|
697
|
+
skills:
|
|
698
|
+
- github
|
|
699
|
+
- linear
|
|
700
|
+
`;
|
|
701
|
+
const skills = extractSkillsFromYaml(yaml);
|
|
702
|
+
expect(skills.sort()).toEqual(["github", "linear"]);
|
|
703
|
+
});
|
|
704
|
+
it("deduplicates across block-style nodes", () => {
|
|
705
|
+
const yaml = `nodes:
|
|
706
|
+
first:
|
|
707
|
+
skills:
|
|
708
|
+
- github
|
|
709
|
+
- sentry
|
|
710
|
+
second:
|
|
711
|
+
skills:
|
|
712
|
+
- github
|
|
713
|
+
- linear
|
|
714
|
+
`;
|
|
715
|
+
const skills = extractSkillsFromYaml(yaml).sort();
|
|
716
|
+
expect(skills).toEqual(["github", "linear", "sentry"]);
|
|
717
|
+
});
|
|
718
|
+
it("handles mixed inline and block-style skills in the same doc", () => {
|
|
719
|
+
const yaml = `nodes:
|
|
720
|
+
a:
|
|
721
|
+
skills: [github, datadog]
|
|
722
|
+
b:
|
|
723
|
+
skills:
|
|
724
|
+
- linear
|
|
725
|
+
- jira
|
|
726
|
+
`;
|
|
727
|
+
const skills = extractSkillsFromYaml(yaml).sort();
|
|
728
|
+
expect(skills).toEqual(["datadog", "github", "jira", "linear"]);
|
|
729
|
+
});
|
|
730
|
+
it("ignores non-string skill entries defensively", () => {
|
|
731
|
+
const yaml = `nodes:
|
|
732
|
+
review:
|
|
733
|
+
skills:
|
|
734
|
+
- github
|
|
735
|
+
- 42
|
|
736
|
+
- null
|
|
737
|
+
- ""
|
|
738
|
+
`;
|
|
739
|
+
// Only "github" survives the typeof-string + non-empty filter
|
|
740
|
+
expect(extractSkillsFromYaml(yaml)).toEqual(["github"]);
|
|
741
|
+
});
|
|
742
|
+
it("returns empty array for malformed YAML (does not throw)", () => {
|
|
743
|
+
const yaml = `nodes:
|
|
744
|
+
review:
|
|
745
|
+
skills: [github, linear
|
|
746
|
+
# unclosed bracket above — invalid yaml
|
|
747
|
+
`;
|
|
748
|
+
expect(extractSkillsFromYaml(yaml)).toEqual([]);
|
|
749
|
+
});
|
|
750
|
+
it("returns empty array for YAML with no nodes key", () => {
|
|
751
|
+
expect(extractSkillsFromYaml("foo: bar\n")).toEqual([]);
|
|
752
|
+
});
|
|
753
|
+
it("returns empty array for empty/whitespace input", () => {
|
|
754
|
+
expect(extractSkillsFromYaml("")).toEqual([]);
|
|
755
|
+
expect(extractSkillsFromYaml(" \n")).toEqual([]);
|
|
756
|
+
});
|
|
757
|
+
it("returns empty array when nodes entries have no skills field", () => {
|
|
758
|
+
const yaml = `nodes:
|
|
759
|
+
review:
|
|
760
|
+
name: Code Review
|
|
761
|
+
instruction: Review the PR
|
|
762
|
+
`;
|
|
763
|
+
expect(extractSkillsFromYaml(yaml)).toEqual([]);
|
|
764
|
+
});
|
|
765
|
+
});
|
|
685
766
|
describe("SKILL_CREDENTIALS", () => {
|
|
686
767
|
it("has entries for all built-in skills", () => {
|
|
687
768
|
const expected = [
|
package/dist/cli/publish.js
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
import * as fs from "node:fs";
|
|
10
10
|
import * as path from "node:path";
|
|
11
|
-
import {
|
|
11
|
+
import { execFileSync } from "node:child_process";
|
|
12
12
|
import * as p from "@clack/prompts";
|
|
13
13
|
import chalk from "chalk";
|
|
14
14
|
import { parse as parseYaml } from "yaml";
|
|
@@ -108,7 +108,7 @@ export function validateSkillDir(dirPath) {
|
|
|
108
108
|
// ── GitHub PR helpers ──────────────────────────────────────────────────
|
|
109
109
|
function ghAvailable() {
|
|
110
110
|
try {
|
|
111
|
-
|
|
111
|
+
execFileSync("gh", ["--version"], { stdio: "pipe" });
|
|
112
112
|
return true;
|
|
113
113
|
}
|
|
114
114
|
catch {
|
|
@@ -117,7 +117,7 @@ function ghAvailable() {
|
|
|
117
117
|
}
|
|
118
118
|
function ghAuthenticated() {
|
|
119
119
|
try {
|
|
120
|
-
|
|
120
|
+
execFileSync("gh", ["auth", "status"], { stdio: "pipe" });
|
|
121
121
|
return true;
|
|
122
122
|
}
|
|
123
123
|
catch {
|
|
@@ -287,21 +287,21 @@ async function openGitHubPR(type, id, sourcePath, meta) {
|
|
|
287
287
|
s.start("Forking marketplace repository...");
|
|
288
288
|
try {
|
|
289
289
|
// Fork the marketplace repo (idempotent — if already forked, just continues)
|
|
290
|
-
|
|
290
|
+
execFileSync("gh", ["repo", "fork", "swenyai/marketplace", "--clone=false"], { stdio: "pipe" });
|
|
291
291
|
// Get the fork name
|
|
292
|
-
const forkInfo =
|
|
292
|
+
const forkInfo = execFileSync("gh", ["api", "user"], { stdio: "pipe", encoding: "utf-8" });
|
|
293
293
|
const username = JSON.parse(forkInfo).login;
|
|
294
294
|
const forkRepo = `${username}/marketplace`;
|
|
295
295
|
s.message("Creating branch...");
|
|
296
296
|
const branchName = `publish/${type}/${id}`;
|
|
297
297
|
// Clone to temp, create branch, add file, push, create PR
|
|
298
298
|
const tmpDir = fs.mkdtempSync(path.join(process.env.TMPDIR ?? "/tmp", "sweny-publish-"));
|
|
299
|
-
|
|
300
|
-
|
|
299
|
+
execFileSync("gh", ["repo", "clone", forkRepo, tmpDir, "--", "--depth", "1"], { stdio: "pipe" });
|
|
300
|
+
execFileSync("git", ["-C", tmpDir, "checkout", "-b", branchName], { stdio: "pipe" });
|
|
301
301
|
if (type === "workflow") {
|
|
302
302
|
const dest = path.join(tmpDir, "workflows", "community", `${id}.yml`);
|
|
303
303
|
fs.copyFileSync(sourcePath, dest);
|
|
304
|
-
|
|
304
|
+
execFileSync("git", ["-C", tmpDir, "add", `workflows/community/${id}.yml`], { stdio: "pipe" });
|
|
305
305
|
}
|
|
306
306
|
else {
|
|
307
307
|
const destDir = path.join(tmpDir, "skills", "community", id);
|
|
@@ -311,17 +311,28 @@ async function openGitHubPR(type, id, sourcePath, meta) {
|
|
|
311
311
|
for (const file of files) {
|
|
312
312
|
fs.copyFileSync(path.join(sourcePath, file), path.join(destDir, file));
|
|
313
313
|
}
|
|
314
|
-
|
|
314
|
+
execFileSync("git", ["-C", tmpDir, "add", `skills/community/${id}`], { stdio: "pipe" });
|
|
315
315
|
}
|
|
316
316
|
s.message("Pushing...");
|
|
317
317
|
const commitMsg = type === "workflow" ? `feat: add ${id} workflow` : `feat: add ${id} skill`;
|
|
318
|
-
|
|
319
|
-
|
|
318
|
+
execFileSync("git", ["-C", tmpDir, "commit", "-m", commitMsg], { stdio: "pipe" });
|
|
319
|
+
execFileSync("git", ["-C", tmpDir, "push", "origin", branchName], { stdio: "pipe" });
|
|
320
320
|
s.message("Creating pull request...");
|
|
321
321
|
const prBody = type === "workflow"
|
|
322
322
|
? `## New Workflow: ${id}\n\n**Author:** ${meta.author}\n**Category:** ${meta.category}\n**Tags:** ${meta.tags?.join(", ")}\n\nSubmitted via \`sweny publish\`.`
|
|
323
323
|
: `## New Skill: ${id}\n\nSubmitted via \`sweny publish\`.`;
|
|
324
|
-
const prResult =
|
|
324
|
+
const prResult = execFileSync("gh", [
|
|
325
|
+
"pr",
|
|
326
|
+
"create",
|
|
327
|
+
"--repo",
|
|
328
|
+
"swenyai/marketplace",
|
|
329
|
+
"--head",
|
|
330
|
+
`${username}:${branchName}`,
|
|
331
|
+
"--title",
|
|
332
|
+
`feat: add ${type} ${id}`,
|
|
333
|
+
"--body",
|
|
334
|
+
prBody,
|
|
335
|
+
], { stdio: "pipe", encoding: "utf-8" });
|
|
325
336
|
const prUrl = prResult.trim();
|
|
326
337
|
// Cleanup temp dir
|
|
327
338
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
package/dist/cli/templates.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Starter workflow templates offered during `sweny
|
|
2
|
+
* Starter workflow templates offered during `sweny new`.
|
|
3
3
|
*
|
|
4
4
|
* Each template is a valid SWEny workflow YAML. Templates are self-contained
|
|
5
5
|
* strings — no filesystem reads needed, works from npm install.
|
package/dist/cli/templates.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Starter workflow templates offered during `sweny
|
|
2
|
+
* Starter workflow templates offered during `sweny new`.
|
|
3
3
|
*
|
|
4
4
|
* Each template is a valid SWEny workflow YAML. Templates are self-contained
|
|
5
5
|
* strings — no filesystem reads needed, works from npm install.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sweny-ai/core",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.54",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"bin": {
|
|
6
6
|
"sweny": "./dist/cli/main.js"
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
"./workflows": "./dist/workflows/index.js",
|
|
16
16
|
"./testing": "./dist/testing.js",
|
|
17
17
|
"./schema": "./dist/schema.js",
|
|
18
|
-
"./
|
|
18
|
+
"./new": "./dist/cli/new.js"
|
|
19
19
|
},
|
|
20
20
|
"typesVersions": {
|
|
21
21
|
"*": {
|
|
@@ -37,8 +37,8 @@
|
|
|
37
37
|
"schema": [
|
|
38
38
|
"dist/schema.d.ts"
|
|
39
39
|
],
|
|
40
|
-
"
|
|
41
|
-
"dist/cli/
|
|
40
|
+
"new": [
|
|
41
|
+
"dist/cli/new.d.ts"
|
|
42
42
|
]
|
|
43
43
|
}
|
|
44
44
|
},
|
|
File without changes
|