Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 8 additions & 3 deletions packages/core/src/chat/marketMessages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,9 +113,12 @@ export type MarketRequest =
// buy a specific set of skills in one go (e.g. a workflow's required skills)
| { type: "buyRequiredSkills"; items: { skillId: string; creatorWallet?: string }[] }
| { type: "postAgentNote"; agentWallet: string; text: string; gitLink?: string; title?: string; image?: string }
// publish a skill from the UI (make-skill). priceSol is the human SOL amount as a
// string ("0.1"); the host converts to lamports. image is optional — an http URL
// or a base58 on-chain txid/PDA (the UI badges on-chain values), see skill-nft-json §3.
// publish a skill (or workflow) from the UI (make-skill). priceSol is the human SOL
// amount as a string ("0.1"); the host converts to lamports. image is optional — an
// http URL or a base58 on-chain txid/PDA (the UI badges on-chain values), see
// skill-nft-json §3. kind picks the item type (default "skill" when omitted, for
// back-compat with older clients); requiredSkills (workflow only) are the prerequisite
// skill mint ids the buyer must already hold.
| {
type: "publishSkill";
name: string;
Expand All @@ -125,6 +128,8 @@ export type MarketRequest =
hashtags?: string[];
priceSol: string;
image?: string;
kind?: "skill" | "workflow";
requiredSkills?: string[];
};

