diff --git a/packages/core/src/chat/marketMessages.ts b/packages/core/src/chat/marketMessages.ts index 5de4ae7..c0fdeea 100644 --- a/packages/core/src/chat/marketMessages.ts +++ b/packages/core/src/chat/marketMessages.ts @@ -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; @@ -125,6 +128,8 @@ export type MarketRequest = hashtags?: string[]; priceSol: string; image?: string; + kind?: "skill" | "workflow"; + requiredSkills?: string[]; }; // ── host -> UI (responses / pushes) ───────────────────────────────────────── diff --git a/packages/core/src/nft/workflow.spec.ts b/packages/core/src/nft/workflow.spec.ts index b549b86..bb15850 100644 --- a/packages/core/src/nft/workflow.spec.ts +++ b/packages/core/src/nft/workflow.spec.ts @@ -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( diff --git a/packages/core/src/nft/workflow.ts b/packages/core/src/nft/workflow.ts index b279d80..b4428e4 100644 --- a/packages/core/src/nft/workflow.ts +++ b/packages/core/src/nft/workflow.ts @@ -44,7 +44,27 @@ export async function publishWorkflow( input: PublishWorkflowInput, onProgress?: (p: PublishProgress) => void, ): Promise { - 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); } diff --git a/packages/core/src/skill-market/index.spec.ts b/packages/core/src/skill-market/index.spec.ts index cbb55c2..9da380c 100644 --- a/packages/core/src/skill-market/index.spec.ts +++ b/packages/core/src/skill-market/index.spec.ts @@ -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(), @@ -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", {}); diff --git a/packages/core/src/skill-market/index.ts b/packages/core/src/skill-market/index.ts index dd3c765..58ac116 100644 --- a/packages/core/src/skill-market/index.ts +++ b/packages/core/src/skill-market/index.ts @@ -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"; @@ -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)."), }, }, ]; @@ -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}` }] }; } } diff --git a/packages/core/src/skill-market/ingest/env.spec.ts b/packages/core/src/skill-market/ingest/env.spec.ts index ce106e1..fedf5de 100644 --- a/packages/core/src/skill-market/ingest/env.spec.ts +++ b/packages/core/src/skill-market/ingest/env.spec.ts @@ -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(); + }); }); diff --git a/packages/core/src/skill-market/ingest/env.ts b/packages/core/src/skill-market/ingest/env.ts index b9f5e8c..f709eb7 100644 --- a/packages/core/src/skill-market/ingest/env.ts +++ b/packages/core/src/skill-market/ingest/env.ts @@ -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, diff --git a/surfaces/cli/src/views/SkillMarket.tsx b/surfaces/cli/src/views/SkillMarket.tsx index 7e18fa7..c83f40e 100644 --- a/surfaces/cli/src/views/SkillMarket.tsx +++ b/surfaces/cli/src/views/SkillMarket.tsx @@ -17,7 +17,7 @@ export interface MarketApi { solBalance(): Promise; postNote(skillId: string, skillType: "skill" | "workflow" | undefined, text: string, gitLink?: string): Promise<{ ok: boolean; error?: string }>; publishSkill( - input: { name: string; description: string; text: string; category?: string; hashtags?: string[]; priceSol: string; image?: string }, + 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 }>; listAgents(): Promise; @@ -27,6 +27,7 @@ export interface MarketApi { disposeSkill(skillId: string): Promise<{ ok: boolean; slug?: string; error?: string }>; reEquipSkill(skillId: string): Promise<{ ok: boolean; slug?: string; error?: string }>; disposedSkillMints?(): Promise>; + ownedSkillMints?(): Promise>; } type Stage = @@ -40,9 +41,10 @@ type Stage = | "helius" | "blogCompose"; -// Publish form fields in order — tab/arrow cycles through them. -type PublishField = "name" | "desc" | "text" | "category" | "hashtags" | "price" | "image"; -const PUBLISH_FIELDS: PublishField[] = ["name", "desc", "text", "category", "hashtags", "price", "image"]; +// Publish form fields in order — tab/arrow cycles through them. "kind" is a two-state +// toggle (not free text); "requiredSkills" only appears in the cycle for a workflow. +type PublishField = "kind" | "name" | "desc" | "text" | "category" | "hashtags" | "requiredSkills" | "price" | "image"; +const ALL_PUBLISH_FIELDS: PublishField[] = ["kind", "name", "desc", "text", "category", "hashtags", "requiredSkills", "price", "image"]; type BlogField = "title" | "text" | "image" | "gitLink"; const BLOG_FIELDS: BlogField[] = ["title", "text", "image", "gitLink"]; @@ -96,16 +98,22 @@ export function SkillMarket({ const [commentField, setCommentField] = useState<"text" | "gitLink">("text"); // publish stage - const [pubField, setPubField] = useState("name"); + const [pubField, setPubField] = useState("kind"); + const [pubKind, setPubKind] = useState<"skill" | "workflow">("skill"); const [pubName, setPubName] = useState(""); const [pubDesc, setPubDesc] = useState(""); const [pubText, setPubText] = useState(""); const [pubCategory, setPubCategory] = useState(""); const [pubHashtags, setPubHashtags] = useState(""); + const [pubRequiredSkills, setPubRequiredSkills] = useState(""); // comma-separated owned skill names const [pubPrice, setPubPrice] = useState("0.1"); const [pubImage, setPubImage] = useState(""); const [pubResult, setPubResult] = useState(null); const [pubProgress, setPubProgress] = useState(null); + const [ownedMints, setOwnedMints] = useState>({}); // name -> mint + + // Fields actually shown/cycled: requiredSkills only makes sense for a workflow. + const PUBLISH_FIELDS = ALL_PUBLISH_FIELDS.filter((f) => f !== "requiredSkills" || pubKind === "workflow"); // agents stage const [agents, setAgents] = useState([]); @@ -162,6 +170,7 @@ export function SkillMarket({ void api.solBalance().then(setBalance).catch(() => setBalance(null)); void refreshRpcStatus(); void api.disposedSkillMints?.().then((m) => setDisposedNames(new Set(Object.keys(m)))).catch(() => {}); + void api.ownedSkillMints?.().then(setOwnedMints).catch(() => {}); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); @@ -268,6 +277,14 @@ export function SkillMarket({ async function doPublish() { if (!pubName.trim() || !pubDesc.trim() || !pubText.trim()) return; + let requiredSkills: string[] | undefined; + if (pubKind === "workflow") { + const names = pubRequiredSkills.split(",").map((s) => s.trim()).filter(Boolean); + if (names.length === 0) { setPubResult("failed: enter at least one required skill name"); return; } + const unresolved = names.filter((n) => !ownedMints[n]); + if (unresolved.length > 0) { setPubResult(`failed: unknown skill name(s): ${unresolved.join(", ")}`); return; } + requiredSkills = names.map((n) => ownedMints[n]); + } setBusy(true); setPubProgress(null); const res = await api.publishSkill( @@ -279,6 +296,8 @@ export function SkillMarket({ hashtags: pubHashtags.trim() ? pubHashtags.split(",").map((h) => h.trim()).filter(Boolean) : undefined, priceSol: pubPrice.trim() || "0.1", image: pubImage.trim() || undefined, + kind: pubKind, + requiredSkills, }, (p) => setPubProgress(p), ); @@ -362,12 +381,15 @@ export function SkillMarket({ setHeliusFlash(key ? "key saved" : "key cleared"); } - // get/set helpers for publish form fields + // get/set helpers for publish form fields. "kind" is read-only here — it's a two-state + // toggle flipped by the useInput handler below, not free text. function pubGet(f: PublishField) { - return { name: pubName, desc: pubDesc, text: pubText, category: pubCategory, hashtags: pubHashtags, price: pubPrice, image: pubImage }[f]; + if (f === "kind") return pubKind; + return { name: pubName, desc: pubDesc, text: pubText, category: pubCategory, hashtags: pubHashtags, requiredSkills: pubRequiredSkills, price: pubPrice, image: pubImage }[f]; } function pubSet(f: PublishField, v: string) { - ({ name: setPubName, desc: setPubDesc, text: setPubText, category: setPubCategory, hashtags: setPubHashtags, price: setPubPrice, image: setPubImage }[f])(v); + if (f === "kind") return; + ({ name: setPubName, desc: setPubDesc, text: setPubText, category: setPubCategory, hashtags: setPubHashtags, requiredSkills: setPubRequiredSkills, price: setPubPrice, image: setPubImage }[f])(v); } function blogGet(f: BlogField) { @@ -467,7 +489,14 @@ export function SkillMarket({ setPubField(PUBLISH_FIELDS[(fi + PUBLISH_FIELDS.length - 1) % PUBLISH_FIELDS.length]); return; } + // "kind" is a two-state toggle, not free text: left/right (or space) flips it without + // advancing; Enter (below) flips it AND advances, matching Enter's existing semantics. + if (pubField === "kind" && (key.leftArrow || key.rightArrow || input === " ")) { + setPubKind((k) => (k === "skill" ? "workflow" : "skill")); + return; + } if (key.return) { + if (pubField === "kind") setPubKind((k) => (k === "skill" ? "workflow" : "skill")); if (fi < PUBLISH_FIELDS.length - 1) { setPubField(PUBLISH_FIELDS[fi + 1]); } else { @@ -475,6 +504,7 @@ export function SkillMarket({ } return; } + if (pubField === "kind") return; // no character input on the toggle field if (key.backspace || key.delete) { pubSet(pubField, pubGet(pubField).slice(0, -1)); return; } if (input && !key.ctrl && !key.meta) { pubSet(pubField, pubGet(pubField) + input); return; } return; @@ -550,7 +580,7 @@ export function SkillMarket({ } if (input === "/") return setTyping(true); if (input === "a") { setStage("agents"); void loadAgents(); return; } - if (input === "p") { setPubResult(null); setPubProgress(null); setPubField("name"); setStage("publish"); return; } + if (input === "p") { setPubResult(null); setPubProgress(null); setPubField("kind"); setStage("publish"); return; } if (input === "r") { setHeliusFlash(null); setHeliusKeyInput(""); setStage("helius"); return; } if (input === "h") { setHideOwned((v) => !v); setIdx(0); return; } if (key.tab) { @@ -663,19 +693,34 @@ export function SkillMarket({ ); } const fieldLabels: Record = { - name: "name ", desc: "desc ", text: "skill text", - category: "category ", hashtags: "hashtags ", price: "price (SOL)", image: "image ", + kind: "kind ", name: "name ", desc: "desc ", text: "skill text", + category: "category ", hashtags: "hashtags ", requiredSkills: "required ", + price: "price (SOL)", image: "image ", }; const fieldValues: Record = { - name: pubName, desc: pubDesc, text: pubText.slice(0, 60) + (pubText.length > 60 ? "…" : ""), - category: pubCategory, hashtags: pubHashtags, price: pubPrice, image: pubImage, + kind: pubKind, name: pubName, desc: pubDesc, text: pubText.slice(0, 60) + (pubText.length > 60 ? "…" : ""), + category: pubCategory, hashtags: pubHashtags, requiredSkills: pubRequiredSkills, + price: pubPrice, image: pubImage, }; + const kindTint = pubKind === "workflow" ? colors.warn : colors.iqViolet; return ( - - ❖ publish skill + + ❖ publish {pubKind} {PUBLISH_FIELDS.map((f) => { const on = f === pubField; + if (f === "kind") { + return ( + + {on ? "▸ " : " "} + {fieldLabels[f]} + + {pubKind === "skill" ? "‹ skill ›" : "‹ workflow ›"} + + {on ? : null} + + ); + } return ( {on ? "▸ " : " "} @@ -688,9 +733,12 @@ export function SkillMarket({ {busy ? : null} - ↑/↓/[tab] field · ↵ next / submit on image · esc cancel + ↑/↓/[tab] field · ←/→ toggle kind · ↵ next / submit on image · esc cancel hashtags = comma-separated · image = link or on-chain ref, optional + {pubKind === "workflow" ? ( + required = comma-separated names of skills YOU own + ) : null} ); } diff --git a/surfaces/localhost/src/index.ts b/surfaces/localhost/src/index.ts index f159d74..1f3802b 100644 --- a/surfaces/localhost/src/index.ts +++ b/surfaces/localhost/src/index.ts @@ -619,7 +619,7 @@ function attachMarketHandlers(c: Client) { case "publishSkill": { try { const r = await mkt.publishSkill( - { name: m.name, description: m.description, text: m.text, category: m.category, hashtags: m.hashtags, priceSol: m.priceSol, image: m.image }, + { name: m.name, description: m.description, text: m.text, category: m.category, hashtags: m.hashtags, priceSol: m.priceSol, image: m.image, kind: m.kind, requiredSkills: m.requiredSkills }, (p) => c.send({ type: "publishProgress", phase: p.phase, signed: p.signed, percent: p.percent, kind: p.kind }), ); c.send({ type: "publishResult", ...r }); diff --git a/surfaces/webview/src/index.css b/surfaces/webview/src/index.css index d2f0e44..5776f45 100644 --- a/surfaces/webview/src/index.css +++ b/surfaces/webview/src/index.css @@ -963,6 +963,7 @@ body { .an-btn-green { --acc: #4ade80; --ink: #06140c; } .an-btn-orange { --acc: #f0913e; --ink: #1a0f06; } .an-btn-violet { --acc: #8b5cf6; --ink: #0c0618; } +.an-btn-amber { --acc: #fbbf24; --ink: #1a1206; } /* secondary — plain outline, no brackets/fill */ .an-btn-outline { padding: 13px 14px; color: #bdbdbd; border: 1px solid #34343a; } .an-btn-outline::before, .an-btn-outline::after { display: none; } diff --git a/surfaces/webview/src/market/PublishForm.tsx b/surfaces/webview/src/market/PublishForm.tsx index 04767e9..fd12753 100644 --- a/surfaces/webview/src/market/PublishForm.tsx +++ b/surfaces/webview/src/market/PublishForm.tsx @@ -67,6 +67,7 @@ interface Props { export function PublishForm({ onBack }: Props) { const { send, state, clearPublishResult } = useStore(); + const [kind, setKind] = useState<"skill" | "workflow">("skill"); const [name, setName] = useState(""); const [description, setDescription] = useState(""); const [text, setText] = useState(""); @@ -74,15 +75,24 @@ export function PublishForm({ onBack }: Props) { const [hashtags, setHashtags] = useState(""); const [priceSol, setPriceSol] = useState("0"); const [imageUrl, setImageUrl] = useState(""); // http/on-chain link only — no file upload + const [requiredSkills, setRequiredSkills] = useState>({}); // mint -> selected const [submitting, setSubmitting] = useState(false); const result = state.publishResult; + const theme = PUBLISH_THEME[kind]; + + // Belt-and-suspenders: MarketScreen already sends this on tab mount, but a future entry + // point that skips it would otherwise leave the required-skills picker empty. + useEffect(() => { send({ type: "ownedSkills" }); }, []); // A result (success OR failure) ends the in-flight state so the form/button come back. useEffect(() => { if (result) setSubmitting(false); }, [result]); + const chosenSkills = Object.keys(requiredSkills).filter((m) => requiredSkills[m]); + function handleSubmit() { if (!name.trim() || !text.trim()) return; + if (kind === "workflow" && chosenSkills.length === 0) return; setSubmitting(true); clearPublishResult(); // Image is a link ONLY (http URL or on-chain address). The app can't attach/upload a @@ -96,6 +106,8 @@ export function PublishForm({ onBack }: Props) { hashtags: hashtags.split(",").map((h) => h.trim()).filter(Boolean), priceSol: priceSol || "0", image: imageUrl.trim() || undefined, + kind, + requiredSkills: kind === "workflow" ? chosenSkills : undefined, }); setTimeout(() => setSubmitting(false), 15000); } @@ -107,11 +119,11 @@ export function PublishForm({ onBack }: Props) {
- Publish Skill + Publish {kind === "workflow" ? "Workflow" : "Skill"}
-
- -

