Skip to content

Commit e6a3de9

Browse files
authored
Merge pull request #95 from IQCoreTeam/feat/workflow-publish-ui
Workflow publishing: contract + backend + webview/VSCode/mobile + CLI + MCP tool (#88)
2 parents 5a5f567 + 94f9c6c commit e6a3de9

12 files changed

Lines changed: 332 additions & 47 deletions

File tree

packages/core/src/chat/marketMessages.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -113,9 +113,12 @@ export type MarketRequest =
113113
// buy a specific set of skills in one go (e.g. a workflow's required skills)
114114
| { type: "buyRequiredSkills"; items: { skillId: string; creatorWallet?: string }[] }
115115
| { type: "postAgentNote"; agentWallet: string; text: string; gitLink?: string; title?: string; image?: string }
116-
// publish a skill from the UI (make-skill). priceSol is the human SOL amount as a
117-
// string ("0.1"); the host converts to lamports. image is optional — an http URL
118-
// or a base58 on-chain txid/PDA (the UI badges on-chain values), see skill-nft-json §3.
116+
// publish a skill (or workflow) from the UI (make-skill). priceSol is the human SOL
117+
// amount as a string ("0.1"); the host converts to lamports. image is optional — an
118+
// http URL or a base58 on-chain txid/PDA (the UI badges on-chain values), see
119+
// skill-nft-json §3. kind picks the item type (default "skill" when omitted, for
120+
// back-compat with older clients); requiredSkills (workflow only) are the prerequisite
121+
// skill mint ids the buyer must already hold.
119122
| {
120123
type: "publishSkill";
121124
name: string;
@@ -125,6 +128,8 @@ export type MarketRequest =
125128
hashtags?: string[];
126129
priceSol: string;
127130
image?: string;
131+
kind?: "skill" | "workflow";
132+
requiredSkills?: string[];
128133
};
129134

130135
// ── host -> UI (responses / pushes) ─────────────────────────────────────────

packages/core/src/nft/workflow.spec.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,39 @@ Workflow body here, long enough to pass the body length check easily.`;
8989
expect(mockConn.sendRawTransaction).toHaveBeenCalled();
9090
});
9191

92+
// A clean form (separate name/description/requiredSkills/category fields, no hand-typed
93+
// YAML) must be able to publish — publishWorkflow should synthesize frontmatter for
94+
// validation ONLY, and still store the raw plain body on-chain (mirrors publishSkill's
95+
// synthesis in skill.spec.ts).
96+
it("publishes a workflow from a plain body with no own frontmatter (synthesizes for validation only)", async () => {
97+
const plainBody = "Workflow body here, long enough to pass the body length check easily.";
98+
99+
const mintAddr = await publishWorkflow(mockConn as any, signer, {
100+
name: "test-workflow",
101+
description: "This is a test workflow that chains skills",
102+
text: plainBody,
103+
requiredSkills: ["So11111111111111111111111111111111111111112"],
104+
category: "ai",
105+
});
106+
107+
expect(typeof mintAddr).toBe("string");
108+
const json = JSON.parse(vi.mocked(chain.codeIn).mock.calls[0][1] as string);
109+
// The stored body is the raw plain text, NOT the synthesized frontmatter+body.
110+
expect(json.skillText).toBe(plainBody);
111+
});
112+
113+
it("rejects a plain body missing requiredSkills (synthesis can't invent prerequisites)", async () => {
114+
await expect(
115+
publishWorkflow(mockConn as any, signer, {
116+
name: "test-workflow",
117+
description: "This is a test workflow that chains skills",
118+
text: "Workflow body here, long enough to pass the body length check easily.",
119+
requiredSkills: [],
120+
category: "ai",
121+
}),
122+
).rejects.toThrow(FormatError);
123+
});
124+
92125
it("rejects publish if the workflow MD is invalid (type not workflow)", async () => {
93126
const invalidMd = VALID_WORKFLOW_MD.replace("type: workflow", "type: skill");
94127
await expect(

packages/core/src/nft/workflow.ts

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,27 @@ export async function publishWorkflow(
4444
input: PublishWorkflowInput,
4545
onProgress?: (p: PublishProgress) => void,
4646
): Promise<string> {
47-
const format = checkWorkflowFormat(input.text);
47+
// Validate the workflow the way it will actually exist: a SKILL.md whose frontmatter
48+
// (name/description/type/requiredSkills) comes from the separate form fields, mirroring
49+
// publishSkill's synthesis (skill.ts) so a clean form — no hand-typed YAML — can publish.
50+
// A legacy body that already carries its own frontmatter is validated verbatim.
51+
const hasOwnFrontmatter = /^?---\s*\n[\s\S]*?\n---\s*(\n|$)/.test(input.text);
52+
const descLine = input.description.replace(/\s*\n\s*/g, " ").trim();
53+
const mdToCheck = hasOwnFrontmatter
54+
? input.text
55+
: [
56+
"---",
57+
`name: ${input.name}`,
58+
`description: ${descLine}`,
59+
"type: workflow",
60+
`requiredSkills: [${input.requiredSkills.join(", ")}]`,
61+
...(input.category ? [`category: ${input.category}`] : []),
62+
...(input.hashtags?.length ? [`hashtags: [${input.hashtags.join(", ")}]`] : []),
63+
"---",
64+
"",
65+
input.text,
66+
].join("\n");
67+
const format = checkWorkflowFormat(mdToCheck);
4868
if (!format.ok) {
4969
throw new FormatError(format.errors);
5070
}

packages/core/src/skill-market/index.spec.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,14 @@ import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
99
import { readSkillManifest } from "./registry.js";
1010
import { searchSkills } from "../search/search.js";
1111
import { buySkill, publishSkill } from "../nft/skill.js";
12+
import { publishWorkflow } from "../nft/workflow.js";
1213
import { postNote, postAgentNote } from "../notes/notes.js";
1314
import { readSkillText, readSkillMintMetadata } from "../nft/token2022.js";
1415
import { Keypair } from "@solana/web3.js";
1516

1617
vi.mock("../search/search.js", () => ({ searchSkills: vi.fn() }));
1718
vi.mock("../nft/skill.js", () => ({ buySkill: vi.fn(), publishSkill: vi.fn() }));
19+
vi.mock("../nft/workflow.js", () => ({ publishWorkflow: vi.fn() }));
1820
vi.mock("../nft/token2022.js", () => ({ readSkillText: vi.fn(), readSkillMintMetadata: vi.fn() }));
1921
vi.mock("../notes/notes.js", () => ({
2022
postNote: vi.fn(),
@@ -112,6 +114,37 @@ describe("skill-market", () => {
112114
expect(publishSkill).not.toHaveBeenCalled();
113115
});
114116

117+
it("publish_skill routes to publishWorkflow when requiredSkills is non-empty", async () => {
118+
vi.mocked(publishWorkflow).mockResolvedValue("workflowMint123");
119+
const result = await handleToolCall(mockConn, signer, "defaultCreator", "publish_skill", {
120+
name: "chain-refactor",
121+
description: "Chains two skills together.",
122+
text: "# Chain refactor\n...",
123+
requiredSkills: ["skillMint1", "skillMint2"],
124+
});
125+
expect(result.content[0].text).toContain("workflow");
126+
expect(result.content[0].text).toContain("workflowMint123");
127+
expect(publishWorkflow).toHaveBeenCalledWith(mockConn, signer, expect.objectContaining({
128+
name: "chain-refactor",
129+
requiredSkills: ["skillMint1", "skillMint2"],
130+
price: 100_000_000n,
131+
}), expect.any(Function));
132+
expect(publishSkill).not.toHaveBeenCalled();
133+
});
134+
135+
it("publish_skill still takes the skill path when requiredSkills is omitted/empty", async () => {
136+
vi.mocked(publishSkill).mockResolvedValue("mintAddr123");
137+
const result = await handleToolCall(mockConn, signer, "defaultCreator", "publish_skill", {
138+
name: "clean-code",
139+
description: "Refactor toward clean code.",
140+
text: "# Clean code\n...",
141+
requiredSkills: [],
142+
});
143+
expect(result.content[0].text).toContain("mintAddr123");
144+
expect(publishWorkflow).not.toHaveBeenCalled();
145+
expect(publishSkill).toHaveBeenCalled();
146+
});
147+
115148
it("search_skills returns empty when there are no results", async () => {
116149
vi.mocked(searchSkills).mockResolvedValue([]);
117150
const result = await handleToolCall(mockConn, signer, "defaultCreator", "search_skills", {});

packages/core/src/skill-market/index.ts

Lines changed: 31 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import type { Connection } from "@solana/web3.js";
2727
import type { SignerInput } from "@iqlabs-official/solana-sdk/utils";
2828
import { searchSkills } from "../search/search.js";
2929
import { publishSkill } from "../nft/skill.js";
30+
import { publishWorkflow } from "../nft/workflow.js";
3031
import { readSkillText } from "../nft/token2022.js";
3132
import { SkillSync } from "./ingest/index.js";
3233
import { postNote, postAgentNote } from "../notes/notes.js";
@@ -225,15 +226,16 @@ const SKILL_TOOLS: { name: string; description: string; schema: z.ZodRawShape }[
225226
{
226227
name: "publish_skill",
227228
description:
228-
"Publish a new skill to the marketplace: mint it as a soulbound Token-2022 NFT and store the SKILL.md body on-chain (you, the creator, auto-receive 1 copy). This is the raw publish action — it does NOT decide WHETHER something is worth becoming a skill; call it once you've authored the SKILL.md content and chosen a name/price.",
229+
"Publish a new skill OR workflow to the marketplace: mint it as a soulbound Token-2022 NFT and store the SKILL.md body on-chain (you, the creator, auto-receive 1 copy). Pass requiredSkills (non-empty) to publish a WORKFLOW instead of a plain skill — a workflow gates its mint on the buyer holding every listed prerequisite skill. This is the raw publish action — it does NOT decide WHETHER something is worth becoming a skill/workflow; call it once you've authored the SKILL.md content and chosen a name/price.",
229230
schema: {
230-
name: z.string().describe("Short skill name / slug, e.g. 'clean-code-refactor'."),
231-
description: z.string().describe("One or two lines on what the skill does."),
232-
text: z.string().describe("The full SKILL.md body the agent reads when this skill fires."),
231+
name: z.string().describe("Short skill/workflow name / slug, e.g. 'clean-code-refactor'."),
232+
description: z.string().describe("One or two lines on what it does."),
233+
text: z.string().describe("The full SKILL.md body the agent reads when this fires."),
233234
category: z.string().optional().describe("Optional single category, e.g. 'clean-code'."),
234235
hashtags: z.array(z.string()).optional().describe("Optional tags, e.g. ['refactoring','testing']."),
235236
priceSol: z.string().optional().describe("Price in SOL a buyer pays (e.g. '0.1'). Use '0' for a free skill. Defaults to 0.1 if omitted."),
236237
image: z.string().optional().describe("Cover image, ONLY if the user explicitly gave you an image URL or on-chain (base58) address. Pass it through verbatim. Do NOT generate, invent, or ask for one, and NEVER pass raw/base64 image data — omit this field entirely when the user didn't provide a link."),
238+
requiredSkills: z.array(z.string()).optional().describe("Base58 skill mint addresses this item requires the buyer to already hold. Non-empty = publish a WORKFLOW (gated); omitted/empty = publish a plain skill (no gate)."),
237239
},
238240
},
239241
];
@@ -425,21 +427,35 @@ export async function handleToolCall(
425427
if (lamports === null) {
426428
return { isError: true, content: [{ type: "text", text: `Invalid priceSol "${priceSol}" — use a SOL amount like "0.1" or "0".` }] };
427429
}
430+
// Non-empty requiredSkills IS the workflow signal — no separate kind param needed for
431+
// this structured-args tool (unlike the freeform-text UI forms, which sniff/toggle it).
432+
const requiredSkills = (args?.requiredSkills as string[] | undefined)?.filter(Boolean) ?? [];
433+
const isWorkflow = requiredSkills.length > 0;
428434
try {
429-
const mint = await publishSkill(conn, signer, {
430-
name: skillName,
431-
description,
432-
text,
433-
category: args?.category as string | undefined,
434-
hashtags: args?.hashtags as string[] | undefined,
435-
price: lamports,
436-
image: args?.image as string | undefined,
437-
}, (p) => emit({ type: "publishProgress", phase: p.phase, signed: p.signed, percent: p.percent, kind: p.kind }));
435+
const mint = isWorkflow
436+
? await publishWorkflow(conn, signer, {
437+
name: skillName,
438+
description,
439+
text,
440+
requiredSkills,
441+
category: args?.category as string | undefined,
442+
hashtags: args?.hashtags as string[] | undefined,
443+
price: lamports,
444+
}, (p) => emit({ type: "publishProgress", phase: p.phase, signed: p.signed, percent: p.percent, kind: p.kind }))
445+
: await publishSkill(conn, signer, {
446+
name: skillName,
447+
description,
448+
text,
449+
category: args?.category as string | undefined,
450+
hashtags: args?.hashtags as string[] | undefined,
451+
price: lamports,
452+
image: args?.image as string | undefined,
453+
}, (p) => emit({ type: "publishProgress", phase: p.phase, signed: p.signed, percent: p.percent, kind: p.kind }));
438454
emit({ type: "publishResult", ok: true, mint });
439-
return { content: [{ type: "text", text: `Published skill "${skillName}" — mint: ${mint}` }] };
455+
return { content: [{ type: "text", text: `Published ${isWorkflow ? "workflow" : "skill"} "${skillName}" — mint: ${mint}` }] };
440456
} catch (err: any) {
441457
emit({ type: "publishResult", ok: false, error: err.message });
442-
return { isError: true, content: [{ type: "text", text: `Failed to publish skill: ${err.message}` }] };
458+
return { isError: true, content: [{ type: "text", text: `Failed to publish ${isWorkflow ? "workflow" : "skill"}: ${err.message}` }] };
443459
}
444460
}
445461

packages/core/src/skill-market/ingest/env.spec.ts

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,4 +110,61 @@ Some skill body`;
110110
}, undefined);
111111
expect(corePublishWorkflow).not.toHaveBeenCalled();
112112
});
113+
114+
it("prefers explicit kind/requiredSkills over frontmatter-sniffing (plain body, no frontmatter at all)", async () => {
115+
const text = "Pure markdown without frontmatter";
116+
117+
const env = await marketplaceEnv(mockWallet);
118+
const result = await env.publishSkill({
119+
name: "My Workflow",
120+
description: "A workflow",
121+
text,
122+
category: "testing",
123+
hashtags: ["test", "workflow"],
124+
priceSol: "0.25",
125+
kind: "workflow",
126+
requiredSkills: ["skillMint1", "skillMint2"],
127+
});
128+
129+
expect(result).toEqual({ ok: true, mint: "mockWorkflowMint" });
130+
expect(corePublishWorkflow).toHaveBeenCalledWith(expect.any(Object), mockWallet, {
131+
name: "My Workflow",
132+
description: "A workflow",
133+
text,
134+
requiredSkills: ["skillMint1", "skillMint2"],
135+
category: "testing",
136+
hashtags: ["test", "workflow"],
137+
price: 250000000n,
138+
}, undefined);
139+
expect(corePublishSkill).not.toHaveBeenCalled();
140+
});
141+
142+
it("explicit kind: 'skill' takes the skill path even if the body happens to embed workflow frontmatter", async () => {
143+
const text = `---
144+
type: workflow
145+
requiredSkills: [skillMint1]
146+
---
147+
Some body`;
148+
149+
const env = await marketplaceEnv(mockWallet);
150+
const result = await env.publishSkill({
151+
name: "My Skill",
152+
description: "A skill",
153+
text,
154+
priceSol: "0.1",
155+
kind: "skill",
156+
});
157+
158+
expect(result).toEqual({ ok: true, mint: "mockSkillMint" });
159+
expect(corePublishSkill).toHaveBeenCalledWith(expect.any(Object), mockWallet, {
160+
name: "My Skill",
161+
description: "A skill",
162+
text,
163+
category: undefined,
164+
hashtags: undefined,
165+
price: 100000000n,
166+
image: undefined,
167+
}, undefined);
168+
expect(corePublishWorkflow).not.toHaveBeenCalled();
169+
});
113170
});

