@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.
@@ -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, detectGitRemote, buildSwenyYml, buildEnvTemplate, buildActionWorkflow, PROVIDER_CREDENTIALS, } from "./init.js";
6
+ import { collectCredentials, collectCredentialsForSkills, extractSkillsFromYaml, detectGitRemote, buildSwenyYml, buildEnvTemplate, buildActionWorkflow, PROVIDER_CREDENTIALS, SKILL_CREDENTIALS, } from "./init.js";
7
7
  // ── detectGitRemote ────────────────────────────────────────────────────
8
8
  describe("detectGitRemote", () => {
9
9
  const tmpDirs = [];
@@ -171,50 +171,17 @@ describe("PROVIDER_CREDENTIALS", () => {
171
171
  // ── buildSwenyYml ──────────────────────────────────────────────────────
172
172
  describe("buildSwenyYml", () => {
173
173
  it("includes all selected providers", () => {
174
- const yml = buildSwenyYml({
175
- sourceControl: "github",
176
- observability: "datadog",
177
- issueTracker: "linear",
178
- notification: "slack",
179
- githubAction: true,
180
- cronExpression: "0 8 * * 1",
181
- });
174
+ const yml = buildSwenyYml("github", "datadog", "linear");
182
175
  expect(yml).toContain("source-control-provider: github");
183
176
  expect(yml).toContain("observability-provider: datadog");
184
177
  expect(yml).toContain("issue-tracker-provider: linear");
185
- expect(yml).toContain("notification-provider: slack");
186
178
  });
187
179
  it("omits observability-provider when null", () => {
188
- const yml = buildSwenyYml({
189
- sourceControl: "github",
190
- observability: null,
191
- issueTracker: "github-issues",
192
- notification: "slack",
193
- githubAction: false,
194
- cronExpression: null,
195
- });
180
+ const yml = buildSwenyYml("github", null, "github-issues");
196
181
  expect(yml).not.toContain("observability-provider");
197
182
  });
198
- it("omits notification-provider when console (default)", () => {
199
- const yml = buildSwenyYml({
200
- sourceControl: "github",
201
- observability: "sentry",
202
- issueTracker: "github-issues",
203
- notification: "console",
204
- githubAction: false,
205
- cronExpression: null,
206
- });
207
- expect(yml).not.toContain("notification-provider");
208
- });
209
183
  it("includes header comments", () => {
210
- const yml = buildSwenyYml({
211
- sourceControl: "github",
212
- observability: null,
213
- issueTracker: "github-issues",
214
- notification: "console",
215
- githubAction: false,
216
- cronExpression: null,
217
- });
184
+ const yml = buildSwenyYml("github", null, "github-issues");
218
185
  expect(yml).toContain("# .sweny.yml");
219
186
  });
220
187
  });
@@ -407,49 +374,25 @@ describe("detectGitRemote — edge cases", () => {
407
374
  // ── buildSwenyYml: YAML validity ──────────────────────────────────────
408
375
  describe("buildSwenyYml — YAML validity", () => {
409
376
  it("generates valid YAML that can be parsed", () => {
410
- const yml = buildSwenyYml({
411
- sourceControl: "github",
412
- observability: "datadog",
413
- issueTracker: "linear",
414
- notification: "slack",
415
- githubAction: false,
416
- cronExpression: null,
417
- });
377
+ const yml = buildSwenyYml("github", "datadog", "linear");
418
378
  const parsed = parseYaml(yml);
419
379
  expect(parsed["source-control-provider"]).toBe("github");
420
380
  expect(parsed["observability-provider"]).toBe("datadog");
421
381
  expect(parsed["issue-tracker-provider"]).toBe("linear");
422
- expect(parsed["notification-provider"]).toBe("slack");
423
382
  });
424
383
  it("generates valid YAML with minimal selections", () => {
425
- const yml = buildSwenyYml({
426
- sourceControl: "github",
427
- observability: null,
428
- issueTracker: "github-issues",
429
- notification: "console",
430
- githubAction: false,
431
- cronExpression: null,
432
- });
384
+ const yml = buildSwenyYml("github", null, "github-issues");
433
385
  const parsed = parseYaml(yml);
434
386
  expect(parsed["source-control-provider"]).toBe("github");
435
387
  expect(parsed["issue-tracker-provider"]).toBe("github-issues");
436
388
  expect(parsed["observability-provider"]).toBeUndefined();
437
- expect(parsed["notification-provider"]).toBeUndefined();
438
389
  });
439
390
  it("uses correct key names matching config parser expectations", () => {
440
- const yml = buildSwenyYml({
441
- sourceControl: "gitlab",
442
- observability: "sentry",
443
- issueTracker: "jira",
444
- notification: "teams",
445
- githubAction: false,
446
- cronExpression: null,
447
- });
391
+ const yml = buildSwenyYml("gitlab", "sentry", "jira");
448
392
  // These are the exact keys the config parser reads
449
393
  expect(yml).toContain("source-control-provider:");
450
394
  expect(yml).toContain("observability-provider:");
451
395
  expect(yml).toContain("issue-tracker-provider:");
452
- expect(yml).toContain("notification-provider:");
453
396
  // Must NOT contain the short form
454
397
  expect(yml).not.toMatch(/^source-control:/m);
455
398
  expect(yml).not.toMatch(/^issue-tracker:/m);
@@ -458,23 +401,13 @@ describe("buildSwenyYml — YAML validity", () => {
458
401
  const sourceControls = ["github", "gitlab"];
459
402
  const observabilities = ["datadog", "sentry", "betterstack", "newrelic", "cloudwatch", null];
460
403
  const issueTrackers = ["github-issues", "linear", "jira"];
461
- const notifications = ["console", "slack", "discord", "teams", "webhook"];
462
404
  for (const sc of sourceControls) {
463
405
  for (const obs of observabilities) {
464
406
  for (const it of issueTrackers) {
465
- for (const notif of notifications) {
466
- const yml = buildSwenyYml({
467
- sourceControl: sc,
468
- observability: obs,
469
- issueTracker: it,
470
- notification: notif,
471
- githubAction: false,
472
- cronExpression: null,
473
- });
474
- // Should not throw
475
- const parsed = parseYaml(yml);
476
- expect(parsed["source-control-provider"]).toBe(sc);
477
- }
407
+ const yml = buildSwenyYml(sc, obs, it);
408
+ // Should not throw
409
+ const parsed = parseYaml(yml);
410
+ expect(parsed["source-control-provider"]).toBe(sc);
478
411
  }
479
412
  }
480
413
  }
@@ -662,3 +595,119 @@ describe("collectCredentials — additional cases", () => {
662
595
  }
663
596
  });
664
597
  });
598
+ // ═══════════════════════════════════════════════════════════════════════
599
+ // Workflow-first functions: extractSkillsFromYaml, collectCredentialsForSkills
600
+ // ═══════════════════════════════════════════════════════════════════════
601
+ describe("extractSkillsFromYaml", () => {
602
+ it("extracts skills from a single node", () => {
603
+ const yaml = `
604
+ nodes:
605
+ review:
606
+ name: Code Review
607
+ instruction: Review the PR
608
+ skills: [github]
609
+ `;
610
+ expect(extractSkillsFromYaml(yaml)).toEqual(["github"]);
611
+ });
612
+ it("extracts and deduplicates skills from multiple nodes", () => {
613
+ const yaml = `
614
+ nodes:
615
+ review:
616
+ name: Code Review
617
+ skills: [github, linear]
618
+ scan:
619
+ name: Security Scan
620
+ skills: [github, sentry]
621
+ `;
622
+ const skills = extractSkillsFromYaml(yaml);
623
+ expect(skills).toContain("github");
624
+ expect(skills).toContain("linear");
625
+ expect(skills).toContain("sentry");
626
+ expect(skills.filter((s) => s === "github")).toHaveLength(1);
627
+ });
628
+ it("returns empty array when no skills present", () => {
629
+ const yaml = `
630
+ nodes:
631
+ review:
632
+ name: Code Review
633
+ instruction: Review the PR
634
+ `;
635
+ expect(extractSkillsFromYaml(yaml)).toEqual([]);
636
+ });
637
+ it("handles skills with spaces around commas", () => {
638
+ const yaml = `
639
+ nodes:
640
+ review:
641
+ skills: [github , linear , sentry]
642
+ `;
643
+ const skills = extractSkillsFromYaml(yaml);
644
+ expect(skills).toEqual(["github", "linear", "sentry"]);
645
+ });
646
+ it("handles single-skill arrays", () => {
647
+ const yaml = `
648
+ nodes:
649
+ review:
650
+ skills: [datadog]
651
+ `;
652
+ expect(extractSkillsFromYaml(yaml)).toEqual(["datadog"]);
653
+ });
654
+ });
655
+ describe("collectCredentialsForSkills", () => {
656
+ it("always includes ANTHROPIC_API_KEY even with no skills", () => {
657
+ const creds = collectCredentialsForSkills([]);
658
+ expect(creds.map((c) => c.key)).toEqual(["ANTHROPIC_API_KEY"]);
659
+ });
660
+ it("includes credentials for specified skills", () => {
661
+ const creds = collectCredentialsForSkills(["github", "linear"]);
662
+ const keys = creds.map((c) => c.key);
663
+ expect(keys).toContain("ANTHROPIC_API_KEY");
664
+ expect(keys).toContain("GITHUB_TOKEN");
665
+ expect(keys).toContain("LINEAR_API_KEY");
666
+ expect(keys).toContain("LINEAR_TEAM_ID");
667
+ });
668
+ it("deduplicates credentials across skills", () => {
669
+ const creds = collectCredentialsForSkills(["github", "github"]);
670
+ const tokenCount = creds.filter((c) => c.key === "GITHUB_TOKEN").length;
671
+ expect(tokenCount).toBe(1);
672
+ });
673
+ it("skips unknown skills gracefully", () => {
674
+ const creds = collectCredentialsForSkills(["github", "nonexistent-skill"]);
675
+ const keys = creds.map((c) => c.key);
676
+ expect(keys).toContain("ANTHROPIC_API_KEY");
677
+ expect(keys).toContain("GITHUB_TOKEN");
678
+ expect(keys).toHaveLength(2);
679
+ });
680
+ it("ANTHROPIC_API_KEY is always first", () => {
681
+ const creds = collectCredentialsForSkills(["datadog", "github"]);
682
+ expect(creds[0].key).toBe("ANTHROPIC_API_KEY");
683
+ });
684
+ });
685
+ describe("SKILL_CREDENTIALS", () => {
686
+ it("has entries for all built-in skills", () => {
687
+ const expected = [
688
+ "github",
689
+ "gitlab",
690
+ "datadog",
691
+ "sentry",
692
+ "betterstack",
693
+ "newrelic",
694
+ "linear",
695
+ "jira",
696
+ "slack",
697
+ "discord",
698
+ "notification",
699
+ ];
700
+ for (const skill of expected) {
701
+ expect(SKILL_CREDENTIALS).toHaveProperty(skill);
702
+ }
703
+ });
704
+ it("every skill has valid credential entries", () => {
705
+ for (const [skill, creds] of Object.entries(SKILL_CREDENTIALS)) {
706
+ expect(Array.isArray(creds), `${skill} should have an array`).toBe(true);
707
+ for (const cred of creds) {
708
+ expect(typeof cred.key, `${skill}.${cred.key} should be a string`).toBe("string");
709
+ expect(cred.key, `${skill}.${cred.key} should be UPPER_SNAKE_CASE`).toMatch(/^[A-Z][A-Z0-9_]+$/);
710
+ }
711
+ }
712
+ });
713
+ });
package/dist/cli/main.js CHANGED
@@ -28,6 +28,7 @@ import { registerTriageCommand, registerImplementCommand, parseCliInputs, valida
28
28
  import { c, formatBanner, getStepDetails, formatStepLine, formatDagResultHuman, formatDagResultMarkdown, formatResultJson, formatValidationErrors, formatCrashError, formatCheckResults, } from "./output.js";
29
29
  import { checkProviderConnectivity } from "./check.js";
30
30
  import { registerSetupCommand } from "./setup.js";
31
+ import { registerPublishCommand } from "./publish.js";
31
32
  // ── Stream observer (NDJSON) ────────────────────────────────────────
32
33
  /**
33
34
  * Create an observer that writes NDJSON ExecutionEvents to stdout.
@@ -100,6 +101,7 @@ program
100
101
  process.exit(hasFailure ? 1 : 0);
101
102
  });
102
103
  registerSetupCommand(program);
104
+ registerPublishCommand(program);
103
105
  // ── Credential map builder ──────────────────────────────────────────
104
106
  // buildCredentialMap lives in ./credentials.ts so tests can import it
105
107
  // without triggering main.ts's top-level program.parse() side effect.
@@ -0,0 +1,34 @@
1
+ /**
2
+ * sweny publish — Interactive CLI for publishing workflows and skills to the marketplace.
3
+ *
4
+ * Supports:
5
+ * - Workflow YAML files → marketplace community submission
6
+ * - Skill directories (SKILL.md) → marketplace skill submission
7
+ * - Three output modes: GitHub PR (via `gh`), copy to clipboard, save locally
8
+ */
9
+ import type { Command } from "commander";
10
+ export interface PublishResult {
11
+ type: "workflow" | "skill";
12
+ id: string;
13
+ name: string;
14
+ outputPath?: string;
15
+ prUrl?: string;
16
+ }
17
+ export declare function validateWorkflowFile(filePath: string): {
18
+ valid: boolean;
19
+ id?: string;
20
+ name?: string;
21
+ nodeCount?: number;
22
+ edgeCount?: number;
23
+ errors: string[];
24
+ };
25
+ export declare function validateSkillDir(dirPath: string): {
26
+ valid: boolean;
27
+ id?: string;
28
+ name?: string;
29
+ hasInstruction: boolean;
30
+ hasMcp: boolean;
31
+ errors: string[];
32
+ };
33
+ export declare function runPublish(): Promise<PublishResult | null>;
34
+ export declare function registerPublishCommand(program: Command): void;
@@ -0,0 +1,368 @@
1
+ /**
2
+ * sweny publish — Interactive CLI for publishing workflows and skills to the marketplace.
3
+ *
4
+ * Supports:
5
+ * - Workflow YAML files → marketplace community submission
6
+ * - Skill directories (SKILL.md) → marketplace skill submission
7
+ * - Three output modes: GitHub PR (via `gh`), copy to clipboard, save locally
8
+ */
9
+ import * as fs from "node:fs";
10
+ import * as path from "node:path";
11
+ import { execSync } from "node:child_process";
12
+ import * as p from "@clack/prompts";
13
+ import chalk from "chalk";
14
+ import { parse as parseYaml } from "yaml";
15
+ import { parseWorkflow, validateWorkflow } from "../schema.js";
16
+ // ── Types ──────────────────────────────────────────────────────────────
17
+ const VALID_CATEGORIES = ["triage", "security", "devops", "code-review", "testing", "content", "ops"];
18
+ const VALID_SKILL_ID = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/;
19
+ // ── Validation helpers ─────────────────────────────────────────────────
20
+ export function validateWorkflowFile(filePath) {
21
+ const errors = [];
22
+ if (!fs.existsSync(filePath)) {
23
+ return { valid: false, errors: ["File not found"] };
24
+ }
25
+ const raw = fs.readFileSync(filePath, "utf-8");
26
+ let parsed;
27
+ try {
28
+ parsed = parseYaml(raw);
29
+ }
30
+ catch (e) {
31
+ return { valid: false, errors: [`Invalid YAML: ${e instanceof Error ? e.message : e}`] };
32
+ }
33
+ try {
34
+ const workflow = parseWorkflow(parsed);
35
+ const schemaErrors = validateWorkflow(workflow);
36
+ if (schemaErrors.length > 0) {
37
+ return {
38
+ valid: false,
39
+ id: workflow.id,
40
+ name: workflow.name,
41
+ errors: schemaErrors.map((e) => e.message),
42
+ };
43
+ }
44
+ return {
45
+ valid: true,
46
+ id: workflow.id,
47
+ name: workflow.name,
48
+ nodeCount: Object.keys(workflow.nodes).length,
49
+ edgeCount: workflow.edges.length,
50
+ errors: [],
51
+ };
52
+ }
53
+ catch (e) {
54
+ return { valid: false, errors: [`Schema error: ${e instanceof Error ? e.message : e}`] };
55
+ }
56
+ }
57
+ export function validateSkillDir(dirPath) {
58
+ const errors = [];
59
+ const skillMdPath = path.join(dirPath, "SKILL.md");
60
+ if (!fs.existsSync(skillMdPath)) {
61
+ return { valid: false, hasInstruction: false, hasMcp: false, errors: ["SKILL.md not found in directory"] };
62
+ }
63
+ const content = fs.readFileSync(skillMdPath, "utf-8");
64
+ // Parse frontmatter
65
+ const fmMatch = content.match(/^---\n([\s\S]*?)\n---/);
66
+ if (!fmMatch) {
67
+ return { valid: false, hasInstruction: false, hasMcp: false, errors: ["No YAML frontmatter found"] };
68
+ }
69
+ let fm;
70
+ try {
71
+ fm = parseYaml(fmMatch[1]);
72
+ }
73
+ catch (e) {
74
+ return {
75
+ valid: false,
76
+ hasInstruction: false,
77
+ hasMcp: false,
78
+ errors: [`Invalid frontmatter YAML: ${e instanceof Error ? e.message : e}`],
79
+ };
80
+ }
81
+ const id = String(fm.name ?? "");
82
+ if (!id) {
83
+ errors.push("Missing 'name' field in frontmatter");
84
+ }
85
+ else if (!VALID_SKILL_ID.test(id) || id.includes("--") || id.length > 64) {
86
+ errors.push(`Invalid skill ID: "${id}" — must be lowercase, hyphens, no consecutive hyphens, max 64 chars`);
87
+ }
88
+ // Check directory name matches skill name
89
+ const dirName = path.basename(dirPath);
90
+ if (id && dirName !== id) {
91
+ errors.push(`Directory name "${dirName}" doesn't match skill name "${id}"`);
92
+ }
93
+ const body = content.slice(fmMatch[0].length).trim();
94
+ const hasInstruction = body.length > 0;
95
+ const hasMcp = fm.mcp != null;
96
+ if (!hasInstruction && !hasMcp) {
97
+ errors.push("Skill must have an instruction body or an mcp config");
98
+ }
99
+ return {
100
+ valid: errors.length === 0,
101
+ id,
102
+ name: id,
103
+ hasInstruction,
104
+ hasMcp,
105
+ errors,
106
+ };
107
+ }
108
+ // ── GitHub PR helpers ──────────────────────────────────────────────────
109
+ function ghAvailable() {
110
+ try {
111
+ execSync("gh --version", { stdio: "pipe" });
112
+ return true;
113
+ }
114
+ catch {
115
+ return false;
116
+ }
117
+ }
118
+ function ghAuthenticated() {
119
+ try {
120
+ execSync("gh auth status", { stdio: "pipe" });
121
+ return true;
122
+ }
123
+ catch {
124
+ return false;
125
+ }
126
+ }
127
+ // ── Cancel helper ──────────────────────────────────────────────────────
128
+ function cancel() {
129
+ p.cancel("Publish cancelled.");
130
+ process.exit(0);
131
+ }
132
+ // ── Main flow ──────────────────────────────────────────────────────────
133
+ export async function runPublish() {
134
+ p.intro("Publish to the SWEny Marketplace");
135
+ // Step 1: What to publish
136
+ const contentType = await p.select({
137
+ message: "What would you like to publish?",
138
+ options: [
139
+ { value: "workflow", label: "Workflow", hint: ".yml file" },
140
+ { value: "skill", label: "Skill", hint: "SKILL.md directory" },
141
+ ],
142
+ });
143
+ if (p.isCancel(contentType))
144
+ cancel();
145
+ if (contentType === "workflow") {
146
+ return await publishWorkflow();
147
+ }
148
+ else {
149
+ return await publishSkill();
150
+ }
151
+ }
152
+ async function publishWorkflow() {
153
+ // Step 2: Path to workflow
154
+ const filePath = await p.text({
155
+ message: "Path to workflow YAML file",
156
+ placeholder: ".sweny/workflows/my-workflow.yml",
157
+ validate: (v) => {
158
+ if (!v || v.trim().length === 0)
159
+ return "Path is required";
160
+ if (!fs.existsSync(v.trim()))
161
+ return "File not found";
162
+ return undefined;
163
+ },
164
+ });
165
+ if (p.isCancel(filePath))
166
+ cancel();
167
+ const absPath = path.resolve(filePath.trim());
168
+ const validation = validateWorkflowFile(absPath);
169
+ if (!validation.valid) {
170
+ p.log.error(`Validation failed:\n${validation.errors.map((e) => ` - ${e}`).join("\n")}`);
171
+ return null;
172
+ }
173
+ p.log.success(`Validated: ${chalk.cyan(validation.name)} — ${validation.nodeCount} nodes, ${validation.edgeCount} edges`);
174
+ // Step 3: Metadata
175
+ const raw = parseYaml(fs.readFileSync(absPath, "utf-8"));
176
+ let author = raw.author;
177
+ if (!author) {
178
+ const authorInput = await p.text({
179
+ message: "Author name",
180
+ placeholder: "your-username",
181
+ validate: (v) => (!v || v.trim().length === 0 ? "Author is required" : undefined),
182
+ });
183
+ if (p.isCancel(authorInput))
184
+ cancel();
185
+ author = authorInput.trim();
186
+ }
187
+ else {
188
+ p.log.info(`Author: ${chalk.cyan(author)}`);
189
+ }
190
+ let category = raw.category;
191
+ if (!category || !VALID_CATEGORIES.includes(category)) {
192
+ const catInput = await p.select({
193
+ message: "Category",
194
+ options: VALID_CATEGORIES.map((c) => ({ value: c, label: c })),
195
+ });
196
+ if (p.isCancel(catInput))
197
+ cancel();
198
+ category = catInput;
199
+ }
200
+ else {
201
+ p.log.info(`Category: ${chalk.cyan(category)}`);
202
+ }
203
+ let tags = raw.tags;
204
+ if (!tags || !Array.isArray(tags) || tags.length === 0) {
205
+ const tagsInput = await p.text({
206
+ message: "Tags (comma-separated)",
207
+ placeholder: "automation, testing, ci",
208
+ });
209
+ if (p.isCancel(tagsInput))
210
+ cancel();
211
+ tags = tagsInput
212
+ .split(",")
213
+ .map((t) => t.trim())
214
+ .filter(Boolean);
215
+ }
216
+ else {
217
+ p.log.info(`Tags: ${chalk.cyan(tags.join(", "))}`);
218
+ }
219
+ // Step 4: Output mode
220
+ return await selectOutputMode("workflow", validation.id, absPath, {
221
+ author,
222
+ category,
223
+ tags,
224
+ });
225
+ }
226
+ async function publishSkill() {
227
+ // Step 2: Path to skill directory
228
+ const dirPath = await p.text({
229
+ message: "Path to skill directory (containing SKILL.md)",
230
+ placeholder: ".sweny/skills/my-skill/",
231
+ validate: (v) => {
232
+ if (!v || v.trim().length === 0)
233
+ return "Path is required";
234
+ const resolved = path.resolve(v.trim());
235
+ if (!fs.existsSync(resolved))
236
+ return "Directory not found";
237
+ if (!fs.statSync(resolved).isDirectory())
238
+ return "Path must be a directory";
239
+ if (!fs.existsSync(path.join(resolved, "SKILL.md")))
240
+ return "SKILL.md not found in directory";
241
+ return undefined;
242
+ },
243
+ });
244
+ if (p.isCancel(dirPath))
245
+ cancel();
246
+ const absDir = path.resolve(dirPath.trim());
247
+ const validation = validateSkillDir(absDir);
248
+ if (!validation.valid) {
249
+ p.log.error(`Validation failed:\n${validation.errors.map((e) => ` - ${e}`).join("\n")}`);
250
+ return null;
251
+ }
252
+ const badges = [];
253
+ if (validation.hasInstruction)
254
+ badges.push("instruction");
255
+ if (validation.hasMcp)
256
+ badges.push("MCP");
257
+ p.log.success(`Validated: ${chalk.cyan(validation.id)} [${badges.join(" + ")}]`);
258
+ return await selectOutputMode("skill", validation.id, absDir, {});
259
+ }
260
+ async function selectOutputMode(type, id, sourcePath, meta) {
261
+ const hasGh = ghAvailable();
262
+ const options = [
263
+ ...(hasGh
264
+ ? [{ value: "pr", label: "Open GitHub PR", hint: "recommended — forks marketplace repo and opens a PR" }]
265
+ : []),
266
+ { value: "save", label: "Save submission file", hint: "saves to ./sweny-publish/" },
267
+ ];
268
+ const mode = await p.select({
269
+ message: "How would you like to publish?",
270
+ options,
271
+ });
272
+ if (p.isCancel(mode))
273
+ cancel();
274
+ if (mode === "pr") {
275
+ return await openGitHubPR(type, id, sourcePath, meta);
276
+ }
277
+ else {
278
+ return await saveLocally(type, id, sourcePath);
279
+ }
280
+ }
281
+ async function openGitHubPR(type, id, sourcePath, meta) {
282
+ if (!ghAuthenticated()) {
283
+ p.log.error("Not authenticated with GitHub. Run: gh auth login");
284
+ return null;
285
+ }
286
+ const s = p.spinner();
287
+ s.start("Forking marketplace repository...");
288
+ try {
289
+ // Fork the marketplace repo (idempotent — if already forked, just continues)
290
+ execSync("gh repo fork swenyai/marketplace --clone=false 2>&1", { stdio: "pipe" });
291
+ // Get the fork name
292
+ const forkInfo = execSync("gh api user", { stdio: "pipe", encoding: "utf-8" });
293
+ const username = JSON.parse(forkInfo).login;
294
+ const forkRepo = `${username}/marketplace`;
295
+ s.message("Creating branch...");
296
+ const branchName = `publish/${type}/${id}`;
297
+ // Clone to temp, create branch, add file, push, create PR
298
+ const tmpDir = fs.mkdtempSync(path.join(process.env.TMPDIR ?? "/tmp", "sweny-publish-"));
299
+ execSync(`gh repo clone ${forkRepo} ${tmpDir} -- --depth 1`, { stdio: "pipe" });
300
+ execSync(`git -C ${tmpDir} checkout -b ${branchName}`, { stdio: "pipe" });
301
+ if (type === "workflow") {
302
+ const dest = path.join(tmpDir, "workflows", "community", `${id}.yml`);
303
+ fs.copyFileSync(sourcePath, dest);
304
+ execSync(`git -C ${tmpDir} add workflows/community/${id}.yml`, { stdio: "pipe" });
305
+ }
306
+ else {
307
+ const destDir = path.join(tmpDir, "skills", "community", id);
308
+ fs.mkdirSync(destDir, { recursive: true });
309
+ // Copy entire skill directory
310
+ const files = fs.readdirSync(sourcePath);
311
+ for (const file of files) {
312
+ fs.copyFileSync(path.join(sourcePath, file), path.join(destDir, file));
313
+ }
314
+ execSync(`git -C ${tmpDir} add skills/community/${id}`, { stdio: "pipe" });
315
+ }
316
+ s.message("Pushing...");
317
+ const commitMsg = type === "workflow" ? `feat: add ${id} workflow` : `feat: add ${id} skill`;
318
+ execSync(`git -C ${tmpDir} commit -m "${commitMsg}"`, { stdio: "pipe" });
319
+ execSync(`git -C ${tmpDir} push origin ${branchName}`, { stdio: "pipe" });
320
+ s.message("Creating pull request...");
321
+ const prBody = type === "workflow"
322
+ ? `## New Workflow: ${id}\n\n**Author:** ${meta.author}\n**Category:** ${meta.category}\n**Tags:** ${meta.tags?.join(", ")}\n\nSubmitted via \`sweny publish\`.`
323
+ : `## New Skill: ${id}\n\nSubmitted via \`sweny publish\`.`;
324
+ const prResult = execSync(`gh pr create --repo swenyai/marketplace --head ${username}:${branchName} --title "feat: add ${type} ${id}" --body "${prBody.replace(/"/g, '\\"')}"`, { stdio: "pipe", encoding: "utf-8" });
325
+ const prUrl = prResult.trim();
326
+ // Cleanup temp dir
327
+ fs.rmSync(tmpDir, { recursive: true, force: true });
328
+ s.stop(`PR created: ${chalk.cyan(prUrl)}`);
329
+ return { type, id, name: id, prUrl };
330
+ }
331
+ catch (e) {
332
+ s.stop("Failed to create PR");
333
+ p.log.error(`GitHub error: ${e instanceof Error ? e.message : e}`);
334
+ p.log.info("Falling back to save locally...");
335
+ return await saveLocally(type, id, sourcePath);
336
+ }
337
+ }
338
+ async function saveLocally(type, id, sourcePath) {
339
+ const outputDir = path.resolve("sweny-publish");
340
+ fs.mkdirSync(outputDir, { recursive: true });
341
+ if (type === "workflow") {
342
+ const dest = path.join(outputDir, `${id}.yml`);
343
+ fs.copyFileSync(sourcePath, dest);
344
+ p.log.success(`Saved to ${chalk.cyan(dest)}`);
345
+ p.log.info(`Submit manually: copy to workflows/community/ in a fork of swenyai/marketplace`);
346
+ return { type, id, name: id, outputPath: dest };
347
+ }
348
+ else {
349
+ const destDir = path.join(outputDir, id);
350
+ fs.mkdirSync(destDir, { recursive: true });
351
+ const files = fs.readdirSync(sourcePath);
352
+ for (const file of files) {
353
+ fs.copyFileSync(path.join(sourcePath, file), path.join(destDir, file));
354
+ }
355
+ p.log.success(`Saved to ${chalk.cyan(destDir)}`);
356
+ p.log.info(`Submit manually: copy to skills/community/ in a fork of swenyai/marketplace`);
357
+ return { type, id, name: id, outputPath: destDir };
358
+ }
359
+ }
360
+ // ── Command registration ───────────────────────────────────────────────
361
+ export function registerPublishCommand(program) {
362
+ program
363
+ .command("publish")
364
+ .description("Publish a workflow or skill to the SWEny Marketplace")
365
+ .action(async () => {
366
+ await runPublish();
367
+ });
368
+ }