// ── host -> UI (responses / pushes) ─────────────────────────────────────────
Expand Down
33 changes: 33 additions & 0 deletions packages/core/src/nft/workflow.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,39 @@ Workflow body here, long enough to pass the body length check easily.`;
expect(mockConn.sendRawTransaction).toHaveBeenCalled();
});

// A clean form (separate name/description/requiredSkills/category fields, no hand-typed
// YAML) must be able to publish — publishWorkflow should synthesize frontmatter for
// validation ONLY, and still store the raw plain body on-chain (mirrors publishSkill's
// synthesis in skill.spec.ts).
it("publishes a workflow from a plain body with no own frontmatter (synthesizes for validation only)", async () => {
const plainBody = "Workflow body here, long enough to pass the body length check easily.";

const mintAddr = await publishWorkflow(mockConn as any, signer, {
name: "test-workflow",
description: "This is a test workflow that chains skills",
text: plainBody,
requiredSkills: ["So11111111111111111111111111111111111111112"],
category: "ai",
});

expect(typeof mintAddr).toBe("string");
const json = JSON.parse(vi.mocked(chain.codeIn).mock.calls[0][1] as string);
// The stored body is the raw plain text, NOT the synthesized frontmatter+body.
expect(json.skillText).toBe(plainBody);
});

it("rejects a plain body missing requiredSkills (synthesis can't invent prerequisites)", async () => {
await expect(
publishWorkflow(mockConn as any, signer, {
name: "test-workflow",
description: "This is a test workflow that chains skills",
text: "Workflow body here, long enough to pass the body length check easily.",
requiredSkills: [],
category: "ai",
}),
).rejects.toThrow(FormatError);
});

it("rejects publish if the workflow MD is invalid (type not workflow)", async () => {
const invalidMd = VALID_WORKFLOW_MD.replace("type: workflow", "type: skill");
await expect(
Expand Down
22 changes: 21 additions & 1 deletion packages/core/src/nft/workflow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,27 @@ export async function publishWorkflow(
input: PublishWorkflowInput,
onProgress?: (p: PublishProgress) => void,
): Promise<string> {
const format = checkWorkflowFormat(input.text);
// Validate the workflow the way it will actually exist: a SKILL.md whose frontmatter
// (name/description/type/requiredSkills) comes from the separate form fields, mirroring
// publishSkill's synthesis (skill.ts) so a clean form — no hand-typed YAML — can publish.
// A legacy body that already carries its own frontmatter is validated verbatim.
const hasOwnFrontmatter = /^?---\s*\n[\s\S]*?\n---\s*(\n|$)/.test(input.text);
const descLine = input.description.replace(/\s*\n\s*/g, " ").trim();
const mdToCheck = hasOwnFrontmatter
? input.text
: [
"---",
`name: ${input.name}`,
`description: ${descLine}`,
"type: workflow",
`requiredSkills: [${input.requiredSkills.join(", ")}]`,
...(input.category ? [`category: ${input.category}`] : []),
...(input.hashtags?.length ? [`hashtags: [${input.hashtags.join(", ")}]`] : []),
"---",
"",
input.text,
].join("\n");
const format = checkWorkflowFormat(mdToCheck);
if (!format.ok) {
throw new FormatError(format.errors);
}
Expand Down
33 changes: 33 additions & 0 deletions packages/core/src/skill-market/index.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,14 @@ import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
import { readSkillManifest } from "./registry.js";
import { searchSkills } from "../search/search.js";
import { buySkill, publishSkill } from "../nft/skill.js";
import { publishWorkflow } from "../nft/workflow.js";
import { postNote, postAgentNote } from "../notes/notes.js";
import { readSkillText, readSkillMintMetadata } from "../nft/token2022.js";
import { Keypair } from "@solana/web3.js";

vi.mock("../search/search.js", () => ({ searchSkills: vi.fn() }));
vi.mock("../nft/skill.js", () => ({ buySkill: vi.fn(), publishSkill: vi.fn() }));
vi.mock("../nft/workflow.js", () => ({ publishWorkflow: vi.fn() }));
vi.mock("../nft/token2022.js", () => ({ readSkillText: vi.fn(), readSkillMintMetadata: vi.fn() }));
vi.mock("../notes/notes.js", () => ({
postNote: vi.fn(),
Expand Down Expand Up @@ -112,6 +114,37 @@ describe("skill-market", () => {
expect(publishSkill).not.toHaveBeenCalled();
});

it("publish_skill routes to publishWorkflow when requiredSkills is non-empty", async () => {
vi.mocked(publishWorkflow).mockResolvedValue("workflowMint123");
const result = await handleToolCall(mockConn, signer, "defaultCreator", "publish_skill", {
name: "chain-refactor",
description: "Chains two skills together.",
text: "# Chain refactor\n...",
requiredSkills: ["skillMint1", "skillMint2"],
});
expect(result.content[0].text).toContain("workflow");
expect(result.content[0].text).toContain("workflowMint123");
expect(publishWorkflow).toHaveBeenCalledWith(mockConn, signer, expect.objectContaining({
name: "chain-refactor",
requiredSkills: ["skillMint1", "skillMint2"],
price: 100_000_000n,
}), expect.any(Function));
expect(publishSkill).not.toHaveBeenCalled();
});

it("publish_skill still takes the skill path when requiredSkills is omitted/empty", async () => {
vi.mocked(publishSkill).mockResolvedValue("mintAddr123");
const result = await handleToolCall(mockConn, signer, "defaultCreator", "publish_skill", {
name: "clean-code",
description: "Refactor toward clean code.",
text: "# Clean code\n...",
requiredSkills: [],
});
expect(result.content[0].text).toContain("mintAddr123");
expect(publishWorkflow).not.toHaveBeenCalled();
expect(publishSkill).toHaveBeenCalled();
});

it("search_skills returns empty when there are no results", async () => {
vi.mocked(searchSkills).mockResolvedValue([]);
const result = await handleToolCall(mockConn, signer, "defaultCreator", "search_skills", {});
Expand Down
46 changes: 31 additions & 15 deletions packages/core/src/skill-market/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import type { Connection } from "@solana/web3.js";
import type { SignerInput } from "@iqlabs-official/solana-sdk/utils";
import { searchSkills } from "../search/search.js";
import { publishSkill } from "../nft/skill.js";
import { publishWorkflow } from "../nft/workflow.js";
import { readSkillText } from "../nft/token2022.js";
import { SkillSync } from "./ingest/index.js";
import { postNote, postAgentNote } from "../notes/notes.js";
Expand Down Expand Up @@ -225,15 +226,16 @@ const SKILL_TOOLS: { name: string; description: string; schema: z.ZodRawShape }[
{
name: "publish_skill",
description:
"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.",
"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.",
schema: {
name: z.string().describe("Short skill name / slug, e.g. 'clean-code-refactor'."),
description: z.string().describe("One or two lines on what the skill does."),
text: z.string().describe("The full SKILL.md body the agent reads when this skill fires."),
name: z.string().describe("Short skill/workflow name / slug, e.g. 'clean-code-refactor'."),
description: z.string().describe("One or two lines on what it does."),
text: z.string().describe("The full SKILL.md body the agent reads when this fires."),
category: z.string().optional().describe("Optional single category, e.g. 'clean-code'."),
hashtags: z.array(z.string()).optional().describe("Optional tags, e.g. ['refactoring','testing']."),
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."),
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."),
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)."),
},
},
];
Expand Down Expand Up @@ -425,21 +427,35 @@ export async function handleToolCall(
if (lamports === null) {
return { isError: true, content: [{ type: "text", text: `Invalid priceSol "${priceSol}" — use a SOL amount like "0.1" or "0".` }] };
}
// Non-empty requiredSkills IS the workflow signal — no separate kind param needed for
// this structured-args tool (unlike the freeform-text UI forms, which sniff/toggle it).
const requiredSkills = (args?.requiredSkills as string[] | undefined)?.filter(Boolean) ?? [];
const isWorkflow = requiredSkills.length > 0;
try {
const mint = await publishSkill(conn, signer, {
name: skillName,
description,
text,
category: args?.category as string | undefined,
hashtags: args?.hashtags as string[] | undefined,
price: lamports,
image: args?.image as string | undefined,
}, (p) => emit({ type: "publishProgress", phase: p.phase, signed: p.signed, percent: p.percent, kind: p.kind }));
const mint = isWorkflow
? await publishWorkflow(conn, signer, {
name: skillName,
description,
text,
requiredSkills,
category: args?.category as string | undefined,
hashtags: args?.hashtags as string[] | undefined,
price: lamports,
}, (p) => emit({ type: "publishProgress", phase: p.phase, signed: p.signed, percent: p.percent, kind: p.kind }))
: await publishSkill(conn, signer, {
name: skillName,
description,
text,
category: args?.category as string | undefined,
hashtags: args?.hashtags as string[] | undefined,
price: lamports,
image: args?.image as string | undefined,
}, (p) => emit({ type: "publishProgress", phase: p.phase, signed: p.signed, percent: p.percent, kind: p.kind }));
emit({ type: "publishResult", ok: true, mint });
return { content: [{ type: "text", text: `Published skill "${skillName}" — mint: ${mint}` }] };
return { content: [{ type: "text", text: `Published ${isWorkflow ? "workflow" : "skill"} "${skillName}" — mint: ${mint}` }] };
} catch (err: any) {
emit({ type: "publishResult", ok: false, error: err.message });
return { isError: true, content: [{ type: "text", text: `Failed to publish skill: ${err.message}` }] };
return { isError: true, content: [{ type: "text", text: `Failed to publish ${isWorkflow ? "workflow" : "skill"}: ${err.message}` }] };
}
}

Expand Down
57 changes: 57 additions & 0 deletions packages/core/src/skill-market/ingest/env.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,4 +110,61 @@ Some skill body`;
}, undefined);
expect(corePublishWorkflow).not.toHaveBeenCalled();
});