Skill minted!

+
+ +

{kind === "workflow" ? "Workflow" : "Skill"} minted!

{result.mint &&

{result.mint}

}
@@ -144,10 +156,27 @@ export function PublishForm({ onBack }: Props) { > - Publish Skill + Publish {kind === "workflow" ? "Workflow" : "Skill"}
+
+ {(["skill", "workflow"] as const).map((k) => { + const t = PUBLISH_THEME[k]; + const on = kind === k; + return ( + + ); + })} +
)} + {kind === "workflow" && ( + + {state.marketOwned.length === 0 ? ( +

+ You don't own any skills yet — buy at least one before publishing a workflow. +

+ ) : ( +
+ {state.marketOwned.map((skillName) => { + const mint = state.marketOwnedMints[skillName]; + if (!mint) return null; + const on = !!requiredSkills[mint]; + return ( + + ); + })} +
+ )} +
+ )}
@@ -240,10 +305,10 @@ export function PublishForm({ onBack }: Props) { )}
diff --git a/surfaces/webview/src/transport/protocol.ts b/surfaces/webview/src/transport/protocol.ts index 1e8bc1d..59fba16 100644 --- a/surfaces/webview/src/transport/protocol.ts +++ b/surfaces/webview/src/transport/protocol.ts @@ -150,6 +150,8 @@ export type ClientMessage = hashtags?: string[]; priceSol: string; image?: string; + kind?: "skill" | "workflow"; + requiredSkills?: string[]; } | { type: "submitGithubToken"; token: string } | { type: "clearGithubToken" }