@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/__tests__/publish.test.d.ts +1 -0
- package/dist/__tests__/publish.test.js +146 -0
- package/dist/__tests__/schema.test.js +156 -1
- package/dist/__tests__/skills.test.js +135 -4
- package/dist/__tests__/templates.test.d.ts +1 -0
- package/dist/__tests__/templates.test.js +49 -0
- package/dist/__tests__/version-consistency.test.js +2 -2
- package/dist/cli/init.d.ts +27 -4
- package/dist/cli/init.js +158 -197
- package/dist/cli/init.test.js +127 -78
- package/dist/cli/main.js +2 -0
- package/dist/cli/publish.d.ts +34 -0
- package/dist/cli/publish.js +368 -0
- package/dist/cli/templates.d.ts +13 -0
- package/dist/cli/templates.js +181 -0
- package/dist/executor.js +63 -20
- package/dist/executor.test.js +233 -0
- package/dist/index.d.ts +5 -5
- package/dist/index.js +3 -3
- package/dist/mcp.d.ts +2 -0
- package/dist/mcp.js +8 -0
- package/dist/mcp.test.js +67 -0
- package/dist/schema.d.ts +428 -18
- package/dist/schema.js +71 -4
- package/dist/skills/custom-loader.d.ts +12 -12
- package/dist/skills/custom-loader.js +105 -50
- package/dist/types.d.ts +18 -1
- package/dist/workflows/browser.js +3 -3
- package/package.json +6 -2
package/dist/cli/init.test.js
CHANGED
|
@@ -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
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
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
|
+
}
|