packages/core/src/skill-market/ingest/env.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -317,17 +317,22 @@ export async function marketplaceEnv(wallet: Wallet) {
317317
async publishSkill(input: {
318318
name: string; description: string; text: string;
319319
category?: string; hashtags?: string[]; priceSol: string; image?: string;
320+
kind?: "skill" | "workflow"; requiredSkills?: string[];
320321
}, onProgress?: (p: PublishProgress) => void): Promise<{ ok: boolean; mint?: string; error?: string }> {
321322
try {
322323
const lamports = solToLamports(input.priceSol);
323324
if (lamports === null) return { ok: false, error: "Enter a valid price in SOL (e.g. 0.1)" };
324-
const frontmatter = publishFrontmatter(input.text);
325-
if (frontmatter.type === "workflow") {
325+
// Prefer the explicit kind/requiredSkills a form sends; fall back to sniffing the
326+
// body's own frontmatter only when kind is absent, for back-compat with hand-typed
327+
// YAML bodies (chat/agent callers, older UI builds) that predate these fields.
328+
const frontmatter = input.kind ? {} : publishFrontmatter(input.text);
329+
const isWorkflow = input.kind === "workflow" || frontmatter.type === "workflow";
330+
if (isWorkflow) {
326331
const mint = await corePublishWorkflow(conn, wallet, {
327332
name: input.name,
328333
description: input.description,
329334
text: input.text,
330-
requiredSkills: frontmatter.requiredSkills ?? [],
335+
requiredSkills: input.requiredSkills ?? frontmatter.requiredSkills ?? [],
331336
category: input.category,
332337
hashtags: input.hashtags,
333338
price: lamports,

0 commit comments

Comments
 (0)