it("prefers explicit kind/requiredSkills over frontmatter-sniffing (plain body, no frontmatter at all)", async () => {
const text = "Pure markdown without frontmatter";

const env = await marketplaceEnv(mockWallet);
const result = await env.publishSkill({
name: "My Workflow",
description: "A workflow",
text,
category: "testing",
hashtags: ["test", "workflow"],
priceSol: "0.25",
kind: "workflow",
requiredSkills: ["skillMint1", "skillMint2"],
});

expect(result).toEqual({ ok: true, mint: "mockWorkflowMint" });
expect(corePublishWorkflow).toHaveBeenCalledWith(expect.any(Object), mockWallet, {
name: "My Workflow",
description: "A workflow",
text,
requiredSkills: ["skillMint1", "skillMint2"],
category: "testing",
hashtags: ["test", "workflow"],
price: 250000000n,
}, undefined);
expect(corePublishSkill).not.toHaveBeenCalled();
});

it("explicit kind: 'skill' takes the skill path even if the body happens to embed workflow frontmatter", async () => {
const text = `---
type: workflow
requiredSkills: [skillMint1]
---
Some body`;

const env = await marketplaceEnv(mockWallet);
const result = await env.publishSkill({
name: "My Skill",
description: "A skill",
text,
priceSol: "0.1",
kind: "skill",
});

expect(result).toEqual({ ok: true, mint: "mockSkillMint" });
expect(corePublishSkill).toHaveBeenCalledWith(expect.any(Object), mockWallet, {
name: "My Skill",
description: "A skill",
text,
category: undefined,
hashtags: undefined,
price: 100000000n,
image: undefined,
}, undefined);
expect(corePublishWorkflow).not.toHaveBeenCalled();
});
});
11 changes: 8 additions & 3 deletions packages/core/src/skill-market/ingest/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -317,17 +317,22 @@ export async function marketplaceEnv(wallet: Wallet) {
async publishSkill(input: {
name: string; description: string; text: string;
category?: string; hashtags?: string[]; priceSol: string; image?: string;
kind?: "skill" | "workflow"; requiredSkills?: string[];
}, onProgress?: (p: PublishProgress) => void): Promise<{ ok: boolean; mint?: string; error?: string }> {
try {
const lamports = solToLamports(input.priceSol);
if (lamports === null) return { ok: false, error: "Enter a valid price in SOL (e.g. 0.1)" };
const frontmatter = publishFrontmatter(input.text);
if (frontmatter.type === "workflow") {
// Prefer the explicit kind/requiredSkills a form sends; fall back to sniffing the
// body's own frontmatter only when kind is absent, for back-compat with hand-typed
// YAML bodies (chat/agent callers, older UI builds) that predate these fields.
const frontmatter = input.kind ? {} : publishFrontmatter(input.text);
const isWorkflow = input.kind === "workflow" || frontmatter.type === "workflow";
if (isWorkflow) {
const mint = await corePublishWorkflow(conn, wallet, {
name: input.name,
description: input.description,
text: input.text,
requiredSkills: frontmatter.requiredSkills ?? [],
requiredSkills: input.requiredSkills ?? frontmatter.requiredSkills ?? [],
category: input.category,
hashtags: input.hashtags,
price: lamports,
Expand Down
Loading